Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 27 additions & 19 deletions .github/workflows/developer-guide-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ jobs:
docs:
- 'docs/developer-guide/**'
- 'docs/demos/common/src/main/snippets/**'
- 'scripts/developer-guide/migrate-inline-guide-snippets.py'
- 'scripts/developer-guide/validate-guide-snippets.py'
# The whole directory, not two named files. on.pull_request.paths
# already triggers on scripts/developer-guide/**, so a change to any
# other script here started the workflow while leaving this filter
# false -- which skipped the very steps that script governs. A
# change to compare-screenshots.py could have merged without the
# screenshot check ever running it.
- 'scripts/developer-guide/**'
- '.github/workflows/developer-guide-docs.yml'
# Triggering the workflow is not enough on its own: the HTML and PDF
# build and the steps beside it are gated on this filter, so the
Expand Down Expand Up @@ -122,6 +127,7 @@ jobs:
run: |
set -euo pipefail
GENERATED_DIR="$RUNNER_TEMP/pre-advanced-theming-screenshots"
echo "GUIDE_GENERATED_SCREENSHOTS=$GENERATED_DIR" >> "$GITHUB_ENV"
rm -rf "$GENERATED_DIR"
mkdir -p "$GENERATED_DIR"
xvfb-run -a mvn -B -ntp \
Expand All @@ -131,23 +137,25 @@ jobs:
-P guide-screenshot-generator \
-Dguide.screenshot.output="$GENERATED_DIR" \
verify
COUNT="$(find "$GENERATED_DIR" -maxdepth 1 -type f -name '*.png' | wc -l | tr -d ' ')"
if [ "$COUNT" != "24" ]; then
echo "::error::Expected 24 generated pre-Advanced Theming screenshots, found $COUNT"
exit 1
fi
for generated in "$GENERATED_DIR"/*.png; do
name="$(basename "$generated")"
committed="docs/developer-guide/img/$name"
if [ ! -f "$committed" ]; then
echo "::error::Generated screenshot has no committed counterpart: $name"
exit 1
fi
if ! cmp -s "$generated" "$committed"; then
echo "::error::Committed screenshot is stale: $name"
exit 1
fi
done
pip install --user --quiet Pillow
# Byte equality is still the rule; a figure only gets a bounded
# difference if it carries a .tolerance sidecar saying why.
python3 scripts/developer-guide/compare-screenshots.py \
Comment thread
shai-almog marked this conversation as resolved.
--generated "$GENERATED_DIR" \
--committed docs/developer-guide/img \
--expected-count 24

# When the byte compare fails, the message names the file but not what it
# actually rendered, which leaves no way to tell a real regression from an
# environment difference. Publishing what this runner produced makes the
# two distinguishable without adding a debugging round trip to CI.
- name: Upload generated screenshots when they do not match
if: failure() && env.GUIDE_GENERATED_SCREENSHOTS != ''
uses: actions/upload-artifact@v7
with:
name: guide-generated-screenshots
path: ${{ env.GUIDE_GENERATED_SCREENSHOTS }}
if-no-files-found: warn

- name: Verify developer guide images are referenced
if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codenameone.developerguide.screenshots;

import com.codename1.components.SpanLabel;
import com.codenameone.developerguide.snippets.generated.BasicsJava034Snippet;
import com.codename1.ui.Button;
import com.codename1.ui.Component;
import com.codename1.ui.Container;
import com.codename1.ui.Display;
import com.codename1.ui.CN;
import com.codename1.ui.FontImage;
import com.codename1.ui.Font;
import com.codename1.ui.Form;
import com.codename1.ui.Graphics;
import com.codename1.ui.Image;
import com.codename1.ui.Label;
import com.codename1.ui.TextArea;
import com.codename1.ui.TextField;
import com.codename1.ui.layouts.BorderLayout;
import com.codename1.ui.layouts.BoxLayout;
Expand Down Expand Up @@ -40,10 +64,33 @@ public final class PreAdvancedThemingScreenshots {
private static final int BLUE = 0x0b57d0;
private static final int GREEN = 0x06a806;
private static final int WHITE = 0xffffff;
private static final Font TITLE_FONT = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE)
.derive(35, Font.STYLE_PLAIN);
private static final Font BLOCK_FONT = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE)
.derive(24, Font.STYLE_PLAIN);
private static final Font TITLE_FONT = screenshotFont(35);
private static final Font BLOCK_FONT = screenshotFont(24);
/// Sized to match the height the theme's own default font had (13px), so
/// pinning the face on components the block styling does not touch keeps
/// their layout exactly where it was.
private static final Font FIELD_FONT = screenshotFont(11);

/// Loads a figure font from the port's bundled Roboto rather than from the host.
///
/// `Font.createSystemFont` resolves through `JavaSEPort.fontFaceSystem`, which is
/// "Arial" on macOS and Linux alike. Arial exists on a developer's Mac and not on a
/// stock CI runner, so AWT silently substitutes a different face and every glyph in
/// every figure changes -- which is why none of these screenshots could be
/// regenerated outside CI and byte-compared against what was committed. The
/// `native:` scheme reads `/com/codename1/impl/javase/Roboto-*.ttf` off the
/// classpath, so the result does not depend on what the machine happens to have
/// installed. It is also what this project's font rule requires everywhere.
private static Font screenshotFont(int pixelSize) {
Font font = Font.createTrueTypeFont("native:MainRegular", "native:MainRegular");
Comment thread
shai-almog marked this conversation as resolved.
if (font == null) {
// Falling back to a host font would quietly restore the very
// non-determinism this exists to remove, so refuse instead.
throw new IllegalStateException(
"the native font scheme is unavailable, so figures would render with a host font");
}
return font.derive(pixelSize, Font.STYLE_PLAIN);
}

private PreAdvancedThemingScreenshots() {
}
Expand All @@ -53,6 +100,32 @@ public interface ScreenshotSink {
}

public static void generate(ScreenshotSink sink) throws IOException {
// JavaSEPort.loadTrueTypeFont has an earlier branch for native: fonts:
// when isIOS is set -- which loadSkinFile does for any skin whose
// systemFontFamily contains "helvetica" -- it resolves to the first
// INSTALLED SF or Helvetica family and never reaches the bundled Roboto.
// This generator never loads a skin, so that branch is not taken, and the
// measurement agrees: figures rendered on a Mac match the Linux runner
// byte for byte, which could not happen if one side were using Helvetica
// Neue. Guard it anyway, because a future change that loads a skin here
// would put the host's fonts back into the output without any other
// symptom.
String platform = Display.getInstance().getPlatformName();
if ("ios".equals(platform)) {
throw new IllegalStateException(
"an iOS skin is active, so native: fonts would resolve to installed "
+ "system faces instead of the bundled ones and these figures would "
+ "stop being reproducible off this machine");
}

// MigLayout picks its default gaps from PlatformDefaults, which reads
// System.getProperty("os.name") and returns MAC_OSX, GNOME or WINDOWS_XP.
// The gaps differ per platform, so mig-layout.png came out with macOS
// spacing on a Mac and GNOME spacing on the Linux runner -- a 12.8% pixel
// difference that has nothing to do with fonts. Pin it so the figure shows
// the same thing wherever it is generated.
com.codename1.ui.layouts.mig.PlatformDefaults.setPlatform(
com.codename1.ui.layouts.mig.PlatformDefaults.GNOME);
write(sink, "flow-layout.png", flow("Flow Layout", Component.LEFT, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT);
write(sink, "flow-layout-center.png", flow("Flow Layout", Component.CENTER, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT);
write(sink, "flow-layout-right.png", flow("Flow Layout", Component.RIGHT, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT);
Expand Down Expand Up @@ -328,6 +401,22 @@ private static void applyScreenshotStyle(Form form) {
private static void applyBlockStyleToContent(Container container) {
for (int i = 0; i < container.getComponentCount(); i++) {
Component component = container.getComponentAt(i);
// Pin the font on EVERY component rather than on the types that also
// get block colours. Anything left on the theme's default font
// resolves through the host, and that is what made
// guibuilder-2-insets-3.png -- the only figure containing a
// TextField -- differ between a Mac and CI while the other 23
// matched byte for byte. Enumerating the types that carry text
// would leave the next one to be added broken in the same way.
component.getAllStyles().setFont(FIELD_FONT);
if (component instanceof TextArea) {
// The hint is painted by a Label that is not in the component
// tree, so the walk above never reaches it.
Label hint = ((TextArea) component).getHintLabel();
if (hint != null) {
hint.getAllStyles().setFont(FIELD_FONT);
}
}
if (component instanceof Label || component instanceof Button) {
styleBlock(component);
}
Expand Down
Binary file modified docs/developer-guide/img/border-layout-RTL.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/border-layout-center.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/border-layout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/box-layout-x-no-grow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/box-layout-x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/box-layout-y.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/flow-layout-center-middle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/flow-layout-center.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/flow-layout-right.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/flow-layout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/grid-layout-2x2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/grid-layout-2x4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/grid-layout-autofit-landscape.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/grid-layout-autofit-portrait.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/gridbag-layout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/group-layout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/guibuilder-2-insets-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/guibuilder-2-insets-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/guibuilder-2-insets-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/layered-layout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions docs/developer-guide/img/layered-layout.tolerance
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# This figure contains a FontImage material glyph. Measured against the CI
# runner's own output, the glyph lands at exactly the same size and position --
# a 55x49 bounding box at the same origin -- and differs only in antialiased
# edge coverage: 946 fully-white pixels against 916, 173 differing pixels in
# total, 0.113% of the image. Java2D rasterizes the same glyph from the same
# bundled font at the same size slightly differently on macOS and Linux.
#
# The two bounds are independent and both bind. maxMismatchPercent limits how
# much of the image may change AT ALL -- every differing pixel counts, not only
# those past the delta -- so a wholesale recolour cannot slip through by moving
# each channel a little at a time. maxChannelDelta then caps how far any single
# pixel may move: 160 clears the measured worst of 141 with room, while a solid
# overwrite still fails on it.
maxChannelDelta=160
Comment thread
shai-almog marked this conversation as resolved.
maxMismatchPercent=0.3
Binary file modified docs/developer-guide/img/mig-layout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/table-layout-2x2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/table-layout-constraints.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/developer-guide/img/table-layout-enclose.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
138 changes: 138 additions & 0 deletions scripts/developer-guide/compare-screenshots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Compare generated guide figures against the committed ones.

Byte equality is the default and is what nearly every figure achieves: with the
font loaded from the port's bundled Roboto and MigLayout's platform pinned, 23 of
the 24 render identically on macOS and on the Linux runner.

The exception is a figure containing a `FontImage` material glyph. Measured
against the runner's own output, the glyph lands at exactly the same size and
position -- a 55x49 bounding box at the same origin -- and differs only in
antialiased edge coverage, 946 fully-white pixels against 916. That is Java2D
rasterizing the same glyph from the same font at the same size slightly
differently on the two platforms, and no amount of pinning on our side changes
it. Demanding byte equality there would mean either deleting legitimate content
from the figure or carrying a permanently red check.

So a figure may carry a `<name>.tolerance` sidecar, in the same key=value shape
the CN1SS screenshot suites already use, and only then is a bounded difference
accepted. Everything without a sidecar must still match exactly.

The two bounds are applied independently, which is where this differs from the
CN1SS comparator: `maxMismatchPercent` limits how much of the image may change at
all, and `maxChannelDelta` caps how far any single pixel may move. Counting only
the pixels that exceed the delta would let an unlimited number of sub-threshold
changes through.
"""
from __future__ import annotations

import argparse
import sys
from pathlib import Path

try:
from PIL import Image
except ImportError: # pragma: no cover - the workflow installs Pillow
print("Pillow is required to compare screenshots", file=sys.stderr)
raise


def read_tolerance(path: Path) -> tuple[int, float] | None:
if not path.exists():
return None
max_delta, max_percent = 0, 0.0
for line in path.read_text(encoding="utf-8").split("\n"):
line = line.split("#", 1)[0].strip()
if not line or "=" not in line:
continue
key, _, value = line.partition("=")
if key.strip() == "maxChannelDelta":
max_delta = int(value)
elif key.strip() == "maxMismatchPercent":
max_percent = float(value)
return max_delta, max_percent


def compare(generated: Path, committed: Path, tolerance: tuple[int, float] | None) -> str | None:
"""Return None when the pair is acceptable, else a description of the failure."""
if generated.read_bytes() == committed.read_bytes():
return None
if tolerance is None:
return "differs and has no tolerance sidecar"
max_delta, max_percent = tolerance
# RGBA, not RGB: these figures are saved with an alpha channel, and dropping
# it would make a change that touches only transparency invisible here --
# including one that turned the whole figure see-through while leaving every
# colour channel intact.
a = Image.open(generated).convert("RGBA")
b = Image.open(committed).convert("RGBA")
if a.size != b.size:
return f"size changed: generated {a.size[0]}x{a.size[1]}, committed {b.size[0]}x{b.size[1]}"
pa, pb = a.load(), b.load()
width, height = a.size
changed = 0
worst = 0
for y in range(height):
for x in range(width):
first, second = pa[x, y], pb[x, y]
if first == second:
continue
# Every changed pixel counts toward the area budget, and the channel
# delta is a separate ceiling. Counting only the pixels that EXCEED
# the delta -- which is what the CN1SS comparator does -- leaves an
# unbounded hole: with maxChannelDelta=160, recolouring these figures'
# green #06a806 to #a608a6 moves every channel by exactly 160, so not
# one pixel would be counted and a completely different image would
# pass.
changed += 1
worst = max(worst, max(abs(first[i] - second[i]) for i in range(4)))
percent = 100.0 * changed / (width * height)
if percent > max_percent:
return f"{percent:.3f}% of pixels changed (allowed {max_percent}%)"
if worst > max_delta:
return f"worst channel delta {worst} exceeds maxChannelDelta={max_delta}"
return None


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--generated", required=True, type=Path)
parser.add_argument("--committed", required=True, type=Path)
parser.add_argument("--expected-count", type=int, required=True)
args = parser.parse_args()

produced = sorted(args.generated.glob("*.png"))
if len(produced) != args.expected_count:
print(
f"::error::Expected {args.expected_count} generated figures, found {len(produced)}",
file=sys.stderr,
)
return 1

failures = 0
tolerated = 0
for image in produced:
committed = args.committed / image.name
if not committed.exists():
print(f"::error::Generated figure has no committed counterpart: {image.name}", file=sys.stderr)
failures += 1
continue
sidecar = args.committed / (image.stem + ".tolerance")
tolerance = read_tolerance(sidecar)
problem = compare(image, committed, tolerance)
if problem:
print(f"::error::{image.name}: {problem}", file=sys.stderr)
failures += 1
elif tolerance is not None and image.read_bytes() != committed.read_bytes():
tolerated += 1
print(f"{image.name}: within its tolerance sidecar")

if failures:
print(f"\n{failures} figure(s) do not match.", file=sys.stderr)
return 1
print(f"All {len(produced)} figures match ({tolerated} within a tolerance sidecar).")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading