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

@@ -27,6 +27,12 @@ public final class Settings {
CONTINUOUS CONTINUOUS
} }
/** Which look-and-feel variant the window is painted with. */
public enum Appearance {
LIGHT,
DARK
}
/** Voice-activation strategy, mirroring the TS3 client's VAD modes. */ /** Voice-activation strategy, mirroring the TS3 client's VAD modes. */
public enum VadMode { public enum VadMode {
/** Speech-probability detector only. */ /** Speech-probability detector only. */
@@ -125,6 +131,9 @@ public final class Settings {
/** Extra folder to look for icon packs in, on top of the well-known locations. */ /** Extra folder to look for icon packs in, on top of the well-known locations. */
public String iconPackDir = ""; public String iconPackDir = "";
/** Look-and-feel variant; see {@link Appearance}. */
public Appearance appearance = Appearance.LIGHT;
// ---- window chrome ---- // ---- window chrome ----
/** Whether the status bar at the bottom of the window is shown. */ /** Whether the status bar at the bottom of the window is shown. */
public boolean showStatusBar = true; public boolean showStatusBar = true;
@@ -218,6 +227,7 @@ public final class Settings {
soundPackDir = props.getProperty("soundPackDir", soundPackDir); soundPackDir = props.getProperty("soundPackDir", soundPackDir);
iconPack = props.getProperty("iconPack", iconPack); iconPack = props.getProperty("iconPack", iconPack);
iconPackDir = props.getProperty("iconPackDir", iconPackDir); iconPackDir = props.getProperty("iconPackDir", iconPackDir);
appearance = parseAppearance(props.getProperty("appearance"), appearance);
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar); showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider); showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
notifications.load(props); notifications.load(props);
@@ -259,6 +269,7 @@ public final class Settings {
props.setProperty("soundPackDir", soundPackDir); props.setProperty("soundPackDir", soundPackDir);
props.setProperty("iconPack", iconPack); props.setProperty("iconPack", iconPack);
props.setProperty("iconPackDir", iconPackDir); props.setProperty("iconPackDir", iconPackDir);
props.setProperty("appearance", appearance.name());
props.setProperty("showStatusBar", Boolean.toString(showStatusBar)); props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider)); props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
notifications.store(props); notifications.store(props);
@@ -273,6 +284,15 @@ public final class Settings {
} }
} }
private static Appearance parseAppearance(String v, Appearance def) {
if (v == null) return def;
try {
return Appearance.valueOf(v);
} catch (IllegalArgumentException e) {
return def;
}
}
private static VadMode parseVadMode(String v, VadMode def) { private static VadMode parseVadMode(String v, VadMode def) {
if (v == null) return def; if (v == null) return def;
try { try {

View File

@@ -23,6 +23,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<ts3j.version>1.0.3</ts3j.version> <ts3j.version>1.0.3</ts3j.version>
<jsvg.version>2.1.0</jsvg.version> <jsvg.version>2.1.0</jsvg.version>
<flatlaf.version>3.7.2</flatlaf.version>
<surefire.version>3.5.6</surefire.version> <surefire.version>3.5.6</surefire.version>
</properties> </properties>
@@ -50,6 +51,11 @@
<artifactId>jsvg</artifactId> <artifactId>jsvg</artifactId>
<version>${jsvg.version}</version> <version>${jsvg.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
<version>${flatlaf.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.ts3client</groupId> <groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId> <artifactId>ts3-client-core</artifactId>

View File

@@ -35,6 +35,10 @@
<groupId>com.github.weisj</groupId> <groupId>com.github.weisj</groupId>
<artifactId>jsvg</artifactId> <artifactId>jsvg</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
@@ -84,6 +88,15 @@
<include>**</include> <include>**</include>
</includes> </includes>
</filter> </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> <filter>
<artifact>*:*</artifact> <artifact>*:*</artifact>
<excludes> <excludes>

View File

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

View File

@@ -108,7 +108,7 @@ final class ChannelPermissionsPanel extends JPanel {
void setStatus(String message, boolean error) { void setStatus(String message, boolean error) {
status.setText(message == null || message.isEmpty() ? " " : message); 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) { private void setEditable(boolean editable) {

View File

@@ -16,8 +16,6 @@ import javax.swing.SwingUtilities;
import javax.swing.event.HyperlinkEvent; import javax.swing.event.HyperlinkEvent;
import javax.swing.text.BadLocationException; import javax.swing.text.BadLocationException;
import javax.swing.text.html.HTMLDocument; 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.BorderLayout;
import java.awt.Component; import java.awt.Component;
import java.awt.Cursor; import java.awt.Cursor;
@@ -75,7 +73,7 @@ public final class ChatPanel extends JPanel {
public ChatPanel() { public ChatPanel() {
super(new BorderLayout()); super(new BorderLayout());
tabs.setFont(Theme.UI_FONT); tabs.setFont(Theme.uiFont());
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
addTab(serverTab, false); addTab(serverTab, false);
addTab(channelTab, false); addTab(channelTab, false);
@@ -186,7 +184,7 @@ public final class ChatPanel extends JPanel {
/** System notices always land in the server tab. */ /** System notices always land in the server tab. */
public void appendSystem(String text) { public void appendSystem(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp() edt(() -> serverTab.appendLine("<span class=\"muted\">" + stamp()
+ BBCode.escape(text) + "</span>")); + BBCode.escape(text) + "</span>"));
} }
@@ -196,7 +194,7 @@ public final class ChatPanel extends JPanel {
* clickable here — everything else in them is literal text. * clickable here — everything else in them is literal text.
*/ */
public void appendServerLog(String 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>")); + BBCode.linksToHtml(text) + "</span>"));
} }
@@ -288,13 +286,12 @@ public final class ChatPanel extends JPanel {
final Target target; final Target target;
final int clientId; final int clientId;
final String title; 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; final JScrollPane scroll;
/** Set for a static-content tab (e.g. a moved-out description); null for a conversation. */ /** Set for a static-content tab (e.g. a moved-out description); null for a conversation. */
final String noteKey; final String noteKey;
final Runnable onClose; final Runnable onClose;
private final Icon icon; private final Icon icon;
private final HTMLDocument doc;
private JLabel titleLabel; private JLabel titleLabel;
Tab(Target target, int clientId, String title) { Tab(Target target, int clientId, String title) {
@@ -314,28 +311,7 @@ public final class ChatPanel extends JPanel {
this.noteKey = noteKey; this.noteKey = noteKey;
this.onClose = onClose; 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>"); log.setText("<html><body><div id=\"chatlog\"></div></body></html>");
doc = (HTMLDocument) log.getDocument();
log.addHyperlinkListener(e -> { log.addHyperlinkListener(e -> {
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return; if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
onLink(e.getDescription()); onLink(e.getDescription());
@@ -363,14 +339,14 @@ public final class ChatPanel extends JPanel {
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, icon, JLabel.LEADING); titleLabel = new JLabel(title, icon, JLabel.LEADING);
titleLabel.setFont(Theme.UI_FONT); titleLabel.setFont(Theme.uiFont());
p.add(titleLabel); p.add(titleLabel);
dragReorder.attach(p); dragReorder.attach(p);
dragReorder.attach(titleLabel); dragReorder.attach(titleLabel);
if (closable) { if (closable) {
JLabel close = new JLabel("×"); JLabel close = new JLabel("×");
close.setFont(Theme.UI_BOLD); close.setFont(Theme.uiBold());
close.setForeground(Theme.CHAT_SYSTEM); close.setForeground(Theme.chatSystem());
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
close.setToolTipText("Close chat"); close.setToolTipText("Close chat");
close.addMouseListener(new MouseAdapter() { close.addMouseListener(new MouseAdapter() {
@@ -394,8 +370,8 @@ public final class ChatPanel extends JPanel {
void setUnread(boolean unread) { void setUnread(boolean unread) {
if (titleLabel == null) return; if (titleLabel == null) return;
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT); titleLabel.setFont(unread ? Theme.uiBold() : Theme.uiFont());
titleLabel.setForeground(unread ? Theme.ACCENT : null); titleLabel.setForeground(unread ? Theme.accent() : null);
} }
void appendMessage(int fromId, String from, String text) { void appendMessage(int fromId, String from, String text) {
@@ -403,8 +379,8 @@ public final class ChatPanel extends JPanel {
String sender = fromId > 0 String sender = fromId > 0
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\"" ? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>" + BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
: "<b style=\"color:" + hex(Theme.CHAT_NAME) + "\">" + name + "</b>"; : "<span class=\"name\">" + name + "</span>";
appendLine("<span style=\"color:#8A8A8A\">" + stamp() + "</span>" appendLine("<span class=\"muted\">" + stamp() + "</span>"
+ sender + ": " + BBCode.toHtml(text)); + sender + ": " + BBCode.toHtml(text));
} }
@@ -416,6 +392,7 @@ public final class ChatPanel extends JPanel {
void appendLine(String html) { void appendLine(String html) {
try { try {
HTMLDocument doc = (HTMLDocument) log.getDocument();
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>"); doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength()); log.setCaretPosition(doc.getLength());
} catch (BadLocationException | IOException ignored) { } catch (BadLocationException | IOException ignored) {
@@ -423,8 +400,4 @@ public final class ChatPanel extends JPanel {
if (tabs.getSelectedComponent() != scroll) setUnread(true); 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; this.clientId = clientId;
JPanel content = new JPanel(new BorderLayout(0, 10)); 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.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
content.add(buildSummary(), BorderLayout.NORTH); content.add(buildSummary(), BorderLayout.NORTH);
content.add(buildTabs(), BorderLayout.CENTER); content.add(buildTabs(), BorderLayout.CENTER);
@@ -92,7 +92,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildSummary() { private JPanel buildSummary() {
JPanel grid = new JPanel(new GridBagLayout()); JPanel grid = new JPanel(new GridBagLayout());
grid.setBackground(Theme.WINDOW_BG); grid.setBackground(Theme.windowBg());
int row = 0; int row = 0;
addRow(grid, row++, "Address", addressValue); addRow(grid, row++, "Address", addressValue);
addRow(grid, row++, "Client version", versionValue); addRow(grid, row++, "Client version", versionValue);
@@ -106,8 +106,8 @@ public final class ConnectionInfoDialog extends JDialog {
private JTabbedPane buildTabs() { private JTabbedPane buildTabs() {
JTabbedPane tabs = new JTabbedPane(); JTabbedPane tabs = new JTabbedPane();
tabs.setFont(Theme.UI_FONT); tabs.setFont(Theme.uiFont());
tabs.setBackground(Theme.WINDOW_BG); tabs.setBackground(Theme.windowBg());
tabs.addTab("Total", totalTab); tabs.addTab("Total", totalTab);
tabs.addTab("Speech", speechTab); tabs.addTab("Speech", speechTab);
tabs.addTab("Keep Alive", keepAliveTab); tabs.addTab("Keep Alive", keepAliveTab);
@@ -118,7 +118,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildButtons() { private JPanel buildButtons() {
JPanel bar = new JPanel(); JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS)); bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG); bar.setBackground(Theme.windowBg());
bar.add(Box.createHorizontalGlue()); bar.add(Box.createHorizontalGlue());
JButton close = new JButton("Close"); JButton close = new JButton("Close");
close.addActionListener(e -> dispose()); close.addActionListener(e -> dispose());
@@ -128,8 +128,8 @@ public final class ConnectionInfoDialog extends JDialog {
private static JLabel value() { private static JLabel value() {
JLabel l = new JLabel(""); JLabel l = new JLabel("");
l.setFont(Theme.UI_FONT); l.setFont(Theme.uiFont());
l.setForeground(Theme.TREE_TEXT); l.setForeground(Theme.treeText());
return l; return l;
} }
@@ -140,7 +140,7 @@ public final class ConnectionInfoDialog extends JDialog {
lc.anchor = GridBagConstraints.WEST; lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 0, 2, 16); lc.insets = new Insets(2, 0, 2, 16);
JLabel key = new JLabel(label); JLabel key = new JLabel(label);
key.setFont(Theme.UI_FONT); key.setFont(Theme.uiFont());
key.setForeground(new Color(0x5A6B7B)); key.setForeground(new Color(0x5A6B7B));
grid.add(key, lc); grid.add(key, lc);
@@ -255,7 +255,7 @@ public final class ConnectionInfoDialog extends JDialog {
KindTab() { KindTab() {
super(new GridBagLayout()); super(new GridBagLayout());
setBackground(Theme.WINDOW_BG); setBackground(Theme.windowBg());
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12)); setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
int row = 0; int row = 0;
addRow(this, row++, "Packet loss", packetLoss); addRow(this, row++, "Packet loss", packetLoss);

View File

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

View File

@@ -26,13 +26,14 @@ import java.awt.Insets;
import java.io.File; import java.io.File;
/** /**
* Options page for icon packs: which pack the user interface is drawn with, where * Options page for how the client looks: the light or dark look-and-feel, and the icon
* to look for more of them, and a viewer showing everything the pack contains. * 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 * <p>Both apply right away so the change can be seen in the window behind the dialog;
* behind the dialog; cancelling puts the previous one back. * cancelling puts the previous ones back.
*/ */
final class IconPackPanel extends JPanel { final class DesignPanel extends JPanel {
/** Size of the tiles in the icon viewer. */ /** Size of the tiles in the icon viewer. */
private static final int PREVIEW_SIZE = 32; private static final int PREVIEW_SIZE = 32;
@@ -44,18 +45,22 @@ final class IconPackPanel extends JPanel {
private final Settings settings; private final Settings settings;
private final String originalPackId; private final String originalPackId;
private final String originalPackDir; 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 JComboBox<Object> packCombo = new JComboBox<>();
private final JLabel packInfo = new JLabel(); private final JLabel packInfo = new JLabel();
private final JTextField packDirField; private final JTextField packDirField;
private final DefaultListModel<String> previewModel = new DefaultListModel<>(); private final DefaultListModel<String> previewModel = new DefaultListModel<>();
private final JList<String> preview = new JList<>(previewModel); private final JList<String> preview = new JList<>(previewModel);
IconPackPanel(Settings settings) { DesignPanel(Settings settings) {
super(new BorderLayout(0, 8)); super(new BorderLayout(0, 8));
this.settings = settings; this.settings = settings;
this.originalPackId = settings.iconPack; this.originalPackId = settings.iconPack;
this.originalPackDir = settings.iconPackDir; this.originalPackDir = settings.iconPackDir;
this.originalAppearance = settings.appearance;
this.packDirField = new JTextField(settings.iconPackDir, 18); this.packDirField = new JTextField(settings.iconPackDir, 18);
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
@@ -65,18 +70,23 @@ final class IconPackPanel extends JPanel {
reloadPacks(); 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() { void apply() {
settings.appearance = selectedAppearance();
settings.iconPackDir = packDirField.getText().trim(); settings.iconPackDir = packDirField.getText().trim();
Object selected = packCombo.getSelectedItem(); Object selected = packCombo.getSelectedItem();
settings.iconPack = selected instanceof IconPack pack ? pack.id() : ""; 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() { void revert() {
settings.iconPack = originalPackId; settings.iconPack = originalPackId;
settings.iconPackDir = originalPackDir; settings.iconPackDir = originalPackDir;
IconTheme.get().reload(settings); IconTheme.get().reload(settings);
if (settings.appearance != originalAppearance) {
settings.appearance = originalAppearance;
LookAndFeelManager.switchTo(originalAppearance);
}
} }
// ---- layout ---- // ---- 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; int row = 0;
addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo); addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
c.gridx = 1; c.gridx = 1;
c.gridy = row++; c.gridy = row++;
@@ -159,6 +183,18 @@ final class IconPackPanel extends JPanel {
onPackSelected(); 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() { private void onPackSelected() {
Object selected = packCombo.getSelectedItem(); Object selected = packCombo.getSelectedItem();
IconPack pack = selected instanceof IconPack p ? p : null; 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.Icon;
import javax.swing.JTree; import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.TreeModel; import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath; import javax.swing.tree.TreePath;
import java.awt.BasicStroke; import java.awt.BasicStroke;
import java.awt.Graphics; import java.awt.Graphics;
import java.awt.Graphics2D; import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle; import java.awt.Rectangle;
import java.awt.RenderingHints; import java.awt.RenderingHints;
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.util.List; import java.util.List;
import java.util.function.Predicate; import java.util.function.Predicate;
@@ -62,17 +61,9 @@ final class DropIndicatorTree extends JTree {
public void mouseExited(MouseEvent e) { public void mouseExited(MouseEvent e) {
setHoverRow(-1); 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); addMouseMotionListener(hover);
addMouseListener(hover); addMouseListener(hover);
addMouseWheelListener(hover);
// The selected row is filled across the full width below, before the rows // 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. // themselves are drawn, so this component must not clear its own background.
setOpaque(false); setOpaque(false);
@@ -95,6 +86,15 @@ final class DropIndicatorTree extends JTree {
return row < 0 ? null : getPathForRow(row); 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) { private void setHoverRow(int row) {
if (row == hoverRow) return; if (row == hoverRow) return;
repaintRow(hoverRow); repaintRow(hoverRow);
@@ -156,7 +156,7 @@ final class DropIndicatorTree extends JTree {
Graphics2D g2 = (Graphics2D) g.create(); Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(Theme.ACCENT); g2.setColor(Theme.accent());
if (highlight != null || loc.getChildIndex() < 0) { if (highlight != null || loc.getChildIndex() < 0) {
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath()); Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
if (r != null) { if (r != null) {
@@ -182,7 +182,7 @@ final class DropIndicatorTree extends JTree {
int[] rows = getSelectionRows(); int[] rows = getSelectionRows();
if (rows == null) return; if (rows == null) return;
Rectangle visible = getVisibleRect(); Rectangle visible = getVisibleRect();
g.setColor(Theme.TREE_SELECTION); g.setColor(Theme.treeSelection());
for (int row : rows) { for (int row : rows) {
Rectangle bounds = getRowBounds(row); Rectangle bounds = getRowBounds(row);
if (bounds != null) g.fillRect(visible.x, bounds.y, visible.width, bounds.height); 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(); Rectangle visible = getVisibleRect();
Graphics2D g2 = (Graphics2D) g.create(); Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 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.drawRoundRect(visible.x, bounds.y, visible.width - 1, bounds.height - 1, 4, 4);
g2.dispose(); g2.dispose();
} }

View File

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

View File

@@ -121,7 +121,7 @@ public final class Icons {
} }
public static ImageIcon clientIdle(int size) { 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() { public static ImageIcon clientTalking() {
@@ -129,7 +129,7 @@ public final class Icons {
} }
public static ImageIcon clientTalking(int size) { 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() { public static ImageIcon clientAway() {
@@ -137,7 +137,7 @@ public final class Icons {
} }
public static ImageIcon clientAway(int size) { 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. */ /** A channel commander that is not talking. */
@@ -151,7 +151,7 @@ public final class Icons {
} }
public static ImageIcon clientQuery() { 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() { public static ImageIcon micMuted() {
@@ -199,12 +199,12 @@ public final class Icons {
/** A channel commander that is not talking. */ /** A channel commander that is not talking. */
public static ImageIcon clientCommander(int size) { 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. */ /** A channel commander that is talking. */
public static ImageIcon clientCommanderTalking(int size) { 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 ---- // ---- toolbar / action icons ----
@@ -257,8 +257,8 @@ public final class Icons {
Graphics2D g = img.createGraphics(); Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null); g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null);
g.setColor(Theme.IDLE_CLIENT); g.setColor(Theme.idleClient());
g.setFont(Theme.UI_FONT); g.setFont(Theme.uiFont());
java.awt.FontMetrics fm = g.getFontMetrics(); java.awt.FontMetrics fm = g.getFontMetrics();
int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2; int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2;
g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1); g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1);
@@ -270,7 +270,7 @@ public final class Icons {
/** 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()));
} }
public static ImageIcon settings() { public static ImageIcon settings() {
@@ -329,7 +329,7 @@ public final class Icons {
} }
private static void paintMicMuted(Graphics2D g) { private static void paintMicMuted(Graphics2D g) {
g.setColor(Theme.MUTED); g.setColor(Theme.muted());
g.fillRoundRect(6, 2, 4, 7, 2, 2); g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140); g.drawArc(4, 6, 8, 6, 200, 140);
@@ -339,7 +339,7 @@ public final class Icons {
} }
private static void paintSpeakerMuted(Graphics2D g) { private static void paintSpeakerMuted(Graphics2D g) {
g.setColor(Theme.MUTED); g.setColor(Theme.muted());
g.fillRect(2, 6, 3, 4); g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10}; 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. */ /** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
private static void paintMicDisabled(Graphics2D g) { private static void paintMicDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT); g.setColor(Theme.idleClient());
g.fillRoundRect(6, 2, 4, 7, 2, 2); g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140); 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. */ /** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
private static void paintSpeakerDisabled(Graphics2D g) { private static void paintSpeakerDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT); g.setColor(Theme.idleClient());
g.fillRect(2, 6, 3, 4); g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10}; 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. */ /** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
private static void paintMicLocalMuted(Graphics2D g) { private static void paintMicLocalMuted(Graphics2D g) {
paintMicMuted(g); paintMicMuted(g);
g.setColor(Theme.ACCENT); g.setColor(Theme.accent());
g.fillOval(11, 10, 4, 4); g.fillOval(11, 10, 4, 4);
} }
@@ -387,7 +387,7 @@ public final class Icons {
} }
private static void paintDisconnect(Graphics2D g) { private static void paintDisconnect(Graphics2D g) {
g.setColor(Theme.MUTED); g.setColor(Theme.muted());
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8); g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2); g.fillRoundRect(8, 5, 5, 6, 2, 2);
@@ -414,7 +414,7 @@ public final class Icons {
} }
private static void paintMicActive(Graphics2D g) { private static void paintMicActive(Graphics2D g) {
g.setColor(Theme.TALKING); g.setColor(Theme.talking());
g.fillOval(1, 1, 14, 14); g.fillOval(1, 1, 14, 14);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.fillRoundRect(6, 3, 4, 6, 2, 2); g.fillRoundRect(6, 3, 4, 6, 2, 2);
@@ -450,7 +450,7 @@ public final class Icons {
} }
private static void paintApp(Graphics2D g) { private static void paintApp(Graphics2D g) {
g.setColor(Theme.ACCENT); g.setColor(Theme.accent());
g.fillRoundRect(1, 1, 14, 14, 4, 4); g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f)); 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 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 DescriptionHandler descriptionHandler;
private ChannelNode shownChannel; private ChannelNode shownChannel;
private ClientEntry shownClient; private ClientEntry shownClient;
@@ -43,20 +43,6 @@ public final class InfoPanel extends JScrollPane {
private boolean inChatTab; private boolean inChatTab;
public InfoPanel() { 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.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
pane.addHyperlinkListener(e -> { pane.addHyperlinkListener(e -> {
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return; if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
@@ -69,10 +55,15 @@ public final class InfoPanel extends JScrollPane {
} }
}); });
setViewportView(pane); setViewportView(pane);
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
clear(); clear();
} }
@Override
public void updateUI() {
super.updateUI();
setBorder(BorderFactory.createLineBorder(Theme.border()));
}
public void clear() { public void clear() {
shownChannel = null; shownChannel = null;
shownClient = null; shownClient = null;
@@ -118,7 +109,7 @@ public final class InfoPanel extends JScrollPane {
private String body() { private String body() {
if (shownChannel != null) return channelBody(shownChannel); if (shownChannel != null) return channelBody(shownChannel);
if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons); 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) { private static String channelBody(ChannelNode ch) {
@@ -132,16 +123,16 @@ public final class InfoPanel extends JScrollPane {
if (ch.description != null && !ch.description.isEmpty()) { if (ch.description != null && !ch.description.isEmpty()) {
sb.append("<div>").append(multiline(ch.description)).append("</div>"); sb.append("<div>").append(multiline(ch.description)).append("</div>");
} else if (ch.descriptionLoaded) { } else if (ch.descriptionLoaded) {
sb.append("<i style='color:#8a8a8a'>No description.</i>"); sb.append("<i class='muted'>No description.</i>");
} else { } else {
sb.append("<i style='color:#8a8a8a'>Loading description…</i>"); sb.append("<i class='muted'>Loading description…</i>");
} }
return sb.toString(); return sb.toString();
} }
private static String clientBody(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 class='muted'>(you)</span>" : "")));
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds); List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
if (serverGroups.isEmpty()) { if (serverGroups.isEmpty()) {
@@ -184,8 +175,7 @@ public final class InfoPanel extends JScrollPane {
} }
private void setHtml(String body) { private void setHtml(String body) {
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>" pane.setText("<html><body>" + body + "</body></html>");
+ body + "</body></html>");
pane.setCaretPosition(0); pane.setCaretPosition(0);
} }
@@ -206,7 +196,7 @@ public final class InfoPanel extends JScrollPane {
} }
private static void row(StringBuilder sb, String label, String value) { 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>"); .append(label).append(":</span> ").append(value).append("</div>");
} }
@@ -219,7 +209,4 @@ public final class InfoPanel extends JScrollPane {
return BBCode.escape(s); 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); g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
int level = dbToX(levelDb, w - 2); 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); g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
if (showThreshold) { 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; this.listener = listener;
setFloatable(false); setFloatable(false);
setBackground(Theme.TOOLBAR_BG); setBackground(Theme.toolbarBg());
setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6)); setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
setComponentPopupMenu(buildContextMenu()); setComponentPopupMenu(buildContextMenu());

View File

@@ -47,7 +47,7 @@ final class NicknameCellEditor extends DefaultTreeCellEditor {
static NicknameCellEditor create(JTree tree, DefaultTreeCellRenderer renderer, IntPredicate isSelf) { static NicknameCellEditor create(JTree tree, DefaultTreeCellRenderer renderer, IntPredicate isSelf) {
JTextField field = new JTextField(); JTextField field = new JTextField();
// Only our own row is edited, and that name is bold in the tree. // 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); 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)); cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0));
JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING); 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()); label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
// A label with a tooltip swallows mouse events, so select the tab explicitly. // A label with a tooltip swallows mouse events, so select the tab explicitly.
label.addMouseListener(new MouseAdapter() { label.addMouseListener(new MouseAdapter() {
@@ -169,12 +169,12 @@ final class ServerTabPane extends JPanel {
dragReorder.attach(label); dragReorder.attach(label);
JButton close = new JButton(""); JButton close = new JButton("");
close.setFont(Theme.UI_FONT); close.setFont(Theme.uiFont());
close.setFocusable(false); close.setFocusable(false);
close.setBorder(BorderFactory.createEmptyBorder()); close.setBorder(BorderFactory.createEmptyBorder());
close.setContentAreaFilled(false); close.setContentAreaFilled(false);
close.setMargin(new Insets(0, 0, 0, 0)); close.setMargin(new Insets(0, 0, 0, 0));
close.setForeground(Theme.CHAT_SYSTEM); close.setForeground(Theme.chatSystem());
close.setPreferredSize(new Dimension(14, 14)); close.setPreferredSize(new Dimension(14, 14));
close.setToolTipText("Close this connection"); close.setToolTipText("Close this connection");
close.addActionListener(e -> listener.closeTab(tab)); close.addActionListener(e -> listener.closeTab(tab));

View File

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

View File

@@ -151,8 +151,8 @@ public final class ServerTreePanel extends JScrollPane {
ui.setRightChildIndent(10); ui.setRightChildIndent(10);
} }
tree.setRowHeight(20); tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG); tree.setBackground(Theme.treeBg());
tree.setFont(Theme.UI_FONT); tree.setFont(Theme.uiFont());
ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId); ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId);
tree.setCellRenderer(renderer); tree.setCellRenderer(renderer);
nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId); nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId);
@@ -161,10 +161,13 @@ public final class ServerTreePanel extends JScrollPane {
tree.setPathEditable(nicknameEditor::editsPath); tree.setPathEditable(nicknameEditor::editsPath);
tree.setInvokesStopCellEditing(true); tree.setInvokesStopCellEditing(true);
setViewportView(tree); 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 // 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. // pixels a scroll would reuse are stale; repaint the whole viewport instead.
getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE); 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 // 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. // 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 DevicesPanel devicesPanel;
private final VoiceActivationPanel voiceActivationPanel; private final VoiceActivationPanel voiceActivationPanel;
private final NotificationsPanel notificationsPanel; private final NotificationsPanel notificationsPanel;
private final IconPackPanel iconPackPanel; private final DesignPanel designPanel;
private final HotkeysPanel hotkeysPanel; private final HotkeysPanel hotkeysPanel;
private final ClientVersionPanel clientVersionPanel; private final ClientVersionPanel clientVersionPanel;
@@ -46,7 +46,7 @@ public final class SettingsDialog extends JDialog {
this.onApply = onApply; this.onApply = onApply;
notificationsPanel = new NotificationsPanel(settings, sounds); notificationsPanel = new NotificationsPanel(settings, sounds);
iconPackPanel = new IconPackPanel(settings); designPanel = new DesignPanel(settings);
hotkeysPanel = new HotkeysPanel(hotkeys); hotkeysPanel = new HotkeysPanel(hotkeys);
clientVersionPanel = new ClientVersionPanel(settings); clientVersionPanel = new ClientVersionPanel(settings);
devicesPanel = new DevicesPanel(settings, livePlayback, devicesPanel = new DevicesPanel(settings, livePlayback,
@@ -58,7 +58,7 @@ public final class SettingsDialog extends JDialog {
tabs.addTab("Playback / Capture", scrollable(devicesPanel)); tabs.addTab("Playback / Capture", scrollable(devicesPanel));
tabs.addTab("Voice Activation", scrollable(voiceActivationPanel)); tabs.addTab("Voice Activation", scrollable(voiceActivationPanel));
tabs.addTab("Notifications", notificationsPanel); tabs.addTab("Notifications", notificationsPanel);
tabs.addTab("Design", iconPackPanel); tabs.addTab("Design", designPanel);
tabs.addTab("Hotkeys", hotkeysPanel); tabs.addTab("Hotkeys", hotkeysPanel);
tabs.addTab("Client Version", scrollable(clientVersionPanel)); tabs.addTab("Client Version", scrollable(clientVersionPanel));
@@ -128,7 +128,7 @@ public final class SettingsDialog extends JDialog {
private void apply() { private void apply() {
writeAudioSettings(settings); writeAudioSettings(settings);
notificationsPanel.apply(); notificationsPanel.apply();
iconPackPanel.apply(); designPanel.apply();
clientVersionPanel.apply(); clientVersionPanel.apply();
hotkeysPanel.apply(); hotkeysPanel.apply();
settings.save(); settings.save();
@@ -156,7 +156,7 @@ public final class SettingsDialog extends JDialog {
/** Leaves without applying, putting back the settings that preview themselves live. */ /** Leaves without applying, putting back the settings that preview themselves live. */
private void cancel() { private void cancel() {
notificationsPanel.revert(); notificationsPanel.revert();
iconPackPanel.revert(); designPanel.revert();
close(); close();
} }

View File

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

View File

@@ -1,39 +1,144 @@
package com.ts3client.ui; package com.ts3client.ui;
import javax.swing.UIManager;
import java.awt.Color; import java.awt.Color;
import java.awt.Font; 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 final class Theme {
public static final Color WINDOW_BG = new Color(0xF0F0F0); private static boolean dark;
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 Theme() { 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;
}
} }