From e046bcb7ff0d8e33c7e5243b25f2a68e98dc1fc6 Mon Sep 17 00:00:00 2001 From: Jim Schaff Date: Tue, 11 Aug 2026 10:04:34 -0400 Subject: [PATCH 1/2] ui: scale look-and-feel fonts by vcell.ui.fontScale (inert at 1.0) Groundwork for a configurable UI font size. Walks the look-and-feel defaults after setLookAndFeel and multiplies every Font, before any window is built, because a Swing component resolves its font once at construction. Fonts are written back as FontUIResource rather than plain Font: a plain Font reads as a user-set override, which would stop the look and feel managing it. This reaches only fonts obtained implicitly from the look and feel. The ~138 sites that construct a Font with a literal point size keep theirs and must be converted separately - that is the next phase, and this commit is what makes the cost of it measurable. The scaling also runs on every platform. The existing 39-key UIManager.put block is inside `if (isMac)`, so Windows and Linux had no font handling at all; scaling is applied before that block so the Mac path derives from it rather than undoing it. Default 1.0 is a no-op. Unparseable or out-of-range values log and fall back to 1.0 rather than preventing the client from starting. Measured at 1.25 against a live client (bridge-captured component trees, 93 components matched): 17 grew wider, 71 kept their width. Damage is confined to fixed-width containers, chiefly the left navigation panel - tree labels clip without an ellipsis, a database tab is pushed out of the tab strip into scroll mode, and a horizontal scrollbar appears. Nothing failed to lay out. Co-Authored-By: Claude Opus 5 --- .../cbit/vcell/client/VCellLookAndFeel.java | 86 ++++++++++++++++++- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/vcell-client/src/main/java/cbit/vcell/client/VCellLookAndFeel.java b/vcell-client/src/main/java/cbit/vcell/client/VCellLookAndFeel.java index fe1217a9c0..b64023ceb1 100644 --- a/vcell-client/src/main/java/cbit/vcell/client/VCellLookAndFeel.java +++ b/vcell-client/src/main/java/cbit/vcell/client/VCellLookAndFeel.java @@ -12,9 +12,13 @@ import java.awt.Font; import java.awt.Toolkit; +import java.util.ArrayList; +import java.util.List; +import javax.swing.UIDefaults; import javax.swing.UIManager; import javax.swing.UnsupportedLookAndFeelException; +import javax.swing.plaf.FontUIResource; import cbit.vcell.resource.ResourceUtil; import org.apache.logging.log4j.LogManager; @@ -24,7 +28,77 @@ public class VCellLookAndFeel { private final static Logger lg = LogManager.getLogger(VCellLookAndFeel.class); - + + /** + * Multiplies every font the look and feel supplies. 1.0 (the default) leaves the UI exactly + * as it was. + *

+ * This only reaches fonts obtained implicitly, from the look and feel's defaults. A + * component whose font was set explicitly - {@code setFont(new Font("Dialog", PLAIN, 12))} - + * keeps that font, because a plain {@code Font} is not a {@link javax.swing.plaf.UIResource} + * and the look and feel will not replace it. Those sites have to be converted separately. + */ + public static final String PROPERTY_FONT_SCALE = "vcell.ui.fontScale"; + + private static final float MIN_FONT_SCALE = 0.5f; + private static final float MAX_FONT_SCALE = 4.0f; + + /** + * @return the requested font scale, or 1.0 if unset, unparseable or out of range - a bad value + * here must never stop the client from starting. + */ + static float getFontScale() { + final String raw = System.getProperty(PROPERTY_FONT_SCALE); + if (raw == null || raw.trim().isEmpty()) { + return 1.0f; + } + final float scale; + try { + scale = Float.parseFloat(raw.trim()); + } catch (NumberFormatException e) { + lg.warn("ignoring " + PROPERTY_FONT_SCALE + "='" + raw + "': not a number"); + return 1.0f; + } + if (scale < MIN_FONT_SCALE || scale > MAX_FONT_SCALE) { + lg.warn("ignoring " + PROPERTY_FONT_SCALE + "=" + scale + ": outside [" + + MIN_FONT_SCALE + ", " + MAX_FONT_SCALE + "]"); + return 1.0f; + } + return scale; + } + + /** + * Scales every {@link Font} in the look and feel's defaults, in place, before any window is + * built. Must run after {@code setLookAndFeel} (which replaces the whole defaults table) and + * before anything reads a font, because Swing components resolve their font once, at + * construction. + */ + private static void applyFontScale(float scale) { + if (scale == 1.0f) { + return; + } + final UIDefaults defaults = UIManager.getLookAndFeelDefaults(); + // snapshot the keys: resolving a lazy value can add entries, and we are writing as we go + final List keys = new ArrayList<>(defaults.keySet()); + int count = 0; + for (Object key : keys) { + final Object value; + try { + value = defaults.get(key); // resolves LazyValue / ActiveValue + } catch (Exception e) { + continue; // a defaults entry that cannot be resolved is not ours to fix + } + if (value instanceof Font) { + final Font font = (Font) value; + // FontUIResource, not Font: a plain Font would be treated as a user-set override + UIManager.put(key, new FontUIResource(font.deriveFont(font.getSize2D() * scale))); + count++; + } + } + lg.info("scaled " + count + " look-and-feel fonts by " + scale + + " (" + PROPERTY_FONT_SCALE + ")"); + } + public static Font defaultFont = null; public static void setVCellLookAndFeel() { OperatingSystemInfo osi = OperatingSystemInfo.getInstance(); @@ -40,10 +114,14 @@ public static void setVCellLookAndFeel() { lg.warn("Error while setting look and feel:", e); } // } + // before anything reads a font: setLookAndFeel above replaced the defaults table, and the + // Mac block below derives from Label.font, so it inherits the scale rather than undoing it. + applyFontScale(getFontScale()); + final boolean isMac = osi.isMac(); - - if (defaultFont == null) { - defaultFont = UIManager.getFont("Label.font"); + + if (defaultFont == null) { + defaultFont = UIManager.getFont("Label.font"); if (isMac) { defaultFont = defaultFont.deriveFont(defaultFont.getSize2D() - 2); } From 652e40b6b655a717982bbce94176d5c542c30c57 Mon Sep 17 00:00:00 2001 From: Jim Schaff Date: Tue, 11 Aug 2026 10:10:58 -0400 Subject: [PATCH 2/2] ui: scale the document editor's split-pane and minimum-size constants At 1.25 the left navigation panel was where every visible defect was: tree labels clipped without an ellipsis ("Parameters, Functions, Units, etc." lost its period), counts truncated mid-digit ("(538)" became "(53"), the database tab strip pushed "Pathway Comm" out into scroll mode, and a horizontal scrollbar appeared. None of that came from a font: it came from splitPane.setDividerLocation(270), which pins the panel's width in pixels. Measured across two live runs, the components inside it - LeftBottomTabbedPane, DatabaseWindowPanel and the three tree panels - kept their exact widths while buttons and checkboxes elsewhere grew normally, because those sit in GridBag cells that size from preferred size. Adds VCellLookAndFeel.scaleTextPixels for constants that bound text, and applies it to the two divider locations and two minimum sizes here. Icon sizes, insets and borders are deliberately out of scope - they should not move with the font. Verified at 1.25: LeftBottomTabbedPane 264 -> 332, BioModelDbTreePanel1 222 -> 290, both exactly the requested scale, and all four defects above are gone. Inert at the default 1.0. Co-Authored-By: Claude Opus 5 --- .../cbit/vcell/client/VCellLookAndFeel.java | 26 ++++++++++++++++++- .../desktop/biomodel/DocumentEditor.java | 13 ++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/vcell-client/src/main/java/cbit/vcell/client/VCellLookAndFeel.java b/vcell-client/src/main/java/cbit/vcell/client/VCellLookAndFeel.java index b64023ceb1..e64f44b6ad 100644 --- a/vcell-client/src/main/java/cbit/vcell/client/VCellLookAndFeel.java +++ b/vcell-client/src/main/java/cbit/vcell/client/VCellLookAndFeel.java @@ -43,11 +43,35 @@ public class VCellLookAndFeel { private static final float MIN_FONT_SCALE = 0.5f; private static final float MAX_FONT_SCALE = 4.0f; + /** Resolved once: the property cannot change while the client is running. */ + private static volatile Float cachedFontScale = null; + /** * @return the requested font scale, or 1.0 if unset, unparseable or out of range - a bad value * here must never stop the client from starting. */ - static float getFontScale() { + public static float getFontScale() { + Float scale = cachedFontScale; + if (scale == null) { + scale = computeFontScale(); + cachedFontScale = scale; + } + return scale; + } + + /** + * Scales a hard-coded pixel dimension that exists to fit text - a split-pane divider, a + * minimum size, a column width. Such a constant was chosen against the default font, so it + * has to move with the font or the text it was sized for no longer fits. + *

+ * This is for dimensions that bound text. Do not use it for icon sizes, insets or + * borders, which should stay where they are. + */ + public static int scaleTextPixels(int pixels) { + return Math.round(pixels * getFontScale()); + } + + private static float computeFontScale() { final String raw = System.getProperty(PROPERTY_FONT_SCALE); if (raw == null || raw.trim().isEmpty()) { return 1.0f; diff --git a/vcell-client/src/main/java/cbit/vcell/client/desktop/biomodel/DocumentEditor.java b/vcell-client/src/main/java/cbit/vcell/client/desktop/biomodel/DocumentEditor.java index 969442a365..e2587ebbfd 100644 --- a/vcell-client/src/main/java/cbit/vcell/client/desktop/biomodel/DocumentEditor.java +++ b/vcell-client/src/main/java/cbit/vcell/client/desktop/biomodel/DocumentEditor.java @@ -59,6 +59,7 @@ import org.vcell.util.gui.VCellIcons; import cbit.vcell.biomodel.BioModel; +import cbit.vcell.client.VCellLookAndFeel; import cbit.vcell.client.constants.GuiConstants; import cbit.vcell.client.desktop.DatabaseWindowPanel; import cbit.vcell.client.desktop.biomodel.DocumentEditorTreeModel.DocumentEditorTreeFolderClass; @@ -511,16 +512,17 @@ public boolean isPathEditable(TreePath path) { JScrollPane treePanel = new javax.swing.JScrollPane(documentEditorTree); leftSplitPane.setTopComponent(treePanel); - leftBottomTabbedPane.setMinimumSize(new java.awt.Dimension(198, 148)); + leftBottomTabbedPane.setMinimumSize(new java.awt.Dimension( + VCellLookAndFeel.scaleTextPixels(198), VCellLookAndFeel.scaleTextPixels(148))); leftSplitPane.setBottomComponent(leftBottomTabbedPane); leftSplitPane.setResizeWeight(0.5); - leftSplitPane.setDividerLocation(300); + leftSplitPane.setDividerLocation(VCellLookAndFeel.scaleTextPixels(300)); leftSplitPane.setDividerSize(8); leftSplitPane.setOneTouchExpandable(true); rightSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); rightSplitPane.setResizeWeight(0.7); - rightSplitPane.setDividerLocation(400); + rightSplitPane.setDividerLocation(VCellLookAndFeel.scaleTextPixels(400)); rightSplitPane.setDividerSize(8); rightSplitPane.setOneTouchExpandable(true); @@ -562,11 +564,12 @@ public boolean isPathEditable(TreePath path) { rightBottomTabbedPane.addTab(TAB_TITLE_OBJECT_PROPERTIES, rightBottomEmptyPanel); rightBottomTabbedPane.addTab(TAB_TITLE_ANNOTATIONS, rightBottomEmptyAnnotationsPanel); rightBottomTabbedPane.addTab(TAB_TITLE_PROBLEMS, issuePanel); - rightBottomTabbedPane.setMinimumSize(new java.awt.Dimension(198, 148)); + rightBottomTabbedPane.setMinimumSize(new java.awt.Dimension( + VCellLookAndFeel.scaleTextPixels(198), VCellLookAndFeel.scaleTextPixels(148))); rightSplitPane.setBottomComponent(rightBottomTabbedPane); JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); - splitPane.setDividerLocation(270); + splitPane.setDividerLocation(VCellLookAndFeel.scaleTextPixels(270)); splitPane.setOneTouchExpandable(true); splitPane.setResizeWeight(0.3); splitPane.setDividerSize(8);