From 71084ea3095782f6fd33f43d7c4e8a90231e9181 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Mon, 17 Aug 2026 21:47:12 +0000 Subject: [PATCH] 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 --- .../com/ts3client/net/ConnectionListener.java | 7 + .../ts3client/net/TeamspeakConnection.java | 136 ++++++++++++++++-- .../main/java/com/ts3client/ui/ChatPanel.java | 87 ++++++++++- .../java/com/ts3client/ui/ClientMenu.java | 3 + .../src/main/java/com/ts3client/ui/Icons.java | 37 +++++ .../main/java/com/ts3client/ui/InfoPanel.java | 85 ++++++++++- .../main/java/com/ts3client/ui/MainFrame.java | 1 - .../main/java/com/ts3client/ui/ServerTab.java | 57 +++++++- .../com/ts3client/ui/ServerTreePanel.java | 61 ++++++-- 9 files changed, 439 insertions(+), 35 deletions(-) diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java index 94bf3bb..c28c3cb 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java @@ -33,4 +33,11 @@ public interface ConnectionListener { /** Someone poked the local client. */ void onPoke(String fromName, String message); + + /** + * Something happened on the server that native TS3 records in the server + * log: a client connecting/disconnecting/moving, a channel being edited, + * a group being (un)assigned, and so on. + */ + void onServerLog(String message); } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index cf14096..bdedc39 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -791,14 +791,23 @@ public final class TeamspeakConnection implements TS3Listener { c.serverGroupIds = parseIntList(e.getClientServerGroups()); c.channelGroupId = e.getClientChannelGroupId(); c.self = (e.getClientId() == selfClientId); - if (e.getClientId() != selfClientId) announceClientEntered(e); + if (e.getClientId() != selfClientId) { + announceClientEntered(e); + logClientEntered(e); + } ui.onModelChanged(); } @Override public void onClientLeave(ClientLeaveEvent e) { - if (e.getClientId() == selfClientId) announceOwnRemoval(safeInt(e, "reasonid"), e); - else announceClientLeft(e); + if (e.getClientId() == selfClientId) { + announceOwnRemoval(safeInt(e, "reasonid"), e); + } else { + ClientEntry leaving = model.getClient(e.getClientId()); + String name = leaving != null ? leaving.nickname : "Client " + e.getClientId(); + announceClientLeft(e); + logClientLeft(e, name); + } model.removeClient(e.getClientId()); if (playback != null) playback.removeClient(e.getClientId()); ui.onModelChanged(); @@ -810,12 +819,84 @@ public final class TeamspeakConnection implements TS3Listener { if (c != null) { int from = c.channelId; c.channelId = e.getTargetChannelId(); - if (e.getClientId() == selfClientId) announceOwnMove(safeInt(e, "reasonid"), e); - else announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId()); + if (e.getClientId() == selfClientId) { + announceOwnMove(safeInt(e, "reasonid"), e); + } else { + announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId()); + logClientMoved(e, c.nickname, from, e.getTargetChannelId()); + } ui.onModelChanged(); } } + /** A client became visible to us, logged the way native TS3's server tab does. */ + private void logClientEntered(ClientJoinEvent e) { + String name = e.getClientNickname(); + switch (safeInt(e, "reasonid")) { + case REASON_MOVED: + log(name + " appears, coming from channel \"" + channelName(e.getClientFromId()) + "\""); + break; + case REASON_CHANNEL_KICK: + log(name + " appears, was kicked from channel \"" + channelName(e.getClientFromId()) + + "\" by " + invokerName(e)); + break; + case REASON_SWITCHED: + log(name + " switched to channel \"" + channelName(e.getClientTargetId()) + + "\", coming from channel \"" + channelName(e.getClientFromId()) + "\""); + break; + default: + log(name + " connected to channel \"" + channelName(e.getClientTargetId()) + "\""); + } + } + + /** A client stopped being visible to us. */ + private void logClientLeft(ClientLeaveEvent e, String name) { + String reasonMsg = orEmpty(e.get("reasonmsg")); + String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")"; + switch (safeInt(e, "reasonid")) { + case REASON_TIMEOUT: + log(name + " dropped (ping timeout)"); + break; + case REASON_SERVER_KICK: + log(name + " was kicked from the server by " + invokerName(e) + suffix); + break; + case REASON_BAN: + log(name + " was banned from the server by " + invokerName(e) + suffix); + break; + case REASON_CHANNEL_KICK: + log(name + " left: was kicked to channel \"" + channelName(e.getClientTargetId()) + + "\" by " + invokerName(e) + suffix); + break; + case REASON_MOVED: + log(name + " left, heading to channel \"" + channelName(e.getClientTargetId()) + "\""); + break; + case REASON_SWITCHED: + log(name + " left, switched to channel \"" + channelName(e.getClientTargetId()) + "\""); + break; + default: + log(name + " disconnected"); + } + } + + /** A client we can see moved between two channels we can see. */ + private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) { + String reasonMsg = orEmpty(e.get("reasonmsg")); + String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")"; + switch (safeInt(e, "reasonid")) { + case REASON_MOVED: + log(name + " was moved from channel \"" + channelName(fromChannel) + "\" to \"" + + channelName(toChannel) + "\" by " + invokerName(e)); + break; + case REASON_CHANNEL_KICK: + log(name + " was kicked from channel \"" + channelName(fromChannel) + "\" to \"" + + channelName(toChannel) + "\" by " + invokerName(e) + suffix); + break; + default: + log(name + " switched from channel \"" + channelName(fromChannel) + "\" to \"" + + channelName(toChannel) + "\""); + } + } + // ---- who went where: TeamSpeak's reason ids ---- /** {@code reasonid} of a client view/move notification. */ @@ -950,6 +1031,7 @@ public final class TeamspeakConnection implements TS3Listener { ClientEntry c = model.getClient(e.getClientId()); if (c == null) return; boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname); + String oldName = c.nickname; if (renamed) c.nickname = e.get("client_nickname"); if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted"); if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted"); @@ -966,12 +1048,12 @@ public final class TeamspeakConnection implements TS3Listener { if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power"); if (has(e, "client_is_channel_commander")) c.channelCommander = e.getBoolean("client_is_channel_commander"); - announceClientUpdate(e, c, renamed); + announceClientUpdate(e, c, renamed, oldName); ui.onModelChanged(); } /** Renames, talk-power changes and recording flags all arrive as a client update. */ - private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed) { + private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) { if (!connected) return; boolean self = c.id == selfClientId; Map vars = clientVars(c.id, null); @@ -979,6 +1061,7 @@ public final class TeamspeakConnection implements TS3Listener { if (renamed) { sound(safeInt(e, "invokerid") == selfClientId ? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars); + log(oldName + " is now known as " + c.nickname); } if (safeInt(e, "client_talk_request") > 0 && !self) { sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars); @@ -1012,6 +1095,7 @@ public final class TeamspeakConnection implements TS3Listener { if (connected) { sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER, SoundEvent.CHANNEL_CREATED_BY_OTHER), channelVars(cid, e.get("invokername"))); + log("Channel \"" + name + "\" was created by " + invokerName(e)); } ui.onModelChanged(); } @@ -1022,6 +1106,7 @@ public final class TeamspeakConnection implements TS3Listener { if (connected) { sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER, SoundEvent.CHANNEL_DELETED_BY_SERVER), channelVars(cid, e.get("invokername"))); + log("Channel \"" + channelName(cid) + "\" was deleted by " + invokerName(e)); } model.removeChannel(cid); ui.onModelChanged(); @@ -1042,6 +1127,7 @@ public final class TeamspeakConnection implements TS3Listener { : byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU, SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER), channelVars(ch.id, e.get("invokername"))); + log("Channel \"" + ch.name + "\" was edited by " + invokerName(e)); } ui.onModelChanged(); } @@ -1057,6 +1143,7 @@ public final class TeamspeakConnection implements TS3Listener { if (connected) { sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER, SoundEvent.CHANNEL_MOVED_BY_OTHER), channelVars(ch.id, e.get("invokername"))); + log("Channel \"" + ch.name + "\" was moved by " + invokerName(e)); } ui.onModelChanged(); } @@ -1098,17 +1185,22 @@ public final class TeamspeakConnection implements TS3Listener { : byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER), groupVars(e.getClientId(), e.getName())); + log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName() + + "\" by " + invokerName(e) + "."); } @Override public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) { - boolean self = safeInt(e, "clid") == selfClientId; + int clientId = safeInt(e, "clid"); + boolean self = clientId == selfClientId; sound(self ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER) : byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER), - groupVars(safeInt(e, "clid"), e.get("name"))); + groupVars(clientId, e.get("name"))); + log(clientLogName(clientId) + " was removed from server group \"" + orEmpty(e.get("name")) + + "\" by " + invokerName(e) + "."); } @Override @@ -1116,15 +1208,24 @@ public final class TeamspeakConnection implements TS3Listener { ClientEntry c = model.getClient(e.getClientId()); if (c != null) c.channelGroupId = e.getChannelGroupId(); boolean self = e.getClientId() == selfClientId; + String groupName = model.channelGroupName(e.getChannelGroupId()); sound(self ? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER) : byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER), - groupVars(e.getClientId(), model.channelGroupName(e.getChannelGroupId()))); + groupVars(e.getClientId(), groupName)); + log("Channel group \"" + orEmpty(groupName) + "\" was assigned to " + clientLogName(e.getClientId()) + + " by " + invokerName(e) + "."); ui.onModelChanged(); } + /** A client's nickname for a log line, falling back to its id once it has left. */ + private String clientLogName(int clientId) { + ClientEntry c = model.getClient(clientId); + return c != null ? c.nickname : "Client " + clientId; + } + /** Picks the event variant matching who caused the change: us, another client, or the server. */ private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) { int invoker = safeInt(e, "invokerid"); @@ -1583,6 +1684,21 @@ public final class TeamspeakConnection implements TS3Listener { sounds.fire(event, deafened, variables); } + /** Records a line in the server tab's log, the way native TS3 reports server activity. */ + private void log(String message) { + if (connected) ui.onServerLog(message); + } + + private String channelName(int channelId) { + ChannelNode ch = model.getChannel(channelId); + return ch != null ? ch.name : "channel #" + channelId; + } + + private static String invokerName(BaseEvent e) { + String name = orEmpty(e.get("invokername")); + return name.isEmpty() ? "the server" : name; + } + /** The placeholder values a pack may reference for an action involving a client. */ private Map clientVars(int clientId, String fallbackName) { ClientEntry c = model.getClient(clientId); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java index ed678cb..8cf5006 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java @@ -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 privateTabs = new LinkedHashMap<>(); + /** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */ + private final Map 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) + "")); } + /** Server-side events (client joins/leaves/moves, group changes, channel edits, …). */ + public void appendServerLog(String text) { + edt(() -> serverTab.appendLine("" + stamp() + + BBCode.escape(text) + "")); + } + 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 + "
"); + log.setCaretPosition(0); + } + void appendLine(String html) { try { doc.insertBeforeEnd(doc.getElement("chatlog"), "
" + html + "
"); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java index 6edb47e..9bf7a4f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java @@ -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); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java index 6a700bd..f7df09e 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -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)); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index 6531c51..f9b1051 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -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("Select a channel or client to see details."); + 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 "Select a channel or client to see details."; + } + + 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("Loading description…"); } - 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 ? " (you)" : ""))); @@ -105,7 +174,13 @@ public final class InfoPanel extends JScrollPane { if (cl.description != null && !cl.description.isEmpty()) { sb.append("
").append(multiline(cl.description)).append("
"); } - 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 ""; } private void setHtml(String body) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index e5d13eb..817ba09 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index e32ddf8..cc1aaf5 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -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(() -> { 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 4d7c56c..7094b58 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 @@ -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 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 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) {