Paint the client with FlatLaf, in a light and a dark theme

The look-and-feel is FlatLaf now, chosen light or dark on the new Design
tab and remembered in the settings. Theme keeps only the colours TeamSpeak
gives meaning to (talking, away, muted, channel and server names) and takes
its surfaces and fonts from the look-and-feel, so a switch recolours the
whole window, chat logs included: the HTML panes share one themed stylesheet
and re-parse their content when the theme changes.

Also fixes the channel tree not scrolling: the tree listened for the mouse
wheel to re-check its hover row, and AWT only forwards a wheel event to the
enclosing scroll pane when the component under the pointer has no wheel
listener of its own. The hover row now follows the viewport instead, which
also covers scrollbar drags and keyboard scrolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 21:06:55 +00:00
parent d952ffd856
commit 35f10ed14c
25 changed files with 484 additions and 191 deletions

View File

@@ -35,6 +35,10 @@
<groupId>com.github.weisj</groupId>
<artifactId>jsvg</artifactId>
</dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -84,6 +88,15 @@
<include>**</include>
</includes>
</filter>
<!-- FlatLaf instantiates its UI delegates by name and reads its
themes from .properties resources; neither is reachable
statically, so minimizeJar must not touch it. -->
<filter>
<artifact>com.formdev:flatlaf</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>

View File

@@ -2,14 +2,14 @@ package com.ts3client;
import com.ts3client.config.Settings;
import com.ts3client.ui.IconTheme;
import com.ts3client.ui.LookAndFeelManager;
import com.ts3client.ui.MainFrame;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
/**
* Application entry point. Loads settings, applies the system look-and-feel and
* shows the main window on the Swing event dispatch thread.
* Application entry point. Loads settings, installs the look-and-feel and shows the
* main window on the Swing event dispatch thread.
*/
public final class Main {
@@ -23,11 +23,7 @@ public final class Main {
final Settings settings = Settings.load();
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
// fall back to cross-platform L&F
}
LookAndFeelManager.install(settings.appearance);
IconTheme.get().reload(settings);
new MainFrame(settings).setVisible(true);
});

View File

