diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java new file mode 100644 index 0000000..797bf47 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java @@ -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 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 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(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java new file mode 100644 index 0000000..c89b0a6 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java @@ -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(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeDragAndDrop.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeDragAndDrop.java new file mode 100644 index 0000000..10e7f1f --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeDragAndDrop.java @@ -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 pathOf; + + ServerTreeDragAndDrop(ServerModel model, ServerTreePanel.Actions actions, DropIndicatorTree tree, + Function 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); + } + } +} 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 7094b58..33968af 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 @@ -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 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 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(); - } - } }