The toolbar gains an away toggle with a drop-down: it toggles away on the
current server with no message, while the menu carries the global actions
("Set Globally Away", "Set Globally Away Status"), the saved presets and
their editor. Presets live in ~/.ts3jclient/away.properties and are
managed in a list with add/remove and double-click renaming.
Clients in the channel tree now show their away message in brackets after
the nickname instead of their primary server group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
628 lines
25 KiB
Java
628 lines
25 KiB
Java
package com.ts3client.ui;
|
|
|
|
import com.ts3client.net.ChannelNode;
|
|
import com.ts3client.net.ClientEntry;
|
|
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;
|
|
import javax.swing.JTree;
|
|
import javax.swing.JViewport;
|
|
import javax.swing.SwingUtilities;
|
|
import javax.swing.TransferHandler;
|
|
import javax.swing.tree.DefaultMutableTreeNode;
|
|
import javax.swing.tree.DefaultTreeModel;
|
|
import javax.swing.tree.DefaultTreeCellRenderer;
|
|
import javax.swing.tree.TreeModel;
|
|
import javax.swing.tree.TreePath;
|
|
import java.awt.BasicStroke;
|
|
import java.awt.Component;
|
|
import java.awt.Graphics;
|
|
import java.awt.Graphics2D;
|
|
import java.awt.Rectangle;
|
|
import java.awt.RenderingHints;
|
|
import java.awt.datatransfer.DataFlavor;
|
|
import java.awt.datatransfer.Transferable;
|
|
import java.awt.datatransfer.UnsupportedFlavorException;
|
|
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 toggleClientMute(ClientEntry client);
|
|
|
|
void showConnectionInfo(ClientEntry client);
|
|
|
|
/** Open the file repository browser for a channel. */
|
|
void browseFiles(ChannelNode channel);
|
|
|
|
boolean isClientLocallyMuted(int clientId);
|
|
|
|
/** 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);
|
|
}
|
|
|
|
/** Carries the dragged node inside this JVM; drops elsewhere get the BBCode text. */
|
|
private static final DataFlavor NODE_FLAVOR = new DataFlavor(
|
|
DataFlavor.javaJVMLocalObjectMimeType + ";class=java.lang.Object",
|
|
"TeamSpeak tree node");
|
|
|
|
private final DropIndicatorTree tree;
|
|
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, GroupIcons groupIcons, Actions actions) {
|
|
this.model = model;
|
|
this.groupIcons = groupIcons;
|
|
this.actions = actions;
|
|
root.setUserObject("Not connected");
|
|
this.tree = new DropIndicatorTree(treeModel);
|
|
tree.setRootVisible(true);
|
|
tree.setShowsRootHandles(true);
|
|
tree.setRowHeight(20);
|
|
tree.setBackground(Theme.TREE_BG);
|
|
tree.setFont(Theme.UI_FONT);
|
|
tree.setCellRenderer(new Renderer());
|
|
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 TreeTransferHandler());
|
|
|
|
tree.addTreeSelectionListener(e -> {
|
|
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) {
|
|
maybePopup(e);
|
|
}
|
|
|
|
@Override
|
|
public void mouseReleased(MouseEvent e) {
|
|
maybePopup(e);
|
|
}
|
|
|
|
@Override
|
|
public void mouseClicked(MouseEvent e) {
|
|
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
|
|
Object obj = nodeAt(e);
|
|
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
|
|
actions.joinChannel(((ChannelNode) obj).id);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public void setSelfClientId(int id) {
|
|
this.selfClientId = id;
|
|
}
|
|
|
|
private Object nodeAt(MouseEvent e) {
|
|
TreePath path = tree.getPathForLocation(e.getX(), 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.getPathForLocation(e.getX(), 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);
|
|
}
|
|
}
|
|
|
|
private void showClientMenu(ClientEntry client, MouseEvent e) {
|
|
ClientMenu.build(client, client.id == selfClientId, 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());
|
|
}
|
|
|
|
// ---- drag and drop ----
|
|
|
|
private static DefaultMutableTreeNode nodeOf(TreePath path) {
|
|
return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent();
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* The channel a dragged client would land in, or {@code null} if the drop makes
|
|
* no sense (outside a channel, or the channel the client is already in).
|
|
*/
|
|
private ChannelNode resolveClientDrop(JTree.DropLocation loc, ClientEntry dragged) {
|
|
DefaultMutableTreeNode target = nodeOf(loc.getPath());
|
|
if (target == null) return null;
|
|
Object obj = target.getUserObject();
|
|
ChannelNode channel = null;
|
|
if (obj instanceof ChannelNode) {
|
|
channel = (ChannelNode) obj;
|
|
} else if (obj instanceof ClientEntry) {
|
|
channel = model.getChannel(((ClientEntry) obj).channelId);
|
|
}
|
|
if (channel == null || Spacers.isSpacer(channel.name)) return null;
|
|
if (channel.id == dragged.channelId) return null;
|
|
return channel;
|
|
}
|
|
|
|
/**
|
|
* The new parent and predecessor for a dragged channel as {@code {cpid, order}},
|
|
* or {@code null} if this drop is not a legal (or meaningful) move.
|
|
*/
|
|
private int[] resolveChannelDrop(JTree.DropLocation loc, ChannelNode dragged) {
|
|
DefaultMutableTreeNode target = nodeOf(loc.getPath());
|
|
if (target == null) return null;
|
|
|
|
DefaultMutableTreeNode parentNode;
|
|
int insertIndex;
|
|
if (loc.getChildIndex() >= 0) {
|
|
parentNode = target;
|
|
insertIndex = loc.getChildIndex();
|
|
} else {
|
|
// Dropped onto a node: become its last subchannel.
|
|
parentNode = target.getUserObject() instanceof ClientEntry
|
|
? (DefaultMutableTreeNode) target.getParent() : target;
|
|
if (parentNode == null) return null;
|
|
insertIndex = parentNode.getChildCount();
|
|
}
|
|
|
|
int parentId = 0;
|
|
Object parentObj = parentNode.getUserObject();
|
|
if (parentObj instanceof ChannelNode) {
|
|
ChannelNode parent = (ChannelNode) parentObj;
|
|
if (Spacers.isSpacer(parent.name)) return null;
|
|
if (isSelfOrDescendant(parent, dragged)) return null; // would detach the subtree
|
|
parentId = parent.id;
|
|
} else if (parentNode != root) {
|
|
return null;
|
|
}
|
|
|
|
int predecessorId = 0;
|
|
for (int i = 0; i < insertIndex && i < parentNode.getChildCount(); i++) {
|
|
Object o = ((DefaultMutableTreeNode) parentNode.getChildAt(i)).getUserObject();
|
|
if (o instanceof ChannelNode && ((ChannelNode) o).id != dragged.id) {
|
|
predecessorId = ((ChannelNode) o).id;
|
|
}
|
|
}
|
|
if (parentId == dragged.parentId && predecessorId == dragged.order) return null; // no-op
|
|
return new int[]{parentId, predecessorId};
|
|
}
|
|
|
|
/** Whether {@code candidate} is {@code ancestor} itself or sits below it. */
|
|
private boolean isSelfOrDescendant(ChannelNode candidate, ChannelNode ancestor) {
|
|
ChannelNode c = candidate;
|
|
for (int guard = 0; c != null && guard < 64; guard++) {
|
|
if (c.id == ancestor.id) return true;
|
|
c = model.getChannel(c.parentId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private final class TreeTransferHandler extends TransferHandler {
|
|
@Override
|
|
public int getSourceActions(JComponent c) {
|
|
return COPY | MOVE;
|
|
}
|
|
|
|
@Override
|
|
protected Transferable createTransferable(JComponent c) {
|
|
TreePath path = tree.getSelectionPath();
|
|
if (path == null) return null;
|
|
Object obj = nodeOf(path).getUserObject();
|
|
if (obj instanceof ClientEntry) {
|
|
ClientEntry cl = (ClientEntry) obj;
|
|
return new NodeTransferable(cl, TsLink.clientBBCode(cl.id, cl.uniqueId, cl.nickname));
|
|
}
|
|
if (obj instanceof ChannelNode) {
|
|
ChannelNode ch = (ChannelNode) obj;
|
|
// Spacers have no meaningful link text, but can still be re-ordered.
|
|
return new NodeTransferable(ch, Spacers.isSpacer(ch.name)
|
|
? null : TsLink.channelBBCode(ch.id, ch.name));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public boolean canImport(TransferSupport support) {
|
|
if (!support.isDrop() || !support.isDataFlavorSupported(NODE_FLAVOR)) {
|
|
tree.highlightChannel(null);
|
|
return false;
|
|
}
|
|
if ((support.getSourceDropActions() & MOVE) == MOVE) support.setDropAction(MOVE);
|
|
Object resolved = resolve(support);
|
|
// A client always lands *inside* a channel, so mark that channel instead of
|
|
// drawing a line that would suggest a position among its clients.
|
|
tree.highlightChannel(resolved instanceof ChannelNode
|
|
? pathOf((ChannelNode) resolved) : null);
|
|
// The indicator spans the full width, past the row rectangles Swing
|
|
// repaints on its own when the drop location moves.
|
|
tree.repaint();
|
|
return resolved != null;
|
|
}
|
|
|
|
@Override
|
|
protected void exportDone(JComponent source, Transferable data, int action) {
|
|
tree.highlightChannel(null);
|
|
tree.repaint();
|
|
}
|
|
|
|
@Override
|
|
public boolean importData(TransferSupport support) {
|
|
if (!canImport(support)) return false;
|
|
Object dragged = draggedNode(support);
|
|
Object resolved = resolve(support);
|
|
tree.highlightChannel(null);
|
|
if (dragged instanceof ClientEntry) {
|
|
actions.moveClientToChannel((ClientEntry) dragged, (ChannelNode) resolved);
|
|
} else if (dragged instanceof ChannelNode) {
|
|
int[] place = (int[]) resolved;
|
|
actions.moveChannel((ChannelNode) dragged, place[0], place[1]);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** The destination for this drop: a ChannelNode, an {@code {cpid, order}} pair, or null. */
|
|
private Object resolve(TransferSupport support) {
|
|
Object dragged = draggedNode(support);
|
|
if (!(support.getDropLocation() instanceof JTree.DropLocation)) return null;
|
|
JTree.DropLocation loc = (JTree.DropLocation) support.getDropLocation();
|
|
if (dragged instanceof ClientEntry) return resolveClientDrop(loc, (ClientEntry) dragged);
|
|
if (dragged instanceof ChannelNode) return resolveChannelDrop(loc, (ChannelNode) dragged);
|
|
return null;
|
|
}
|
|
|
|
private Object draggedNode(TransferSupport support) {
|
|
try {
|
|
return support.getTransferable().getTransferData(NODE_FLAVOR);
|
|
} catch (Exception e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Offers the dragged node locally and its TS3 link BBCode to other applications. */
|
|
private static final class NodeTransferable implements Transferable {
|
|
private final Object node;
|
|
private final String text;
|
|
|
|
NodeTransferable(Object node, String text) {
|
|
this.node = node;
|
|
this.text = text;
|
|
}
|
|
|
|
@Override
|
|
public DataFlavor[] getTransferDataFlavors() {
|
|
return text == null ? new DataFlavor[]{NODE_FLAVOR}
|
|
: new DataFlavor[]{NODE_FLAVOR, DataFlavor.stringFlavor};
|
|
}
|
|
|
|
@Override
|
|
public boolean isDataFlavorSupported(DataFlavor flavor) {
|
|
return NODE_FLAVOR.equals(flavor) || (text != null && DataFlavor.stringFlavor.equals(flavor));
|
|
}
|
|
|
|
@Override
|
|
public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException {
|
|
if (NODE_FLAVOR.equals(flavor)) return node;
|
|
if (text != null && DataFlavor.stringFlavor.equals(flavor)) return text;
|
|
throw new UnsupportedFlavorException(flavor);
|
|
}
|
|
}
|
|
|
|
// ---- 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 the icons of every visible row — a client's group icons, a channel's own
|
|
* icon — 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();
|
|
List<Icon> icons = badgesOf(obj);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** The icon strip a row shows on its right, empty when it has none (yet). */
|
|
private List<Icon> badgesOf(Object node) {
|
|
if (node instanceof ClientEntry) {
|
|
ClientEntry cl = (ClientEntry) node;
|
|
return groupIcons.iconsOf(
|
|
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
|
|
}
|
|
if (node instanceof ChannelNode) {
|
|
ChannelNode ch = (ChannelNode) node;
|
|
Icon icon = Spacers.isSpacer(ch.name) ? null : groupIcons.icon(ch.iconId);
|
|
if (icon != null) return List.of(icon);
|
|
}
|
|
return List.of();
|
|
}
|
|
|
|
/**
|
|
* Draws where the drop will land: an insertion line between rows, or an outline
|
|
* around the row that will receive the dragged node.
|
|
*/
|
|
private final class DropIndicatorTree extends JTree {
|
|
/** Set while a drop would move a client into this channel row. */
|
|
private TreePath highlight;
|
|
|
|
DropIndicatorTree(TreeModel model) {
|
|
super(model);
|
|
}
|
|
|
|
/**
|
|
* Widens every repaint request to the full visible width. Swing only asks for
|
|
* the row rectangle, which stops short of the right-aligned icon strip and would
|
|
* leave it behind when a row's label changes width.
|
|
*/
|
|
@Override
|
|
public void repaint(long tm, int x, int y, int width, int height) {
|
|
Rectangle visible = getVisibleRect();
|
|
super.repaint(tm, visible.x, y, visible.width, height);
|
|
}
|
|
|
|
void highlightChannel(TreePath path) {
|
|
if (path == highlight || (path != null && path.equals(highlight))) return;
|
|
highlight = path;
|
|
repaint();
|
|
}
|
|
|
|
@Override
|
|
protected void paintComponent(Graphics g) {
|
|
super.paintComponent(g);
|
|
paintBadges(g, this);
|
|
JTree.DropLocation loc = getDropLocation();
|
|
if (loc == null || loc.getPath() == null) return;
|
|
|
|
Graphics2D g2 = (Graphics2D) g.create();
|
|
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
|
g2.setColor(Theme.ACCENT);
|
|
if (highlight != null || loc.getChildIndex() < 0) {
|
|
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
|
|
if (r != null) {
|
|
g2.setStroke(new BasicStroke(2f));
|
|
g2.drawRoundRect(r.x, r.y + 1, r.width - 1, r.height - 3, 4, 4);
|
|
}
|
|
} else {
|
|
Rectangle line = insertLine(loc);
|
|
if (line != null) {
|
|
g2.fillRect(line.x, line.y - 1, line.width, 2);
|
|
g2.fillOval(line.x - 3, line.y - 4, 7, 7);
|
|
}
|
|
}
|
|
g2.dispose();
|
|
}
|
|
|
|
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
|
|
private Rectangle insertLine(JTree.DropLocation loc) {
|
|
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
|
|
int index = loc.getChildIndex();
|
|
if (index < parent.getChildCount()) {
|
|
Rectangle r = getPathBounds(loc.getPath().pathByAddingChild(parent.getChildAt(index)));
|
|
return r == null ? null : new Rectangle(r.x, r.y, getWidth() - r.x, 2);
|
|
}
|
|
if (parent.getChildCount() == 0) {
|
|
Rectangle r = getPathBounds(loc.getPath());
|
|
if (r == null) return null;
|
|
int x = r.x + getRowHeight();
|
|
return new Rectangle(x, r.y + r.height, getWidth() - x, 2);
|
|
}
|
|
// Past the last child: below that child's whole (expanded) subtree.
|
|
TreePath lastChild = loc.getPath().pathByAddingChild(parent.getChildAt(parent.getChildCount() - 1));
|
|
Rectangle head = getPathBounds(lastChild);
|
|
Rectangle tail = getPathBounds(lastVisibleRow(lastChild));
|
|
if (head == null || tail == null) return null;
|
|
return new Rectangle(head.x, tail.y + tail.height, getWidth() - head.x, 2);
|
|
}
|
|
|
|
private TreePath lastVisibleRow(TreePath path) {
|
|
int row = getRowForPath(path);
|
|
if (row < 0) return path;
|
|
for (int i = row + 1; i < getRowCount(); i++) {
|
|
if (!path.isDescendant(getPathForRow(i))) break;
|
|
row = i;
|
|
}
|
|
return getPathForRow(row);
|
|
}
|
|
}
|
|
|
|
/** Rebuilds the tree from the model, preserving full expansion. */
|
|
public void rebuild() {
|
|
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);
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
boolean hasFocus) {
|
|
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
|
|
setBackgroundNonSelectionColor(Theme.TREE_BG);
|
|
setBackgroundSelectionColor(Theme.TREE_SELECTION);
|
|
setBorderSelectionColor(Theme.TREE_SELECTION);
|
|
|
|
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
|
|
if (obj instanceof ChannelNode) {
|
|
ChannelNode c = (ChannelNode) obj;
|
|
Spacers.Spacer spacer = Spacers.parse(c.name);
|
|
if (spacer != null) {
|
|
setText(Spacers.render(spacer, 40));
|
|
setIcon(null);
|
|
setForeground(Theme.IDLE_CLIENT);
|
|
setFont(Theme.UI_FONT);
|
|
} else {
|
|
setText(c.name);
|
|
setIcon(iconFor(c));
|
|
setForeground(Theme.CHANNEL_TEXT);
|
|
setFont(Theme.UI_BOLD);
|
|
}
|
|
} else if (obj instanceof ClientEntry) {
|
|
ClientEntry cl = (ClientEntry) obj;
|
|
String label = cl.nickname;
|
|
if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]";
|
|
setText(label);
|
|
setIcon(iconFor(cl));
|
|
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
|
|
setFont(cl.talking ? Theme.UI_BOLD : Theme.UI_FONT);
|
|
} else {
|
|
// root / server
|
|
setText(String.valueOf(obj));
|
|
setIcon(Icons.server());
|
|
setForeground(Theme.SERVER_TEXT);
|
|
setFont(Theme.UI_BOLD);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
private ImageIcon iconFor(ChannelNode c) {
|
|
if (c.hasPassword) return Icons.channelLocked();
|
|
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull();
|
|
return Icons.channel();
|
|
}
|
|
|
|
/** The client's state, in the order the official client gives them priority. */
|
|
private ImageIcon iconFor(ClientEntry cl) {
|
|
if (cl.isQuery()) return Icons.clientQuery();
|
|
if (cl.outputMuted) return Icons.speakerMuted();
|
|
if (cl.inputMuted) return Icons.micMuted();
|
|
if (cl.away) return Icons.clientAway();
|
|
if (cl.channelCommander) {
|
|
return cl.talking ? Icons.clientCommanderTalking() : Icons.clientCommander();
|
|
}
|
|
if (cl.talking) return Icons.clientTalking();
|
|
return Icons.clientIdle();
|
|
}
|
|
}
|
|
}
|