Files
ts3java/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeDragAndDrop.java
ericek111 e5fb87a796 Split ServerTreePanel into focused collaborator classes
Extract cell rendering, right-aligned group-icon-strip painting, the
drop-indicator JTree subclass, and drag-and-drop handling out of
ServerTreePanel into ServerTreeCellRenderer, DropIndicatorTree, and
ServerTreeDragAndDrop, leaving ServerTreePanel as the coordinator
(706 -> 284 lines). Pure refactor, no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:35:42 +00:00

236 lines
9.6 KiB
Java

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.JComponent;
import javax.swing.JTree;
import javax.swing.TransferHandler;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.TreePath;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.util.function.Function;
/**
* Drag-and-drop for the {@link ServerTreePanel} tree: dragging a client onto a
* channel moves it there, dragging a channel reorders/reparents it, and dropping
* either outside the tree yields its TS3 link BBCode, which the chat input accepts
* as plain text.
*/
final class ServerTreeDragAndDrop {
/** 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 ServerModel model;
private final ServerTreePanel.Actions actions;
private final DropIndicatorTree tree;
/** Looks up the tree path currently showing a given channel/client, or {@code null}. */
private final Function<Object, TreePath> pathOf;
ServerTreeDragAndDrop(ServerModel model, ServerTreePanel.Actions actions, DropIndicatorTree tree,
Function<Object, TreePath> pathOf) {
this.model = model;
this.actions = actions;
this.tree = tree;
this.pathOf = pathOf;
}
TransferHandler transferHandler() {
return new TreeTransferHandler();
}
private static DefaultMutableTreeNode nodeOf(TreePath path) {
return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent();
}
/**
* 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.getParent() != null) {
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.apply(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);
}
}
}