Files
ts3java/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java
ericek111 de02f88f1c Add browser-style drag-to-reorder for chat and server tabs
Dragging a tab shows animated snapshots on the glass pane and swaps
with whichever neighbour it reaches the midpoint of; the real tab
model is only touched once, on release, so an in-flight mouse grab
never gets pulled out from under a rebuilt tab component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 12:49:26 +00:00

423 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.ts3client.ui;
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;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.event.HyperlinkEvent;
import javax.swing.text.BadLocationException;
import javax.swing.text.html.HTMLDocument;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
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;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Chat area with one tab per conversation (server, current channel and one per
* private chat). The tab strip sits between the log and the input line. Logs are
* rendered as HTML: sender names are links that open the client context menu,
* and message bodies go through the {@link BBCode} renderer.
*/
public final class ChatPanel extends JPanel {
/** Where an outgoing message should go. */
public enum Target {CHANNEL, SERVER, PRIVATE}
public interface SendHandler {
/** {@code clientId} is only meaningful for {@link Target#PRIVATE}. */
void send(Target target, int clientId, String text);
}
/** Invoked when a client or channel link in the log is clicked. */
public interface LinkHandler {
void onClientLink(TsLink.Ref ref, Component source, int x, int y);
void onChannelLink(TsLink.Ref ref, Component source, int x, int y);
}
private final JTabbedPane tabs = new JTabbedPane(JTabbedPane.BOTTOM);
private final JTextField input = new JTextField();
private final SimpleDateFormat time = new SimpleDateFormat("HH:mm:ss");
private final Tab serverTab = new Tab(Target.SERVER, 0, "Server");
private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
/** Private chats keyed by the peer's client id. */
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
/** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
private final TabDragReorder dragReorder = new TabDragReorder(tabs, this::moveTab);
private SendHandler sendHandler;
private LinkHandler linkHandler;
public ChatPanel() {
super(new BorderLayout());
tabs.setFont(Theme.UI_FONT);
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
addTab(serverTab, false);
addTab(channelTab, false);
tabs.setSelectedIndex(0);
tabs.addChangeListener(e -> {
Tab t = selectedTab();
if (t != null) t.setUnread(false);
});
tabs.addMouseWheelListener(this::onWheel);
dragReorder.attach(tabs);
add(tabs, BorderLayout.CENTER);
JPanel bottom = new JPanel(new BorderLayout(4, 0));
bottom.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0));
bottom.add(input, BorderLayout.CENTER);
JButton send = new JButton("Send");
bottom.add(send, BorderLayout.EAST);
add(bottom, BorderLayout.SOUTH);
send.addActionListener(e -> fireSend());
input.addActionListener(e -> fireSend());
setInputEnabled(false);
}
public void setSendHandler(SendHandler h) {
this.sendHandler = h;
}
public void setLinkHandler(LinkHandler h) {
this.linkHandler = h;
}
/** Appends text to the input line, e.g. a client link dropped onto the chat. */
public void appendToInput(String text) {
edt(() -> {
String cur = input.getText();
input.setText(cur.isEmpty() || cur.endsWith(" ") ? cur + text : cur + " " + text);
input.requestFocusInWindow();
});
}
public void setInputEnabled(boolean enabled) {
input.setEnabled(enabled);
}
/** Opens (or focuses) the private chat with {@code clientId}. */
public void openPrivateChat(int clientId, String nickname) {
edt(() -> {
Tab tab = privateTab(clientId, nickname);
tabs.setSelectedComponent(tab.scroll);
input.requestFocusInWindow();
});
}
/** Drops every private chat tab, e.g. after disconnecting. */
public void closePrivateChats() {
edt(() -> {
for (Tab t : privateTabs.values()) tabs.remove(t.scroll);
privateTabs.clear();
});
}
/** 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;
Tab tab = selectedTab();
if (tab == null) return;
sendHandler.send(tab.target, tab.clientId, text);
input.setText("");
}
// ---- append helpers (safe from any thread) ----
/** System notices always land in the server tab. */
public void appendSystem(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp()
+ BBCode.escape(text) + "</span>"));
}
/** Server-side events (client joins/leaves/moves, group changes, channel edits, …). */
public void appendServerLog(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:" + hex(Theme.CHANNEL_TEXT) + "\">" + stamp()
+ BBCode.escape(text) + "</span>"));
}
public void appendServerMessage(int fromId, String from, String text) {
edt(() -> serverTab.appendMessage(fromId, from, text));
}
public void appendChannelMessage(int fromId, String from, String text) {
edt(() -> channelTab.appendMessage(fromId, from, text));
}
/** Appends to the private chat with {@code peerId}, creating the tab if needed. */
public void appendPrivateMessage(int peerId, String peerName, int fromId, String from, String text) {
edt(() -> privateTab(peerId, peerName).appendMessage(fromId, from, text));
}
private String stamp() {
return "[" + time.format(new Date()) + "] ";
}
private Tab privateTab(int clientId, String nickname) {
Tab tab = privateTabs.get(clientId);
if (tab == null) {
tab = new Tab(Target.PRIVATE, clientId, nickname);
privateTabs.put(clientId, tab);
addTab(tab, true);
}
return tab;
}
private void addTab(Tab tab, boolean closable) {
tabs.addTab(tab.title, tab.scroll);
tabs.setTabComponentAt(tabs.indexOfComponent(tab.scroll), tab.header(closable));
}
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);
}
tabs.remove(tab.scroll);
}
/** Drags a tab from one position to another, keeping the current selection on screen. */
private void moveTab(int from, int to) {
if (from < 0 || to < 0 || from >= tabs.getTabCount() || to >= tabs.getTabCount() || from == to) return;
Component content = tabs.getComponentAt(from);
String title = tabs.getTitleAt(from);
Icon icon = tabs.getIconAt(from);
String tip = tabs.getToolTipTextAt(from);
Component header = tabs.getTabComponentAt(from);
boolean wasSelected = tabs.getSelectedIndex() == from;
tabs.removeTabAt(from);
tabs.insertTab(title, icon, content, tip, to);
tabs.setTabComponentAt(to, header);
if (wasSelected) tabs.setSelectedIndex(to);
}
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;
if (c == channelTab.scroll) return channelTab;
for (Tab t : privateTabs.values()) {
if (t.scroll == c) return t;
}
return null;
}
private void edt(Runnable r) {
if (SwingUtilities.isEventDispatchThread()) r.run();
else SwingUtilities.invokeLater(r);
}
/** One conversation: its HTML log, its scroll pane and the tab-strip header. */
private final class Tab {
final Target target;
final int clientId;
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();
css.addStyleSheet(kit.getStyleSheet());
css.addRule("body { font-family:sans-serif; font-size:11px; color:#202020; margin:4px 6px; }");
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
// Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
// External links are underlined so they can't be mistaken for an identity.
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
kit.setStyleSheet(css);
log.setEditorKit(kit);
log.setEditable(false);
log.setBackground(Theme.CHAT_BG);
log.setText("<html><body><div id=\"chatlog\"></div></body></html>");
doc = (HTMLDocument) log.getDocument();
log.addHyperlinkListener(e -> {
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
onLink(e.getDescription());
});
scroll = new JScrollPane(log);
scroll.setBorder(BorderFactory.createEmptyBorder());
}
private void onLink(String href) {
if (href == null) return;
TsLink.Ref ref = TsLink.parse(href);
if (ref == null) {
Links.open(href, ChatPanel.this);
return;
}
if (linkHandler == null) return;
Point p = log.getMousePosition();
if (p == null) p = new Point(0, 0);
if (ref.client) linkHandler.onClientLink(ref, log, p.x, p.y);
else linkHandler.onChannelLink(ref, log, p.x, p.y);
}
Component header(boolean closable) {
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
p.setOpaque(false);
titleLabel = new JLabel(title, icon, JLabel.LEADING);
titleLabel.setFont(Theme.UI_FONT);
p.add(titleLabel);
dragReorder.attach(p);
dragReorder.attach(titleLabel);
if (closable) {
JLabel close = new JLabel("×");
close.setFont(Theme.UI_BOLD);
close.setForeground(Theme.CHAT_SYSTEM);
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
close.setToolTipText("Close chat");
close.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
closeTab(Tab.this);
}
});
p.add(close);
}
// Clicking the header itself must still switch tabs.
p.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
tabs.setSelectedComponent(scroll);
}
});
p.setPreferredSize(new Dimension(p.getPreferredSize().width, 20));
return p;
}
void setUnread(boolean unread) {
if (titleLabel == null) return;
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT);
titleLabel.setForeground(unread ? Theme.ACCENT : null);
}
void appendMessage(int fromId, String from, String text) {
String name = BBCode.escape(from);
String sender = fromId > 0
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
: "<b style=\"color:" + hex(Theme.CHAT_NAME) + "\">" + name + "</b>";
appendLine("<span style=\"color:#8A8A8A\">" + stamp() + "</span>"
+ sender + ": " + BBCode.toHtml(text));
}
/** Replaces the whole log with fixed content; used by static-content tabs. */
void setStaticContent(String html) {
log.setText("<html><body><div id=\"chatlog\">" + html + "</div></body></html>");
log.setCaretPosition(0);
}
void appendLine(String html) {
try {
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength());
} catch (BadLocationException | IOException ignored) {
}
if (tabs.getSelectedComponent() != scroll) setUnread(true);
}
}
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
}