From d2de76f53406f139f143c19754ce33501536543e Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 1 Oct 2026 16:44:04 +0000 Subject: [PATCH] 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 --- .../java/com/ts3client/config/Settings.java | 4 + .../main/java/com/ts3client/ui/ChatPanel.java | 5 +- .../java/com/ts3client/ui/HtmlStyles.java | 98 ++++++++++++++-- .../main/java/com/ts3client/ui/InfoPanel.java | 3 +- .../main/java/com/ts3client/ui/MainFrame.java | 18 +++ .../main/java/com/ts3client/ui/ServerTab.java | 4 +- .../main/java/com/ts3client/ui/TextZoom.java | 109 ++++++++++++++++++ 7 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/TextZoom.java diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index 886deea..7c1cfc4 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -198,6 +198,10 @@ public final class Settings { public String chatLogDir = ""; /** Whether descriptions show their pictures, fetched from the web, or only links to them. */ public boolean descriptionPictures = true; + /** Ctrl+wheel zoom of the chat's text and pictures, 1.0 being 100 %. */ + public double chatZoom = 1.0; + /** Ctrl+wheel zoom of the info panel's text and pictures, 1.0 being 100 %. */ + public double infoZoom = 1.0; public static Settings load() { Settings s = new Settings(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java index 440fc0a..a2d2543 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java @@ -97,9 +97,11 @@ public final class ChatPanel extends JPanel { private SendHandler sendHandler; private LinkHandler linkHandler; + private final TextZoom zoom; - public ChatPanel() { + public ChatPanel(TextZoom zoom) { super(new BorderLayout()); + this.zoom = zoom; tabs.setFont(Theme.uiFont()); tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); @@ -319,6 +321,7 @@ public final class ChatPanel extends JPanel { private void addTab(Tab tab, boolean closable) { tabs.addTab(tab.title, tab.scroll); + zoom.attach(tab.log); tabs.setTabComponentAt(tabs.indexOfComponent(tab.scroll), tab.header(closable)); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java index 8574586..a471c94 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java @@ -8,9 +8,20 @@ import javax.swing.JViewport; import javax.swing.SwingUtilities; import javax.swing.text.html.HTMLEditorKit; 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 java.awt.Color; 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 — @@ -72,8 +83,8 @@ final class HtmlStyles { } private static void installKit(JEditorPane pane, String html, boolean atEnd) { - HTMLEditorKit kit = new HTMLEditorKit(); - StyleSheet css = new ScaledStyleSheet(); + HTMLEditorKit kit = new ZoomedKit(pane); + StyleSheet css = new StyleSheet(); css.addStyleSheet(kit.getStyleSheet()); 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()); } - /** - * Scales every font size, the way a browser scales CSS pixels: Swing takes a - * {@code font-size} in pixels literally, and the text is written in unscaled ones. - */ - private static final class ScaledStyleSheet extends StyleSheet { + /** Scales every font size by the pane's {@link TextZoom}; FlatLaf has already applied the interface scale. */ + private static final class ZoomedStyleSheet extends StyleSheet { + private final JEditorPane pane; + + ZoomedStyleSheet(JEditorPane pane) { + this.pane = pane; + } + @Override public Font getFont(AttributeSet 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(); + } } } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index 417a66e..e262851 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -114,8 +114,9 @@ public final class InfoPanel extends JPanel { if (showsPictures) keepingScroll(this::render); }); - public InfoPanel(Settings settings, PictureDownloads downloads) { + public InfoPanel(Settings settings, PictureDownloads downloads, TextZoom zoom) { super(new BorderLayout()); + zoom.attach(pane); this.downloads = downloads; this.pictures = new DescriptionPictures(downloads, settings); pane.setBorder(new ScaledEmptyBorder(6, 8, 6, 8)); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 94a81b2..3c25d59 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -66,6 +66,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private final ContactStore contacts = ContactStore.load(); private final AudioBackend audio = new DesktopAudioBackend(); 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. */ private final SoundNotifier sounds; private final SoundPlayer soundPlayer; @@ -102,6 +104,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { public MainFrame(Settings settings) { super("TS3J — TeamSpeak 3 Java Client"); 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); new TeamSpeakImporter(identities, bookmarks, settings).seedIfFresh(); 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. */ + TextZoom chatZoom() { + return chatZoom; + } + + TextZoom infoZoom() { + return infoZoom; + } + DesktopNotifier notifier() { return notifier; } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index 036bc42..a351080 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -57,7 +57,7 @@ final class ServerTab { SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) { this.host = host; 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 // 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.conn = session.connection(); 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.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, contacts, settings, treeActions); treeActions.attach(treePanel); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/TextZoom.java b/ts3-client/swing/src/main/java/com/ts3client/ui/TextZoom.java new file mode 100644 index 0000000..d870263 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/TextZoom.java @@ -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 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; + } +}