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