Every tree rebuild, which any join, leave or talk-power change causes, expanded every row again, so a collapsed channel reopened within seconds. The tree now remembers which channels were collapsed and opens everything else; reopening one restores what is below it. While the find bar filters, everything opens so every match shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
482 lines
20 KiB
Java
482 lines
20 KiB
Java
package com.ts3client.ui;
|
|
|
|
import com.formdev.flatlaf.util.UIScale;
|
|
import com.ts3client.config.Settings;
|
|
import com.ts3client.contacts.ContactStore;
|
|
import com.ts3client.contacts.ContactType;
|
|
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.AbstractAction;
|
|
import javax.swing.DropMode;
|
|
import javax.swing.JPanel;
|
|
import javax.swing.JScrollPane;
|
|
import javax.swing.JViewport;
|
|
import javax.swing.KeyStroke;
|
|
import javax.swing.SwingUtilities;
|
|
import javax.swing.plaf.basic.BasicTreeUI;
|
|
import javax.swing.event.TreeExpansionEvent;
|
|
import javax.swing.event.TreeExpansionListener;
|
|
import javax.swing.tree.DefaultMutableTreeNode;
|
|
import javax.swing.tree.DefaultTreeModel;
|
|
import javax.swing.tree.TreePath;
|
|
import java.awt.BorderLayout;
|
|
import java.awt.Rectangle;
|
|
import java.awt.event.ActionEvent;
|
|
import java.awt.event.MouseAdapter;
|
|
import java.awt.event.MouseEvent;
|
|
import java.util.HashSet;
|
|
import java.util.List;
|
|
import java.util.Set;
|
|
|
|
/**
|
|
* 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 JPanel {
|
|
|
|
/** 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);
|
|
|
|
/** Files a client in the contact list as a friend, blocked or neutral. */
|
|
void addContact(ClientEntry client, ContactType type);
|
|
|
|
/** Opens the contact list at this client's entry. */
|
|
void editContact(ClientEntry client);
|
|
|
|
void removeContact(ClientEntry client);
|
|
|
|
/** The type a client is filed under, or {@code null} for a client that is no contact. */
|
|
ContactType contactTypeOf(ClientEntry client);
|
|
|
|
/** Opens the per-client volume dialog. */
|
|
void changeClientVolume(ClientEntry client);
|
|
}
|
|
|
|
private final DropIndicatorTree tree;
|
|
private final JScrollPane scroll = new JScrollPane() {
|
|
@Override
|
|
public void updateUI() {
|
|
super.updateUI();
|
|
// The rows stop at the last channel, so the space below them belongs to the tree
|
|
// rather than to the window behind it.
|
|
if (getViewport() != null) getViewport().setBackground(Theme.treeBg());
|
|
}
|
|
};
|
|
private final ServerTreeSearch search;
|
|
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 the tree is changed programmatically: a {@link #rebuild()} clearing and restoring
|
|
* the selection, or channels being reopened. The listeners ignore what happens meanwhile.
|
|
*/
|
|
private boolean rebuilding;
|
|
/** Channels the user collapsed; every other channel is shown open. */
|
|
private final Set<Integer> collapsedChannels = new HashSet<>();
|
|
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, ContactStore contacts, Settings settings,
|
|
Actions actions) {
|
|
super(new BorderLayout());
|
|
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(UIScale.scale(20));
|
|
// Lays out only the rows on screen. The default cache measures every row as it is
|
|
// expanded, which freezes the window for seconds on servers with hundreds of channels.
|
|
tree.setLargeModel(true);
|
|
tree.setBackground(Theme.treeBg());
|
|
tree.setFont(Theme.uiFont());
|
|
ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId, contacts, settings);
|
|
tree.setCellRenderer(renderer);
|
|
nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId);
|
|
tree.setCellEditor(nicknameEditor);
|
|
tree.setEditable(true);
|
|
tree.setPathEditable(nicknameEditor::editsPath);
|
|
tree.setInvokesStopCellEditing(true);
|
|
scroll.setViewportView(tree);
|
|
// 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.
|
|
scroll.getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
|
|
// Scrolling slides the rows past a pointer that has not moved, so the outlined
|
|
// row has to be worked out again.
|
|
scroll.getViewport().addChangeListener(e -> tree.refreshHoverRow());
|
|
scroll.getViewport().setBackground(Theme.treeBg());
|
|
add(scroll, BorderLayout.CENTER);
|
|
|
|
search = new ServerTreeSearch(tree, root, contacts, this::rebuild);
|
|
add(search.bar(), BorderLayout.SOUTH);
|
|
String find = "ts3-tree-find";
|
|
getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke("control F"), find);
|
|
getActionMap().put(find, new AbstractAction() {
|
|
@Override
|
|
public void actionPerformed(ActionEvent e) {
|
|
openFind();
|
|
}
|
|
});
|
|
|
|
// 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.addTreeExpansionListener(new TreeExpansionListener() {
|
|
@Override
|
|
public void treeCollapsed(TreeExpansionEvent e) {
|
|
if (rebuilding) return;
|
|
Object obj = ((DefaultMutableTreeNode) e.getPath().getLastPathComponent()).getUserObject();
|
|
if (obj instanceof ChannelNode) collapsedChannels.add(((ChannelNode) obj).id);
|
|
}
|
|
|
|
@Override
|
|
public void treeExpanded(TreeExpansionEvent e) {
|
|
if (rebuilding) return;
|
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) e.getPath().getLastPathComponent();
|
|
if (!(node.getUserObject() instanceof ChannelNode)) return;
|
|
collapsedChannels.remove(((ChannelNode) node.getUserObject()).id);
|
|
// A rebuild while it was shut left everything below it shut too.
|
|
rebuilding = true;
|
|
try {
|
|
expandOpenChannels(node, e.getPath());
|
|
} finally {
|
|
rebuilding = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
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) {
|
|
if (((ClientEntry) obj).id == selfClientId) {
|
|
tree.startEditingAtPath(tree.pathAt(e.getY()));
|
|
} else {
|
|
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);
|
|
}
|
|
|
|
/** Opens the find bar above the tree. */
|
|
public void openFind() {
|
|
search.open();
|
|
}
|
|
|
|
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();
|
|
boolean filtering = search.isFiltering();
|
|
for (ChannelNode c : roots) {
|
|
DefaultMutableTreeNode node = buildChannel(c, filtering);
|
|
if (node != null) root.add(node);
|
|
}
|
|
treeModel.reload();
|
|
expandOpenChannels(root, new TreePath(root));
|
|
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);
|
|
search.treeRebuilt();
|
|
}
|
|
|
|
/**
|
|
* Opens {@code node} and every channel below it, except the ones the user collapsed and
|
|
* whatever is below those. While the find bar filters, everything opens, so every match shows.
|
|
*/
|
|
private void expandOpenChannels(DefaultMutableTreeNode node, TreePath path) {
|
|
tree.expandPath(path);
|
|
boolean filtering = search.isFiltering();
|
|
for (int i = 0; i < node.getChildCount(); i++) {
|
|
DefaultMutableTreeNode child = (DefaultMutableTreeNode) node.getChildAt(i);
|
|
if (child.isLeaf() || !(child.getUserObject() instanceof ChannelNode)) continue;
|
|
if (!filtering && collapsedChannels.contains(((ChannelNode) child.getUserObject()).id)) continue;
|
|
expandOpenChannels(child, path.pathByAddingChild(child));
|
|
}
|
|
}
|
|
|
|
private Object selectedUserObject() {
|
|
TreePath path = tree.getSelectionPath();
|
|
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
|
}
|
|
|
|
/**
|
|
* @param filtering keep only what the find bar matched: a matching channel with its
|
|
* clients, a matching client, and the channels leading to either
|
|
* @return the channel's node, or {@code null} when filtering left nothing of it
|
|
*/
|
|
private DefaultMutableTreeNode buildChannel(ChannelNode c, boolean filtering) {
|
|
DefaultMutableTreeNode node = new DefaultMutableTreeNode(c);
|
|
boolean channelMatches = filtering && search.matches(c);
|
|
for (ClientEntry client : c.clients) {
|
|
if (!filtering || channelMatches || search.matches(client)) node.add(new DefaultMutableTreeNode(client));
|
|
}
|
|
for (ChannelNode child : c.children) {
|
|
DefaultMutableTreeNode childNode = buildChannel(child, filtering);
|
|
if (childNode != null) node.add(childNode);
|
|
}
|
|
return !filtering || channelMatches || node.getChildCount() > 0 ? node : null;
|
|
}
|
|
|
|
/** Clears the tree back to the disconnected placeholder state. */
|
|
public void showDisconnected() {
|
|
root.setUserObject("Not connected");
|
|
root.removeAllChildren();
|
|
collapsedChannels.clear();
|
|
treeModel.reload();
|
|
search.treeRebuilt();
|
|
}
|
|
|
|
/** 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());
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|