fix(io): scale GUI fonts for HiDPI + live zoom (Ctrl +/-/0)

Java Swing does not reliably pick up desktop display scaling on every
Linux session (tiny text on HiDPI laptops). Detect the display scale from
the default GraphicsConfiguration transform (falling back to screen DPI)
and multiply all font sizes by it, plus the window size. Add live zoom via
Ctrl +/-/0 as a guaranteed manual override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:25:04 +02:00
parent 650bbbe58c
commit d3a9bd06cc

View File

@@ -6,13 +6,16 @@ import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style; import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText; import thb.jeanluc.adventure.io.text.StyledText;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JFrame; import javax.swing.JFrame;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JTextArea; import javax.swing.JTextArea;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.JTextPane; import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException; import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet; import javax.swing.text.SimpleAttributeSet;
@@ -22,8 +25,12 @@ import java.awt.BorderLayout;
import java.awt.Color; import java.awt.Color;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.Font; import java.awt.Font;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsEnvironment; import java.awt.GraphicsEnvironment;
import java.awt.Insets; import java.awt.Insets;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -34,9 +41,20 @@ import java.util.concurrent.LinkedBlockingQueue;
* a styled output pane (centre), a side panel for exits / future map (right), * a styled output pane (centre), a side panel for exits / future map (right),
* and an input field (bottom). A bundled TrueType font is loaded if present so * and an input field (bottom). A bundled TrueType font is loaded if present so
* colours and glyphs work without the player installing anything. * colours and glyphs work without the player installing anything.
*
* <p>Font sizes are multiplied by a detected display scale (HiDPI) and by a
* user-adjustable zoom ({@code Ctrl +} / {@code Ctrl -} / {@code Ctrl 0}), so
* text stays readable on high-DPI laptops even when the JVM does not pick up
* the desktop scaling factor.</p>
*/ */
public class SwingIO implements GameIO { public class SwingIO implements GameIO {
/** Unscaled base size for the main output/input font, in points. */
private static final float BASE_SIZE = 15f;
/** Unscaled size for the HUD and side-panel font, in points. */
private static final float SMALL_SIZE = 12.5f;
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>(); private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
private final JFrame frame; private final JFrame frame;
private final JTextPane output; private final JTextPane output;
@@ -46,14 +64,20 @@ public class SwingIO implements GameIO {
private final JTextArea side; private final JTextArea side;
private final Font baseFont; private final Font baseFont;
/** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */
private final float uiScale;
/** User zoom multiplier, adjusted live via keyboard. */
private float zoom = 1f;
public SwingIO(String title) { public SwingIO(String title) {
baseFont = loadFont(); baseFont = loadFont();
uiScale = detectScale();
output = new JTextPane(); output = new JTextPane();
output.setEditable(false); output.setEditable(false);
output.setBackground(new Color(0x0b, 0x0e, 0x13)); output.setBackground(new Color(0x0b, 0x0e, 0x13));
output.setForeground(new Color(0xcf, 0xd6, 0xe0)); output.setForeground(new Color(0xcf, 0xd6, 0xe0));
output.setFont(baseFont);
output.setMargin(new Insets(8, 8, 8, 8)); output.setMargin(new Insets(8, 8, 8, 8));
doc = output.getStyledDocument(); doc = output.getStyledDocument();
@@ -61,20 +85,17 @@ public class SwingIO implements GameIO {
hud.setOpaque(true); hud.setOpaque(true);
hud.setBackground(new Color(0x11, 0x15, 0x1c)); hud.setBackground(new Color(0x11, 0x15, 0x1c));
hud.setForeground(new Color(0x8b, 0x94, 0xa3)); hud.setForeground(new Color(0x8b, 0x94, 0xa3));
hud.setFont(baseFont.deriveFont(12f));
hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12)); hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));
side = new JTextArea(); side = new JTextArea();
side.setEditable(false); side.setEditable(false);
side.setBackground(new Color(0x0e, 0x12, 0x18)); side.setBackground(new Color(0x0e, 0x12, 0x18));
side.setForeground(new Color(0x6f, 0xcf, 0x73)); side.setForeground(new Color(0x6f, 0xcf, 0x73));
side.setFont(baseFont.deriveFont(12f));
side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JScrollPane sideScroll = new JScrollPane(side); JScrollPane sideScroll = new JScrollPane(side);
sideScroll.setPreferredSize(new Dimension(210, 0)); sideScroll.setPreferredSize(new Dimension((int) (210 * uiScale), 0));
input = new JTextField(); input = new JTextField();
input.setFont(baseFont);
input.addActionListener(e -> { input.addActionListener(e -> {
String line = input.getText(); String line = input.getText();
input.setText(""); input.setText("");
@@ -89,23 +110,93 @@ public class SwingIO implements GameIO {
frame.add(sideScroll, BorderLayout.EAST); frame.add(sideScroll, BorderLayout.EAST);
frame.add(input, BorderLayout.SOUTH); frame.add(input, BorderLayout.SOUTH);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(900, 600); frame.setSize((int) (900 * uiScale), (int) (600 * uiScale));
frame.setLocationRelativeTo(null); frame.setLocationRelativeTo(null);
installZoomKeys();
applyFonts();
frame.setVisible(true); frame.setVisible(true);
input.requestFocusInWindow(); input.requestFocusInWindow();
} }
/** Loads the bundled font if present, else falls back to a logical monospaced font. */
private Font loadFont() { private Font loadFont() {
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) { try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
if (in != null) { if (in != null) {
Font f = Font.createFont(Font.TRUETYPE_FONT, in).deriveFont(14f); Font f = Font.createFont(Font.TRUETYPE_FONT, in);
GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f); GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
return f; return f;
} }
} catch (Exception e) { } catch (Exception e) {
// fall through to the logical monospaced fallback // fall through to the logical monospaced fallback
} }
return new Font(Font.MONOSPACED, Font.PLAIN, 14); return new Font(Font.MONOSPACED, Font.PLAIN, 12);
}
/** Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. */
private float detectScale() {
try {
GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
.getDefaultScreenDevice().getDefaultConfiguration();
double transform = gc.getDefaultTransform().getScaleX();
if (transform > 1.0) {
return (float) transform;
}
} catch (Exception ignored) {
// fall through to DPI heuristic
}
try {
int dpi = Toolkit.getDefaultToolkit().getScreenResolution();
return Math.max(1f, dpi / 96f);
} catch (Exception ignored) {
return 1f;
}
}
/** Re-derives and applies all component fonts from the current scale and zoom. */
private void applyFonts() {
float main = BASE_SIZE * uiScale * zoom;
float small = SMALL_SIZE * uiScale * zoom;
output.setFont(baseFont.deriveFont(main));
input.setFont(baseFont.deriveFont(main));
hud.setFont(baseFont.deriveFont(small));
side.setFont(baseFont.deriveFont(small));
frame.revalidate();
frame.repaint();
}
/** Binds Ctrl +, Ctrl - and Ctrl 0 to live font zoom. */
private void installZoomKeys() {
JComponent root = frame.getRootPane();
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_0, KeyEvent.CTRL_DOWN_MASK), "zoomReset");
root.getActionMap().put("zoomIn", action(e -> setZoom(zoom + 0.1f)));
root.getActionMap().put("zoomOut", action(e -> setZoom(zoom - 0.1f)));
root.getActionMap().put("zoomReset", action(e -> setZoom(1f)));
}
private void bind(JComponent c, KeyStroke ks, String name) {
c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name);
}
private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
return new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
body.accept(e);
}
};
}
private void setZoom(float z) {
zoom = Math.max(0.6f, Math.min(3.0f, z));
applyFonts();
} }
private Color colorFor(Style s) { private Color colorFor(Style s) {