Files
ts3j/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java
ericek111 f2885d33ad Add the Create Channel dialog
The official client creates and edits channels with one dialog, so
ChannelEditDialog becomes ChannelDialog with two entry points. Creating
starts from caller-chosen defaults instead of a channelinfo and sends a
channelcreate carrying only what deviates from a plain new channel —
TeamSpeak checks a create permission per property that is present.

The server row now has a context menu with "Create Channel" and "Create
Spacer" (pre-filled with the lowest free [spacerN], since channel names
must be unique), and a channel's menu offers "Create Channel" and
"Create Sub-Channel", both starting out temporary.

Verified against the test server: a temporary channel is created, and a
permanent one a guest may not create reports the server's refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:31:07 +00:00

317 lines
12 KiB
Java

package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.ServerModel;
import javax.swing.DropMode;
import javax.swing.JScrollPane;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import javax.swing.plaf.basic.BasicTreeUI;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreePath;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
/**
* The server view: a tree of channels each containing its clients, styled to
* resemble the TeamSpeak 3 client. Talk state colours clients green live.
*/
public final class ServerTreePanel extends JScrollPane {
/** Actions the tree can request of the controller. */
public interface Actions {
void joinChannel(int channelId);
void openPrivateChat(ClientEntry client);
void pokeClient(ClientEntry client);
void kickClientFromChannel(ClientEntry client);
void kickClientFromServer(ClientEntry client);
void banClient(ClientEntry client);
void toggleClientMute(ClientEntry client);
void showConnectionInfo(ClientEntry client);
/** Moves a client into the channel we are currently in. */
void moveClientToOwnChannel(ClientEntry client);
/** Opens the channel editor for a channel. */
void editChannel(ChannelNode channel);
/**
* Opens the channel creator.
*
* @param parent the channel the new one goes below, or {@code null} for a top-level one
* @param type the channel type to start with
*/
void createChannel(ChannelNode parent, ChannelSettings.Type type);
/** Opens the channel creator pre-filled as a spacer, which is always top-level. */
void createSpacer();
/** Whether the server is there to act on at all. */
boolean isConnected();
/** Open the file repository browser for a channel. */
void browseFiles(ChannelNode channel);
/**
* Changes a channel's subscription.
*
* @param family whether the channels below it are included
*/
void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed);
boolean isClientLocallyMuted(int clientId);
/** Selects and scrolls to a client in the tree, e.g. from a chat/log link. */
void findClientInTree(ClientEntry client);
/** 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);
/** Assigns or removes a server group for a client. */
void setClientServerGroup(ClientEntry client, Group group, boolean assign);
/** Assigns a channel group for a client in its current channel. */
void setClientChannelGroup(ClientEntry client, Group group);
/** Opens the full server-groups list dialog for a client. */
void showServerGroupsDialog(ClientEntry client);
}
private final DropIndicatorTree tree;
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
/** True while {@link #rebuild()} clears and restores the selection, to swallow the transient null in between. */
private boolean rebuilding;
private final ServerModel model;
private final GroupIcons groupIcons;
private final Actions actions;
private int selfClientId = -1;
public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) {
this.model = model;
this.groupIcons = groupIcons;
this.actions = actions;
root.setUserObject("Not connected");
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
// is scarce and channel names are long.
tree.setShowsRootHandles(false);
if (tree.getUI() instanceof BasicTreeUI) {
BasicTreeUI ui = (BasicTreeUI) tree.getUI();
ui.setLeftChildIndent(4);
ui.setRightChildIndent(10);
}
tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG);
tree.setFont(Theme.UI_FONT);
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
// pixels a scroll would reuse are stale; repaint the whole viewport instead.
getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
// 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.setDropMode(DropMode.ON_OR_INSERT);
tree.setTransferHandler(new ServerTreeDragAndDrop(model, actions, tree, this::pathOf).transferHandler());
tree.addTreeSelectionListener(e -> {
if (rebuilding) return;
TreePath path = tree.getSelectionPath();
Object obj = null;
if (path != null) {
obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
}
actions.onSelectionChanged(obj);
});
tree.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
maybePopup(e);
}
@Override
public void mouseReleased(MouseEvent e) {
maybePopup(e);
}
@Override
public void mouseClicked(MouseEvent e) {
Object obj = nodeAt(e);
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
actions.joinChannel(((ChannelNode) obj).id);
} else if (obj instanceof ClientEntry) {
actions.openPrivateChat((ClientEntry) obj);
}
} else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) {
actions.showConnectionInfo((ClientEntry) obj);
}
}
});
}
public void setSelfClientId(int id) {
this.selfClientId = id;
}
/** Selects and scrolls to the row showing {@code client}, if it is currently visible. */
public void selectClient(int clientId) {
ClientEntry client = model.getClient(clientId);
if (client == null) return;
TreePath path = pathOf(client);
if (path == null) return;
tree.setSelectionPath(path);
tree.scrollPathToVisible(path);
}
private Object nodeAt(MouseEvent e) {
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if (path == null) return null;
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
return n.getUserObject();
}
private void maybePopup(MouseEvent e) {
if (!e.isPopupTrigger()) return;
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if (path == null) return;
tree.setSelectionPath(path);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
if (obj instanceof ClientEntry) {
showClientMenu((ClientEntry) obj, e);
} else if (obj instanceof ChannelNode) {
showChannelMenu((ChannelNode) obj, e);
} else if (path.getPathCount() == 1 && actions.isConnected()) {
// The root row is the server itself, whose user object is just its name.
ServerMenu.build(actions).show(tree, e.getX(), e.getY());
}
}
private void showClientMenu(ClientEntry client, MouseEvent e) {
ClientMenu.build(client, client.id == selfClientId, true, model, groupIcons, actions)
.show(tree, e.getX(), e.getY());
}
private void showChannelMenu(ChannelNode channel, MouseEvent e) {
if (Spacers.isSpacer(channel.name)) return; // spacers aren't interactive
ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY());
}
/** 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;
}
/**
* Rebuilds the tree from the model, preserving full expansion. Rebuilding
* replaces every tree node, which would otherwise drop the current
* selection on every update (a client talking, a group change, …); the
* previously selected channel/client is restored by identity once the new
* nodes are in place, so a selection sticks until the user changes it.
*/
public void rebuild() {
Object selected = selectedUserObject();
rebuilding = true;
try {
root.setUserObject(model.getServerName());
root.removeAllChildren();
List<ChannelNode> roots = model.buildTree();
for (ChannelNode c : roots) {
root.add(buildChannel(c));
}
treeModel.reload();
for (int i = 0; i < tree.getRowCount(); i++) {
tree.expandRow(i);
}
if (selected != null) {
TreePath path = pathOf(selected);
if (path != null) tree.setSelectionPath(path);
}
} finally {
rebuilding = false;
}
// The selection listener was swallowed above; tell the caller only if it actually changed
// (e.g. the previously selected channel/client is gone), since it already knows the rest.
Object nowSelected = selectedUserObject();
if (nowSelected != selected) actions.onSelectionChanged(nowSelected);
}
private Object selectedUserObject() {
TreePath path = tree.getSelectionPath();
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
}
private DefaultMutableTreeNode buildChannel(ChannelNode c) {
DefaultMutableTreeNode node = new DefaultMutableTreeNode(c);
for (ClientEntry client : c.clients) {
node.add(new DefaultMutableTreeNode(client));
}
for (ChannelNode child : c.children) {
node.add(buildChannel(child));
}
return node;
}
/** Clears the tree back to the disconnected placeholder state. */
public void showDisconnected() {
root.setUserObject("Not connected");
root.removeAllChildren();
treeModel.reload();
}
/** Repaint only (e.g. talk-state changes) without rebuilding structure. */
public void refreshVisual() {
tree.repaint();
}
/**
* Re-measures every visible row, for changes that alter a row's width (a group
* icon that finished downloading). A plain repaint would keep the cached widths
* and clip the new icons.
*/
public void refreshRowSizes() {
for (int i = tree.getRowCount() - 1; i >= 0; i--) {
TreePath path = tree.getPathForRow(i);
if (path != null) {
treeModel.nodeChanged((DefaultMutableTreeNode) path.getLastPathComponent());
}
}
}
}