Add preset themes selector under User Preferences > Application

Replace the single Dark Mode toggle with a curated selection of 9 preset
themes, exposed as a vertical radio-button group:

  Light                IntelliJ              Nord
  Dark                 Darcula               Dracula
  Solarized Light      Solarized Dark        Monokai Pro

Each Theme enum value pairs a human-readable display name with a FlatLaf
class name and a dark flag.  The first four come from the core flatlaf
dependency; the others come from flatlaf-intellij-themes which is added
as a new implementation dependency (~1MB).

ApplicationPreference now stores the selected theme by enum name under
the new ui.theme key.  isDarkMode() is retained as a compatibility shim
that delegates to the current theme.  Existing users with the legacy
dark.mode boolean are auto-migrated on first read.

ThemeManager is refactored to drive the LAF from the Theme:
- Reflectively instantiates the FlatLaf class.
- Reads Panel.background, Button.background, TextField.background,
  List.selectionBackground/Foreground, Label.foreground/disabledForeground,
  Component.borderColor and Table.alternateRowColor from UIManager *after*
  FlatLaf installs, then seeds those values into the JIDE-specific keys.
  Each theme automatically contributes its own palette to JIDE widgets
  without per-theme hardcoded colours.

ApplicationPreferenceEditor swaps the ToggleSwitch for a ToggleGroup of
RadioButtons that persist the selection through ApplicationPreference.setTheme.
This commit is contained in:
Akumas_Coffin_
2026-05-14 23:16:17 +10:00
parent 856b17197f
commit 56b0f6c9ca
5 changed files with 304 additions and 147 deletions

View File

@@ -94,6 +94,7 @@ dependencies {
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.20.1'
implementation 'com.fazecast:jSerialComm:2.11.4'
implementation 'com.formdev:flatlaf:3.5.4'
implementation 'com.formdev:flatlaf-intellij-themes:3.5.4'
implementation 'com.github.jiconfont:jiconfont-font_awesome:4.7.0.1'
implementation 'com.github.jiconfont:jiconfont-javafx:1.0.0'
implementation 'com.github.jiconfont:jiconfont-swing:1.0.1'

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,14 +19,17 @@
package io.github.dsheirer.gui.preference.application;
import io.github.dsheirer.gui.theme.Theme;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.application.ApplicationPreference;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.scene.control.Label;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Separator;
import javafx.scene.control.Spinner;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
@@ -45,7 +48,7 @@ public class ApplicationPreferenceEditor extends HBox
private Label mAutoStartTimeoutLabel;
private Spinner<Integer> mTimeoutSpinner;
private ToggleSwitch mAutomaticDiagnosticMonitoringToggle;
private ToggleSwitch mDarkModeToggle;
private VBox mThemeRadioGroup;
/**
* Constructs an instance
@@ -75,11 +78,9 @@ public class ApplicationPreferenceEditor extends HBox
mEditorPane.setHgap(3);
mEditorPane.setPadding(new Insets(10, 10, 10, 10));
Label appearanceLabel = new Label("Appearance");
mEditorPane.add(appearanceLabel, 0, row, 2, 1);
GridPane.setHalignment(getDarkModeToggle(), HPos.RIGHT);
mEditorPane.add(getDarkModeToggle(), 0, ++row);
mEditorPane.add(new Label("Dark Mode"), 1, row, 2, 1);
Label appearanceLabel = new Label("Theme");
mEditorPane.add(appearanceLabel, 0, row, 3, 1);
mEditorPane.add(getThemeRadioGroup(), 0, ++row, 3, 1);
Separator appearanceSeparator = new Separator(Orientation.HORIZONTAL);
GridPane.setHgrow(appearanceSeparator, Priority.ALWAYS);
@@ -152,18 +153,35 @@ public class ApplicationPreferenceEditor extends HBox
}
/**
* Toggle switch to enable/disable dark mode.
* Vertical group of radio buttons - one per {@link Theme}. Selecting one persists the theme
* via {@link ApplicationPreference#setTheme(Theme)} which triggers a global preference event
* and a re-theme through the ThemeManager.
*/
private ToggleSwitch getDarkModeToggle()
private VBox getThemeRadioGroup()
{
if(mDarkModeToggle == null)
if(mThemeRadioGroup == null)
{
mDarkModeToggle = new ToggleSwitch();
mDarkModeToggle.setSelected(mApplicationPreference.isDarkMode());
mDarkModeToggle.selectedProperty().addListener((observable, oldValue, enabled) ->
mApplicationPreference.setDarkMode(enabled));
mThemeRadioGroup = new VBox(4);
mThemeRadioGroup.setPadding(new Insets(2, 0, 2, 4));
ToggleGroup group = new ToggleGroup();
Theme current = mApplicationPreference.getTheme();
for(Theme theme: Theme.values())
{
RadioButton rb = new RadioButton(theme.getDisplayName());
rb.setToggleGroup(group);
rb.setUserData(theme);
rb.setSelected(theme == current);
rb.setOnAction(e -> {
if(rb.isSelected())
{
mApplicationPreference.setTheme((Theme) rb.getUserData());
}
});
mThemeRadioGroup.getChildren().add(rb);
}
}
return mDarkModeToggle;
return mThemeRadioGroup;
}
}

