package com.ts3client.ui; import com.formdev.flatlaf.util.ScaledEmptyBorder; import com.formdev.flatlaf.util.UIScale; import com.ts3client.config.Settings; import com.ts3client.contacts.Contact; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; import com.ts3client.net.Details; import com.ts3client.net.Group; import com.ts3client.net.IconRepository; import com.ts3client.net.PictureDownloads; import com.ts3client.net.ServerModel; import com.ts3client.net.avatar.Avatar; import com.ts3client.text.BBCode; import javax.swing.BorderFactory; import javax.swing.JEditorPane; import javax.swing.JFileChooser; import javax.swing.JLabel; import javax.swing.JLayeredPane; import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.SwingUtilities; import javax.swing.text.AttributeSet; import javax.swing.text.BadLocationException; import javax.swing.text.StyleConstants; import javax.swing.text.html.HTML; import javax.swing.text.html.HTMLDocument; import java.awt.BorderLayout; import java.awt.Cursor; import java.awt.Dimension; import java.awt.Point; import java.awt.Rectangle; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.geom.Rectangle2D; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.util.List; import java.util.function.Consumer; /** * Read-only detail view for the server, channel or client selected, mirroring the * TeamSpeak 3 info box; what it says comes from {@link Details}. */ public final class InfoPanel extends JPanel { /** Moves the whole info panel's content out to a persistent chat tab. */ public interface DescriptionHandler { /** Shows/updates {@code html} in the info chat tab, creating it if needed. */ void showInfoTab(String html, Runnable onClose); /** Closes the info chat tab (the user chose to show details in the panel again). */ void closeInfoTab(); /** Hides (or restores) the panel itself, freeing its space, while its content lives in the chat tab. */ void setPanelHidden(boolean hidden); } private static final int AVATAR_MIN_SIZE = 64; private static final int AVATAR_BOX_STEP = 16; /** Pictures in a chat tab have no panel to fit, so they get this much. */ private static final int CHAT_TAB_PICTURE_WIDTH = 480; private static final int PICTURE_WIDTH_STEP = 32; private final JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;"); private final JScrollPane scroll = new JScrollPane(pane); /** Floats over the bottom-right corner of the details, out of the text's way. */ private final JLabel toggle = new JLabel("Show in chat tab"); private DescriptionHandler descriptionHandler; /** Takes someone else's avatar as ours; null where that cannot be done. */ private Consumer avatarStealer; private ChannelNode shownChannel; private ClientEntry shownClient; /** The server's address while the server itself is shown. */ private String shownServer; private ServerModel shownModel; private IconRepository shownIcons; private Contact shownContact; private Avatar shownAvatar; private boolean inChatTab; /** The avatar box the current HTML was built for; a resize that changes it re-renders. */ private int renderedAvatarBox; private final PictureDownloads downloads; private final DescriptionPictures pictures; /** Whether the description shown has pictures, which re-render it as they come or the panel resizes. */ private boolean showsPictures; private int renderedPictureWidth; private final Runnable onPicture = () -> SwingUtilities.invokeLater(() -> { if (showsPictures) keepingScroll(this::render); }); public InfoPanel(Settings settings, PictureDownloads downloads) { super(new BorderLayout()); this.downloads = downloads; this.pictures = new DescriptionPictures(downloads, settings); pane.setBorder(new ScaledEmptyBorder(6, 8, 6, 8)); pane.addHyperlinkListener(e -> { if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return; Links.open(e.getDescription(), this); }); pane.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { if (e.isPopupTrigger()) showAvatarMenu(e); } @Override public void mouseReleased(MouseEvent e) { if (e.isPopupTrigger()) showAvatarMenu(e); } }); HtmlStyles.fillViewport(pane); scroll.getViewport().addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { if (inChatTab) return; boolean avatar = shownAvatar != null && avatarBox() != renderedAvatarBox; boolean pictures = showsPictures && pictureWidth() != renderedPictureWidth; if (avatar || pictures) keepingScroll(InfoPanel.this::render); } }); toggle.setFont(Theme.uiSmall()); toggle.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); toggle.setToolTipText("Show these details in a chat tab instead"); toggle.setOpaque(true); toggle.setBorder(new ScaledEmptyBorder(1, 4, 1, 4)); toggle.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { inChatTab = true; render(); } }); // The link floats on top of the text rather than following it, so it stays // in the corner however long the description is. JLayeredPane layers = new JLayeredPane() { @Override public void doLayout() { scroll.setBounds(0, 0, getWidth(), getHeight()); // Lay the scroll pane out first: the link goes in the corner of the // viewport, which is where the scroll bars have already been taken out. scroll.doLayout(); Rectangle view = scroll.getViewport().getBounds(); Dimension d = toggle.getPreferredSize(); toggle.setBounds(view.x + view.width - d.width, view.y + view.height - d.height, d.width, d.height); } // Without a layout manager we have no size of our own, and the split pane // above us sizes the panel from it. @Override public Dimension getPreferredSize() { return scroll.getPreferredSize(); } @Override public Dimension getMinimumSize() { return scroll.getMinimumSize(); } }; layers.add(scroll, JLayeredPane.DEFAULT_LAYER); layers.add(toggle, JLayeredPane.PALETTE_LAYER); add(layers, BorderLayout.CENTER); applyTheme(); clear(); } @Override public void addNotify() { super.addNotify(); downloads.addListener(onPicture); } @Override public void removeNotify() { downloads.removeListener(onPicture); super.removeNotify(); } /** Runs {@code change}, which renders again, without the view jumping back to the top. */ private void keepingScroll(Runnable change) { Point at = scroll.getViewport().getViewPosition(); change.run(); SwingUtilities.invokeLater(() -> scroll.getViewport().setViewPosition(at)); } @Override public void updateUI() { super.updateUI(); // Runs from the superclass constructor too, before our own fields exist. if (scroll != null) applyTheme(); } private void applyTheme() { scroll.setBorder(BorderFactory.createLineBorder(Theme.border())); toggle.setBackground(pane.getBackground()); toggle.setForeground(Theme.link()); } public void clear() { shownChannel = null; shownClient = null; shownServer = null; inChatTab = false; render(); } public void setDescriptionHandler(DescriptionHandler handler) { this.descriptionHandler = handler; } public void setAvatarStealer(Consumer stealer) { this.avatarStealer = stealer; } /** Runs when the user closes the info chat tab; shows details in the panel again. */ private void onInfoTabClosed() { inChatTab = false; render(); } public void showServer(ServerModel model, String address) { shownServer = address; shownModel = model; shownChannel = null; shownClient = null; render(); } public void showChannel(ChannelNode ch) { shownChannel = ch; shownClient = null; shownServer = null; render(); } /** @param avatar the client's avatar, or {@code null} while there is none to show */ public void showClient(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact, Avatar avatar) { shownClient = cl; shownChannel = null; shownServer = null; shownModel = model; shownIcons = icons; shownContact = contact; shownAvatar = avatar; render(); } private void render() { boolean selected = shownChannel != null || shownClient != null || shownServer != null; boolean hide = inChatTab && selected; if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide); toggle.setVisible(selected && !hide); if (hide) { descriptionHandler.showInfoTab(body(), this::onInfoTabClosed); } else { setHtml(body()); } } private String body() { showsPictures = false; if (shownServer != null) return html(Details.server(shownModel, shownServer), "", "", null, null); if (shownChannel != null) return html(Details.channel(shownChannel), "", "", null, "No description."); if (shownClient != null) { renderedAvatarBox = inChatTab ? UIScale.scale(AvatarHtml.MAX_SIZE) : avatarBox(); return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox); } return "Select the server, a channel or a client to see details."; } /** * How large an avatar may be drawn beside the details: a share of the panel's * width, so a narrow panel keeps room for the text. Rounded to steps so that * dragging a divider does not rebuild the HTML on every pixel. */ private int avatarBox() { int width = scroll.getViewport().getWidth(); int max = UIScale.scale(AvatarHtml.MAX_SIZE); if (width <= 0) return max; int step = UIScale.scale(AVATAR_BOX_STEP); int box = width * 2 / 5 / step * step; return Math.max(UIScale.scale(AVATAR_MIN_SIZE), Math.min(max, box)); } private void showAvatarMenu(MouseEvent e) { Avatar avatar = shownClient == null ? null : shownAvatar; if (avatar == null || !overAvatar(avatar, e.getPoint())) return; JPopupMenu menu = new JPopupMenu(); JMenuItem save = new JMenuItem("Save…"); save.addActionListener(a -> saveAvatar(avatar, "avatar_" + shownClient.nickname)); menu.add(save); if (!shownClient.self && avatarStealer != null) { JMenuItem steal = new JMenuItem("Steal for avatar"); steal.addActionListener(a -> avatarStealer.accept(avatar)); menu.add(steal); } menu.show(pane, e.getX(), e.getY()); } /** Whether {@code point} falls on the avatar's {@code }. */ private boolean overAvatar(Avatar avatar, Point point) { if (avatar.file() == null) return false; String src = avatar.file().toURI().toString(); int pos = pane.viewToModel2D(point); for (int at : new int[]{pos, pos - 1}) { if (at < 0) continue; var el = ((HTMLDocument) pane.getDocument()).getCharacterElement(at); AttributeSet attrs = el.getAttributes(); if (attrs.getAttribute(StyleConstants.NameAttribute) != HTML.Tag.IMG || !src.equals(attrs.getAttribute(HTML.Attribute.SRC))) continue; try { Rectangle2D start = pane.modelToView2D(el.getStartOffset()); Rectangle2D end = pane.modelToView2D(el.getEndOffset()); if (start != null && end != null && start.createUnion(end).contains(point)) return true; } catch (BadLocationException ignored) { } } return false; } private void saveAvatar(Avatar avatar, String name) { JFileChooser chooser = new JFileChooser(); chooser.setDialogTitle("Save avatar"); chooser.setSelectedFile(new File(name + "." + avatar.format().extension)); if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return; try { Files.write(chooser.getSelectedFile().toPath(), avatar.data()); } catch (IOException ex) { JOptionPane.showMessageDialog(this, "Could not save the avatar: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); } } private String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact, Avatar avatar, int avatarBox) { String details = clientDetails(cl, model, icons, contact); String picture = AvatarHtml.imgTag(avatar, avatarBox); if (picture.isEmpty()) return details; // The avatar sits top-right beside the details, like the official client's info frame. return "" + "" + "" + "
" + details + "" + picture + "
"; } private String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) { String you = cl.self ? " (you)" : ""; // The country's flag goes before the name, as in TeamSpeak's info frame. return html(Details.client(cl, model, contact), flagTag(cl.country), you, icons, null); } /** * @param titlePrefix HTML before the title * @param titleSuffix HTML after the title * @param icons for the icons of the groups a field names; null for none * @param none what to say when there is no description; null to say nothing */ private String html(Details details, String titlePrefix, String titleSuffix, IconRepository icons, String none) { StringBuilder sb = new StringBuilder(); sb.append(heading(titlePrefix + esc(details.title()) + titleSuffix)); for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons)); if (!details.description().isEmpty()) { sb.append("
").append(description(details.description())).append("
"); } else if (details.pending()) { sb.append("
Loading description…"); } else if (none != null) { sb.append("
").append(none).append(""); } return sb.toString(); } private static String value(Details.Field field, IconRepository icons) { if (field.groups().isEmpty() || icons == null) return esc(field.value()); StringBuilder sb = new StringBuilder(); for (Group g : field.groups()) { if (sb.length() > 0) sb.append(", "); sb.append(iconTag(g, icons)).append(esc(g.name)); } return sb.toString(); } private void setHtml(String body) { pane.setText("" + body + ""); pane.setCaretPosition(0); } /** * An inline {@code } for a group's icon, or nothing while the icon is missing * or still downloading. Swing's HTML renderer only loads images by URL, so the icon * is served from the repository's on-disk cache. */ private static String iconTag(Group group, IconRepository icons) { if (group.iconId == 0) return ""; File file = icons.file(group.iconId); if (file == null) return ""; return " "; } /** An inline {@code } for a country's flag, or nothing for no country. */ private static String flagTag(String country) { File file = IconFiles.flag(country); if (file == null) return ""; return " "; } private static String heading(String text) { return "
" + text + "
"; } private static void row(StringBuilder sb, String label, String value) { sb.append("
") .append(label).append(": ").append(value).append("
"); } /** Descriptions may carry BBCode markup, pictures included. */ private String description(String bbcode) { String html = BBCode.toHtml(bbcode, true); if (!DescriptionPictures.hasPictures(html)) return html; showsPictures = true; renderedPictureWidth = pictureWidth(); return pictures.html(html, renderedPictureWidth); } /** * How wide a picture may be: the panel's width inside its padding, in steps so that * dragging a divider does not rebuild the HTML on every pixel. */ private int pictureWidth() { if (inChatTab) return UIScale.scale(CHAT_TAB_PICTURE_WIDTH); int step = UIScale.scale(PICTURE_WIDTH_STEP); int width = scroll.getViewport().getWidth() - UIScale.scale(24); return Math.max(step, width / step * step); } private static String esc(String s) { return BBCode.escape(s); } }