Darken the dark theme, and hover a tree row like a menu

The client's own look-and-feel defaults now live beside LookAndFeelManager as
FlatLaf properties files, which FlatLaf loads on top of each theme's own,
instead of a handful of UIManager.put calls.

The dark theme is built on three surfaces keyed to the toolbar's #282828: the
window chrome, the tab strips and menu bar one step below it at #232323, and
the content the client shows — channel tree, chat log, text fields, lists — at
#1e1e1e. Selections are #364b6f, and channel names in the tree are white; the
chat lines about server events keep the blue they had, which channel names no
longer carry. Everything FlatLaf derives (borders, scroll bars, hover states)
follows from those.

A read-only editor pane is a chat log or an information view here, not a
disabled input, so it keeps the content background rather than FlatLaf's
inactive one; and since neither a text view nor a tree fills its viewport, the
space below the last line takes their background instead of the window's.

The row under the pointer is now washed in the same colour a hovered menu is,
rather than outlined. FlatLaf states that colour as a shift of the surface
beneath it, so it is resolved against the window background exactly as the
menu bar resolves it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:07:19 +00:00
parent 35f10ed14c
commit 98719d717e
9 changed files with 100 additions and 46 deletions

View File

@@ -318,6 +318,7 @@ public final class ChatPanel extends JPanel {
}); });
scroll = new JScrollPane(log); scroll = new JScrollPane(log);
HtmlStyles.fillViewport(log);
scroll.setBorder(BorderFactory.createEmptyBorder()); scroll.setBorder(BorderFactory.createEmptyBorder());
} }

View File

@@ -148,9 +148,9 @@ final class DropIndicatorTree extends JTree {
g.setColor(getBackground()); g.setColor(getBackground());
g.fillRect(clip.x, clip.y, clip.width, clip.height); g.fillRect(clip.x, clip.y, clip.width, clip.height);
paintSelection(g); paintSelection(g);
paintHover(g);
super.paintComponent(g); super.paintComponent(g);
paintBadges(g); paintBadges(g);
paintHover(g);
JTree.DropLocation loc = getDropLocation(); JTree.DropLocation loc = getDropLocation();
if (loc == null || loc.getPath() == null) return; if (loc == null || loc.getPath() == null) return;
@@ -190,22 +190,19 @@ final class DropIndicatorTree extends JTree {
} }
/** /**
* Outlines the row under the pointer, across the full width the row occupies — * Washes the row under the pointer across the full width it occupies — the same
* the same area a click on it acts on. Spacers, which nothing can be done with, * area a click on it acts on. A selected row keeps its selection colour, and
* are left alone. * spacers, which nothing can be done with, are left alone.
*/ */
private void paintHover(Graphics g) { private void paintHover(Graphics g) {
if (hoverRow < 0 || getDropLocation() != null) return; if (hoverRow < 0 || getDropLocation() != null || isRowSelected(hoverRow)) return;
Rectangle bounds = getRowBounds(hoverRow); Rectangle bounds = getRowBounds(hoverRow);
TreePath path = getPathForRow(hoverRow); TreePath path = getPathForRow(hoverRow);
if (bounds == null || path == null || isSpacer(path)) return; if (bounds == null || path == null || isSpacer(path)) return;
Rectangle visible = getVisibleRect(); Rectangle visible = getVisibleRect();
Graphics2D g2 = (Graphics2D) g.create(); g.setColor(Theme.hover());
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
g2.setColor(Theme.treeHover());
g2.drawRoundRect(visible.x, bounds.y, visible.width - 1, bounds.height - 1, 4, 4);
g2.dispose();
} }
private boolean isSpacer(TreePath path) { private boolean isSpacer(TreePath path) {

View File

@@ -3,6 +3,7 @@ package com.ts3client.ui;
import com.ts3client.text.BBCode; import com.ts3client.text.BBCode;
import javax.swing.JEditorPane; import javax.swing.JEditorPane;
import javax.swing.JViewport;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.text.html.HTMLEditorKit; import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet; import javax.swing.text.html.StyleSheet;
@@ -42,6 +43,17 @@ final class HtmlStyles {
return pane; return pane;
} }
/**
* Paints the scroll pane {@code pane} sits in with the pane's own background: text
* only reaches as far as it goes, and the empty space below it would otherwise show
* the window's colour instead of the log's.
*/
static void fillViewport(JEditorPane pane) {
if (pane.getParent() instanceof JViewport viewport) {
viewport.setBackground(pane.getBackground());
}
}
/** Re-themes {@code pane}, keeping the text it shows. */ /** Re-themes {@code pane}, keeping the text it shows. */
private static void restyle(JEditorPane pane) { private static void restyle(JEditorPane pane) {
// Also runs from JEditorPane's constructor, before the pane is one of ours. // Also runs from JEditorPane's constructor, before the pane is one of ours.
@@ -68,7 +80,7 @@ final class HtmlStyles {
// about what happened on the server. // about what happened on the server.
css.addRule(".muted { color:" + hex(Theme.chatSystem()) + "; }"); css.addRule(".muted { color:" + hex(Theme.chatSystem()) + "; }");
css.addRule(".name { color:" + hex(Theme.chatName()) + "; font-weight:bold; }"); css.addRule(".name { color:" + hex(Theme.chatName()) + "; font-weight:bold; }");
css.addRule(".event { color:" + hex(Theme.channelText()) + "; }"); css.addRule(".event { color:" + hex(Theme.chatEvent()) + "; }");
css.addRule("a { color:" + hex(Theme.chatName()) + "; text-decoration:none; }"); css.addRule("a { color:" + hex(Theme.chatName()) + "; text-decoration:none; }");
// Client references look exactly like a message author's name. // Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
@@ -83,6 +95,7 @@ final class HtmlStyles {
kit.setStyleSheet(css); kit.setStyleSheet(css);
pane.setEditorKit(kit); pane.setEditorKit(kit);
fillViewport(pane);
if (html != null) { if (html != null) {
pane.setText(html); pane.setText(html);
pane.setCaretPosition(atEnd ? pane.getDocument().getLength() : 0); pane.setCaretPosition(atEnd ? pane.getDocument().getLength() : 0);

View File

@@ -55,6 +55,7 @@ public final class InfoPanel extends JScrollPane {
} }
}); });
setViewportView(pane); setViewportView(pane);
HtmlStyles.fillViewport(pane);
clear(); clear();
} }

