diff --git a/build.gradle b/build.gradle
index beec9739..01640454 100644
--- a/build.gradle
+++ b/build.gradle
@@ -89,6 +89,8 @@ dependencies {
implementation 'ch.qos.logback:logback-core:1.5.21'
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/settings.gradle b/settings.gradle
index 78382907..62a13f1d 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,4 +1,4 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
-rootProject.name = 'sdr-trunk'
\ No newline at end of file
+rootProject.name = 'sdr-trunk'
diff --git a/src/main/java/com/sun/java/swing/plaf/windows/WindowsLookAndFeel.java b/src/main/java/com/sun/java/swing/plaf/windows/WindowsLookAndFeel.java
new file mode 100644
index 00000000..8603623a
--- /dev/null
+++ b/src/main/java/com/sun/java/swing/plaf/windows/WindowsLookAndFeel.java
@@ -0,0 +1,81 @@
+/*
+ * *****************************************************************************
+ * 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 com.sun.java.swing.plaf.windows;
+
+import javax.swing.LookAndFeel;
+import javax.swing.UIDefaults;
+
+/**
+ * No-op stub for the JDK's Windows look-and-feel.
+ *
+ *
JIDE 3.6.18's {@code LookAndFeelFactory} contains compile-time
+ * {@code instanceof com.sun.java.swing.plaf.windows.WindowsLookAndFeel} checks throughout its
+ * dispatch logic. On Linux and macOS JDK builds that class is genuinely absent from
+ * {@code java.desktop}, so the JVM throws {@link NoClassDefFoundError} the first time JIDE's
+ * dispatch code executes the {@code instanceof} bytecode. That happens during
+ * {@link com.jidesoft.swing.JideSplitPane#updateUI()} on every LAF change.
+ *
+ *
This stub lives in the classpath so the class reference resolves on non-Windows JDKs.
+ * On Windows JDK builds the real {@code WindowsLookAndFeel} is loaded from the
+ * {@code java.desktop} module via the boot class loader and shadows this class.
+ *
+ *
The stub reports itself as not native and not supported - JIDE never instantiates this
+ * class, it only uses it for {@code instanceof} comparisons against the active LAF. Since
+ * sdrtrunk never installs this LAF, the {@code instanceof} check always returns false and
+ * JIDE proceeds to its next branch.
+ */
+public class WindowsLookAndFeel extends LookAndFeel
+{
+ @Override
+ public String getName()
+ {
+ return "Windows (stub)";
+ }
+
+ @Override
+ public String getID()
+ {
+ return "WindowsStub";
+ }
+
+ @Override
+ public String getDescription()
+ {
+ return "Compatibility stub for JIDE's instanceof checks on non-Windows JDKs.";
+ }
+
+ @Override
+ public boolean isNativeLookAndFeel()
+ {
+ return false;
+ }
+
+ @Override
+ public boolean isSupportedLookAndFeel()
+ {
+ return false;
+ }
+
+ @Override
+ public UIDefaults getDefaults()
+ {
+ return new UIDefaults();
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastStatusPanel.java b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastStatusPanel.java
index 06303991..d2b16bfc 100644
--- a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastStatusPanel.java
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastStatusPanel.java
@@ -151,7 +151,10 @@ public class BroadcastStatusPanel extends JPanel
else if(state == BroadcastState.DISABLED)
{
setBackground(table.getBackground());
- setForeground(Color.LIGHT_GRAY);
+ //Use the LAF's disabled-foreground so the cell reads correctly on any
+ //theme - LIGHT_GRAY is invisible on a light background.
+ Color disabled = javax.swing.UIManager.getColor("Label.disabledForeground");
+ setForeground(disabled != null ? disabled : table.getForeground());
}
else if(state == BroadcastState.INVALID_SETTINGS ||
state == BroadcastState.NETWORK_UNAVAILABLE)
diff --git a/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java b/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java
index b6933576..e0119f17 100644
--- a/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java
+++ b/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java
@@ -28,7 +28,6 @@ import io.github.dsheirer.module.decode.event.MessageActivityPanel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.settings.SettingsManager;
-import java.awt.Color;
import net.miginfocom.swing.MigLayout;
import javax.swing.JPanel;
@@ -98,7 +97,6 @@ public class NowPlayingPanel extends JPanel
mTabbedPane.addTab("Messages", mMessageActivityPanel);
mTabbedPane.addTab("Channel", mChannelSpectrumSquelchPanel);
mTabbedPane.setFont(this.getFont());
- mTabbedPane.setForeground(Color.BLACK);
//Register state change listener to toggle visibility state for channel tab to turn-on/off FFT processing
mTabbedPane.addChangeListener(e -> mChannelSpectrumSquelchPanel.setPanelVisible(getTabbedPane().getSelectedIndex() == getTabbedPane()
.indexOfComponent(mChannelSpectrumSquelchPanel)));
diff --git a/src/main/java/io/github/dsheirer/controller/ControllerPanel.java b/src/main/java/io/github/dsheirer/controller/ControllerPanel.java
index 1f4c62d6..79a7e707 100644
--- a/src/main/java/io/github/dsheirer/controller/ControllerPanel.java
+++ b/src/main/java/io/github/dsheirer/controller/ControllerPanel.java
@@ -100,7 +100,6 @@ public class ControllerPanel extends JPanel
}
};
mTabbedPane.setFont(this.getFont());
- mTabbedPane.setForeground(Color.BLACK);
mTabbedPane.addTab("Now Playing", mNowPlayingPanel);
mTabbedPane.addTab("Map", mMapPanel);
mTabbedPane.addTab("Tuners", mTunerManagerPanel);
diff --git a/src/main/java/io/github/dsheirer/gui/JavaFxWindowManager.java b/src/main/java/io/github/dsheirer/gui/JavaFxWindowManager.java
index f7b13a17..8d44606d 100644
--- a/src/main/java/io/github/dsheirer/gui/JavaFxWindowManager.java
+++ b/src/main/java/io/github/dsheirer/gui/JavaFxWindowManager.java
@@ -35,6 +35,7 @@ import io.github.dsheirer.gui.preference.PreferenceEditorType;
import io.github.dsheirer.gui.preference.UserPreferencesEditor;
import io.github.dsheirer.gui.preference.ViewUserPreferenceEditorRequest;
import io.github.dsheirer.gui.preference.calibration.CalibrationDialog;
+import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.gui.viewer.MessageRecordingViewer;
import io.github.dsheirer.gui.viewer.ViewRecordingViewerRequest;
import io.github.dsheirer.icon.IconModel;
@@ -135,6 +136,7 @@ public class JavaFxWindowManager extends Application
//JFXPanel has to be populated on the FX event thread
Platform.runLater(() -> {
Scene scene = new Scene(new StatusBox(resourceMonitor));
+ ThemeManager.getInstance().register(scene);
mStatusPanel.setScene(scene);
});
}
@@ -207,6 +209,7 @@ public class JavaFxWindowManager extends Application
{
createJFXPanel();
Scene scene = new Scene(getRecordingViewer(), 1100, 800);
+ ThemeManager.getInstance().register(scene);
mRecordingViewerStage = new Stage();
mRecordingViewerStage.setTitle("sdrtrunk - Message Recording Viewer (.bits)");
mRecordingViewerStage.setScene(scene);
@@ -232,6 +235,7 @@ public class JavaFxWindowManager extends Application
{
createJFXPanel();
Scene scene = new Scene(getIconManager(), 500, 500);
+ ThemeManager.getInstance().register(scene);
mIconManagerStage = new Stage();
mIconManagerStage.setTitle("sdrtrunk - Icon Manager");
mIconManagerStage.setScene(scene);
@@ -280,6 +284,7 @@ public class JavaFxWindowManager extends Application
{
createJFXPanel();
Scene scene = new Scene(getJmbeEditor(), 650, 650);
+ ThemeManager.getInstance().register(scene);
mJmbeEditorStage = new Stage();
mJmbeEditorStage.setTitle("sdrtrunk - JMBE Library Updater");
mJmbeEditorStage.setScene(scene);
@@ -321,6 +326,7 @@ public class JavaFxWindowManager extends Application
{
createJFXPanel();
Scene scene = new Scene(getPlaylistEditor(), 1000, 750);
+ ThemeManager.getInstance().register(scene);
mPlaylistStage = new Stage();
mPlaylistStage.setTitle("sdrtrunk - Playlist Editor");
mPlaylistStage.setScene(scene);
@@ -371,6 +377,7 @@ public class JavaFxWindowManager extends Application
{
createJFXPanel();
Scene scene = new Scene(getUserPreferencesEditor(), 900, 500);
+ ThemeManager.getInstance().register(scene);
mUserPreferencesStage = new Stage();
mUserPreferencesStage.setTitle("sdrtrunk - User Preferences");
mUserPreferencesStage.setScene(scene);
@@ -414,6 +421,7 @@ public class JavaFxWindowManager extends Application
{
createJFXPanel();
Scene scene = new Scene(getChannelMapEditor(), 500, 500);
+ ThemeManager.getInstance().register(scene);
mChannelMapStage = new Stage();
mChannelMapStage.setTitle("sdrtrunk - Channel Map Editor");
mChannelMapStage.setScene(scene);
diff --git a/src/main/java/io/github/dsheirer/gui/SDRTrunk.java b/src/main/java/io/github/dsheirer/gui/SDRTrunk.java
index 0c8808c2..1585ec12 100644
--- a/src/main/java/io/github/dsheirer/gui/SDRTrunk.java
+++ b/src/main/java/io/github/dsheirer/gui/SDRTrunk.java
@@ -18,7 +18,6 @@
*/
package io.github.dsheirer.gui;
-import com.jidesoft.plaf.LookAndFeelFactory;
import com.jidesoft.swing.JideSplitPane;
import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.audio.DuplicateCallDetector;
@@ -38,6 +37,7 @@ import io.github.dsheirer.gui.preference.CalibrateRequest;
import io.github.dsheirer.gui.preference.PreferenceEditorType;
import io.github.dsheirer.gui.preference.ViewUserPreferenceEditorRequest;
import io.github.dsheirer.gui.preference.calibration.CalibrationDialog;
+import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.gui.viewer.ViewRecordingViewerRequest;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.log.ApplicationLog;
@@ -81,7 +81,6 @@ import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
-import java.util.Locale;
import java.util.Optional;
import java.util.prefs.Preferences;
import javafx.application.Platform;
@@ -102,10 +101,8 @@ import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JSeparator;
import javax.swing.KeyStroke;
-import javax.swing.UIManager;
import javax.swing.event.MenuEvent;
import javax.swing.event.MenuListener;
-import javax.swing.plaf.metal.MetalLookAndFeel;
public class SDRTrunk implements Listener
{
@@ -147,6 +144,9 @@ public class SDRTrunk implements Listener
{
if(!GraphicsEnvironment.isHeadless())
{
+ //Initialize the theme manager before any Swing components are realized so the chosen
+ //look-and-feel applies to the very first window that is created.
+ ThemeManager.getInstance().initialize(mUserPreferences);
mMainGui = new JFrame();
}
@@ -163,21 +163,6 @@ public class SDRTrunk implements Listener
mResourceMonitor = new ResourceMonitor(mUserPreferences);
- String operatingSystem = System.getProperty("os.name", "generic").toLowerCase(Locale.ENGLISH);
-
- if(operatingSystem.contains("mac") || operatingSystem.contains("nux"))
- {
- try
- {
- UIManager.setLookAndFeel(MetalLookAndFeel.class.getName());
- LookAndFeelFactory.installJideExtension();
- }
- catch(Exception e)
- {
- mLog.error("Error trying to set Metal look and feel for OS [" + operatingSystem + "]");
- }
- }
-
ThreadPool.logSettings();
//Load properties file
diff --git a/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java
index 99615575..90116bd1 100644
--- a/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java
+++ b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java
@@ -24,6 +24,7 @@ import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
import io.github.dsheirer.gui.power.SignalPowerView;
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
import io.github.dsheirer.gui.symbol.SymbolView;
+import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.FeedbackDecoder;
import io.github.dsheirer.module.decode.PrimaryDecoder;
@@ -112,6 +113,11 @@ public class ChannelSpectrumPanel extends JPanel implements Listener {
Scene scene = new Scene(mNoiseSquelchView);
+ ThemeManager.getInstance().register(scene);
mNoiseSquelchPanel.setScene(scene);
Scene scene2 = new Scene(mSymbolView);
URL resource = getClass().getResource("/sdrtrunk_style.css");
@@ -239,6 +248,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener applicationItem = new TreeItem<>("Application");
applicationItem.getChildren().add(new TreeItem(PreferenceEditorType.APPLICATION));
+ applicationItem.getChildren().add(new TreeItem(PreferenceEditorType.APPEARANCE));
treeRoot.getChildren().add(applicationItem);
applicationItem.setExpanded(true);
diff --git a/src/main/java/io/github/dsheirer/gui/preference/application/AppearancePreferenceEditor.java b/src/main/java/io/github/dsheirer/gui/preference/application/AppearancePreferenceEditor.java
new file mode 100644
index 00000000..5d2ddbe3
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/preference/application/AppearancePreferenceEditor.java
@@ -0,0 +1,205 @@
+/*
+ * *****************************************************************************
+ * 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.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.Insets;
+import javafx.geometry.Orientation;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.RadioButton;
+import javafx.scene.control.Separator;
+import javafx.scene.control.Slider;
+import javafx.scene.control.ToggleGroup;
+import javafx.scene.layout.ColumnConstraints;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+
+/**
+ * Appearance preferences - selects the active UI {@link Theme} and the global GUI zoom factor for
+ * both the Swing main window and the JavaFX panels.
+ */
+public class AppearancePreferenceEditor extends HBox
+{
+ private final ApplicationPreference mApplicationPreference;
+ private GridPane mEditorPane;
+ private VBox mThemeRadioGroup;
+ private Slider mGuiScaleSlider;
+ private Label mGuiScaleValueLabel;
+
+ public AppearancePreferenceEditor(UserPreferences userPreferences)
+ {
+ mApplicationPreference = userPreferences.getApplicationPreference();
+ setMaxWidth(Double.MAX_VALUE);
+
+ VBox vbox = new VBox();
+ vbox.setMaxHeight(Double.MAX_VALUE);
+ vbox.setMaxWidth(Double.MAX_VALUE);
+ vbox.getChildren().add(getEditorPane());
+ HBox.setHgrow(vbox, Priority.ALWAYS);
+ getChildren().add(vbox);
+ }
+
+ private GridPane getEditorPane()
+ {
+ if(mEditorPane == null)
+ {
+ int row = 0;
+ mEditorPane = new GridPane();
+ mEditorPane.setMaxWidth(Double.MAX_VALUE);
+ mEditorPane.setVgap(10);
+ mEditorPane.setHgap(8);
+ mEditorPane.setPadding(new Insets(10, 10, 10, 10));
+
+ mEditorPane.add(new Label("Theme"), 0, row, 3, 1);
+ mEditorPane.add(getThemeRadioGroup(), 0, ++row, 3, 1);
+
+ Separator separator = new Separator(Orientation.HORIZONTAL);
+ GridPane.setHgrow(separator, Priority.ALWAYS);
+ mEditorPane.add(separator, 0, ++row, 3, 1);
+
+ mEditorPane.add(new Label("GUI Scale"), 0, ++row, 3, 1);
+
+ HBox sliderRow = new HBox(8);
+ sliderRow.setPadding(new Insets(2, 0, 2, 4));
+ HBox.setHgrow(getGuiScaleSlider(), Priority.ALWAYS);
+ Button resetButton = new Button("Reset");
+ resetButton.setOnAction(e -> {
+ getGuiScaleSlider().setValue(ApplicationPreference.DEFAULT_GUI_SCALE);
+ mApplicationPreference.setGuiScale(ApplicationPreference.DEFAULT_GUI_SCALE);
+ });
+ sliderRow.getChildren().addAll(getGuiScaleSlider(), getGuiScaleValueLabel(), resetButton);
+ mEditorPane.add(sliderRow, 0, ++row, 3, 1);
+
+ Label hint = new Label("Adjusts the size of all UI components. Takes effect immediately.");
+ hint.setWrapText(true);
+ mEditorPane.add(hint, 0, ++row, 3, 1);
+
+ ColumnConstraints c1 = new ColumnConstraints();
+ c1.setPercentWidth(30);
+ ColumnConstraints c2 = new ColumnConstraints();
+ c2.setHgrow(Priority.ALWAYS);
+ mEditorPane.getColumnConstraints().addAll(c1, c2);
+ }
+
+ return mEditorPane;
+ }
+
+ /**
+ * 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 VBox getThemeRadioGroup()
+ {
+ if(mThemeRadioGroup == null)
+ {
+ 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 mThemeRadioGroup;
+ }
+
+ /**
+ * Slider to adjust the global GUI zoom factor. The slider snaps to 10% increments and only
+ * persists the value when the user releases the slider so we do not thrash a LAF reinstall on
+ * every intermediate value during a drag.
+ */
+ private Slider getGuiScaleSlider()
+ {
+ if(mGuiScaleSlider == null)
+ {
+ mGuiScaleSlider = new Slider(ApplicationPreference.MIN_GUI_SCALE,
+ ApplicationPreference.MAX_GUI_SCALE,
+ mApplicationPreference.getGuiScale());
+ mGuiScaleSlider.setMajorTickUnit(0.25d);
+ mGuiScaleSlider.setMinorTickCount(1);
+ mGuiScaleSlider.setSnapToTicks(true);
+ mGuiScaleSlider.setShowTickMarks(true);
+ mGuiScaleSlider.setShowTickLabels(false);
+ mGuiScaleSlider.setBlockIncrement(0.1d);
+
+ //Update the live percentage label as the user drags.
+ mGuiScaleSlider.valueProperty().addListener((obs, oldV, newV) -> {
+ if(newV != null)
+ {
+ getGuiScaleValueLabel().setText(formatScale(newV.doubleValue()));
+ }
+ });
+
+ //Persist + apply only when the user finishes the drag - avoids reinstalling the LAF
+ //on every intermediate frame.
+ mGuiScaleSlider.valueChangingProperty().addListener((obs, wasChanging, changing) -> {
+ if(wasChanging && !changing)
+ {
+ mApplicationPreference.setGuiScale(mGuiScaleSlider.getValue());
+ }
+ });
+
+ //Cover keyboard / scroll-wheel adjustments which never enter the changing=true state.
+ mGuiScaleSlider.setOnMouseReleased(e -> {
+ if(!mGuiScaleSlider.isValueChanging())
+ {
+ mApplicationPreference.setGuiScale(mGuiScaleSlider.getValue());
+ }
+ });
+ }
+
+ return mGuiScaleSlider;
+ }
+
+ private Label getGuiScaleValueLabel()
+ {
+ if(mGuiScaleValueLabel == null)
+ {
+ mGuiScaleValueLabel = new Label(formatScale(mApplicationPreference.getGuiScale()));
+ mGuiScaleValueLabel.setMinWidth(50);
+ }
+
+ return mGuiScaleValueLabel;
+ }
+
+ private static String formatScale(double scale)
+ {
+ return Math.round(scale * 100) + "%";
+ }
+}
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..39f9790f
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/theme/Theme.java
@@ -0,0 +1,96 @@
+/*
+ * *****************************************************************************
+ * 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),
+ 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),
+ MATERIAL_DEEP_OCEAN("Material Deep Ocean", "com.formdev.flatlaf.intellijthemes.materialthemeuilite.FlatMaterialDeepOceanIJTheme", true),
+ ARC_DARK("Arc Dark", "com.formdev.flatlaf.intellijthemes.FlatArcDarkIJTheme", 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
new file mode 100644
index 00000000..f206ed76
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java
@@ -0,0 +1,789 @@
+/*
+ * *****************************************************************************
+ * 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;
+
+import com.formdev.flatlaf.FlatLightLaf;
+import com.google.common.eventbus.Subscribe;
+import com.jidesoft.plaf.LookAndFeelFactory;
+import io.github.dsheirer.eventbus.MyEventBus;
+import io.github.dsheirer.preference.PreferenceType;
+import io.github.dsheirer.preference.UserPreferences;
+import java.awt.Color;
+import java.awt.EventQueue;
+import java.awt.Window;
+import java.lang.ref.WeakReference;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import javafx.application.Platform;
+import javafx.scene.Scene;
+import javax.swing.LookAndFeel;
+import javax.swing.SwingUtilities;
+import javax.swing.UIDefaults;
+import javax.swing.UIManager;
+import javax.swing.plaf.ColorUIResource;
+import javax.swing.plaf.metal.MetalLookAndFeel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Centralized theme manager that drives both Swing and JavaFX appearance from the user
+ * preference selection.
+ *
+ *
Each {@link Theme} pairs a FlatLaf look-and-feel class with a dark/light flag. When a
+ * theme is applied:
+ *
+ *
The Swing LAF is installed (reflected from {@link Theme#getLafClassName()}).
+ *
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.
+ *
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.
+ *
The dark JavaFX stylesheet is applied to (or removed from) every registered Scene
+ * depending on the theme's dark flag.
+ *
+ */
+public class ThemeManager
+{
+ private final static Logger mLog = LoggerFactory.getLogger(ThemeManager.class);
+ private static final String DARK_STYLESHEET = "/sdrtrunk_dark.css";
+ private static final ThemeManager INSTANCE = new ThemeManager();
+
+ private final Set> mScenes = new LinkedHashSet<>();
+ private final Set> mSwingRoots = new LinkedHashSet<>();
+ private final String mDarkStylesheetUrl;
+ private volatile UserPreferences mUserPreferences;
+ private volatile Theme mCurrentTheme = Theme.LIGHT;
+ private volatile double mCurrentGuiScale = 1.0d;
+ /** Data URL of the currently-active per-theme accent stylesheet, or null. */
+ private volatile String mAccentStylesheetUrl;
+
+ private ThemeManager()
+ {
+ java.net.URL url = ThemeManager.class.getResource(DARK_STYLESHEET);
+ mDarkStylesheetUrl = (url != null) ? url.toExternalForm() : null;
+
+ if(mDarkStylesheetUrl == null)
+ {
+ mLog.warn("Dark stylesheet [{}] not found on classpath - dark themes will only affect Swing.",
+ DARK_STYLESHEET);
+ }
+ }
+
+ public static ThemeManager getInstance()
+ {
+ return INSTANCE;
+ }
+
+ /**
+ * 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.
+ */
+ public void initialize(UserPreferences userPreferences)
+ {
+ if(userPreferences == null)
+ {
+ return;
+ }
+
+ boolean firstBind = (mUserPreferences == null);
+ mUserPreferences = userPreferences;
+ mCurrentTheme = userPreferences.getApplicationPreference().getTheme();
+ mCurrentGuiScale = userPreferences.getApplicationPreference().getGuiScale();
+
+ if(firstBind)
+ {
+ MyEventBus.getGlobalEventBus().register(this);
+ attachJavaFxWindowListener();
+ }
+
+ //applyAll installs the Swing LAF *and* computes the per-theme accent stylesheet URL so it
+ //is ready before any Scene.register call. At init time the FX-side scene walk is a no-op
+ //because nothing has registered yet, but mAccentStylesheetUrl gets populated for later
+ //register(...) calls.
+ applyAll(mCurrentTheme);
+ }
+
+ /**
+ * Install a listener on {@link javafx.stage.Window#getWindows()} so that every JavaFX window
+ * created in the application (including ad-hoc {@code Alert}, {@code TextInputDialog} and
+ * other transient dialogs spawned by the playlist editor and similar) automatically picks up
+ * the current theme without each call site needing to remember to register its Scene. Idle
+ * cost is one listener invocation per window opened; the listener short-circuits when the FX
+ * toolkit is not yet started.
+ */
+ private void attachJavaFxWindowListener()
+ {
+ Runnable attach = () -> {
+ try
+ {
+ javafx.stage.Window.getWindows().addListener(
+ (javafx.collections.ListChangeListener) change -> {
+ while(change.next())
+ {
+ if(change.wasAdded())
+ {
+ for(javafx.stage.Window w: change.getAddedSubList())
+ {
+ themeWindowWhenSceneReady(w);
+ }
+ }
+ }
+ });
+
+ //Also cover any windows that already existed when we installed the listener.
+ for(javafx.stage.Window w: javafx.stage.Window.getWindows())
+ {
+ themeWindowWhenSceneReady(w);
+ }
+ }
+ catch(Throwable t)
+ {
+ mLog.warn("Unable to attach JavaFX Window list listener for auto-theming", t);
+ }
+ };
+
+ if(Platform.isFxApplicationThread())
+ {
+ attach.run();
+ }
+ else
+ {
+ try
+ {
+ Platform.runLater(attach);
+ }
+ catch(IllegalStateException ignored)
+ {
+ //FX toolkit not started yet - first JFXPanel construction will start it; we
+ //re-attempt the attach lazily inside applyAll() via the Window.getWindows() walk.
+ }
+ }
+ }
+
+ private void themeWindowWhenSceneReady(javafx.stage.Window window)
+ {
+ if(window == null)
+ {
+ return;
+ }
+
+ Scene scene = window.getScene();
+ if(scene != null)
+ {
+ applyToScene(scene, isDarkMode());
+ }
+ else
+ {
+ //The Scene may be assigned after the Window is added to the list (the
+ //typical Alert/Dialog lifecycle). Listen once for the assignment.
+ window.sceneProperty().addListener((obs, oldScene, newScene) -> {
+ if(newScene != null)
+ {
+ applyToScene(newScene, isDarkMode());
+ }
+ });
+ }
+ }
+
+ /**
+ * @return the currently active theme.
+ */
+ public Theme getCurrentTheme()
+ {
+ return mCurrentTheme;
+ }
+
+ /**
+ * @return true if the currently active theme is dark.
+ */
+ public boolean isDarkMode()
+ {
+ return mCurrentTheme != null && mCurrentTheme.isDark();
+ }
+
+ public void register(Scene scene)
+ {
+ if(scene == null)
+ {
+ return;
+ }
+
+ synchronized(mScenes)
+ {
+ pruneDeadReferencesAndCheckPresent(scene);
+ mScenes.add(new WeakReference<>(scene));
+ }
+
+ 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). 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)
+ {
+ if(component == null)
+ {
+ return;
+ }
+
+ synchronized(mSwingRoots)
+ {
+ Iterator> it = mSwingRoots.iterator();
+ while(it.hasNext())
+ {
+ java.awt.Component existing = it.next().get();
+ if(existing == null || existing == component)
+ {
+ it.remove();
+ }
+ }
+ mSwingRoots.add(new WeakReference<>(component));
+ }
+ }
+
+ @Subscribe
+ public void onPreferenceUpdated(PreferenceType type)
+ {
+ if(type != PreferenceType.APPLICATION || mUserPreferences == null)
+ {
+ return;
+ }
+
+ Theme updated = mUserPreferences.getApplicationPreference().getTheme();
+ double updatedScale = mUserPreferences.getApplicationPreference().getGuiScale();
+
+ if(updated == mCurrentTheme && Math.abs(updatedScale - mCurrentGuiScale) < 1e-6)
+ {
+ return;
+ }
+
+ mCurrentTheme = updated;
+ mCurrentGuiScale = updatedScale;
+ applyAll(updated);
+ }
+
+ private void applyAll(Theme theme)
+ {
+ applySwingLookAndFeel(theme);
+
+ //Compute the per-theme accent stylesheet on the calling thread so the data URL is ready
+ //before the FX apply runs. Capture the previous URL so we can remove it from each Scene.
+ String previousAccentUrl = mAccentStylesheetUrl;
+ String nextAccentUrl = buildAccentStylesheet(theme);
+ mAccentStylesheetUrl = nextAccentUrl;
+
+ Runnable fxApply = () -> {
+ synchronized(mScenes)
+ {
+ Iterator> it = mScenes.iterator();
+ while(it.hasNext())
+ {
+ Scene scene = it.next().get();
+ if(scene == null)
+ {
+ it.remove();
+ }
+ else
+ {
+ applyToScene(scene, theme.isDark(), previousAccentUrl, nextAccentUrl);
+ }
+ }
+ }
+
+ for(javafx.stage.Window window: javafx.stage.Window.getWindows())
+ {
+ Scene scene = window.getScene();
+ if(scene != null)
+ {
+ applyToScene(scene, theme.isDark(), previousAccentUrl, nextAccentUrl);
+ }
+ }
+ };
+
+ if(Platform.isFxApplicationThread())
+ {
+ fxApply.run();
+ }
+ else
+ {
+ try
+ {
+ Platform.runLater(fxApply);
+ }
+ catch(IllegalStateException ignored)
+ {
+ //FX toolkit not started yet - scenes will be themed at registration time instead.
+ }
+ }
+ }
+
+ /**
+ * Apply the current theme to a single Scene. Used when a new Scene is registered or appears
+ * mid-session. The accent-stylesheet swap is handled by the (previousUrl, nextUrl) overload.
+ */
+ private void applyToScene(Scene scene, boolean darkMode)
+ {
+ applyToScene(scene, darkMode, null, mAccentStylesheetUrl);
+ }
+
+ /**
+ * Update a Scene's stylesheets to match the current theme: ensures the dark-mode CSS is
+ * present iff {@code darkMode}, removes {@code previousAccentUrl} if present, and ensures
+ * {@code nextAccentUrl} is present so theme-specific accent colours are applied.
+ */
+ private void applyToScene(Scene scene, boolean darkMode, String previousAccentUrl,
+ String nextAccentUrl)
+ {
+ Runnable r = () -> {
+ if(mDarkStylesheetUrl != null)
+ {
+ scene.getStylesheets().remove(mDarkStylesheetUrl);
+ if(darkMode)
+ {
+ scene.getStylesheets().add(mDarkStylesheetUrl);
+ }
+ }
+
+ if(previousAccentUrl != null && !previousAccentUrl.equals(nextAccentUrl))
+ {
+ scene.getStylesheets().remove(previousAccentUrl);
+ }
+
+ if(nextAccentUrl != null && !scene.getStylesheets().contains(nextAccentUrl))
+ {
+ scene.getStylesheets().add(nextAccentUrl);
+ }
+ };
+
+ if(Platform.isFxApplicationThread())
+ {
+ r.run();
+ }
+ else
+ {
+ Platform.runLater(r);
+ }
+ }
+
+ /**
+ * Build a small per-theme stylesheet that overrides Modena's accent and surface colours so
+ * each theme reads visibly different in the JavaFX panels. Returns a {@code data:} URL that
+ * can be added to a Scene's stylesheets list directly.
+ */
+ private String buildAccentStylesheet(Theme theme)
+ {
+ try
+ {
+ UIDefaults laf = UIManager.getLookAndFeelDefaults();
+ boolean dark = theme.isDark();
+
+ String base = hex(laf.getColor("Panel.background"), dark ? 0x2b2b2b : 0xf2f2f2);
+ String inner = hex(laf.getColor("TextField.background"), dark ? 0x313335 : 0xffffff);
+ String innerAlt = hex(laf.getColor("Table.alternateRowColor"), dark ? 0x353739 : 0xfafafa);
+ String accent = hex(laf.getColor("Component.accentColor"),
+ hexInt(laf.getColor("List.selectionBackground"), dark ? 0x4a90d9 : 0x2675bf));
+ String selectionBg = hex(laf.getColor("List.selectionBackground"),
+ dark ? 0x214283 : 0x2675bf);
+ String selectionFg = hex(laf.getColor("List.selectionForeground"), 0xffffff);
+ String text = hex(laf.getColor("Label.foreground"), dark ? 0xe6e6e6 : 0x1e1e1e);
+ String border = hex(laf.getColor("Component.borderColor"),
+ dark ? 0x4f5356 : 0xc4c4c4);
+
+ //JavaFX side of the GUI scale: setting -fx-font-size on .root cascades through
+ //Modena because most sizes are em-relative. Modena's default is 12px; multiplying
+ //by mCurrentGuiScale and rounding to one decimal gives a clean scaled baseline.
+ double scaledFontSize = Math.round(12.0d * mCurrentGuiScale * 10.0d) / 10.0d;
+
+ StringBuilder css = new StringBuilder();
+ css.append(".root {");
+ css.append("-fx-font-size: ").append(scaledFontSize).append("px;");
+ css.append("-fx-base: ").append(base).append(";");
+ css.append("-fx-background: ").append(base).append(";");
+ css.append("-fx-control-inner-background: ").append(inner).append(";");
+ css.append("-fx-control-inner-background-alt: ").append(innerAlt).append(";");
+ css.append("-fx-accent: ").append(accent).append(";");
+ css.append("-fx-default-button: ").append(accent).append(";");
+ css.append("-fx-focus-color: ").append(accent).append(";");
+ css.append("-fx-faint-focus-color: ").append(accent).append("22;");
+ css.append("-fx-selection-bar: ").append(selectionBg).append(";");
+ css.append("-fx-selection-bar-text: ").append(selectionFg).append(";");
+ css.append("-fx-selection-bar-non-focused: ").append(innerAlt).append(";");
+ css.append("-fx-text-fill: ").append(text).append(";");
+ css.append("-fx-text-base-color: ").append(text).append(";");
+ css.append("-fx-text-background-color: ").append(text).append(";");
+ css.append("-fx-mark-color: ").append(text).append(";");
+ css.append("}");
+ css.append(".table-row-cell:filled:selected, .list-cell:filled:selected,")
+ .append(" .tree-cell:filled:selected, .tree-table-row-cell:filled:selected {")
+ .append("-fx-background: ").append(selectionBg).append(";")
+ .append("-fx-background-color: ").append(selectionBg).append(";")
+ .append("-fx-text-fill: ").append(selectionFg).append(";")
+ .append("-fx-table-cell-border-color: ").append(border).append(";")
+ .append("}");
+ css.append(".button:default {")
+ .append("-fx-base: ").append(accent).append(";")
+ .append("-fx-text-fill: ").append(selectionFg).append(";")
+ .append("}");
+ css.append(".scroll-bar .thumb {")
+ .append("-fx-background-color: ").append(border).append(";")
+ .append("-fx-background-radius: 3;")
+ .append("}");
+ css.append(".scroll-bar .thumb:hover {")
+ .append("-fx-background-color: ").append(accent).append(";")
+ .append("}");
+
+ //URL-encode the body; java.net.URLEncoder is fine for our restricted charset.
+ String encoded = java.net.URLEncoder.encode(css.toString(),
+ java.nio.charset.StandardCharsets.UTF_8);
+ return "data:text/css," + encoded;
+ }
+ catch(Throwable t)
+ {
+ mLog.warn("Unable to build per-theme accent stylesheet for theme " + theme, t);
+ return null;
+ }
+ }
+
+ private static String hex(Color c, int fallbackRgb)
+ {
+ int rgb = (c != null) ? (c.getRGB() & 0xFFFFFF) : (fallbackRgb & 0xFFFFFF);
+ return String.format("#%06x", rgb);
+ }
+
+ private static int hexInt(Color c, int fallbackRgb)
+ {
+ return (c != null) ? (c.getRGB() & 0xFFFFFF) : (fallbackRgb & 0xFFFFFF);
+ }
+
+ private void applySwingLookAndFeel(Theme theme)
+ {
+ //FlatLaf reads the flatlaf.uiScale system property when its LAF instance is initialised.
+ //Set it before constructing the LAF so this theme apply picks up the user's zoom factor.
+ //We also clear any previous UIManager-stored override so the system property takes effect.
+ System.setProperty("flatlaf.uiScale", String.valueOf(mCurrentGuiScale));
+ UIManager.put("flatlaf.uiScale", null);
+
+ //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
+ {
+ UIManager.setLookAndFeel(newLaf);
+ }
+ catch(Exception e)
+ {
+ mLog.error("Unable to apply Swing look-and-feel for theme = " + theme, e);
+ return;
+ }
+
+ try
+ {
+ //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.
+ LookAndFeelFactory.installJideExtension(UIManager.getLookAndFeelDefaults(),
+ new MetalLookAndFeel(), LookAndFeelFactory.VSNET_STYLE);
+ }
+ catch(Throwable t)
+ {
+ mLog.warn("Unable to install JIDE LAF extension after theme change", t);
+ }
+
+ //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 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 = instantiate(theme).getDefaults();
+ UIDefaults active = UIManager.getLookAndFeelDefaults();
+ for(java.util.Map.Entry