package com.ts3client.ui; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; import com.ts3client.net.ServerModel; import com.ts3client.text.TsLink; import javax.swing.DropMode; import javax.swing.Icon; import javax.swing.ImageIcon; import javax.swing.JComponent; import javax.swing.JScrollPane; import javax.swing.JTree; import javax.swing.JViewport; import javax.swing.SwingUtilities; import javax.swing.TransferHandler; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.TreeModel; import javax.swing.tree.TreePath; import java.awt.BasicStroke; import java.awt.Component; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.Transferable; import java.awt.datatransfer.UnsupportedFlavorException; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.List; /** * The server view: a tree of channels each containing its clients, styled to * resemble the TeamSpeak 3 client. Talk state colours clients green live. */ public final class ServerTreePanel extends JScrollPane { /** Actions the tree can request of the controller. */ public interface Actions { void joinChannel(int channelId); void openPrivateChat(ClientEntry client); void pokeClient(ClientEntry client); void toggleClientMute(ClientEntry client); void showConnectionInfo(ClientEntry client); /** Open the file repository browser for a channel. */ void browseFiles(ChannelNode channel); boolean isClientLocallyMuted(int clientId); /** A channel or client node was selected (or {@code null} when cleared). */ void onSelectionChanged(Object userObject); /** Drag-and-drop of a client onto a channel. */ void moveClientToChannel(ClientEntry client, ChannelNode target); /** * Drag-and-drop of a channel to a new place in the tree. * * @param orderPredecessorId the channel it should sit below among its new * siblings, or 0 to place it first */ void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId); } /** Carries the dragged node inside this JVM; drops elsewhere get the BBCode text. */ private static final DataFlavor NODE_FLAVOR = new DataFlavor( DataFlavor.javaJVMLocalObjectMimeType + ";class=java.lang.Object", "TeamSpeak tree node"); private final DropIndicatorTree tree; private final DefaultMutableTreeNode root = new DefaultMutableTreeNode(); private final DefaultTreeModel treeModel = new DefaultTreeModel(root); private final ServerModel model; private final GroupIcons groupIcons; private final Actions actions; private int selfClientId = -1; public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) { this.model = model; this.groupIcons = groupIcons; this.actions = actions; root.setUserObject("Not connected"); this.tree = new DropIndicatorTree(treeModel); tree.setRootVisible(true); tree.setShowsRootHandles(true); tree.setRowHeight(20); tree.setBackground(Theme.TREE_BG); tree.setFont(Theme.UI_FONT); tree.setCellRenderer(new Renderer()); setViewportView(tree); getViewport().setBackground(Theme.TREE_BG); // The icon strip is drawn against the viewport's right edge, so the blitted // pixels a scroll would reuse are stale; repaint the whole viewport instead. getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE); // Within the tree a drag moves the client or channel; dropped elsewhere it // yields the TS3 link BBCode, which the chat input accepts as plain text. tree.setDragEnabled(true); tree.setDropMode(DropMode.ON_OR_INSERT); tree.setTransferHandler(new TreeTransferHandler()); tree.addTreeSelectionListener(e -> { TreePath path = tree.getSelectionPath(); Object obj = null; if (path != null) { obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); } actions.onSelectionChanged(obj); }); tree.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { maybePopup(e); } @Override public void mouseReleased(MouseEvent e) { maybePopup(e); } @Override public void mouseClicked(MouseEvent e) { if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) { Object obj = nodeAt(e); if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) { actions.joinChannel(((ChannelNode) obj).id); } } } }); } public void setSelfClientId(int id) { this.selfClientId = id; } private Object nodeAt(MouseEvent e) { TreePath path = tree.getPathForLocation(e.getX(), e.getY()); if (path == null) return null; DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent(); return n.getUserObject(); } private void maybePopup(MouseEvent e) { if (!e.isPopupTrigger()) return; TreePath path = tree.getPathForLocation(e.getX(), e.getY()); if (path == null) return; tree.setSelectionPath(path); Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); if (obj instanceof ClientEntry) { showClientMenu((ClientEntry) obj, e); } else if (obj instanceof ChannelNode) { showChannelMenu((ChannelNode) obj, e); } } private void showClientMenu(ClientEntry client, MouseEvent e) { ClientMenu.build(client, client.id == selfClientId, actions).show(tree, e.getX(), e.getY()); } private void showChannelMenu(ChannelNode channel, MouseEvent e) { if (Spacers.isSpacer(channel.name)) return; // spacers aren't interactive ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY()); } // ---- drag and drop ---- private static DefaultMutableTreeNode nodeOf(TreePath path) { return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent(); } /** The path of the tree node showing {@code target}, or {@code null}. */ private TreePath pathOf(Object target) { java.util.Enumeration nodes = root.breadthFirstEnumeration(); while (nodes.hasMoreElements()) { DefaultMutableTreeNode n = (DefaultMutableTreeNode) nodes.nextElement(); if (n.getUserObject() == target) return new TreePath(n.getPath()); } return null; } /** * The channel a dragged client would land in, or {@code null} if the drop makes * no sense (outside a channel, or the channel the client is already in). */ private ChannelNode resolveClientDrop(JTree.DropLocation loc, ClientEntry dragged) { DefaultMutableTreeNode target = nodeOf(loc.getPath()); if (target == null) return null; Object obj = target.getUserObject(); ChannelNode channel = null; if (obj instanceof ChannelNode) { channel = (ChannelNode) obj; } else if (obj instanceof ClientEntry) { channel = model.getChannel(((ClientEntry) obj).channelId); } if (channel == null || Spacers.isSpacer(channel.name)) return null; if (channel.id == dragged.channelId) return null; return channel; } /** * The new parent and predecessor for a dragged channel as {@code {cpid, order}}, * or {@code null} if this drop is not a legal (or meaningful) move. */ private int[] resolveChannelDrop(JTree.DropLocation loc, ChannelNode dragged) { DefaultMutableTreeNode target = nodeOf(loc.getPath()); if (target == null) return null; DefaultMutableTreeNode parentNode; int insertIndex; if (loc.getChildIndex() >= 0) { parentNode = target; insertIndex = loc.getChildIndex(); } else { // Dropped onto a node: become its last subchannel. parentNode = target.getUserObject() instanceof ClientEntry ? (DefaultMutableTreeNode) target.getParent() : target; if (parentNode == null) return null; insertIndex = parentNode.getChildCount(); } int parentId = 0; Object parentObj = parentNode.getUserObject(); if (parentObj instanceof ChannelNode) { ChannelNode parent = (ChannelNode) parentObj; if (Spacers.isSpacer(parent.name)) return null; if (isSelfOrDescendant(parent, dragged)) return null; // would detach the subtree parentId = parent.id; } else if (parentNode != root) { return null; } int predecessorId = 0; for (int i = 0; i < insertIndex && i < parentNode.getChildCount(); i++) { Object o = ((DefaultMutableTreeNode) parentNode.getChildAt(i)).getUserObject(); if (o instanceof ChannelNode && ((ChannelNode) o).id != dragged.id) { predecessorId = ((ChannelNode) o).id; } } if (parentId == dragged.parentId && predecessorId == dragged.order) return null; // no-op return new int[]{parentId, predecessorId}; } /** Whether {@code candidate} is {@code ancestor} itself or sits below it. */ private boolean isSelfOrDescendant(ChannelNode candidate, ChannelNode ancestor) { ChannelNode c = candidate; for (int guard = 0; c != null && guard < 64; guard++) { if (c.id == ancestor.id) return true; c = model.getChannel(c.parentId); } return false; } private final class TreeTransferHandler extends TransferHandler { @Override public int getSourceActions(JComponent c) { return COPY | MOVE; } @Override protected Transferable createTransferable(JComponent c) { TreePath path = tree.getSelectionPath(); if (path == null) return null; Object obj = nodeOf(path).getUserObject(); if (obj instanceof ClientEntry) { ClientEntry cl = (ClientEntry) obj; return new NodeTransferable(cl, TsLink.clientBBCode(cl.id, cl.uniqueId, cl.nickname)); } if (obj instanceof ChannelNode) { ChannelNode ch = (ChannelNode) obj; // Spacers have no meaningful link text, but can still be re-ordered. return new NodeTransferable(ch, Spacers.isSpacer(ch.name) ? null : TsLink.channelBBCode(ch.id, ch.name)); } return null; } @Override public boolean canImport(TransferSupport support) { if (!support.isDrop() || !support.isDataFlavorSupported(NODE_FLAVOR)) { tree.highlightChannel(null); return false; } if ((support.getSourceDropActions() & MOVE) == MOVE) support.setDropAction(MOVE); Object resolved = resolve(support); // A client always lands *inside* a channel, so mark that channel instead of // drawing a line that would suggest a position among its clients. tree.highlightChannel(resolved instanceof ChannelNode ? pathOf((ChannelNode) resolved) : null); // The indicator spans the full width, past the row rectangles Swing // repaints on its own when the drop location moves. tree.repaint(); return resolved != null; } @Override protected void exportDone(JComponent source, Transferable data, int action) { tree.highlightChannel(null); tree.repaint(); } @Override public boolean importData(TransferSupport support) { if (!canImport(support)) return false; Object dragged = draggedNode(support); Object resolved = resolve(support); tree.highlightChannel(null); if (dragged instanceof ClientEntry) { actions.moveClientToChannel((ClientEntry) dragged, (ChannelNode) resolved); } else if (dragged instanceof ChannelNode) { int[] place = (int[]) resolved; actions.moveChannel((ChannelNode) dragged, place[0], place[1]); } return true; } /** The destination for this drop: a ChannelNode, an {@code {cpid, order}} pair, or null. */ private Object resolve(TransferSupport support) { Object dragged = draggedNode(support); if (!(support.getDropLocation() instanceof JTree.DropLocation)) return null; JTree.DropLocation loc = (JTree.DropLocation) support.getDropLocation(); if (dragged instanceof ClientEntry) return resolveClientDrop(loc, (ClientEntry) dragged); if (dragged instanceof ChannelNode) return resolveChannelDrop(loc, (ChannelNode) dragged); return null; } private Object draggedNode(TransferSupport support) { try { return support.getTransferable().getTransferData(NODE_FLAVOR); } catch (Exception e) { return null; } } } /** Offers the dragged node locally and its TS3 link BBCode to other applications. */ private static final class NodeTransferable implements Transferable { private final Object node; private final String text; NodeTransferable(Object node, String text) { this.node = node; this.text = text; } @Override public DataFlavor[] getTransferDataFlavors() { return text == null ? new DataFlavor[]{NODE_FLAVOR} : new DataFlavor[]{NODE_FLAVOR, DataFlavor.stringFlavor}; } @Override public boolean isDataFlavorSupported(DataFlavor flavor) { return NODE_FLAVOR.equals(flavor) || (text != null && DataFlavor.stringFlavor.equals(flavor)); } @Override public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException { if (NODE_FLAVOR.equals(flavor)) return node; if (text != null && DataFlavor.stringFlavor.equals(flavor)) return text; throw new UnsupportedFlavorException(flavor); } } // ---- group icon strip ---- /** Gap kept between a row's label and the right-aligned icon strip. */ private static final int BADGE_GAP = 8; /** Inset of the strip from the visible right edge. */ private static final int BADGE_MARGIN = 4; /** * Paints the icons of every visible row — a client's group icons, a channel's own * icon — flush with the right edge of the viewport, the way TeamSpeak lines them up. * Drawing them here rather than in the cell renderer keeps the rows' measured widths * (and thus the selection highlight) tied to the label alone. */ private void paintBadges(Graphics g, JTree tree) { Rectangle visible = tree.getVisibleRect(); int right = visible.x + visible.width - BADGE_MARGIN; for (int row = 0; row < tree.getRowCount(); row++) { Rectangle bounds = tree.getRowBounds(row); if (bounds == null || bounds.y + bounds.height < visible.y) continue; if (bounds.y > visible.y + visible.height) break; TreePath path = tree.getPathForRow(row); Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); List icons = badgesOf(obj); if (icons.isEmpty()) continue; GroupIcons.Row strip = new GroupIcons.Row(icons); int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth()); strip.paintIcon(tree, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2); } } /** The icon strip a row shows on its right, empty when it has none (yet). */ private List badgesOf(Object node) { if (node instanceof ClientEntry) { ClientEntry cl = (ClientEntry) node; return groupIcons.iconsOf( model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId)); } if (node instanceof ChannelNode) { ChannelNode ch = (ChannelNode) node; Icon icon = Spacers.isSpacer(ch.name) ? null : groupIcons.icon(ch.iconId); if (icon != null) return List.of(icon); } return List.of(); } /** * Draws where the drop will land: an insertion line between rows, or an outline * around the row that will receive the dragged node. */ private final class DropIndicatorTree extends JTree { /** Set while a drop would move a client into this channel row. */ private TreePath highlight; DropIndicatorTree(TreeModel model) { super(model); } /** * Widens every repaint request to the full visible width. Swing only asks for * the row rectangle, which stops short of the right-aligned icon strip and would * leave it behind when a row's label changes width. */ @Override public void repaint(long tm, int x, int y, int width, int height) { Rectangle visible = getVisibleRect(); super.repaint(tm, visible.x, y, visible.width, height); } void highlightChannel(TreePath path) { if (path == highlight || (path != null && path.equals(highlight))) return; highlight = path; repaint(); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); paintBadges(g, this); JTree.DropLocation loc = getDropLocation(); if (loc == null || loc.getPath() == null) return; Graphics2D g2 = (Graphics2D) g.create(); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.setColor(Theme.ACCENT); if (highlight != null || loc.getChildIndex() < 0) { Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath()); if (r != null) { g2.setStroke(new BasicStroke(2f)); g2.drawRoundRect(r.x, r.y + 1, r.width - 1, r.height - 3, 4, 4); } } else { Rectangle line = insertLine(loc); if (line != null) { g2.fillRect(line.x, line.y - 1, line.width, 2); g2.fillOval(line.x - 3, line.y - 4, 7, 7); } } g2.dispose(); } /** The 1px-tall strip where the insertion line goes, in tree coordinates. */ private Rectangle insertLine(JTree.DropLocation loc) { DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent(); int index = loc.getChildIndex(); if (index < parent.getChildCount()) { Rectangle r = getPathBounds(loc.getPath().pathByAddingChild(parent.getChildAt(index))); return r == null ? null : new Rectangle(r.x, r.y, getWidth() - r.x, 2); } if (parent.getChildCount() == 0) { Rectangle r = getPathBounds(loc.getPath()); if (r == null) return null; int x = r.x + getRowHeight(); return new Rectangle(x, r.y + r.height, getWidth() - x, 2); } // Past the last child: below that child's whole (expanded) subtree. TreePath lastChild = loc.getPath().pathByAddingChild(parent.getChildAt(parent.getChildCount() - 1)); Rectangle head = getPathBounds(lastChild); Rectangle tail = getPathBounds(lastVisibleRow(lastChild)); if (head == null || tail == null) return null; return new Rectangle(head.x, tail.y + tail.height, getWidth() - head.x, 2); } private TreePath lastVisibleRow(TreePath path) { int row = getRowForPath(path); if (row < 0) return path; for (int i = row + 1; i < getRowCount(); i++) { if (!path.isDescendant(getPathForRow(i))) break; row = i; } return getPathForRow(row); } } /** Rebuilds the tree from the model, preserving full expansion. */ public void rebuild() { root.setUserObject(model.getServerName()); root.removeAllChildren(); List roots = model.buildTree(); for (ChannelNode c : roots) { root.add(buildChannel(c)); } treeModel.reload(); for (int i = 0; i < tree.getRowCount(); i++) { tree.expandRow(i); } } private DefaultMutableTreeNode buildChannel(ChannelNode c) { DefaultMutableTreeNode node = new DefaultMutableTreeNode(c); for (ClientEntry client : c.clients) { node.add(new DefaultMutableTreeNode(client)); } for (ChannelNode child : c.children) { node.add(buildChannel(child)); } return node; } /** Clears the tree back to the disconnected placeholder state. */ public void showDisconnected() { root.setUserObject("Not connected"); root.removeAllChildren(); treeModel.reload(); } /** Repaint only (e.g. talk-state changes) without rebuilding structure. */ public void refreshVisual() { tree.repaint(); } /** * Re-measures every visible row, for changes that alter a row's width (a group * icon that finished downloading). A plain repaint would keep the cached widths * and clip the new icons. */ public void refreshRowSizes() { for (int i = tree.getRowCount() - 1; i >= 0; i--) { TreePath path = tree.getPathForRow(i); if (path != null) { treeModel.nodeChanged((DefaultMutableTreeNode) path.getLastPathComponent()); } } } /** * Draws a tree row: the status icon and the label. The group icon strip is painted * separately, right-aligned, by {@link #paintBadges}. */ private final class Renderer extends DefaultTreeCellRenderer { @Override public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, boolean leaf, int row, boolean hasFocus) { super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus); setBackgroundNonSelectionColor(Theme.TREE_BG); setBackgroundSelectionColor(Theme.TREE_SELECTION); setBorderSelectionColor(Theme.TREE_SELECTION); Object obj = ((DefaultMutableTreeNode) value).getUserObject(); if (obj instanceof ChannelNode) { ChannelNode c = (ChannelNode) obj; Spacers.Spacer spacer = Spacers.parse(c.name); if (spacer != null) { setText(Spacers.render(spacer, 40)); setIcon(null); setForeground(Theme.IDLE_CLIENT); setFont(Theme.UI_FONT); } else { setText(c.name); setIcon(iconFor(c)); setForeground(Theme.CHANNEL_TEXT); setFont(Theme.UI_BOLD); } } else if (obj instanceof ClientEntry) { ClientEntry cl = (ClientEntry) obj; String label = cl.nickname; if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]"; setText(label); setIcon(iconFor(cl)); setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT); setFont(cl.talking ? Theme.UI_BOLD : Theme.UI_FONT); } else { // root / server setText(String.valueOf(obj)); setIcon(Icons.server()); setForeground(Theme.SERVER_TEXT); setFont(Theme.UI_BOLD); } return this; } private ImageIcon iconFor(ChannelNode c) { if (c.hasPassword) return Icons.channelLocked(); if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(); return Icons.channel(); } /** The client's state, in the order the official client gives them priority. */ private ImageIcon iconFor(ClientEntry cl) { if (cl.isQuery()) return Icons.clientQuery(); if (cl.outputMuted) return Icons.speakerMuted(); if (cl.inputMuted) return Icons.micMuted(); if (cl.away) return Icons.clientAway(); if (cl.channelCommander) { return cl.talking ? Icons.clientCommanderTalking() : Icons.clientCommander(); } if (cl.talking) return Icons.clientTalking(); return Icons.clientIdle(); } } }