From 56b0f6c9caabc6d3b5e5bd2bf6914be64609a34e Mon Sep 17 00:00:00 2001 From: Akumas_Coffin_ <68540569+AkumasCoffin@users.noreply.github.com> Date: Thu, 14 May 2026 23:16:17 +1000 Subject: [PATCH] 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. --- build.gradle | 1 + .../ApplicationPreferenceEditor.java | 48 ++-- .../io/github/dsheirer/gui/theme/Theme.java | 97 +++++++ .../dsheirer/gui/theme/ThemeManager.java | 253 +++++++++--------- .../application/ApplicationPreference.java | 52 +++- 5 files changed, 304 insertions(+), 147 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/gui/theme/Theme.java diff --git a/build.gradle b/build.gradle index ddf30202..b927855a 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/io/github/dsheirer/gui/preference/application/ApplicationPreferenceEditor.java b/src/main/java/io/github/dsheirer/gui/preference/application/ApplicationPreferenceEditor.java index 1b5b815e..7fd1252d 100644 --- a/src/main/java/io/github/dsheirer/gui/preference/application/ApplicationPreferenceEditor.java +++ b/src/main/java/io/github/dsheirer/gui/preference/application/ApplicationPreferenceEditor.java @@ -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 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; } } diff --git a/src/main/java/io/github/dsheirer/gui/theme/Theme.java b/src/main/java/io/github/dsheirer/gui/theme/Theme.java new file mode 100644 index 00000000..e88afdec --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/theme/Theme.java @@ -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 + * **************************************************************************** + */ + +package io.github.dsheirer.gui.theme; + +/** + * Curated preset themes for the application. + * + *

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; + } +} diff --git a/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java b/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java index bc122ece..bc43b747 100644 --- a/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java +++ b/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java @@ -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. * - *

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

Subscribes to the global event bus for {@link PreferenceType#APPLICATION} updates. + *

Each {@link Theme} pairs a FlatLaf look-and-feel class with a dark/light flag. When a + * theme is applied: + *

*/ public class ThemeManager { @@ -64,7 +72,7 @@ public class ThemeManager private final Set> 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 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> it = mScenes.iterator(); diff --git a/src/main/java/io/github/dsheirer/preference/application/ApplicationPreference.java b/src/main/java/io/github/dsheirer/preference/application/ApplicationPreference.java index 64bfc576..eb98c805 100644 --- a/src/main/java/io/github/dsheirer/preference/application/ApplicationPreference.java +++ b/src/main/java/io/github/dsheirer/preference/application/ApplicationPreference.java @@ -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(); + } }