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..e64f44b6ad 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,101 @@ 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; + + /** 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. + */ + 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; + } + 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 +138,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); } 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);