The client's own look-and-feel defaults now live beside LookAndFeelManager as FlatLaf properties files, which FlatLaf loads on top of each theme's own, instead of a handful of UIManager.put calls. The dark theme is built on three surfaces keyed to the toolbar's #282828: the window chrome, the tab strips and menu bar one step below it at #232323, and the content the client shows — channel tree, chat log, text fields, lists — at #1e1e1e. Selections are #364b6f, and channel names in the tree are white; the chat lines about server events keep the blue they had, which channel names no longer carry. Everything FlatLaf derives (borders, scroll bars, hover states) follows from those. A read-only editor pane is a chat log or an information view here, not a disabled input, so it keeps the content background rather than FlatLaf's inactive one; and since neither a text view nor a tree fills its viewport, the space below the last line takes their background instead of the window's. The row under the pointer is now washed in the same colour a hovered menu is, rather than outlined. FlatLaf states that colour as a shift of the surface beneath it, so it is resolved against the window background exactly as the menu bar resolves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
8.1 KiB
Java
214 lines
8.1 KiB
Java
package com.ts3client.ui;
|
|
|
|
import com.ts3client.net.ChannelNode;
|
|
import com.ts3client.net.ClientEntry;
|
|
import com.ts3client.net.Group;
|
|
import com.ts3client.net.IconRepository;
|
|
import com.ts3client.net.ServerModel;
|
|
import com.ts3client.text.BBCode;
|
|
|
|
import javax.swing.BorderFactory;
|
|
import javax.swing.JEditorPane;
|
|
import javax.swing.JScrollPane;
|
|
import java.io.File;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* Read-only detail view for the currently selected channel or client, mirroring
|
|
* the TeamSpeak 3 info box: channel topic/description, or a client's server and
|
|
* channel groups, platform and version.
|
|
*/
|
|
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 = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
|
|
private DescriptionHandler descriptionHandler;
|
|
private ChannelNode shownChannel;
|
|
private ClientEntry shownClient;
|
|
private ServerModel shownModel;
|
|
private IconRepository shownIcons;
|
|
private boolean inChatTab;
|
|
|
|
public InfoPanel() {
|
|
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
|
pane.addHyperlinkListener(e -> {
|
|
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);
|
|
}
|
|
});
|
|
setViewportView(pane);
|
|
HtmlStyles.fillViewport(pane);
|
|
clear();
|
|
}
|
|
|
|
@Override
|
|
public void updateUI() {
|
|
super.updateUI();
|
|
setBorder(BorderFactory.createLineBorder(Theme.border()));
|
|
}
|
|
|
|
public void clear() {
|
|
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 class='muted'>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");
|
|
if (ch.maxClients >= 0) row(sb, "Max clients", Integer.toString(ch.maxClients));
|
|
if (ch.hasPassword) row(sb, "Password", "protected");
|
|
if (!ch.topic.isEmpty()) row(sb, "Topic", esc(ch.topic));
|
|
sb.append("<hr>");
|
|
if (ch.description != null && !ch.description.isEmpty()) {
|
|
sb.append("<div>").append(multiline(ch.description)).append("</div>");
|
|
} else if (ch.descriptionLoaded) {
|
|
sb.append("<i class='muted'>No description.</i>");
|
|
} else {
|
|
sb.append("<i class='muted'>Loading description…</i>");
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons) {
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
|
|
|
|
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
|
|
if (serverGroups.isEmpty()) {
|
|
List<String> names = model.serverGroupNames(cl.serverGroupIds);
|
|
row(sb, "Server groups", names.isEmpty() ? "—" : esc(String.join(", ", names)));
|
|
} else {
|
|
StringBuilder groups = new StringBuilder();
|
|
for (Group g : serverGroups) {
|
|
if (groups.length() > 0) groups.append(", ");
|
|
groups.append(iconTag(g, icons)).append(esc(g.name));
|
|
}
|
|
row(sb, "Server groups", groups.toString());
|
|
}
|
|
|
|
Group channelGroup = model.channelGroup(cl.channelGroupId);
|
|
row(sb, "Channel group", channelGroup != null
|
|
? iconTag(channelGroup, icons) + esc(channelGroup.name)
|
|
: "#" + cl.channelGroupId);
|
|
|
|
if (cl.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower));
|
|
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
|
|
if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version));
|
|
if (cl.away) row(sb, "Status", "Away");
|
|
if (!cl.inputHardware) row(sb, "Microphone", "disabled");
|
|
else if (cl.inputMuted) row(sb, "Microphone", "muted");
|
|
if (!cl.outputHardware) row(sb, "Speakers", "disabled");
|
|
else if (cl.outputMuted) row(sb, "Speakers", "muted");
|
|
if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId));
|
|
|
|
if (cl.description != null && !cl.description.isEmpty()) {
|
|
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
|
|
}
|
|
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) {
|
|
pane.setText("<html><body>" + body + "</body></html>");
|
|
pane.setCaretPosition(0);
|
|
}
|
|
|
|
/**
|
|
* An inline {@code <img>} for a group's icon, or nothing while the icon is missing
|
|
* or still downloading. Swing's HTML renderer only loads images by URL, so the icon
|
|
* is served from the repository's on-disk cache.
|
|
*/
|
|
private static String iconTag(Group group, IconRepository icons) {
|
|
if (group.iconId == 0) return "";
|
|
File file = icons.file(group.iconId);
|
|
if (file == null) return "";
|
|
return "<img src='" + file.toURI() + "' width='14' height='14'> ";
|
|
}
|
|
|
|
private static String heading(String text) {
|
|
return "<div style='font-weight:bold;font-size:13px;margin-bottom:4px'>" + text + "</div>";
|
|
}
|
|
|
|
private static void row(StringBuilder sb, String label, String value) {
|
|
sb.append("<div style='margin:1px 0'><span class='muted'>")
|
|
.append(label).append(":</span> ").append(value).append("</div>");
|
|
}
|
|
|
|
/** Descriptions may carry BBCode markup. */
|
|
private static String multiline(String text) {
|
|
return BBCode.toHtml(text);
|
|
}
|
|
|
|
private static String esc(String s) {
|
|
return BBCode.escape(s);
|
|
}
|
|
|
|
}
|