View File

@@ -0,0 +1,97 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.theme;
/**
* Curated preset themes for the application.
*
* <p>Each theme pairs a FlatLaf Swing look-and-feel class with a flag indicating whether the
* palette is dark. The dark flag drives whether the dark JavaFX stylesheet is applied to
* registered Scenes. Other per-theme values (colours used to seed JIDE-specific UIManager
* keys) are read at runtime from {@link javax.swing.UIManager} after the LAF installs, so each
* theme automatically contributes its own palette to JIDE components.
*/
public enum Theme
{
LIGHT("Light", "com.formdev.flatlaf.FlatLightLaf", false),
DARK("Dark", "com.formdev.flatlaf.FlatDarkLaf", true),
INTELLIJ("IntelliJ", "com.formdev.flatlaf.FlatIntelliJLaf", false),
DARCULA("Darcula", "com.formdev.flatlaf.FlatDarculaLaf", true),
NORD("Nord", "com.formdev.flatlaf.intellijthemes.FlatNordIJTheme", true),
DRACULA("Dracula", "com.formdev.flatlaf.intellijthemes.FlatDraculaIJTheme", true),
SOLARIZED_LIGHT("Solarized Light", "com.formdev.flatlaf.intellijthemes.FlatSolarizedLightIJTheme", false),
SOLARIZED_DARK("Solarized Dark", "com.formdev.flatlaf.intellijthemes.FlatSolarizedDarkIJTheme", true),
MONOKAI_PRO("Monokai Pro", "com.formdev.flatlaf.intellijthemes.FlatMonokaiProIJTheme", true);
private final String mDisplayName;
private final String mLafClassName;
private final boolean mDark;
Theme(String displayName, String lafClassName, boolean dark)
{
mDisplayName = displayName;
mLafClassName = lafClassName;
mDark = dark;
}
/**
* @return human-readable name shown in the preferences UI.
*/
public String getDisplayName()
{
return mDisplayName;
}
/**
* @return fully qualified FlatLaf class name to instantiate via reflection.
*/
public String getLafClassName()
{
return mLafClassName;
}
/**
* @return true if this theme is a dark palette - the JavaFX dark stylesheet is applied for
* dark themes and removed for light themes.
*/
public boolean isDark()
{
return mDark;
}
/**
* Resolve a theme by its enum name, returning {@link #LIGHT} as a safe default if {@code name}
* is null or unrecognised.
*/
public static Theme fromName(String name)
{
if(name != null)
{
for(Theme t: values())
{
if(t.name().equals(name))
{
return t;
}
}
}
return LIGHT;
}
}

View File

