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: + *

+ */ +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 entry: flatFresh.entrySet()) + { + Object key = entry.getKey(); + Object value = entry.getValue(); + String keyName = String.valueOf(key); + + if(keyName.startsWith("Jide") || keyName.contains(".Jide")) + { + continue; + } + + if(value != null) + { + active.put(key, value); + } + } + } + catch(Throwable t) + { + mLog.warn("Unable to re-apply FlatLaf defaults after JIDE install", t); + } + + //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 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()) + { + SwingUtilities.updateComponentTreeUI(window); + } + + synchronized(mSwingRoots) + { + Iterator> it = mSwingRoots.iterator(); + while(it.hasNext()) + { + java.awt.Component c = it.next().get(); + if(c == null) + { + it.remove(); + } + else + { + SwingUtilities.updateComponentTreeUI(c); + } + } + } + }; + + if(EventQueue.isDispatchThread()) + { + updateRealized.run(); + } + else + { + EventQueue.invokeLater(updateRealized); + } + } + + /** + * 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) + { + try + { + Class cls = Class.forName(theme.getLafClassName()); + return (LookAndFeel) cls.getDeclaredConstructor().newInstance(); + } + catch(Throwable t) + { + 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(); + + //CRITICAL: read from UIManager.getLookAndFeelDefaults() (LAF layer only) rather than + //UIManager.getColor() / UIManager.getDefaults() (which is the merged view). The merged + //view returns user-defaults entries from earlier applyExplicitOverrides calls instead of + //the fresh LAF values, which means a second theme switch picks up the previous theme's + //palette and the toggle appears broken after the first change. + UIDefaults laf = UIManager.getLookAndFeelDefaults(); + + ColorUIResource bgPanel = uir(laf.getColor("Panel.background"), + darkMode ? 0x2b2b2b : 0xf2f2f2); + ColorUIResource bgRaised = uir(laf.getColor("Button.background"), + darkMode ? 0x3c3f41 : 0xffffff); + ColorUIResource bgInput = uir(laf.getColor("TextField.background"), + darkMode ? 0x313335 : 0xffffff); + ColorUIResource bgSelection = uir(laf.getColor("List.selectionBackground"), + darkMode ? 0x214283 : 0x2675bf); + ColorUIResource fgPrimary = uir(laf.getColor("Label.foreground"), + darkMode ? 0xe6e6e6 : 0x1e1e1e); + ColorUIResource fgSelection = uir(laf.getColor("List.selectionForeground"), + 0xffffff); + ColorUIResource fgDisabled = uir(laf.getColor("Label.disabledForeground"), + darkMode ? 0x6a6a6a : 0x8c8c8c); + ColorUIResource border = uir(laf.getColor("Component.borderColor"), + darkMode ? 0x4f5356 : 0xc4c4c4); + ColorUIResource altRow = uir(laf.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 = { + "Button", "ToggleButton", "CheckBox", "RadioButton", + "Label", "Menu", "MenuItem", "CheckBoxMenuItem", "RadioButtonMenuItem", + "MenuBar", "PopupMenu", "Panel", "Viewport", "ScrollPane", + "Separator", "ToolBar", "ToolTip", "FormattedTextField", + "TitledBorder", "TabbedPane", "OptionPane", "ProgressBar", + "Spinner", "Slider", "ComboBox", "EditorPane", "PasswordField" + }; + + for(String c: simpleComponents) + { + UIManager.put(c + ".background", bgPanel); + UIManager.put(c + ".foreground", fgPrimary); + UIManager.put(c + ".disabledForeground", fgDisabled); + UIManager.put(c + ".disabledText", fgDisabled); + } + + for(String c: new String[]{"Button", "ToggleButton", "ComboBox", "Spinner"}) + { + UIManager.put(c + ".background", bgRaised); + } + + for(String c: new String[]{"TextField", "TextArea", "TextPane", "EditorPane", + "FormattedTextField", "PasswordField"}) + { + UIManager.put(c + ".background", bgInput); + UIManager.put(c + ".foreground", fgPrimary); + UIManager.put(c + ".caretForeground", fgPrimary); + UIManager.put(c + ".inactiveForeground", fgDisabled); + UIManager.put(c + ".selectionBackground", bgSelection); + UIManager.put(c + ".selectionForeground", fgSelection); + } + + for(String c: new String[]{"Table", "Tree", "List"}) + { + UIManager.put(c + ".background", bgInput); + UIManager.put(c + ".foreground", fgPrimary); + UIManager.put(c + ".selectionBackground", bgSelection); + UIManager.put(c + ".selectionForeground", fgSelection); + } + + UIManager.put("Table.gridColor", border); + 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)); + + //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); + UIManager.put("TabbedPane.selected", bgRaised); + UIManager.put("TabbedPane.contentAreaColor", bgPanel); + UIManager.put("TabbedPane.darkShadow", border); + UIManager.put("TabbedPane.shadow", border); + + UIManager.put("MenuItem.selectionBackground", bgSelection); + UIManager.put("MenuItem.selectionForeground", fgSelection); + UIManager.put("Menu.selectionBackground", bgSelection); + UIManager.put("Menu.selectionForeground", fgSelection); + + UIManager.put("SplitPane.background", bgPanel); + UIManager.put("SplitPane.dividerFocusColor", bgSelection); + UIManager.put("SplitPaneDivider.draggingColor", bgSelection); + UIManager.put("Component.borderColor", border); + UIManager.put("Component.disabledBorderColor", border); + UIManager.put("Separator.foreground", border); + UIManager.put("Separator.background", bgPanel); + + UIManager.put("ToolTip.background", bgRaised); + UIManager.put("ToolTip.foreground", fgPrimary); + + //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); + UIManager.put("JideTabbedPane.selectedTabBackground", bgRaised); + UIManager.put("JideTabbedPane.activeTabBackground", bgRaised); + UIManager.put("JideTabbedPane.tabListBackground", bgRaised); + UIManager.put("JideTabbedPane.selectedTabTextForeground", fgPrimary); + UIManager.put("JideTabbedPane.unselectedTabTextForeground", fgPrimary); + UIManager.put("JideTabbedPane.activeTabTextForeground", fgPrimary); + UIManager.put("JideTabbedPane.shadow", border); + UIManager.put("JideTabbedPane.darkShadow", border); + UIManager.put("JideTabbedPane.light", bgRaised); + UIManager.put("JideTabbedPane.highlight", bgRaised); + + UIManager.put("JideSplitPane.background", bgPanel); + UIManager.put("JideSplitPaneDivider.background", bgPanel); + UIManager.put("JideSplitPane.dividerColor", border); + + UIManager.put("JideButton.background", bgRaised); + UIManager.put("JideButton.foreground", fgPrimary); + UIManager.put("JideButton.selectedBackground", bgSelection); + UIManager.put("JideButton.selectedForeground", fgSelection); + UIManager.put("JideButton.shadow", border); + UIManager.put("JideButton.darkShadow", border); + UIManager.put("JideButton.light", bgRaised); + UIManager.put("JideButton.highlight", bgRaised); + + UIManager.put("JideLabel.background", bgPanel); + 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(); + while(it.hasNext()) + { + Scene existing = it.next().get(); + if(existing == null) + { + it.remove(); + } + else if(existing == scene) + { + it.remove(); + } + } + } +} 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 58d16914..04133836 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; @@ -33,11 +34,21 @@ 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 static final String PREFERENCE_KEY_GUI_SCALE = "ui.gui.scale"; + + /** Hard bounds for the GUI zoom factor. */ + public static final double MIN_GUI_SCALE = 0.5d; + public static final double MAX_GUI_SCALE = 2.0d; + public static final double DEFAULT_GUI_SCALE = 1.0d; private final static Logger mLog = LoggerFactory.getLogger(ApplicationPreference.class); private Preferences mPreferences = Preferences.userNodeForPackage(ApplicationPreference.class); private Integer mChannelAutoStartTimeout; private Boolean mAutomaticDiagnosticMonitoring; + private Theme mTheme; + private Double mGuiScale; /** * Constructs an instance @@ -104,4 +115,88 @@ public class ApplicationPreference extends Preference mPreferences.putBoolean(PREFERENCE_KEY_CHANNEL_AUTO_DIAGNOSTIC_MONITORING, enabled); notifyPreferenceUpdated(); } + + /** + * 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 Theme getTheme() + { + if(mTheme == null) + { + 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 mTheme; + } + + /** + * Sets the selected UI theme. + */ + public void setTheme(Theme theme) + { + 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(); + } + + /** + * Returns the GUI zoom factor. 1.0 = 100% (default). Bounded to + * [{@link #MIN_GUI_SCALE}, {@link #MAX_GUI_SCALE}]. + */ + public double getGuiScale() + { + if(mGuiScale == null) + { + mGuiScale = mPreferences.getDouble(PREFERENCE_KEY_GUI_SCALE, DEFAULT_GUI_SCALE); + mGuiScale = clampScale(mGuiScale); + } + + return mGuiScale; + } + + /** + * Sets the GUI zoom factor. Out-of-range values are clamped to the supported bounds. + */ + public void setGuiScale(double scale) + { + double clamped = clampScale(scale); + mGuiScale = clamped; + mPreferences.putDouble(PREFERENCE_KEY_GUI_SCALE, clamped); + notifyPreferenceUpdated(); + } + + private static double clampScale(double scale) + { + if(Double.isNaN(scale) || scale < MIN_GUI_SCALE) return MIN_GUI_SCALE; + if(scale > MAX_GUI_SCALE) return MAX_GUI_SCALE; + return scale; + } } diff --git a/src/main/java/io/github/dsheirer/source/tuner/ui/TunerViewPanel.java b/src/main/java/io/github/dsheirer/source/tuner/ui/TunerViewPanel.java index 99863df4..4b37d28d 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/ui/TunerViewPanel.java +++ b/src/main/java/io/github/dsheirer/source/tuner/ui/TunerViewPanel.java @@ -274,7 +274,10 @@ public class TunerViewPanel extends JPanel } else if(tunerStatus == TunerStatus.DISABLED) { - component.setForeground(Color.DARK_GRAY); + //Use the LAF's disabled-foreground colour so the row reads correctly on any + //theme - DARK_GRAY is invisible on dark backgrounds. + Color disabled = javax.swing.UIManager.getColor("Label.disabledForeground"); + component.setForeground(disabled != null ? disabled : table.getForeground()); } else { diff --git a/src/main/resources/sdrtrunk_dark.css b/src/main/resources/sdrtrunk_dark.css new file mode 100644 index 00000000..2172bb05 --- /dev/null +++ b/src/main/resources/sdrtrunk_dark.css @@ -0,0 +1,458 @@ +/* + * ***************************************************************************** + * 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 + * **************************************************************************** + */ + +/* sdrtrunk dark theme - applied on top of the default Modena stylesheet. */ + +.root +{ + -fx-base: #2b2b2b; + -fx-background: #2b2b2b; + -fx-control-inner-background: #313335; + -fx-control-inner-background-alt: #3c3f41; + -fx-accent: #4a90d9; + -fx-default-button: #4a90d9; + -fx-focus-color: #4a90d9; + -fx-faint-focus-color: #4a90d922; + -fx-text-fill: #ececec; + -fx-text-base-color: #ececec; + -fx-text-background-color: #ececec; + -fx-mark-color: #ececec; + -fx-mark-highlight-color: #ffffff; + -fx-selection-bar: #214283; + -fx-selection-bar-non-focused: #3a3d3f; + -fx-cell-hover-color: #3a3d3f; +} + +.label +{ + -fx-text-fill: #ececec; +} + +.text +{ + -fx-fill: #ececec; +} + +.button, .toggle-button, .menu-button, .split-menu-button, .choice-box, .combo-box-base +{ + -fx-background-color: #3c3f41, #4a4d4f, #3c3f41, #3c3f41; + -fx-text-fill: #ececec; +} + +.button:hover, .toggle-button:hover, .menu-button:hover +{ + -fx-background-color: #4a90d9, #5aa0e9, #4a90d9, #3c3f41; +} + +.button:armed, .toggle-button:selected +{ + -fx-background-color: #214283, #3672c4, #214283, #214283; + -fx-text-fill: #ffffff; +} + +.text-field, .text-area, .password-field +{ + -fx-background-color: #313335; + -fx-text-fill: #ececec; + -fx-prompt-text-fill: #888888; + -fx-highlight-fill: #214283; + -fx-highlight-text-fill: #ffffff; + -fx-border-color: #4f5356; + -fx-border-width: 1; +} + +.text-area .content +{ + -fx-background-color: #313335; +} + +.scroll-pane, .scroll-pane > .viewport +{ + -fx-background-color: #2b2b2b; +} + +.scroll-bar +{ + -fx-background-color: #2b2b2b; +} + +.scroll-bar .thumb +{ + -fx-background-color: #555a5e; + -fx-background-radius: 3; +} + +.scroll-bar .thumb:hover +{ + -fx-background-color: #6a7075; +} + +.scroll-bar .increment-button, .scroll-bar .decrement-button +{ + -fx-background-color: #2b2b2b; +} + +.scroll-bar .increment-arrow, .scroll-bar .decrement-arrow +{ + -fx-background-color: #c0c0c0; +} + +.menu-bar +{ + -fx-background-color: #3c3f41; +} + +.menu, .menu-item, .context-menu, .menu-bar > .container > .menu-button +{ + -fx-background-color: transparent; +} + +.menu-item +{ + -fx-background-color: #3c3f41; +} + +.menu-item:focused, .menu-item:hover +{ + -fx-background-color: #4a90d9; +} + +.menu-item .label +{ + -fx-text-fill: #ececec; +} + +.menu-item:focused .label, .menu-item:hover .label +{ + -fx-text-fill: #ffffff; +} + +.context-menu +{ + -fx-background-color: #3c3f41; + -fx-border-color: #555a5e; +} + +.tab-pane > .tab-header-area > .headers-region > .tab +{ + -fx-background-color: #3c3f41; +} + +.tab-pane > .tab-header-area > .headers-region > .tab:selected +{ + -fx-background-color: #2b2b2b; +} + +.tab-pane > .tab-header-area > .headers-region > .tab .tab-label +{ + -fx-text-fill: #b0b0b0; +} + +.tab-pane > .tab-header-area > .headers-region > .tab:selected .tab-label +{ + -fx-text-fill: #ffffff; +} + +.tab-pane > .tab-header-area +{ + -fx-background-color: #2b2b2b; +} + +.table-view, .tree-view, .list-view, .tree-table-view +{ + -fx-background-color: #2b2b2b; + -fx-control-inner-background: #313335; + -fx-control-inner-background-alt: #353739; + -fx-table-cell-border-color: #4f5356; + -fx-border-color: #4f5356; + -fx-border-width: 1; +} + +.table-view .column-header, .table-view .filler, .table-view .column-header-background, +.tree-table-view .column-header, .tree-table-view .filler, .tree-table-view .column-header-background +{ + -fx-background-color: #3c3f41; +} + +.table-view .column-header .label, .tree-table-view .column-header .label +{ + -fx-text-fill: #ececec; +} + +.table-row-cell, .tree-table-row-cell, .list-cell, .tree-cell +{ + -fx-text-fill: #ececec; + -fx-background-color: -fx-control-inner-background; +} + +.table-row-cell:odd, .tree-table-row-cell:odd, .list-cell:odd, .tree-cell:odd +{ + -fx-background-color: -fx-control-inner-background-alt; +} + +.table-row-cell:selected, .tree-table-row-cell:selected, .list-cell:selected, .tree-cell:selected +{ + -fx-background-color: #214283; + -fx-text-fill: #ffffff; +} + +.table-row-cell:filled:hover, .tree-table-row-cell:filled:hover, .list-cell:filled:hover, .tree-cell:filled:hover +{ + -fx-background-color: #3a3d3f; +} + +.titled-pane > .title +{ + -fx-background-color: #3c3f41; +} + +.titled-pane > .title > .text +{ + -fx-fill: #ececec; +} + +.titled-pane > .content +{ + -fx-background-color: #2b2b2b; + -fx-border-color: #4f5356; + -fx-border-width: 1; +} + +.separator .line +{ + -fx-border-color: #4f5356; + -fx-border-width: 1 0 0 0; +} + +.split-pane +{ + -fx-background-color: #2b2b2b; +} + +.split-pane > .split-pane-divider +{ + -fx-background-color: #4f5356; + -fx-padding: 0 1 0 1; +} + +.tooltip +{ + -fx-background-color: #3c3f41; + -fx-text-fill: #ececec; + -fx-border-color: #555a5e; +} + +.check-box .box, .radio-button .radio +{ + -fx-background-color: #3c3f41, #4a4d4f; +} + +.check-box:selected .mark, .radio-button:selected .dot +{ + -fx-background-color: #ffffff; +} + +.toggle-switch +{ + -fx-text-fill: #ececec; +} + +.toggle-switch > .thumb-area +{ + -fx-background-color: #3c3f41; + -fx-border-color: #555a5e; +} + +.toggle-switch:selected > .thumb-area +{ + -fx-background-color: #4a90d9; + -fx-border-color: #4a90d9; +} + +.toggle-switch > .thumb +{ + -fx-background-color: #ececec; +} + +.spinner +{ + -fx-background-color: #313335; +} + +.spinner .text-field +{ + -fx-background-color: #313335; + -fx-text-fill: #ececec; +} + +.spinner .increment-arrow-button, .spinner .decrement-arrow-button +{ + -fx-background-color: #3c3f41; +} + +.spinner .increment-arrow, .spinner .decrement-arrow +{ + -fx-background-color: #ececec; +} + +.progress-bar > .track +{ + -fx-background-color: #3c3f41; +} + +.progress-bar > .bar +{ + -fx-background-color: #4a90d9; +} + +.combo-box .list-cell, .choice-box .label +{ + -fx-text-fill: #ececec; + -fx-background-color: transparent; +} + +.combo-box-popup .list-view +{ + -fx-background-color: #3c3f41; + -fx-border-color: #555a5e; +} + +.hyperlink +{ + -fx-text-fill: #6ab0ff; +} + +.hyperlink:visited +{ + -fx-text-fill: #9683c2; +} + +.dialog-pane +{ + -fx-background-color: #2b2b2b; +} + +.dialog-pane > .button-bar > .container +{ + -fx-background-color: #2b2b2b; +} + +.dialog-pane .header-panel +{ + -fx-background-color: #3c3f41; +} + +.dialog-pane .header-panel .label +{ + -fx-text-fill: #ffffff; +} + +.chart +{ + -fx-background-color: #2b2b2b; + -fx-padding: 5px; +} + +.chart-content +{ + -fx-background-color: #2b2b2b; +} + +.chart-plot-background +{ + -fx-background-color: #1c1c1c; +} + +.axis +{ + -fx-tick-label-fill: #ececec; + AXIS_COLOR: #4f5356; +} + +.axis .axis-tick-mark, .axis .axis-minor-tick-mark +{ + -fx-stroke: #6a6f73; +} + +.axis-label +{ + -fx-text-fill: #ececec; +} + +.chart-vertical-grid-lines, .chart-horizontal-grid-lines +{ + -fx-stroke: #3c3f41; +} + +.chart-alternative-row-fill +{ + -fx-fill: transparent; + -fx-stroke: transparent; +} + +.chart-alternative-column-fill +{ + -fx-fill: transparent; + -fx-stroke: transparent; +} + +.chart-legend +{ + -fx-background-color: #313335; + -fx-text-fill: #ececec; + -fx-border-color: #4f5356; + -fx-border-width: 1; +} + +.chart-legend-item +{ + -fx-text-fill: #ececec; +} + +.chart-title +{ + -fx-text-fill: #ececec; +} + +/* Bright high-contrast palette for chart series lines on a dark plot background. */ +.default-color0.chart-series-line { -fx-stroke: #ff5555; } +.default-color1.chart-series-line { -fx-stroke: #55aaff; } +.default-color2.chart-series-line { -fx-stroke: #55ff7f; } +.default-color3.chart-series-line { -fx-stroke: #ffd34f; } +.default-color4.chart-series-line { -fx-stroke: #cf9bff; } +.default-color5.chart-series-line { -fx-stroke: #4fe6ff; } +.default-color6.chart-series-line { -fx-stroke: #ff7fbf; } +.default-color7.chart-series-line { -fx-stroke: #ffffff; } + +/* All chart data points render as bright white dots regardless of series. Lines still carry + the per-series colour so they remain distinguishable. Only -fx-background-color is set so + the symbol size and shape continue to match Modena's defaults. */ +.chart-symbol, +.chart-line-symbol, +.default-color0.chart-symbol, .default-color0.chart-line-symbol, +.default-color1.chart-symbol, .default-color1.chart-line-symbol, +.default-color2.chart-symbol, .default-color2.chart-line-symbol, +.default-color3.chart-symbol, .default-color3.chart-line-symbol, +.default-color4.chart-symbol, .default-color4.chart-line-symbol, +.default-color5.chart-symbol, .default-color5.chart-line-symbol, +.default-color6.chart-symbol, .default-color6.chart-line-symbol, +.default-color7.chart-symbol, .default-color7.chart-line-symbol +{ + -fx-background-color: #ffffff; +}