From b01ed43d60ee66e6e962b6d0ad7ea6b16f230e09 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 24 Sep 2026 15:09:45 +0000 Subject: [PATCH] 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 --- .../com/ts3client/ui/DropIndicatorTree.java | 24 ++ .../com/ts3client/ui/ServerTreePanel.java | 71 ++++-- .../com/ts3client/ui/ServerTreeSearch.java | 237 ++++++++++++++++++ 3 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeSearch.java 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 index 0f9f3ad..fd9d869 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java @@ -18,7 +18,9 @@ import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.function.Predicate; /** @@ -41,6 +43,8 @@ final class DropIndicatorTree extends JTree { private TreePath highlight; /** The row the pointer is over, or -1 when it is over none. */ private int hoverRow = -1; + /** User objects of the rows a find bar matched, compared by identity. */ + private Set searchHits = Collections.emptySet(); private Predicate pathEditable = path -> false; DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) { @@ -137,6 +141,11 @@ final class DropIndicatorTree extends JTree { super.repaint(tm, visible.x, y, visible.width, height); } + void setSearchHits(Set hits) { + searchHits = hits; + repaint(); + } + void highlightChannel(TreePath path) { if (path == highlight || (path != null && path.equals(highlight))) return; highlight = path; @@ -148,6 +157,7 @@ final class DropIndicatorTree extends JTree { Rectangle clip = g.getClipBounds(); g.setColor(getBackground()); g.fillRect(clip.x, clip.y, clip.width, clip.height); + paintSearchHits(g); paintSelection(g); paintHover(g); super.paintComponent(g); @@ -192,6 +202,20 @@ final class DropIndicatorTree extends JTree { } } + private void paintSearchHits(Graphics g) { + if (searchHits.isEmpty()) return; + Rectangle visible = getVisibleRect(); + int first = Math.max(0, getClosestRowForLocation(visible.x, visible.y)); + g.setColor(Theme.searchMatch()); + for (int row = first; row < getRowCount(); row++) { + Rectangle bounds = getRowBounds(row); + if (bounds == null) continue; + if (bounds.y > visible.y + visible.height) break; + Object obj = ((DefaultMutableTreeNode) getPathForRow(row).getLastPathComponent()).getUserObject(); + if (searchHits.contains(obj)) g.fillRect(visible.x, bounds.y, visible.width, bounds.height); + } + } + /** * Washes the row under the pointer across the full width it occupies — the same * area a click on it acts on. A selected row keeps its selection colour, and 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 710f30e..13be12c 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 @@ -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 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. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeSearch.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeSearch.java new file mode 100644 index 0000000..e732092 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeSearch.java @@ -0,0 +1,237 @@ +package com.ts3client.ui; + +import com.ts3client.contacts.Contact; +import com.ts3client.contacts.ContactStore; +import com.ts3client.net.ChannelNode; +import com.ts3client.net.ClientEntry; +import com.ts3client.text.TextQuery; + +import javax.swing.Icon; +import javax.swing.JButton; +import javax.swing.JToggleButton; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.TreePath; +import java.awt.Dimension; +import java.awt.Insets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; + +/** + * Find in the server tree: looks for clients or for channels by name, washes every + * matching row and selects them one at a time. Optionally prunes the tree down to + * the matches (and the channels leading to them). + */ +final class ServerTreeSearch implements FindBar.Listener { + + private final DropIndicatorTree tree; + private final DefaultMutableTreeNode root; + private final ContactStore contacts; + /** Rebuilds the tree, which asks {@link #isFiltering()} and {@link #matches} what to keep. */ + private final Runnable rebuild; + + private final FindBar bar = new FindBar(this); + private boolean searchChannels; + /** Switches between finding clients and finding channels, showing the kind it finds. */ + private final JButton scope = new JButton() { + @Override + public Icon getIcon() { + return searchChannels ? Icons.channel(true) : Icons.clientIdle(); + } + + /** Sized for the wider of both icons, so the field beside it stays put on a switch. */ + @Override + public Dimension getPreferredSize() { + Icon client = Icons.clientIdle(); + Icon channel = Icons.channel(true); + Insets in = getInsets(); + return new Dimension(Math.max(client.getIconWidth(), channel.getIconWidth()) + in.left + in.right, + Math.max(client.getIconHeight(), channel.getIconHeight()) + in.top + in.bottom); + } + + @Override + public Dimension getMaximumSize() { + return getPreferredSize(); + } + }; + private final JToggleButton onlyMatching; + + private TextQuery query = TextQuery.EMPTY; + private boolean filtering; + /** Matching rows in tree order. */ + private final List hits = new ArrayList<>(); + + ServerTreeSearch(DropIndicatorTree tree, DefaultMutableTreeNode root, ContactStore contacts, Runnable rebuild) { + this.tree = tree; + this.root = root; + this.contacts = contacts; + this.rebuild = rebuild; + scope.addActionListener(e -> { + searchChannels = !searchChannels; + scopeChanged(); + }); + bar.addLeading(scope); + onlyMatching = bar.addFilterToggle("Show only matches"); + bar.addResettable(() -> !searchChannels, () -> { + searchChannels = false; + showScope(); + }); + showScope(); + } + + FindBar bar() { + return bar; + } + + void open() { + bar.open(); + } + + /** Whether the tree should hold only matches and the channels leading to them. */ + boolean isFiltering() { + return filtering; + } + + /** Whether a channel or client row matches the current query in the current scope. */ + boolean matches(Object node) { + if (query.isEmpty()) return false; + if (node instanceof ClientEntry c) { + if (searchChannels) return false; + Contact contact = contacts.find(c.uniqueId); + return query.matches(c.nickname) || (contact != null && query.matches(contact.displayName(c.nickname))); + } + if (node instanceof ChannelNode ch) { + if (!searchChannels) return false; + Spacers.Spacer spacer = Spacers.parse(ch.name); + if (spacer == null) return query.matches(ch.name); + return spacer.align != '*' && query.matches(spacer.caption); + } + return false; + } + + /** The tree was rebuilt from the model: its rows (and so the matches) may have changed. */ + void treeRebuilt() { + if (!bar.isOpen()) return; + collectHits(); + updateStatus(); + } + + private void scopeChanged() { + showScope(); + if (bar.isOpen()) bar.fireQueryChanged(); + } + + private void showScope() { + scope.setToolTipText(searchChannels ? "Finding channels — click to find clients" + : "Finding clients — click to find channels"); + scope.repaint(); + bar.setPlaceholder(searchChannels ? "Find channels" : "Find clients"); + } + + @Override + public void queryChanged(TextQuery q) { + query = q.isInvalid() ? TextQuery.EMPTY : q; + boolean filter = onlyMatching.isSelected() && !query.isEmpty(); + if (filter || filtering) { + filtering = filter; + rebuild.run(); + } + collectHits(); + if (!hits.isEmpty()) select(hits.get(0)); + updateStatus(); + } + + @Override + public void next() { + step(true); + } + + @Override + public void previous() { + step(false); + } + + @Override + public void closed() { + query = TextQuery.EMPTY; + hits.clear(); + tree.setSearchHits(Collections.emptySet()); + if (filtering) { + filtering = false; + rebuild.run(); + } + TreePath selected = tree.getSelectionPath(); + if (selected != null) tree.scrollPathToVisible(selected); + tree.requestFocusInWindow(); + } + + /** Moves to the match after (or before) the selected row, wrapping around. */ + private void step(boolean forward) { + if (hits.isEmpty()) return; + List order = preorder(); + int at = order.indexOf(selectedNode()); + DefaultMutableTreeNode target = null; + if (forward) { + for (DefaultMutableTreeNode hit : hits) { + if (order.indexOf(hit) > at) { + target = hit; + break; + } + } + if (target == null) target = hits.get(0); + } else { + for (int i = hits.size() - 1; i >= 0; i--) { + if (at < 0 || order.indexOf(hits.get(i)) < at) { + target = hits.get(i); + break; + } + } + if (target == null) target = hits.get(hits.size() - 1); + } + select(target); + updateStatus(); + } + + private void select(DefaultMutableTreeNode node) { + TreePath path = new TreePath(node.getPath()); + tree.setSelectionPath(path); + tree.scrollPathToVisible(path); + } + + private void collectHits() { + hits.clear(); + Set washed = Collections.newSetFromMap(new IdentityHashMap<>()); + for (DefaultMutableTreeNode n : preorder()) { + if (matches(n.getUserObject())) { + hits.add(n); + washed.add(n.getUserObject()); + } + } + tree.setSearchHits(washed); + } + + private void updateStatus() { + if (query.isEmpty()) { + if (!bar.query().isInvalid()) bar.clearCount(); + bar.setNavigable(false); + return; + } + bar.setNavigable(hits.size() > 1 || (hits.size() == 1 && hits.get(0) != selectedNode())); + bar.setCount(hits.indexOf(selectedNode()) + 1, hits.size(), false); + } + + private DefaultMutableTreeNode selectedNode() { + TreePath path = tree.getSelectionPath(); + return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent(); + } + + private List preorder() { + List out = new ArrayList<>(); + Enumeration e = root.preorderEnumeration(); + while (e.hasMoreElements()) out.add((DefaultMutableTreeNode) e.nextElement()); + return out; + } +}