Zoom chat and descriptions with Ctrl+mouse wheel

Text and pictures in the chat tabs and the info panel zoom in browser
steps (50-300 %), keeping the line at the top in view. The chat and the
info panel each have their own level, shared by every server tab and
saved in the settings.

The HTML documents now take their fonts from a stylesheet that applies
the zoom. The old ScaledStyleSheet never reached the document, and
FlatLaf already applies the interface scale, so it only zooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:44:04 +00:00
parent 0ff0919db2
commit d2de76f534
7 changed files with 229 additions and 12 deletions

View File

@@ -198,6 +198,10 @@ public final class Settings {
public String chatLogDir = ""; public String chatLogDir = "";
/** Whether descriptions show their pictures, fetched from the web, or only links to them. */ /** Whether descriptions show their pictures, fetched from the web, or only links to them. */
public boolean descriptionPictures = true; public boolean descriptionPictures = true;
/** Ctrl+wheel zoom of the chat's text and pictures, 1.0 being 100&nbsp;%. */
public double chatZoom = 1.0;
/** Ctrl+wheel zoom of the info panel's text and pictures, 1.0 being 100&nbsp;%. */
public double infoZoom = 1.0;
public static Settings load() { public static Settings load() {
Settings s = new Settings(); Settings s = new Settings();

View File

@@ -97,9 +97,11 @@ public final class ChatPanel extends JPanel {
private SendHandler sendHandler; private SendHandler sendHandler;
private LinkHandler linkHandler; private LinkHandler linkHandler;
private final TextZoom zoom;
public ChatPanel() { public ChatPanel(TextZoom zoom) {
super(new BorderLayout()); super(new BorderLayout());
this.zoom = zoom;
tabs.setFont(Theme.uiFont()); tabs.setFont(Theme.uiFont());
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
@@ -319,6 +321,7 @@ public final class ChatPanel extends JPanel {
private void addTab(Tab tab, boolean closable) { private void addTab(Tab tab, boolean closable) {
tabs.addTab(tab.title, tab.scroll); tabs.addTab(tab.title, tab.scroll);
zoom.attach(tab.log);
tabs.setTabComponentAt(tabs.indexOfComponent(tab.scroll), tab.header(closable)); tabs.setTabComponentAt(tabs.indexOfComponent(tab.scroll), tab.header(closable));
} }

View File

@@ -8,9 +8,20 @@ import javax.swing.JViewport;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.text.html.HTMLEditorKit; import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.AttributeSet; import javax.swing.text.AttributeSet;
import javax.swing.text.Document;
import javax.swing.text.html.HTMLDocument;
import javax.swing.text.Element;
import javax.swing.text.View;
import javax.swing.text.ViewFactory;
import javax.swing.text.html.ImageView;
import javax.swing.text.html.StyleSheet; import javax.swing.text.html.StyleSheet;
import java.awt.Color; import java.awt.Color;
import java.awt.Font; import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Shape;
/** /**
* The HTML panes showing TeamSpeak text — chat logs, client and channel information — * The HTML panes showing TeamSpeak text — chat logs, client and channel information —
@@ -72,8 +83,8 @@ final class HtmlStyles {
} }
private static void installKit(JEditorPane pane, String html, boolean atEnd) { private static void installKit(JEditorPane pane, String html, boolean atEnd) {
HTMLEditorKit kit = new HTMLEditorKit(); HTMLEditorKit kit = new ZoomedKit(pane);
StyleSheet css = new ScaledStyleSheet(); StyleSheet css = new StyleSheet();
css.addStyleSheet(kit.getStyleSheet()); css.addStyleSheet(kit.getStyleSheet());
Object bodyStyle = pane.getClientProperty(BODY_STYLE); Object bodyStyle = pane.getClientProperty(BODY_STYLE);
@@ -114,15 +125,86 @@ final class HtmlStyles {
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue()); return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
} }
/** /** Scales every font size by the pane's {@link TextZoom}; FlatLaf has already applied the interface scale. */
* Scales every font size, the way a browser scales CSS pixels: Swing takes a private static final class ZoomedStyleSheet extends StyleSheet {
* {@code font-size} in pixels literally, and the text is written in unscaled ones. private final JEditorPane pane;
*/
private static final class ScaledStyleSheet extends StyleSheet { ZoomedStyleSheet(JEditorPane pane) {
this.pane = pane;
}
@Override @Override
public Font getFont(AttributeSet a) { public Font getFont(AttributeSet a) {
Font font = super.getFont(a); Font font = super.getFont(a);
return UIScale.getUserScaleFactor() == 1f ? font : font.deriveFont(UIScale.scale(font.getSize2D())); float zoom = TextZoom.of(pane);
return zoom == 1f ? font : font.deriveFont(font.getSize2D() * zoom);
}
}
/** Zooms the text of the documents it makes, and draws their pictures at the pane's {@link TextZoom}. */
private static final class ZoomedKit extends HTMLEditorKit {
private final JEditorPane pane;
private final ViewFactory factory = new HTMLFactory() {
@Override
public View create(Element elem) {
View view = super.create(elem);
return view instanceof ImageView ? new ZoomedImageView(elem) : view;
}
};
ZoomedKit(JEditorPane pane) {
this.pane = pane;
}
/** As the default, but the document takes its fonts from a {@link ZoomedStyleSheet}. */
@Override
public Document createDefaultDocument() {
StyleSheet css = new ZoomedStyleSheet(pane);
css.addStyleSheet(getStyleSheet());
HTMLDocument doc = new HTMLDocument(css);
doc.setParser(getParser());
doc.setAsynchronousLoadPriority(4);
doc.setTokenThreshold(100);
return doc;
}
@Override
public ViewFactory getViewFactory() {
return factory;
}
}
private static final class ZoomedImageView extends ImageView {
ZoomedImageView(Element elem) {
super(elem);
}
private float zoom() {
return TextZoom.of(getContainer());
}
@Override
public float getPreferredSpan(int axis) {
return super.getPreferredSpan(axis) * zoom();
}
@Override
public void paint(Graphics g, Shape a) {
float zoom = zoom();
if (zoom == 1f) {
super.paint(g, a);
return;
}
Rectangle r = a.getBounds();
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2.translate(r.x, r.y);
g2.scale(zoom, zoom);
super.paint(g2, new Rectangle(0, 0, Math.round(r.width / zoom), Math.round(r.height / zoom)));
} finally {
g2.dispose();
}
} }
} }
} }

View File

@@ -114,8 +114,9 @@ public final class InfoPanel extends JPanel {
if (showsPictures) keepingScroll(this::render); if (showsPictures) keepingScroll(this::render);
}); });
public InfoPanel(Settings settings, PictureDownloads downloads) { public InfoPanel(Settings settings, PictureDownloads downloads, TextZoom zoom) {
super(new BorderLayout()); super(new BorderLayout());
zoom.attach(pane);
this.downloads = downloads; this.downloads = downloads;
this.pictures = new DescriptionPictures(downloads, settings); this.pictures = new DescriptionPictures(downloads, settings);
pane.setBorder(new ScaledEmptyBorder(6, 8, 6, 8)); pane.setBorder(new ScaledEmptyBorder(6, 8, 6, 8));

View File

@@ -66,6 +66,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private final ContactStore contacts = ContactStore.load(); private final ContactStore contacts = ContactStore.load();
private final AudioBackend audio = new DesktopAudioBackend(); private final AudioBackend audio = new DesktopAudioBackend();
private final PictureDownloads pictures = new PictureDownloads(new File(AppDirs.profile(), "pictures"), true); private final PictureDownloads pictures = new PictureDownloads(new File(AppDirs.profile(), "pictures"), true);
private final TextZoom chatZoom;
private final TextZoom infoZoom;
/** Sound pack playback, shared by every connection. */ /** Sound pack playback, shared by every connection. */
private final SoundNotifier sounds; private final SoundNotifier sounds;
private final SoundPlayer soundPlayer; private final SoundPlayer soundPlayer;
@@ -102,6 +104,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
public MainFrame(Settings settings) { public MainFrame(Settings settings) {
super("TS3J — TeamSpeak 3 Java Client"); super("TS3J — TeamSpeak 3 Java Client");
this.settings = settings; this.settings = settings;
this.chatZoom = new TextZoom(settings.chatZoom, z -> {
settings.chatZoom = z;
settings.save();
});
this.infoZoom = new TextZoom(settings.infoZoom, z -> {
settings.infoZoom = z;
settings.save();
});
this.identities = IdentityStore.load(settings); this.identities = IdentityStore.load(settings);
new TeamSpeakImporter(identities, bookmarks, settings).seedIfFresh(); new TeamSpeakImporter(identities, bookmarks, settings).seedIfFresh();
this.sounds = new SoundNotifier(settings); this.sounds = new SoundNotifier(settings);
@@ -469,6 +479,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
} }
/** Tells the user of a poke or a private message while the client is in the background. */ /** Tells the user of a poke or a private message while the client is in the background. */
TextZoom chatZoom() {
return chatZoom;
}
TextZoom infoZoom() {
return infoZoom;
}
DesktopNotifier notifier() { DesktopNotifier notifier() {
return notifier; return notifier;
} }

View File

@@ -57,7 +57,7 @@ final class ServerTab {
SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) { SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) {
this.host = host; this.host = host;
this.settings = settings; this.settings = settings;
this.infoPanel = new InfoPanel(settings, pictures); this.infoPanel = new InfoPanel(settings, pictures, host.infoZoom());
// ServerTabConnectionEvents must exist before the session (which needs a listener // ServerTabConnectionEvents must exist before the session (which needs a listener
// up front), and the session must exist before the tree/chat panels and the other // up front), and the session must exist before the tree/chat panels and the other
@@ -67,7 +67,7 @@ final class ServerTab {
this.session = new ServerSession(settings, identities, audio, sounds, contacts, events); this.session = new ServerSession(settings, identities, audio, sounds, contacts, events);
this.conn = session.connection(); this.conn = session.connection();
this.groupIcons = new GroupIcons(conn.getIcons()); this.groupIcons = new GroupIcons(conn.getIcons());
this.chatPanel = new ChatPanel(); this.chatPanel = new ChatPanel(host.chatZoom());
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons); this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons);
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, contacts, settings, treeActions); this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, contacts, settings, treeActions);
treeActions.attach(treePanel); treeActions.attach(treePanel);

View File

@@ -0,0 +1,109 @@
package com.ts3client.ui;
import javax.swing.JEditorPane;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyledDocument;
import java.awt.Component;
import java.awt.Point;
import java.awt.event.MouseWheelEvent;
import java.util.Collections;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.function.DoubleConsumer;
/**
* A zoom level for the text and pictures of {@link HtmlStyles} panes, changed with
* Ctrl+mouse wheel over any of them and applied to all of them, in a browser's steps.
*/
final class TextZoom {
private static final double[] LEVELS = {0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3};
private final Set<JEditorPane> panes = Collections.newSetFromMap(new WeakHashMap<>());
private final DoubleConsumer onChange;
private double factor;
/** @param onChange told the new factor after every change, e.g. to save it */
TextZoom(double factor, DoubleConsumer onChange) {
this.factor = nearestLevel(factor);
this.onChange = onChange;
}
/** The zoom of {@code c}, an {@link HtmlStyles} pane, or 1 if it has none. */
static float of(Component c) {
return c instanceof JEditorPane pane && pane.getClientProperty(TextZoom.class) instanceof TextZoom zoom
? (float) zoom.factor : 1f;
}
/** Zooms {@code pane} with this zoom, and lets Ctrl+wheel over it change it. */
void attach(JEditorPane pane) {
if (pane.getClientProperty(TextZoom.class) == this) return;
pane.putClientProperty(TextZoom.class, this);
panes.add(pane);
pane.addMouseWheelListener(e -> onWheel(pane, e));
if (factor != 1) relayout(pane);
}
private void onWheel(JEditorPane pane, MouseWheelEvent e) {
if (!e.isControlDown()) {
// Listening for the wheel takes it from the scroll pane, so hand plain scrolling on.
Component parent = pane.getParent();
if (parent != null) parent.dispatchEvent(SwingUtilities.convertMouseEvent(pane, e, parent));
return;
}
e.consume();
if (e.getWheelRotation() != 0) step(e.getWheelRotation() < 0 ? 1 : -1);
}
private void step(int direction) {
int i = indexOf(factor);
int next = Math.max(0, Math.min(LEVELS.length - 1, i + direction));
if (next == i) return;
factor = LEVELS[next];
for (JEditorPane pane : panes) relayout(pane);
onChange.accept(factor);
}
/** Lays {@code pane} out again at the new size, keeping the same text in view. */
private static void relayout(JEditorPane pane) {
Document doc = pane.getDocument();
if (!(doc instanceof StyledDocument styled) || doc.getLength() == 0) return;
// An empty change over everything makes every view fetch its font and size again.
if (!(pane.getParent() instanceof JViewport viewport)) {
styled.setCharacterAttributes(0, doc.getLength(), new SimpleAttributeSet(), false);
return;
}
Point top = viewport.getViewPosition();
boolean atEnd = top.y + viewport.getExtentSize().height >= pane.getHeight();
int anchor = pane.viewToModel2D(top);
styled.setCharacterAttributes(0, doc.getLength(), new SimpleAttributeSet(), false);
viewport.getParent().validate();
int max = Math.max(0, pane.getHeight() - viewport.getExtentSize().height);
int y = max;
if (!atEnd) {
try {
y = Math.min(max, pane.modelToView2D(anchor).getBounds().y);
} catch (BadLocationException ignored) {
// Cannot happen: the text has not changed, only its size.
}
}
viewport.setViewPosition(new Point(top.x, y));
}
private static double nearestLevel(double factor) {
return LEVELS[indexOf(factor)];
}
private static int indexOf(double factor) {
int best = 0;
for (int i = 1; i < LEVELS.length; i++) {
if (Math.abs(LEVELS[i] - factor) < Math.abs(LEVELS[best] - factor)) best = i;
}
return best;
}
}