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..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 @@ -178,7 +178,45 @@ 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; + 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); + } + for (FieldInfo f : allFields) { if (f.isStatic()) continue; if (f.getName().startsWith("this$")) continue; // inner-class outer ref boolean useAccessor = false; @@ -193,7 +231,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 +240,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 +479,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 +1521,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(); @@ -1490,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 8c9608c45b9..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 @@ -112,6 +112,172 @@ 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 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/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..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()); 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/DerivedValue.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java new file mode 100644 index 00000000000..586837defef --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/DerivedValue.java @@ -0,0 +1,177 @@ +/* + * 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. +/// +/// 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; + 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; + /// 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) { + 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; + } + + 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; + } + } + } + + /// 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 && !registered.isEmpty()) { + 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: + 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: + return v == null ? null : (U) function.apply(v); + } + } + + @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/Effects.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Effects.java index 4b90112e739..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 @@ -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 @@ -74,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 @@ -91,15 +107,82 @@ 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) { - n.cn1SetEffectCache(null); - live--; + if (n != node && contained && within != null && coversItsBox(n) && inside(within, n)) { + continue; + } + 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) { + 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) { @@ -274,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; } } @@ -294,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/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..65a4ac46b91 --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Fling.java @@ -0,0 +1,163 @@ +/* + * 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 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 + /// "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)) { + 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/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/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/Overscroll.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java new file mode 100644 index 00000000000..9df36116a80 --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/Overscroll.java @@ -0,0 +1,182 @@ +/* + * 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; + } + + /// 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; + } + + /// 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/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/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/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/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 ffe19e54bab..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 @@ -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 @@ -102,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)) { 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/WrappingLabel.java b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java new file mode 100644 index 00000000000..410ce52de2c --- /dev/null +++ b/maven/javafx-compat/src/main/java/com/codename1/fxcompat/runtime/WrappingLabel.java @@ -0,0 +1,187 @@ +/* + * 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; + } + // 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 = t.indexOf('\n', start); + int end = nl < 0 ? t.length() : nl; + breakParagraph(font, t.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; + } + } + + 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, 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(); + // 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; + } + 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 = 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); + 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/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/Node.java b/maven/javafx-compat/src/main/java/javafx/scene/Node.java index fca1e55b47a..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()); } @@ -1273,6 +1276,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/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/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/Cell.java b/maven/javafx-compat/src/main/java/javafx/scene/control/Cell.java index 2b37c3ca8b5..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 @@ -204,29 +204,53 @@ 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); - 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/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/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..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 @@ -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()); } @@ -189,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]); @@ -207,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. @@ -317,10 +329,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. @@ -350,11 +378,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; @@ -384,16 +413,34 @@ 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()); + // 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. - int areaX = Units.toPixels(in.getLeft()) + base.getPaddingLeftNoRTL(); - int areaY = Units.toPixels(in.getTop()) + base.getPaddingTop(); - int areaW = Units.toPixels(getWidth() - in.getLeft() - in.getRight()) - base.getPaddingLeftNoRTL() - - base.getPaddingRightNoRTL(); - int areaH = Units.toPixels(getHeight() - in.getTop() - in.getBottom()) - base.getPaddingTop() - - base.getPaddingBottom(); + // Less what was given up of the theme's padding (fitPadding). + 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]; @@ -428,6 +475,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 @@ -459,12 +556,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/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/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 d0c9056475e..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 @@ -80,11 +80,39 @@ 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; 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() { + @Override + public boolean flingBy(double delta) { + double before = offset; + 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) { this.rows = rows; @@ -94,10 +122,12 @@ interface Rows { 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) { double before = offset; + overscroll.reset(); scrollTo(offset - event.getDeltaY()); if (Double.compare(before, offset) != 0) { event.consume(); @@ -107,8 +137,9 @@ public void handle(ScrollEvent event) { addEventHandler(MouseEvent.MOUSE_PRESSED, new EventHandler() { @Override public void handle(MouseEvent event) { + fling.stop(); pressY = event.getSceneY(); - pressOffset = offset; + pressOffset = offset + overscroll.grab(); dragging = false; } }); @@ -118,12 +149,36 @@ 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); + double to = pressOffset - moved; + scrollTo(to); + 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)); + } } } }); + addEventHandler(MouseEvent.MOUSE_RELEASED, new EventHandler() { + @Override + public void handle(MouseEvent event) { + if (dragging) { + if (overscroll.value() != 0) { + overscroll.release(); + } else { + fling.release(); + } + } + dragging = false; + } + }); } /// Whether every second row is shaded across the whole view, and the @@ -142,8 +197,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; @@ -256,10 +312,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 +331,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 +346,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) { @@ -319,14 +384,14 @@ 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; 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 aa53f8bd62e..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 @@ -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; @@ -153,6 +157,40 @@ 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; + } + + @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"); setFocusTraversable(false); @@ -160,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(); } @@ -168,14 +207,27 @@ public void handle(ScrollEvent event) { addEventHandler(MouseEvent.MOUSE_PRESSED, new EventHandler() { @Override 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())) { + if (com.codename1.fxcompat.runtime.Overscroll.touchScreen()) { + fling.track(offset(getVvalue(), getVmin(), getVmax(), contentHeight - viewport.getHeight())); + } event.consume(); } } @@ -183,6 +235,13 @@ public void handle(MouseEvent event) { addEventHandler(MouseEvent.MOUSE_RELEASED, new EventHandler() { @Override public void handle(MouseEvent event) { + if (panning) { + if (overscroll.value() != 0) { + overscroll.release(); + } else { + fling.release(); + } + } panning = false; } }); @@ -289,7 +348,11 @@ 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)); + if (com.codename1.fxcompat.runtime.Overscroll.touchScreen()) { + overscroll.drag(to < 0 ? to : (to > extentY ? to - extentY : 0), viewport.getHeight()); + } } return extentX > 0 || extentY > 0; } @@ -305,20 +368,25 @@ 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 || 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/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/main/java/javafx/scene/image/Image.java b/maven/javafx-compat/src/main/java/javafx/scene/image/Image.java index 25da7282b5a..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 @@ -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,105 @@ 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) { + // 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(); + } + listeners.add(listener); } /// Stops telling a view. void removePixelListener(Runnable listener) { + if (listeners != null) { + listeners.remove(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(); + } + } + + /// 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(key, 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) { + forget(key); + fail(failed); + } catch (RuntimeException failed) { + forget(key); + fail(failed); + } + firePixelsChanged(); + } + }, 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(); + } + }, 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) { + 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 +409,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 +423,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 +456,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 cb519d08f9f..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`. @@ -312,4 +318,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/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)); + } +} 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..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 @@ -351,6 +351,76 @@ 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 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); 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; 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/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); + } +} 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..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 @@ -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(); @@ -364,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/OverscrollTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java new file mode 100644 index 00000000000..0e04a541484 --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/OverscrollTest.java @@ -0,0 +1,164 @@ +/* + * 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); + } + + /// 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/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()); + } +} 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..490c1ea056e --- /dev/null +++ b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/RemoteImageTest.java @@ -0,0 +1,162 @@ +/* + * 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 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"); + 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/com/codename1/fxcompat/ShadowEffectTest.java b/maven/javafx-compat/src/test/java/com/codename1/fxcompat/ShadowEffectTest.java index eb299e74b50..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(); } @@ -214,6 +217,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(); 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)); + } +} 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()); + } +} 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); + } +} 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 diff --git a/scripts/android/screenshots/DesktopCompatFxData.png b/scripts/android/screenshots/DesktopCompatFxData.png index a7e4a27279b..994e7e983cd 100644 Binary files a/scripts/android/screenshots/DesktopCompatFxData.png and b/scripts/android/screenshots/DesktopCompatFxData.png differ diff --git a/scripts/macos/screenshots/DesktopCompatFxData.png b/scripts/macos/screenshots/DesktopCompatFxData.png index 51733b492e9..4d9dc574c37 100644 Binary files a/scripts/macos/screenshots/DesktopCompatFxData.png and b/scripts/macos/screenshots/DesktopCompatFxData.png differ