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);
HtmlStyles.fillViewport(log);
scroll.setBorder(BorderFactory.createEmptyBorder());
}

View File

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

View File

@@ -3,6 +3,7 @@ package com.ts3client.ui;
import com.ts3client.text.BBCode;
import javax.swing.JEditorPane;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
@@ -42,6 +43,17 @@ final class HtmlStyles {
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. */
private static void restyle(JEditorPane pane) {
// 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.
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(".event { color:" + hex(Theme.chatEvent()) + "; }");
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
@@ -83,6 +95,7 @@ final class HtmlStyles {
kit.setStyleSheet(css);
pane.setEditorKit(kit);
fillViewport(pane);
if (html != null) {
pane.setText(html);
pane.setCaretPosition(atEnd ? pane.getDocument().getLength() : 0);

View File

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

View File

@@ -5,29 +5,34 @@ 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.
* <p>The client's own look-and-feel defaults — rounded controls, and the darker palette
* 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 {
/** 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() {
}
/** 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();
// Whatever look-and-feel stays installed on failure, Theme falls back to its own palette.
FlatLaf.setup(laf);
Theme.refresh();
}
@@ -43,17 +48,4 @@ public final class LookAndFeelManager {
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.setInvokesStopCellEditing(true);
setViewportView(tree);
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);
@@ -279,6 +278,14 @@ public final class ServerTreePanel extends JScrollPane {
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}. */
private TreePath pathOf(Object target) {
java.util.Enumeration<?> nodes = root.breadthFirstEnumeration();

View File

@@ -1,5 +1,7 @@
package com.ts3client.ui;
import com.formdev.flatlaf.ui.FlatUIUtils;
import javax.swing.UIManager;
import java.awt.Color;
import java.awt.Font;
@@ -44,11 +46,11 @@ public final class Theme {
// ---- surfaces, from the look-and-feel ----
public static Color windowBg() {
return ui("Panel.background", 0xF0F0F0, 0x3C3F41);
return ui("Panel.background", 0xF0F0F0, 0x282828);
}
public static Color toolbarBg() {
return ui("ToolBar.background", 0xE6E9ED, 0x3C3F41);
return ui("ToolBar.background", 0xE6E9ED, 0x282828);
}
public static Color statusBg() {
@@ -56,42 +58,52 @@ public final class Theme {
}
public static Color treeBg() {
return ui("Tree.background", 0xFFFFFF, 0x2B2B2B);
return ui("Tree.background", 0xFFFFFF, 0x1E1E1E);
}
public static Color treeText() {
return ui("Tree.foreground", 0x1E1E1E, 0xDFE1E5);
return ui("Tree.foreground", 0x1E1E1E, 0xE0E0E0);
}
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() {
return pick(0x9CC4EE, 0x4A6E96);
/**
* Behind the row the pointer is over: the same wash the menu bar puts behind a hovered
* 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() {
return ui("TextPane.background", 0xFAFAFA, 0x2B2B2B);
return ui("TextPane.background", 0xFAFAFA, 0x1E1E1E);
}
public static Color border() {
return ui("Component.borderColor", 0xD0D0D0, 0x4B4B4B);
return ui("Component.borderColor", 0xD0D0D0, 0x3E3E3E);
}
public static Color chatText() {
return ui("TextPane.foreground", 0x202020, 0xDFE1E5);
return ui("TextPane.foreground", 0x202020, 0xE0E0E0);
}
// ---- status colours ----
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);
}
public static Color serverText() {
return pick(0x123456, 0xB9D4F0);
return pick(0x123456, 0xDCE8F5);
}
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