Right-clicking a client's avatar offers to save it to a file or to take it as our own avatar on that server, as the Android picture viewer does. Our own avatar only offers saving. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
452 lines
18 KiB
Java
452 lines
18 KiB
Java
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<Avatar> 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<Avatar> 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 "<i class='muted'>Select the server, a channel or a client to see details.</i>";
|
|
}
|
|
|
|
/**
|
|
* 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 <img>}. */
|
|
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 "<table width='100%' cellpadding='0' cellspacing='0'><tr>"
|
|
+ "<td valign='top'>" + details + "</td>"
|
|
+ "<td valign='top' align='right' style='padding-left:" + HtmlStyles.px(8) + "'>" + picture + "</td>"
|
|
+ "</tr></table>";
|
|
}
|
|
|
|
private String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
|
String you = cl.self ? " <span class='muted'>(you)</span>" : "";
|
|
// 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("<hr><div>").append(description(details.description())).append("</div>");
|
|
} else if (details.pending()) {
|
|
sb.append("<hr><i class='muted'>Loading description…</i>");
|
|
} else if (none != null) {
|
|
sb.append("<hr><i class='muted'>").append(none).append("</i>");
|
|
}
|
|
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("<html><body>" + body + "</body></html>");
|
|
pane.setCaretPosition(0);
|
|
}
|
|
|
|
/**
|
|
* An inline {@code <img>} 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 "<img src='" + file.toURI() + "' width='" + UIScale.scale(14) + "' height='" + UIScale.scale(14) + "'> ";
|
|
}
|
|
|
|
/** An inline {@code <img>} 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 "<img src='" + file.toURI() + "' width='" + UIScale.scale(16) + "' height='" + UIScale.scale(11) + "'> ";
|
|
}
|
|
|
|
private static String heading(String text) {
|
|
return "<div style='font-weight:bold;font-size:13px;margin-bottom:" + HtmlStyles.px(4) + "'>" + text + "</div>";
|
|
}
|
|
|
|
private static void row(StringBuilder sb, String label, String value) {
|
|
sb.append("<div style='margin:1px 0'><span class='muted'>")
|
|
.append(label).append(":</span> ").append(value).append("</div>");
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
|
|
}
|