diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index b356ea6..c8ba187 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -27,6 +27,12 @@ public final class Settings { 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. */ public enum VadMode { /** 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. */ public String iconPackDir = ""; + /** Look-and-feel variant; see {@link Appearance}. */ + public Appearance appearance = Appearance.LIGHT; + // ---- window chrome ---- /** Whether the status bar at the bottom of the window is shown. */ public boolean showStatusBar = true; @@ -218,6 +227,7 @@ public final class Settings { soundPackDir = props.getProperty("soundPackDir", soundPackDir); iconPack = props.getProperty("iconPack", iconPack); iconPackDir = props.getProperty("iconPackDir", iconPackDir); + appearance = parseAppearance(props.getProperty("appearance"), appearance); showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar); showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider); notifications.load(props); @@ -259,6 +269,7 @@ public final class Settings { props.setProperty("soundPackDir", soundPackDir); props.setProperty("iconPack", iconPack); props.setProperty("iconPackDir", iconPackDir); + props.setProperty("appearance", appearance.name()); props.setProperty("showStatusBar", Boolean.toString(showStatusBar)); props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider)); 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) { if (v == null) return def; try { diff --git a/ts3-client/pom.xml b/ts3-client/pom.xml index db3079e..6cdfc81 100644 --- a/ts3-client/pom.xml +++ b/ts3-client/pom.xml @@ -23,6 +23,7 @@ UTF-8 1.0.3 2.1.0 + 3.7.2 3.5.6 @@ -50,6 +51,11 @@ jsvg ${jsvg.version} + + com.formdev + flatlaf + ${flatlaf.version} + com.ts3client ts3-client-core diff --git a/ts3-client/swing/pom.xml b/ts3-client/swing/pom.xml index ad09dbd..815c722 100644 --- a/ts3-client/swing/pom.xml +++ b/ts3-client/swing/pom.xml @@ -35,6 +35,10 @@ com.github.weisj jsvg + + com.formdev + flatlaf + org.junit.jupiter junit-jupiter @@ -84,6 +88,15 @@ ** + + + com.formdev:flatlaf + + ** + + *:* diff --git a/ts3-client/swing/src/main/java/com/ts3client/Main.java b/ts3-client/swing/src/main/java/com/ts3client/Main.java index 0be765e..6aa28a9 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/Main.java +++ b/ts3-client/swing/src/main/java/com/ts3client/Main.java @@ -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); }); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelPermissionsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelPermissionsPanel.java index 3a88662..46b8ab1 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelPermissionsPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelPermissionsPanel.java @@ -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) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java index b266390..5aa4a77 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java @@ -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("" + stamp() + edt(() -> serverTab.appendLine("" + stamp() + BBCode.escape(text) + "")); } @@ -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("" + stamp() + edt(() -> serverTab.appendLine("" + stamp() + BBCode.linksToHtml(text) + "")); } @@ -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("
"); - 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 ? "" + name + "" - : "" + name + ""; - appendLine("" + stamp() + "" + : "" + name + ""; + appendLine("" + stamp() + "" + 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"), "
" + html + "
"); 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); - } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectionInfoDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectionInfoDialog.java index 82442f2..f709051 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectionInfoDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectionInfoDialog.java @@ -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); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionEditorDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionEditorDialog.java index 203f676..455aaf2 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionEditorDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionEditorDialog.java @@ -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()); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconPackPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java similarity index 75% rename from ts3-client/swing/src/main/java/com/ts3client/ui/IconPackPanel.java rename to ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java index 214dcc6..84ea932 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IconPackPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java @@ -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. * - *

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. + *

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 appearanceCombo = + new JComboBox<>(Settings.Appearance.values()); private final JComboBox packCombo = new JComboBox<>(); private final JLabel packInfo = new JLabel(); private final JTextField packDirField; private final DefaultListModel previewModel = new DefaultListModel<>(); private final JList 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; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java index b8839be..b5f4846 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java @@ -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(); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java index 5f3f706..8da5064 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java @@ -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); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java new file mode 100644 index 0000000..02cf7c5 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java @@ -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. + * + *

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()); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconChooserDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IconChooserDialog.java index f5a821e..f83285f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IconChooserDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IconChooserDialog.java @@ -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); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java index f7df09e..af4141a 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -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)); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index f9b1051..3db2a84 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -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 "Select a channel or client to see details."; + return "Select a channel or client to see details."; } 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("

").append(multiline(ch.description)).append("
"); } else if (ch.descriptionLoaded) { - sb.append("No description."); + sb.append("No description."); } else { - sb.append("Loading description…"); + sb.append("Loading description…"); } 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 ? " (you)" : ""))); + sb.append(heading(esc(cl.nickname) + (cl.self ? " (you)" : ""))); List 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("" - + body + ""); + pane.setText("" + body + ""); 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("
") + sb.append("
") .append(label).append(": ").append(value).append("
"); } @@ -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); - } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/LevelMeter.java b/ts3-client/swing/src/main/java/com/ts3client/ui/LevelMeter.java index 88d7913..d874c27 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/LevelMeter.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/LevelMeter.java @@ -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) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/LookAndFeelManager.java b/ts3-client/swing/src/main/java/com/ts3client/ui/LookAndFeelManager.java new file mode 100644 index 0000000..da3b83c --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/LookAndFeelManager.java @@ -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. + * + *

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); + } + +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java index 093d327..37cc0d0 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java @@ -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()); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/NicknameCellEditor.java b/ts3-client/swing/src/main/java/com/ts3client/ui/NicknameCellEditor.java index 2f935a2..918cdfe 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/NicknameCellEditor.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/NicknameCellEditor.java @@ -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); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java index 84a46dc..1911a2c 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java @@ -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)); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java index 5824978..4ded35a 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeCellRenderer.java @@ -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; } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index 74e855d..631961c 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -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. diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java index 815367d..37617cb 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -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(); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/StatusBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/StatusBar.java index 5286743..76ee6b2 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/StatusBar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/StatusBar.java @@ -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); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java index 21234ff..d9dbdb6 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java @@ -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. + * + *

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