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>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.ServerModel;
|
||||
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.TreeModel;
|
||||
import javax.swing.tree.TreePath;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The {@link ServerTreePanel} tree, extended to draw where a drag-and-drop would
|
||||
* land: an insertion line between rows, or an outline around the row that will
|
||||
* receive the dragged node. Also keeps the server row permanently expanded and
|
||||
* paints the right-aligned group icon strip, both of which need to hook into
|
||||
* this component's own paint cycle.
|
||||
*/
|
||||
final class DropIndicatorTree extends JTree {
|
||||
|
||||
/** 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;
|
||||
|
||||
private final ServerModel model;
|
||||
private final GroupIcons groupIcons;
|
||||
/** Set while a drop would move a client into this channel row. */
|
||||
private TreePath highlight;
|
||||
|
||||
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
|
||||
super(treeModel);
|
||||
this.model = model;
|
||||
this.groupIcons = groupIcons;
|
||||
}
|
||||
|
||||
/** Keeps the server row permanently open; collapsing it would hide everything. */
|
||||
@Override
|
||||
public void setExpandedState(TreePath path, boolean state) {
|
||||
if (!state && path.getPathCount() == 1) return;
|
||||
super.setExpandedState(path, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 separately from the cell renderer keeps the rows'
|
||||
* measured widths (and thus the selection highlight) tied to the label alone.
|
||||
*/
|
||||
private void paintBadges(Graphics g) {
|
||||
Rectangle visible = getVisibleRect();
|
||||
int right = visible.x + visible.width - BADGE_MARGIN;
|
||||
for (int row = 0; row < getRowCount(); row++) {
|
||||
Rectangle bounds = getRowBounds(row);
|
||||
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
|
||||
if (bounds.y > visible.y + visible.height) break;
|
||||
|
||||
TreePath path = getPathForRow(row);
|
||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
List<Icon> 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(this, 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<Icon> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||
import java.awt.Component;
|
||||
|
||||
/**
|
||||
* Draws a {@link ServerTreePanel} row: the status icon and the label. The group
|
||||
* icon strip is painted separately, right-aligned, by {@link DropIndicatorTree}.
|
||||
*/
|
||||
final class ServerTreeCellRenderer 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(c.subscribed);
|
||||
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);
|
||||
return Icons.channel(c.subscribed);
|
||||
}
|
||||
|
||||
/** 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.outputHardware) return Icons.speakerDisabled();
|
||||
if (cl.outputMuted) return Icons.speakerMuted();
|
||||
if (!cl.inputHardware) return Icons.micDisabled();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,32 +3,15 @@ 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.plaf.basic.BasicTreeUI;
|
||||
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;
|
||||
@@ -90,11 +73,6 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
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);
|
||||
@@ -110,7 +88,7 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
this.groupIcons = groupIcons;
|
||||
this.actions = actions;
|
||||
root.setUserObject("Not connected");
|
||||
this.tree = new DropIndicatorTree(treeModel);
|
||||
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
|
||||
@@ -124,7 +102,7 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
tree.setRowHeight(20);
|
||||
tree.setBackground(Theme.TREE_BG);
|
||||
tree.setFont(Theme.UI_FONT);
|
||||
tree.setCellRenderer(new Renderer());
|
||||
tree.setCellRenderer(new ServerTreeCellRenderer());
|
||||
setViewportView(tree);
|
||||
getViewport().setBackground(Theme.TREE_BG);
|
||||
// The icon strip is drawn against the viewport's right edge, so the blitted
|
||||
@@ -135,7 +113,7 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
// 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.setTransferHandler(new ServerTreeDragAndDrop(model, actions, tree, this::pathOf).transferHandler());
|
||||
|
||||
tree.addTreeSelectionListener(e -> {
|
||||
if (rebuilding) return;
|
||||
@@ -217,12 +195,6 @@ 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();
|
||||
@@ -233,331 +205,6 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
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<Icon> 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<Icon> 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);
|
||||
}
|
||||
|
||||
/** Keeps the server row permanently open; collapsing it would hide everything. */
|
||||
@Override
|
||||
public void setExpandedState(TreePath path, boolean state) {
|
||||
if (!state && path.getPathCount() == 1) return;
|
||||
super.setExpandedState(path, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Rebuilding
|
||||
* replaces every tree node, which would otherwise drop the current
|
||||
@@ -634,73 +281,4 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(c.subscribed);
|
||||
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);
|
||||
return Icons.channel(c.subscribed);
|
||||
}
|
||||
|
||||
/** 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.outputHardware) return Icons.speakerDisabled();
|
||||
if (cl.outputMuted) return Icons.speakerMuted();
|
||||
if (!cl.inputHardware) return Icons.micDisabled();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user