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); + } + } + } +}