Log server activity, scrollable chat tabs, and move info panel to a chat tab

Native TS3 logs client joins/leaves/moves, group changes and channel edits
to the server tab; minor local-only notices (mic mute, tray) no longer
spam the log. Chat tabs scroll by mouse wheel like server tabs. The info
panel can now be moved into a persistent, generically-labelled chat tab
that tracks tree selection and collapses the panel's space entirely; a
tree rebuild no longer briefly fires a null selection that reset this.
Added "Find Client in Channel Tree" to the client context menu, and tree
selection now survives model rebuilds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 21:47:12 +00:00
parent 047c89404c
commit 71084ea309
9 changed files with 439 additions and 35 deletions

View File

@@ -4,6 +4,7 @@ import com.ts3client.text.BBCode;
import com.ts3client.text.TsLink;
import javax.swing.BorderFactory;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JEditorPane;
import javax.swing.JLabel;
@@ -25,6 +26,7 @@ import java.awt.FlowLayout;
import java.awt.Point;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
@@ -62,6 +64,8 @@ public final class ChatPanel extends JPanel {
private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
/** Private chats keyed by the peer's client id. */
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
/** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
private SendHandler sendHandler;
private LinkHandler linkHandler;
@@ -70,6 +74,7 @@ public final class ChatPanel extends JPanel {
super(new BorderLayout());
tabs.setFont(Theme.UI_FONT);
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
addTab(serverTab, false);
addTab(channelTab, false);
tabs.setSelectedIndex(0);
@@ -77,6 +82,7 @@ public final class ChatPanel extends JPanel {
Tab t = selectedTab();
if (t != null) t.setUnread(false);
});
tabs.addMouseWheelListener(this::onWheel);
add(tabs, BorderLayout.CENTER);
JPanel bottom = new JPanel(new BorderLayout(4, 0));
@@ -130,6 +136,40 @@ public final class ChatPanel extends JPanel {
});
}
/** Drops every static-content tab (moved-out descriptions), e.g. after disconnecting. */
public void closeNoteTabs() {
edt(() -> {
for (Tab t : noteTabs.values()) tabs.remove(t.scroll);
noteTabs.clear();
});
}
/** Closes a single static-content tab by its key, if open; does not run its {@code onClose}. */
public void closeNoteTab(String key) {
edt(() -> {
Tab tab = noteTabs.remove(key);
if (tab != null) tabs.remove(tab.scroll);
});
}
/**
* Opens (or replaces the content of, and focuses) a static-content tab, e.g. a
* channel/client description moved out of the info panel. {@code onClose} runs
* when the tab is closed, so the caller can un-hide the description again.
*/
public void openDescriptionTab(String key, Icon icon, String title, String htmlBody, Runnable onClose) {
edt(() -> {
Tab tab = noteTabs.get(key);
if (tab == null) {
tab = new Tab(key, icon, title, onClose);
noteTabs.put(key, tab);
addTab(tab, true);
}
tab.setStaticContent(htmlBody);
tabs.setSelectedComponent(tab.scroll);
});
}
private void fireSend() {
String text = input.getText().trim();
if (text.isEmpty() || sendHandler == null) return;
@@ -147,6 +187,12 @@ public final class ChatPanel extends JPanel {
+ BBCode.escape(text) + "</span>"));
}
/** Server-side events (client joins/leaves/moves, group changes, channel edits, …). */
public void appendServerLog(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:" + hex(Theme.CHANNEL_TEXT) + "\">" + stamp()
+ BBCode.escape(text) + "</span>"));
}
public void appendServerMessage(int fromId, String from, String text) {
edt(() -> serverTab.appendMessage(fromId, from, text));
}
@@ -180,10 +226,25 @@ public final class ChatPanel extends JPanel {
}
private void closeTab(Tab tab) {
privateTabs.remove(tab.clientId);
if (tab.noteKey != null) {
noteTabs.remove(tab.noteKey);
if (tab.onClose != null) tab.onClose.run();
} else {
privateTabs.remove(tab.clientId);
}
tabs.remove(tab.scroll);
}
private void onWheel(MouseWheelEvent e) {
Component content = tabs.getSelectedComponent();
if (content != null && content.getBounds().contains(e.getPoint())) return;
int steps = e.getWheelRotation();
if (steps == 0) return;
int target = Math.max(0, Math.min(tabs.getTabCount() - 1, tabs.getSelectedIndex() + steps));
if (target != tabs.getSelectedIndex()) tabs.setSelectedIndex(target);
e.consume();
}
private Tab selectedTab() {
Component c = tabs.getSelectedComponent();
if (c == serverTab.scroll) return serverTab;
@@ -207,13 +268,29 @@ public final class ChatPanel extends JPanel {
final String title;
final JEditorPane log = new JEditorPane();
final JScrollPane scroll;
/** Set for a static-content tab (e.g. a moved-out description); null for a conversation. */
final String noteKey;
final Runnable onClose;
private final Icon icon;
private final HTMLDocument doc;
private JLabel titleLabel;
Tab(Target target, int clientId, String title) {
this(target, clientId, title, null, null, null);
}
/** A static-content tab: no send target, closing it runs {@code onClose}. */
Tab(String noteKey, Icon icon, String title, Runnable onClose) {
this(null, 0, title, icon, noteKey, onClose);
}
private Tab(Target target, int clientId, String title, Icon icon, String noteKey, Runnable onClose) {
this.target = target;
this.clientId = clientId;
this.title = title;
this.icon = icon;
this.noteKey = noteKey;
this.onClose = onClose;
HTMLEditorKit kit = new HTMLEditorKit();
StyleSheet css = new StyleSheet();
@@ -259,7 +336,7 @@ public final class ChatPanel extends JPanel {
Component header(boolean closable) {
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
p.setOpaque(false);
titleLabel = new JLabel(title);
titleLabel = new JLabel(title, icon, JLabel.LEADING);
titleLabel.setFont(Theme.UI_FONT);
p.add(titleLabel);
if (closable) {
@@ -303,6 +380,12 @@ public final class ChatPanel extends JPanel {
+ sender + ": " + BBCode.toHtml(text));
}
/** Replaces the whole log with fixed content; used by static-content tabs. */
void setStaticContent(String html) {
log.setText("<html><body><div id=\"chatlog\">" + html + "</div></body></html>");
log.setCaretPosition(0);
}
void appendLine(String html) {
try {
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");

View File

@@ -44,6 +44,9 @@ final class ClientMenu {
menu.add(me);
}
menu.addSeparator();
JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("PLAYER_ON"));
findInTree.addActionListener(a -> actions.findClientInTree(client));
menu.add(findInTree);
JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO"));
info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info);

View File

@@ -243,6 +243,31 @@ public final class Icons {
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
}
/**
* A fixed-width "channel icon / edit icon" label icon, used where a tab must not
* change width as the object it shows changes (e.g. the info-panel-in-chat tab).
*/
public static ImageIcon channelClientPair() {
ImageIcon channel = channel(true);
ImageIcon edit = themed("EDIT", Icons::paintEdit);
int gap = 8;
int width = channel.getIconWidth() + gap + edit.getIconWidth();
int height = Math.max(channel.getIconHeight(), edit.getIconHeight());
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null);
g.setColor(Theme.IDLE_CLIENT);
g.setFont(Theme.UI_FONT);
java.awt.FontMetrics fm = g.getFontMetrics();
int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2;
g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1);
g.drawImage(edit.getImage(), channel.getIconWidth() + gap,
(height - edit.getIconHeight()) / 2, null);
g.dispose();
return new ImageIcon(img);
}
/** The toolbar's away marker. */
public static ImageIcon away() {
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.AWAY));
@@ -398,6 +423,18 @@ public final class Icons {
g.drawLine(8, 11, 8, 13);
}
/** A pencil glyph, drawn when no icon pack ships an "EDIT" icon. */
private static void paintEdit(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(1.4f));
g.drawLine(3, 13, 11, 5);
g.drawLine(3, 13, 4, 10);
g.drawLine(4, 10, 11, 3);
g.drawLine(11, 3, 13, 5);
g.drawLine(13, 5, 11, 7);
g.drawLine(11, 5, 13, 7);
}
private static void paintSettings(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(2f));

View File

@@ -20,7 +20,27 @@ import java.util.List;
*/
public final class InfoPanel extends JScrollPane {
/** Moves the whole info panel's content out to a persistent chat tab. */
public interface DescriptionHandler {
/** Shows/updates {@code html} in the info chat tab, creating it if needed. */
void showInfoTab(String html, Runnable onClose);
/** Closes the info chat tab (the user chose to show details in the panel again). */
void closeInfoTab();
/** Hides (or restores) the panel itself, freeing its space, while its content lives in the chat tab. */
void setPanelHidden(boolean hidden);
}
private static final String TOGGLE_HREF = "app:toggle-info-tab";
private final JEditorPane pane = new JEditorPane();
private DescriptionHandler descriptionHandler;
private ChannelNode shownChannel;
private ClientEntry shownClient;
private ServerModel shownModel;
private IconRepository shownIcons;
private boolean inChatTab;
public InfoPanel() {
javax.swing.text.html.HTMLEditorKit kit = new javax.swing.text.html.HTMLEditorKit();
@@ -39,7 +59,12 @@ public final class InfoPanel extends JScrollPane {
pane.setBackground(Theme.CHAT_BG);
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
pane.addHyperlinkListener(e -> {
if (e.getEventType() == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) {
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
if (TOGGLE_HREF.equals(e.getDescription())) {
inChatTab = !inChatTab;
if (!inChatTab && descriptionHandler != null) descriptionHandler.closeInfoTab();
render();
} else {
Links.open(e.getDescription(), this);
}
});
@@ -49,10 +74,54 @@ public final class InfoPanel extends JScrollPane {
}
public void clear() {
setHtml("<i style='color:#8a8a8a'>Select a channel or client to see details.</i>");
shownChannel = null;
shownClient = null;
inChatTab = false;
render();
}
public void setDescriptionHandler(DescriptionHandler handler) {
this.descriptionHandler = handler;
}
/** Runs when the user closes the info chat tab; shows details in the panel again. */
private void onInfoTabClosed() {
inChatTab = false;
render();
}
public void showChannel(ChannelNode ch) {
shownChannel = ch;
shownClient = null;
render();
}
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
shownClient = cl;
shownChannel = null;
shownModel = model;
shownIcons = icons;
render();
}
private void render() {
boolean selected = shownChannel != null || shownClient != null;
boolean hide = inChatTab && selected;
if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide);
if (hide) {
descriptionHandler.showInfoTab(body(), this::onInfoTabClosed);
} else {
setHtml(body() + (selected ? toggleLink(true) : ""));
}
}
private String body() {
if (shownChannel != null) return channelBody(shownChannel);
if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons);
return "<i style='color:#8a8a8a'>Select a channel or client to see details.</i>";
}
private static String channelBody(ChannelNode ch) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(ch.name)));
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
@@ -67,10 +136,10 @@ public final class InfoPanel extends JScrollPane {
} else {
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
}
setHtml(sb.toString());
return sb.toString();
}
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
@@ -105,7 +174,13 @@ public final class InfoPanel extends JScrollPane {
if (cl.description != null && !cl.description.isEmpty()) {
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
}
setHtml(sb.toString());
return sb.toString();
}
/** The link that toggles between showing details here and in a chat tab. */
private static String toggleLink(boolean toChat) {
return "<div style='margin-top:8px'><a href='" + TOGGLE_HREF + "' style='font-size:10px'>"
+ (toChat ? "Show in chat tab" : "Show here") + "</a></div>";
}
private void setHtml(String body) {

View File

@@ -132,7 +132,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
selectTab(first);
first.chat().appendSystem("Welcome to the TS3J Swing client.");
first.chat().appendSystem("Use Connections → Connect to join a server.");
first.chat().appendSystem(tray.status());
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
statusTimer.start();

View File

@@ -39,6 +39,9 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private final ChatPanel chatPanel;
private final InfoPanel infoPanel = new InfoPanel();
private final JComponent component;
private JSplitPane leftColumn;
private int normalDividerSize;
private int savedDividerLocation = -1;
/** Label shown in the tab bar: the server name once known, the address before that. */
private String title = "New connection";
@@ -82,9 +85,27 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
});
chatPanel.setInputEnabled(false);
JSplitPane leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
infoPanel.setDescriptionHandler(new InfoPanel.DescriptionHandler() {
@Override
public void showInfoTab(String html, Runnable onClose) {
chatPanel.openDescriptionTab("info", Icons.channelClientPair(), "", html, onClose);
}
@Override
public void closeInfoTab() {
chatPanel.closeNoteTab("info");
}
@Override
public void setPanelHidden(boolean hidden) {
ServerTab.this.setInfoPanelHidden(hidden);
}
});
leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
leftColumn.setResizeWeight(0.68);
leftColumn.setContinuousLayout(true);
normalDividerSize = leftColumn.getDividerSize();
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
split.setResizeWeight(0.55);
@@ -234,21 +255,18 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
void setMicMuted(boolean muted) {
micMuted = muted;
conn.setMicMuted(muted);
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
}
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
void setMicLocalMuted(boolean muted) {
micLocalMuted = muted;
conn.setMicLocalMuted(muted);
chatPanel.appendSystem(muted ? "Microphone locally muted." : "Microphone locally unmuted.");
}
void setDeafened(boolean deaf) {
deafened = deaf;
conn.setDeafened(deaf);
if (deaf) micMuted = true;
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
}
/** Hands the capture device to (or takes it from) this connection. */
@@ -264,8 +282,6 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
this.away = away;
if (message != null) this.awayMessage = message;
conn.setAway(away, awayMessage);
chatPanel.appendSystem(!away ? "No longer away."
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
}
void setCommander(boolean commander) {
@@ -425,7 +441,12 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (conn.getPlayback() == null) return;
boolean now = !conn.getPlayback().isClientMuted(client.id);
conn.getPlayback().setClientMuted(client.id, now);
chatPanel.appendSystem((now ? "Muted " : "Unmuted ") + client.nickname + ".");
}
@Override
public void findClientInTree(ClientEntry client) {
host.selectTab(this);
treePanel.selectClient(client.id);
}
@Override
@@ -481,6 +502,22 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
}
}
/** Collapses the info panel to nothing while its content lives in the chat tab, or restores it. */
private void setInfoPanelHidden(boolean hidden) {
if (hidden == !infoPanel.isVisible()) return;
if (hidden) {
savedDividerLocation = leftColumn.getDividerLocation();
infoPanel.setVisible(false);
leftColumn.setDividerSize(0);
leftColumn.setDividerLocation(1.0);
} else {
infoPanel.setVisible(true);
leftColumn.setDividerSize(normalDividerSize);
if (savedDividerLocation >= 0) leftColumn.setDividerLocation(savedDividerLocation);
}
leftColumn.revalidate();
}
// ---- ConnectionListener (marshal to EDT) ----
@Override
@@ -518,6 +555,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
infoPanel.clear();
chatPanel.setInputEnabled(false);
chatPanel.closePrivateChats();
chatPanel.closeNoteTabs();
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
host.tabDisconnected(this);
});
@@ -581,6 +619,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
});
}
@Override
public void onServerLog(String message) {
SwingUtilities.invokeLater(() -> chatPanel.appendServerLog(message));
}
@Override
public void onPoke(String fromName, String message) {
SwingUtilities.invokeLater(() -> {

View File

@@ -72,6 +72,9 @@ public final class ServerTreePanel extends JScrollPane {
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);
@@ -95,6 +98,8 @@ public final class ServerTreePanel extends JScrollPane {
private final DropIndicatorTree tree;
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
/** True while {@link #rebuild()} clears and restores the selection, to swallow the transient null in between. */
private boolean rebuilding;
private final ServerModel model;
private final GroupIcons groupIcons;
private final Actions actions;
@@ -133,6 +138,7 @@ public final class ServerTreePanel extends JScrollPane {
tree.setTransferHandler(new TreeTransferHandler());
tree.addTreeSelectionListener(e -> {
if (rebuilding) return;
TreePath path = tree.getSelectionPath();
Object obj = null;
if (path != null) {
@@ -172,6 +178,16 @@ public final class ServerTreePanel extends JScrollPane {
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.getPathForLocation(e.getX(), e.getY());
if (path == null) return null;
@@ -542,18 +558,43 @@ public final class ServerTreePanel extends JScrollPane {
}
}
/** Rebuilds the tree from the model, preserving full expansion. */
/**
* 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() {
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);
Object selected = selectedUserObject();
rebuilding = true;
try {
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);
}
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);
}
private Object selectedUserObject() {
TreePath path = tree.getSelectionPath();
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
}
private DefaultMutableTreeNode buildChannel(ChannelNode c) {