Show server and channel group icons in the tree

Download the icons a server advertises for its groups over the file
transfer channel, cache them under the config directory, and draw them as
a badge strip on each client's row, right-aligned against the tree's
visible edge like the TeamSpeak client does. Groups without a custom icon
fall back to the bundled default set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 07:06:23 +00:00
parent 0333b92ce5
commit 7c86a1f166
18 changed files with 597 additions and 38 deletions

View File

@@ -6,6 +6,7 @@ import com.ts3client.net.ServerModel;
import com.ts3client.text.TsLink;
import javax.swing.DropMode;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JComponent;
import javax.swing.JScrollPane;
@@ -77,11 +78,13 @@ public final class ServerTreePanel extends JScrollPane {
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
private final ServerModel model;
private final GroupIcons groupIcons;
private final Actions actions;
private int selfClientId = -1;
public ServerTreePanel(ServerModel model, Actions actions) {
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);
@@ -364,11 +367,47 @@ public final class ServerTreePanel extends JScrollPane {
}
}
// ---- group icon strip ----
/** Gap kept between a row's label and the right-aligned icon strip. */
private static final int BADGE_GAP = 8;
/** Inset of the strip from the visible right edge. */
private static final int BADGE_MARGIN = 4;
/**
* Paints every visible client's group icons flush with the right edge of the
* viewport, the way TeamSpeak lines them up. Drawing them here rather than in the
* cell renderer keeps the rows' measured widths (and thus the selection highlight)
* tied to the label alone.
*/
private void paintBadges(Graphics g, JTree tree) {
Rectangle visible = tree.getVisibleRect();
int right = visible.x + visible.width - BADGE_MARGIN;
for (int row = 0; row < tree.getRowCount(); row++) {
Rectangle bounds = tree.getRowBounds(row);
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
if (bounds.y > visible.y + visible.height) break;
TreePath path = tree.getPathForRow(row);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
if (!(obj instanceof ClientEntry)) continue;
ClientEntry cl = (ClientEntry) obj;
List<Icon> icons = groupIcons.iconsOf(
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
if (icons.isEmpty()) continue;
GroupIcons.Row strip = new GroupIcons.Row(icons);
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
strip.paintIcon(tree, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
}
}
/**
* Draws where the drop will land: an insertion line between rows, or an outline
* around the row that will receive the dragged node.
*/
private static final class DropIndicatorTree extends JTree {
private final class DropIndicatorTree extends JTree {
/** Set while a drop would move a client into this channel row. */
private TreePath highlight;
@@ -385,6 +424,7 @@ public final class ServerTreePanel extends JScrollPane {
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
paintBadges(g, this);
JTree.DropLocation loc = getDropLocation();
if (loc == null || loc.getPath() == null) return;
@@ -477,7 +517,26 @@ public final class ServerTreePanel extends JScrollPane {
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());
}
}
}
/**
* Draws a tree row: the status icon and the label. The group icon strip is painted
* separately, right-aligned, by {@link #paintBadges}.
*/
private final class Renderer extends DefaultTreeCellRenderer {
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
boolean expanded, boolean leaf, int row,
@@ -504,9 +563,15 @@ public final class ServerTreePanel extends JScrollPane {
}
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
String label = cl.nickname;
if (primaryGroup != null) label += " [" + primaryGroup + "]";
boolean hasIcons = !groupIcons.iconsOf(
model.serverGroupsOf(cl.serverGroupIds),
model.channelGroup(cl.channelGroupId)).isEmpty();
if (!hasIcons) {
// No icons (yet): fall back to naming the primary group inline.
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
if (primaryGroup != null) label += " [" + primaryGroup + "]";
}
setText(label);
setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);