Find clients and channels in the server tree

Ctrl+F in the tree opens a find bar below it that washes matching rows,
steps the selection through them and can prune the tree down to the
matches and the channels leading to them. ServerTreePanel becomes a
panel holding the scroll pane and the bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 15:09:45 +00:00
parent d05c04afb4
commit b01ed43d60
3 changed files with 315 additions and 17 deletions

View File

@@ -10,15 +10,20 @@ 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.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.List;
@@ -27,7 +32,7 @@ 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 {
public final class ServerTreePanel extends JPanel {
/** Actions the tree can request of the controller. */
public interface Actions {
@@ -127,6 +132,16 @@ public final class ServerTreePanel extends JScrollPane {
}
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
@@ -154,6 +169,7 @@ public final class ServerTreePanel extends JScrollPane {
public ServerTreePanel(ServerModel model, GroupIcons groupIcons, ContactStore contacts, Settings settings,
Actions actions) {
super(new BorderLayout());
this.model = model;
this.groupIcons = groupIcons;
this.actions = actions;
@@ -182,13 +198,26 @@ public final class ServerTreePanel extends JScrollPane {
tree.setEditable(true);
tree.setPathEditable(nicknameEditor::editsPath);
tree.setInvokesStopCellEditing(true);
setViewportView(tree);
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.
getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
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.
getViewport().addChangeListener(e -> tree.refreshHoverRow());
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.
@@ -253,6 +282,11 @@ public final class ServerTreePanel extends JScrollPane {
tree.setSelectionPath(path);
}
/** Opens the find bar above the tree. */
public void openFind() {
search.open();
}
public void setSelfClientId(int id) {
this.selfClientId = id;
}
@@ -300,14 +334,6 @@ public final class ServerTreePanel extends JScrollPane {
ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY());
}
@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());
}
/** The path of the tree node showing {@code target}, or {@code null}. */
private TreePath pathOf(Object target) {
java.util.Enumeration<?> nodes = root.breadthFirstEnumeration();
@@ -332,8 +358,10 @@ public final class ServerTreePanel extends JScrollPane {
root.setUserObject(model.getServerName());
root.removeAllChildren();
List<ChannelNode> roots = model.buildTree();
boolean filtering = search.isFiltering();
for (ChannelNode c : roots) {
root.add(buildChannel(c));
DefaultMutableTreeNode node = buildChannel(c, filtering);
if (node != null) root.add(node);
}
treeModel.reload();
for (int i = 0; i < tree.getRowCount(); i++) {
@@ -350,6 +378,7 @@ public final class ServerTreePanel extends JScrollPane {
// (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();
}
private Object selectedUserObject() {
@@ -357,15 +386,22 @@ public final class ServerTreePanel extends JScrollPane {
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
}
private DefaultMutableTreeNode buildChannel(ChannelNode c) {
/**
* @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) {
node.add(new DefaultMutableTreeNode(client));
if (!filtering || channelMatches || search.matches(client)) node.add(new DefaultMutableTreeNode(client));
}
for (ChannelNode child : c.children) {
node.add(buildChannel(child));
DefaultMutableTreeNode childNode = buildChannel(child, filtering);
if (childNode != null) node.add(childNode);
}
return node;
return !filtering || channelMatches || node.getChildCount() > 0 ? node : null;
}
/** Clears the tree back to the disconnected placeholder state. */
@@ -373,6 +409,7 @@ public final class ServerTreePanel extends JScrollPane {
root.setUserObject("Not connected");
root.removeAllChildren();
treeModel.reload();
search.treeRebuilt();
}
/** Repaint only (e.g. talk-state changes) without rebuilding structure. */