View File

@@ -5,29 +5,34 @@ import com.formdev.flatlaf.FlatLaf;
import com.formdev.flatlaf.FlatLightLaf; import com.formdev.flatlaf.FlatLightLaf;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import javax.swing.UIManager;
import java.awt.Window; import java.awt.Window;
/** /**
* Installs FlatLaf in the requested variant and keeps {@link Theme} in step with it. * 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 * <p>The client's own look-and-feel defaults — rounded controls, and the darker palette
* controls, a slimmer tree row and scroll bars that only show their thumb. * the dark theme is built on — live beside this class as FlatLaf properties files, which
* FlatLaf loads on top of the theme's own.
*/ */
public final class LookAndFeelManager { public final class LookAndFeelManager {
/** Where our own FlatLaf properties files sit. */
private static final String DEFAULTS_PACKAGE = "com.ts3client.ui.laf";
static {
// Registered once: FlatLaf keeps every registration, and install() runs again on
// each theme switch.
FlatLaf.registerCustomDefaultsSource(DEFAULTS_PACKAGE);
}
private LookAndFeelManager() { private LookAndFeelManager() {
} }
/** Installs the look-and-feel for {@code appearance}. Call before building any window. */ /** Installs the look-and-feel for {@code appearance}. Call before building any window. */
public static void install(Settings.Appearance appearance) { public static void install(Settings.Appearance appearance) {
FlatLaf laf = appearance == Settings.Appearance.DARK ? new FlatDarkLaf() : new FlatLightLaf(); FlatLaf laf = appearance == Settings.Appearance.DARK ? new FlatDarkLaf() : new FlatLightLaf();
if (!FlatLaf.setup(laf)) { // Whatever look-and-feel stays installed on failure, Theme falls back to its own palette.
// Leave whatever look-and-feel is installed; Theme falls back to its own palette. FlatLaf.setup(laf);
Theme.refresh();
return;
}
applyDefaults();
Theme.refresh(); Theme.refresh();
} }
@@ -43,17 +48,4 @@ public final class LookAndFeelManager {
w.repaint(); 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

@@ -161,7 +161,6 @@ 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.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);
@@ -279,6 +278,14 @@ public final class ServerTreePanel extends JScrollPane {
ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY()); ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY());
} }
@Override
public void updateUI() {
super.updateUI();
// The rows stop at the last channel, so the space below them belongs to the tree
// rather than to the window behind it.
if (getViewport() != null) getViewport().setBackground(Theme.treeBg());
}
/** The path of the tree node showing {@code target}, or {@code null}. */ /** The path of the tree node showing {@code target}, or {@code null}. */
private TreePath pathOf(Object target) { private TreePath pathOf(Object target) {
java.util.Enumeration<?> nodes = root.breadthFirstEnumeration(); java.util.Enumeration<?> nodes = root.breadthFirstEnumeration();

View File

@@ -1,5 +1,7 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.formdev.flatlaf.ui.FlatUIUtils;
import javax.swing.UIManager; import javax.swing.UIManager;
import java.awt.Color; import java.awt.Color;
import java.awt.Font; import java.awt.Font;
@@ -44,11 +46,11 @@ public final class Theme {
// ---- surfaces, from the look-and-feel ---- // ---- surfaces, from the look-and-feel ----
public static Color windowBg() { public static Color windowBg() {
return ui("Panel.background", 0xF0F0F0, 0x3C3F41); return ui("Panel.background", 0xF0F0F0, 0x282828);
} }
public static Color toolbarBg() { public static Color toolbarBg() {
return ui("ToolBar.background", 0xE6E9ED, 0x3C3F41); return ui("ToolBar.background", 0xE6E9ED, 0x282828);
} }
public static Color statusBg() { public static Color statusBg() {
@@ -56,42 +58,52 @@ public final class Theme {
} }
public static Color treeBg() { public static Color treeBg() {
return ui("Tree.background", 0xFFFFFF, 0x2B2B2B); return ui("Tree.background", 0xFFFFFF, 0x1E1E1E);
} }
public static Color treeText() { public static Color treeText() {
return ui("Tree.foreground", 0x1E1E1E, 0xDFE1E5); return ui("Tree.foreground", 0x1E1E1E, 0xE0E0E0);
} }
public static Color treeSelection() { public static Color treeSelection() {
return ui("Tree.selectionBackground", 0xCFE3FB, 0x2F5075); return ui("Tree.selectionBackground", 0xCFE3FB, 0x364B6F);
} }
/** Outline drawn around the row the pointer is over. */ /**
public static Color treeHover() { * Behind the row the pointer is over: the same wash the menu bar puts behind a hovered
return pick(0x9CC4EE, 0x4A6E96); * menu. The look-and-feel states it as a shift of whatever surface it sits on, and the
* menu bar resolves it against the window, so this does too — a row hover and a menu
* hover then carry the very same colour.
*/
public static Color hover() {
return FlatUIUtils.deriveColor(ui("MenuBar.hoverBackground", 0xE6E6E6, 0x3D3D3D), windowBg());
} }
public static Color chatBg() { public static Color chatBg() {
return ui("TextPane.background", 0xFAFAFA, 0x2B2B2B); return ui("TextPane.background", 0xFAFAFA, 0x1E1E1E);
} }
public static Color border() { public static Color border() {
return ui("Component.borderColor", 0xD0D0D0, 0x4B4B4B); return ui("Component.borderColor", 0xD0D0D0, 0x3E3E3E);
} }
public static Color chatText() { public static Color chatText() {
return ui("TextPane.foreground", 0x202020, 0xDFE1E5); return ui("TextPane.foreground", 0x202020, 0xE0E0E0);
} }
// ---- status colours ---- // ---- status colours ----
public static Color channelText() { public static Color channelText() {
return pick(0x21486B, 0xFFFFFF);
}
/** The colour of a chat line about what happened on the server. */
public static Color chatEvent() {
return pick(0x21486B, 0x8FBCE6); return pick(0x21486B, 0x8FBCE6);
} }
public static Color serverText() { public static Color serverText() {
return pick(0x123456, 0xB9D4F0); return pick(0x123456, 0xDCE8F5);
} }
public static Color talking() { public static Color talking() {

View File

@@ -0,0 +1,17 @@
#
# A darker dark theme than FlatLaf's own, keyed to the toolbar's #282828.
#
# Three levels of surface: the window chrome (toolbar, panels, status bar) at
# @background, the tab strips one step below it, and the content the client shows
# (channel tree, chat log, text fields, lists) one step below those.
#
@background = #282828
@foreground = #e0e0e0
@componentBackground = #1e1e1e
@menuBackground = #232323
# Selected channel tree row, and every other selection with it.
@selectionBackground = #364b6f
TabbedPane.background = #232323

View File

@@ -0,0 +1,14 @@
#
# Look-and-feel defaults for both themes. Loaded by FlatLaf itself, on top of the
# theme's own properties; see LookAndFeelManager.
#
# Softer corners than FlatLaf's default, and a tab strip with room to breathe.
Component.arc = 6
Button.arc = 6
Component.focusWidth = 1
TabbedPane.tabHeight = 26
# A read-only editor pane is a chat log or an information view here, not a disabled
# input, so it keeps the content background instead of the window's.
EditorPane.inactiveBackground = @componentBackground