Improve color contrast checker

Add option to remove color opacity (flatten).
This commit is contained in:
mkpaz 2022-08-26 14:44:06 +04:00
parent 197618ef2e
commit 2d6393cc2b
5 changed files with 157 additions and 63 deletions

@ -1,7 +1,9 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general; package atlantafx.sampler.page.general;
import atlantafx.base.theme.Styles; import atlantafx.base.theme.Styles;
import atlantafx.sampler.util.Containers; import atlantafx.sampler.util.Containers;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.AnchorPane; import javafx.scene.layout.AnchorPane;
@ -23,6 +25,7 @@ class ColorBlock extends VBox {
private final String fgColorName; private final String fgColorName;
private final String bgColorName; private final String bgColorName;
private final String borderColorName; private final String borderColorName;
private final ReadOnlyObjectProperty<Color> bgBaseColor;
private final AnchorPane colorBox; private final AnchorPane colorBox;
private final Text fgText; private final Text fgText;
@ -32,10 +35,14 @@ class ColorBlock extends VBox {
private Consumer<ColorBlock> actionHandler; private Consumer<ColorBlock> actionHandler;
public ColorBlock(String fgColorName, String bgColorName, String borderColorName) { public ColorBlock(String fgColorName,
String bgColorName,
String borderColorName,
ReadOnlyObjectProperty<Color> bgBaseColor) {
this.fgColorName = validateColorName(fgColorName); this.fgColorName = validateColorName(fgColorName);
this.bgColorName = validateColorName(bgColorName); this.bgColorName = validateColorName(bgColorName);
this.borderColorName = validateColorName(borderColorName); this.borderColorName = validateColorName(borderColorName);
this.bgBaseColor = bgBaseColor;
fgText = new Text(); fgText = new Text();
fgText.setStyle("-fx-fill:" + fgColorName + ";"); fgText.setStyle("-fx-fill:" + fgColorName + ";");
@ -58,11 +65,15 @@ class ColorBlock extends VBox {
colorBox.getStyleClass().add("box"); colorBox.getStyleClass().add("box");
colorBox.getChildren().setAll(fgText, wsagLabel, expandIcon); colorBox.getChildren().setAll(fgText, wsagLabel, expandIcon);
colorBox.setOnMouseEntered(e -> { colorBox.setOnMouseEntered(e -> {
toggleHover(true);
var bgFill = getBgColor(); var bgFill = getBgColor();
// this happens when css isn't updated yet
if (bgFill == null) { return; }
toggleHover(true);
// doesn't play quite well with transparency, because we not calc // doesn't play quite well with transparency, because we not calc
// actual underlying background color to flatten bgFill // actual underlying background color to flatten bgFill
expandIcon.setFill(getColorLuminance(flattenColor(Color.WHITE, bgFill)) < LUMINANCE_THRESHOLD ? expandIcon.setFill(getColorLuminance(flattenColor(bgBaseColor.get(), bgFill)) < LUMINANCE_THRESHOLD ?
Color.WHITE : Color.BLACK Color.WHITE : Color.BLACK
); );
}); });
@ -94,6 +105,12 @@ class ColorBlock extends VBox {
wsagLabel.setOpacity(state ? 0.5 : 1); wsagLabel.setOpacity(state ? 0.5 : 1);
} }
private Text description(String text) {
var t = new Text(text);
t.getStyleClass().addAll("description", Styles.TEXT_SMALL);
return t;
}
public void update() { public void update() {
var fgFill = getFgColor(); var fgFill = getFgColor();
var bgFill = getBgColor(); var bgFill = getBgColor();
@ -105,7 +122,7 @@ class ColorBlock extends VBox {
return; return;
} }
double contrastRatio = 1 / getContrastRatioOpacityAware(bgFill, fgFill); double contrastRatio = 1 / getContrastRatioOpacityAware(bgFill, fgFill, bgBaseColor.get());
colorBox.pseudoClassStateChanged(PASSED, contrastRatio >= 4.5); colorBox.pseudoClassStateChanged(PASSED, contrastRatio >= 4.5);
wsagIcon.setIconCode(contrastRatio >= 4.5 ? Material2AL.CHECK : Material2AL.CLOSE); wsagIcon.setIconCode(contrastRatio >= 4.5 ? Material2AL.CHECK : Material2AL.CLOSE);
@ -119,9 +136,8 @@ class ColorBlock extends VBox {
} }
public Color getBgColor() { public Color getBgColor() {
return colorBox.getBackground() != null & !colorBox.getBackground().isEmpty() ? return colorBox.getBackground() != null && !colorBox.getBackground().isEmpty() ?
(Color) colorBox.getBackground().getFills().get(0).getFill() : (Color) colorBox.getBackground().getFills().get(0).getFill() : null;
null;
} }
public String getFgColorName() { public String getFgColorName() {
@ -139,10 +155,4 @@ class ColorBlock extends VBox {
public void setOnAction(Consumer<ColorBlock> actionHandler) { public void setOnAction(Consumer<ColorBlock> actionHandler) {
this.actionHandler = actionHandler; this.actionHandler = actionHandler;
} }
private Text description(String text) {
var t = new Text(text);
t.getStyleClass().addAll("description", Styles.TEXT_SMALL);
return t;
}
} }

@ -1,3 +1,4 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general; package atlantafx.sampler.page.general;
import atlantafx.base.controls.CustomTextField; import atlantafx.base.controls.CustomTextField;
@ -12,11 +13,14 @@ import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.css.PseudoClass; import javafx.css.PseudoClass;
import javafx.event.Event;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay; import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.Slider; import javafx.scene.control.Slider;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
@ -36,6 +40,7 @@ public class ColorContrastChecker extends GridPane {
static final PseudoClass PASSED = PseudoClass.getPseudoClass("passed"); static final PseudoClass PASSED = PseudoClass.getPseudoClass("passed");
static final float[] COLOR_WHITE = new float[] { 255f, 255f, 255f, 1f }; static final float[] COLOR_WHITE = new float[] { 255f, 255f, 255f, 1f };
static final float[] COLOR_BLACK = new float[] { 0f, 0f, 0f, 1f }; static final float[] COLOR_BLACK = new float[] { 0f, 0f, 0f, 1f };
static final double CONTRAST_RATIO_THRESHOLD = 1.5;
static final double LUMINANCE_THRESHOLD = 0.55; static final double LUMINANCE_THRESHOLD = 0.55;
private static final int SLIDER_WIDTH = 300; private static final int SLIDER_WIDTH = 300;
@ -45,11 +50,8 @@ public class ColorContrastChecker extends GridPane {
private final ObservableHSLAColor bgColor = new ObservableHSLAColor(Color.WHITE); private final ObservableHSLAColor bgColor = new ObservableHSLAColor(Color.WHITE);
private final ObservableHSLAColor fgColor = new ObservableHSLAColor(Color.BLACK); private final ObservableHSLAColor fgColor = new ObservableHSLAColor(Color.BLACK);
private final DoubleBinding contrastRatio = Bindings.createDoubleBinding( private final ReadOnlyObjectProperty<Color> bgBaseColor;
() -> 1 / getContrastRatioOpacityAware(bgColor.getColor(), fgColor.getColor()), private final DoubleBinding contrastRatio;
bgColor.colorProperty(),
fgColor.colorProperty()
);
private Label bgColorNameLabel; private Label bgColorNameLabel;
private Label fgColorNameLabel; private Label fgColorNameLabel;
@ -62,8 +64,17 @@ public class ColorContrastChecker extends GridPane {
private Slider fgLightnessSlider; private Slider fgLightnessSlider;
private Slider fgAlphaSlider; private Slider fgAlphaSlider;
public ColorContrastChecker() { public ColorContrastChecker(ReadOnlyObjectProperty<Color> bgBaseColor) {
super(); super();
this.bgBaseColor = bgBaseColor;
this.contrastRatio = Bindings.createDoubleBinding(
() -> 1 / getContrastRatioOpacityAware(bgColor.getColor(), fgColor.getColor(), bgBaseColor.get()),
bgColor.colorProperty(),
fgColor.colorProperty(),
bgBaseColor
);
createView(); createView();
} }
@ -155,6 +166,7 @@ public class ColorContrastChecker extends GridPane {
bgTextField.textProperty().bind(Bindings.createStringBinding( bgTextField.textProperty().bind(Bindings.createStringBinding(
() -> bgColor.getColorHexWithAlpha().substring(1), bgColor.colorProperty() () -> bgColor.getColorHexWithAlpha().substring(1), bgColor.colorProperty()
)); ));
bgTextField.addEventFilter(ContextMenuEvent.CONTEXT_MENU_REQUESTED, Event::consume);
fgColorNameLabel = new Label("Foreground Color"); fgColorNameLabel = new Label("Foreground Color");
fgColorNameLabel.setPadding(new Insets(-15, 0, 0, 0)); fgColorNameLabel.setPadding(new Insets(-15, 0, 0, 0));
@ -166,6 +178,7 @@ public class ColorContrastChecker extends GridPane {
fgTextField.textProperty().bind(Bindings.createStringBinding( fgTextField.textProperty().bind(Bindings.createStringBinding(
() -> fgColor.getColorHexWithAlpha().substring(1), fgColor.colorProperty() () -> fgColor.getColorHexWithAlpha().substring(1), fgColor.colorProperty()
)); ));
fgTextField.addEventFilter(ContextMenuEvent.CONTEXT_MENU_REQUESTED, Event::consume);
bgHueSlider = slider(1, 360, 1, 1); bgHueSlider = slider(1, 360, 1, 1);
bgHueSlider.valueProperty().addListener((obs, old, val) -> { bgHueSlider.valueProperty().addListener((obs, old, val) -> {
@ -243,6 +256,17 @@ public class ColorContrastChecker extends GridPane {
// ~ // ~
var flattenButton = new Button("Flatten");
flattenButton.setOnAction(e -> {
double[] flatBg = flattenColor(bgBaseColor.get(), bgColor.getColor());
setBackground(Color.color(flatBg[0], flatBg[1], flatBg[2]));
double[] flatFg = flattenColor(bgBaseColor.get(), fgColor.getColor());
setForeground(Color.color(flatFg[0], flatFg[1], flatFg[2]));
});
// ~
getStyleClass().add("contrast-checker"); getStyleClass().add("contrast-checker");
// column 0 // column 0
@ -259,6 +283,8 @@ public class ColorContrastChecker extends GridPane {
add(bgAlphaLabel, 0, 10); add(bgAlphaLabel, 0, 10);
add(bgAlphaSlider, 0, 11); add(bgAlphaSlider, 0, 11);
add(flattenButton, 0, 12);
// column 1 // column 1
add(wsagBox, 1, 0); add(wsagBox, 1, 0);
add(new Label("Foreground Color"), 1, 1); add(new Label("Foreground Color"), 1, 1);
@ -287,14 +313,28 @@ public class ColorContrastChecker extends GridPane {
float[] fg = fgColor.getRGBAColor(); float[] fg = fgColor.getRGBAColor();
// use fallback color if contrast ratio is too low // use fallback color if contrast ratio is too low
if (contrastRatio.get() <= LUMINANCE_THRESHOLD) { if (contrastRatio.get() <= CONTRAST_RATIO_THRESHOLD) {
fg = getColorLuminance(flattenColor(Color.WHITE, bgColor.getColor())) < 0.55 ? COLOR_WHITE : COLOR_BLACK; fg = getColorLuminance(flattenColor(bgBaseColor.get(), bgColor.getColor())) < LUMINANCE_THRESHOLD ?
COLOR_WHITE :
COLOR_BLACK;
} }
setStyle(String.format("-color-contrast-checker-bg:rgba(%.0f,%.0f,%.0f,%.2f);-color-contrast-checker-fg:rgba(%.0f,%.0f,%.0f,%.2f);", // flat colors are necessary for controls that use reverse styling (bg color over fg color),
// it won't be readable if we not remove transparency first
double[] bgFlat = flattenColor(bgBaseColor.get(), bgColor.getColor());
double[] fgFlat = flattenColor(bgBaseColor.get(), fgColor.getColor());
var style = String.format("-color-contrast-checker-bg:rgba(%.0f,%.0f,%.0f,%.2f);" +
"-color-contrast-checker-fg:rgba(%.0f,%.0f,%.0f,%.2f);" +
"-color-contrast-checker-bg-flat:%s;" +
"-color-contrast-checker-fg-flat:%s;",
bg[0], bg[1], bg[2], bg[3], bg[0], bg[1], bg[2], bg[3],
fg[0], fg[1], fg[2], fg[3] fg[0], fg[1], fg[2], fg[3],
)); JColor.color((float) bgFlat[0], (float) bgFlat[1], (float) bgFlat[2]).getColorHex(),
JColor.color((float) fgFlat[0], (float) fgFlat[1], (float) fgFlat[2]).getColorHex()
);
setStyle(style);
} }
private void setBackground(Color color) { private void setBackground(Color color) {
@ -357,9 +397,9 @@ public class ColorContrastChecker extends GridPane {
return slider; return slider;
} }
static double getContrastRatioOpacityAware(Color bgColor, Color fgColor) { static double getContrastRatioOpacityAware(Color bgColor, Color fgColor, Color bgBaseColor) {
double luminance1 = getColorLuminance(flattenColor(Color.WHITE, bgColor)); double luminance1 = getColorLuminance(flattenColor(bgBaseColor, bgColor));
double luminance2 = getColorLuminance(flattenColor(Color.WHITE, fgColor)); double luminance2 = getColorLuminance(flattenColor(bgBaseColor, fgColor));
return getContrastRatio(luminance1, luminance2); return getContrastRatio(luminance1, luminance2);
} }
@ -371,11 +411,11 @@ public class ColorContrastChecker extends GridPane {
private final ReadOnlyObjectWrapper<Color> color = new ReadOnlyObjectWrapper<>() { }; private final ReadOnlyObjectWrapper<Color> color = new ReadOnlyObjectWrapper<>() { };
public ObservableHSLAColor(Color initialColor) { public ObservableHSLAColor(Color initialColor) {
color.set(initialColor);
values.addListener((ListChangeListener<Float>) c -> { values.addListener((ListChangeListener<Float>) c -> {
float[] rgb = getRGBAArithmeticColor(); float[] rgb = getRGBAArithmeticColor();
color.set(Color.color(rgb[0], rgb[1], rgb[2], getAlpha())); color.set(Color.color(rgb[0], rgb[1], rgb[2], getAlpha()));
}); });
setColor(initialColor);
} }
public Color getColor() { public Color getColor() {

@ -1,8 +1,12 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general; package atlantafx.sampler.page.general;
import atlantafx.base.controls.Spacer; import atlantafx.base.controls.Spacer;
import atlantafx.base.theme.Styles; import atlantafx.base.theme.Styles;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.Label; import javafx.scene.control.Label;
@ -10,6 +14,7 @@ import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import org.kordamp.ikonli.feather.Feather; import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.javafx.FontIcon;
@ -22,14 +27,35 @@ import java.util.function.Consumer;
class ColorPalette extends VBox { class ColorPalette extends VBox {
private final List<ColorBlock> blocks = new ArrayList<>();
private Label headerLabel; private Label headerLabel;
private Button backBtn; private Button backBtn;
private GridPane colorGrid; private GridPane colorGrid;
private ColorContrastChecker contrastChecker; private ColorContrastChecker contrastChecker;
private VBox contrastCheckerArea; private VBox contrastCheckerArea;
private final List<ColorBlock> blocks = new ArrayList<>();
private final Consumer<ColorBlock> colorBlockActionHandler = colorBlock -> {
ColorContrastChecker c = getOrCreateContrastChecker();
c.setValues(colorBlock.getFgColorName(),
colorBlock.getFgColor(),
colorBlock.getBgColorName(),
colorBlock.getBgColor()
);
if (contrastCheckerArea.getChildren().isEmpty()) {
contrastCheckerArea.getChildren().setAll(c);
}
showContrastChecker();
};
private final ReadOnlyBooleanWrapper contrastCheckerActive = new ReadOnlyBooleanWrapper(false);
private final ReadOnlyObjectWrapper<Color> bgBaseColor = new ReadOnlyObjectWrapper<>(Color.WHITE);
public ReadOnlyBooleanProperty contrastCheckerActiveProperty() {
return contrastCheckerActive.getReadOnlyProperty();
}
public ColorPalette() { public ColorPalette() {
super(); super();
createView(); createView();
@ -43,22 +69,22 @@ class ColorPalette extends VBox {
backBtn.getStyleClass().add(Styles.FLAT); backBtn.getStyleClass().add(Styles.FLAT);
backBtn.setVisible(false); backBtn.setVisible(false);
backBtn.setManaged(false); backBtn.setManaged(false);
backBtn.setOnAction(e -> { backBtn.setOnAction(e -> showColorPalette());
headerLabel.setText("Color Palette");
backBtn.setVisible(false);
backBtn.setManaged(false);
getChildren().set(1, colorGrid);
});
var headerBox = new HBox(); var headerBox = new HBox();
headerBox.getChildren().setAll(headerLabel, new Spacer(), backBtn); headerBox.getChildren().setAll(headerLabel, new Spacer(), backBtn);
headerBox.setAlignment(Pos.CENTER_LEFT); headerBox.setAlignment(Pos.CENTER_LEFT);
headerBox.getStyleClass().add("header");
contrastCheckerArea = new VBox(); contrastCheckerArea = new VBox();
contrastCheckerArea.getStyleClass().add("contrast-checker-area"); contrastCheckerArea.getStyleClass().add("contrast-checker-area");
colorGrid = colorGrid(); colorGrid = colorGrid();
backgroundProperty().addListener((obs, old, val) -> bgBaseColor.set(
val != null && !val.getFills().isEmpty() ? (Color) val.getFills().get(0).getFill() : Color.WHITE
));
getChildren().setAll(headerBox, colorGrid); getChildren().setAll(headerBox, colorGrid);
setId("color-palette"); setId("color-palette");
} }
@ -100,39 +126,34 @@ class ColorPalette extends VBox {
} }
private ColorBlock colorBlock(String fgColor, String bgColor, String borderColor) { private ColorBlock colorBlock(String fgColor, String bgColor, String borderColor) {
var actionHandler = new Consumer<ColorBlock>() { var block = new ColorBlock(fgColor, bgColor, borderColor, bgBaseColor.getReadOnlyProperty());
@Override block.setOnAction(colorBlockActionHandler);
public void accept(ColorBlock colorBlock) {
ColorContrastChecker c = getOrCreateContrastChecker();
c.setValues(colorBlock.getFgColorName(),
colorBlock.getFgColor(),
colorBlock.getBgColorName(),
colorBlock.getBgColor()
);
if (contrastCheckerArea.getChildren().isEmpty()) {
contrastCheckerArea.getChildren().setAll(c);
}
headerLabel.setText("Contrast Checker");
backBtn.setVisible(true);
backBtn.setManaged(true);
getChildren().set(1, contrastCheckerArea);
}
};
var block = new ColorBlock(fgColor, bgColor, borderColor);
block.setOnAction(actionHandler);
blocks.add(block); blocks.add(block);
return block; return block;
} }
private ColorContrastChecker getOrCreateContrastChecker() { private ColorContrastChecker getOrCreateContrastChecker() {
if (contrastChecker == null) { contrastChecker = new ColorContrastChecker(); } if (contrastChecker == null) { contrastChecker = new ColorContrastChecker(bgBaseColor.getReadOnlyProperty()); }
VBox.setVgrow(contrastChecker, Priority.ALWAYS); VBox.setVgrow(contrastChecker, Priority.ALWAYS);
return contrastChecker; return contrastChecker;
} }
private void showColorPalette() {
headerLabel.setText("Color Palette");
backBtn.setVisible(false);
backBtn.setManaged(false);
getChildren().set(1, colorGrid);
contrastCheckerActive.set(false);
}
private void showContrastChecker() {
headerLabel.setText("Contrast Checker");
backBtn.setVisible(true);
backBtn.setManaged(true);
getChildren().set(1, contrastCheckerArea);
contrastCheckerActive.set(true);
}
// To calculate contrast ratio, we have to obtain all components colors first. // To calculate contrast ratio, we have to obtain all components colors first.
// Unfortunately, JavaFX doesn't provide an API to observe when stylesheet changes has been applied. // Unfortunately, JavaFX doesn't provide an API to observe when stylesheet changes has been applied.
// The timer is introduced to defer widget update to a time when scene changes supposedly will be finished. // The timer is introduced to defer widget update to a time when scene changes supposedly will be finished.

@ -43,6 +43,7 @@ public class ThemePage extends AbstractPage {
private GridPane optionsGrid() { private GridPane optionsGrid() {
ChoiceBox<Theme> themeSelector = themeSelector(); ChoiceBox<Theme> themeSelector = themeSelector();
themeSelector.setPrefWidth(200); themeSelector.setPrefWidth(200);
themeSelector.disableProperty().bind(colorPalette.contrastCheckerActiveProperty());
// ~ // ~

@ -101,6 +101,11 @@
#color-palette { #color-palette {
-fx-spacing: 20px; -fx-spacing: 20px;
/* mandatory, base bg color for flatten feature */
-fx-background-color: -color-bg-default;
}
#color-palette > .header {
-fx-pref-height: 40px;
} }
#color-palette > .grid { #color-palette > .grid {
-fx-vgap: 20px; -fx-vgap: 20px;
@ -150,6 +155,23 @@
-fx-border-color: -color-contrast-checker-fg; -fx-border-color: -color-contrast-checker-fg;
-fx-border-width: 0 0 1 0; -fx-border-width: 0 0 1 0;
} }
#color-palette > .contrast-checker-area > .contrast-checker .button {
-color-button-bg: -color-contrast-checker-fg-flat;
-color-button-fg: -color-contrast-checker-bg-flat;
-color-button-border: -color-contrast-checker-bg-flat;
-color-button-bg-hover: -color-contrast-checker-fg-flat;
-color-button-fg-hover: -color-contrast-checker-bg-flat;
-color-button-border-hover: -color-contrast-checker-bg-flat;
-color-button-bg-focused: -color-contrast-checker-fg-flat;
-color-button-fg-focused: -color-contrast-checker-bg-flat;
-color-button-border-focused: -color-contrast-checker-bg-flat;
-color-button-bg-pressed: -color-contrast-checker-bg-flat;
-color-button-fg-pressed: -color-contrast-checker-fg-flat;
-color-button-border-pressed: -color-contrast-checker-fg-flat;
}
#color-palette > .contrast-checker-area > .contrast-checker .ikonli-font-icon { #color-palette > .contrast-checker-area > .contrast-checker .ikonli-font-icon {
-fx-icon-color: -color-contrast-checker-fg; -fx-icon-color: -color-contrast-checker-fg;
-fx-fill: -color-contrast-checker-fg; -fx-fill: -color-contrast-checker-fg;