diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 3a14fe2..db1d4ab 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -82,7 +82,32 @@ public final class ServerModel { } public synchronized void removeChannel(int id) { - channels.remove(id); + ChannelNode c = channels.remove(id); + if (c == null) return; + // Whatever followed it now follows its predecessor. + for (ChannelNode o : channels.values()) { + if (o.parentId == c.parentId && o.order == id) o.order = c.order; + } + } + + /** + * Places a channel after {@code newOrder} (0 = first) under {@code newParentId}, + * repairing the sibling links the server does not re-announce: the channel that + * used to follow it, and the one that follows it now. + */ + public synchronized void relinkChannel(int cid, int newParentId, int newOrder) { + ChannelNode c = channels.get(cid); + if (c == null) return; + for (ChannelNode o : channels.values()) { + if (o.id == cid) continue; + if (o.parentId == c.parentId && o.order == cid) o.order = c.order; + } + for (ChannelNode o : channels.values()) { + if (o.id == cid) continue; + if (o.parentId == newParentId && o.order == newOrder) o.order = cid; + } + c.parentId = newParentId; + c.order = newOrder; } /** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */ @@ -185,20 +210,45 @@ public final class ServerModel { if (ch != null) ch.clients.add(cl); } - Comparator byOrder = Comparator.comparingInt((ChannelNode c) -> c.order) - .thenComparing(c -> c.name == null ? "" : c.name.toLowerCase()); Comparator byClient = Comparator .comparingInt((ClientEntry c) -> -c.talkPower) .thenComparing(c -> c.nickname == null ? "" : c.nickname.toLowerCase()); - roots.sort(byOrder); + sortSiblings(roots); for (ChannelNode c : channels.values()) { - c.children.sort(byOrder); + sortSiblings(c.children); c.clients.sort(byClient); } return roots; } + /** + * Orders one set of sibling channels in place. TS3 stores the position as + * {@code channel_order} = the id of the channel above it (0 = topmost), so the + * siblings form a linked list rather than a sortable key. + */ + private static void sortSiblings(List siblings) { + if (siblings.size() < 2) return; + Map byPredecessor = new LinkedHashMap<>(); + for (ChannelNode c : siblings) byPredecessor.putIfAbsent(c.order, c); + + List ordered = new ArrayList<>(siblings.size()); + java.util.Set placed = new java.util.HashSet<>(); + for (int key = 0; ; ) { + ChannelNode next = byPredecessor.get(key); + if (next == null || !placed.add(next.id)) break; + ordered.add(next); + key = next.id; + } + // A broken chain (duplicate or dangling links) leaves stragglers; keep them + // rather than dropping channels from the tree. + for (ChannelNode c : siblings) { + if (!placed.contains(c.id)) ordered.add(c); + } + siblings.clear(); + siblings.addAll(ordered); + } + public synchronized int clientCount() { int n = 0; for (ClientEntry c : clients.values()) if (!c.isQuery()) n++; diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index af28e97..d0a2b15 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -428,6 +428,38 @@ public final class TeamspeakConnection implements TS3Listener { }, "ts3j-join").start(); } + /** Moves another client (or ourselves) into a channel. */ + public void moveClient(int clientId, int channelId, String password) { + new Thread(() -> { + try { + client.clientMove(clientId, channelId, + (password == null || password.isEmpty()) ? null : password); + } catch (Exception e) { + ui.onError("Could not move client: " + rootMessage(e)); + } + }, "ts3j-move-client").start(); + } + + /** + * Re-parents and repositions a channel. + * + * @param orderPredecessorId the channel this one should sit below among its new + * siblings, or 0 to place it first + */ + public void moveChannel(int channelId, int newParentId, int orderPredecessorId) { + new Thread(() -> { + try { + SingleCommand cmd = new SingleCommand("channelmove", ProtocolRole.CLIENT); + cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId))); + cmd.add(new CommandSingleParameter("cpid", Integer.toString(newParentId))); + cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId))); + client.executeCommand(cmd).complete(); + } catch (Exception e) { + ui.onError("Could not move channel: " + rootMessage(e)); + } + }, "ts3j-move-channel").start(); + } + public void sendChannelMessage(String text) { new Thread(() -> { try { @@ -570,6 +602,7 @@ public final class TeamspeakConnection implements TS3Listener { if (pid == 0) pid = safeInt(e, "pid"); int order = safeInt(e, "channel_order"); model.putChannel(cid, name, pid, order); + model.relinkChannel(cid, pid, order); ui.onModelChanged(); } @@ -594,8 +627,9 @@ public final class TeamspeakConnection implements TS3Listener { public void onChannelMoved(ChannelMovedEvent e) { ChannelNode ch = model.getChannel(safeInt(e, "cid")); if (ch != null) { - if (e.get("cpid") != null) ch.parentId = e.getInt("cpid"); - if (e.get("order") != null) ch.order = e.getInt("order"); + int parent = e.get("cpid") != null ? e.getInt("cpid") : ch.parentId; + int order = e.get("order") != null ? e.getInt("order") : ch.order; + model.relinkChannel(ch.id, parent, order); ui.onModelChanged(); } } 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 0001f0d..a46ecaa 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 @@ -292,6 +292,21 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { if (conn.isConnected()) conn.joinChannel(channelId, null); } + @Override + public void moveClientToChannel(ClientEntry client, ChannelNode target) { + if (!conn.isConnected()) return; + if (client.id == conn.getSelfClientId()) { + conn.joinChannel(target.id, null); + } else { + conn.moveClient(client.id, target.id, null); + } + } + + @Override + public void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId) { + if (conn.isConnected()) conn.moveChannel(channel.id, newParentId, orderPredecessorId); + } + @Override public void openPrivateChat(ClientEntry client) { chatPanel.openPrivateChat(client.id, client.nickname); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index 4a20e29..d1942f7 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -5,6 +5,7 @@ import com.ts3client.net.ClientEntry; import com.ts3client.net.ServerModel; import com.ts3client.text.TsLink; +import javax.swing.DropMode; import javax.swing.ImageIcon; import javax.swing.JComponent; import javax.swing.JScrollPane; @@ -14,10 +15,17 @@ 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.datatransfer.StringSelection; +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; @@ -47,9 +55,25 @@ public final class ServerTreePanel extends JScrollPane { /** 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); } - private final JTree tree; + /** 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; @@ -60,7 +84,7 @@ public final class ServerTreePanel extends JScrollPane { this.model = model; this.actions = actions; root.setUserObject("Not connected"); - this.tree = new JTree(treeModel); + this.tree = new DropIndicatorTree(treeModel); tree.setRootVisible(true); tree.setShowsRootHandles(true); tree.setRowHeight(20); @@ -70,31 +94,11 @@ public final class ServerTreePanel extends JScrollPane { setViewportView(tree); getViewport().setBackground(Theme.TREE_BG); - // Dragging a client or channel out of the tree yields the TS3 link BBCode, - // which the chat input accepts as plain text. + // 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.setTransferHandler(new TransferHandler() { - @Override - public int getSourceActions(JComponent c) { - return COPY; - } - - @Override - protected Transferable createTransferable(JComponent c) { - TreePath path = tree.getSelectionPath(); - if (path == null) return null; - Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); - if (obj instanceof ClientEntry) { - ClientEntry cl = (ClientEntry) obj; - return new StringSelection(TsLink.clientBBCode(cl.id, cl.uniqueId, cl.nickname)); - } - if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) { - ChannelNode ch = (ChannelNode) obj; - return new StringSelection(TsLink.channelBBCode(ch.id, ch.name)); - } - return null; - } - }); + tree.setDropMode(DropMode.ON_OR_INSERT); + tree.setTransferHandler(new TreeTransferHandler()); tree.addTreeSelectionListener(e -> { TreePath path = tree.getSelectionPath(); @@ -161,6 +165,281 @@ public final class ServerTreePanel extends JScrollPane { 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); + } + } + + /** + * Draws where the drop will land: an insertion line between rows, or an outline + * around the row that will receive the dragged node. + */ + private static 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); + } + + 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); + 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());