@@ -19,7 +19,6 @@
package io.github.dsheirer.gui.theme;
import com.formdev.flatlaf.FlatDarkLaf;
import com.formdev.flatlaf.FlatLightLaf;
import com.google.common.eventbus.Subscribe;
import com.jidesoft.plaf.LookAndFeelFactory;
@@ -45,14 +44,23 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Centralized light/dark theme manager.
* Centralized theme manager that drives both Swing and JavaFX appearance from the user
* preference selection.
*
* <p>Tracks every JavaFX {@link Scene} that opts in and applies (or removes) the dark stylesheet
* when the user toggles the {@code Dark Mode} preference. Also drives Swing look-and-feel
* via {@link FlatDarkLaf} / {@link FlatLightLaf} so the main Swing window and all child windows
* follow the same setting.
*
* <p>Subscribes to the global event bus for {@link PreferenceType#APPLICATION} updates.
* <p>Each {@link Theme} pairs a FlatLaf look-and-feel class with a dark/light flag. When a
* theme is applied:
* <ul>
* <li>The Swing LAF is installed (reflected from {@link Theme#getLafClassName()}).</li>
* <li>JIDE's LAF extension is reinstalled with an explicit Metal LAF for type detection so
* JIDE's compile-time {@code WindowsLookAndFeel} {@code instanceof} checks do not hit
* the missing-class branch on Linux/macOS JDKs.</li>
* <li>FlatLaf's standard defaults are re-applied on top of JIDE's clobber, and a set of
* JIDE-specific colour keys (JideTabbedPane.*, JideSplitPane.*, JideButton.*) are seeded
* from FlatLaf's just-installed values so each theme's palette propagates to JIDE widgets
* automatically.</li>
* <li>The dark JavaFX stylesheet is applied to (or removed from) every registered Scene
* depending on the theme's dark flag.</li>
* </ul>
*/
public class ThemeManager
{
@@ -64,7 +72,7 @@ public class ThemeManager
private final Set<WeakReference<java.awt.Component>> mSwingRoots = new LinkedHashSet<>();
private final String mDarkStylesheetUrl;
private volatile UserPreferences mUserPreferences;
private volatile boolean mDarkMode;
private volatile Theme mCurrentTheme = Theme.LIGHT;
private ThemeManager()
{
@@ -73,14 +81,11 @@ public class ThemeManager
if(mDarkStylesheetUrl == null)
{
mLog.warn("Dark stylesheet [{}] not found on classpath - dark mode will only affect Swing.",
mLog.warn("Dark stylesheet [{}] not found on classpath - dark themes will only affect Swing.",
DARK_STYLESHEET);
}
}
/**
* @return the singleton instance.
*/
public static ThemeManager getInstance()
{
return INSTANCE;
@@ -89,8 +94,6 @@ public class ThemeManager
/**
* Bind the theme manager to the user preferences and apply the current preference immediately.
* Safe to call multiple times - subsequent calls update the reference and re-apply.
*
* @param userPreferences source of the dark-mode preference.
*/
public void initialize(UserPreferences userPreferences)
{
@@ -101,30 +104,32 @@ public class ThemeManager
boolean firstBind = (mUserPreferences == null);
mUserPreferences = userPreferences;
mDarkMode = userPreferences.getApplicationPreference().isDarkMode();
mCurrentTheme = userPreferences.getApplicationPreference().getTheme();
if(firstBind)
{
MyEventBus.getGlobalEventBus().register(this);
}
applySwingLookAndFeel(mDarkMode);
applySwingLookAndFeel(mCurrentTheme);
}
/**
* @return current dark-mode state.
* @return the currently active theme.
*/
public Theme getCurrentTheme()
{
return mCurrentTheme;
}
/**
* @return true if the currently active theme is dark.
*/
public boolean isDarkMode()
{
return mDarkMode;
return mCurrentTheme != null && mCurrentTheme.isDark();
}
/**
* Register a Scene to receive theme updates. The Scene is held via {@link WeakReference}
* so callers do not need to unregister - garbage-collected Scenes are pruned on the next pass.
*
* @param scene to track.
*/
public void register(Scene scene)
{
if(scene == null)
@@ -138,16 +143,15 @@ public class ThemeManager
mScenes.add(new WeakReference<>(scene));
}
applyToScene(scene, mDarkMode);
applyToScene(scene, isDarkMode());
}
/**
* Register a Swing component that may be temporarily detached from any Window's component tree
* (e.g. swappable right-pane components in the Channel tab). Plain
* {@link SwingUtilities#updateComponentTreeUI(java.awt.Component)} walks of
* {@link Window#getWindows()} miss such components and they end up carrying stale colours from
* the LAF that was active when they were last attached. Registered components get
* {@code updateComponentTreeUI} called on them whenever the theme is applied.
* (e.g. swappable right-pane components in the Channel tab). Registered components get
* {@link SwingUtilities#updateComponentTreeUI(java.awt.Component)} called on them whenever the
* theme is applied, so they pick up the current palette even if they weren't attached when the
* user toggled.
*/
public void registerSwing(java.awt.Component component)
{
@@ -171,10 +175,6 @@ public class ThemeManager
}
}
/**
* Receives notification when any preference changes. Re-reads the dark-mode setting and
* propagates if it differs from the last known value.
*/
@Subscribe
public void onPreferenceUpdated(PreferenceType type)
{
@@ -183,20 +183,20 @@ public class ThemeManager
return;
}
boolean updated = mUserPreferences.getApplicationPreference().isDarkMode();
Theme updated = mUserPreferences.getApplicationPreference().getTheme();
if(updated == mDarkMode)
if(updated == mCurrentTheme)
{
return;
}
mDarkMode = updated;
mCurrentTheme = updated;
applyAll(updated);
}
private void applyAll(boolean darkMode)
private void applyAll(Theme theme)
{
applySwingLookAndFeel(darkMode);
applySwingLookAndFeel(theme);
Runnable fxApply = () -> {
synchronized(mScenes)
@@ -211,19 +211,17 @@ public class ThemeManager
}
else
{
applyToScene(scene, darkMode);
applyToScene(scene, theme.isDark());
}
}
}
//Also walk every currently-shown JavaFX window so that dialogs, alerts and
//popups that were not explicitly registered still pick up the theme.
for(javafx.stage.Window window: javafx.stage.Window.getWindows())
{
Scene scene = window.getScene();
if(scene != null)
{
applyToScene(scene, darkMode);
applyToScene(scene, theme.isDark());
}
}
};
@@ -270,21 +268,19 @@ public class ThemeManager
}
}
private void applySwingLookAndFeel(boolean darkMode)
private void applySwingLookAndFeel(Theme theme)
{
//Install the LAF synchronously on the calling thread so that any Swing components
//constructed immediately afterwards (in particular the main JFrame) find a fully
//populated UIDefaults set. UIManager.setLookAndFeel is safe to call before the EDT
//is running.
LookAndFeel newLaf;
//Install the LAF synchronously on the calling thread so any Swing components constructed
//immediately afterwards (in particular the main JFrame) find a fully populated UIDefaults
//set. UIManager.setLookAndFeel is safe to call before the EDT is running.
LookAndFeel newLaf = instantiate(theme);
try
{
newLaf = darkMode ? new FlatDarkLaf() : new FlatLightLaf();
UIManager.setLookAndFeel(newLaf);
}
catch(Exception e)
{
mLog.error("Unable to apply Swing look-and-feel for dark mode = " + darkMode, e);
mLog.error("Unable to apply Swing look-and-feel for theme = " + theme, e);
return;
}
@@ -292,8 +288,8 @@ public class ThemeManager
{
//JIDE 3.6.18 has compile-time `instanceof WindowsLookAndFeel` checks throughout
//LookAndFeelFactory. The 3-arg overload lets us hand JIDE an explicit Metal LAF
//instance for type detection while leaving the real (FlatLaf) defaults table in
//place; JIDE's Metal branch matches first.
//instance for type detection while leaving the real (FlatLaf) defaults table in place;
//JIDE's Metal branch matches first.
LookAndFeelFactory.installJideExtension(UIManager.getLookAndFeelDefaults(),
new MetalLookAndFeel(), LookAndFeelFactory.VSNET_STYLE);
}
@@ -303,12 +299,12 @@ public class ThemeManager
}
//JIDE's Metal-flavored extension overwrites FlatLaf's color/font/border defaults for
//standard Swing component keys. Re-apply FlatLaf's defaults on top of JIDE's install
//so the inner panels pick up dark backgrounds. JIDE's UI delegate registrations live
//under Jide-prefixed keys that FlatLaf does not define, so they survive untouched.
//standard Swing component keys. Re-apply FlatLaf's defaults on top of JIDE's install so
//the inner panels pick up the right colours. JIDE's UI delegate registrations live under
//Jide-prefixed keys that FlatLaf does not define, so they survive untouched.
try
{
UIDefaults flatFresh = (darkMode ? new FlatDarkLaf() : new FlatLightLaf()).getDefaults();
UIDefaults flatFresh = instantiate(theme).getDefaults();
UIDefaults active = UIManager.getLookAndFeelDefaults();
for(java.util.Map.Entry<Object, Object> entry: flatFresh.entrySet())
{
@@ -332,17 +328,14 @@ public class ThemeManager
mLog.warn("Unable to re-apply FlatLaf defaults after JIDE install", t);
}
//FlatLaf's per-component foreground/background lookups go through derived/lazy values
//that JIDE's Metal initializer was also feeding from, so even after the defaults copy
//above some labels and buttons end up with Metal-era dark foregrounds on a dark
//background. Brute-force the standard component colour keys via UIManager.put which
//writes to the user-defaults layer that takes precedence over the LAF defaults. Use
//near-white rather than FlatLaf's #bbbbbb so text reads sharply on the dark panels.
applyExplicitOverrides(darkMode);
//Brute-force the standard component colour keys via UIManager.put which writes to the
//user-defaults layer that takes precedence over the LAF defaults. Colours are read from
//the FlatLaf-installed defaults so each theme contributes its own palette.
applyExplicitOverrides(theme);
//Re-rendering any already-realized Swing windows must happen on the EDT. Also walk any
//Swing components explicitly registered via registerSwing(...) - they may be detached
//from any Window's tree at toggle time (e.g. swappable channel-tab right components).
//Re-rendering already-realized Swing windows must happen on the EDT. Also walk any Swing
//components explicitly registered via registerSwing(...) - they may be detached from any
//Window's tree at toggle time (e.g. swappable channel-tab right components).
Runnable updateRealized = () -> {
for(Window window: Window.getWindows())
{
@@ -377,44 +370,57 @@ public class ThemeManager
}
}
private void applyExplicitOverrides(boolean darkMode)
/**
* Reflectively instantiate the FlatLaf class for the given theme. Falls back to
* {@link FlatLightLaf} if the class is missing - that should not happen in a normal build but
* keeps the app usable if a theme jar is removed.
*/
private LookAndFeel instantiate(Theme theme)
{
//Colors stored under UIManager keys must be ColorUIResource (or null) for
//LookAndFeel.installColorsAndFont() to overwrite a component's existing colour
//on subsequent updateComponentTreeUI calls. Using a plain java.awt.Color pins
//the component foreground/background permanently and prevents the theme from
//ever toggling back.
ColorUIResource bgPanel;
ColorUIResource bgRaised;
ColorUIResource bgInput;
ColorUIResource bgSelection;
ColorUIResource fgPrimary;
ColorUIResource fgSelection;
ColorUIResource fgDisabled;
ColorUIResource border;
if(darkMode)
try
{
bgPanel = new ColorUIResource(0x2b2b2b);
bgRaised = new ColorUIResource(0x3c3f41);
bgInput = new ColorUIResource(0x313335);
bgSelection = new ColorUIResource(0x214283);
fgPrimary = new ColorUIResource(0xe6e6e6);
fgSelection = new ColorUIResource(Color.WHITE);
fgDisabled = new ColorUIResource(0x6a6a6a);
border = new ColorUIResource(0x4f5356);
Class<?> cls = Class.forName(theme.getLafClassName());
return (LookAndFeel) cls.getDeclaredConstructor().newInstance();
}
else
catch(Throwable t)
{
//Light mode mirrors FlatLightLaf's palette.
bgPanel = new ColorUIResource(0xf2f2f2);
bgRaised = new ColorUIResource(0xffffff);
bgInput = new ColorUIResource(0xffffff);
bgSelection = new ColorUIResource(0x2675bf);
fgPrimary = new ColorUIResource(0x1e1e1e);
fgSelection = new ColorUIResource(Color.WHITE);
fgDisabled = new ColorUIResource(0x8c8c8c);
border = new ColorUIResource(0xc4c4c4);
mLog.error("Unable to instantiate LAF [{}] for theme {} - falling back to FlatLightLaf",
theme.getLafClassName(), theme, t);
return new FlatLightLaf();
}
}
private void applyExplicitOverrides(Theme theme)
{
boolean darkMode = theme.isDark();
//Read the FlatLaf-installed colours so each theme contributes its own palette to the
//JIDE-specific keys and the user-defaults overrides. Fall back to reasonable defaults if
//a key is missing.
ColorUIResource bgPanel = uir(UIManager.getColor("Panel.background"),
darkMode ? 0x2b2b2b : 0xf2f2f2);
ColorUIResource bgRaised = uir(UIManager.getColor("Button.background"),
darkMode ? 0x3c3f41 : 0xffffff);
ColorUIResource bgInput = uir(UIManager.getColor("TextField.background"),
darkMode ? 0x313335 : 0xffffff);
ColorUIResource bgSelection = uir(UIManager.getColor("List.selectionBackground"),
darkMode ? 0x214283 : 0x2675bf);
ColorUIResource fgPrimary = uir(UIManager.getColor("Label.foreground"),
darkMode ? 0xe6e6e6 : 0x1e1e1e);
ColorUIResource fgSelection = uir(UIManager.getColor("List.selectionForeground"),
0xffffff);
ColorUIResource fgDisabled = uir(UIManager.getColor("Label.disabledForeground"),
darkMode ? 0x6a6a6a : 0x8c8c8c);
ColorUIResource border = uir(UIManager.getColor("Component.borderColor"),
darkMode ? 0x4f5356 : 0xc4c4c4);
ColorUIResource altRow = uir(UIManager.getColor("Table.alternateRowColor"),
darkMode ? 0x353739 : 0xfafafa);
//Use a slightly brighter foreground than FlatLaf's default in dark mode so text reads
//sharper on the JIDE-flavoured surfaces. Light themes keep their own contrast.
if(darkMode && fgPrimary.getRed() < 0xd0)
{
fgPrimary = new ColorUIResource(0xe6e6e6);
}
String[] simpleComponents = {
@@ -434,13 +440,11 @@ public class ThemeManager
UIManager.put(c + ".disabledText", fgDisabled);
}
//Buttons sit on a slightly raised tone than the panel they live on.
for(String c: new String[]{"Button", "ToggleButton", "ComboBox", "Spinner"})
{
UIManager.put(c + ".background", bgRaised);
}
//Text inputs use their own background tone for affordance.
for(String c: new String[]{"TextField", "TextArea", "TextPane", "EditorPane",
"FormattedTextField", "PasswordField"})
{
@@ -452,7 +456,6 @@ public class ThemeManager
UIManager.put(c + ".selectionForeground", fgSelection);
}
//Tables, trees and lists.
for(String c: new String[]{"Table", "Tree", "List"})
{
UIManager.put(c + ".background", bgInput);
@@ -462,15 +465,15 @@ public class ThemeManager
}
UIManager.put("Table.gridColor", border);
UIManager.put("Table.alternateRowColor", new ColorUIResource(darkMode ? 0x353739 : 0xfafafa));
UIManager.put("Table.alternateRowColor", altRow);
UIManager.put("TableHeader.background", bgRaised);
UIManager.put("TableHeader.foreground", fgPrimary);
UIManager.put("TableHeader.cellBorder", javax.swing.BorderFactory.createMatteBorder(0, 0, 1, 1, border));
UIManager.put("TableHeader.cellBorder",
javax.swing.BorderFactory.createMatteBorder(0, 0, 1, 1, border));
//Tab labels read black-on-dark with the JIDE-flavoured TabbedPane otherwise.
//Selected tabs sit on the raised surface (white in light mode, dark gray in dark mode),
//not on the blue selection background, so the selected-tab text is primary foreground -
//not the white selection foreground that is right for list/table row selections.
//Tab labels: selected tabs sit on the raised surface (which is white in light themes),
//not on the blue selection background, so the selected-tab text is the primary
//foreground - not the white selection foreground that is right for row selections.
UIManager.put("TabbedPane.foreground", fgPrimary);
UIManager.put("TabbedPane.background", bgPanel);
UIManager.put("TabbedPane.selectedForeground", fgPrimary);
@@ -479,13 +482,11 @@ public class ThemeManager
UIManager.put("TabbedPane.darkShadow", border);
UIManager.put("TabbedPane.shadow", border);
//Menu hover and selected states.
UIManager.put("MenuItem.selectionBackground", bgSelection);
UIManager.put("MenuItem.selectionForeground", fgSelection);
UIManager.put("Menu.selectionBackground", bgSelection);
UIManager.put("Menu.selectionForeground", fgSelection);
//Borders and split-pane divider colour.
UIManager.put("SplitPane.background", bgPanel);
UIManager.put("SplitPane.dividerFocusColor", bgSelection);
UIManager.put("SplitPaneDivider.draggingColor", bgSelection);
@@ -494,16 +495,11 @@ public class ThemeManager
UIManager.put("Separator.foreground", border);
UIManager.put("Separator.background", bgPanel);
//ToolTip needs to stand out from the panel.
UIManager.put("ToolTip.background", bgRaised);
UIManager.put("ToolTip.foreground", fgPrimary);
//JIDE components (JideTabbedPane, JideSplitPane, JideButton, etc.) read their colors
//from JIDE-specific UIManager keys, not the standard Swing keys. Set those too so
//tabs and other JIDE widgets render with the same palette.
//Selected/active tab text uses fgPrimary because the JIDE tabs sit on the raised
//surface, not the blue selection background. In light mode the raised tone is white,
//so fgSelection (also white) would render invisible-on-white.
//JIDE components read their colours from JIDE-specific UIManager keys, not the standard
//Swing keys. Seed those from the same palette so JIDE widgets follow the theme.
UIManager.put("JideTabbedPane.background", bgPanel);
UIManager.put("JideTabbedPane.foreground", fgPrimary);
UIManager.put("JideTabbedPane.tabAreaBackground", bgPanel);
@@ -535,6 +531,23 @@ public class ThemeManager
UIManager.put("JideLabel.foreground", fgPrimary);
}
/**
* Convert a {@link Color} to a {@link ColorUIResource}, falling back to {@code fallbackRgb} if
* the input is null. Existing UIResource colours are returned as-is.
*/
private static ColorUIResource uir(Color c, int fallbackRgb)
{
if(c == null)
{
return new ColorUIResource(fallbackRgb);
}
if(c instanceof ColorUIResource cuir)
{
return cuir;
}
return new ColorUIResource(c);
}
private void pruneDeadReferencesAndCheckPresent(Scene scene)
{
Iterator<WeakReference<Scene>> it = mScenes.iterator();

View File

@@ -19,6 +19,7 @@
package io.github.dsheirer.preference.application;
import io.github.dsheirer.gui.theme.Theme;
import io.github.dsheirer.preference.Preference;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.sample.Listener;
@@ -34,12 +35,13 @@ public class ApplicationPreference extends Preference
private static final String PREFERENCE_KEY_CHANNEL_AUTO_DIAGNOSTIC_MONITORING = "automatic.diagnostic.monitoring";
private static final String PREFERENCE_KEY_CHANNEL_AUTO_START_TIMEOUT = "channel.auto.start.timeout";
private static final String PREFERENCE_KEY_DARK_MODE = "dark.mode";
private static final String PREFERENCE_KEY_THEME = "ui.theme";
private final static Logger mLog = LoggerFactory.getLogger(ApplicationPreference.class);
private Preferences mPreferences = Preferences.userNodeForPackage(ApplicationPreference.class);
private Integer mChannelAutoStartTimeout;
private Boolean mAutomaticDiagnosticMonitoring;
private Boolean mDarkMode;
private Theme mTheme;
/**
* Constructs an instance
@@ -108,27 +110,53 @@ public class ApplicationPreference extends Preference
}
/**
* Indicates whether dark mode is enabled for the application UI.
* @return enabled.
* Returns the selected UI theme. Falls back to a sensible default if no preference has been
* stored - if the legacy {@code dark.mode} boolean is true the default is {@link Theme#DARK},
* otherwise {@link Theme#LIGHT}.
*/
public boolean isDarkMode()
public Theme getTheme()
{
if(mDarkMode == null)
if(mTheme == null)
{
mDarkMode = mPreferences.getBoolean(PREFERENCE_KEY_DARK_MODE, false);
String stored = mPreferences.get(PREFERENCE_KEY_THEME, null);
if(stored != null)
{
mTheme = Theme.fromName(stored);
}
else
{
//Migrate from the older boolean preference so existing users keep their setting.
mTheme = mPreferences.getBoolean(PREFERENCE_KEY_DARK_MODE, false) ? Theme.DARK : Theme.LIGHT;
}
}
return mDarkMode;
return mTheme;
}
/**
* Sets the dark mode preference.
* @param enabled true to enable dark mode.
* Sets the selected UI theme.
*/
public void setDarkMode(boolean enabled)
public void setTheme(Theme theme)
{
mDarkMode = enabled;
mPreferences.putBoolean(PREFERENCE_KEY_DARK_MODE, enabled);
if(theme == null)
{
theme = Theme.LIGHT;
}
mTheme = theme;
mPreferences.put(PREFERENCE_KEY_THEME, theme.name());
//Keep the legacy boolean in sync so any older code path that reads it still behaves
//correctly.
mPreferences.putBoolean(PREFERENCE_KEY_DARK_MODE, theme.isDark());
notifyPreferenceUpdated();
}
/**
* @return true if the currently selected theme is a dark palette. Retained for callers that
* only care about the dark/light distinction.
*/
public boolean isDarkMode()
{
return getTheme().isDark();
}
}