Files
ts3j/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java
ericek111 acd438dd14 Don't send a rename that changes nothing
Closing the inline editor without touching the name asked the server to
rename us to the name we already hold, which it refuses with "nickname
is already in use".

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

362 lines
14 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.Rectangle;
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);
/** Edits a client's description — our own included. */
void changeClientDescription(ClientEntry client);
/** A new nickname for ourselves, typed into the tree row. */
void renameSelf(String nickname);
}
private final DropIndicatorTree tree;
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
/**
* The nodes only mirror the connection's model, so an edited row is not written
* back into the tree: the new nickname goes to the server, and the row follows
* once it reports the rename.
*/
private final DefaultTreeModel treeModel = new DefaultTreeModel(root) {
@Override
public void valueForPathChanged(TreePath path, Object newValue) {
String nickname = String.valueOf(newValue).trim();
Object edited = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
// The server answers a rename to the name already held with "nickname is
// already in use", so a field closed unchanged is simply left alone.
if (edited instanceof ClientEntry && nickname.equals(((ClientEntry) edited).nickname)) return;
if (!nickname.isEmpty()) actions.renameSelf(nickname);
}
};
/** 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 final NicknameCellEditor nicknameEditor;
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);
ServerTreeCellRenderer renderer = new ServerTreeCellRenderer();
tree.setCellRenderer(renderer);
nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId);
tree.setCellEditor(nicknameEditor);
tree.setEditable(true);
tree.setPathEditable(nicknameEditor::editsPath);
tree.setInvokesStopCellEditing(true);
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) {
selectRowUnder(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 && ((ClientEntry) obj).id != selfClientId) {
actions.openPrivateChat((ClientEntry) obj);
}
} else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) {
actions.showConnectionInfo((ClientEntry) obj);
}
}
});
}
/**
* Selects the row a press landed on. A row acts on its whole line, but Swing's own
* hit testing stops at the end of the label, so a press further right would leave
* the selection where it was.
*/
private void selectRowUnder(MouseEvent e) {
if (!SwingUtilities.isLeftMouseButton(e) && !e.isPopupTrigger()) return;
TreePath path = tree.pathAt(e.getY());
if (path == null || path.equals(tree.getSelectionPath())) return;
Rectangle bounds = tree.getPathBounds(path);
// Left of the label is the expand handle, which Swing works the row without selecting it.
if (bounds != null && e.getX() < bounds.x) return;
tree.setSelectionPath(path);
}
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.pathAt(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.pathAt(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());
}
}
}
}