diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000000..55c8a622616 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,11 @@ +# actionlint configuration. See scripts/check-workflows.sh. + +self-hosted-runner: + # windows-11-arm is a REAL GitHub-hosted label -- parparvm-tests-windows.yml runs on it + # and has done for months. actionlint ships a static list of known labels and that list + # lags GitHub's, so the label is reported as unknown. + # + # Declared here rather than by muting the runner-label rule, so a genuine typo in a + # runs-on is still caught. + labels: + - windows-11-arm diff --git a/.github/workflows/_build-mac-port.yml b/.github/workflows/_build-mac-port.yml index 9b5fb1568d0..4c8dd56047b 100644 --- a/.github/workflows/_build-mac-port.yml +++ b/.github/workflows/_build-mac-port.yml @@ -33,6 +33,9 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Verify native macOS font aliases with AppKit + run: python3 scripts/test_mac_native_font.py + - name: Cache CocoaPods and user gems uses: actions/cache@v5 with: @@ -204,7 +207,7 @@ jobs: - name: Build macOS port if: steps.cn1_built.outputs.cache-hit != 'true' - run: ./scripts/build-mac-port.sh -q -DskipTests + run: ./scripts/build-mac-port.sh -q -Dtest=MacNativeFontModeTest -Dsurefire.failIfNoSpecifiedTests=false timeout-minutes: 40 - name: Report cache outcome diff --git a/.github/workflows/check-workflows.yml b/.github/workflows/check-workflows.yml new file mode 100644 index 00000000000..366f628ddc8 --- /dev/null +++ b/.github/workflows/check-workflows.yml @@ -0,0 +1,59 @@ +name: Check workflows + +# A workflow file GitHub rejects is not a broken job, it is a broken BRANCH: an invalid +# file produces a failed check run on every push to every branch that contains it, +# regardless of that workflow's own triggers and regardless of what the push touched. The +# only symptom is "This run likely failed because of a workflow file issue" with no log, +# which reads like infrastructure noise rather than a file someone has to fix. +# +# That happened here. A job-level `if:` referencing the `matrix` context took two unrelated +# pull requests red for hours. Nothing local objected, because the file is valid YAML -- +# yaml.safe_load parses it, an editor shows nothing, and the error exists only in GitHub's +# own expression parser. actionlint reimplements that parser. +# +# Runs on pull_request as well as push, because the point is to catch the file BEFORE it +# reaches a branch other people build on. It is cheap: one ubuntu minute, no build. + +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + branches: + - master + push: + branches: + - master + +jobs: + check-workflows: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # Deliberately NOT filtered by paths. A workflow file is only one of the ways this + # check goes stale: actionlint's own rules move, and a run over an unchanged tree + # that starts failing is information rather than noise. + - name: Validate every workflow file + run: scripts/check-workflows.sh + + # This workflow is unfiltered, so changes to the spec, its validator, or any + # native reference table are checked before they reach master. + - name: Validate fidelity spec and native reference tables + run: | + python3 scripts/test_check_fidelity_spec.py + python3 scripts/check-fidelity-spec.py + + - name: Check desktop hover queues under saturation + run: python3 scripts/test_native_hover_queue.py + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Check fidelity baseline coverage enforcement + run: | + python3 scripts/test_fidelity_gate.py + python3 scripts/test_fidelity_geometry.py diff --git a/.github/workflows/fidelity-desktop-native-ref.yml b/.github/workflows/fidelity-desktop-native-ref.yml new file mode 100644 index 00000000000..542398ed7f5 --- /dev/null +++ b/.github/workflows/fidelity-desktop-native-ref.yml @@ -0,0 +1,151 @@ +--- +name: Desktop native reference capture + +# Captures REAL native desktop widget screenshots -- Windows 11 Fluent (WinUI 3), +# macOS (AppKit) and GNOME (GTK4 + libadwaita) -- on hosted runners, and uploads +# them for human review. It commits nothing and gates nothing. +# +# MANUAL DISPATCH ONLY, on purpose. There is deliberately no schedule and no path +# filter. A native reference *defines* the design generation the themes are +# authored against; a job that re-captured it on its own would quietly re-anchor +# that definition, turning a real OS-level design change into a green build. This +# is the same doctrine scripts/build-ios-native-ref.sh and +# scripts/build-android-native-ref.sh already state for the mobile references, +# which CI is likewise forbidden from generating. +# +# Why the maintainer's own machine cannot do this: a working developer's Mac has a +# chosen accent colour, a chosen appearance and custom fonts, and there is no +# pristine Windows 11 or GNOME box to hand. The hosted runner IS the pristine +# machine -- which is the whole reason this workflow exists. +# +# Promotion of an artifact into a committed golden set is a human act with a +# written protocol: scripts/fidelity-app/goldens/README.md. + +'on': + workflow_dispatch: + inputs: + targets: + description: 'Which reference toolkits to run' + required: true + default: all + type: choice + options: [all, windows, macos, gnome] + mode: + description: 'probe = answer the environment go/no-go questions only; capture = produce reference tiles' + required: true + default: probe + type: choice + options: [probe, capture] + windows_runner: + description: 'windows-11-arm is a real Windows 11 client; windows-latest is Windows SERVER (Mica falls back silently)' + required: false + default: windows-11-arm + type: choice + options: [windows-11-arm, windows-latest] + +# No concurrency group at all, which took two tries to get right. +# +# Cancelling in progress -- copied from the PR-gating workflows, where a superseded commit's +# result is genuinely worthless -- is wrong here: a capture run exists to produce evidence, +# and a second dispatch to iterate on one platform would destroy the answer the first was +# dispatched for. +# +# But merely turning cancellation off is not enough, because the group still serialises. +# Measured: a Windows-only dispatch sat in `pending` behind an unrelated run whose macOS leg +# had been waiting an hour for a runner, so the cheap 3-minute leg could not iterate at all. +# The legs are independent and a human dispatches this a few times a day; there is no +# pile-up to protect against, and any grouping only couples platforms that share nothing. + +permissions: + contents: read + +jobs: + gnome-ref: + if: ${{ inputs.targets == 'all' || inputs.targets == 'gnome' }} + name: GNOME (GTK4 + libadwaita) + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + CN1SS_FIDELITY_GOLDEN_SET: gnome-adwaita + NATIVEREF_MODE: ${{ inputs.mode }} + steps: + - uses: actions/checkout@v6 + - name: Install the GTK4 / libadwaita stack + run: | + scripts/ci/apt-get-install.sh \ + libgtk-4-dev libadwaita-1-dev adwaita-icon-theme fonts-cantarell \ + xvfb openbox dbus-x11 libgl1-mesa-dri + - name: Capture + run: scripts/build-gnome-native-ref.sh + - name: Report the environment this run saw + if: always() + run: | + echo '--- capture-manifest.json ---' + cat artifacts/desktop-native-ref/gnome/capture-manifest.json || true + - uses: actions/upload-artifact@v7 + if: always() + with: + name: desktop-native-ref-gnome + path: artifacts/desktop-native-ref/gnome/** + if-no-files-found: error + retention-days: 14 + + windows-ref: + if: ${{ inputs.targets == 'all' || inputs.targets == 'windows' }} + name: Windows (WinUI 3 / Fluent) + runs-on: ${{ inputs.windows_runner }} + timeout-minutes: 30 + env: + CN1SS_FIDELITY_GOLDEN_SET: windows-11-fluent + NATIVEREF_MODE: ${{ inputs.mode }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + # The WindowsAppSDK packaging targets resolve eight MSBuild tasks out of Visual + # Studio's AppxPackage assembly, which the .NET SDK has never carried. `dotnet build` + # therefore cannot build a WinUI 3 app at all, whichever SDK version is selected. + - name: Put Visual Studio's MSBuild on PATH + uses: microsoft/setup-msbuild@v2 + - name: Capture + shell: pwsh + run: ./scripts/build-windows-native-ref.ps1 + - name: Report the environment this run saw + if: always() + shell: pwsh + run: | + Write-Host '--- capture-manifest.json ---' + Get-Content artifacts/desktop-native-ref/windows/capture-manifest.json -ErrorAction SilentlyContinue + - uses: actions/upload-artifact@v7 + if: always() + with: + name: desktop-native-ref-windows + path: artifacts/desktop-native-ref/windows/** + if-no-files-found: error + retention-days: 14 + + macos-ref: + if: ${{ inputs.targets == 'all' || inputs.targets == 'macos' }} + name: macOS (AppKit) + runs-on: macos-15 + timeout-minutes: 30 + env: + CN1SS_FIDELITY_GOLDEN_SET: macos-aqua + NATIVEREF_MODE: ${{ inputs.mode }} + steps: + - uses: actions/checkout@v6 + - name: Capture + run: scripts/build-macos-native-ref.sh + - name: Report the environment this run saw + if: always() + run: | + echo '--- capture-manifest.json ---' + cat artifacts/desktop-native-ref/macos/capture-manifest.json || true + - uses: actions/upload-artifact@v7 + if: always() + with: + name: desktop-native-ref-macos + path: artifacts/desktop-native-ref/macos/** + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/native-themes-sync.yml b/.github/workflows/native-themes-sync.yml index 9f407c8d1d8..e5666d6395a 100644 --- a/.github/workflows/native-themes-sync.yml +++ b/.github/workflows/native-themes-sync.yml @@ -66,6 +66,10 @@ jobs: bash $GITHUB_WORKSPACE/scripts/ci/retry.sh mvn -B -pl css-compiler -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase - name: Rebuild native themes + env: + # Makes the script record every file it wrote, so the commit step below stages + # what this run actually produced instead of a list kept by hand. + NATIVE_THEMES_MANIFEST: ${{ runner.temp }}/native-themes-written.txt run: ./scripts/build-native-themes.sh - name: Commit and push regenerated .res files @@ -80,11 +84,32 @@ jobs: git config --global --add safe.directory "$GITHUB_WORKSPACE" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Themes/ holds the single source of truth. Each downstream consumer - # (nativeios.jar, the Android port jar, the simulator fat-jar, the - # BuildDaemon's sibling cn1 checkout) copies from Themes/ at its own - # build time, so this workflow only commits these two files. - git add Themes/iOSModernTheme.res Themes/AndroidMaterialTheme.res + # Stage what THIS RUN wrote, from the manifest the script emits, rather than + # from a list kept here. The list used to be the two mobile themes and stayed + # that way when three desktop themes and two committed port mirrors joined the + # script: a desktop-only CSS change regenerated three .res files, staged none, + # reported "nothing to commit" and left every downstream build on the previous + # binary -- silently, because "nothing to commit" is what a no-op run says too. + # + # Untracked outputs are skipped deliberately. The script also mirrors into the + # JavaScript port's asset directory, which does not commit these themes, and + # staging blind there would start committing binaries nobody asked for. + staged="${RUNNER_TEMP}/native-themes-staged.txt" + : > "$staged" + while IFS= read -r f; do + [ -n "$f" ] || continue + if git ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then + printf '%s\n' "$f" >> "$staged" + else + echo "not tracked, not staging: $f" + fi + done < "${RUNNER_TEMP}/native-themes-written.txt" + if [ ! -s "$staged" ]; then + echo "The theme build recorded no tracked outputs; refusing to report success." >&2 + exit 1 + fi + echo "Staging:"; cat "$staged" + git add --pathspec-from-file="$staged" if git diff --staged --quiet; then echo "No .res changes; nothing to commit." exit 0 diff --git a/.github/workflows/scripts-fidelity-desktop.yml b/.github/workflows/scripts-fidelity-desktop.yml new file mode 100644 index 00000000000..8e51046ead6 --- /dev/null +++ b/.github/workflows/scripts-fidelity-desktop.yml @@ -0,0 +1,228 @@ +--- +name: Desktop theme fidelity + +# Scores the desktop native themes -- Windows Fluent, macOS Aqua, GNOME Adwaita -- against the +# captured native references, the same way scripts-fidelity.yml does for iOS and Android. +# +# A SEPARATE workflow rather than three more jobs in scripts-fidelity.yml. That file's +# pull_request paths trigger the WHOLE workflow, so adding these there would run the Android +# emulator and the iOS simulator legs on every desktop CSS tweak, and would tie the desktop +# gate's timing to theirs. They share the comparator and the golden-set machinery, which is +# where sharing actually pays. +# +# Each theme is scored on ITS OWN runner. A Fluent theme measured on a Mac would be measured +# in the wrong system font, and text metrics are most of a fidelity score. +# +# Runs on master and on dispatch. NOT on pull_request, deliberately. +# +# It had a pull_request trigger for about an hour and that was a mistake: it put a +# three-platform, quarter-hour job onto every PR that happened to touch scripts/lib, +# scripts/common/java or a theme, none of which is those authors' concern. If this ever +# becomes a PR gate it should be after the themes have stopped moving and with the paths +# narrowed to the theme sources alone. +# +# Gating on master still catches a regression the same day it lands, which is what the +# ratchet is for. That split is the point: a leg with no golden set exits 24 on +# every run, and wiring it to a trigger would mean either a permanently red workflow, which +# teaches people to ignore it, or an unseeded run reported as a pass, which is a check +# satisfied by nothing having happened. +# +# windows-11-fluent seeded from run 34998745240, baselined, gating +# gnome-adwaita seeded from run 34990802562, baselined, gating +# macos-aqua baseline seeded from run 35102280850, gating +# +# Baseline provenance and inspected changes are in scripts/fidelity-app/baseline/README.md. + +'on': + workflow_dispatch: + inputs: + targets: + description: 'Which themes to score' + type: choice + default: all + options: [all, windows, macos, gnome] + push: + branches: [master] + # Watch desktop rendering inputs, theme sources and output, and this suite. + # + # The list used to include scripts/lib/cn1ss.sh and scripts/common/java/**, which are + # shared with the iOS and Android fidelity suites and with every screenshot runner in + # the tree. Those ARE inputs to this score, so including them looks right -- and it + # means a change to the shared comparator drags a three-platform, quarter-hour desktop + # job behind it. That cost lands on whoever touched the shared file, which is not their + # concern, and the same regression is caught by the next push that does touch a theme. + # + # A gate is worth what it catches minus what it costs everyone else. + paths: + - '.github/workflows/scripts-fidelity-desktop.yml' + - 'scripts/run-desktop-fidelity-tests.sh' + # These sources build the renderer used for every desktop capture. Mobile + # fidelity excludes Desktop* rows and cannot cover their hover/font behavior. + - 'CodenameOne/src/**' + - 'Ports/JavaSE/src/**' + # Sync's GITHUB_TOKEN commit does not trigger another workflow. Score generator + # changes from the original master push; this job rebuilds CSS before scoring. + - 'maven/css-compiler/**' + - 'scripts/build-native-themes.sh' + - 'scripts/fidelity-app/desktop-runner/**' + - 'scripts/fidelity-app/common/src/main/resources/fidelity-tests.yaml' + - 'scripts/fidelity-app/common/src/main/java/**' + - 'scripts/fidelity-app/goldens/windows-11-fluent/**' + - 'scripts/fidelity-app/goldens/gnome-adwaita/**' + - 'scripts/fidelity-app/goldens/macos-aqua/**' + - 'scripts/fidelity-app/baseline/windows-11-fluent-*.json' + - 'scripts/fidelity-app/baseline/gnome-adwaita-*.json' + - 'scripts/fidelity-app/baseline/macos-aqua-*.json' + - 'native-themes/windows-fluent/**' + - 'native-themes/gnome-adwaita/**' + - 'native-themes/macos-aqua/**' + - 'Themes/WindowsFluentTheme.res' + - 'Themes/GnomeAdwaitaTheme.res' + - 'Themes/MacOSAquaTheme.res' +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Which legs run is decided HERE and emitted as the matrix, rather than with an `if` on + # the scoring job. A job-level `if` cannot see the matrix context -- GitHub rejects the + # whole file with "Unrecognized named-value: 'matrix'", and the only symptom is a run + # that fails instantly with "this run likely failed because of a workflow file issue" + # and no log at all. Building the matrix in a prior job keeps the decision in one place + # and costs one ubuntu minute. + select: + name: select legs + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.pick.outputs.matrix }} + any: ${{ steps.pick.outputs.any }} + steps: + - id: pick + shell: bash + env: + # On a dispatch, whatever was asked for. On a trigger, every platform whose + # golden set is committed, which is now all three. + # + # Every platform now has hosted-runner scores and geometry baselines. Keep + # each baseline tied to its own runner; font metrics differ across hosts. + TARGETS: ${{ github.event_name == 'workflow_dispatch' && inputs.targets || 'gated' }} + run: | + set -eo pipefail + case "$TARGETS" in + gated) legs='gnome windows macos' ;; + all) legs='gnome windows macos' ;; + gnome|windows|macos) legs="$TARGETS" ;; + *) echo "unknown targets '$TARGETS'" >&2; exit 2 ;; + esac + entries='' + for leg in $legs; do + case "$leg" in + gnome) runner=ubuntu-latest ;; + windows) runner=windows-11-arm ;; + macos) runner=macos-15 ;; + esac + entries="$entries{\"platform\":\"$leg\",\"runner\":\"$runner\"}," + done + matrix="{\"include\":[${entries%,}]}" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + echo "any=$([ -n "$entries" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "legs: $legs" + + fidelity-desktop: + name: ${{ matrix.platform }} + needs: select + if: needs.select.outputs.any == 'true' + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.select.outputs.matrix) }} + env: + CN1SS_FIDELITY_EPSILON: '2.0' + steps: + - uses: actions/checkout@v6 + + # JDK 8 builds the framework; the simulator needs 11 or newer to run. Both are needed, + # which is why two setup steps rather than one. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + # Match the Windows 11 font inventory used by native reference capture. + # Java 8 and the simulator native libraries run through x64 emulation there. + architecture: ${{ matrix.platform == 'windows' && 'x64' || '' }} + - id: jdk21 + shell: bash + run: echo "path=$JAVA_HOME" >> "$GITHUB_OUTPUT" + - uses: actions/setup-java@v4 + with: + # Temurin 8 has no macOS ARM64 build; Zulu provides native Java 8. + distribution: ${{ matrix.platform == 'macos' && 'zulu' || 'temurin' }} + java-version: '8' + architecture: ${{ matrix.platform == 'windows' && 'x64' || '' }} + + # Needed by the BUILD as well as by the run. The fidelity app is a Codename One + # application, so its build runs the cn1 css goal, which rasterizes through CEF and + # needs a display -- it fails with "An error occurred while compiling the CSS files" + # on a bare runner. Installed before the build for that reason, not only before the + # simulator starts. + - name: Install display and native reference font + if: matrix.platform == 'gnome' + run: bash scripts/ci/apt-get-install.sh xvfb fonts-cantarell + + - name: Build the core, the simulator and the fidelity app + shell: bash + env: + JDK21: ${{ steps.jdk21.outputs.path }} + run: | + set -eo pipefail + cd maven + # ONE invocation with -am, not module builds in sequence. `-pl core` on its own + # cannot resolve codenameone-factory, which is a sibling module rather than a + # published artifact, so the first real run failed there before anything else + # ran. -am builds each module's dependencies, which pulls in factory and + # cn1-binaries. + # + # codenameone-maven-plugin is in the list because the fidelity app's own build + # runs it, and nothing else installs it on a clean runner. + mvn -B -q -DskipTests -Plocal-dev-javase \ + -pl javase,codenameone-maven-plugin -am install + + # The fidelity app targets 17 and JAVA_HOME is still the JDK 8 the framework + # needs, so this half runs on the 21 installed first ("invalid target release: + # 17" otherwise). Two JDKs in one job is the reason both setup-java steps exist. + cd ../scripts/fidelity-app + XVFB=() + if [ "$(uname -s)" = "Linux" ]; then + XVFB=(xvfb-run -a) + fi + JAVA_HOME="$JDK21" "${XVFB[@]}" ./mvnw -B -q -pl common,desktop-runner -DskipTests install + + - name: Regenerate the desktop themes from CSS + shell: bash + # theme.css is the source of truth; Themes/*.res is compiled output that happens to + # be committed. run-desktop-fidelity-tests.sh deliberately puts Themes/ FIRST on the + # classpath so a stale copy elsewhere cannot win -- which also means that without + # this step a push that changes only native-themes//theme.css is scored + # against the PREVIOUS compiled theme and can pass without the change being tested. + # Same step, same reason, as the Android and iOS legs in scripts-fidelity.yml. + run: ./scripts/build-native-themes.sh + + - name: Score the theme against the native reference + shell: bash + env: + # The simulator refuses to start on JDK 8, so the tiles are rendered with the 21 + # that was installed first. The build above still runs on 8. + CN1SS_DESKTOP_JAVA: ${{ steps.jdk21.outputs.path }}/bin/java + run: scripts/run-desktop-fidelity-tests.sh "${{ matrix.platform }}" + + - uses: actions/upload-artifact@v7 + if: always() + with: + name: desktop-fidelity-${{ matrix.platform }} + path: artifacts/${{ matrix.platform }}-fidelity/** + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/scripts-fidelity.yml b/.github/workflows/scripts-fidelity.yml index bfc7a285905..577c265b100 100644 --- a/.github/workflows/scripts-fidelity.yml +++ b/.github/workflows/scripts-fidelity.yml @@ -27,6 +27,14 @@ name: Native theme fidelity - 'scripts/lib/cn1ss.sh' - 'scripts/common/java/**' - 'scripts/fidelity-app/**' + # The standalone native-reference apps are excluded on purpose. CI never runs + # them -- references are captured off-CI and committed (see + # scripts/fidelity-app/README.md) -- so a change to one cannot move an Android or + # iOS fidelity score, and letting them through costs a 60 + 90 minute emulator and + # simulator run per edit. That matters most for the desktop reference apps, which + # are iterated against a manual dispatch workflow and would otherwise fire this one + # on every push. + - '!scripts/fidelity-app/*-native-ref/**' - 'native-themes/ios-modern/**' - 'native-themes/android-material/**' - 'CodenameOne/src/**' @@ -211,27 +219,18 @@ jobs: echo "app_path=$APP" >> "$GITHUB_OUTPUT" - name: Boot simulator (runtime matching the golden set) id: sim - run: | - set -euo pipefail - # The golden set names the OS design generation it was captured on - # (ios-26-metal). The suite MUST run on a matching runtime: a different - # iOS generation renders different SF fonts/glyphs and produces phantom - # regressions. Fail loudly if the runner lacks the runtime -- the fix is - # pinning a runner image/Xcode that ships it, never regenerating - # references on whatever the runner happens to have. - RUNTIME="$(xcrun simctl list runtimes | grep -Eo 'com.apple.CoreSimulator.SimRuntime.iOS-26[0-9-]*' | head -n1)" - if [ -z "$RUNTIME" ]; then - echo "::error::No iOS 26 simulator runtime on this runner (required by golden set ios-26-metal)." - xcrun simctl list runtimes - exit 78 - fi - UDID="$(xcrun simctl list devices "$RUNTIME" available | grep -E 'iPhone 16 \(' | grep -Eo '[0-9A-F-]{36}' | head -n1 || true)" - if [ -z "$UDID" ]; then - UDID="$(xcrun simctl create "iPhone16-fidelity" "iPhone 16" "$RUNTIME")" - fi - xcrun simctl boot "$UDID" - xcrun simctl bootstatus "$UDID" -b - echo "udid=$UDID" >> "$GITHUB_OUTPUT" + # The golden set names the OS design generation it was captured on + # (ios-26-metal). The suite MUST run on a matching runtime: a different + # iOS generation renders different SF fonts/glyphs and produces phantom + # regressions. The helper fails loudly (78) if the runner lacks the + # runtime -- the fix is pinning a runner image/Xcode that ships it, + # never regenerating references on whatever the runner happens to have. + # + # It also puts every simctl call on a deadline. Inline, these four calls + # twice hung with no output at all and burned the job's entire 90-minute + # budget, ending as "cancelled" rather than as a failure; the script's + # header records the runs and why the one retry it does is allowed. + run: ./scripts/ci/boot-ios-simulator.sh 'iOS-26[0-9-]*' 'iPhone 16' iPhone16-fidelity - name: Run fidelity suite (simulator, Metal) # The committed baseline is the ratchet floor: the gate fails the job # on any regression. To re-anchor intentionally, run locally with diff --git a/.github/workflows/windows-cross-compile.yml b/.github/workflows/windows-cross-compile.yml index f65ff8b682a..b2d5e05a41e 100644 --- a/.github/workflows/windows-cross-compile.yml +++ b/.github/workflows/windows-cross-compile.yml @@ -128,7 +128,9 @@ jobs: # and this step does not set pipefail. Reading the wrong one would make every # attempt look successful, which is the opposite failure to the one being # fixed and would hide everything. - JAVA_HOME="$JDK_8_HOME" mvn -B -pl windows -am -DskipTests \ + # Recheck missing releases after a transient repository error instead of + # replaying Maven's cached resolution failure on every retry. + JAVA_HOME="$JDK_8_HOME" mvn -B -U -pl windows -am -DskipTests \ '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' $goal 2>&1 \ | tee /tmp/windows-cross-build.log status=${PIPESTATUS[0]} diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/Build.java b/CodenameOne/src/com/codename1/annotations/buildhints/Build.java index ae7db56d9a9..88eaa0c470b 100644 --- a/CodenameOne/src/com/codename1/annotations/buildhints/Build.java +++ b/CodenameOne/src/com/codename1/annotations/buildhints/Build.java @@ -49,12 +49,21 @@ @Hint(name = "gcm.sender_id") String gcmSenderId() default ""; - /// `modern`, `legacy`, `custom` (default unset). Cross-platform override that - /// sets both `ios.themeMode` and `and.themeMode` together when those aren't - /// set explicitly. `modern` = liquid glass + Material 3, `legacy` = iOS 7 flat - /// + Holo Light, `custom` disables the framework native theme entirely. The - /// legacy alias `cn1.nativeTheme` is still accepted. - @Hint(valuePattern = "modern|legacy|custom") + /// `native`, `modern`, `legacy`, `custom` (default unset). Cross-platform + /// override that sets `ios.themeMode` and `and.themeMode` together when those + /// aren't set explicitly. `modern` = liquid glass + Material 3, `legacy` = + /// iOS 7 flat + Holo Light, `custom` disables the framework native theme + /// entirely. The legacy alias `cn1.nativeTheme` is still accepted. + /// + /// `native` is `modern` plus the desktop: it additionally selects the host's + /// own desktop theme -- Fluent, Aqua or Adwaita -- the way + /// `desktop.themeMode = auto` does. `modern` stops short of the desktop on + /// purpose, because it predates the desktop themes by years and an application + /// that set it for its phone builds never asked for its desktop screens to be + /// redrawn. + /// `desktop.themeMode` overrides this hint either way, and the full per-platform + /// table is on the `@DesktopBuild` annotation. + @Hint(valuePattern = "native|modern|legacy|custom") ThemeMode nativeTheme() default ThemeMode.DEFAULT; /// true/false (defaults to false). Blocks codename one from injecting its own diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/DesktopBuild.java b/CodenameOne/src/com/codename1/annotations/buildhints/DesktopBuild.java index e91c94a3588..ebbb304eb2a 100644 --- a/CodenameOne/src/com/codename1/annotations/buildhints/DesktopBuild.java +++ b/CodenameOne/src/com/codename1/annotations/buildhints/DesktopBuild.java @@ -38,6 +38,44 @@ /// /// The platform is stated once on the annotation, not on every attribute. An /// attribute repeats it only to disagree with it. +/// +/// #### Which theme a desktop application gets +/// +/// [#themeMode()] is resolved against the machine the application starts on, +/// because one desktop binary runs on all three operating systems: +/// +/// | `desktop.themeMode` | Windows | macOS | Linux / GNOME | +/// | --- | --- | --- | --- | +/// | unset, or `legacy` | *unchanged* | *unchanged* | *unchanged* | +/// | `auto`, `native`, `modern` | Windows Fluent | macOS Aqua | GNOME Adwaita | +/// | `fluent` | Windows Fluent | Windows Fluent | Windows Fluent | +/// | `aqua` | macOS Aqua | macOS Aqua | macOS Aqua | +/// | `adwaita` | GNOME Adwaita | GNOME Adwaita | GNOME Adwaita | +/// | `custom` | *none* | *none* | *none* | +/// +/// *unchanged* is the default and is deliberate: it is whatever the application was +/// built and tested against before these themes existed, because flipping it would +/// move every screen of every desktop application already shipped. `custom` differs +/// from it by installing no framework theme at all. +/// +/// #### How that relates to the other theme hints +/// +/// Each platform has its own hint, and each governs only its own platform: +/// +/// | hint | governs | see | +/// | --- | --- | --- | +/// | `desktop.themeMode` | the JavaSE desktop application, on all three desktops | [#themeMode()] | +/// | `ios.themeMode` | iOS | [Ios#themeMode()] | +/// | `and.themeMode` | Android | [Android#themeMode()] | +/// | `mac.themeMode` | the native macOS build, a separate target from the JavaSE desktop application | [Mac#themeMode()] | +/// | `nativeTheme` | the default for the three above, where they are unset | [Build#nativeTheme()] | +/// +/// The one value in that last row that also reaches the desktop is +/// `nativeTheme = ThemeMode.NATIVE`, which is the single hint for "look like the +/// platform, everywhere". `ThemeMode.MODERN` reaches iOS and Android only: it +/// shipped years before the desktop themes, so an application that set it for its +/// phone builds never asked for its desktop screens to be redrawn. `themeMode` here +/// outranks both. @Hint(platform = "desktop") @Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) @@ -62,6 +100,39 @@ /// desktop build is resizable Toggle resizable() default Toggle.DEFAULT; + // No javadoc link syntax in the `///` block below, and no note inside it about why. + // An attribute's prose is harvested verbatim into the developer guide's hint table, + // so a bracketed reference reaches a reader as literal text pointing at a symbol the + // guide does not publish -- and a parenthetical explaining that to the next editor is + // an internal note published in a customer-facing document. This comment is the right + // home for both: BuildHintAnnotationReader collects only the `///` run, and only the + // one immediately preceding the declaration, so a `//` comment ABOVE it is invisible + // to the table. Below it would be worse than invisible: a non-`///` line discards the + // pending comment, and the attribute would reach the guide with no description at all. + /// Which native theme a desktop build installs, and the one hint that decides + /// whether a desktop application looks like the platform it's running on. + /// + /// One desktop binary runs on Windows, macOS and Linux, so the value is resolved + /// against the machine the application starts on rather than at build time. + /// `auto`, `native` and `modern` are one value under three spellings and select + /// the host's own look: Fluent, Aqua or Adwaita. Naming a theme outright with + /// `fluent`, `aqua` or `adwaita` pins that one look on every machine instead, + /// which is what an application with a deliberate cross-platform identity wants. + /// `legacy`, which is also the default, keeps whatever the application was built + /// and tested against before these themes existed, and `custom` installs no + /// framework theme at all so the application's own is the only one loaded. + /// + /// The per-value and per-platform tables, and how this relates to the iOS, + /// Android, macOS and cross-platform theme hints, are on the `@DesktopBuild` + /// annotation itself. + /// + /// Read by the JavaSE port at runtime rather than by a builder, so unlike most + /// hints here it changes what the running application does rather than what's + /// produced for it. + @Hint(name = "desktop.themeMode", + valuePattern = "auto|native|modern|fluent|aqua|adwaita|legacy|custom") + String themeMode() default ""; + /// How the desktop window is framed: native for the OS title bar and menu bar, /// custom for an undecorated window with a Codename One drawn title bar, or /// toolbar for the legacy in-app Toolbar. An unrecognized value falls back to diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/ThemeMode.java b/CodenameOne/src/com/codename1/annotations/buildhints/ThemeMode.java index 6117bae7214..d8e0560207e 100644 --- a/CodenameOne/src/com/codename1/annotations/buildhints/ThemeMode.java +++ b/CodenameOne/src/com/codename1/annotations/buildhints/ThemeMode.java @@ -47,6 +47,19 @@ public enum ThemeMode { @HintValue(value = "modern", accepts = {"liquid", "material"}) MODERN, + /// The current platform look on EVERY operating system the application runs + /// on, desktop included. + /// + /// This is what separates it from [#MODERN], which reaches iOS and Android + /// only. Desktop is deliberately excluded there: the desktop native themes + /// arrived years after the mobile ones, so a desktop application that set + /// `nativeTheme = ThemeMode.MODERN` for its phone builds would have had + /// every one of its screens redrawn by a hint it set for another platform. + /// Asking for the desktop look has to be something you said, which is this + /// constant. + @HintValue("native") + NATIVE, + /// The flat iOS 7 look. @HintValue(value = "ios7", accepts = {"flat"}) IOS7, diff --git a/CodenameOne/src/com/codename1/ui/Button.java b/CodenameOne/src/com/codename1/ui/Button.java index eda5f432419..62db4f62651 100644 --- a/CodenameOne/src/com/codename1/ui/Button.java +++ b/CodenameOne/src/com/codename1/ui/Button.java @@ -429,6 +429,9 @@ public int getState() { void setState(int state) { if (state != this.state) { this.state = state; + if (isHovered()) { + checkHoverAnimationHierarchy(); + } fireStateChange(); } } diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index c1849fd00a6..e07c16af9b9 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -345,6 +345,9 @@ static int rubberBandDecompress(int compressed, int dim) { private boolean scrollSizeRequestedByUser = false; private Style unSelectedStyle; private Style pressedStyle; + private Style hoverStyle; + /// Set only on the desktop; see setHovered. + private boolean hovered; private Style selectedStyle; private Style disabledStyle; private Style allStyles; @@ -757,6 +760,13 @@ public Object getNativeOverlay() { /// hi.show(); /// ``` /// + /// The hover style is deliberately NOT part of this proxy. A component only has one when + /// its theme declares hover for that UIID, so including it would either force one into + /// existence for every component this is called on -- the blank-default repaint + /// {@link #getHoverStyle()} exists to prevent -- or make the proxy cover four states + /// sometimes and five others, depending on the theme and on when it was first called. + /// Style hover in the theme, which is where the desktop themes do it. + /// /// #### Returns /// /// a unified style object to set values on all styles @@ -1240,6 +1250,14 @@ public void setVisible(boolean visible) { return; } this.visible = visible; + if (!visible) { + // Hiding an attached subtree ends its pointer ownership just like removal. + // Showing it again must wait for a fresh pointer event, including tooltips. + clearHoverForInactiveSubtree(); + } + if (hovered) { + checkHoverAnimationHierarchy(); + } accessibilityChanged(AccessibilityManager.CHANGE_STRUCTURE); } @@ -1877,6 +1895,7 @@ protected final void setUIIDFinal(String id) { selectedStyle = null; disabledStyle = null; pressedStyle = null; + hoverStyle = null; allStyles = null; if (!sizeRequestedByUser) { preferredSize = null; @@ -1889,6 +1908,7 @@ boolean onOrientationChange() { selectedStyle = null; disabledStyle = null; pressedStyle = null; + hoverStyle = null; allStyles = null; if (!sizeRequestedByUser) { preferredSize = null; @@ -1934,6 +1954,7 @@ public void setInlineAllStyles(String styles) { selectedStyle = null; disabledStyle = null; pressedStyle = null; + hoverStyle = null; allStyles = null; if (!sizeRequestedByUser) { preferredSize = null; @@ -1970,6 +1991,7 @@ public void setInlineSelectedStyles(String styles) { selectedStyle = null; disabledStyle = null; pressedStyle = null; + hoverStyle = null; allStyles = null; if (!sizeRequestedByUser) { preferredSize = null; @@ -2007,6 +2029,7 @@ public void setInlineUnselectedStyles(String styles) { selectedStyle = null; disabledStyle = null; pressedStyle = null; + hoverStyle = null; allStyles = null; if (!sizeRequestedByUser) { preferredSize = null; @@ -2043,6 +2066,7 @@ public void setInlineDisabledStyles(String styles) { selectedStyle = null; disabledStyle = null; pressedStyle = null; + hoverStyle = null; allStyles = null; if (!sizeRequestedByUser) { preferredSize = null; @@ -2079,6 +2103,7 @@ public void setInlinePressedStyles(String styles) { selectedStyle = null; disabledStyle = null; pressedStyle = null; + hoverStyle = null; allStyles = null; if (!sizeRequestedByUser) { preferredSize = null; @@ -4390,6 +4415,9 @@ public void setFocus(boolean focused) { return; } this.focused = focused; + if (hovered) { + checkHoverAnimationHierarchy(); + } accessibilityChanged(AccessibilityManager.CHANGE_FOCUS); } @@ -4659,9 +4687,14 @@ public boolean isBlockLead() { /// /// - `blockLead`: the blockLead to set public void setBlockLead(boolean blockLead) { + if (this.blockLead == blockLead) { + return; + } + HoverTracker tracker = HoverTracker.prepareLeadChange(this); this.blockLead = blockLead; - if (blockLead) { - hasLead = false; + hasLead = !blockLead && getLeadComponent() != null; + if (tracker != null) { + tracker.finishLeadChange(this); } } @@ -7520,6 +7553,20 @@ public Style getStyle() { return getPressedStyle(); } + if (keepTextInputFocusStyle(lead)) { + return getSelectedStyle(); + } + + // Hover follows the same text-input focus exception as the main path. + // The tracker marks the lead component; its parent and siblings paint + // the same state through this branch before the main path is reached. + if (lead.isHovered()) { + Style hover = getHoverStyle(); + if (hover != null) { + return hover; + } + } + if (lead.hasFocus() && Display.getInstance().shouldRenderSelection(this)) { return getSelectedStyle(); } @@ -7536,6 +7583,21 @@ public Style getStyle() { return getPressedStyle(); } + // Text inputs encode the focus ring in their selected style. Keep the complete + // style (including its border padding and background) while the pointer remains; + // copying only its border onto hover would mix incompatible geometry. Buttons + // retain hover feedback while focused, since their selected state is not editing. + if (keepTextInputFocusStyle(this)) { + return getSelectedStyle(); + } + // An undeclared hover state still falls through for legacy themes. + if (hovered) { + Style hover = getHoverStyle(); + if (hover != null) { + return hover; + } + } + if (hasFocus() && Display.getInstance().shouldRenderSelection(this)) { return getSelectedStyle(); } @@ -7543,6 +7605,11 @@ public Style getStyle() { return unSelectedStyle; } + private boolean keepTextInputFocusStyle(Component owner) { + return owner instanceof TextArea && owner.hasFocus() + && Display.getInstance().shouldRenderSelection(this); + } + boolean isPressedStyle() { return false; } @@ -7575,6 +7642,111 @@ public Style getPressedStyle() { return pressedStyle; } + /// Returns the Component Style for the hover state, or `null` when the theme says + /// nothing about hovering this UIID. + /// + /// Hover is the one desktop state the mobile design languages never needed, and it is the + /// state a Fluent or Adwaita control is mostly made of. It is deliberately the only + /// per-state getter here that can return null, and the null is the whole point: a theme + /// authored before hover existed declares no `hover#` entries, and + /// {@link com.codename1.ui.plaf.UIManager#getComponentCustomStyle(String, String)} never + /// returns null -- asked for a type the theme does not define it hands back a copy of the + /// blank default style: white background, black foreground. Building one unconditionally would therefore + /// repaint every hovered component in an existing application the moment the pointer + /// crossed it. So the theme is asked first, and a theme with no opinion leaves + /// {@link #getStyle()} to fall through to the ordinary chain. + /// + /// #### Returns + /// + /// the component Style object for the hover state, or null when the theme defines none + public Style getHoverStyle() { + if (hoverStyle == null) { + if (!getUIManager().hasComponentCustomStyle(getUIID(), "hover")) { + return null; + } + hoverStyle = createHoverStyle(getUIManager(), getUIID()); + if (initialized && hoverStyle.getElevation() > 0) { + registerElevatedInternal(this); + } + if (initialized) { + setSurface(hoverStyle.isSurface()); + } + hoverStyle.addStyleListener(this); + if (hoverStyle.getBgPainter() == null) { + hoverStyle.setBgPainter(new BGPainter()); + } + // UIID and inline-style changes rebuild this lazily while the pointer can + // remain stationary. Start the new background once the style is complete. + if (initialized && isEffectivelyHovered()) { + checkAnimation(); + } + } + return hoverStyle; + } + + private Style createHoverStyle(UIManager manager, String id) { + // Inline-all overlays an already declared hover state. Keep the same resource + // prerequisite as the other inline states, without inventing hover for old themes. + if (getInlineStylesTheme() != null && inlineAllStyles != null) { + return manager.parseComponentCustomStyle(getInlineStylesTheme(), id, + getInlineStylesUIID(id), "hover", inlineAllStyles); + } + return manager.getComponentCustomStyle(id, "hover"); + } + + /// Sets the Component Style for the hover state allowing us to manipulate the look of the + /// component when the pointer is over it. + /// + /// #### Parameters + /// + /// - `style`: the component Style object + public void setHoverStyle(Style style) { + if (hoverStyle != null) { + hoverStyle.removeStyleListener(this); + } + hoverStyle = style; + if (initialized && hoverStyle.getElevation() > 0) { + registerElevatedInternal(this); + } + if (initialized) { + setSurface(hoverStyle.isSurface()); + } + hoverStyle.addStyleListener(this); + if (hoverStyle.getBgPainter() == null) { + hoverStyle.setBgPainter(new BGPainter()); + } + setShouldCalcPreferredSize(true); + checkAnimation(); + } + + /// True while the pointer is over this component. Only ever set on the desktop, by + /// {@link Form#pointerHover(int[], int[])}; a touch device has no hover to report. + /// + /// #### Returns + /// + /// true when the pointer is currently over this component + public boolean isHovered() { + return hovered; + } + + /// Marks this component as hovered, repainting when the state actually changes AND the + /// theme has a hover style to show for it -- otherwise the repaint would be pure cost, + /// because nothing about the render depends on the flag. + /// + /// #### Parameters + /// + /// - `hovered`: true when the pointer is over this component + public void setHovered(boolean hovered) { + if (this.hovered == hovered) { + return; + } + this.hovered = hovered; + if (getHoverStyle() != null) { + repaint(); + } + checkHoverAnimationHierarchy(); + } + /// Sets the Component Style for the pressed state allowing us to manipulate /// the look of the component when it is pressed /// @@ -7911,12 +8083,31 @@ protected void refreshTheme(String id, boolean merge) { setPressedStyle(mergeStyle(pressedStyle, manager.getComponentCustomStyle(id, "press"))); } } + // Merge local overrides just like the other states. When a theme removes its + // hover rule, only application-modified properties survive; the remaining + // properties follow the refreshed normal style instead of the removed rule. + if (hoverStyle != null) { + if (manager.hasComponentCustomStyle(id, "hover")) { + setHoverStyle(mergeStyle(hoverStyle, createHoverStyle(manager, id))); + } else if (hoverStyle.isModified()) { + setHoverStyle(mergeStyle(hoverStyle, getUnselectedStyle())); + } else { + // The refreshed theme dropped hover for this UIID. Unregister before + // letting go: every other arm in this block goes through a setter that + // removes the listener first, and a Style left holding a listener to a + // component that no longer reads it is both a leak and a source of + // spurious style callbacks. + hoverStyle.removeStyleListener(this); + hoverStyle = null; + } + } } else { unSelectedStyle = null; getUnselectedStyle(); selectedStyle = null; disabledStyle = null; pressedStyle = null; + hoverStyle = null; allStyles = null; } @@ -7967,7 +8158,113 @@ void restoreFadingScrollbar() { checkAnimation(); } + private Animation hoverBackgroundAnimation; + private TopLevelContainer hoverAnimationHost; + + private boolean isEffectivelyHovered() { + if (hasLead && !blockLead) { + Component lead = getLeadComponent(); + return lead != null && lead.isHovered(); + } + return hovered; + } + + void checkHoverAnimationHierarchy() { + checkAnimation(); + Component leadParent = LeadUtil.leadParentImpl(this); + if (leadParent != null && leadParent != this) { // NOPMD CompareObjectsWithEquals + leadParent.checkLeadHoverAnimations(this); + } + } + + private void checkLeadHoverAnimations(Component lead) { + // Only the lead owns the pointer flag, but its parent and siblings paint + // their own state styles. Each affected background owns its own registration. + if (this != lead && initialized && hasLead && !blockLead // NOPMD CompareObjectsWithEquals + && getLeadComponent() == lead) { // NOPMD CompareObjectsWithEquals + checkAnimation(); + } + if (this instanceof Container) { + Container container = (Container) this; + for (int i = 0; i < container.getComponentCount(); i++) { + container.getComponentAt(i).checkLeadHoverAnimations(lead); + } + } + } + + private boolean hasAnimatedHoverBackground() { + if (!isEffectivelyHovered() || !isVisible() || isHidden(true)) { + return false; + } + // Resolve first: an active UIID/inline change may have cleared hoverStyle. + Style active = getStyle(); + if (hoverStyle == null || active != hoverStyle) { // NOPMD CompareObjectsWithEquals + return false; + } + Image image = hoverStyle.getBgImage(); + Painter painter = hoverStyle.getBgPainter(); + return (image != null && image.isAnimation()) + || (painter instanceof Animation && !(painter instanceof BGPainter)); + } + + private void stopHoverBackgroundAnimation() { + if (hoverAnimationHost != null) { + hoverAnimationHost.deregisterAnimated(hoverBackgroundAnimation); + hoverAnimationHost = null; + } + } + + private void registerHoverBackgroundAnimation() { + TopLevelContainer host = getTopLevelContainer(); + if (host == null || host == hoverAnimationHost) { // NOPMD CompareObjectsWithEquals + return; + } + stopHoverBackgroundAnimation(); + if (hoverBackgroundAnimation == null) { + // Own a separate registration: removing the Component itself on hover exit + // would also cancel an animation explicitly registered by application code. + hoverBackgroundAnimation = new Animation() { + @Override + public boolean animate() { + if (!isInitialized() || !hasAnimatedHoverBackground()) { + stopHoverBackgroundAnimation(); + return false; + } + Image image = hoverStyle.getBgImage(); + boolean changed = image != null && image.isAnimation() && image.animate(); + Painter painter = hoverStyle.getBgPainter(); + if (painter instanceof Animation && !(painter instanceof BGPainter)) { + changed = ((Animation) painter).animate() || changed; + } + if (changed) { + repaint(); + } + return false; + } + + @Override + public void paint(Graphics graphics) { + // The component repaints itself when the background changes. + } + }; + } + hoverAnimationHost = host; + host.registerAnimated(hoverBackgroundAnimation); + } + void checkAnimation() { + if (isEffectivelyHovered() && (!isVisible() || isHidden(true))) { + stopHoverBackgroundAnimation(); + return; + } + if (hasAnimatedHoverBackground()) { + registerHoverBackgroundAnimation(); + // The hover callback advances only the background. A restored scrollbar + // still needs Component.animate() in the independent internal registry. + checkScrollbarAnimation(); + return; + } + stopHoverBackgroundAnimation(); Image bgImage = getStyle().getBgImage(); if (bgImage != null && bgImage.isAnimation()) { registerForAnimation(); @@ -7976,13 +8273,16 @@ void checkAnimation() { if (p != null && p.getClass() != BGPainter.class && p instanceof Animation) { registerForAnimation(); } else { - if (scrollOpacity == 0xff && isScrollable() && getUIManager().getLookAndFeel().isFadeScrollBar()) { - // trigger initial fade process on a fresh view. - Container pf = TopLevelSupport.rootOf(this); - if (pf != null) { - pf.registerAnimatedInternal(this); - } - } + checkScrollbarAnimation(); + } + } + } + + private void checkScrollbarAnimation() { + if (scrollOpacity == 0xff && isScrollable() && getUIManager().getLookAndFeel().isFadeScrollBar()) { + Container root = TopLevelSupport.rootOf(this); + if (root != null) { + root.registerAnimatedInternal(this); } } } @@ -8061,7 +8361,11 @@ public boolean animate() { return false; } Image bgImage = getStyle().getBgImage(); - boolean animateBackground = bgImage != null && bgImage.isAnimation() && bgImage.animate(); + // A separately registered hover background advances once even if the app also + // registered this Component (or its scrolling/ticker uses the internal list). + boolean hoverBackgroundScheduled = hoverAnimationHost != null && getStyle() == hoverStyle; // NOPMD CompareObjectsWithEquals + boolean animateBackground = !hoverBackgroundScheduled && bgImage != null + && bgImage.isAnimation() && bgImage.animate(); Motion m = getAnimationMotion(); // perform regular scrolling @@ -8207,7 +8511,7 @@ public boolean animate() { Painter bgp = getStyle().getBgPainter(); - boolean animateBackgroundB = bgp != null && + boolean animateBackgroundB = !hoverBackgroundScheduled && bgp != null && !(bgp instanceof BGPainter) && bgp instanceof Animation && ((Animation) bgp).animate(); @@ -8596,6 +8900,7 @@ void deinitializeImpl() { if (p instanceof BGPainter) { ((BGPainter) p).radialCache = null; } + clearHoverForInactiveSubtree(); if (stateChangeListeners != null) { stateChangeListeners.fireActionEvent(new ComponentStateChangeEvent(this, false)); } @@ -8612,6 +8917,26 @@ void deinitializeImpl() { f.removePointerPressedListener(refreshTaskDragListener); } } + } else { + clearHoverForInactiveSubtree(); + } + } + + private void clearHoverForInactiveSubtree() { + stopHoverBackgroundAnimation(); + // Hiding or removal outside a pointer callback must release the owner's target. + // Reset directly: setHovered would register the newly active style for + // animation while this component is hidden or being torn down. + hovered = false; + clearInteractiveScrollHover(); + Container root = TopLevelSupport.rootOf(this); + HoverTracker tracker = root == null ? null : root.getHoverTracker(); + if (tracker != null) { + tracker.clearFor(this); + } + TooltipManager tooltip = TooltipManager.getInstance(); + if (tooltip != null) { + tooltip.clearTooltipFor(this); } } @@ -8910,6 +9235,9 @@ public void setEnabled(boolean enabled) { return; } this.enabled = enabled; + if (hovered) { + checkHoverAnimationHierarchy(); + } accessibilityChanged(AccessibilityManager.CHANGE_STATE); repaint(); } @@ -9680,6 +10008,10 @@ public void setHidden(boolean b, boolean changeMargin) { } setPreferredSize(new Dimension()); } + if (isHidden()) { + // Collapsing does not change visible, but must release hover/tooltip ownership. + clearHoverForInactiveSubtree(); + } } else { setPreferredSize(null); if (changeMargin) { @@ -9905,6 +10237,20 @@ public void paint(Graphics g, Rectangle rect) { } } + final boolean isDefaultBackgroundPainter(Style style) { + Painter painter = style.getBgPainter(); + if (painter == null) { + return true; + } + if (painter.getClass() != BGPainter.class) { + return false; + } + BGPainter background = (BGPainter) painter; + return background.painter == null && background.wMotion == null && background.hMotion == null + && background.previousTint == null + && (background.constantStyle == null || background.constantStyle == style); //NOPMD CompareObjectsWithEquals + } + class BGPainter implements Painter, Animation { Image radialCache; CodenameOneImplementation impl; diff --git a/CodenameOne/src/com/codename1/ui/Container.java b/CodenameOne/src/com/codename1/ui/Container.java index e4c96f229eb..93c35732150 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -548,6 +548,7 @@ public final void setLeadComponent(Component lead) { if (lead == leadComponent) { //NOPMD CompareObjectsWithEquals return; } + HoverTracker tracker = HoverTracker.prepareLeadChange(this); leadComponent = lead; if (lead == null) { // clear the lead component from the hierarchy @@ -565,6 +566,9 @@ public final void setLeadComponent(Component lead) { initLead(); } } + if (tracker != null) { + tracker.finishLeadChange(this); + } } /// Returns the lead container thats handling the leading, this is useful for @@ -1466,6 +1470,11 @@ protected void cancelRepaints() { } } + // Only top-level containers own pointer hover tracking. + HoverTracker getHoverTracker() { + return null; + } + /// Cleansup the initialization flags in the hierachy @Override void deinitializeImpl() { diff --git a/CodenameOne/src/com/codename1/ui/Form.java b/CodenameOne/src/com/codename1/ui/Form.java index 2b90c371751..fce503b772f 100644 --- a/CodenameOne/src/com/codename1/ui/Form.java +++ b/CodenameOne/src/com/codename1/ui/Form.java @@ -134,7 +134,13 @@ boolean isRevalidateFromRoot() { private Component dragged; // Last component whose interactive scrollbar showed a hover highlight, so the highlight can be // cleared when the pointer moves to a different scrollable (desktop interactive scrollbars only) - private Component lastInteractiveScrollHover; + private final HoverTracker hoverTracker = new HoverTracker(); + + @Override + HoverTracker getHoverTracker() { + return hoverTracker; + } + private boolean enableCursors; private TextSelection textSelection; private ArrayList componentsAwaitingRelease; @@ -2836,6 +2842,10 @@ void deinitializeImpl() { componentsAwaitingRelease = null; pressedCmp = null; dragged = null; + // A form that is going away must not leave a component believing the pointer is still + // over it: the flag would survive into the next time the form is shown, and the + // component would paint hovered with the pointer somewhere else entirely. + hoverTracker.pointerOver(null, -1, -1); } /// The four kinds of pointer listener an embedded form hands to its host. @@ -4566,6 +4576,24 @@ public void pointerHoverPressed(int[] x, int[] y) { } } + /// The component a hover at these coordinates resolves to: the deepest one that accepts + /// pointer events, mapped to its lead parent. Resolution only -- nothing is dispatched -- + /// so a caller that just needs to know what is under the pointer does not also fire a + /// component's hover callback or start a tooltip timer. + Component hoverTargetAt(int x, int y) { + Container actual = getActualPane(formLayeredPane, x, y); + // getComponentAt returns the container itself for an outside point. A window + // leave must resolve to nothing, even when the root pane has a hover style. + if (actual == null || !actual.contains(x, y)) { + return null; + } + Component cmp = actual.getComponentAt(x, y); + while (cmp != null && cmp.isIgnorePointerEvents()) { + cmp = cmp.getParent(); + } + return cmp == null ? null : LeadUtil.leadParentImpl(cmp); + } + /// {@inheritDoc} @Override public void pointerHover(int[] x, int[] y) { @@ -4575,51 +4603,32 @@ public void pointerHover(int[] x, int[] y) { return; } - Container actual = getActualPane(formLayeredPane, x[0], y[0]); - if (actual != null) { - Component cmp = actual.getComponentAt(x[0], y[0]); - while (cmp != null && cmp.isIgnorePointerEvents()) { - cmp = cmp.getParent(); - } + Component cmp = hoverTargetAt(x[0], y[0]); + // Callbacks must observe the entered/left states, and navigation inside a + // callback must be able to clear them without a later update restoring them. + // Null also clears the previous target when the pointer leaves the surface. + hoverTracker.pointerOver(cmp, x[0], y[0]); + try { if (cmp != null) { - cmp = LeadUtil.leadParentImpl(cmp); - if (!isScrollWheeling && cmp.isFocusable() && cmp.isEnabled() && !Display.getInstance().isDesktop()) { setFocused(cmp); } LeadUtil.pointerHover(cmp, x, y); - updateInteractiveScrollHover(cmp, x[0], y[0]); } - if (TooltipManager.getInstance() != null) { - String tip = cmp.getTooltip(); - if (tip != null && tip.length() > 0) { - TooltipManager.getInstance().prepareTooltip(tip, cmp); - } else { - TooltipManager.getInstance().clearTooltip(); - } + } finally { + hoverTracker.clearDetached(this); + } + TooltipManager tm = TooltipManager.getInstance(); + if (tm != null) { + String tip = hoverTracker.isOver(cmp) ? cmp.getTooltip() : null; + if (tip != null && tip.length() > 0) { + tm.prepareTooltip(tip, cmp); + } else { + tm.clearTooltip(); } } } - /// Routes a hover to the nearest scrollable ancestor of the hovered component so an interactive - /// (desktop) scrollbar can highlight its thumb, and clears the highlight on the previously - /// hovered scrollable. Inert unless interactive scrollbars are enabled. - private void updateInteractiveScrollHover(Component cmp, int x, int y) { - if (!getUIManager().getLookAndFeel().isInteractiveScroll()) { - return; - } - Component scrollable = cmp; - while (scrollable != null && !scrollable.isScrollableY() && !scrollable.isScrollableX()) { - scrollable = scrollable.getParent(); - } - if (lastInteractiveScrollHover != null && lastInteractiveScrollHover != scrollable) { //NOPMD CompareObjectsWithEquals - lastInteractiveScrollHover.clearInteractiveScrollHover(); - } - if (scrollable != null) { - scrollable.updateInteractiveScrollHover(x, y); - } - lastInteractiveScrollHover = scrollable; - } /// Returns true if there is only one focusable member in this form. This is useful /// so setHandlesInput would always be true for this case. @@ -4692,6 +4701,7 @@ private boolean fireReleaseListeners(int x, int y) { /// {@inheritDoc} @Override public void pointerReleased(int x, int y) { + final boolean hoverOnRelease = HoverTracker.canHoverOnRelease(); // A press that never became a drag releases the operation the press staged, so a // later gesture somewhere else cannot start the drag this one declined to. // @@ -4886,6 +4896,22 @@ public void pointerReleased(int x, int y) { } } finally { currentPointerPress = null; + // Hover is deliberately NOT tracked during a drag -- pointerHover returns early + // while dragged is set -- so the release is where it has to be caught up. If the + // pointer then stays put no further motion event arrives (Windows sends none for + // a stationary cursor), which left whatever was hovered when the drag began still + // lit and whatever is under the pointer now never lit. Resolved rather than + // dispatched, so the release does not also fire a hover callback or a tooltip. + // + // In the finally, beside the other piece of end-of-gesture bookkeeping, because + // this method returns from six places inside the try above and a catch-up after + // the block is reached by none of them. + // A release callback may navigate and deinitialize this form. Do not + // restore the hover that deinitialization just cleared on a hidden form. + // During a transition, getCurrent() can still name that deinitialized source. + if (hoverOnRelease && isInitialized() && isTopLevelShowing()) { + hoverTracker.pointerOver(hoverTargetAt(x, y), x, y); + } } } diff --git a/CodenameOne/src/com/codename1/ui/HoverTracker.java b/CodenameOne/src/com/codename1/ui/HoverTracker.java new file mode 100644 index 00000000000..0a568301b78 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/HoverTracker.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui; + +import com.codename1.ui.events.PointerEvent; + +/// Remembers which component a single top level container's pointer is over, and moves the +/// hover state as it changes. +/// +/// This lives in its own class because there are two top level containers and they share no +/// base class that could hold it -- `Form` and `Window` both extend `Container` directly. The +/// first version of hover tracking was private to `Form`, which is exactly why a control in a +/// secondary `Window` could never show a declared hover style: the events reached the window +/// and nothing there recorded them. +/// +/// Tracking has to live at this level rather than in `Component` because only the container +/// knows what the pointer LEFT. A component is never told the pointer moved off it; it simply +/// stops being the one underneath. +/// +/// Desktop only in practice, because nothing else generates hover events. +class HoverTracker { + // Capture this before release callbacks: nested dispatch may change the current device. + // Desktop touch screens still send finger releases, which must never create hover. + static boolean canHoverOnRelease() { + Display display = Display.getInstance(); + int type = display.getPointerType(); + return display.isDesktop() && (type == PointerEvent.TYPE_MOUSE + || type == PointerEvent.TYPE_STYLUS || type == PointerEvent.TYPE_ERASER); + } + + private Component hovered; + private Component lastInteractiveScrollHover; + private Component pointerTarget; + private int pointerX; + private int pointerY; + + /// Reports where the pointer now is. + /// + /// #### Parameters + /// + /// - `cmp`: the component the pointer resolved to, already passed through + /// `LeadUtil.leadParentImpl` by the caller, or **null** when the pointer is over + /// nothing -- which is how a desktop port reports the cursor leaving the window, as a + /// hover at (-1, -1). Both pieces of state have to be cleared in that case or the last + /// component and the last scrollbar thumb stay lit with the cursor elsewhere. + /// + /// - `x`: the position of the event + /// + /// - `y`: the position of the event + void pointerOver(Component cmp, int x, int y) { + if (cmp != null && cmp.isHidden(true)) { + cmp = null; + } + pointerTarget = cmp; + pointerX = x; + pointerY = y; + updateHovered(cmp); + updateInteractiveScrollHover(cmp, x, y); + } + + static HoverTracker prepareLeadChange(Component changed) { + Container root = TopLevelSupport.rootOf(changed); + HoverTracker tracker = root == null ? null : root.getHoverTracker(); + if (tracker == null || tracker.hovered == null + || (!isInSubtree(changed, tracker.hovered) + && !isInSubtree(LeadUtil.leadParentImpl(changed), tracker.hovered))) { + return null; + } + // Release the old hierarchy while it still owns its flags and animations. + // Keep the pointer coordinates so a stationary pointer can acquire the new lead. + tracker.updateHovered(null); + TooltipManager tooltip = TooltipManager.getInstance(); + if (tooltip != null && tracker.pointerTarget != null) { + tooltip.clearTooltipFor(tracker.pointerTarget); + } + return tracker; + } + + void finishLeadChange(Component changed) { + Container root = TopLevelSupport.rootOf(changed); + Component target = root instanceof Form ? ((Form) root).hoverTargetAt(pointerX, pointerY) + : (root instanceof Window ? ((Window) root).hoverTargetAt(pointerX, pointerY) : null); + // Reuse normal hit-testing, including focusable-parent capture. Resolve state + // only; a topology mutation must not dispatch a pointer callback. + pointerOver(target, pointerX, pointerY); + } + + // A hover callback can remove its own component without deinitializing the + // entire form/window. Drop that detached target before scheduling a tooltip. + void clearDetached(TopLevelContainer owner) { + if ((hovered != null && hovered.getTopLevelContainer() != owner) //NOPMD CompareObjectsWithEquals + || (lastInteractiveScrollHover != null + && lastInteractiveScrollHover.getTopLevelContainer() != owner)) { //NOPMD CompareObjectsWithEquals + pointerOver(null, -1, -1); + } + } + + void clearFor(Component removed) { + if (isInSubtree(removed, hovered) || isInSubtree(removed, lastInteractiveScrollHover)) { + pointerOver(null, -1, -1); + } + } + + static boolean isInSubtree(Component root, Component target) { + return target != null && (root == target //NOPMD CompareObjectsWithEquals + || root instanceof Container && ((Container) root).contains(target)); + } + + boolean isOver(Component cmp) { + return cmp != null && hovered == LeadUtil.leadComponentImpl(cmp); //NOPMD CompareObjectsWithEquals + } + + private void updateHovered(Component cmp) { + // The flag goes on the lead COMPONENT, not on the lead parent the pointer resolved + // to, because the lead component is what Component.getStyle() consults: a component + // inside a lead hierarchy returns out of the lead branch after asking + // lead.isHovered(), and never reaches the plain hover check below it. Marking the + // parent therefore left every MultiButton, SpanButton and toolbar command container + // unable to show a hover style its theme declared. + // + // This is the rule the pressed state already follows -- LeadUtil.pointerPressed + // delivers to leadComponentImpl(cmp) and getStyle() asks lead.isPressedStyle(). + // leadComponentImpl answers the component itself when there is no lead, so an + // ordinary component is unaffected. + Component target = cmp == null ? null : LeadUtil.leadComponentImpl(cmp); + // Preferred-size collapse can precede layout, leaving stale hit-test bounds. + if (cmp != null && (cmp.isHidden(true) || (target != null && target.isHidden(true)))) { + target = null; + } + // Identity is the question being asked -- whether this is the same component + // instance the pointer was already over -- so equals() would be wrong here as well + // as slower. + if (hovered == target) { //NOPMD CompareObjectsWithEquals + return; + } + if (hovered != null) { + repaintLeadParent(hovered); + hovered.setHovered(false); + } + hovered = target; + if (target != null) { + target.setHovered(true); + repaintLeadParent(target); + } + } + + /// Component.setHovered repaints the component whose flag moved, which is enough for an + /// ordinary component and not enough for a lead hierarchy: the style change is read by + /// the lead PARENT and by every sibling under it, none of which were asked to repaint. + private static void repaintLeadParent(Component cmp) { + Component parent = LeadUtil.leadParentImpl(cmp); + if (parent != null && parent != cmp) { //NOPMD CompareObjectsWithEquals + parent.repaint(); + } + } + + /// Routes a hover to the nearest scrollable ancestor of the hovered component so an + /// interactive (desktop) scrollbar can highlight its thumb, and clears the highlight on + /// the previously hovered scrollable. Inert unless interactive scrollbars are enabled. + private void updateInteractiveScrollHover(Component cmp, int x, int y) { + if (cmp != null && !cmp.getUIManager().getLookAndFeel().isInteractiveScroll()) { + return; + } + if (cmp == null && lastInteractiveScrollHover == null) { + return; + } + Component scrollable = cmp; + while (scrollable != null && !scrollable.isScrollableY() && !scrollable.isScrollableX()) { + scrollable = scrollable.getParent(); + } + if (lastInteractiveScrollHover != null && lastInteractiveScrollHover != scrollable) { //NOPMD CompareObjectsWithEquals + lastInteractiveScrollHover.clearInteractiveScrollHover(); + } + if (scrollable != null) { + scrollable.updateInteractiveScrollHover(x, y); + } + lastInteractiveScrollHover = scrollable; + } +} diff --git a/CodenameOne/src/com/codename1/ui/Slider.java b/CodenameOne/src/com/codename1/ui/Slider.java index b4645e219f0..de5e0aba28f 100644 --- a/CodenameOne/src/com/codename1/ui/Slider.java +++ b/CodenameOne/src/com/codename1/ui/Slider.java @@ -30,6 +30,8 @@ import com.codename1.ui.events.ActionSource; import com.codename1.ui.events.DataChangedListener; import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Border; +import com.codename1.ui.plaf.RoundBorder; import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.EventDispatcher; @@ -238,6 +240,7 @@ public boolean isInfinite() { public void setInfinite(boolean i) { if (infinite != i) { infinite = i; + setShouldCalcPreferredSize(true); if (isInitialized()) { if (i) { TopLevelSupport.registerAnimatedInternal(this, this); @@ -422,6 +425,32 @@ protected Dimension calcPreferredSize() { prefH = Math.max(prefH, Font.getDefaultFont().getHeight()); } } + // A progress bar that paints a thin native capsule should also MEASURE as one. + // Without this its preferred height is a full font height -- 19px against a 3px + // Fluent bar and an 8px Adwaita one -- so layout reserves six times the space the + // platform does, and the capsule is centred in a box the user cannot see. The + // widget looks almost right and sits wrong. + // + // Gated on exactly what makes it paint that way, so nothing that renders the + // legacy full-height fill changes size. + if (usesNativeProgressStyles()) { + String progressTrackMM = getUIManager().getThemeConstant("progressTrackThicknessMM", null); + if (progressTrackMM != null) { + try { + float tmm = Float.parseFloat(progressTrackMM.trim()); + if (tmm > 0) { + int trackHeight = Math.max(2, Display.getInstance().convertToPixels(tmm)); + // Overlay text still needs a full font-height box; the painter centers + // the thin track inside that box without clipping the Label text. + prefH = isRenderPercentageOnTop() || isRenderValueOnTop() + ? Math.max(trackHeight, Math.max(prefH, style.getFont().getHeight())) + : trackHeight; + } + } catch (NumberFormatException notANumber) { + // Malformed constant: keep the font-height default rather than guess. + } + } + } if (prefH != 0) { prefH += style.getVerticalPadding(); } @@ -431,13 +460,61 @@ protected Dimension calcPreferredSize() { return new Dimension(prefW, prefH); } + private boolean usesNativeProgressStyles() { + if (infinite || vertical || isEditable() || thumbImage != null) { + return false; + } + // Reserve legacy height before any state transition can expose custom artwork. + // Pressed also matters when this slider inherits state from a lead component. + Style hover = getHoverStyle(); + return isNativeProgressStyle(getUnselectedStyle()) + && isNativeProgressStyle(getSelectedStyle()) + && isNativeProgressStyle(getDisabledStyle()) + && isNativeProgressStyle(getPressedStyle()) + && (hover == null || isNativeProgressStyle(hover)) + && isNativeProgressStyle(getSliderFullUnselectedStyle()) + && isNativeProgressStyle(getSliderFullSelectedStyle()); + } + + private boolean isNativeProgressStyle(Style style) { + if (style.getBgImage() != null || !isDefaultBackgroundPainter(style)) { + return false; + } + byte background = style.getBackgroundType(); + if (background != Style.BACKGROUND_NONE && background != Style.BACKGROUND_IMAGE_SCALED) { + return false; + } + Border border = style.getBorder(); + if (border == null) { + return (style.getBgTransparency() & 0xff) == 255; + } + // Bundled themes use plain pill borders. Keep those native, but preserve + // application borders, gradients, images and painters through the legacy path. + if (border.getClass() != RoundBorder.class) { + return false; + } + RoundBorder round = (RoundBorder) border; + return round.isRectangle() && !round.isOnlyLeftRounded() && !round.isOnlyRightRounded() + && round.getColor() == style.getBgColor() && round.getOpacity() == 255 + && (round.getStrokeOpacity() == 0 || round.getStrokeThickness() == 0) + && round.getShadowOpacity() == 0; + } + + /// {@inheritDoc} + @Override + public void styleChanged(String propertyName, Style source) { + super.styleChanged(propertyName, source); + // Background customization can switch both the renderer and its preferred height. + setShouldCalcPreferredSize(true); + } + /// Paint the progress indicator @Override public void paintComponentBackground(Graphics g) { // Native progress indicators are thin capsules even when their component // receives a taller touch/layout box. Keep this opt-in so legacy themes // and progress bars with image backgrounds retain their existing painter. - if (!infinite && !vertical && !isEditable()) { + if (usesNativeProgressStyles()) { String progressTrackMM = getUIManager().getThemeConstant("progressTrackThicknessMM", null); if (progressTrackMM != null && paintNativeProgress(g, progressTrackMM)) { return; @@ -676,10 +753,10 @@ private boolean paintNativeProgress(Graphics g, String trackMM) { if (aa) { g.setAntiAliased(true); } - g.setColor(getSliderEmptyUnselectedStyle().getBgColor()); + g.setColor(super.getStyle().getBgColor()); g.fillRoundRect(x, y, width, track, track, track); if (fullWidth > 0) { - g.setColor(getSliderFullUnselectedStyle().getBgColor()); + g.setColor((hasFocus() ? getSliderFullSelectedStyle() : getSliderFullUnselectedStyle()).getBgColor()); g.fillRoundRect(x, y, Math.min(width, fullWidth), track, track, track); } if (aa) { @@ -703,7 +780,10 @@ public boolean isVertical() { /// /// - `vertical`: true if the slider is vertical public void setVertical(boolean vertical) { - this.vertical = vertical; + if (this.vertical != vertical) { + this.vertical = vertical; + setShouldCalcPreferredSize(true); + } } /// Indicates the slider is modifyable @@ -722,6 +802,9 @@ public boolean isEditable() { /// /// - `editable`: true if the slider is editable public void setEditable(boolean editable) { + if (this.editable != editable) { + setShouldCalcPreferredSize(true); + } this.editable = editable; setFocusable(editable); } @@ -982,7 +1065,10 @@ public boolean isRenderPercentageOnTop() { /// /// - `renderPercentageOnTop`: true to render percentages public void setRenderPercentageOnTop(boolean renderPercentageOnTop) { - this.renderPercentageOnTop = renderPercentageOnTop; + if (this.renderPercentageOnTop != renderPercentageOnTop) { + this.renderPercentageOnTop = renderPercentageOnTop; + setShouldCalcPreferredSize(true); + } } /// #### Returns @@ -996,7 +1082,10 @@ public boolean isRenderValueOnTop() { /// /// - `renderValueOnTop`: the renderValueOnTop to set public void setRenderValueOnTop(boolean renderValueOnTop) { - this.renderValueOnTop = renderValueOnTop; + if (this.renderValueOnTop != renderValueOnTop) { + this.renderValueOnTop = renderValueOnTop; + setShouldCalcPreferredSize(true); + } } /// #### Returns @@ -1043,6 +1132,7 @@ public Image getThumbImage() { /// - `thumbImage`: the thumbImage to set public void setThumbImage(Image thumbImage) { this.thumbImage = thumbImage; + setShouldCalcPreferredSize(true); } /// {@inheritDoc} diff --git a/CodenameOne/src/com/codename1/ui/TooltipManager.java b/CodenameOne/src/com/codename1/ui/TooltipManager.java index a75173480e0..79ee4e337e5 100644 --- a/CodenameOne/src/com/codename1/ui/TooltipManager.java +++ b/CodenameOne/src/com/codename1/ui/TooltipManager.java @@ -85,6 +85,14 @@ protected void clearTooltip() { currentComponent = null; } + void clearTooltipFor(Component removed) { + // The manager is shared across windows: unrelated removals must not + // cancel the current anchor's timer or visible tooltip. + if (HoverTracker.isInSubtree(removed, currentComponent)) { + clearTooltip(); + } + } + /// Gets ready to show the tooltip, this method implements the delay /// before the actual showing of the tooltip. It's invoked internally /// by the framework diff --git a/CodenameOne/src/com/codename1/ui/Window.java b/CodenameOne/src/com/codename1/ui/Window.java index 4f81ed872f8..10f3ae55655 100644 --- a/CodenameOne/src/com/codename1/ui/Window.java +++ b/CodenameOne/src/com/codename1/ui/Window.java @@ -102,6 +102,17 @@ public class Window extends Container implements TopLevelContainer { /// caps how many windows can be dragged at once. private final com.codename1.impl.PointerDragActivation dragActivation = new com.codename1.impl.PointerDragActivation(); + /// Which component the pointer is over, and the scrollbar thumb it lit. Shared with Form + /// rather than reimplemented: a window that tracked this differently would be a second + /// definition of what hover means. + private final HoverTracker hoverTracker = new HoverTracker(); + + @Override + HoverTracker getHoverTracker() { + return hoverTracker; + } + + private Graphics windowGraphics; /// Set as soon as dispose() begins, so re-entering it is a no-op. private boolean disposing; @@ -2728,6 +2739,10 @@ public void run() { } nativePeer = null; windowGraphics = null; + // A window that is going away must not leave a component believing the pointer is + // still over it -- the flag outlives the window and the component would paint + // hovered the next time it is shown. Same reason Form clears it in deinitialize. + hoverTracker.pointerOver(null, -1, -1); // showModal parks on Display.lock and wakes on this flag, so publish it under // the very monitor the waiter is blocked on synchronized (Display.lock) { @@ -3395,6 +3410,7 @@ public void pointerDragged(int[] x, int[] y) { /// {@inheritDoc} @Override public void pointerReleased(int x, int y) { + final boolean hoverOnRelease = HoverTracker.canHoverOnRelease(); // Not once the gesture has been taken away. This resolves the component under // the pointer afresh, so after an overlay took the pointer it hit tested into // that overlay and handed it the rest of a gesture whose press it never saw -- @@ -3440,6 +3456,7 @@ public void pointerReleased(int x, int y) { // Still cleared: the gesture is over regardless of who handled it, // and leaving these set would strand the next press. endGesture(releasing); + refreshHoverAfterRelease(x, y, hoverOnRelease); return; } } @@ -3452,6 +3469,7 @@ public void pointerReleased(int x, int y) { LeadUtil.dragFinished(releasingDragged, x, y); } endGesture(releasing); + refreshHoverAfterRelease(x, y, hoverOnRelease); return; } Component target = releasingDragged != null ? releasingDragged : releasingPressed; @@ -3468,6 +3486,27 @@ public void pointerReleased(int x, int y) { } } endGesture(releasing); + refreshHoverAfterRelease(x, y, hoverOnRelease); + } + + /// Catches hover up at the end of a gesture, the way Form.pointerReleased does. + /// + /// Hover is not tracked while a drag is in progress, and a pointer that stops moving + /// after the release produces no further motion event, so the component hovered when the + /// drag began would stay lit and the one under the pointer now would never light up. + /// Resolved rather than dispatched, so a release raises no tooltip of its own. + /// + /// Called from each of this method's three exits rather than once at the bottom: two of + /// them return early, and a single call after the last endGesture is reached by neither + /// -- which is exactly how the same catch-up in Form started out as dead code. + private void refreshHoverAfterRelease(int x, int y, boolean hoverOnRelease) { + // A release callback can hide a reusable window; cancellation has already + // cleared its hover, and catch-up must not restore it on the hidden surface. + if (!hoverOnRelease || !isTopLevelShowing()) { + return; + } + Component after = hoverTargetAt(x, y); + hoverTracker.pointerOver(after == null ? null : LeadUtil.leadParentImpl(after), x, y); } /// Clears the pressed state for the gesture identified by `token`, and only that @@ -3821,6 +3860,14 @@ private void tactileTouchVibe(int x, int y, Component cmp) { /// pressed state with no release coming; and the framework's own recorded targets /// and timers, which otherwise keep firing into a tree nobody can see. void cancelPendingInput() { + // Hide/minimize shares this path with disposal, including HIDE_ON_CLOSE. + hoverTracker.pointerOver(null, -1, -1); + TooltipManager tooltip = TooltipManager.getInstance(); + if (tooltip != null) { + // Hidden/reusable windows retain their tree; cancel its tooltip now, + // without dismissing an anchor in another window sharing the manager. + tooltip.clearTooltipFor(this); + } // Held keys never arrive as releases once the window has gone, so their // recorded scopes would sit here until some later press happened to reuse the // same key code. @@ -3852,6 +3899,13 @@ void cancelPendingInput() { Display.getInstance().windowInputCancelled(this); } + Component hoverTargetAt(int x, int y) { + // Keep this check specific to hover: captured drag/release dispatch can still + // target a pressed component outside the window. A leave must not hover its root. + Container actual = getActualPane(x, y); + return actual != null && actual.contains(x, y) ? resolveComponentAt(x, y) : null; + } + private Component resolveComponentAt(int x, int y) { Component cmp = getActualPane(x, y).getComponentAt(x, y); while (cmp != null && cmp.isIgnorePointerEvents()) { @@ -4290,18 +4344,28 @@ public void pointerHover(int[] x, int[] y) { LeadUtil.pointerHover(dragged, x, y); return; } - Component cmp = resolveComponentAt(x[0], y[0]); + Component cmp = hoverTargetAt(x[0], y[0]); if (cmp != null) { - LeadUtil.pointerHover(cmp, x, y); + cmp = LeadUtil.leadParentImpl(cmp); + } + // Publish the new state before callbacks, which can hide the window or + // remove the target. Null clears the old state on pointer leave. + hoverTracker.pointerOver(cmp, x[0], y[0]); + try { + if (cmp != null) { + LeadUtil.pointerHover(cmp, x, y); + } + } finally { + hoverTracker.clearDetached(this); } // The tooltip timer starts here or it never starts at all: this is the only // hover dispatch a window has. The manager resolves the surface through // getTopLevelContainer() and hosts the tooltip on it, so a tooltip raised from - // a window appears on that window. Guarded on cmp, which the Form path is not - // -- a hover over empty space there would already have been a null dereference. + // a window appears on that window. Leaving the window must also cancel a pending + // timer or dismiss a visible tooltip, even though there is no component to query. TooltipManager tm = TooltipManager.getInstance(); - if (tm != null && cmp != null) { - String tip = cmp.getTooltip(); + if (tm != null) { + String tip = hoverTracker.isOver(cmp) ? cmp.getTooltip() : null; if (tip != null && tip.length() > 0) { tm.prepareTooltip(tip, cmp); } else { diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 9b66565ba7a..89286bb4573 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -1,5 +1,24 @@ /* * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.ui.css; @@ -19,6 +38,7 @@ /// - `UIID:selected` /// - `UIID:pressed` /// - `UIID:disabled` +/// - `UIID:hover` /// - `*` (mapped to `Component`) /// - `:root` (for constants only) /// @@ -63,10 +83,102 @@ public void compile(String css, MutableResource resources, String themeName) { for (Rule rule : rules) { applyRule(theme, resources, rule); } + inheritHoverDerivations(theme); resolveThemeConstantVars(theme); resources.setTheme(themeName, theme); } + private void inheritHoverDerivations(Hashtable theme) { + ArrayList ids = new ArrayList(); + for (Object keyObj : theme.keySet()) { + String key = String.valueOf(keyObj); + int dot = key.indexOf('.'); + if (key.startsWith("@") || dot < 0) { + continue; + } + String id = key.substring(0, dot); + if (id.startsWith("$Dark")) { + id = id.substring(5); + } + if (!ids.contains(id)) { + ids.add(id); + } + } + // Prefixed style lookup does not follow the normal derive key. Materialize + // only hover derivations whose base actually declares that state. + for (int appearance = 0; appearance < 2; appearance++) { + boolean dark = appearance == 1; + String prefix = dark ? "$Dark" : ""; + // Each pass can expose another link in a derive chain; this is a + // convergence bound, not an iteration over individual component IDs. + int remainingPasses = ids.size(); + while (remainingPasses-- > 0) { + boolean changed = false; + for (String id : ids) { + String base = hoverBase(theme, id, dark); + String key = prefix + id + ".hover#derive"; + if (base == null || theme.containsKey(key) || cyclicDerivation(theme, id, dark)) { + continue; + } + boolean baseHasHover = hasHoverDefinition(theme, prefix + base); + if (dark && theme.containsKey("$Dark" + id + ".derive")) { + baseHasHover |= hasHoverDefinition(theme, base); + } + if (baseHasHover) { + if (dark) { + // An explicit dark derive bypasses UIManager's light-style + // fallback, so retain the child's own light hover overrides. + ArrayList keys = new ArrayList(theme.keySet()); + String lightPrefix = id + ".hover#"; + for (Object property : keys) { + String lightKey = String.valueOf(property); + if (lightKey.startsWith(lightPrefix) && !lightKey.endsWith("#derive") + && !theme.containsKey("$Dark" + lightKey)) { + theme.put("$Dark" + lightKey, theme.get(property)); + } + } + } + theme.put(key, base + ".hover"); + changed = true; + } + } + if (!changed) { + break; + } + } + } + } + + private String hoverBase(Hashtable theme, String id, boolean dark) { + Object base = dark ? theme.get("$Dark" + id + ".derive") : null; + if (base == null) { + base = theme.get(id + ".derive"); + } + return base instanceof String ? (String) base : null; + } + + private boolean cyclicDerivation(Hashtable theme, String id, boolean dark) { + ArrayList seen = new ArrayList(); + while (id != null) { + if (seen.contains(id)) { + return true; + } + seen.add(id); + id = hoverBase(theme, id, dark); + } + return false; + } + + private boolean hasHoverDefinition(Hashtable theme, String id) { + String prefix = id + ".hover#"; + for (Object key : theme.keySet()) { + if (String.valueOf(key).startsWith(prefix)) { + return true; + } + } + return false; + } + private void resolveThemeConstantVars(Hashtable theme) { for (Object keyObj : theme.keySet()) { String key = String.valueOf(keyObj); @@ -333,6 +445,13 @@ private String statePrefix(String pseudo) { if ("disabled".equals(pseudo)) { return "dis#"; } + // The desktop state. This runtime compiler is a separate implementation from the + // build-time one in maven/css-compiler and shares none of its code, so a sheet using + // .hover compiled at run time -- CSS live reload, a theme built by an application -- + // threw "Unsupported pseudo state" until it was taught the same prefix. + if ("hover".equals(pseudo)) { + return "hover#"; + } throw new CSSSyntaxException("Unsupported pseudo state: " + pseudo); } diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index 70f2b3da985..5e65f57ab27 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -666,6 +666,56 @@ public Style getComponentCustomStyle(String id, String type) { return getComponentStyleImpl(id, false, type + "#"); } + /// True when a custom style was installed programmatically or the theme declares an entry for the style + /// type on this UIID -- `Button.hover#bgColor`, `Button.hover#derive` and so on. + /// + /// This exists because {@link #getComponentCustomStyle(String, String)} *never returns + /// null*: asked for a type the theme says nothing about, it falls through to a copy of + /// the blank default style, whose background is white and whose foreground is black. For + /// `press` and `dis` that is harmless, because the shipped themes declare them wherever a + /// component consults them. For a state a component may consult on any UIID -- hover is + /// the first -- it is not: a theme written before that state existed would repaint every + /// hovered component in the blank default, which reads as a rendering bug and has no + /// obvious cause. So a caller that can tolerate "no such style" asks here first and skips + /// the state entirely. + /// + /// The dark spelling is checked as well, because a theme is free to declare a state only + /// inside `@media (prefers-color-scheme: dark)`, which the CSS compiler emits as + /// `$Dark<UIID>`. + /// + /// The answer is memoised for a whole theme generation by the same style-definition index + /// that backs dark-style resolution, so the linear scan behind it happens once per UIID + /// and type, not once per query. + /// + /// #### Parameters + /// + /// - `id`: the component id whose custom style we are asking about + /// + /// - `type`: the style type, e.g. `hover` + /// + /// #### Returns + /// + /// true when that custom style is installed or declared for this UIID + public boolean hasComponentCustomStyle(String id, String type) { + if (type == null || type.length() == 0) { + return false; + } + String dotted = (id == null || id.length() == 0) ? "" : dottedId(id); + String suffix = dotted + type + "#"; + // Typed installations live in styles, while generated custom-style prototypes + // live in prefixedStyles. Only the former explicitly opts a UIID into hover. + if (styles.get(suffix) != null || hasStyleDefinition(suffix)) { + return true; + } + // A $Dark-only declaration counts ONLY while dark mode is actually on. In light + // mode the caller goes on to ask for the LIGHT key, which does not exist, and + // getComponentCustomStyle builds it out of blank defaults -- so a theme that + // declares $DarkButton.hover# and no light hover would drop the button to the + // default colours on hover instead of leaving its normal style alone. + Boolean darkMode = CN.isDarkMode(); + return darkMode != null && darkMode.booleanValue() && hasStyleDefinition("$Dark" + suffix); + } + /// Returns the selected style of the component with the given baseStyle or a **new instance** of the default /// style, but overrides styles based on the directives in the styleStrings. /// @@ -725,12 +775,15 @@ private Style getComponentStyleImpl(String id, boolean selected, String prefix) // Cached on prefix + id, exactly as the unprefixed styles // are. The returned Style is a copy either way, so a // caller still gets its own mutable instance. - if (programmaticStyleInstalled) { + // The typed setter stores an explicit prototype under id + prefix. + // Detecting its existence alone is insufficient: return its values too. + style = styles.get(id + prefix); + if (style == null && programmaticStyleInstalled) { // Rebuild every time, exactly as this did before the // cache existed: a base installed programmatically can // be mutated by whoever installed it without telling us. style = createStyle(id, prefix, false); - } else { + } else if (style == null) { String key = prefixedKey(prefix, id); style = prefixedStyles.get(key); if (style == null) { @@ -2383,8 +2436,12 @@ Style parseStyle(Resources theme, String id, String prefix, String baseStyle, bo } else { id = id + "."; } - if (Arrays.toString(styleString).equals(parseCache().get(cacheKey)) && ((selected && selectedStyles.containsKey(id)) || (!selected && this.styles.containsKey(id)))) { - + // A cached normal style does not prove a custom state survived theme refresh. + // Check the same cache that getComponentStyleImpl reads for this prefix. + boolean cachedStyle = selected ? selectedStyles.containsKey(id) + : ((prefix == null || prefix.length() == 0) ? this.styles.containsKey(id) + : prefixedStyles.containsKey(prefixedKey(prefix, id))); + if (Arrays.toString(styleString).equals(parseCache().get(cacheKey)) && cachedStyle) { return getComponentStyleImpl(originalId, selected, prefix); } parseCache().put(cacheKey, Arrays.toString(styleString)); @@ -2403,7 +2460,9 @@ Style parseStyle(Resources theme, String id, String prefix, String baseStyle, bo resetThemeProps(null); } if (baseStyle != null) { - themeProps.put(id + "derive", baseStyle); + // Hover inline overrides inherit the hover state itself. Deriving from the + // bare UIID would discard unspecified hover colors, padding and borders. + themeProps.put(id + "derive", "hover#".equals(prefix) ? baseStyle + ".hover" : baseStyle); } else { themeProps.remove(id + "derive"); } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index e0a4b44a54a..fb7b6640bd2 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -5972,7 +5972,12 @@ public void installNativeTheme() { if (mode == null) { String shared = d.getProperty("nativeTheme", d.getProperty("cn1.nativeTheme", null)); - if ("modern".equalsIgnoreCase(shared)) { + // "native" is "modern plus the desktop": the desktop half belongs to + // the JavaSE port, and Android's own answer to "the platform's own + // look" is Material either way. Without it the value fell through to + // the hololight default below, so asking for the native look got the + // legacy one. + if ("modern".equalsIgnoreCase(shared) || "native".equalsIgnoreCase(shared)) { mode = "material"; } else if ("legacy".equalsIgnoreCase(shared)) { mode = "hololight"; diff --git a/Ports/JavaSE/build.xml b/Ports/JavaSE/build.xml index 55d3ee40f87..6b7bd91f478 100644 --- a/Ports/JavaSE/build.xml +++ b/Ports/JavaSE/build.xml @@ -166,6 +166,9 @@ + + + @@ -175,6 +178,9 @@ + + + diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index 06a4ac37f0c..308eb3c17b5 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -122,9 +122,9 @@ static void register() { // Group. set("{{@nativeTheme}}.label", "Native Theme"); set("{{@nativeTheme}}.description", - "Controls the Codename One look & feel on iOS, Android, and " - + "the JavaScript port (browser OS auto-detection: iOS/Mac " - + "browsers get the iOS theme, everything else gets the " + "Controls the Codename One look & feel on iOS, Android, the " + + "desktop and the JavaScript port (browser OS auto-detection: " + + "iOS/Mac browsers get the iOS theme, everything else gets the " + "Android theme). Modern themes are generated from CSS " + "under native-themes/; legacy themes remain selectable " + "via the values below."); @@ -132,10 +132,13 @@ static void register() { // Cross-platform meta hint. set("{{#nativeTheme#nativeTheme}}.label", "Shared override"); set("{{#nativeTheme#nativeTheme}}.type", "Select"); - set("{{#nativeTheme#nativeTheme}}.values", "modern,legacy,custom"); + set("{{#nativeTheme#nativeTheme}}.values", "native,modern,legacy,custom"); set("{{#nativeTheme#nativeTheme}}.description", - "Overrides both iOS and Android native theme selection. " - + "\"modern\" = liquid glass / Material 3. \"legacy\" = iOS 7 " + "Overrides the per-platform native theme selection. " + + "\"native\" = the platform's own look on every OS, desktop " + + "included. \"modern\" = liquid glass / Material 3, on iOS and " + + "Android only -- it predates the desktop themes, so it leaves a " + + "desktop app's screens where they were. \"legacy\" = iOS 7 " + "flat / Android Holo Light. \"custom\" disables the framework " + "default and expects the app to install its own. " + "(Deprecated alias: cn1.nativeTheme.)"); @@ -159,6 +162,19 @@ static void register() { + "Android theme. (Deprecated alias: cn1.androidTheme; " + "and.hololight=true is also accepted for back-compat.)"); + // Desktop (JavaSE). Resolved against the machine the app starts on, because + // one desktop binary runs on Windows, macOS and Linux. + set("{{#nativeTheme#desktop.themeMode}}.label", "Desktop theme"); + set("{{#nativeTheme#desktop.themeMode}}.type", "Select"); + set("{{#nativeTheme#desktop.themeMode}}.values", + "legacy,auto,fluent,aqua,adwaita,custom"); + set("{{#nativeTheme#desktop.themeMode}}.description", + "legacy = what desktop apps have always had (default -- these " + + "themes arrived after the apps did). auto / native / modern = the " + + "host's own look: Fluent on Windows, Aqua on macOS, Adwaita on " + + "GNOME. fluent / aqua / adwaita pin that one look on every " + + "machine. custom installs no framework theme at all."); + // The wearable build has no build hints: a project declares the watch // lifecycle class as codename1.watchMain next to codename1.mainName and // both the Apple Watch and the Wear OS app are built from that root. diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 4a6e9c47ce2..b85cc98a60e 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -230,10 +230,23 @@ public class JavaSEPort extends CodenameOneImplementation { private static Set availableFontNamesLowercase; private static final String PREF_AUTO_UPDATE_DEFAULT_BUNDLE = "cn1.autoDefaultResourceBundle"; public final static boolean IS_MAC; + /// True when the desktop JVM is running on Linux. + /// + /// There was no such flag, and getPlatformName() answered "win" for anything that was + /// not a Mac -- so a Codename One desktop app on Linux reported itself as Windows. That + /// was invisible while no desktop theme existed, and is not once one does: the theme + /// resolver, the platform- resource layer and Resources.openLayered all key off that + /// name, so a Linux user would have been handed the Fluent theme and any platform-win- + /// resource override. + public final static boolean IS_LINUX; private static boolean isIOS; public static boolean blockNativeBrowser; private static final boolean isWindows; private static String fontFaceSystem; + private static boolean fontFacesExplicitlyConfigured; + private static boolean desktopNativeFonts; + private final java.util.Map desktopAliasFonts = + new java.util.WeakHashMap(); private Boolean darkMode; private AutoLocalizationBundle autoLocalizationBundle; private boolean autoUpdateDefaultResourceBundle; @@ -763,16 +776,15 @@ public static double getRetinaScale() { } else { IS_MAC = false; } + IS_LINUX = n != null && n.startsWith("Linux"); isWindows = File.separatorChar == '\\'; if (System.getProperty("apple.laf.useScreenMenuBar") == null) { System.setProperty("apple.laf.useScreenMenuBar", "true"); } - if(isWindows) { - fontFaceSystem = "ArialUnicodeMS"; - } else { - fontFaceSystem = "Arial"; - } + // New desktop fonts are opt-in with the native desktop theme. Keep legacy + // system-font metrics unchanged for applications that only upgrade the framework. + fontFaceSystem = isWindows ? "ArialUnicodeMS" : "Arial"; } /** @@ -1935,6 +1947,11 @@ public static void setFontSize(int medium, int small, int large) { } public static void setFontFaces(String system, String proportional, String monospace) { + setFontFaces(system, proportional, monospace, true); + } + + private static void setFontFaces(String system, String proportional, String monospace, boolean explicit) { + fontFacesExplicitlyConfigured = explicit; fontFaceSystem = system; fontFaceProportional = proportional; fontFaceMonospace = monospace; @@ -2929,7 +2946,106 @@ public void run() { } public static void setNativeTheme(String resFile) { + // Existing generated and archetype stubs pass this fallback. Resolve their packaged + // hint here too; simulator-only resolution cannot change a shipped application's theme. + // An explicitly named custom resource remains an override, even with a theme hint. + if ("/NativeTheme.res".equals(resFile)) { + Properties theme = new Properties(); + try (InputStream in = JavaSEPort.class.getResourceAsStream("/codenameone-desktop.properties")) { + if (in != null) { + theme.load(in); + } + } catch (IOException ex) { + throw new IllegalStateException("Cannot read packaged desktop theme configuration", ex); + } + resFile = resolvePackagedDesktopNativeTheme(IS_MAC ? "mac" : (IS_LINUX ? "linux" : "win"), theme); + } nativeTheme = resFile; + configureNativeThemeFonts(resFile); + } + + /** + * Temporarily configures native font aliases for a directly loaded theme. + * Call on the EDT before opening the resource; run the returned callback on + * the EDT to restore the prior font configuration without changing the + * application's selected native theme resource. + * + * @param resource theme resource path + * @return callback that restores the previous font configuration + * @since 8.0 + */ + public static Runnable pushNativeThemeFontConfiguration(String resource) { + final boolean previousDesktopFonts = desktopNativeFonts; + final String previousSystemFace = fontFaceSystem; + configureNativeThemeFonts(resource); + com.codename1.ui.Font.clearDerivedFontCache(); + return new Runnable() { + @Override + public void run() { + desktopNativeFonts = previousDesktopFonts; + fontFaceSystem = previousSystemFace; + com.codename1.ui.Font.clearDerivedFontCache(); + } + }; + } + + private static void configureNativeThemeFonts(String resFile) { + desktopNativeFonts = isDesktopNativeThemeResource(resFile); + if (!fontFacesExplicitlyConfigured) { + fontFaceSystem = defaultSystemFontForTheme(IS_MAC ? "mac" : (IS_LINUX ? "linux" : "win"), resFile); + // Theme selection changes FACE_SYSTEM, not the default font's size. Its + // sizing remains owned by setFontSize/setFontFaces; changing it here also + // resizes font-relative controls such as Switch and Slider on startup. + } + } + + private static boolean isDesktopNativeThemeResource(String resource) { + return "/WindowsFluentTheme.res".equals(resource) || "/MacOSAquaTheme.res".equals(resource) + || "/GnomeAdwaitaTheme.res".equals(resource); + } + + static String defaultSystemFontForTheme(String platform, String resource) { + if (!isDesktopNativeThemeResource(resource)) { + return "win".equals(platform) ? "ArialUnicodeMS" : "Arial"; + } + if (!"win".equals(platform) && !"mac".equals(platform) && !"linux".equals(platform)) { + return "Arial"; + } + String[] candidates = "mac".equals(platform) + ? new String[]{".AppleSystemUIFont", "SF Pro Text", "Helvetica Neue"} + : ("linux".equals(platform) ? new String[]{"Cantarell", "Adwaita Sans", "SansSerif"} + : new String[]{"Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI"}); + String installed = findFirstInstalledFontCandidate(candidates, getAvailableFontNamesLowercase()); + return installed == null ? "SansSerif" : installed; + } + + static String resolvePackagedDesktopNativeTheme(String platformName, Properties theme) { + String mode = System.getProperty("codename1.arg.desktop.themeMode"); + if (mode == null || mode.isEmpty()) { + mode = theme.getProperty("desktop.themeMode"); + } + if (mode == null) { + // Mobile nativeTheme hints must not opt existing desktop apps into a new theme. + mode = buildHint("desktop.themeMode"); + } + return resolveDesktopNativeThemeResource(platformName, mode, "/NativeTheme.res"); + } + + static void setSimulatorDesktopNativeTheme(String platformName, boolean uwpDesktopSkin) { + setNativeTheme(uwpDesktopSkin ? "/winTheme.res" + : resolveDesktopNativeThemeResource(platformName, buildHint("desktop.themeMode"), "/iOS7Theme.res")); + } + + private static String resolveDesktopNativeThemeResource(String platformName, String mode, String legacyResource) { + mode = mode == null ? null : mode.trim(); + // A null theme basename can mean either custom or legacy. Keep that distinction + // at both installation paths so the simulator does not reintroduce a framework base. + // Custom means no framework base; legacy still uses the stub's historical resource. + if ("custom".equalsIgnoreCase(mode)) { + return null; + } + String resolved = resolveDesktopNativeTheme(platformName, mode); + return resolved == null ? legacyResource : "/" + resolved + ".res"; } public static void setNativeTheme(Resources resFile) { @@ -3122,6 +3238,68 @@ private static String resolveAutoNativeTheme(String platformName) { // Default for an Android skin is Material 3. return "AndroidMaterialTheme"; } + return resolveDesktopNativeTheme(platformName); + } + + /// Resolves the desktop native theme for a host platform name. + /// + /// Returns null when the developer has asked for no framework theme, which leaves the + /// existing behaviour exactly as it was. That default is deliberate and matches how the + /// modern mobile themes shipped: a desktop application written before these themes + /// existed keeps the look it was built and tested against until it opts in, because + /// flipping it silently would move every screen of every shipping desktop app. + /// + /// `auto` and `native` mean "whatever this machine is", which is the only sensible + /// reading of a native theme on desktop, where one binary runs on all three. The + /// platform names are the ones getPlatformName answers with: "win", "mac", "linux". + private static String resolveDesktopNativeTheme(String platformName) { + if (platformName == null) { + return null; + } + // Desktop selection is independent of the shared iOS/Android nativeTheme hint. + return resolveDesktopNativeTheme(platformName, buildHint("desktop.themeMode")); + } + + private static String resolveDesktopNativeTheme(String platformName, String mode) { + if (mode == null || mode.trim().isEmpty()) { + // The cross-platform nativeTheme hint reaches desktop through exactly one of + // its values. "native" says "the platform's own look, everywhere", and desktop + // is part of everywhere. "modern" does not, and must not: it predates the + // desktop themes by years, so every application that set it for its phone + // builds would otherwise have its desktop screens redrawn by a hint it set for + // another platform. That is the whole difference between the two constants. + if ("native".equalsIgnoreCase(sharedNativeThemeHint())) { + mode = "native"; + } + } + if (mode == null || "legacy".equalsIgnoreCase(mode)) { + // What a desktop app has always had. Not a recommendation, just continuity. + return null; + } + if ("custom".equalsIgnoreCase(mode)) { + return null; + } + if ("fluent".equalsIgnoreCase(mode)) { + return "WindowsFluentTheme"; + } + if ("aqua".equalsIgnoreCase(mode)) { + return "MacOSAquaTheme"; + } + if ("adwaita".equalsIgnoreCase(mode)) { + return "GnomeAdwaitaTheme"; + } + if ("auto".equalsIgnoreCase(mode) || "native".equalsIgnoreCase(mode) + || "modern".equalsIgnoreCase(mode)) { + if ("mac".equals(platformName)) { + return "MacOSAquaTheme"; + } + if ("linux".equals(platformName)) { + return "GnomeAdwaitaTheme"; + } + if ("win".equals(platformName)) { + return "WindowsFluentTheme"; + } + } return null; } @@ -4622,6 +4800,20 @@ public void run() { com.codename1.ui.TooltipManager.hideTooltip(); } }); + // And clear the hover. Motion simply stops when the pointer leaves the canvas, + // and Form only re-points its tracked hover when a DIFFERENT component is + // reported, so without this the last control stayed lit with the cursor + // somewhere else entirely. -1,-1 is the same "nothing is under the pointer" + // coordinate the Windows and Linux ports send from their leave events; a real + // canvas coordinate is never negative. + // Through windowPointerHover, NOT pointerHover: a secondary window's canvas + // has windowId > 0 and its hover must reach that window's Desktop entry, the + // way mouseMoved above sends it. Routing the leave to the main form instead + // left the secondary window's control hovered until another motion event + // happened to reach it. + if (JavaSEPort.this.isDesktop()) { + JavaSEPort.this.windowPointerHover(windowId, -1, -1); + } } public void mouseDragged(MouseEvent e) { e.consume(); @@ -5454,6 +5646,7 @@ private void loadSkinFile(InputStream skin, final JFrame frm) { byte[] nativeThemeData = null; nativeThemeRes = null; nativeTheme = null; + desktopNativeFonts = false; while (e != null) { String name = e.getName(); if (name.equals("skin.png")) { @@ -5699,7 +5892,12 @@ private void loadSkinFile(InputStream skin, final JFrame frm) { isIOS = props.getProperty("systemFontFamily", "Arial").toLowerCase().contains("helvetica"); setFontFaces(props.getProperty("systemFontFamily", "Arial"), props.getProperty("proportionalFontFamily", "SansSerif"), - props.getProperty("monospaceFontFamily", "Monospaced")); + props.getProperty("monospaceFontFamily", "Monospaced"), false); + desktopNativeFonts = isDesktopNativeThemeResource("/" + overrideTheme + ".res"); + if (desktopNativeFonts) { + fontFaceSystem = defaultSystemFontForTheme(IS_MAC ? "mac" : (IS_LINUX ? "linux" : "win"), + "/" + overrideTheme + ".res"); + } int med; int sm; int la; @@ -8542,6 +8740,9 @@ private JMenu createNativeThemeMenu(final JFrame frm) { {"AndroidMaterialTheme", "Android Material"}, {"android_holo_light", "Android Holo Light"}, {"androidTheme", "Android Legacy"}, + {"WindowsFluentTheme", "Windows 11 Fluent"}, + {"MacOSAquaTheme", "macOS Aqua"}, + {"GnomeAdwaitaTheme", "GNOME Adwaita"}, {"embedded", "Use skin's embedded theme"} }; String current = Preferences.userNodeForPackage(JavaSEPort.class) @@ -10847,11 +11048,17 @@ public void init(Object m) { frame.setSize(new Dimension(300, 400)); m = panel; window = frame; - if (pref.getBoolean("uwpDesktopSkin", false)) { - setNativeTheme("/winTheme.res"); - } else { - setNativeTheme("/iOS7Theme.res"); - } + // The desktop pseudo-skin. This used to be a straight choice between the + // UWP-era winTheme stub and iOS 7 -- neither of which is what a desktop looks + // like on any platform, and the iOS 7 branch is why a Codename One desktop app + // has always previewed as a flat iPhone. + // + // Now it asks the same resolver the generated desktop app does, so the simulator + // previews what the app will actually ship with. The uwpDesktopSkin preference + // still forces the old stub for anyone relying on it, and a developer who has + // opted into nothing still gets iOS 7, unchanged. + setSimulatorDesktopNativeTheme(IS_MAC ? "mac" : (IS_LINUX ? "linux" : "win"), + pref.getBoolean("uwpDesktopSkin", false)); } setInvokePointerHover(desktopSkin || invokePointerHover); @@ -13993,11 +14200,44 @@ private static boolean endsWithIgnoreCase(String value, String suffix) { && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length()); } + private java.awt.Font desktopNativeFont(String alias) { + String family = fontFaceSystem; + // FACE_SYSTEM and native aliases share the installed family resolved at + // theme selection. Windows Java may expose "Segoe UI Variable" without + // the preferred "Text" suffix; retaining that missing name gives Dialog. + String variant = alias.substring("native:".length()); + boolean italic = variant.startsWith("Italic"); + if (!italic && !variant.startsWith("Main")) { + throw new IllegalArgumentException("Unsupported native font type: " + alias); + } + String weightName = variant.substring(italic ? 6 : 4); + 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 ("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); + java.util.Map attributes = new java.util.HashMap(); + attributes.put(TextAttribute.FAMILY, family); + attributes.put(TextAttribute.SIZE, Float.valueOf(medianFontSize)); + attributes.put(TextAttribute.WEIGHT, weight); + attributes.put(TextAttribute.POSTURE, italic ? TextAttribute.POSTURE_OBLIQUE : TextAttribute.POSTURE_REGULAR); + java.awt.Font out = new java.awt.Font(attributes); + desktopAliasFonts.put(out, Boolean.TRUE); + return out; + } + @Override public Object loadTrueTypeFont(String fontName, String fileName) { File fontFile = null; try { if(fontName.startsWith("native:")) { + // Desktop CSS uses native: aliases too. Changing FACE_SYSTEM alone + // leaves these aliases on the mobile Roboto path. + if (desktopNativeFonts) { + return desktopNativeFont(fontName); + } if(isIOS) { String nn = nativeFontName(fontName); if (nn != null) { @@ -14139,11 +14379,24 @@ public Object deriveTrueTypeFont(Object font, float size, int weight) { if ((weight & com.codename1.ui.Font.STYLE_ITALIC) == com.codename1.ui.Font.STYLE_ITALIC) { style = style | java.awt.Font.ITALIC; } - java.awt.Font fff = fnt.deriveFont(style, (float)(size * getFontScale())); + java.awt.Font fff; + if (desktopAliasFonts.containsKey(fnt)) { + // STYLE_PLAIN must retain the alias's light/bold/italic attributes. + java.util.Map attributes = new java.util.HashMap(); + attributes.put(TextAttribute.SIZE, Float.valueOf((float)(size * getFontScale()))); + if ((style & java.awt.Font.BOLD) != 0) attributes.put(TextAttribute.WEIGHT, TextAttribute.WEIGHT_BOLD); + if ((style & java.awt.Font.ITALIC) != 0) attributes.put(TextAttribute.POSTURE, TextAttribute.POSTURE_OBLIQUE); + fff = fnt.deriveFont(attributes); + desktopAliasFonts.put(fff, Boolean.TRUE); + } else { + fff = fnt.deriveFont(style, (float)(size * getFontScale())); + } if(Math.abs(size / 2 - fff.getSize()) < 3) { // retina display bug! - return fnt.deriveFont(style, (float)(size * 2 * getFontScale())); + java.awt.Font retina = fff.deriveFont((float)(size * 2 * getFontScale())); + if (desktopAliasFonts.containsKey(fnt)) desktopAliasFonts.put(retina, Boolean.TRUE); + return retina; } return fff; } @@ -16979,6 +17232,9 @@ public String getPlatformName() { if(IS_MAC) { return "mac"; } + if(IS_LINUX) { + return "linux"; + } return "win"; } return platformName; diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java index 1185ab0b520..c678972755f 100644 --- a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java +++ b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java @@ -30,6 +30,7 @@ import com.codename1.ui.util.Resources; import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.InvocationInterceptor; @@ -71,9 +72,11 @@ * in {@code @AfterEach}. */ public class CodenameOneExtension - implements BeforeAllCallback, BeforeEachCallback, InvocationInterceptor { + implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, InvocationInterceptor { private static final Object DISPLAY_BOOT_LOCK = new Object(); + private static final ExtensionContext.Namespace FONT_STATE = + ExtensionContext.Namespace.create(CodenameOneExtension.class, "themeFonts"); @Override public void beforeAll(ExtensionContext context) { @@ -112,7 +115,7 @@ public void beforeEach(ExtensionContext context) throws Exception { final ResolvedVisualConfig config = ResolvedVisualConfig.resolve(testClass, method); if (config.hasAny()) { try { - applyVisualConfigOnEdt(config); + applyVisualConfigOnEdt(config, context); } catch (Exception e) { throw e; } catch (Throwable t) { @@ -124,6 +127,26 @@ public void beforeEach(ExtensionContext context) throws Exception { } } + @Override + public void afterEach(ExtensionContext context) throws Exception { + final Runnable restore = context.getStore(FONT_STATE).remove("restore", Runnable.class); + if (restore != null) { + try { + dispatchOnEdt(new Invocation() { + @Override + public Void proceed() { + restore.run(); + return null; + } + }, 10000L, "restore theme fonts"); + } catch (Exception e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException(t); + } + } + } + @Override public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext ctx, @@ -224,7 +247,7 @@ private static void applyProperty(SimulatorProperty prop, boolean displayReady) * Mirrors the body of {@code JavaSEPort.applyThemeOnlyRefresh} via the * publicly visible {@link UIManager}/{@link Form} APIs. */ - private static void applyVisualConfigOnEdt(final ResolvedVisualConfig cfg) throws Throwable { + private static void applyVisualConfigOnEdt(final ResolvedVisualConfig cfg, final ExtensionContext context) throws Throwable { final AtomicReference thrown = new AtomicReference(); final Object lock = new Object(); final boolean[] done = new boolean[1]; @@ -234,7 +257,7 @@ private static void applyVisualConfigOnEdt(final ResolvedVisualConfig cfg) throw public void run() { try { if (cfg.theme != null) { - installTheme(cfg.theme); + installTheme(cfg.theme, context); } if (cfg.darkMode != null) { Display.getInstance().setDarkMode(cfg.darkMode); @@ -288,15 +311,24 @@ public void run() { } } - private static void installTheme(String resourcePath) throws java.io.IOException { - Resources r = Resources.open(resourcePath); - String[] names = r.getThemeResourceNames(); - if (names == null || names.length == 0) { - throw new IllegalStateException( - "Theme resource " + resourcePath + " contains no themes"); + private static void installTheme(String resourcePath, ExtensionContext context) throws java.io.IOException { + // Resources resolve native aliases while opening, before setThemeProps. + // Scope the font mode to this test, including failures and later mobile tests. + Runnable restore = JavaSEPort.pushNativeThemeFontConfiguration(resourcePath); + try { + Resources r = Resources.open(resourcePath); + String[] names = r.getThemeResourceNames(); + if (names == null || names.length == 0) { + throw new IllegalStateException( + "Theme resource " + resourcePath + " contains no themes"); + } + Hashtable themeProps = r.getTheme(names[0]); + UIManager.getInstance().setThemeProps(themeProps); + context.getStore(FONT_STATE).put("restore", restore); + } catch (java.io.IOException | RuntimeException | Error e) { + restore.run(); + throw e; } - Hashtable themeProps = r.getTheme(names[0]); - UIManager.getInstance().setThemeProps(themeProps); } /** diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/NativeTheme.java b/Ports/JavaSE/src/com/codename1/testing/junit/NativeTheme.java index 6e011f39ee0..bed8b8053f3 100644 --- a/Ports/JavaSE/src/com/codename1/testing/junit/NativeTheme.java +++ b/Ports/JavaSE/src/com/codename1/testing/junit/NativeTheme.java @@ -60,7 +60,16 @@ public enum NativeTheme { ANDROID_HOLO_LIGHT("/android_holo_light.res", "Android Holo Light"), /** "Android Legacy" -- the pre-Material Android look. */ - ANDROID_LEGACY("/androidTheme.res", "Android Legacy"); + ANDROID_LEGACY("/androidTheme.res", "Android Legacy"), + + /** "Windows 11 Fluent" -- the WinUI 3 desktop look. */ + WINDOWS_FLUENT("/WindowsFluentTheme.res", "Windows 11 Fluent"), + + /** "macOS Aqua" -- the AppKit desktop look. */ + MACOS_AQUA("/MacOSAquaTheme.res", "macOS Aqua"), + + /** "GNOME Adwaita" -- the GTK4 / libadwaita desktop look. */ + GNOME_ADWAITA("/GnomeAdwaitaTheme.res", "GNOME Adwaita"); private final String resourcePath; private final String displayName; diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 0ef6c5ebf06..82bcfcf2e2d 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -4534,7 +4534,10 @@ private static String resolveNativeThemeResource() { if (iosLike) { String iosMode = d.getProperty("ios.themeMode", null); if (iosMode == null && shared != null) { - if ("modern".equalsIgnoreCase(shared) || "auto".equalsIgnoreCase(shared)) { + // "native" joins modern/auto here: it means the platform's own look on + // every OS, and on an iOS-like browser that is the modern theme. + if ("modern".equalsIgnoreCase(shared) || "auto".equalsIgnoreCase(shared) + || "native".equalsIgnoreCase(shared)) { iosMode = "modern"; } else if ("legacy".equalsIgnoreCase(shared)) { iosMode = "ios7"; @@ -4556,7 +4559,8 @@ private static String resolveNativeThemeResource() { } String androidMode = d.getProperty("and.themeMode", d.getProperty("cn1.androidTheme", null)); if (androidMode == null && shared != null) { - if ("modern".equalsIgnoreCase(shared) || "auto".equalsIgnoreCase(shared)) { + if ("modern".equalsIgnoreCase(shared) || "auto".equalsIgnoreCase(shared) + || "native".equalsIgnoreCase(shared)) { androidMode = "material"; } else if ("legacy".equalsIgnoreCase(shared)) { androidMode = "hololight"; diff --git a/Ports/JavaScriptPort/src/main/webapp/assets/.gitignore b/Ports/JavaScriptPort/src/main/webapp/assets/.gitignore index 48c6b4dfaeb..3c7e45d2a9f 100644 --- a/Ports/JavaScriptPort/src/main/webapp/assets/.gitignore +++ b/Ports/JavaScriptPort/src/main/webapp/assets/.gitignore @@ -3,3 +3,6 @@ # Themes/ and is committed; this mirror is a build artifact. iOSModernTheme.res AndroidMaterialTheme.res +GnomeAdwaitaTheme.res +WindowsFluentTheme.res +MacOSAquaTheme.res diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h index 3ed7d28b08d..fcb88a5d1f5 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux.h @@ -88,18 +88,25 @@ typedef enum { * gesture ended, because a touchpad produces no pointer events and the * two-pointer path that normally ends a pinch never runs. */ CN1_EVENT_PINCH_BEGIN = 20, - CN1_EVENT_PINCH_END = 21 + CN1_EVENT_PINCH_END = 21, + /* Pointer motion with NO button held. Dropped entirely before the desktop + * themes existed, because a mobile port has no use for it. It is what drives + * Component's hover style, so without it an Adwaita button never lights up + * under the cursor and the theme's hover rules are dead entries in the .res. + * Deliberately the same number the Windows port uses, so the two desktop wire + * protocols do not drift apart. */ + CN1_EVENT_POINTER_HOVER = 22 } CN1EventType; /* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */ #define CN1_GESTURE_FIXED 10000 -/* For pointer (pressed/released/dragged) events the otherwise-unused keyCode +/* For pointer (pressed/released/dragged/hover) events the otherwise-unused keyCode * field carries the pointer metadata: the low bits are a button bitmask that * mirrors com.codename1.ui.events.PointerEvent.MASK_* (so a press/release carry * the button that changed and a drag carries the buttons held down), and the - * high bits flag a touch digitizer so the Java side reports TYPE_TOUCH. A value - * of 0 means "no detail" and defaults to a primary mouse press. + * high bits flag a touch digitizer or pen. Hover carries no button bits; + * a contact event with no button detail defaults to a primary press. * LinuxImplementation.drainInput decodes this. */ #define CN1_PE_MASK_PRIMARY 1 #define CN1_PE_MASK_SECONDARY 2 @@ -107,6 +114,8 @@ typedef enum { #define CN1_PE_MASK_BACK 8 #define CN1_PE_MASK_FORWARD 16 #define CN1_PE_TOUCH_FLAG 256 +#define CN1_PE_PEN_FLAG 512 +#define CN1_PE_ERASER_FLAG 1024 /* Pushes one event onto the ring buffer (called from the GTK thread). */ /* Turns fractional smooth-scroll notches into whole ones, carrying the remainder diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c index bf17af9c44a..65c2c25e54b 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c @@ -304,10 +304,23 @@ static gboolean cn1DesktopOnButton(GtkWidget* widget, GdkEventButton* e, gpointe } if (e->type == GDK_BUTTON_PRESS) { cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_PRESSED, - (int) e->x, (int) e->y, cn1DesktopButtonMask(e->button)); + (int) e->x, (int) e->y, cn1DesktopButtonMask(e->button) | cn1LinuxPointerSourceFlag((GdkEvent*) e)); } else if (e->type == GDK_BUTTON_RELEASE) { cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_RELEASED, - (int) e->x, (int) e->y, cn1DesktopButtonMask(e->button)); + (int) e->x, (int) e->y, cn1DesktopButtonMask(e->button) | cn1LinuxPointerSourceFlag((GdkEvent*) e)); + } + return FALSE; +} + +/* The pointer left a SECONDARY window: clear its hover. -1,-1 is the agreed "nothing is + * under the pointer" coordinate, the same one the main window and the Windows port use. + * GDK_NOTIFY_INFERIOR is the pointer moving onto a child, which has not left at all. */ +static gboolean cn1DesktopOnLeave(GtkWidget* widget, GdkEventCrossing* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w != 0 && e->detail != GDK_NOTIFY_INFERIOR) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_HOVER, -1, -1, + cn1LinuxPointerSourceFlag((GdkEvent*) e)); } return FALSE; } @@ -328,7 +341,14 @@ static gboolean cn1DesktopOnMotion(GtkWidget* widget, GdkEventMotion* e, gpointe if (e->state & GDK_BUTTON3_MASK) { mask |= CN1_PE_MASK_SECONDARY; } if (mask != 0) { cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_DRAGGED, - (int) e->x, (int) e->y, mask); + (int) e->x, (int) e->y, mask | cn1LinuxPointerSourceFlag((GdkEvent*) e)); + } else { + /* Buttonless motion in a SECONDARY window is hover, dropped here for the same + * reason it was dropped in the main window: a mobile port has no use for it. + * windowPointerHover routes by window id, so a control in a secondary window + * reaches the hover state like any other. */ + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_HOVER, + (int) e->x, (int) e->y, cn1LinuxPointerSourceFlag((GdkEvent*) e)); } return FALSE; } @@ -540,8 +560,7 @@ static gboolean cn1DesktopOnDelete(GtkWidget* widget, GdkEvent* e, gpointer data * the touch handler drives the pointer instead -- otherwise every contact * dispatches twice, once unflagged. Same rule the main window applies. */ static int cn1DesktopIsTouchSource(GdkEvent* e) { - GdkDevice* dev = gdk_event_get_source_device(e); - return dev != NULL && gdk_device_get_source(dev) == GDK_SOURCE_TOUCHSCREEN; + return cn1LinuxPointerSourceFlag(e) == CN1_PE_TOUCH_FLAG; } /* Real touch sequences, mirroring the main window's cn1OnTouch with the window id @@ -715,7 +734,7 @@ static void cn1DesktopCreateOnMain(void* arg) { gtk_widget_add_events(w->drawingArea, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK + | GDK_POINTER_MOTION_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_SCROLL_MASK | GDK_SMOOTH_SCROLL_MASK | GDK_TOUCHPAD_GESTURE_MASK | GDK_TOUCH_MASK); @@ -727,6 +746,7 @@ static void cn1DesktopCreateOnMain(void* arg) { g_signal_connect(w->drawingArea, "button-press-event", G_CALLBACK(cn1DesktopOnButton), w); g_signal_connect(w->drawingArea, "button-release-event", G_CALLBACK(cn1DesktopOnButton), w); g_signal_connect(w->drawingArea, "motion-notify-event", G_CALLBACK(cn1DesktopOnMotion), w); + g_signal_connect(w->drawingArea, "leave-notify-event", G_CALLBACK(cn1DesktopOnLeave), w); g_signal_connect(w->drawingArea, "scroll-event", G_CALLBACK(cn1DesktopOnScroll), w); g_signal_connect(w->drawingArea, "touch-event", G_CALLBACK(cn1DesktopOnTouch), w); /* Touchpad gestures arrive through the generic "event" signal rather than one diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h b/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h index 18db2c4e617..6d9fe33786b 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h @@ -46,6 +46,9 @@ extern "C" { #endif +/* Shared source decoding for main and secondary window pointer events. */ +int cn1LinuxPointerSourceFlag(GdkEvent* event); + /* * A drawing target: a Cairo context over a backing image surface. The on-screen * window target and every mutable/offscreen image share this struct, so all the diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c index 42bf289f41a..a81ac209b50 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c @@ -83,13 +83,16 @@ void cn1LinuxPushEvent(int type, int x, int y, int keyCode) { * Note the asymmetry with presses, which stay droppable: a release that arrives with * no press behind it finds no recorded target and is discarded harmlessly, so when * something has to go it must never be the release. */ -static int cn1LinuxIsProtectedEvent(int type) { +/* Unlike hover motion, leave has no later motion outside the window to repair + * a dropped notification. Protect only the terminal sentinel, not the motion stream. */ +static int cn1LinuxIsProtectedEvent(int type, int x, int y) { return type == CN1_EVENT_WINDOW_SHOWN || type == CN1_EVENT_WINDOW_HIDDEN || type == CN1_EVENT_WINDOW_CLOSE || type == CN1_EVENT_KEY_RELEASED || type == CN1_EVENT_POINTER_RELEASED || type == CN1_EVENT_WINDOW_FOCUS - || type == CN1_EVENT_SIZE_CHANGED; + || type == CN1_EVENT_SIZE_CHANGED + || (type == CN1_EVENT_POINTER_HOVER && x == -1 && y == -1); } /* Visibility only. A close request is protected from eviction like any other @@ -155,7 +158,8 @@ static void cn1LinuxRemoveAtLocked(int idx) { static int cn1LinuxEvictInputLocked(void) { int idx = cn1EventHead; while (idx != cn1EventTail) { - if (!cn1LinuxIsProtectedEvent(cn1EventRing[idx].type)) { + if (!cn1LinuxIsProtectedEvent(cn1EventRing[idx].type, + cn1EventRing[idx].x, cn1EventRing[idx].y)) { cn1LinuxRemoveAtLocked(idx); return 1; } @@ -203,7 +207,9 @@ static int cn1LinuxEvictOldestTerminationLocked(void) { while (idx != cn1EventTail) { int t = cn1EventRing[idx].type; if (t == CN1_EVENT_KEY_RELEASED || t == CN1_EVENT_POINTER_RELEASED - || t == CN1_EVENT_WINDOW_FOCUS) { + || t == CN1_EVENT_WINDOW_FOCUS + || (t == CN1_EVENT_POINTER_HOVER && cn1EventRing[idx].x == -1 + && cn1EventRing[idx].y == -1)) { cn1LinuxRemoveAtLocked(idx); return 1; } @@ -215,7 +221,7 @@ static int cn1LinuxEvictOldestTerminationLocked(void) { void cn1LinuxPushWindowEvent(int windowId, int type, int x, int y, int keyCode) { pthread_mutex_lock(&cn1EventLock); int next = (cn1EventTail + 1) % CN1_EVENT_RING; - if (next == cn1EventHead && cn1LinuxIsProtectedEvent(type)) { + if (next == cn1EventHead && cn1LinuxIsProtectedEvent(type, x, y)) { /* Full, and this one must not be the casualty. Supersede this window's own * queued transition if it has one, otherwise take the room from an input event, * and failing that from a transition that a later one already supersedes. Never @@ -484,12 +490,31 @@ static int cn1LinuxStateMask(guint state) { return mask; } +/* Hover and contact must retain the same physical source. GDK_SOURCE_CURSOR + * is a tablet puck, not a pen; pen and eraser retain their distinct pointer types. */ +int cn1LinuxPointerSourceFlag(GdkEvent* event) { + GdkDevice* device = gdk_event_get_source_device(event); + if (device == NULL) { + return 0; + } + GdkInputSource source = gdk_device_get_source(device); + if (source == GDK_SOURCE_TOUCHSCREEN) { + return CN1_PE_TOUCH_FLAG; + } + if (source == GDK_SOURCE_ERASER) { + return CN1_PE_ERASER_FLAG; + } + if (source == GDK_SOURCE_PEN) { + return CN1_PE_PEN_FLAG; + } + return 0; +} + /* True when an event originated from a touchscreen. GTK also synthesizes button * / motion events from touch for widgets that ignore touch, so we drop those * here and let cn1OnTouch drive the pointer instead (avoids double dispatch). */ static int cn1LinuxIsTouchSource(GdkEvent* e) { - GdkDevice* dev = gdk_event_get_source_device(e); - return dev != NULL && gdk_device_get_source(dev) == GDK_SOURCE_TOUCHSCREEN; + return cn1LinuxPointerSourceFlag(e) == CN1_PE_TOUCH_FLAG; } static gboolean cn1OnButton(GtkWidget* widget, GdkEventButton* e, gpointer data) { @@ -499,7 +524,7 @@ static gboolean cn1OnButton(GtkWidget* widget, GdkEventButton* e, gpointer data) return TRUE; } cn1LinuxPushEvent(e->type == GDK_BUTTON_PRESS ? CN1_EVENT_POINTER_PRESSED : CN1_EVENT_POINTER_RELEASED, - (int) e->x, (int) e->y, cn1LinuxButtonMask(e->button)); + (int) e->x, (int) e->y, cn1LinuxButtonMask(e->button) | cn1LinuxPointerSourceFlag((GdkEvent*) e)); return TRUE; } @@ -511,7 +536,16 @@ static gboolean cn1OnMotion(GtkWidget* widget, GdkEventMotion* e, gpointer data) } int mask = cn1LinuxStateMask(e->state); if (mask != 0) { - cn1LinuxPushEvent(CN1_EVENT_POINTER_DRAGGED, (int) e->x, (int) e->y, mask); + cn1LinuxPushEvent(CN1_EVENT_POINTER_DRAGGED, (int) e->x, (int) e->y, + mask | cn1LinuxPointerSourceFlag((GdkEvent*) e)); + } else { + /* No button held: this is hover, and it used to be dropped here. + * Component's hover style is driven by Form.pointerHover, which has + * nothing else to fire it, so every hover rule in a desktop theme was + * inert. Droppable rather than protected: a lost hover costs nothing + * because hover is idempotent and the next motion re-establishes it. */ + cn1LinuxPushEvent(CN1_EVENT_POINTER_HOVER, (int) e->x, (int) e->y, + cn1LinuxPointerSourceFlag((GdkEvent*) e)); } return TRUE; } @@ -520,6 +554,26 @@ static gboolean cn1OnMotion(GtkWidget* widget, GdkEventMotion* e, gpointer data) * model). Additional concurrent fingers are ignored until it ends. */ static GdkEventSequence* cn1TouchSeq = NULL; +/* The pointer left the drawing area: clear hover. + * + * Without this the cursor can move straight off the window and the last hovered control + * stays lit -- motion simply stops, and Form only clears its tracked hover when a + * DIFFERENT component is reported. -1,-1 is the agreed "nothing is under the pointer" + * coordinate, the same one the Windows port sends from WM_MOUSELEAVE; a real coordinate + * is never negative, so the two cannot be confused. + * + * GDK_NOTIFY_INFERIOR is ignored: that is the pointer moving onto a CHILD of the drawing + * area, which has not left the window at all, and treating it as a leave would blink the + * hover off and on again. */ +static gboolean cn1OnLeave(GtkWidget* widget, GdkEventCrossing* e, gpointer data) { + (void) widget; + (void) data; + if (e->detail != GDK_NOTIFY_INFERIOR) { + cn1LinuxPushEvent(CN1_EVENT_POINTER_HOVER, -1, -1, cn1LinuxPointerSourceFlag((GdkEvent*) e)); + } + return FALSE; +} + static gboolean cn1OnTouch(GtkWidget* widget, GdkEventTouch* e, gpointer data) { (void) widget; (void) data; @@ -938,6 +992,10 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in cn1DrawingArea = gtk_drawing_area_new(); gtk_widget_set_events(cn1DrawingArea, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | + /* LEAVE_NOTIFY drives the hover clear (cn1OnLeave). A g_signal_connect for + * an event the mask does not select is never called, so the handler would + * have been dead code without this bit. */ + GDK_LEAVE_NOTIFY_MASK | GDK_KEY_PRESS_MASK | GDK_KEY_RELEASE_MASK | GDK_SCROLL_MASK | GDK_SMOOTH_SCROLL_MASK | GDK_TOUCH_MASK | GDK_TOUCHPAD_GESTURE_MASK | GDK_STRUCTURE_MASK); @@ -963,6 +1021,7 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in g_signal_connect(cn1DrawingArea, "button-press-event", G_CALLBACK(cn1OnButton), 0); g_signal_connect(cn1DrawingArea, "button-release-event", G_CALLBACK(cn1OnButton), 0); g_signal_connect(cn1DrawingArea, "motion-notify-event", G_CALLBACK(cn1OnMotion), 0); + g_signal_connect(cn1DrawingArea, "leave-notify-event", G_CALLBACK(cn1OnLeave), 0); g_signal_connect(cn1DrawingArea, "touch-event", G_CALLBACK(cn1OnTouch), 0); g_signal_connect(cn1DrawingArea, "event", G_CALLBACK(cn1OnGenericEvent), 0); g_signal_connect(cn1Window, "key-press-event", G_CALLBACK(cn1OnKey), 0); @@ -1411,3 +1470,56 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_captureWindowToPngBytes___R_byt free(data); return arr; } + +/* ------------------------------------------------------------- colour scheme */ + +/* The desktop's colour scheme: 1 dark, 0 light, -1 unknown. + * + * Asked of the DESKTOP's setting, not of GTK's. The obvious-looking + * gtk-application-prefer-dark-theme is the wrong source: it expresses whether the + * application is ASKING for a dark GTK theme, and stays false unless the application sets + * it -- so reading it reported light on a GNOME desktop in dark mode, and every $Dark + * entry in the Adwaita theme stayed unreachable. + * + * org.gnome.desktop.interface color-scheme is what the user's toggle actually writes, and + * what the XDG appearance portal reports to sandboxed apps. Queried through GSettings + * rather than over D-Bus so there is no round trip and no portal dependency. + * + * The schema is looked up before it is opened. g_settings_new ABORTS the process when the + * schema is not installed, which is a real configuration on a minimal container or a + * non-GNOME desktop, and a theme query has no business killing the application. + * + * -1 is a real answer, not an error smuggled into the return: a session with no such + * schema has no preference to report, and calling that "light" would be a guess presented + * as a fact. The Java side maps it to null. + * + * The signature is ParparVM's and is checked by nothing at build time -- a wrong name + * compiles, links, and leaves the Java method looking unused to the dead-code pass, + * which then removes it. scripts/check-native-signatures.sh is what catches that. + */ +JAVA_INT com_codename1_impl_linux_LinuxNative_systemColorScheme___R_int(CODENAME_ONE_THREAD_STATE) { + GSettingsSchemaSource* source = g_settings_schema_source_get_default(); + if (source == NULL) { + return -1; + } + GSettingsSchema* schema = g_settings_schema_source_lookup(source, + "org.gnome.desktop.interface", TRUE); + if (schema == NULL) { + return -1; + } + int result = -1; + /* has_key as well as the schema lookup: color-scheme arrived in GNOME 42, and the + * schema exists without it on older desktops. g_settings_get_string on a missing key + * aborts the same way a missing schema does. */ + if (g_settings_schema_has_key(schema, "color-scheme")) { + GSettings* settings = g_settings_new("org.gnome.desktop.interface"); + gchar* scheme = g_settings_get_string(settings, "color-scheme"); + if (scheme != NULL) { + result = strcmp(scheme, "prefer-dark") == 0 ? 1 : 0; + g_free(scheme); + } + g_object_unref(settings); + } + g_settings_schema_unref(schema); + return result; +} diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index db999862d74..9a6ed0eb863 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -119,6 +119,10 @@ public boolean isScreenReaderEnabled() { private static final int EVENT_PINCH_BEGIN = 20; private static final int EVENT_PINCH_END = 21; + /// Pointer motion with no button held. Must match CN1_EVENT_POINTER_HOVER in + /// nativeSources/cn1_linux.h -- the two tables are the wire protocol and a + /// mismatch routes an event to the wrong handler rather than failing. + private static final int EVENT_POINTER_HOVER = 22; private static final int EVENT_ROTATE = 11; private static final int EVENT_ACCESSIBILITY_ACTION = 12; // Additional desktop windows. These always carry a non-zero window id. @@ -761,20 +765,28 @@ public void edtIdle(boolean enter) { // not pump or drain on its own (it is not the window's owning thread). } - // High bit the native layer ORs into a pointer event's key field to flag a touch - // digitizer (see cn1_linux.h CN1_PE_TOUCH_FLAG); the low byte is the button - // bitmask (PointerEvent.MASK_*). + // High bits in the native key flag touch, pen, or eraser input (see cn1_linux.h); + // the low byte is the button bitmask (PointerEvent.MASK_*). private static final int POINTER_BUTTON_BITS = 0xFF; private static final int POINTER_TOUCH_FLAG = 256; + private static final int POINTER_PEN_FLAG = 512; + private static final int POINTER_ERASER_FLAG = 1024; - // Decodes the native pointer key field (button mask + touch flag) into the + // Decodes the native pointer key field (button mask + pointer source flags) into the // cross-platform PointerEvent metadata for the next dispatched pointer event, so // the rich pointer / context-menu APIs report the real button and device type. private void markPointer(int keyField) { int mask = keyField & POINTER_BUTTON_BITS; - int type = (keyField & POINTER_TOUCH_FLAG) != 0 - ? com.codename1.ui.events.PointerEvent.TYPE_TOUCH - : com.codename1.ui.events.PointerEvent.TYPE_MOUSE; + int type; + if ((keyField & POINTER_ERASER_FLAG) != 0) { + type = com.codename1.ui.events.PointerEvent.TYPE_ERASER; + } else if ((keyField & POINTER_PEN_FLAG) != 0) { + type = com.codename1.ui.events.PointerEvent.TYPE_STYLUS; + } else if ((keyField & POINTER_TOUCH_FLAG) != 0) { + type = com.codename1.ui.events.PointerEvent.TYPE_TOUCH; + } else { + type = com.codename1.ui.events.PointerEvent.TYPE_MOUSE; + } int button; if (mask == 0) { mask = com.codename1.ui.events.PointerEvent.MASK_PRIMARY; @@ -793,6 +805,16 @@ private void markPointer(int keyField) { setPointerEventMetadata(button, mask, type, 1f, 0, 0, 0, 0, false); } + void dispatchPointerHover(int windowId, int x, int y, int keyField) { + // Preserve the native source while clearing all earlier contact metadata. + // Hover is pressure-free for both mice and pens. + markPointer(keyField); + setPointerButton(com.codename1.ui.events.PointerEvent.BUTTON_NONE, 0); + setPointerPressure(0f); + setPointerHovering(true); + windowPointerHover(windowId, x, y); + } + private void drainInput() { while (LinuxNative.pollEvent(eventScratch)) { int type = eventScratch[0]; @@ -841,6 +863,14 @@ private void drainInput() { markPointer(key); windowPointerDragged(windowId, x, y); break; + case EVENT_POINTER_HOVER: + // Routed by window id rather than restricted to the main one. + // windowPointerHover hands a secondary window's event to that + // window's own Desktop instance, so a control in one reaches the + // hover state like any other; the earlier main-window-only guard + // made hover unreachable there. + dispatchPointerHover(windowId, x, y, key); + break; case EVENT_KEY_PRESSED: windowKeyPressed(windowId, key); break; @@ -3190,6 +3220,48 @@ public String getPlatformName() { return "linux"; } + /// Linux is a desktop, always. + /// + /// CodenameOneImplementation.isDesktop() answers false, and this port never + /// overrode it, so a linux application was a mobile one as far as the framework + /// was concerned. That reached further than it looks: no `_desktop.ovr` resource + /// layer, no `device-desktop-` theme layer, no @defaultDesktopFontSizeInt, no + /// @desktopTitleBarMode, and the mobile branch of Button.pointerHover, + /// TextSelection and SplitPane. + @Override + public boolean isDesktop() { + return true; + } + + /// @inheritDoc + /// + /// Desktop layers. Matches the JavaSE desktop port and the macOS port + /// (`desktop`, `tablet`, ...) so one override written for the desktop covers all + /// three, with `linux` last so a layer can name this port specifically. + @Override + public String[] getPlatformOverrides() { + return new String[] {"desktop", "tablet", "linux"}; + } + + /// @inheritDoc + /// + /// CodenameOneImplementation.isDarkMode() answers false and this port never + /// overrode it, so every $Dark entry in a desktop theme was dead weight in the + /// .res: the Adwaita theme's whole dark palette could never be selected. + /// + /// Returns Boolean rather than boolean because the contract distinguishes "the + /// platform does not know" (null) from "light" (FALSE) -- UIManager's dark-mode + /// resolution tests for null explicitly -- and on Linux that distinction is real: + /// a session with no desktop settings daemon has no answer to give. + @Override + public Boolean isDarkMode() { + int v = LinuxNative.systemColorScheme(); + if (v < 0) { + return null; + } + return v == 1 ? Boolean.TRUE : Boolean.FALSE; + } + @Override public String getNativeLogSnapshot() { try { diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index a0c5c75935a..ef1e9309839 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -41,6 +41,16 @@ private LinuxNative() { /** Writes a line to the native debug log (OutputDebugString + stderr). */ public static native void nativeLog(String message); + /** + * The desktop's colour scheme: 1 dark, 0 light, -1 unknown. + * + * Three states rather than a boolean because on Linux "unknown" is real -- a + * session with no settings daemon has no answer, and reporting that as light + * would be a guess presented as a fact. LinuxImplementation.isDarkMode() maps + * -1 to null, which UIManager's dark-mode resolution tests for explicitly. + */ + public static native int systemColorScheme(); + /* ------------------------------------------------------------- VideoIO */ /** True when the GStreamer runtime backing VideoIO is available (libgstreamer-1.0 loadable). */ diff --git a/Ports/MacPort/src/com/codename1/impl/mac/MacImplementation.java b/Ports/MacPort/src/com/codename1/impl/mac/MacImplementation.java index e38f63f2add..852231a7ded 100644 --- a/Ports/MacPort/src/com/codename1/impl/mac/MacImplementation.java +++ b/Ports/MacPort/src/com/codename1/impl/mac/MacImplementation.java @@ -89,6 +89,34 @@ public String[] getPlatformOverrides() { return new String[] {"desktop", "tablet", "mac"}; } + /// @inheritDoc + /// + /// Aqua unless the application asks for an iOS theme by name. The port previously + /// inherited IOSImplementation's chain, whose only choices are iOSModernTheme, + /// iOS7Theme and iPhoneTheme, so a native Mac application installed an iPhone design + /// language however its build hints were set. Bundling MacOSAquaTheme.res alongside + /// them changed nothing on its own -- nothing loaded it. + /// + /// `modern` / `liquid` and `ios7` / `flat` stay meaningful and return null so the iOS + /// chain handles them, which is how a project that deliberately wants the iOS look on + /// macOS keeps it. + @Override + protected String nativeThemeResourceName(String mode) { + if (mode == null || "auto".equals(mode) || "aqua".equals(mode) || "native".equals(mode)) { + return "MacOSAquaTheme"; + } + return null; + } + + @Override + protected String nativeFontName(String fontName) { + // Aqua uses AppKit's regular weight and system italics. The iOS-style + // modes deliberately retain their historical medium/Helvetica mapping; + // changing those aliases would restyle existing modern-mode Mac apps. + return nativeThemeResourceName(nativeThemeMode()) != null + ? fontName : super.nativeFontName(fontName); + } + /// The natives this class needs directly. The window manager owns its own; /// density is asked for long before any secondary window exists. private final MacNative macNative = new MacNative(); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows.h b/Ports/WindowsPort/nativeSources/cn1_windows.h index 6bc683bb9ec..104f4bbaaaa 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows.h +++ b/Ports/WindowsPort/nativeSources/cn1_windows.h @@ -115,7 +115,13 @@ typedef enum { /* Touchpad pinch phases, kept on the same numbers the Linux port uses so the * two desktop wire protocols do not drift apart. */ CN1_EVENT_PINCH_BEGIN = 20, - CN1_EVENT_PINCH_END = 21 + CN1_EVENT_PINCH_END = 21, + /* Pointer motion with NO button held. Dropped entirely before the desktop + * themes existed, because a mobile port has no use for it: WM_MOUSEMOVE with + * an empty button mask simply returned. It is what drives Component's hover + * style, so without it a Fluent button never lights up under the cursor and + * the theme's hover rules are dead entries in the .res. */ + CN1_EVENT_POINTER_HOVER = 22 } CN1EventType; /* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */ diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_desktopwindow.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_desktopwindow.cpp index fa254693f18..3d8558d600d 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_desktopwindow.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_desktopwindow.cpp @@ -378,8 +378,32 @@ static LRESULT CALLBACK cn1WinDesktopWndProc(HWND hwnd, UINT msg, WPARAM wParam, if (wParam & MK_XBUTTON2) { mask |= CN1_PE_MASK_FORWARD; } cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_DRAGGED, lParam, mask | cn1WinTouchFlag()); + } else { + /* Buttonless motion in a SECONDARY window is hover, dropped here for the + * same reason it was dropped in the main window: a mobile port has no use + * for it. windowPointerHover routes by window id, so a control in a + * secondary window reaches the hover state like any other. */ + /* Touch-promoted motion is not hover; see the main window's handler. */ + int source = cn1WinTouchFlag(); + if ((source & CN1_PE_TOUCH_FLAG) == 0) { + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_HOVER, lParam, source); + } + /* One-shot, so re-armed on every hover. Without it the pointer can leave + * this window and the last control stays lit. */ + TRACKMOUSEEVENT tme; + tme.cbSize = sizeof(tme); + tme.dwFlags = TME_LEAVE; + tme.hwndTrack = hwnd; + tme.dwHoverTime = HOVER_DEFAULT; + TrackMouseEvent(&tme); } return 0; + case WM_MOUSELEAVE: + /* -1,-1 is the agreed "nothing is under the pointer" coordinate; a real client + * coordinate is never negative. */ + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_HOVER, MAKELPARAM(-1, -1), + cn1WinTouchFlag()); + return 0; case WM_MOUSEWHEEL: case WM_MOUSEHWHEEL: { /* Same shape as the main window's handler: the wheel message reports the diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp index 413cd42ba61..2a63481ee7c 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp @@ -361,13 +361,16 @@ void cn1WinPushEvent(CN1EventType type, int x, int y, int keyCode) { * Note the asymmetry with presses, which stay droppable: a release that arrives with * no press behind it finds no recorded target and is discarded harmlessly, so when * something has to go it must never be the release. */ -static int cn1WinIsProtectedEvent(CN1EventType type) { +/* Unlike hover motion, leave has no later motion outside the window to repair + * a dropped notification. Protect only the terminal sentinel, not the motion stream. */ +static int cn1WinIsProtectedEvent(CN1EventType type, int x, int y) { return type == CN1_EVENT_WINDOW_SHOWN || type == CN1_EVENT_WINDOW_HIDDEN || type == CN1_EVENT_WINDOW_CLOSE || type == CN1_EVENT_KEY_RELEASED || type == CN1_EVENT_POINTER_RELEASED || type == CN1_EVENT_WINDOW_FOCUS - || type == CN1_EVENT_SIZE_CHANGED; + || type == CN1_EVENT_SIZE_CHANGED + || (type == CN1_EVENT_POINTER_HOVER && x == -1 && y == -1); } /* Visibility only. A close request is protected from eviction like any other @@ -433,7 +436,8 @@ static void cn1WinRemoveAtLocked(LONG idx) { static int cn1WinEvictInputLocked(void) { LONG idx = cn1Win.eventHead; while (idx != cn1Win.eventTail) { - if (!cn1WinIsProtectedEvent((CN1EventType) cn1Win.events[idx].type)) { + if (!cn1WinIsProtectedEvent((CN1EventType) cn1Win.events[idx].type, + cn1Win.events[idx].x, cn1Win.events[idx].y)) { cn1WinRemoveAtLocked(idx); return 1; } @@ -483,7 +487,9 @@ static int cn1WinEvictOldestTerminationLocked(void) { while (idx != cn1Win.eventTail) { CN1EventType t = (CN1EventType) cn1Win.events[idx].type; if (t == CN1_EVENT_KEY_RELEASED || t == CN1_EVENT_POINTER_RELEASED - || t == CN1_EVENT_WINDOW_FOCUS) { + || t == CN1_EVENT_WINDOW_FOCUS + || (t == CN1_EVENT_POINTER_HOVER && cn1Win.events[idx].x == -1 + && cn1Win.events[idx].y == -1)) { cn1WinRemoveAtLocked(idx); return 1; } @@ -495,7 +501,7 @@ static int cn1WinEvictOldestTerminationLocked(void) { void cn1WinPushWindowEvent(int windowId, CN1EventType type, int x, int y, int keyCode) { EnterCriticalSection(&cn1Win.eventLock); LONG next = (cn1Win.eventTail + 1) % CN1_EVENT_QUEUE_CAPACITY; - if (next == cn1Win.eventHead && cn1WinIsProtectedEvent(type)) { + if (next == cn1Win.eventHead && cn1WinIsProtectedEvent(type, x, y)) { /* Full, and this one must not be the casualty. Supersede this window's own * queued transition if it has one, otherwise take the room from an input event, * and failing that from a transition that a later one already supersedes. Never @@ -588,7 +594,10 @@ static int cn1WinMoveMask(WPARAM wParam) { int cn1WinTouchFlag(void) { LONG_PTR extra = GetMessageExtraInfo(); if ((extra & 0xFFFFFF00) == 0xFF515700) { - return (extra & 0x80) ? CN1_PE_PEN_FLAG : CN1_PE_TOUCH_FLAG; + /* Microsoft defines bit 0x80 as TOUCH, not pen. Reversing it makes a + * touch-only hover filter admit fingers and discard hovering pens. + * https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages */ + return (extra & 0x80) ? CN1_PE_TOUCH_FLAG : CN1_PE_PEN_FLAG; } return 0; } @@ -711,9 +720,46 @@ LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam if (moveMask != 0) { cn1WinPushEvent(CN1_EVENT_POINTER_DRAGGED, GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), moveMask | cn1WinTouchFlag()); + } else { + /* No button held: this is hover, and it used to be dropped here. + * Component's hover style is driven by Form.pointerHover, which + * has nothing else to fire it, so every hover rule in a desktop + * theme was inert. + * + * Droppable rather than protected, which is the right side of + * that line: on overflow the ring discards the newest event, and + * a lost hover costs nothing because hover is idempotent -- the + * next motion re-establishes it. A lost RELEASE, by contrast, + * leaves a button held for good, which is why that one is + * protected. */ + /* A hovering pen is valid hover; only touch-promoted motion is excluded. + * Keep the source flag so Java callbacks receive stylus metadata. */ + int source = cn1WinTouchFlag(); + if ((source & CN1_PE_TOUCH_FLAG) == 0) { + cn1WinPushEvent(CN1_EVENT_POINTER_HOVER, GET_X_LPARAM(lParam), + GET_Y_LPARAM(lParam), source); + } + /* Ask for one WM_MOUSELEAVE. Without it the cursor can move straight off + * the window and the last hovered control stays lit: motion simply stops, + * and Form only clears its tracked hover when a DIFFERENT component is + * reported. TrackMouseEvent is one-shot, so it is re-armed on every hover + * rather than once at creation. */ + TRACKMOUSEEVENT tme; + tme.cbSize = sizeof(tme); + tme.dwFlags = TME_LEAVE; + tme.hwndTrack = hwnd; + tme.dwHoverTime = HOVER_DEFAULT; + TrackMouseEvent(&tme); } return 0; } + case WM_MOUSELEAVE: { + /* -1,-1 is the agreed "nothing is under the pointer" coordinate: the Java side + * turns it into pointerHover over no component, which clears the hover style. + * A real client coordinate is never negative, so the two cannot be confused. */ + cn1WinPushEvent(CN1_EVENT_POINTER_HOVER, -1, -1, cn1WinTouchFlag()); + return 0; + } #ifdef WM_GESTURE case WM_GESTURE: if (cn1WinHandleGesture(hwnd, 0, lParam)) { @@ -1264,3 +1310,51 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_parkMainThread___int( } /* extern "C" */ #endif /* _WIN32 */ + +/* ---------------------------------------------------------------- dark mode */ + +/* INSIDE extern "C", and that is not decoration. This file is C++ and wraps its whole + * body in an extern "C" block that closes above; a ParparVM native appended after it + * gets C++ name mangling, and the generated C calls the unmangled name. It compiles, and + * the LINKER fails: + * + * lld-link: error: undefined symbol: + * com_codename1_impl_windows_WindowsNative_systemUsesDarkTheme___R_boolean + * + * Note scripts/check-native-signatures.sh does NOT catch this. It verifies that the + * NAME matches the Java signature, which it did; linkage is a different property and the + * only thing that reports it is a real device build. */ +extern "C" { + +/* True when the user has chosen the dark app theme. + * + * AppsUseLightTheme under HKCU\...\Themes\Personalize is what the Settings app writes + * and what every Windows application reads. The name is the trap: it says "use LIGHT", + * so 0 is dark and 1 is light, and a MISSING value is light -- the key does not exist + * before Windows 10 1607, and reading a failure as "dark" would put every older system + * on a dark theme it cannot render. + * + * RegGetValueW rather than RegOpenKeyEx + RegQueryValueEx: it opens, queries, type + * checks and closes in one call, so there is no key handle to leak on an error path. + * + * The signature is ParparVM's and is checked by nothing at build time -- a wrong name + * compiles, links, and leaves the Java method looking unused to the dead-code pass, + * which then removes it. scripts/check-native-signatures.sh is what catches that. + */ +JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_systemUsesDarkTheme___R_boolean(CODENAME_ONE_THREAD_STATE) { + DWORD value = 1; + DWORD size = sizeof(value); + LSTATUS st = RegGetValueW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"AppsUseLightTheme", + RRF_RT_REG_DWORD, + NULL, + &value, + &size); + if (st != ERROR_SUCCESS) { + return JAVA_FALSE; + } + return value == 0 ? JAVA_TRUE : JAVA_FALSE; +} + +} /* extern "C" */ diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index f89825ffd32..c247bcfefc1 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -108,6 +108,10 @@ public boolean isScreenReaderEnabled() { /// gesture cannot inherit the previous one's zoom. private float pinchScale = 1f; + /// Pointer motion with no button held. Must match CN1_EVENT_POINTER_HOVER in + /// nativeSources/cn1_windows.h -- the two tables are the wire protocol and a + /// mismatch routes an event to the wrong handler rather than failing. + private static final int EVENT_POINTER_HOVER = 22; private static final int EVENT_PINCH_BEGIN = 20; private static final int EVENT_PINCH_END = 21; private static final int EVENT_ROTATE = 11; @@ -792,6 +796,16 @@ private void markPointer(int keyField) { setPointerEventMetadata(button, mask, type, 1f, 0, 0, 0, 0, false); } + void dispatchPointerHover(int windowId, int x, int y, int keyField) { + // Keep the native mouse/pen source, but hover has no contact pressure. + // markPointer resets the other fields so earlier contact metadata cannot leak in. + markPointer(keyField); + setPointerButton(com.codename1.ui.events.PointerEvent.BUTTON_NONE, 0); + setPointerPressure(0f); + setPointerHovering(true); + windowPointerHover(windowId, x, y); + } + private void drainInput() { while (WindowsNative.pollEvent(eventScratch)) { int type = eventScratch[0]; @@ -814,6 +828,14 @@ private void drainInput() { markPointer(key); windowPointerDragged(windowId, x, y); break; + case EVENT_POINTER_HOVER: + // Routed by window id rather than restricted to the main one. + // windowPointerHover hands a secondary window's event to that + // window's own Desktop instance, so a control in one reaches the + // hover state like any other; the earlier main-window-only guard + // made hover unreachable there. + dispatchPointerHover(windowId, x, y, key); + break; case EVENT_KEY_PRESSED: windowKeyPressed(windowId, key); break; @@ -3204,6 +3226,49 @@ public String getPlatformName() { return "win"; } + /// Windows is a desktop, always. + /// + /// CodenameOneImplementation.isDesktop() answers false, and this port never + /// overrode it, so a windows application was a mobile one as far as the framework + /// was concerned. That reached further than it looks: no `_desktop.ovr` resource + /// layer, no `device-desktop-` theme layer, no @defaultDesktopFontSizeInt, no + /// @desktopTitleBarMode, and the mobile branch of Button.pointerHover, + /// TextSelection and SplitPane. + @Override + public boolean isDesktop() { + return true; + } + + /// @inheritDoc + /// + /// Desktop layers. Matches the JavaSE desktop port and the macOS port + /// (`desktop`, `tablet`, ...) so one override written for the desktop covers all + /// three, with the port's own name last so a layer can name this port specifically. + /// + /// That last entry is `win`, spelled exactly as getPlatformName() reports it above: + /// Resources.openLayered appends the value literally as `_.ovr`, and the + /// resource editor writes the Windows override as `_win.ovr`. Spelling it `windows` here + /// asked for a file nothing produces, so the layer this method exists to load would never + /// have been found. The macOS and Linux ports pair the same two the same way. + @Override + public String[] getPlatformOverrides() { + return new String[] {"desktop", "tablet", "win"}; + } + + /// @inheritDoc + /// + /// CodenameOneImplementation.isDarkMode() answers false and this port never + /// overrode it, so every $Dark entry in a desktop theme was dead weight in the + /// .res: the Fluent theme's whole dark palette could never be selected. + /// + /// Returns Boolean rather than boolean because the contract distinguishes "the + /// platform does not know" (null) from "light" (FALSE), and callers such as + /// UIManager's dark-mode resolution treat the two differently. + @Override + public Boolean isDarkMode() { + return WindowsNative.systemUsesDarkTheme() ? Boolean.TRUE : Boolean.FALSE; + } + @Override public String getNativeLogSnapshot() { try { diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index d342018f022..922c189e201 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -42,6 +42,17 @@ private WindowsNative() { /** Writes a line to the native debug log (OutputDebugString + stderr). */ public static native void nativeLog(String message); + /** + * True when the user has chosen the dark app theme. + * + * Reads AppsUseLightTheme under + * HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize, which is + * the value the Settings app writes and the one every Windows application reads. + * Note the inversion: the value is "use LIGHT", so 0 means dark and a missing value + * means light, which is the pre-Windows-10 default. + */ + public static native boolean systemUsesDarkTheme(); + /* ------------------------------------------------------------- VideoIO */ /** True when the Media Foundation backend for VideoIO is available (MFStartup ok). */ public static native boolean videoBackendAvailable(); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 8c8981324b1..30112b23c17 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -13538,6 +13538,37 @@ void com_codename1_impl_ios_IOSNative_registerBundledFont___java_lang_String(CN1 POOL_END(); } +#if TARGET_OS_OSX +static NSFont *cn1MacSystemFontForAlias(NSString *name, CGFloat size) { + BOOL italic = [name hasPrefix:@"native:Italic"]; + NSString *weightName; + if (italic) { + weightName = [name substringFromIndex:[@"native:Italic" length]]; + } else if ([name hasPrefix:@"native:Main"]) { + weightName = [name substringFromIndex:[@"native:Main" length]]; + } else { + return nil; + } + CGFloat weight; + if ([weightName isEqualToString:@"Thin"]) { + weight = NSFontWeightThin; + } else if ([weightName isEqualToString:@"Light"]) { + weight = NSFontWeightLight; + } else if ([weightName isEqualToString:@"Regular"]) { + weight = NSFontWeightRegular; + } else if ([weightName isEqualToString:@"Bold"]) { + weight = NSFontWeightBold; + } else if ([weightName isEqualToString:@"Black"]) { + weight = NSFontWeightBlack; + } else { + return nil; + } + NSFont *font = [NSFont systemFontOfSize:size weight:weight]; + return italic ? [[NSFontManager sharedFontManager] convertFont:font + toHaveTrait:NSItalicFontMask] : font; +} +#endif + JAVA_LONG com_codename1_impl_ios_IOSNative_createTruetypeFont___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT name) { int pSize = 14; @@ -13545,8 +13576,13 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_createTruetypeFont___java_lang_String POOL_BEGIN(); NSString* str = toNSString(CN1_THREAD_STATE_PASS_ARG name); - CN1Font* fnt; - if(isIOS8_2() && [str hasPrefix:@"HelveticaNeue"]) { + CN1Font* fnt = nil; +#if TARGET_OS_OSX + // Native Mac aliases use AppKit weights, including regular and italic. + // Explicit font names continue through the existing shared loader. + fnt = cn1MacSystemFontForAlias(str, pSize); +#endif + if(fnt == nil && isIOS8_2() && [str hasPrefix:@"HelveticaNeue"]) { if([str isEqualToString:@"HelveticaNeue-UltraLight"]) { fnt = [CN1Font systemFontOfSize:pSize weight:UIFontWeightUltraLight]; } else { @@ -13569,7 +13605,7 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_createTruetypeFont___java_lang_String } } } - } else { + } else if (fnt == nil) { fnt = [CN1Font fontWithName:str size:pSize]; } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index cf2095476ea..553f8c0b71d 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -2877,6 +2877,10 @@ public boolean hasNativeTheme() { public static void setIosMode(String l) { iosMode = l; } + + protected String nativeThemeMode() { + return iosMode == null ? "auto" : iosMode.toLowerCase(); + } private static boolean waitForAnimationLock(Form f) { while (!f.grabAnimationLock()) { @@ -2899,10 +2903,41 @@ public void run() { * Installs the native theme, this is only applicable if hasNativeTheme() returned true. Notice that this method * might replace the DefaultLookAndFeel instance and the default transitions. */ + + /// The native theme resource a SUBCLASS wants for this theme mode, without the ".res", + /// or null to use the iOS chain. + /// + /// Exists because the macOS port extends this class. It ships MacOSAquaTheme.res, which + /// installNativeTheme() below knows nothing about, so before this hook a native Mac + /// application installed an iOS theme however its build hints were set -- an iPhone + /// design language on a desktop. + /// + /// Returning null is the iOS behaviour unchanged, which is what this class does. + protected String nativeThemeResourceName(String mode) { + return null; + } + public void installNativeTheme() { try { Resources r; - String mode = iosMode == null ? "auto" : iosMode.toLowerCase(); + String mode = nativeThemeMode(); + // A subclass may own a theme this class knows nothing about. The macOS port + // extends this one and ships Aqua, which is not in the list below; without the + // hook it inherited the iOS chain and installed an iPhone theme on a Mac. + // Returning null keeps the iOS behaviour exactly as it was. + String subclassTheme = nativeThemeResourceName(mode); + if (subclassTheme != null) { + InputStream sub = getResourceAsStream("/" + subclassTheme + ".res"); + if (sub != null) { + r = Resources.open(sub); + Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); + injectDesktopThemeConstants(tp); + UIManager.getInstance().setThemeProps(tp); + return; + } + // Not in the bundle (a framework build that has not generated it yet): + // fall through to the iOS chain so the application still boots. + } // Modern (liquid-glass) theme is opt-in via ios.themeMode=modern / // liquid / material. Keep the default ("auto" or unset) on the // legacy iOS 7 / pre-flat theme so existing apps and screenshot @@ -11023,7 +11058,7 @@ public boolean isNativeFontSchemeSupported() { - private String nativeFontName(String fontName) { + protected String nativeFontName(String fontName) { if(fontName != null && fontName.startsWith("native:")) { if("native:MainThin".equals(fontName)) { return "HelveticaNeue-UltraLight"; diff --git a/Themes/GnomeAdwaitaTheme.res b/Themes/GnomeAdwaitaTheme.res new file mode 100644 index 00000000000..1d6a46a5011 Binary files /dev/null and b/Themes/GnomeAdwaitaTheme.res differ diff --git a/Themes/MacOSAquaTheme.res b/Themes/MacOSAquaTheme.res new file mode 100644 index 00000000000..5905cc771e8 Binary files /dev/null and b/Themes/MacOSAquaTheme.res differ diff --git a/Themes/WindowsFluentTheme.res b/Themes/WindowsFluentTheme.res new file mode 100644 index 00000000000..a459f7f338e Binary files /dev/null and b/Themes/WindowsFluentTheme.res differ diff --git a/docs/demos/common/src/main/css/guide-snippets-theme.css b/docs/demos/common/src/main/css/guide-snippets-theme.css index b1b88f158ec..066d1f1ceec 100644 --- a/docs/demos/common/src/main/css/guide-snippets-theme.css +++ b/docs/demos/common/src/main/css/guide-snippets-theme.css @@ -834,3 +834,9 @@ Separator { border: none; } /* end::psd-theme[] */ + +/* tag::native-themes-css-hover[] */ +Button.hover { + background-color: #f6f6f6; +} +/* end::native-themes-css-hover[] */ diff --git a/docs/developer-guide/Native-Themes.asciidoc b/docs/developer-guide/Native-Themes.asciidoc index 4cda26fa6c8..d95bd3c188b 100644 --- a/docs/developer-guide/Native-Themes.asciidoc +++ b/docs/developer-guide/Native-Themes.asciidoc @@ -53,6 +53,79 @@ legacy alias `cn1.nativeTheme` is still honored for back-compat. The legacy `and.hololight=true` hint still works and maps to `and.themeMode=hololight`. +=== Desktop themes + +Windows, macOS and GNOME have their own native themes, selected with +`desktop.themeMode` on the JavaSE desktop build and with +`macos.themeMode` on the native macOS port. + +[cols="1,2,3", options="header"] +|=== +|Hint |Values |Description + +|`desktop.themeMode` +|`legacy` (default) + +`auto` / `native` + +`fluent` / `aqua` / `adwaita` + +`custom` +|Unset is `legacy`: an existing desktop project keeps the theme it has +always loaded, so upgrading the framework doesn't restyle an application +nobody asked to restyle. `auto` and `native` opt in and both mean +"whatever this machine is" -- Windows 11 Fluent, macOS Aqua or GNOME +Adwaita -- which is the only sensible reading on desktop, where one binary +runs on all three. Naming a theme outright is what a build that wants one +look everywhere asks for. `custom` installs no framework theme so your own +`theme.css` is the only one. + +|`macos.themeMode` +|`modern` (default) + +`aqua` / `native` + +`liquid` + +`ios7` / `flat` +|The native macOS port. `aqua` selects the macOS theme; `modern` / `liquid` +is the iOS Liquid Glass theme, which is what the port shipped before an Aqua +theme existed and what it still installs unless asked otherwise. The default +moves to `aqua` in the change that reseeds this port's screenshot baselines, +which is kept separate from the one that introduces the theme. +|=== + +The three desktop themes are generated from `native-themes/windows-fluent`, +`native-themes/macos-aqua` and `native-themes/gnome-adwaita`. They're +separate files rather than one file with per-platform blocks, and they're +held to the same surface by `DesktopNativeThemeParityTest`: the same UIIDs, +the same `#Constants`, and a `$Dark` counterpart for everything that paints +a color. That matters to you and not only to the themes -- an application +retunes a parent theme by redeclaring `#Constants`, so one vocabulary means +the same CSS retunes all three platforms rather than only the one it was +written against. + +==== Hover + +Desktop themes carry a state mobile ones have no use for. A `.hover` rule +styles a component while the pointer is over it: + +[source,css] +---- +include::../demos/common/src/main/css/guide-snippets-theme.css[tag=native-themes-css-hover,indent=0] +---- + +It compiles to `hover#` entries the same way `.pressed` compiles to +`press#`, and `Component.getHoverStyle()` returns `null` when the theme +declares none -- so a component with no hover rule keeps its normal style +rather than falling back to a blank one. + +Hover has no meaning on a touch device and isn't delivered there. On +desktop it's driven by real pointer motion: the JavaSE, Windows and Linux +ports all report motion with no button held, which is what +`Form.pointerHover` resolves against. + +NOTE: macOS is the exception, and the reason belongs to AppKit rather than +to Codename One. AppKit draws no rollover state for buttons, fields, +sliders, switches or popup buttons, so the Aqua theme leaves hover equal to +normal. The +captured native reference says the same, and the fidelity gate holds it +there. + === Light and dark mode Both modern themes ship a dark variant via a diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/src/desktop/java/__mainName__Stub.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/src/desktop/java/__mainName__Stub.java index a0e97efb59b..593df14f890 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/src/desktop/java/__mainName__Stub.java +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/src/desktop/java/__mainName__Stub.java @@ -62,10 +62,6 @@ public class ${mainName}Stub implements Runnable, WindowListener { public static final String BUILD_KEY = ""; public static final String PACKAGE_NAME = ""; public static final String BUILT_BY_USER = ""; - private static final boolean isWindows; - static { - isWindows = File.separatorChar == '\\'; - } private static final String[] fontFaces = null; @@ -109,18 +105,11 @@ public static void main(String[] args) { JavaSEPort.setDesktopTitleBarMode(APP_DESKTOP_TITLEBAR); JavaSEPort.setDesktopInteractiveScrollbars(APP_DESKTOP_INTERACTIVE_SCROLLBARS); + // Keep JavaSEPort's platform system font unless the app explicitly overrides it. if(fontFaces != null) { JavaSEPort.setFontFaces(fontFaces[0], fontFaces[1], fontFaces[2]); - } else { - // workaround for a bug in Windows where Arials unicode version isn't used - if(isWindows) { - JavaSEPort.setFontFaces("ArialUnicodeMS", "SansSerif", "Monospaced"); - } else { - JavaSEPort.setFontFaces("Arial", "SansSerif", "Monospaced"); - } } - frm = new JFrame(APP_TITLE); Toolkit tk = Toolkit.getDefaultToolkit(); JavaSEPort.setDefaultPixelMilliRatio(tk.getScreenResolution() / 25.4 * JavaSEPort.getRetinaScale()); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 557899627b3..476ebdfa1ac 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -91,8 +91,24 @@ public class AndroidGradleBuilder extends Executor { private static final String DESUGAR_JDK_LIBS_VERSION = "2.1.5"; private static final String GRADLE_8_DISTRIBUTION_URL = "https://services.gradle.org/distributions/gradle-" + GRADLE_8_VERSION + "-bin.zip"; - private static final int GRADLE_DOWNLOAD_ATTEMPTS = 3; - private static final long GRADLE_DOWNLOAD_RETRY_DELAY_MS = 2000L; + // Four attempts with a GROWING wait -- 10s, 40s, 160s -- rather than three a couple + // of seconds apart. + // + // The distribution is served by GitHub releases, and what fails there is an outage + // with a duration, not a blip: an Android job died on three HTTP 500s inside six + // seconds, then the same URL served a range request perfectly a few minutes later. + // Three closely spaced attempts all land inside the same outage window, so they cost + // the runner six seconds and buy nothing -- and then a sixteen-minute job is thrown + // away over a transient upstream error. This is the lesson scripts/ci/retry.sh + // already records for Maven Central 403s, applied to the other download this build + // cannot proceed without. + // + // The worst case adds about three and a half minutes before the build gives up, + // which is cheap next to the job it saves and next to a developer re-running it. + private static final int GRADLE_DOWNLOAD_ATTEMPTS = 4; + private static final long GRADLE_DOWNLOAD_RETRY_DELAY_MS = 10000L; + private static final long GRADLE_DOWNLOAD_RETRY_DELAY_FACTOR = 4L; + private static final long GRADLE_DOWNLOAD_MAX_RETRY_DELAY_MS = 180000L; private static final int GRADLE_DOWNLOAD_CONNECT_TIMEOUT_MS = 30000; private static final int GRADLE_DOWNLOAD_READ_TIMEOUT_MS = 300000; @@ -10216,6 +10232,7 @@ public void extract(InputStream source, File dir, String sdkPath) throws IOExcep private void downloadGradleDistribution(File gradleZip) throws BuildException { File partialGradleZip = new File(gradleZip.getAbsolutePath() + ".part"); Exception lastFailure = null; + long retryDelayMs = GRADLE_DOWNLOAD_RETRY_DELAY_MS; for (int attempt = 1; attempt <= GRADLE_DOWNLOAD_ATTEMPTS; attempt++) { if (partialGradleZip.exists() && !partialGradleZip.delete()) { throw new BuildException("Failed to remove partial gradle distribution at " + partialGradleZip); @@ -10239,13 +10256,16 @@ private void downloadGradleDistribution(File gradleZip) throws BuildException { gradleZip.deleteOnExit(); } if (attempt < GRADLE_DOWNLOAD_ATTEMPTS) { - log("Gradle distribution download failed: " + ex.getMessage() + ". Retrying..."); + log("Gradle distribution download failed: " + ex.getMessage() + + ". Retrying in " + (retryDelayMs / 1000L) + "s..."); try { - Thread.sleep(GRADLE_DOWNLOAD_RETRY_DELAY_MS * attempt); + Thread.sleep(retryDelayMs); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new BuildException("Interrupted while retrying gradle distribution download", interrupted); } + retryDelayMs = Math.min(retryDelayMs * GRADLE_DOWNLOAD_RETRY_DELAY_FACTOR, + GRADLE_DOWNLOAD_MAX_RETRY_DELAY_MS); } } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index d99481fe23a..426157d1934 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -2207,7 +2207,14 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException request.getArg("cn1.nativeTheme", null)); if ("legacy".equalsIgnoreCase(sharedMode)) { iosMode = "ios7"; - } else if ("modern".equalsIgnoreCase(sharedMode)) { + } else if ("modern".equalsIgnoreCase(sharedMode) + || "native".equalsIgnoreCase(sharedMode)) { + // "native" is "modern plus the desktop". The desktop half is the + // JavaSE port's to resolve; iOS's own answer to "the platform's own + // look" is the same theme either way. Without this arm it fell to the + // else below and iOS got "auto", which installNativeTheme() resolves + // to the FLAT iOS 7 theme -- the exact opposite of what was asked for, + // and silently, because an unrecognised mode is not an error here. iosMode = "modern"; } else { iosMode = "auto"; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSBuildHints.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSBuildHints.java index 16f04452e41..fd94443d17a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSBuildHints.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSBuildHints.java @@ -726,8 +726,29 @@ public String getBundleVersion(String applicationVersion) { public String getThemeMode() { String mode = hint(source, "themeMode", null); if (mode == null) { + // Aqua by default on macOS, which is where this target parts company with iOS. + // iOS keeps its legacy theme so applications already shipped, and their + // screenshot baselines, keep rendering as before; this port has no such + // history with an Aqua theme, and defaulting to "modern" put an iPhone design + // language on a desktop. The cross-platform `nativeTheme` hint is still + // honoured, with `legacy` mapping to ios7. String shared = source.get("nativeTheme", source.get("cn1.nativeTheme", null)); - mode = "legacy".equalsIgnoreCase(shared) ? "ios7" : "modern"; + if ("legacy".equalsIgnoreCase(shared)) { + mode = "ios7"; + } else if (shared != null) { + // An explicit cross-platform request is honoured as written. Only the + // UNSET case changes to aqua: someone who asked for `nativeTheme=modern` + // asked for the modern iOS look and still gets it. + mode = shared; + } else { + // NOT aqua yet, and that is the same sequencing as the Windows and Linux + // poms: making Aqua the default restyles every screen and reseeds this + // port's committed screenshot baselines, which deserves its own review and + // wants doing once, after the theme reaches its fidelity target. Until + // then macos.themeMode=aqua selects it explicitly, which is what the + // whitelist above is for and what the review asked for. + mode = "modern"; + } } // Interpolated into generated Java source, so it is constrained to the // vocabulary the runtime understands rather than passed through. A hint @@ -738,11 +759,17 @@ public String getThemeMode() { return THEME_MODES[iter]; } } + // A value the whitelist rejects behaves as if the hint were unset. return "modern"; } /// Every value IOSImplementation.installNativeTheme() acts on. private static final String[] THEME_MODES = { + // aqua / native select the macOS theme. Without them in this list the documented + // macos.themeMode=aqua was sanitized to "modern" and reached + // MacImplementation.nativeThemeResourceName() as a value it answers null for, so + // the Aqua theme could not be selected at all -- by a hint or by default. + "aqua", "native", "modern", "liquid", "material", "ios7", "flat", "auto", }; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java index 5a349e9351f..382e60af171 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java @@ -34,6 +34,8 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.util.Properties; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -63,10 +65,27 @@ public class GenerateDesktopAppWrapperMojo extends AbstractCN1Mojo { @Override protected void executeImpl() throws MojoExecutionException, MojoFailureException { generateIcons(); + generateThemeConfiguration(); generateStub(); registerCustomStubSourceRoot(); } + // Written even when a custom/archetype stub suppresses source generation. Packaged apps + // have no source settings file; JavaSEPort reads this beside the bundled NativeTheme.res. + void generateThemeConfiguration() throws MojoExecutionException { + Properties theme = new Properties(); + theme.setProperty("desktop.themeMode", arg("desktop.themeMode", sharedThemeModeDefault())); + File output = new File(project.getBuild().getOutputDirectory(), "codenameone-desktop.properties"); + try { + Files.createDirectories(output.toPath().getParent()); + try (OutputStream stream = Files.newOutputStream(output.toPath())) { + theme.store(stream, "Packaged desktop theme selection"); + } + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write desktop theme configuration", ex); + } + } + private void generateIcons() throws MojoExecutionException { String iconPath = properties.getProperty("codename1.icon"); if (iconPath == null) { @@ -198,6 +217,23 @@ private String sanitizeTitleBarMode(String value) { return "native"; } + /// What `desktop.themeMode` resolves to when the project does not set it. + /// + /// The cross-platform `nativeTheme` hint selects the mobile themes, and only its + /// `native` value also asks for the desktop one -- `modern` deliberately does not, + /// because it shipped years before the desktop themes existed and an application + /// that set it for its phone builds never asked for its desktop screens to move. + /// Every other value, and no value at all, leaves the desktop on what it has always + /// had. + /// + /// Resolved here rather than at runtime because this file IS the packaged answer: + /// a packaged desktop app has no codenameone_settings.properties to read the shared + /// hint back out of. + private String sharedThemeModeDefault() { + String shared = arg("nativeTheme", arg("cn1.nativeTheme", null)); + return "native".equalsIgnoreCase(shared) ? "native" : "legacy"; + } + private String arg(String name, String defaultValue) { String v = properties.getProperty("codename1.arg." + name); return (v == null || v.isEmpty()) ? defaultValue : v; diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/desktop-app-stub-template.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/desktop-app-stub-template.java index d8f4619a17b..a58238fd89a 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/desktop-app-stub-template.java +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/desktop-app-stub-template.java @@ -58,10 +58,6 @@ public class __MAIN_NAME__Stub implements Runnable, WindowListener { public static final String BUILD_KEY = ""; public static final String PACKAGE_NAME = ""; public static final String BUILT_BY_USER = ""; - private static final boolean isWindows; - static { - isWindows = File.separatorChar == '\\'; - } private static final String[] fontFaces = null; @@ -101,14 +97,9 @@ public static void main(String[] args) { JavaSEPort.setDesktopTitleBarMode(APP_DESKTOP_TITLEBAR); JavaSEPort.setDesktopInteractiveScrollbars(APP_DESKTOP_INTERACTIVE_SCROLLBARS); + // Keep JavaSEPort's platform system font unless the app explicitly overrides it. if(fontFaces != null) { JavaSEPort.setFontFaces(fontFaces[0], fontFaces[1], fontFaces[2]); - } else { - if(isWindows) { - JavaSEPort.setFontFaces("ArialUnicodeMS", "SansSerif", "Monospaced"); - } else { - JavaSEPort.setFontFaces("Arial", "SansSerif", "Monospaced"); - } } frm = new JFrame(APP_TITLE); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java index b2f762fe270..8aea9e40c11 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java @@ -601,6 +601,10 @@ public void theBuildNumberHonoursTheLegacySpellingBetweenTheOtherTwo() { @Test public void theNativeThemeDefaultsToModernAndIsConstrainedToKnownModes() { assertEquals("modern", parse(raw(), "p").getThemeMode()); + // aqua and native have to survive the whitelist or the hint cannot select the + // theme it names. + assertEquals("aqua", parse(raw("macos.themeMode", "aqua"), "p").getThemeMode()); + assertEquals("native", parse(raw("macos.themeMode", "native"), "p").getThemeMode()); assertEquals("ios7", parse(raw("macos.themeMode", "ios7"), "p").getThemeMode()); assertEquals("ios7", parse(raw("macNative.themeMode", "ios7"), "p").getThemeMode()); @@ -609,6 +613,11 @@ public void theNativeThemeDefaultsToModernAndIsConstrainedToKnownModes() { assertEquals("ios7", parse(raw("nativeTheme", "legacy"), "p").getThemeMode()); assertEquals("modern", parse(raw("nativeTheme", "modern"), "p").getThemeMode()); assertEquals("ios7", parse(raw("cn1.nativeTheme", "legacy"), "p").getThemeMode()); + // "native" means the platform's own look on every OS. On macOS that is Aqua, + // and it reaches MacImplementation.nativeThemeResourceName() only if the + // whitelist above lets it through unchanged. + assertEquals("native", parse(raw("nativeTheme", "native"), "p").getThemeMode()); + assertEquals("native", parse(raw("cn1.nativeTheme", "native"), "p").getThemeMode()); // The value is interpolated into generated Java source, so it is // constrained to what the runtime understands rather than passed diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateDesktopAppWrapperMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateDesktopAppWrapperMojoTest.java index 157ce6ce1b5..68b57ea857e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateDesktopAppWrapperMojoTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateDesktopAppWrapperMojoTest.java @@ -23,12 +23,17 @@ package com.codename1.maven; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.apache.maven.project.MavenProject; +import java.nio.file.Path; +import java.nio.file.Files; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Properties; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -100,4 +105,89 @@ void invalidTitleBarFallsBackToNative() throws Exception { assertTrue(src.contains("private static final String APP_DESKTOP_TITLEBAR = \"native\";"), "an invalid titleBar hint must fall back to native"); } + @Test + void packagesThemeHintEvenWithAnArchetypeStub(@TempDir Path root) throws Exception { + GenerateDesktopAppWrapperMojo mojo = new GenerateDesktopAppWrapperMojo(); + mojo.project = new MavenProject(); + mojo.project.setFile(root.resolve("pom.xml").toFile()); + mojo.project.getBuild().setDirectory(root.resolve("target").toString()); + mojo.project.getBuild().setOutputDirectory(root.resolve("target/classes").toString()); + mojo.properties = new Properties(); + mojo.properties.setProperty("codename1.packageName", "com.example"); + mojo.properties.setProperty("codename1.mainName", "MyApp"); + Path customStub = root.resolve("src/desktop/java/com/example/MyAppStub.java"); + Files.createDirectories(customStub.getParent()); + Files.write(customStub, new byte[0]); + for (String mode : new String[]{"auto", "fluent", "aqua", "adwaita", "legacy", "custom"}) { + mojo.properties.setProperty("codename1.arg.desktop.themeMode", mode); + mojo.executeImpl(); + Properties packaged = new Properties(); + try (InputStream in = Files.newInputStream(root.resolve("target/classes/codenameone-desktop.properties"))) { + packaged.load(in); + } + assertEquals(mode, packaged.getProperty("desktop.themeMode")); + assertEquals(1, packaged.size(), "do not package unrelated build hints or credentials"); + } + assertFalse(Files.exists(root.resolve("target/generated-sources/cn1-desktop/com/example/MyAppStub.java")), + "the custom stub remains the source override"); + } + + @Test + void mobileThemeHintsDoNotChangeThePackagedDesktopDefault(@TempDir Path root) throws Exception { + GenerateDesktopAppWrapperMojo mojo = new GenerateDesktopAppWrapperMojo(); + mojo.project = new MavenProject(); + mojo.project.getBuild().setOutputDirectory(root.toString()); + mojo.properties = new Properties(); + for (String hint : new String[]{"nativeTheme", "cn1.nativeTheme"}) { + for (String mode : new String[]{"modern", "custom", "legacy"}) { + mojo.properties.clear(); + mojo.properties.setProperty("codename1.arg." + hint, mode); + mojo.generateThemeConfiguration(); + Properties packaged = new Properties(); + try (InputStream in = Files.newInputStream(root.resolve("codenameone-desktop.properties"))) { + packaged.load(in); + } + assertEquals("legacy", packaged.getProperty("desktop.themeMode"), hint + "=" + mode); + } + } + } + + // The exception to the test above, and the only one: "native" means the platform's + // own look on every OS, so the packaged desktop answer follows it. Resolved here + // rather than at runtime because a packaged app has no settings file to read the + // shared hint back out of -- this properties file IS the answer. + @Test + void theSharedNativeHintDoesChangeThePackagedDesktopDefault(@TempDir Path root) throws Exception { + GenerateDesktopAppWrapperMojo mojo = new GenerateDesktopAppWrapperMojo(); + mojo.project = new MavenProject(); + mojo.project.getBuild().setOutputDirectory(root.toString()); + mojo.properties = new Properties(); + for (String hint : new String[]{"nativeTheme", "cn1.nativeTheme"}) { + mojo.properties.clear(); + mojo.properties.setProperty("codename1.arg." + hint, "native"); + mojo.generateThemeConfiguration(); + Properties packaged = new Properties(); + try (InputStream in = Files.newInputStream(root.resolve("codenameone-desktop.properties"))) { + packaged.load(in); + } + assertEquals("native", packaged.getProperty("desktop.themeMode"), hint); + + // An explicit desktop hint outranks it. + mojo.properties.setProperty("codename1.arg.desktop.themeMode", "legacy"); + mojo.generateThemeConfiguration(); + try (InputStream in = Files.newInputStream(root.resolve("codenameone-desktop.properties"))) { + packaged.clear(); + packaged.load(in); + } + assertEquals("legacy", packaged.getProperty("desktop.themeMode"), hint); + } + } + + @Test + void defaultWrapperPreservesPlatformFontsAndExplicitOverrides() throws Exception { + String source = render(null, null); + assertFalse(source.contains("setFontFaces(\"Arial")); + assertTrue(source.contains("JavaSEPort.setFontFaces(fontFaces[0], fontFaces[1], fontFaces[2])")); + } + } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/ComponentGroupSegmentedTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/ComponentGroupSegmentedTest.java index 4c8fd147954..e725a737433 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/ComponentGroupSegmentedTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/ComponentGroupSegmentedTest.java @@ -24,6 +24,7 @@ package com.codename1.ui; import com.codename1.junit.UITestBase; +import com.codename1.junit.FormTest; import com.codename1.ui.animations.ComponentAnimation; import com.codename1.ui.layouts.BoxLayout; import com.codename1.ui.plaf.UIManager; @@ -365,7 +366,8 @@ void testTheSpinnerSnapshotDoesNotSurviveIntoTheNextMembership() { + "snapshot the first membership took"); } - @Test + // A shown form drains its animation queue on the EDT; mutate/flush it there too. + @FormTest void testRemovalDuringAnAnimationStillRestoresTheMember() { // Container.removeComponentImpl queues the physical removal while the // AnimationManager is animating and leaves the child in the component list, so a @@ -402,7 +404,8 @@ protected void updateState() { "and its UIID with it"); } - @Test + // A shown form drains its animation queue on the EDT; mutate/flush it there too. + @FormTest void testSurvivorsAreRegroupedAfterAnAnimatedRemovalCompletes() { // removeComponentImpl runs while the AnimationManager still has the departing // member queued, so positional UIIDs recomputed there count it and the survivor @@ -462,7 +465,8 @@ void testReplacingAMemberHandsTheOutgoingOneItsUiidBack() { + "insertComponentAt"); } - @Test + // A shown form drains its animation queue on the EDT; mutate/flush it there too. + @FormTest void testAnInsertionDuringAnAnimationIsGroupedWhenItCompletes() { // insertComponentAt only queues the insertion while the AnimationManager is // animating, so recomputing positions there does not count the arriving member diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/HoverDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/HoverDeliveryTest.java new file mode 100644 index 00000000000..ed3ec0a3d2d --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/HoverDeliveryTest.java @@ -0,0 +1,944 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.events.PointerEvent; +import com.codename1.ui.plaf.UIManager; + +import java.util.Hashtable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertSame; + +/// Hover DELIVERY: what actually happens when a port reports a pointer position. +/// +/// `ComponentHoverStyleTest` covers the style resolution with the flag set by hand. The two +/// cases here are the ones that looked right in that test and still rendered nothing on a +/// real desktop, because the flag was being put somewhere `getStyle()` does not read, or +/// nowhere at all. +class HoverDeliveryTest extends UITestBase { + + @FormTest + void hoverOnlyPainterReleasesItsOwnAnimationRegistration() { + class AnimatedPainter implements Painter, com.codename1.ui.animations.Animation { + int ticks; + public void paint(Graphics g, com.codename1.ui.geom.Rectangle rect) { } + public void paint(Graphics g) { } + // An idle frame must not deregister an animation with later frames. + public boolean animate() { ticks++; return ticks % 2 == 0; } + } + class AnimatedImage extends Image { + int ticks; + AnimatedImage() { super(Image.createImage(2, 2).getImage()); } + public boolean isAnimation() { return true; } + public boolean animate() { ticks++; return ticks % 2 == 0; } + } + implementation.setDesktop(true); + implementation.setMultiWindowSupported(true); + for (boolean secondary : new boolean[]{false, true}) { + Form form = new Form("hover animation", new BorderLayout()); + form.show(); + Window window = secondary ? new Window("hover animation", new BorderLayout()) : null; + Container root = secondary ? window : form; + TopLevelContainer host = secondary ? window : form; + Label component = new Label("animated hover"); + root.add(BorderLayout.CENTER, component); + if (secondary) { + window.show(); + } + DisplayTest.flushEdt(); + try { + AnimatedPainter painter = new AnimatedPainter(); + AnimatedImage image = new AnimatedImage(); + com.codename1.ui.plaf.Style hover = new com.codename1.ui.plaf.Style(component.getUnselectedStyle()); + hover.setBgPainter(painter); + hover.setBgImage(image); + component.setHoverStyle(hover); + assertFalse(secondary ? window.hasAnimations() : form.hasAnimations()); + component.setHovered(true); + assertTrue(secondary ? window.hasAnimations() : form.hasAnimations()); + int before = painter.ticks; + for (int i = 0; i < 3; i++) { + if (secondary) { window.repaintAnimations(); } else { form.repaintAnimations(); } + } + assertEquals(before + 3, painter.ticks, "idle frames must retain the hover registration"); + assertEquals(painter.ticks, image.ticks, "image and painter each advance once per frame"); + component.setHovered(false); + assertFalse(secondary ? window.hasAnimations() : form.hasAnimations(), + "hover exit must let an otherwise idle top level sleep"); + + // A public registration remains owned by the application, whether made + // before or after hover starts. Its background still advances only once. + for (boolean registerFirst : new boolean[]{true, false}) { + if (registerFirst) { host.registerAnimated(component); } + component.setHovered(true); + if (!registerFirst) { host.registerAnimated(component); } + before = painter.ticks; + if (secondary) { window.repaintAnimations(); } else { form.repaintAnimations(); } + assertEquals(before + 1, painter.ticks); + assertEquals(painter.ticks, image.ticks); + component.setHovered(false); + assertTrue(secondary ? window.hasAnimations() : form.hasAnimations()); + host.deregisterAnimated(component); + assertFalse(secondary ? window.hasAnimations() : form.hasAnimations()); + } + component.setHovered(true); + root.removeComponent(component); + assertFalse(secondary ? window.hasAnimations() : form.hasAnimations(), + "teardown must remove the hover animation from its original owner"); + } finally { + if (secondary) { window.dispose(); } + } + } + } + + private static class CountingHoverPainter implements Painter, com.codename1.ui.animations.Animation { + int ticks; + public void paint(Graphics g, com.codename1.ui.geom.Rectangle rect) { } + public void paint(Graphics g) { } + public boolean animate() { ticks++; return true; } + } + + @FormTest + void leadParentsAndSiblingsAnimateTheirEffectiveHoverStyles() { + implementation.setMultiWindowSupported(true); + for (boolean secondary : new boolean[]{false, true}) { + Form form = new Form("lead animation", new BorderLayout()); + form.show(); + Window window = secondary ? new Window("lead animation", new BorderLayout()) : null; + Container surface = secondary ? window : form; + Container row = new Container(new BorderLayout()); + Button lead = new Button("lead"); + Label sibling = new Label("sibling"); + row.add(BorderLayout.WEST, lead).add(BorderLayout.CENTER, sibling); + surface.add(BorderLayout.CENTER, row); + if (secondary) { window.setWindowSize(500, 400); window.show(); } + surface.revalidate(); + DisplayTest.flushEdt(); + row.setLeadComponent(lead); + CountingHoverPainter parentPainter = new CountingHoverPainter(); + CountingHoverPainter childPainter = new CountingHoverPainter(); + com.codename1.ui.plaf.Style parentHover = new com.codename1.ui.plaf.Style(row.getUnselectedStyle()); + parentHover.setBgPainter(parentPainter); + row.setHoverStyle(parentHover); + com.codename1.ui.plaf.Style childHover = new com.codename1.ui.plaf.Style(sibling.getUnselectedStyle()); + childHover.setBgPainter(childPainter); + sibling.setHoverStyle(childHover); + try { + surface.pointerHover(new int[]{sibling.getAbsoluteX() + sibling.getWidth() / 2}, + new int[]{sibling.getAbsoluteY() + sibling.getHeight() / 2}); + assertTrue(lead.isHovered()); + assertFalse(row.isHovered(), "only the lead owns the pointer flag"); + for (int transition = 0; transition < 3; transition++) { + int parentBefore = parentPainter.ticks; + int childBefore = childPainter.ticks; + if (secondary) { window.repaintAnimations(); } else { form.repaintAnimations(); } + assertEquals(parentBefore + 1, parentPainter.ticks); + assertEquals(childBefore + 1, childPainter.ticks); + lead.setState(Button.STATE_PRESSED); + assertFalse(secondary ? window.hasAnimations() : form.hasAnimations()); + lead.setState(Button.STATE_DEFAULT); + assertTrue(secondary ? window.hasAnimations() : form.hasAnimations()); + } + surface.pointerHover(new int[]{-1}, new int[]{-1}); + assertFalse(secondary ? window.hasAnimations() : form.hasAnimations()); + } finally { + if (secondary) { window.dispose(); } + } + } + } + + @FormTest + void stationaryPointerFollowsLeadTopologyChanges() { + implementation.setMultiWindowSupported(true); + for (boolean secondary : new boolean[]{false, true}) { + Form form = new Form("lead changes", new BorderLayout()); + form.show(); + Window window = secondary ? new Window("lead changes", new BorderLayout()) : null; + Container surface = secondary ? window : form; + Container row = new Container(new BorderLayout()); + Button first = new Button("first"); + Button second = new Button("second"); + Label target = new Label("pointer target"); + row.add(BorderLayout.WEST, first).add(BorderLayout.EAST, second).add(BorderLayout.CENTER, target); + surface.add(BorderLayout.CENTER, row); + if (secondary) { window.setWindowSize(500, 400); window.show(); } + surface.revalidate(); + DisplayTest.flushEdt(); + CountingHoverPainter parentPainter = new CountingHoverPainter(); + CountingHoverPainter targetPainter = new CountingHoverPainter(); + com.codename1.ui.plaf.Style rowHover = new com.codename1.ui.plaf.Style(row.getUnselectedStyle()); + rowHover.setBgPainter(parentPainter); + row.setHoverStyle(rowHover); + com.codename1.ui.plaf.Style targetHover = new com.codename1.ui.plaf.Style(target.getUnselectedStyle()); + targetHover.setBgPainter(targetPainter); + target.setHoverStyle(targetHover); + try { + surface.pointerHover(new int[]{target.getAbsoluteX() + target.getWidth() / 2}, + new int[]{target.getAbsoluteY() + target.getHeight() / 2}); + Runnable[] changes = new Runnable[]{() -> { }, () -> row.setLeadComponent(first), + () -> row.setLeadComponent(second), () -> { + // A standalone child must remain hittable inside a focusable lead row. + target.setFocusable(true); + target.setBlockLead(true); + }, + () -> target.setBlockLead(false), () -> row.setLeadComponent(null)}; + Component[] owners = new Component[]{target, first, second, target, second, target}; + for (int i = 0; i < changes.length; i++) { + changes[i].run(); + assertEquals(owners[i] == target, target.isHovered(), "transition " + i + " secondary=" + secondary); + assertEquals(owners[i] == first, first.isHovered()); + assertEquals(owners[i] == second, second.isHovered()); + assertSame(targetHover, target.getStyle()); + boolean rowHovered = owners[i] != target; + assertSame(rowHovered ? rowHover : row.getUnselectedStyle(), row.getStyle()); + int parentBefore = parentPainter.ticks; + int targetBefore = targetPainter.ticks; + if (secondary) { window.repaintAnimations(); } else { form.repaintAnimations(); } + assertEquals(parentBefore + (rowHovered ? 1 : 0), parentPainter.ticks); + assertEquals(targetBefore + 1, targetPainter.ticks); + } + surface.pointerHover(new int[]{-1}, new int[]{-1}); + assertFalse(target.isHovered()); + assertFalse(first.isHovered()); + assertFalse(second.isHovered()); + assertFalse(secondary ? window.hasAnimations() : form.hasAnimations()); + } finally { + if (secondary) { window.dispose(); } + } + } + } + + private static class CountingHoverImage extends Image { + int ticks; + CountingHoverImage() { super(Image.createImage(2, 2).getImage()); } + public boolean isAnimation() { return true; } + public boolean animate() { ticks++; return true; } + } + + @FormTest + void lazyHoverStyleReplacementStartsAnimationWithoutPointerReentry() { + Form form = new Form("replacement", new BorderLayout()); + Label label = new Label("hover"); + form.add(BorderLayout.CENTER, label); + form.show(); + DisplayTest.flushEdt(); + UIManager manager = UIManager.getInstance(); + Hashtable theme = new Hashtable(); + theme.put("StaticHover.hover#bgColor", "123456"); + CountingHoverImage painter = new CountingHoverImage(); + manager.addThemeProps(theme); + com.codename1.ui.plaf.Style animatedStyle = new com.codename1.ui.plaf.Style(); + animatedStyle.setBgImage(painter); + manager.setComponentStyle("AnimatedHover", animatedStyle, "hover"); + label.setUIID("StaticHover"); + label.setHovered(true); + assertFalse(form.hasAnimations()); + label.setUIID("AnimatedHover"); + assertSame(painter, label.getStyle().getBgImage()); + assertTrue(form.hasAnimations(), "lazy UIID resolution must register the replacement"); + int before = painter.ticks; + form.repaintAnimations(); + assertEquals(before + 1, painter.ticks, "replacement must not also register the Component"); + label.setUIID("StaticHover"); + label.getStyle(); + assertFalse(form.hasAnimations()); + // Invalidation by inline setters takes the same lazy path, even when no inline + // Resources are installed and the replacement still comes from the theme. + CountingHoverImage replacement = new CountingHoverImage(); + com.codename1.ui.plaf.Style installed = new com.codename1.ui.plaf.Style(); + installed.setBgImage(replacement); + manager.setComponentStyle("StaticHover", installed, "hover"); + label.setInlineAllStyles("fgColor:abcdef;"); + assertSame(replacement, label.getStyle().getBgImage()); + assertTrue(form.hasAnimations()); + form.repaintAnimations(); + assertEquals(1, replacement.ticks); + label.setHovered(false); + assertFalse(form.hasAnimations()); + } + + @FormTest + void hoverAnimationResumesAfterTemporaryStyleChanges() { + class AnimatedPainter implements Painter, com.codename1.ui.animations.Animation { + int ticks; + public void paint(Graphics g, com.codename1.ui.geom.Rectangle rect) { } + public void paint(Graphics g) { } + public boolean animate() { ticks++; return true; } + } + Form form = new Form("hover state changes", new BorderLayout()); + Button button = new Button("animated hover"); + form.add(BorderLayout.CENTER, button); + form.show(); + DisplayTest.flushEdt(); + AnimatedPainter painter = new AnimatedPainter(); + com.codename1.ui.plaf.Style hover = new com.codename1.ui.plaf.Style(button.getUnselectedStyle()); + hover.setBgPainter(painter); + button.setHoverStyle(hover); + button.setHovered(true); + for (int change = 0; change < 3; change++) { + if (change == 0) { button.setEnabled(false); } + if (change == 1) { button.setVisible(false); } + if (change == 2) { button.setState(Button.STATE_PRESSED); } + assertFalse(form.hasAnimations(), "an inactive hover style must release its registration, change " + change); + int before = painter.ticks; + form.repaintAnimations(); + assertEquals(before, painter.ticks); + if (change == 0) { button.setEnabled(true); } + if (change == 1) { + button.setVisible(true); + assertFalse(button.isHovered(), "showing a hidden component cannot restore stale hover"); + assertFalse(form.hasAnimations()); + hoverForm(form, button); + } + if (change == 2) { button.setState(Button.STATE_DEFAULT); } + assertTrue(form.hasAnimations(), "restoring the active hover style must resume animation"); + form.repaintAnimations(); + assertEquals(before + 1, painter.ticks); + } + button.setHovered(false); + assertFalse(form.hasAnimations()); + } + + /// setDesktop is global to the implementation, so a test that turns it on has to put it + /// back or every later test in the run inherits a desktop it did not ask for. + @org.junit.jupiter.api.AfterEach + void restoreDesktopFlag() { + implementation.setDesktop(false); + implementation.resetPointerEventMetadata(); + } + + /// Theme with a hover colour on the row UIID and on a plain button. + /// + /// Installed AFTER the surface is shown and followed by a refresh: showing loads the + /// default theme, and a component caches the style it already built, so props set before + /// show are overwritten and props set after show are not picked up without the refresh. + private static void installHoverTheme(Container refresh) { + Hashtable theme = new Hashtable(); + theme.put("LeadRow.bgColor", "112233"); + theme.put("LeadRow.hover#bgColor", "44ff88"); + theme.put("Button.bgColor", "112233"); + theme.put("Button.hover#bgColor", "44ff88"); + // The selected colours are pinned to the normal ones because showing a surface + // focuses its first focusable component, and getStyle() answers the SELECTED style + // for a focused component. Without these the baseline reads as the blank default and + // says nothing about hover. It also makes the hover assertion sharper: hover outranks + // focus, which is the desktop behaviour, so 44ff88 can only come from the hover style. + theme.put("LeadRow.sel#bgColor", "112233"); + theme.put("Button.sel#bgColor", "112233"); + // addThemeProps, and the full refresh sequence a live theme change uses. setThemeProps + // REPLACES the table, which drops the defaults the surface was built against, and a + // component keeps the style it already built until the surface is refreshed. + UIManager.getInstance().addThemeProps(theme); + UIManager.getInstance().refreshTheme(); + refresh.refreshTheme(true); + refresh.revalidate(); + DisplayTest.flushEdt(); + } + + /// Hovers the centre of a component the way a port does. + private static void hoverForm(Form f, Component cmp) { + f.pointerHover(new int[]{cmp.getAbsoluteX() + cmp.getWidth() / 2}, + new int[]{cmp.getAbsoluteY() + cmp.getHeight() / 2}); + } + + /// A container with a lead component paints its own hover style when the pointer is over + /// any of its children. + /// + /// This is the case a MultiButton, a SpanButton or a toolbar command container is: the + /// pointer lands on an inner label, `Form.pointerHover` resolves it to the lead PARENT, + /// and `Component.getStyle()` returns out of its lead branch after consulting the lead + /// COMPONENT. Marking the parent therefore satisfied nothing that paints, and the row + /// stayed at its normal colour with the pointer sitting on it. + @FormTest + void aLeadContainerShowsItsHoverStyleWhenAChildIsHovered() { + Form f = new Form("lead", new BorderLayout()); + Container row = new Container(new BorderLayout()); + row.setUIID("LeadRow"); + Button lead = new Button("lead"); + Label child = new Label("child"); + row.add(BorderLayout.WEST, lead); + row.add(BorderLayout.CENTER, child); + f.add(BorderLayout.NORTH, row); + f.show(); + DisplayTest.flushEdt(); + // After showing: setLeadComponent only builds the lead hierarchy on an initialized + // container, so doing this before show leaves hasLead false and the test proves + // nothing about lead components at all. + row.setLeadComponent(lead); + f.revalidate(); + DisplayTest.flushEdt(); + installHoverTheme(f); + + assertEquals(0x112233, row.getStyle().getBgColor(), "before any hover"); + + hoverForm(f, child); + DisplayTest.flushEdt(); + assertEquals(0x44ff88, row.getStyle().getBgColor(), + "hovering a child of a lead container must paint the container's hover style"); + // The child does NOT take the row's hover colour. Hover is opt-in per UIID and the + // theme declares none for Label, so the lead gives the label the ability to resolve + // hover from the row's pointer -- and resolving it yields nothing, which is the + // property that keeps every pre-hover application looking the way it always did. + assertNotEquals(0x44ff88, child.getStyle().getBgColor(), + "a UIID with no hover entry must not inherit the row's hover colour"); + + // And away again: the ports report leaving the window as a hover at (-1,-1). + f.pointerHover(new int[]{-1}, new int[]{-1}); + DisplayTest.flushEdt(); + assertEquals(0x112233, row.getStyle().getBgColor(), "leaving must clear it"); + } + + /// Releasing a drag over a different component moves the hover there. + /// + /// Hover is not tracked during a drag -- pointerHover returns early while a component is + /// being dragged -- and a pointer that stops moving after the release produces no further + /// motion event, so without a catch-up on release the component the drag STARTED on stays + /// hover-styled and the one under the pointer never lights up. + @FormTest + void releasingADragOverAnotherComponentMovesTheHover() { + // The catch-up is desktop-only, because nothing else generates hover, so the test + // has to be one -- otherwise it passes for the wrong reason on any implementation. + implementation.setDesktop(true); + Form f = new Form("drag", new com.codename1.ui.layouts.BoxLayout( + com.codename1.ui.layouts.BoxLayout.Y_AXIS)); + Button a = new Button("A"); + Button b = new Button("B"); + f.add(a); + f.add(b); + f.show(); + DisplayTest.flushEdt(); + installHoverTheme(f); + + hoverForm(f, a); + DisplayTest.flushEdt(); + assertEquals(0x44ff88, a.getStyle().getBgColor(), "A is hovered to begin with"); + + // Press on A, then release over B without any motion event in between -- which is + // what a drag that ends on a stationary pointer looks like to the form. + f.pointerPressed(new int[]{a.getAbsoluteX() + a.getWidth() / 2}, + new int[]{a.getAbsoluteY() + a.getHeight() / 2}); + DisplayTest.flushEdt(); + implementation.setPointerType(PointerEvent.TYPE_MOUSE); + f.pointerReleased(b.getAbsoluteX() + b.getWidth() / 2, + b.getAbsoluteY() + b.getHeight() / 2); + DisplayTest.flushEdt(); + + assertNotEquals(0x44ff88, a.getStyle().getBgColor(), + "the component the drag started on must not stay hovered"); + assertEquals(0x44ff88, b.getStyle().getBgColor(), + "the component under the pointer at release must be hovered"); + } + + @FormTest + void releaseNavigationDoesNotRestoreHoverOnTheHiddenForm() { + implementation.setDesktop(true); + final Form destination = new Form("destination"); + Form source = new Form("source", new BorderLayout()); + source.setTransitionOutAnimator(com.codename1.ui.animations.CommonTransitions.createEmpty()); + destination.setTransitionInAnimator(com.codename1.ui.animations.CommonTransitions.createEmpty()); + Button button = new Button("navigate"); + source.add(BorderLayout.CENTER, button); + button.addActionListener(event -> destination.show()); + source.show(); + DisplayTest.flushEdt(); + installHoverTheme(source); + hoverForm(source, button); + assertTrue(button.isHovered()); + int x = button.getAbsoluteX() + button.getWidth() / 2; + int y = button.getAbsoluteY() + button.getHeight() / 2; + implementation.setPointerType(PointerEvent.TYPE_MOUSE); + source.pointerPressed(x, y); + source.pointerReleased(x, y); + DisplayTest.flushEdt(); + assertEquals(destination, Display.getInstance().getCurrent()); + assertFalse(button.isHovered(), "release must not restore hover after navigation deinitializes the source"); + source.show(); + DisplayTest.flushEdt(); + assertFalse(button.isHovered(), "showing the source again must not revive stale hover"); + } + + @FormTest + void removalOutsideHoverDispatchClearsFlagsTrackerAndTooltip() throws Exception { + implementation.setDesktop(true); + implementation.setMultiWindowSupported(true); + java.lang.reflect.Field pending = TooltipManager.class.getDeclaredField("pendingTooltip"); + java.lang.reflect.Field visible = TooltipManager.class.getDeclaredField("currentTooltip"); + java.lang.reflect.Field anchor = TooltipManager.class.getDeclaredField("currentComponent"); + pending.setAccessible(true); + visible.setAccessible(true); + anchor.setAccessible(true); + for (boolean secondary : new boolean[]{false, true}) { + for (boolean lead : new boolean[]{false, true}) { + Form main = new Form("removal", new com.codename1.ui.layouts.BoxLayout( + com.codename1.ui.layouts.BoxLayout.Y_AXIS)); + main.show(); + Window window = secondary ? new Window("removal", new com.codename1.ui.layouts.BoxLayout( + com.codename1.ui.layouts.BoxLayout.Y_AXIS)) : null; + Container surface = window == null ? main : window; + Container row = new Container(new BorderLayout()); + Button target = new Button("hovered"); + row.add(BorderLayout.CENTER, target); + Label unrelated = new Label("unrelated"); + surface.add(row); + surface.add(unrelated); + if (window != null) { + window.setWindowSize(500, 400); + window.show(); + } + installHoverTheme(surface); + if (lead) { + row.setLeadComponent(target); + } + Component hoverTarget = lead ? row : target; + hoverTarget.setTooltip("removed anchor"); + TooltipManager previous = TooltipManager.getInstance(); + TooltipManager manager = new TooltipManager(); + manager.setTooltipShowDelay(60000); + TooltipManager.enableTooltips(manager); + try { + surface.pointerHover(new int[]{target.getAbsoluteX() + target.getWidth() / 2}, + new int[]{target.getAbsoluteY() + target.getHeight() / 2}); + assertTrue(target.isHovered()); + assertNotNull(pending.get(manager)); + manager.showTooltip(hoverTarget.getTooltip(), hoverTarget); + assertNotNull(visible.get(manager)); + surface.removeComponent(unrelated); + assertTrue(target.isHovered(), "an unrelated removal must preserve hover"); + assertNotNull(pending.get(manager), "an unrelated removal must preserve the tooltip"); + assertNotNull(visible.get(manager)); + Container parent = lead ? surface : row; + Component removed = lead ? row : target; + parent.removeComponent(removed); + assertFalse(target.isHovered(), "teardown must clear the flag without another pointer event"); + assertFalse(surface.getHoverTracker().isOver(hoverTarget), "the owner must release its detached target"); + assertNull(pending.get(manager)); + assertNull(visible.get(manager)); + assertNull(anchor.get(manager)); + if (lead) { + parent.add(removed); + } else { + parent.add(BorderLayout.CENTER, removed); + } + surface.revalidate(); + assertFalse(target.isHovered(), "reattachment must not revive stale hover"); + assertNotEquals(0x44ff88, target.getStyle().getBgColor()); + } finally { + manager.clearTooltip(); + TooltipManager.enableTooltips(previous); + if (window != null) { + window.dispose(); + } + } + } + } + } + + @FormTest + void hoverCallbacksSeeCurrentStateAndCanDetachOrHide() { + implementation.setDesktop(true); + implementation.setMultiWindowSupported(true); + for (boolean secondary : new boolean[]{false, true}) { + for (final boolean detach : new boolean[]{false, true}) { + final Form main = new Form("hover callback", new com.codename1.ui.layouts.BoxLayout( + com.codename1.ui.layouts.BoxLayout.Y_AXIS)); + main.setTransitionOutAnimator(com.codename1.ui.animations.CommonTransitions.createEmpty()); + main.show(); + DisplayTest.flushEdt(); + final Window window = secondary ? new Window("hover callback", new com.codename1.ui.layouts.BoxLayout( + com.codename1.ui.layouts.BoxLayout.Y_AXIS)) : null; + final Container surface = window == null ? main : window; + final Button previous = new Button("previous"); + Button target = new Button("target") { + @Override + public void pointerHover(int[] x, int[] y) { + assertTrue(isHovered(), "enter callback sees its new hover state"); + assertFalse(previous.isHovered(), "the previous target is already cleared"); + assertEquals(0x44ff88, getStyle().getBgColor()); + if (detach) { + getParent().removeComponent(this); + } else if (window != null) { + window.hide(); + } else { + new Form("navigated").show(); + } + } + }; + surface.add(previous); + surface.add(target); + if (window != null) { + window.setWindowSize(500, 400); + window.show(); + } + installHoverTheme(surface); + try { + surface.pointerHover(new int[]{previous.getAbsoluteX() + previous.getWidth() / 2}, + new int[]{previous.getAbsoluteY() + previous.getHeight() / 2}); + assertTrue(previous.isHovered()); + surface.pointerHover(new int[]{target.getAbsoluteX() + target.getWidth() / 2}, + new int[]{target.getAbsoluteY() + target.getHeight() / 2}); + DisplayTest.flushEdt(); + assertFalse(target.isHovered(), "a callback must not leave a detached or hidden target hovered"); + } finally { + if (window != null) window.dispose(); + } + } + } + } + + @FormTest + void hidingAReusableWindowClearsHoverIncludingDuringRelease() { + implementation.setDesktop(true); + implementation.setMultiWindowSupported(true); + new Form("main").show(); + DisplayTest.flushEdt(); + final Window window = new Window("reusable", new BorderLayout()); + Button button = new Button("hide"); + window.add(BorderLayout.CENTER, button); + window.setWindowSize(500, 400); + window.show(); + installHoverTheme(window); + try { + int x = button.getAbsoluteX() + button.getWidth() / 2; + int y = button.getAbsoluteY() + button.getHeight() / 2; + window.pointerHover(new int[]{x}, new int[]{y}); + assertTrue(button.isHovered()); + window.hide(); + assertFalse(button.isHovered()); + window.show(); + assertFalse(button.isHovered()); + button.addActionListener(event -> window.hide()); + implementation.setPointerType(PointerEvent.TYPE_MOUSE); + window.pointerPressed(x, y); + window.pointerReleased(x, y); + assertFalse(window.isTopLevelShowing()); + assertFalse(button.isHovered(), "release catch-up must not revive hover after hide"); + window.show(); + assertFalse(button.isHovered()); + } finally { + window.dispose(); + } + } + + /// A control in a secondary window responds to hover. + /// + /// `Window` is not a `Form` -- it extends `Container` -- and its `pointerHover` only + /// forwarded the event. Nothing recorded which component the pointer was over, so with + /// the native ports now delivering hover per window, a control there still could not + /// paint a hover style its theme declared. + @FormTest + void aComponentInAWindowShowsItsHoverStyle() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + DisplayTest.flushEdt(); + + Window w = new Window("host", new BorderLayout()); + w.setWindowSize(500, 400); + Button b = new Button("hover me"); + w.add(BorderLayout.CENTER, b); + w.show(); + DisplayTest.flushEdt(); + installHoverTheme(w); + + assertEquals(0x112233, b.getStyle().getBgColor(), "before any hover"); + + w.pointerHover(new int[]{b.getAbsoluteX() + b.getWidth() / 2}, + new int[]{b.getAbsoluteY() + b.getHeight() / 2}); + DisplayTest.flushEdt(); + assertTrue(b.isHovered(), "the window has to record what its pointer is over"); + assertEquals(0x44ff88, b.getStyle().getBgColor(), + "a component in a window must paint its hover style"); + + w.pointerHover(new int[]{-1}, new int[]{-1}); + DisplayTest.flushEdt(); + assertFalse(b.isHovered(), "leaving the window has to clear it"); + assertEquals(0x112233, b.getStyle().getBgColor(), "and it must paint normally again"); + + w.dispose(); + DisplayTest.flushEdt(); + } + @FormTest + void releasesOnlyCreateHoverForMouseAndPenOnForms() { + checkReleaseSources(false); + } + + @FormTest + void releasesOnlyCreateHoverForMouseAndPenOnWindows() { + checkReleaseSources(true); + } + + private void checkReleaseSources(boolean secondaryWindow) { + implementation.setDesktop(true); + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + DisplayTest.flushEdt(); + Window window = secondaryWindow ? new Window("release", new BorderLayout()) : null; + Container surface = secondaryWindow ? window : main; + Button button = new Button("release"); + surface.add(BorderLayout.CENTER, button); + if (window != null) { + window.setWindowSize(500, 400); + window.show(); + } + surface.revalidate(); + DisplayTest.flushEdt(); + installHoverTheme(surface); + int x = button.getAbsoluteX() + button.getWidth() / 2; + int y = button.getAbsoluteY() + button.getHeight() / 2; + try { + for (int type : new int[]{PointerEvent.TYPE_TOUCH, PointerEvent.TYPE_MOUSE, + PointerEvent.TYPE_STYLUS, PointerEvent.TYPE_ERASER, PointerEvent.TYPE_UNKNOWN}) { + surface.pointerHover(new int[]{-1}, new int[]{-1}); + implementation.setPointerType(type); + surface.pointerPressed(x, y); + surface.pointerReleased(x, y); + assertEquals(type == PointerEvent.TYPE_MOUSE || type == PointerEvent.TYPE_STYLUS + || type == PointerEvent.TYPE_ERASER, button.isHovered(), + "release hover for pointer type " + type + " in window=" + secondaryWindow); + } + } finally { + if (window != null) { + window.dispose(); + } + } + } + + @FormTest + void hidingAnAttachedSubtreeClearsHoverAndTooltips() throws Exception { + implementation.setDesktop(true); + implementation.setMultiWindowSupported(true); + java.lang.reflect.Field pending = TooltipManager.class.getDeclaredField("pendingTooltip"); + java.lang.reflect.Field visible = TooltipManager.class.getDeclaredField("currentTooltip"); + java.lang.reflect.Field anchor = TooltipManager.class.getDeclaredField("currentComponent"); + pending.setAccessible(true); + visible.setAccessible(true); + anchor.setAccessible(true); + for (boolean secondary : new boolean[]{false, true}) { + Form form = new Form("visibility", new BorderLayout()); + form.show(); + Window window = secondary ? new Window("visibility", new BorderLayout()) : null; + Container surface = secondary ? window : form; + Container subtree = new Container(new BorderLayout()); + Button target = new Button("hover target"); + target.setTooltip("attached target"); + subtree.add(BorderLayout.CENTER, target); + surface.add(BorderLayout.CENTER, subtree); + if (window != null) { + window.setWindowSize(500, 400); + window.show(); + } + surface.revalidate(); + DisplayTest.flushEdt(); + com.codename1.ui.plaf.Style hover = new com.codename1.ui.plaf.Style(target.getUnselectedStyle()); + hover.setBgPainter(new CountingHoverPainter()); + target.setHoverStyle(hover); + TooltipManager previous = TooltipManager.getInstance(); + TooltipManager manager = new TooltipManager(); + manager.setTooltipShowDelay(60000); + TooltipManager.enableTooltips(manager); + try { + for (int mode = 0; mode < 3; mode++) { + for (Component hidden : new Component[]{target, subtree}) { + int x = target.getAbsoluteX() + target.getWidth() / 2; + int y = target.getAbsoluteY() + target.getHeight() / 2; + surface.pointerHover(new int[]{x}, new int[]{y}); + assertTrue(target.isHovered()); + assertNotNull(pending.get(manager)); + manager.showTooltip(target.getTooltip(), target); + assertNotNull(visible.get(manager)); + new Container().setVisible(false); + assertNotNull(pending.get(manager), "unrelated hiding must preserve the timer"); + assertNotNull(visible.get(manager), "unrelated hiding must preserve the popup"); + if (mode == 0) { hidden.setVisible(false); } + else { hidden.setHidden(true, mode == 1); } + assertFalse(target.isHovered()); + assertFalse(surface.getHoverTracker().isOver(target)); + assertNull(pending.get(manager)); + assertNull(visible.get(manager)); + assertNull(anchor.get(manager)); + assertFalse(secondary ? window.hasAnimations() : form.hasAnimations()); + surface.pointerHover(new int[]{x}, new int[]{y}); + assertFalse(target.isHovered(), "collapsed bounds must not reacquire hover before layout"); + assertNull(pending.get(manager)); + if (mode == 0) { hidden.setVisible(true); } + else { hidden.setHidden(false, mode == 1); } + assertFalse(target.isHovered(), "show waits for a new pointer event"); + surface.pointerHover(new int[]{x}, new int[]{y}); + assertTrue(target.isHovered(), "a new event restores hover on the same target"); + assertNotNull(pending.get(manager), "the new event schedules a fresh tooltip"); + manager.clearTooltip(); + } + } + } finally { + manager.clearTooltip(); + TooltipManager.enableTooltips(previous); + if (window != null) { + window.dispose(); + } + } + } + } + + @FormTest + void windowInputCancellationClearsOnlyItsOwnTooltip() throws Exception { + implementation.setDesktop(true); + implementation.setMultiWindowSupported(true); + java.lang.reflect.Field pending = TooltipManager.class.getDeclaredField("pendingTooltip"); + java.lang.reflect.Field visible = TooltipManager.class.getDeclaredField("currentTooltip"); + java.lang.reflect.Field anchor = TooltipManager.class.getDeclaredField("currentComponent"); + pending.setAccessible(true); + visible.setAccessible(true); + anchor.setAccessible(true); + for (int mode = 0; mode < 3; mode++) { + Window window = new Window("tooltip owner", new BorderLayout()); + window.setWindowSize(500, 400); + Button target = new Button("anchor"); + target.setTooltip("owner tooltip"); + window.add(BorderLayout.CENTER, target); + window.show(); + Window other = new Window("other"); + other.show(); + DisplayTest.flushEdt(); + TooltipManager previous = TooltipManager.getInstance(); + TooltipManager manager = new TooltipManager(); + manager.setTooltipShowDelay(60000); + TooltipManager.enableTooltips(manager); + try { + window.pointerHover(new int[]{target.getAbsoluteX() + target.getWidth() / 2}, + new int[]{target.getAbsoluteY() + target.getHeight() / 2}); + assertNotNull(pending.get(manager)); + manager.showTooltip(target.getTooltip(), target); + assertNotNull(visible.get(manager)); + other.cancelPendingInput(); + assertNotNull(pending.get(manager), "another window must not cancel this timer"); + assertNotNull(visible.get(manager), "another window must not dismiss this tooltip"); + if (mode == 0) { + window.hide(); + } else if (mode == 1) { + window.hideNotify(); + } else { + Desktop.getInstance().windowFocusChanged(window.getWindowId(), false); + DisplayTest.flushEdt(); + } + assertNull(pending.get(manager)); + assertNull(visible.get(manager)); + assertNull(anchor.get(manager)); + assertFalse(target.isHovered()); + window.show(); + DisplayTest.flushEdt(); + assertNull(visible.get(manager), "showing a reusable window must not revive its tooltip"); + } finally { + manager.clearTooltip(); + TooltipManager.enableTooltips(previous); + other.dispose(); + window.dispose(); + } + } + } + + @FormTest + void leavingAWindowCancelsPendingAndVisibleTooltips() throws Exception { + implementation.setMultiWindowSupported(true); + new Form("main", new BorderLayout()).show(); + Window window = new Window("tooltip", new BorderLayout()); + window.setWindowSize(500, 400); + Button button = new Button("tip"); + button.setTooltip("Window tooltip"); + window.add(BorderLayout.CENTER, button); + window.show(); + DisplayTest.flushEdt(); + TooltipManager previous = TooltipManager.getInstance(); + TooltipManager manager = new TooltipManager(); + manager.setTooltipShowDelay(60000); + TooltipManager.enableTooltips(manager); + java.lang.reflect.Field pending = TooltipManager.class.getDeclaredField("pendingTooltip"); + java.lang.reflect.Field visible = TooltipManager.class.getDeclaredField("currentTooltip"); + pending.setAccessible(true); + visible.setAccessible(true); + try { + window.pointerHover(new int[]{button.getAbsoluteX() + button.getWidth() / 2}, + new int[]{button.getAbsoluteY() + button.getHeight() / 2}); + assertNotNull(pending.get(manager), "hover schedules a tooltip"); + window.pointerHover(new int[]{-1}, new int[]{-1}); + assertNull(pending.get(manager), "leaving cancels the scheduled tooltip"); + manager.showTooltip(button.getTooltip(), button); + assertNotNull(visible.get(manager), "tooltip is visible before leaving"); + window.pointerHover(new int[]{-1}, new int[]{-1}); + assertNull(visible.get(manager), "leaving dismisses a visible tooltip"); + } finally { + manager.clearTooltip(); + TooltipManager.enableTooltips(previous); + window.dispose(); + } + } + + @FormTest + void leavingAFormDoesNotHoverItsRootPane() { + checkRootPaneLeave(false); + } + + @FormTest + void leavingAWindowDoesNotHoverItsRootPane() { + checkRootPaneLeave(true); + } + + private void checkRootPaneLeave(boolean secondaryWindow) { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + Window window = secondaryWindow ? new Window("root", new BorderLayout()) : null; + if (window != null) { + window.setWindowSize(500, 400); + window.show(); + } + DisplayTest.flushEdt(); + Container surface = window == null ? main : window; + Container root = window == null ? main.getContentPane() : window.getContentPane(); + int x = root.getAbsoluteX() + root.getWidth() / 2; + int y = root.getAbsoluteY() + root.getHeight() / 2; + try { + for (int[] outside : new int[][]{{-1, -1}, {surface.getWidth(), y}, + {x, surface.getHeight()}, {x, -1}}) { + surface.pointerHover(new int[]{x}, new int[]{y}); + assertTrue(root.isHovered(), "empty root pane is hovered while inside"); + surface.pointerHover(new int[]{outside[0]}, new int[]{outside[1]}); + assertFalse(root.isHovered(), "outside coordinates must not hit the root pane"); + } + } finally { + if (window != null) { + window.dispose(); + } + } + } + +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/ScrollWheelGestureTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/ScrollWheelGestureTest.java index 844496ac055..3d7bfbf3bfd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/ScrollWheelGestureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/ScrollWheelGestureTest.java @@ -385,6 +385,50 @@ void aWheelBringsAFadedScrollbarBack() { } } + @FormTest + void aWheelRestartsScrollbarFadeWhileTheHoverBackgroundAnimates() { + class AnimatedPainter implements Painter, com.codename1.ui.animations.Animation { + int ticks; + public void paint(Graphics g, com.codename1.ui.geom.Rectangle rect) { } + public void paint(Graphics g) { } + public boolean animate() { ticks++; return true; } + } + boolean pureTouch = Display.getInstance().isPureTouch(); + Form form = scrollingForm(); + Container page = form.getContentPane(); + boolean fading = page.getUIManager().getLookAndFeel().isFadeScrollBar(); + try { + page.getUIManager().getLookAndFeel().setFadeScrollBar(true); + Display.getInstance().setPureTouch(true); + fadeOut(page); + assertEquals(0, page.getScrollOpacity()); + assertFalse(form.hasAnimations()); + AnimatedPainter painter = new AnimatedPainter(); + com.codename1.ui.plaf.Style hover = new com.codename1.ui.plaf.Style(page.getUnselectedStyle()); + hover.setBgPainter(painter); + page.setHoverStyle(hover); + page.setHovered(true); + assertFalse(page.internalRegisteredAnimated, "only the hover background starts registered"); + + wheelAt(page.getAbsoluteX() + page.getWidth() / 2, + page.getAbsoluteY() + page.getHeight() / 2, 0, -px(30)); + assertEquals(0xff, page.getScrollOpacity()); + assertTrue(page.internalRegisteredAnimated, "wheel restoration must also register the fade"); + for (int i = 0; i < 300; i++) { + form.repaintAnimations(); + } + assertEquals(0, page.getScrollOpacity(), "the real animation loop must fade the restored scrollbar"); + assertFalse(page.internalRegisteredAnimated); + assertTrue(painter.ticks > 0, "the background continues animating during the fade"); + page.setHovered(false); + assertFalse(form.hasAnimations()); + } finally { + page.setHovered(false); + page.getUIManager().getLookAndFeel().setFadeScrollBar(fading); + Display.getInstance().setPureTouch(pureTouch); + } + } + @FormTest void aListenerAboveTheComponentStillBeatsItsBuiltInHandling() { Form f = new Form("ancestor listener", new BorderLayout()); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SliderNativeProgressSizeTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SliderNativeProgressSizeTest.java new file mode 100644 index 00000000000..82c77d3b13b --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SliderNativeProgressSizeTest.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.Border; +import com.codename1.ui.plaf.RoundBorder; +import java.util.Hashtable; +import static org.junit.jupiter.api.Assertions.*; + +class SliderNativeProgressSizeTest extends UITestBase { + @FormTest + void thinProgressTrackReservesTextHeightOnlyWhenTextIsEnabled() { + Hashtable theme = new Hashtable(); + theme.put("@progressTrackThicknessMM", "0.3"); + UIManager.getInstance().setThemeProps(theme); + Slider progress = new Slider(); + progress.setEditable(false); + progress.getAllStyles().setPadding(2, 2, 0, 0); + progress.getAllStyles().setBorder(null); + progress.getAllStyles().setBgTransparency(255); + progress.getSliderFullUnselectedStyle().setBgTransparency(255); + progress.getSliderFullSelectedStyle().setBgTransparency(255); + progress.getAllStyles().setFont(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE)); + int trackHeight = Math.max(2, Display.getInstance().convertToPixels(0.3f)); + int textHeight = progress.getStyle().getFont().getHeight(); + assertEquals(trackHeight + 4, progress.getPreferredH()); + progress.setRenderPercentageOnTop(true); + assertTrue(progress.getPreferredH() >= textHeight + 4, "percentage text must fit after toggling a cached size"); + progress.setRenderPercentageOnTop(false); + assertEquals(trackHeight + 4, progress.getPreferredH()); + progress.setRenderValueOnTop(true); + assertTrue(progress.getPreferredH() >= textHeight + 4, "value text must fit after toggling a cached size"); + progress.setRenderValueOnTop(false); + assertEquals(trackHeight + 4, progress.getPreferredH()); + progress.setEditable(true); + assertTrue(progress.getPreferredH() >= Font.getDefaultFont().getHeight() + 4, + "editable sliders must discard the cached thin progress height"); + progress.setEditable(false); + assertEquals(trackHeight + 4, progress.getPreferredH(), + "switching back to a progress bar must discard the cached slider height"); + progress.setVertical(true); + assertTrue(progress.getPreferredH() >= Font.getDefaultFont().getHeight() + 4, + "vertical mode must discard the cached horizontal track height"); + progress.setVertical(false); + assertEquals(trackHeight + 4, progress.getPreferredH(), + "horizontal mode must discard the cached vertical height"); + progress.setInfinite(true); + assertTrue(progress.getPreferredH() >= Font.getDefaultFont().getHeight() + 4, + "indeterminate mode must discard the cached thin-track height"); + progress.setInfinite(false); + assertEquals(trackHeight + 4, progress.getPreferredH(), + "returning to determinate mode must discard the cached full height"); + } + + private Slider nativeProgress() { + Hashtable theme = new Hashtable(); + theme.put("@progressTrackThicknessMM", "0.3"); + UIManager.getInstance().setThemeProps(theme); + Slider progress = new Slider(); + progress.setEditable(false); + progress.setProgress(50); + progress.setWidth(100); + progress.setHeight(40); + for (Style style : new Style[] {progress.getUnselectedStyle(), progress.getSelectedStyle(), + progress.getDisabledStyle(), progress.getPressedStyle(), + progress.getSliderFullUnselectedStyle(), progress.getSliderFullSelectedStyle()}) { + style.setPadding(0, 0, 0, 0); + style.setBgTransparency(255); + style.setBackgroundType(Style.BACKGROUND_NONE); + style.setBorder(null); + } + return progress; + } + + @FormTest + void customProgressAssetsKeepLegacyHeightAndPainters() { + Slider progress = nativeProgress(); + int trackHeight = Math.max(2, Display.getInstance().convertToPixels(0.3f)); + assertEquals(trackHeight, progress.getPreferredH()); + Style full = progress.getSliderFullUnselectedStyle(); + Painter original = full.getBgPainter(); + int[] paints = {0}; + full.setBgPainter((g, rect) -> paints[0]++); + assertTrue(progress.getPreferredH() >= Font.getDefaultFont().getHeight()); + progress.paintComponentBackground(Image.createImage(100, 40).getGraphics()); + assertEquals(1, paints[0], "custom fill painter must run"); + full.setBgPainter(original); + assertEquals(trackHeight, progress.getPreferredH()); + + Style empty = progress.getUnselectedStyle(); + Painter emptyOriginal = empty.getBgPainter(); + empty.setBgPainter((g, rect) -> paints[0]++); + progress.paintComponentBackground(Image.createImage(100, 40).getGraphics()); + assertEquals(2, paints[0], "custom empty painter must run"); + empty.setBgPainter(emptyOriginal); + assertEquals(trackHeight, progress.getPreferredH()); + + full.setBgImage(Image.createImage(8, 24)); + assertTrue(progress.getPreferredH() > trackHeight, "fill image must use legacy sizing"); + full.setBgImage(null); + assertEquals(trackHeight, progress.getPreferredH()); + empty.setBorder(Border.createLineBorder(2)); + assertTrue(progress.getPreferredH() > trackHeight, "custom border must use legacy sizing"); + empty.setBorder(null); + assertEquals(trackHeight, progress.getPreferredH()); + progress.setThumbImage(Image.createImage(8, 32)); + assertTrue(progress.getPreferredH() >= 32, "thumb must fit after caching thin size"); + progress.setThumbImage(null); + assertEquals(trackHeight, progress.getPreferredH()); + empty.setBackgroundType(Style.BACKGROUND_GRADIENT_LINEAR_VERTICAL); + assertTrue(progress.getPreferredH() > trackHeight, "gradient must retain legacy painter"); + empty.setBackgroundType(Style.BACKGROUND_NONE); + empty.setBgTransparency(100); + assertTrue(progress.getPreferredH() > trackHeight, "translucent backgrounds must remain translucent"); + } + + @FormTest + void bundledPlainPillsStayNativeWhileDecoratedPillsKeepLegacyPath() { + Slider progress = nativeProgress(); + int trackHeight = Math.max(2, Display.getInstance().convertToPixels(0.3f)); + for (Style style : new Style[] {progress.getUnselectedStyle(), progress.getSelectedStyle(), + progress.getDisabledStyle(), progress.getPressedStyle(), + progress.getSliderFullUnselectedStyle(), progress.getSliderFullSelectedStyle()}) { + style.setBgColor(0x007aff); + style.setBgTransparency(0); + style.setBorder(RoundBorder.create().rectangle(true).color(0x007aff) + .stroke(1, false).strokeOpacity(0)); + } + assertEquals(trackHeight, progress.getPreferredH(), "CSS pill borders must retain native sizing"); + progress.getSliderFullUnselectedStyle().setBorder(RoundBorder.create().rectangle(true) + .color(0x007aff).stroke(2, false).strokeOpacity(255)); + assertTrue(progress.getPreferredH() > trackHeight, "decorated pill border must be preserved"); + } + + + @FormTest + void customStateArtworkReservesLegacyHeightBeforeEnteringThatState() { + boolean pureTouch = display.isPureTouch(); + display.setPureTouch(false); + try { + for (int state = 0; state < 4; state++) { + Slider progress = nativeProgress(); + Style custom; + if (state == 0) { + custom = new Style(progress.getUnselectedStyle()); + progress.setHoverStyle(custom); + } else if (state == 1) { + custom = progress.getSelectedStyle(); + } else if (state == 2) { + custom = progress.getDisabledStyle(); + } else { + custom = progress.getPressedStyle(); + Button lead = new Button(); + Container parent = new Container(); + parent.add(progress).add(lead); + parent.setLeadComponent(lead); + Form form = new Form(); + form.add(parent); + form.show(); + progress.setWidth(100); + progress.setHeight(40); + } + int[] paints = {0}; + custom.setBgPainter((g, rect) -> paints[0]++); + int cachedHeight = progress.getPreferredH(); + assertTrue(cachedHeight >= Font.getDefaultFont().getHeight(), + "normal state must already reserve the custom state's legacy height: " + state); + if (state == 0) { + progress.setHovered(true); + } else if (state == 1) { + progress.setFocusable(true); + progress.setFocus(true); + } else if (state == 2) { + progress.setEnabled(false); + } else { + ((Button) progress.getLeadComponent()).setState(Button.STATE_PRESSED); + } + assertEquals(cachedHeight, progress.getPreferredH()); + progress.paintComponentBackground(Image.createImage(100, 40).getGraphics()); + assertEquals(1, paints[0], "custom state painter must run: " + state); + } + } finally { + display.setPureTouch(pureTouch); + } + } + +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 2a670187e70..8161c0a0f8c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.ui.css; import com.codename1.junit.UITestBase; @@ -17,6 +39,54 @@ public class CSSThemeCompilerTest extends UITestBase { + @Test + public void runtimeHoverFollowsNormalDerivationAndChildOverrides() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Leaf{cn1-derive:Child;} Child{cn1-derive:Base;}" + + "Child:hover{color:#334455;} Base:hover{color:#112233;background-color:#abcdef;}" + + "Plain{cn1-derive:Other;} Other{color:#777777;}", resource, "Theme"); + UIManager.getInstance().addThemeProps(resource.getTheme("Theme")); + for (String uiid : new String[]{"Child", "Leaf"}) { + Button button = new Button(); + button.setUIID(uiid); + assertNotNull(button.getHoverStyle(), uiid); + assertEquals(0x334455, button.getHoverStyle().getFgColor()); + assertEquals(0xabcdef, button.getHoverStyle().getBgColor()); + } + Button plain = new Button(); + plain.setUIID("Plain"); + org.junit.jupiter.api.Assertions.assertNull(plain.getHoverStyle()); + } + + @Test + public void runtimeDarkHoverInheritanceRemainsDarkOnlyAndAvoidsCycles() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("DarkChild{cn1-derive:DarkBase;}" + + "DarkOwn{cn1-derive:DarkBase;} DarkOwn:hover{color:#654321;}" + + "@media (prefers-color-scheme: dark) { DarkBase:hover{color:#123456;background-color:#abcdef;} }" + + "CycleA{cn1-derive:CycleB;} CycleB{cn1-derive:CycleA;} CycleA:hover{color:#abcdef;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("DarkChild.hover#derive")); + assertEquals("DarkBase.hover", theme.get("$DarkDarkChild.hover#derive")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("CycleA.hover#derive")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("CycleB.hover#derive")); + Boolean previous = com.codename1.ui.CN.isDarkMode(); + try { + com.codename1.ui.CN.setDarkMode(Boolean.TRUE); + UIManager.getInstance().addThemeProps(theme); + Button child = new Button(); + child.setUIID("DarkChild"); + assertNotNull(child.getHoverStyle()); + assertEquals(0x123456, child.getHoverStyle().getFgColor()); + Button own = new Button(); + own.setUIID("DarkOwn"); + assertEquals(0x654321, own.getHoverStyle().getFgColor()); + assertEquals(0xabcdef, own.getHoverStyle().getBgColor()); + } finally { + com.codename1.ui.CN.setDarkMode(previous); + } + } + @Test public void testCompilesThemeConstantsDeriveAndMutableImages() { CSSThemeCompiler compiler = new CSSThemeCompiler(); @@ -70,11 +140,38 @@ public void testThrowsOnMalformedCss() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> compiler.compile("Button{color:#ff00ff;text-align:middle;}", resource, "Theme") ); + // Deliberately a nonsense pseudo state rather than a real-but-unimplemented one. + // This assertion used ":hover", which stopped throwing the moment hover became a + // supported state -- and then the test read as a regression instead of as a feature + // landing. A name no state will ever be called keeps it testing what it is for: + // that an unknown pseudo state is rejected rather than silently ignored. assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> - compiler.compile("Button:hover{color:#ff00ff;}", resource, "Theme") + compiler.compile("Button:notarealstate{color:#ff00ff;}", resource, "Theme") ); } + /** + * Both spellings of the hover state reach the same prefix. This compiler accepts a pseudo + * (`:hover`) and a dot-class (`.hover`) selector interchangeably - {@code selector()} + * splits on whichever separator comes first - whereas the build-time compiler in + * maven/css-compiler only understands the dot-class form, which is what + * native-themes/README.md tells theme authors to write. Pinning both here means the + * looser one cannot quietly drift. + */ + @Test + public void testCompilesHoverInBothSelectorSpellings() { + CSSThemeCompiler compiler = new CSSThemeCompiler(); + MutableResource resource = new MutableResource(); + compiler.compile("Button{color:#111111;}" + + "Button:hover{color:#222222;}" + + "Label{color:#333333;}" + + "Label.hover{color:#444444;}", resource, "Theme"); + + Hashtable theme = resource.getTheme("Theme"); + assertEquals("222222", theme.get("Button.hover#fgColor")); + assertEquals("444444", theme.get("Label.hover#fgColor")); + } + @Test public void testCompilesDarkModeMediaQueriesToDarkUiids() { CSSThemeCompiler compiler = new CSSThemeCompiler(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/ComponentHoverStyleTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/ComponentHoverStyleTest.java new file mode 100644 index 00000000000..0743c89eb9c --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/ComponentHoverStyleTest.java @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.plaf; + +import com.codename1.junit.UITestBase; +import com.codename1.junit.FormTest; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.Container; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.css.CSSThemeCompiler; +import com.codename1.ui.util.MutableResource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.Hashtable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The hover style state, which the desktop native themes are built on and which the + * mobile ones never needed. + * + *

The property worth defending here is the one that is easy to lose: hover must be + * opt-in per UIID. {@link UIManager#getComponentCustomStyle(String, String)} never + * returns null, so asking it for a state the theme says nothing about yields a copy of the + * blank default style - white background, black foreground. Building a hover style + * unconditionally would therefore repaint every hovered component of every application + * written before hover existed, the instant the pointer crossed it, with no obvious cause. + * So a theme with no hover entries must produce no hover style at all.

+ */ +public class ComponentHoverStyleTest extends UITestBase { + + /** A theme that predates hover: hovering must change nothing whatsoever. */ + @Test + public void themeWithoutHoverEntriesHasNoHoverStyle() { + Hashtable theme = new Hashtable(); + theme.put("Button.bgColor", "112233"); + UIManager.getInstance().setThemeProps(theme); + + Button b = new Button("plain"); + b.setUIID("Button"); + Style before = b.getStyle(); + + assertNull(b.getHoverStyle(), "a theme with no hover# entries must yield no hover style"); + b.setHovered(true); + assertTrue(b.isHovered(), "the flag itself still tracks the pointer"); + assertSame(before, b.getStyle(), "getStyle must fall through to the ordinary chain"); + assertEquals(0x112233, b.getStyle().getBgColor()); + } + + @Test + public void programmaticallyInstalledHoverIsRenderedAndClearedWithTheTheme() { + UIManager manager = UIManager.getInstance(); + Hashtable theme = new Hashtable(); + theme.put("Button.bgColor", "112233"); + manager.setThemeProps(theme); + Button button = new Button("installed hover"); + assertNull(button.getHoverStyle()); + manager.getComponentCustomStyle("Button", "hover"); + assertNull(button.getHoverStyle(), "a generated fallback does not declare hover"); + + Style installed = new Style(); + installed.setBgColor(0x44ff88); + manager.setComponentStyle("Button", installed, "hover"); + button.setHovered(true); + assertEquals(0x44ff88, button.getStyle().getBgColor()); + button.getStyle().setBgColor(0x123456); + assertEquals(0x44ff88, installed.getBgColor(), "components receive defensive copies"); + + installed.setBgColor(0xabcdef); + button.refreshTheme(false); + assertEquals(0xabcdef, button.getStyle().getBgColor(), "refresh reads later mutations"); + Style replacement = new Style(); + replacement.setBgColor(0x765432); + manager.setComponentStyle("Button", replacement, "hover"); + button.refreshTheme(false); + assertEquals(0x765432, button.getStyle().getBgColor(), "replacement bypasses old caches"); + + manager.setThemeProps(theme); + button.refreshTheme(false); + assertNull(button.getHoverStyle(), "installations have the same lifetime as the theme"); + assertEquals(0x112233, button.getStyle().getBgColor()); + } + + /** A theme that declares hover gets it. */ + @Test + public void declaredHoverStyleAppliesWhileHovered() { + Hashtable theme = new Hashtable(); + theme.put("Button.bgColor", "112233"); + theme.put("Button.hover#bgColor", "44ff88"); + UIManager.getInstance().setThemeProps(theme); + + Button b = new Button("hoverable"); + b.setUIID("Button"); + assertEquals(0x112233, b.getStyle().getBgColor(), "not hovered yet"); + + assertNotNull(b.getHoverStyle()); + b.setHovered(true); + assertEquals(0x44ff88, b.getStyle().getBgColor(), "hovered"); + + b.setHovered(false); + assertEquals(0x112233, b.getStyle().getBgColor(), "pointer moved away"); + } + + /** + * Buttons retain hover feedback while focused. Text inputs separately preserve + * their selected style because it carries the editing focus indicator. + */ + @Test + public void hoverOutranksSelectedStyle() { + Hashtable theme = new Hashtable(); + theme.put("Button.bgColor", "112233"); + theme.put("Button.sel#bgColor", "0000ff"); + theme.put("Button.hover#bgColor", "44ff88"); + UIManager.getInstance().setThemeProps(theme); + + Button b = new Button("both"); + b.setUIID("Button"); + b.setHovered(true); + assertEquals(0x44ff88, b.getStyle().getBgColor(), + "hover must win over the selected/focus style"); + } + + @FormTest + public void focusedTextInputsKeepTheirFocusStyleDuringHover() { + Form form = new Form(BoxLayout.y()); + Button other = new Button("other"); + TextField field = new TextField("field"); + TextArea area = new TextArea("area"); + Container leadRow = new Container(BoxLayout.y()); + TextField lead = new TextField("lead"); + leadRow.add(lead); + form.add(field).add(area).add(leadRow).add(other); + form.show(); + leadRow.setLeadComponent(lead); + for (TextArea input : new TextArea[]{field, area, lead}) { + Style hover = new Style(input.getUnselectedStyle()); + hover.setBorder(Border.createLineBorder(1, 0x777777)); + input.setHoverStyle(hover); + input.getSelectedStyle().setBorder(Border.createLineBorder(2, 0x0078d4)); + form.setFocused(input); + input.setHovered(true); + assertSame(input.getSelectedStyle(), input.getStyle(), "focus survives a stationary pointer"); + if (input == lead) { + assertSame(leadRow.getSelectedStyle(), leadRow.getStyle(), "lead styling follows its focused input"); + } + input.setEnabled(false); + assertSame(input.getDisabledStyle(), input.getStyle()); + input.setEnabled(true); + form.setFocused(other); + assertSame(hover, input.getStyle(), "unfocused inputs still show hover feedback"); + input.setHovered(false); + } + } + + /** Disabled still outranks everything, as it does for pressed. */ + @Test + public void disabledOutranksHover() { + Hashtable theme = new Hashtable(); + theme.put("Button.bgColor", "112233"); + theme.put("Button.dis#bgColor", "888888"); + theme.put("Button.hover#bgColor", "44ff88"); + UIManager.getInstance().setThemeProps(theme); + + Button b = new Button("off"); + b.setUIID("Button"); + b.setEnabled(false); + b.setHovered(true); + assertEquals(0x888888, b.getStyle().getBgColor(), "disabled must win over hover"); + } + + /** Changing the UIID must drop the cached hover style with the rest of the states. */ + @Test + public void changingUiidRebuildsTheHoverStyle() { + Hashtable theme = new Hashtable(); + theme.put("Button.hover#bgColor", "44ff88"); + theme.put("Other.hover#bgColor", "ff0000"); + UIManager.getInstance().setThemeProps(theme); + + Button b = new Button("switch"); + b.setUIID("Button"); + b.setHovered(true); + assertEquals(0x44ff88, b.getStyle().getBgColor()); + + b.setUIID("Other"); + assertEquals(0xff0000, b.getStyle().getBgColor(), + "the hover style must be rebuilt for the new UIID, not reused"); + } + + /** + * End to end through the runtime CSS compiler: a `.hover` rule in a stylesheet reaches a + * live component's render. + * + *

This is a second, independent CSS implementation from the build-time one in + * {@code maven/css-compiler} - they share no code - and it rejected every pseudo state it + * did not recognise, so a sheet using `.hover` threw "Unsupported pseudo state" rather + * than quietly ignoring the rule. Both compilers have to agree on the `hover#` prefix or + * the same stylesheet means different things depending on when it was compiled.

+ */ + @Test + public void runtimeCssCompilerCompilesHoverThroughToTheRender() { + CSSThemeCompiler compiler = new CSSThemeCompiler(); + MutableResource resource = new MutableResource(); + compiler.compile("Button{background-color:#112233;}" + + "Button.hover{background-color:#44ff88;}", resource, "Theme"); + + UIManager.getInstance().setThemeProps(resource.getTheme("Theme")); + + Button b = new Button("css"); + b.setUIID("Button"); + assertEquals(0x112233, b.getStyle().getBgColor(), "not hovered"); + b.setHovered(true); + assertEquals(0x44ff88, b.getStyle().getBgColor(), "hovered, straight from the stylesheet"); + } + + /// Dark mode is global state on Display, so a test that sets it has to put it back or + /// every later test in the run inherits it. + @AfterEach + public void resetDarkMode() { + display.setDarkMode(null); + } + + /// A UIID that declares hover ONLY in dark mode declares it only WHILE dark mode is on. + /// + /// The CSS compiler emits a `@media (prefers-color-scheme: dark)` block as + /// `$Dark`, so `$DarkButton.hover#` is a real hover declaration -- for dark mode. + /// Counting it in light mode too made getHoverStyle() go on to request the LIGHT + /// `Button.hover#` key, which does not exist, and getComponentCustomStyle builds a style + /// out of blank defaults from a missing key: hovering would drop the button to the + /// default colours instead of leaving its normal light style alone. + @Test + public void darkOnlyHoverDeclarationDoesNotApplyInLightMode() { + display.setDarkMode(Boolean.FALSE); + Hashtable theme = new Hashtable(); + theme.put("Button.bgColor", "112233"); + theme.put("$DarkButton.hover#bgColor", "44ff88"); + UIManager.getInstance().setThemeProps(theme); + + Component c = new Button("light"); + c.setUIID("Button"); + assertNull(c.getHoverStyle(), + "a dark-only hover declaration must not register while light mode is active"); + c.setHovered(true); + assertEquals(0x112233, c.getStyle().getBgColor(), + "hovering must leave the normal light style untouched"); + } + + /// The other half of the same rule: in dark mode the $Dark declaration IS the hover + /// style, and hovering has to pick up the colour it declares. + @Test + public void darkOnlyHoverDeclarationAppliesInDarkMode() { + display.setDarkMode(Boolean.TRUE); + Hashtable theme = new Hashtable(); + theme.put("Button.bgColor", "112233"); + theme.put("$DarkButton.hover#bgColor", "44ff88"); + UIManager.getInstance().setThemeProps(theme); + + Component c = new Button("dark"); + c.setUIID("Button"); + assertNotNull(c.getHoverStyle(), + "a dark-only hover declaration is a declaration while dark mode is active"); + c.setHovered(true); + assertEquals(0x44ff88, c.getStyle().getBgColor(), "hovered, from the $Dark block"); + } + @Test + public void programmaticHoverSurvivesMergedRefreshWithoutThemeHover() { + UIManager.getInstance().setThemeProps(new Hashtable()); + Button button = new Button("local hover"); + Style hover = new Style(); + hover.setBgColor(0x44ff88); + button.setHoverStyle(hover); + button.refreshTheme(true); + assertNotNull(button.getHoverStyle()); + assertEquals(0x44ff88, button.getHoverStyle().getBgColor()); + } + + @Test + public void removingThemeHoverKeepsOnlyLocalOverrides() { + Hashtable theme = new Hashtable(); + theme.put("Button.hover#bgColor", "44ff88"); + theme.put("Button.hover#fgColor", "ff0000"); + UIManager.getInstance().setThemeProps(theme); + Button button = new Button("local override"); + button.getHoverStyle().setBgColor(0x123456); + Hashtable replacement = new Hashtable(); + replacement.put("Button.fgColor", "112233"); + UIManager.getInstance().setThemeProps(replacement); + button.refreshTheme(true); + assertNotNull(button.getHoverStyle()); + assertEquals(0x123456, button.getHoverStyle().getBgColor()); + assertEquals(0x112233, button.getHoverStyle().getFgColor(), + "removed theme hover properties must not survive as local overrides"); + } + + @Test + public void removingUnmodifiedThemeHoverDropsTheCachedStyle() { + Hashtable theme = new Hashtable(); + theme.put("Button.hover#bgColor", "44ff88"); + UIManager.getInstance().setThemeProps(theme); + Button button = new Button("theme hover"); + assertNotNull(button.getHoverStyle()); + UIManager.getInstance().setThemeProps(new Hashtable()); + button.refreshTheme(true); + assertNull(button.getHoverStyle()); + } + + @Test + public void inlineAllStylesOverlayHoverBeforeAndAfterThemeRefresh() { + for (boolean merge : new boolean[]{true, false}) { + Hashtable theme = new Hashtable(); + theme.put("Button.hover#bgColor", "445566"); + theme.put("Button.hover#fgColor", "112233"); + UIManager.getInstance().setThemeProps(theme); + Button button = new Button("inline hover"); + button.setInlineStylesTheme(new MutableResource()); + button.setInlineAllStyles("fgColor:ff0000; padding:7px; font:18px"); + button.setHovered(true); + assertEquals(0xff0000, button.getStyle().getFgColor()); + assertEquals(7, button.getStyle().getPaddingTop()); + assertEquals(button.getUnselectedStyle().getFont().getPixelSize(), button.getStyle().getFont().getPixelSize()); + assertEquals(0x445566, button.getStyle().getBgColor(), "unspecified properties retain the hover theme"); + + button.setInlineAllStyles("fgColor:00ff00; padding:9px; font:20px"); + assertEquals(0x00ff00, button.getStyle().getFgColor(), "changing inline-all invalidates cached hover"); + Hashtable replacement = new Hashtable(); + replacement.put("Button.hover#bgColor", "abcdef"); + replacement.put("Button.hover#fgColor", "654321"); + UIManager.getInstance().setThemeProps(replacement); + button.refreshTheme(merge); + assertEquals(0x00ff00, button.getStyle().getFgColor()); + assertEquals(9, button.getStyle().getPaddingTop()); + assertEquals(button.getUnselectedStyle().getFont().getPixelSize(), button.getStyle().getFont().getPixelSize()); + assertEquals(0xabcdef, button.getStyle().getBgColor()); + } + } + + @Test + public void inlineAllDoesNotCreateAnUndeclaredHoverStateOrBypassResourceRequirement() { + UIManager.getInstance().setThemeProps(new Hashtable()); + Button legacy = new Button("legacy inline"); + legacy.setInlineStylesTheme(new MutableResource()); + legacy.setInlineAllStyles("fgColor:ff0000"); + legacy.setHovered(true); + assertNull(legacy.getHoverStyle()); + assertEquals(0xff0000, legacy.getStyle().getFgColor()); + + Hashtable theme = new Hashtable(); + theme.put("Button.hover#fgColor", "112233"); + UIManager.getInstance().setThemeProps(theme); + Button noResources = new Button("no inline resource context"); + noResources.setInlineAllStyles("fgColor:ff0000"); + noResources.setHovered(true); + assertEquals(0x112233, noResources.getStyle().getFgColor()); + } + +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DesktopNativeThemeParityTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DesktopNativeThemeParityTest.java new file mode 100644 index 00000000000..dd0f012fed3 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DesktopNativeThemeParityTest.java @@ -0,0 +1,290 @@ +/* + * 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.plaf; + +import com.codename1.ui.Button; +import com.codename1.ui.util.Resources; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Holds the three desktop native themes -- Windows Fluent, macOS Aqua and GNOME + * Adwaita -- to the same surface as each other. + * + *

They are three separate hand-written CSS files by deliberate choice: a shared + * base would make "what you read is what compiles" false, which is what makes the + * strictNoCef failure messages actionable. The cost of that choice is drift. A UIID + * added to Fluent and forgotten in Aqua does not fail anything -- the missing UIID + * falls back to the blank default style, which is white-on-black and looks like a + * theme bug reported months later by a user on the one platform nobody tested. + * + *

So the drift is closed by a test instead of by structure. This asserts the + * three define the same UIIDs, the same {@code #Constants} keys, and a {@code } + * counterpart for every UIID that needs one. It is not a fidelity measurement and + * needs no golden captures, so unlike the fidelity gate it protects the themes from + * the moment they land. + * + *

Loads each {@code .res} straight from the repo's {@code Themes/} build output + * and silently skips when absent, the same convention as + * {@link NativeThemeBindingsTest} -- the test only fires when a freshly-built native + * theme is on disk. + */ +public class DesktopNativeThemeParityTest extends UITestBase { + + private static final String[] DESKTOP_THEMES = { + "WindowsFluentTheme.res", + "MacOSAquaTheme.res", + "GnomeAdwaitaTheme.res", + }; + + /// Accent colour each desktop theme declares, as its platform's default: + /// Windows SystemAccentColor, NSColor.controlAccentColor, Adwaita blue. + private static final String[][] ACCENTS = { + {"WindowsFluentTheme.res", "0078d4"}, + {"MacOSAquaTheme.res", "007aff"}, + {"GnomeAdwaitaTheme.res", "3584e4"}, + }; + + @Test + public void desktopThemesDefineTheSameUiids() throws Exception { + Map> byTheme = new LinkedHashMap>(); + for (String name : DESKTOP_THEMES) { + Hashtable theme = loadTheme(name); + if (theme == null) { + return; // not built; skip like the sibling native-theme tests + } + byTheme.put(name, uiids(theme, false)); + } + assertSameSets(byTheme, "UIID"); + } + + @Test + public void desktopThemesDefineTheSameConstants() throws Exception { + Map> byTheme = new LinkedHashMap>(); + for (String name : DESKTOP_THEMES) { + Hashtable theme = loadTheme(name); + if (theme == null) { + return; + } + TreeSet constants = new TreeSet(); + for (Object k : theme.keySet()) { + String key = k.toString(); + // Theme constants are the "@name" keys. The "@cn1-bind:" family is a + // per-UIID binding record rather than a constant, and is covered by + // the UIID comparison instead. + if (key.startsWith("@") && !key.startsWith("@cn1-bind:")) { + constants.add(key); + } + } + byTheme.put(name, constants); + } + assertSameSets(byTheme, "theme constant"); + } + + @Test + public void everyDesktopUiidHasADarkCounterpart() throws Exception { + for (String name : DESKTOP_THEMES) { + Hashtable theme = loadTheme(name); + if (theme == null) { + return; + } + TreeSet light = uiids(theme, false); + TreeSet dark = uiids(theme, true); + List missing = new ArrayList(); + for (String uiid : light) { + if (dark.contains(uiid) || !paintsAColour(theme, uiid)) { + continue; + } + missing.add(uiid); + } + if (!missing.isEmpty()) { + fail(name + " defines " + missing.size() + " UIID(s) that paint a colour in light" + + " mode and have no $Dark counterpart, so they keep their LIGHT colours on" + + " a dark form: " + missing + + ".\n The usual cause is cn1-derive. It is flattened against the light" + + " parent at compile time, so a UIID that derives only in the light block" + + " gets a concrete copy of the light colours and no $Dark entry at all --" + + " repeat the derive inside the dark block."); + } + } + } + + @Test + public void desktopAccentColorRetunesTheButton() throws Exception { + for (String[] row : ACCENTS) { + Hashtable theme = loadTheme(row[0]); + if (theme == null) { + return; + } + assertEquals(row[1].toUpperCase(), theme.get("@accent-color"), + row[0] + " must export its platform's default accent as a theme constant"); + + UIManager.getInstance().setThemeProps(theme); + Hashtable override = new Hashtable(); + override.put("@accent-color", "ff2d95"); + UIManager.getInstance().addThemeProps(override); + + // Whichever UIID the theme binds to --accent-color must follow the + // override. Without this the binding pass is inert and an application + // that retunes the accent silently gets the theme default. + boolean anyBound = false; + for (Object k : new TreeSet(theme.keySet())) { + String key = k.toString(); + if (!key.startsWith("@cn1-bind:") || !key.endsWith(".bgColor")) { + continue; + } + if (!"accent-color".equals(theme.get(key))) { + continue; + } + String uiid = key.substring("@cn1-bind:".length(), key.length() - ".bgColor".length()); + Button b = new Button("x"); + b.setUIID(uiid); + assertEquals(0xff2d95, b.getUnselectedStyle().getBgColor(), + row[0] + ": " + uiid + ".bgColor is bound to --accent-color but did not retune"); + anyBound = true; + } + assertTrue(anyBound, row[0] + " binds no UIID background to --accent-color, so nothing" + + " follows the OS accent colour"); + } + } + + /// Whether a UIID actually paints something whose colour is appearance-specific. + /// + /// A fully transparent UIID -- a scroll TRACK, a spacer -- has nothing to recolour + /// for dark mode and legitimately has no $Dark counterpart, so requiring one would + /// be bookkeeping rather than a defect. A UIID with a foreground colour, or an + /// opaque background, renders wrong on the other appearance and must have one. + private static boolean paintsAColour(Hashtable theme, String uiid) { + if (theme.get(uiid + ".fgColor") != null) { + return true; + } + if (theme.get(uiid + ".bgColor") == null) { + return false; + } + Object transparency = theme.get(uiid + ".transparency"); + if (transparency == null) { + return true; + } + try { + return Integer.parseInt(transparency.toString().trim()) > 0; + } catch (NumberFormatException ex) { + return true; + } + } + + private void assertSameSets(Map> byTheme, String what) { + TreeSet union = new TreeSet(); + for (TreeSet s : byTheme.values()) { + union.addAll(s); + } + List problems = new ArrayList(); + for (String entry : union) { + List absent = new ArrayList(); + for (Map.Entry> e : byTheme.entrySet()) { + if (!e.getValue().contains(entry)) { + absent.add(e.getKey()); + } + } + if (!absent.isEmpty()) { + problems.add(entry + " missing from " + absent); + } + } + if (!problems.isEmpty()) { + fail("The desktop native themes have drifted apart -- " + problems.size() + " " + + what + "(s) are not defined by all three. A UIID one theme lacks falls back" + + " to the blank default style (white on black), which reads as a theme bug on" + + " exactly one platform:\n " + String.join("\n ", problems)); + } + } + + /// The UIID set a theme defines, taken from the plain "." keys. + /// State-prefixed keys ("sel#", "press#", "dis#", "hover#") name the same UIIDs + /// and are skipped so a theme that styles one extra state does not read as an + /// extra UIID. + private static TreeSet uiids(Hashtable theme, boolean dark) { + TreeSet out = new TreeSet(); + for (Object k : theme.keySet()) { + String key = k.toString(); + boolean isDark = key.startsWith("$Dark"); + if (isDark != dark) { + continue; + } + if (isDark) { + key = key.substring("$Dark".length()); + } + if (key.startsWith("@") || key.indexOf('#') >= 0) { + continue; + } + int dot = key.lastIndexOf('.'); + if (dot > 0) { + out.add(key.substring(0, dot)); + } + } + return out; + } + + private static Hashtable loadTheme(String fileName) throws Exception { + File themeFile = locateNativeTheme(fileName); + if (themeFile == null) { + return null; + } + Resources res; + InputStream stream = new FileInputStream(themeFile); + try { + res = Resources.open(stream); + } finally { + stream.close(); + } + String[] names = res.getThemeResourceNames(); + assertNotNull(names, fileName + " has no theme resource"); + return res.getTheme(names[0]); + } + + private static File locateNativeTheme(String fileName) { + File cwd = new File(".").getAbsoluteFile(); + for (int i = 0; i < 6 && cwd != null; i++) { + File candidate = new File(cwd, "Themes/" + fileName); + if (candidate.isFile()) { + return candidate; + } + cwd = cwd.getParentFile(); + } + return null; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/NativeThemeLightDarkConsistencyTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/NativeThemeLightDarkConsistencyTest.java index a51e8660670..4edfdfaa342 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/NativeThemeLightDarkConsistencyTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/NativeThemeLightDarkConsistencyTest.java @@ -1,3 +1,25 @@ +/* + * 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.plaf; import com.codename1.junit.UITestBase; @@ -51,6 +73,21 @@ public void androidMaterialLightAndDarkBorderShapesMatch() throws Exception { assertLightDarkBorderShapeParity("AndroidMaterialTheme.res"); } + @Test + public void windowsFluentLightAndDarkBorderShapesMatch() throws Exception { + assertLightDarkBorderShapeParity("WindowsFluentTheme.res"); + } + + @Test + public void macosAquaLightAndDarkBorderShapesMatch() throws Exception { + assertLightDarkBorderShapeParity("MacOSAquaTheme.res"); + } + + @Test + public void gnomeAdwaitaLightAndDarkBorderShapesMatch() throws Exception { + assertLightDarkBorderShapeParity("GnomeAdwaitaTheme.res"); + } + private void assertLightDarkBorderShapeParity(String fileName) throws Exception { File themeFile = locateNativeTheme(fileName); if (themeFile == null) { diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 459380e47ad..f9c6cbbe515 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -1791,6 +1791,12 @@ public boolean requiresCaptureHtml() { if (el.requiresBackgroundImageGeneration(pressedStyle) || el.requiresImageBorder(pressedStyle)) { return true; } + if (el.declaresHover()) { + Map hoverStyle = el.getHover().getFlattenedStyle(); + if (el.requiresBackgroundImageGeneration(hoverStyle) || el.requiresImageBorder(hoverStyle)) { + return true; + } + } Map disabledStyle = el.getDisabled().getFlattenedStyle(); if (el.requiresBackgroundImageGeneration(disabledStyle) || el.requiresImageBorder(disabledStyle)) { return true; @@ -1826,6 +1832,13 @@ public String generateCaptureHtml() { if (el.requiresBackgroundImageGeneration(pressedStyle) || el.requiresImageBorder(pressedStyle)) { sb.append(el.getPressed().getEmptyHtmlWithId(name+".press", pressedStyle)); } + // Keep the DOM IDs aligned with the hover processors registered by createImageBorders. + if (el.declaresHover()) { + Map hoverStyle = el.getHover().getFlattenedStyle(); + if (el.requiresBackgroundImageGeneration(hoverStyle) || el.requiresImageBorder(hoverStyle)) { + sb.append(el.getHover().getEmptyHtmlWithId(name+".hover", hoverStyle)); + } + } Map disabledStyle = el.getDisabled().getFlattenedStyle(); if (el.requiresBackgroundImageGeneration(disabledStyle) || el.requiresImageBorder(disabledStyle)) { sb.append(el.getDisabled().getEmptyHtmlWithId(name+".dis", disabledStyle)); @@ -2370,12 +2383,14 @@ public void updateResources() { Map unselectedStyles = el.getUnselected().getFlattenedStyle(); Map selectedStyles = el.getSelected().getFlattenedStyle(); Map pressedStyles = el.getPressed().getFlattenedStyle(); + Map hoverStyles = el.getHover().getFlattenedStyle(); Map disabledStyles = el.getDisabled().getFlattenedStyle(); Element selected = el.getSelected(); String selId = id+".sel"; String unselId = id; String pressedId = id+".press"; + String hoverId = id+".hover"; String disabledId = id+".dis"; currToken = "padding"; res.setThemeProperty(themeName, unselId+".padding", el.getThemePadding(unselectedStyles)); @@ -2690,7 +2705,99 @@ public void updateResources() { res.setThemeProperty(themeName, disabledId+"#border", el.getThemeBorder(disabledStyles)); } - + // --- hover --------------------------------------------------------- + // The desktop state. Emitted as its own `.hover#` prefix rather than + // folded into `.sel#`, because a desktop control distinguishes "the + // pointer is over me" from "I have keyboard focus" and draws them + // differently -- Fluent and Adwaita both do. The runtime only builds a + // hover style when a UIID actually declares one (Component.getHoverStyle), + // so a theme that never mentions hover emits nothing here and costs + // nothing there. + currToken = "hover padding"; + // Emitted only for a UIID that actually declares a hover rule. Every other + // state writes its padding, margin, font and the rest unconditionally, which is + // fine for states the runtime only consults on components that opt into them. + // Hover is different on both ends: it would add a full key set to every UIID of + // every theme ever recompiled, and Component.getHoverStyle decides whether a + // component has a hover style by asking the theme whether it declares one. An + // unconditional emission would answer yes everywhere and hand back a style built + // from blank defaults -- the exact regression that guard exists to prevent. + if (el.declaresHover()) { + currToken = "hover iconGap"; + // Recomputed rather than reusing the fan-out locals above: the icon gap is + // derived from the UNSELECTED style and shared by every state, and gapUnit is + // scoped to the else branch that produced it. + float hoverGap = el.getThemeIconGap(unselectedStyles); + if (hoverGap < 0) { + res.setThemeProperty(themeName, hoverId+"#iconGap", null); + res.setThemeProperty(themeName, hoverId+"#iconGapUnit", null); + } else { + res.setThemeProperty(themeName, hoverId+"#iconGap", hoverGap); + res.setThemeProperty(themeName, hoverId+"#iconGapUnit", el.getThemeIconGapUnit(unselectedStyles)); + } + currToken = "hover padding"; + res.setThemeProperty(themeName, hoverId+"#padding", el.getThemePadding(hoverStyles)); + currToken = "hover padUnit"; + res.setThemeProperty(themeName, hoverId+"#padUnit", el.getThemePaddingUnit(hoverStyles)); + currToken = "hover margin"; + res.setThemeProperty(themeName, hoverId+"#margin", el.getThemeMargin(hoverStyles)); + currToken = "hover marUnit"; + res.setThemeProperty(themeName, hoverId+"#marUnit", el.getThemeMarginUnit(hoverStyles)); + currToken = "hover elevation"; + if (hoverStyles.containsKey("elevation")) { + res.setThemeProperty(themeName, hoverId + "#elevation", el.getThemeElevation(hoverStyles)); + } + currToken = "hover letterSpacing"; + if (hoverStyles.containsKey("letter-spacing")) { + res.setThemeProperty(themeName, hoverId+"#letterSpacing", el.getThemeLetterSpacing(hoverStyles)); + } + currToken = "hover surface"; + if (hoverStyles.containsKey("surface")) { + res.setThemeProperty(themeName, hoverId + "#surface", el.getThemeSurface(hoverStyles)); + } + currToken = "hover fgColor"; + res.setThemeProperty(themeName, hoverId+"#fgColor", el.getThemeFgColor(hoverStyles)); + emitColorBinding(res, themeName, hoverId+"#fgColor", el.getHover(), "color"); + currToken = "hover fgAlpha"; + res.setThemeProperty(themeName, hoverId+"#fgAlpha", el.getThemeFgAlpha(hoverStyles)); + currToken = "hover bgColor"; + res.setThemeProperty(themeName, hoverId+"#bgColor", el.getThemeBgColor(hoverStyles)); + emitColorBinding(res, themeName, hoverId+"#bgColor", el.getHover(), "background-color"); + currToken = "hover transparency"; + res.setThemeProperty(themeName, hoverId+"#transparency", el.getThemeTransparency(hoverStyles)); + currToken = "hover align"; + res.setThemeProperty(themeName, hoverId+"#align", el.getThemeAlignment(hoverStyles)); + currToken = "hover font"; + res.setThemeProperty(themeName, hoverId+"#font", el.getThemeFont(hoverStyles)); + currToken = "hover textDecoration"; + res.setThemeProperty(themeName, hoverId+"#textDecoration", el.getThemeTextDecoration(hoverStyles)); + currToken = "hover bgGradient"; + res.setThemeProperty(themeName, hoverId+"#bgGradient", el.getThemeBgGradient(hoverStyles)); + currToken = "hover bgType"; + res.setThemeProperty(themeName, hoverId+"#bgType", el.getThemeBgType(hoverStyles)); + currToken = "hover bgGradientEx"; + res.setThemeProperty(themeName, hoverId+"#bgGradientEx", el.getThemeGradient(hoverStyles)); + emitFilterBlur(res, hoverId+"#filterBlur", el.getFilterBlurRadius(hoverStyles)); + emitFilterBlur(res, hoverId+"#backdropFilterBlur", el.getBackdropFilterBlurRadius(hoverStyles)); + emitFilterColorMatrix(res, hoverId+"#filterColorMatrix", el.getFilterColorMatrix(hoverStyles)); + emitFilterColorMatrix(res, hoverId+"#backdropFilterColorMatrix", el.getBackdropFilterColorMatrix(hoverStyles)); + currToken = "hover derive"; + res.setThemeProperty(themeName, hoverId+"#derive", el.getThemeDerive(hoverStyles, ".hover")); + currToken = "hover opacity"; + res.setThemeProperty(themeName, hoverId+"#opacity", el.getThemeOpacity(hoverStyles)); + currToken = "hover bgImage"; + if (el.hasBackgroundImage(hoverStyles) && !el.requiresBackgroundImageGeneration(hoverStyles) && !el.requiresImageBorder(hoverStyles)) { + Image[] imageId = getBackgroundImages(hoverStyles); + if (imageId != null && imageId.length > 0) { + + res.setThemeProperty(themeName, hoverId+"#bgImage", imageId[0]); + } + } + currToken = "hover border"; + if (!el.requiresImageBorder(hoverStyles) && !el.requiresBackgroundImageGeneration(hoverStyles)) { + res.setThemeProperty(themeName, hoverId+"#border", el.getThemeBorder(hoverStyles)); + } + } } catch (RuntimeException t) { System.err.println("An error occurred while updating resources for UIID "+id+". Processing property "+currToken); throw t; @@ -3402,14 +3509,14 @@ public static interface WebViewProvider { private void enforceNoCef() { List offenders = new ArrayList(); - String[] states = new String[] {"unselected", "selected", "pressed", "disabled"}; + String[] states = new String[] {"unselected", "selected", "pressed", "hover", "disabled"}; for (String id : elements.keySet()) { if (!isModified(id)) { continue; } Element e = (Element) elements.get(id); Element[] stateElements = new Element[] { - e.getUnselected(), e.getSelected(), e.getPressed(), e.getDisabled() + e.getUnselected(), e.getSelected(), e.getPressed(), e.getHover(), e.getDisabled() }; for (int i = 0; i < stateElements.length; i++) { Map styles = @@ -3596,6 +3703,57 @@ public void createImageBorders(WebViewProvider webviewProvider) { } } + // Same declaration guard as the property emission: no hover rule, no work, + // and above all no image generated under a .hover# key nothing asked for. + if (e.declaresHover()) { + Element hover = e.getHover(); + Map hoverStyles = (Map) hover.getFlattenedStyle(); + + b = hover.createBorder(hoverStyles); + Border hoverBorder = b; + if (e.requiresImageBorder(hoverStyles)) { + if (!borders.contains(b)) { + borders.add(b); + resm.addImageProcessor(id + ".hover", (img) -> { + Insets insets = hover.getImageBorderInsets(hoverStyles, img.getWidth(), img.getHeight()); + + resm.targetDensity = getSourceDensity(hoverStyles, resm.targetDensity); + com.codename1.ui.plaf.Border border = resm.create9PieceBorder(img, id, (int) insets.top, (int) insets.right, (int) insets.bottom, (int) insets.left); + + resm.put(id + ".hover#border", border); + hoverBorder.border = border; + resm.targetDensity = targetDensity; + }); + } else { + onComplete.add(() -> { + resm.put(id + ".hover#border", borders.get(borders.indexOf(hoverBorder)).border); + }); + + } + } else if (e.requiresBackgroundImageGeneration(hoverStyles)) { + if (!borders.contains(b)) { + borders.add(b); + resm.addImageProcessor(id + ".hover", (img) -> { + int i = 1; + while (res.containsResource(id + "_" + i + ".png")) { + i++; + } + String prefix = id + "_" + i + ".png"; + resm.targetDensity = getSourceDensity(hoverStyles, resm.targetDensity); + Image im = resm.storeImage(EncodedImage.create(ResourcesMutator.toPngOrJpeg(img)), prefix, false); + hoverBorder.imageId = prefix; + resm.put(id + ".hover#bgImage", im/*res.findId(im, true)*/); + resm.targetDensity = targetDensity; + //resm.put(id+".hover#bgType", Style.B) + }); + } else { + onComplete.add(() -> { + resm.put(id + ".hover#bgImage", res.findId(hoverBorder.imageId, true)); + }); + } + } + } + Element disabled = e.getDisabled(); Map disabledStyles = (Map) disabled.getFlattenedStyle(); @@ -3759,7 +3917,7 @@ public boolean canBeAchievedWithRoundRectBorder(Map styles) } } } - if (hasUnequalBorders() || (this.hasGradient() && !supportedGradient) || !isBorderLineOrNone() || !isNone(backgroundImageUrl) || hasBoxShadow() || hasBorderImage()) { + if (hasUnequalBorders() || (this.hasGradient() && !supportedGradient) || !isBorderLineOrNone() || !isNone(backgroundImageUrl) || (hasBoxShadow() && !boxShadowIsNativeRoundRect(styles)) || hasBorderImage()) { return false; } @@ -3930,6 +4088,98 @@ public boolean hasBoxShadow() { return !isNone(bs); } + /// True when this rule's `box-shadow` is one {@link com.codename1.ui.plaf.RoundRectBorder} + /// can draw natively, so it needs no CEF-rasterized image border. + /// + /// `createRoundRectBorder` already translates every shadow property the resource + /// format carries -- shadowX, shadowY, shadowBlur, shadowSpread and shadowOpacity. + /// That code was simply unreachable: the predicate above used to reject any + /// box-shadow outright, so an elevated surface fell through to rasterization and, + /// in a native theme, to a hard `strictNoCef` failure. + /// + /// Unsupported shadow geometry stays on the raster path: + /// + /// - Any blur other than explicit zero: software rendering has no CSS blur halo. + /// - An omitted, zero, or negative spread: the software painter requires spread. + /// - An `inset` shadow. RoundRectBorder only draws an outer drop shadow. + /// - A shadow tinted anything other than black. The reader never reads a shadow + /// colour (`Resources` cases 0xff13 and 0xff15 stop at shadowY), so a coloured + /// shadow would round-trip to black and the hue would vanish silently -- which is + /// worse than refusing it, because nothing downstream could tell. + /// + /// An alpha on the colour is fine: it is carried as shadowOpacity, which is exactly + /// what it means. + public boolean boxShadowIsNativeRoundRect(Map styles) { + if (!hasBoxShadow()) { + return false; + } + LexicalUnit inset = styles.get("cn1-box-shadow-inset"); + if (inset != null && "inset".equals(inset.getStringValue())) { + return false; + } + LexicalUnit color = styles.get("cn1-box-shadow-color"); + if (color != null && !isNone(color) && (getColorInt(color) & 0xffffff) != 0) { + return false; + } + // A small blur is not safe merely because it is smaller than spread: + // the software target has no separate CSS blur halo allocation, and the + // cached fast path skips Gaussian blur. Keep every nonzero CSS blur on + // the raster path until the native painter can preserve those semantics. + // Requiring explicit zero also avoids the constructor's default blur. + ScaledUnit blur = (ScaledUnit) styles.get("cn1-box-shadow-blur"); + if (blur == null || blur.getNumericValue() != 0) { + return false; + } + // CSS omitted spread is zero, not RoundRectBorder's density-dependent default. + // The software painter needs at least one spread pixel; retaining its constructor + // default would silently change CSS geometry instead of representing zero spread. + ScaledUnit spread = (ScaledUnit) styles.get("cn1-box-shadow-spread"); + if (spread == null || spread.getNumericValue() <= 0 + || (spread.getLexicalUnitType() == LexicalUnit.SAC_PIXEL + && spread.getNumericValue() < 1)) { + return false; + } + switch (spread.getLexicalUnitType()) { + case LexicalUnit.SAC_PIXEL: + case LexicalUnit.SAC_MILLIMETER: + case LexicalUnit.SAC_CENTIMETER: + case LexicalUnit.SAC_INCH: + case LexicalUnit.SAC_POINT: + break; + default: + return false; // The native constructor cannot translate relative spread units. + } + // RoundRectBorder stores positions as ratios in [0,1]. Larger offsets + // would be clipped; increasing spread to fit would change the requested shadow. + for (String axis : new String[]{"cn1-box-shadow-h", "cn1-box-shadow-v"}) { + ScaledUnit offset = (ScaledUnit) styles.get(axis); + if (offset == null) { + continue; + } + switch (offset.getLexicalUnitType()) { + case LexicalUnit.SAC_INTEGER: + case LexicalUnit.SAC_REAL: + // Explicit CN1 unitless properties are ratios, not CSS lengths. + if (offset.getNumericValue() < 0 || offset.getNumericValue() > 1) { + return false; + } + break; + case LexicalUnit.SAC_PIXEL: + case LexicalUnit.SAC_MILLIMETER: + case LexicalUnit.SAC_CENTIMETER: + case LexicalUnit.SAC_INCH: + case LexicalUnit.SAC_POINT: + if (Math.abs(shadowLengthMM(offset)) > shadowLengthMM(spread)) { + return false; + } + break; + default: + return false; + } + } + return true; + } + public boolean hasBorderImage() { String bs = borderImage; if (bs != null) { @@ -3945,6 +4195,16 @@ public boolean hasBorderImage() { + private static float shadowLengthMM(ScaledUnit value) { + float amount = (float)value.getNumericValue(); + switch (value.getLexicalUnitType()) { + case LexicalUnit.SAC_MILLIMETER: return amount; + case LexicalUnit.SAC_CENTIMETER: return amount * 10f; + case LexicalUnit.SAC_INCH: return amount * 25.4f; + default: return amount / 72f * 25.4f; // px and pt use the compiler's 72dpi scale. + } + } + private static boolean eq(Object o1, Object o2) { return o1 == null ? o1 == null : o1.equals(o2); } @@ -4019,6 +4279,7 @@ public class Element { Element unselected; Element selected; Element pressed; + Element hover; Element disabled; public String getChecksum() { @@ -4027,62 +4288,34 @@ public String getChecksum() { .append(";UNSELECTED=").append(this.getFlattenedUnselectedStyle()) .append(";SELECTED=").append(this.getFlattenedSelectedStyle()) .append(";PRESSED=").append(this.getFlattenedPressedStyle()) + .append(";HOVER=").append(this.getFlattenedHoverStyle()) .append(";DISABLED=").append(this.getFlattenedDisabledStyle()); return generateMD5(sb.toString()); } - Insets getBoxShadowPadding(Map style) { - Insets i = new Insets(); - ScaledUnit boxShadow = (ScaledUnit)style.get("cn1-box-shadow-h"); - ScaledUnit tmp = boxShadow; - while (tmp != null) { - tmp = (ScaledUnit)tmp.getPreviousLexicalUnit(); - if (tmp != null) { - boxShadow = tmp; - } + private float shadowCapturePixels(LexicalUnit value) { + if (value == null || (value.getLexicalUnitType() == LexicalUnit.SAC_IDENT + && "none".equals(value.getStringValue()))) { + return 0; } + return ((ScaledUnit)value).getPixelValue(); + } - if (isNone(boxShadow)) { + Insets getBoxShadowPadding(Map style) { + Insets i = new Insets(); + LexicalUnit inset = style.get("cn1-box-shadow-inset"); + if (inset != null && "inset".equals(inset.getStringValue())) { return i; } + // Read the parsed properties, not the original token chain: omitted spread + // must not consume the trailing color as a length, and normalized zero offsets + // need no linked tokens to recover inset or the other shadow dimensions. + double hShadow = shadowCapturePixels(style.get("cn1-box-shadow-h")); + double vShadow = shadowCapturePixels(style.get("cn1-box-shadow-v")); + double blur = shadowCapturePixels(style.get("cn1-box-shadow-blur")); + double spread = shadowCapturePixels(style.get("cn1-box-shadow-spread")); - ScaledUnit insetUnit = boxShadow; - while (insetUnit != null) { - if ("inset".equals(insetUnit.getStringValue())) { - return i; - } - insetUnit = (ScaledUnit)insetUnit.getNextLexicalUnit(); - } - - double hShadow = boxShadow.getPixelValue(); - boxShadow = (ScaledUnit)boxShadow.getNextLexicalUnit(); - - double vShadow = 0; - if (boxShadow == null) { - boxShadow = (ScaledUnit)style.get("cn1-box-shadow-v"); - } - if (boxShadow != null) { - vShadow = boxShadow.getPixelValue(); - boxShadow = (ScaledUnit)boxShadow.getNextLexicalUnit(); - } - - double blur = 0; - if (boxShadow == null) { - boxShadow = (ScaledUnit)style.get("cn1-box-shadow-blur"); - } - if (boxShadow != null) { - blur = boxShadow.getPixelValue(); - boxShadow = (ScaledUnit)boxShadow.getNextLexicalUnit(); - } - double spread = 0; - if (boxShadow == null) { - boxShadow = (ScaledUnit)style.get("cn1-box-shadow-spread"); - } - if (boxShadow != null) { - spread = boxShadow.getPixelValue(); - } - i.top = Math.max(0,(int)Math.ceil(spread - vShadow + blur/2)); i.left = Math.max(0, (int)Math.ceil(spread - hShadow + blur/2)); i.bottom = Math.max(0, (int)Math.ceil(spread + vShadow + blur/2)); @@ -4155,7 +4388,9 @@ String generateStyleCSS() { void setParent(String name) { Element parentEl = getElementByName(name); Element self = this; - if (this.isSelectedStyle() || this.isDisabledStyle() || this.isDisabledStyle() || this.isUnselectedStyle()) { + // State elements must keep their owning UIID as parent so flattening + // follows the derived UIID's matching state rather than its normal style. + if (this.isSelectedStyle() || this.isDisabledStyle() || this.isUnselectedStyle() || this.isHoverStyle()) { self = this.parent; } @@ -4225,6 +4460,51 @@ Map getFlattenedPressedStyle() { return out; } + /// True when a `.hover` rule was actually written for this UIID or for something it + /// inherits from. + /// + /// `getFlattenedHoverStyle()` cannot answer this: like every other state it inherits + /// the base UIID's properties, so it is non-empty for every element in the sheet and + /// would report that all of them declare hover. What distinguishes a real declaration + /// is the state Element carrying style properties of its OWN. This walk visits base + /// elements, not state wrappers, and deliberately reads `hover` directly rather than + /// calling `getHover()`, which would create the very thing being tested for. + boolean declaresHover() { + Element el = this; + while (el != null) { + if (el.hover != null && !el.hover.style.isEmpty()) { + return true; + } + // Base elements link directly to their derived parent; only state wrappers + // need the flattener's extra hop through their owning base element. + el = el.parent; + } + return false; + } + + Map getFlattenedHoverStyle() { + Map out = new LinkedHashMap(); + + LinkedList stack = new LinkedList(); + Element el = this; + if (!el.isHoverStyle()) { + el = el.getHover(); + } + while (el != null) { + stack.push(el.style); + + el = el.parent.parent; + if (el != null) { + el = el.getHover(); + } + } + + while (!stack.isEmpty()) { + out.putAll(stack.pop()); + } + return out; + } + Map getFlattenedUnselectedStyle() { Map out = new LinkedHashMap(); @@ -4294,6 +4574,17 @@ Map getFlattenedStyle() { out.putAll(parent.getFlattenedStyle()); } out.putAll(getFlattenedPressedStyle()); + } else if (this.isHoverStyle()) { + // Hover dispatches here like every other state. Without this branch a hover + // element fell through to the default below, which merges the parent chain + // with this element's RAW style map -- so a UIID that derives from one + // declaring .hover, without redeclaring hover itself, emitted the parent's + // normal properties and none of its hover ones, and the derived control + // silently lost its rollover colours. + if (parent != null) { + out.putAll(parent.getFlattenedStyle()); + } + out.putAll(getFlattenedHoverStyle()); } else { if (parent != null) { out.putAll(parent.getFlattenedStyle()); @@ -4344,6 +4635,14 @@ Element getDisabled() { return disabled; } + Element getHover() { + if (hover == null) { + hover = new Element(); + hover.parent = this; + } + return hover; + } + /// Returns the var name a given CSS property is bound to, walking /// the current Element first and falling back to its `parent` /// chain. State sub-Elements have their parent set to the @@ -4525,6 +4824,10 @@ boolean isPressedStyle() { } + boolean isHoverStyle() { + return parent != null && parent.hover == this; + } + boolean isDisabledStyle() { return parent != null && parent.disabled == this; } @@ -5880,6 +6183,15 @@ private int getShadowSpreadPx(com.codename1.ui.plaf.Border b) { private float calculateShadowRatio(com.codename1.ui.plaf.Border out, boolean spreadMM, float spreadMMVal, ScaledUnit value) { float val = (float)value.getNumericValue(); + if (out instanceof RoundRectBorder && spreadMM && spreadMMVal > 0) { + // Convert lengths using the compiler's physical-unit scale, not the + // headless Display's pixel conversion (which can return zero). + if (value.getLexicalUnitType() == LexicalUnit.SAC_INTEGER + || value.getLexicalUnitType() == LexicalUnit.SAC_REAL) { + return val; // Explicit CN1 unitless properties are position ratios. + } + return 0.5f - shadowLengthMM(value) / (2f * spreadMMVal); + } if (val == 0 || getShadowSpreadPx(out) == 0) { // leave alone if (val == 0) { @@ -6416,25 +6728,29 @@ private com.codename1.ui.plaf.Border createRoundRectBorder(Map style // switching dispatch fixes Dialog without changing iOS / // Android pixels (RoundRectBorder produces a visually- // equivalent rounded rect there). - if (b.canBeAchievedWithRoundRectBorder(styles) && b.hasBorderRadius() + // A box-shadow reaches RoundRectBorder here whether or not the rule also asked + // for a radius: an elevated surface with square corners is still a RoundRectBorder, + // just one with cornerRadius 0. Without the second term it would fall past this + // branch, past the CSSBorder branch (which rejects shadows outright) and into + // rasterization. + if (b.canBeAchievedWithRoundRectBorder(styles) + && (b.hasBorderRadius() || b.boxShadowIsNativeRoundRect(styles)) && !b.hasBorderImage() && !b.hasUnequalBorders()) { return createRoundRectBorder(styles); } @@ -7645,7 +7985,17 @@ public void apply(Element style, String property, LexicalUnit value) { case LexicalUnit.SAC_POINT: case LexicalUnit.SAC_INTEGER: case LexicalUnit.SAC_REAL: - apply(style, params[i++], value); + LexicalUnit shadowValue = value; + if (i < 2 && (value.getLexicalUnitType() == LexicalUnit.SAC_INTEGER + || value.getLexicalUnitType() == LexicalUnit.SAC_REAL) + && ((ScaledUnit) value).getNumericValue() == 0) { + // CSS shorthand 0 means zero pixels. Only the explicit CN1 + // shadow-h/v properties use unitless values as position ratios. + ScaledUnit unit = (ScaledUnit) value; + shadowValue = new ScaledUnit(new PixelUnit(0), unit.dpi, + unit.screenWidth, unit.screenHeight); + } + apply(style, params[i++], shadowValue); break; case LexicalUnit.SAC_RGBCOLOR: @@ -7713,6 +8063,8 @@ Element getElementForSelector(String media, Selector sel) { return parent.getUnselected(); case "pressed" : return parent.getPressed(); + case "hover" : + return parent.getHover(); case "disabled" : return parent.getDisabled(); default : @@ -7736,6 +8088,8 @@ Element getElementForSelector(String media, Selector sel) { return parent.getUnselected(); case "pressed" : return parent.getPressed(); + case "hover" : + return parent.getHover(); case "disabled" : return parent.getDisabled(); default : diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSBoxShadowNativeBorderTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSBoxShadowNativeBorderTest.java new file mode 100644 index 00000000000..e6bc1a122ca --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSBoxShadowNativeBorderTest.java @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import com.codename1.ui.plaf.RoundRectBorder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Hashtable; + +/** + * Supported unblurred positive-spread shadows compile to native rounded borders. + * + *

The resource format has always carried RoundRectBorder's shadow -- shadowBlur, + * shadowOpacity, shadowSpread, shadowX and shadowY all round-trip through + * {@code Resources} cases 0xff13 and 0xff15 -- and {@code createRoundRectBorder} has + * always translated every one of them. That code was unreachable: + * {@code canBeAchievedWithRoundRectBorder} rejected any {@code box-shadow} outright, so + * an elevated surface fell through to a CEF-rasterized 9-piece image border and, in a + * native theme compiled with {@code strictNoCef}, to a hard build failure. Every desktop + * design language is built on elevation, so the themes were approximating shadows with + * flat strokes.

+ * + *

These tests pin the boundary: what is now drawn natively, and what still legitimately + * needs rasterization because the format cannot express it.

+ */ +public class CSSBoxShadowNativeBorderTest { + + @BeforeAll + static void installHeadlessImplementation() throws Exception { + HeadlessTestSupport.installHeadlessImplementation(); + } + + /** + * The case the desktop themes need: a rounded, elevated surface. It must compile to a + * RoundRectBorder carrying the shadow, and must NOT ask for an image border. + */ + @Test + void testSimpleBlackShadowCompilesToRoundRectBorder() throws Exception { + Hashtable theme = compile("Card { background-color: #ffffff; border-radius: 2.1mm;" + + " box-shadow: 0 1px 0px 1px rgba(0,0,0,0.13); }"); + Object border = theme.get("Card.border"); + assertInstanceOf(RoundRectBorder.class, border, "Card.border"); + RoundRectBorder rr = (RoundRectBorder) border; + assertTrue(rr.getShadowOpacity() > 0, + "shadow opacity should carry the rgba alpha, was " + rr.getShadowOpacity()); + assertTrue(rr.getShadowSpread() > 0f, "explicit spread must survive"); + assertTrue(rr.getShadowBlur() == 0f, "explicit zero blur must survive"); + assertTrue(rr.getCornerRadius() > 0f, + "corner radius should survive, was " + rr.getCornerRadius()); + } + + /** + * An elevated surface with square corners is still a RoundRectBorder, just one with + * cornerRadius 0. Without this the rule falls past the RoundRectBorder branch, past the + * CSSBorder branch (which rejects shadows), and into rasterization. + */ + @Test + void testShadowWithoutRadiusStillCompilesToRoundRectBorder() throws Exception { + Hashtable theme = compile("Flat { background-color: #ffffff;" + + " box-shadow: 0 1px 0px 1px rgba(0,0,0,0.2); }"); + assertInstanceOf(RoundRectBorder.class, theme.get("Flat.border"), "Flat.border"); + } + + /** + * An inset shadow has no RoundRectBorder equivalent -- it only draws an outer drop + * shadow -- so it must still be refused rather than silently drawn as an outer one. + */ + @Test + void testInsetShadowIsStillRefusedInNoCefMode() throws Exception { + assertRefusedByStrictNoCef("Inset { background-color: #ffffff; border-radius: 2mm;" + + " box-shadow: inset 0 1px 0px 1px rgba(0,0,0,0.3); }", "inset shadow"); + } + + /** + * A tinted shadow must still be refused. The format stops at shadowY and never reads a + * shadow colour, so accepting one would round-trip it to black and drop the hue with + * nothing downstream able to tell. + */ + @Test + void testColouredShadowIsStillRefusedInNoCefMode() throws Exception { + assertRefusedByStrictNoCef("Tinted { background-color: #ffffff; border-radius: 2mm;" + + " box-shadow: 0 1px 0px 1px rgba(255,0,0,0.5); }", "coloured shadow"); + } + + @Test + void testExplicitNonpositiveSpreadRequiresRasterization() throws Exception { + for (String spread : new String[]{"", "0", "0px", "0mm", "-1px", "0.5px"}) { + for (String radius : new String[]{"", "border-radius: 2mm;"}) { + assertRefusedByStrictNoCef("Card { background-color: #ffffff; " + radius + + " box-shadow: 0 0 0px " + spread + " rgba(0,0,0,0.2); }", + "explicit spread " + spread); + } + } + } + + @Test + void testExplicitZeroBlurRemainsNativeAcrossSpreads() throws Exception { + for (String spread : new String[]{"1px", "4px", "8px"}) { + RoundRectBorder border = (RoundRectBorder) compile("Card { background-color: white;" + + " box-shadow: 0 0.5px 0 " + spread + " black; }").get("Card.border"); + assertTrue(border.getShadowBlur() == 0, "native blur must stay zero for " + spread); + assertTrue(border.getShadowSpread() > 0, "software shadow needs positive spread"); + } + } + + @Test + void testNonzeroBlursRequireRasterizationRegardlessOfSpread() throws Exception { + for (String spread : new String[]{"1px", "4px", "8px"}) { + for (String blur : new String[]{"0.5px", "4px", "12px", "0.1mm", "0.01cm", "0.5pt"}) { + for (String radius : new String[]{"", "border-radius: 2mm;"}) { + assertRefusedByStrictNoCef("Card { background-color: white; " + radius + + " box-shadow: 0 1px " + blur + " " + spread + " black; }", + "blur " + blur + " with spread " + spread); + } + } + } + } + + @Test + void testUnitlessZeroOffsetsMatchPixelZero() throws Exception { + RoundRectBorder unitless = (RoundRectBorder) compile("Card { background-color: #ffffff;" + + " box-shadow: 0 0 0px 1px rgba(0,0,0,0.2); }").get("Card.border"); + RoundRectBorder pixels = (RoundRectBorder) compile("Card { background-color: #ffffff;" + + " box-shadow: 0px 0px 0px 1px rgba(0,0,0,0.2); }").get("Card.border"); + assertTrue(unitless.getShadowX() == pixels.getShadowX(), "CSS zero x offsets must be equivalent"); + assertTrue(unitless.getShadowY() == pixels.getShadowY(), "CSS zero y offsets must be equivalent"); + RoundRectBorder ratios = (RoundRectBorder) compile("Card { background-color: #ffffff;" + + " box-shadow: 0px 0px 0px 1px rgba(0,0,0,0.2);" + + " cn1-box-shadow-h: 0; cn1-box-shadow-v: 0; }").get("Card.border"); + assertTrue(ratios.getShadowX() == 0 && ratios.getShadowY() == 0, + "explicit CN1 properties retain their ratio semantics"); + } + + @Test + void testFractionalNativeSpreadsKeepTheirPrecision() throws Exception { + String[] spreads = {"0.2mm", "0.02cm", "0.6pt", "1.5px"}; + float[] expectedMM = {0.2f, 0.2f, 0.6f * 25.4f / 72f, + 1.5f * 25.4f / 72f}; + for (int i = 0; i < spreads.length; i++) { + RoundRectBorder border = (RoundRectBorder) compile("Card { background-color: #ffffff;" + + " box-shadow: 0 0.1px 0px " + spreads[i] + " rgba(0,0,0,0.2); }").get("Card.border"); + assertTrue(Math.abs(border.getShadowSpread() - expectedMM[i]) < 0.0001f, + "fractional spread changed for " + spreads[i] + ": " + border.getShadowSpread()); + assertTrue(!Float.isInfinite(border.getShadowY()) && !Float.isNaN(border.getShadowY()), + "offset conversion must be independent of headless Display density"); + } + RoundRectBorder border = (RoundRectBorder) compile("Card { background-color: #ffffff;" + + " box-shadow: 0.5px 1.5px 0px 1.5px black; }").get("Card.border"); + assertTrue(Math.abs(border.getShadowX() - (0.5f - 0.5f / 3f)) < 0.0001f, + "fractional x offset must survive"); + assertTrue(Math.abs(border.getShadowY()) < 0.0001f, "fractional y offset must survive"); + assertTrue(border.getShadowBlur() == 0f, "explicit zero blur must survive"); + } + + @Test + void testOffsetsOutsideNativeSpreadRequireRasterization() throws Exception { + for (String offset : new String[]{"2px", "-2px", "0.1cm", "-1mm", "2pt"}) { + for (String axes : new String[]{offset + " 0", "0 " + offset}) { + assertRefusedByStrictNoCef("Card { background-color: white; border-radius: 2mm;" + + " box-shadow: " + axes + " 0px 1px black; }", "offset " + axes); + } + } + for (String ratio : new String[]{"-0.1", "1.1"}) { + assertRefusedByStrictNoCef("Card { background-color: white; border-radius: 2mm;" + + " box-shadow: 0 0 0px 1px black; cn1-box-shadow-h: " + ratio + "; }", + "position ratio " + ratio); + } + } + + @Test + void testOffsetsAtNativeSpreadBoundaryRemainSupported() throws Exception { + for (String axes : new String[]{"1px -1px", "-1px 1px", "0 0", "0.1cm -1mm"}) { + String spread = axes.contains("cm") ? "1mm" : "1px"; + RoundRectBorder border = (RoundRectBorder) compile("Card { background-color: white;" + + " box-shadow: " + axes + " 0px " + spread + " black; }").get("Card.border"); + assertTrue(border.getShadowX() >= 0 && border.getShadowX() <= 1, "x ratio in range"); + assertTrue(border.getShadowY() >= 0 && border.getShadowY() <= 1, "y ratio in range"); + } + } + + /** Compiles the sheet and returns the resulting theme properties. */ + private static Hashtable compile(String css) throws Exception { + Path cssFile = Files.createTempFile("cn1-box-shadow", ".css"); + Path resFile = Files.createTempFile("cn1-box-shadow", ".res"); + try { + Files.write(cssFile, css.getBytes(StandardCharsets.UTF_8)); + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = resFile.toFile(); + theme.res = new com.codename1.ui.util.EditableResourcesForCSS(resFile.toFile()); + theme.res.setTheme("Theme", new Hashtable()); + theme.updateResources(); + return theme.res.getTheme("Theme"); + } finally { + deleteIfExists(cssFile); + deleteIfExists(resFile); + } + } + + /** + * Asserts the sheet is rejected by the no-cef gate, which is the failure a native-theme + * build sees. strictNoCef is a static flag, so it is restored in a finally block or it + * leaks into every later test in the JVM. + */ + private static void assertRefusedByStrictNoCef(String css, String what) throws Exception { + Path cssFile = Files.createTempFile("cn1-box-shadow-reject", ".css"); + Path resFile = Files.createTempFile("cn1-box-shadow-reject", ".res"); + boolean previous = CSSTheme.strictNoCef; + try { + Files.write(cssFile, css.getBytes(StandardCharsets.UTF_8)); + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = resFile.toFile(); + theme.res = new com.codename1.ui.util.EditableResourcesForCSS(resFile.toFile()); + theme.res.setTheme("Theme", new Hashtable()); + CSSTheme.strictNoCef = true; + try { + theme.createImageBorders(null); + } catch (IllegalStateException expected) { + return; + } + throw new AssertionError("Expected " + what + " to be refused in no-cef mode"); + } finally { + CSSTheme.strictNoCef = previous; + deleteIfExists(cssFile); + deleteIfExists(resFile); + } + } + + private static void deleteIfExists(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + } + + private static void assertInstanceOf(Class expected, Object actual, String message) { + if (!expected.isInstance(actual)) { + throw new AssertionError(message + " expected a " + expected.getSimpleName() + + " but was " + (actual == null ? "null" : actual.getClass().getName())); + } + } + + private static void assertTrue(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSHoverStateTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSHoverStateTest.java new file mode 100644 index 00000000000..615473ff266 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSHoverStateTest.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Hashtable; + +/** + * The `.hover` state selector, which the desktop native themes are built on. + * + *

The behaviour worth defending is that hover is emitted only for a UIID that + * actually declares it. Every other state writes its padding, margin, font and the rest for + * every UIID unconditionally; doing that for hover would add a full key set to every theme + * recompiled after this change, and - far worse - would defeat the runtime guard, because + * {@code Component.getHoverStyle()} decides whether a component has a hover style by asking + * the theme whether it declares one. Emit hover everywhere and every component answers yes, + * then renders a style assembled from blank defaults the moment the pointer touches it.

+ */ +public class CSSHoverStateTest { + + @BeforeAll + static void installHeadlessImplementation() throws Exception { + HeadlessTestSupport.installHeadlessImplementation(); + } + + @Test + void testHoverSelectorCompilesToHoverPrefixedKeys() throws Exception { + Hashtable theme = compile("Button { color: #111111; background-color: #ffffff; }" + + "Button.hover { background-color: #f0f0f0; }"); + assertEquals("F0F0F0", theme.get("Button.hover#bgColor"), "hover bgColor"); + assertEquals("111111", theme.get("Button.fgColor"), "the base is untouched"); + } + + /** A UIID that says nothing about hover must contribute no hover keys at all. */ + @Test + void testUiidWithoutHoverRuleEmitsNoHoverKeys() throws Exception { + Hashtable theme = compile("Button { color: #111111; }" + + "Button.hover { background-color: #f0f0f0; }" + + "Label { color: #222222; }"); + for (Object key : theme.keySet()) { + String k = (String) key; + if (k.startsWith("Label") && k.indexOf("hover#") > -1) { + throw new AssertionError("Label declares no hover rule but emitted " + k); + } + } + } + + /** Nothing anywhere in a sheet without a single hover rule. */ + @Test + void testSheetWithoutAnyHoverRuleEmitsNoHoverKeys() throws Exception { + Hashtable theme = compile("Button { color: #111111; }" + + "Button.pressed { color: #222222; }" + + "Label { color: #333333; }"); + for (Object key : theme.keySet()) { + if (((String) key).indexOf("hover#") > -1) { + throw new AssertionError("no hover rule was written, yet " + key + " was emitted"); + } + } + } + + /** Dark-mode hover lands on the $Dark spelling the runtime looks for. */ + @Test + void testDarkHoverCompilesToDarkPrefixedKeys() throws Exception { + Hashtable theme = compile("Button { background-color: #ffffff; }" + + "Button.hover { background-color: #f0f0f0; }" + + "@media (prefers-color-scheme: dark) {" + + " Button.hover { background-color: #303030; }" + + "}"); + assertEquals("F0F0F0", theme.get("Button.hover#bgColor"), "light hover"); + assertEquals("303030", theme.get("$DarkButton.hover#bgColor"), "dark hover"); + } + + @Test + void testHoverInheritanceVisitsDirectAndTransitiveParents() throws Exception { + Hashtable theme = compile("Parent { color: #111111; }" + + "Parent.hover { background-color: #abcdef; }" + + "Child { cn1-derive: Parent; }" + + "Grandchild { cn1-derive: Child; }" + + "Unrelated { color: #222222; }"); + assertEquals("ABCDEF", theme.get("Child.hover#bgColor"), "direct parent hover"); + assertEquals("ABCDEF", theme.get("Grandchild.hover#bgColor"), "transitive parent hover"); + assertEquals(null, theme.get("Unrelated.hover#bgColor"), "unrelated UIID stays opt-in"); + } + + @Test + void testDeriveDeclaredOnHoverPreservesStateInheritanceAndOverrides() throws Exception { + Hashtable theme = compile("Base { background-color: #111111; color: #222222; }" + + "Base.hover { background-color: #abcdef; color: #123456; }" + + "Child { background-color: #ffffff; }" + + "Child.hover { cn1-derive: Base; color: #334455; }" + + "Grandchild.hover { cn1-derive: Child; }"); + assertEquals("FFFFFF", theme.get("Child.bgColor"), "own normal style remains intact"); + assertEquals("ABCDEF", theme.get("Child.hover#bgColor"), "hover derives the parent's hover background"); + assertEquals("334455", theme.get("Child.hover#fgColor"), "local hover overrides remain intact"); + assertEquals("ABCDEF", theme.get("Grandchild.hover#bgColor"), "transitive state-level derivation"); + assertEquals("334455", theme.get("Grandchild.hover#fgColor"), "transitive local override"); + } + + @Test + void testHoverOnlyRasterEffectTriggersCaptureAndHasAnHtmlElement() throws Exception { + assertHoverCapture("Button { background-color: #ffffff; }" + + "Button.hover { box-shadow: inset 0 2px 4px black; }", false); + } + + @Test + void testHoverCaptureCoexistsWithOtherStatesAndInheritance() throws Exception { + assertHoverCapture("Button { background-color: #ffffff; }" + + "Button.hover { box-shadow: 0 2px 4px rgba(255,0,0,0.5); }" + + "Child { cn1-derive: Button; }" + + "Other { box-shadow: inset 0 1px 3px black; }", true); + } + + private static void assertHoverCapture(String css, boolean inherited) throws Exception { + Path cssFile = Files.createTempFile("cn1-hover-capture", ".css"); + try { + Files.write(cssFile, css.getBytes(StandardCharsets.UTF_8)); + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + assertEquals(true, theme.requiresCaptureHtml(), "hover raster effects need capture"); + String html = theme.generateCaptureHtml(); + assertEquals(true, html.contains("id=\"Button.hover\""), "hover processor needs matching HTML"); + assertEquals(false, html.contains("id=\"Button\""), "native base style needs no capture"); + if (!inherited) { + assertEquals(true, html.contains("data-box-shadow-padding=\"0.0,0.0,0.0,0.0\""), + "inset shadows do not reserve outer capture padding"); + } + if (inherited) { + assertEquals(true, html.contains("id=\"Child.hover\""), "inherited hover capture"); + assertEquals(true, html.contains("id=\"Other\""), "existing state capture remains present"); + assertEquals(false, html.contains("id=\"Other.hover\""), "no hover capture without a declaration"); + } + } finally { + deleteIfExists(cssFile); + } + } + + private static Hashtable compile(String css) throws Exception { + Path cssFile = Files.createTempFile("cn1-hover", ".css"); + Path resFile = Files.createTempFile("cn1-hover", ".res"); + try { + Files.write(cssFile, css.getBytes(StandardCharsets.UTF_8)); + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = resFile.toFile(); + theme.res = new com.codename1.ui.util.EditableResourcesForCSS(resFile.toFile()); + theme.res.setTheme("Theme", new Hashtable()); + theme.updateResources(); + return theme.res.getTheme("Theme"); + } finally { + deleteIfExists(cssFile); + deleteIfExists(resFile); + } + } + + private static void deleteIfExists(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + } + + private static void assertEquals(Object expected, Object actual, String message) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw new AssertionError(message + " expected=" + expected + " actual=" + actual); + } + } +} diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index 4fc57a865d6..ddc83ff588a 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -381,6 +381,9 @@ + + + diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/DesktopNativeThemeSelectionTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/DesktopNativeThemeSelectionTest.java new file mode 100644 index 00000000000..25fdcf9d56f --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/DesktopNativeThemeSelectionTest.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if + * you need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.io.Properties; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class DesktopNativeThemeSelectionTest { + @org.junit.jupiter.api.AfterEach + void resetFontInventory() { + JavaSEPort.clearAvailableFontNamesLowercaseForTest(); + } + + @Test + void systemFaceUsesTheInstalledWindowsFamilyName() { + java.util.Set installed = new java.util.HashSet(); + installed.add("segoe ui variable"); + JavaSEPort.setAvailableFontNamesLowercaseForTest(installed); + assertEquals("Segoe UI Variable", JavaSEPort.defaultSystemFontForTheme("win", "/WindowsFluentTheme.res")); + installed.clear(); + installed.add("segoe ui"); + assertEquals("Segoe UI", JavaSEPort.defaultSystemFontForTheme("win", "/WindowsFluentTheme.res")); + } + + @Test + void mobileHintsDoNotSelectDesktopThemesInSimulatorOrPackagedApps() throws Exception { + String[] keys = {"codename1.arg.desktop.themeMode", "codename1.arg.nativeTheme", + "codename1.arg.cn1.nativeTheme"}; + String[] previous = new String[keys.length]; + for (int i = 0; i < keys.length; i++) { + previous[i] = System.getProperty(keys[i]); + System.clearProperty(keys[i]); + } + java.lang.reflect.Method resolver = JavaSEPort.class.getDeclaredMethod("resolveAutoNativeTheme", String.class); + resolver.setAccessible(true); + try { + for (String hint : new String[]{keys[1], keys[2]}) { + for (String mode : new String[]{"modern", "custom", "legacy"}) { + System.setProperty(hint, mode); + for (String host : new String[]{"win", "mac", "linux"}) { + assertNull(resolver.invoke(null, host), hint + "=" + mode); + assertEquals("/NativeTheme.res", JavaSEPort.resolvePackagedDesktopNativeTheme(host, new Properties())); + } + if ("custom".equals(mode)) { + assertNull(resolver.invoke(null, "ios")); + assertNull(resolver.invoke(null, "and")); + } else if ("modern".equals(mode)) { + assertEquals("iOSModernTheme", resolver.invoke(null, "ios")); + assertEquals("AndroidMaterialTheme", resolver.invoke(null, "and")); + } + } + System.clearProperty(hint); + } + System.setProperty(keys[1], "custom"); + System.setProperty(keys[0], "auto"); + assertEquals("WindowsFluentTheme", resolver.invoke(null, "win")); + assertEquals("/WindowsFluentTheme.res", JavaSEPort.resolvePackagedDesktopNativeTheme("win", new Properties())); + } finally { + for (int i = 0; i < keys.length; i++) { + if (previous[i] == null) { + System.clearProperty(keys[i]); + } else { + System.setProperty(keys[i], previous[i]); + } + } + } + } + + /// nativeTheme=native is the one cross-platform value that also reaches desktop. + /// The test above pins the other half of the rule -- that modern, legacy and custom + /// do not -- and the two together are the whole contract, so neither is complete + /// without the other. + @Test + void theSharedNativeValueAlsoSelectsTheDesktopTheme() throws Exception { + String[] keys = {"codename1.arg.desktop.themeMode", "codename1.arg.nativeTheme", + "codename1.arg.cn1.nativeTheme"}; + String[] previous = new String[keys.length]; + for (int i = 0; i < keys.length; i++) { + previous[i] = System.getProperty(keys[i]); + System.clearProperty(keys[i]); + } + java.lang.reflect.Method resolver = JavaSEPort.class.getDeclaredMethod("resolveAutoNativeTheme", String.class); + resolver.setAccessible(true); + try { + for (String hint : new String[]{keys[1], keys[2]}) { + System.setProperty(hint, "native"); + assertEquals("WindowsFluentTheme", resolver.invoke(null, "win"), hint); + assertEquals("MacOSAquaTheme", resolver.invoke(null, "mac"), hint); + assertEquals("GnomeAdwaitaTheme", resolver.invoke(null, "linux"), hint); + assertEquals("/WindowsFluentTheme.res", + JavaSEPort.resolvePackagedDesktopNativeTheme("win", new Properties()), hint); + // The mobile half of the hint is unchanged: native is modern plus desktop, + // not a different mobile theme. + assertEquals("iOSModernTheme", resolver.invoke(null, "ios"), hint); + assertEquals("AndroidMaterialTheme", resolver.invoke(null, "and"), hint); + // An explicit desktop hint still decides, in both directions. + System.setProperty(keys[0], "legacy"); + assertNull(resolver.invoke(null, "win"), hint); + System.setProperty(keys[0], "aqua"); + assertEquals("MacOSAquaTheme", resolver.invoke(null, "win"), hint); + System.clearProperty(keys[0]); + System.clearProperty(hint); + } + } finally { + for (int i = 0; i < keys.length; i++) { + if (previous[i] == null) { + System.clearProperty(keys[i]); + } else { + System.setProperty(keys[i], previous[i]); + } + } + } + } + + @Test + void packagedHintSelectsTheHostThemeWithoutSourceSettings() { + String previous = System.getProperty("codename1.arg.desktop.themeMode"); + System.clearProperty("codename1.arg.desktop.themeMode"); + try { + Properties theme = new Properties(); + theme.setProperty("desktop.themeMode", "auto"); + assertEquals("/WindowsFluentTheme.res", JavaSEPort.resolvePackagedDesktopNativeTheme("win", theme)); + assertEquals("/MacOSAquaTheme.res", JavaSEPort.resolvePackagedDesktopNativeTheme("mac", theme)); + assertEquals("/GnomeAdwaitaTheme.res", JavaSEPort.resolvePackagedDesktopNativeTheme("linux", theme)); + for (String[] entry : new String[][]{{"fluent", "WindowsFluentTheme"}, + {"aqua", "MacOSAquaTheme"}, {"adwaita", "GnomeAdwaitaTheme"}}) { + theme.setProperty("desktop.themeMode", entry[0]); + assertEquals("/" + entry[1] + ".res", JavaSEPort.resolvePackagedDesktopNativeTheme("mac", theme)); + } + for (String mode : new String[]{"legacy", "invalid"}) { + theme.setProperty("desktop.themeMode", mode); + assertEquals("/NativeTheme.res", JavaSEPort.resolvePackagedDesktopNativeTheme("mac", theme)); + } + theme.setProperty("desktop.themeMode", " custom "); + assertNull(JavaSEPort.resolvePackagedDesktopNativeTheme("mac", theme)); + System.setProperty("codename1.arg.desktop.themeMode", "fluent"); + assertEquals("/WindowsFluentTheme.res", JavaSEPort.resolvePackagedDesktopNativeTheme("mac", theme), + "a launch-time override takes precedence over the packaged hint"); + } finally { + if (previous == null) { + System.clearProperty("codename1.arg.desktop.themeMode"); + } else { + System.setProperty("codename1.arg.desktop.themeMode", previous); + } + } + } + @Test + void legacyStubCallUsesTheHintButExplicitResourcesRemainOverrides() throws Exception { + String previousMode = System.getProperty("codename1.arg.desktop.themeMode"); + java.lang.reflect.Field nativeTheme = JavaSEPort.class.getDeclaredField("nativeTheme"); + nativeTheme.setAccessible(true); + Object previousTheme = nativeTheme.get(null); + java.util.Map previousFonts = saveFontState(); + try { + System.setProperty("codename1.arg.desktop.themeMode", "fluent"); + JavaSEPort.setNativeTheme("/NativeTheme.res"); + assertEquals("/WindowsFluentTheme.res", nativeTheme.get(null)); + System.setProperty("codename1.arg.desktop.themeMode", "custom"); + JavaSEPort.setNativeTheme("/NativeTheme.res"); + assertNull(nativeTheme.get(null), "custom must not install the legacy framework base"); + JavaSEPort.setNativeTheme("/ApplicationCustomTheme.res"); + assertEquals("/ApplicationCustomTheme.res", nativeTheme.get(null)); + } finally { + nativeTheme.set(null, previousTheme); + restoreFontState(previousFonts); + if (previousMode == null) { + System.clearProperty("codename1.arg.desktop.themeMode"); + } else { + System.setProperty("codename1.arg.desktop.themeMode", previousMode); + } + } + } + + @Test + void simulatorPseudoSkinKeepsCustomDistinctFromLegacyAtInstallation() throws Exception { + String previousMode = System.getProperty("codename1.arg.desktop.themeMode"); + java.lang.reflect.Field nativeTheme = field("nativeTheme"); + Object previousTheme = nativeTheme.get(null); + java.util.Map previousFonts = saveFontState(); + try { + for (String[] host : new String[][]{{"win", "/WindowsFluentTheme.res"}, + {"mac", "/MacOSAquaTheme.res"}, {"linux", "/GnomeAdwaitaTheme.res"}}) { + for (String mode : new String[]{"auto", " custom ", "legacy", "invalid", "CUSTOM"}) { + System.setProperty("codename1.arg.desktop.themeMode", mode); + JavaSEPort.setSimulatorDesktopNativeTheme(host[0], false); + String expected = "custom".equalsIgnoreCase(mode.trim()) ? null + : ("auto".equals(mode) ? host[1] : "/iOS7Theme.res"); + assertEquals(expected, nativeTheme.get(null), host[0] + " / " + mode); + } + JavaSEPort.setSimulatorDesktopNativeTheme(host[0], true); + assertEquals("/winTheme.res", nativeTheme.get(null), "the explicit UWP skin preference still wins"); + } + } finally { + nativeTheme.set(null, previousTheme); + restoreFontState(previousFonts); + if (previousMode == null) { + System.clearProperty("codename1.arg.desktop.themeMode"); + } else { + System.setProperty("codename1.arg.desktop.themeMode", previousMode); + } + } + } + + @Test + void systemFontsAreOptInAndExplicitFacesRemainOverrides() throws Exception { + java.util.Set installed = new java.util.HashSet(); + installed.add("segoe ui variable text"); + installed.add(".applesystemuifont"); + installed.add("cantarell"); + JavaSEPort.setAvailableFontNamesLowercaseForTest(installed); + for (String platform : new String[]{"win", "mac", "linux"}) { + String legacy = "win".equals(platform) ? "ArialUnicodeMS" : "Arial"; + for (String resource : new String[]{null, "/NativeTheme.res", "/iOS7Theme.res", "/Custom.res"}) { + assertEquals(legacy, JavaSEPort.defaultSystemFontForTheme(platform, resource)); + } + String modern = "win".equals(platform) ? "Segoe UI Variable Text" + : ("mac".equals(platform) ? ".AppleSystemUIFont" : "Cantarell"); + for (String resource : new String[]{"/WindowsFluentTheme.res", "/MacOSAquaTheme.res", "/GnomeAdwaitaTheme.res"}) { + assertEquals(modern, JavaSEPort.defaultSystemFontForTheme(platform, resource)); + } + } + java.util.Map previous = saveFontState(); + java.lang.reflect.Field nativeTheme = field("nativeTheme"); + Object previousTheme = nativeTheme.get(null); + try { + field("fontFacesExplicitlyConfigured").set(null, false); + Object defaultFont = field("DEFAULT_FONT").get(null); + JavaSEPort.setNativeTheme("/MacOSAquaTheme.res"); + assertEquals(defaultFont, field("DEFAULT_FONT").get(null), "theme selection must not resize default-font controls"); + String host = JavaSEPort.IS_MAC ? "mac" : (JavaSEPort.IS_LINUX ? "linux" : "win"); + assertEquals(JavaSEPort.defaultSystemFontForTheme(host, "/MacOSAquaTheme.res"), field("fontFaceSystem").get(null)); + JavaSEPort.setNativeTheme((String) null); + assertEquals(JavaSEPort.defaultSystemFontForTheme(host, null), field("fontFaceSystem").get(null)); + JavaSEPort.setFontFaces("ExplicitFace", "ExplicitProportional", "ExplicitMonospace"); + JavaSEPort.setNativeTheme("/MacOSAquaTheme.res"); + assertEquals("ExplicitFace", field("fontFaceSystem").get(null)); + } finally { + nativeTheme.set(null, previousTheme); + restoreFontState(previous); + } + } + + private static java.lang.reflect.Field field(String name) throws Exception { + java.lang.reflect.Field out = JavaSEPort.class.getDeclaredField(name); + out.setAccessible(true); + return out; + } + + private static java.util.Map saveFontState() throws Exception { + java.util.Map state = new java.util.HashMap(); + for (String name : new String[]{"fontFaceSystem", "fontFaceProportional", "fontFaceMonospace", + "fontFacesExplicitlyConfigured", "desktopNativeFonts", "DEFAULT_FONT", "autoAdjustFontSize"}) { + state.put(name, field(name).get(null)); + } + return state; + } + + private static void restoreFontState(java.util.Map state) throws Exception { + for (java.util.Map.Entry entry : state.entrySet()) { + field(entry.getKey()).set(null, entry.getValue()); + } + } + +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortFontMappingTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortFontMappingTest.java index c7528ca4988..cf1bb4f4009 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortFontMappingTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortFontMappingTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if + * you need additional information or have any questions. + */ package com.codename1.impl.javase; import java.util.HashSet; @@ -16,9 +38,19 @@ public class JavaSEPortFontMappingTest { private Boolean originalIsIOS; private JavaSEPort originalInstance; private boolean instanceCaptured; + private final java.util.Map originalFonts = new java.util.HashMap(); @BeforeEach - public void captureInstance() { + public void captureInstance() throws Exception { + for (String name : new String[]{"nativeTheme", "desktopNativeFonts", "fontFaceSystem", "fontFaceProportional", + "fontFaceMonospace", "fontFacesExplicitlyConfigured", "DEFAULT_FONT", "autoAdjustFontSize"}) { + Field f = JavaSEPort.class.getDeclaredField(name); + f.setAccessible(true); + originalFonts.put(name, f.get(null)); + } + Field desktop = JavaSEPort.class.getDeclaredField("desktopNativeFonts"); + desktop.setAccessible(true); + desktop.setBoolean(null, false); // new JavaSEPort() inside the loadTrueTypeFont tests overwrites the // global JavaSEPort.instance via the port's constructor. Other test // classes (CodenameOneExtensionTest) reach back through that static @@ -30,6 +62,11 @@ public void captureInstance() { @AfterEach public void tearDown() throws Exception { + for (java.util.Map.Entry entry : originalFonts.entrySet()) { + Field f = JavaSEPort.class.getDeclaredField(entry.getKey()); + f.setAccessible(true); + f.set(null, entry.getValue()); + } JavaSEPort.clearAvailableFontNamesLowercaseForTest(); if (originalIsIOS != null) { setIsIOS(originalIsIOS.booleanValue()); @@ -106,4 +143,44 @@ public void testLoadTrueTypeFontFallsBackWhenNoIOSFamilyInstalled() throws Excep assertNotNull(out); assertEquals(java.awt.Font.class, out.getClass()); } + @Test + public void desktopAliasesUseConfiguredFaceAndPreserveVariantsWhenDerived() throws Exception { + setIsIOS(false); + JavaSEPort.setFontFaces("Serif", "SansSerif", "Monospaced"); + JavaSEPort.setNativeTheme("/MacOSAquaTheme.res"); + JavaSEPort port = new JavaSEPort(); + String[] weights = {"Thin", "Light", "Regular", "Bold", "Black"}; + Float[] values = {java.awt.font.TextAttribute.WEIGHT_EXTRA_LIGHT, java.awt.font.TextAttribute.WEIGHT_LIGHT, + java.awt.font.TextAttribute.WEIGHT_REGULAR, java.awt.font.TextAttribute.WEIGHT_BOLD, + java.awt.font.TextAttribute.WEIGHT_HEAVY}; + for (String prefix : new String[]{"Main", "Italic"}) { + for (int i = 0; i < weights.length; i++) { + String alias = "native:" + prefix + weights[i]; + java.awt.Font font = (java.awt.Font) port.loadTrueTypeFont(alias, alias); + java.awt.Font derived = (java.awt.Font) port.deriveTrueTypeFont(font, 17f, 0); + assertEquals("Serif", derived.getFamily()); + Object actualWeight = derived.getAttributes().get(java.awt.font.TextAttribute.WEIGHT); + assertEquals(values[i], actualWeight == null ? java.awt.font.TextAttribute.WEIGHT_REGULAR : actualWeight); + assertEquals("Italic".equals(prefix), derived.isItalic()); + } + } + JavaSEPort.setNativeTheme("/Custom.res"); + java.awt.Font legacy = (java.awt.Font) port.loadTrueTypeFont("native:MainRegular", "native:MainRegular"); + org.junit.jupiter.api.Assertions.assertTrue(legacy.getName().startsWith("Roboto")); + } + + @Test + public void desktopAliasSelectsInstalledHostFamilyBeforeMobileFallback() throws Exception { + setIsIOS(true); // A desktop skin override must win over the skin's mobile alias mapping. + Field explicit = JavaSEPort.class.getDeclaredField("fontFacesExplicitlyConfigured"); + explicit.setAccessible(true); + explicit.setBoolean(null, false); + String family = JavaSEPort.IS_MAC ? ".AppleSystemUIFont" : (JavaSEPort.IS_LINUX ? "Cantarell" : "Segoe UI Variable Text"); + Set installed = new HashSet(); + installed.add(family.toLowerCase(java.util.Locale.ROOT)); + JavaSEPort.setAvailableFontNamesLowercaseForTest(installed); + JavaSEPort.setNativeTheme("/WindowsFluentTheme.res"); + java.awt.Font out = (java.awt.Font) new JavaSEPort().loadTrueTypeFont("native:MainRegular", "native:MainRegular"); + assertEquals(family, out.getName()); + } } diff --git a/maven/javase/src/test/java/com/codename1/testing/junit/DesktopThemeFontLifecycleTest.java b/maven/javase/src/test/java/com/codename1/testing/junit/DesktopThemeFontLifecycleTest.java new file mode 100644 index 00000000000..19c81fc1599 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/testing/junit/DesktopThemeFontLifecycleTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.ui.Font; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import static org.junit.jupiter.api.Assertions.*; + +@CodenameOneTest +@RunOnEdt +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisabledIfSystemProperty(named = "java.awt.headless", matches = "true") +class DesktopThemeFontLifecycleTest { + private static Object originalMode; + private static Object originalFamily; + private static Object originalTheme; + + private static Object portField(String name) throws Exception { + Field field = JavaSEPort.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(null); + } + + @BeforeAll + static void captureFontConfiguration() throws Exception { + originalMode = portField("desktopNativeFonts"); + originalFamily = portField("fontFaceSystem"); + originalTheme = portField("nativeTheme"); + } + + private void assertDesktopAliases() throws Exception { + assertEquals(Boolean.TRUE, portField("desktopNativeFonts")); + java.awt.Font alias = (java.awt.Font) Font.createTrueTypeFont("native:MainRegular") + .derive(17, Font.STYLE_PLAIN).getNativeFont(); + Method resolver = JavaSEPort.class.getDeclaredMethod("defaultSystemFontForTheme", String.class, String.class); + resolver.setAccessible(true); + String host = JavaSEPort.IS_MAC ? "mac" : (JavaSEPort.IS_LINUX ? "linux" : "win"); + String family = (String) resolver.invoke(null, host, "/WindowsFluentTheme.res"); + assertEquals(family, portField("fontFaceSystem")); + java.awt.Font expected = new java.awt.Font(family, java.awt.Font.PLAIN, 17); + assertEquals(expected.getFamily(), alias.getFamily()); + assertEquals(originalTheme, portField("nativeTheme"), "test font scope must not replace the application theme"); + } + + @Test @Order(1) @Theme(nativeTheme = NativeTheme.WINDOWS_FLUENT) + void fluentUsesDesktopAliases() throws Exception { assertDesktopAliases(); } + + @Test @Order(2) @Theme(nativeTheme = NativeTheme.MACOS_AQUA) + void aquaUsesDesktopAliases() throws Exception { assertDesktopAliases(); } + + @Test @Order(3) @Theme(nativeTheme = NativeTheme.GNOME_ADWAITA) + void adwaitaUsesDesktopAliases() throws Exception { assertDesktopAliases(); } + + @Test @Order(4) + void unthemedTestRestoresOriginalFontConfiguration() throws Exception { + assertEquals(originalMode, portField("desktopNativeFonts")); + assertEquals(originalFamily, portField("fontFaceSystem")); + } + + @Test @Order(5) @Theme(nativeTheme = NativeTheme.ANDROID_MATERIAL) + void mobileThemeDoesNotUseDesktopAliases() throws Exception { + assertEquals(Boolean.FALSE, portField("desktopNativeFonts")); + } + + @Test @Order(6) + void failedResourceLoadRestoresFontConfiguration() throws Exception { + Method install = CodenameOneExtension.class.getDeclaredMethod("installTheme", String.class, + org.junit.jupiter.api.extension.ExtensionContext.class); + install.setAccessible(true); + InvocationTargetException failure = assertThrows(InvocationTargetException.class, + () -> install.invoke(null, "/missing-theme-for-lifecycle-test.res", null)); + assertTrue(failure.getCause() instanceof java.io.IOException); + assertEquals(originalMode, portField("desktopNativeFonts")); + assertEquals(originalFamily, portField("fontFaceSystem")); + } +} diff --git a/maven/javase/src/test/java/com/codename1/testing/junit/NativeProgressThemeTest.java b/maven/javase/src/test/java/com/codename1/testing/junit/NativeProgressThemeTest.java new file mode 100644 index 00000000000..6e46bab2ca2 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/testing/junit/NativeProgressThemeTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.testing.junit; + +import com.codename1.ui.Display; +import com.codename1.ui.Slider; +import com.codename1.ui.plaf.UIManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@CodenameOneTest +@RunOnEdt +@DarkMode(enabled = false) +@DisabledIfSystemProperty(named = "java.awt.headless", matches = "true") +class NativeProgressThemeTest { + private void assertNativeTrackHeight() { + Slider progress = new Slider(); + progress.setUIID("ProgressBar"); + progress.setEditable(false); + progress.setRenderPercentageOnTop(false); + progress.setRenderValueOnTop(false); + float thickness = Float.parseFloat(UIManager.getInstance() + .getThemeConstant("progressTrackThicknessMM", "0")); + int expected = Math.max(2, Display.getInstance().convertToPixels(thickness)) + + progress.getStyle().getVerticalPadding(); + assertEquals(expected, progress.getPreferredH(), + "bundled CSS pill borders must keep the thin native progress path"); + } + + @Test @Theme(nativeTheme = NativeTheme.WINDOWS_FLUENT) + void fluentPillsRemainNative() { assertNativeTrackHeight(); } + + @Test @Theme(nativeTheme = NativeTheme.MACOS_AQUA) + void aquaPillsRemainNative() { assertNativeTrackHeight(); } + + @Test @Theme(nativeTheme = NativeTheme.GNOME_ADWAITA) + void adwaitaPillsRemainNative() { assertNativeTrackHeight(); } + + @Test @Theme(nativeTheme = NativeTheme.IOS_MODERN) + void iosPillsRemainNative() { assertNativeTrackHeight(); } + + @Test @DarkMode @Theme(nativeTheme = NativeTheme.WINDOWS_FLUENT) + void darkFluentPillsRemainNative() { assertNativeTrackHeight(); } + + @Test @DarkMode @Theme(nativeTheme = NativeTheme.MACOS_AQUA) + void darkAquaPillsRemainNative() { assertNativeTrackHeight(); } + + @Test @DarkMode @Theme(nativeTheme = NativeTheme.GNOME_ADWAITA) + void darkAdwaitaPillsRemainNative() { assertNativeTrackHeight(); } + + @Test @DarkMode @Theme(nativeTheme = NativeTheme.IOS_MODERN) + void darkIosPillsRemainNative() { assertNativeTrackHeight(); } + + @Test @Theme(nativeTheme = NativeTheme.IOS_MODERN) + void genericSliderKeepsItsRoundedRectangleBorderHeight() { + Slider slider = new Slider(); + // Slider's rounded rectangle is distinct from ProgressBar's plain pill. + // Preserve its legacy border and height rather than discarding its shape. + org.junit.jupiter.api.Assertions.assertInstanceOf( + com.codename1.ui.plaf.RoundRectBorder.class, slider.getStyle().getBorder()); + assertEquals(com.codename1.ui.Font.getDefaultFont().getHeight() + + slider.getStyle().getVerticalPadding(), slider.getPreferredH()); + } + +} diff --git a/maven/linux/pom.xml b/maven/linux/pom.xml index 6840b6a74fa..c6f689d66c8 100644 --- a/maven/linux/pom.xml +++ b/maven/linux/pom.xml @@ -38,6 +38,11 @@ com.codenameone codenameone-core + + org.junit.jupiter + junit-jupiter + test + @@ -78,9 +83,18 @@ maven-antrun-plugin - + diff --git a/maven/mac/src/test/java/com/codename1/impl/mac/MacNativeFontModeTest.java b/maven/mac/src/test/java/com/codename1/impl/mac/MacNativeFontModeTest.java new file mode 100644 index 00000000000..8129e07b592 --- /dev/null +++ b/maven/mac/src/test/java/com/codename1/impl/mac/MacNativeFontModeTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.mac; + +import com.codename1.impl.ios.IOSImplementation; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class MacNativeFontModeTest { + private final MacImplementation implementation = new MacImplementation(); + + @After + public void restoreDefaultMode() { + IOSImplementation.setIosMode("auto"); + } + + @Test + public void aquaModesUseAppKitAliases() { + String[] modes = {null, "auto", "aqua", "native", "AQUA"}; + for (String mode : modes) { + IOSImplementation.setIosMode(mode); + assertEquals(mode, "native:MainRegular", implementation.nativeFontName("native:MainRegular")); + assertEquals(mode, "native:ItalicRegular", implementation.nativeFontName("native:ItalicRegular")); + } + } + + @Test + public void iosStyleModesKeepTheirHistoricalFontMapping() { + String[] modes = {"modern", "liquid", "ios7", "flat", "material", "MODERN"}; + for (String mode : modes) { + IOSImplementation.setIosMode(mode); + assertEquals(mode, "HelveticaNeue-Medium", implementation.nativeFontName("native:MainRegular")); + assertEquals(mode, "HelveticaNeue-MediumItalic", implementation.nativeFontName("native:ItalicRegular")); + } + } + + @Test + public void namedApplicationFontsAreNotRemapped() { + for (String mode : new String[] {"aqua", "modern"}) { + IOSImplementation.setIosMode(mode); + assertEquals(mode, "Material Icons", implementation.nativeFontName("Material Icons")); + assertEquals(mode, "HelveticaNeue-Medium", implementation.nativeFontName("HelveticaNeue-Medium")); + assertEquals(mode, null, implementation.nativeFontName(null)); + } + } +} diff --git a/maven/windows/pom.xml b/maven/windows/pom.xml index f4f2070a71a..32c88479cd3 100644 --- a/maven/windows/pom.xml +++ b/maven/windows/pom.xml @@ -38,6 +38,11 @@ com.codenameone codenameone-core + + org.junit.jupiter + junit-jupiter + test + @@ -78,9 +83,18 @@ maven-antrun-plugin - + + + 17 + 17 + + + + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + + com.codenameone + codenameone-javase + ${cn1.version} + + + diff --git a/scripts/fidelity-app/desktop-runner/src/main/java/com/codenameone/fidelity/DesktopTileRunner.java b/scripts/fidelity-app/desktop-runner/src/main/java/com/codenameone/fidelity/DesktopTileRunner.java new file mode 100644 index 00000000000..9ce1869482b --- /dev/null +++ b/scripts/fidelity-app/desktop-runner/src/main/java/com/codenameone/fidelity/DesktopTileRunner.java @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.fidelity; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codenameone.fidelity.render.Cn1WidgetRenderer; +import com.codenameone.fidelity.spec.ComponentSpec; +import com.codenameone.fidelity.spec.FidelitySpec; +import com.codenameone.fidelity.spec.FidelitySpecParser; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; + +/** + * Renders the Codename One half of the desktop fidelity comparison. + * + *

Unlike the mobile runners this one does not stream tiles over a WebSocket from a device. + * The desktop side runs in the same process as the host, so it writes PNGs straight to a + * directory and the comparison picks them up from there -- the transport existed to get bytes + * off a phone, and there is no phone here.

+ * + *

The platform is passed in rather than read from the port. JavaSE answers "win", "mac" or + * "linux" for the HOST it happens to be running on, which is right for an application and + * different from the fixture identifiers here (windows, macos, gnome). Each fixture must + * run on its matching host with the native reference font installed.

+ * + *

Tiles are named {@code ___cn1.png}, which is the convention + * ProcessScreenshots pairs against {@code __.png} in the golden + * directory.

+ */ +public final class DesktopTileRunner { + private DesktopTileRunner() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.err.println("Usage: DesktopTileRunner "); + System.exit(2); + } + final String platform = args[0]; + final String themeRes = args[1]; + final File outDir = new File(args[2]); + outDir.mkdirs(); + + // Native tiles use 1x desktop logical pixels (96 dpi), not the mobile + // density fallback of 5 px/mm or the host monitor's Retina backing scale. + // Set these before JavaSEPort initializes its static font/scale defaults. + System.setProperty("cn1.retinaScale", "1"); + System.setProperty("cn1.javase.pixelMilliRatio", Double.toString(96.0 / 25.4)); + com.codename1.impl.javase.JavaSEPort.setDefaultPixelMilliRatio(Double.valueOf(96.0 / 25.4)); + // Select through the packaged-app entry point so font defaults follow the theme. + com.codename1.impl.javase.JavaSEPort.setNativeTheme("/" + themeRes + ".res"); + Display.init(new java.awt.Container()); + final int[] written = new int[1]; + final Throwable[] failure = new Throwable[1]; + final java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(1); + Display.getInstance().callSerially(new Runnable() { + public void run() { + try { + written[0] = renderAll(platform, themeRes, outDir); + } catch (Throwable t) { + failure[0] = t; + } finally { + done.countDown(); + } + } + }); + + // The EDT does the work; this thread waits for it. Through a latch rather than a + // sleep loop over the two arrays: a plain field written on one thread has no + // happens-before edge to a read on another, so nothing required this thread to ever + // observe the render finishing. A JVM is free to keep serving the initial values, + // wait out the entire timeout and report "no tiles were rendered" for a run that + // rendered everything -- or to swallow the real exception behind that message. + // await() supplies the edge, so everything the EDT wrote before countDown() is + // visible here. Bounded rather than open-ended so a genuine hang fails the run + // instead of holding a CI job until the job timeout. + if (!done.await(120, java.util.concurrent.TimeUnit.SECONDS)) { + System.err.println("DesktopTileRunner: the render did not finish within the deadline"); + System.exit(4); + } + if (failure[0] != null) { + failure[0].printStackTrace(); + System.exit(3); + } + if (written[0] == 0) { + System.err.println("DesktopTileRunner: no tiles were rendered"); + System.exit(4); + } + System.out.println("CN1SS:INFO desktop tiles written: " + written[0]); + System.exit(0); + } + + private static int renderAll(String platform, String themeRes, File outDir) throws Exception { + java.awt.Font nativeFont = (java.awt.Font) com.codename1.impl.javase.JavaSEPort.instance.loadTrueTypeFont( + "native:MainRegular", "native:MainRegular"); + int pixelsPer100mm = Display.getInstance().convertToPixels(100f); + if (pixelsPer100mm != 378) throw new IllegalStateException("Unexpected capture density: " + pixelsPer100mm); + System.out.println("Desktop capture scale: 1x, 96 dpi (100mm=" + pixelsPer100mm + "px)"); + String family = nativeFont.getFamily(); + System.out.println("Desktop native font: " + nativeFont.getName() + " (family=" + family + ")"); + boolean expected = "gnome".equals(platform) ? "Cantarell".equals(family) + : ("macos".equals(platform) ? ".AppleSystemUIFont".equals(family) + : family.startsWith("Segoe UI Variable")); + if (!expected) throw new IllegalStateException("Native reference font unavailable: " + family); + FidelitySpec spec = FidelitySpecParser.parse(readSpec()); + int count = 0; + List appearances = spec.getAppearances(); + if (appearances == null || appearances.isEmpty()) { + appearances = java.util.Arrays.asList(new String[]{"light"}); + } + StringBuffer backgrounds = new StringBuffer(); + for (Object ao : appearances) { + String appearance = (String) ao; + boolean dark = "dark".equals(appearance); + // Set the mode BEFORE installing the theme: UIManager resolves the $Dark entries + // while it builds, and the check-box and radio glyphs are baked during that pass, + // so flipping afterwards leaves the glyphs coloured for the other scheme. + CN.setDarkMode(Boolean.valueOf(dark)); + installTheme(themeRes); + backgrounds.append(appearance).append('=') + .append(toHex(tileBackground())).append('\n'); + + List components = spec.getComponents(); + for (int i = 0; i < components.size(); i++) { + ComponentSpec c = (ComponentSpec) components.get(i); + if (!c.appliesToPlatform(platform)) { + continue; + } + // A new row has no baseline yet, so silently dropping it evades the gate. + if (!Cn1WidgetRenderer.isSupported(c.getId())) { + throw new IllegalArgumentException("Unsupported desktop fidelity component: " + c.getId()); + } + int w = spec.tileWidthPx(c); + int h = spec.tileHeightPx(c); + List states = c.getStates(); + for (int j = 0; j < states.size(); j++) { + String state = (String) states.get(j); + if (renderTile(c, state, appearance, w, h, outDir)) { + count++; + } + } + } + } + writeBackgrounds(backgrounds.toString(), outDir); + return count; + } + + /// The colour the tiles are painted on, read back from the theme that was just + /// installed rather than written down anywhere. + /// + /// The comparator needs this to tell widget pixels from backdrop, and it used to + /// assume white for light and black for dark -- which is true of the mobile tiles + /// and true of no desktop platform. Fluent's light surface is #F3F3F3 and its dark + /// one #202020, Aqua's is #ECECEC, Adwaita's #FAFAFA. Against a hardcoded white + /// the mask's tolerance (10 per channel) is exceeded by all but one of them, so + /// roughly 80% of every tile -- the empty backdrop -- was classified as widget + /// content. That does not fail; it inflates the score, because both tiles agree + /// about the backdrop they are both mostly made of. + /// + /// Reading it from the theme rather than declaring it in the spec keeps one copy + /// of the value. Restyle a theme's background and the measurement follows; a + /// second copy in the YAML would go stale silently and in the direction that + /// looks like success. + /// Known limitation, recorded here because the number it produces looks fine: + /// Fluent's light control fill (#FBFBFB) is 8 levels off its page surface (#F3F3F3), + /// under the comparator's 10-per-channel content tolerance. So on Fluent light tiles + /// the mask sees the button's BORDER and TEXT and not its fill, and the reported + /// geometry bbox is the text's, not the control's. It does not bias the fidelity + /// score -- the native Fluent button is the same two colours, so both sides mask + /// identically -- but do not read a Fluent light width_ratio as a control width. + /// Adwaita is tighter still at 5. Lowering the tolerance is not the fix; it would + /// start counting anti-aliasing as content everywhere else. + /// Kept in sync BY HAND with FULL_WIDTH_KINDS in each native reference app. The two + /// sides must agree: if one stretches a control to the tile and the other does not, the + /// comparison is between two different geometries and the score means nothing. + private static final java.util.Set FULL_WIDTH_IDS = + new java.util.HashSet(java.util.Arrays.asList( + "DesktopSlider", "DesktopProgressBar", "DesktopTextField")); + + private static int tileBackground() { + return UIManager.getInstance().getComponentStyle("Form").getBgColor(); + } + + private static String toHex(int rgb) { + String h = Integer.toHexString(rgb & 0xffffff); + while (h.length() < 6) { + h = "0" + h; + } + return "#" + h.toUpperCase(); + } + + private static void writeBackgrounds(String body, File outDir) throws Exception { + OutputStream out = new FileOutputStream(new File(outDir, "tile-backgrounds.properties")); + try { + out.write(("# Written by DesktopTileRunner; read by ProcessScreenshots --mode fidelity.\n" + + "# The backdrop colour each appearance's tiles were painted on, taken from the\n" + + "# installed theme's Form style. See tileBackground().\n" + + body).getBytes("UTF-8")); + } finally { + out.close(); + } + } + + private static boolean renderTile(ComponentSpec c, String state, String appearance, + int w, int h, File outDir) throws Exception { + Form f = new Form(new BorderLayout()); + // A Form always builds a title area, and it is NOT free here. The desktop tile + // contract is "the widget at its natural size, anchored top-left on the theme's + // surface"; an unhidden title area both pushes the widget down and paints a strip + // of its own across the top of every tile. On Fluent and Aqua the strip is the + // same colour as the page so nothing looks wrong, and on Adwaita -- whose + // headerbar is #ebebeb against a #fafafa page -- it is 15 levels off the + // background, which is over the content mask's tolerance, so it was measured as + // widget content on every single GNOME tile. + // + // setHidden(true) takes it out of layout as well as out of the paint, which + // setVisible(false) alone does not. + f.getTitleArea().setHidden(true); + f.getTitleArea().setVisible(false); + f.show(); + Component comp = Cn1WidgetRenderer.build(c, state, appearance); + if (comp == null) { + return false; + } + // Controls with no natural width: layout always assigns one, so the tile width is + // the honest answer and it is the rule the native reference apps apply too. Left to + // size itself a text field measures to its content, which is not a control anyone + // would recognise -- AppKit gives 39px for the string "Text". + if (FULL_WIDTH_IDS.contains(c.getId())) { + comp.setPreferredW(w); + } + comp.getAllStyles().setMargin(0, 0, 0, 0); + // getAllStyles() deliberately EXCLUDES the hover style, so it is zeroed here as well + // -- and here rather than inside the renderer, because this clear runs AFTER build() + // returns. A renderer that normalised hover during build had its work undone one line + // later for every component whose own branch did not zero margins (DesktopSwitch and + // DesktopSlider keep the theme's 0.5/0.8mm), leaving the hover tile at a different + // offset and size from both the native control and the CN1 normal state -- scored as + // a fidelity loss that has nothing to do with the hover colours being measured. + Style hoverStyle = comp.getHoverStyle(); + if (hoverStyle != null) { + hoverStyle.setMargin(0, 0, 0, 0); + } + + // NORTH, not CENTER: BorderLayout's centre region would centre the widget + // vertically in whatever space is left, and the contract is top-left. + Container row = new Container(new FlowLayout()); + row.getAllStyles().setMargin(0, 0, 0, 0); + row.getAllStyles().setPadding(0, 0, 0, 0); + row.add(comp); + f.add(BorderLayout.NORTH, row); + Cn1WidgetRenderer.applyAttachedState(comp, state); + f.setSize(new Dimension(w, h)); + f.layoutContainer(); + + Image img = Image.createImage(w, h); + f.paintComponent(img.getGraphics(), true); + + String name = c.getId() + "_" + state + "_" + appearance + "_cn1.png"; + writePng(img, new File(outDir, name)); + return true; + } + + private static void installTheme(String themeRes) throws Exception { + InputStream in = DesktopTileRunner.class.getResourceAsStream("/" + themeRes + ".res"); + if (in == null) { + throw new IllegalStateException("theme resource not on the classpath: /" + themeRes + ".res"); + } + try { + Resources r = Resources.open(in); + String[] names = r.getThemeResourceNames(); + if (names == null || names.length == 0) { + throw new IllegalStateException("no themes inside " + themeRes); + } + UIManager.getInstance().setThemeProps(r.getTheme(names[0])); + } finally { + in.close(); + } + } + + /// Writes one tile with javax.imageio, straight out of the JavaSE port's own + /// BufferedImage peer. + /// + /// Host APIs are correct here and are the reason this class is not in `common`: + /// that module is compiled as Codename One application code under a + /// bytecode-compliance gate, and CN1's FileSystemStorage does not address a host + /// directory the comparator can read anyway. + private static void writePng(Image img, File dest) throws Exception { + Object peer = img.getImage(); + if (peer instanceof java.awt.image.RenderedImage) { + javax.imageio.ImageIO.write((java.awt.image.RenderedImage) peer, "png", dest); + return; + } + // The JavaSE port backs a CN1 Image with a BufferedImage, so the branch above is the + // one that runs. Falling through means the port changed underneath this, which must + // fail rather than write nothing and report success. + throw new IllegalStateException("cannot encode the tile: image peer is " + + (peer == null ? "null" : peer.getClass().getName())); + } + + private static String readSpec() throws Exception { + InputStream in = DesktopTileRunner.class.getResourceAsStream("/fidelity-tests.yaml"); + if (in == null) { + throw new IllegalStateException("fidelity-tests.yaml is not on the classpath"); + } + try { + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + bos.write(buf, 0, n); + } + return new String(bos.toByteArray(), "UTF-8"); + } finally { + in.close(); + } + } +} diff --git a/scripts/fidelity-app/gnome-native-ref/native-ref.c b/scripts/fidelity-app/gnome-native-ref/native-ref.c new file mode 100644 index 00000000000..28e46ba1159 --- /dev/null +++ b/scripts/fidelity-app/gnome-native-ref/native-ref.c @@ -0,0 +1,705 @@ +/* + * 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. + */ +/* + * GNOME (GTK4 + libadwaita) native reference app for the Codename One fidelity suite. + * + * Desktop counterpart to ios-native-ref/NativeRef.swift and android-native-ref/. It + * renders real Adwaita widgets and writes one PNG per tile, plus a capture-manifest.json + * describing the environment that produced them, so a later run can prove it is + * comparable. + * + * Two modes, selected by NATIVEREF_MODE: + * + * probe -- answer the environment questions only, and say so loudly. Does the window + * actually activate under Xvfb? Which font did fontconfig really resolve? + * Which GSK renderer is live? This mode exists because every one of those + * silently degrades rather than failing, and a degraded reference is worse + * than no reference: it bakes a wrong design into the theme and the fidelity + * metric cannot tell you it happened. + * capture -- the same, plus the reference tiles. + * + * Built by scripts/build-gnome-native-ref.sh with a single cc line -- no meson, no + * autotools -- which is the spiritual equivalent of NativeRef.swift having no xcodeproj. + */ +#include +#include +#include +#include +#include +#include + +static const char *out_dir = NULL; +static int is_probe = 1; +static int tiles_written = 0; + +/* Checksum of each "_normal_" tile, and the states that came out + * identical to it. + * + * Identical is not automatically wrong: Adwaita genuinely does not restyle a GtkEntry + * on hover, only on focus. It is wrong only when it is a SURPRISE, so it is recorded + * in the manifest rather than left for whoever later wonders why a theme's hover rule + * scores the same either way. */ +#define CN1_MAX_IDENTICAL 64 +static char normal_keys[CN1_MAX_IDENTICAL][128]; +static char normal_sums[CN1_MAX_IDENTICAL][80]; +static int normal_count = 0; +static char identical[CN1_MAX_IDENTICAL][128]; +static int identical_count = 0; + +/* Backdrop colour sampled from each appearance's tiles. See assert_appearances_differ. */ +static char backdrop_light[16] = ""; +static char backdrop_dark[16] = ""; + + +static void note_if_identical_to_normal(const char *name, const char *path) { + char id[96], state[32], appearance[32]; + const char *u2 = strrchr(name, '_'); + if (!u2) { + return; + } + g_strlcpy(appearance, u2 + 1, sizeof(appearance)); + size_t head = (size_t) (u2 - name); + char without[128]; + if (head >= sizeof(without)) { + return; + } + memcpy(without, name, head); + without[head] = 0; + const char *u1 = strrchr(without, '_'); + if (!u1) { + return; + } + g_strlcpy(state, u1 + 1, sizeof(state)); + size_t idlen = (size_t) (u1 - without); + if (idlen >= sizeof(id)) { + return; + } + memcpy(id, without, idlen); + id[idlen] = 0; + + gchar *contents = NULL; + gsize len = 0; + if (!g_file_get_contents(path, &contents, &len, NULL)) { + return; + } + gchar *sum = g_compute_checksum_for_data(G_CHECKSUM_MD5, (const guchar *) contents, len); + g_free(contents); + + char key[128]; + snprintf(key, sizeof(key), "%s_%s", id, appearance); + if (strcmp(state, "normal") == 0) { + if (normal_count < CN1_MAX_IDENTICAL) { + g_strlcpy(normal_keys[normal_count], key, sizeof(normal_keys[0])); + g_strlcpy(normal_sums[normal_count], sum, sizeof(normal_sums[0])); + normal_count++; + } + g_free(sum); + return; + } + for (int i = 0; i < normal_count; i++) { + if (strcmp(normal_keys[i], key) == 0 && strcmp(normal_sums[i], sum) == 0) { + if (identical_count < CN1_MAX_IDENTICAL) { + g_strlcpy(identical[identical_count], name, sizeof(identical[0])); + identical_count++; + } + printf("NATIVEREF:INFO %s is identical to its normal tile; Adwaita does not " + "restyle this control for this state\n", name); + break; + } + } + g_free(sum); +} + +/* The tile the widget is anchored top-left in. Mirrors tile_width_px / tile_height_px in + * fidelity-tests.yaml; if those change, this must change with them. */ +#define TILE_W 240 +#define TILE_H 56 + +/* One row of the desktop matrix. `kind` is the native_gnome key in fidelity-tests.yaml, + * and the ids and states are that file's too: the two lists must agree or the comparator + * pairs a CN1 render against nothing. NULL terminates each state list. */ +typedef struct { + const char *id; + const char *kind; + const char *states[6]; +} Spec; + +static const Spec SPECS[] = { + {"DesktopButton", "adw_button", {"normal", "hover", "pressed", "disabled", NULL}}, + {"DesktopAccentButton", "adw_button_suggested", {"normal", "hover", "pressed", "disabled", NULL}}, + {"DesktopTextField", "gtk_entry", {"normal", "hover", "disabled", NULL}}, + {"DesktopCheckBox", "gtk_check_button", {"normal", "selected", "hover", "disabled", NULL}}, + {"DesktopRadioButton", "gtk_radio_button", {"normal", "selected", "hover", "disabled", NULL}}, + {"DesktopSwitch", "gtk_switch", {"normal", "selected", "hover", "disabled", NULL}}, + {"DesktopSlider", "gtk_scale", {"normal", "hover", "disabled", NULL}}, + {"DesktopProgressBar", "gtk_progressbar", {"normal", NULL}}, + {"DesktopComboBox", "gtk_dropdown", {"normal", "hover", "disabled", NULL}}, +}; +#define SPEC_COUNT ((int) (sizeof(SPECS) / sizeof(SPECS[0]))) + +/* Controls with no natural width: layout always assigns one, so the tile width is the + * honest answer. Kept in sync BY HAND with FULL_WIDTH_KINDS in the other reference apps + * and FULL_WIDTH_IDS in DesktopTileRunner -- if one side stretches a control and the other + * does not, the comparison is between two geometries and the score means nothing. */ +static int is_full_width(const char *kind) { + return strcmp(kind, "gtk_scale") == 0 + || strcmp(kind, "gtk_progressbar") == 0 + || strcmp(kind, "gtk_entry") == 0; +} +static int exit_code = 0; + +/* Findings that must fail the run rather than produce a quietly wrong reference. */ +static char blockers[8][256]; +static int blocker_count = 0; + +static void blocker(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + if (blocker_count < 8) { + vsnprintf(blockers[blocker_count], sizeof(blockers[0]), fmt, ap); + blocker_count++; + } + va_end(ap); + exit_code = 20; +} + +/* Fails the run when the light and dark passes were captured on the same backdrop. + * + * This is here because it happened on the Windows reference: the tile background was read + * from a dictionary that does no element-theme resolution, so the controls went dark and + * the surface behind them stayed light. Sixty tiles, zero blockers, and half the set on a + * backdrop the platform never shows. Nothing downstream catches it either -- the CN1 side + * renders its dark tiles on the real dark surface, so the pair simply scores badly and + * reads as a theme that needs work. */ +static void assert_appearances_differ(void) { + if (backdrop_light[0] && backdrop_dark[0]) { + printf("NATIVEREF:INFO light backdrop %s, dark backdrop %s\n", + backdrop_light, backdrop_dark); + if (strcmp(backdrop_light, backdrop_dark) == 0) { + blocker("the light and dark passes were both captured on backdrop %s: the " + "appearance did not actually change, so half the set is mislabelled", + backdrop_light); + } + } +} + +static char *json_escape(const char *s) { + GString *o = g_string_new(""); + for (; s && *s; s++) { + if (*s == '"' || *s == '\\') g_string_append_printf(o, "\\%c", *s); + else if (*s == '\n') g_string_append(o, "\\n"); + else g_string_append_c(o, *s); + } + return g_string_free(o, FALSE); +} + +/* + * The resolved font is load-bearing and is the thing most likely to be wrong without + * anyone noticing. Adwaita's default is Cantarell (GNOME 46 and earlier) or Adwaita Sans + * (47+); with neither package installed fontconfig silently substitutes DejaVu Sans and + * every text metric in the reference is then measuring font availability rather than + * theme fidelity. + */ +static char *resolved_font(GtkWidget *w) { + PangoContext *pc = gtk_widget_get_pango_context(w); + const PangoFontDescription *d = pango_context_get_font_description(pc); + return pango_font_description_to_string((PangoFontDescription *) d); +} + +static void write_manifest(GtkWindow *win, GtkWidget *probe_widget) { + char path[1024]; + snprintf(path, sizeof(path), "%s/capture-manifest.json", out_dir); + FILE *f = fopen(path, "w"); + if (!f) { + fprintf(stderr, "NATIVEREF:ERR cannot write %s\n", path); + exit_code = 21; + return; + } + + char *font = resolved_font(probe_widget); + char *font_esc = json_escape(font); + const char *renderer = g_getenv("GSK_RENDERER"); + GtkSettings *settings = gtk_settings_get_default(); + char *font_name = NULL; + gboolean animations = TRUE; + g_object_get(settings, "gtk-font-name", &font_name, "gtk-enable-animations", &animations, NULL); + char *font_name_esc = json_escape(font_name ? font_name : ""); + AdwStyleManager *sm = adw_style_manager_get_default(); + int scale = gtk_widget_get_scale_factor(GTK_WIDGET(win)); + + fprintf(f, + "{\n" + " \"schema\": 1,\n" + " \"platform\": \"gnome\",\n" + " \"golden_set\": \"%s\",\n" + " \"mode\": \"%s\",\n" + " \"toolkit\": {\n" + " \"name\": \"GTK4\",\n" + " \"gtk\": \"%d.%d.%d\",\n" + " \"libadwaita\": \"%d.%d.%d\",\n" + " \"gsk_renderer\": \"%s\"\n" + " },\n" + " \"display\": {\n" + " \"scale_factor\": %d,\n" + " \"gdk_scale\": \"%s\",\n" + " \"gdk_dpi_scale\": \"%s\"\n" + " },\n" + " \"appearance\": {\n" + " \"color_scheme\": \"%s\",\n" + " \"dark\": %s,\n" + " \"high_contrast\": %s,\n" + " \"animations_enabled\": %s\n" + " },\n" + " \"fonts\": {\n" + " \"gtk_font_name\": \"%s\",\n" + " \"resolved\": \"%s\"\n" + " },\n" + " \"window\": {\n" + " \"active\": %s\n" + " },\n" + " \"tiles_written\": %d,\n", + g_getenv("CN1SS_FIDELITY_GOLDEN_SET") ? g_getenv("CN1SS_FIDELITY_GOLDEN_SET") : "gnome-adwaita", + is_probe ? "probe" : "capture", + gtk_get_major_version(), gtk_get_minor_version(), gtk_get_micro_version(), + ADW_MAJOR_VERSION, ADW_MINOR_VERSION, ADW_MICRO_VERSION, + renderer ? renderer : "(default)", + scale, + g_getenv("GDK_SCALE") ? g_getenv("GDK_SCALE") : "(unset)", + g_getenv("GDK_DPI_SCALE") ? g_getenv("GDK_DPI_SCALE") : "(unset)", + adw_style_manager_get_dark(sm) ? "dark" : "light", + adw_style_manager_get_dark(sm) ? "true" : "false", + adw_style_manager_get_high_contrast(sm) ? "true" : "false", + animations ? "true" : "false", + font_name_esc, + font_esc, + gtk_window_is_active(win) ? "true" : "false", + tiles_written); + + /* Written as two more passes rather than squeezed into the format above: both are + * variable-length arrays, and the manifest previously carried NO blockers field at + * all -- so a consumer reading it could not tell "no blockers" from "this manifest + * does not report them", which the Windows one does report. */ + fprintf(f, " \"backdrop_by_appearance\": {\"light\": \"%s\", \"dark\": \"%s\"},\n", + backdrop_light, backdrop_dark); + fprintf(f, " \"states_identical_to_normal\": ["); + for (int i = 0; i < identical_count; i++) { + fprintf(f, "%s\"%s\"", i ? ", " : "", identical[i]); + } + fprintf(f, "],\n \"blockers\": ["); + for (int i = 0; i < blocker_count; i++) { + char *esc = json_escape(blockers[i]); + fprintf(f, "%s\"%s\"", i ? ", " : "", esc ? esc : ""); + g_free(esc); + } + fprintf(f, "]\n}\n"); + fclose(f); + g_free(font); + g_free(font_esc); + g_free(font_name); + g_free(font_name_esc); + printf("NATIVEREF:INFO wrote %s\n", path); +} + + +/* Pumps the main loop until the widget has a real allocation, or gives up. + * + * A fixed number of g_main_context_iteration(NULL, FALSE) calls is NOT enough and that is + * how the first capture run failed: non-blocking iteration returns immediately when + * nothing is pending, GTK allocates on a frame-clock tick that has not been scheduled yet, + * and every tile after the first was read back at 0x0 and reported "was never laid out". + * The first tile survived only because the initial present had laid it out. + * + * So wait on the CONDITION rather than on a count. The sleep is what lets the frame clock + * actually fire; 2ms x 400 is 800ms of headroom per tile, and in practice it takes a + * handful of turns. */ +static int wait_for_allocation(GtkWidget *w) { + for (int i = 0; i < 400; i++) { + while (g_main_context_iteration(NULL, FALSE)) { + /* drain whatever is pending */ + } + if (gtk_widget_get_width(w) > 0 && gtk_widget_get_height(w) > 0) { + return 1; + } + g_usleep(2000); + } + return 0; +} + +/* Renders a widget through the window's OWN GskRenderer -- the same renderer that painted + * it on screen -- rather than grabbing X11 pixels. The widget is realized, allocated and + * state-flagged inside a real mapped window, so measurement and CSS state resolution are + * genuinely live; only the final read-back avoids the grab, which removes cursor-in-shot, + * root-window bleed and compositor flakiness in one go. */ +static void capture_widget(GtkWindow *win, GtkWidget *w, const char *name) { + int width = gtk_widget_get_width(w); + int height = gtk_widget_get_height(w); + if (width <= 0 || height <= 0) { + blocker("%s has no allocation (%dx%d) -- it was never laid out", name, width, height); + return; + } + GdkPaintable *p = gtk_widget_paintable_new(w); + GtkSnapshot *snap = gtk_snapshot_new(); + gdk_paintable_snapshot(p, GDK_SNAPSHOT(snap), (double) width, (double) height); + GskRenderNode *node = gtk_snapshot_free_to_node(snap); + if (!node) { + blocker("%s produced an empty render node", name); + g_object_unref(p); + return; + } + GskRenderer *r = gtk_native_get_renderer(GTK_NATIVE(win)); + GdkTexture *tex = gsk_renderer_render_texture(r, node, NULL); + char path[1024]; + snprintf(path, sizeof(path), "%s/%s.png", out_dir, name); + if (!gdk_texture_save_to_png(tex, path)) { + blocker("%s could not be written to %s", name, path); + } else { + tiles_written++; + printf("NATIVEREF:wrote %s %dx%d\n", name, width, height); + note_if_identical_to_normal(name, path); + /* Bottom-right corner: every widget in the matrix anchors top-left and none is as + * tall as the tile, so this pixel is always backdrop. */ + GdkTexture *t2 = gdk_texture_new_from_filename(path, NULL); + if (t2) { + GBytes *bytes = NULL; + int tw = gdk_texture_get_width(t2), th = gdk_texture_get_height(t2); + guchar *data = g_malloc((gsize) tw * th * 4); + gdk_texture_download(t2, data, (gsize) tw * 4); + guchar *px = data + ((gsize) (th - 1) * tw * 4) + (gsize) (tw - 1) * 4; + char hex[16]; + /* gdk_texture_download writes BGRA. */ + snprintf(hex, sizeof(hex), "#%02X%02X%02X", px[2], px[1], px[0]); + if (strstr(name, "_light")) { + g_strlcpy(backdrop_light, hex, sizeof(backdrop_light)); + } else if (strstr(name, "_dark")) { + g_strlcpy(backdrop_dark, hex, sizeof(backdrop_dark)); + } + g_free(data); + (void) bytes; + g_object_unref(t2); + } + } + g_object_unref(tex); + gsk_render_node_unref(node); + g_object_unref(p); +} + +/* Group leader for the radio buttons. GTK4 has no GtkRadioButton: a radio IS a + * GtkCheckButton that belongs to a group, and one on its own renders as a CHECK box. The + * leader is never captured; it exists only to make the group real. */ +static GtkWidget *radio_group_leader = NULL; + +static GtkWidget *make_widget(const char *kind) { + if (strcmp(kind, "adw_button") == 0) { + return gtk_button_new_with_label("Button"); + } + if (strcmp(kind, "adw_button_suggested") == 0) { + GtkWidget *b = gtk_button_new_with_label("Button"); + /* The accent-filled button in Adwaita is the suggested action, which is a CSS + * class rather than a widget type. */ + gtk_widget_add_css_class(b, "suggested-action"); + return b; + } + if (strcmp(kind, "gtk_entry") == 0) { + GtkWidget *e = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(e), "Text"); + return e; + } + if (strcmp(kind, "gtk_check_button") == 0) { + return gtk_check_button_new_with_label("Check"); + } + if (strcmp(kind, "gtk_radio_button") == 0) { + GtkWidget *r = gtk_check_button_new_with_label("Radio"); + if (!radio_group_leader) { + radio_group_leader = gtk_check_button_new(); + g_object_ref_sink(radio_group_leader); + } + gtk_check_button_set_group(GTK_CHECK_BUTTON(r), GTK_CHECK_BUTTON(radio_group_leader)); + return r; + } + if (strcmp(kind, "gtk_switch") == 0) { + return gtk_switch_new(); + } + if (strcmp(kind, "gtk_scale") == 0) { + GtkWidget *s = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL, 0.0, 1.0, 0.01); + gtk_range_set_value(GTK_RANGE(s), 0.5); + gtk_scale_set_draw_value(GTK_SCALE(s), FALSE); + return s; + } + if (strcmp(kind, "gtk_progressbar") == 0) { + GtkWidget *p = gtk_progress_bar_new(); + gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(p), 0.6); + return p; + } + if (strcmp(kind, "gtk_dropdown") == 0) { + const char *items[] = {"Option", NULL}; + return gtk_drop_down_new_from_strings(items); + } + blocker("unknown native_gnome kind '%s'", kind); + return NULL; +} + +/* Sets a state flag on a widget and every descendant. + * + * A composite control draws through CHILD css nodes -- a GtkDropDown is a GtkToggleButton + * in a box -- and the node Adwaita styles for :hover is the button's, not the container's. + * Setting the flag only on the outer widget leaves the visible part unstyled, so the tile + * comes out identical to normal and looks like "this platform has no hover" when the real + * answer is "the flag was put on the wrong node". + * + * Applied to the whole subtree so the answer the capture records is the platform's. Where + * a control genuinely has no style for the state -- Adwaita restyles a GtkEntry on focus, + * not on hover -- the tile is still identical, and that is then a real finding, which the + * manifest reports by name. */ +static void set_state_flags_recursive(GtkWidget *w, GtkStateFlags flag) { + gtk_widget_set_state_flags(w, flag, FALSE); + for (GtkWidget *c = gtk_widget_get_first_child(w); c; c = gtk_widget_get_next_sibling(c)) { + set_state_flags_recursive(c, flag); + } +} + +/* Applies one state. Returns 0 when the state cannot be expressed, which is a reason to + * skip the tile rather than to write a mislabelled one. */ +static int apply_state(GtkWidget *w, const char *state, const char *kind) { + if (strcmp(state, "normal") == 0) { + return 1; + } + if (strcmp(state, "hover") == 0) { + /* PRELIGHT is exactly what the CSS :hover pseudo-class resolves from, so Adwaita + * restyles the widget for real rather than the app drawing its idea of a hover. */ + set_state_flags_recursive(w, GTK_STATE_FLAG_PRELIGHT); + return 1; + } + if (strcmp(state, "pressed") == 0) { + set_state_flags_recursive(w, GTK_STATE_FLAG_ACTIVE); + return 1; + } + if (strcmp(state, "disabled") == 0) { + gtk_widget_set_sensitive(w, FALSE); + return 1; + } + if (strcmp(state, "selected") == 0) { + if (strcmp(kind, "gtk_switch") == 0) { + gtk_switch_set_active(GTK_SWITCH(w), TRUE); + return 1; + } + if (GTK_IS_CHECK_BUTTON(w)) { + gtk_check_button_set_active(GTK_CHECK_BUTTON(w), TRUE); + return 1; + } + return 0; + } + blocker("unknown state '%s'", state); + return 0; +} + +/* Builds one tile: a fixed 240x56 surface carrying the theme's window background, with the + * widget anchored top-left. Returns the tile, or NULL when the state could not be applied. + * + * The "background" CSS class is what makes the surface the window colour. Without it the + * container paints nothing, the PNG comes out transparent behind the widget, and the + * comparator's content mask -- which measures distance from a backdrop colour -- has no + * backdrop to measure from. */ +static GtkWidget *build_tile(const Spec *spec, const char *state) { + GtkWidget *w = make_widget(spec->kind); + if (!w) { + return NULL; + } + if (!apply_state(w, state, spec->kind)) { + g_object_ref_sink(w); + g_object_unref(w); + return NULL; + } + gtk_widget_set_halign(w, is_full_width(spec->kind) ? GTK_ALIGN_FILL : GTK_ALIGN_START); + gtk_widget_set_valign(w, GTK_ALIGN_START); + if (is_full_width(spec->kind)) { + gtk_widget_set_size_request(w, TILE_W, -1); + gtk_widget_set_hexpand(w, TRUE); + } + + GtkWidget *tile = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(tile, "background"); + gtk_widget_set_size_request(tile, TILE_W, TILE_H); + gtk_box_append(GTK_BOX(tile), w); + return tile; +} + +/* Captures the whole matrix for one appearance. The window's child is swapped per tile and + * the main loop is pumped so GTK actually lays the new child out before it is read back -- + * without that every tile after the first is captured at the previous one's allocation. */ +static void capture_appearance(GtkWindow *win, const char *appearance) { + AdwStyleManager *sm = adw_style_manager_get_default(); + /* FORCE_* rather than PREFER_*: the forcing variants override whatever the desktop + * portal reports, so no xdg-desktop-portal needs to be running and the appearance + * cannot drift with the runner image. */ + adw_style_manager_set_color_scheme(sm, + strcmp(appearance, "dark") == 0 ? ADW_COLOR_SCHEME_FORCE_DARK + : ADW_COLOR_SCHEME_FORCE_LIGHT); + /* Let the style change propagate. Adwaita restyles every widget from the colour + * scheme, and a tile captured mid-transition carries the previous palette. */ + for (int i = 0; i < 50; i++) { + while (g_main_context_iteration(NULL, FALSE)) { + /* drain */ + } + g_usleep(2000); + } + + for (int s = 0; s < SPEC_COUNT; s++) { + const Spec *spec = &SPECS[s]; + for (int j = 0; spec->states[j]; j++) { + char name[256]; + snprintf(name, sizeof(name), "%s_%s_%s", spec->id, spec->states[j], appearance); + GtkWidget *tile = build_tile(spec, spec->states[j]); + if (!tile) { + blocker("%s produced no tile", name); + continue; + } + gtk_window_set_child(win, tile); + if (!wait_for_allocation(tile)) { + blocker("%s was never allocated; GTK did not lay the tile out", name); + continue; + } + capture_widget(win, tile, name); + } + } +} + +static gboolean on_ready(gpointer data) { + GtkWindow *win = GTK_WINDOW(data); + GtkWidget *content = gtk_window_get_child(win); + GtkWidget *probe_widget = content ? gtk_widget_get_first_child(content) : NULL; + + /* Under a bare Xvfb with no window manager nothing takes focus, so every toplevel + * sits in GTK_STATE_FLAG_BACKDROP and Adwaita draws the whole window in its dimmed, + * unfocused style. That is the GTK equivalent of an unfocused NSWindow greying every + * AppKit control, and it would produce a whole reference set wrong in the same + * direction -- which is exactly the kind of error nobody spots for a month. Assert it + * rather than hoping openbox did its job. */ + if (!gtk_window_is_active(win)) { + blocker("the window is not active: every widget would be captured in the " + "dimmed backdrop state. Is a window manager running on $DISPLAY?"); + } + + char *font = resolved_font(probe_widget ? probe_widget : GTK_WIDGET(win)); + printf("NATIVEREF:INFO resolved font = %s\n", font); + if (font && (strstr(font, "DejaVu") || strstr(font, "Sans ") == font)) { + blocker("fontconfig resolved '%s' -- Cantarell / Adwaita Sans is missing, so " + "every text metric would measure font availability, not fidelity", font); + } + g_free(font); + + if (is_probe) { + /* Even in probe mode take one tile: a manifest that says the environment is fine + * while the render path is broken is a check satisfiable by nothing happening. */ + GtkWidget *tile = build_tile(&SPECS[0], "normal"); + if (tile) { + gtk_window_set_child(win, tile); + if (!wait_for_allocation(tile)) { + blocker("the probe tile was never allocated"); + } + capture_widget(win, tile, "probe_DesktopButton_normal_light"); + } else { + blocker("the probe tile could not be built"); + } + } else { + capture_appearance(win, "light"); + capture_appearance(win, "dark"); + assert_appearances_differ(); + int expected = 0; + for (int s = 0; s < SPEC_COUNT; s++) { + for (int j = 0; SPECS[s].states[j]; j++) { + expected++; + } + } + expected *= 2; + if (tiles_written != expected) { + blocker("wrote %d tiles, expected %d: a partial set would be committed as if " + "it were the whole matrix", tiles_written, expected); + } + } + + write_manifest(win, probe_widget ? probe_widget : GTK_WIDGET(win)); + + for (int i = 0; i < blocker_count; i++) { + fprintf(stderr, "NATIVEREF:BLOCKER %s\n", blockers[i]); + } + printf("NATIVEREF:DONE tiles=%d exit=%d\n", tiles_written, exit_code); + gtk_window_close(win); + return G_SOURCE_REMOVE; +} + +int main(int argc, char **argv) { + out_dir = g_getenv("NATIVEREF_OUT"); + if (!out_dir) { + fprintf(stderr, "NATIVEREF:ERR NATIVEREF_OUT is not set\n"); + return 2; + } + const char *mode = g_getenv("NATIVEREF_MODE"); + is_probe = !(mode && strcmp(mode, "capture") == 0); + + adw_init(); + + AdwStyleManager *sm = adw_style_manager_get_default(); + adw_style_manager_set_color_scheme(sm, ADW_COLOR_SCHEME_FORCE_LIGHT); + + GtkSettings *settings = gtk_settings_get_default(); + g_object_set(settings, + /* Set explicitly, not inherited. There is no GNOME settings daemon on a + * bare Xvfb, so GTK falls back to "Sans 10" and fontconfig resolves DejaVu + * -- installing fonts-cantarell is necessary but not sufficient, which is + * exactly what the first probe run reported. */ + "gtk-font-name", "Cantarell 11", + "gtk-enable-animations", FALSE, + "gtk-cursor-blink", FALSE, + /* Grayscale, not subpixel. The runner default produces coloured fringes + * on every glyph edge that Codename One's grayscale AA can never match, + * which would show up as a permanent, unfixable text residual. */ + "gtk-xft-rgba", "none", + "gtk-xft-antialias", 1, + "gtk-xft-hinting", 1, + "gtk-xft-hintstyle", "hintslight", + "gtk-icon-theme-name", "Adwaita", + NULL); + + GtkWidget *win = gtk_window_new(); + gtk_window_set_title(GTK_WINDOW(win), "cn1-native-ref"); + /* Sized to one tile: the window IS the tile surface, so nothing else can bleed into a + * capture and the allocation the widget gets is the allocation it is measured at. */ + gtk_window_set_default_size(GTK_WINDOW(win), TILE_W, TILE_H); + gtk_window_set_decorated(GTK_WINDOW(win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(win), FALSE); + + GtkWidget *first = build_tile(&SPECS[0], "normal"); + gtk_window_set_child(GTK_WINDOW(win), first); + + gtk_window_present(GTK_WINDOW(win)); + + /* Give the frame clock a couple of turns so the first frame is actually presented + * before anything is read back; a capture taken before the first present is the + * classic uniformly-blank tile. */ + g_timeout_add(600, on_ready, win); + + while (g_list_model_get_n_items(gtk_window_get_toplevels()) > 0) { + g_main_context_iteration(NULL, TRUE); + } + return exit_code; +} diff --git a/scripts/fidelity-app/goldens/README.md b/scripts/fidelity-app/goldens/README.md new file mode 100644 index 00000000000..afbd77aa138 --- /dev/null +++ b/scripts/fidelity-app/goldens/README.md @@ -0,0 +1,91 @@ +# Native reference goldens + +Each directory here is one **golden set**: the captured appearance of a platform's +real widgets, which the Codename One render is scored against. A golden is not a +test output. It is the definition of what the theme is trying to look like, so +nothing in CI ever writes one. + +| Set | Captured from | +|---|---| +| `ios-26-metal` | iOS simulator, `scripts/build-ios-native-ref.sh` | +| `android-m3` | Android emulator, `scripts/build-android-native-ref.sh` | +| `windows-11-fluent` | Hosted Windows runner, WinUI 3 | +| `macos-aqua` | Hosted macOS runner, AppKit | +| `gnome-adwaita` | Hosted Linux runner, GTK4 + libadwaita under Xvfb | + +## Why the desktop sets come from CI + +The other sets are captured on a maintainer's machine. The desktop ones cannot +be: a working developer's Mac has a chosen accent colour, a chosen appearance +and custom fonts, and a reference captured there would encode all three. A +hosted runner is the closest available thing to a default-configured machine. + +This is measurable rather than theoretical. The same capture app run on a +maintainer's macOS 26 machine reports a window background of `#FFFFFF` and +`#171717`; on the macos-15 runner it reports `#E7E7E7` and `#262626`. Both are +correct for their machine and only one of them is a reference. + +## Capturing a set + +``` +gh workflow run fidelity-desktop-native-ref.yml -f targets=windows -f mode=capture +``` + +`targets` is `all`, `windows`, `macos` or `gnome`. `mode=probe` answers only the +environment questions and writes one tile; `mode=capture` writes the full matrix. + +The workflow is **dispatch only**, with no schedule and no path trigger, and that +is deliberate: a native reference defines the design generation a theme is written +against, so a job that re-captured it on its own would turn a real OS design +change into a green build. + +## Promoting a run to a golden set + +1. **Download the artifact and look at every frame.** Not the tile count -- the + frames. A capture that is wrong in one direction reports a full count and no + blockers, which is exactly how a light-mode backdrop ended up behind the dark + half of a Windows set. +2. **Read `capture-manifest.json`** and check it describes the environment you + intended: OS build, scale factor, accent colour, fonts, transparency and + contrast settings, toolkit versions. It also carries two fields worth reading + every time: + - `backdrop_by_appearance` -- the surface each half was captured on. The apps + refuse a set where the two are equal, but the values themselves are what you + compare against the theme's own `--window-bg-color`. + - `states_identical_to_normal` -- states the platform does not restyle. These + are real findings, not gaps: AppKit draws no rollover state at all, so every + macOS hover tile is listed, and Adwaita restyles a `GtkEntry` on focus rather + than on hover. **A theme must leave those states equal too**, or it diverges + from a reference that cannot move. +3. **Commit the set in one commit that names the run**, so the provenance of every + byte is recoverable. +4. **Dispatch the same capture again and require byte-identical output.** + Nondeterminism is fixed in the reference app or by pinning an environment knob, + never with a tolerance file. There are no tolerance sidecars here and there + will not be. + + What that took on Windows, since the same trail is likely to be walked again: + a screen `BitBlt` reads whatever is in front of those coordinates, and the app + cannot take the foreground on a hosted runner, so two runs differed on *every* + tile and one came back `#E0E0E0` in both appearances -- not the window at all. + `PrintWindow` with `PW_RENDERFULLCONTENT` renders the window's own content + instead and took it to 3 tiles. Control-template storyboards are not covered by + `SPI_SETCLIENTAREAANIMATION`, so the app grabs repeatedly and writes only when + two consecutive grabs agree. + + **The measured residual, recorded rather than tolerated:** the Windows set + reproduces byte-for-byte except for 2-3 pixels on the slider thumb's + anti-aliased edge in dark mode, which differ by +/-1 in a channel between runs. + That is GPU rasterizer rounding; nothing in the app or the environment pins it. + It is far below the comparator's content threshold and does not move a score. + It is written down here so the next person does not spend a run discovering it, + and it is NOT a licence to accept a larger one. +5. **Record the first baseline separately**, with `FIDELITY_UPDATE_BASELINE=1`, so + the commit that defines the goldens and the commit that defines the ratchet are + two reviewable changes rather than one. + +## What CI may and may not do + +CI scores against these files and never writes them. `FIDELITY_UPDATE_GOLDENS` +must not be set in any desktop workflow -- the desktop suite follows the iOS +model, where committed goldens are the contract, not the Android one. diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_disabled_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_disabled_dark.png new file mode 100644 index 00000000000..2a36140c8cd Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_disabled_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_disabled_light.png new file mode 100644 index 00000000000..485bc10b01e Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_hover_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_hover_dark.png new file mode 100644 index 00000000000..1d6a3e73128 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_hover_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_hover_light.png new file mode 100644 index 00000000000..1b0b76f1f9e Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_normal_dark.png new file mode 100644 index 00000000000..60c13a1bbd7 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_normal_light.png new file mode 100644 index 00000000000..59248753000 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_pressed_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_pressed_dark.png new file mode 100644 index 00000000000..3ab97d9429a Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_pressed_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_pressed_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_pressed_light.png new file mode 100644 index 00000000000..35f680424a4 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopAccentButton_pressed_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_disabled_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_disabled_dark.png new file mode 100644 index 00000000000..de1b240bb41 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_disabled_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_disabled_light.png new file mode 100644 index 00000000000..0c29ecf5b90 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_hover_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_hover_dark.png new file mode 100644 index 00000000000..1ed95bef695 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_hover_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_hover_light.png new file mode 100644 index 00000000000..b6098d5a4b7 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_normal_dark.png new file mode 100644 index 00000000000..9b01bd9ef7b Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_normal_light.png new file mode 100644 index 00000000000..02831173dc7 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_pressed_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_pressed_dark.png new file mode 100644 index 00000000000..c1060888b9c Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_pressed_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_pressed_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_pressed_light.png new file mode 100644 index 00000000000..c2bf0bb5565 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopButton_pressed_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_disabled_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_disabled_dark.png new file mode 100644 index 00000000000..c41016221c8 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_disabled_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_disabled_light.png new file mode 100644 index 00000000000..e11b5683020 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_hover_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_hover_dark.png new file mode 100644 index 00000000000..3e2ec128c4c Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_hover_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_hover_light.png new file mode 100644 index 00000000000..c7b0c718e96 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_normal_dark.png new file mode 100644 index 00000000000..fa3e5757df2 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_normal_light.png new file mode 100644 index 00000000000..ae8185dd398 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_selected_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_selected_dark.png new file mode 100644 index 00000000000..819672eedae Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_selected_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_selected_light.png new file mode 100644 index 00000000000..f5eb5a27a5d Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopCheckBox_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_disabled_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_disabled_dark.png new file mode 100644 index 00000000000..553f443e9c8 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_disabled_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_disabled_light.png new file mode 100644 index 00000000000..180e3fabcbf Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_hover_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_hover_dark.png new file mode 100644 index 00000000000..77069ce0552 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_hover_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_hover_light.png new file mode 100644 index 00000000000..f3bcc6ae94f Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_normal_dark.png new file mode 100644 index 00000000000..6d942b52cf1 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_normal_light.png new file mode 100644 index 00000000000..abcc77db6d2 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopComboBox_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopProgressBar_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopProgressBar_normal_dark.png new file mode 100644 index 00000000000..bf0d37502b1 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopProgressBar_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopProgressBar_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopProgressBar_normal_light.png new file mode 100644 index 00000000000..0cb03d3be39 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopProgressBar_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_disabled_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_disabled_dark.png new file mode 100644 index 00000000000..9b3f2e06cf2 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_disabled_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_disabled_light.png new file mode 100644 index 00000000000..d899889ea0b Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_hover_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_hover_dark.png new file mode 100644 index 00000000000..1e6046bfc95 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_hover_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_hover_light.png new file mode 100644 index 00000000000..569bc347782 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_normal_dark.png new file mode 100644 index 00000000000..5720ae07380 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_normal_light.png new file mode 100644 index 00000000000..9294b47cde9 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_selected_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_selected_dark.png new file mode 100644 index 00000000000..de5a38b6b6e Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_selected_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_selected_light.png new file mode 100644 index 00000000000..5f8ddedfbf0 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopRadioButton_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_disabled_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_disabled_dark.png new file mode 100644 index 00000000000..561e2bcffa1 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_disabled_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_disabled_light.png new file mode 100644 index 00000000000..ad84d7fb139 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_hover_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_hover_dark.png new file mode 100644 index 00000000000..107bc0c4925 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_hover_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_hover_light.png new file mode 100644 index 00000000000..f88fb1bef03 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_normal_dark.png new file mode 100644 index 00000000000..2ecf5f1fd58 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_normal_light.png new file mode 100644 index 00000000000..4827d304e4f Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSlider_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_disabled_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_disabled_dark.png new file mode 100644 index 00000000000..6b7675f116d Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_disabled_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_disabled_light.png new file mode 100644 index 00000000000..5d205e3a17f Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_hover_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_hover_dark.png new file mode 100644 index 00000000000..ca52bbd2b6a Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_hover_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_hover_light.png new file mode 100644 index 00000000000..4eb8865c9aa Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_normal_dark.png new file mode 100644 index 00000000000..7ccc97aedec Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_normal_light.png new file mode 100644 index 00000000000..0b014373d01 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_selected_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_selected_dark.png new file mode 100644 index 00000000000..9f6554afec5 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_selected_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_selected_light.png new file mode 100644 index 00000000000..fe2cd8a56cd Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopSwitch_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_disabled_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_disabled_dark.png new file mode 100644 index 00000000000..565c59412c6 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_disabled_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_disabled_light.png new file mode 100644 index 00000000000..cf8e794dc74 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_hover_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_hover_dark.png new file mode 100644 index 00000000000..17a88e73d72 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_hover_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_hover_light.png new file mode 100644 index 00000000000..741633c5f45 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_normal_dark.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_normal_dark.png new file mode 100644 index 00000000000..17a88e73d72 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_normal_light.png b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_normal_light.png new file mode 100644 index 00000000000..741633c5f45 Binary files /dev/null and b/scripts/fidelity-app/goldens/gnome-adwaita/DesktopTextField_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/gnome-adwaita/capture-manifest.json b/scripts/fidelity-app/goldens/gnome-adwaita/capture-manifest.json new file mode 100644 index 00000000000..65967990227 --- /dev/null +++ b/scripts/fidelity-app/goldens/gnome-adwaita/capture-manifest.json @@ -0,0 +1,34 @@ +{ + "schema": 1, + "platform": "gnome", + "golden_set": "gnome-adwaita", + "mode": "capture", + "toolkit": { + "name": "GTK4", + "gtk": "4.14.5", + "libadwaita": "1.5.0", + "gsk_renderer": "cairo" + }, + "display": { + "scale_factor": 1, + "gdk_scale": "1", + "gdk_dpi_scale": "1" + }, + "appearance": { + "color_scheme": "dark", + "dark": true, + "high_contrast": false, + "animations_enabled": false + }, + "fonts": { + "gtk_font_name": "Cantarell 11", + "resolved": "" + }, + "window": { + "active": true + }, + "tiles_written": 60, + "backdrop_by_appearance": {"light": "#FAFAFA", "dark": "#242424"}, + "states_identical_to_normal": ["DesktopTextField_hover_light", "DesktopTextField_hover_dark"], + "blockers": [] +} diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_disabled_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_disabled_dark.png new file mode 100644 index 00000000000..eb35cc4d117 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_disabled_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_disabled_light.png new file mode 100644 index 00000000000..653b815fec1 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_hover_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_hover_dark.png new file mode 100644 index 00000000000..ff464ae41c9 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_hover_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_hover_light.png new file mode 100644 index 00000000000..8fd5e121d60 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_normal_dark.png new file mode 100644 index 00000000000..ff464ae41c9 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_normal_light.png new file mode 100644 index 00000000000..8fd5e121d60 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_pressed_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_pressed_dark.png new file mode 100644 index 00000000000..75a14879b7e Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_pressed_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_pressed_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_pressed_light.png new file mode 100644 index 00000000000..3495650d6f5 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopAccentButton_pressed_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_disabled_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_disabled_dark.png new file mode 100644 index 00000000000..eb35cc4d117 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_disabled_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_disabled_light.png new file mode 100644 index 00000000000..653b815fec1 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_hover_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_hover_dark.png new file mode 100644 index 00000000000..8d528e1d7d0 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_hover_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_hover_light.png new file mode 100644 index 00000000000..5d5df5667ce Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_normal_dark.png new file mode 100644 index 00000000000..8d528e1d7d0 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_normal_light.png new file mode 100644 index 00000000000..5d5df5667ce Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_pressed_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_pressed_dark.png new file mode 100644 index 00000000000..9b67c1b3138 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_pressed_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_pressed_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_pressed_light.png new file mode 100644 index 00000000000..eeeb18226a9 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopButton_pressed_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_disabled_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_disabled_dark.png new file mode 100644 index 00000000000..ac4c8c04c46 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_disabled_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_disabled_light.png new file mode 100644 index 00000000000..74de671186e Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_hover_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_hover_dark.png new file mode 100644 index 00000000000..1c3dbc25dcb Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_hover_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_hover_light.png new file mode 100644 index 00000000000..741f8918dd6 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_normal_dark.png new file mode 100644 index 00000000000..1c3dbc25dcb Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_normal_light.png new file mode 100644 index 00000000000..741f8918dd6 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_selected_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_selected_dark.png new file mode 100644 index 00000000000..b9b71e920d5 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_selected_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_selected_light.png new file mode 100644 index 00000000000..6ad3ad717f8 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopCheckBox_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_disabled_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_disabled_dark.png new file mode 100644 index 00000000000..3809e1f07e3 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_disabled_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_disabled_light.png new file mode 100644 index 00000000000..e63873f4cea Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_hover_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_hover_dark.png new file mode 100644 index 00000000000..c0152bf6e38 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_hover_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_hover_light.png new file mode 100644 index 00000000000..fd23bd6d8cf Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_normal_dark.png new file mode 100644 index 00000000000..c0152bf6e38 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_normal_light.png new file mode 100644 index 00000000000..fd23bd6d8cf Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopComboBox_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopProgressBar_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopProgressBar_normal_dark.png new file mode 100644 index 00000000000..778c6a5a628 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopProgressBar_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopProgressBar_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopProgressBar_normal_light.png new file mode 100644 index 00000000000..4085d5acf6d Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopProgressBar_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_disabled_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_disabled_dark.png new file mode 100644 index 00000000000..80acbbc7308 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_disabled_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_disabled_light.png new file mode 100644 index 00000000000..900ce064222 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_hover_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_hover_dark.png new file mode 100644 index 00000000000..00aaf608cc1 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_hover_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_hover_light.png new file mode 100644 index 00000000000..ac38d964bab Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_normal_dark.png new file mode 100644 index 00000000000..00aaf608cc1 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_normal_light.png new file mode 100644 index 00000000000..ac38d964bab Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_selected_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_selected_dark.png new file mode 100644 index 00000000000..a3a507258d6 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_selected_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_selected_light.png new file mode 100644 index 00000000000..0850dfa994d Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopRadioButton_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_disabled_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_disabled_dark.png new file mode 100644 index 00000000000..96e321e799f Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_disabled_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_disabled_light.png new file mode 100644 index 00000000000..9adc1a500b6 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_hover_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_hover_dark.png new file mode 100644 index 00000000000..c46090d5267 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_hover_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_hover_light.png new file mode 100644 index 00000000000..800dccca749 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_normal_dark.png new file mode 100644 index 00000000000..c46090d5267 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_normal_light.png new file mode 100644 index 00000000000..800dccca749 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSlider_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_disabled_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_disabled_dark.png new file mode 100644 index 00000000000..722ec016df0 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_disabled_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_disabled_light.png new file mode 100644 index 00000000000..afc296305dc Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_hover_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_hover_dark.png new file mode 100644 index 00000000000..b4fa9b04776 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_hover_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_hover_light.png new file mode 100644 index 00000000000..5d0b5d31996 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_normal_dark.png new file mode 100644 index 00000000000..b4fa9b04776 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_normal_light.png new file mode 100644 index 00000000000..5d0b5d31996 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_selected_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_selected_dark.png new file mode 100644 index 00000000000..c07d114d93a Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_selected_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_selected_light.png new file mode 100644 index 00000000000..44a56a1cccb Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopSwitch_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_disabled_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_disabled_dark.png new file mode 100644 index 00000000000..cbb150fff8c Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_disabled_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_disabled_light.png new file mode 100644 index 00000000000..bfdef5dad53 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_hover_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_hover_dark.png new file mode 100644 index 00000000000..f46e60c6cd4 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_hover_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_hover_light.png new file mode 100644 index 00000000000..2468a0bde71 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_normal_dark.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_normal_dark.png new file mode 100644 index 00000000000..f46e60c6cd4 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_normal_light.png b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_normal_light.png new file mode 100644 index 00000000000..2468a0bde71 Binary files /dev/null and b/scripts/fidelity-app/goldens/macos-aqua/DesktopTextField_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/macos-aqua/capture-manifest.json b/scripts/fidelity-app/goldens/macos-aqua/capture-manifest.json new file mode 100644 index 00000000000..06f7c2e6d90 --- /dev/null +++ b/scripts/fidelity-app/goldens/macos-aqua/capture-manifest.json @@ -0,0 +1,42 @@ +{ + "schema": 1, + "platform": "macos", + "golden_set": "macos-aqua", + "mode": "capture", + "tiles_written": 60, + "backdrop_by_appearance": {"dark": "#262626", "light": "#E7E7E7"}, + "os": { + "version": "15.7.9", + "build": "Version 15.7.9 (Build 24G830)" + }, + "toolkit": { + "name": "AppKit", + "deployment": "unsigned-bundle" + }, + "display": { + "backing_scale_factor": 1.0, + "capture_scale": 1.0, + "tile_size": "240x56", + "screen_size": "1024x768" + }, + "window": { + "key": true, + "main": true, + "app_active": true + }, + "appearance": { + "effective": "NSAppearanceNameDarkAqua", + "accent_color": "#007AFF", + "highlight_color": "#0064E1", + "window_background": "#ECECEC", + "reduce_transparency": false, + "increase_contrast": false + }, + "states_identical_to_normal": ["DesktopButton_hover_light", "DesktopAccentButton_hover_light", "DesktopTextField_hover_light", "DesktopCheckBox_hover_light", "DesktopRadioButton_hover_light", "DesktopSwitch_hover_light", "DesktopSlider_hover_light", "DesktopComboBox_hover_light", "DesktopButton_hover_dark", "DesktopAccentButton_hover_dark", "DesktopTextField_hover_dark", "DesktopCheckBox_hover_dark", "DesktopRadioButton_hover_dark", "DesktopSwitch_hover_dark", "DesktopSlider_hover_dark", "DesktopComboBox_hover_dark"], + "capture": { + "method": "cachedisplay", + "vibrancy_capturable": false, + "hover_supported": false + }, + "blockers": [] +} diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_disabled_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_disabled_dark.png new file mode 100644 index 00000000000..c1b8a929e42 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_disabled_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_disabled_light.png new file mode 100644 index 00000000000..2de19af48f5 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_hover_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_hover_dark.png new file mode 100644 index 00000000000..655a2534166 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_hover_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_hover_light.png new file mode 100644 index 00000000000..c6b7009b9ee Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_normal_dark.png new file mode 100644 index 00000000000..f42f15ecf6b Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_normal_light.png new file mode 100644 index 00000000000..ec54e09b90a Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_pressed_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_pressed_dark.png new file mode 100644 index 00000000000..f0eedd74dfa Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_pressed_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_pressed_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_pressed_light.png new file mode 100644 index 00000000000..ff8b2355f26 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopAccentButton_pressed_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_disabled_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_disabled_dark.png new file mode 100644 index 00000000000..47809a09c35 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_disabled_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_disabled_light.png new file mode 100644 index 00000000000..0feca8989f4 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_hover_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_hover_dark.png new file mode 100644 index 00000000000..e33d0f1b94a Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_hover_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_hover_light.png new file mode 100644 index 00000000000..ce27673e382 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_normal_dark.png new file mode 100644 index 00000000000..bd3a50c6cea Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_normal_light.png new file mode 100644 index 00000000000..85dd03eb3c5 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_pressed_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_pressed_dark.png new file mode 100644 index 00000000000..2253a76d9d4 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_pressed_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_pressed_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_pressed_light.png new file mode 100644 index 00000000000..4ef27d74826 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopButton_pressed_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_disabled_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_disabled_dark.png new file mode 100644 index 00000000000..47524144ecf Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_disabled_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_disabled_light.png new file mode 100644 index 00000000000..7bc9cbe65f9 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_hover_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_hover_dark.png new file mode 100644 index 00000000000..f86a7697538 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_hover_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_hover_light.png new file mode 100644 index 00000000000..dee2a0bbdad Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_normal_dark.png new file mode 100644 index 00000000000..b6184781066 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_normal_light.png new file mode 100644 index 00000000000..022695dbce9 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_selected_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_selected_dark.png new file mode 100644 index 00000000000..7c9ce99829a Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_selected_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_selected_light.png new file mode 100644 index 00000000000..3cb1e7a1cde Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopCheckBox_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_disabled_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_disabled_dark.png new file mode 100644 index 00000000000..8066e5dcff0 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_disabled_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_disabled_light.png new file mode 100644 index 00000000000..550a14afb9e Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_hover_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_hover_dark.png new file mode 100644 index 00000000000..e9e5471d8e0 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_hover_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_hover_light.png new file mode 100644 index 00000000000..7da6311eccb Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_normal_dark.png new file mode 100644 index 00000000000..926ba71f7e1 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_normal_light.png new file mode 100644 index 00000000000..79de7565e52 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopComboBox_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopProgressBar_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopProgressBar_normal_dark.png new file mode 100644 index 00000000000..0e51deee6d6 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopProgressBar_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopProgressBar_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopProgressBar_normal_light.png new file mode 100644 index 00000000000..29539342746 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopProgressBar_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_disabled_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_disabled_dark.png new file mode 100644 index 00000000000..e4e241de620 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_disabled_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_disabled_light.png new file mode 100644 index 00000000000..38724b527db Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_hover_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_hover_dark.png new file mode 100644 index 00000000000..04041529512 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_hover_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_hover_light.png new file mode 100644 index 00000000000..a7f3ac4db66 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_normal_dark.png new file mode 100644 index 00000000000..a2b45b269cc Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_normal_light.png new file mode 100644 index 00000000000..ce565af914c Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_selected_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_selected_dark.png new file mode 100644 index 00000000000..559f4beae07 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_selected_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_selected_light.png new file mode 100644 index 00000000000..e9e17b01aa4 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopRadioButton_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_disabled_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_disabled_dark.png new file mode 100644 index 00000000000..05131a2ba20 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_disabled_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_disabled_light.png new file mode 100644 index 00000000000..12183a3c528 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_hover_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_hover_dark.png new file mode 100644 index 00000000000..5ca82096761 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_hover_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_hover_light.png new file mode 100644 index 00000000000..c74d78f6788 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_normal_dark.png new file mode 100644 index 00000000000..3f3e7cafc14 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_normal_light.png new file mode 100644 index 00000000000..079487a331e Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSlider_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_disabled_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_disabled_dark.png new file mode 100644 index 00000000000..07ce65e7312 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_disabled_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_disabled_light.png new file mode 100644 index 00000000000..1ffdb0ba8bf Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_hover_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_hover_dark.png new file mode 100644 index 00000000000..1f0af0a489a Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_hover_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_hover_light.png new file mode 100644 index 00000000000..f6fe7cda4d0 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_normal_dark.png new file mode 100644 index 00000000000..808c13048b3 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_normal_light.png new file mode 100644 index 00000000000..144d6d651df Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_selected_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_selected_dark.png new file mode 100644 index 00000000000..75c537c73a3 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_selected_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_selected_light.png new file mode 100644 index 00000000000..843b6dcf7ad Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopSwitch_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_disabled_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_disabled_dark.png new file mode 100644 index 00000000000..6e29af81a49 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_disabled_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_disabled_light.png new file mode 100644 index 00000000000..b088ac3cd78 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_hover_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_hover_dark.png new file mode 100644 index 00000000000..db8794bc034 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_hover_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_hover_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_hover_light.png new file mode 100644 index 00000000000..90cea140b14 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_hover_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_normal_dark.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_normal_dark.png new file mode 100644 index 00000000000..fb402d590af Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_normal_light.png b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_normal_light.png new file mode 100644 index 00000000000..ee968c8acf7 Binary files /dev/null and b/scripts/fidelity-app/goldens/windows-11-fluent/DesktopTextField_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/windows-11-fluent/capture-manifest.json b/scripts/fidelity-app/goldens/windows-11-fluent/capture-manifest.json new file mode 100644 index 00000000000..67f42e77dcd --- /dev/null +++ b/scripts/fidelity-app/goldens/windows-11-fluent/capture-manifest.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "platform": "windows", + "golden_set": "windows-11-fluent", + "mode": "capture", + "os": { + "version": "10.0.26200.0", + "description": "Microsoft Windows 10.0.26200", + "architecture": "Arm64", + "product": "Windows 10 Enterprise", + "display_version": "25H2", + "build": "26200", + "installation_type": "Client" + }, + "toolkit": { + "name": "WinUI3", + "deployment": "unpackaged-self-contained", + "dotnet": ".NET 9.0.20" + }, + "display": { + "rasterization_scale": 1 + }, + "appearance": { + "mica_supported": true, + "transparency_effects": true, + "animations_enabled": true, + "accent_color": "#0078D4" + }, + "capture": { + "occluded": false, + "was_foreground": false, + "client_background": "#F3F3F3" + }, + "fonts": { + "control_family": "Segoe UI Variable", + "segoe_ui_variable_installed": true + }, + "tiles_written": 60, + "animations_disabled_by_app": true, + "backdrop_by_appearance": {"light": "#F3F3F3", "dark": "#202020"}, + "states_identical_to_normal": [], + "blockers": [] +} diff --git a/scripts/fidelity-app/macos-native-ref/NativeRef.swift b/scripts/fidelity-app/macos-native-ref/NativeRef.swift new file mode 100644 index 00000000000..11c9f564498 --- /dev/null +++ b/scripts/fidelity-app/macos-native-ref/NativeRef.swift @@ -0,0 +1,579 @@ +/* + * 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. + */ +// macOS (AppKit) native reference app for the Codename One fidelity suite. +// +// Direct counterpart to ios-native-ref/NativeRef.swift, and built the same way: one Swift +// file, no xcodeproj, a hand-written Info.plist (see scripts/build-macos-native-ref.sh). +// +// AppKit rather than SwiftUI. SwiftUI works in a -parse-as-library single file, but it +// needs an NSHostingView, adds a layout-timing indirection, and on macOS renders +// AppKit-derived controls anyway. AppKit gives direct control over isHighlighted / +// isEnabled / state and the natural intrinsicContentSize the tile contract depends on. +// +// CAPTURE IS 1x, DELIBERATELY, and this is the one setting most likely to look wrong. +// Every real Mac is 2x and the iOS reference forces 2x for exactly that reason. The +// desktop tiles are different: they are specified in LOGICAL pixels (1/96 inch), the CN1 +// side renders them at 1x, and the comparator overlays the two 1:1 after cropping to their +// common top-left region. A 2x native tile would therefore be compared against the CN1 +// tile's top-left QUARTER, at double scale, and score near zero for a reason no one would +// find by looking at the widget. +// +// HOVER ON macOS IS THE SAME RENDER AS NORMAL, and that is a finding rather than a gap. +// AppKit exposes no rollover state for push buttons, fields, sliders, switches or popups +// (`showsBorderOnlyWhileMouseInside` is a different feature on a different bezel style). +// So the hover tiles are captured from an untouched control and the manifest records +// hover_supported: false. The CN1 desktop themes must therefore leave Aqua's hover styling +// equal to normal -- and because these goldens say so, the gate now enforces that rather +// than leaving it to whoever writes the CSS. +import AppKit + +let outDir = ProcessInfo.processInfo.environment["NATIVEREF_OUT"] ?? "" +let isProbe = (ProcessInfo.processInfo.environment["NATIVEREF_MODE"] ?? "probe") != "capture" +let goldenSet = ProcessInfo.processInfo.environment["CN1SS_FIDELITY_GOLDEN_SET"] ?? "macos-aqua" + +/// Logical pixels; see the note above. Must equal the CN1 tile renderer's scale. +let CAPTURE_SCALE: CGFloat = 1.0 + +/// The tile the widget is anchored top-left in. Mirrors tile_width_px / tile_height_px in +/// fidelity-tests.yaml; if those change, this must change with them. +let TILE_W: CGFloat = 240 +let TILE_H: CGFloat = 56 + +var blockers: [String] = [] + +func blocker(_ msg: String) { blockers.append(msg) } + +func jsonEscape(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") +} + +/// The tile surface. +/// +/// It draws windowBackgroundColor in draw(_:) rather than assigning +/// `NSColor.windowBackgroundColor.cgColor` to a layer, and the difference is not stylistic. +/// A dynamic NSColor resolves against `NSAppearance.current`, which is only set inside a +/// drawing context. Read as `.cgColor` from ordinary code it resolves against whatever the +/// MACHINE is set to -- so on a Mac in Dark Mode every "light" tile was captured with a +/// dark backdrop, and the capture still reported 60 tiles and zero blockers. +final class TileView: NSView { + override var isFlipped: Bool { true } + override func draw(_ dirtyRect: NSRect) { + NSColor.windowBackgroundColor.setFill() + dirtyRect.fill() + } +} + +/// One row of the desktop matrix. `kind` is the native_mac key in fidelity-tests.yaml, and +/// the ids and states are that file's too: the two lists must agree or the comparator pairs +/// a CN1 render against nothing. +struct Spec { + let id: String + let kind: String + let states: [String] +} + +let SPECS: [Spec] = [ + Spec(id: "DesktopButton", kind: "appkit_push_button", states: ["normal", "hover", "pressed", "disabled"]), + Spec(id: "DesktopAccentButton", kind: "appkit_push_button_default", states: ["normal", "hover", "pressed", "disabled"]), + Spec(id: "DesktopTextField", kind: "appkit_textfield", states: ["normal", "hover", "disabled"]), + Spec(id: "DesktopCheckBox", kind: "appkit_checkbox", states: ["normal", "selected", "hover", "disabled"]), + Spec(id: "DesktopRadioButton", kind: "appkit_radio", states: ["normal", "selected", "hover", "disabled"]), + Spec(id: "DesktopSwitch", kind: "appkit_switch", states: ["normal", "selected", "hover", "disabled"]), + Spec(id: "DesktopSlider", kind: "appkit_slider", states: ["normal", "hover", "disabled"]), + Spec(id: "DesktopProgressBar", kind: "appkit_progress", states: ["normal"]), + Spec(id: "DesktopComboBox", kind: "appkit_popupbutton", states: ["normal", "hover", "disabled"]), +] + +/// Controls that own the full tile width rather than sizing to their content. A slider, a +/// progress bar and a text field have no natural width -- AppKit gives each whatever it is +/// asked for -- so the tile width is the honest answer, and it is the same rule the CN1 +/// renderer applies. Left to size themselves, a text field measures to its placeholder +/// (39px for "Text"), which is not a control anyone would recognise or ship. +let FULL_WIDTH_KINDS: Set = ["appkit_slider", "appkit_progress", "appkit_textfield"] + +final class RefApp: NSObject, NSApplicationDelegate { + var window: NSWindow! + var host: NSView! + var written = 0 + /// Backdrop colour sampled from each appearance's tiles. See assertAppearancesDiffer(). + var backdropByAppearance: [String: String] = [:] + + /// Hash of each "_normal_" tile, and the states that came out + /// identical to it. Reported in the manifest the way the Windows and GNOME references + /// report theirs. + /// + /// On this platform that list is long and expected: AppKit draws no rollover state for + /// any control in this matrix, so every hover tile matches its normal one. Saying so + /// per tile is the point -- "the theme may leave hover equal here" is a claim a theme + /// author should be able to check rather than take on trust from a comment. + var normalHashes: [String: String] = [:] + var identicalToNormal: [String] = [] + + func applicationDidFinishLaunching(_ note: Notification) { + // .regular so the app can actually become frontmost. An NSWindow that is not key + // draws EVERY AppKit control in its inactive, greyed style -- the same class of + // silent, uniform wrongness as a GTK window stuck in backdrop state. + NSApp.setActivationPolicy(.regular) + + let rect = NSRect(x: 0, y: 0, width: TILE_W, height: TILE_H) + window = NSWindow(contentRect: rect, + styleMask: [.titled, .closable], + backing: .buffered, + defer: false) + window.title = "cn1-native-ref" + window.animationBehavior = .none + + host = NSView(frame: rect) + window.contentView = host + + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + NSAnimationContext.current.duration = 0 + + // macOS 14 and later will not let an app steal focus from the frontmost one, so a + // single activate() call is not enough on a machine where something else is in + // front. On a CI runner nothing is competing and this succeeds on the first turn; + // locally it will not, which is precisely the difference this app exists to + // measure. Retried rather than called once so a slow session start is not mistaken + // for a restriction. + for _ in 0..<10 { + NSApp.activate(ignoringOtherApps: true) + window.makeKey() + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + if window.isKeyWindow && NSApp.isActive { break } + } + + // Let the first frame actually present before anything is read back. + RunLoop.current.run(until: Date().addingTimeInterval(1.0)) + finish() + } + + // MARK: widget construction + + func makeWidget(_ kind: String) -> NSView? { + switch kind { + case "appkit_push_button": + let b = NSButton(title: "Button", target: nil, action: nil) + b.bezelStyle = .rounded + return b + case "appkit_push_button_default": + let b = NSButton(title: "Button", target: nil, action: nil) + b.bezelStyle = .rounded + // The accent-filled button on macOS is the DEFAULT button, and the only + // supported way to make one is to give it the return key. Setting bezelColor + // instead produces a tinted button that is not what the system draws. + b.keyEquivalent = "\r" + return b + case "appkit_textfield": + let t = NSTextField(string: "Text") + t.isEditable = true + t.isBezeled = true + t.bezelStyle = .roundedBezel + return t + case "appkit_checkbox": + return NSButton(checkboxWithTitle: "Check", target: nil, action: nil) + case "appkit_radio": + return NSButton(radioButtonWithTitle: "Radio", target: nil, action: nil) + case "appkit_switch": + return NSSwitch() + case "appkit_slider": + let s = NSSlider(value: 0.5, minValue: 0, maxValue: 1, target: nil, action: nil) + s.isContinuous = true + return s + case "appkit_popupbutton": + let pop = NSPopUpButton(frame: .zero, pullsDown: false) + pop.addItem(withTitle: "Option") + return pop + case "appkit_progress": + let p = NSProgressIndicator() + p.style = .bar + p.isIndeterminate = false + p.minValue = 0 + p.maxValue = 1 + p.doubleValue = 0.6 + // An animating bar is a different pixel every frame. The suite has no tolerance + // file by design, so anything that moves has to be stopped rather than averaged. + p.usesThreadedAnimation = false + p.stopAnimation(nil) + return p + default: + blocker("unknown native_mac kind '\(kind)'") + return nil + } + } + + /// Applies one state. Returns false when the state cannot be expressed, which is a + /// reason to skip the tile rather than to write a mislabelled one. + func applyState(_ view: NSView, _ state: String, _ kind: String) -> Bool { + switch state { + case "normal": + return true + case "hover": + // Deliberately a no-op: see the file header. AppKit draws no rollover state for + // any control in this matrix, so the honest hover reference IS the normal one. + return true + case "pressed": + guard let b = view as? NSButton else { return false } + b.isHighlighted = true + return true + case "selected": + if let sw = view as? NSSwitch { sw.state = .on; return true } + if let b = view as? NSButton { b.state = .on; return true } + return false + case "disabled": + if let c = view as? NSControl { c.isEnabled = false; return true } + return false + default: + blocker("unknown state '\(state)'") + return false + } + } + + // MARK: capture + + /// Lays one widget out top-left in a tile-sized view on the window's own surface and + /// renders it. Returns nil when the state could not be applied. + /// + /// NSView.cacheDisplay rather than CGWindowListCreateImage: it renders every DRAWN + /// AppKit control correctly, needs no Screen Recording consent (which cannot be granted + /// on a hosted runner at all), and the desktop matrix contains no vibrancy tile, which + /// is the one thing it cannot see. Aqua vibrancy is recorded as out of scope in + /// native-themes/COVERAGE.md rather than captured wrong. + func renderTile(_ spec: Spec, _ state: String) -> NSImage? { + guard let widget = makeWidget(spec.kind) else { return nil } + // The tile surface is the window background, which is what the CN1 side paints its + // tiles on. Read from the system rather than written down, so a macOS release that + // retunes windowBackgroundColor moves both sides together. See TileView for why it + // is drawn rather than assigned to a layer. + let tile = TileView(frame: NSRect(x: 0, y: 0, width: TILE_W, height: TILE_H)) + + if !applyState(widget, state, spec.kind) { + return nil + } + // fittingSize, not sizeToFit: the latter is NSControl's, and NSSwitch and + // NSProgressIndicator are not NSControls. + if let control = widget as? NSControl { + control.sizeToFit() + } + // The two dimensions are resolved SEPARATELY, because AppKit routinely answers one + // and not the other. NSProgressIndicator measures at (0.0, 20.0) fitting and + // (-1.0, 20.0) intrinsic -- it has a real height and genuinely no natural width, + // NSView.noIntrinsicMetric being -1. Testing them together threw the good height + // away with the missing width and laid the bar out at zero height, so it was + // dropped from the set as "produced no image". + var size = widget.fittingSize + if size.height <= 0 { size.height = widget.intrinsicContentSize.height } + if size.height <= 0 { size.height = widget.frame.height } + if FULL_WIDTH_KINDS.contains(spec.kind) { + size.width = TILE_W + } else { + if size.width <= 0 { size.width = widget.intrinsicContentSize.width } + if size.width <= 0 { size.width = widget.frame.width } + } + if size.width <= 0 || size.height <= 0 { + blocker("\(spec.id) \(state) laid out to \(size.width)x\(size.height)") + return nil + } + // TileView is flipped, so its origin is already top-left and matches the tile + // contract directly rather than through a height subtraction. + widget.setFrameSize(size) + widget.setFrameOrigin(NSPoint(x: 0, y: 0)) + tile.addSubview(widget) + + // In the window, not detached: an AppKit control renders in its inactive style + // unless it belongs to the key window, and a detached view has no window at all. + host.subviews.forEach { $0.removeFromSuperview() } + host.addSubview(tile) + tile.layoutSubtreeIfNeeded() + // Let the state land before it is read back. isHighlighted in particular is applied + // through the cell and is not visible in the same turn of the run loop. + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: Int(TILE_W * CAPTURE_SCALE), + pixelsHigh: Int(TILE_H * CAPTURE_SCALE), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .calibratedRGB, + bytesPerRow: 0, + bitsPerPixel: 0) else { + blocker("\(spec.id) \(state): could not allocate the tile bitmap") + return nil + } + rep.size = NSSize(width: TILE_W, height: TILE_H) + tile.cacheDisplay(in: tile.bounds, to: rep) + let img = NSImage(size: rep.size) + img.addRepresentation(rep) + return img + } + + func isBlank(_ image: NSImage) -> Bool { + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff) else { return true } + var seen = Set() + let w = rep.pixelsWide, h = rep.pixelsHigh + if w == 0 || h == 0 { return true } + for y in stride(from: 0, to: h, by: max(1, h / 32)) { + for x in stride(from: 0, to: w, by: max(1, w / 32)) { + if let c = rep.colorAt(x: x, y: y) { + let k = (UInt32(c.redComponent * 255) << 16) + | (UInt32(c.greenComponent * 255) << 8) + | UInt32(c.blueComponent * 255) + seen.insert(k) + if seen.count > 1 { return false } + } + } + } + return true + } + + func write(_ image: NSImage, _ name: String) { + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { + blocker("\(name) could not be encoded to PNG") + return + } + let path = (outDir as NSString).appendingPathComponent("\(name).png") + do { + try png.write(to: URL(fileURLWithPath: path)) + written += 1 + print("NATIVEREF:wrote \(name) \(rep.pixelsWide)x\(rep.pixelsHigh)") + // Bottom-right corner: every widget in the matrix anchors top-left and none is + // as tall as the tile, so this pixel is always backdrop. + if let c = rep.colorAt(x: rep.pixelsWide - 1, y: rep.pixelsHigh - 1), + let appearance = name.split(separator: "_").last { + backdropByAppearance[String(appearance)] = String(format: "#%02X%02X%02X", + Int(c.redComponent * 255), Int(c.greenComponent * 255), Int(c.blueComponent * 255)) + } + noteIfIdenticalToNormal(name, png) + } catch { + blocker("\(name) could not be written: \(error)") + } + } + + /// Captures the whole matrix for one appearance. + func captureAppearance(_ appearance: String) { + let named: NSAppearance.Name = appearance == "dark" ? .darkAqua : .aqua + let appAppearance = NSAppearance(named: named) + NSApp.appearance = appAppearance + // The WINDOW too. NSApp.appearance is only the fallback for windows that do not + // declare their own, and the controls resolve theirs from the window they are in. + window.appearance = appAppearance + // The appearance change has to propagate through the view tree before anything is + // rendered; without this the first tile of a dark pass comes out light. + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + for spec in SPECS { + for state in spec.states { + let name = "\(spec.id)_\(state)_\(appearance)" + guard let img = renderTile(spec, state) else { + blocker("\(name) produced no image") + continue + } + if isBlank(img) { + blocker("\(name) rendered blank") + continue + } + write(img, name) + } + } + } + + /// Records whether a state tile is byte-identical to its own normal tile. + func noteIfIdenticalToNormal(_ name: String, _ png: Data) { + let parts = name.split(separator: "_").map(String.init) + guard parts.count == 3 else { return } + let (id, state, appearance) = (parts[0], parts[1], parts[2]) + let key = "\(id)_\(appearance)" + // FNV-1a rather than CryptoKit: this needs to tell "same bytes" from "different + // bytes" and nothing more, and it keeps the file free of another import. + var hash: UInt64 = 0xcbf29ce484222325 + for byte in png { + hash ^= UInt64(byte) + hash = hash &* 0x100000001b3 + } + let digest = String(hash, radix: 16) + if state == "normal" { + normalHashes[key] = digest + return + } + if normalHashes[key] == digest { + identicalToNormal.append(name) + print("NATIVEREF:INFO \(name) is identical to its normal tile; AppKit does not " + + "restyle this control for this state") + } + } + + /// Fails the run when the light and dark passes were captured on the same backdrop. + /// + /// This is here because it happened. On this file it was NSColor.windowBackgroundColor + /// read as `.cgColor`, which resolves against NSAppearance.current rather than the + /// appearance being set, so on a Mac in Dark Mode the whole light pass came out dark. + /// On the Windows reference it was the same shape of mistake in a different API. Both + /// produced a full tile count and zero blockers. + /// + /// Nothing downstream catches it: the CN1 side renders its tiles on the real surface + /// for each appearance, so the pair simply scores badly and reads as a theme that needs + /// work rather than as a reference that was captured wrong. + func assertAppearancesDiffer() { + for (appearance, colour) in backdropByAppearance.sorted(by: { $0.key < $1.key }) { + print("NATIVEREF:INFO \(appearance) backdrop \(colour)") + } + if Set(backdropByAppearance.values).count == 1, let only = backdropByAppearance.values.first { + blocker("the light and dark passes were both captured on backdrop \(only): the " + + "appearance did not actually change, so half the set is mislabelled") + } + } + + func finish() { + // AppKit's active control appearance follows the application being active and the + // window being key/main, and they can disagree -- so all three are recorded and the + // blocker fires only when the render really would be the inactive one. A single + // isKeyWindow test reports a problem on any developer machine that simply has + // another app in front, which would make this check noise rather than a gate. + if !(window.isKeyWindow && NSApp.isActive) { + blocker("the app did not become active (key=\(window.isKeyWindow) " + + "main=\(window.isMainWindow) appActive=\(NSApp.isActive)): every AppKit " + + "control would be captured in its inactive, greyed style, making the " + + "whole set wrong in one direction") + } + + // Assert rather than write these. Turning them off needs a cfprefsd restart to take + // effect reliably, so if a future runner image ships them ON you want to be told, + // not to paper over it and capture a low-transparency, high-contrast reference. + let ua = UserDefaults(suiteName: "com.apple.universalaccess") + if ua?.bool(forKey: "reduceTransparency") == true { + blocker("reduceTransparency is on: every material would render as a flat fill") + } + if ua?.bool(forKey: "increaseContrast") == true { + blocker("increaseContrast is on: control borders and fills are not the defaults") + } + + if isProbe { + // One tile is enough to answer "can this environment render a control at all", + // and it is prefixed so it can never be mistaken for a golden. + if let img = renderTile(SPECS[0], "normal"), !isBlank(img) { + write(img, "probe_DesktopButton_normal_light") + } else { + blocker("the probe tile produced nothing usable") + } + } else { + captureAppearance("light") + captureAppearance("dark") + assertAppearancesDiffer() + let expected = SPECS.reduce(0) { $0 + $1.states.count } * 2 + if written != expected { + blocker("wrote \(written) tiles, expected \(expected): a partial set would be " + + "committed as if it were the whole matrix") + } + } + + writeManifest(captureMethod: "cachedisplay") + + for b in blockers { FileHandle.standardError.write("NATIVEREF:BLOCKER \(b)\n".data(using: .utf8)!) } + print("NATIVEREF:DONE tiles=\(written) exit=\(blockers.isEmpty ? 0 : 20)") + // Explicit flush. Launched through `open --stdout `, stdout is a FILE, so it + // is block buffered rather than line buffered, and the build script reads the exit + // status back out of that last line. exit() does flush stdio, but the verdict line + // is the one thing the whole run is judged on and a macOS runner slot costs over an + // hour of queueing, so it is not left to inference. + fflush(stdout) + exit(blockers.isEmpty ? 0 : 20) + } + + func writeManifest(captureMethod: String) { + let screen = NSScreen.main + let accent = NSColor.controlAccentColor.usingColorSpace(.sRGB) + let highlight = NSColor.selectedContentBackgroundColor.usingColorSpace(.sRGB) + let windowBg = NSColor.windowBackgroundColor.usingColorSpace(.sRGB) + func hex(_ c: NSColor?) -> String { + guard let c = c else { return "unknown" } + return String(format: "#%02X%02X%02X", + Int(c.redComponent * 255), Int(c.greenComponent * 255), Int(c.blueComponent * 255)) + } + let os = ProcessInfo.processInfo.operatingSystemVersion + let json = """ + { + "schema": 1, + "platform": "macos", + "golden_set": "\(jsonEscape(goldenSet))", + "mode": "\(isProbe ? "probe" : "capture")", + "tiles_written": \(written), + "backdrop_by_appearance": {\(backdropByAppearance.sorted(by: { $0.key < $1.key }).map { "\"\($0.key)\": \"\($0.value)\"" }.joined(separator: ", "))}, + "os": { + "version": "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)", + "build": "\(jsonEscape(ProcessInfo.processInfo.operatingSystemVersionString))" + }, + "toolkit": { + "name": "AppKit", + "deployment": "unsigned-bundle" + }, + "display": { + "backing_scale_factor": \(screen?.backingScaleFactor ?? 0), + "capture_scale": \(CAPTURE_SCALE), + "tile_size": "\(Int(TILE_W))x\(Int(TILE_H))", + "screen_size": "\(Int(screen?.frame.width ?? 0))x\(Int(screen?.frame.height ?? 0))" + }, + "window": { + "key": \(window.isKeyWindow), + "main": \(window.isMainWindow), + "app_active": \(NSApp.isActive) + }, + "appearance": { + "effective": "\(jsonEscape(NSApp.effectiveAppearance.name.rawValue))", + "accent_color": "\(hex(accent))", + "highlight_color": "\(hex(highlight))", + "window_background": "\(hex(windowBg))", + "reduce_transparency": \(UserDefaults(suiteName: "com.apple.universalaccess")?.bool(forKey: "reduceTransparency") ?? false), + "increase_contrast": \(UserDefaults(suiteName: "com.apple.universalaccess")?.bool(forKey: "increaseContrast") ?? false) + }, + "states_identical_to_normal": [\(identicalToNormal.map { "\"\($0)\"" }.joined(separator: ", "))], + "capture": { + "method": "\(captureMethod)", + "vibrancy_capturable": false, + "hover_supported": false + }, + "blockers": [\(blockers.map { "\"\(jsonEscape($0))\"" }.joined(separator: ", "))] + } + """ + let path = (outDir as NSString).appendingPathComponent("capture-manifest.json") + try? (json + "\n").write(toFile: path, atomically: true, encoding: .utf8) + print("NATIVEREF:INFO wrote \(path)") + } +} + +// -parse-as-library forbids top-level expressions, so the entry point is explicit. The +// delegate is held in a static: NSApplication.delegate is a weak reference, and a locally +// scoped delegate is deallocated before applicationDidFinishLaunching ever fires. +@main +struct NativeRefMain { + static let delegate = RefApp() + + static func main() { + let app = NSApplication.shared + app.delegate = delegate + app.run() + } +} diff --git a/scripts/fidelity-app/pom.xml b/scripts/fidelity-app/pom.xml index 1be714e08f7..4ca02a302a0 100644 --- a/scripts/fidelity-app/pom.xml +++ b/scripts/fidelity-app/pom.xml @@ -17,6 +17,7 @@ common + desktop-runner 8.0-SNAPSHOT diff --git a/scripts/fidelity-app/windows-native-ref/App.xaml b/scripts/fidelity-app/windows-native-ref/App.xaml new file mode 100644 index 00000000000..e6fcc6598b7 --- /dev/null +++ b/scripts/fidelity-app/windows-native-ref/App.xaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + diff --git a/scripts/fidelity-app/windows-native-ref/NativeRef.csproj b/scripts/fidelity-app/windows-native-ref/NativeRef.csproj new file mode 100644 index 00000000000..401f4b25bcc --- /dev/null +++ b/scripts/fidelity-app/windows-native-ref/NativeRef.csproj @@ -0,0 +1,56 @@ + + + + WinExe + net9.0-windows10.0.22621.0 + 10.0.19041.0 + Cn1NativeRef + true + None + true + true + + true + + false + disable + enable + + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN + + + + + + + diff --git a/scripts/fidelity-app/windows-native-ref/Program.cs b/scripts/fidelity-app/windows-native-ref/Program.cs new file mode 100644 index 00000000000..560a299dd97 --- /dev/null +++ b/scripts/fidelity-app/windows-native-ref/Program.cs @@ -0,0 +1,1390 @@ +/* + * 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. + */ +// Windows (WinUI 3 / Fluent) native reference app for the Codename One fidelity suite. +// +// Desktop counterpart to ios-native-ref/NativeRef.swift. It renders real WinUI controls in +// a real, composited window and writes reference tiles plus a capture-manifest.json. +// +// The manifest is not bookkeeping here, it is the point. `windows-latest` is Windows +// SERVER, where Mica and Acrylic fall back to a plain solid brush rather than failing, and +// where Segoe UI Variable -- the font every WinUI control uses -- may be absent. Capture a +// reference under those conditions and you get Fluent-with-the-materials-off, commit it as +// "the Windows 11 reference", tune the Codename One theme until it matches the fallback, +// and ship a theme that looks wrong on every actual Windows 11 machine. The fidelity +// metric cannot detect this: two sides that degrade into the same flat render score HIGH. +// +// So this app refuses to produce a reference set it cannot vouch for. In probe mode it +// answers the environment questions and exits; in capture mode it additionally writes +// tiles, but only after the same assertions pass. +// TWO STATIC-ANALYSIS RULES FIRE ALL OVER THIS FILE AND ARE ANSWERED HERE ONCE, because a +// review thread is not read by whoever edits this next. +// +// "Calls to unmanaged code -- replace with managed code if possible." There is no managed +// equivalent for any of it. This program exists to photograph what the Windows compositor +// actually put on screen: PrintWindow with PW_RENDERFULLCONTENT, BitBlt with CAPTUREBLT, +// DwmFlush, TrackMouseEvent, SystemParametersInfo. .NET exposes none of those, and a +// managed screenshot API would answer a different question -- "what does XAML think it +// drew" rather than "what did DWM composite" -- which is precisely the substitution that +// made an earlier version of this app report success while capturing unstyled controls. +// +// "Generic catch clause." Deliberate, and narrowing them would make this app worse at its +// one job. It is a capture probe whose contract is that it refuses to produce a reference +// set it cannot vouch for: every failure has to become a recorded blocker, not an escaping +// exception that kills the process with no manifest and no log. The broad catches are each +// paired with a documented fallback (a zero rect, "(unidentifiable)", false, "") or with a +// blocker that names the stage and the HRESULT. A typed list would have to enumerate every +// COM failure a hosted runner can produce, and the ones it missed would crash silently +// instead of being reported -- trading a described failure for an undescribed one. +// +// The floating-point finding on RasterizationScale was real and is fixed at its site. + +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.UI.Composition.SystemBackdrops; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Windows.UI.ViewManagement; + +namespace Cn1NativeRef; + +public static class Program +{ + [STAThread] + static void Main(string[] args) + { + Microsoft.UI.Xaml.Application.Start(_ => new App()); + } +} + +public partial class App : Application +{ + public App() + { + // Generated from App.xaml, which is where the style dictionary is merged. Doing that + // merge from C# instead -- in this constructor, and then in OnLaunched -- crashed the + // process with 0xC000027B both times, because without a XAML file the project + // produces no app resources.pri and XamlControlsResources cannot load its dictionary. + InitializeComponent(); + } + + /// Merged in OnLaunched, not in the constructor. + /// + /// Merges WinUI's default style dictionary. + /// + /// The template projects do this in App.xaml, which this app does not have -- it is pure + /// C# with no XAML file at all. Without it there are no control styles: a Button renders + /// as a flat square grey rectangle instead of a #FDFDFD Fluent capsule with 4px corners, + /// and nothing errors, because an unstyled control is still a perfectly valid control. + /// + /// This was three commits of chasing the wrong cause. The controls were unstyled, and the + /// resource index was missing too, so the missing PRI looked like the explanation; it + /// was not, and the build output carrying both framework PRIs while the button stayed + /// (194,194,194) is what finally ruled it out. + /// + /// Doing this in the App constructor crashed the process outright with 0xC000027B, a + /// stowed WinRT exception: Application.Resources is not ready to be touched that early. + + private Window _window; + private readonly List _blockers = new(); + private string _outDir; + private bool _occluded; + private string _stage = "(not started)"; + private string _clientBackground = "(not sampled)"; + private IntPtr _hwnd; + private FrameworkElement _probeControl; + private Grid _tileHost; + private bool _animationsDisabled; + private byte[] _lastWrittenTile; + private Windows.Foundation.Rect _probeBounds; + private bool _isProbe; + + /// The tile the widget is anchored top-left in. Mirrors tile_width_px / tile_height_px + /// in fidelity-tests.yaml; if those change, this must change with them. + private const int TileW = 240; + private const int TileH = 56; + + private int _tilesWritten; + + /// Hash of each "_normal_" tile, and the states that came out + /// byte-identical to it. + /// + /// Identical is not automatically wrong: a platform genuinely may not restyle a + /// control for a state (AppKit draws no hover at all). It is only wrong when it is a + /// SURPRISE, so it is recorded in the manifest instead of being left for whoever + /// later wonders why a theme's hover rule scores the same either way. + private readonly Dictionary _normalHashes = new(); + private readonly List _identicalToNormal = new(); + + /// Backdrop colour sampled from each appearance's tiles, and the check that the two + /// are not the same. See AssertAppearancesDiffer(). + private readonly Dictionary _backdropByAppearance = new(); + + /// One row of the desktop matrix. Kind is the native_win key in fidelity-tests.yaml, + /// and the ids and states are that file's too: the two lists must agree or the + /// comparator pairs a CN1 render against nothing. + private sealed record Spec(string Id, string Kind, string[] States); + + private static readonly Spec[] Specs = + { + new("DesktopButton", "winui_button", new[] { "normal", "hover", "pressed", "disabled" }), + new("DesktopAccentButton", "winui_button_accent", new[] { "normal", "hover", "pressed", "disabled" }), + new("DesktopTextField", "winui_textbox", new[] { "normal", "hover", "disabled" }), + new("DesktopCheckBox", "winui_checkbox", new[] { "normal", "selected", "hover", "disabled" }), + new("DesktopRadioButton", "winui_radiobutton", new[] { "normal", "selected", "hover", "disabled" }), + new("DesktopSwitch", "winui_toggleswitch", new[] { "normal", "selected", "hover", "disabled" }), + new("DesktopSlider", "winui_slider", new[] { "normal", "hover", "disabled" }), + new("DesktopProgressBar", "winui_progressbar", new[] { "normal" }), + new("DesktopComboBox", "winui_combobox", new[] { "normal", "hover", "disabled" }), + }; + + /// Controls with no natural width: layout always assigns one, so the tile width is the + /// honest answer. Kept in sync BY HAND with FULL_WIDTH_KINDS in the other reference apps + /// and FULL_WIDTH_IDS in DesktopTileRunner. If one side stretches a control and the + /// other does not, the comparison is between two geometries and the score means nothing. + private static bool IsFullWidth(string kind) => + kind is "winui_slider" or "winui_progressbar" or "winui_textbox"; + + private static FrameworkElement MakeWidget(string kind) => kind switch + { + "winui_button" => new Button { Content = "Button" }, + // The accent-filled button is a STYLE in WinUI, not a control: AccentButtonStyle is + // the documented resource key, and tinting a plain Button by hand produces a colour + // the system never draws. + "winui_button_accent" => new Button + { + Content = "Button", + Style = (Style)Application.Current.Resources["AccentButtonStyle"], + }, + "winui_textbox" => new TextBox { Text = "Text" }, + "winui_checkbox" => new CheckBox { Content = "Check" }, + "winui_radiobutton" => new RadioButton { Content = "Radio" }, + "winui_toggleswitch" => new ToggleSwitch(), + "winui_slider" => new Slider { Minimum = 0, Maximum = 1, Value = 0.5, StepFrequency = 0.01 }, + "winui_progressbar" => new ProgressBar { Minimum = 0, Maximum = 1, Value = 0.6 }, + "winui_combobox" => MakeComboBox(), + _ => null, + }; + + private static ComboBox MakeComboBox() + { + var c = new ComboBox(); + c.Items.Add("Option"); + c.SelectedIndex = 0; + return c; + } + + /// Applies one state. + /// + /// Two different mechanisms on purpose. Enabled and checked are real PROPERTIES, so they + /// are set directly and WinUI resolves the visuals itself. Hover and pressed have no + /// property -- they exist only as visual states the input system would normally drive -- + /// so they go through VisualStateManager. + /// + /// The state NAMES are per control and not guessable: a Button is "PointerOver", a + /// CheckBox is "UncheckedPointerOver" or "CheckedPointerOver" because its check and + /// interaction states are one combined group. So each is tried in turn and the result of + /// GoToState is CHECKED -- a name that does not exist returns false and silently leaves + /// the control in Normal, which would write a "hover" tile identical to normal and call + /// the theme faithful when it had never been tested. + private bool ApplyState(FrameworkElement widget, string state, string kind, string tileName) + { + switch (state) + { + case "normal": + return true; + case "selected": + if (widget is ToggleSwitch ts) { ts.IsOn = true; return true; } + if (widget is CheckBox cb) { cb.IsChecked = true; return true; } + if (widget is RadioButton rb) { rb.IsChecked = true; return true; } + _blockers.Add($"{tileName}: {kind} has no selected state"); + return false; + case "disabled": + if (widget is Control dc) { dc.IsEnabled = false; return true; } + widget.IsHitTestVisible = false; + return true; + case "hover": + case "pressed": + { + if (widget is not Control control) + { + _blockers.Add($"{tileName}: {kind} is not a Control, so it has no visual states"); + return false; + } + string[] candidates = state == "hover" + ? new[] { "PointerOver", "UncheckedPointerOver", "CheckedPointerOver" } + : new[] { "Pressed", "UncheckedPressed", "CheckedPressed" }; + foreach (var name in candidates) + { + if (VisualStateManager.GoToState(control, name, false)) + { + return true; + } + } + _blockers.Add($"{tileName}: none of [{string.Join(", ", candidates)}] is a " + + $"visual state of {kind}, so the tile would be a copy of normal"); + return false; + } + default: + _blockers.Add($"{tileName}: unknown state '{state}'"); + return false; + } + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + _outDir = Environment.GetEnvironmentVariable("NATIVEREF_OUT") + ?? throw new InvalidOperationException("NATIVEREF_OUT is not set"); + Directory.CreateDirectory(_outDir); + _isProbe = (Environment.GetEnvironmentVariable("NATIVEREF_MODE") ?? "probe") != "capture"; + + // Before the window exists, so nothing has animated yet. + bool off = false; + _animationsDisabled = SystemParametersInfo(SPI_SETCLIENTAREAANIMATION, 0, ref off, SPIF_SENDCHANGE); + if (!_animationsDisabled) + { + _blockers.Add("could not turn system UI animations off (SPI_SETCLIENTAREAANIMATION " + + $"failed, GetLastError={Marshal.GetLastWin32Error()}); tiles would not be " + + "reproducible between runs"); + } + + _window = new Window { Title = "cn1-native-ref" }; + + // Mica is what makes a Fluent surface look like Windows 11 rather than like a flat + // grey box. Requesting it tells us nothing -- the request succeeds either way -- so + // the answer comes from IsSupported plus whether the system says transparency + // effects are even on, and both go in the manifest. + var backdrop = new MicaBackdrop(); + bool micaSupported = MicaController.IsSupported(); + _window.SystemBackdrop = backdrop; + + // Always-on-top as well as foreground. The foreground check happens before the + // BitBlt, and without this a dialog appearing in between could still slide over the + // window in the gap -- which is a race that would show up as an occasional wrong + // capture rather than a consistent one, and those are far worse to diagnose. + _hwnd = WinRT.Interop.WindowNative.GetWindowHandle(_window); + var idEarly = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(_hwnd); + if (AppWindow.GetFromWindowId(idEarly).Presenter is OverlappedPresenter op) + { + op.IsAlwaysOnTop = true; + } + + // The window content IS one tile: no padding, no spacing, nothing around it. That + // is what lets the capture take the client rect as the tile region rather than + // computing an offset into a larger window, and an offset computed wrong is a whole + // set shifted by a few pixels that still looks entirely plausible. + var root = new Grid + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + }; + _tileHost = root; + var button = new Button { Content = "Button" }; + var probeHost = new Grid { Width = TileW, Height = TileH }; + probeHost.Children.Add(button); + root.Children.Add(probeHost); + _window.Content = root; + + // Resize the CLIENT area to exactly one tile. AppWindow.ResizeClient sizes the + // client rather than the outer frame, so the title bar DWM always draws is excluded + // rather than subtracted afterwards. + AppWindow.GetFromWindowId(idEarly).ResizeClient(new Windows.Graphics.SizeInt32(TileW, TileH)); + + _probeControl = button; + root.Loaded += async (_, _) => await OnReadyAsync(root, button, micaSupported); + _window.Activate(); + } + + private async Task OnReadyAsync(FrameworkElement root, Button button, bool micaSupported) + { + var ui = new UISettings(); + bool transparency = ui.AdvancedEffectsEnabled; + bool animations = ui.AnimationsEnabled; + var accent = ui.GetColorValue(UIColorType.Accent); + double rasterScale = root.XamlRoot?.RasterizationScale ?? 0; + string fontFamily = button.FontFamily?.Source ?? "(none)"; + bool segoeVariable = FontIsInstalled("Segoe UI Variable Text"); + + // Each of these produces a reference that is subtly, silently wrong rather than + // one that fails, which is why they are blockers and not warnings. + if (!micaSupported) + { + _blockers.Add("MicaController.IsSupported() is false: this OS cannot draw the " + + "Mica backdrop, so every Fluent surface here is a flat fallback brush."); + } + if (!transparency) + { + _blockers.Add("Transparency effects are OFF (UISettings.AdvancedEffectsEnabled " + + "is false), which disables Mica and Acrylic regardless of OS support. " + + "This is the Windows Server default."); + } + if (!segoeVariable) + { + _blockers.Add("Segoe UI Variable is not installed. Every WinUI control would be " + + "measured in a substitute face, so the text residual would be font " + + "availability rather than theme fidelity."); + } + // Tolerance rather than ==. RasterizationScale is a double, and a display that is + // 1x in every way that matters can report a value a hair off it; an exact compare + // would raise a blocker about a scale nobody set. 0 stays an exact compare because + // it is not a measurement -- it is the sentinel for "XamlRoot was null", assigned + // literally, and exactly representable. + if (Math.Abs(rasterScale - 1.0) > 0.001 && rasterScale != 0) + { + // Not fatal, but it must be recorded and matched on the Codename One side or + // the absolute-position metric compares tiles of different sizes. + Console.WriteLine($"NATIVEREF:WARN rasterization scale is {rasterScale}, not 1.0"); + } + + // Waiting for XAML to paint happens FIRST, before anything awaits, because it is the + // only part of this that touches XAML at all. CompositionTarget.Rendering must be + // subscribed from the UI thread, and after an await the continuation is not reliably + // on it -- the previous run proved that precisely: COMException 0x8001010E, + // RPC_E_WRONGTHREAD, at stage 'await-frames'. Everything after this point is Win32, + // which does not care which thread calls it. + // Where the control actually is, taken on the UI thread while it is safe to ask. The + // capture then checks THAT rectangle rather than guessing at coordinates. + try + { + // Relative to the WINDOW, not to root. TransformToVisual(root) gives the offset + // inside root's own coordinate space, which excludes root's margin -- so the + // button reported 0,0 and the sampler read the page background at both points, + // #F3F3F3 twice, and called a correctly styled control unstyled. + var t = _probeControl.TransformToVisual(null); + var origin = t.TransformPoint(new Windows.Foundation.Point(0, 0)); + _probeBounds = new Windows.Foundation.Rect(origin.X, origin.Y, + _probeControl.ActualWidth, _probeControl.ActualHeight); + } + catch + { + _probeBounds = new Windows.Foundation.Rect(0, 0, 0, 0); + } + + _stage = "await-frames"; + var drawn = new TaskCompletionSource(); + int frames = 0; + EventHandler onFrame = null; + onFrame = (_, _) => + { + if (++frames >= 3) + { + CompositionTarget.Rendering -= onFrame; + drawn.TrySetResult(true); + } + }; + CompositionTarget.Rendering += onFrame; + await Task.WhenAny(drawn.Task, Task.Delay(5000)); + Console.WriteLine($"NATIVEREF:INFO composed frames observed: {frames}"); + if (frames == 0) + { + _blockers.Add("XAML never presented a frame, so the client area would be " + + "captured empty while DWM still draws the title bar -- which is what a " + + "blocked UI thread looks like"); + } + + // Capture a real tile even in probe mode. A green build is not a rendered one: + // AppxGeneratePriEnabled is off (see NativeRef.csproj), so WinUI's own .pri files + // are not expanded into the output, and if that mattered the controls would come + // back unstyled or absent rather than failing loudly. A picture is the only thing + // that distinguishes "built" from "drew a Fluent button". + // Probe verification ALWAYS runs, capture mode included: it is what distinguishes + // "built" from "drew a Fluent button", and a 60-tile set of unstyled controls would + // otherwise be committed as a reference. + await CaptureWindowAsync(); + + if (!_isProbe) + { + await CaptureMatrixAsync(); + } + + WriteManifest(micaSupported, transparency, animations, accent, rasterScale, fontFamily, segoeVariable); + + foreach (var b in _blockers) + { + Console.Error.WriteLine($"NATIVEREF:BLOCKER {b}"); + } + Console.WriteLine($"NATIVEREF:DONE tiles={_tilesWritten} exit={(_blockers.Count > 0 ? 20 : 0)}"); + Console.Out.Flush(); + Environment.Exit(_blockers.Count > 0 ? 20 : 0); + } + + /// Turns the system's UI animations off for this session. + /// + /// SPI_SETCLIENTAREAANIMATION is what UISettings.AnimationsEnabled reports and what + /// XAML's theme transitions check, so setting it false makes a control snap to its + /// state instead of animating into it. + /// + /// Needed because the capture must be reproducible byte for byte. With animations on, + /// two runs of the same commit produced eight tiles that differed -- the check-box + /// check drawing in, the switch knob sliding, the slider thumb and the progress bar -- + /// because the frame was grabbed at different points along each transition. The + /// protocol in goldens/README.md is that nondeterminism is fixed in the app or by + /// pinning an environment knob, never with a tolerance file, and this is the knob. + private const uint SPI_SETCLIENTAREAANIMATION = 0x1043; + // SPIF_SENDCHANGE is already declared below, beside the foreground-lock call that + // also uses it. + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SystemParametersInfo(uint action, uint param, ref bool value, uint winIni); + + /// Renders a window's own content into a DC, independent of what is on screen. + /// + /// PW_RENDERFULLCONTENT (2) is the flag that makes it work for a DirectComposition + /// surface, which is what WinUI 3 draws into; without it the call returns an empty + /// bitmap for exactly this kind of app. + [DllImport("user32.dll")] + private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint flags); + + private const uint PW_RENDERFULLCONTENT = 0x00000002; + + [DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hWnd); + [DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); + [DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleDC(IntPtr hdc); + [DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int w, int h); + [DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr hdc, IntPtr h); + [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr h); + [DllImport("gdi32.dll")] private static extern bool DeleteDC(IntPtr hdc); + [DllImport("gdi32.dll")] private static extern bool BitBlt(IntPtr dst, int x, int y, int w, int h, + IntPtr src, int sx, int sy, uint rop); + [DllImport("dwmapi.dll")] private static extern int DwmFlush(); + [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr hWnd); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); + [DllImport("user32.dll")] private static extern bool AttachThreadInput(uint attach, uint attachTo, bool fAttach); + [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SystemParametersInfo(uint action, uint param, IntPtr pv, uint winIni); + [DllImport("user32.dll")] private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr w, IntPtr l); + [DllImport("user32.dll")] private static extern bool IsWindow(IntPtr hWnd); + [DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] private static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); + [DllImport("user32.dll")] private static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra); + + private const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001; + private const uint SPIF_SENDCHANGE = 0x02; + private const uint WM_CLOSE = 0x0010; + private const uint MOUSEEVENTF_LEFTDOWN = 0x0002; + private const uint MOUSEEVENTF_LEFTUP = 0x0004; + private const byte VK_ESCAPE = 0x1B; + private const uint KEYEVENTF_KEYUP = 0x0002; + [DllImport("user32.dll")] private static extern IntPtr WindowFromPoint(POINT p); + [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr hWnd, uint flags); + + [StructLayout(LayoutKind.Sequential)] + private struct POINT { public int X, Y; } + + private const uint GA_ROOT = 2; + [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hWnd, out RECT r); + [DllImport("user32.dll")] private static extern bool GetClientRect(IntPtr hWnd, out RECT r); + [DllImport("user32.dll")] private static extern bool ClientToScreen(IntPtr hWnd, ref POINT p); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowTextW(IntPtr hWnd, StringBuilder s, int max); + + [StructLayout(LayoutKind.Sequential)] + private struct RECT { public int Left, Top, Right, Bottom; } + + private const int SW_MINIMIZE = 6; + private const int SW_SHOW = 5; + private const int SW_RESTORE = 9; + + private const uint SRCCOPY = 0x00CC0020; + private const uint CAPTUREBLT = 0x40000000; + + /// Captures the whole matrix, both appearances. + /// + /// The window content is swapped per tile and the client area is held at exactly one + /// tile, so the BitBlt region never has to be reasoned about: what is composited IS the + /// tile. Each swap waits for real presented frames rather than a fixed sleep, because a + /// tile captured before its first present is a picture of the previous one. + private async Task CaptureMatrixAsync() + { + foreach (var appearance in new[] { "light", "dark" }) + { + bool dark = appearance == "dark"; + await OnUiAsync(() => + { + if (_window.Content is FrameworkElement rootEl) + { + rootEl.RequestedTheme = dark ? ElementTheme.Dark : ElementTheme.Light; + } + return true; + }); + await WaitForFramesAsync(3); + + foreach (var spec in Specs) + { + foreach (var state in spec.States) + { + var name = $"{spec.Id}_{state}_{appearance}"; + var widget = await OnUiAsync(() => + { + var w = MakeWidget(spec.Kind); + if (w is null) + { + return null; + } + w.HorizontalAlignment = IsFullWidth(spec.Kind) + ? HorizontalAlignment.Stretch + : HorizontalAlignment.Left; + w.VerticalAlignment = VerticalAlignment.Top; + w.Margin = new Thickness(0); + + var host = new Grid + { + Width = TileW, + Height = TileH, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + // The tile surface. Left transparent the Mica backdrop shows + // through, which is a different surface from the one the CN1 + // tiles are painted on and would be compared against the wrong + // thing. + // The backdrop comes from a Style carrying a {ThemeResource} + // (App.xaml, TileHostStyle) rather than from a brush read out of + // Application.Current.Resources here. That read resolves against + // the application dictionary once and does no element-theme + // resolution, so the dark half of the set was captured as dark + // controls on the LIGHT #F3F3F3 surface -- a backdrop no Windows + // 11 application ever shows, and one the CN1 side could never + // have matched. + Style = (Style)Application.Current.Resources["TileHostStyle"], + // NOT RequestedTheme here. The root already carries it and the + // tile inherits it. + }; + host.Children.Add(w); + _tileHost.Children.Clear(); + _tileHost.Children.Add(host); + return w; + }); + if (widget is null) + { + _blockers.Add($"{name}: unknown native_win kind '{spec.Kind}'"); + continue; + } + + // After the tree is live: a visual state cannot be applied to a control + // that has not had its template expanded yet, and GoToState returns false + // if it is tried too early. + await WaitForFramesAsync(2); + bool applied = await OnUiAsync(() => ApplyState(widget, state, spec.Kind, name)); + if (!applied) + { + continue; + } + // Applied TWICE, with frames in between, and it is not superstition. + // The runner reports animations_enabled: true, and WinUI drives a + // PointerOver transition over several frames rather than switching + // brushes outright -- so a capture taken too early lands on frame zero + // of the transition, which IS the normal appearance. That produced dark + // hover tiles for Button, TextBox and ComboBox byte-identical to their + // normal tiles while the light ones differed, a difference no platform + // has. Re-applying also covers the other candidate cause, a template + // re-application resetting the group back to Normal. + await WaitForFramesAsync(3); + await OnUiAsync(() => ApplyState(widget, state, spec.Kind, name)); + await WaitForFramesAsync(5); + await CaptureSettledTileAsync(name); + } + } + } + + AssertAppearancesDiffer(); + + int expected = 0; + foreach (var spec in Specs) + { + expected += spec.States.Length; + } + expected *= 2; + if (_tilesWritten != expected) + { + _blockers.Add($"wrote {_tilesWritten} tiles, expected {expected}: a partial set " + + "would be committed as if it were the whole matrix"); + } + } + + /// Runs a piece of XAML work on the UI thread and returns its result. + /// + /// Every XAML touch in the capture loop goes through this, and that is not defensive + /// style: the first capture run crashed with RPC_E_WRONGTHREAD (0x8001010E) reading + /// _window.Content, because an await earlier in the run had resumed the continuation + /// on a ThreadPool thread and XAML objects have hard thread affinity. Win32 is thread + /// agnostic and deliberately stays outside this -- GetClientRect and BitBlt do not care + /// which thread calls them. + private Task OnUiAsync(Func work) + { + var tcs = new TaskCompletionSource(); + var queue = _window.DispatcherQueue; + if (queue is null || !queue.TryEnqueue(() => + { + try { tcs.TrySetResult(work()); } + catch (Exception ex) { tcs.TrySetException(ex); } + })) + { + tcs.TrySetException(new InvalidOperationException( + "the UI dispatcher queue refused the work; the window is gone")); + } + return tcs.Task; + } + + /// Fails the run when the light and dark passes were captured on the same backdrop. + /// + /// This is here because it happened. The tile background was read as + /// Application.Current.Resources["SolidBackgroundFillColorBaseBrush"], which resolves + /// against the application dictionary once and does no element-theme resolution, so the + /// controls went dark and the surface behind them stayed light #F3F3F3. Sixty tiles, + /// zero blockers, a manifest that said "capture", and half the set on a backdrop no + /// Windows 11 application ever shows. + /// + /// Nothing downstream would have caught it either: the CN1 side renders its dark tiles + /// on the real dark surface, so the pair would simply have scored badly and read as a + /// theme that needed work. + private void AssertAppearancesDiffer() + { + if (_backdropByAppearance.Count < 2) + { + return; + } + var distinct = new HashSet(_backdropByAppearance.Values); + if (distinct.Count == 1) + { + _blockers.Add("the light and dark passes were both captured on backdrop " + + distinct.First() + ": the appearance did not actually change, so half the " + + "set is mislabelled"); + } + foreach (var kv in _backdropByAppearance) + { + Console.WriteLine($"NATIVEREF:INFO {kv.Key} backdrop {kv.Value}"); + } + } + + /// Records whether a state tile is byte-identical to its own normal tile. + private void NoteIfIdenticalToNormal(string name, string path) + { + var parts = name.Split('_'); + if (parts.Length != 3) + { + return; + } + string id = parts[0], state = parts[1], appearance = parts[2]; + string key = $"{id}_{appearance}"; + string hash; + using (var md5 = System.Security.Cryptography.MD5.Create()) + using (var fs = File.OpenRead(path)) + { + hash = Convert.ToHexString(md5.ComputeHash(fs)); + } + if (state == "normal") + { + _normalHashes[key] = hash; + return; + } + if (_normalHashes.TryGetValue(key, out var normalHash) && normalHash == hash) + { + _identicalToNormal.Add(name); + Console.WriteLine($"NATIVEREF:INFO {name} is byte-identical to its normal tile; " + + "WinUI does not restyle this control for this state"); + } + } + + /// Waits for n genuinely composed frames. Not Task.Delay: on a loaded runner a fixed + /// sleep is either wasteful or too short, and too short here means capturing the + /// previous tile. + /// + /// The subscription itself is made on the UI thread -- CompositionTarget.Rendering is + /// XAML and has the same affinity as everything else here. + private async Task WaitForFramesAsync(int n) + { + var drawn = new TaskCompletionSource(); + int frames = 0; + EventHandler onFrame = null; + onFrame = (_, _) => + { + if (++frames >= n) + { + CompositionTarget.Rendering -= onFrame; + drawn.TrySetResult(true); + } + }; + await OnUiAsync(() => { CompositionTarget.Rendering += onFrame; return true; }); + await Task.WhenAny(drawn.Task, Task.Delay(3000)); + await OnUiAsync(() => { CompositionTarget.Rendering -= onFrame; return true; }); + } + + /// Captures a tile only once two consecutive captures agree, so the frame written is + /// provably settled rather than assumed to be. + /// + /// Turning system animations off was not enough, and the measurement is why this exists + /// rather than a longer sleep. SPI_SETCLIENTAREAANIMATION governs theme TRANSITIONS; + /// the check-box check, the switch knob, the slider thumb and the progress bar animate + /// through storyboards in their own control templates, which it does not reach. Two runs + /// of the same commit still differed on exactly those eight tiles -- by 1 to 58 pixels + /// out of 13440, the moving edge of each one. + /// + /// A fixed delay would be a guess at how long each storyboard takes, wrong on a loaded + /// runner, and silently wrong in the direction that looks fine. Comparing consecutive + /// captures asks the question directly and answers it per tile, and a tile that never + /// settles is a blocker rather than a coin flip written into a golden set. + private async Task CaptureSettledTileAsync(string name) + { + const int MaxAttempts = 8; + // Before the FIRST grab, not only between grabs. Splitting the old capture into + // grab-and-compare dropped this delay, and two fast grabs then both landed before + // the new tile had been composited -- which the loop accepted, because an unpainted + // window is trivially stable. It wrote #E0E0E0 for both appearances and the + // backdrop assertion caught it, which is the whole reason that assertion exists. + await Task.Delay(120); + byte[] previous = null; + for (int attempt = 0; attempt < MaxAttempts; attempt++) + { + byte[] current = GrabClientArea(name); + if (current is null) + { + return; + } + if (previous != null && previous.AsSpan().SequenceEqual(current)) + { + if (_lastWrittenTile != null && _lastWrittenTile.AsSpan().SequenceEqual(current)) + { + // Stable AND identical to the tile before it: the window is showing the + // previous tile, not this one. Two different widgets cannot render the + // same bytes, so this is a swap that has not landed rather than a + // coincidence, and waiting is the right response. + previous = null; + await Task.Delay(200); + continue; + } + WriteTile(name, current); + _lastWrittenTile = current; + return; + } + previous = current; + await Task.Delay(120); + } + _blockers.Add($"{name}: never produced two identical consecutive captures in " + + $"{MaxAttempts} attempts, so whatever is still moving would be frozen at a " + + "random point in a golden set"); + } + + /// BitBlts the client area, which is held at exactly one tile, and writes it. + /// One BitBlt of the client area, returned as raw pixels. No file is written: the + /// caller compares consecutive grabs and only writes when they agree. + private byte[] GrabClientArea(string name) + { + DwmFlush(); + GetClientRect(_hwnd, out RECT clientRect); + int w = clientRect.Right - clientRect.Left; + int h = clientRect.Bottom - clientRect.Top; + if (w <= 0 || h <= 0) + { + _blockers.Add($"{name}: the client area has no size ({w}x{h})"); + return null; + } + // PrintWindow, NOT a screen BitBlt, and this is the difference between a + // reproducible set and a flaky one. + // + // A screen grab reads whatever is in front of those coordinates. The probe already + // reports that this window cannot take the foreground on a hosted runner -- the + // shell's own Search window holds it -- so the grab depends on nothing wandering + // over the region in the moment it runs. Two runs of the same commit differed on + // every tile, and one whole run came back #E0E0E0 in both appearances: not the + // window at all. + // + // PrintWindow renders the window's OWN content and does not care what is on top or + // whether it is foreground. It cannot see the Mica backdrop, which is drawn behind + // the window by the compositor -- but the tile paints an opaque + // SolidBackgroundFillColorBase over that region anyway, so the matrix never needed + // it. The probe capture still uses the screen BitBlt, because Mica is precisely + // what it is there to answer. + // PrintWindow renders the WHOLE window, title bar included, from the window's own + // origin -- so the bitmap has to be window sized and the client area cropped out of + // it afterwards. Rendering into a client-sized bitmap would have captured the title + // bar and called it a widget. + if (!GetWindowRect(_hwnd, out RECT wr)) + { + _blockers.Add($"{name}: GetWindowRect failed"); + return null; + } + int ww = wr.Right - wr.Left, wh = wr.Bottom - wr.Top; + var clientOrigin = new POINT { X = 0, Y = 0 }; + ClientToScreen(_hwnd, ref clientOrigin); + int offX = clientOrigin.X - wr.Left, offY = clientOrigin.Y - wr.Top; + + IntPtr screen = GetDC(IntPtr.Zero); + IntPtr mem = CreateCompatibleDC(screen); + IntPtr bmp = CreateCompatibleBitmap(screen, ww, wh); + IntPtr old = SelectObject(mem, bmp); + bool ok = PrintWindow(_hwnd, mem, PW_RENDERFULLCONTENT); + SelectObject(mem, old); + try + { + if (!ok) + { + _blockers.Add($"{name}: PrintWindow of the client area failed"); + return null; + } + using var whole = System.Drawing.Image.FromHbitmap(bmp); + if (offX < 0 || offY < 0 || offX + w > whole.Width || offY + h > whole.Height) + { + _blockers.Add($"{name}: the client area ({offX},{offY} {w}x{h}) does not lie " + + $"inside the window bitmap ({whole.Width}x{whole.Height})"); + return null; + } + using var image = whole.Clone(new System.Drawing.Rectangle(offX, offY, w, h), whole.PixelFormat); + if (IsUniform(image, 0, 0, image.Width, image.Height)) + { + _blockers.Add($"{name}: captured a uniform image, so nothing was composited"); + return null; + } + using var ms = new MemoryStream(); + image.Save(ms, System.Drawing.Imaging.ImageFormat.Png); + return ms.ToArray(); + } + finally + { + DeleteObject(bmp); + DeleteDC(mem); + ReleaseDC(IntPtr.Zero, screen); + } + } + + /// Writes a settled grab and records the two things the manifest reports about it. + private void WriteTile(string name, byte[] png) + { + var path = Path.Combine(_outDir, name + ".png"); + File.WriteAllBytes(path, png); + _tilesWritten++; + using var ms = new MemoryStream(png); + using var image = new System.Drawing.Bitmap(ms); + Console.WriteLine($"NATIVEREF:wrote {name} {image.Width}x{image.Height}"); + NoteIfIdenticalToNormal(name, path); + // Bottom-right corner: every widget in the matrix anchors top-left and none is + // as tall as the tile, so this pixel is always backdrop. + var corner = image.GetPixel(image.Width - 1, image.Height - 1); + var appearanceKey = name.Substring(name.LastIndexOf('_') + 1); + _backdropByAppearance[appearanceKey] = $"#{corner.R:X2}{corner.G:X2}{corner.B:X2}"; + } + + /// Grabs what DWM actually put on the screen, which is the only way the Mica backdrop + /// appears at all: it is drawn behind the window by the compositor and is not part of + /// the XAML visual tree, so RenderTargetBitmap would silently return the widget without + /// its material. This is the direct analogue of the iOS reference capturing a real + /// UIWindow rather than re-rendering a layer off-screen. + private async Task CaptureWindowAsync() + { + try + { + _stage = "hwnd"; + var hwnd = _hwnd; + + // Reading the SCREEN means reading whatever is on top of it. The first run that + // got this far captured the Windows out-of-box privacy dialog sitting over this + // app, reported zero blockers, and looked entirely plausible -- a 768x519 image + // full of real controls. So the window is forced to the front and the result is + // verified, rather than assumed from the fact that we asked for it. + _stage = "show"; + ShowWindow(hwnd, SW_RESTORE); + ShowWindow(hwnd, SW_SHOW); + + _stage = "window-rect"; + if (!GetWindowRect(hwnd, out RECT r)) + { + _blockers.Add("GetWindowRect failed; the window region is unknown"); + return; + } + int w = r.Right - r.Left, h = r.Bottom - r.Top; + if (w <= 0 || h <= 0) + { + _blockers.Add($"the window has no size ({w}x{h}); nothing was composited"); + return; + } + + // Everything from here down is Win32, which is thread agnostic. The XAML part -- + // waiting for presented frames -- deliberately happens before this method is + // called, while we are still provably on the UI thread. + DwmFlush(); + await Task.Delay(400); + + _stage = "minimise-occluders"; + var centre = new POINT { X = r.Left + w / 2, Y = r.Top + h / 2 }; + IntPtr atCentre = GetAncestor(WindowFromPoint(centre), GA_ROOT); + + // The runner desktop ships with a "Microsoft account" out-of-box window that + // sits above even an always-on-top presenter, so asking politely for the + // foreground is not enough. Minimise whatever is covering us, and keep going in + // case several are stacked. This is a throwaway CI desktop whose only purpose is + // to photograph this window; there is nothing here to be polite to. + for (int i = 0; i < 5 && atCentre != hwnd && atCentre != IntPtr.Zero; i++) + { + // Closed, not just minimised. Minimising moved it out of the frame but it + // KEPT the foreground -- measured: "the window is unoccluded but never became + // active (foreground is 0x1020A \"Microsoft account\")" with that same + // window already minimised. A window that is gone cannot hold the foreground. + // Minimise remains the fallback for anything that refuses to close. + Console.WriteLine($"NATIVEREF:INFO clearing 0x{atCentre.ToInt64():X} " + + $"({DescribeWindow(atCentre)}) which is covering the capture area"); + PostMessage(atCentre, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); + await Task.Delay(400); + if (IsWindow(atCentre)) + { + Console.WriteLine($"NATIVEREF:INFO 0x{atCentre.ToInt64():X} would not close; minimising"); + ShowWindow(atCentre, SW_MINIMIZE); + } + await Task.Delay(250); + BringWindowToTop(hwnd); + atCentre = GetAncestor(WindowFromPoint(centre), GA_ROOT); + } + + _occluded = atCentre != hwnd; + if (_occluded) + { + _blockers.Add("something is covering this window, so the capture would be of " + + $"it and not of us ({DescribeForegroundWindow()}, " + + $"at centre 0x{atCentre.ToInt64():X}). Screen capture reads whatever is " + + "on top, and a picture of the wrong window still looks like a picture."); + return; + } + // Only NOW is it worth asking for the foreground. The earlier attempt ran before + // the occluders were minimised and could never have succeeded: Win32 refuses + // SetForegroundWindow to a process that is not already foreground, and the + // out-of-box window held it. Nothing retried once the obstacle was gone, so the + // window ended up unoccluded but inactive -- visible, and rendered in the + // inactive style. + // + // Activation is not cosmetic for a reference set. An inactive window renders its + // title bar differently and does not get the Mica backdrop, so capturing one + // would encode a look no user sees, which is the same class of error as + // capturing with Reduce Transparency on under macOS. + _stage = "activate"; + bool isForeground = await TryTakeForegroundAsync(hwnd); + if (!isForeground) + { + // Recorded, not fatal -- and that distinction is the point of the measurement + // that prompted it. Sampling the earlier capture's title bar found its + // darkest glyph at (145,145,145); an active Windows 11 title bar draws that + // text near black, so the window really is rendering inactive. + // + // What that costs depends on what is being photographed. The title bar and + // the Mica backdrop are drawn differently for an inactive window. WinUI + // CONTROLS are not: unlike AppKit, which greys every control in an inactive + // window and is exactly why the macOS probe treats activation as fatal. + // + // So a widget tile is unaffected and a window-chrome tile would not be, and + // the tiles this reference set needs first are widget tiles. Blocking on it + // would refuse usable captures for a property they do not depend on, which is + // the same mistake the occlusion check had to be rewritten to stop making. + // + // TO VERIFY before any window-chrome or Mica tile is captured: the claim that + // WinUI controls render identically inactive is reasoned from the platform's + // behaviour, not yet measured here. Capture the same control active and + // inactive and diff them. + Console.WriteLine("NATIVEREF:WARN could not take the foreground " + + $"({DescribeForegroundWindow()}); widget tiles are unaffected, window " + + "chrome and Mica would be. Recorded in the manifest."); + } + // Activating can re-order windows, so confirm nothing slid back over us. + atCentre = GetAncestor(WindowFromPoint(centre), GA_ROOT); + if (atCentre != hwnd) + { + // Clear it and re-check rather than giving up. Start and Search both close on + // Esc, and the previous run failed here for a self-inflicted reason -- the + // activation click had landed on the taskbar and opened Start, which then + // covered the window. + Console.WriteLine($"NATIVEREF:INFO 0x{atCentre.ToInt64():X} " + + $"({DescribeWindow(atCentre)}) came over the window after activating; " + + "dismissing"); + keybd_event(VK_ESCAPE, 0, 0, UIntPtr.Zero); + keybd_event(VK_ESCAPE, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + await Task.Delay(500); + BringWindowToTop(hwnd); + await Task.Delay(300); + atCentre = GetAncestor(WindowFromPoint(centre), GA_ROOT); + } + if (atCentre != hwnd) + { + _occluded = true; + _blockers.Add($"activating let 0x{atCentre.ToInt64():X} " + + $"({DescribeWindow(atCentre)}) back over the window, and it would not " + + "dismiss"); + return; + } + await Task.Delay(300); + Console.WriteLine("NATIVEREF:INFO active and unoccluded"); + + // The tile is the CLIENT area, not the whole window. A widget reference wants the + // widgets; the title bar is chrome we cannot reliably activate on this image, and + // including it would bake an inactive title bar into every tile. A window-chrome + // tile, when there is one, is a separate capture with its own requirements. + GetClientRect(hwnd, out RECT clientRect); + var clientOrigin = new POINT { X = 0, Y = 0 }; + ClientToScreen(hwnd, ref clientOrigin); + int clientW = clientRect.Right - clientRect.Left; + int clientH = clientRect.Bottom - clientRect.Top; + if (clientW <= 0 || clientH <= 0) + { + _blockers.Add($"the client area has no size ({clientW}x{clientH})"); + return; + } + w = clientW; + h = clientH; + var pos = new { X = clientOrigin.X, Y = clientOrigin.Y }; + _stage = "bitblt"; + IntPtr screen = GetDC(IntPtr.Zero); + IntPtr mem = CreateCompatibleDC(screen); + IntPtr bmp = CreateCompatibleBitmap(screen, w, h); + IntPtr old = SelectObject(mem, bmp); + bool ok = BitBlt(mem, 0, 0, w, h, screen, pos.X, pos.Y, SRCCOPY | CAPTUREBLT); + SelectObject(mem, old); + + if (!ok) + { + _blockers.Add("BitBlt of the window region failed"); + } + else + { + using var image = System.Drawing.Image.FromHbitmap(bmp); + var name = _isProbe ? "probe_Button_normal_light" : "Button_normal_light"; + var path = Path.Combine(_outDir, name + ".png"); + image.Save(path, System.Drawing.Imaging.ImageFormat.Png); + Console.WriteLine($"NATIVEREF:wrote {name} {w}x{h}"); + // Deliberately the CLIENT area, not the whole window. The title bar is drawn + // by DWM whatever the app does, so a whole-window uniformity test passes an + // utterly empty window -- which is exactly what it did: a captured + // cn1-native-ref frame with a correct Windows 11 title bar and nothing at + // all beneath it. + // The captured tile is now exactly the client area, so these offsets are + // simply the whole image. + // Sample the background well away from the button and record it. + // Whether the Mica backdrop is actually reaching the window is otherwise an + // eyeball judgement on a PNG: a flat theme fill and a Mica surface over a + // pale desktop look similar at a glance, and "mica_supported: true" only + // says the OS could draw it, not that this window got it. + int cx = 0, cy = 0, cw = w, ch = h; + if (cw > 0 && ch > 0 && cx >= 0 && cy >= 0 && cx + cw <= w && cy + ch <= h) + { + var bg = image.GetPixel(cx + cw * 3 / 4, cy + ch * 3 / 4); + _clientBackground = $"#{bg.R:X2}{bg.G:X2}{bg.B:X2}"; + Console.WriteLine($"NATIVEREF:INFO client background sample {_clientBackground}"); + } + // The definitive styling check: a Fluent Button has rounded corners, so its + // corner pixel is the page behind it and its centre pixel is the fill. When + // those are equal the control is a plain rectangle, which is what an unstyled + // fallback looks like. This measures the control itself, rather than + // inferring from whether some build artefact was produced -- which is what + // sent the previous three attempts after the wrong cause. + if (_probeBounds.Width > 4 && _probeBounds.Height > 4) + { + int bx = (int)_probeBounds.X, by = (int)_probeBounds.Y; + int bw = (int)_probeBounds.Width, bh = (int)_probeBounds.Height; + if (bx >= 0 && by >= 0 && bx + bw <= w && by + bh <= h) + { + // One pixel in from the corner: exactly on it can land on the + // antialiased edge, which is neither the page nor the fill. + var corner = image.GetPixel(bx + 1, by + 1); + var mid = image.GetPixel(bx + bw / 2, by + bh / 2); + Console.WriteLine($"NATIVEREF:INFO probe control {bw}x{bh} at {bx},{by} " + + $"corner=#{corner.R:X2}{corner.G:X2}{corner.B:X2} " + + $"centre=#{mid.R:X2}{mid.G:X2}{mid.B:X2}"); + if (corner == mid) + { + _blockers.Add($"the control is a plain rectangle (corner and centre " + + $"both #{mid.R:X2}{mid.G:X2}{mid.B:X2}); a Fluent Button has " + + "rounded corners, so its styles did not load"); + } + } + } + + if (cw > 0 && ch > 0 && cx >= 0 && cy >= 0 && cx + cw <= w && cy + ch <= h + && IsUniform(image, cx, cy, cw, ch)) + { + _blockers.Add($"{name} has an empty client area ({cw}x{ch} of one flat " + + "colour): the window chrome drew but the content did not"); + } + } + + DeleteObject(bmp); + DeleteDC(mem); + ReleaseDC(IntPtr.Zero, screen); + } + catch (Exception e) + { + // COMException's Message is routinely empty, and "capture threw COMException:" + // is worth nothing to whoever reads it next. Record where it got to, the + // HRESULT, and the frame it came from -- a probe whose failures are not + // self-describing just converts one unknown into another. + _blockers.Add($"capture threw {e.GetType().Name} (0x{e.HResult:X8}) at stage " + + $"'{_stage}': {(string.IsNullOrWhiteSpace(e.Message) ? "(no message)" : e.Message)}"); + Console.Error.WriteLine($"NATIVEREF:EXCEPTION stage={_stage} {e}"); + } + } + + + /// Takes the foreground, working around the Win32 rule that a process which is not + /// already foreground may not call SetForegroundWindow. Attaching to the current + /// foreground window's input thread lifts that restriction for the duration. + private static async Task TryTakeForegroundAsync(IntPtr hwnd) + { + // Windows enforces a foreground LOCK TIMEOUT: after another process has been + // activated, SetForegroundWindow is refused for a period regardless of the + // input-thread attach. Setting it to zero is the documented way to opt out, and on a + // CI desktop whose only job is to photograph one window there is nothing to protect + // the user from. + SystemParametersInfo(SPI_SETFOREGROUNDLOCKTIMEOUT, 0, IntPtr.Zero, SPIF_SENDCHANGE); + + for (int i = 0; i < 20 && GetForegroundWindow() != hwnd; i++) + { + IntPtr fg = GetForegroundWindow(); + uint fgThread = GetWindowThreadProcessId(fg, IntPtr.Zero); + uint thisThread = GetCurrentThreadId(); + bool attached = fgThread != 0 && fgThread != thisThread + && AttachThreadInput(fgThread, thisThread, true); + try + { + BringWindowToTop(hwnd); + SetForegroundWindow(hwnd); + } + finally + { + if (attached) AttachThreadInput(fgThread, thisThread, false); + } + await Task.Delay(150); + } + if (GetForegroundWindow() == hwnd) + { + return true; + } + + // Last resort: activate the way a person would, by clicking on it. The shell's + // "Search" window holds the foreground on this image and does not respond to + // WM_CLOSE the way an ordinary app window does, and SetForegroundWindow keeps losing + // to it. A real click travels the normal input path, which Windows always honours -- + // there is no policy that refuses to activate the window the user just clicked. + // + // The point is inside our client area but far from the widgets, so the click lands + // on empty background: activating the window must not also press the control being + // photographed. + // The click point comes from the CLIENT rect, not the window rect, and is verified to + // belong to us before any button goes down. Computed from the window rect at four + // fifths down, the previous attempt landed on the TASKBAR and opened the Start menu -- + // which then covered the window. Activating by clicking is only safe if you know what + // you are clicking on. + GetClientRect(hwnd, out RECT clientR); + var clientTopLeft = new POINT { X = 0, Y = 0 }; + ClientToScreen(hwnd, ref clientTopLeft); + if (clientR.Right > clientR.Left && clientR.Bottom > clientR.Top) + { + int cx = clientTopLeft.X + (clientR.Right - clientR.Left) * 3 / 4; + int cy = clientTopLeft.Y + (clientR.Bottom - clientR.Top) / 2; + IntPtr atClick = GetAncestor(WindowFromPoint(new POINT { X = cx, Y = cy }), GA_ROOT); + if (atClick != hwnd) + { + Console.WriteLine($"NATIVEREF:WARN not clicking: {cx},{cy} belongs to " + + $"0x{atClick.ToInt64():X} ({DescribeWindow(atClick)}), not to us"); + return GetForegroundWindow() == hwnd; + } + Console.WriteLine($"NATIVEREF:INFO clicking our own client area at {cx},{cy} to activate " + + $"(foreground was {DescribeForegroundWindow()})"); + SetCursorPos(cx, cy); + await Task.Delay(120); + mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero); + await Task.Delay(60); + mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero); + await Task.Delay(500); + + // Escape first, in case the desktop popped Start or Search. Those are the two + // surfaces that keep taking the foreground on this image, and both close on Esc. + keybd_event(VK_ESCAPE, 0, 0, UIntPtr.Zero); + keybd_event(VK_ESCAPE, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + await Task.Delay(300); + + // Retry the API after the click. SetForegroundWindow is allowed when the calling + // process received the last input event, which a real click is -- so the attempt + // that was refused a moment ago may now be granted. + for (int i = 0; i < 10 && GetForegroundWindow() != hwnd; i++) + { + SetForegroundWindow(hwnd); + await Task.Delay(150); + } + } + return GetForegroundWindow() == hwnd; + } + + /// Names whatever is actually in front, so a capture failure says which window stole + /// the screen instead of leaving someone to guess from the picture. + private static string DescribeForegroundWindow() + { + var fg = GetForegroundWindow(); + return $"foreground is 0x{fg.ToInt64():X} \"{DescribeWindow(fg)}\""; + } + + private static string DescribeWindow(IntPtr h) + { + try + { + var buf = new StringBuilder(256); + int n = GetWindowTextW(h, buf, buf.Capacity); + return n > 0 ? buf.ToString() : "(untitled)"; + } + catch + { + return "(unidentifiable)"; + } + } + + /// A uniformly coloured tile is the classic captured-before-present result, and it + /// scores as a perfect match against another blank tile rather than as a failure. + private static bool IsUniform(System.Drawing.Bitmap image, int x0, int y0, int w, int h) + { + var first = image.GetPixel(x0, y0); + int stepX = Math.Max(1, w / 32), stepY = Math.Max(1, h / 32); + for (int y = y0; y < y0 + h; y += stepY) + for (int x = x0; x < x0 + w; x += stepX) + if (image.GetPixel(x, y) != first) return false; + return true; + } + + private static bool FontIsInstalled(string family) + { + try + { + var dir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var fonts = Path.Combine(dir, "Fonts"); + // Segoe UI Variable ships as SegUIVar*.ttf; matching the file rather than + // asking a font-fallback API is deliberate, because font fallback answers + // "something will render" for every family name ever passed to it. + return Directory.Exists(fonts) + && Directory.GetFiles(fonts, "SegUIVar*.ttf").Length > 0; + } + catch + { + return false; + } + } + + private void WriteManifest(bool micaSupported, bool transparency, bool animations, + Windows.UI.Color accent, double rasterScale, string fontFamily, bool segoeVariable) + { + var sb = new StringBuilder(); + sb.AppendLine("{"); + sb.AppendLine(" \"schema\": 1,"); + sb.AppendLine(" \"platform\": \"windows\","); + sb.AppendLine($" \"golden_set\": \"{Env("CN1SS_FIDELITY_GOLDEN_SET", "windows-11-fluent")}\","); + sb.AppendLine($" \"mode\": \"{(_isProbe ? "probe" : "capture")}\","); + sb.AppendLine(" \"os\": {"); + sb.AppendLine($" \"version\": \"{Environment.OSVersion.Version}\","); + sb.AppendLine($" \"description\": \"{Escape(RuntimeInformation.OSDescription)}\","); + sb.AppendLine($" \"architecture\": \"{RuntimeInformation.OSArchitecture}\","); + sb.AppendLine($" \"product\": \"{Escape(ReadRegistry("ProductName"))}\","); + sb.AppendLine($" \"display_version\": \"{Escape(ReadRegistry("DisplayVersion"))}\","); + sb.AppendLine($" \"build\": \"{Escape(ReadRegistry("CurrentBuildNumber"))}\","); + sb.AppendLine($" \"installation_type\": \"{Escape(ReadRegistry("InstallationType"))}\""); + sb.AppendLine(" },"); + sb.AppendLine(" \"toolkit\": {"); + sb.AppendLine(" \"name\": \"WinUI3\","); + sb.AppendLine(" \"deployment\": \"unpackaged-self-contained\","); + sb.AppendLine($" \"dotnet\": \"{Escape(RuntimeInformation.FrameworkDescription)}\""); + sb.AppendLine(" },"); + sb.AppendLine(" \"display\": {"); + sb.AppendLine($" \"rasterization_scale\": {rasterScale.ToString(System.Globalization.CultureInfo.InvariantCulture)}"); + sb.AppendLine(" },"); + sb.AppendLine(" \"appearance\": {"); + sb.AppendLine($" \"mica_supported\": {Json(micaSupported)},"); + sb.AppendLine($" \"transparency_effects\": {Json(transparency)},"); + sb.AppendLine($" \"animations_enabled\": {Json(animations)},"); + sb.AppendLine($" \"accent_color\": \"#{accent.R:X2}{accent.G:X2}{accent.B:X2}\""); + sb.AppendLine(" },"); + sb.AppendLine(" \"capture\": {"); + sb.AppendLine($" \"occluded\": {Json(_occluded)},"); + sb.AppendLine($" \"was_foreground\": {Json(GetForegroundWindow() == _hwnd)},"); + sb.AppendLine($" \"client_background\": \"{Escape(_clientBackground)}\""); + sb.AppendLine(" },"); + sb.AppendLine(" \"fonts\": {"); + sb.AppendLine($" \"control_family\": \"{Escape(fontFamily)}\","); + sb.AppendLine($" \"segoe_ui_variable_installed\": {Json(segoeVariable)}"); + sb.AppendLine(" },"); + sb.AppendLine($" \"tiles_written\": {_tilesWritten},"); + sb.AppendLine($" \"animations_disabled_by_app\": {(_animationsDisabled ? "true" : "false")},"); + sb.Append(" \"backdrop_by_appearance\": {"); + sb.Append(string.Join(", ", _backdropByAppearance.Select(kv => $"\"{Escape(kv.Key)}\": \"{Escape(kv.Value)}\""))); + sb.AppendLine("},"); + sb.Append(" \"states_identical_to_normal\": ["); + sb.Append(string.Join(", ", _identicalToNormal.Select(n => $"\"{Escape(n)}\""))); + sb.AppendLine("],"); + sb.Append(" \"blockers\": ["); + sb.Append(string.Join(", ", _blockers.Select(b => $"\"{Escape(b)}\""))); + sb.AppendLine("]"); + sb.AppendLine("}"); + + var path = Path.Combine(_outDir, "capture-manifest.json"); + File.WriteAllText(path, sb.ToString()); + Console.WriteLine($"NATIVEREF:INFO wrote {path}"); + } + + private static string Env(string name, string fallback) + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback; + + private static string Json(bool b) => b ? "true" : "false"; + + /// Escapes for JSON, control characters included. A window title arrived carrying + /// embedded NULs and wrote them raw into the manifest, which made it unparseable -- and + /// a raw control byte in a text file is precisely what scripts/check-control-characters.py + /// exists to prevent, because it turns the file binary to every tool that reads it. + private static string Escape(string s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + var sb = new StringBuilder(s.Length); + foreach (var c in s) + { + switch (c) + { + case '\\': sb.Append("\\\\"); break; + case '"': sb.Append("\\\""); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (char.IsControl(c)) sb.Append($"\\u{(int)c:X4}"); + else sb.Append(c); + break; + } + } + return sb.ToString(); + } + + private static string ReadRegistry(string name) + { + try + { + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + return key?.GetValue(name)?.ToString() ?? ""; + } + catch + { + return ""; + } + } +} diff --git a/scripts/fidelity-app/windows-native-ref/app.manifest b/scripts/fidelity-app/windows-native-ref/app.manifest new file mode 100644 index 00000000000..7441675b102 --- /dev/null +++ b/scripts/fidelity-app/windows-native-ref/app.manifest @@ -0,0 +1,9 @@ + + + + + PerMonitorV2 + true + + + diff --git a/scripts/fidelity-app/windows-native-ref/global.json b/scripts/fidelity-app/windows-native-ref/global.json new file mode 100644 index 00000000000..b1787c8dced --- /dev/null +++ b/scripts/fidelity-app/windows-native-ref/global.json @@ -0,0 +1,7 @@ +{ + "//": "WindowsAppSDK 1.6's MrtCore PRI generation loads Microsoft.Build.Packaging.Pri.Tasks.dll from the SDK's AppxPackage folder, which .NET SDK 10 no longer lays out the same way -- the build dies with MSB4062 before compiling a line. setup-dotnet installs 9.0.x, but dotnet picks the NEWEST installed SDK unless pinned here, and the runner ships 10.0.400. rollForward latestFeature keeps this working as 9.0.x moves.", + "sdk": { + "version": "9.0.100", + "rollForward": "latestFeature" + } +} diff --git a/scripts/hellocodenameone/android/src/main/java/com/codenameone/examples/hellocodenameone/InPlaceEditViewNativeImpl.java b/scripts/hellocodenameone/android/src/main/java/com/codenameone/examples/hellocodenameone/InPlaceEditViewNativeImpl.java index 7a3733b6d56..6ad2428b817 100644 --- a/scripts/hellocodenameone/android/src/main/java/com/codenameone/examples/hellocodenameone/InPlaceEditViewNativeImpl.java +++ b/scripts/hellocodenameone/android/src/main/java/com/codenameone/examples/hellocodenameone/InPlaceEditViewNativeImpl.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codenameone.examples.hellocodenameone; import com.codename1.ui.Display; @@ -33,30 +55,18 @@ public void runReproductionTest() { try { for (int i = 0; i < 50; i++) { // Start editing - Display.getInstance().callSeriallyAndWait(() -> { - try { - InPlaceEditView.edit(androidImpl, ta, ta.getConstraint()); - } catch (Exception e) { - e.printStackTrace(); - } - }); + callOnEdtAndWait(() -> InPlaceEditView.edit(androidImpl, ta, ta.getConstraint())); // Schedule reLayoutEdit calls for (int j = 0; j < 5; j++) { - try { - InPlaceEditView.reLayoutEdit(); - Thread.sleep(10); - } catch (Exception ex) {} + callOnEdtAndWait(() -> InPlaceEditView.reLayoutEdit()); + Thread.sleep(10); } - // Stop editing - Display.getInstance().callSeriallyAndWait(() -> { - try { - InPlaceEditView.stopEdit(); - } catch (Exception e) { - e.printStackTrace(); - } - }); + // stopEdit removes Android views and requires Android's UI thread, + // not the CN1 EDT. The production wrapper performs that handoff + // and waits for teardown while queued relayouts can still race it. + callOnEdtAndWait(() -> AndroidImplementation.stopEditing()); } Display.getInstance().callSerially(() -> InPlaceEditViewTest.onSuccess()); } catch (Throwable t) { @@ -72,6 +82,23 @@ public void runReproductionTest() { }); } + private static void callOnEdtAndWait(Runnable action) throws Throwable { + final java.util.concurrent.atomic.AtomicReference failure = + new java.util.concurrent.atomic.AtomicReference(); + Display.getInstance().callSeriallyAndWait(() -> { + try { + action.run(); + } catch (Throwable t) { + failure.set(t); + } + }); + // callSeriallyAndWait does not propagate EDT exceptions to the worker. + // Forward them so a failed iteration cannot become a successful test. + if (failure.get() != null) { + throw failure.get(); + } + } + public boolean isSupported() { return true; } diff --git a/scripts/lib/cn1ss.sh b/scripts/lib/cn1ss.sh index 616691d84b9..a6a4e87ba50 100644 --- a/scripts/lib/cn1ss.sh +++ b/scripts/lib/cn1ss.sh @@ -862,6 +862,37 @@ cn1ss_process_fidelity() { return $comment_rc fi + # An ABSENT baseline file cannot ratchet anything. FidelityGate reads a missing file as + # an empty map, so every pair is "new", its score is printed, and the run exits 0. That + # is the right behaviour for a set whose numbers have never been recorded -- an invented + # baseline is worse than none, because it is a contract measured on the wrong machine -- + # but left silent it means a leg listed as gated stays green for good without ever + # gating a regression, which is indistinguishable from a leg that works. + # + # So say it out loud, and write the baseline this run WOULD record into the artifact + # directory, through FidelityGate's own writer so the file is committable verbatim. + # Seeding the set is then downloading that file into the baseline directory. Note the + # writer refuses to record a partial or broken run, so a seed only appears for a run + # that scored cleanly; the real gate below still runs and still fails on broken pairs. + if [ -n "${baseline_file:-}" ] && [ ! -f "$baseline_file" ]; then + local seed_file="$artifacts_dir/$(basename "$baseline_file")" + cn1ss_log "NOTICE: no baseline at $baseline_file -- this run MEASURES but does not GATE regressions." + if cn1ss_java_run "$CN1SS_FIDELITY_GATE_CLASS" "${gate_args[@]}" --update-baseline "$seed_file"; then + cn1ss_log "NOTICE: candidate baseline written to $seed_file -- commit it as $baseline_file to arm the ratchet." + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### $platform_title: no fidelity baseline yet" + echo + echo "There is no baseline at \`$baseline_file\`, so this leg scored every pair and" + echo "gated none of them. A candidate baseline measured by THIS runner is in the job" + echo "artifact as \`$(basename "$baseline_file")\`; commit it to arm the ratchet." + } >> "$GITHUB_STEP_SUMMARY" + fi + else + cn1ss_log "WARNING: no candidate baseline written -- the run did not score cleanly enough to record one." + fi + fi + cn1ss_log "STAGE:FIDELITY_GATE -> Enforcing the fidelity ratchet against the baseline" if cn1ss_java_run "$CN1SS_FIDELITY_GATE_CLASS" "${gate_args[@]}"; then cn1ss_log "Fidelity gate passed." diff --git a/scripts/linux/screenshots-arm/DesktopMode.png b/scripts/linux/screenshots-arm/DesktopMode.png index 625ef1390aa..80de41abb5c 100644 Binary files a/scripts/linux/screenshots-arm/DesktopMode.png and b/scripts/linux/screenshots-arm/DesktopMode.png differ diff --git a/scripts/linux/screenshots/DesktopMode.png b/scripts/linux/screenshots/DesktopMode.png index 625ef1390aa..80de41abb5c 100644 Binary files a/scripts/linux/screenshots/DesktopMode.png and b/scripts/linux/screenshots/DesktopMode.png differ diff --git a/scripts/macos/screenshots/NativeMapFallback.png b/scripts/macos/screenshots/NativeMapFallback.png index 0d15465fc53..3ccd6bec47b 100644 Binary files a/scripts/macos/screenshots/NativeMapFallback.png and b/scripts/macos/screenshots/NativeMapFallback.png differ diff --git a/scripts/macos/screenshots/README.md b/scripts/macos/screenshots/README.md index d2530c25770..5918ec20630 100644 --- a/scripts/macos/screenshots/README.md +++ b/scripts/macos/screenshots/README.md @@ -69,3 +69,16 @@ Start with none, and add one only when a test is shown to be nondeterministic between two runs of the same binary. "AppKit rasterizes text differently from Catalyst" is deterministic and permanent: the answer is this baseline, which is what this directory is for. + +## Window layout progress references + +The three `Window-Layout-*.png` references come from +[run 35169743013](https://github.com/codenameone/CodenameOne/actions/runs/35169743013) +at `e1a67fc944`. This fixture uses a generic `Slider`, whose rounded rectangle +border retains its legacy height and painting. The thin native capsule remains +available through the `ProgressBar` styles; treating every border as a capsule +would discard application border geometry. + +All 157 other captures matched. In these three images, every changed pixel is +inside the progress strip at rows 193 through 208; text, surrounding layout, and +window dimensions are unchanged. No tolerance was changed. diff --git a/scripts/macos/screenshots/RealOsmVector.png b/scripts/macos/screenshots/RealOsmVector.png index 3a2440386a4..3b9eef6a28d 100644 Binary files a/scripts/macos/screenshots/RealOsmVector.png and b/scripts/macos/screenshots/RealOsmVector.png differ diff --git a/scripts/macos/screenshots/VectorMapDarkStyle.png b/scripts/macos/screenshots/VectorMapDarkStyle.png index c8ecc87fae9..4df20217b03 100644 Binary files a/scripts/macos/screenshots/VectorMapDarkStyle.png and b/scripts/macos/screenshots/VectorMapDarkStyle.png differ diff --git a/scripts/macos/screenshots/VectorMapMarkers.png b/scripts/macos/screenshots/VectorMapMarkers.png index 478aaedc1ae..d97b6fe2ef5 100644 Binary files a/scripts/macos/screenshots/VectorMapMarkers.png and b/scripts/macos/screenshots/VectorMapMarkers.png differ diff --git a/scripts/macos/screenshots/VectorMapShapes.png b/scripts/macos/screenshots/VectorMapShapes.png index 88285bd4d34..e72221d99ac 100644 Binary files a/scripts/macos/screenshots/VectorMapShapes.png and b/scripts/macos/screenshots/VectorMapShapes.png differ diff --git a/scripts/macos/screenshots/Window-Layout-1000x400.png b/scripts/macos/screenshots/Window-Layout-1000x400.png index ee4f9e5c5ec..7e580101fcb 100644 Binary files a/scripts/macos/screenshots/Window-Layout-1000x400.png and b/scripts/macos/screenshots/Window-Layout-1000x400.png differ diff --git a/scripts/macos/screenshots/Window-Layout-400x300.png b/scripts/macos/screenshots/Window-Layout-400x300.png index 1dda6a29c80..a33e0e6e2a2 100644 Binary files a/scripts/macos/screenshots/Window-Layout-400x300.png and b/scripts/macos/screenshots/Window-Layout-400x300.png differ diff --git a/scripts/macos/screenshots/Window-Layout-900x700.png b/scripts/macos/screenshots/Window-Layout-900x700.png index b69f81a5374..512c1b07af6 100644 Binary files a/scripts/macos/screenshots/Window-Layout-900x700.png and b/scripts/macos/screenshots/Window-Layout-900x700.png differ diff --git a/scripts/run-desktop-fidelity-tests.sh b/scripts/run-desktop-fidelity-tests.sh new file mode 100755 index 00000000000..ef3d515cf80 --- /dev/null +++ b/scripts/run-desktop-fidelity-tests.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +### +# Score the Codename One desktop native themes against the captured native references. +# +# Usage: run-desktop-fidelity-tests.sh +# +# Runs the Codename One side through the JAVASE port rather than a native desktop port. That +# is a deliberate choice for the PR-gating leg: the JavaSE simulator renders the same theme +# through the same core, starts in seconds rather than after a ParparVM translation and a +# native toolchain build, and is where most desktop Codename One applications actually run. +# The native Windows, Linux and macOS ports get a separate, slower leg. +# +# Each theme is scored on ITS OWN platform's runner. A Fluent theme measured on a Mac would +# be measured in the wrong system font, and text metrics are most of a fidelity score -- so +# there is no point running all three anywhere. +# +# Honours CN1SS_FIDELITY_GOLDEN_SET, FIDELITY_UPDATE_BASELINE and CN1SS_FIDELITY_EPSILON. +### +set -euo pipefail + +rf_log() { echo "[run-desktop-fidelity-tests] $1"; } + +if [ $# -lt 1 ]; then + rf_log "Usage: $0 " >&2 + exit 2 +fi +PLATFORM="$1" + +case "$PLATFORM" in + windows) THEME_RES="WindowsFluentTheme"; DEFAULT_SET="windows-11-fluent" ;; + macos) THEME_RES="MacOSAquaTheme"; DEFAULT_SET="macos-aqua" ;; + gnome) THEME_RES="GnomeAdwaitaTheme"; DEFAULT_SET="gnome-adwaita" ;; + *) rf_log "Unknown platform '$PLATFORM' (expected windows, macos or gnome)" >&2; exit 2 ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +APP_DIR="${CN1_APP_DIR:-scripts/fidelity-app}" +GOLDEN_SET="${CN1SS_FIDELITY_GOLDEN_SET:-$DEFAULT_SET}" +GOLDENS_DIR="$APP_DIR/goldens/$GOLDEN_SET" +BASELINE_FILE="$APP_DIR/baseline/${GOLDEN_SET}-fidelity-baseline.json" +SPEC_FILE="$APP_DIR/common/src/main/resources/fidelity-tests.yaml" +mkdir -p "$GOLDENS_DIR" "$(dirname "$BASELINE_FILE")" + +CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" +source "$SCRIPT_DIR/lib/cn1ss.sh" +cn1ss_log() { rf_log "$1"; } + +# Compile the shared Java helpers (ProcessScreenshots, FidelityGate, the report +# renderers) and point the library at the JVM that runs them. Without this every +# cn1ss_* helper refuses with "CN1SS_JAVA_BIN is not configured" -- which reads as +# an unset variable rather than as a missing setup call, so it is worth naming. +# +# The SAME JVM renders the tiles and runs the helpers: the helpers need 17+ for +# switch expressions and the simulator needs 11+, while tools/env.sh puts JDK 8 on +# PATH for the framework build. CN1SS_DESKTOP_JAVA is how CI passes the newer one. +JAVA_BIN="${CN1SS_DESKTOP_JAVA:-$(command -v java || true)}" +# Normalize a Windows path before testing it. On the Windows leg this value comes from +# setup-java's JAVA_HOME, which is a native spelling like C:\hostedtoolcache\... . Git +# Bash's `test -x` does not resolve that, so the check below would fail and the leg would +# exit 25 without rendering a single tile -- a green-looking "no java" message on a runner +# that has one. The fidelity app's own mvnw converts JAVA_HOME the same way. +case "$(uname)" in + CYGWIN* | MINGW* | MSYS*) + if command -v cygpath >/dev/null 2>&1 && [ -n "$JAVA_BIN" ]; then + JAVA_BIN="$(cygpath --unix "$JAVA_BIN" 2>/dev/null || printf '%s' "$JAVA_BIN")" + # setup-java points at the JDK root, so the .exe suffix is added here rather than + # being assumed present: C:\...\bin\java exists only as java.exe on Windows. + [ -x "$JAVA_BIN" ] || [ ! -x "${JAVA_BIN}.exe" ] || JAVA_BIN="${JAVA_BIN}.exe" + fi + ;; +esac +if [ -z "$JAVA_BIN" ] || [ ! -x "$JAVA_BIN" ]; then + rf_log "FAILED: no usable java (tried '$JAVA_BIN'); set CN1SS_DESKTOP_JAVA to a JDK 17+ java binary." + exit 25 +fi +if ! cn1ss_setup "$JAVA_BIN" "$CN1SS_HELPER_SOURCE_DIR"; then + rf_log "FAILED: could not prepare the Java helpers with $JAVA_BIN" + exit 25 +fi + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts/${PLATFORM}-fidelity}" +mkdir -p "$ARTIFACTS_DIR" +TMPDIR="${TMPDIR:-/tmp}"; TMPDIR="${TMPDIR%/}" +WORK_DIR="$(mktemp -d "${TMPDIR}/cn1ss-fid-${PLATFORM}-XXXXXX")" +TILE_DIR="$WORK_DIR/tiles"; mkdir -p "$TILE_DIR" +PREVIEW_DIR="$WORK_DIR/previews"; mkdir -p "$PREVIEW_DIR" + +# The golden set is the contract. Scoring against an empty directory would compare every +# tile with nothing, and the comparator reports that as "missing_expected" rather than as a +# score -- which reads as a broken run instead of as an unseeded one, so it is said plainly +# here instead. +if [ -z "$(ls -A "$GOLDENS_DIR" 2>/dev/null | grep -v README || true)" ]; then + rf_log "No native references in $GOLDENS_DIR." + rf_log "Capture them first with the manual workflow:" + rf_log " gh workflow run fidelity-desktop-native-ref.yml -f targets=$PLATFORM -f mode=capture" + rf_log "then review and commit them per $APP_DIR/goldens/README.md." + exit 24 +fi + +rf_log "Rendering Codename One tiles for $PLATFORM using $THEME_RES" + +# The runner is told which platform it is rather than inferring it. JavaSE answers "win", +# "mac" or "linux" from the HOST, which is right for an application and wrong here: the same +# host must be able to render whichever theme it is asked for, and the golden set is named +# for the design generation rather than for the machine. +SIM_JAR="$(ls maven/javase/target/codenameone-javase-*-jar-with-dependencies.jar 2>/dev/null | head -n1 || true)" +CLASSES_DIR="$APP_DIR/common/target/classes" +# The tile renderer is in its own module: it is host code (java.awt, java.io) and +# `common` is compiled as CN1 application code under a bytecode-compliance gate. +RUNNER_CLASSES="$APP_DIR/desktop-runner/target/classes" +if [ ! -f "$RUNNER_CLASSES/com/codenameone/fidelity/DesktopTileRunner.class" ]; then + rf_log "FAILED: the fidelity app is not built ($RUNNER_CLASSES)." + rf_log "Build it with: (cd $APP_DIR && ./mvnw -q -pl common,desktop-runner install)" + exit 27 +fi +if [ ! -f "$CLASSES_DIR/fidelity-tests.yaml" ]; then + # The runner reads the spec off its own classpath, so a classes directory without it + # renders nothing and would otherwise fail later with a less obvious message. + rf_log "FAILED: fidelity-tests.yaml is missing from $CLASSES_DIR" + exit 27 +fi +if [ -z "$SIM_JAR" ]; then + rf_log "FAILED: the JavaSE simulator jar is not built." + rf_log "Build it with: (cd maven && mvn -pl javase -Plocal-dev-javase -DskipTests install)" + exit 25 +fi + +set +e +# Themes/ comes FIRST on the class path, and that order is load-bearing rather than tidy. +# There are THREE copies of every native theme in a built tree -- Themes/ (the build output +# and the single source of truth), a copy the fidelity module's pom stages into its +# target/classes, and a copy BUNDLED INSIDE the javase jar -- and whichever the class loader +# reaches first is the one that gets scored. +# +# Both stale copies were found the same way and neither announced itself: a theme edit +# scored identically to no edit at all, which reads as "that CSS change did nothing" rather +# than as "the change was never loaded". The jar's copy was missing three constants the +# source had; the staged copy goes stale the moment build-native-themes.sh runs without a +# module rebuild behind it. +# +# Putting the build output first means the thing just compiled is the thing measured. +# +# NOT -Djava.awt.headless=true. The JavaSE port creates a real AWT window during +# Display.init and throws HeadlessException when it cannot, so the simulator needs a +# display rather than the absence of one -- which is why every other simulator runner +# here (run-javase-device-tests.sh, archetype-smoke.yml) reaches for xvfb-run on Linux +# instead. macOS and Windows runners have a session already. +# +# useAppFrame=false keeps the simulator's inspector/AppFrame chrome out of the run: it +# is stored as a per-user preference, so without pinning it the tiles depend on what +# the last person to open this app in the simulator happened to click. +DISPLAY_WRAPPER=() +if [ "$(uname -s)" = "Linux" ]; then + if ! command -v xvfb-run >/dev/null 2>&1; then + rf_log "FAILED: xvfb-run is required on Linux (apt-get install xvfb)." + exit 25 + fi + DISPLAY_WRAPPER=(xvfb-run -a) +fi +${DISPLAY_WRAPPER[@]+"${DISPLAY_WRAPPER[@]}"} "$JAVA_BIN" -Dcn1.simulator.useAppFrame=false \ + -Dcn1ss.fidelity.platform="$PLATFORM" \ + -Dcn1ss.fidelity.themeResource="/$THEME_RES.res" \ + -cp "$REPO_ROOT/Themes:$REPO_ROOT/$CLASSES_DIR:$REPO_ROOT/$RUNNER_CLASSES:$REPO_ROOT/$SIM_JAR" \ + com.codenameone.fidelity.DesktopTileRunner "$PLATFORM" "$THEME_RES" "$TILE_DIR" +rc=$? +set -e +if [ "$rc" -ne 0 ]; then + rf_log "FAILED: tile rendering exited $rc" + exit "$rc" +fi + +TILES="$(ls -1 "$TILE_DIR"/*.png 2>/dev/null | wc -l | tr -d ' ')" +rf_log "Rendered $TILES tile(s)" +if [ "$TILES" = "0" ]; then + rf_log "FAILED: no tiles were produced, so there is nothing to score." + exit 26 +fi + +export CN1SS_FIDELITY_SPEC="$SPEC_FILE" +export CN1SS_FIDELITY_PLATFORM="$PLATFORM" + +# Without this the ratchet is not a gate. cn1ss_process_fidelity runs FidelityGate either +# way, but only TURNS a gate failure into a non-zero return when CN1SS_FAIL_ON_MISMATCH=1 +# -- otherwise it logs "reported regressions ... not failing" and returns success. +# +# Measured before it was set: raising one baseline entry by five points produced the +# correct "[gate] FAIL: 1 fidelity regression(s)" on stdout and an exit status of 0. A +# workflow would have gone green on a regression it had just printed. +# +# Defaulted rather than forced, so a local exploratory run can still see every score +# without the run aborting, which is what the other suites do too (run-tv-ui-tests.sh, +# run-watch-ui-tests.sh). +export CN1SS_FAIL_ON_MISMATCH="${CN1SS_FAIL_ON_MISMATCH:-1}" + +# One --actual entry per TILE, "=". The comparator takes files, not +# a directory, and handing it a directory is not a usage error it reports -- it is +# an entry whose path does not exist, so the run dies inside the helper with nothing +# to say. The glob also leaves tile-backgrounds.properties behind, which is read +# from the tile directory rather than passed in. +shopt -s nullglob +declare -a COMPARE_ENTRIES=() +for png in "$TILE_DIR"/*_cn1.png; do + base="$(basename "$png" .png)" + COMPARE_ENTRIES+=("${base%_cn1}=${png}") +done +shopt -u nullglob +if [ "${#COMPARE_ENTRIES[@]}" -eq 0 ]; then + rf_log "FAILED: $TILE_DIR holds no *_cn1.png tiles to score." + exit 26 +fi +rf_log "Scoring ${#COMPARE_ENTRIES[@]} tile(s) against $GOLDEN_SET" + +# set -e does not apply to the last command of a script in the way that matters here: +# the status has to be captured and re-raised deliberately, so a gate failure leaves this +# script with a non-zero status rather than whatever the last log line returned. +rc=0 +cn1ss_process_fidelity \ + "Desktop fidelity ($PLATFORM, $GOLDEN_SET)" \ + "$WORK_DIR/compare.json" \ + "$WORK_DIR/summary.md" \ + "$WORK_DIR/comment.md" \ + "$GOLDENS_DIR" \ + "$PREVIEW_DIR" \ + "$ARTIFACTS_DIR" \ + "$BASELINE_FILE" \ + "${COMPARE_ENTRIES[@]}" || rc=$? +if [ "$rc" -ne 0 ]; then + rf_log "FAILED: the fidelity gate reported a regression (rc=$rc)." + exit "$rc" +fi diff --git a/scripts/test_check_fidelity_spec.py b/scripts/test_check_fidelity_spec.py new file mode 100644 index 00000000000..5b0386ea8b8 --- /dev/null +++ b/scripts/test_check_fidelity_spec.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Regression checks for source/spec mismatches that otherwise silently lose coverage.""" +import contextlib +import importlib.util +import io +from pathlib import Path +import tempfile +import unittest + +module = importlib.util.spec_from_file_location("fidelity_spec", Path(__file__).with_name("check-fidelity-spec.py")) +validator = importlib.util.module_from_spec(module) +module.loader.exec_module(validator) + + +class FidelitySpecTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.spec = validator.SPEC + self.sources = validator.NATIVE_REF_SOURCES + self.addCleanup(setattr, validator, "SPEC", self.spec) + self.addCleanup(setattr, validator, "NATIVE_REF_SOURCES", self.sources) + validator.SPEC = Path(self.temp.name) / "spec.yaml" + validator.SPEC.write_text(self.spec.read_text()) + validator.NATIVE_REF_SOURCES = {} + for platform, path in self.sources.items(): + target = Path(self.temp.name) / path.name + target.write_text(path.read_text()) + validator.NATIVE_REF_SOURCES[platform] = target + + def check(self): + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + return validator.main() + + def test_current_sources(self): + self.assertEqual(0, self.check()) + + def test_unknown_mobile_renderer_ids_fail_even_with_valid_native_keys(self): + original = validator.SPEC.read_text() + for known in ("Button", "TextField", "SwitchMorph"): + with self.subTest(original_id=known): + validator.SPEC.write_text(original.replace(" - id: " + known + "\n", + " - id: UnsupportedProbe\n", 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("unsupported CN1 renderer id" in error for error in validator.ERRORS)) + validator.SPEC.write_text(original.replace(" - id: GlassPanelGrad\n", " - id: GlassPanelProbe\n", 1)) + self.assertEqual(0, self.check(), "the renderer intentionally supports the GlassPanel prefix") + + def test_material_values_match_comparator_modes(self): + original = validator.SPEC.read_text() + self.assertIn(" material: glass", original) + for material in ("glas", "Glass", "glass,lens", ""): + with self.subTest(material=material): + validator.SPEC.write_text(original.replace("material: glass", "material: " + material, 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("unknown material" in error for error in validator.ERRORS)) + for material in ("normal", "glass", "lens", '"glass"', "'lens'"): + with self.subTest(material=material): + validator.SPEC.write_text(original.replace("material: glass", "material: " + material, 1)) + self.assertEqual(0, self.check()) + validator.SPEC.write_text(original.replace(" material: glass\n", "", 1)) + self.assertEqual(0, self.check(), "omitting material retains the legacy heuristic") + + def test_component_dimensions_require_positive_java_integers(self): + original = validator.SPEC.read_text() + for key in sorted(validator.TILE_KEYS): + row = "DesktopButton" if key.endswith("_px") else "Button" + marker = " - id: " + row + "\n" + for value in ("24O", "0", "-1", "2147483648", "1.5", "", "1px"): + with self.subTest(key=key, value=value): + validator.SPEC.write_text(original.replace(marker, marker + " " + key + ": " + value + "\n", 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("positive Java integer" in error for error in validator.ERRORS)) + for value in ("1", "+24", "2147483647", '"24"'): + with self.subTest(key=key, value=value): + validator.SPEC.write_text(original.replace(marker, marker + " " + key + ": " + value + "\n", 1)) + self.assertEqual(0, self.check()) + + def test_backdrop_values_match_renderer_contract(self): + original = validator.SPEC.read_text() + self.assertIn("backdrop: grouped", original) + for value in ("phoot", "Photo", "fff", "fffffff", "gg0000", ""): + with self.subTest(value=value): + validator.SPEC.write_text(original.replace("backdrop: grouped", "backdrop: " + value, 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("backdrop must be" in error for error in validator.ERRORS)) + for value in ("photo", "gradient", "grouped", "000000", "Ab09fF", '"808080"', "'photo'"): + with self.subTest(value=value): + validator.SPEC.write_text(original.replace("backdrop: grouped", "backdrop: " + value, 1)) + self.assertEqual(0, self.check()) + + def test_frames_are_unique_valid_progress_values_and_capture_names(self): + original = validator.SPEC.read_text() + marker = "frames: 0,25,50,75,100" + self.assertIn(marker, original) + for value in ("0,5O,100", "", "0,,100", "0,0,100", "0,00,100", "0,101", "-1", + "0,+50,100", "0000", "2147483648", "0,'50',100", "0,50.5,100"): + with self.subTest(value=value): + validator.SPEC.write_text(original.replace(marker, "frames: " + value, 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("frame" in error for error in validator.ERRORS)) + for value in ("0,50,100", "000,050,100", "0", "100", '"0, 25, 100"'): + with self.subTest(value=value): + validator.SPEC.write_text(original.replace(marker, "frames: " + value, 1)) + self.assertEqual(0, self.check()) + + def test_platform_allow_list_must_reach_a_declared_capture_target(self): + original = validator.SPEC.read_text() + for row, platforms in (("DesktopButton", "ios"), ("Button", "windows,gnome")): + with self.subTest(row=row, platforms=platforms): + marker = " - id: " + row + "\n" + validator.SPEC.write_text(original.replace(marker, marker + " platforms: " + platforms + "\n", 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("excludes every declared capture target" in error for error in validator.ERRORS)) + for row, platforms in (("DesktopButton", "win"), ("DesktopButton", "ios,window"), + ("Button", "and"), ("Button", '"ios"')): + with self.subTest(row=row, platforms=platforms): + marker = " - id: " + row + "\n" + validator.SPEC.write_text(original.replace(marker, marker + " platforms: " + platforms + "\n", 1)) + self.assertEqual(0, self.check()) + # The committed SwitchMorph/TabsMorph rows intentionally have only frames. + validator.SPEC.write_text(original) + self.assertEqual(0, self.check()) + + def test_gnome_rows_reject_linux_host_aliases(self): + original = validator.SPEC.read_text() + marker = " - id: DesktopButton\n" + for platform in ("linux", "lin", "linux-gnu", "linux,gnome"): + with self.subTest(platform=platform): + validator.SPEC.write_text(original.replace( + marker, marker + " platforms: " + platform + "\n", 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("unknown platform" in error for error in validator.ERRORS)) + validator.SPEC.write_text(original.replace(marker, marker + " platforms: gnome\n", 1)) + self.assertEqual(0, self.check()) + + def test_unknown_defaults_are_rejected(self): + original = validator.SPEC.read_text() + for old, new in (("appearances:", "appearance:"), ("tile_width_px:", "tile_wdith_px:"), + ("defaults:", "default:")): + with self.subTest(new=new): + validator.SPEC.write_text(original.replace(old, new, 1)) + self.assertEqual(1, self.check()) + + def test_default_indentation_and_tabs_are_rejected(self): + original = validator.SPEC.read_text() + for replacement in (" appearances:", " appearances:", "\tappearances:", + " appearances\t:", " appearances "): + with self.subTest(replacement=replacement): + validator.SPEC.write_text(original.replace(" appearances:", replacement, 1)) + self.assertEqual(1, self.check()) + + def test_invalid_default_values_and_duplicates_are_rejected(self): + original = validator.SPEC.read_text() + for old, new in (("tile_width_px: 240", "tile_width_px: 240px"), + ("tile_width_px: 240", "tile_width_px: 0"), + ("tile_width_px: 240", "tile_width_px: 2147483648"), + ("bg: ffffff", "bg: nothex"), + ("appearances: light,dark", "appearances: light,drak"), + ("appearances: light,dark", "appearances:"), + ("appearances: light,dark", "appearances: light,light"), + ("appearances: light,dark", "appearances: light,dark\n appearances: light")): + with self.subTest(new=new): + validator.SPEC.write_text(original.replace(old, new, 1)) + self.assertEqual(1, self.check()) + + def test_quoted_defaults_and_deliberate_single_appearance_are_valid(self): + original = validator.SPEC.read_text() + for appearances in ('"light,dark"', "'light'", "dark"): + with self.subTest(appearances=appearances): + validator.SPEC.write_text(original.replace("appearances: light,dark", "appearances: " + appearances) + .replace("tile_width_px: 240", 'tile_width_px: "240"') + .replace("bg: ffffff", "bg: 'ffffff'")) + self.assertEqual(0, self.check()) + + def test_malformed_component_indentation_is_not_silently_ignored(self): + original = validator.SPEC.read_text() + validator.SPEC.write_text(original.replace(" - id: Button", " - id: Button", 1)) + self.assertEqual(1, self.check()) + + def test_platform_typo_is_rejected_but_runtime_prefixes_are_valid(self): + original = validator.SPEC.read_text() + for token in ("gnmoe", "windwos", "unknown", "linux"): + with self.subTest(token=token): + validator.SPEC.write_text(original.replace("platforms: ios", "platforms: " + token, 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("unknown platform" in e for e in validator.ERRORS)) + for token in ("window", "win", "and", "mac", "gnome"): + with self.subTest(token=token): + validator.SPEC.write_text(original.replace("platforms: ios", "platforms: " + token, 1)) + self.assertEqual(0, self.check()) + + def test_wrong_widget_label_is_rejected_even_when_both_literals_remain(self): + changes = { + "windows": ('Content = "Button"', 'Content = "Option"'), + "macos": ('NSButton(title: "Button"', 'NSButton(title: "Option"'), + "gnome": ('gtk_button_new_with_label("Button")', 'gtk_button_new_with_label("Option")'), + } + for platform, (old, new) in changes.items(): + with self.subTest(platform=platform): + path = validator.NATIVE_REF_SOURCES[platform] + original = path.read_text() + self.assertIn(old, original) + path.write_text(original.replace(old, new, 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("DesktopButton" in e and "renders 'Option'" in e for e in validator.ERRORS)) + path.write_text(original) + + def test_native_table_mapping_must_match_spec(self): + changes = {"windows": ("winui_button", "winui_combobox"), + "macos": ("appkit_push_button", "appkit_popupbutton"), + "gnome": ("adw_button", "gtk_dropdown")} + for platform, (old, new) in changes.items(): + with self.subTest(platform=platform): + path = validator.NATIVE_REF_SOURCES[platform] + original = path.read_text() + path.write_text(original.replace('"' + old + '"', '"' + new + '"', 1)) + self.assertEqual(1, self.check()) + self.assertTrue(any("DesktopButton maps to" in e for e in validator.ERRORS)) + path.write_text(original) + + def test_comments_do_not_mask_a_wrong_label(self): + path = validator.NATIVE_REF_SOURCES["windows"] + path.write_text(path.read_text().replace('Content = "Button"', '/* Content = "Button" */ Content = "Option"', 1)) + self.assertEqual(1, self.check()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_fidelity_gate.py b/scripts/test_fidelity_gate.py new file mode 100644 index 00000000000..c3acc3064a3 --- /dev/null +++ b/scripts/test_fidelity_gate.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Exercise the compiled fidelity gate against complete and shrinking capture sets.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class FidelityGateTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.classes = tempfile.TemporaryDirectory(prefix="cn1-fidelity-gate-") + cls.addClassCleanup(cls.classes.cleanup) + java_home = os.environ.get("JAVA_HOME") + cls.java = str(Path(java_home) / "bin/java") if java_home else "java" + javac = str(Path(java_home) / "bin/javac") if java_home else "javac" + subprocess.run([javac, "-d", cls.classes.name, + str(ROOT / "scripts/common/java/FidelityGate.java")], check=True) + + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="cn1-fidelity-data-") + self.addCleanup(self.directory.cleanup) + path = Path(self.directory.name) + self.baseline = path / "baseline.json" + self.comparison = path / "compare.json" + self.baseline.write_text(json.dumps({"pairs": {"kept": 90, "removed": 80}})) + + def gate(self, scores, update=False, geometry=None): + self.comparison.write_text(json.dumps({"results": [ + {"test": key, "status": "compared", "details": {"fidelity_percent": value, **({"geometry": geometry} if geometry is not None else {})}} + for key, value in scores.items()]})) + command = [self.java, "-cp", self.classes.name, "FidelityGate", + "--compare-json", str(self.comparison), "--baseline", str(self.baseline)] + if update: + command += ["--update-baseline", str(self.baseline)] + return subprocess.run(command, capture_output=True, text=True) + + def test_complete_set_passes_and_score_regressions_still_fail(self): + self.assertEqual(0, self.gate({"kept": 90, "removed": 80}).returncode) + self.assertEqual(20, self.gate({"kept": 70, "removed": 80}).returncode) + + def test_missing_pair_fails_until_its_baseline_is_explicitly_removed(self): + result = self.gate({"kept": 90}) + self.assertEqual(20, result.returncode) + self.assertIn("removed (baseline pair absent", result.stderr) + self.assertEqual(0, self.gate({"kept": 90}, update=True, geometry={"center_offset": 0, "width_ratio": 1, "height_ratio": 1}).returncode) + self.assertIn("removed", json.loads(self.baseline.read_text())["pairs"], + "a partial baseline update must not silently delete coverage") + self.assertEqual(20, self.gate({"kept": 90}).returncode) + self.baseline.write_text(json.dumps({"pairs": {"kept": 90}})) + self.assertEqual(0, self.gate({"kept": 90}).returncode) + + def test_existing_geometry_cannot_disappear_or_be_incomplete(self): + geometry = {"center_offset": 0, "width_ratio": 1, "height_ratio": 1} + self.baseline.write_text(json.dumps({"pairs": {"kept": 90}, "geometry": {"kept": geometry}})) + self.assertEqual(0, self.gate({"kept": 90}, geometry=geometry).returncode) + for missing in (None, {}, {"empty": True}, {"center_offset": 0, "width_ratio": 1}): + for update in (False, True): + with self.subTest(geometry=missing, update=update): + result = self.gate({"kept": 90}, update=update, geometry=missing) + self.assertEqual(20, result.returncode) + self.assertIn("missing or incomplete geometry", result.stderr) + # A partial refresh can omit a pair entirely without deleting its geometry. + self.assertEqual(0, self.gate({}, update=True).returncode) + self.assertEqual(geometry, json.loads(self.baseline.read_text())["geometry"]["kept"]) + + def test_baseline_updates_require_geometry_for_new_and_legacy_pairs(self): + geometry = {"center_offset": 0, "width_ratio": 1, "height_ratio": 1} + for name in ("new", "kept"): + for missing in (None, {}, {"empty": True}, {"center_offset": 0}): + with self.subTest(pair=name, geometry=missing): + before = self.baseline.read_text() + result = self.gate({name: 90}, update=True, geometry=missing) + self.assertEqual(20, result.returncode) + self.assertIn("missing or incomplete geometry", result.stderr) + self.assertEqual(before, self.baseline.read_text()) + self.assertEqual(0, self.gate({name: 90}, update=True, geometry=geometry).returncode) + self.assertEqual(geometry, json.loads(self.baseline.read_text())["geometry"][name]) + + def test_empty_capture_set_cannot_pass_an_existing_baseline(self): + result = self.gate({}) + self.assertEqual(20, result.returncode) + self.assertIn("kept (baseline pair absent", result.stderr) + self.assertIn("removed (baseline pair absent", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_fidelity_geometry.py b/scripts/test_fidelity_geometry.py new file mode 100644 index 00000000000..c0fce66c774 --- /dev/null +++ b/scripts/test_fidelity_geometry.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Exercise geometry masking through the compiled screenshot comparator.""" +import json +import os +from pathlib import Path +import struct +import subprocess +import tempfile +import unittest +import zlib + +ROOT = Path(__file__).resolve().parents[1] + + +def tile(path, background, foreground, box): + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + + x0, y0, width, height = box + rows = bytearray() + for y in range(80): + rows.append(0) + for x in range(120): + color = foreground if x0 <= x < x0 + width and y0 <= y < y0 + height else background + rows.extend(bytes.fromhex(color)) + path.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 120, 80, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")) + + +class FidelityGeometryTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.classes = tempfile.TemporaryDirectory(prefix="cn1-fidelity-geometry-") + cls.addClassCleanup(cls.classes.cleanup) + java_home = os.environ.get("JAVA_HOME") + cls.java = str(Path(java_home) / "bin/java") if java_home else "java" + javac = str(Path(java_home) / "bin/javac") if java_home else "javac" + subprocess.run([javac, "-d", cls.classes.name, + str(ROOT / "scripts/common/java/ProcessScreenshots.java")], check=True) + cls.gate_classes = tempfile.TemporaryDirectory(prefix="cn1-glass-gate-") + cls.addClassCleanup(cls.gate_classes.cleanup) + subprocess.run([javac, "-d", cls.gate_classes.name, + str(ROOT / "scripts/common/java/FidelityGate.java")], check=True) + + def test_backdrop_only_glass_and_lens_pairs_fail_even_without_a_baseline(self): + with tempfile.TemporaryDirectory(prefix="cn1-glass-data-") as directory: + path = Path(directory) + reference = path / "reference" + reference.mkdir() + backdrop = path / "backdrop.png" + actual = path / "actual.png" + tile(backdrop, "345678", "ffffff", (0, 0, 0, 0)) + spec = path / "spec.yaml" + baseline = path / "baseline.json" + comparison = path / "compare.json" + for material in ("glass", "lens"): + spec.write_text("components:\n - id: Probe\n native: probe\n material: " + material + "\n") + for box in ((0, 0, 0, 0), (10, 10, 80, 30)): + with self.subTest(material=material, box=box): + tile(actual, "345678", "ffffff", box) + tile(reference / "Probe_normal_light.png", "345678", "ffffff", box) + result = subprocess.run([self.java, "-Djava.awt.headless=true", "-cp", self.classes.name, + "ProcessScreenshots", "--mode", "fidelity", "--reference-dir", str(reference), + "--spec", str(spec), "--backdrop", str(backdrop), + "--actual", "Probe_normal_light=" + str(actual)], + capture_output=True, text=True, check=True) + row = json.loads(result.stdout)["results"][0] + blank = box[2] == 0 + self.assertEqual("blank_pair" if blank else "compared", row["status"]) + self.assertEqual(0 if blank else 100, row["details"]["fidelity_percent"]) + comparison.write_text(result.stdout) + for update in (False, True): + baseline.write_text(json.dumps({"pairs": {}})) + command = [self.java, "-cp", self.gate_classes.name, "FidelityGate", + "--compare-json", str(comparison), "--baseline", str(baseline)] + if update: + command += ["--update-baseline", str(baseline)] + gate = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(20 if blank else 0, gate.returncode, gate.stderr) + + def test_grouped_backdrops_preserve_field_size_position_and_empty_detection(self): + with tempfile.TemporaryDirectory(prefix="cn1-geometry-data-") as directory: + path = Path(directory) + spec = path / "spec.yaml" + spec.write_text("components:\n - id: TextField\n native: TextField\n" + " material: normal\n backdrop: grouped\n") + for appearance, background, foreground in (("light", "f2f2f7", "ffffff"), + ("dark", "1c1c1e", "2c2c2e")): + name = "TextField_normal_" + appearance + tile(path / (name + ".png"), background, foreground, (10, 10, 80, 30)) + actual = path / "actual.png" + for box in ((10, 10, 80, 30), (20, 15, 60, 20), (0, 0, 0, 0)): + with self.subTest(appearance=appearance, box=box): + tile(actual, background, foreground, box) + result = subprocess.run([self.java, "-Djava.awt.headless=true", "-cp", self.classes.name, + "ProcessScreenshots", "--mode", "fidelity", "--reference-dir", str(path), + "--spec", str(spec), "--actual", name + "=" + str(actual)], + capture_output=True, text=True, check=True) + geometry = json.loads(result.stdout)["results"][0]["details"]["geometry"] + if box[2] == 0: + self.assertTrue(geometry["empty"]) + else: + self.assertEqual([10, 10, 80, 30], geometry["native_bbox"]) + self.assertEqual(list(box), geometry["cn1_bbox"]) + self.assertAlmostEqual(box[2] / 80, geometry["width_ratio"], places=4) + self.assertAlmostEqual(box[3] / 30, geometry["height_ratio"], places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_mac_native_font.py b/scripts/test_mac_native_font.py new file mode 100644 index 00000000000..62b77ab723f --- /dev/null +++ b/scripts/test_mac_native_font.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Run the native macOS alias resolver and font binding against real AppKit.""" +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class MacNativeFontTest(unittest.TestCase): + def test_native_aliases_use_appkit_family_weights_and_italics(self): + source = (ROOT / "Ports/iOSPort/nativeSources/IOSNative.m").read_text() + start = source.index("static NSFont *cn1MacSystemFontForAlias(") + end = source.index("\n#endif", start) + resolver = source[start:end] + start = source.index("JAVA_LONG com_codename1_impl_ios_IOSNative_createTruetypeFont___java_lang_String(") + end = source.index("\nJAVA_LONG ", start + 1) + binding = source[start:end] + harness = r''' +#import +#include +#define CN1_THREAD_STATE_MULTI_ARG +#define CN1_THREAD_STATE_PASS_ARG +#define JAVA_LONG intptr_t +#define JAVA_OBJECT id +#define CN1_USE_ARC 1 +#define POOL_BEGIN() +#define POOL_END() +#define BRIDGE_CAST __bridge +#define toNSString(value) ((NSString *)(value)) +#define UIFontWeightUltraLight NSFontWeightUltraLight +#define UIFontWeightLight NSFontWeightLight +#define UIFontWeightMedium NSFontWeightMedium +#define UIFontWeightBold NSFontWeightBold +#define UIFontWeightHeavy NSFontWeightHeavy +typedef NSFont CN1Font; +static int scaleValue = 1; +static int registrations = 0; +static int isIOS8_2(void) { return 1; } +static void cn1RegisterBundledFontsOnce(void) { registrations++; } +RESOLVER +BINDING +int main(void) { + @autoreleasepool { + NSArray *weights = @[@"Thin", @"Light", @"Regular", @"Bold", @"Black"]; + CGFloat values[] = {NSFontWeightThin, NSFontWeightLight, NSFontWeightRegular, + NSFontWeightBold, NSFontWeightBlack}; + for (NSUInteger i = 0; i < [weights count]; i++) { + for (int italic = 0; italic <= 1; italic++) { + NSString *alias = [NSString stringWithFormat:@"native:%@%@", + italic ? @"Italic" : @"Main", weights[i]]; + for (int size = 11; size <= 22; size += 11) { + NSFont *actual = cn1MacSystemFontForAlias(alias, size); + intptr_t peer = com_codename1_impl_ios_IOSNative_createTruetypeFont___java_lang_String(nil, alias); + NSFont *loaded = (__bridge NSFont *)(void *)peer; + assert([loaded.fontName isEqualToString:actual.fontName]); + assert(loaded.pointSize == 14); + NSFont *expected = [NSFont systemFontOfSize:size weight:values[i]]; + if (italic) { + expected = [[NSFontManager sharedFontManager] convertFont:expected + toHaveTrait:NSItalicFontMask]; + } + assert(actual != nil); + assert([actual.fontName isEqualToString:expected.fontName]); + assert(actual.pointSize == size); + NSFontTraitMask traits = [[NSFontManager sharedFontManager] traitsOfFont:actual]; + assert(((traits & NSItalicFontMask) != 0) == italic); + assert([actual.familyName isEqualToString:expected.familyName]); + } + } + } + assert(registrations == 0); + intptr_t namedPeer = com_codename1_impl_ios_IOSNative_createTruetypeFont___java_lang_String(nil, @"HelveticaNeue-Medium"); + NSFont *named = (__bridge NSFont *)(void *)namedPeer; + assert([named.fontName isEqualToString:[NSFont systemFontOfSize:14 weight:NSFontWeightMedium].fontName]); + assert(cn1MacSystemFontForAlias(@"HelveticaNeue-Medium", 14) == nil); + assert(cn1MacSystemFontForAlias(@"Material Icons", 14) == nil); + assert(cn1MacSystemFontForAlias(@"native:MainUnknown", 14) == nil); + assert(cn1MacSystemFontForAlias(nil, 14) == nil); + } + return 0; +} +''' + with tempfile.TemporaryDirectory(prefix="cn1-mac-font-") as directory: + path = pathlib.Path(directory) + source_file = path / "font-test.m" + binary = path / "font-test" + source_file.write_text(harness.replace("RESOLVER", resolver).replace("BINDING", binding)) + subprocess.run(["xcrun", "clang", "-fobjc-arc", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-framework", "AppKit", str(source_file), "-o", str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_native_hover_queue.py b/scripts/test_native_hover_queue.py new file mode 100644 index 00000000000..eb85bb34adc --- /dev/null +++ b/scripts/test_native_hover_queue.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Compile the real desktop event queues and exercise overflow without a native UI. + +The Linux queue uses pthreads; the Windows harness substitutes only its lock/signal +primitives. Event declarations and all queue operations come from the port sources. +""" +import os +from pathlib import Path +import re +import shlex +import subprocess +import tempfile +import unittest + +REPO = Path(__file__).resolve().parent.parent + +HARNESS = r''' +#include +static void drain(void) { int out[5]; while (pop(out)) {} } +static void fillMotion(int count) { + for (int i = 0; i < count; i++) push(0, CN1_EVENT_POINTER_HOVER, i, 12, 0); +} +int main(void) { + for (int window = 0; window <= 7; window += 7) { + int out[5], count, leaves, ordinary; + drain(); + fillMotion(CAPACITY - 1); + push(window, CN1_EVENT_POINTER_HOVER, -1, -1, 512); + count = leaves = 0; + while (pop(out)) { + count++; + if (out[0] == CN1_EVENT_POINTER_HOVER && out[1] == -1 && out[2] == -1) { + leaves++; assert(out[3] == 512 && out[4] == window); + assert(count == CAPACITY - 1); /* terminal state stays last */ + } + } + assert(count == CAPACITY - 1 && leaves == 1); + + /* A queued leave must not be the droppable event evicted for a release. */ + push(window, CN1_EVENT_POINTER_HOVER, -1, -1, 512); + fillMotion(CAPACITY - 2); + push(7, CN1_EVENT_KEY_RELEASED, 0, 0, 65); + count = leaves = 0; + while (pop(out)) { + count++; + if (out[0] == CN1_EVENT_POINTER_HOVER && out[1] == -1 && out[2] == -1) leaves++; + } + assert(count == CAPACITY - 1 && leaves == 1); + + /* Ordinary hover remains droppable. Repeated fills also wrap both cursors. */ + fillMotion(CAPACITY - 1); + push(window, CN1_EVENT_POINTER_HOVER, 99, 99, 12345); + count = ordinary = 0; + while (pop(out)) { count++; if (out[3] == 12345) ordinary++; } + assert(count == CAPACITY - 1 && ordinary == 0); + } + return 0; +} +''' + + +class NativeHoverQueueTest(unittest.TestCase): + def test_linux_pointer_sources_remain_distinct(self): + native = REPO / 'Ports/LinuxPort/nativeSources' + header = (native / 'cn1_linux.h').read_text() + flags = '\n'.join(re.findall(r'^#define CN1_PE_(?:TOUCH|PEN|ERASER)_FLAG\s+\d+', header, re.M)) + source = (native / 'cn1_linux_window.c').read_text() + helper = source[source.index('int cn1LinuxPointerSourceFlag('):source.index('/* True when an event originated')] + code = r''' +#include +#include +typedef enum { GDK_SOURCE_MOUSE, GDK_SOURCE_PEN, GDK_SOURCE_ERASER, + GDK_SOURCE_CURSOR, GDK_SOURCE_TOUCHSCREEN } GdkInputSource; +typedef struct { GdkInputSource source; } GdkDevice; +typedef struct { GdkDevice* device; } GdkEvent; +static GdkDevice* gdk_event_get_source_device(GdkEvent* event) { return event->device; } +static GdkInputSource gdk_device_get_source(GdkDevice* device) { return device->source; } +''' + flags + '\n' + helper + r''' +int main(void) { + GdkDevice device; + GdkEvent event = { &device }; + GdkInputSource sources[] = { GDK_SOURCE_MOUSE, GDK_SOURCE_PEN, GDK_SOURCE_ERASER, + GDK_SOURCE_CURSOR, GDK_SOURCE_TOUCHSCREEN }; + int expected[] = { 0, 512, 1024, 0, 256 }; + for (int i = 0; i < 5; i++) { + device.source = sources[i]; + assert(cn1LinuxPointerSourceFlag(&event) == expected[i]); + } + event.device = NULL; + assert(cn1LinuxPointerSourceFlag(&event) == 0); + return 0; +} +''' + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'sources.c' + binary = Path(directory) / 'sources' + path.write_text(code) + subprocess.run(shlex.split(os.environ.get('CC', 'cc')) + + ['-std=c11', '-Wall', '-Wextra', '-Werror', str(path), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + def compile_and_run(self, platform): + native = REPO / 'Ports' / (platform + 'Port') / 'nativeSources' + stem = 'cn1_' + platform.lower() + header = (native / (stem + '.h')).read_text() + enum = re.search(r'typedef enum\s*\{[^}]+\}\s*CN1EventType;', header).group(0) + code = '#include \n' + enum + '\n' + if platform == 'Linux': + source = (native / 'cn1_linux_window.c').read_text() + queue = source[source.index('#define CN1_EVENT_RING'):source.index('/* ------------------------------------------------------------- globals */')] + code += 'void cn1LinuxPushWindowEvent(int, int, int, int, int);\n' + queue + code += '\n#define CAPACITY CN1_EVENT_RING\n#define push cn1LinuxPushWindowEvent\n#define pop cn1LinuxPopEvent\n' + else: + source = (native / 'cn1_windows_window.cpp').read_text() + event = re.search(r'typedef struct\s*\{[^}]+\}\s*CN1Event;', header).group(0) + capacity = re.search(r'#define CN1_EVENT_QUEUE_CAPACITY\s+\d+', header).group(0) + code += 'typedef int JAVA_INT;\ntypedef long LONG;\n' + event + '\n' + capacity + r''' +static struct { + CN1Event events[CN1_EVENT_QUEUE_CAPACITY]; + LONG eventHead, eventTail; + pthread_mutex_t eventLock; + int eventSignal; +} cn1Win = { .eventLock = PTHREAD_MUTEX_INITIALIZER }; +#define EnterCriticalSection(lock) pthread_mutex_lock(lock) +#define LeaveCriticalSection(lock) pthread_mutex_unlock(lock) +#define SetEvent(signal) ((void)(signal)) +void cn1WinPushWindowEvent(int, CN1EventType, int, int, int); +''' + queue = source[source.index('void cn1WinPushEvent('):source.index('/* ------------------------------------------------------------- input helpers */')] + code += queue + r''' +#define CAPACITY CN1_EVENT_QUEUE_CAPACITY +#define push cn1WinPushWindowEvent +static int pop(int* out) { + CN1Event event; + if (!cn1WinPollEvent(&event)) return 0; + out[0] = event.type; out[1] = event.x; out[2] = event.y; + out[3] = event.keyCode; out[4] = event.windowId; + return 1; +} +''' + with tempfile.TemporaryDirectory() as directory: + source_path = Path(directory) / 'queue.c' + binary = Path(directory) / 'queue' + source_path.write_text(code + HARNESS) + subprocess.run(shlex.split(os.environ.get('CC', 'cc')) + + ['-std=c11', '-Wall', '-Wextra', '-Werror', '-pthread', str(source_path), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + def test_linux_queue(self): + self.compile_and_run('Linux') + + def test_windows_queue(self): + self.compile_and_run('Windows') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/windows/screenshots/DesktopMode.png b/scripts/windows/screenshots/DesktopMode.png index 2334d58d208..cd6487bba7a 100644 Binary files a/scripts/windows/screenshots/DesktopMode.png and b/scripts/windows/screenshots/DesktopMode.png differ diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index 5b6e22b1648..faec55f3668 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -1341,13 +1341,22 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt winmm runtimeobject dbghelp)\n"); + // advapi32: RegGetValueW, which cn1_windows_window.cpp calls unconditionally to + // read AppsUseLightTheme for dark mode. It used to be listed only inside the + // optional WebView2 block below, where it backs that loader -- so an + // application built without WEBVIEW2_SDK_DIR, which is every ordinary one, + // failed at the LINK step on a symbol our own port references. Our CI never + // saw it because the cross-compile leg sets WEBVIEW2_SDK_DIR and got the + // library by accident, which is the same way the iOS port once lost + // UniformTypeIdentifiers. + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt winmm runtimeobject dbghelp advapi32)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static // loader (arch-specific) and define CN1_HAVE_WEBVIEW2; otherwise the // browser natives compile as stubs and the port reports the browser - // as unsupported. version/shell32/advapi32/shlwapi back the loader. + // as unsupported. version/shell32/advapi32/shlwapi back the loader; advapi32 + // is in the unconditional list above as well, because the port itself needs it. writer.append(" if(DEFINED ENV{WEBVIEW2_SDK_DIR} AND EXISTS \"$ENV{WEBVIEW2_SDK_DIR}/include/WebView2.h\")\n"); writer.append(" target_include_directories(${PROJECT_NAME} PRIVATE \"$ENV{WEBVIEW2_SDK_DIR}/include\")\n"); writer.append(" target_compile_definitions(${PROJECT_NAME} PRIVATE CN1_HAVE_WEBVIEW2=1)\n"); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java index 0ff230dabc6..28f28a4eff8 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java @@ -28,6 +28,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.extension.TestWatcher; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -37,11 +40,12 @@ import java.net.InetSocketAddress; import java.net.ServerSocket; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.SSLSocket; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -94,6 +98,75 @@ class BackendHttpIntegrationTest { private static Path work; private static String skipReason; + /** + * On a failure, say what each server process said and whether it is still + * alive. + * + * Every server here already redirects its combined output to a file and + * nothing ever read one back, so an intermittent failure arrived as + * "expected: <200> but was: <-1>" and nothing else. That does not + * distinguish the three answers that matter -- the server refused the + * request, the server never saw it, or the server is gone -- and without + * the distinction there is nothing to debug from. Two CI runs were lost to + * exactly that: an upload answered with an empty reply in 20ms, twice, with + * no way to tell whether a connection was dropped or a process had died. + * + * A watcher rather than a message on each assertion, because the next + * occurrence will not be in a test that was thought to need one. + */ + @RegisterExtension + static final TestWatcher SERVER_DIAGNOSTICS = new TestWatcher() { + @Override + public void testFailed(ExtensionContext context, Throwable cause) { + dumpServerDiagnostics(context.getDisplayName()); + } + }; + + private static void dumpServerDiagnostics(String test) { + if (work == null) { + return; + } + System.err.println("---- backend servers after the failure of: " + test + " ----"); + dumpServer("main", server, work.resolve("server.log")); + dumpServer("tls", tlsServer, work.resolve("tls-server.log")); + dumpServer("busy", busyServer, work.resolve("busy-server.log")); + dumpServer("small-upload", smallUploadServer, smallUploadLog); + } + + /** The last few lines are the useful part; a healthy server logs once at startup. */ + private static void dumpServer(String name, Process process, Path log) { + String state; + if (process == null) { + state = "never started"; + } else if (process.isAlive()) { + state = "alive"; + } else { + state = "EXITED with " + process.exitValue(); + } + System.err.println("[" + name + "] " + state); + if (log == null || !Files.exists(log)) { + System.err.println("[" + name + "] no log file"); + return; + } + try { + // Decoded leniently and split by hand: this is a native process's + // combined output, so a partial write can leave bytes that are not + // valid UTF-8, and a diagnostic that throws while reporting a failure + // replaces the failure it was meant to explain. + String text = new String(Files.readAllBytes(log), StandardCharsets.UTF_8); + String[] lines = text.split("\n"); + int from = Math.max(0, lines.length - 40); + if (from > 0) { + System.err.println("[" + name + "] ... " + from + " earlier line(s) omitted"); + } + for (int i = from; i < lines.length; i++) { + System.err.println("[" + name + "] " + lines[i]); + } + } catch (IOException err) { + System.err.println("[" + name + "] log could not be read: " + err); + } + } + @BeforeAll void startServer() throws Exception { if (CompilerHelper.isWindows()) { @@ -1700,12 +1773,37 @@ void tlsSlowReaderReceivesTheWholeResponse() throws Exception { private SSLSocket openTls() throws Exception { Assumptions.assumeTrue(tlsServer != null && tlsPort != 0, "no TLS server (openssl unavailable, or it did not start)"); + // PINNED to the certificate startTlsServer just generated, rather than a + // TrustManager whose check methods are empty. + // + // The empty one was here first and it verifies nothing at all -- including + // that the server presented the certificate it was configured with, which + // is the one thing a TLS test is in a position to assert. It is also the + // shape every "disable certificate checking" answer on the internet has, + // so it is worth not leaving a copy of it in this repository to be found + // and pasted somewhere it is not a throwaway localhost socket. CodeQL + // agrees and flags it as a high-severity alert. + // + // Path validation only: these sockets connect to 127.0.0.1 while the + // certificate names localhost, and a raw SSLSocket does no hostname check + // unless one is asked for. Pinning the self-signed certificate as a trust + // anchor is exactly the assertion that fits. + X509Certificate pinned; + InputStream certBytes = Files.newInputStream(work.resolve("cert.pem")); + try { + pinned = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(certBytes); + } finally { + certBytes.close(); + } + KeyStore anchors = KeyStore.getInstance(KeyStore.getDefaultType()); + anchors.load(null, null); + anchors.setCertificateEntry("backend", pinned); + TrustManagerFactory trust = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trust.init(anchors); SSLContext context = SSLContext.getInstance("TLS"); - context.init(null, new TrustManager[]{ new X509TrustManager() { - public void checkClientTrusted(X509Certificate[] chain, String authType) { } - public void checkServerTrusted(X509Certificate[] chain, String authType) { } - public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } - } }, null); + context.init(null, trust.getTrustManagers(), null); SSLSocket socket = (SSLSocket) context.getSocketFactory() .createSocket("127.0.0.1", tlsPort); socket.setSoTimeout(20000);