@@ -108,7 +108,7 @@ final class ChannelPermissionsPanel extends JPanel {
void setStatus(String message, boolean error) {
status.setText(message == null || message.isEmpty() ? " " : message);
status.setForeground(error ? Color.RED.darker() : Theme.CHAT_SYSTEM);
status.setForeground(error ? Color.RED.darker() : Theme.chatSystem());
}
private void setEditable(boolean editable) {

View File

@@ -16,8 +16,6 @@ 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;
@@ -75,7 +73,7 @@ public final class ChatPanel extends JPanel {
public ChatPanel() {
super(new BorderLayout());
tabs.setFont(Theme.UI_FONT);
tabs.setFont(Theme.uiFont());
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
addTab(serverTab, false);
addTab(channelTab, false);
@@ -186,7 +184,7 @@ public final class ChatPanel extends JPanel {
/** System notices always land in the server tab. */
public void appendSystem(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp()
edt(() -> serverTab.appendLine("<span class=\"muted\">" + stamp()
+ BBCode.escape(text) + "</span>"));
}
@@ -196,7 +194,7 @@ public final class ChatPanel extends JPanel {
* clickable here — everything else in them is literal text.
*/
public void appendServerLog(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:" + hex(Theme.CHANNEL_TEXT) + "\">" + stamp()
edt(() -> serverTab.appendLine("<span class=\"event\">" + stamp()
+ BBCode.linksToHtml(text) + "</span>"));
}
@@ -288,13 +286,12 @@ public final class ChatPanel extends JPanel {
final Target target;
final int clientId;
final String title;
final JEditorPane log = new JEditorPane();
final JEditorPane log = HtmlStyles.pane("font-family:sans-serif; font-size:11px; margin:4px 6px;");
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) {
@@ -314,28 +311,7 @@ public final class ChatPanel extends JPanel {
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; }");
// Channel (and other TeamSpeak protocol) references stand out of a line the
// same way a client's name does.
css.addRule("a." + BBCode.TS_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());
@@ -363,14 +339,14 @@ public final class ChatPanel extends JPanel {
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);
titleLabel.setFont(Theme.uiFont());
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.setFont(Theme.uiBold());
close.setForeground(Theme.chatSystem());
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
close.setToolTipText("Close chat");
close.addMouseListener(new MouseAdapter() {
@@ -394,8 +370,8 @@ public final class ChatPanel extends JPanel {
void setUnread(boolean unread) {
if (titleLabel == null) return;
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT);
titleLabel.setForeground(unread ? Theme.ACCENT : null);
titleLabel.setFont(unread ? Theme.uiBold() : Theme.uiFont());
titleLabel.setForeground(unread ? Theme.accent() : null);
}
void appendMessage(int fromId, String from, String text) {
@@ -403,8 +379,8 @@ public final class ChatPanel extends JPanel {
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>"
: "<span class=\"name\">" + name + "</span>";
appendLine("<span class=\"muted\">" + stamp() + "</span>"
+ sender + ": " + BBCode.toHtml(text));
}
@@ -416,6 +392,7 @@ public final class ChatPanel extends JPanel {
void appendLine(String html) {
try {
HTMLDocument doc = (HTMLDocument) log.getDocument();
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength());
} catch (BadLocationException | IOException ignored) {
@@ -423,8 +400,4 @@ public final class ChatPanel extends JPanel {
if (tabs.getSelectedComponent() != scroll) setUnread(true);
}
}
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
}

View File

@@ -63,7 +63,7 @@ public final class ConnectionInfoDialog extends JDialog {
this.clientId = clientId;
JPanel content = new JPanel(new BorderLayout(0, 10));
content.setBackground(Theme.WINDOW_BG);
content.setBackground(Theme.windowBg());
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
content.add(buildSummary(), BorderLayout.NORTH);
content.add(buildTabs(), BorderLayout.CENTER);
@@ -92,7 +92,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildSummary() {
JPanel grid = new JPanel(new GridBagLayout());
grid.setBackground(Theme.WINDOW_BG);
grid.setBackground(Theme.windowBg());
int row = 0;
addRow(grid, row++, "Address", addressValue);
addRow(grid, row++, "Client version", versionValue);
@@ -106,8 +106,8 @@ public final class ConnectionInfoDialog extends JDialog {
private JTabbedPane buildTabs() {
JTabbedPane tabs = new JTabbedPane();
tabs.setFont(Theme.UI_FONT);
tabs.setBackground(Theme.WINDOW_BG);
tabs.setFont(Theme.uiFont());
tabs.setBackground(Theme.windowBg());
tabs.addTab("Total", totalTab);
tabs.addTab("Speech", speechTab);
tabs.addTab("Keep Alive", keepAliveTab);
@@ -118,7 +118,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildButtons() {
JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG);
bar.setBackground(Theme.windowBg());
bar.add(Box.createHorizontalGlue());
JButton close = new JButton("Close");
close.addActionListener(e -> dispose());
@@ -128,8 +128,8 @@ public final class ConnectionInfoDialog extends JDialog {
private static JLabel value() {
JLabel l = new JLabel("");
l.setFont(Theme.UI_FONT);
l.setForeground(Theme.TREE_TEXT);
l.setFont(Theme.uiFont());
l.setForeground(Theme.treeText());
return l;
}
@@ -140,7 +140,7 @@ public final class ConnectionInfoDialog extends JDialog {
lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 0, 2, 16);
JLabel key = new JLabel(label);
key.setFont(Theme.UI_FONT);
key.setFont(Theme.uiFont());
key.setForeground(new Color(0x5A6B7B));
grid.add(key, lc);
@@ -255,7 +255,7 @@ public final class ConnectionInfoDialog extends JDialog {
KindTab() {
super(new GridBagLayout());
setBackground(Theme.WINDOW_BG);
setBackground(Theme.windowBg());
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
int row = 0;
addRow(this, row++, "Packet loss", packetLoss);

View File

@@ -51,14 +51,14 @@ final class DescriptionEditorDialog extends JDialog {
area.setCaretPosition(0);
preview.setEditable(false);
preview.setBackground(Theme.CHAT_BG);
preview.setBackground(Theme.chatBg());
preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
body.add(new JScrollPane(area), EDIT_CARD);
body.add(new JScrollPane(preview), PREVIEW_CARD);
JLabel hint = new JLabel("Press Button to \"Preview\" the changes in channel info.");
hint.setForeground(Theme.CHAT_SYSTEM);
hint.setForeground(Theme.chatSystem());
hint.setBorder(BorderFactory.createEmptyBorder(4, 6, 0, 6));
getContentPane().setLayout(new BorderLayout());

View File

@@ -26,13 +26,14 @@ import java.awt.Insets;
import java.io.File;
/**
* Options page for icon packs: which pack the user interface is drawn with, where
* to look for more of them, and a viewer showing everything the pack contains.
* Options page for how the client looks: the light or dark look-and-feel, and the icon
* pack the user interface is drawn with where to look for more of them, and a viewer
* showing everything the active pack contains.
*
* <p>Picking a pack applies it right away so the change can be seen in the window
* behind the dialog; cancelling puts the previous one back.
* <p>Both apply right away so the change can be seen in the window behind the dialog;
* cancelling puts the previous ones back.
*/
final class IconPackPanel extends JPanel {
final class DesignPanel extends JPanel {
/** Size of the tiles in the icon viewer. */
private static final int PREVIEW_SIZE = 32;
@@ -44,18 +45,22 @@ final class IconPackPanel extends JPanel {
private final Settings settings;
private final String originalPackId;
private final String originalPackDir;
private final Settings.Appearance originalAppearance;
private final JComboBox<Settings.Appearance> appearanceCombo =
new JComboBox<>(Settings.Appearance.values());
private final JComboBox<Object> packCombo = new JComboBox<>();
private final JLabel packInfo = new JLabel();
private final JTextField packDirField;
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
private final JList<String> preview = new JList<>(previewModel);
IconPackPanel(Settings settings) {
DesignPanel(Settings settings) {
super(new BorderLayout(0, 8));
this.settings = settings;
this.originalPackId = settings.iconPack;
this.originalPackDir = settings.iconPackDir;
this.originalAppearance = settings.appearance;
this.packDirField = new JTextField(settings.iconPackDir, 18);
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
@@ -65,18 +70,23 @@ final class IconPackPanel extends JPanel {
reloadPacks();
}
/** Copies the chosen pack into the settings; the caller saves them. */
/** Copies the chosen appearance and pack into the settings; the caller saves them. */
void apply() {
settings.appearance = selectedAppearance();
settings.iconPackDir = packDirField.getText().trim();
Object selected = packCombo.getSelectedItem();
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
}
/** Puts back the pack the dialog started with, for a cancelled edit. */
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
void revert() {
settings.iconPack = originalPackId;
settings.iconPackDir = originalPackDir;
IconTheme.get().reload(settings);
if (settings.appearance != originalAppearance) {
settings.appearance = originalAppearance;
LookAndFeelManager.switchTo(originalAppearance);
}
}
// ---- layout ----
@@ -100,7 +110,21 @@ final class IconPackPanel extends JPanel {
}
});
appearanceCombo.setToolTipText("Light or dark window colours");
appearanceCombo.setSelectedItem(settings.appearance);
appearanceCombo.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focus) {
super.getListCellRendererComponent(list, value, index, selected, focus);
setText(value == Settings.Appearance.DARK ? "Dark" : "Light");
return this;
}
});
appearanceCombo.addActionListener(e -> onAppearanceSelected());
int row = 0;
addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
c.gridx = 1;
c.gridy = row++;
@@ -159,6 +183,18 @@ final class IconPackPanel extends JPanel {
onPackSelected();
}
private Settings.Appearance selectedAppearance() {
Object selected = appearanceCombo.getSelectedItem();
return selected instanceof Settings.Appearance a ? a : Settings.Appearance.LIGHT;
}
private void onAppearanceSelected() {
Settings.Appearance chosen = selectedAppearance();
if (chosen == settings.appearance) return;
settings.appearance = chosen;
LookAndFeelManager.switchTo(chosen);
}
private void onPackSelected() {
Object selected = packCombo.getSelectedItem();
IconPack pack = selected instanceof IconPack p ? p : null;

View File

@@ -6,18 +6,17 @@ import com.ts3client.net.ServerModel;
import javax.swing.Icon;
import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath;
import java.awt.BasicStroke;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.util.List;
import java.util.function.Predicate;
@@ -62,17 +61,9 @@ final class DropIndicatorTree extends JTree {
public void mouseExited(MouseEvent e) {
setHoverRow(-1);
}
@Override
public void mouseWheelMoved(MouseWheelEvent e) {
// The pointer stays put while the rows move under it, so the row it is
// over has to be looked up again once the scroll has been applied.
SwingUtilities.invokeLater(() -> setHoverRow(rowAt(e.getY())));
}
};
addMouseMotionListener(hover);
addMouseListener(hover);
addMouseWheelListener(hover);
// The selected row is filled across the full width below, before the rows
// themselves are drawn, so this component must not clear its own background.
setOpaque(false);
@@ -95,6 +86,15 @@ final class DropIndicatorTree extends JTree {
return row < 0 ? null : getPathForRow(row);
}
/**
* Re-reads the row the pointer is over. Scrolling moves the rows under a pointer
* that never moved itself, so the enclosing scroll pane calls this on every scroll.
*/
void refreshHoverRow() {
Point pointer = getMousePosition();
setHoverRow(pointer == null ? -1 : rowAt(pointer.y));
}
private void setHoverRow(int row) {
if (row == hoverRow) return;
repaintRow(hoverRow);
@@ -156,7 +156,7 @@ final class DropIndicatorTree extends JTree {
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(Theme.ACCENT);
g2.setColor(Theme.accent());
if (highlight != null || loc.getChildIndex() < 0) {
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
if (r != null) {
@@ -182,7 +182,7 @@ final class DropIndicatorTree extends JTree {
int[] rows = getSelectionRows();
if (rows == null) return;
Rectangle visible = getVisibleRect();
g.setColor(Theme.TREE_SELECTION);
g.setColor(Theme.treeSelection());
for (int row : rows) {
Rectangle bounds = getRowBounds(row);
if (bounds != null) g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
@@ -203,7 +203,7 @@ final class DropIndicatorTree extends JTree {
Rectangle visible = getVisibleRect();
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(Theme.TREE_HOVER);
g2.setColor(Theme.treeHover());
g2.drawRoundRect(visible.x, bounds.y, visible.width - 1, bounds.height - 1, 4, 4);
g2.dispose();
}

View File

@@ -66,7 +66,7 @@ public final class FileBrowserDialog extends JDialog {
this.channelPassword = "";
JPanel content = new JPanel(new BorderLayout(0, 8));
content.setBackground(Theme.WINDOW_BG);
content.setBackground(Theme.windowBg());
content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
content.add(buildToolbar(), BorderLayout.NORTH);
content.add(buildTable(), BorderLayout.CENTER);
@@ -85,7 +85,7 @@ public final class FileBrowserDialog extends JDialog {
private JPanel buildToolbar() {
JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG);
bar.setBackground(Theme.windowBg());
upButton.addActionListener(e -> navigateUp());
JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH"));
@@ -111,13 +111,13 @@ public final class FileBrowserDialog extends JDialog {
bar.add(Box.createHorizontalStrut(6));
bar.add(deleteButton);
pathLabel.setFont(Theme.UI_FONT);
pathLabel.setForeground(Theme.TREE_TEXT);
pathLabel.setFont(Theme.uiFont());
pathLabel.setForeground(Theme.treeText());
return bar;
}
private JScrollPane buildTable() {
table.setFont(Theme.UI_FONT);
table.setFont(Theme.uiFont());
table.setRowHeight(20);
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
table.setFillsViewportHeight(true);
@@ -137,17 +137,17 @@ public final class FileBrowserDialog extends JDialog {
}
});
JScrollPane scroll = new JScrollPane(table);
scroll.getViewport().setBackground(Theme.TREE_BG);
scroll.getViewport().setBackground(Theme.treeBg());
return scroll;
}
private JScrollPane buildTransfers() {
transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS));
transfersPanel.setBackground(Theme.WINDOW_BG);
transfersPanel.setBackground(Theme.windowBg());
JScrollPane scroll = new JScrollPane(transfersPanel);
scroll.setBorder(BorderFactory.createTitledBorder("Transfers"));
scroll.setPreferredSize(new Dimension(10, 120));
scroll.getViewport().setBackground(Theme.WINDOW_BG);
scroll.getViewport().setBackground(Theme.windowBg());
return scroll;
}
@@ -326,10 +326,10 @@ public final class FileBrowserDialog extends JDialog {
TransferRow() {
setLayout(new BorderLayout(8, 0));
setBackground(Theme.WINDOW_BG);
setBackground(Theme.windowBg());
setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2));
setMaximumSize(new Dimension(Integer.MAX_VALUE, 44));
label.setFont(Theme.UI_FONT);
label.setFont(Theme.uiFont());
bar.setStringPainted(true);
add(label, BorderLayout.NORTH);
add(bar, BorderLayout.CENTER);

View File

@@ -0,0 +1,95 @@
package com.ts3client.ui;
import com.ts3client.text.BBCode;
import javax.swing.JEditorPane;
import javax.swing.SwingUtilities;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.Color;
/**
* The HTML panes showing TeamSpeak text — chat logs, client and channel information —
* and the theme-dependent stylesheet they share.
*
* <p>Text that is already on screen keeps the colours its stylesheet was built with, so
* a look-and-feel change re-installs the editor kit and parses the pane's content again
* instead of only recolouring what is written afterwards.
*/
final class HtmlStyles {
private static final String BODY_STYLE = "HtmlStyles.bodyStyle";
private HtmlStyles() {
}
/**
* @param bodyStyle extra CSS for {@code body}, e.g. font and margins; the text colour
* is added by the theme
* @return a read-only HTML pane whose stylesheet follows the current theme
*/
static JEditorPane pane(String bodyStyle) {
JEditorPane pane = new JEditorPane() {
@Override
public void updateUI() {
super.updateUI();
restyle(this);
}
};
pane.putClientProperty(BODY_STYLE, bodyStyle);
pane.setEditable(false);
installKit(pane, null, false);
return pane;
}
/** Re-themes {@code pane}, keeping the text it shows. */
private static void restyle(JEditorPane pane) {
// Also runs from JEditorPane's constructor, before the pane is one of ours.
if (pane.getClientProperty(BODY_STYLE) == null) return;
int length = pane.getDocument().getLength();
String html = length == 0 ? null : pane.getText();
// A chat log sits at its newest line; keep it there rather than jumping to the top.
boolean atEnd = pane.getCaretPosition() >= length;
// Not during the look-and-feel update that got us here: the pane is rebuilding its
// views, and replacing the document underneath it would race with that.
SwingUtilities.invokeLater(() -> installKit(pane, html, atEnd));
}
private static void installKit(JEditorPane pane, String html, boolean atEnd) {
HTMLEditorKit kit = new HTMLEditorKit();
StyleSheet css = new StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
Object bodyStyle = pane.getClientProperty(BODY_STYLE);
css.addRule("body { " + (bodyStyle == null ? "" : bodyStyle + " ")
+ "color:" + hex(Theme.chatText()) + "; }");
// Secondary text (timestamps, labels, placeholders), an author's name and the lines
// about what happened on the server.
css.addRule(".muted { color:" + hex(Theme.chatSystem()) + "; }");
css.addRule(".name { color:" + hex(Theme.chatName()) + "; font-weight:bold; }");
css.addRule(".event { color:" + hex(Theme.channelText()) + "; }");
css.addRule("a { color:" + hex(Theme.chatName()) + "; text-decoration:none; }");
// Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
// Channel (and other TeamSpeak protocol) references stand out of a line the same
// way a client's name does.
css.addRule("a." + BBCode.TS_LINK_CLASS
+ " { color:" + hex(Theme.chatName()) + "; 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);
pane.setEditorKit(kit);
if (html != null) {
pane.setText(html);
pane.setCaretPosition(atEnd ? pane.getDocument().getLength() : 0);
}
}
private static String hex(Color c) {
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
}
}

View File

@@ -70,7 +70,7 @@ final class IconChooserDialog extends JDialog {
split.setResizeWeight(0.6);
status.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8));
status.setForeground(Theme.CHAT_SYSTEM);
status.setForeground(Theme.chatSystem());
getContentPane().setLayout(new BorderLayout());
getContentPane().add(split, BorderLayout.CENTER);

View File

@@ -121,7 +121,7 @@ public final class Icons {
}
public static ImageIcon clientIdle(int size) {
return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT));
return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
}
public static ImageIcon clientTalking() {
@@ -129,7 +129,7 @@ public final class Icons {
}
public static ImageIcon clientTalking(int size) {
return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.TALKING));
return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.talking()));
}
public static ImageIcon clientAway() {
@@ -137,7 +137,7 @@ public final class Icons {
}
public static ImageIcon clientAway(int size) {
return themed("AWAY", size, g -> paintPerson(g, Theme.AWAY));
return themed("AWAY", size, g -> paintPerson(g, Theme.away()));
}
/** A channel commander that is not talking. */
@@ -151,7 +151,7 @@ public final class Icons {
}
public static ImageIcon clientQuery() {
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.IDLE_CLIENT));
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.idleClient()));
}
public static ImageIcon micMuted() {
@@ -199,12 +199,12 @@ public final class Icons {
/** A channel commander that is not talking. */
public static ImageIcon clientCommander(int size) {
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT));
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
}
/** A channel commander that is talking. */
public static ImageIcon clientCommanderTalking(int size) {
return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.TALKING));
return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.talking()));
}
// ---- toolbar / action icons ----
@@ -257,8 +257,8 @@ public final class Icons {
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);
g.setColor(Theme.idleClient());
g.setFont(Theme.uiFont());
java.awt.FontMetrics fm = g.getFontMetrics();
int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2;
g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1);
@@ -270,7 +270,7 @@ public final class Icons {
/** The toolbar's away marker. */
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()));
}
public static ImageIcon settings() {
@@ -329,7 +329,7 @@ public final class Icons {
}
private static void paintMicMuted(Graphics2D g) {
g.setColor(Theme.MUTED);
g.setColor(Theme.muted());
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
@@ -339,7 +339,7 @@ public final class Icons {
}
private static void paintSpeakerMuted(Graphics2D g) {
g.setColor(Theme.MUTED);
g.setColor(Theme.muted());
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
@@ -350,7 +350,7 @@ public final class Icons {
/** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
private static void paintMicDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT);
g.setColor(Theme.idleClient());
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
@@ -361,7 +361,7 @@ public final class Icons {
/** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
private static void paintSpeakerDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT);
g.setColor(Theme.idleClient());
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
@@ -373,7 +373,7 @@ public final class Icons {
/** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
private static void paintMicLocalMuted(Graphics2D g) {
paintMicMuted(g);
g.setColor(Theme.ACCENT);
g.setColor(Theme.accent());
g.fillOval(11, 10, 4, 4);
}
@@ -387,7 +387,7 @@ public final class Icons {
}
private static void paintDisconnect(Graphics2D g) {
g.setColor(Theme.MUTED);
g.setColor(Theme.muted());
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
@@ -414,7 +414,7 @@ public final class Icons {
}
private static void paintMicActive(Graphics2D g) {
g.setColor(Theme.TALKING);
g.setColor(Theme.talking());
g.fillOval(1, 1, 14, 14);
g.setColor(Color.WHITE);
g.fillRoundRect(6, 3, 4, 6, 2, 2);
@@ -450,7 +450,7 @@ public final class Icons {
}
private static void paintApp(Graphics2D g) {
g.setColor(Theme.ACCENT);
g.setColor(Theme.accent());
g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f));

View File

@@ -34,7 +34,7 @@ public final class InfoPanel extends JScrollPane {
private static final String TOGGLE_HREF = "app:toggle-info-tab";
private final JEditorPane pane = new JEditorPane();
private final JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
private DescriptionHandler descriptionHandler;
private ChannelNode shownChannel;
private ClientEntry shownClient;
@@ -43,20 +43,6 @@ public final class InfoPanel extends JScrollPane {
private boolean inChatTab;
public InfoPanel() {
javax.swing.text.html.HTMLEditorKit kit = new javax.swing.text.html.HTMLEditorKit();
javax.swing.text.html.StyleSheet css = new javax.swing.text.html.StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
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);
pane.setEditorKit(kit);
pane.setEditable(false);
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) return;
@@ -69,10 +55,15 @@ public final class InfoPanel extends JScrollPane {
}
});
setViewportView(pane);
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
clear();
}
@Override
public void updateUI() {
super.updateUI();
setBorder(BorderFactory.createLineBorder(Theme.border()));
}
public void clear() {
shownChannel = null;
shownClient = null;
@@ -118,7 +109,7 @@ public final class InfoPanel extends JScrollPane {
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>";
return "<i class='muted'>Select a channel or client to see details.</i>";
}
private static String channelBody(ChannelNode ch) {
@@ -132,16 +123,16 @@ public final class InfoPanel extends JScrollPane {
if (ch.description != null && !ch.description.isEmpty()) {
sb.append("<div>").append(multiline(ch.description)).append("</div>");
} else if (ch.descriptionLoaded) {
sb.append("<i style='color:#8a8a8a'>No description.</i>");
sb.append("<i class='muted'>No description.</i>");
} else {
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
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 style='color:#8a8a8a'>(you)</span>" : "")));
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
if (serverGroups.isEmpty()) {
@@ -184,8 +175,7 @@ public final class InfoPanel extends JScrollPane {
}
private void setHtml(String body) {
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>"
+ body + "</body></html>");
pane.setText("<html><body>" + body + "</body></html>");
pane.setCaretPosition(0);
}
@@ -206,7 +196,7 @@ public final class InfoPanel extends JScrollPane {
}
private static void row(StringBuilder sb, String label, String value) {
sb.append("<div style='margin:1px 0'><span style='color:#5a6b7b'>")
sb.append("<div style='margin:1px 0'><span class='muted'>")
.append(label).append(":</span> ").append(value).append("</div>");
}
@@ -219,7 +209,4 @@ public final class InfoPanel extends JScrollPane {
return BBCode.escape(s);
}
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
}

View File

@@ -73,7 +73,7 @@ public final class LevelMeter extends JComponent {
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
int level = dbToX(levelDb, w - 2);
g.setColor(transmitting ? Theme.TALKING : new Color(0x5A9BD4));
g.setColor(transmitting ? Theme.talking() : new Color(0x5A9BD4));
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
if (showThreshold) {

View File

@@ -0,0 +1,59 @@
package com.ts3client.ui;
import com.formdev.flatlaf.FlatDarkLaf;
import com.formdev.flatlaf.FlatLaf;
import com.formdev.flatlaf.FlatLightLaf;
import com.ts3client.config.Settings;
import javax.swing.UIManager;
import java.awt.Window;
/**
* Installs FlatLaf in the requested variant and keeps {@link Theme} in step with it.
*
* <p>Also carries the few look-and-feel defaults the client wants everywhere: rounded
* controls, a slimmer tree row and scroll bars that only show their thumb.
*/
public final class LookAndFeelManager {
private LookAndFeelManager() {
}
/** Installs the look-and-feel for {@code appearance}. Call before building any window. */
public static void install(Settings.Appearance appearance) {
FlatLaf laf = appearance == Settings.Appearance.DARK ? new FlatDarkLaf() : new FlatLightLaf();
if (!FlatLaf.setup(laf)) {
// Leave whatever look-and-feel is installed; Theme falls back to its own palette.
Theme.refresh();
return;
}
applyDefaults();
Theme.refresh();
}
/**
* Switches the look-and-feel of the running client. Components that read {@link Theme}
* while painting follow immediately; the ones that copied a colour when they were built
* are refreshed by the look-and-feel update.
*/
public static void switchTo(Settings.Appearance appearance) {
install(appearance);
FlatLaf.updateUI();
for (Window w : Window.getWindows()) {
w.repaint();
}
}
private static void applyDefaults() {
UIManager.put("Component.focusWidth", 1);
UIManager.put("Component.arc", 6);
UIManager.put("Button.arc", 6);
UIManager.put("ScrollBar.showButtons", false);
UIManager.put("ScrollBar.thumbArc", 8);
UIManager.put("ScrollBar.trackArc", 8);
UIManager.put("TabbedPane.tabHeight", 26);
UIManager.put("Tree.rowHeight", 0);
UIManager.put("Tree.paintLines", false);
}
}

View File

@@ -73,7 +73,7 @@ final class MainToolbar extends JToolBar {
this.listener = listener;
setFloatable(false);
setBackground(Theme.TOOLBAR_BG);
setBackground(Theme.toolbarBg());
setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
setComponentPopupMenu(buildContextMenu());

View File

@@ -47,7 +47,7 @@ final class NicknameCellEditor extends DefaultTreeCellEditor {
static NicknameCellEditor create(JTree tree, DefaultTreeCellRenderer renderer, IntPredicate isSelf) {
JTextField field = new JTextField();
// Only our own row is edited, and that name is bold in the tree.
field.setFont(Theme.UI_BOLD);
field.setFont(Theme.uiBold());
return new NicknameCellEditor(tree, renderer, field, isSelf);
}

View File

@@ -155,7 +155,7 @@ final class ServerTabPane extends JPanel {
cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0));
JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING);
label.setFont(Theme.UI_FONT);
label.setFont(Theme.uiFont());
label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
// A label with a tooltip swallows mouse events, so select the tab explicitly.
label.addMouseListener(new MouseAdapter() {
@@ -169,12 +169,12 @@ final class ServerTabPane extends JPanel {
dragReorder.attach(label);
JButton close = new JButton("");
close.setFont(Theme.UI_FONT);
close.setFont(Theme.uiFont());
close.setFocusable(false);
close.setBorder(BorderFactory.createEmptyBorder());
close.setContentAreaFilled(false);
close.setMargin(new Insets(0, 0, 0, 0));
close.setForeground(Theme.CHAT_SYSTEM);
close.setForeground(Theme.chatSystem());
close.setPreferredSize(new Dimension(14, 14));
close.setToolTipText("Close this connection");
close.addActionListener(e -> listener.closeTab(tab));

View File

@@ -28,9 +28,9 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
boolean expanded, boolean leaf, int row,
boolean hasFocus) {
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
setBackgroundNonSelectionColor(Theme.TREE_BG);
setBackgroundSelectionColor(Theme.TREE_SELECTION);
setBorderSelectionColor(Theme.TREE_SELECTION);
setBackgroundNonSelectionColor(Theme.treeBg());
setBackgroundSelectionColor(Theme.treeSelection());
setBorderSelectionColor(Theme.treeSelection());
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
if (obj instanceof ChannelNode) {
@@ -39,13 +39,13 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
if (spacer != null) {
setText(Spacers.render(spacer, 40));
setIcon(null);
setForeground(Theme.IDLE_CLIENT);
setFont(Theme.UI_FONT);
setForeground(Theme.idleClient());
setFont(Theme.uiFont());
} else {
setText(c.name);
setIcon(iconFor(c));
setForeground(Theme.CHANNEL_TEXT);
setFont(Theme.UI_BOLD);
setForeground(Theme.channelText());
setFont(Theme.uiBold());
}
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
@@ -57,14 +57,14 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
// name as well would also re-measure the row mid-speech, which leaves the
// nickname clipped to an ellipsis for as long as it lasts. Our own name is
// bold throughout, so its width never changes either.
setForeground(Theme.TREE_TEXT);
setFont(isSelf.test(cl.id) ? Theme.UI_BOLD : Theme.UI_FONT);
setForeground(Theme.treeText());
setFont(isSelf.test(cl.id) ? Theme.uiBold() : Theme.uiFont());
} else {
// root / server
setText(String.valueOf(obj));
setIcon(Icons.server());
setForeground(Theme.SERVER_TEXT);
setFont(Theme.UI_BOLD);
setForeground(Theme.serverText());
setFont(Theme.uiBold());
}
return this;
}

View File

@@ -151,8 +151,8 @@ public final class ServerTreePanel extends JScrollPane {
ui.setRightChildIndent(10);
}
tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG);
tree.setFont(Theme.UI_FONT);
tree.setBackground(Theme.treeBg());
tree.setFont(Theme.uiFont());
ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId);
tree.setCellRenderer(renderer);
nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId);
@@ -161,10 +161,13 @@ public final class ServerTreePanel extends JScrollPane {
tree.setPathEditable(nicknameEditor::editsPath);
tree.setInvokesStopCellEditing(true);
setViewportView(tree);
getViewport().setBackground(Theme.TREE_BG);
getViewport().setBackground(Theme.treeBg());
// The icon strip is drawn against the viewport's right edge, so the blitted
// pixels a scroll would reuse are stale; repaint the whole viewport instead.
getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
// Scrolling slides the rows past a pointer that has not moved, so the outlined
// row has to be worked out again.
getViewport().addChangeListener(e -> tree.refreshHoverRow());
// Within the tree a drag moves the client or channel; dropped elsewhere it
// yields the TS3 link BBCode, which the chat input accepts as plain text.

View File

@@ -32,7 +32,7 @@ public final class SettingsDialog extends JDialog {
private final DevicesPanel devicesPanel;
private final VoiceActivationPanel voiceActivationPanel;
private final NotificationsPanel notificationsPanel;
private final IconPackPanel iconPackPanel;
private final DesignPanel designPanel;
private final HotkeysPanel hotkeysPanel;
private final ClientVersionPanel clientVersionPanel;
@@ -46,7 +46,7 @@ public final class SettingsDialog extends JDialog {
this.onApply = onApply;
notificationsPanel = new NotificationsPanel(settings, sounds);
iconPackPanel = new IconPackPanel(settings);
designPanel = new DesignPanel(settings);
hotkeysPanel = new HotkeysPanel(hotkeys);
clientVersionPanel = new ClientVersionPanel(settings);
devicesPanel = new DevicesPanel(settings, livePlayback,
@@ -58,7 +58,7 @@ public final class SettingsDialog extends JDialog {
tabs.addTab("Playback / Capture", scrollable(devicesPanel));
tabs.addTab("Voice Activation", scrollable(voiceActivationPanel));
tabs.addTab("Notifications", notificationsPanel);
tabs.addTab("Design", iconPackPanel);
tabs.addTab("Design", designPanel);
tabs.addTab("Hotkeys", hotkeysPanel);
tabs.addTab("Client Version", scrollable(clientVersionPanel));
@@ -128,7 +128,7 @@ public final class SettingsDialog extends JDialog {
private void apply() {
writeAudioSettings(settings);
notificationsPanel.apply();
iconPackPanel.apply();
designPanel.apply();
clientVersionPanel.apply();
hotkeysPanel.apply();
settings.save();
@@ -156,7 +156,7 @@ public final class SettingsDialog extends JDialog {
/** Leaves without applying, putting back the settings that preview themselves live. */
private void cancel() {
notificationsPanel.revert();
iconPackPanel.revert();
designPanel.revert();
close();
}

View File

@@ -13,11 +13,11 @@ final class StatusBar extends JPanel {
StatusBar() {
super(new BorderLayout());
setBackground(Theme.STATUS_BG);
setBackground(Theme.statusBg());
setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.UI_FONT);
codecLabel.setFont(Theme.UI_FONT);
codecLabel.setForeground(Theme.CHAT_SYSTEM);
statusLabel.setFont(Theme.uiFont());
codecLabel.setFont(Theme.uiFont());
codecLabel.setForeground(Theme.chatSystem());
add(statusLabel, BorderLayout.WEST);
add(codecLabel, BorderLayout.EAST);
}

View File

@@ -1,39 +1,144 @@
package com.ts3client.ui;
import javax.swing.UIManager;
import java.awt.Color;
import java.awt.Font;
/** Central palette + fonts approximating the light TeamSpeak 3 look. */
/**
* The palette and fonts the client paints with, on top of the active look-and-feel.
*
* <p>Surfaces (window, tree, chat background) and fonts come straight from the
* look-and-feel, so the client follows whatever FlatLaf theme is installed. The
* status colours TeamSpeak gives meaning to — talking, away, muted, channel and
* server names — are ours, in a light and a dark variant; {@link #refresh()} picks
* the variant matching the installed theme.
*/
public final class Theme {
public static final Color WINDOW_BG = new Color(0xF0F0F0);
public static final Color TREE_BG = new Color(0xFFFFFF);
public static final Color TREE_SELECTION = new Color(0xCFE3FB);
/** Outline drawn around the row the pointer is over. */
public static final Color TREE_HOVER = new Color(0x9CC4EE);
public static final Color TREE_TEXT = new Color(0x1E1E1E);
public static final Color CHANNEL_TEXT = new Color(0x21486B);
public static final Color SERVER_TEXT = new Color(0x123456);
public static final Color TALKING = new Color(0x33B35A);
public static final Color IDLE_CLIENT = new Color(0x6E7B87);
public static final Color AWAY = new Color(0xC98A1B);
public static final Color MUTED = new Color(0xC0392B);
public static final Color TOOLBAR_BG = new Color(0xE6E9ED);
public static final Color STATUS_BG = new Color(0xE6E9ED);
public static final Color ACCENT = new Color(0x2C7BE5);
public static final Color CHAT_BG = new Color(0xFAFAFA);
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
public static final Color CHAT_NAME = new Color(0x2C7BE5);
public static final Color CHAT_TEXT = new Color(0x202020);
/** Links that leave the client, distinct from the colour used for identities. */
public static final Color LINK = new Color(0x1A5FB4);
public static final Font UI_FONT = new Font("SansSerif", Font.PLAIN, 12);
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);
private static boolean dark;
private Theme() {
}
/** Re-reads the installed look-and-feel; call after changing it. */
static void refresh() {
Color bg = UIManager.getColor("Panel.background");
dark = bg != null && luminance(bg) < 0.5;
}
public static boolean isDark() {
return dark;
}
// ---- fonts ----
public static Font uiFont() {
Font f = UIManager.getFont("Label.font");
return f != null ? f : new Font("SansSerif", Font.PLAIN, 12);
}
public static Font uiBold() {
return uiFont().deriveFont(Font.BOLD);
}
// ---- surfaces, from the look-and-feel ----
public static Color windowBg() {
return ui("Panel.background", 0xF0F0F0, 0x3C3F41);
}
public static Color toolbarBg() {
return ui("ToolBar.background", 0xE6E9ED, 0x3C3F41);
}
public static Color statusBg() {
return windowBg();
}
public static Color treeBg() {
return ui("Tree.background", 0xFFFFFF, 0x2B2B2B);
}
public static Color treeText() {
return ui("Tree.foreground", 0x1E1E1E, 0xDFE1E5);
}
public static Color treeSelection() {
return ui("Tree.selectionBackground", 0xCFE3FB, 0x2F5075);
}
/** Outline drawn around the row the pointer is over. */
public static Color treeHover() {
return pick(0x9CC4EE, 0x4A6E96);
}
public static Color chatBg() {
return ui("TextPane.background", 0xFAFAFA, 0x2B2B2B);
}
public static Color border() {
return ui("Component.borderColor", 0xD0D0D0, 0x4B4B4B);
}
public static Color chatText() {
return ui("TextPane.foreground", 0x202020, 0xDFE1E5);
}
// ---- status colours ----
public static Color channelText() {
return pick(0x21486B, 0x8FBCE6);
}
public static Color serverText() {
return pick(0x123456, 0xB9D4F0);
}
public static Color talking() {
return pick(0x33B35A, 0x4CC479);
}
public static Color idleClient() {
return pick(0x6E7B87, 0x9AA6B2);
}
public static Color away() {
return pick(0xC98A1B, 0xE0A83C);
}
public static Color muted() {
return pick(0xC0392B, 0xE05C4C);
}
public static Color accent() {
return pick(0x2C7BE5, 0x5C9BEE);
}
public static Color chatSystem() {
return pick(0x8A8A8A, 0x9A9A9A);
}
public static Color chatName() {
return accent();
}
/** Links that leave the client, distinct from the colour used for identities. */
public static Color link() {
return pick(0x1A5FB4, 0x6EA8FF);
}
private static Color pick(int light, int darkRgb) {
return new Color(dark ? darkRgb : light);
}
private static Color ui(String key, int lightFallback, int darkFallback) {
// Returned as the look-and-feel's own UIResource colour, so components painted with
// it are recoloured automatically when the look-and-feel is switched.
Color c = UIManager.getColor(key);
return c != null ? c : pick(lightFallback, darkFallback);
}
private static double luminance(Color c) {
return (0.2126 * c.getRed() + 0.7152 * c.getGreen() + 0.0722 * c.getBlue()) / 255.0;
}
}