Files
ts3j/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java
ericek111 98719d717e Darken the dark theme, and hover a tree row like a menu
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>
2026-08-19 22:07:19 +00:00

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);
}
}