Theme-aware icons for the main window menu bar and Playlist Editor tab (#2493)

The main window's font-glyph icons were rendered with IconFontSwing.buildIcon,
which bakes a fixed colour into the image (black by default, DARK_GRAY for the
Playlist Editor tab), so the Screen Capture camera, the Playlist Editor tab icon
and the View menu icons ignored the selected theme and were nearly invisible on
dark themes.

Adds ThemedFontIcon, which resolves its colour from UIManager at paint time
(Label.foreground, MenuItem.selectionForeground while a menu item is armed,
Label.disabledForeground when disabled) and re-renders only when the colour
changes, so icons follow theme switches without a restart.


Claude-Session: https://claude.ai/code/session_01TDsxnQntujmycg3aJyRsuN

Co-authored-by: Dennis Sheirer <dennis.sheirer@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Denny Sheirer
2026-10-12 06:05:18 -04:00
committed by GitHub
parent b37ed51d07
commit 0460f2b77b
5 changed files with 195 additions and 17 deletions

View File

@@ -32,7 +32,7 @@ import java.awt.EventQueue;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.swing.IconFontSwing;
import io.github.dsheirer.gui.theme.ThemedFontIcon;
import net.miginfocom.swing.MigLayout;
import javax.sound.sampled.FloatControl;
@@ -152,7 +152,7 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
{
JPopupMenu popup = new JPopupMenu();
JMenuItem outputMenu = new JMenuItem("Audio Playback Device ...");
Icon icon = IconFontSwing.buildIcon(FontAwesome.COG, 14);
Icon icon = new ThemedFontIcon(FontAwesome.COG, 14);
outputMenu.setIcon(icon);
outputMenu.addActionListener(e -> MyEventBus.getGlobalEventBus()
.post(new ViewUserPreferenceEditorRequest(PreferenceEditorType.AUDIO_OUTPUT)));
@@ -164,7 +164,7 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
popup.add(new JPopupMenu.Separator());
JMenuItem volume = new JMenuItem("Audio Volume");
volume.setEnabled(false);
Icon volumeIcon = IconFontSwing.buildIcon(FontAwesome.VOLUME_UP, 14);
Icon volumeIcon = new ThemedFontIcon(FontAwesome.VOLUME_UP, 14);
volume.setIcon(volumeIcon);
popup.add(volume);
popup.add(new VolumeSlider(mAudioPlaybackManager.getAudioOutput().getGainControl()));

View File

@@ -32,10 +32,9 @@ import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.tuner.manager.TunerManager;
import io.github.dsheirer.source.tuner.ui.TunerViewPanel;
import java.awt.Color;
import java.awt.Dimension;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.swing.IconFontSwing;
import io.github.dsheirer.gui.theme.ThemedFontIcon;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -104,7 +103,7 @@ public class ControllerPanel extends JPanel
mTabbedPane.addTab("Map", mMapPanel);
mTabbedPane.addTab("Tuners", mTunerManagerPanel);
Icon playIcon = IconFontSwing.buildIcon(FontAwesome.PLAY_CIRCLE_O, 20, Color.DARK_GRAY);
Icon playIcon = new ThemedFontIcon(FontAwesome.PLAY_CIRCLE_O, 20);
mTabbedPane.addTab("Playlist Editor", playIcon, new JLabel("Show Playlist Manager"));
mSettingsTabIndex = mTabbedPane.getTabCount() - 1;

View File

@@ -38,6 +38,7 @@ 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.theme.ThemedFontIcon;
import io.github.dsheirer.gui.viewer.ViewRecordingViewerRequest;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.log.ApplicationLog;
@@ -468,14 +469,14 @@ public class SDRTrunk implements Listener<TunerEvent>
JMenu viewMenu = new JMenu("View");
JMenuItem viewPlaylistItem = new JMenuItem("Playlist Editor");
viewPlaylistItem.setIcon(IconFontSwing.buildIcon(FontAwesome.PLAY_CIRCLE_O, 12));
viewPlaylistItem.setIcon(new ThemedFontIcon(FontAwesome.PLAY_CIRCLE_O, 12));
viewPlaylistItem.addActionListener(e -> MyEventBus.getGlobalEventBus().post(new ViewPlaylistRequest()));
viewMenu.add(viewPlaylistItem);
viewMenu.add(new JSeparator());
JMenuItem viewApplicationLogsMenu = new JMenuItem("Application Log Files");
viewApplicationLogsMenu.setIcon(IconFontSwing.buildIcon(FontAwesome.FOLDER_OPEN_O, 12));
viewApplicationLogsMenu.setIcon(new ThemedFontIcon(FontAwesome.FOLDER_OPEN_O, 12));
viewApplicationLogsMenu.addActionListener(arg0 -> {
File logsDirectory = mUserPreferences.getDirectoryPreference().getDirectoryApplicationLog().toFile();
try
@@ -495,7 +496,7 @@ public class SDRTrunk implements Listener<TunerEvent>
viewMenu.add(viewApplicationLogsMenu);
JMenuItem viewRecordingsMenuItem = new JMenuItem("Audio Recordings");
viewRecordingsMenuItem.setIcon(IconFontSwing.buildIcon(FontAwesome.FOLDER_OPEN_O, 12));
viewRecordingsMenuItem.setIcon(new ThemedFontIcon(FontAwesome.FOLDER_OPEN_O, 12));
viewRecordingsMenuItem.addActionListener(arg0 -> {
File recordingsDirectory = mUserPreferences.getDirectoryPreference().getDirectoryRecording().toFile();
@@ -517,7 +518,7 @@ public class SDRTrunk implements Listener<TunerEvent>
viewMenu.add(viewRecordingsMenuItem);
JMenuItem viewEventLogsMenu = new JMenuItem("Channel Event Log Files");
viewEventLogsMenu.setIcon(IconFontSwing.buildIcon(FontAwesome.FOLDER_OPEN_O, 12));
viewEventLogsMenu.setIcon(new ThemedFontIcon(FontAwesome.FOLDER_OPEN_O, 12));
viewEventLogsMenu.addActionListener(arg0 -> {
File eventLogsDirectory = mUserPreferences.getDirectoryPreference().getDirectoryEventLog().toFile();
try
@@ -537,17 +538,17 @@ public class SDRTrunk implements Listener<TunerEvent>
viewMenu.add(viewEventLogsMenu);
JMenuItem iconManagerMenu = new JMenuItem("Icon Manager");
iconManagerMenu.setIcon(IconFontSwing.buildIcon(FontAwesome.PICTURE_O, 12));
iconManagerMenu.setIcon(new ThemedFontIcon(FontAwesome.PICTURE_O, 12));
iconManagerMenu.addActionListener(arg0 -> MyEventBus.getGlobalEventBus().post(new ViewIconManagerRequest()));
viewMenu.add(iconManagerMenu);
JMenuItem recordingViewerMenu = new JMenuItem("Message Recording Viewer (.bits)");
recordingViewerMenu.setIcon(IconFontSwing.buildIcon(FontAwesome.BRAILLE, 12));
recordingViewerMenu.setIcon(new ThemedFontIcon(FontAwesome.BRAILLE, 12));
recordingViewerMenu.addActionListener(e -> MyEventBus.getGlobalEventBus().post(new ViewRecordingViewerRequest()));
viewMenu.add(recordingViewerMenu);
JMenuItem viewScreenCapturesMenu = new JMenuItem("Screen Captures");
viewScreenCapturesMenu.setIcon(IconFontSwing.buildIcon(FontAwesome.FOLDER_OPEN_O, 12));
viewScreenCapturesMenu.setIcon(new ThemedFontIcon(FontAwesome.FOLDER_OPEN_O, 12));
viewScreenCapturesMenu.addActionListener(arg0 -> {
File screenCapturesDirectory = mUserPreferences.getDirectoryPreference().getDirectoryScreenCapture().toFile();
try
@@ -567,7 +568,7 @@ public class SDRTrunk implements Listener<TunerEvent>
viewMenu.add(viewScreenCapturesMenu);
JMenuItem preferencesItem = new JMenuItem("User Preferences");
preferencesItem.setIcon(IconFontSwing.buildIcon(FontAwesome.COG, 12));
preferencesItem.setIcon(new ThemedFontIcon(FontAwesome.COG, 12));
preferencesItem.addActionListener(e -> MyEventBus.getGlobalEventBus().post(new ViewUserPreferenceEditorRequest()));
viewMenu.add(preferencesItem);
@@ -582,7 +583,7 @@ public class SDRTrunk implements Listener<TunerEvent>
menuBar.add(viewMenu);
JMenuItem screenCaptureItem = new JMenuItem("Screen Capture");
screenCaptureItem.setIcon(IconFontSwing.buildIcon(FontAwesome.CAMERA, 12));
screenCaptureItem.setIcon(new ThemedFontIcon(FontAwesome.CAMERA, 12));
screenCaptureItem.setMnemonic(KeyEvent.VK_C);
screenCaptureItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_C, ActionEvent.ALT_MASK));
screenCaptureItem.setMaximumSize(screenCaptureItem.getPreferredSize());

View File

@@ -0,0 +1,178 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.theme;
import com.formdev.flatlaf.FlatLaf;
import java.awt.Color;
import java.awt.Component;
import java.awt.Graphics;
import javax.swing.ButtonModel;
import javax.swing.Icon;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.UIManager;
import jiconfont.IconCode;
import jiconfont.swing.IconFontSwing;
/**
* Swing font-glyph icon whose colour follows the active theme.
*
* <p>{@link IconFontSwing#buildIcon(IconCode, float)} bakes a fixed colour (black by default) into
* the rendered image, so those icons never change when the user switches themes and disappear on
* dark palettes. This icon resolves its colour from {@link UIManager} each time it is painted and
* only re-renders the glyph when that colour changes, so it automatically tracks theme changes
* applied by {@link ThemeManager}.
*
* <p>Colour resolution:
* <ul>
* <li>Disabled component: {@code Label.disabledForeground}</li>
* <li>Armed/selected menu item: {@code MenuItem.selectionForeground}</li>
* <li>Otherwise: the configured foreground key (default {@code Label.foreground})</li>
* </ul>
*/
public class ThemedFontIcon implements Icon, FlatLaf.DisabledIconProvider
{
private static final String DEFAULT_FOREGROUND_KEY = "Label.foreground";
private static final String DISABLED_FOREGROUND_KEY = "Label.disabledForeground";
private static final String SELECTION_FOREGROUND_KEY = "MenuItem.selectionForeground";
private final IconCode mIconCode;
private final float mSize;
private final String mForegroundKey;
private final boolean mForceDisabled;
private Color mCachedColor;
private Icon mCachedIcon;
private ThemedFontIcon mDisabledIcon;
/**
* Constructs a theme-aware glyph icon using the standard label foreground colour.
* @param iconCode glyph to render
* @param size in points
*/
public ThemedFontIcon(IconCode iconCode, float size)
{
this(iconCode, size, DEFAULT_FOREGROUND_KEY);
}
/**
* Constructs a theme-aware glyph icon using the specified UIManager colour key.
* @param iconCode glyph to render
* @param size in points
* @param foregroundKey UIManager colour key used for the normal (enabled) state
*/
public ThemedFontIcon(IconCode iconCode, float size, String foregroundKey)
{
this(iconCode, size, foregroundKey, false);
}
private ThemedFontIcon(IconCode iconCode, float size, String foregroundKey, boolean forceDisabled)
{
mIconCode = iconCode;
mSize = size;
mForegroundKey = foregroundKey;
mForceDisabled = forceDisabled;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y)
{
getIcon(resolveColor(c)).paintIcon(c, g, x, y);
}
//Glyph dimensions do not depend on colour, so size queries reuse whatever is cached rather than
//forcing a re-render in a different colour between paints.
@Override
public int getIconWidth()
{
return getSizingIcon().getIconWidth();
}
@Override
public int getIconHeight()
{
return getSizingIcon().getIconHeight();
}
private synchronized Icon getSizingIcon()
{
return mCachedIcon != null ? mCachedIcon : getIcon(resolveColor(null));
}
/**
* Disabled variant used by FlatLaf when the owning component is disabled.
*/
@Override
public Icon getDisabledIcon()
{
if(mDisabledIcon == null)
{
mDisabledIcon = new ThemedFontIcon(mIconCode, mSize, mForegroundKey, true);
}
return mDisabledIcon;
}
/**
* Determines the colour to paint with from the current theme and component state.
*/
private Color resolveColor(Component c)
{
if(mForceDisabled || (c != null && !c.isEnabled()))
{
return color(DISABLED_FOREGROUND_KEY, Color.GRAY);
}
//Menu items render their text in the selection foreground while armed (hovered) - match it
//so the glyph stays legible against the selection background. Top-level menus on the menu
//bar report as 'selected' while their popup is open.
if(c instanceof JMenuItem menuItem)
{
ButtonModel model = menuItem.getModel();
boolean highlighted = model.isArmed() || (menuItem instanceof JMenu && model.isSelected());
if(highlighted)
{
return color(SELECTION_FOREGROUND_KEY, color(mForegroundKey, Color.BLACK));
}
}
return color(mForegroundKey, Color.BLACK);
}
private static Color color(String key, Color fallback)
{
Color color = UIManager.getColor(key);
return color != null ? color : fallback;
}
/**
* Returns the glyph rendered in the requested colour, re-rendering only when the colour changes.
*/
private synchronized Icon getIcon(Color color)
{
if(mCachedIcon == null || !color.equals(mCachedColor))
{
mCachedIcon = IconFontSwing.buildIcon(mIconCode, mSize, color);
mCachedColor = color;
}
return mCachedIcon;
}
}

View File

@@ -20,7 +20,7 @@ package io.github.dsheirer.spectrum;
import io.github.dsheirer.properties.SystemProperties;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.swing.IconFontSwing;
import io.github.dsheirer.gui.theme.ThemedFontIcon;
import javax.swing.JMenuItem;
@@ -38,7 +38,7 @@ public class DisableSpectrumWaterfallMenuItem extends JMenuItem
public DisableSpectrumWaterfallMenuItem(SpectralDisplayPanel spectralDisplayPanel)
{
super("Disable Spectrum & Waterfall");
setIcon(IconFontSwing.buildIcon(FontAwesome.EYE_SLASH, 12));
setIcon(new ThemedFontIcon(FontAwesome.EYE_SLASH, 12));
mSpectralDisplayPanel = spectralDisplayPanel;