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:
@@ -33,4 +33,11 @@ public interface ConnectionListener {
|
|||||||
|
|
||||||
/** Someone poked the local client. */
|
/** Someone poked the local client. */
|
||||||
void onPoke(String fromName, String message);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -791,14 +791,23 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||||
c.channelGroupId = e.getClientChannelGroupId();
|
c.channelGroupId = e.getClientChannelGroupId();
|
||||||
c.self = (e.getClientId() == selfClientId);
|
c.self = (e.getClientId() == selfClientId);
|
||||||
if (e.getClientId() != selfClientId) announceClientEntered(e);
|
if (e.getClientId() != selfClientId) {
|
||||||
|
announceClientEntered(e);
|
||||||
|
logClientEntered(e);
|
||||||
|
}
|
||||||
ui.onModelChanged();
|
ui.onModelChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onClientLeave(ClientLeaveEvent e) {
|
public void onClientLeave(ClientLeaveEvent e) {
|
||||||
if (e.getClientId() == selfClientId) announceOwnRemoval(safeInt(e, "reasonid"), e);
|
if (e.getClientId() == selfClientId) {
|
||||||
else announceClientLeft(e);
|
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());
|
model.removeClient(e.getClientId());
|
||||||
if (playback != null) playback.removeClient(e.getClientId());
|
if (playback != null) playback.removeClient(e.getClientId());
|
||||||
ui.onModelChanged();
|
ui.onModelChanged();
|
||||||
@@ -810,12 +819,84 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
if (c != null) {
|
if (c != null) {
|
||||||
int from = c.channelId;
|
int from = c.channelId;
|
||||||
c.channelId = e.getTargetChannelId();
|
c.channelId = e.getTargetChannelId();
|
||||||
if (e.getClientId() == selfClientId) announceOwnMove(safeInt(e, "reasonid"), e);
|
if (e.getClientId() == selfClientId) {
|
||||||
else announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
|
announceOwnMove(safeInt(e, "reasonid"), e);
|
||||||
|
} else {
|
||||||
|
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
|
||||||
|
logClientMoved(e, c.nickname, from, e.getTargetChannelId());
|
||||||
|
}
|
||||||
ui.onModelChanged();
|
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 ----
|
// ---- who went where: TeamSpeak's reason ids ----
|
||||||
|
|
||||||
/** {@code reasonid} of a client view/move notification. */
|
/** {@code reasonid} of a client view/move notification. */
|
||||||
@@ -950,6 +1031,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
ClientEntry c = model.getClient(e.getClientId());
|
ClientEntry c = model.getClient(e.getClientId());
|
||||||
if (c == null) return;
|
if (c == null) return;
|
||||||
boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname);
|
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 (renamed) c.nickname = e.get("client_nickname");
|
||||||
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
|
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");
|
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_talk_power")) c.talkPower = e.getInt("client_talk_power");
|
||||||
if (has(e, "client_is_channel_commander"))
|
if (has(e, "client_is_channel_commander"))
|
||||||
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
||||||
announceClientUpdate(e, c, renamed);
|
announceClientUpdate(e, c, renamed, oldName);
|
||||||
ui.onModelChanged();
|
ui.onModelChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renames, talk-power changes and recording flags all arrive as a client update. */
|
/** 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;
|
if (!connected) return;
|
||||||
boolean self = c.id == selfClientId;
|
boolean self = c.id == selfClientId;
|
||||||
Map<String, String> vars = clientVars(c.id, null);
|
Map<String, String> vars = clientVars(c.id, null);
|
||||||
@@ -979,6 +1061,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
if (renamed) {
|
if (renamed) {
|
||||||
sound(safeInt(e, "invokerid") == selfClientId
|
sound(safeInt(e, "invokerid") == selfClientId
|
||||||
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
|
? 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) {
|
if (safeInt(e, "client_talk_request") > 0 && !self) {
|
||||||
sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
|
sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
|
||||||
@@ -1012,6 +1095,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
if (connected) {
|
if (connected) {
|
||||||
sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
|
sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
|
||||||
SoundEvent.CHANNEL_CREATED_BY_OTHER), channelVars(cid, e.get("invokername")));
|
SoundEvent.CHANNEL_CREATED_BY_OTHER), channelVars(cid, e.get("invokername")));
|
||||||
|
log("Channel \"" + name + "\" was created by " + invokerName(e));
|
||||||
}
|
}
|
||||||
ui.onModelChanged();
|
ui.onModelChanged();
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1106,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
if (connected) {
|
if (connected) {
|
||||||
sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
|
sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
|
||||||
SoundEvent.CHANNEL_DELETED_BY_SERVER), channelVars(cid, e.get("invokername")));
|
SoundEvent.CHANNEL_DELETED_BY_SERVER), channelVars(cid, e.get("invokername")));
|
||||||
|
log("Channel \"" + channelName(cid) + "\" was deleted by " + invokerName(e));
|
||||||
}
|
}
|
||||||
model.removeChannel(cid);
|
model.removeChannel(cid);
|
||||||
ui.onModelChanged();
|
ui.onModelChanged();
|
||||||
@@ -1042,6 +1127,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
|
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
|
||||||
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
|
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
|
||||||
channelVars(ch.id, e.get("invokername")));
|
channelVars(ch.id, e.get("invokername")));
|
||||||
|
log("Channel \"" + ch.name + "\" was edited by " + invokerName(e));
|
||||||
}
|
}
|
||||||
ui.onModelChanged();
|
ui.onModelChanged();
|
||||||
}
|
}
|
||||||
@@ -1057,6 +1143,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
if (connected) {
|
if (connected) {
|
||||||
sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
|
sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
|
||||||
SoundEvent.CHANNEL_MOVED_BY_OTHER), channelVars(ch.id, e.get("invokername")));
|
SoundEvent.CHANNEL_MOVED_BY_OTHER), channelVars(ch.id, e.get("invokername")));
|
||||||
|
log("Channel \"" + ch.name + "\" was moved by " + invokerName(e));
|
||||||
}
|
}
|
||||||
ui.onModelChanged();
|
ui.onModelChanged();
|
||||||
}
|
}
|
||||||
@@ -1098,17 +1185,22 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER,
|
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER,
|
||||||
SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER),
|
SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER),
|
||||||
groupVars(e.getClientId(), e.getName()));
|
groupVars(e.getClientId(), e.getName()));
|
||||||
|
log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
|
||||||
|
+ "\" by " + invokerName(e) + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
|
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
|
||||||
boolean self = safeInt(e, "clid") == selfClientId;
|
int clientId = safeInt(e, "clid");
|
||||||
|
boolean self = clientId == selfClientId;
|
||||||
sound(self
|
sound(self
|
||||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
|
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
|
||||||
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
|
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
|
||||||
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER,
|
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER,
|
||||||
SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER),
|
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
|
@Override
|
||||||
@@ -1116,15 +1208,24 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
ClientEntry c = model.getClient(e.getClientId());
|
ClientEntry c = model.getClient(e.getClientId());
|
||||||
if (c != null) c.channelGroupId = e.getChannelGroupId();
|
if (c != null) c.channelGroupId = e.getChannelGroupId();
|
||||||
boolean self = e.getClientId() == selfClientId;
|
boolean self = e.getClientId() == selfClientId;
|
||||||
|
String groupName = model.channelGroupName(e.getChannelGroupId());
|
||||||
sound(self
|
sound(self
|
||||||
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
|
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
|
||||||
SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER)
|
SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER)
|
||||||
: byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER,
|
: byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER,
|
||||||
SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER),
|
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();
|
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. */
|
/** 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) {
|
private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) {
|
||||||
int invoker = safeInt(e, "invokerid");
|
int invoker = safeInt(e, "invokerid");
|
||||||
@@ -1583,6 +1684,21 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
sounds.fire(event, deafened, variables);
|
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. */
|
/** The placeholder values a pack may reference for an action involving a client. */
|
||||||
private Map<String, String> clientVars(int clientId, String fallbackName) {
|
private Map<String, String> clientVars(int clientId, String fallbackName) {
|
||||||
ClientEntry c = model.getClient(clientId);
|
ClientEntry c = model.getClient(clientId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.ts3client.text.BBCode;
|
|||||||
import com.ts3client.text.TsLink;
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Icon;
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JEditorPane;
|
import javax.swing.JEditorPane;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
@@ -25,6 +26,7 @@ import java.awt.FlowLayout;
|
|||||||
import java.awt.Point;
|
import java.awt.Point;
|
||||||
import java.awt.event.MouseAdapter;
|
import java.awt.event.MouseAdapter;
|
||||||
import java.awt.event.MouseEvent;
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.awt.event.MouseWheelEvent;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Date;
|
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 final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
|
||||||
/** Private chats keyed by the peer's client id. */
|
/** Private chats keyed by the peer's client id. */
|
||||||
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
|
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 SendHandler sendHandler;
|
||||||
private LinkHandler linkHandler;
|
private LinkHandler linkHandler;
|
||||||
@@ -70,6 +74,7 @@ public final class ChatPanel extends JPanel {
|
|||||||
super(new BorderLayout());
|
super(new BorderLayout());
|
||||||
|
|
||||||
tabs.setFont(Theme.UI_FONT);
|
tabs.setFont(Theme.UI_FONT);
|
||||||
|
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
|
||||||
addTab(serverTab, false);
|
addTab(serverTab, false);
|
||||||
addTab(channelTab, false);
|
addTab(channelTab, false);
|
||||||
tabs.setSelectedIndex(0);
|
tabs.setSelectedIndex(0);
|
||||||
@@ -77,6 +82,7 @@ public final class ChatPanel extends JPanel {
|
|||||||
Tab t = selectedTab();
|
Tab t = selectedTab();
|
||||||
if (t != null) t.setUnread(false);
|
if (t != null) t.setUnread(false);
|
||||||
});
|
});
|
||||||
|
tabs.addMouseWheelListener(this::onWheel);
|
||||||
add(tabs, BorderLayout.CENTER);
|
add(tabs, BorderLayout.CENTER);
|
||||||
|
|
||||||
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
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() {
|
private void fireSend() {
|
||||||
String text = input.getText().trim();
|
String text = input.getText().trim();
|
||||||
if (text.isEmpty() || sendHandler == null) return;
|
if (text.isEmpty() || sendHandler == null) return;
|
||||||
@@ -147,6 +187,12 @@ public final class ChatPanel extends JPanel {
|
|||||||
+ BBCode.escape(text) + "</span>"));
|
+ 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) {
|
public void appendServerMessage(int fromId, String from, String text) {
|
||||||
edt(() -> serverTab.appendMessage(fromId, from, text));
|
edt(() -> serverTab.appendMessage(fromId, from, text));
|
||||||
}
|
}
|
||||||
@@ -180,10 +226,25 @@ public final class ChatPanel extends JPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void closeTab(Tab tab) {
|
private void closeTab(Tab tab) {
|
||||||
|
if (tab.noteKey != null) {
|
||||||
|
noteTabs.remove(tab.noteKey);
|
||||||
|
if (tab.onClose != null) tab.onClose.run();
|
||||||
|
} else {
|
||||||
privateTabs.remove(tab.clientId);
|
privateTabs.remove(tab.clientId);
|
||||||
|
}
|
||||||
tabs.remove(tab.scroll);
|
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() {
|
private Tab selectedTab() {
|
||||||
Component c = tabs.getSelectedComponent();
|
Component c = tabs.getSelectedComponent();
|
||||||
if (c == serverTab.scroll) return serverTab;
|
if (c == serverTab.scroll) return serverTab;
|
||||||
@@ -207,13 +268,29 @@ public final class ChatPanel extends JPanel {
|
|||||||
final String title;
|
final String title;
|
||||||
final JEditorPane log = new JEditorPane();
|
final JEditorPane log = new JEditorPane();
|
||||||
final JScrollPane scroll;
|
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 final HTMLDocument doc;
|
||||||
private JLabel titleLabel;
|
private JLabel titleLabel;
|
||||||
|
|
||||||
Tab(Target target, int clientId, String title) {
|
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.target = target;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.title = title;
|
this.title = title;
|
||||||
|
this.icon = icon;
|
||||||
|
this.noteKey = noteKey;
|
||||||
|
this.onClose = onClose;
|
||||||
|
|
||||||
HTMLEditorKit kit = new HTMLEditorKit();
|
HTMLEditorKit kit = new HTMLEditorKit();
|
||||||
StyleSheet css = new StyleSheet();
|
StyleSheet css = new StyleSheet();
|
||||||
@@ -259,7 +336,7 @@ public final class ChatPanel extends JPanel {
|
|||||||
Component header(boolean closable) {
|
Component header(boolean closable) {
|
||||||
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
|
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
|
||||||
p.setOpaque(false);
|
p.setOpaque(false);
|
||||||
titleLabel = new JLabel(title);
|
titleLabel = new JLabel(title, icon, JLabel.LEADING);
|
||||||
titleLabel.setFont(Theme.UI_FONT);
|
titleLabel.setFont(Theme.UI_FONT);
|
||||||
p.add(titleLabel);
|
p.add(titleLabel);
|
||||||
if (closable) {
|
if (closable) {
|
||||||
@@ -303,6 +380,12 @@ public final class ChatPanel extends JPanel {
|
|||||||
+ sender + ": " + BBCode.toHtml(text));
|
+ 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) {
|
void appendLine(String html) {
|
||||||
try {
|
try {
|
||||||
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
|
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ final class ClientMenu {
|
|||||||
menu.add(me);
|
menu.add(me);
|
||||||
}
|
}
|
||||||
menu.addSeparator();
|
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"));
|
JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO"));
|
||||||
info.addActionListener(a -> actions.showConnectionInfo(client));
|
info.addActionListener(a -> actions.showConnectionInfo(client));
|
||||||
menu.add(info);
|
menu.add(info);
|
||||||
|
|||||||
@@ -243,6 +243,31 @@ public final class Icons {
|
|||||||
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
|
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. */
|
/** The toolbar's away marker. */
|
||||||
public static ImageIcon away() {
|
public static ImageIcon away() {
|
||||||
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.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);
|
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) {
|
private static void paintSettings(Graphics2D g) {
|
||||||
g.setColor(new Color(0x37474F));
|
g.setColor(new Color(0x37474F));
|
||||||
g.setStroke(new BasicStroke(2f));
|
g.setStroke(new BasicStroke(2f));
|
||||||
|
|||||||
@@ -20,7 +20,27 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public final class InfoPanel extends JScrollPane {
|
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 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() {
|
public InfoPanel() {
|
||||||
javax.swing.text.html.HTMLEditorKit kit = new javax.swing.text.html.HTMLEditorKit();
|
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.setBackground(Theme.CHAT_BG);
|
||||||
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||||
pane.addHyperlinkListener(e -> {
|
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);
|
Links.open(e.getDescription(), this);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -49,10 +74,54 @@ public final class InfoPanel extends JScrollPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void clear() {
|
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) {
|
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();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(heading(esc(ch.name)));
|
sb.append(heading(esc(ch.name)));
|
||||||
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
|
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
|
||||||
@@ -67,10 +136,10 @@ public final class InfoPanel extends JScrollPane {
|
|||||||
} else {
|
} else {
|
||||||
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
|
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();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
|
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()) {
|
if (cl.description != null && !cl.description.isEmpty()) {
|
||||||
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
|
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) {
|
private void setHtml(String body) {
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
selectTab(first);
|
selectTab(first);
|
||||||
first.chat().appendSystem("Welcome to the TS3J Swing client.");
|
first.chat().appendSystem("Welcome to the TS3J Swing client.");
|
||||||
first.chat().appendSystem("Use Connections → Connect to join a server.");
|
first.chat().appendSystem("Use Connections → Connect to join a server.");
|
||||||
first.chat().appendSystem(tray.status());
|
|
||||||
|
|
||||||
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
||||||
statusTimer.start();
|
statusTimer.start();
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
private final ChatPanel chatPanel;
|
private final ChatPanel chatPanel;
|
||||||
private final InfoPanel infoPanel = new InfoPanel();
|
private final InfoPanel infoPanel = new InfoPanel();
|
||||||
private final JComponent component;
|
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. */
|
/** Label shown in the tab bar: the server name once known, the address before that. */
|
||||||
private String title = "New connection";
|
private String title = "New connection";
|
||||||
@@ -82,9 +85,27 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
});
|
});
|
||||||
chatPanel.setInputEnabled(false);
|
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.setResizeWeight(0.68);
|
||||||
leftColumn.setContinuousLayout(true);
|
leftColumn.setContinuousLayout(true);
|
||||||
|
normalDividerSize = leftColumn.getDividerSize();
|
||||||
|
|
||||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
|
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
|
||||||
split.setResizeWeight(0.55);
|
split.setResizeWeight(0.55);
|
||||||
@@ -234,21 +255,18 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
void setMicMuted(boolean muted) {
|
void setMicMuted(boolean muted) {
|
||||||
micMuted = muted;
|
micMuted = muted;
|
||||||
conn.setMicMuted(muted);
|
conn.setMicMuted(muted);
|
||||||
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
|
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
|
||||||
void setMicLocalMuted(boolean muted) {
|
void setMicLocalMuted(boolean muted) {
|
||||||
micLocalMuted = muted;
|
micLocalMuted = muted;
|
||||||
conn.setMicLocalMuted(muted);
|
conn.setMicLocalMuted(muted);
|
||||||
chatPanel.appendSystem(muted ? "Microphone locally muted." : "Microphone locally unmuted.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDeafened(boolean deaf) {
|
void setDeafened(boolean deaf) {
|
||||||
deafened = deaf;
|
deafened = deaf;
|
||||||
conn.setDeafened(deaf);
|
conn.setDeafened(deaf);
|
||||||
if (deaf) micMuted = true;
|
if (deaf) micMuted = true;
|
||||||
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hands the capture device to (or takes it from) this connection. */
|
/** 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;
|
this.away = away;
|
||||||
if (message != null) this.awayMessage = message;
|
if (message != null) this.awayMessage = message;
|
||||||
conn.setAway(away, awayMessage);
|
conn.setAway(away, awayMessage);
|
||||||
chatPanel.appendSystem(!away ? "No longer away."
|
|
||||||
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setCommander(boolean commander) {
|
void setCommander(boolean commander) {
|
||||||
@@ -425,7 +441,12 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
if (conn.getPlayback() == null) return;
|
if (conn.getPlayback() == null) return;
|
||||||
boolean now = !conn.getPlayback().isClientMuted(client.id);
|
boolean now = !conn.getPlayback().isClientMuted(client.id);
|
||||||
conn.getPlayback().setClientMuted(client.id, now);
|
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
|
@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) ----
|
// ---- ConnectionListener (marshal to EDT) ----
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -518,6 +555,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
infoPanel.clear();
|
infoPanel.clear();
|
||||||
chatPanel.setInputEnabled(false);
|
chatPanel.setInputEnabled(false);
|
||||||
chatPanel.closePrivateChats();
|
chatPanel.closePrivateChats();
|
||||||
|
chatPanel.closeNoteTabs();
|
||||||
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
||||||
host.tabDisconnected(this);
|
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
|
@Override
|
||||||
public void onPoke(String fromName, String message) {
|
public void onPoke(String fromName, String message) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
|
|
||||||
boolean isClientLocallyMuted(int clientId);
|
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). */
|
/** A channel or client node was selected (or {@code null} when cleared). */
|
||||||
void onSelectionChanged(Object userObject);
|
void onSelectionChanged(Object userObject);
|
||||||
|
|
||||||
@@ -95,6 +98,8 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
private final DropIndicatorTree tree;
|
private final DropIndicatorTree tree;
|
||||||
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
|
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 ServerModel model;
|
||||||
private final GroupIcons groupIcons;
|
private final GroupIcons groupIcons;
|
||||||
private final Actions actions;
|
private final Actions actions;
|
||||||
@@ -133,6 +138,7 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
tree.setTransferHandler(new TreeTransferHandler());
|
tree.setTransferHandler(new TreeTransferHandler());
|
||||||
|
|
||||||
tree.addTreeSelectionListener(e -> {
|
tree.addTreeSelectionListener(e -> {
|
||||||
|
if (rebuilding) return;
|
||||||
TreePath path = tree.getSelectionPath();
|
TreePath path = tree.getSelectionPath();
|
||||||
Object obj = null;
|
Object obj = null;
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
@@ -172,6 +178,16 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
this.selfClientId = id;
|
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) {
|
private Object nodeAt(MouseEvent e) {
|
||||||
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
||||||
if (path == null) return null;
|
if (path == null) return null;
|
||||||
@@ -542,8 +558,17 @@ 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() {
|
public void rebuild() {
|
||||||
|
Object selected = selectedUserObject();
|
||||||
|
rebuilding = true;
|
||||||
|
try {
|
||||||
root.setUserObject(model.getServerName());
|
root.setUserObject(model.getServerName());
|
||||||
root.removeAllChildren();
|
root.removeAllChildren();
|
||||||
List<ChannelNode> roots = model.buildTree();
|
List<ChannelNode> roots = model.buildTree();
|
||||||
@@ -554,6 +579,22 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
for (int i = 0; i < tree.getRowCount(); i++) {
|
for (int i = 0; i < tree.getRowCount(); i++) {
|
||||||
tree.expandRow(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) {
|
private DefaultMutableTreeNode buildChannel(ChannelNode c) {
|
||||||
|
|||||||
Reference in New Issue
Block a user