From acd780a1bef5c9d30cc4c71a78a7937ec379b6b9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:19:09 +0200 Subject: [PATCH 1/3] Safe area: a bottom bar no longer jumps between two heights A setSafeArea(true) container measured its preferred size from its current bounds and cached it. Measured before its first layout, or while its parent had moved and it had not, a bottom tab bar was sized for its content alone; layout then padded the bottom inset into that height and clipped the labels. Whichever later pass re-measured it put it back, so the bar jumped between the two layouts as the screen changed (Savor's customer tabs show it). Measurement now also counts the inset the last layout applied, and layout compares the inset it applies with the one measured, on any axis the parent sized from the preferred size; when they differ the size is measured again and the root lays out again in the same revalidate (bounded), so no frame paints the stale size. The inset arithmetic is a pure function, so the check writes no style and fires no extra events. Apps without display insets take no new path. Co-Authored-By: Claude Opus 5.5 --- .../src/com/codename1/ui/Container.java | 256 ++++++++++++++--- .../ui/SafeAreaBottomBarStabilityTest.java | 267 ++++++++++++++++++ 2 files changed, 480 insertions(+), 43 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java diff --git a/CodenameOne/src/com/codename1/ui/Container.java b/CodenameOne/src/com/codename1/ui/Container.java index 7d252c6f81c..92e8be0d760 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -142,6 +142,23 @@ public class Container extends Component implements Iterable { private int doLayoutDepth; private TmpInsets calcTmpInsets; private int calcPreferredSizeDepth; + /// The safe-area padding, in pixels (top, bottom, left, right), that the + /// last measurement of this container's preferred size added, or null when + /// there is nothing to hold its layout to. See checkMeasuredSafeArea. + private int[] measuredSafeInsets; + /// The safe-area padding, in pixels, the last layout of this container + /// applied; null before the first. + private int[] laidOutSafeInsets; + /// Corrections checkMeasuredSafeArea made in a row; capped so a layout that + /// cannot settle is left as it is rather than laid out without end. + private int safeAreaCorrections; + private int safeAreaCleanPasses; + /// Nesting of doLayout across all containers; the outermost call re-runs a + /// layout that a safe-area container found was sized for the wrong inset. + /// Static because layout only runs on the EDT; a flag raised in another + /// tree laid out inside this one costs a wasted pass, nothing worse. + private static int layoutNesting; + private static boolean safeAreaRelayout; /// Constructs a new Container with a new layout manager and UIID /// @@ -2829,13 +2846,41 @@ private boolean isSafeAreaInternal(boolean checkParents) { /// For iPhone X primarily. This will check if the current bounds goes outside the /// safe area. If so, it will add padding to make the contents fit the safe area. - private boolean snapToSafeAreaInternal() { - if (isHidden()) { + private boolean snapToSafeAreaInternal(int[] pad) { + Style style = getStyle(); + if (pad == null) { return false; } + if (pad[0] != style.getPaddingTop()) { + style.setPaddingUnitTop(Style.UNIT_TYPE_PIXELS); + style.setPaddingTop(pad[0]); + } + if (pad[1] != style.getPaddingBottom()) { + style.setPaddingUnitBottom(Style.UNIT_TYPE_PIXELS); + style.setPaddingBottom(pad[1]); + } + if (pad[2] != style.getPaddingLeftNoRTL()) { + style.setPaddingUnitLeft(Style.UNIT_TYPE_PIXELS); + style.setPaddingLeft(pad[2]); + } + if (pad[3] != style.getPaddingRightNoRTL()) { + style.setPaddingUnitRight(Style.UNIT_TYPE_PIXELS); + style.setPaddingRight(pad[3]); + } + return true; + } + + /// The padding (top, bottom, left, right, in pixels) that keeps this + /// container's content clear of the display's unsafe edges, at its current + /// bounds, or null when it needs none. + /// Pure: the style is read, never written. + private int[] safeAreaPadding() { + if (isHidden()) { + return null; + } Container safeAreaRoot = getSafeAreaRoot(); if (safeAreaRoot == null) { - return false; + return null; } Rectangle rect; int surfaceWidth; @@ -2863,12 +2908,12 @@ private boolean snapToSafeAreaInternal() { int safeTopMargin = rect.getY(); int safeBottomMargin = surfaceHeight - rect.getHeight() - rect.getY(); if (safeLeftMargin == 0 && safeRightMargin == 0 && safeBottomMargin == 0 && safeTopMargin == 0) { - return false; + return null; } rect.setWidth(Math.max(0, safeAreaRoot.getWidth() - safeLeftMargin - safeRightMargin)); rect.setHeight(Math.max(0, safeAreaRoot.getHeight() - safeTopMargin - safeBottomMargin)); if (rect.getWidth() == 0 || rect.getHeight() == 0) { - return false; + return null; } Rectangle safeArea = rect; //Form f = getComponentForm(); @@ -2901,8 +2946,7 @@ private boolean snapToSafeAreaInternal() { int absX = getAbsoluteX() - safeAreaRoot.getAbsoluteX(); - int w = getWidth(); - int absX2 = absX + w; + int absX2 = absX + getWidth(); if (absX >= 0) { if (absX + paddingLeft < safeX1) { @@ -2916,8 +2960,7 @@ private boolean snapToSafeAreaInternal() { } int absY = getAbsoluteY() - safeAreaRoot.getAbsoluteY(); - int h = getHeight(); - int absY2 = absY + h; + int absY2 = absY + getHeight(); if (absY >= 0) { @@ -2932,40 +2975,29 @@ private boolean snapToSafeAreaInternal() { } boolean changed = false; - if (newPaddingTop != paddingTop || newPaddingBottom != paddingBottom) { - if (!hasScrollableYParentInternal()) { - changed = true; - - if (newPaddingTop != paddingTop) { - style.setPaddingUnitTop(Style.UNIT_TYPE_PIXELS); - style.setPaddingTop(newPaddingTop); - } - if (newPaddingBottom != paddingBottom) { - style.setPaddingUnitBottom(Style.UNIT_TYPE_PIXELS); - style.setPaddingBottom(newPaddingBottom); - } - - } + if ((newPaddingTop != paddingTop || newPaddingBottom != paddingBottom) + && !hasScrollableYParentInternal()) { + changed = true; + } else { + newPaddingTop = paddingTop; + newPaddingBottom = paddingBottom; } - - if (newPaddingLeft != paddingLeft || newPaddingRight != paddingRight) { - if (!hasScrollableXParentInternal()) { - changed = true; - - if (newPaddingLeft != paddingLeft) { - style.setPaddingUnitLeft(Style.UNIT_TYPE_PIXELS); - style.setPaddingLeft(newPaddingLeft); - } - if (newPaddingRight != paddingRight) { - style.setPaddingUnitRight(Style.UNIT_TYPE_PIXELS); - style.setPaddingRight(newPaddingRight); - } - - - } + if ((newPaddingLeft != paddingLeft || newPaddingRight != paddingRight) + && !hasScrollableXParentInternal()) { + changed = true; + } else { + newPaddingLeft = paddingLeft; + newPaddingRight = paddingRight; } - return changed; - + if (!changed) { + return null; + } + int[] out = new int[4]; + out[0] = newPaddingTop; + out[1] = newPaddingBottom; + out[2] = newPaddingLeft; + out[3] = newPaddingRight; + return out; } // NOT suppressed, deliberately -- see the history before changing this. @@ -2990,7 +3022,105 @@ private boolean snapToSafeAreaInternal() { // Fixing the treadmill therefore needs the peers to drive their own // repaints first; until then the wasted passes are the cheaper defect. + /// The inset a safe-area container adds depends on where it sits, but its + /// preferred size is cached from wherever it sat when it was measured: not + /// yet placed, or placed by a parent that had moved since. A bottom tab bar + /// measured that way was sized for its content alone, and its layout then + /// padded the inset into that height and clipped the labels; whichever pass + /// next re-measured it put it back, so it jumped between the two as the + /// screen changed. + /// + /// Measurement therefore also counts the inset the last layout applied, so a + /// position that is stale mid-layout cannot take it away, and layout + /// compares the inset it applies with the one measured, having the size + /// computed again when they differ. Only an axis the parent sized from the + /// preferred size is compared: a stretched container's size never came from + /// its measurement, so a different inset there changes nothing. + private void checkMeasuredSafeArea(int[] applied) { + int[] measured = measuredSafeInsets; + if (measured == null) { + return; + } + if (shouldCalcPreferredSize) { + // Already to be measured again before anything reads it. + return; + } + // The final preferred size, after preferredSizeStr, setPreferredH, + // sameWidth and the like: what the parent really sized it from. + Dimension pref = getPreferredSize(); + boolean sizedY = getHeight() == pref.getHeight(); + boolean sizedX = getWidth() == pref.getWidth(); + boolean stale = false; + for (int i = 0; i < 4; i++) { + if (applied[i] != measured[i] && (i < 2 ? sizedY : sizedX)) { + stale = true; + } + } + if (!stale) { + // Forgiven only after two clean passes in a row, so a layout that + // alternates stale and clean still runs into the cap. + if (safeAreaCorrections > 0) { + safeAreaCleanPasses++; + if (safeAreaCleanPasses >= 2) { + safeAreaCorrections = 0; + safeAreaCleanPasses = 0; + } + } + return; + } + safeAreaCleanPasses = 0; + if (safeAreaCorrections >= 3) { + return; + } + safeAreaCorrections++; + measuredSafeInsets = null; + setShouldCalcPreferredSize(true); + safeAreaRelayout = true; + } + void doLayout() { + layoutNesting++; + boolean completed = false; + try { + doLayoutImpl(); + completed = true; + } finally { + layoutNesting--; + if (layoutNesting == 0 && !completed) { + safeAreaRelayout = false; + } + } + if (layoutNesting == 0 && safeAreaRelayout) { + safeAreaRelayout = false; + // Within this same revalidate where this is the root, so no frame + // is painted with the stale size; a container laid out on its own + // cannot resize itself, so its parent goes next. + Container parent = getParent(); + if (parent == null) { + // Each pass places the bar where the next measures it, so a + // change of size can take more than one; bounded, as is each + // container's own count of corrections. + layoutNesting++; + try { + for (int pass = 0; pass < 3; pass++) { + safeAreaRelayout = false; + shouldLayout = true; + layoutContainer(); + if (!safeAreaRelayout) { + break; + } + } + } finally { + layoutNesting--; + safeAreaRelayout = false; + } + } else { + parent.revalidateLater(); + } + } + } + + private void doLayoutImpl() { doLayoutDepth++; boolean restoreBounds = false; if (safeArea && doLayoutDepth == 1) { @@ -3006,7 +3136,18 @@ void doLayout() { } Style s = getStyle(); tmpInsets.set(s); - restoreBounds = snapToSafeAreaInternal(); + int[] base = {s.getPaddingTop(), s.getPaddingBottom(), s.getPaddingLeftNoRTL(), s.getPaddingRightNoRTL()}; + int[] pad = safeAreaPadding(); + int[] applied = laidOutSafeInsets; + if (applied == null) { + applied = new int[4]; + } + for (int i = 0; i < 4; i++) { + applied[i] = pad == null ? 0 : pad[i] - base[i]; + } + laidOutSafeInsets = applied; + checkMeasuredSafeArea(applied); + restoreBounds = snapToSafeAreaInternal(pad); } } layout.layoutContainer(this); @@ -3629,8 +3770,37 @@ protected Dimension calcPreferredSize() { } Style s = getStyle(); calcTmpInsets.set(s); - restoreBounds = snapToSafeAreaInternal(); + int[] base = {s.getPaddingTop(), s.getPaddingBottom(), s.getPaddingLeftNoRTL(), s.getPaddingRightNoRTL()}; + int[] pad = safeAreaPadding(); + int[] measured = measuredSafeInsets; + if (measured == null) { + measured = new int[4]; + } + boolean any = false; + for (int i = 0; i < 4; i++) { + measured[i] = pad == null ? 0 : pad[i] - base[i]; + if (laidOutSafeInsets != null && laidOutSafeInsets[i] > measured[i]) { + measured[i] = laidOutSafeInsets[i]; + } + if (measured[i] != 0) { + any = true; + } + } + measuredSafeInsets = measured; + if (any) { + pad = new int[4]; + for (int i = 0; i < 4; i++) { + pad[i] = base[i] + measured[i]; + } + restoreBounds = snapToSafeAreaInternal(pad); + } + } else { + measuredSafeInsets = null; } + } else if (calcPreferredSizeDepth == 1) { + // Not placed yet: nothing was added, and the first layout judges + // whether something should have been. + measuredSafeInsets = safeArea && (getWidth() <= 0 || getHeight() <= 0) ? new int[4] : null; } Dimension d = layout.getPreferredSize(this); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java new file mode 100644 index 00000000000..94f2133dadb --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.Layout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A safe-area tab bar at the bottom of a screen must keep one height across +/// revalidates. It used to flip between two layouts: one sized for its content +/// plus the bottom inset, and one sized for the content alone with the inset +/// padded into it, clipping the labels. Which one a pass produced depended on +/// the bounds the bar happened to have when it was measured. +/// +/// The fix has layout re-judge the inset and lay out again when the +/// measurement was stale, so the other tests here hold it to doing that only +/// when it must: a settled bar, a stretched safe-area pane and a scrollable one +/// are laid out exactly once per revalidate. +class SafeAreaBottomBarStabilityTest extends UITestBase { + + private static final int TOP_INSET = 47; + private static final int INSET = 34; + + /// Counts its layouts, to tell a settled layout from one that keeps asking + /// for another pass. + private static final class Counting extends Container { + int layouts; + + Counting(Layout l) { + super(l); + } + + @Override + public void laidOut() { + super.laidOut(); + layouts++; + } + } + + private Container body; + private Counting tabs; + private Button cart; + private Container top; + + private Form notched() { + Form f = Display.getInstance().getCurrent(); + int w = Display.getInstance().getDisplayWidth(); + int h = Display.getInstance().getDisplayHeight(); + implementation.setDisplaySafeArea(new Rectangle(0, TOP_INSET, w, h - TOP_INSET - INSET)); + f.setLayout(new BorderLayout()); + return f; + } + + private Form build() { + Form f = notched(); + Container screen = new Container(new BorderLayout()); + top = new Container(BoxLayout.y()); + top.add(new Label("Deliver to")); + top.setSafeArea(true); + screen.add(BorderLayout.NORTH, top); + body = new Container(new BorderLayout()); + screen.add(BorderLayout.CENTER, body); + Container south = new Container(BoxLayout.y()); + cart = new Button("View cart"); + cart.setHidden(true); + cart.setVisible(false); + south.add(cart); + tabs = new Counting(new GridLayout(1, 4)); + for (String s : new String[]{"Home", "Search", "Orders", "Favorites"}) { + tabs.add(new Button(s)); + } + tabs.setSafeArea(true); + south.add(tabs); + screen.add(BorderLayout.SOUTH, south); + f.add(BorderLayout.CENTER, screen); + f.show(); + f.revalidate(); + return f; + } + + private void switchTo(Form f, int rows) { + body.removeAll(); + Container list = new Container(BoxLayout.y()); + for (int i = 0; i < rows; i++) { + list.add(new Label("Row " + i)); + } + list.setScrollableY(true); + body.add(BorderLayout.CENTER, list); + f.revalidate(); + } + + private int contentHeight() { + int max = 0; + for (Component c : tabs) { + max = Math.max(max, c.getOuterHeight()); + } + return max + tabs.getStyle().getVerticalPadding(); + } + + @FormTest + void bottomBarKeepsItsHeightAcrossTabSwitches() { + Form f = build(); + int expected = contentHeight() + INSET; + int[] rows = {3, 40, 0, 12, 1, 60, 5}; + for (int i = 0; i < rows.length; i++) { + switchTo(f, rows[i]); + assertEquals(expected, tabs.getHeight(), "tab bar height after switch " + i); + assertEquals(f.getContentPane().getAbsoluteY() + f.getContentPane().getHeight(), + tabs.getAbsoluteY() + tabs.getHeight(), "tab bar bottom after switch " + i); + // The header is nowhere near the bottom edge: no bottom inset. + assertEquals(top.getComponentAt(0).getOuterHeight() + top.getStyle().getVerticalPadding() + + Math.max(0, TOP_INSET - top.getAbsoluteY()), top.getHeight(), + "header height after switch " + i); + } + } + + @FormTest + void bottomBarKeepsItsHeightWhenTheCartAppearsAndGoes() { + Form f = build(); + int expected = contentHeight() + INSET; + for (int i = 0; i < 6; i++) { + boolean show = i % 2 == 0; + cart.setHidden(!show); + cart.setVisible(show); + f.revalidate(); + assertEquals(expected, tabs.getHeight(), "tab bar height, cart shown=" + show); + } + } + + @FormTest + void aSettledBottomBarIsLaidOutOncePerRevalidate() { + Form f = build(); + switchTo(f, 4); + int height = tabs.getHeight(); + for (int i = 0; i < 3; i++) { + tabs.layouts = 0; + f.revalidate(); + assertEquals(1, tabs.layouts, "settled tab bar, revalidate " + i); + assertEquals(height, tabs.getHeight()); + } + } + + @FormTest + void aStretchedSafeAreaPaneIsLaidOutOncePerRevalidate() { + Form f = notched(); + Counting pane = new Counting(new BorderLayout()); + pane.setSafeArea(true); + Label content = new Label("Short content"); + pane.add(BorderLayout.NORTH, content); + f.add(BorderLayout.CENTER, pane); + f.show(); + f.revalidate(); + for (int i = 0; i < 3; i++) { + pane.layouts = 0; + f.revalidate(); + assertEquals(1, pane.layouts, "stretched safe-area pane, revalidate " + i); + } + // Stretched to the bottom edge, its content still stays clear of it. + int safeBottom = Display.getInstance().getDisplayHeight() - INSET; + assertTrue(pane.getAbsoluteY() + pane.getHeight() > safeBottom, "pane reaches the unsafe strip"); + } + + @FormTest + void aScrollableSafeAreaPaneKeepsTheInsetInItsScrollSize() { + Form f = notched(); + Counting pane = new Counting(BoxLayout.y()); + pane.setScrollableY(true); + pane.setSafeArea(true); + for (int i = 0; i < 200; i++) { + pane.add(new Label("Row " + i)); + } + f.add(BorderLayout.CENTER, pane); + f.show(); + f.revalidate(); + int content = 0; + for (Component c : pane) { + content += c.getOuterHeight(); + } + int scroll = pane.getScrollDimension().getHeight(); + assertTrue(scroll >= content + INSET, + "scroll size " + scroll + " must let the last row clear the inset (content " + content + ")"); + pane.layouts = 0; + f.revalidate(); + assertEquals(1, pane.layouts, "scrollable safe-area pane, one layout per revalidate"); + } + + @FormTest + void bottomBarDropsTheInsetWhenTheSafeAreaGoesAway() { + Form f = build(); + int content = contentHeight(); + assertEquals(content + INSET, tabs.getHeight()); + int w = Display.getInstance().getDisplayWidth(); + int h = Display.getInstance().getDisplayHeight(); + implementation.setDisplaySafeArea(new Rectangle(0, 0, w, h)); + for (int i = 0; i < 2; i++) { + switchTo(f, 3 + i); + } + assertEquals(content, tabs.getHeight(), "no inset once the display has none"); + } + + @FormTest + void aBarMovedToTheTopLosesTheBottomInset() { + Form f = build(); + int content = contentHeight(); + Container south = tabs.getParent(); + south.removeComponent(tabs); + top.add(tabs); + f.revalidate(); + f.revalidate(); + assertEquals(content, tabs.getHeight(), "a bar at the top has no bottom inset"); + } + + @FormTest + void aSafeAreaBarInsideASafeAreaPaneGetsOneInset() { + Form f = notched(); + Container pane = new Container(new BorderLayout()); + pane.setSafeArea(true); + pane.add(BorderLayout.CENTER, new Label("Body")); + Counting bar = new Counting(new GridLayout(1, 2)); + bar.add(new Button("A")); + bar.add(new Button("B")); + bar.setSafeArea(true); + pane.add(BorderLayout.SOUTH, bar); + f.add(BorderLayout.CENTER, pane); + f.show(); + f.revalidate(); + int content = 0; + for (Component c : bar) { + content = Math.max(content, c.getOuterHeight()); + } + content += bar.getStyle().getVerticalPadding(); + for (int i = 0; i < 3; i++) { + bar.layouts = 0; + f.revalidate(); + assertEquals(1, bar.layouts, "nested bar, revalidate " + i); + assertEquals(content, bar.getHeight(), "the pane already pads the inset; the bar adds none"); + } + } +} From e5dfc07a83e5533606964c6230dffff76eb53ef6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:09:25 +0200 Subject: [PATCH 2/3] UIManager doc snippet declares the resource file it opens The snippet compiled only with an undeclared resourceFile, and master's edit of it left a baseline entry for a hash that no longer exists. Co-Authored-By: Claude Opus 5.5 --- CodenameOne/src/com/codename1/ui/plaf/UIManager.java | 1 + scripts/doc-snippet-baseline.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index 541f9d89d2d..91d4f5c29d2 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -563,6 +563,7 @@ private static Font parseFont(String fontStr) { /// where we would be out of options if something like that happens. Effectively this is the same as writing: /// /// ```java + /// String resourceFile = "/theme"; /// Resources theme = null; /// try { /// theme = Resources.openLayered(resourceFile); diff --git a/scripts/doc-snippet-baseline.txt b/scripts/doc-snippet-baseline.txt index 0693af2cadc..8543a681e35 100644 --- a/scripts/doc-snippet-baseline.txt +++ b/scripts/doc-snippet-baseline.txt @@ -321,7 +321,6 @@ CodenameOne/src/com/codename1/ui/list/CellRenderer.java#5eca0098701e CodenameOne/src/com/codename1/ui/list/ListCellRenderer.java#500e2416fe67 CodenameOne/src/com/codename1/ui/list/ListModel.java#8d4d9b014972 CodenameOne/src/com/codename1/ui/list/ListModel.java#d0795f2aa202 -CodenameOne/src/com/codename1/ui/plaf/UIManager.java#6aaadca1b2ce CodenameOne/src/com/codename1/util/regex/RE.java#32627ff643a4 CodenameOne/src/com/codename1/vpn/profile/Vpn.java#aaa2e660926c CodenameOne/src/com/codename1/vpn/profile/VpnProfile.java#a1cc6689b4e5 From e8a5d3fffcf0bf80ded01d567c35e18b362c76b8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:44:00 +0200 Subject: [PATCH 3/3] Safe area: a bar measured during its own layout counts the inset once A bottom Tabs bar is measured again from inside its own layout, while its style holds the snapped padding; the remembered inset was then added on top of it, and the bar was an inset too tall (iOS TabsBehavior). Layout now records the padding it snapped from, saved and restored around nested layouts, and measurement counts the inset against that. Co-Authored-By: Claude Opus 5.5 --- .../src/com/codename1/ui/Container.java | 41 +++++++++++++++---- .../ui/SafeAreaBottomBarStabilityTest.java | 26 ++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/Container.java b/CodenameOne/src/com/codename1/ui/Container.java index 808e905b482..5340f3c038b 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -151,6 +151,8 @@ public class Container extends Component implements Iterable { private int[] laidOutSafeInsets; // What laidOutSafeInsets was computed against (see safeAreaContext). private int[] laidOutSafeContext; + // The padding before the snap, while a layout holds the snapped one. + private int[] snappedFromPadding; // Written by every safeAreaPadding call: the safe margins and surface the // answer depended on. EDT only, like layoutNesting. private static final int[] safeAreaContext = new int[6]; @@ -3164,6 +3166,7 @@ void doLayout() { private void doLayoutImpl() { doLayoutDepth++; boolean restoreBounds = false; + int[] tmpSnappedFrom = null; if (safeArea && doLayoutDepth == 1) { // If this container is marked as a safe area // then we may need to add padding to make it *safe* @@ -3193,19 +3196,30 @@ private void doLayoutImpl() { System.arraycopy(safeAreaContext, 0, laidOutSafeContext, 0, safeAreaContext.length); checkMeasuredSafeArea(applied); restoreBounds = snapToSafeAreaInternal(pad, false); + tmpSnappedFrom = base; } else { laidOutSafeInsets = null; } } - layout.layoutContainer(this); - int count = getComponentCount(); - for (int i = 0; i < count; i++) { - Component c = getComponentAt(i); - if (c instanceof Container) { - ((Container) c).layoutContainer(); - } else { - c.laidOut(); + // Saved, not cleared: a nested layout of this container must not drop + // the outer one's snapped padding while that layout still holds it. + int[] outerSnappedFrom = snappedFromPadding; + if (restoreBounds) { + snappedFromPadding = tmpSnappedFrom; + } + try { + layout.layoutContainer(this); + int count = getComponentCount(); + for (int i = 0; i < count; i++) { + Component c = getComponentAt(i); + if (c instanceof Container) { + ((Container) c).layoutContainer(); + } else { + c.laidOut(); + } } + } finally { + snappedFromPadding = outerSnappedFrom; } if (restoreBounds && tmpInsets != null) { tmpInsets.restore(getStyle()); @@ -3826,6 +3840,17 @@ protected Dimension calcPreferredSize() { // last layout's inset says nothing about this one. laidOut = null; } + int[] snappedFrom = snappedFromPadding; + if (snappedFrom != null) { + // Measured from inside its own layout, which holds the + // snapped padding: the inset is already in the style, so + // it is counted once, against the padding it replaced. + laidOut = new int[4]; + for (int i = 0; i < 4; i++) { + laidOut[i] = Math.max(0, base[i] - snappedFrom[i]); + base[i] = snappedFrom[i]; + } + } int[] measured = measuredSafeInsets; if (measured == null) { measured = new int[4]; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java index fe6de7da8f5..ec16677f9b6 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java @@ -290,6 +290,32 @@ void aBarTakesANewInsetInOneRevalidate() { assertEquals(content + INSET, tabs.getHeight(), "the bar gets the new inset in the same revalidate"); } + @FormTest + void aBarMeasuredDuringItsOwnLayoutCountsTheInsetOnce() { + // A bottom Tabs bar is measured again from inside its own layout, when + // its style already holds the snapped padding; adding the inset to + // that again made its preferred size an inset too tall. + notched(); + Form f = Display.getInstance().getCurrent(); + Tabs t = new Tabs(Component.BOTTOM); + t.addTab("A", new Label("first")); + t.addTab("B", new Label("second")); + f.add(BorderLayout.CENTER, t); + f.revalidate(); + t.setSelectedIndex(1); + f.revalidate(); + Container bar = t.getTabsContainer(); + int content = 0; + for (Component c : bar) { + content = Math.max(content, c.getOuterHeight()); + } + int overlap = bar.getAbsoluteY() + bar.getHeight() - (Display.getInstance().getDisplayHeight() - INSET); + assertTrue(overlap > 0, "the bar reaches into the unsafe edge: " + overlap); + assertEquals(content + bar.getStyle().getVerticalPadding() + overlap, bar.getPreferredH(), + "the bar is measured with one inset"); + assertEquals(bar.getPreferredH(), bar.getHeight()); + } + @FormTest void aSafeAreaBarInsideASafeAreaPaneGetsOneInset() { Form f = notched();