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; + } +}