From af5dbcfcdc96250afe4e6397026d4058aa5ca499 Mon Sep 17 00:00:00 2001 From: Akumas_Coffin_ <68540569+AkumasCoffin@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:20:40 +1000 Subject: [PATCH] Dark mode (#2411) * Add dark mode toggle under User Preferences > Application - Add darkMode boolean preference to ApplicationPreference (persisted via java.util.prefs) - Add ToggleSwitch labeled "Dark Mode" to ApplicationPreferenceEditor - Add ThemeManager singleton that listens for preference updates and applies/removes the dark stylesheet to all registered JavaFX Scenes and swaps the Swing LAF - Add sdrtrunk_dark.css with dark styling for controls, tables, menus, dialogs, charts - Add FlatLaf dependency for full Swing dark theming (replaces previous OS-specific MetalLookAndFeel setup; FlatLaf works cross-platform) - Register all Stages created in JavaFxWindowManager with the ThemeManager and walk Window.getWindows() at apply-time so unregistered dialogs/alerts also pick up the theme - Add Foojay toolchain resolver to settings.gradle so Gradle can auto-provision JDK 25 * Fix dark-mode startup failures on Linux - Apply Swing LAF synchronously on the calling thread so the main JFrame's RootPane has a populated UIDefaults set when constructed. Previously the LAF install was scheduled via EventQueue.invokeLater, so JFrame construction raced ahead and threw "no ComponentUI class for: javax.swing.JPanel". - Call LookAndFeelFactory.installJideExtension(VSNET_STYLE) with an explicit style instead of the no-arg auto-detection variant. The auto-detection path probes com.sun.java.swing.plaf.windows.WindowsLookAndFeel via Class.forName, which is absent on non-Windows JDKs and threw ClassNotFoundException at startup. - Bounce only the SwingUtilities.updateComponentTreeUI walk through the EDT so it redraws already-realized windows on theme toggle. * Fix JIDE/Windows-LookAndFeel crash on non-Windows JDKs JIDE 3.6.18's LookAndFeelFactory contains compile-time \`instanceof com.sun.java.swing.plaf.windows.WindowsLookAndFeel\` checks in multiple methods, including a JIDE PropertyChangeListener that fires on every UIManager.setLookAndFeel call. On Linux and macOS JDKs the class is genuinely absent from java.desktop, so the JVM throws NoClassDefFoundError the first time JIDE's dispatch code is executed (during JFrame init and again from JideSplitPane.updateUI on each LAF change). Two complementary fixes: 1. Pass an explicit MetalLookAndFeel instance to the 3-arg installJideExtension(UIDefaults, LookAndFeel, int) overload so JIDE's Metal branch matches first and never falls through to the Windows branch in the normal install path. 2. Add a no-op stub class at com.sun.java.swing.plaf.windows.WindowsLookAndFeel so that the compile-time class reference resolves on non-Windows JDKs. The stub reports isSupportedLookAndFeel = false and is never instantiated - JIDE only uses it for instanceof comparisons against the active LAF, which always return false in our setup. On Windows JDKs the real class in java.desktop is loaded by the platform classloader and shadows this stub. * Re-apply FlatLaf defaults after JIDE install + strengthen FX borders Symptom: With dark mode enabled, the Swing main window's spectrum chrome and menu bar were dark (FlatLaf) but tables, tabbed panes and the inner content area were light/Metal coloured. Root cause: LookAndFeelFactory.installJideExtension installs JIDE's Metal-flavored UI defaults (VsnetMetalUtils etc.) into the live UIDefaults table, overwriting FlatLaf's standard component colors and fonts for keys like Table.background, TabbedPane.foreground, Panel.background. Fix: After the JIDE install, walk a fresh FlatLaf UIDefaults table and put every key back into the active defaults, skipping anything Jide-prefixed. JIDE's UI delegate registrations live under Jide* keys that FlatLaf does not define, so they survive untouched while the standard Swing color and font keys are restored to their dark equivalents. Also strengthen the JavaFX dark stylesheet: explicit borders on text fields, tables, titled panes, split-pane dividers and separators so components have more defining outlines in dark mode. * Force-override Swing foreground colors via UIManager user-defaults layer Symptom: With dark mode enabled the inner Swing content area now had dark panels (good) but tab labels, table cells, status-bar labels and button text were rendering nearly black-on-dark (bad). Root cause: UIManager.getLookAndFeelDefaults() lookups for FlatLaf's component foreground keys still resolve to Metal-era dark values after the JIDE install + FlatLaf re-apply, because some FlatLaf keys are derived / LazyValue and recompute against state that JIDE seeded. Fix: After the bulk FlatLaf-defaults copy, explicitly UIManager.put the foreground / background / selection / disabled / border colors for every standard Swing component family. UIManager.put writes to the user-defaults layer that takes precedence over the LAF defaults, so these overrides win regardless of what JIDE or derived-value resolution produces. Also bump the JavaFX dark stylesheet text colour from #d6d6d6 to #ececec so labels read closer to white on the dark panels. * Theme JIDE-specific UIManager keys so JideTabbedPane reads dark colors The Now Playing / Map / Tuners / Playlist Editor / Details / Events / Messages / Channel tabs are JIDE JideTabbedPane components and their UI delegate reads JideTabbedPane.* UIManager keys, not the standard TabbedPane.* keys. The previous overrides only set the standard keys so the tabs were still rendering with JIDE's Metal-era dark foreground on the now-dark background. Override the JIDE-specific text foreground keys (JideTabbedPane.foreground, .selectedTabTextForeground, .unselectedTabTextForeground, .activeTabTextForeground) along with the matching background and shadow keys. Also theme JideSplitPane, JideButton and JideLabel keys for consistency. * Remove hardcoded Color.BLACK foreground on JideTabbedPanes ControllerPanel.getTabbedPane() and NowPlayingPanel.getTabbedPane() each called setForeground(Color.BLACK) on their JideTabbedPane instances. Color.BLACK is a plain java.awt.Color, not a ColorUIResource, so it is never replaced by LookAndFeel.installColorsAndFont() on subsequent UI installs - it permanently pins the tab labels to black regardless of any JIDE or FlatLaf defaults the theme manager sets. In dark mode this leaves the tab labels black-on-dark and unreadable. Drop the explicit foreground calls so the LAF (JideTabbedPaneUI reading JideTabbedPane.foreground / .unselectedTabTextForeground / .selectedTabTextForeground / .activeTabTextForeground) drives the colour in both light and dark modes. * Register Channel tab's JFXPanel scenes with the ThemeManager The Channel tab embeds two JavaFX scenes inside Swing via JFXPanel: the noise squelch chart (NoiseSquelchView) and the symbol view. Neither was registered with the ThemeManager, so the dark stylesheet was never applied to them. In dark mode the chart legend, axis area and surrounding panel rendered with JavaFX's default light Modena styling against the otherwise dark UI. JFXPanel-hosted scenes are invisible to javafx.stage.Window.getWindows(), so the theme manager cannot find them automatically on toggle - they have to be explicitly registered at creation time, the same as the JavaFX Stages JavaFxWindowManager already registers. * Use ColorUIResource for theme overrides + brighten chart series colors Toggle-off problem: applyExplicitOverrides was storing plain java.awt.Color values via UIManager.put. LookAndFeel.installColorsAndFont only replaces a component's existing colour when it is null or an instance of UIResource - plain Colors are not, so once a component picked up its dark colour it kept that colour for the rest of the JVM lifetime even after toggling back to light mode. Switching every override to ColorUIResource lets subsequent updateComponentTreeUI calls replace the cached colour cleanly. Chart visibility: the JavaFX charts on the Channel tab (NoiseSquelchView) were inheriting Modena's default 8-colour series palette, which has several hues that read poorly against a dark plot background. The existing sdrtrunk_style.css also hardcodes .chart-symbol to solid black. Override .default-color0..7.chart-series-line and matching .chart-line-symbol selectors with a brighter high-contrast palette, darken the plot background to #1c1c1c for stronger contrast against the grid lines, and add explicit colours for axis tick marks, alternate row fills and legend border. * Use primary foreground for selected tab text instead of selection foreground The previous overrides set JideTabbedPane.selectedTabTextForeground and TabbedPane.selectedForeground to fgSelection (always white). Selected tabs render on the raised surface (bgRaised) which is white in light mode, so white-on-white made selected tab labels invisible. Selected tabs are not list/table row selections that use the blue bgSelection background - they sit on the same raised tone as other surfaces. Use fgPrimary so the text is dark on the light raised surface in light mode and light on the dark raised surface in dark mode. * Brighten chart data points to solid white in dark mode * Restore default chart symbol size, only override colour * Refresh detached Channel-tab swing components on theme toggle The Channel tab swaps one of three right-pane components into its JSplitPane depending on the active decoder type: SignalPowerView for AM, the NoiseSquelchPanel JFXPanel for NBFM, the SymbolPanel JFXPanel for feedback decoders. Only one is attached at any moment - the other two are detached references on the ChannelSpectrumPanel. SwingUtilities.updateComponentTreeUI(Window) during a theme toggle walks only the currently-realized component trees, so the detached swappables miss the refresh and carry stale colours from the LAF that was active when they were last attached. Swapping back in then shows e.g. an AM SignalPowerView in dark colours after a light-mode toggle. Add ThemeManager.registerSwing(Component) tracking, walk those refs on every toggle, and register the three swappables when constructed. * Add preset themes selector under User Preferences > Application Replace the single Dark Mode toggle with a curated selection of 9 preset themes, exposed as a vertical radio-button group: Light IntelliJ Nord Dark Darcula Dracula Solarized Light Solarized Dark Monokai Pro Each Theme enum value pairs a human-readable display name with a FlatLaf class name and a dark flag. The first four come from the core flatlaf dependency; the others come from flatlaf-intellij-themes which is added as a new implementation dependency (~1MB). ApplicationPreference now stores the selected theme by enum name under the new ui.theme key. isDarkMode() is retained as a compatibility shim that delegates to the current theme. Existing users with the legacy dark.mode boolean are auto-migrated on first read. ThemeManager is refactored to drive the LAF from the Theme: - Reflectively instantiates the FlatLaf class. - Reads Panel.background, Button.background, TextField.background, List.selectionBackground/Foreground, Label.foreground/disabledForeground, Component.borderColor and Table.alternateRowColor from UIManager *after* FlatLaf installs, then seeds those values into the JIDE-specific keys. Each theme automatically contributes its own palette to JIDE widgets without per-theme hardcoded colours. ApplicationPreferenceEditor swaps the ToggleSwitch for a ToggleGroup of RadioButtons that persist the selection through ApplicationPreference.setTheme. * Fix theme switching past first toggle + move Themes to Appearance sub-page Critical regression in 56b0f6c9: applyExplicitOverrides reads colours via UIManager.getColor(...) which consults the merged defaults (user defaults overlay LAF defaults). After the first apply our own UIManager.put calls populate the user-defaults layer, so subsequent reads return our previous theme's colours instead of the newly-installed LAF's colours. The result: the first theme switch works, every switch after that silently re-applies the previous palette and the UI appears frozen on the first picked theme. Switch every palette read to UIManager.getLookAndFeelDefaults().getColor(...) which is the LAF-only layer and ignores the user-defaults overlay we wrote. Also: - Move the Themes selector to its own sub-node: User Preferences > Application > Appearance PreferenceEditorType gets a new APPEARANCE entry, the existing APPLICATION leaf is relabelled "General" so the tree no longer reads "Application > Application > Application". New AppearancePreferenceEditor hosts the theme radio group; ApplicationPreferenceEditor loses it. - Replace a few hardcoded label foregrounds that read as invisible-on-dark-bg with LAF-driven equivalents: TunerViewPanel TunerStatus.DISABLED: DARK_GRAY -> Label.disabledForeground BroadcastStatusPanel DISABLED state: LIGHT_GRAY -> Label.disabledForeground JFrequencyControl Digit mouseExited: Color.WHITE -> TextField.background Semantic colour usage (RED/YELLOW for error and warning states, the audio-panel hardware-style black backgrounds) is intentionally kept. * Auto-theme every JavaFX window so transient dialogs follow the theme The Playlist Editor's main Scene is already registered with the ThemeManager, but the many Alert / TextInputDialog / RadioReferenceUnavailableAlert popups it spawns each create a fresh Stage with a fresh Scene that nothing registers. Once one is shown after a theme switch it renders with default Modena styling on top of an otherwise themed app. Install a ListChangeListener on javafx.stage.Window.getWindows() at startup. Every newly-added Window is themed immediately if its Scene is set, otherwise a one-shot sceneProperty listener applies the theme the moment the Scene is attached. Already-open windows at install time are also walked once. This removes the need to manually register every transient dialog and covers the playlist editor, alias editor confirmations, broadcastify error alerts, channel-map editor warnings, and any future ad-hoc Alert / Dialog the codebase grows. * Curate themes to 8 modern variants + per-theme JavaFX accent colours Theme set is trimmed and refocused: Drop: IntelliJ (too similar to Light), FlatDarculaLaf (duplicates Dracula), Monokai Pro (low identity in this app). Add: Material Deep Ocean (vibrant teal dark), Arc Dark (flat modern dark). Keep: Light, Dark, Nord, Dracula (FlatDraculaIJTheme - the one user liked), Solarized Light, Solarized Dark. Each theme now contributes its own accent palette to the JavaFX panels too, not just the Swing chrome. ThemeManager builds a small per-theme stylesheet at apply time by reading the FlatLaf-installed colours (Panel.background, TextField.background, Component.accentColor, List.selectionBackground/Foreground, Label.foreground, Component.borderColor, Table.alternateRowColor) and substituting them into Modena's core variables (-fx-base, -fx-accent, -fx-selection-bar, -fx-control-inner-background, etc.) plus a handful of explicit rules for table selection rows, default button, scroll bar thumb. The generated CSS is attached to every registered Scene as a data: URL, so Nord reads blue-tinted, Dracula purple-tinted, Solarized cream/teal, Material Deep Ocean dark teal, Arc Dark cool gray. The static sdrtrunk_dark.css remains as the structural baseline for all dark themes. * Add GUI scale slider under Appearance preferences New slider in User Preferences > Application > Appearance lets users zoom the whole UI between 50% and 200% in 10% increments. The value is persisted as a new ApplicationPreference (ui.gui.scale, default 1.0, bounded 0.5-2.0) and applied immediately on slider release - intermediate drag positions do not trigger a re-apply so the LAF reinstall cost is paid once at the end of the drag. Swing side: ThemeManager sets the flatlaf.uiScale system property before each setLookAndFeel call, so the new FlatLaf instance picks up the user's zoom on initialise. All component sizes (fonts, padding, icons) scale together because FlatLaf does the math internally. JavaFX side: the per-theme accent stylesheet emits an .root { -fx-font-size: <12 * scale>px; } rule. Modena's component sizes are em-relative so cascading the font size scales the JavaFX panels in sync with the Swing chrome. The onPreferenceUpdated guard now compares both theme and scale, so moving only the slider still triggers a re-apply. A Reset button next to the slider returns to 100%. --- build.gradle | 2 + settings.gradle | 2 +- .../plaf/windows/WindowsLookAndFeel.java | 81 ++ .../audio/broadcast/BroadcastStatusPanel.java | 5 +- .../channel/metadata/NowPlayingPanel.java | 2 - .../dsheirer/controller/ControllerPanel.java | 1 - .../dsheirer/gui/JavaFxWindowManager.java | 8 + .../java/io/github/dsheirer/gui/SDRTrunk.java | 23 +- .../gui/channel/ChannelSpectrumPanel.java | 10 + .../gui/control/JFrequencyControl.java | 5 +- .../preference/PreferenceEditorFactory.java | 3 + .../gui/preference/PreferenceEditorType.java | 3 +- .../gui/preference/UserPreferencesEditor.java | 1 + .../AppearancePreferenceEditor.java | 205 +++++ .../io/github/dsheirer/gui/theme/Theme.java | 96 +++ .../dsheirer/gui/theme/ThemeManager.java | 789 ++++++++++++++++++ .../application/ApplicationPreference.java | 95 +++ .../source/tuner/ui/TunerViewPanel.java | 5 +- src/main/resources/sdrtrunk_dark.css | 458 ++++++++++ 19 files changed, 1767 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/sun/java/swing/plaf/windows/WindowsLookAndFeel.java create mode 100644 src/main/java/io/github/dsheirer/gui/preference/application/AppearancePreferenceEditor.java create mode 100644 src/main/java/io/github/dsheirer/gui/theme/Theme.java create mode 100644 src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java create mode 100644 src/main/resources/sdrtrunk_dark.css 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; +}