From 73eedc255f4da032065dd741bbdd48308d863a81 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:59:40 +0200 Subject: [PATCH 01/27] javafx-compat: list property content events, bound cell graphic, ObservableValue.map, Region.insetsProperty, ImageView fit CSS Co-Authored-By: Claude Opus 5.5 --- .../fxcompat/runtime/DerivedValue.java | 145 ++++++++++++++ .../fxcompat/runtime/ValueListeners.java | 21 +++ .../fxcompat/runtime/css/CssProperties.java | 4 +- .../beans/property/ListPropertyBase.java | 4 +- .../javafx/beans/value/ObservableValue.java | 21 +++ .../main/java/javafx/scene/control/Cell.java | 7 +- .../java/javafx/scene/image/ImageView.java | 38 ++++ .../main/java/javafx/scene/layout/Region.java | 22 +++ .../com/codename1/fxcompat/PortGapsTest.java | 178 ++++++++++++++++++ 9 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/PortGapsTest.java diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java new file mode 100644 index 00000000000..919cee0d549 --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java @@ -0,0 +1,145 @@ +/* + * 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.fxcompat.runtime; + +import java.util.function.Function; + +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +/// The value behind `ObservableValue.map`, `flatMap` and `orElse`: computed +/// from its source when asked, and invalid whenever the source (or, for +/// `flatMap`, the value the source maps to) is. +public final class DerivedValue implements ObservableValue { + + private static final int MAP = 0; + private static final int FLAT = 1; + private static final int OR_ELSE = 2; + + private final ObservableValue source; + private final Function function; + private final U other; + private final int kind; + private ValueListeners helper; + private ObservableValue inner; + private final InvalidationListener relay = new InvalidationListener() { + @Override + public void invalidated(Observable observable) { + sourceChanged(); + } + }; + + private DerivedValue(ObservableValue source, Function function, U other, int kind) { + if (source == null || (kind != OR_ELSE && function == null)) { + throw new NullPointerException(); + } + this.source = source; + this.function = function; + this.other = other; + this.kind = kind; + source.addListener(relay); + if (kind == FLAT) { + follow(); + } + } + + /// The value `function` maps the source's to, null for null. + public static ObservableValue map(ObservableValue source, Function function) { + return new DerivedValue(source, function, null, MAP); + } + + /// The value of the observable value `function` maps the source's to. + public static ObservableValue flatMap(ObservableValue source, + Function> function) { + return new DerivedValue(source, function, null, FLAT); + } + + /// The source's value, or `other` while it is null. + public static ObservableValue orElse(ObservableValue source, S other) { + return new DerivedValue(source, null, other, OR_ELSE); + } + + private void follow() { + ObservableValue next = null; + S v = source.getValue(); + if (v != null) { + Object o = function.apply(v); + if (o instanceof ObservableValue) { + next = (ObservableValue) o; + } + } + if (next != inner) { + if (inner != null) { + inner.removeListener(relay); + } + inner = next; + if (inner != null) { + inner.addListener(relay); + } + } + } + + private void sourceChanged() { + if (kind == FLAT) { + follow(); + } + ValueListeners.fire(helper); + } + + @Override + @SuppressWarnings("unchecked") + public U getValue() { + S v = source.getValue(); + switch (kind) { + case OR_ELSE: + return v == null ? other : (U) v; + case FLAT: + follow(); + return inner == null ? null : (U) inner.getValue(); + default: + return v == null ? null : (U) function.apply(v); + } + } + + @Override + public void addListener(InvalidationListener listener) { + helper = ValueListeners.add(helper, this, listener); + } + + @Override + public void removeListener(InvalidationListener listener) { + helper = ValueListeners.remove(helper, listener); + } + + @Override + public void addListener(ChangeListener listener) { + helper = ValueListeners.add(helper, this, listener); + } + + @Override + public void removeListener(ChangeListener listener) { + helper = ValueListeners.remove(helper, listener); + } +} diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ValueListeners.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ValueListeners.java index 892c1ac16a3..ab1554dd904 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ValueListeners.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ValueListeners.java @@ -118,4 +118,25 @@ public static void fire(ValueListeners helper) { } } } + + /// Tells every listener the content of the held value changed in place: + /// the change listeners are handed the same object as old and new value, + /// as JavaFX does for a list property whose list was modified. + public static void fireContentChanged(ValueListeners helper) { + if (helper == null) { + return; + } + ListenerSet> set = helper.set; + ListenerSet.fireInvalidation(set, helper.observable); + List> listeners = ListenerSet.changeListeners(set); + if (!listeners.isEmpty()) { + T value = helper.observable.getValue(); + T oldValue = helper.currentValue; + helper.currentValue = value; + T before = oldValue == value ? value : oldValue; + for (int i = 0; i < listeners.size(); i++) { + listeners.get(i).changed(helper.observable, before, value); + } + } + } } diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/css/CssProperties.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/css/CssProperties.java index 2991b72c596..be22d878135 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/css/CssProperties.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/css/CssProperties.java @@ -86,7 +86,7 @@ public final class CssProperties { static { add(PAINT, "-fx-background-color", "-fx-border-color", "-fx-text-fill", "-fx-prompt-text-fill", "-fx-fill", "-fx-stroke"); - add(LENGTH, "-fx-min-width", "-fx-pref-width", "-fx-max-width", "-fx-min-height", "-fx-pref-height", + add(LENGTH, "-fx-fit-width", "-fx-fit-height", "-fx-min-width", "-fx-pref-width", "-fx-max-width", "-fx-min-height", "-fx-pref-height", "-fx-max-height", "-fx-spacing", "-fx-hgap", "-fx-vgap", "-fx-graphic-text-gap", "-fx-stroke-width", "-fx-stroke-dash-offset", "-fx-line-spacing", "-fx-translate-x", "-fx-translate-y", "-fx-size"); add(NUMBER, "-fx-opacity", "-fx-rotate", "-fx-scale-x", "-fx-scale-y", "-fx-stroke-miter-limit"); @@ -94,7 +94,7 @@ public final class CssProperties { add(RADII, "-fx-background-radius", "-fx-border-radius"); add(BOOLEAN, "-fx-managed", "-fx-snap-to-pixel", "-fx-fill-height", "-fx-fill-width", "-fx-wrap-text", "-fx-underline", "-fx-strikethrough", "-fx-fit-to-width", "-fx-fit-to-height", "-fx-pannable", - "-fx-smooth", "-fx-scale-shape", "-fx-position-shape"); + "-fx-smooth", "-fx-scale-shape", "-fx-position-shape", "-fx-preserve-ratio"); add(TEXT, "-fx-shape"); add(RAW, "-fx-background-image", "-fx-background-repeat", "-fx-background-position", "-fx-background-size", "-fx-border-image-source", "-fx-border-image-slice", diff --git a/maven/javafx-compat/src/main/java/javafx/beans/property/ListPropertyBase.java b/maven/javafx-compat/src/main/java/javafx/beans/property/ListPropertyBase.java index 8603b9dac47..de7997018e3 100644 --- a/maven/javafx-compat/src/main/java/javafx/beans/property/ListPropertyBase.java +++ b/maven/javafx-compat/src/main/java/javafx/beans/property/ListPropertyBase.java @@ -152,7 +152,9 @@ protected void invalidated() { private void contentChanged(ListChangeListener.Change change) { fireDerived(); invalidated(); - fireValueChangedEvent(); + // JavaFX tells change listeners about a modification of the held + // list too, with the list as both old and new value. + ValueListeners.fireContentChanged(helper); fireValueChangedEvent(new Changes.Relayed(this, change)); } diff --git a/maven/javafx-compat/src/main/java/javafx/beans/value/ObservableValue.java b/maven/javafx-compat/src/main/java/javafx/beans/value/ObservableValue.java index 84e38803318..db56ac1d145 100644 --- a/maven/javafx-compat/src/main/java/javafx/beans/value/ObservableValue.java +++ b/maven/javafx-compat/src/main/java/javafx/beans/value/ObservableValue.java @@ -22,6 +22,10 @@ */ package javafx.beans.value; +import java.util.function.Function; + +import com.codename1.fxcompat.runtime.DerivedValue; + import javafx.beans.Observable; /// A value that can be read and observed. @@ -39,4 +43,21 @@ public interface ObservableValue extends Observable { /// Returns the current value. T getValue(); + + /// Returns a value holding what `mapper` makes of this one, computed + /// when asked; a null value maps to null without calling `mapper`. + default ObservableValue map(Function mapper) { + return DerivedValue.map(this, mapper); + } + + /// Returns a value holding the value of the observable value `mapper` + /// makes of this one; null while this value or that one is null. + default ObservableValue flatMap(Function> mapper) { + return DerivedValue.flatMap(this, mapper); + } + + /// Returns a value holding this one, or `constant` while it is null. + default ObservableValue orElse(T constant) { + return DerivedValue.orElse(this, constant); + } } diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java index 2b37c3ca8b5..bd860bc5d72 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java @@ -204,7 +204,12 @@ protected void layoutChildren() { Node g = getGraphic(); if (g != null) { boolean show = shownGraphic() != null; - g.setVisible(show); + // A graphic whose visibility the application bound must not be + // set (JavaFX's skin never touches it); it already says itself + // whether it shows. + if (!g.visibleProperty().isBound()) { + g.setVisible(show); + } if (show) { Insets in = getInsets(); double w = g.prefWidth(-1); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/image/ImageView.java b/maven/javafx-compat/src/main/java/javafx/scene/image/ImageView.java index cb519d08f9f..dc8a226016b 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/image/ImageView.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/image/ImageView.java @@ -312,4 +312,42 @@ public void cn1Paint(Renderer renderer) { double[] size = shownSize(); renderer.drawImage(picture, getX(), getY(), size[0], size[1]); } + + // -------------------------------------------------------------- styling + + @Override + protected Object cn1StyleValue(String property) { + if ("-fx-fit-width".equals(property)) { + return Double.valueOf(getFitWidth()); + } else if ("-fx-fit-height".equals(property)) { + return Double.valueOf(getFitHeight()); + } else if ("-fx-preserve-ratio".equals(property)) { + return Boolean.valueOf(isPreserveRatio()); + } + return super.cn1StyleValue(property); + } + + @Override + protected boolean cn1SetStyleValue(String property, Object value) { + boolean w = "-fx-fit-width".equals(property); + if (w || "-fx-fit-height".equals(property)) { + if (!(value instanceof Number)) { + return false; + } + double d = ((Number) value).doubleValue(); + if (w) { + setFitWidth(d); + } else { + setFitHeight(d); + } + return true; + } else if ("-fx-preserve-ratio".equals(property)) { + if (!(value instanceof Boolean)) { + return false; + } + setPreserveRatio(((Boolean) value).booleanValue()); + return true; + } + return super.cn1SetStyleValue(property, value); + } } diff --git a/maven/javafx-compat/src/main/java/javafx/scene/layout/Region.java b/maven/javafx-compat/src/main/java/javafx/scene/layout/Region.java index e1f02d7bcea..91e15b26975 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/layout/Region.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/layout/Region.java @@ -33,11 +33,15 @@ import com.codename1.fxcompat.runtime.StyleList; import com.codename1.fxcompat.runtime.Units; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyDoubleProperty; import javafx.beans.property.ReadOnlyDoubleWrapper; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.geometry.BoundingBox; import javafx.geometry.Bounds; import javafx.geometry.HPos; @@ -130,6 +134,7 @@ public class Region extends Parent { private Object styleEdgeRepeat; private Object styleEdgeInsets; private Insets insetsCache; + private ReadOnlyObjectWrapper insets; /// Creates an empty region. public Region() { @@ -472,6 +477,23 @@ public final ObjectProperty paddingProperty() { return padding; } + /// The space between the edges and the content, as [#getInsets()] + /// reports it; follows the padding and the border. + public final ReadOnlyObjectProperty insetsProperty() { + if (insets == null) { + insets = new ReadOnlyObjectWrapper(this, "insets", getInsets()); + InvalidationListener sync = new InvalidationListener() { + @Override + public void invalidated(Observable o) { + insets.set(getInsets()); + } + }; + padding.addListener(sync); + border.addListener(sync); + } + return insets.getReadOnlyProperty(); + } + /// Returns the space between the edges and the content: the border's /// insets plus the padding. public final Insets getInsets() { diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/PortGapsTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/PortGapsTest.java new file mode 100644 index 00000000000..21f019d10c7 --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/PortGapsTest.java @@ -0,0 +1,178 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.fxcompat.runtime.StyleEngine; +import com.codename1.fxcompat.runtime.Units; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.ListCell; +import javafx.scene.control.ScrollPane; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; + +/// Gaps found porting a real JavaFX application to the layer. +public class PortGapsTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + @Before + public void setUp() { + HeadlessImplementation.install(); + Units.setScale(1); + } + + @After + public void tearDown() { + Units.setScale(0); + StyleEngine.setInstance(null); + } + + /// JavaFX tells the change listeners of a list property about a change + /// of the list's content, with the list as old and new value. + @Test + public void listPropertyListenersHearContentChanges() { + ObservableList list = FXCollections.observableArrayList(); + SimpleListProperty p = new SimpleListProperty(list); + final List seen = new ArrayList(); + final int[] invalid = new int[1]; + p.addListener((javafx.beans.value.ChangeListener>) (o, a, b) -> { + seen.add(a); + seen.add(b); + }); + p.addListener((javafx.beans.InvalidationListener) o -> invalid[0]++); + list.add("x"); + assertEquals(2, seen.size()); + assertSame(list, seen.get(0)); + assertSame(list, seen.get(1)); + assertEquals(1, invalid[0]); + } + + /// A cell's graphic whose visibility the application bound is laid out + /// without setting it. + @Test + public void cellLeavesABoundGraphicVisibilityAlone() { + ListCell cell = new ListCell(); + HBox box = new HBox(); + box.visibleProperty().bind(cell.itemProperty().isNotNull()); + cell.setGraphic(box); + StackPane root = new StackPane(cell); + new Scene(root, 100, 40); + root.layout(); + cell.requestLayout(); + cell.layout(); + assertTrue(box.visibleProperty().isBound()); + } + + @Test + public void mapFlatMapAndOrElse() { + SimpleStringProperty s = new SimpleStringProperty("ab"); + ObservableValue len = s.map(String::length); + final int[] changes = new int[1]; + len.addListener((o, a, b) -> changes[0]++); + assertEquals(Integer.valueOf(2), len.getValue()); + s.set("abc"); + assertEquals(Integer.valueOf(3), len.getValue()); + assertEquals(1, changes[0]); + s.set(null); + assertNull(len.getValue()); + assertEquals(Integer.valueOf(-1), len.orElse(-1).getValue()); + + SimpleStringProperty inner = new SimpleStringProperty("in"); + ObjectProperty outer = new SimpleObjectProperty(inner); + ObservableValue flat = outer.flatMap(x -> x); + final int[] flatChanges = new int[1]; + flat.addListener((o, a, b) -> flatChanges[0]++); + inner.set("changed"); + assertEquals("changed", flat.getValue()); + assertEquals(1, flatChanges[0]); + outer.set(null); + assertNull(flat.getValue()); + } + + @Test + public void insetsPropertyFollowsPadding() { + Region r = new Region(); + assertEquals(Insets.EMPTY, r.insetsProperty().get()); + final int[] changes = new int[1]; + r.insetsProperty().addListener((o, a, b) -> changes[0]++); + r.setPadding(new Insets(3, 4, 5, 6)); + assertEquals(new Insets(3, 4, 5, 6), r.insetsProperty().get()); + assertEquals(1, changes[0]); + } + + /// `-fx-fit-to-width` and the bar policies reach a scroll pane from CSS. + @Test + public void scrollPaneTakesItsPropertiesFromCss() { + Region content = new Region(); + content.setPrefSize(500, 50); + ScrollPane pane = new ScrollPane(content); + pane.setStyle("-fx-fit-to-width: true; -fx-hbar-policy: never; -fx-pannable: true"); + StackPane root = new StackPane(pane); + new Scene(root, 200, 100); + pane.applyCss(); + assertTrue(pane.isFitToWidth()); + assertTrue(pane.isPannable()); + assertEquals(ScrollPane.ScrollBarPolicy.NEVER, pane.getHbarPolicy()); + root.layout(); + assertTrue("content " + content.getWidth(), content.getWidth() <= 200); + } + + @Test + public void imageViewTakesFitSizeFromCss() { + ImageView view = new ImageView(); + view.setStyle("-fx-fit-width: 40; -fx-fit-height: 30; -fx-preserve-ratio: true"); + StackPane root = new StackPane(view); + new Scene(root, 200, 100); + view.applyCss(); + assertEquals(40, view.getFitWidth(), 0); + assertEquals(30, view.getFitHeight(), 0); + assertTrue(view.isPreserveRatio()); + } +} From 9c5f8d843001b358c024cc073f29262ea8de32a7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:02:32 +0200 Subject: [PATCH 02/27] Mapping processor: canonical names for nested types, map inherited properties Nested class/enum field types were emitted as binary names (Outer$Inner), which javac rejects in source. Properties inherited from a non-@Mapped base class were silently dropped by the generated mapper. Co-Authored-By: Claude Opus 5.5 --- .../MappingAnnotationProcessor.java | 97 +++++++++++++- .../MappingAnnotationProcessorTest.java | 120 ++++++++++++++++++ 2 files changed, 214 insertions(+), 3 deletions(-) diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java index e7cfc00b87d..c23947fff0f 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java @@ -178,7 +178,36 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces mc.xmlRootName = deriveXmlRoot(mc.simpleName); } - for (FieldInfo f : cls.getFields()) { + // Properties are the class's own fields plus those it inherits. A + // @Mapped class commonly extends a plain (non-@Mapped, often abstract) + // model base holding id/name/...; reading only the class's own fields + // silently dropped every inherited property in both directions. The + // chain is walked up to java.lang.Object (or the first class not on + // the classpath), base fields first so the JSON order reads naturally, + // and a field hidden by a same-named field lower down is skipped. + // Accessors are looked up from the subclass upward, so an override wins. + List chain = new ArrayList(); + chain.add(cls); + if (!isRecord) { + String sup = cls.getSuperInternalName(); + while (sup != null && !"java/lang/Object".equals(sup) && chain.size() < 64) { + AnnotatedClass sc = ctx.lookupWithDependencies(sup); + if (sc == null) break; + chain.add(sc); + sup = sc.getSuperInternalName(); + } + } + List allFields = new ArrayList(); + java.util.Set seenNames = new java.util.HashSet(); + for (AnnotatedClass c : chain) { + List own = new ArrayList(); + for (FieldInfo f : c.getFields()) { + if (f.isStatic() || !seenNames.add(f.getName())) continue; + own.add(f); + } + allFields.addAll(0, own); + } + for (FieldInfo f : allFields) { if (f.isStatic()) continue; if (f.getName().startsWith("this$")) continue; // inner-class outer ref boolean useAccessor = false; @@ -193,7 +222,7 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces // replaced, only its inner value mutated through .set(...) / // .add(...). PropertyTypeKind probeKind = PropertyTypeKind.of(f); - String getter = findGetter(cls, f, probeKind); + String getter = findGetter(chain, f, probeKind); if (getter == null) { ctx.getLog().debug("cn1: @Mapped skipping private field " + f.getName() + " on " + cls.getBinaryName() @@ -202,7 +231,7 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces } boolean needsSetter = !(probeKind.kind == PropertyTypeKind.Kind.PROPERTY || probeKind.kind == PropertyTypeKind.Kind.LIST_PROPERTY); - String setter = needsSetter ? findSetter(cls, f) : null; + String setter = needsSetter ? findSetter(chain, f) : null; // A @JsonCreator class sets what it needs through the creator, so a // getter alone is enough: the field is written, and read only if the // creator takes it. @@ -441,7 +470,51 @@ private static String fieldType(MappedField f) { } } + /// Generates the mapper source, then rewrites every nested type it names + /// from its binary form (`pkg.Outer$Inner`) to the canonical form + /// (`pkg.Outer.Inner`). The model keeps binary names because class-file + /// lookups need them, and the emitters append them as-is in dozens of + /// places (class literals, casts, generics, `valueOf`, `instanceof`); but + /// javac does not resolve `Outer$Inner` in source, so a field whose type + /// is a nested class or enum produced a mapper that did not compile. + /// Only the exact names this mapper references are rewritten, and only as + /// whole tokens, so the mapper's own `Outer$InnerCn1Mapper` name (a legal + /// identifier) is left alone. private static String generateMapperSource(MappedClass mc) { + String src = generateMapperSourceRaw(mc); + java.util.Set nested = new java.util.TreeSet(); + collectNestedNames(mc, nested); + for (String bin : nested) { + src = src.replaceAll("(? out) { + addNested(mc.binaryName, out); + for (MappedField f : mc.fields) { + if (f.kind != null) { + addNested(f.kind.binaryName, out); + addNested(f.kind.elementBinaryName, out); + } + addNested(f.adapter, out); + addNested(f.adapterCast, out); + addNested(f.elemAdapter, out); + } + for (MappedClass sp : mc.specs) { + collectNestedNames(sp, out); + } + } + + private static void addNested(String bin, java.util.Set out) { + if (bin != null && bin.indexOf('$') > 0) { + out.add(bin); + } + } + + private static String generateMapperSourceRaw(MappedClass mc) { StringBuilder sb = new StringBuilder(2048); if (mc.packageName.length() > 0) { sb.append("package ").append(mc.packageName).append(";\n\n"); @@ -1439,6 +1512,24 @@ private static boolean hasPublicNoArgConstructor(AnnotatedClass cls) { /// `active`), then the literal-prefix variant (`getURL` when the /// field is `URL`). Returns the method name or null when no accessor /// matches. + /// [#findGetter] over a class and its superclasses, nearest first. + private static String findGetter(List chain, FieldInfo f, PropertyTypeKind kind) { + for (AnnotatedClass c : chain) { + String g = findGetter(c, f, kind); + if (g != null) return g; + } + return null; + } + + /// [#findSetter] over a class and its superclasses, nearest first. + private static String findSetter(List chain, FieldInfo f) { + for (AnnotatedClass c : chain) { + String s = findSetter(c, f); + if (s != null) return s; + } + return null; + } + private static String findGetter(AnnotatedClass cls, FieldInfo f, PropertyTypeKind kind) { String fieldName = f.getName(); String fieldDesc = f.getDescriptor(); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java index 8c9608c45b9..e0c9aee39df 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java @@ -112,6 +112,126 @@ public void pojoRoundTripsThroughGeneratedMapper() throws Exception { } } + /// A field typed as a nested class, a list of one, or a nested enum: the + /// generated mapper must name them canonically (`Download.DownloadFile`), + /// since javac does not resolve the binary `Download$DownloadFile` in source. + @Test + public void nestedClassAndEnumFieldsCompileAndRoundTrip() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.Download", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "import java.util.List;\n" + + "@Mapped public class Download {\n" + + " public DownloadFile main;\n" + + " public List files;\n" + + " public DownloadType type;\n" + + " public List types;\n" + + " public Download() {}\n" + + " @Mapped public static class DownloadFile {\n" + + " public String name;\n" + + " public DownloadFile() {}\n" + + " }\n" + + " public enum DownloadType { FULL, DELTA }\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + runProcessorOrFail(classes); + + try (URLClassLoader cl = childLoader(classes)) { + Class dlCls = cl.loadClass("com.example.Download"); + Class fileCls = cl.loadClass("com.example.Download$DownloadFile"); + Class typeCls = cl.loadClass("com.example.Download$DownloadType"); + Class mapperCls = cl.loadClass("com.example.DownloadCn1Mapper"); + Object mapper = mapperCls.newInstance(); + + Map f1 = new LinkedHashMap(); + f1.put("name", "a.bin"); + Map f2 = new LinkedHashMap(); + f2.put("name", "b.bin"); + Map in = new LinkedHashMap(); + in.put("main", f1); + in.put("files", Arrays.asList(f1, f2)); + in.put("type", "DELTA"); + in.put("types", Arrays.asList("FULL")); + // Nested mappers resolve through the registry, so install them. + Class.forName("com.example.Download$DownloadFileCn1Mapper", true, cl) + .getMethod("register").invoke(null); + mapperCls.getMethod("register").invoke(null); + Object d = mapperCls.getMethod("fromMap", Map.class).invoke(mapper, in); + Object main = dlCls.getField("main").get(d); + assertEquals("a.bin", fileCls.getField("name").get(main)); + List files = (List) dlCls.getField("files").get(d); + assertEquals(2, files.size()); + assertEquals("b.bin", fileCls.getField("name").get(files.get(1))); + Object delta = typeCls.getMethod("valueOf", String.class).invoke(null, "DELTA"); + assertEquals(delta, dlCls.getField("type").get(d)); + + @SuppressWarnings("unchecked") + Map out = (Map) mapperCls + .getMethod("toMap", dlCls).invoke(mapper, d); + assertEquals("DELTA", out.get("type")); + assertEquals(Arrays.asList("FULL"), out.get("types")); + } + } + + /// Properties inherited from an abstract, non-@Mapped base class are + /// mapped too, both ways, alongside the subclass's own. + @Test + public void inheritedPropertiesFromPlainBaseRoundTrip() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.ModelObject", + "package com.example;\n" + + "import java.util.List;\n" + + "public abstract class ModelObject {\n" + + " private String id;\n" + + " private boolean active;\n" + + " private List personIds;\n" + + " public String getId() { return id; }\n" + + " public void setId(String v) { id = v; }\n" + + " public boolean isActive() { return active; }\n" + + " public void setActive(boolean v) { active = v; }\n" + + " public List getPersonIds() { return personIds; }\n" + + " public void setPersonIds(List v) { personIds = v; }\n" + + "}\n"); + sources.put("com.example.RealWorldApp", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public class RealWorldApp extends ModelObject {\n" + + " private String url;\n" + + " public RealWorldApp() {}\n" + + " public String getUrl() { return url; }\n" + + " public void setUrl(String v) { url = v; }\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + runProcessorOrFail(classes); + + try (URLClassLoader cl = childLoader(classes)) { + Class appCls = cl.loadClass("com.example.RealWorldApp"); + Class mapperCls = cl.loadClass("com.example.RealWorldAppCn1Mapper"); + Object mapper = mapperCls.newInstance(); + Map in = new LinkedHashMap(); + in.put("id", "a1"); + in.put("active", Boolean.TRUE); + in.put("personIds", Arrays.asList("p1", "p2")); + in.put("url", "https://example.com"); + Object app = mapperCls.getMethod("fromMap", Map.class).invoke(mapper, in); + assertEquals("a1", appCls.getMethod("getId").invoke(app)); + assertEquals(Boolean.TRUE, appCls.getMethod("isActive").invoke(app)); + assertEquals(Arrays.asList("p1", "p2"), appCls.getMethod("getPersonIds").invoke(app)); + assertEquals("https://example.com", appCls.getMethod("getUrl").invoke(app)); + + @SuppressWarnings("unchecked") + Map out = (Map) mapperCls + .getMethod("toMap", appCls).invoke(mapper, app); + assertEquals("a1", out.get("id")); + assertEquals(Boolean.TRUE, out.get("active")); + assertEquals(Arrays.asList("p1", "p2"), out.get("personIds")); + assertEquals("https://example.com", out.get("url")); + } + } + @Test public void enumFieldsRoundTripThroughJson() throws Exception { File classes = tmp.newFolder("classes"); From d278c1a8d071648d05d00ad2b6b91077fd642903 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:02:32 +0200 Subject: [PATCH 03/27] Default network error path no longer blocks the EDT with a modal prompt An HTTP error code is not a crash, so Lifecycle no longer sends the log for it; the simulator's built_by_user prompt is skipped on the EDT or headless. Co-Authored-By: Claude Opus 5.5 --- CodenameOne/src/com/codename1/system/Lifecycle.java | 7 ++++++- Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/system/Lifecycle.java b/CodenameOne/src/com/codename1/system/Lifecycle.java index 8b29c39b509..64be7d7da77 100644 --- a/CodenameOne/src/com/codename1/system/Lifecycle.java +++ b/CodenameOne/src/com/codename1/system/Lifecycle.java @@ -108,10 +108,15 @@ public Resources getTheme() { protected void handleNetworkError(NetworkEvent err) { // prevent the event from propagating err.consume(); + // Only a thrown exception is an application failure worth a crash log. + // An HTTP error code (404, 500...) is the server's answer, not a bug in + // the app, so it must not send the log: sending registers the device, + // which in the simulator asks for the build account with a modal + // prompt and used to block the EDT on a plain 404. if (err.getError() != null) { Log.e(err.getError()); + Log.sendLogAsync(); } - Log.sendLogAsync(); Dialog.show("Connection Error", "There was a networking error in the connection to " + err.getConnectionRequest().getUrl(), "OK", null); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 19ad187b7e8..8c0f7eb05ad 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -16724,6 +16724,13 @@ public String getProperty(String key, String defaultValue) { String user = p.get("user", null); Display d = Display.getInstance(); if (user == null) { + // The prompt is modal. Asked for from the Codename One EDT (log + // sending after a network error reaches here) it froze the + // application until answered, and headless nobody can answer + // it. Answer the default instead, as an unregistered user does. + if (d.isEdt() || java.awt.GraphicsEnvironment.isHeadless()) { + return defaultValue; + } JPanel pnl = new JPanel(); JTextField tf = new JTextField(20); pnl.add(new JLabel("E-Mail For Push")); From 11cd60d7425482bbdede252c4b6535e27c8247d7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:17:56 +0200 Subject: [PATCH 04/27] Storage entry size no longer spins forever on a stream that ends The default getStorageEntrySize looped until skip() answered -1, but skip() answers 0 at the end of a stream, so an implementation without its own override hung its background thread on the first cached image. Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index b0534c53be2..55c69316608 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -8074,13 +8074,15 @@ public int getStorageEntrySize(String name) { InputStream i = null; //NOPMD CloseResource try { i = createStorageInputStream(name); - long val = i.skip(1000000); - if (val > -1) { - size = 0; - while (val > -1) { - size += val; - val = i.skip(1000000); - } + // Counted with read(), never skip(): skip() answers 0 at the end + // of a stream, not -1, so a loop waiting for -1 from it never + // ended on a stream that honours that contract. + byte[] buffer = new byte[8192]; + size = 0; + int val = i.read(buffer); + while (val > -1) { + size += val; + val = i.read(buffer); } } catch (IOException err) { Log.e(err); From 568a1cdab48463cb45e506fb08599d84f10e368d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:17:56 +0200 Subject: [PATCH 05/27] javafx-compat: load http and https images in the background, cached in Storage Image downloads a remote URL through ConnectionRequest.downloadImageToStorage, the cache URLImage uses, and tells the views showing it when it arrives. Co-Authored-By: Claude Opus 5.5 --- .../main/java/javafx/scene/image/Image.java | 105 ++++++++++++++-- .../java/javafx/scene/image/ImageView.java | 6 + .../codename1/fxcompat/RemoteImageTest.java | 114 ++++++++++++++++++ .../javafx/scene/image/ImageTestAccess.java | 35 ++++++ 4 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java create mode 100644 maven/javafx-compat/src/test/java/javafx/scene/image/ImageTestAccess.java diff --git a/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java b/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java index 25da7282b5a..fba568fa34b 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java @@ -49,16 +49,20 @@ /// `getResource(...).toExternalForm()` all work. The whole path is /// tried first and then the file name alone, because a Codename One /// application keeps its resources in one flat directory on a device. -/// - `http:` and `https:` URLs are **not loaded**; the image reports an -/// error. +/// - `http:` and `https:` URLs are downloaded in the background through +/// Codename One's network manager and cached in `Storage`, as +/// `URLImage` caches, so a later run reads the picture from the device. /// -/// #### Loading is synchronous +/// #### When the picture is there /// -/// The constructor returns with the image decoded or failed, also when -/// `backgroundLoading` is asked for; the flag is recorded and -/// `getProgress()` is 1 for a loaded image from the start. A failure -/// never throws: `isError()` answers `true`, `getException()` says why -/// and the size is zero. +/// A local image is decoded before the constructor returns, also when +/// `backgroundLoading` is asked for; `getProgress()` is 1 for it from the +/// start. A remote image always loads in the background, whatever the +/// flag says, because a device cannot block its UI thread on the +/// network: it starts with progress 0 and size zero, and on the EDT its +/// size, progress and the view showing it change when the picture +/// arrives. A failure never throws: `isError()` answers `true`, +/// `getException()` says why and the size is zero. /// /// The size is in pixels of the picture, and one of them is drawn as one /// logical pixel. A requested size scales the decoded picture once, here. @@ -119,6 +123,15 @@ public Image(@NamedArg("url") String url, @NamedArg("requestedWidth") double req this.preserveRatio = preserveRatio; this.smooth = smooth; this.backgroundLoading = backgroundLoading; + if (startsWith(url, "http:") || startsWith(url, "https:")) { + try { + download(url); + } catch (RuntimeException failed) { + // As for a local URL: the image is in error, never the caller. + fail(failed); + } + return; + } InputStream in = null; try { in = open(url); @@ -193,13 +206,78 @@ int[] pixels() { return argb; } - /// Has a view told when the pixels change; an image that was decoded - /// never changes. + /// Has a view told when the pixels change: a decoded image never + /// changes, a remote one changes once, when it arrives. void addPixelListener(Runnable listener) { + if (listeners == null) { + listeners = new java.util.ArrayList(); + } + listeners.add(listener); } /// Stops telling a view. void removePixelListener(Runnable listener) { + if (listeners != null) { + listeners.remove(listener); + } + } + + private java.util.ArrayList listeners; + + private void firePixelsChanged() { + if (listeners == null) { + return; + } + Runnable[] all = listeners.toArray(new Runnable[listeners.size()]); + for (Runnable r : all) { + r.run(); + } + } + + /// Fetches a remote picture into `Storage`, or reads it from there when + /// an earlier run fetched it, and applies it on the EDT. + private void download(final String remote) { + com.codename1.io.ConnectionRequest req = new com.codename1.io.ConnectionRequest(remote, false); + // A missing picture is the image's error, never the application's + // global network error dialog. + req.setFailSilently(true); + req.setReadResponseForErrors(false); + req.downloadImageToStorage(storageKey(remote), new com.codename1.util.SuccessCallback() { + @Override + public void onSucess(com.codename1.ui.Image value) { + try { + if (value == null) { + throw new IOException("Not an image: " + remote); + } + apply(value); + } catch (IOException failed) { + fail(failed); + } catch (RuntimeException failed) { + fail(failed); + } + firePixelsChanged(); + } + }, new com.codename1.util.FailureCallback() { + @Override + public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { + fail(new IOException("Could not load " + remote + " (" + errorCode + "): " + + (err != null ? err.toString() : errorMessage))); + firePixelsChanged(); + } + }, true); + } + + /// A `Storage` name for a URL: storage names are flat and short, so the + /// URL's hash and length, then its last characters made safe. + static String storageKey(String remote) { + StringBuilder tail = new StringBuilder(); + int from = Math.max(0, remote.length() - 40); + for (int i = from; i < remote.length(); i++) { + char c = remote.charAt(i); + boolean safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.'; + tail.append(safe ? c : '_'); + } + return "fximg-" + Integer.toHexString(remote.hashCode()) + "-" + remote.length() + "-" + tail; } /// Returns what reads the pixels of the image, or `null` for an @@ -304,6 +382,7 @@ private static InputStream open(String url) throws IOException { private void fail(Exception why) { nativeImage = null; + argb = null; width.set(0); height.set(0); exception.set(why); @@ -317,6 +396,11 @@ private void decode(InputStream in) throws IOException { } catch (RuntimeException undecodable) { throw new IOException("Not an image: " + undecodable); } + apply(decoded); + } + + /// Takes a decoded picture, scaled to the requested size. + private void apply(com.codename1.ui.Image decoded) throws IOException { if (decoded == null || decoded.getWidth() <= 0 || decoded.getHeight() <= 0) { throw new IOException("Not an image"); } @@ -345,6 +429,7 @@ private void decode(InputStream in) throws IOException { decoded = decoded.scaled(pw, ph); } nativeImage = decoded; + argb = null; width.set(pw); height.set(ph); progress.set(1); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/image/ImageView.java b/maven/javafx-compat/src/main/java/javafx/scene/image/ImageView.java index dc8a226016b..8bb4b4cd896 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/image/ImageView.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/image/ImageView.java @@ -109,6 +109,12 @@ private void pixelsChanged() { shown = null; shownValid = false; super.cn1Invalidated(Dirty.PAINT); + // A remote image changes size when it arrives, which changes this + // view's preferred size. + javafx.scene.Parent p = getParent(); + if (p != null) { + p.requestLayout(); + } } /// Returns the image, or `null`. diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java new file mode 100644 index 00000000000..6a4fb504f6a --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java @@ -0,0 +1,114 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.io.Storage; + +import javafx.scene.image.Image; + +/// `http:` and `https:` images load in the background and cache in `Storage`. +public class RemoteImageTest { + + @Rule + public final MainThreadRule main = new MainThreadRule(); + + @Before + public void setUp() { + HeadlessImplementation.install(); + // An encoded image is decoded for real, so the cached PNG has a size. + HeadlessImplementation.pixelImages = true; + } + + @After + public void tearDown() { + HeadlessImplementation.pixelImages = false; + } + + @Test + public void remoteImageNeverBlocksOrThrowsInTheConstructor() { + Image image = new Image("https://example.invalid/never-there.png"); + assertEquals(0, image.getWidth(), 0); + assertEquals(0, image.getProgress(), 0); + assertFalse("not failed before the request ran", image.isError()); + } + + @Test + public void cachedRemoteImageArrivesWithItsSize() throws Exception { + String url = "https://example.invalid/cached-logo.png"; + java.awt.image.BufferedImage picture = new java.awt.image.BufferedImage(7, 5, + java.awt.image.BufferedImage.TYPE_INT_ARGB); + ByteArrayOutputStream png = new ByteArrayOutputStream(); + javax.imageio.ImageIO.write(picture, "png", png); + OutputStream out = Storage.getInstance().createOutputStream(cacheKey(url)); + out.write(png.toByteArray()); + out.close(); + + final Image image = new Image(url); + // The test runs on the EDT, where the image's callback is delivered: + // wait off it, with the EDT still dispatching. + com.codename1.ui.Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + for (int i = 0; i < 500 && image.getProgress() < 1 && !image.isError(); i++) { + try { + Thread.sleep(10); + } catch (InterruptedException stop) { + return; + } + } + } + }); + assertFalse(String.valueOf(image.getException()), image.isError()); + assertEquals(1, image.getProgress(), 0); + assertEquals(7, image.getWidth(), 0); + assertEquals(5, image.getHeight(), 0); + assertNotNull(image.cn1Native()); + } + + @Test + public void storageKeysAreFlatAndDistinct() { + String a = cacheKey("https://example.com/a/b/c.png?x=1"); + String b = cacheKey("https://example.com/a/b/c.png?x=2"); + assertTrue(a, a.indexOf('/') < 0 && a.indexOf('?') < 0 && a.indexOf(':') < 0); + assertFalse(a.equals(b)); + } + + private static String cacheKey(String url) { + return javafx.scene.image.ImageTestAccess.storageKey(url); + } +} diff --git a/maven/javafx-compat/src/test/java/javafx/scene/image/ImageTestAccess.java b/maven/javafx-compat/src/test/java/javafx/scene/image/ImageTestAccess.java new file mode 100644 index 00000000000..e598862e897 --- /dev/null +++ b/maven/javafx-compat/src/test/java/javafx/scene/image/ImageTestAccess.java @@ -0,0 +1,35 @@ +/* + * 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 javafx.scene.image; + +/// Reaches the package private parts of [Image] a test needs. +public final class ImageTestAccess { + + private ImageTestAccess() { + } + + /// The `Storage` name a remote image caches under. + public static String storageKey(String url) { + return Image.storageKey(url); + } +} From b5d79d7175bf2e3f084ba938f7c5bf20a347a9e5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:13:19 +0200 Subject: [PATCH 06/27] javafx-compat: wrapText wraps a Label onto several lines A Label with wrapText on now has a horizontal content bias, a preferred height of its text wrapped at the given width, and a native peer (WrappingLabel) that paints the wrapped lines. VBox, HBox and ScrollPane already ask for height-for-width, so a column of wrapping labels in a fit-to-width scroll pane gets the right height. Co-Authored-By: Claude Opus 5.5 --- .../fxcompat/runtime/WrappingLabel.java | 166 ++++++++++++++++++ .../main/java/javafx/scene/control/Label.java | 7 +- .../java/javafx/scene/control/Labeled.java | 64 ++++++- .../com/codename1/fxcompat/WrapTextTest.java | 116 ++++++++++++ 4 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/WrapTextTest.java diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java new file mode 100644 index 00000000000..bb82cf90ce5 --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java @@ -0,0 +1,166 @@ +/* + * 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.fxcompat.runtime; + +import java.util.ArrayList; +import java.util.List; + +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; + +/// The native peer of a JavaFX `Label`: a Codename One label that, while +/// wrapping is on, breaks its text into lines at its own width and paints +/// them itself. With wrapping off it is a plain label in every respect. +/// +/// Lines break at spaces and at line feeds; a word wider than the whole +/// line is broken between characters, and only then. Each line is placed +/// by the label's alignment, and the block of lines is centred in the +/// height the label is given, as JavaFX centres a wrapped text. +public class WrappingLabel extends com.codename1.ui.Label { + + private boolean wrap; + + /// Turns wrapping on or off. + public void setWrap(boolean wrap) { + if (this.wrap != wrap) { + this.wrap = wrap; + repaint(); + } + } + + /// Whether the text wraps. + public boolean isWrap() { + return wrap; + } + + /// Breaks a text into the lines it takes at a width in device pixels. + public static List lines(Font font, String text, int width) { + List out = new ArrayList(); + if (text == null || text.length() == 0) { + out.add(""); + return out; + } + int start = 0; + while (true) { + int nl = text.indexOf('\n', start); + int end = nl < 0 ? text.length() : nl; + breakParagraph(font, text.substring(start, end), width, out); + if (nl < 0) { + break; + } + start = nl + 1; + } + return out; + } + + private static void breakParagraph(Font font, String p, int width, List out) { + if (p.length() == 0 || width <= 0 || font.stringWidth(p) <= width) { + out.add(p); + return; + } + int pos = 0; + int n = p.length(); + boolean first = true; + while (pos < n) { + // Spaces at the start of a wrapped line are dropped, as JavaFX + // drops them. + if (!first) { + while (pos < n && p.charAt(pos) == ' ') { + pos++; + } + if (pos >= n) { + break; + } + } + first = false; + int lastFit = -1; + for (int i = pos + 1; i <= n; i++) { + if (i == n || p.charAt(i) == ' ') { + if (font.stringWidth(p.substring(pos, i)) > width) { + break; + } + lastFit = i; + } + } + if (lastFit > pos) { + out.add(p.substring(pos, lastFit)); + pos = lastFit; + continue; + } + // A single word wider than the line: as many characters as fit, + // at least one so the loop always moves on. + int e = pos + 1; + while (e < n && p.charAt(e) != ' ' && font.stringWidth(p.substring(pos, e + 1)) <= width) { + e++; + } + out.add(p.substring(pos, e)); + pos = e; + } + } + + @Override + public void paint(Graphics g) { + String text = getText(); + Style s = getStyle(); + Font f = s.getFont(); + if (!wrap || f == null || text == null || text.length() == 0) { + super.paint(g); + return; + } + int left = s.getPaddingLeftNoRTL(); + int right = s.getPaddingRightNoRTL(); + int top = s.getPaddingTop(); + int bottom = s.getPaddingBottom(); + int area = getWidth() - left - right; + if (text.indexOf('\n') < 0 && f.stringWidth(text) <= area) { + super.paint(g); + return; + } + List lines = lines(f, text, area); + int lh = f.getHeight(); + int block = lh * lines.size(); + int y = getY() + top + Math.max(0, (getHeight() - top - bottom - block) / 2); + int oldColor = g.getColor(); + int oldAlpha = g.getAlpha(); + Font oldFont = g.getFont(); + g.setFont(f); + g.setColor(s.getFgColor()); + g.setAlpha(s.getOpacity() & 0xff); + int align = getAlignment(); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + int x = getX() + left; + if (align == CENTER) { + x += (area - f.stringWidth(line)) / 2; + } else if (align == RIGHT) { + x += area - f.stringWidth(line); + } + g.drawString(line, x, y); + y += lh; + } + g.setFont(oldFont); + g.setColor(oldColor); + g.setAlpha(oldAlpha); + } +} diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Label.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Label.java index 251f8da8b67..20a319d95b4 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Label.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Label.java @@ -58,7 +58,12 @@ private void init() { @Override protected Component cn1CreateNative() { - return new com.codename1.ui.Label(); + return new com.codename1.fxcompat.runtime.WrappingLabel(); + } + + @Override + boolean cn1CreatesWrappingNative() { + return true; } @Override diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java index 9be54fa3e22..5464f916144 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java @@ -68,8 +68,12 @@ /// `graphicTextGap` away from it; the label's padding makes the room, so /// the preferred size covers both. A text too long for the control is cut /// short and ends in three points, and the minimum width is the width of -/// those. `wrapText`, `ellipsisString`, `textOverrun`, `lineSpacing` are -/// recorded or absent and have no effect on a single line native label. +/// those. With `wrapText` on, a `Label` breaks its text into lines at +/// the width it is given instead (`com.codename1.fxcompat.runtime.WrappingLabel`), +/// its content bias is horizontal and its preferred height is that of the +/// wrapped lines at a width; other labeled controls stay on one line. +/// `ellipsisString`, `textOverrun` and `lineSpacing` are recorded or +/// absent and have no effect. /// /// With `mnemonicParsing` on, as it is for every button and off for a /// label, the underscore that marks a mnemonic is taken out of the text @@ -152,7 +156,11 @@ protected void cn1SyncNative() { String t = isMnemonicParsing() ? Mnemonics.strip(getText()) : getText(); label.setText(t == null || getContentDisplay() == ContentDisplay.GRAPHIC_ONLY ? "" : t); Style style = com.codename1.fxcompat.runtime.PeerPaint.allStyles(label); - if (c.getClass() == com.codename1.ui.Label.class) { + if (c instanceof com.codename1.fxcompat.runtime.WrappingLabel) { + ((com.codename1.fxcompat.runtime.WrappingLabel) c).setWrap(isWrapText()); + } + if (c.getClass() == com.codename1.ui.Label.class + || c.getClass() == com.codename1.fxcompat.runtime.WrappingLabel.class) { style.setBgTransparency(0); style.setBorder(com.codename1.ui.plaf.Border.createEmpty()); } @@ -428,6 +436,56 @@ protected void layoutChildren() { positionInArea(g, Units.toLogical(gx), Units.toLogical(gy), w, height, 0, HPos.CENTER, VPos.CENTER); } + /// A label that wraps its text is as tall as its lines at a width. + @Override + public javafx.geometry.Orientation getContentBias() { + return wraps() ? javafx.geometry.Orientation.HORIZONTAL : super.getContentBias(); + } + + private boolean wraps() { + return isWrapText() && cn1CreatesWrappingNative(); + } + + /// Whether the native label breaks its text into lines when + /// `wrapText` is on. True of a label. + boolean cn1CreatesWrappingNative() { + return false; + } + + /// The height of the wrapped text at a width, when the label wraps; + /// the native label's own height otherwise. + @Override + protected double computePrefHeight(double width) { + double single = super.computePrefHeight(width); + if (width < 0 || !wraps()) { + return single; + } + Component c = cn1NativeIfCreated(); + String t = isMnemonicParsing() ? Mnemonics.strip(getText()) : getText(); + if (t == null || t.length() == 0 || getContentDisplay() == ContentDisplay.GRAPHIC_ONLY) { + return single; + } + com.codename1.ui.Font f = null; + int padLeft = 0; + int padRight = 0; + if (c != null) { + Style s = c.getUnselectedStyle(); + f = s.getFont(); + padLeft = s.getPaddingLeftNoRTL(); + padRight = s.getPaddingRightNoRTL(); + } + if (f == null) { + f = Fonts.of(getFont()); + } + Insets in = getInsets(); + int area = Units.toPixels(width - in.getLeft() - in.getRight()) - padLeft - padRight; + int lines = com.codename1.fxcompat.runtime.WrappingLabel.lines(f, t, area).size(); + if (lines <= 1) { + return single; + } + return single + snapSizeY(Units.toLogical((lines - 1) * f.getHeight())); + } + /// A labeled control can be as narrow as its ellipsis: the native /// label cuts a text that does not fit short and ends it with three /// points, so a row that is too narrow shrinks its labels rather than diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/WrapTextTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/WrapTextTest.java new file mode 100644 index 00000000000..96f3ff9a834 --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/WrapTextTest.java @@ -0,0 +1,116 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.fxcompat.runtime.StyleEngine; +import com.codename1.fxcompat.runtime.Units; + +import javafx.geometry.Orientation; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.VBox; + +/// A label with `wrapText` on breaks its text into lines at its width. +public class WrapTextTest { + + private static final String TEXT = "The quick brown fox jumps over the lazy dog and keeps on running " + + "far past the end of a narrow card"; + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + @Before + public void setUp() { + HeadlessImplementation.install(); + Units.setScale(1); + } + + @After + public void tearDown() { + Units.setScale(0); + StyleEngine.setInstance(null); + } + + private static Label label(boolean wrap) { + Label l = new Label(TEXT); + l.setWrapText(wrap); + return l; + } + + @Test + public void wrappingLabelGrowsAtANarrowWidth() { + Label l = label(true); + new Scene(new VBox(l), 400, 400); + assertSame(Orientation.HORIZONTAL, l.getContentBias()); + double oneLine = l.prefHeight(-1); + double narrow = l.prefHeight(60); + assertTrue("narrow " + narrow + " one line " + oneLine, narrow >= oneLine * 3); + assertEquals(oneLine, l.prefHeight(l.prefWidth(-1) + 10), 0.01); + assertTrue(l.minWidth(-1) < 60); + } + + @Test + public void wrapTextOffIsUnchanged() { + Label l = label(false); + new Scene(new VBox(l), 400, 400); + assertNull(l.getContentBias()); + assertEquals(l.prefHeight(-1), l.prefHeight(60), 0.01); + } + + @Test + public void columnOfWrappingLabelsIsTallerWhenNarrow() { + VBox box = new VBox(label(true), label(true), label(true)); + new Scene(box, 400, 400); + assertSame(Orientation.HORIZONTAL, box.getContentBias()); + double wide = box.prefHeight(2000); + double narrow = box.prefHeight(80); + assertTrue("narrow " + narrow + " wide " + wide, narrow > wide * 2); + } + + @Test + public void scrollPaneFitToWidthGivesWrappedHeight() { + Label l = label(true); + VBox box = new VBox(l); + ScrollPane sp = new ScrollPane(box); + sp.setFitToWidth(true); + new Scene(sp, 90, 400); + sp.layout(); + box.layout(); + double one = l.prefHeight(-1); + assertTrue("label " + l.getHeight() + " one line " + one, l.getHeight() >= one * 3); + assertTrue(box.getHeight() >= l.getHeight()); + } +} From 07fbcb5bb82f0e804b6022c25b032c836293813d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:05:16 +0200 Subject: [PATCH 07/27] getStorageEntrySize is abstract; every implementation asks its own storage Reading an entry through to count it was slow, and the loop the default used never ended on a stream whose skip() honours its contract. The native Linux and Windows ports ask the file length, the headless implementations the stored array. Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 31 +++++-------------- .../impl/linux/LinuxImplementation.java | 9 ++++++ .../impl/windows/WindowsImplementation.java | 9 ++++++ .../testing/HeadlessImplementation.java | 6 ++++ .../HeadlessCssCompilerImplementation.java | 1 + .../flutter/testsupport/RasterDisplay.java | 5 +++ 6 files changed, 37 insertions(+), 24 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 55c69316608..28739046e97 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -8060,7 +8060,10 @@ public void clearStorage() { /// the names of all the storage files public abstract String[] listStorageEntries(); - /// Returns the size of the entry in bytes + /// Returns the size of the entry in bytes, asked of the platform's own + /// storage. Every implementation answers it directly: reading the entry + /// through to count it was what a missing override cost, and a large + /// cached download made that painfully slow. /// /// #### Parameters /// @@ -8068,29 +8071,9 @@ public void clearStorage() { /// /// #### Returns /// - /// the size - public int getStorageEntrySize(String name) { - long size = -1; - InputStream i = null; //NOPMD CloseResource - try { - i = createStorageInputStream(name); - // Counted with read(), never skip(): skip() answers 0 at the end - // of a stream, not -1, so a loop waiting for -1 from it never - // ended on a stream that honours that contract. - byte[] buffer = new byte[8192]; - size = 0; - int val = i.read(buffer); - while (val > -1) { - size += val; - val = i.read(buffer); - } - } catch (IOException err) { - Log.e(err); - } finally { - Util.cleanup(i); - } - return (int) size; - } + /// the size; 0 or -1 when there is no such entry, so a caller that must + /// tell a missing entry from an empty one asks `storageFileExists` + public abstract int getStorageEntrySize(String name); /// Returns the filesystem roots from which the structure of the file system /// can be traversed diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 748cfa23e36..9566957a1e1 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -3480,6 +3480,15 @@ public InputStream createStorageInputStream(String name) throws IOException { return new LinuxInputStream(h, false); } + @Override + public int getStorageEntrySize(String name) { + String path = storagePath(name); + if (!LinuxNative.fileExists(path)) { + return -1; + } + return (int) LinuxNative.fileLength(path); + } + @Override public boolean storageFileExists(String name) { return LinuxNative.fileExists(storagePath(name)); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 73ab1291934..d477a15578f 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -3055,6 +3055,15 @@ public InputStream createStorageInputStream(String name) throws IOException { return new WindowsInputStream(h, false); } + @Override + public int getStorageEntrySize(String name) { + String path = storagePath(name); + if (!WindowsNative.fileExists(path)) { + return -1; + } + return (int) WindowsNative.fileLength(path); + } + @Override public boolean storageFileExists(String name) { return WindowsNative.fileExists(storagePath(name)); diff --git a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java index 41cfa723707..a3647169706 100644 --- a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java +++ b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java @@ -1456,6 +1456,12 @@ public java.io.InputStream createStorageInputStream(java.lang.String a0) throws return new java.io.ByteArrayInputStream(b); } + @Override + public int getStorageEntrySize(java.lang.String a0) { + byte[] b = STORAGE.get(a0); + return b == null ? -1 : b.length; + } + @Override public boolean storageFileExists(java.lang.String a0) { return STORAGE.containsKey(a0); diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java index c0bbaa99b86..da0fa007285 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java @@ -325,6 +325,7 @@ private static BufferedImage toArgb(BufferedImage src) { @Override public OutputStream createStorageOutputStream(String name) throws IOException { return null; } @Override public InputStream createStorageInputStream(String name) throws IOException { return null; } @Override public boolean storageFileExists(String name) { return false; } + @Override public int getStorageEntrySize(String name) { return -1; } @Override public String[] listStorageEntries() { return new String[0]; } @Override public String[] listFilesystemRoots() { return new String[0]; } @Override public String[] listFiles(String directory) throws IOException { return new String[0]; } diff --git a/maven/flutter-runtime/src/test/java/com/codename1/flutter/testsupport/RasterDisplay.java b/maven/flutter-runtime/src/test/java/com/codename1/flutter/testsupport/RasterDisplay.java index d319a62f8d9..b92ecc464c3 100644 --- a/maven/flutter-runtime/src/test/java/com/codename1/flutter/testsupport/RasterDisplay.java +++ b/maven/flutter-runtime/src/test/java/com/codename1/flutter/testsupport/RasterDisplay.java @@ -715,6 +715,11 @@ public InputStream createStorageInputStream(String name) throws IOException { throw new IOException("unsupported"); } + @Override + public int getStorageEntrySize(String name) { + return -1; + } + @Override public boolean storageFileExists(String name) { return false; From 5e36cb1c80b88e4c4217d2e43bc66d8ee33b3c1c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:12:43 +0200 Subject: [PATCH 08/27] Review fixes: no view leak or poisoned cache in remote images, lazy map/flatMap - Image holds view listeners only while a remote load is pending, and drops a cached entry that failed to decode so the next request downloads again. - DerivedValue observes its source only while it is observed itself. - WrappingLabel caches its lines per text, font and width; tabs break. - The mapping processor skips an inherited field whose type is a type variable of the superclass instead of mapping it erased. Co-Authored-By: Claude Opus 5.5 --- .../MappingAnnotationProcessor.java | 27 +++++ .../MappingAnnotationProcessorTest.java | 46 +++++++++ .../fxcompat/runtime/DerivedValue.java | 40 +++++++- .../fxcompat/runtime/WrappingLabel.java | 23 ++++- .../main/java/javafx/scene/image/Image.java | 29 +++++- .../codename1/fxcompat/DerivedValueTest.java | 98 +++++++++++++++++++ .../codename1/fxcompat/RemoteImageTest.java | 48 +++++++++ 7 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/DerivedValueTest.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java index c23947fff0f..463214f2fdb 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java @@ -203,6 +203,15 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces List own = new ArrayList(); for (FieldInfo f : c.getFields()) { if (f.isStatic() || !seenNames.add(f.getName())) continue; + if (c != cls && usesTypeVariable(f)) { + // Base's `T value` is erased here: its type depends on + // the subclass's type arguments, which this pass does not + // resolve, so mapping it would read or write the wrong type. + ctx.getLog().warn("cn1: @Mapped skipping inherited field " + f.getName() + + " of " + c.getBinaryName() + " on " + cls.getBinaryName() + + " (its type is a type variable of the superclass)"); + continue; + } own.add(f); } allFields.addAll(0, own); @@ -1581,6 +1590,24 @@ private static String findSetter(AnnotatedClass cls, FieldInfo f) { return null; } + /// Whether a field's generic signature names a type variable (`TT;`), + /// anywhere, including as a type argument (`Ljava/util/List;`). + static boolean usesTypeVariable(FieldInfo f) { + String sig = f.getSignature(); + if (sig == null) { + return false; + } + for (int i = 0; i < sig.length(); i++) { + char c = sig.charAt(i); + if (c == 'T' && (i == 0 || sig.charAt(i - 1) == '<' || sig.charAt(i - 1) == ';' + || sig.charAt(i - 1) == '[' || sig.charAt(i - 1) == '+' || sig.charAt(i - 1) == '-' + || sig.charAt(i - 1) == '*')) { + return true; + } + } + return false; + } + private static boolean hasInstanceMethod(AnnotatedClass cls, String name, String descriptor) { for (MethodInfo m : cls.getMethods()) { if (!name.equals(m.getName())) continue; diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java index e0c9aee39df..3ef9e38b96a 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java @@ -232,6 +232,52 @@ public void inheritedPropertiesFromPlainBaseRoundTrip() throws Exception { } } + @Test + public void genericSuperclassFieldsAreSkippedNotMistyped() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.Holder", + "package com.example;\n" + + "import java.util.List;\n" + + "public abstract class Holder {\n" + + " private T value;\n" + + " private List values;\n" + + " private String label;\n" + + " public T getValue() { return value; }\n" + + " public void setValue(T v) { value = v; }\n" + + " public List getValues() { return values; }\n" + + " public void setValues(List v) { values = v; }\n" + + " public String getLabel() { return label; }\n" + + " public void setLabel(String v) { label = v; }\n" + + "}\n"); + sources.put("com.example.NameHolder", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public class NameHolder extends Holder {\n" + + " public NameHolder() {}\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + runProcessorOrFail(classes); + + try (URLClassLoader cl = childLoader(classes)) { + Class cls = cl.loadClass("com.example.NameHolder"); + Class mapperCls = cl.loadClass("com.example.NameHolderCn1Mapper"); + Object mapper = mapperCls.newInstance(); + Map in = new LinkedHashMap(); + in.put("label", "l"); + in.put("value", "v"); + Object o = mapperCls.getMethod("fromMap", Map.class).invoke(mapper, in); + assertEquals("l", cls.getMethod("getLabel").invoke(o)); + // The type-variable fields are left alone rather than mapped as Object. + assertEquals(null, cls.getMethod("getValue").invoke(o)); + @SuppressWarnings("unchecked") + Map out = (Map) mapperCls.getMethod("toMap", cls).invoke(mapper, o); + assertEquals("l", out.get("label")); + assertFalse(out.containsKey("value")); + assertFalse(out.containsKey("values")); + } + } + @Test public void enumFieldsRoundTripThroughJson() throws Exception { File classes = tmp.newFolder("classes"); diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java index 919cee0d549..586837defef 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java @@ -32,6 +32,10 @@ /// The value behind `ObservableValue.map`, `flatMap` and `orElse`: computed /// from its source when asked, and invalid whenever the source (or, for /// `flatMap`, the value the source maps to) is. +/// +/// As in JavaFX, it observes its source only while something observes it: +/// a value mapped and dropped (in a cell's `updateItem`, say) adds nothing to +/// the source and can be collected. public final class DerivedValue implements ObservableValue { private static final int MAP = 0; @@ -44,6 +48,9 @@ public final class DerivedValue implements ObservableValue { private final int kind; private ValueListeners helper; private ObservableValue inner; + /// The listeners registered here, so the source is observed exactly + /// while there is at least one. + private final java.util.ArrayList registered = new java.util.ArrayList(); private final InvalidationListener relay = new InvalidationListener() { @Override public void invalidated(Observable observable) { @@ -59,9 +66,25 @@ private DerivedValue(ObservableValue source, Function function, this.function = function; this.other = other; this.kind = kind; - source.addListener(relay); - if (kind == FLAT) { - follow(); + } + + private void registered(Object listener) { + registered.add(listener); + if (registered.size() == 1) { + source.addListener(relay); + if (kind == FLAT) { + follow(); + } + } + } + + private void unregistered(Object listener) { + if (registered.remove(listener) && registered.isEmpty()) { + source.removeListener(relay); + if (inner != null) { + inner.removeListener(relay); + inner = null; + } } } @@ -95,7 +118,7 @@ private void follow() { inner.removeListener(relay); } inner = next; - if (inner != null) { + if (inner != null && !registered.isEmpty()) { inner.addListener(relay); } } @@ -116,6 +139,11 @@ public U getValue() { case OR_ELSE: return v == null ? other : (U) v; case FLAT: + if (registered.isEmpty()) { + // Unobserved: asked fresh, nothing subscribed. + Object o = v == null ? null : function.apply(v); + return o instanceof ObservableValue ? (U) ((ObservableValue) o).getValue() : null; + } follow(); return inner == null ? null : (U) inner.getValue(); default: @@ -126,20 +154,24 @@ public U getValue() { @Override public void addListener(InvalidationListener listener) { helper = ValueListeners.add(helper, this, listener); + registered(listener); } @Override public void removeListener(InvalidationListener listener) { helper = ValueListeners.remove(helper, listener); + unregistered(listener); } @Override public void addListener(ChangeListener listener) { helper = ValueListeners.add(helper, this, listener); + registered(listener); } @Override public void removeListener(ChangeListener listener) { helper = ValueListeners.remove(helper, listener); + unregistered(listener); } } diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java index bb82cf90ce5..910f74a6f6f 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java @@ -119,12 +119,31 @@ private static void breakParagraph(Font font, String p, int width, List } } + private String cachedText; + private Font cachedFont; + private int cachedWidth = -1; + private List cachedLines; + + /// The lines of the last paint, kept while text, font and width stay: + /// breaking measures substrings and a list repaints every frame. + private List cachedLines(Font f, String text, int width) { + if (cachedLines == null || width != cachedWidth || f != cachedFont || !text.equals(cachedText)) { + cachedLines = lines(f, text.replace('\t', ' '), width); + cachedText = text; + cachedFont = f; + cachedWidth = width; + } + return cachedLines; + } + @Override public void paint(Graphics g) { String text = getText(); Style s = getStyle(); Font f = s.getFont(); - if (!wrap || f == null || text == null || text.length() == 0) { + // An icon on the native label is drawn by the single line path; the + // layer places a graphic as a child node, so a wrapping label has none. + if (!wrap || f == null || text == null || text.length() == 0 || getIcon() != null) { super.paint(g); return; } @@ -137,7 +156,7 @@ public void paint(Graphics g) { super.paint(g); return; } - List lines = lines(f, text, area); + List lines = cachedLines(f, text, area); int lh = f.getHeight(); int block = lh * lines.size(); int y = getY() + top + Math.max(0, (getHeight() - top - bottom - block) / 2); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java b/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java index fba568fa34b..c232a0b2d9f 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java @@ -209,6 +209,12 @@ int[] pixels() { /// Has a view told when the pixels change: a decoded image never /// changes, a remote one changes once, when it arrives. void addPixelListener(Runnable listener) { + // Only a remote load still under way changes; holding a view's + // listener for any other image would keep that view reachable for + // as long as the image is. + if (!pending) { + return; + } if (listeners == null) { listeners = new java.util.ArrayList(); } @@ -224,11 +230,16 @@ void removePixelListener(Runnable listener) { private java.util.ArrayList listeners; + /// Whether a remote picture is still on its way. + private boolean pending; + private void firePixelsChanged() { + pending = false; if (listeners == null) { return; } Runnable[] all = listeners.toArray(new Runnable[listeners.size()]); + listeners = null; for (Runnable r : all) { r.run(); } @@ -237,12 +248,14 @@ private void firePixelsChanged() { /// Fetches a remote picture into `Storage`, or reads it from there when /// an earlier run fetched it, and applies it on the EDT. private void download(final String remote) { + pending = true; + final String key = storageKey(remote); com.codename1.io.ConnectionRequest req = new com.codename1.io.ConnectionRequest(remote, false); // A missing picture is the image's error, never the application's // global network error dialog. req.setFailSilently(true); req.setReadResponseForErrors(false); - req.downloadImageToStorage(storageKey(remote), new com.codename1.util.SuccessCallback() { + req.downloadImageToStorage(key, new com.codename1.util.SuccessCallback() { @Override public void onSucess(com.codename1.ui.Image value) { try { @@ -251,8 +264,10 @@ public void onSucess(com.codename1.ui.Image value) { } apply(value); } catch (IOException failed) { + forget(key); fail(failed); } catch (RuntimeException failed) { + forget(key); fail(failed); } firePixelsChanged(); @@ -260,6 +275,7 @@ public void onSucess(com.codename1.ui.Image value) { }, new com.codename1.util.FailureCallback() { @Override public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { + forget(key); fail(new IOException("Could not load " + remote + " (" + errorCode + "): " + (err != null ? err.toString() : errorMessage))); firePixelsChanged(); @@ -267,6 +283,17 @@ public void onError(Object sender, Throwable err, int errorCode, String errorMes }, true); } + /// Drops a cached entry that did not decode or was cut short, so the + /// next request downloads the picture again instead of failing on the + /// same bytes for good. + private static void forget(String key) { + try { + com.codename1.io.Storage.getInstance().deleteStorageFile(key); + } catch (RuntimeException ignored) { + // Nothing cached to drop. + } + } + /// A `Storage` name for a URL: storage names are flat and short, so the /// URL's hash and length, then its last characters made safe. static String storageKey(String remote) { diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/DerivedValueTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/DerivedValueTest.java new file mode 100644 index 00000000000..bb32faadd73 --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/DerivedValueTest.java @@ -0,0 +1,98 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +/// `map`, `flatMap` and `orElse` observe their source only while observed. +public class DerivedValueTest { + + /// A property that counts the listeners on it. + private static final class Counted extends SimpleStringProperty { + int listeners; + + Counted(String v) { + super(v); + } + + @Override + public void addListener(InvalidationListener l) { + listeners++; + super.addListener(l); + } + + @Override + public void removeListener(InvalidationListener l) { + listeners--; + super.removeListener(l); + } + } + + @Test + public void unobservedMapAddsNothingToTheSource() { + Counted source = new Counted("a"); + for (int i = 0; i < 10; i++) { + ObservableValue length = source.map(String::length); + assertEquals(Integer.valueOf(1), length.getValue()); + } + assertEquals(0, source.listeners); + } + + @Test + public void observedMapFollowsAndLetsGo() { + Counted source = new Counted("a"); + ObservableValue length = source.map(String::length); + final int[] seen = new int[1]; + ChangeListener l = (obs, o, n) -> seen[0] = n; + length.addListener(l); + assertEquals(1, source.listeners); + source.set("abc"); + assertEquals(3, seen[0]); + length.removeListener(l); + assertEquals(0, source.listeners); + } + + @Test + public void flatMapReadsTheInnerValueWhileUnobserved() { + final SimpleStringProperty inner = new SimpleStringProperty("x"); + Counted source = new Counted("a"); + ObservableValue flat = source.flatMap(s -> inner); + assertEquals("x", flat.getValue()); + inner.set("y"); + assertEquals("y", flat.getValue()); + assertEquals(0, source.listeners); + InvalidationListener l = (Observable o) -> { }; + flat.addListener(l); + assertEquals(1, source.listeners); + flat.removeListener(l); + assertEquals(0, source.listeners); + } +} diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java index 6a4fb504f6a..490c1ea056e 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java @@ -100,6 +100,54 @@ public void run() { assertNotNull(image.cn1Native()); } + @Test + public void corruptCachedEntryFailsOnceAndIsDropped() throws Exception { + String url = "https://example.invalid/corrupt.png"; + OutputStream out = Storage.getInstance().createOutputStream(cacheKey(url)); + out.write(new byte[] {1, 2, 3, 4, 5}); + out.close(); + final Image image = new Image(url); + waitFor(image); + assertTrue("undecodable bytes are an error", image.isError()); + assertFalse("and are not served again", Storage.getInstance().exists(cacheKey(url))); + } + + @Test + public void cachedRemoteImageIsScaledToTheRequestedSize() throws Exception { + String url = "https://example.invalid/scaled.png"; + seed(url, 20, 10); + final Image image = new Image(url, 10, 0, true, false, true); + waitFor(image); + assertFalse(String.valueOf(image.getException()), image.isError()); + assertEquals(10, image.getWidth(), 0); + assertEquals(5, image.getHeight(), 0); + } + + private static void seed(String url, int w, int h) throws Exception { + java.awt.image.BufferedImage picture = new java.awt.image.BufferedImage(w, h, + java.awt.image.BufferedImage.TYPE_INT_ARGB); + ByteArrayOutputStream png = new ByteArrayOutputStream(); + javax.imageio.ImageIO.write(picture, "png", png); + OutputStream out = Storage.getInstance().createOutputStream(cacheKey(url)); + out.write(png.toByteArray()); + out.close(); + } + + private static void waitFor(final Image image) { + com.codename1.ui.Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + for (int i = 0; i < 500 && image.getProgress() < 1 && !image.isError(); i++) { + try { + Thread.sleep(10); + } catch (InterruptedException stop) { + return; + } + } + } + }); + } + @Test public void storageKeysAreFlatAndDistinct() { String a = cacheKey("https://example.com/a/b/c.png?x=1"); From f5b52f9015684ab2837dda8a7800f2104f3c89b5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:14:20 +0200 Subject: [PATCH 09/27] WrappingLabel: tabs break lines the same way in layout and paint Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/fxcompat/runtime/WrappingLabel.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java index 910f74a6f6f..410ce52de2c 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java @@ -61,11 +61,13 @@ public static List lines(Font font, String text, int width) { out.add(""); return out; } + // A tab breaks a line as a space does, in layout and paint alike. + String t = text.replace('\t', ' '); int start = 0; while (true) { - int nl = text.indexOf('\n', start); - int end = nl < 0 ? text.length() : nl; - breakParagraph(font, text.substring(start, end), width, out); + int nl = t.indexOf('\n', start); + int end = nl < 0 ? t.length() : nl; + breakParagraph(font, t.substring(start, end), width, out); if (nl < 0) { break; } @@ -128,7 +130,7 @@ private static void breakParagraph(Font font, String p, int width, List /// breaking measures substrings and a list repaints every frame. private List cachedLines(Font f, String text, int width) { if (cachedLines == null || width != cachedWidth || f != cachedFont || !text.equals(cachedText)) { - cachedLines = lines(f, text.replace('\t', ' '), width); + cachedLines = lines(f, text, width); cachedText = text; cachedFont = f; cachedWidth = width; From bc602dc799e30391867a6bffcee79a681e17efb4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:41:04 +0200 Subject: [PATCH 10/27] javafx-compat: a labeled control with a graphic gives up theme padding before letters A tab bar of icon-over-text toggle buttons on a phone theme cut "Home" to "Ho..." between margins wide enough for the word: fitPadding refused to shed anything once a graphic was set. The theme's part of the padding is now given up as it is without a graphic; the graphic's own room never is, and the graphic is placed against the padding that remains. Co-Authored-By: Claude Opus 5.5 --- .../java/javafx/scene/control/Labeled.java | 38 ++++++++++++++----- .../codename1/fxcompat/ControlLookTest.java | 34 +++++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java index 5464f916144..06e6df10950 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java @@ -325,10 +325,26 @@ private int shed(Component c) { } /// Whether the control is one that gives up padding: a native label - /// with padding of its theme, and no graphic whose room is kept in it. + /// with padding of its theme. A graphic's room in that padding is never + /// given up (see [#sheddable(Component, Node, int, boolean)]), but the + /// theme's part of it is: a tab bar of icon-over-text buttons otherwise + /// cut "Home" to "Ho..." between margins wide enough for the word. private boolean sheds(Component c, Node graphic) { - return c instanceof com.codename1.ui.Label && graphic == null && !paddedForGraphic && !cn1BareText() - && !ownsGraphic(); + return c instanceof com.codename1.ui.Label && !cn1BareText() && !ownsGraphic() + && (graphic != null || !paddedForGraphic); + } + + /// How much of a side's padding, `full` device pixels of it, may be + /// given up: three quarters of it without a graphic, and with one only + /// three quarters of what the theme put there, so the graphic's room + /// stays whole. + private int sheddable(Component c, Node graphic, int full, boolean left) { + if (graphic == null) { + return full - kept(full); + } + Style base = UIManager.getInstance().getComponentStyle(c.getUIID()); + int theme = left ? base.getPaddingLeftNoRTL() : base.getPaddingRightNoRTL(); + return Math.max(0, Math.min(full, theme) - kept(theme)); } /// The least of its padding a control keeps on a side: a quarter. @@ -358,11 +374,12 @@ private void fitPadding(Component c, Node graphic) { int left = 0; int right = 0; if (missing > 0) { - int most = fullLeft - kept(fullLeft) + fullRight - kept(fullRight); - int give = Math.min(missing, most); - left = Math.min(fullLeft - kept(fullLeft), (give + 1) / 2); - right = Math.min(fullRight - kept(fullRight), give - left); - left = Math.min(fullLeft - kept(fullLeft), give - right); + int maxLeft = sheddable(c, graphic, fullLeft, true); + int maxRight = sheddable(c, graphic, fullRight, false); + int give = Math.min(missing, maxLeft + maxRight); + left = Math.min(maxLeft, (give + 1) / 2); + right = Math.min(maxRight, give - left); + left = Math.min(maxLeft, give - right); } if (left == shedLeft && right == shedRight) { return; @@ -396,10 +413,11 @@ protected void layoutChildren() { Insets in = getInsets(); // The area the label lays its content out in, less the room made // for the graphic: where graphic and text sit together. - int areaX = Units.toPixels(in.getLeft()) + base.getPaddingLeftNoRTL(); + // Less what was given up of the theme's padding (fitPadding). + int areaX = Units.toPixels(in.getLeft()) + base.getPaddingLeftNoRTL() - shedLeft; int areaY = Units.toPixels(in.getTop()) + base.getPaddingTop(); int areaW = Units.toPixels(getWidth() - in.getLeft() - in.getRight()) - base.getPaddingLeftNoRTL() - - base.getPaddingRightNoRTL(); + - base.getPaddingRightNoRTL() + shedLeft + shedRight; int areaH = Units.toPixels(getHeight() - in.getTop() - in.getBottom()) - base.getPaddingTop() - base.getPaddingBottom(); int gw = box[4]; diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlLookTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlLookTest.java index a401922c0f7..24dba10b883 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlLookTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlLookTest.java @@ -351,6 +351,40 @@ public void aGraphicIsShownBesideTheTextAndCounted() { assertNull(mark.getParent()); } + /// A tab-bar button: its text under a graphic, squeezed narrower than + /// it would like. The theme's padding beside the text is given up before + /// any letter is, as it is for a button with no graphic; the graphic's + /// own room is not. + @Test + public void textUnderAGraphicKeepsItsLettersBeforeThePadding() { + javafx.scene.control.ToggleButton button = new javafx.scene.control.ToggleButton("Home"); + javafx.scene.control.Label icon = new javafx.scene.control.Label("X"); + icon.setFont(javafx.scene.text.Font.font(24)); + button.setGraphic(icon); + button.setContentDisplay(ContentDisplay.TOP); + button.setGraphicTextGap(0); + StackPane root = new StackPane(button); + Scene scene = new Scene(root, 300, 100); + assertNotNull(scene); + com.codename1.ui.Label n = nativeLabel(button); + Style theme = com.codename1.ui.plaf.UIManager.getInstance().getComponentStyle(n.getUIID()); + int pad = theme.getPaddingLeftNoRTL() + theme.getPaddingRightNoRTL(); + assertTrue("the theme pads a toggle button", pad >= 8); + int text = n.getUnselectedStyle().getFont().stringWidth("Home"); + double full = button.prefWidth(-1); + // Room for every letter and a quarter of the padding, no more. + double squeezed = Units.toLogical(text + pad / 2); + assertTrue(squeezed < full); + button.resize(squeezed, button.prefHeight(-1)); + button.layout(); + Style st = n.getUnselectedStyle(); + int room = n.getWidth() - st.getPaddingLeftNoRTL() - st.getPaddingRightNoRTL(); + assertTrue("room " + room + " for a text " + text + " wide", room >= text); + assertEquals("the preferred width still counts all of the padding", full, button.prefWidth(-1), 1.01); + double mid = icon.getBoundsInParent().getMinX() + icon.getBoundsInParent().getWidth() / 2; + assertEquals("the graphic stays centred over the text", button.getWidth() / 2, mid, 1.01); + } + @Test public void theConstructorsWithAGraphicShowIt() { Rectangle a = new Rectangle(16, 16); From 9aef058ffe55a2ba23c567bb8d762efe6162e90c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:56:47 +0200 Subject: [PATCH 11/27] javafx-compat: a labeled control's minimum width keeps its graphic's room computeMinWidth counted the whole padding as sheddable, graphic room included, while fitPadding only gives up theme padding; a control shrunk to its minimum lost the text to the graphic. Co-Authored-By: Claude Opus 5.5 --- .../java/javafx/scene/control/Labeled.java | 6 ++-- .../codename1/fxcompat/ControlLookTest.java | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java index 06e6df10950..035bef2be9b 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java @@ -535,12 +535,14 @@ protected double computeMinWidth(double height) { String t = label.getText(); // Before any of the text goes, the padding beside it does. int padding = 0; - if (sheds(c, shownGraphic())) { + Node graphic = shownGraphic(); + if (sheds(c, graphic)) { shed(c); Style s = c.getUnselectedStyle(); int left = s.getPaddingLeftNoRTL() + shedLeft; int right = s.getPaddingRightNoRTL() + shedRight; - padding = left - kept(left) + right - kept(right); + // Only what fitPadding can give up: the graphic's room stays. + padding = sheddable(c, graphic, left, true) + sheddable(c, graphic, right, false); } if (f == null || t == null || t.length() == 0 || !label.isEndsWith3Points()) { return Math.max(0, pref - Units.toLogical(padding)); diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlLookTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlLookTest.java index 24dba10b883..5af3f9de875 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlLookTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlLookTest.java @@ -385,6 +385,42 @@ public void textUnderAGraphicKeepsItsLettersBeforeThePadding() { assertEquals("the graphic stays centred over the text", button.getWidth() / 2, mid, 1.01); } + @Test + public void aGraphicBesideTheTextKeepsItsRoomWhenSqueezed() { + javafx.scene.control.ToggleButton button = new javafx.scene.control.ToggleButton("Home"); + Rectangle icon = new Rectangle(40, 16); + button.setGraphic(icon); + button.setContentDisplay(ContentDisplay.LEFT); + button.setGraphicTextGap(4); + StackPane root = new StackPane(button); + Scene scene = new Scene(root, 300, 100); + assertNotNull(scene); + com.codename1.ui.Label n = nativeLabel(button); + assertTrue("the minimum keeps the graphic and its gap", button.minWidth(-1) >= 44); + Style theme = com.codename1.ui.plaf.UIManager.getInstance().getComponentStyle(n.getUIID()); + int pad = theme.getPaddingLeftNoRTL() + theme.getPaddingRightNoRTL(); + int text = n.getUnselectedStyle().getFont().stringWidth("Home"); + double squeezed = 44 + Units.toLogical(text + pad / 2); + assertTrue(squeezed < button.prefWidth(-1)); + button.resize(squeezed, button.prefHeight(-1)); + button.layout(); + Style st = n.getUnselectedStyle(); + int room = n.getWidth() - st.getPaddingLeftNoRTL() - st.getPaddingRightNoRTL(); + assertTrue("room " + room + " for a text " + text + " wide", room >= text); + double textStart = Units.toLogical(st.getPaddingLeftNoRTL()); + assertTrue("the graphic ends at " + icon.getBoundsInParent().getMaxX() + ", the text starts at " + textStart, + icon.getBoundsInParent().getMaxX() <= textStart + 0.5); + // At the width it advertises as its least, the graphic and its gap + // still fit before the text: fitting gives up only theme padding. + button.resize(button.minWidth(-1), button.prefHeight(-1)); + button.layout(); + st = n.getUnselectedStyle(); + int squeezedRoom = n.getWidth() - st.getPaddingLeftNoRTL() - st.getPaddingRightNoRTL(); + int ellipsis = n.getUnselectedStyle().getFont().stringWidth("..."); + assertTrue("room " + squeezedRoom + " at the minimum, an ellipsis is " + ellipsis, + squeezedRoom >= ellipsis - 1); + } + @Test public void theConstructorsWithAGraphicShowIt() { Rectangle a = new Rectangle(16, 16); From 4f10fa53093c04a2c7c5579ab0363d315be5c69e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:20:22 +0200 Subject: [PATCH 12/27] javafx-compat: a cell's graphic fits the cell, and rows measure at their width Cell laid its graphic out at its preferred width, so a list cell with wrapping text drew it on one line past the edge of the list. The graphic is now no wider than the cell, as in JavaFX, and is as tall as it is at that width; RowFlow measures the row height at the column width and measures again when that changes. Co-Authored-By: Claude Opus 5.5 --- .../main/java/javafx/scene/control/Cell.java | 27 +++++++++++-- .../java/javafx/scene/control/RowFlow.java | 18 +++++++-- .../codename1/fxcompat/ControlsDataTest.java | 40 +++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java index bd860bc5d72..2b4c1ab7485 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java @@ -212,26 +212,45 @@ protected void layoutChildren() { } if (show) { Insets in = getInsets(); - double w = g.prefWidth(-1); - double h = g.prefHeight(-1); + double x = in.getLeft() + leading() + Units.toLogical(Math.max(0, basePadding)); + // As JavaFX's skin does, a graphic gets the cell's width at + // most, and a graphic that wraps is as tall as it is there: + // a cell of a list as wide as the screen otherwise drew its + // text on one line, past the edge. + double w = graphicWidth(g, getWidth() - x - in.getRight()); + double h = graphicHeight(g, w); double space = getHeight() - in.getTop() - in.getBottom(); if (g.isResizable()) { g.resize(w, h); } - g.relocate(in.getLeft() + leading() + Units.toLogical(Math.max(0, basePadding)) + g.relocate(x - g.getLayoutBounds().getMinX(), in.getTop() + (space - h) / 2 - g.getLayoutBounds().getMinY()); } } } + /// The width a graphic is laid out at: its preferred width, no wider + /// than `room` but no narrower than its minimum. + private static double graphicWidth(Node g, double room) { + double pref = g.prefWidth(-1); + return room > 0 && pref > room ? Math.max(g.minWidth(-1), room) : pref; + } + + /// The height of a graphic laid out `width` wide. + private static double graphicHeight(Node g, double width) { + return g.getContentBias() == javafx.geometry.Orientation.HORIZONTAL ? g.prefHeight(width) : g.prefHeight(-1); + } + @Override protected double computePrefHeight(double width) { double h = super.computePrefHeight(width); Node g = shownGraphic(); if (g != null) { Insets in = getInsets(); - h = Math.max(h, in.getTop() + g.prefHeight(-1) + in.getBottom()); + double x = in.getLeft() + leading() + Units.toLogical(Math.max(0, basePadding)); + double gw = width < 0 ? g.prefWidth(-1) : graphicWidth(g, width - x - in.getRight()); + h = Math.max(h, in.getTop() + graphicHeight(g, gw) + in.getBottom()); } return h; } diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java index d0c9056475e..f132e27f40c 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java @@ -80,6 +80,9 @@ interface Rows { private final Region thumb = new Region(); private double offset; private double measured; + /// The width the row height was measured at: a cell that wraps is as + /// tall as its lines at the width it is given. + private double measuredWidth = -1; private boolean force; private double pressY; private double pressOffset; @@ -256,10 +259,18 @@ private IndexedCell[] newSlot(int columns) { return slot; } - private void measure(int columns, int count) { + private void measure(int columns, int count, double width) { + if (measured > 0 && Math.abs(width - measuredWidth) > 0.5) { + measured = 0; + } if (rows.fixedCellSize() > 0 || measured > 0 || columns == 0) { return; } + double[] xs = new double[columns]; + double[] ws = new double[columns]; + if (width > 0) { + rows.columns(width, xs, ws); + } if (slots.isEmpty()) { slots.add(newSlot(columns)); } @@ -267,10 +278,11 @@ private void measure(int columns, int count) { double h = 0; for (int c = 0; c < slot.length; c++) { rows.updateCell(slot[c], c, count > 0 ? 0 : -1, true); - h = Math.max(h, slot[c].prefHeight(-1)); + h = Math.max(h, slot[c].prefHeight(ws[c] > 0 ? ws[c] : -1)); } if (h > 0 && count > 0) { measured = h; + measuredWidth = width; } } @@ -281,8 +293,8 @@ protected void layoutChildren() { if (!slots.isEmpty() && slots.get(0).length != columns) { rebuild(); } - measure(columns, count); double w = getWidth(); + measure(columns, count, w); double h = getHeight(); double rh = rowHeight(); if (pendingRow >= 0) { diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlsDataTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlsDataTest.java index 85aba3e7713..046e2ac8b28 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlsDataTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ControlsDataTest.java @@ -341,6 +341,46 @@ public void listViewShowsAPlaceholderWhileEmpty() { assertFalse(empty.isVisible()); } + @Test + public void aCellGraphicWiderThanTheListWrapsInsideIt() { + final String longText = "A sentence long enough to need several lines in a list this narrow"; + final List> cells = new ArrayList>(); + ListView list = new ListView(); + list.getItems().add(longText); + list.setCellFactory(new Callback, ListCell>() { + @Override + public ListCell call(ListView view) { + ListCell cell = new ListCell() { + private final Label wrapped = new Label(); + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + wrapped.setWrapText(true); + wrapped.setText(item); + setGraphic(empty || item == null ? null : wrapped); + } + }; + cells.add(cell); + return cell; + } + }); + sceneOf(list, 150, 400); + ListCell shown = null; + for (ListCell c : cells) { + if (longText.equals(c.getItem()) && c.isVisible()) { + shown = c; + } + } + assertNotNull(shown); + Node g = shown.getGraphic(); + assertTrue("graphic " + g.getLayoutBounds().getWidth() + " wide in a cell " + shown.getWidth(), + g.getLayoutBounds().getWidth() <= shown.getWidth() + 0.5); + double oneLine = new Label("A").prefHeight(-1); + assertTrue("the row is " + shown.getHeight() + " tall, one line is " + oneLine, + shown.getHeight() > oneLine * 1.5); + } + /// A row of the table tests. private static final class Person { final StringProperty name; From 0cfe9cd1773f50a2dccd5e5e1479e70e3af4d63f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:00:47 +0200 Subject: [PATCH 13/27] Smooth touch scrolling and press repaints in the JavaFX layer - Lists and scroll panes glide on after a touch drag, decelerating the way a UIScrollView does (runtime Fling, driven by the frame clock). They used to stop dead the moment the finger lifted. - A native button's own repaint (its pressed state) now goes through its node, so it no longer paints over the graphic drawn above it or outside the clips and effects of its ancestors. A touched tab lost its icon until the next full repaint. - ParentPeer's layout reports overlap support, so a peer that repaints alone also repaints the siblings drawn over it. - Component skips a backdrop blur that its own opaque fill hides; the iOS theme's text field blur was most of the time a frame took. Co-Authored-By: Claude Opus 5.5 --- .../src/com/codename1/ui/Component.java | 24 ++- .../TestCodenameOneImplementation.java | 20 +++ .../ui/BackdropUnderOpaqueFillTest.java | 69 ++++++++ .../com/codename1/fxcompat/runtime/Fling.java | 154 ++++++++++++++++ .../fxcompat/runtime/ParentPeer.java | 11 ++ .../java/javafx/scene/control/Button.java | 9 +- .../java/javafx/scene/control/Control.java | 15 ++ .../java/javafx/scene/control/Hyperlink.java | 9 +- .../javafx/scene/control/RadioButton.java | 9 +- .../java/javafx/scene/control/RowFlow.java | 21 +++ .../java/javafx/scene/control/ScrollPane.java | 21 +++ .../javafx/scene/control/ToggleButton.java | 9 +- .../com/codename1/fxcompat/FlingTest.java | 165 ++++++++++++++++++ 13 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/BackdropUnderOpaqueFillTest.java create mode 100644 maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/FlingTest.java diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index 3b00374f7d1..1f8b262836a 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -3194,7 +3194,7 @@ final void paintBackgroundLayerAt(Graphics g, int x, int y, int w, int h) { private void paintBackgroundLayerImpl(Graphics g) { float backdropBlur = getStyle().getBackdropFilterBlurRadius(); - if (backdropBlur > 0) { + if (backdropBlur > 0 && !opaqueFillHidesBackdrop()) { // The glass surface's material INTENT comes from a typed, named // recipe (GlassRecipe -- plain blur, chrome bar, floating pill, // glass panel), resolved per UIID via GlassRecipe / @@ -3237,6 +3237,28 @@ private void paintBackgroundLayerImpl(Graphics g) { paintComponentBackground(g); } + /// Whether this component's own background paints every pixel of its + /// bounds opaquely, so a backdrop blurred under it could never be seen. + /// A theme can give an opaque surface a backdrop filter -- the iOS theme + /// does for `TextField` -- and the blur is then computed on every repaint + /// for nothing: one field in a scrolling screen spent most of the frame + /// in it. Only a plain fill or a `RoundRectBorder`, which fills from the + /// same style colour and opacity over the same shape the glass takes, is + /// trusted; an image, a gradient or any other border keeps the blur. + private boolean opaqueFillHidesBackdrop() { + Style s = getStyle(); + // With no image, every image type paints the plain fill; only the + // gradients paint something else. + byte type = s.getBackgroundType(); + boolean gradient = (type >= Style.BACKGROUND_GRADIENT_LINEAR_VERTICAL && type <= Style.BACKGROUND_GRADIENT_RADIAL) + || type >= Style.BACKGROUND_GRADIENT_LINEAR; + if (s.getBgTransparency() != (byte) 0xff || s.getBgImage() != null || gradient) { + return false; + } + Border b = s.getBorder(); + return b == null || !b.isBackgroundPainter() || b instanceof RoundRectBorder; + } + void internalPaintImpl(Graphics g, boolean paintIntersects) { g.clipRect(getX(), getY(), getWidth(), getHeight()); // CSS backdrop-filter:blur() -- the "liquid glass" effect. Blur whatever has diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index 12d0bf16acd..3a89977a3de 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -215,6 +215,26 @@ public class TestCodenameOneImplementation extends CodenameOneImplementation { private int[] lastTitleBarRegions; private int[] nativeTitleBarInsets; + private int backdropBlurs; + + /// How many backdrop blurs (plain or glass) have been asked for. + public int getBackdropBlurCount() { + return backdropBlurs; + } + + @Override + public boolean blurRegion(Object graphics, int x, int y, int width, int height, float radius) { + backdropBlurs++; + return false; + } + + @Override + public boolean glassRegion(Object graphics, int x, int y, int width, int height, float radius, float cornerRadius, + float sat, float scale, float offset, float refract, float specular, float curve, float curveMid, float outline) { + backdropBlurs++; + return false; + } + public void setNativeTitleBarInsets(int... values) { nativeTitleBarInsets = values; } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/BackdropUnderOpaqueFillTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/BackdropUnderOpaqueFillTest.java new file mode 100644 index 00000000000..4bfdcea1095 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/BackdropUnderOpaqueFillTest.java @@ -0,0 +1,69 @@ +/* + * 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; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.plaf.RoundRectBorder; +import com.codename1.ui.plaf.Style; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// A backdrop blur under a background that covers it is never computed. +class BackdropUnderOpaqueFillTest extends UITestBase { + + private int blursPainting(Label l) { + Image img = Image.createImage(100, 50); + l.setWidth(100); + l.setHeight(50); + int before = implementation.getBackdropBlurCount(); + l.paintComponent(img.getGraphics(), true); + return implementation.getBackdropBlurCount() - before; + } + + private Label blurred(int transparency) { + Label l = new Label("x"); + Style s = l.getAllStyles(); + s.setBackdropFilterBlurRadius(20); + s.setBgColor(0xffffff); + s.setBgTransparency(transparency); + return l; + } + + @FormTest + void opaqueFillSkipsTheBlur() { + assertEquals(0, blursPainting(blurred(255))); + } + + @FormTest + void opaqueRoundedFillSkipsTheBlur() { + Label l = blurred(255); + l.getAllStyles().setBorder(RoundRectBorder.create().cornerRadius(2)); + assertEquals(0, blursPainting(l)); + } + + @FormTest + void translucentFillKeepsTheBlur() { + assertEquals(1, blursPainting(blurred(128))); + } +} diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java new file mode 100644 index 00000000000..34dd8de2c4e --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java @@ -0,0 +1,154 @@ +/* + * 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.fxcompat.runtime; + +import com.codename1.util.MathUtil; + +/// The glide a touch scroll keeps after the finger lifts, as a phone's +/// native list does. A view feeds it the position of every drag, calls +/// [#release()] when the finger lifts and [#stop()] when a new press +/// arrives; while it glides it moves the view through [Target] once a +/// frame, slowing down the way iOS does, until the speed is spent or the +/// view reaches an end. +/// +/// Positions are the view's scroll offsets in logical pixels, growing as +/// the content moves up. +public final class Fling implements FrameClock.Pulse { + + /// What a fling moves. + public interface Target { + + /// Scrolls by a distance; answers false when the view could not + /// move the whole way, which ends the glide. + boolean flingBy(double delta); + } + + /// The fraction of its speed a glide keeps per millisecond, the + /// "normal" deceleration of a UIScrollView. + private static final double DECAY = 0.998; + private static final double LOG_DECAY = MathUtil.log(DECAY); + /// Below this speed, in pixels per millisecond, the glide stops. + private static final double MIN_SPEED = 0.02; + /// A speed is read over the drags of this many last milliseconds; a + /// finger that paused before lifting does not fling. + private static final long WINDOW_NANOS = 100000000L; + private static final double MAX_SPEED = 8; + private static final int SAMPLES = 8; + + private final Target target; + private final long[] times = new long[SAMPLES]; + private final double[] positions = new double[SAMPLES]; + private int count; + private int next; + private double speed; + private long last; + private boolean running; + + public Fling(Target target) { + this.target = target; + } + + /// Ends a glide and forgets the drags before it, for a new press. + public void stop() { + count = 0; + speed = 0; + if (running) { + running = false; + FrameClock.remove(this); + } + } + + /// Records where a drag has moved the view to. + public void track(double position) { + times[next] = FrameClock.nowNanos(); + positions[next] = position; + next = (next + 1) % SAMPLES; + if (count < SAMPLES) { + count++; + } + } + + /// The finger lifted: glides on at the speed of the last drags. + public void release() { + speed = speedNow(); + count = 0; + if (Math.abs(speed) < MIN_SPEED) { + speed = 0; + return; + } + last = FrameClock.nowNanos(); + if (!running) { + running = true; + FrameClock.add(this); + } + } + + /// Whether a glide is under way. + public boolean isRunning() { + return running; + } + + private double speedNow() { + if (count < 2) { + return 0; + } + int newest = (next - 1 + SAMPLES) % SAMPLES; + long now = FrameClock.nowNanos(); + if (now - times[newest] > WINDOW_NANOS) { + return 0; + } + int oldest = newest; + for (int i = 1; i < count; i++) { + int at = (newest - i + SAMPLES) % SAMPLES; + if (times[newest] - times[at] > WINDOW_NANOS) { + break; + } + oldest = at; + } + long span = times[newest] - times[oldest]; + if (span <= 0) { + return 0; + } + double v = (positions[newest] - positions[oldest]) / (span / 1000000.0); + return Math.max(-MAX_SPEED, Math.min(MAX_SPEED, v)); + } + + @Override + public void pulse(long nowNanos) { + double ms = (nowNanos - last) / 1000000.0; + last = nowNanos; + if (!(ms > 0)) { + return; + } + // The distance covered while the speed decays over the frame: + // the integral of v * DECAY^t, so a slow frame covers the same + // ground as two fast ones. + double k = LOG_DECAY; + double kept = MathUtil.pow(DECAY, ms); + double delta = speed * (kept - 1) / k; + speed *= kept; + if (!target.flingBy(delta) || Math.abs(speed) < MIN_SPEED) { + stop(); + } + } +} diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ParentPeer.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ParentPeer.java index 4dfd377220e..1ec68802f79 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ParentPeer.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ParentPeer.java @@ -348,6 +348,17 @@ protected boolean mouseWheel(WheelEvent ev) { /// Runs JavaFX layout from the peer of a root. static final class Bridge extends Layout { + /// JavaFX children overlap freely -- a labeled control's graphic + /// lies over the native component that draws its text -- and only a + /// layout that says so makes Codename One paint what lies above a + /// component that repaints alone. Without it a native button + /// redrawing its pressed state on a device painted over its own + /// icon, which stayed missing until something repainted the parent. + @Override + public boolean isOverlapSupported() { + return true; + } + @Override public void layoutContainer(Container parent) { if (!(parent instanceof ParentPeer)) { diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Button.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Button.java index a1e10ee4166..5d1a7c1f3d6 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Button.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Button.java @@ -95,7 +95,14 @@ public Button(String text, Node graphic) { @Override protected Component cn1CreateNative() { - com.codename1.ui.Button b = new com.codename1.ui.Button(); + com.codename1.ui.Button b = new com.codename1.ui.Button() { + @Override + public void repaint() { + if (!repaintThroughNode()) { + super.repaint(); + } + } + }; b.addActionListener(cn1ActionBridge()); return b; } diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Control.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Control.java index af02b472515..812fbf8d0dd 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Control.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Control.java @@ -180,6 +180,21 @@ protected int cn1PeerChildOffset() { return nativeComponent == null ? 0 : 1; } + /// Repaints this node for its native component, which answers true; + /// false when there is no node peer yet and the component has to + /// repaint itself. A native button that repaints itself on a press is + /// drawn alone: over the graphic this node draws on top of it, and + /// outside the clips, opacity and effects of the nodes around it, so a + /// touched tab lost its icon until the next full repaint. Through the + /// node, the press repaints whatever the node's own changes would. + boolean repaintThroughNode() { + if (!cn1HasPeer()) { + return false; + } + cn1Repaint(); + return true; + } + @Override protected void cn1PeerCreated() { creating = true; diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Hyperlink.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Hyperlink.java index e5a7f6bcf8d..5a930873194 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Hyperlink.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Hyperlink.java @@ -90,7 +90,14 @@ public void invalidated(Observable observable) { @Override protected Component cn1CreateNative() { - com.codename1.ui.Button b = new com.codename1.ui.Button(); + com.codename1.ui.Button b = new com.codename1.ui.Button() { + @Override + public void repaint() { + if (!repaintThroughNode()) { + super.repaint(); + } + } + }; Style s = com.codename1.fxcompat.runtime.PeerPaint.allStyles(b); s.setBorder(Border.createEmpty()); s.setBgTransparency(0); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/RadioButton.java b/maven/javafx-compat/src/main/java/javafx/scene/control/RadioButton.java index 4ecc5fc7734..38355f8c375 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/RadioButton.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/RadioButton.java @@ -68,7 +68,14 @@ public String cn1ThemedStyle() { @Override protected Component cn1CreateNative() { - com.codename1.ui.RadioButton b = new com.codename1.ui.RadioButton(); + com.codename1.ui.RadioButton b = new com.codename1.ui.RadioButton() { + @Override + public void repaint() { + if (!repaintThroughNode()) { + super.repaint(); + } + } + }; // The toggle group decides; the native button never turns itself // off on its own. b.addActionListener(cn1ActionBridge()); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java index f132e27f40c..b59dc7c57a1 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java @@ -88,6 +88,16 @@ interface Rows { private double pressOffset; private boolean dragging; private int pendingRow = -1; + /// Glides on after a touch scroll, as a phone's list does. + private final com.codename1.fxcompat.runtime.Fling fling = new com.codename1.fxcompat.runtime.Fling( + new com.codename1.fxcompat.runtime.Fling.Target() { + @Override + public boolean flingBy(double delta) { + double before = offset; + scrollTo(offset + delta); + return Math.abs(offset - before - delta) < 0.5; + } + }); RowFlow(Rows rows) { this.rows = rows; @@ -110,6 +120,7 @@ public void handle(ScrollEvent event) { addEventHandler(MouseEvent.MOUSE_PRESSED, new EventHandler() { @Override public void handle(MouseEvent event) { + fling.stop(); pressY = event.getSceneY(); pressOffset = offset; dragging = false; @@ -124,7 +135,17 @@ public void handle(MouseEvent event) { } if (dragging) { scrollTo(pressOffset - moved); + fling.track(offset); + } + } + }); + addEventHandler(MouseEvent.MOUSE_RELEASED, new EventHandler() { + @Override + public void handle(MouseEvent event) { + if (dragging) { + fling.release(); } + dragging = false; } }); } diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java b/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java index aa53f8bd62e..04e2c0a88da 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java @@ -153,6 +153,22 @@ private static Region thumb() { return r; } + /// Glides on after a touch pan, as a phone's scroll view does. + private final com.codename1.fxcompat.runtime.Fling fling = new com.codename1.fxcompat.runtime.Fling( + new com.codename1.fxcompat.runtime.Fling.Target() { + @Override + public boolean flingBy(double delta) { + double extentY = contentHeight - viewport.getHeight(); + if (!(extentY > 0) || vvalue.isBound()) { + return false; + } + double before = offset(getVvalue(), getVmin(), getVmax(), extentY); + scrollBy(0, delta); + double after = offset(getVvalue(), getVmin(), getVmax(), extentY); + return Math.abs(after - before - delta) < 0.5; + } + }); + private void init() { getStyleClass().add("scroll-pane"); setFocusTraversable(false); @@ -168,6 +184,7 @@ public void handle(ScrollEvent event) { addEventHandler(MouseEvent.MOUSE_PRESSED, new EventHandler() { @Override public void handle(MouseEvent event) { + fling.stop(); rememberPress(event); panning = isPannable() || (Display.isInitialized() && !Display.getInstance().isDesktop()); } @@ -176,6 +193,7 @@ public void handle(MouseEvent event) { @Override public void handle(MouseEvent event) { if (panning && panTo(pressX - event.getSceneX(), pressY - event.getSceneY())) { + fling.track(offset(getVvalue(), getVmin(), getVmax(), contentHeight - viewport.getHeight())); event.consume(); } } @@ -183,6 +201,9 @@ public void handle(MouseEvent event) { addEventHandler(MouseEvent.MOUSE_RELEASED, new EventHandler() { @Override public void handle(MouseEvent event) { + if (panning) { + fling.release(); + } panning = false; } }); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/ToggleButton.java b/maven/javafx-compat/src/main/java/javafx/scene/control/ToggleButton.java index 6d5fb4900be..969d438f79a 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/ToggleButton.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/ToggleButton.java @@ -124,7 +124,14 @@ public String cn1ThemedStyle() { @Override protected Component cn1CreateNative() { - com.codename1.ui.CheckBox b = new com.codename1.ui.CheckBox(); + com.codename1.ui.CheckBox b = new com.codename1.ui.CheckBox() { + @Override + public void repaint() { + if (!repaintThroughNode()) { + super.repaint(); + } + } + }; b.setToggle(true); if (unstyled(b.getUIID())) { // A theme with no look for a toggle button, as the desktop diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/FlingTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/FlingTest.java new file mode 100644 index 00000000000..b9e4539039c --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/FlingTest.java @@ -0,0 +1,165 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.fxcompat.runtime.Fling; +import com.codename1.fxcompat.runtime.FrameClock; +import com.codename1.fxcompat.runtime.Units; +import javafx.scene.Scene; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; + +/// A touch scroll glides on after the finger lifts, slows down, and stops +/// at the end of the content or when a new press arrives. +public class FlingTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + @Before + public void setUp() { + HeadlessImplementation.install(); + Units.setScale(2); + FrameClock.setManual(true); + } + + @After + public void tearDown() { + FrameClock.reset(); + Units.setScale(0); + } + + /// Records every distance it is asked to move, up to a limit. + private static final class Track implements Fling.Target { + double position; + double limit = Double.MAX_VALUE; + + @Override + public boolean flingBy(double delta) { + double to = Math.min(limit, position + delta); + boolean whole = to == position + delta; + position = to; + return whole; + } + } + + private static void drag(Fling fling, double from, double perFrame, int frames) { + for (int i = 0; i <= frames; i++) { + fling.track(from + i * perFrame); + FrameClock.advance(16); + } + } + + @Test + public void aQuickDragGlidesOnAndSlowsToAStop() { + Track t = new Track(); + Fling fling = new Fling(t); + // 1 pixel per millisecond. + drag(fling, 0, 16, 6); + fling.release(); + assertTrue(fling.isRunning()); + FrameClock.advance(16); + double first = t.position; + assertEquals(16 * 0.984, first, 1); + FrameClock.advance(16); + double second = t.position - first; + assertTrue("the glide slows: " + first + " then " + second, second < first); + for (int i = 0; i < 400 && fling.isRunning(); i++) { + FrameClock.advance(16); + } + assertFalse(fling.isRunning()); + // v / -ln(0.998), about 500 pixels for 1 pixel per millisecond. + assertEquals(500, t.position, 15); + } + + @Test + public void aFingerThatPausedBeforeLiftingDoesNotGlide() { + Track t = new Track(); + Fling fling = new Fling(t); + drag(fling, 0, 16, 6); + FrameClock.advance(200); + fling.release(); + assertFalse(fling.isRunning()); + assertEquals(0, t.position, 0); + } + + @Test + public void theGlideStopsAtTheEndAndOnANewPress() { + Track t = new Track(); + t.limit = 40; + Fling fling = new Fling(t); + drag(fling, 0, 16, 6); + fling.release(); + for (int i = 0; i < 10; i++) { + FrameClock.advance(16); + } + assertFalse(fling.isRunning()); + assertEquals(40, t.position, 0); + + Track u = new Track(); + Fling again = new Fling(u); + drag(again, 0, 16, 6); + again.release(); + FrameClock.advance(16); + again.stop(); + double at = u.position; + FrameClock.advance(16); + assertEquals(at, u.position, 0); + } + + @Test + public void aScrollPaneGlidesAfterATouchPan() { + Pane content = new Pane(); + content.setPrefSize(200, 4000); + ScrollPane pane = new ScrollPane(content); + pane.setPannable(true); + Scene scene = new Scene(pane, 200, 400); + scene.cn1Layout(200, 400); + scene.cn1Pointer(MouseEvent.MOUSE_PRESSED, 100, 350, MouseButton.PRIMARY); + for (int i = 1; i <= 6; i++) { + FrameClock.advance(16); + scene.cn1Pointer(MouseEvent.MOUSE_DRAGGED, 100, 350 - 16 * i, MouseButton.PRIMARY); + } + scene.cn1Pointer(MouseEvent.MOUSE_RELEASED, 100, 254, MouseButton.PRIMARY); + double released = pane.getVvalue(); + assertTrue(released > 0); + for (int i = 0; i < 20; i++) { + FrameClock.advance(16); + } + assertTrue("the pane glides on: " + released + " then " + pane.getVvalue(), + pane.getVvalue() > released + 0.02); + } +} From 3a680315d901f0dfe5ebec95e636fb31acb9dd8d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:36:55 +0200 Subject: [PATCH 14/27] javafx-compat: clip to a shape's outline, fit text fields and lists to their CSS, auto-hide touch scroll bars; JavaSE measures text unzoomed Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/impl/javase/JavaSEPort.java | 21 ++++- .../fxcompat/runtime/ScrollIndicator.java | 79 +++++++++++++++++++ .../src/main/java/javafx/scene/Node.java | 8 ++ .../java/javafx/scene/control/ListView.java | 13 ++- .../java/javafx/scene/control/RowFlow.java | 4 +- .../java/javafx/scene/control/ScrollPane.java | 16 +++- .../scene/control/TextInputControl.java | 38 +++++++++ .../com/codename1/fxcompat/ShapeClipTest.java | 63 +++++++++++++++ 8 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ScrollIndicator.java create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShapeClipTest.java diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 8c0f7eb05ad..6cc55dc5c7a 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -15213,11 +15213,26 @@ public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { return stringWidth(nativeFont, new String(ch, offset, length)); } + /** + * The context text is measured in: the screen's hints without its zoom. + * Codename One lays text out in device pixels, and the screen buffer is + * scaled by the simulator's zoom; measured through that transform, every + * glyph advance was rounded at the zoomed size, so a string came out up + * to a pixel per glyph narrower than it draws anywhere unzoomed -- in a + * mutable image, a screenshot, a device. Wrapped text then ran past the + * edge it was wrapped for, and a word drawn after another overlapped + * the space between them. + */ + private FontRenderContext measureFRC() { + FontRenderContext screen = canvas.getFRC(); + return new FontRenderContext(null, screen.getAntiAliasingHint(), screen.getFractionalMetricsHint()); + } + private Rectangle2D getStringBoundsWithEmojis(java.awt.Font font, String str) { if (hasUnsupportedChars(font, str)) { - return mixedFontBounds(createAttributedString(font, str).getIterator(), canvas.getFRC()); + return mixedFontBounds(createAttributedString(font, str).getIterator(), measureFRC()); } else { - return font.getStringBounds(str, canvas.getFRC()); + return font.getStringBounds(str, measureFRC()); } } @@ -15398,7 +15413,7 @@ public int charWidth(Object nativeFont, char ch) { if (isEmojiFontLoaded() && !Character.isHighSurrogate(ch) && !fnt.canDisplay(ch)) { fnt = deriveEmojiFont(fnt.getSize2D()); } - int w = (int) Math.ceil(fnt.getStringBounds(strch, canvas.getFRC()).getWidth()); + int w = (int) Math.ceil(fnt.getStringBounds(strch, measureFRC()).getWidth()); return w; } diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ScrollIndicator.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ScrollIndicator.java new file mode 100644 index 00000000000..99601335b17 --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/ScrollIndicator.java @@ -0,0 +1,79 @@ +/* + * 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.fxcompat.runtime; + +import com.codename1.ui.Display; +import javafx.scene.Node; + +/// When the scroll bars of a view show. On a desktop they always do, as +/// JavaFX's do; on a touch screen they show while the content moves and +/// go a moment after it stops, as a phone's scroll indicator does, rather +/// than sitting over the content for good. +public final class ScrollIndicator implements FrameClock.Pulse { + + private static final long SHOWN_NANOS = 700000000L; + + private final Node[] bars; + private long until; + private boolean waiting; + private double last = Double.NaN; + + /// The bars this indicator shows and hides. + public ScrollIndicator(Node... bars) { + this.bars = bars; + } + + private static boolean touch() { + return Display.isInitialized() && !Display.getInstance().isDesktop(); + } + + /// Called by the view's layout with how far it is scrolled; answers + /// whether its bars are to show now. + public boolean at(double offset) { + if (!touch()) { + return true; + } + // The first layout places the view, it does not scroll it. + if (!Double.isNaN(last) && Double.compare(last, offset) != 0) { + until = FrameClock.nowNanos() + SHOWN_NANOS; + if (!waiting) { + waiting = true; + FrameClock.add(this); + } + } + last = offset; + return waiting; + } + + @Override + public void pulse(long nowNanos) { + if (nowNanos < until) { + return; + } + waiting = false; + FrameClock.remove(this); + for (int i = 0; i < bars.length; i++) { + bars[i].setVisible(false); + } + } +} diff --git a/maven/javafx-compat/src/main/java/javafx/scene/Node.java b/maven/javafx-compat/src/main/java/javafx/scene/Node.java index fca1e55b47a..c7e270d4380 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/Node.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/Node.java @@ -1273,6 +1273,14 @@ public final FxPath cn1ClipPath() { if (by == null) { return null; } + if (by instanceof javafx.scene.shape.Shape) { + // A shape clips to its outline, not to its box: a circle over + // a photo is how an avatar is made round. + FxPath outline = ((javafx.scene.shape.Shape) by).cn1Outline(); + if (!outline.isEmpty()) { + return outline.transformed(by.localToParentMatrix()); + } + } Bounds b = by.getBoundsInParent(); FxPath path = new FxPath(); path.addRect(b.getMinX(), b.getMinY(), Math.max(0, b.getWidth()), Math.max(0, b.getHeight())); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/ListView.java b/maven/javafx-compat/src/main/java/javafx/scene/control/ListView.java index bb26e5e3eb9..87816a72c91 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/ListView.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/ListView.java @@ -42,10 +42,6 @@ import javafx.scene.input.MouseEvent; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; -import javafx.scene.layout.Border; -import javafx.scene.layout.BorderStroke; -import javafx.scene.layout.BorderStrokeStyle; -import javafx.scene.layout.BorderWidths; import javafx.scene.layout.CornerRadii; import javafx.scene.paint.Color; import javafx.util.Callback; @@ -109,9 +105,12 @@ public ListView() { /// Creates a list of some items. public ListView(ObservableList items) { getStyleClass().add("list-view"); - setBackground(new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY))); - setBorder(new Border(new BorderStroke(Color.rgb(200, 200, 200), BorderStrokeStyle.SOLID, CornerRadii.EMPTY, - new BorderWidths(1)))); + // The standard theme's frame is the outer of two fills, not a + // border, so a style sheet that gives the list a background of its + // own takes the frame away with it, as in JavaFX. + setBackground(new Background(new BackgroundFill(Color.rgb(200, 200, 200), CornerRadii.EMPTY, Insets.EMPTY), + new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, new Insets(1)))); + setPadding(new Insets(1)); flow = new RowFlow(new FlowRows()); flow.stripes = true; cn1Children().add(flow); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java index b59dc7c57a1..21a4647d9ef 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java @@ -88,6 +88,7 @@ interface Rows { private double pressOffset; private boolean dragging; private int pendingRow = -1; + private final com.codename1.fxcompat.runtime.ScrollIndicator indicator; /// Glides on after a touch scroll, as a phone's list does. private final com.codename1.fxcompat.runtime.Fling fling = new com.codename1.fxcompat.runtime.Fling( new com.codename1.fxcompat.runtime.Fling.Target() { @@ -107,6 +108,7 @@ public boolean flingBy(double delta) { thumb.setBackground(new Background(new BackgroundFill(Color.color(0, 0, 0, 0.35), new CornerRadii(2), Insets.EMPTY))); cn1Children().add(thumb); + indicator = new com.codename1.fxcompat.runtime.ScrollIndicator(thumb); addEventHandler(ScrollEvent.SCROLL, new EventHandler() { @Override public void handle(ScrollEvent event) { @@ -359,7 +361,7 @@ protected void layoutChildren() { if (max > 0 && h > 0) { double total = count * rh; double th = Math.max(16, total > 0 ? h * h / total : h); - thumb.setVisible(true); + thumb.setVisible(indicator.at(offset)); thumb.resizeRelocate(w - 5, offset / max * (h - th), 3, th); } else { thumb.setVisible(false); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java b/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java index 04e2c0a88da..7f3d6227e29 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java @@ -125,6 +125,10 @@ public enum ScrollBarPolicy { private final Viewport viewport = new Viewport(); private final Region hbar = thumb(); private final Region vbar = thumb(); + private final com.codename1.fxcompat.runtime.ScrollIndicator indicatorX = + new com.codename1.fxcompat.runtime.ScrollIndicator(hbar); + private final com.codename1.fxcompat.runtime.ScrollIndicator indicatorY = + new com.codename1.fxcompat.runtime.ScrollIndicator(vbar); private double contentWidth; private double contentHeight; private double pressX; @@ -333,13 +337,17 @@ private void placeContent() { || old.getHeight() != vh) { viewportBounds.set(new BoundingBox(-ox, -oy, vw, vh)); } - placeThumb(hbar, true, getHbarPolicy(), extentX, vw, vh, contentWidth, ox); - placeThumb(vbar, false, getVbarPolicy(), extentY, vh, vw, contentHeight, oy); + // Both are read every time: each records where it last saw the pane. + boolean movedX = indicatorX.at(ox); + boolean movedY = indicatorY.at(oy); + placeThumb(hbar, true, getHbarPolicy(), extentX, vw, vh, contentWidth, ox, movedX); + placeThumb(vbar, false, getVbarPolicy(), extentY, vh, vw, contentHeight, oy, movedY); } private void placeThumb(Region bar, boolean horizontal, ScrollBarPolicy policy, double extent, double along, - double across, double contentLength, double scrolled) { - boolean shown = policy == ScrollBarPolicy.ALWAYS || (policy != ScrollBarPolicy.NEVER && extent > 0.5); + double across, double contentLength, double scrolled, boolean moving) { + boolean shown = policy == ScrollBarPolicy.ALWAYS + || (policy != ScrollBarPolicy.NEVER && extent > 0.5 && moving); double track = along - 2 * BAR_GAP; if (!shown || !(track > 0)) { bar.setVisible(false); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/TextInputControl.java b/maven/javafx-compat/src/main/java/javafx/scene/control/TextInputControl.java index fb09016c497..acb3d43b9e2 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/TextInputControl.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/TextInputControl.java @@ -104,6 +104,7 @@ public abstract class TextInputControl extends Control { private Paint textFill; private Paint promptTextFill; private boolean fillShown; + private boolean ownBox; private int themeFill; private int themeOpacity; private boolean syncing; @@ -113,6 +114,28 @@ public abstract class TextInputControl extends Control { /// Creates a text input control. protected TextInputControl() { getStyleClass().add("text-input"); + javafx.beans.InvalidationListener chrome = new javafx.beans.InvalidationListener() { + @Override + public void invalidated(javafx.beans.Observable observable) { + cn1Invalidated(Dirty.NATIVE); + } + }; + backgroundProperty().addListener(chrome); + borderProperty().addListener(chrome); + } + + /// Whether the style sheet gives this control a background or a + /// border of its own. That is its whole box in JavaFX, so the native + /// field then draws only its text: with the theme's box as well, a + /// field styled to sit borderless inside a rounded search bar showed + /// a second box of its own. + private boolean drawsOwnBox() { + javafx.scene.layout.Background b = getBackground(); + if (b != null && !b.getFills().isEmpty()) { + return true; + } + javafx.scene.layout.Border border = getBorder(); + return border != null && !border.getStrokes().isEmpty(); } /// Returns the listener a subclass adds to its native text component: @@ -143,6 +166,14 @@ protected void cn1SyncNative() { return; } com.codename1.ui.TextArea area = (com.codename1.ui.TextArea) c; + boolean own = drawsOwnBox(); + if (ownBox && !own) { + // Setting the UIID again brings the theme's box back, and the + // theme's text colour with it. + area.setUIID(area.getUIID()); + fillShown = false; + } + ownBox = own; syncing = true; try { String t = getText(); @@ -163,6 +194,13 @@ protected void cn1SyncNative() { area.setEditable(isEditable()); } Style style = com.codename1.fxcompat.runtime.PeerPaint.allStyles(area); + if (own) { + style.setBgTransparency(0); + style.setBorder(com.codename1.ui.plaf.Border.createEmpty()); + // The control's own padding insets the field. + style.setPaddingUnit(Style.UNIT_TYPE_PIXELS); + style.setPadding(0, 0, 0, 0); + } if (textFill instanceof Color) { if (!fillShown) { // Remembered so that withdrawing the fill shows the diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShapeClipTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShapeClipTest.java new file mode 100644 index 00000000000..7daee14490c --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShapeClipTest.java @@ -0,0 +1,63 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.codename1.fxcompat.runtime.FxPath; +import javafx.scene.image.ImageView; +import javafx.scene.layout.Pane; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Rectangle; +import org.junit.Test; + +/// A clip that is a shape clips to the shape's outline, not to its box. +public class ShapeClipTest { + + @Test + public void aCircleClipLeavesTheCornersOut() { + ImageView photo = new ImageView(); + photo.setClip(new Circle(50, 50, 50)); + FxPath clip = photo.cn1ClipPath(); + assertTrue(clip.contains(50, 50)); + assertTrue(clip.contains(50, 2)); + assertFalse("the corner of the box lies outside the circle", clip.contains(4, 4)); + assertFalse(clip.contains(96, 96)); + } + + @Test + public void theClipShapeIsPlacedByItsOwnTranslation() { + Pane node = new Pane(); + Rectangle r = new Rectangle(0, 0, 20, 20); + r.setArcWidth(20); + r.setArcHeight(20); + r.setLayoutX(30); + r.setTranslateY(10); + node.setClip(r); + FxPath clip = node.cn1ClipPath(); + assertTrue(clip.contains(40, 20)); + assertFalse("outside the rounded corner", clip.contains(31, 11)); + assertFalse("where the shape would be without its translation", clip.contains(10, 10)); + } +} From 9520e5ecf00b4061f5a85e44964bff5d08bd570d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:13:09 +0200 Subject: [PATCH 15/27] javafx-compat: keep an opaque region's shadow when a change inside it moves nothing A press on a tab button re-captured and re-blurred the whole shadowed navigation drawer, on press and on release. A shadow is made from the outline of what is drawn, so a paint-only change (colour, opacity, pressed state) inside a region whose opaque, square, unclipped background fill covers its box cannot change it. Measured in the new test: 5 presses made 5 captures before, 0 after. Co-Authored-By: Claude Opus 5.5 --- .../codename1/fxcompat/runtime/Effects.java | 70 ++++++++++++++++++- .../src/main/java/javafx/scene/Node.java | 5 +- .../codename1/fxcompat/ShadowEffectTest.java | 48 +++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java index 4b90112e739..f0ede2b7fad 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java @@ -26,11 +26,18 @@ import com.codename1.ui.Graphics; import com.codename1.ui.Image; +import java.util.List; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.effect.BlurType; import javafx.scene.effect.DropShadow; import javafx.scene.effect.Effect; import javafx.scene.effect.InnerShadow; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.CornerRadii; +import javafx.scene.layout.Region; import javafx.scene.paint.Color; /// Draws the two effects the layer draws: the shadow a [DropShadow] casts @@ -91,15 +98,76 @@ public static boolean has(Node node) { /// Drops the pictures kept for a node and for everything above it, /// because what they show has changed. public static void invalidate(Node node) { + invalidate(node, false); + } + + /// Drops the pictures kept for a node and for everything above it. + /// `paintOnly` says the change moved and resized nothing (a colour, a + /// pressed state): a shadow is made from the outline of what is drawn, + /// so an ancestor whose own background already covers its whole box + /// keeps its picture when the change lies inside that box. A press on + /// a button in a shadowed drawer re-blurred the whole drawer before. + public static void invalidate(Node node, boolean paintOnly) { if (live <= 0) { return; } - for (Node n = node; n != null; n = n.getParent()) { + Bounds within = null; + boolean contained = paintOnly; + Node below = null; + for (Node n = node; n != null; below = n, n = n.getParent()) { + if (contained && below != null) { + // The changed node's box in this node's coordinates. + within = within == null ? below.getBoundsInParent() : below.localToParent(within); + } if (n.cn1EffectCache() != null) { + if (n != node && contained && within != null && coversItsBox(n) && inside(within, n)) { + continue; + } n.cn1SetEffectCache(null); live--; } + // What a node with its own effect draws reaches past its box. + if (n.getEffect() != null) { + contained = false; + } + } + } + + /// Whether a node's outline is its box: a region filled edge to edge + /// with an opaque colour, square cornered, unclipped and unshaped. + private static boolean coversItsBox(Node n) { + if (!(n instanceof Region) || n.getClip() != null || n.cn1PaintMatrix() != null) { + return false; } + Region r = (Region) n; + if (r.getShape() != null) { + return false; + } + Background bg = r.getBackground(); + if (bg == null) { + return false; + } + List fills = bg.getFills(); + for (int i = 0; i < fills.size(); i++) { + BackgroundFill f = fills.get(i); + Insets in = f.getInsets(); + CornerRadii c = f.getRadii(); + boolean square = c == null || (c.isUniform() && c.getTopLeftHorizontalRadius() == 0 + && c.getTopLeftVerticalRadius() == 0); + boolean edge = in == null || (in.getTop() <= 0 && in.getRight() <= 0 && in.getBottom() <= 0 + && in.getLeft() <= 0); + if (f.getFill() instanceof Color && ((Color) f.getFill()).isOpaque() && square && edge) { + return true; + } + } + return false; + } + + /// Whether bounds in a node's own coordinates lie within its box. + private static boolean inside(Bounds b, Node n) { + Bounds box = n.getLayoutBounds(); + return b.getMinX() >= box.getMinX() && b.getMinY() >= box.getMinY() && b.getMaxX() <= box.getMaxX() + && b.getMaxY() <= box.getMaxY(); } private static int passes(BlurType type) { diff --git a/maven/javafx-compat/src/main/java/javafx/scene/Node.java b/maven/javafx-compat/src/main/java/javafx/scene/Node.java index c7e270d4380..9157c23f672 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/Node.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/Node.java @@ -427,7 +427,10 @@ private static void snapshot(Renderer r, Node node) { @Override public void cn1Invalidated(int what) { // Whatever changed, the picture an effect above keeps of it is old. - Effects.invalidate(this); + // A change that moved and resized nothing leaves the outline of an + // opaque shadowed ancestor as it was; see Effects.invalidate. + Effects.invalidate(this, (what & (Dirty.LAYOUT | Dirty.BOUNDS | Dirty.GEOMETRY | Dirty.NATIVE + | VISIBILITY)) == 0); if ((what & VISIBILITY) != 0 && peer != null) { peer.setVisible(isVisible()); } diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java index eb299e74b50..8338ee8c279 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java @@ -214,6 +214,54 @@ public void aNodeDrawsItsShadowAndKeepsThePictureUntilSomethingUnderItChanges() assertNull(card.cn1EffectCache()); } + @Test + public void aChangeInsideAnOpaqueShadowedRegionKeepsItsPicture() { + Rectangle button = new Rectangle(10, 10, 30, 20); + button.setFill(Color.RED); + Pane drawer = new Pane(button); + drawer.setStyle("-fx-background-color: white;"); + drawer.setPrefSize(120, 60); + drawer.relocate(40, 40); + Stage stage = new Stage(); + stage.setScene(new Scene(new Group(drawer), 300, 200)); + stage.show(); + drawer.applyCss(); + drawer.setEffect(new DropShadow(BlurType.GAUSSIAN, Color.BLACK, 6, 0, 2, 3)); + paint(); + int made = Effects.captureCount(); + + // A press: the colour changes, the outline does not. + for (int i = 0; i < 5; i++) { + button.setFill(i % 2 == 0 ? Color.BLUE : Color.RED); + button.setOpacity(i % 2 == 0 ? 0.5 : 1); + paint(); + } + assertEquals("the drawer is not captured again", made, Effects.captureCount()); + + // A child that reaches past the drawer changes its outline. + button.setWidth(200); + paint(); + assertEquals(made + 1, Effects.captureCount()); + // And while it does, even a colour change there is in the outline. + button.setFill(Color.GREEN); + paint(); + assertEquals(made + 2, Effects.captureCount()); + // Hiding it changes the outline too. + button.setVisible(false); + paint(); + assertEquals(made + 3, Effects.captureCount()); + // A transparent background has no box of its own to hide behind. + button.setVisible(true); + button.setWidth(30); + drawer.setStyle("-fx-background-color: transparent;"); + drawer.applyCss(); + paint(); + int clear = Effects.captureCount(); + button.setFill(Color.BLUE); + paint(); + assertEquals(clear + 1, Effects.captureCount()); + } + @Test public void aStyleSheetsEffectIsADropShadowOrAnInnerShadow() { Pane card = new Pane(); From 14620ce9a9ff1befb7413873118acf4c30a1c151 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:15:19 +0200 Subject: [PATCH 16/27] javafx-compat: a list's scroll moves its rows without relaying out the scene RowFlow.scrollTo called requestLayout on every drag or fling frame, which cleared the size caches of every ancestor up to the scene and laid each of them out again. A new offset changes no size: the flow now marks only itself and asks for a pulse, whose layout pass walks the tree and finds it. Measured in the new test: 10 drag frames laid out the list's holder 10 times before, 0 after. ScrollPane already only relocated its content; the test holds that too (0 content or row layouts over 20 offset changes). Co-Authored-By: Claude Opus 5.5 --- .../java/javafx/scene/control/RowFlow.java | 12 +- .../fxcompat/ScrollWithoutRelayoutTest.java | 126 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/ScrollWithoutRelayoutTest.java diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java index 21a4647d9ef..19d5bfa2f2e 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java @@ -28,6 +28,7 @@ import javafx.geometry.Insets; import javafx.geometry.Point2D; import javafx.scene.Node; +import javafx.scene.Scene; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; import javafx.scene.layout.Background; @@ -205,7 +206,16 @@ void scrollTo(double value) { double v = value < 0 || Double.isNaN(value) ? 0 : (value > max ? max : value); if (Double.compare(v, offset) != 0) { offset = v; - requestLayout(); + // A new offset moves rows and fills the ones that came into + // view; it changes no size, so the ancestors are not asked to + // lay out again (requestLayout would clear every size cache up + // to the scene on each frame of a drag). The pulse's layout + // pass walks the whole tree and finds this flow marked. + cn1MarkNeedsLayout(); + Scene s = getScene(); + if (s != null) { + s.cn1RequestPulse(); + } } } diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ScrollWithoutRelayoutTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ScrollWithoutRelayoutTest.java new file mode 100644 index 00000000000..959d6e32263 --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ScrollWithoutRelayoutTest.java @@ -0,0 +1,126 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.VBox; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/// Scrolling moves the content; it does not lay it out or style it again. +public class ScrollWithoutRelayoutTest { + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + private int layouts; + + @Before + public void setUp() { + HeadlessImplementation.install(); + } + + private int rowLayouts; + + private final class Rows extends VBox { + @Override + protected void layoutChildren() { + layouts++; + super.layoutChildren(); + } + } + + private final class Row extends Label { + Row(String text) { + super(text); + } + + @Override + protected void layoutChildren() { + rowLayouts++; + super.layoutChildren(); + } + } + + @Test + public void draggingAndSettingTheValueOnlyMoveTheContent() { + Rows rows = new Rows(); + for (int i = 0; i < 60; i++) { + rows.getChildren().add(new Row("Row " + i)); + } + ScrollPane pane = new ScrollPane(rows); + pane.setPannable(true); + Scene scene = new Scene(pane, 300, 300); + scene.cn1Layout(300, 300); + scene.cn1Layout(300, 300); + layouts = 0; + rowLayouts = 0; + double before = rows.getLayoutY(); + + scene.cn1Pointer(MouseEvent.MOUSE_PRESSED, 150, 250, MouseButton.PRIMARY); + for (int i = 1; i <= 10; i++) { + scene.cn1Pointer(MouseEvent.MOUSE_DRAGGED, 150, 250 - 15 * i, MouseButton.PRIMARY); + scene.cn1Layout(300, 300); + } + scene.cn1Pointer(MouseEvent.MOUSE_RELEASED, 150, 100, MouseButton.PRIMARY); + for (int i = 1; i <= 10; i++) { + pane.setVvalue(i / 10.0); + scene.cn1Layout(300, 300); + } + assertTrue("the content moved", rows.getLayoutY() < before); + assertEquals("the content is not laid out again", 0, layouts); + assertEquals("nor are its rows", 0, rowLayouts); + } + + @Test + public void draggingAListMovesItsRowsWithoutLayingOutWhatHoldsIt() { + ListView list = new ListView(); + for (int i = 0; i < 200; i++) { + list.getItems().add("Item " + i); + } + Rows holder = new Rows(); + holder.getChildren().add(list); + Scene scene = new Scene(holder, 300, 300); + scene.cn1Layout(300, 300); + scene.cn1Layout(300, 300); + layouts = 0; + scene.cn1Pointer(MouseEvent.MOUSE_PRESSED, 150, 250, MouseButton.PRIMARY); + for (int i = 1; i <= 10; i++) { + scene.cn1Pointer(MouseEvent.MOUSE_DRAGGED, 150, 250 - 15 * i, MouseButton.PRIMARY); + scene.cn1Layout(300, 300); + } + scene.cn1Pointer(MouseEvent.MOUSE_RELEASED, 150, 100, MouseButton.PRIMARY); + assertEquals("what holds the list is not laid out again", 0, layouts); + } +} From 099f4ff04d1c9137452eaaf56ce5cf0ed6752294 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:13:11 +0200 Subject: [PATCH 17/27] javafx-compat: keep a phone's scene out of the status bar and fill the strip with its background Co-Authored-By: Claude Opus 5.5 --- .../codename1/fxcompat/runtime/HostCore.java | 50 +++++++++++++++++-- .../codename1/fxcompat/runtime/StageForm.java | 5 ++ .../codename1/fxcompat/NarrowScreenTest.java | 16 ++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/HostCore.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/HostCore.java index 984ac512390..7a99aa3c9c2 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/HostCore.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/HostCore.java @@ -38,6 +38,8 @@ import javafx.scene.Parent; import javafx.scene.Scene; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; import javafx.scene.layout.Region; import javafx.stage.Window; @@ -101,8 +103,17 @@ public void layoutContainer(Container parent) { if (rootPeer == null) { return; } - int w = parent.getWidth(); - int h = parent.getHeight(); + // Inside the pane's padding, which is where a safe area puts + // the status bar and the notch. + com.codename1.ui.plaf.Style ps = parent.getStyle(); + int left = ps.getPaddingLeftNoRTL(); + int top = ps.getPaddingTop(); + int w = parent.getWidth() - left - ps.getPaddingRightNoRTL(); + // The bottom inset is left to the scene, as it always was: a + // phone app's bottom bar runs to the edge and pads itself, and + // cut short above the home indicator it left a band of form. + int h = parent.getHeight() - top; + fillInsets(parent); if (w <= 0 || h <= 0) { return; } @@ -153,12 +164,43 @@ public void layoutContainer(Container parent) { } parent.setScrollableY(overY); } - rootPeer.setX(0); - rootPeer.setY(0); + rootPeer.setX(left); + rootPeer.setY(top); rootPeer.setWidth(overX ? least.getWidth() : w); rootPeer.setHeight(overY ? least.getHeight() : h); } + /// Paints the pane, which the safe area's padding uncovers above + /// the scene, in the scene's fill, so that the strip under the + /// status bar continues the scene instead of showing the form. + private void fillInsets(Container parent) { + // What shows at the top of the scene is its root's background, + // when it has one, and the scene's fill under that. + Paint fill = null; + if (scene != null && scene.getRoot() instanceof Region) { + javafx.scene.layout.Background bg = ((Region) scene.getRoot()).getBackground(); + if (bg != null && !bg.getFills().isEmpty()) { + fill = bg.getFills().get(bg.getFills().size() - 1).getFill(); + } + } + if (!(fill instanceof Color) || !((Color) fill).isOpaque()) { + fill = scene == null ? null : scene.getFill(); + } + com.codename1.ui.plaf.Style st = parent.getUnselectedStyle(); + if (fill instanceof Color && ((Color) fill).isOpaque()) { + Color c = (Color) fill; + int rgb = ((int) Math.round(c.getRed() * 255) << 16) + | ((int) Math.round(c.getGreen() * 255) << 8) + | (int) Math.round(c.getBlue() * 255); + if (st.getBgColor() != rgb || st.getBgTransparency() != (byte) 0xff) { + st.setBgColor(rgb, true); + st.setBgTransparency(0xff, true); + } + } else if (st.getBgTransparency() != 0) { + st.setBgTransparency(0, true); + } + } + @Override public Dimension getPreferredSize(Container parent) { if (rootPeer == null) { diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageForm.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageForm.java index ffe19e54bab..dbad857a135 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageForm.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageForm.java @@ -79,6 +79,11 @@ public StageForm(Window window, boolean primary) { if (titleArea != null) { titleArea.setHidden(true); } + // With the title area hidden nothing kept the scene out of the + // status bar and the notch: on a phone its top row was drawn + // under the clock. A JavaFX scene knows nothing of either, so + // the window's content stays inside the safe area. + getContentPane().setSafeArea(true); } else { setBackCommand(new Command("Back") { @Override diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/NarrowScreenTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/NarrowScreenTest.java index 6b2df702ea1..0cf53bb2055 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/NarrowScreenTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/NarrowScreenTest.java @@ -110,6 +110,22 @@ public void aSceneThatFitsIsTheSizeOfTheFormAndDoesNotScroll() { assertEquals(null, new Pane().localToScreen(0, 0)); } + @Test + public void aSafeAreaTopInsetMovesTheSceneDownAndIsFilledWithTheRootsBackground() { + Pane root = new Pane(); + root.setStyle("-fx-background-color: #102030;"); + Container content = shown(root); + // A safe area is padding on the content pane, the status bar's + // height at the top; the bottom is left to the scene. + content.getUnselectedStyle().setPadding(40, 30, 0, 0); + content.revalidate(); + assertEquals(content.getHeight() - 40, root.getHeight(), 0.01); + assertEquals(content.getWidth(), root.getWidth(), 0.01); + assertEquals(40, content.getComponentAt(0).getY()); + assertEquals(0x102030, content.getUnselectedStyle().getBgColor()); + assertEquals((byte) 0xff, content.getUnselectedStyle().getBgTransparency()); + } + @Test public void aRootWiderThanTheFormAtItsLeastIsLaidOutThatWideAndScrolls() { Pane root = new Pane(); From 10b217e0ccec361022e8b475e7fe9e7eb7d08abc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:55:11 +0200 Subject: [PATCH 18/27] Revert every change outside the JavaFX layer to master The layer no longer needs them; the headless test harness answers a stored entry's size from its own map. Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 29 ++++++-- .../src/com/codename1/system/Lifecycle.java | 7 +- .../src/com/codename1/ui/Component.java | 24 +------ .../com/codename1/impl/javase/JavaSEPort.java | 28 +------- .../impl/linux/LinuxImplementation.java | 9 --- .../impl/windows/WindowsImplementation.java | 9 --- .../testing/HeadlessImplementation.java | 14 ++-- .../TestCodenameOneImplementation.java | 20 ------ .../ui/BackdropUnderOpaqueFillTest.java | 69 ------------------- .../HeadlessCssCompilerImplementation.java | 1 - .../flutter/testsupport/RasterDisplay.java | 5 -- scripts/website/syndication-state.json | 17 +++++ 12 files changed, 52 insertions(+), 180 deletions(-) delete mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/BackdropUnderOpaqueFillTest.java diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 28739046e97..b0534c53be2 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -8060,10 +8060,7 @@ public void clearStorage() { /// the names of all the storage files public abstract String[] listStorageEntries(); - /// Returns the size of the entry in bytes, asked of the platform's own - /// storage. Every implementation answers it directly: reading the entry - /// through to count it was what a missing override cost, and a large - /// cached download made that painfully slow. + /// Returns the size of the entry in bytes /// /// #### Parameters /// @@ -8071,9 +8068,27 @@ public void clearStorage() { /// /// #### Returns /// - /// the size; 0 or -1 when there is no such entry, so a caller that must - /// tell a missing entry from an empty one asks `storageFileExists` - public abstract int getStorageEntrySize(String name); + /// the size + public int getStorageEntrySize(String name) { + long size = -1; + InputStream i = null; //NOPMD CloseResource + try { + i = createStorageInputStream(name); + long val = i.skip(1000000); + if (val > -1) { + size = 0; + while (val > -1) { + size += val; + val = i.skip(1000000); + } + } + } catch (IOException err) { + Log.e(err); + } finally { + Util.cleanup(i); + } + return (int) size; + } /// Returns the filesystem roots from which the structure of the file system /// can be traversed diff --git a/CodenameOne/src/com/codename1/system/Lifecycle.java b/CodenameOne/src/com/codename1/system/Lifecycle.java index 64be7d7da77..8b29c39b509 100644 --- a/CodenameOne/src/com/codename1/system/Lifecycle.java +++ b/CodenameOne/src/com/codename1/system/Lifecycle.java @@ -108,15 +108,10 @@ public Resources getTheme() { protected void handleNetworkError(NetworkEvent err) { // prevent the event from propagating err.consume(); - // Only a thrown exception is an application failure worth a crash log. - // An HTTP error code (404, 500...) is the server's answer, not a bug in - // the app, so it must not send the log: sending registers the device, - // which in the simulator asks for the build account with a modal - // prompt and used to block the EDT on a plain 404. if (err.getError() != null) { Log.e(err.getError()); - Log.sendLogAsync(); } + Log.sendLogAsync(); Dialog.show("Connection Error", "There was a networking error in the connection to " + err.getConnectionRequest().getUrl(), "OK", null); } diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index 1f8b262836a..3b00374f7d1 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -3194,7 +3194,7 @@ final void paintBackgroundLayerAt(Graphics g, int x, int y, int w, int h) { private void paintBackgroundLayerImpl(Graphics g) { float backdropBlur = getStyle().getBackdropFilterBlurRadius(); - if (backdropBlur > 0 && !opaqueFillHidesBackdrop()) { + if (backdropBlur > 0) { // The glass surface's material INTENT comes from a typed, named // recipe (GlassRecipe -- plain blur, chrome bar, floating pill, // glass panel), resolved per UIID via GlassRecipe / @@ -3237,28 +3237,6 @@ private void paintBackgroundLayerImpl(Graphics g) { paintComponentBackground(g); } - /// Whether this component's own background paints every pixel of its - /// bounds opaquely, so a backdrop blurred under it could never be seen. - /// A theme can give an opaque surface a backdrop filter -- the iOS theme - /// does for `TextField` -- and the blur is then computed on every repaint - /// for nothing: one field in a scrolling screen spent most of the frame - /// in it. Only a plain fill or a `RoundRectBorder`, which fills from the - /// same style colour and opacity over the same shape the glass takes, is - /// trusted; an image, a gradient or any other border keeps the blur. - private boolean opaqueFillHidesBackdrop() { - Style s = getStyle(); - // With no image, every image type paints the plain fill; only the - // gradients paint something else. - byte type = s.getBackgroundType(); - boolean gradient = (type >= Style.BACKGROUND_GRADIENT_LINEAR_VERTICAL && type <= Style.BACKGROUND_GRADIENT_RADIAL) - || type >= Style.BACKGROUND_GRADIENT_LINEAR; - if (s.getBgTransparency() != (byte) 0xff || s.getBgImage() != null || gradient) { - return false; - } - Border b = s.getBorder(); - return b == null || !b.isBackgroundPainter() || b instanceof RoundRectBorder; - } - void internalPaintImpl(Graphics g, boolean paintIntersects) { g.clipRect(getX(), getY(), getWidth(), getHeight()); // CSS backdrop-filter:blur() -- the "liquid glass" effect. Blur whatever has diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 6cc55dc5c7a..19ad187b7e8 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -15213,26 +15213,11 @@ public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { return stringWidth(nativeFont, new String(ch, offset, length)); } - /** - * The context text is measured in: the screen's hints without its zoom. - * Codename One lays text out in device pixels, and the screen buffer is - * scaled by the simulator's zoom; measured through that transform, every - * glyph advance was rounded at the zoomed size, so a string came out up - * to a pixel per glyph narrower than it draws anywhere unzoomed -- in a - * mutable image, a screenshot, a device. Wrapped text then ran past the - * edge it was wrapped for, and a word drawn after another overlapped - * the space between them. - */ - private FontRenderContext measureFRC() { - FontRenderContext screen = canvas.getFRC(); - return new FontRenderContext(null, screen.getAntiAliasingHint(), screen.getFractionalMetricsHint()); - } - private Rectangle2D getStringBoundsWithEmojis(java.awt.Font font, String str) { if (hasUnsupportedChars(font, str)) { - return mixedFontBounds(createAttributedString(font, str).getIterator(), measureFRC()); + return mixedFontBounds(createAttributedString(font, str).getIterator(), canvas.getFRC()); } else { - return font.getStringBounds(str, measureFRC()); + return font.getStringBounds(str, canvas.getFRC()); } } @@ -15413,7 +15398,7 @@ public int charWidth(Object nativeFont, char ch) { if (isEmojiFontLoaded() && !Character.isHighSurrogate(ch) && !fnt.canDisplay(ch)) { fnt = deriveEmojiFont(fnt.getSize2D()); } - int w = (int) Math.ceil(fnt.getStringBounds(strch, measureFRC()).getWidth()); + int w = (int) Math.ceil(fnt.getStringBounds(strch, canvas.getFRC()).getWidth()); return w; } @@ -16739,13 +16724,6 @@ public String getProperty(String key, String defaultValue) { String user = p.get("user", null); Display d = Display.getInstance(); if (user == null) { - // The prompt is modal. Asked for from the Codename One EDT (log - // sending after a network error reaches here) it froze the - // application until answered, and headless nobody can answer - // it. Answer the default instead, as an unregistered user does. - if (d.isEdt() || java.awt.GraphicsEnvironment.isHeadless()) { - return defaultValue; - } JPanel pnl = new JPanel(); JTextField tf = new JTextField(20); pnl.add(new JLabel("E-Mail For Push")); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 9566957a1e1..748cfa23e36 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -3480,15 +3480,6 @@ public InputStream createStorageInputStream(String name) throws IOException { return new LinuxInputStream(h, false); } - @Override - public int getStorageEntrySize(String name) { - String path = storagePath(name); - if (!LinuxNative.fileExists(path)) { - return -1; - } - return (int) LinuxNative.fileLength(path); - } - @Override public boolean storageFileExists(String name) { return LinuxNative.fileExists(storagePath(name)); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index d477a15578f..73ab1291934 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -3055,15 +3055,6 @@ public InputStream createStorageInputStream(String name) throws IOException { return new WindowsInputStream(h, false); } - @Override - public int getStorageEntrySize(String name) { - String path = storagePath(name); - if (!WindowsNative.fileExists(path)) { - return -1; - } - return (int) WindowsNative.fileLength(path); - } - @Override public boolean storageFileExists(String name) { return WindowsNative.fileExists(storagePath(name)); diff --git a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java index a3647169706..f826ca6f63b 100644 --- a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java +++ b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java @@ -1428,6 +1428,14 @@ public java.lang.String[] getHeaderFields(java.lang.String a0, java.lang.Object /// Storage held in memory for the life of the test JVM, so code that /// persists through com.codename1.io.Storage (the AndroidKeyStore) can be /// read back by a fresh instance. + /// The size of a stored entry, read off the map. The inherited answer + /// counts with skip(), which never ends on these in-memory streams. + @Override + public int getStorageEntrySize(java.lang.String a0) { + byte[] b = STORAGE.get(a0); + return b == null ? -1 : b.length; + } + public static final java.util.Map STORAGE = java.util.Collections.synchronizedMap(new java.util.TreeMap()); @@ -1456,12 +1464,6 @@ public java.io.InputStream createStorageInputStream(java.lang.String a0) throws return new java.io.ByteArrayInputStream(b); } - @Override - public int getStorageEntrySize(java.lang.String a0) { - byte[] b = STORAGE.get(a0); - return b == null ? -1 : b.length; - } - @Override public boolean storageFileExists(java.lang.String a0) { return STORAGE.containsKey(a0); diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index 3a89977a3de..12d0bf16acd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -215,26 +215,6 @@ public class TestCodenameOneImplementation extends CodenameOneImplementation { private int[] lastTitleBarRegions; private int[] nativeTitleBarInsets; - private int backdropBlurs; - - /// How many backdrop blurs (plain or glass) have been asked for. - public int getBackdropBlurCount() { - return backdropBlurs; - } - - @Override - public boolean blurRegion(Object graphics, int x, int y, int width, int height, float radius) { - backdropBlurs++; - return false; - } - - @Override - public boolean glassRegion(Object graphics, int x, int y, int width, int height, float radius, float cornerRadius, - float sat, float scale, float offset, float refract, float specular, float curve, float curveMid, float outline) { - backdropBlurs++; - return false; - } - public void setNativeTitleBarInsets(int... values) { nativeTitleBarInsets = values; } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/BackdropUnderOpaqueFillTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/BackdropUnderOpaqueFillTest.java deleted file mode 100644 index 4bfdcea1095..00000000000 --- a/maven/core-unittests/src/test/java/com/codename1/ui/BackdropUnderOpaqueFillTest.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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; - -import com.codename1.junit.FormTest; -import com.codename1.junit.UITestBase; -import com.codename1.ui.plaf.RoundRectBorder; -import com.codename1.ui.plaf.Style; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -/// A backdrop blur under a background that covers it is never computed. -class BackdropUnderOpaqueFillTest extends UITestBase { - - private int blursPainting(Label l) { - Image img = Image.createImage(100, 50); - l.setWidth(100); - l.setHeight(50); - int before = implementation.getBackdropBlurCount(); - l.paintComponent(img.getGraphics(), true); - return implementation.getBackdropBlurCount() - before; - } - - private Label blurred(int transparency) { - Label l = new Label("x"); - Style s = l.getAllStyles(); - s.setBackdropFilterBlurRadius(20); - s.setBgColor(0xffffff); - s.setBgTransparency(transparency); - return l; - } - - @FormTest - void opaqueFillSkipsTheBlur() { - assertEquals(0, blursPainting(blurred(255))); - } - - @FormTest - void opaqueRoundedFillSkipsTheBlur() { - Label l = blurred(255); - l.getAllStyles().setBorder(RoundRectBorder.create().cornerRadius(2)); - assertEquals(0, blursPainting(l)); - } - - @FormTest - void translucentFillKeepsTheBlur() { - assertEquals(1, blursPainting(blurred(128))); - } -} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java index da0fa007285..c0bbaa99b86 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java @@ -325,7 +325,6 @@ private static BufferedImage toArgb(BufferedImage src) { @Override public OutputStream createStorageOutputStream(String name) throws IOException { return null; } @Override public InputStream createStorageInputStream(String name) throws IOException { return null; } @Override public boolean storageFileExists(String name) { return false; } - @Override public int getStorageEntrySize(String name) { return -1; } @Override public String[] listStorageEntries() { return new String[0]; } @Override public String[] listFilesystemRoots() { return new String[0]; } @Override public String[] listFiles(String directory) throws IOException { return new String[0]; } diff --git a/maven/flutter-runtime/src/test/java/com/codename1/flutter/testsupport/RasterDisplay.java b/maven/flutter-runtime/src/test/java/com/codename1/flutter/testsupport/RasterDisplay.java index b92ecc464c3..d319a62f8d9 100644 --- a/maven/flutter-runtime/src/test/java/com/codename1/flutter/testsupport/RasterDisplay.java +++ b/maven/flutter-runtime/src/test/java/com/codename1/flutter/testsupport/RasterDisplay.java @@ -715,11 +715,6 @@ public InputStream createStorageInputStream(String name) throws IOException { throw new IOException("unsupported"); } - @Override - public int getStorageEntrySize(String name) { - return -1; - } - @Override public boolean storageFileExists(String name) { return false; diff --git a/scripts/website/syndication-state.json b/scripts/website/syndication-state.json index f6cc66d697b..fc73e3fcda8 100644 --- a/scripts/website/syndication-state.json +++ b/scripts/website/syndication-state.json @@ -3091,6 +3091,23 @@ "status": "submitted", "published": false, "syndicated_at": "2026-10-30T01:01:52+00:00" + }, + "devto": { + "id": 4949758, + "url": "https://dev.to/codenameone/rewrite-everything-in-java-not-rust-5co8", + "syndicated_at": "2026-10-30T22:46:41+00:00" + } + }, + "build-a-multiplayer-strategy-game-in-java": { + "hashnode": { + "url": "https://debugagent.com/skirmish-a-multiplayer-strategy-game-template-in-java", + "published": true, + "draft_url": "https://hashnode.com/draft/6ae53da2e6bf8b25f54d7f93", + "cover_set": true, + "subheading_set": true, + "tags_set": true, + "canonical_set": true, + "syndicated_at": "2026-10-31T01:01:14+00:00" } } } From d28ff4bc074a97990ece3c7c8123f260895d255f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:14:34 +0200 Subject: [PATCH 19/27] Revert "javafx-compat: a list's scroll moves its rows without relaying out the scene" Recycled rows were filled with their new item but never laid out again, so a row showed the previous item's description while scrolling. Co-Authored-By: Claude Opus 5.5 --- .../java/javafx/scene/control/RowFlow.java | 12 +- .../fxcompat/ScrollWithoutRelayoutTest.java | 126 ------------------ 2 files changed, 1 insertion(+), 137 deletions(-) delete mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/ScrollWithoutRelayoutTest.java diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java index 19d5bfa2f2e..21a4647d9ef 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java @@ -28,7 +28,6 @@ import javafx.geometry.Insets; import javafx.geometry.Point2D; import javafx.scene.Node; -import javafx.scene.Scene; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; import javafx.scene.layout.Background; @@ -206,16 +205,7 @@ void scrollTo(double value) { double v = value < 0 || Double.isNaN(value) ? 0 : (value > max ? max : value); if (Double.compare(v, offset) != 0) { offset = v; - // A new offset moves rows and fills the ones that came into - // view; it changes no size, so the ancestors are not asked to - // lay out again (requestLayout would clear every size cache up - // to the scene on each frame of a drag). The pulse's layout - // pass walks the whole tree and finds this flow marked. - cn1MarkNeedsLayout(); - Scene s = getScene(); - if (s != null) { - s.cn1RequestPulse(); - } + requestLayout(); } } diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ScrollWithoutRelayoutTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ScrollWithoutRelayoutTest.java deleted file mode 100644 index 959d6e32263..00000000000 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ScrollWithoutRelayoutTest.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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.fxcompat; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import com.codename1.compat.testing.HeadlessImplementation; -import com.codename1.compat.testing.MainThreadRule; -import javafx.scene.Scene; -import javafx.scene.control.Label; -import javafx.scene.control.ListView; -import javafx.scene.control.ScrollPane; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; -import javafx.scene.layout.VBox; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; - -/// Scrolling moves the content; it does not lay it out or style it again. -public class ScrollWithoutRelayoutTest { - @Rule - public final MainThreadRule mainThread = new MainThreadRule(); - - private int layouts; - - @Before - public void setUp() { - HeadlessImplementation.install(); - } - - private int rowLayouts; - - private final class Rows extends VBox { - @Override - protected void layoutChildren() { - layouts++; - super.layoutChildren(); - } - } - - private final class Row extends Label { - Row(String text) { - super(text); - } - - @Override - protected void layoutChildren() { - rowLayouts++; - super.layoutChildren(); - } - } - - @Test - public void draggingAndSettingTheValueOnlyMoveTheContent() { - Rows rows = new Rows(); - for (int i = 0; i < 60; i++) { - rows.getChildren().add(new Row("Row " + i)); - } - ScrollPane pane = new ScrollPane(rows); - pane.setPannable(true); - Scene scene = new Scene(pane, 300, 300); - scene.cn1Layout(300, 300); - scene.cn1Layout(300, 300); - layouts = 0; - rowLayouts = 0; - double before = rows.getLayoutY(); - - scene.cn1Pointer(MouseEvent.MOUSE_PRESSED, 150, 250, MouseButton.PRIMARY); - for (int i = 1; i <= 10; i++) { - scene.cn1Pointer(MouseEvent.MOUSE_DRAGGED, 150, 250 - 15 * i, MouseButton.PRIMARY); - scene.cn1Layout(300, 300); - } - scene.cn1Pointer(MouseEvent.MOUSE_RELEASED, 150, 100, MouseButton.PRIMARY); - for (int i = 1; i <= 10; i++) { - pane.setVvalue(i / 10.0); - scene.cn1Layout(300, 300); - } - assertTrue("the content moved", rows.getLayoutY() < before); - assertEquals("the content is not laid out again", 0, layouts); - assertEquals("nor are its rows", 0, rowLayouts); - } - - @Test - public void draggingAListMovesItsRowsWithoutLayingOutWhatHoldsIt() { - ListView list = new ListView(); - for (int i = 0; i < 200; i++) { - list.getItems().add("Item " + i); - } - Rows holder = new Rows(); - holder.getChildren().add(list); - Scene scene = new Scene(holder, 300, 300); - scene.cn1Layout(300, 300); - scene.cn1Layout(300, 300); - layouts = 0; - scene.cn1Pointer(MouseEvent.MOUSE_PRESSED, 150, 250, MouseButton.PRIMARY); - for (int i = 1; i <= 10; i++) { - scene.cn1Pointer(MouseEvent.MOUSE_DRAGGED, 150, 250 - 15 * i, MouseButton.PRIMARY); - scene.cn1Layout(300, 300); - } - scene.cn1Pointer(MouseEvent.MOUSE_RELEASED, 150, 100, MouseButton.PRIMARY); - assertEquals("what holds the list is not laid out again", 0, layouts); - } -} From 64ca97ca749cbdcb13bc81c696b3872cd2515b14 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:32:46 +0200 Subject: [PATCH 20/27] javafx-compat: rebuild a shadow picture after the paint, never during it Co-Authored-By: Claude Opus 5.5 --- .../codename1/fxcompat/runtime/Effects.java | 77 ++++++++++++++++++- .../codename1/fxcompat/ShadowEffectTest.java | 3 + 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java index f0ede2b7fad..cbf10411250 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java @@ -81,6 +81,15 @@ private static final class Kept { int behindX; int behindY; Image over; + /// What the pictures show has changed; they are drawn until new + /// ones are made after the frame. + boolean stale; + /// A rebuild after the frame is queued. + boolean scheduled; + + boolean drawn() { + return behind != null || over != null; + } } /// How many pictures were made since the start; for tests, which @@ -123,8 +132,14 @@ public static void invalidate(Node node, boolean paintOnly) { if (n != node && contained && within != null && coversItsBox(n) && inside(within, n)) { continue; } - n.cn1SetEffectCache(null); - live--; + Object kept = n.cn1EffectCache(); + if (kept instanceof Kept && ((Kept) kept).drawn() && has(n)) { + // Drawn on until the new picture is made; see kept(). + ((Kept) kept).stale = true; + } else { + n.cn1SetEffectCache(null); + live--; + } } // What a node with its own effect draws reaches past its box. if (n.getEffect() != null) { @@ -342,8 +357,18 @@ private static Kept kept(Node node, Component peer) { Object cached = node.cn1EffectCache(); if (cached instanceof Kept) { Kept k = (Kept) cached; - if (k.effect == effect && k.width == peer.getWidth() && k.height == peer.getHeight() - && same(k.values, values)) { + boolean current = k.effect == effect && same(k.values, values); + if (current && !k.stale && k.width == peer.getWidth() && k.height == peer.getHeight()) { + return k; + } + if (current && k.drawn()) { + // A picture is made by painting the node into an image. + // Done in the middle of the screen's paint, that took the + // node itself out of the frame on iOS: the shadowed bottom + // bar of a phone app went blank on every page change. The + // old picture is drawn for this frame and the new one is + // made after it, outside any paint. + rebuildLater(k, node, peer); return k; } } @@ -362,6 +387,50 @@ && same(k.values, values)) { return k; } + /// Rebuilds waiting for the end of the frame, in the order asked for. + private static final java.util.ArrayList PENDING = new java.util.ArrayList(); + private static final Runnable REBUILD = new Runnable() { + @Override + public void run() { + rebuildPending(); + } + }; + + private static void rebuildLater(Kept old, Node node, Component peer) { + if (old.scheduled) { + return; + } + old.scheduled = true; + if (PENDING.isEmpty()) { + com.codename1.ui.Display.getInstance().callSerially(REBUILD); + } + PENDING.add(new Object[] {old, node, peer}); + } + + /// Makes the pictures that changed since the last frame; what the call + /// queued after a paint runs, and what a test runs in its place. + public static void rebuildPending() { + Object[][] all = PENDING.toArray(new Object[PENDING.size()][]); + PENDING.clear(); + for (int i = 0; i < all.length; i++) { + Kept old = (Kept) all[i][0]; + Node node = (Node) all[i][1]; + Component peer = (Component) all[i][2]; + old.scheduled = false; + if (node.cn1EffectCache() != old || node.getEffect() != old.effect) { + continue; + } + Kept k = new Kept(); + k.effect = old.effect; + k.values = old.values; + k.width = peer.getWidth(); + k.height = peer.getHeight(); + node.cn1SetEffectCache(k); + build(k, node, peer); + peer.repaint(); + } + } + private static void build(Kept k, Node node, Component peer) { double scale = Units.scale(); double reach = 0; diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java index 8338ee8c279..930c865c689 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java @@ -165,6 +165,9 @@ private static int paint() { f.revalidate(); f.paintComponent(Image.createImage(HeadlessImplementation.WIDTH, HeadlessImplementation.HEIGHT).getGraphics()); HeadlessImplementation.recordDraws = false; + // A changed picture is made after the frame, outside the paint; + // what the event loop would run next. + Effects.rebuildPending(); return images(); } From 05955e85580b70ca0c45a8c37b28312ffc86e60f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:40:00 +0200 Subject: [PATCH 21/27] javafx-compat: stretch a touch scroll past its ends and spring back A drag past the top or bottom of a ScrollPane or a list moves the content with UIScrollView's rubber-band resistance, a glide that reaches an end carries on and returns, and the row a drag began on loses its pressed look once the drag becomes a scroll. Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/fxcompat/runtime/Fling.java | 11 +- .../fxcompat/runtime/Overscroll.java | 174 ++++++++++++++++++ .../src/main/java/javafx/scene/Scene.java | 7 + .../java/javafx/scene/control/RowFlow.java | 42 ++++- .../java/javafx/scene/control/ScrollPane.java | 41 ++++- .../codename1/fxcompat/OverscrollTest.java | 134 ++++++++++++++ .../src/test/resources/cn1-hooks.txt | 1 + 7 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java index 34dd8de2c4e..65a4ac46b91 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java @@ -41,6 +41,11 @@ public interface Target { /// Scrolls by a distance; answers false when the view could not /// move the whole way, which ends the glide. boolean flingBy(double delta); + + /// The glide reached an end still moving at `speed` pixels a + /// millisecond; a view that stretches past its ends carries it on. + default void reachedEnd(double speed) { + } } /// The fraction of its speed a glide keeps per millisecond, the @@ -147,7 +152,11 @@ public void pulse(long nowNanos) { double kept = MathUtil.pow(DECAY, ms); double delta = speed * (kept - 1) / k; speed *= kept; - if (!target.flingBy(delta) || Math.abs(speed) < MIN_SPEED) { + if (!target.flingBy(delta)) { + double left = speed; + stop(); + target.reachedEnd(left); + } else if (Math.abs(speed) < MIN_SPEED) { stop(); } } diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java new file mode 100644 index 00000000000..1c57924d815 --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java @@ -0,0 +1,174 @@ +/* + * 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.fxcompat.runtime; + +/// The stretch a touch scroll shows past either end, as a phone's native +/// list does: a drag beyond the end moves the content less and less the +/// further it goes, a glide that reaches the end carries on a little way, +/// and either way the content springs back once the finger is off. +/// +/// The value is in logical pixels, beyond the end the view is at: negative +/// above the first content, positive below the last. A view adds it to +/// where it places its content and asks the [Target] to do that again +/// whenever it changes. +public final class Overscroll implements FrameClock.Pulse { + + /// What the stretch moves. + public interface Target { + + /// The value changed; place the content again. + void overscrollChanged(); + } + + /// How hard a drag past the end is held back, the constant of + /// UIScrollView's rubber band. + private static final double RESISTANCE = 0.55; + /// The spring's natural frequency per millisecond, critically damped: + /// it settles in about half a second without swinging past the end. + private static final double OMEGA = 0.014; + /// The share of a glide's speed that carries on past the end. + private static final double CARRY = 0.4; + private static final double MAX_BEYOND = 0.35; + private static final double STEP_MS = 4; + + private final Target target; + private double value; + private double velocity; + private double size = 1; + private long last; + private boolean running; + + public Overscroll(Target target) { + this.target = target; + } + + /// The stretch now, in logical pixels. + public double value() { + return value; + } + + /// Whether the content is stretched past an end or springing back. + public boolean isActive() { + return value != 0 || running; + } + + /// A new press holds the content where the stretch has it; answers how + /// far past the end a drag would have to be to show the same stretch, + /// which the view adds to where the drag starts. + public double grab() { + stopSpring(); + if (value == 0) { + return 0; + } + double r = Math.min(Math.abs(value) / size, 0.999); + double beyond = size / RESISTANCE * (1 / (1 - r) - 1); + return value < 0 ? -beyond : beyond; + } + + /// A drag has reached `beyond` pixels past an end of a view `size` + /// pixels long, or is back within it at 0. + public void drag(double beyond, double size) { + stopSpring(); + this.size = size > 0 ? size : 1; + double a = Math.abs(beyond); + double r = (1 - 1 / (a * RESISTANCE / this.size + 1)) * this.size; + set(beyond < 0 ? -r : r); + } + + /// The finger lifted: springs back to the end. + public void release() { + if (value != 0) { + velocity = 0; + startSpring(); + } + } + + /// A glide reached an end moving at `speed` pixels a millisecond, in + /// the direction the value grows; it carries on past the end and + /// springs back. + public void bounce(double speed, double size) { + this.size = size > 0 ? size : 1; + double v = speed * CARRY; + // The farthest a critically damped spring launched at v travels + // is v / (omega * e); a hard glide is held to a share of the view. + double most = MAX_BEYOND * this.size * OMEGA * Math.E; + velocity = Math.max(-most, Math.min(most, v)); + startSpring(); + } + + /// Drops the stretch at once, for a view that scrolls by other means. + public void reset() { + stopSpring(); + velocity = 0; + set(0); + } + + private void set(double v) { + if (Double.compare(v, value) != 0) { + value = v; + target.overscrollChanged(); + } + } + + private void startSpring() { + last = FrameClock.nowNanos(); + if (!running) { + running = true; + FrameClock.add(this); + } + } + + private void stopSpring() { + if (running) { + running = false; + FrameClock.remove(this); + } + } + + @Override + public void pulse(long nowNanos) { + double ms = (nowNanos - last) / 1000000.0; + last = nowNanos; + if (!(ms > 0)) { + return; + } + double x = value; + double v = velocity; + // Small fixed steps, so a slow frame follows the same curve. + while (ms > 0) { + double dt = Math.min(STEP_MS, ms); + ms -= dt; + double a = -OMEGA * OMEGA * x - 2 * OMEGA * v; + v += a * dt; + x += v * dt; + } + velocity = v; + if (Math.abs(x) < 0.25 && Math.abs(v) < 0.005) { + velocity = 0; + stopSpring(); + set(0); + return; + } + set(x); + } +} diff --git a/maven/javafx-compat/src/main/java/javafx/scene/Scene.java b/maven/javafx-compat/src/main/java/javafx/scene/Scene.java index 71ea6d6b586..f76a88fc641 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/Scene.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/Scene.java @@ -698,6 +698,13 @@ private void updateHover(Node target, double x, double y) { } } + /// Takes the pressed look off the nodes under a press, for a scroller + /// that has taken a drag: on a touch screen that drag is a scroll, not + /// a press of the row it began on. + public final void cn1CancelPress() { + clearPressed(); + } + private void clearPressed() { for (int i = 0; i < pressedNodes.size(); i++) { pressedNodes.get(i).cn1SetPressed(false); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java index 21a4647d9ef..2d14279587a 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java @@ -98,6 +98,20 @@ public boolean flingBy(double delta) { scrollTo(offset + delta); return Math.abs(offset - before - delta) < 0.5; } + + @Override + public void reachedEnd(double speed) { + overscroll.bounce(speed, getHeight()); + } + }); + /// The stretch past either end while a finger drags beyond it, and the + /// spring back after. + private final com.codename1.fxcompat.runtime.Overscroll overscroll = new com.codename1.fxcompat.runtime.Overscroll( + new com.codename1.fxcompat.runtime.Overscroll.Target() { + @Override + public void overscrollChanged() { + requestLayout(); + } }); RowFlow(Rows rows) { @@ -113,6 +127,7 @@ public boolean flingBy(double delta) { @Override public void handle(ScrollEvent event) { double before = offset; + overscroll.reset(); scrollTo(offset - event.getDeltaY()); if (Double.compare(before, offset) != 0) { event.consume(); @@ -124,7 +139,7 @@ public void handle(ScrollEvent event) { public void handle(MouseEvent event) { fling.stop(); pressY = event.getSceneY(); - pressOffset = offset; + pressOffset = offset + overscroll.grab(); dragging = false; } }); @@ -134,10 +149,18 @@ public void handle(MouseEvent event) { double moved = event.getSceneY() - pressY; if (!dragging && Math.abs(moved) > DRAG_SLOP) { dragging = true; + // A drag is a scroll: the row under the finger is not + // being pressed, and must not stay shaded as if it were. + if (getScene() != null) { + getScene().cn1CancelPress(); + } } if (dragging) { - scrollTo(pressOffset - moved); - fling.track(offset); + double to = pressOffset - moved; + scrollTo(to); + double max = maxOffset(); + overscroll.drag(to < 0 ? to : (to > max ? to - max : 0), getHeight()); + fling.track(to < 0 ? 0 : Math.min(to, max)); } } }); @@ -145,7 +168,11 @@ public void handle(MouseEvent event) { @Override public void handle(MouseEvent event) { if (dragging) { - fling.release(); + if (overscroll.value() != 0) { + overscroll.release(); + } else { + fling.release(); + } } dragging = false; } @@ -168,8 +195,9 @@ public void cn1Paint(com.codename1.fxcompat.runtime.Renderer renderer) { } // From the first row in view: a row is shaded to the edge of the // view, past its last cell, and so is the room below the rows. - int first = (int) Math.floor(offset / rh); - double y = first * rh - offset; + double shown = offset + overscroll.value(); + int first = (int) Math.floor(shown / rh); + double y = first * rh - shown; Color alt = com.codename1.fxcompat.runtime.CssEngine.themeColor(this, "-fx-control-inner-background-alt"); if (alt == null) { return; @@ -354,7 +382,7 @@ protected void layoutChildren() { IndexedCell cell = slot[c]; rows.updateCell(cell, c, row < count ? row : -1, force); cell.setVisible(ws[c] > 0); - cell.resizeRelocate(xs[c], row * rh - offset, ws[c], rh); + cell.resizeRelocate(xs[c], row * rh - offset - overscroll.value(), ws[c], rh); } } force = false; diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java b/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java index 7f3d6227e29..01c0ef73a6a 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java @@ -171,7 +171,25 @@ public boolean flingBy(double delta) { double after = offset(getVvalue(), getVmin(), getVmax(), extentY); return Math.abs(after - before - delta) < 0.5; } + + @Override + public void reachedEnd(double speed) { + overscroll.bounce(speed, viewport.getHeight()); + } + }); + /// The stretch past the top or the bottom while a finger drags beyond + /// it, and the spring back after. + private final com.codename1.fxcompat.runtime.Overscroll overscroll = new com.codename1.fxcompat.runtime.Overscroll( + new com.codename1.fxcompat.runtime.Overscroll.Target() { + @Override + public void overscrollChanged() { + placeContent(); + } }); + /// How far past an end the press began, so a press on a stretched + /// view holds it where it is. + private double pressBeyond; + private boolean pressCancelled; private void init() { getStyleClass().add("scroll-pane"); @@ -180,6 +198,7 @@ private void init() { addEventHandler(ScrollEvent.SCROLL, new EventHandler() { @Override public void handle(ScrollEvent event) { + overscroll.reset(); if (scrollBy(-event.getDeltaX(), -event.getDeltaY())) { event.consume(); } @@ -190,12 +209,21 @@ public void handle(ScrollEvent event) { public void handle(MouseEvent event) { fling.stop(); rememberPress(event); + pressBeyond = overscroll.grab(); + pressCancelled = false; panning = isPannable() || (Display.isInitialized() && !Display.getInstance().isDesktop()); } }); addEventHandler(MouseEvent.MOUSE_DRAGGED, new EventHandler() { @Override public void handle(MouseEvent event) { + if (panning && !pressCancelled && getScene() != null + && Math.max(Math.abs(pressX - event.getSceneX()), Math.abs(pressY - event.getSceneY())) > 4) { + // A drag is a scroll: what it began on is not being + // pressed, and must not stay shaded as if it were. + pressCancelled = true; + getScene().cn1CancelPress(); + } if (panning && panTo(pressX - event.getSceneX(), pressY - event.getSceneY())) { fling.track(offset(getVvalue(), getVmin(), getVmax(), contentHeight - viewport.getHeight())); event.consume(); @@ -206,7 +234,11 @@ public void handle(MouseEvent event) { @Override public void handle(MouseEvent event) { if (panning) { - fling.release(); + if (overscroll.value() != 0) { + overscroll.release(); + } else { + fling.release(); + } } panning = false; } @@ -314,7 +346,9 @@ private boolean panTo(double dx, double dy) { setHvalue(valueAt(offset(pressH, getHmin(), getHmax(), extentX) + dx, getHmin(), getHmax(), extentX)); } if (extentY > 0 && !vvalue.isBound()) { - setVvalue(valueAt(offset(pressV, getVmin(), getVmax(), extentY) + dy, getVmin(), getVmax(), extentY)); + double to = offset(pressV, getVmin(), getVmax(), extentY) + pressBeyond + dy; + setVvalue(valueAt(to, getVmin(), getVmax(), extentY)); + overscroll.drag(to < 0 ? to : (to > extentY ? to - extentY : 0), viewport.getHeight()); } return extentX > 0 || extentY > 0; } @@ -330,7 +364,8 @@ private void placeContent() { if (c != null && c.getParent() == viewport) { Bounds lb = c.getLayoutBounds(); c.setLayoutX(-ox - lb.getMinX()); - c.setLayoutY(-oy - lb.getMinY()); + // A stretch past an end moves the content and nothing else. + c.setLayoutY(-oy - snapPositionY(overscroll.value()) - lb.getMinY()); } Bounds old = viewportBounds.get(); if (old == null || old.getMinX() != -ox || old.getMinY() != -oy || old.getWidth() != vw diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java new file mode 100644 index 00000000000..871b20c8507 --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java @@ -0,0 +1,134 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.fxcompat.runtime.FrameClock; +import com.codename1.fxcompat.runtime.Overscroll; +import com.codename1.fxcompat.runtime.Units; +import javafx.scene.Scene; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; + +/// A touch scroll stretches past its ends, less the further it is pulled, +/// and springs back once the finger lifts; a glide that reaches an end +/// carries on a little way and comes back. +public class OverscrollTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + @Before + public void setUp() { + HeadlessImplementation.install(); + Units.setScale(2); + FrameClock.setManual(true); + } + + @After + public void tearDown() { + FrameClock.reset(); + Units.setScale(0); + } + + private static final class Count implements Overscroll.Target { + int changes; + + @Override + public void overscrollChanged() { + changes++; + } + } + + @Test + public void aDragPastAnEndStretchesLessAndLessAndSpringsBack() { + Count c = new Count(); + Overscroll o = new Overscroll(c); + o.drag(-100, 400); + double first = o.value(); + assertTrue("pulled down past the top: " + first, first < 0 && first > -100); + o.drag(-200, 400); + double second = o.value(); + assertTrue("the second hundred pixels stretch less than the first", + second - first > -(-first)); + assertTrue(second < first); + // Grabbing a stretched view starts the drag where it shows. + assertEquals(-200, o.grab(), 0.5); + o.release(); + assertTrue(o.isActive()); + for (int i = 0; i < 100 && o.isActive(); i++) { + FrameClock.advance(16); + assertTrue("it springs back without swinging past the end", o.value() <= 0); + } + assertFalse(o.isActive()); + assertEquals(0, o.value(), 0); + assertTrue(c.changes > 5); + } + + @Test + public void aGlideThatReachesTheEndBouncesBack() { + Overscroll o = new Overscroll(new Count()); + o.bounce(2, 400); + double most = 0; + for (int i = 0; i < 100 && o.isActive(); i++) { + FrameClock.advance(16); + most = Math.max(most, o.value()); + } + assertTrue("it carries on past the end: " + most, most > 10); + assertTrue("a share of the view at most: " + most, most <= 0.35 * 400 + 1); + assertFalse(o.isActive()); + assertEquals(0, o.value(), 0); + } + + @Test + public void aScrollPanePulledPastTheTopMovesItsContentDownAndBack() { + Pane content = new Pane(); + content.setPrefSize(200, 4000); + ScrollPane pane = new ScrollPane(content); + pane.setPannable(true); + Scene scene = new Scene(pane, 200, 400); + scene.cn1Layout(200, 400); + scene.cn1Pointer(MouseEvent.MOUSE_PRESSED, 100, 50, MouseButton.PRIMARY); + scene.cn1Pointer(MouseEvent.MOUSE_DRAGGED, 100, 250, MouseButton.PRIMARY); + double y = content.getLayoutY(); + assertTrue("the content follows the finger, held back: " + y, y > 0 && y < 200); + assertEquals(0, pane.getVvalue(), 0); + scene.cn1Pointer(MouseEvent.MOUSE_RELEASED, 100, 250, MouseButton.PRIMARY); + for (int i = 0; i < 100; i++) { + FrameClock.advance(16); + } + assertEquals(0, content.getLayoutY(), 0); + } +} diff --git a/maven/javafx-compat/src/test/resources/cn1-hooks.txt b/maven/javafx-compat/src/test/resources/cn1-hooks.txt index e5fd2d4428f..6c2c69d7af0 100644 --- a/maven/javafx-compat/src/test/resources/cn1-hooks.txt +++ b/maven/javafx-compat/src/test/resources/cn1-hooks.txt @@ -43,6 +43,7 @@ javafx.scene.Parent#cn1MarkNeedsLayout javafx.scene.Parent#cn1PeerChildOffset javafx.scene.Parent#cn1PickSelf javafx.scene.Parent#cn1SizedByChildren +javafx.scene.Scene#cn1CancelPress javafx.scene.Scene#cn1ContextMenu javafx.scene.Scene#cn1FocusInitial javafx.scene.Scene#cn1Host From a4777734df19eebcedefa03e36e3b691c52182b6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:39:44 +0200 Subject: [PATCH 22/27] javafx-compat: take a drag from its first pixel, and press without restyling the scene Codename One held every drag back until it crossed 5% of the screen, because the scene's components answered not draggable; the stage forms now claim drags over the scene, which tells a click from a drag itself. A pseudo-class some rule asks of an ancestor compound made any node in that state restyle everything below it, so pressing a tab restyled the whole scene twice per tap (27-112 ms each on the iOS simulator). It now counts only on a node that has the rest of that compound. Co-Authored-By: Claude Opus 5.5 --- .../test/java/com/codename1/fxml/CssTest.java | 25 ++++++++++ .../codename1/fxcompat/runtime/CssEngine.java | 13 ++++- .../codename1/fxcompat/runtime/CssSheet.java | 50 ++++++++++++++++++- .../fxcompat/runtime/StageDialog.java | 14 ++++++ .../codename1/fxcompat/runtime/StageForm.java | 14 ++++++ .../fxcompat/runtime/StageWindow.java | 14 ++++++ 6 files changed, 128 insertions(+), 2 deletions(-) diff --git a/maven/fxml-compiler/src/test/java/com/codename1/fxml/CssTest.java b/maven/fxml-compiler/src/test/java/com/codename1/fxml/CssTest.java index eb617931595..8b18ed174f5 100644 --- a/maven/fxml-compiler/src/test/java/com/codename1/fxml/CssTest.java +++ b/maven/fxml-compiler/src/test/java/com/codename1/fxml/CssTest.java @@ -166,6 +166,31 @@ public void selectorsMatchByTypeClassIdAndPosition() throws Exception { assertEquals(Pos.CENTER_RIGHT, bar.getAlignment()); } + /// A pseudo-class an ancestor compound asks for restyles what is below + /// a node only when the node has the rest of that compound: pressing + /// the root must not restyle the scene for `.bar:pressed`, while + /// pressing the bar still does. + @Test + public void anAncestorPseudoClassCountsOnlyOnANodeOfItsCompound() throws Exception { + styled("ancestorpseudo", ".title { -fx-text-fill: blue; }\n" + + ".bar:pressed .title { -fx-text-fill: red; }\n" + + "HBox:hover Label { -fx-underline: true; }\n"); + javafx.css.PseudoClass pressed = javafx.css.PseudoClass.getPseudoClass("pressed"); + javafx.css.PseudoClass hover = javafx.css.PseudoClass.getPseudoClass("hover"); + assertEquals(Color.BLUE, title.getTextFill()); + root.pseudoClassStateChanged(pressed, true); + assertEquals(Color.BLUE, title.getTextFill()); + bar.pseudoClassStateChanged(pressed, true); + assertEquals(Color.RED, title.getTextFill()); + bar.pseudoClassStateChanged(pressed, false); + assertEquals(Color.BLUE, title.getTextFill()); + root.pseudoClassStateChanged(hover, true); + assertFalse(title.isUnderline()); + bar.pseudoClassStateChanged(hover, true); + assertTrue(title.isUnderline()); + assertFalse(plain.isUnderline()); + } + @Test public void theCascadeOrdersBySpecificityThenSourceOrderThenImportance() throws Exception { styled("cascade", "#title { -fx-text-fill: green; }\n" diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/CssEngine.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/CssEngine.java index e4fdb21953e..ac3f148a5a6 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/CssEngine.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/CssEngine.java @@ -404,7 +404,8 @@ private boolean style(Node node) { consider(sheet.byType.get(type), node, s); consider(sheet.universal, node, s); for (int p = 0; p < sheet.ancestorPseudos.length; p++) { - if (node.cn1HasPseudoClass(sheet.ancestorPseudos[p])) { + if (node.cn1HasPseudoClass(sheet.ancestorPseudos[p]) + && guarded(sheet.pseudoGuards[p], id, type, classes)) { signature = signature * 31 + (p + 1) * 7919L + s; } } @@ -461,6 +462,16 @@ private boolean style(Node node) { return changed && !fresh; } + private static boolean guarded(CssSheet.PseudoGuard[] guards, String id, String type, + java.util.List classes) { + for (int i = 0; i < guards.length; i++) { + if (guards[i].matches(id, type, classes)) { + return true; + } + } + return false; + } + private void want(int property, Object value) { if (stamp[property] != generation) { if (value == null) { diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/CssSheet.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/CssSheet.java index c6ee6b88b33..eaf8ee62595 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/CssSheet.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/CssSheet.java @@ -110,6 +110,41 @@ static final class Rule { final HashSet ancestorClasses = new HashSet(); final HashSet ancestorTypes = new HashSet(); PseudoClass[] ancestorPseudos = new PseudoClass[0]; + /// For each of [#ancestorPseudos], the compounds that require it: + /// a node's state of that pseudo-class can only change how a + /// descendant is styled when the node also has everything else one of + /// them asks for. Without this, a single `.button:pressed .text` made + /// pressing anything restyle the whole scene below it, on every tap. + PseudoGuard[][] pseudoGuards = new PseudoGuard[0][]; + + /// The id, type and classes an ancestor compound requires besides a + /// pseudo-class; `null` and empty mean any. + static final class PseudoGuard { + final String id; + final String type; + final String[] classes; + + PseudoGuard(String id, String type, String[] classes) { + this.id = id; + this.type = type; + this.classes = classes; + } + + boolean matches(String nodeId, String nodeType, java.util.List nodeClasses) { + if (id != null && !id.equals(nodeId)) { + return false; + } + if (type != null && !type.equals(nodeType)) { + return false; + } + for (int i = 0; i < classes.length; i++) { + if (!nodeClasses.contains(classes[i])) { + return false; + } + } + return true; + } + } private int ruleCount; private CssSheet() { @@ -199,6 +234,7 @@ private void index(ArrayList rules) { HashMap> types = new HashMap>(); ArrayList any = new ArrayList(); ArrayList pseudos = new ArrayList(); + ArrayList> guards = new ArrayList>(); for (int r = 0; r < rules.size(); r++) { Rule rule = rules.get(r); CssSelector s = rule.selector; @@ -221,10 +257,18 @@ private void index(ArrayList rules) { for (int j = 0; j < s.classCount(c); j++) { ancestorClasses.add(s.styleClass(c, j)); } + String[] required = new String[s.classCount(c)]; + for (int j = 0; j < required.length; j++) { + required[j] = s.styleClass(c, j); + } for (int j = 0; j < rule.pseudo[c].length; j++) { - if (!pseudos.contains(rule.pseudo[c][j])) { + int at = pseudos.indexOf(rule.pseudo[c][j]); + if (at < 0) { + at = pseudos.size(); pseudos.add(rule.pseudo[c][j]); + guards.add(new ArrayList()); } + guards.get(at).add(new PseudoGuard(s.id(c), s.type(c), required)); } } } @@ -233,6 +277,10 @@ private void index(ArrayList rules) { freeze(types, byType); universal = any.toArray(NO_RULES); ancestorPseudos = pseudos.toArray(new PseudoClass[pseudos.size()]); + pseudoGuards = new PseudoGuard[guards.size()][]; + for (int i = 0; i < pseudoGuards.length; i++) { + pseudoGuards[i] = guards.get(i).toArray(new PseudoGuard[guards.get(i).size()]); + } } private static void file(HashMap> into, String key, Rule rule) { diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageDialog.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageDialog.java index d3cdfe1f10f..4c88077f33e 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageDialog.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageDialog.java @@ -69,6 +69,20 @@ public void pointerPressed(int x, int y) { } } + /// A drag over the scene goes to it from the first pixel: the scene + /// tells a click from a drag by its own small threshold. Left to the + /// form, its components answered "not draggable" and Codename One held + /// every drag back until the finger had crossed 5% of the screen, a + /// lag a swipe on a phone shows plainly. + @Override + public int getDragRegionStatus(int x, int y) { + com.codename1.ui.Component peer = core.rootPeer(); + if (peer != null && peer.contains(x, y)) { + return com.codename1.ui.Component.DRAG_REGION_IMMEDIATELY_DRAG_XY; + } + return super.getDragRegionStatus(x, y); + } + @Override public void pointerDragged(int x, int y) { if (!SceneInput.dragged(core.scene(), core.rootPeer(), x, y)) { diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageForm.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageForm.java index dbad857a135..a7adeed5bcd 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageForm.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageForm.java @@ -107,6 +107,20 @@ public void pointerPressed(int x, int y) { } } + /// A drag over the scene goes to it from the first pixel: the scene + /// tells a click from a drag by its own small threshold. Left to the + /// form, its components answered "not draggable" and Codename One held + /// every drag back until the finger had crossed 5% of the screen, a + /// lag a swipe on a phone shows plainly. + @Override + public int getDragRegionStatus(int x, int y) { + com.codename1.ui.Component peer = core.rootPeer(); + if (peer != null && peer.contains(x, y)) { + return com.codename1.ui.Component.DRAG_REGION_IMMEDIATELY_DRAG_XY; + } + return super.getDragRegionStatus(x, y); + } + @Override public void pointerDragged(int x, int y) { if (!SceneInput.dragged(core.scene(), core.rootPeer(), x, y)) { diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageWindow.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageWindow.java index dac4eb647c4..dbe372e74ec 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageWindow.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/StageWindow.java @@ -95,6 +95,20 @@ public void pointerPressed(int x, int y) { } } + /// A drag over the scene goes to it from the first pixel: the scene + /// tells a click from a drag by its own small threshold. Left to the + /// form, its components answered "not draggable" and Codename One held + /// every drag back until the finger had crossed 5% of the screen, a + /// lag a swipe on a phone shows plainly. + @Override + public int getDragRegionStatus(int x, int y) { + com.codename1.ui.Component peer = core.rootPeer(); + if (peer != null && peer.contains(x, y)) { + return com.codename1.ui.Component.DRAG_REGION_IMMEDIATELY_DRAG_XY; + } + return super.getDragRegionStatus(x, y); + } + @Override public void pointerDragged(int x, int y) { if (!SceneInput.dragged(core.scene(), core.rootPeer(), x, y)) { From 0374cea48dea0ac10f6d803a8ffbc2a6dd0a8a56 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:21:24 +0200 Subject: [PATCH 23/27] javafx-compat: survive Codename One's shape clip bug, and re-pad a label whose graphic grew A rounded clip is narrowed by every rectangle painted inside it; Codename One's intersection throws when a curve ends a fraction of a pixel from the rectangle, which took the app down on iOS on a scrolled card list. Clip paths now end every segment on a whole pixel, and narrowing a shape clip falls back to a rectangle if it still throws. A label padded its text for its graphic once, when styled; an icon whose font arrived later grew past that padding and was drawn over the text. Co-Authored-By: Claude Opus 5.5 --- .../codename1/fxcompat/runtime/FxPath.java | 24 +++++-- .../codename1/fxcompat/runtime/Renderer.java | 38 ++++++++++- .../java/javafx/scene/control/Labeled.java | 13 ++++ .../com/codename1/fxcompat/ClipSnapTest.java | 65 +++++++++++++++++++ 4 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/ClipSnapTest.java diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/FxPath.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/FxPath.java index faeb14791a1..b0354d8fb5c 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/FxPath.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/FxPath.java @@ -770,28 +770,40 @@ public FxPath dashed(double[] pattern, double offset) { /// `{a, b, c, d, tx, ty}`, where `x' = a*x + c*y + tx` and /// `y' = b*x + d*y + ty`. public GeneralPath toDevice(double[] m) { + return toDevice(m, false); + } + + /// As [#toDevice(double[])]; with `snap`, the point every segment ends + /// at is rounded to a whole pixel, which a clip wants: Codename One + /// narrows a shape clip by each rectangle it paints into, and throws + /// when a segment ends a fraction of a pixel from a rectangle's edge. + /// Rounded, an end is on the edge or a pixel away from it. Control + /// points are kept, so a curve moves by half a pixel at most. + public GeneralPath toDevice(double[] m, boolean snap) { GeneralPath out = new GeneralPath(); out.setWindingRule(evenOdd ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO); int p = 0; for (int i = 0; i < commandCount; i++) { switch (commands[i]) { case MOVE: - out.moveTo(tx(m, points[p], points[p + 1]), ty(m, points[p], points[p + 1])); + out.moveTo(end(tx(m, points[p], points[p + 1]), snap), end(ty(m, points[p], points[p + 1]), snap)); p += 2; break; case LINE: - out.lineTo(tx(m, points[p], points[p + 1]), ty(m, points[p], points[p + 1])); + out.lineTo(end(tx(m, points[p], points[p + 1]), snap), end(ty(m, points[p], points[p + 1]), snap)); p += 2; break; case QUAD: out.quadTo(tx(m, points[p], points[p + 1]), ty(m, points[p], points[p + 1]), - tx(m, points[p + 2], points[p + 3]), ty(m, points[p + 2], points[p + 3])); + end(tx(m, points[p + 2], points[p + 3]), snap), + end(ty(m, points[p + 2], points[p + 3]), snap)); p += 4; break; case CUBIC: out.curveTo(tx(m, points[p], points[p + 1]), ty(m, points[p], points[p + 1]), tx(m, points[p + 2], points[p + 3]), ty(m, points[p + 2], points[p + 3]), - tx(m, points[p + 4], points[p + 5]), ty(m, points[p + 4], points[p + 5])); + end(tx(m, points[p + 4], points[p + 5]), snap), + end(ty(m, points[p + 4], points[p + 5]), snap)); p += 6; break; default: @@ -802,6 +814,10 @@ public GeneralPath toDevice(double[] m) { return out; } + private static double end(double v, boolean snap) { + return snap ? Math.floor(v + 0.5) : v; + } + /// Returns this path with every point mapped through the affine /// matrix `{a, b, c, d, tx, ty}`. public FxPath transformed(double[] m) { diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Renderer.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Renderer.java index 5bfcb030ead..108990cc889 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Renderer.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Renderer.java @@ -143,7 +143,7 @@ public boolean clip(FxPath path) { g.pushClip(); clipDepth++; if (exact && !path.isEmpty()) { - clipToShape(path.toDevice(m)); + clipToShape(path.toDevice(m, true)); } else { int x1 = (int) Math.floor(b[0]); int y1 = (int) Math.floor(b[1]); @@ -166,7 +166,39 @@ private void clipToShape(com.codename1.ui.geom.Shape shape) { int[] old = g.getClip(); g.setClip(shape); if (!PeerPaint.screenClipUnderMatrix(g)) { - g.clipRect(old[0], old[1], old[2], old[3]); + narrow(g, shape, old[0], old[1], old[2], old[3]); + } + } + + /// Narrows a clip that may be a shape by a rectangle. A shape inside + /// the rectangle is left as it is: narrowing it changes nothing, and + /// Codename One's intersection of a curved path with a rectangle throws + /// ("Unexpected shape segmentation") when a curve ends a fraction of a + /// pixel from an edge -- the rounded corner of a card scrolled to the + /// edge of the screen, which took the whole app down on iOS. Where it + /// still throws, the clip falls back to the rectangle shared by the + /// shape's bounds and the one asked for. + static void narrow(com.codename1.ui.Graphics g, com.codename1.ui.geom.Shape shape, int x, int y, int w, + int h) { + if (shape != null) { + com.codename1.ui.geom.Rectangle sb = shape.getBounds(); + if (sb.getX() >= x && sb.getY() >= y && sb.getX() + sb.getWidth() <= x + w + && sb.getY() + sb.getHeight() <= y + h) { + return; + } + } + try { + g.clipRect(x, y, w, h); + } catch (RuntimeException e) { + if (shape == null) { + throw e; + } + com.codename1.ui.geom.Rectangle sb = shape.getBounds(); + int x1 = Math.max(x, sb.getX()); + int y1 = Math.max(y, sb.getY()); + int x2 = Math.min(x + w, sb.getX() + sb.getWidth()); + int y2 = Math.min(y + h, sb.getY() + sb.getHeight()); + g.setClip(x1, y1, Math.max(0, x2 - x1), Math.max(0, y2 - y1)); } } @@ -435,7 +467,7 @@ private void fillRadial(FxPath path, RadialGradient rg, double bx, double by, do // one before. fill(path, outer, bx, by, bw, bh); g.pushClip(); - clipToShape(path.toDevice(m)); + clipToShape(path.toDevice(m, true)); int rings = (int) Math.max(8, Math.min(64, Math.max(rx, ry) * uniformScale() / 2)); for (int i = rings; i >= 1; i--) { double t = (double) i / rings; diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java index 035bef2be9b..34a00784f2c 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java @@ -197,6 +197,7 @@ protected void cn1SyncNative() { // preferred size of the label covers both. Style base = UIManager.getInstance().getComponentStyle(label.getUIID()); int[] room = g == null ? new int[GRAPHIC_BOX] : graphicBox(label, g); + System.arraycopy(room, 0, paddedRoom, 0, paddedRoom.length); style.setPaddingUnit(Style.UNIT_TYPE_PIXELS); if (bare) { style.setPadding(room[0], room[1], room[2], room[3]); @@ -215,6 +216,9 @@ boolean cn1BareText() { } private static final int GRAPHIC_BOX = 9; + /// The room around the text the native label's padding was last set + /// to hold for the graphic, `{top, bottom, left, right}`. + private final int[] paddedRoom = new int[4]; /// Whether a subclass places the graphic itself, as a cell does; this /// class then leaves the graphic and the padding of the label alone. @@ -409,6 +413,15 @@ protected void layoutChildren() { } com.codename1.ui.Label label = (com.codename1.ui.Label) c; int[] box = graphicBox(label, g); + if (box[0] != paddedRoom[0] || box[1] != paddedRoom[1] || box[2] != paddedRoom[2] + || box[3] != paddedRoom[3]) { + // The graphic changed size since the padding was set -- an icon + // whose font arrives in a style after the label was styled is + // the usual case. Placed by its new size beside text padded for + // the old one, a wider icon was drawn over the first letters. + cn1SyncNative(); + requestLayout(); + } Style base = UIManager.getInstance().getComponentStyle(label.getUIID()); Insets in = getInsets(); // The area the label lays its content out in, less the room made diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ClipSnapTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ClipSnapTest.java new file mode 100644 index 00000000000..38ef8666410 --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ClipSnapTest.java @@ -0,0 +1,65 @@ +/* + * 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.fxcompat; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import com.codename1.fxcompat.runtime.FxPath; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.geom.Rectangle; + +/// A rounded clip is narrowed by every rectangle painted inside it, and +/// Codename One's intersection threw when a corner ended a fraction of a +/// pixel from the rectangle's edge. The numbers are those an iOS build +/// logged when it went down on the showcases list. +public class ClipSnapTest { + + private static FxPath corner() { + FxPath p = new FxPath(); + p.moveTo(300, 2300); + p.lineTo(300, 2380.125); + p.lineTo(102.36949157714844, 2380.125); + p.curveTo(98.39994049072266, 2380.125, 95.18199157714844, 2376.906982421875, + 95.18199157714844, 2372.9375); + p.lineTo(95.18199157714844, 2300); + p.closePath(); + return p; + } + + @Test + public void aSnappedClipNarrowsWhereTheExactOneThrew() { + double[] identity = {1, 0, 0, 1, 0, 0}; + Rectangle rect = new Rectangle(44, 2222, 292, 151); + try { + corner().toDevice(identity).intersection(rect); + fail("the exact path no longer throws; the snapping in Renderer.clip can go"); + } catch (RuntimeException expected) { + // The core bug the snapping works around. + } + GeneralPath snapped = corner().toDevice(identity, true); + assertNotNull(snapped.intersection(rect)); + } +} From 09f60cff3edaf3a4b024a60ccaf16bdae1520e45 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:27:36 +0200 Subject: [PATCH 24/27] javafx-compat: keep desktop mouse drags free of overscroll and fling A mouse drag on the desktop (a ListView's rows, a pannable ScrollPane) stretched past the ends and glided on after release, which JavaFX on the desktop never does. Both now feed the spring and the glide only on a touch screen. Co-Authored-By: Claude Opus 5.5 --- .../fxcompat/runtime/Overscroll.java | 8 ++++ .../java/javafx/scene/control/RowFlow.java | 8 ++-- .../java/javafx/scene/control/ScrollPane.java | 8 +++- .../codename1/fxcompat/OverscrollTest.java | 30 +++++++++++++ .../codename1/fxcompat/ZzLabelProbeTest.java | 43 +++++++++++++++++++ 5 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/ZzLabelProbeTest.java diff --git a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java index 1c57924d815..9df36116a80 100644 --- a/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java @@ -62,6 +62,14 @@ public Overscroll(Target target) { this.target = target; } + /// Whether a drag is a finger on a touch screen. A desktop mouse drag + /// neither stretches past an end nor glides on: JavaFX on the desktop + /// does neither, so callers feed `drag` and a `Fling` only when this + /// answers true. + public static boolean touchScreen() { + return com.codename1.ui.Display.isInitialized() && !com.codename1.ui.Display.getInstance().isDesktop(); + } + /// The stretch now, in logical pixels. public double value() { return value; diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java index 2d14279587a..f4c577a66d9 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/RowFlow.java @@ -158,9 +158,11 @@ public void handle(MouseEvent event) { if (dragging) { double to = pressOffset - moved; scrollTo(to); - double max = maxOffset(); - overscroll.drag(to < 0 ? to : (to > max ? to - max : 0), getHeight()); - fling.track(to < 0 ? 0 : Math.min(to, max)); + if (com.codename1.fxcompat.runtime.Overscroll.touchScreen()) { + double max = maxOffset(); + overscroll.drag(to < 0 ? to : (to > max ? to - max : 0), getHeight()); + fling.track(to < 0 ? 0 : Math.min(to, max)); + } } } }); diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java b/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java index 01c0ef73a6a..93d70489681 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/ScrollPane.java @@ -225,7 +225,9 @@ public void handle(MouseEvent event) { getScene().cn1CancelPress(); } if (panning && panTo(pressX - event.getSceneX(), pressY - event.getSceneY())) { - fling.track(offset(getVvalue(), getVmin(), getVmax(), contentHeight - viewport.getHeight())); + if (com.codename1.fxcompat.runtime.Overscroll.touchScreen()) { + fling.track(offset(getVvalue(), getVmin(), getVmax(), contentHeight - viewport.getHeight())); + } event.consume(); } } @@ -348,7 +350,9 @@ private boolean panTo(double dx, double dy) { if (extentY > 0 && !vvalue.isBound()) { double to = offset(pressV, getVmin(), getVmax(), extentY) + pressBeyond + dy; setVvalue(valueAt(to, getVmin(), getVmax(), extentY)); - overscroll.drag(to < 0 ? to : (to > extentY ? to - extentY : 0), viewport.getHeight()); + if (com.codename1.fxcompat.runtime.Overscroll.touchScreen()) { + overscroll.drag(to < 0 ? to : (to > extentY ? to - extentY : 0), viewport.getHeight()); + } } return extentX > 0 || extentY > 0; } diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java index 871b20c8507..0e04a541484 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java @@ -131,4 +131,34 @@ public void aScrollPanePulledPastTheTopMovesItsContentDownAndBack() { } assertEquals(0, content.getLayoutY(), 0); } + + /// A mouse dragging a pannable pane on the desktop pans it and nothing + /// more: no stretch past the top, and no glide once the button lifts. + @Test + public void aDesktopMouseDragNeitherStretchesNorGlides() { + HeadlessImplementation.setDesktop(true); + try { + Pane content = new Pane(); + content.setPrefSize(200, 4000); + ScrollPane pane = new ScrollPane(content); + pane.setPannable(true); + Scene scene = new Scene(pane, 200, 400); + scene.cn1Layout(200, 400); + scene.cn1Pointer(MouseEvent.MOUSE_PRESSED, 100, 50, MouseButton.PRIMARY); + scene.cn1Pointer(MouseEvent.MOUSE_DRAGGED, 100, 250, MouseButton.PRIMARY); + assertEquals(0, content.getLayoutY(), 0); + for (int i = 0; i < 10; i++) { + FrameClock.advance(5); + scene.cn1Pointer(MouseEvent.MOUSE_DRAGGED, 100, 250 - 30 * (i + 1), MouseButton.PRIMARY); + } + double held = pane.getVvalue(); + scene.cn1Pointer(MouseEvent.MOUSE_RELEASED, 100, -50, MouseButton.PRIMARY); + for (int i = 0; i < 50; i++) { + FrameClock.advance(16); + } + assertEquals(held, pane.getVvalue(), 0); + } finally { + HeadlessImplementation.setDesktop(false); + } + } } diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ZzLabelProbeTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ZzLabelProbeTest.java new file mode 100644 index 00000000000..fdc017ecea2 --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ZzLabelProbeTest.java @@ -0,0 +1,43 @@ +package com.codename1.fxcompat; + +import org.junit.Rule; +import org.junit.Test; + +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.fxcompat.runtime.Units; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +public class ZzLabelProbeTest { + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + @Test + public void probe() { + HeadlessImplementation.install(); + Units.setScale(2); + Label icon = new Label(); + Label title = new Label(); + title.setGraphic(icon); + title.setText("JavaFX"); + title.setMinWidth(0); + VBox cell = new VBox(title); + cell.setPrefWidth(0); + cell.setMinWidth(0); + HBox.setHgrow(cell, Priority.ALWAYS); + HBox row = new HBox(cell, new VBox(), new VBox()); + Scene scene = new Scene(row, 300, 200); + scene.cn1Layout(300, 200); + icon.setText("☕☕☕"); + icon.setStyle("-fx-font-size: 30px;"); + scene.cn1Layout(300, 200); + scene.cn1Layout(300, 200); + com.codename1.ui.Component c = title.cn1Native(); + System.out.println("PROBE icon x=" + icon.getLayoutX() + " w=" + icon.getWidth() + " pref=" + icon.prefWidth(-1) + + " title w=" + title.getWidth() + " pad=" + (c == null ? -1 : c.getStyle().getPaddingLeftNoRTL())); + } +} From c1b9b9db84bf59b97cd4517e06617da6e3924e8f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:28:59 +0200 Subject: [PATCH 25/27] javafx-compat: place a label's graphic without the theme padding a bare label does not have The graphic was placed as if the label kept its theme's padding, which a bare label is drawn without: under the iOS theme every titled card drew its icon over the first letters. Also removes a scratch probe test that was committed by mistake. Co-Authored-By: Claude Opus 5.5 --- .../java/javafx/scene/control/Labeled.java | 20 ++++++--- .../codename1/fxcompat/NarrowScreenTest.java | 24 +++++++++++ .../codename1/fxcompat/ZzLabelProbeTest.java | 43 ------------------- 3 files changed, 38 insertions(+), 49 deletions(-) delete mode 100644 maven/javafx-compat/src/test/java/com/codename1/fxcompat/ZzLabelProbeTest.java diff --git a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java index 34a00784f2c..c789447a656 100644 --- a/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java +++ b/maven/javafx-compat/src/main/java/javafx/scene/control/Labeled.java @@ -423,16 +423,24 @@ protected void layoutChildren() { requestLayout(); } Style base = UIManager.getInstance().getComponentStyle(label.getUIID()); + // A bare label has none of the theme's padding (cn1SyncNative zeroes + // it), so none is counted here either. Counting it put the graphic + // that far into the text: on a phone theme that pads a label, the + // icon of every titled card was drawn over its first letters. + boolean bare = cn1BareText(); + int baseLeft = bare ? 0 : base.getPaddingLeftNoRTL(); + int baseRight = bare ? 0 : base.getPaddingRightNoRTL(); + int baseTop = bare ? 0 : base.getPaddingTop(); + int baseBottom = bare ? 0 : base.getPaddingBottom(); Insets in = getInsets(); // The area the label lays its content out in, less the room made // for the graphic: where graphic and text sit together. // Less what was given up of the theme's padding (fitPadding). - int areaX = Units.toPixels(in.getLeft()) + base.getPaddingLeftNoRTL() - shedLeft; - int areaY = Units.toPixels(in.getTop()) + base.getPaddingTop(); - int areaW = Units.toPixels(getWidth() - in.getLeft() - in.getRight()) - base.getPaddingLeftNoRTL() - - base.getPaddingRightNoRTL() + shedLeft + shedRight; - int areaH = Units.toPixels(getHeight() - in.getTop() - in.getBottom()) - base.getPaddingTop() - - base.getPaddingBottom(); + int areaX = Units.toPixels(in.getLeft()) + baseLeft - shedLeft; + int areaY = Units.toPixels(in.getTop()) + baseTop; + int areaW = Units.toPixels(getWidth() - in.getLeft() - in.getRight()) - baseLeft - baseRight + shedLeft + + shedRight; + int areaH = Units.toPixels(getHeight() - in.getTop() - in.getBottom()) - baseTop - baseBottom; int gw = box[4]; int gh = box[5]; int gap = box[8]; diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/NarrowScreenTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/NarrowScreenTest.java index 0cf53bb2055..94f8ec072fc 100644 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/NarrowScreenTest.java +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/NarrowScreenTest.java @@ -380,4 +380,28 @@ public void changed(javafx.beans.value.ObservableValue o, Numb assertTrue(content.isScrollableX()); assertEquals(wide, root.getWidth(), 1); } + + /// A label is drawn with none of the theme's padding, and its graphic + /// is placed the same way: a theme that pads a label, as the iOS one + /// does, put the icon of a titled card that far in, over the letters. + @Test + public void aLabelsGraphicIgnoresTheThemesPaddingOfALabel() { + java.util.Hashtable theme = new java.util.Hashtable(); + theme.put("Label.padding", "10,10,40,40"); + theme.put("Label.padUnit", new byte[] {0, 0, 0, 0}); + com.codename1.ui.plaf.UIManager.getInstance().addThemeProps(theme); + try { + javafx.scene.control.Label icon = new javafx.scene.control.Label("ab"); + javafx.scene.control.Label title = new javafx.scene.control.Label("Title", icon); + shown(new javafx.scene.layout.VBox(title)); + assertEquals(0, icon.getLayoutX(), 0.5); + com.codename1.ui.Label n = (com.codename1.ui.Label) title.cn1Native(); + assertEquals("the text starts after the graphic and the gap", + com.codename1.fxcompat.runtime.Units.toPixels(icon.getWidth() + title.getGraphicTextGap()), + n.getStyle().getPaddingLeftNoRTL(), 1); + } finally { + theme.put("Label.padding", "0,0,0,0"); + com.codename1.ui.plaf.UIManager.getInstance().addThemeProps(theme); + } + } } diff --git a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ZzLabelProbeTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ZzLabelProbeTest.java deleted file mode 100644 index fdc017ecea2..00000000000 --- a/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ZzLabelProbeTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.codename1.fxcompat; - -import org.junit.Rule; -import org.junit.Test; - -import com.codename1.compat.testing.HeadlessImplementation; -import com.codename1.compat.testing.MainThreadRule; -import com.codename1.fxcompat.runtime.Units; -import javafx.scene.Scene; -import javafx.scene.control.Label; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; - -public class ZzLabelProbeTest { - @Rule - public final MainThreadRule mainThread = new MainThreadRule(); - - @Test - public void probe() { - HeadlessImplementation.install(); - Units.setScale(2); - Label icon = new Label(); - Label title = new Label(); - title.setGraphic(icon); - title.setText("JavaFX"); - title.setMinWidth(0); - VBox cell = new VBox(title); - cell.setPrefWidth(0); - cell.setMinWidth(0); - HBox.setHgrow(cell, Priority.ALWAYS); - HBox row = new HBox(cell, new VBox(), new VBox()); - Scene scene = new Scene(row, 300, 200); - scene.cn1Layout(300, 200); - icon.setText("☕☕☕"); - icon.setStyle("-fx-font-size: 30px;"); - scene.cn1Layout(300, 200); - scene.cn1Layout(300, 200); - com.codename1.ui.Component c = title.cn1Native(); - System.out.println("PROBE icon x=" + icon.getLayoutX() + " w=" + icon.getWidth() + " pref=" + icon.prefWidth(-1) - + " title w=" + title.getWidth() + " pad=" + (c == null ? -1 : c.getStyle().getPaddingLeftNoRTL())); - } -} From dcbac86a918d3e6dbc1b77eaa16949495dfa26bf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:50:28 +0200 Subject: [PATCH 26/27] Reseed the Android DesktopCompatFxData golden for whole-pixel clip edges The JavaFX layer now snaps a clip path to device pixels; the list's antialiased edges move by at most 17/255 on 998 pixels. Identical on the Default, JDK 17 and JDK 21 legs. Co-Authored-By: Claude Opus 5.5 --- .../screenshots/DesktopCompatFxData.png | Bin 35244 -> 35195 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/scripts/android/screenshots/DesktopCompatFxData.png b/scripts/android/screenshots/DesktopCompatFxData.png index a7e4a27279b915d57243d5e50c288edcdd96cba3..994e7e983cd0a7e90c9a2d805ba3c24d5cc923c5 100644 GIT binary patch delta 17658 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 aU462U5b`zS4jbGBCH+|8QMSYjum1 Date: Sun, 1 Nov 2026 10:58:27 +0200 Subject: [PATCH 27/27] Reseed the macOS DesktopCompatFxData golden for the ListView border edge Co-Authored-By: Claude Opus 5.5 --- .../macos/screenshots/DesktopCompatFxData.png | Bin 55344 -> 55355 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/scripts/macos/screenshots/DesktopCompatFxData.png b/scripts/macos/screenshots/DesktopCompatFxData.png index 51733b492e94feafd0f672277a9bec9ad6e09c9e..4d9dc574c372fd5638a63b1299f2d2fe446eef1f 100644 GIT binary patch delta 28785 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 aO&8iIWBz(m@eGLkclNZ7#w#^sz<&We8psm>