diff --git a/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataPanel.java b/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataPanel.java
index 325d0e7d..15343ffd 100644
--- a/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataPanel.java
+++ b/src/main/java/io/github/dsheirer/channel/metadata/ChannelMetadataPanel.java
@@ -27,6 +27,7 @@ import io.github.dsheirer.controller.channel.ChannelModel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
+import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.configuration.FrequencyConfigurationIdentifier;
@@ -246,7 +247,8 @@ public class ChannelMetadataPanel extends JPanel implements ListSelectionListene
{
label.setText(Joiner.on(", ").skipNulls().join(aliases));
label.setIcon(mIconModel.getIcon(aliases.get(0).getIconName(), IconModel.DEFAULT_ICON_SIZE));
- label.setForeground(aliases.get(0).getDisplayColor());
+ label.setForeground(ThemeManager.getInstance().getReadableColor(aliases.get(0).getDisplayColor(),
+ label.getBackground()));
}
else
{
diff --git a/src/main/java/io/github/dsheirer/gui/playlist/alias/AliasConfigurationEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/alias/AliasConfigurationEditor.java
index 9106ed8a..899bde0f 100644
--- a/src/main/java/io/github/dsheirer/gui/playlist/alias/AliasConfigurationEditor.java
+++ b/src/main/java/io/github/dsheirer/gui/playlist/alias/AliasConfigurationEditor.java
@@ -19,15 +19,19 @@
package io.github.dsheirer.gui.playlist.alias;
+import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.alias.AliasFactory;
import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.alias.AliasModel;
+import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.control.MaxLengthUnaryOperator;
import io.github.dsheirer.gui.playlist.Editor;
import io.github.dsheirer.gui.playlist.IAliasListRefreshListener;
+import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.Icon;
import io.github.dsheirer.playlist.PlaylistManager;
+import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import java.util.ArrayList;
import java.util.List;
@@ -66,6 +70,7 @@ import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.util.Callback;
+import javax.swing.UIManager;
import jiconfont.IconCode;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.javafx.IconNode;
@@ -124,6 +129,20 @@ public class AliasConfigurationEditor extends SplitPane implements IAliasListRef
setOrientation(Orientation.VERTICAL);
mCurrentEditor = getAliasItemEditor();
getItems().addAll(topBox, getAliasItemEditor());
+
+ MyEventBus.getGlobalEventBus().register(this);
+ }
+
+ /**
+ * Redraws the alias color swatches after a change to the application theme.
+ */
+ @Subscribe
+ public void onPreferenceUpdated(PreferenceType preferenceType)
+ {
+ if(preferenceType == PreferenceType.APPLICATION)
+ {
+ Platform.runLater(() -> getAliasTableView().refresh());
+ }
}
/**
@@ -727,7 +746,11 @@ public class AliasConfigurationEditor extends SplitPane implements IAliasListRef
if(alias != null)
{
rectangle.setVisible(true);
- rectangle.setFill(ColorUtil.fromInteger(alias.getColor()));
+ java.awt.Color color = alias.getDisplayColor();
+ java.awt.Color readable = ThemeManager.getInstance().getReadableColor(color,
+ UIManager.getColor("TextField.background"));
+ rectangle.setFill(ColorUtil.fromInteger(readable.equals(color) ? alias.getColor() :
+ readable.getRGB()));
}
else
{
diff --git a/src/main/java/io/github/dsheirer/gui/theme/ColorContrast.java b/src/main/java/io/github/dsheirer/gui/theme/ColorContrast.java
new file mode 100644
index 00000000..8d0ba218
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/gui/theme/ColorContrast.java
@@ -0,0 +1,100 @@
+/*
+ * *****************************************************************************
+ * 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 java.awt.Color;
+
+/**
+ * Color contrast utilities based on the WCAG relative luminance and contrast ratio definitions.
+ */
+public final class ColorContrast
+{
+ /**
+ * Minimum contrast ratio to treat a color as readable. This is the WCAG threshold for graphics and large text.
+ * The stricter 4.5 threshold for body text would also replace saturated colors like pure red that read fine on a
+ * dark background.
+ */
+ public static final double MINIMUM_CONTRAST_RATIO = 3.0;
+
+ private static final double BLEND_STEP = 0.05;
+
+ private ColorContrast()
+ {
+ }
+
+ /**
+ * Provides a color that is readable against the background.
+ * @param color that is preferred.
+ * @param background that the color is rendered against.
+ * @return the color when it has sufficient contrast, otherwise its RGB complement, adjusted toward white or black
+ * when the complement also lacks sufficient contrast (e.g. mid-gray is its own complement).
+ */
+ public static Color getReadable(Color color, Color background)
+ {
+ if(getContrastRatio(color, background) >= MINIMUM_CONTRAST_RATIO)
+ {
+ return color;
+ }
+
+ Color complement = new Color(255 - color.getRed(), 255 - color.getGreen(), 255 - color.getBlue());
+ Color target = getContrastRatio(Color.WHITE, background) >= getContrastRatio(Color.BLACK, background) ?
+ Color.WHITE : Color.BLACK;
+
+ Color readable = complement;
+
+ for(double fraction = BLEND_STEP; getContrastRatio(readable, background) < MINIMUM_CONTRAST_RATIO &&
+ fraction < 1.0; fraction += BLEND_STEP)
+ {
+ readable = blend(complement, target, fraction);
+ }
+
+ return getContrastRatio(readable, background) < MINIMUM_CONTRAST_RATIO ? target : readable;
+ }
+
+ /**
+ * Contrast ratio between two colors.
+ * @return ratio in the range 1.0 (identical luminance) to 21.0 (black and white).
+ */
+ public static double getContrastRatio(Color a, Color b)
+ {
+ double luminanceA = getRelativeLuminance(a);
+ double luminanceB = getRelativeLuminance(b);
+ return (Math.max(luminanceA, luminanceB) + 0.05) / (Math.min(luminanceA, luminanceB) + 0.05);
+ }
+
+ private static double getRelativeLuminance(Color color)
+ {
+ return 0.2126 * linearize(color.getRed()) + 0.7152 * linearize(color.getGreen()) +
+ 0.0722 * linearize(color.getBlue());
+ }
+
+ private static double linearize(int value)
+ {
+ double normalized = value / 255.0;
+ return normalized <= 0.04045 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4);
+ }
+
+ private static Color blend(Color from, Color to, double fraction)
+ {
+ return new Color((int)Math.round(from.getRed() + (to.getRed() - from.getRed()) * fraction),
+ (int)Math.round(from.getGreen() + (to.getGreen() - from.getGreen()) * fraction),
+ (int)Math.round(from.getBlue() + (to.getBlue() - from.getBlue()) * fraction));
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java b/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java
index f206ed76..f57afc3b 100644
--- a/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java
+++ b/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java
@@ -221,6 +221,18 @@ public class ThemeManager
return mCurrentTheme != null && mCurrentTheme.isDark();
}
+ /**
+ * Provides the color to render user-selected content (e.g. an alias) against a background in the current theme.
+ * @param color selected by the user.
+ * @param background that the color is rendered against.
+ * @return the color, or a readable alternative when dark mode is active and the color lacks contrast with the
+ * background.
+ */
+ public Color getReadableColor(Color color, Color background)
+ {
+ return isDarkMode() ? ColorContrast.getReadable(color, background) : color;
+ }
+
public void register(Scene scene)
{
if(scene == null)
diff --git a/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventPanel.java b/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventPanel.java
index 113ffea4..245d7528 100644
--- a/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventPanel.java
+++ b/src/main/java/io/github/dsheirer/module/decode/event/DecodeEventPanel.java
@@ -26,6 +26,7 @@ import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.channel.IChannelDescriptor;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.filter.FilterSet;
+import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
@@ -285,7 +286,8 @@ public class DecodeEventPanel extends JPanel implements Listener
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.gui.theme;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.Color;
+import org.junit.jupiter.api.Test;
+
+class ColorContrastTest
+{
+ private static final Color DARK_BACKGROUND = new Color(0x2B2B2B);
+ private static final Color SELECTION_BACKGROUND = new Color(0x214283);
+
+ @Test
+ void blackBecomesWhite()
+ {
+ assertEquals(Color.WHITE, ColorContrast.getReadable(Color.BLACK, DARK_BACKGROUND));
+ }
+
+ @Test
+ void lowContrastColorBecomesComplement()
+ {
+ assertEquals(Color.YELLOW, ColorContrast.getReadable(Color.BLUE, DARK_BACKGROUND));
+ }
+
+ @Test
+ void readableColorsAreUnchanged()
+ {
+ assertEquals(Color.RED, ColorContrast.getReadable(Color.RED, DARK_BACKGROUND));
+ assertEquals(Color.ORANGE, ColorContrast.getReadable(Color.ORANGE, DARK_BACKGROUND));
+ }
+
+ @Test
+ void everyGrayIsReadable()
+ {
+ for(int value = 0; value < 256; value++)
+ {
+ Color gray = new Color(value, value, value);
+
+ for(Color background: new Color[]{DARK_BACKGROUND, SELECTION_BACKGROUND, new Color(127, 127, 127)})
+ {
+ assertTrue(ColorContrast.getContrastRatio(ColorContrast.getReadable(gray, background), background) >=
+ ColorContrast.MINIMUM_CONTRAST_RATIO, "Gray " + value + " on " + background);
+ }
+ }
+ }
+}