diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index dca3c4cd2f..5ea1b65f12 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -75,6 +75,14 @@ public class UIManager { private Style defaultStyle = new Style(); private Style defaultSelectedStyle = new Style(); private boolean useLargerTextScale; + /// Tracks the original (unscaled) Font we replaced in themeProps when + /// [#applyLargerTextScaleToThemeFonts] last ran. Without this, each scale + /// change derives from the previously-scaled font and compounds, so going + /// XL -> XXL over-scales and going XXL -> Large never shrinks back. The + /// parallel scaledFontDerived map records the Font we wrote, so we only + /// restore entries that the theme has not overwritten in the meantime. + private final Map scaledFontOriginals = new HashMap(); + private final Map scaledFontDerived = new HashMap(); /// The resource bundle allows us to implicitly localize the UI on the fly, once its /// installed all internal application strings query the resource bundle and extract /// their values from this table if applicable. @@ -2042,6 +2050,26 @@ private Font scaleFontForLargerText(Font font, float scale) { } private void applyLargerTextScaleToThemeFonts() { + // Roll back any prior scaling we applied so this pass always derives + // from the original installed font. Without the rollback, repeated + // refreshes compound (each scale multiplies the previously-derived + // pixel size) and a return to scale 1.0 never actually shrinks fonts. + if (!scaledFontOriginals.isEmpty()) { + for (Map.Entry entry : scaledFontOriginals.entrySet()) { + String key = entry.getKey(); + Object current = themeProps.get(key); + Font derived = scaledFontDerived.get(key); + // Only restore when the theme still holds the Font we wrote; + // an intervening setThemeProps/addThemeProps may have replaced + // it with a new original we must keep. + if (current == derived) { //NOPMD CompareObjectsWithEquals + themeProps.put(key, entry.getValue()); + } + } + scaledFontOriginals.clear(); + scaledFontDerived.clear(); + } + float scale = getEffectiveLargerTextScale(); if (scale <= 1f) { return; @@ -2052,8 +2080,11 @@ private void applyLargerTextScaleToThemeFonts() { } Object value = entry.getValue(); if (value instanceof Font) { - Font scaled = scaleFontForLargerText((Font) value, scale); - if (scaled != value) { //NOPMD CompareObjectsWithEquals + Font original = (Font) value; + Font scaled = scaleFontForLargerText(original, scale); + if (scaled != original) { //NOPMD CompareObjectsWithEquals + scaledFontOriginals.put(entry.getKey(), original); + scaledFontDerived.put(entry.getKey(), scaled); entry.setValue(scaled); } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 79d58e6ea9..7d62f3b07f 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -5726,7 +5726,7 @@ private void addSkinName(String f) { } } - private void deepRevaliate(com.codename1.ui.Container c) { + private static void deepRevaliate(com.codename1.ui.Container c) { c.setShouldCalcPreferredSize(true); for (int iter = 0; iter < c.getComponentCount(); iter++) { com.codename1.ui.Component cmp = c.getComponentAt(iter); @@ -5933,17 +5933,36 @@ static Rectangle parsePersistedBounds(String s) { private void refreshThemeOnly() { Display.getInstance().callSerially(new Runnable() { public void run() { - UIManager.getInstance().refreshTheme(); - Form curr = Display.getInstance().getCurrent(); - if (curr != null) { - deepRevaliate(curr); - curr.revalidate(); - curr.repaint(); - } + applyThemeOnlyRefresh(Display.getInstance().getCurrent()); } }); } + /// Refreshes the active theme on the given form in place. Extracted so + /// the simulator's Dark/Light + Larger Text menu actions and the unit + /// test that guards them share the exact same sequence: + /// + /// 1. `UIManager.refreshTheme()` rebuilds themeProps and clears the + /// style cache. + /// 2. `Form.refreshTheme(true)` walks the live component tree and + /// re-resolves each Style, so the displayed components actually + /// pick up the new fonts / colors. Without this step the user sees + /// no change until they navigate away and back or restart the + /// simulator -- the bug reported in issue #4963. + /// 3. `deepRevaliate` + `revalidate` + `repaint` redo layout against + /// the new font metrics so a taller font actually claims more + /// pixels on screen. + static void applyThemeOnlyRefresh(Form curr) { + UIManager.getInstance().refreshTheme(); + if (curr == null) { + return; + } + curr.refreshTheme(true); + deepRevaliate(curr); + curr.revalidate(); + curr.repaint(); + } + private ArrayList deinitializeHooks = new ArrayList<>(); public void addDeinitializeHook(Runnable r) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerLargeTextScaleTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerLargeTextScaleTest.java index 6b0c719f70..99956eeeeb 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerLargeTextScaleTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerLargeTextScaleTest.java @@ -3,6 +3,8 @@ import com.codename1.junit.UITestBase; import com.codename1.testing.TestCodenameOneImplementation; import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Label; import java.util.Hashtable; import org.junit.jupiter.api.Test; @@ -69,4 +71,99 @@ public void testLargeTextScaleDisabledByDefault() { Font scaledFont = manager.getComponentStyle("Title").getFont(); assertEquals(12f, scaledFont.getPixelSize(), 0.01f); } + + /// Walking the scale up and back down via repeated [UIManager#refreshTheme] + /// calls (the path the simulator's Larger Text menu takes) must always + /// derive sizes from the original installed font, not from the + /// previously-scaled font. Without the rollback in + /// [UIManager#applyLargerTextScaleToThemeFonts] each step compounded the + /// previous one: XL -> XXL over-scaled, and a return to scale 1.0 never + /// shrank the fonts back. Regression cover for issue #4963. + @Test + public void testRepeatedScaleChangesDoNotCompound() { + TestCodenameOneImplementation impl = implementation; + UIManager manager = UIManager.getInstance(); + manager.setUseLargerTextScale(true); + + Font baseFont = Font.createTrueTypeFont(Font.NATIVE_MAIN_REGULAR, Font.NATIVE_MAIN_REGULAR) + .derive(20f, Font.STYLE_PLAIN); + Hashtable theme = new Hashtable(); + theme.put("Button.font", baseFont); + + impl.setLargerTextEnabled(false); + impl.setLargerTextScale(1.0f); + manager.setThemeProps(theme); + assertEquals(20f, manager.getComponentStyle("Button").getFont().getPixelSize(), 0.01f); + + // First bump: 1.0 -> 1.5 should yield 30. + impl.setLargerTextEnabled(true); + impl.setLargerTextScale(1.5f); + manager.refreshTheme(); + assertEquals(30f, manager.getComponentStyle("Button").getFont().getPixelSize(), 0.01f); + + // Second bump: 1.5 -> 2.0 must yield 40 (20 * 2.0), not 60 (30 * 2.0). + impl.setLargerTextScale(2.0f); + manager.refreshTheme(); + assertEquals(40f, manager.getComponentStyle("Button").getFont().getPixelSize(), 0.01f); + + // Step back down: 2.0 -> 1.25 must yield 25, not some compounded value. + impl.setLargerTextScale(1.25f); + manager.refreshTheme(); + assertEquals(25f, manager.getComponentStyle("Button").getFont().getPixelSize(), 0.01f); + + // Return to default scale: fonts must shrink all the way back to 20. + impl.setLargerTextEnabled(false); + impl.setLargerTextScale(1.0f); + manager.refreshTheme(); + assertEquals(20f, manager.getComponentStyle("Button").getFont().getPixelSize(), 0.01f); + } + + /// Calling [UIManager#refreshTheme] alone rebuilds the theme cache but + /// does not push the rebuilt styles down to components that already + /// resolved their styles. The simulator's Larger Text menu therefore has + /// to follow `UIManager.refreshTheme()` with `Form.refreshTheme(true)` + /// (see `JavaSEPort.refreshThemeOnly`) so the components on screen + /// actually pick up the new fonts. This test pins that contract by + /// verifying an existing Label's resolved font tracks the scale through + /// a full up-and-back-down cycle. Issue #4963. + @Test + public void testFormRefreshThemePropagatesScaleChange() { + TestCodenameOneImplementation impl = implementation; + UIManager manager = UIManager.getInstance(); + manager.setUseLargerTextScale(true); + + Font baseFont = Font.createTrueTypeFont(Font.NATIVE_MAIN_REGULAR, Font.NATIVE_MAIN_REGULAR) + .derive(18f, Font.STYLE_PLAIN); + Hashtable theme = new Hashtable(); + theme.put("Label.font", baseFont); + + impl.setLargerTextEnabled(false); + impl.setLargerTextScale(1.0f); + manager.setThemeProps(theme); + + Form form = new Form(); + Label label = new Label("Hello"); + form.addComponent(label); + // Force the style chain to be resolved so the label is holding a + // Style instance from the pre-refresh theme. + assertEquals(18f, label.getStyle().getFont().getPixelSize(), 0.01f); + + impl.setLargerTextEnabled(true); + impl.setLargerTextScale(1.5f); + manager.refreshTheme(); + form.refreshTheme(true); + assertEquals(27f, label.getStyle().getFont().getPixelSize(), 0.01f); + + impl.setLargerTextScale(2.0f); + manager.refreshTheme(); + form.refreshTheme(true); + assertEquals(36f, label.getStyle().getFont().getPixelSize(), 0.01f); + + impl.setLargerTextEnabled(false); + impl.setLargerTextScale(1.0f); + manager.refreshTheme(); + form.refreshTheme(true); + assertEquals(18f, label.getStyle().getFont().getPixelSize(), 0.01f); + } + }