package com.ts3client.ui; import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelSettings; import com.ts3client.net.ClientEntry; import com.ts3client.net.Group; import com.ts3client.net.ServerModel; import javax.swing.DropMode; import javax.swing.JScrollPane; import javax.swing.JViewport; import javax.swing.SwingUtilities; import javax.swing.plaf.basic.BasicTreeUI; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreePath; import java.awt.Rectangle; 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 kickClientFromChannel(ClientEntry client); void kickClientFromServer(ClientEntry client); void banClient(ClientEntry client); void toggleClientMute(ClientEntry client); void showConnectionInfo(ClientEntry client); /** Moves a client into the channel we are currently in. */ void moveClientToOwnChannel(ClientEntry client); /** Opens the channel editor for a channel. */ void editChannel(ChannelNode channel); /** * Opens the channel creator. * * @param parent the channel the new one goes below, or {@code null} for a top-level one * @param type the channel type to start with */ void createChannel(ChannelNode parent, ChannelSettings.Type type); /** Opens the channel creator pre-filled as a spacer, which is always top-level. */ void createSpacer(); /** Whether the server is there to act on at all. */ boolean isConnected(); /** Open the file repository browser for a channel. */ void browseFiles(ChannelNode channel); /** * Changes a channel's subscription. * * @param family whether the channels below it are included */ void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed); boolean isClientLocallyMuted(int clientId); /** Selects and scrolls to a client in the tree, e.g. from a chat/log link. */ void findClientInTree(ClientEntry client); /** 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); /** Assigns or removes a server group for a client. */ void setClientServerGroup(ClientEntry client, Group group, boolean assign); /** Assigns a channel group for a client in its current channel. */ void setClientChannelGroup(ClientEntry client, Group group); /** Opens the full server-groups list dialog for a client. */ void showServerGroupsDialog(ClientEntry client); /** Edits a client's description — our own included. */ void changeClientDescription(ClientEntry client); /** A new nickname for ourselves, typed into the tree row. */ void renameSelf(String nickname); } private final DropIndicatorTree tree; private final DefaultMutableTreeNode root = new DefaultMutableTreeNode(); /** * The nodes only mirror the connection's model, so an edited row is not written * back into the tree: the new nickname goes to the server, and the row follows * once it reports the rename. */ private final DefaultTreeModel treeModel = new DefaultTreeModel(root) { @Override public void valueForPathChanged(TreePath path, Object newValue) { String nickname = String.valueOf(newValue).trim(); Object edited = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); // The server answers a rename to the name already held with "nickname is // already in use", so a field closed unchanged is simply left alone. if (edited instanceof ClientEntry && nickname.equals(((ClientEntry) edited).nickname)) return; if (!nickname.isEmpty()) actions.renameSelf(nickname); } }; /** True while {@link #rebuild()} clears and restores the selection, to swallow the transient null in between. */ private boolean rebuilding; private final ServerModel model; private final GroupIcons groupIcons; private final Actions actions; private final NicknameCellEditor nicknameEditor; 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, model, groupIcons); tree.setRootVisible(true); // The server node is the only top-level row and always stays open, so it gets // no expand control. Nesting is tightened too: horizontal space in this view // is scarce and channel names are long. tree.setShowsRootHandles(false); if (tree.getUI() instanceof BasicTreeUI) { BasicTreeUI ui = (BasicTreeUI) tree.getUI(); ui.setLeftChildIndent(4); ui.setRightChildIndent(10); } tree.setRowHeight(20); tree.setBackground(Theme.TREE_BG); tree.setFont(Theme.UI_FONT); ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(); tree.setCellRenderer(renderer); nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId); tree.setCellEditor(nicknameEditor); tree.setEditable(true); tree.setPathEditable(nicknameEditor::editsPath); tree.setInvokesStopCellEditing(true); 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 ServerTreeDragAndDrop(model, actions, tree, this::pathOf).transferHandler()); tree.addTreeSelectionListener(e -> { if (rebuilding) return; 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) { selectRowUnder(e); maybePopup(e); } @Override public void mouseReleased(MouseEvent e) { maybePopup(e); } @Override public void mouseClicked(MouseEvent e) { Object obj = nodeAt(e); if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) { if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) { actions.joinChannel(((ChannelNode) obj).id); } else if (obj instanceof ClientEntry && ((ClientEntry) obj).id != selfClientId) { actions.openPrivateChat((ClientEntry) obj); } } else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) { actions.showConnectionInfo((ClientEntry) obj); } } }); } /** * Selects the row a press landed on. A row acts on its whole line, but Swing's own * hit testing stops at the end of the label, so a press further right would leave * the selection where it was. */ private void selectRowUnder(MouseEvent e) { if (!SwingUtilities.isLeftMouseButton(e) && !e.isPopupTrigger()) return; TreePath path = tree.pathAt(e.getY()); if (path == null || path.equals(tree.getSelectionPath())) return; Rectangle bounds = tree.getPathBounds(path); // Left of the label is the expand handle, which Swing works the row without selecting it. if (bounds != null && e.getX() < bounds.x) return; tree.setSelectionPath(path); } public void setSelfClientId(int id) { this.selfClientId = id; } /** Selects and scrolls to the row showing {@code client}, if it is currently visible. */ public void selectClient(int clientId) { ClientEntry client = model.getClient(clientId); if (client == null) return; TreePath path = pathOf(client); if (path == null) return; tree.setSelectionPath(path); tree.scrollPathToVisible(path); } private Object nodeAt(MouseEvent e) { TreePath path = tree.pathAt(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.pathAt(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); } else if (path.getPathCount() == 1 && actions.isConnected()) { // The root row is the server itself, whose user object is just its name. ServerMenu.build(actions).show(tree, e.getX(), e.getY()); } } private void showClientMenu(ClientEntry client, MouseEvent e) { ClientMenu.build(client, client.id == selfClientId, true, model, groupIcons, 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()); } /** 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; } /** * Rebuilds the tree from the model, preserving full expansion. Rebuilding * replaces every tree node, which would otherwise drop the current * selection on every update (a client talking, a group change, …); the * previously selected channel/client is restored by identity once the new * nodes are in place, so a selection sticks until the user changes it. */ public void rebuild() { Object selected = selectedUserObject(); rebuilding = true; try { 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); } if (selected != null) { TreePath path = pathOf(selected); if (path != null) tree.setSelectionPath(path); } } finally { rebuilding = false; } // The selection listener was swallowed above; tell the caller only if it actually changed // (e.g. the previously selected channel/client is gone), since it already knows the rest. Object nowSelected = selectedUserObject(); if (nowSelected != selected) actions.onSelectionChanged(nowSelected); } private Object selectedUserObject() { TreePath path = tree.getSelectionPath(); return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); } 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()); } } } }