#2496 Displays use complementary color for aliases in dark mode (#2548)

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-11-01 05:38:48 -05:00
committed by GitHub
parent 197cec04f3
commit 7b249af944
6 changed files with 208 additions and 3 deletions

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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));
}
}

View File

@@ -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)

View File

@@ -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<ProcessingChain
sb.append(",");
}
sb.append(Joiner.on(", ").skipNulls().join(aliases));
color = aliases.getFirst().getDisplayColor();
color = ThemeManager.getInstance().getReadableColor(
aliases.getFirst().getDisplayColor(), label.getBackground());
icon = mIconModel.getIcon(aliases.getFirst().getIconName(), IconModel.DEFAULT_ICON_SIZE);
}
}

View File

@@ -0,0 +1,66 @@
/*
* *****************************************************************************
* 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 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);
}
}
}
}