Drag and drop clients and channels in the server tree

Clients can be dragged into a channel and channels re-parented or
reordered, with an insertion line (or a row outline, for "into this
channel") showing where the drop lands.

Channel positions come from channel_order, which is the id of the
channel above rather than an index, so siblings are now ordered by
walking that chain and the neighbour links are repaired locally on
create, move and delete -- the server only announces the channel
that changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 17:42:49 +00:00
parent 1249242c89
commit 022aef633b
4 changed files with 412 additions and 34 deletions

View File

@@ -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);

View File

@@ -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());