Improve color palette widget

* add description
* use better color combinations for color blocks
* refactor and pay tech debts
This commit is contained in:
mkpaz 2022-09-01 22:22:45 +04:00
parent fc19ca5a36
commit 8a7204d93c
18 changed files with 505 additions and 217 deletions

@ -1,6 +1,9 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler;
import atlantafx.sampler.event.BrowseEvent;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.Listener;
import atlantafx.sampler.layout.ApplicationWindow;
import atlantafx.sampler.theme.ThemeManager;
import fr.brouillard.oss.cssfx.CSSFX;
@ -63,6 +66,9 @@ public class Launcher extends Application {
stage.setResizable(true);
stage.setOnCloseRequest(t -> Platform.exit());
// register event listeners
DefaultEventBus.getInstance().subscribe(BrowseEvent.class, this::onBrowseEvent);
Platform.runLater(() -> {
stage.show();
stage.requestFocus();
@ -109,4 +115,9 @@ public class Launcher extends Application {
);
CSSFX.start(scene);
}
@Listener
private void onBrowseEvent(BrowseEvent event) {
getHostServices().showDocument(event.getUri().toString());
}
}

@ -0,0 +1,24 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.event;
import java.net.URI;
public class BrowseEvent extends Event {
private final URI uri;
public BrowseEvent(URI uri) {
this.uri = uri;
}
public URI getUri() {
return uri;
}
@Override
public String toString() {
return "BrowseEvent{" +
"uri=" + uri +
'}';
}
}

@ -0,0 +1,92 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.event;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Consumer;
/**
* Simple event bus implementation.
* <p>
* Subscribe and publish events. Events are published in channels distinguished by event type.
* Channels can be grouped using an event type hierarchy.
* <p>
* You can use the default event bus instance {@link #getInstance}, which is a singleton
* or you can create one or multiple instances of {@link DefaultEventBus}.
*/
@SuppressWarnings({"unchecked", "rawtypes"})
public final class DefaultEventBus implements EventBus {
public DefaultEventBus() {}
private final Map<Class<?>, Set<Consumer>> subscribers = new ConcurrentHashMap<>();
@Override
public <E extends Event> void subscribe(Class<? extends E> eventType, Consumer<E> subscriber) {
Objects.requireNonNull(eventType);
Objects.requireNonNull(subscriber);
Set<Consumer> eventSubscribers = getOrCreateSubscribers(eventType);
eventSubscribers.add(subscriber);
}
private <E> Set<Consumer> getOrCreateSubscribers(Class<E> eventType) {
Set<Consumer> eventSubscribers = subscribers.get(eventType);
if (eventSubscribers == null) {
eventSubscribers = new CopyOnWriteArraySet<>();
subscribers.put(eventType, eventSubscribers);
}
return eventSubscribers;
}
@Override
public <E extends Event> void unsubscribe(Consumer<E> subscriber) {
Objects.requireNonNull(subscriber);
subscribers.values().forEach(eventSubscribers -> eventSubscribers.remove(subscriber));
}
@Override
public <E extends Event> void unsubscribe(Class<? extends E> eventType, Consumer<E> subscriber) {
Objects.requireNonNull(eventType);
Objects.requireNonNull(subscriber);
subscribers.keySet().stream()
.filter(eventType::isAssignableFrom)
.map(subscribers::get)
.forEach(eventSubscribers -> eventSubscribers.remove(subscriber));
}
@Override
public <E extends Event> void publish(E event) {
Objects.requireNonNull(event);
Class<?> eventType = event.getClass();
subscribers.keySet().stream()
.filter(type -> type.isAssignableFrom(eventType))
.flatMap(type -> subscribers.get(type).stream())
.forEach(subscriber -> publish(event, subscriber));
}
private <E extends Event> void publish(E event, Consumer<E> subscriber) {
try {
subscriber.accept(event);
} catch (Exception e) {
Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
}
}
///////////////////////////////////////////////////////////////////////////
private static class InstanceHolder {
private static final DefaultEventBus INSTANCE = new DefaultEventBus();
}
public static DefaultEventBus getInstance() {
return InstanceHolder.INSTANCE;
}
}

@ -0,0 +1,35 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.event;
import java.util.UUID;
public abstract class Event {
protected final UUID id = UUID.randomUUID();
protected Event() { }
public UUID getId() {
return id;
}
@Override
public boolean equals(Object o) {
if (this == o) { return true; }
if (o == null || getClass() != o.getClass()) { return false; }
Event event = (Event) o;
return id.equals(event.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
@Override
public String toString() {
return "Event{" +
"id=" + id +
'}';
}
}

@ -0,0 +1,43 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.event;
import java.util.function.Consumer;
public interface EventBus {
/**
* Subscribe to an event type
*
* @param eventType the event type, can be a super class of all events to subscribe.
* @param subscriber the subscriber which will consume the events.
* @param <T> the event type class.
*/
<T extends Event> void subscribe(Class<? extends T> eventType, Consumer<T> subscriber);
/**
* Unsubscribe from all event types.
*
* @param subscriber the subscriber to unsubscribe.
*/
<T extends Event> void unsubscribe(Consumer<T> subscriber);
/**
* Unsubscribe from an event type.
*
* @param eventType the event type, can be a super class of all events to unsubscribe.
* @param subscriber the subscriber to unsubscribe.
* @param <T> the event type class.
*/
<T extends Event> void unsubscribe(Class<? extends T> eventType, Consumer<T> subscriber);
/**
* Publish an event to all subscribers.
* <p>
* The event type is the class of <code>event</code>. The event is published to all consumers which subscribed to
* this event type or any super class.
*
* @param event the event.
*/
<T extends Event> void publish(T event);
}

@ -0,0 +1,8 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.event;
import java.lang.annotation.ElementType;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
public @interface Listener {}

@ -12,13 +12,18 @@ import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import static atlantafx.sampler.util.Controls.hyperlink;
class ColorPalette extends VBox {
private final List<ColorPaletteBlock> blocks = new ArrayList<>();
@ -41,13 +46,30 @@ class ColorPalette extends VBox {
headerBox.setAlignment(Pos.CENTER_LEFT);
headerBox.getStyleClass().add("header");
var noteText = new VBox(6);
noteText.getChildren().setAll(
new TextFlow(
new Text("Color contrast between text and its background must meet "),
hyperlink("required WCAG standards",
URI.create("https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html")
),
new Text(":")
),
new Text(" • 4.5:1 for normal text"),
new Text(" • 3:1 for large text (>24px)"),
new Text(" • 3:1 for UI elements and graphics"),
new Text(" • no contrast requirement for decorative and disabled elements"),
new Text(),
new Text("Click on any color block to observe and modify color combination via built-in contrast checker.")
);
var 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, noteText, colorGrid);
setId("color-palette");
}
@ -56,33 +78,34 @@ class ColorPalette extends VBox {
grid.getStyleClass().add("grid");
grid.add(colorBlock("-color-fg-default", "-color-bg-default", "-color-border-default"), 0, 0);
grid.add(colorBlock("-color-fg-muted", "-color-bg-default", "-color-border-muted"), 1, 0);
grid.add(colorBlock("-color-fg-subtle", "-color-bg-default", "-color-border-subtle"), 2, 0);
grid.add(colorBlock("-color-fg-default", "-color-bg-overlay", "-color-border-default"), 1, 0);
grid.add(colorBlock("-color-fg-muted", "-color-bg-default", "-color-border-muted"), 2, 0);
grid.add(colorBlock("-color-fg-subtle", "-color-bg-default", "-color-border-subtle"), 3, 0);
grid.add(colorBlock("-color-fg-emphasis", "-color-accent-emphasis", "-color-accent-emphasis"), 0, 1);
grid.add(colorBlock("-color-accent-fg", "-color-bg-default", "-color-accent-emphasis"), 1, 1);
grid.add(colorBlock("-color-accent-fg", "-color-accent-muted", "-color-accent-muted"), 2, 1);
grid.add(colorBlock("-color-accent-fg", "-color-accent-subtle", "-color-accent-subtle"), 3, 1);
grid.add(colorBlock("-color-fg-default", "-color-accent-muted", "-color-accent-emphasis"), 2, 1);
grid.add(colorBlock("-color-accent-fg", "-color-accent-subtle", "-color-accent-emphasis"), 3, 1);
grid.add(colorBlock("-color-fg-emphasis", "-color-neutral-emphasis-plus", "-color-neutral-emphasis-plus"), 0, 2);
grid.add(colorBlock("-color-fg-emphasis", "-color-neutral-emphasis", "-color-neutral-emphasis"), 1, 2);
grid.add(colorBlock("-color-fg-muted", "-color-neutral-muted", "-color-neutral-muted"), 2, 2);
grid.add(colorBlock("-color-fg-subtle", "-color-neutral-subtle", "-color-neutral-subtle"), 3, 2);
grid.add(colorBlock("-color-fg-default", "-color-neutral-muted", "-color-neutral-emphasis"), 2, 2);
grid.add(colorBlock("-color-fg-default", "-color-neutral-subtle", "-color-neutral-emphasis"), 3, 2);
grid.add(colorBlock("-color-fg-emphasis", "-color-success-emphasis", "-color-success-emphasis"), 0, 3);
grid.add(colorBlock("-color-success-fg", "-color-bg-default", "-color-success-emphasis"), 1, 3);
grid.add(colorBlock("-color-success-fg", "-color-success-muted", "-color-success-muted"), 2, 3);
grid.add(colorBlock("-color-success-fg", "-color-success-subtle", "-color-success-subtle"), 3, 3);
grid.add(colorBlock("-color-fg-default", "-color-success-muted", "-color-success-emphasis"), 2, 3);
grid.add(colorBlock("-color-success-fg", "-color-success-subtle", "-color-success-emphasis"), 3, 3);
grid.add(colorBlock("-color-fg-emphasis", "-color-warning-emphasis", "-color-warning-emphasis"), 0, 4);
grid.add(colorBlock("-color-warning-fg", "-color-bg-default", "-color-warning-emphasis"), 1, 4);
grid.add(colorBlock("-color-warning-fg", "-color-warning-muted", "-color-warning-muted"), 2, 4);
grid.add(colorBlock("-color-warning-fg", "-color-warning-subtle", "-color-warning-subtle"), 3, 4);
grid.add(colorBlock("-color-fg-default", "-color-warning-muted", "-color-warning-emphasis"), 2, 4);
grid.add(colorBlock("-color-warning-fg", "-color-warning-subtle", "-color-warning-emphasis"), 3, 4);
grid.add(colorBlock("-color-fg-emphasis", "-color-danger-emphasis", "-color-danger-emphasis"), 0, 5);
grid.add(colorBlock("-color-danger-fg", "-color-bg-default", "-color-danger-emphasis"), 1, 5);
grid.add(colorBlock("-color-danger-fg", "-color-danger-muted", "-color-danger-muted"), 2, 5);
grid.add(colorBlock("-color-danger-fg", "-color-danger-subtle", "-color-danger-subtle"), 3, 5);
grid.add(colorBlock("-color-fg-default", "-color-danger-muted", "-color-danger-emphasis"), 2, 5);
grid.add(colorBlock("-color-danger-fg", "-color-danger-subtle", "-color-danger-emphasis"), 3, 5);
return grid;
}

@ -3,6 +3,8 @@ package atlantafx.sampler.page.general;
import atlantafx.base.theme.Styles;
import atlantafx.sampler.util.Containers;
import atlantafx.sampler.util.ContrastLevel;
import atlantafx.sampler.util.NodeUtils;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
@ -10,15 +12,17 @@ import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.text.Text;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2AL;
import java.util.function.Consumer;
import static atlantafx.sampler.page.general.ContrastChecker.*;
import static atlantafx.base.theme.Styles.TITLE_3;
import static atlantafx.sampler.page.general.ContrastChecker.LUMINANCE_THRESHOLD;
import static atlantafx.sampler.page.general.ContrastChecker.PASSED;
import static atlantafx.sampler.util.ContrastLevel.getColorLuminance;
import static atlantafx.sampler.util.ContrastLevel.getContrastRatioOpacityAware;
import static atlantafx.sampler.util.JColorUtils.flattenColor;
import static atlantafx.sampler.util.JColorUtils.getColorLuminance;
class ColorPaletteBlock extends VBox {
@ -27,11 +31,11 @@ class ColorPaletteBlock extends VBox {
private final String borderColorName;
private final ReadOnlyObjectProperty<Color> bgBaseColor;
private final AnchorPane colorBox;
private final Text fgText;
private final FontIcon wsagIcon = new FontIcon();
private final Label wsagLabel = new Label();
private final FontIcon expandIcon = new FontIcon(Feather.MAXIMIZE_2);
private final AnchorPane colorRectangle;
private final Text contrastRatioText;
private final FontIcon contrastLevelIcon = new FontIcon();
private final Label contrastLevelLabel = new Label();
private final FontIcon editIcon = new FontIcon(Material2AL.COLORIZE);
private Consumer<ColorPaletteBlock> actionHandler;
@ -44,69 +48,54 @@ class ColorPaletteBlock extends VBox {
this.borderColorName = validateColorName(borderColorName);
this.bgBaseColor = bgBaseColor;
fgText = new Text();
fgText.setStyle("-fx-fill:" + fgColorName + ";");
fgText.getStyleClass().addAll("text", Styles.TITLE_3);
Containers.setAnchors(fgText, new Insets(5, -1, -1, 5));
contrastRatioText = new Text();
contrastRatioText.setStyle("-fx-fill:" + fgColorName + ";");
contrastRatioText.getStyleClass().addAll("contrast-ratio-text", TITLE_3);
Containers.setAnchors(contrastRatioText, new Insets(5, -1, -1, 5));
wsagLabel.setGraphic(wsagIcon);
wsagLabel.getStyleClass().add("wsag-label");
wsagLabel.setVisible(false);
Containers.setAnchors(wsagLabel, new Insets(-1, 3, 3, -1));
contrastLevelLabel.setGraphic(contrastLevelIcon);
contrastLevelLabel.getStyleClass().add("contrast-level-label");
contrastLevelLabel.setVisible(false);
Containers.setAnchors(contrastLevelLabel, new Insets(-1, 3, 3, -1));
expandIcon.setIconSize(24);
expandIcon.getStyleClass().add("expand-icon");
expandIcon.setVisible(false);
expandIcon.setManaged(false);
Containers.setAnchors(expandIcon, new Insets(3, 3, -1, -1));
editIcon.setIconSize(24);
editIcon.getStyleClass().add("edit-icon");
NodeUtils.toggleVisibility(editIcon, false);
Containers.setAnchors(editIcon, new Insets(3, 3, -1, -1));
colorBox = new AnchorPane();
colorBox.setStyle("-fx-background-color:" + bgColorName + ";" + "-fx-border-color:" + borderColorName + ";");
colorBox.getStyleClass().add("box");
colorBox.getChildren().setAll(fgText, wsagLabel, expandIcon);
colorBox.setOnMouseEntered(e -> {
colorRectangle = new AnchorPane();
colorRectangle.setStyle(
String.format("-fx-background-color:%s;-fx-border-color:%s;", bgColorName, borderColorName)
);
colorRectangle.getStyleClass().add("rectangle");
colorRectangle.getChildren().setAll(contrastRatioText, contrastLevelLabel, editIcon);
colorRectangle.setOnMouseEntered(e -> {
var bgFill = getBgColor();
// this happens when css isn't updated yet
if (bgFill == null) { return; }
toggleHover(true);
expandIcon.setFill(getColorLuminance(flattenColor(bgBaseColor.get(), bgFill)) < LUMINANCE_THRESHOLD ?
editIcon.setFill(getColorLuminance(flattenColor(bgBaseColor.get(), bgFill)) < LUMINANCE_THRESHOLD ?
Color.WHITE : Color.BLACK
);
});
colorBox.setOnMouseExited(e -> toggleHover(false));
colorBox.setOnMouseClicked(e -> {
colorRectangle.setOnMouseExited(e -> toggleHover(false));
colorRectangle.setOnMouseClicked(e -> {
if (actionHandler != null) { actionHandler.accept(this); }
});
getChildren().addAll(
colorBox,
description(fgColorName),
description(bgColorName),
description(borderColorName)
colorRectangle,
colorNameText(fgColorName),
colorNameText(bgColorName),
colorNameText(borderColorName)
);
getStyleClass().add("color-block");
getStyleClass().add("block");
}
static String validateColorName(String colorName) {
if (colorName == null || !colorName.startsWith("-color")) {
throw new IllegalArgumentException("Invalid color name: '" + colorName + "'.");
}
return colorName;
}
private void toggleHover(boolean state) {
expandIcon.setVisible(state);
expandIcon.setManaged(state);
fgText.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 setOnAction(Consumer<ColorPaletteBlock> actionHandler) {
this.actionHandler = actionHandler;
}
public void update() {
@ -114,28 +103,28 @@ class ColorPaletteBlock extends VBox {
var bgFill = getBgColor();
if (fgFill == null || bgFill == null) {
fgText.setText("");
wsagLabel.setText("");
wsagLabel.setVisible(false);
contrastRatioText.setText("");
contrastLevelLabel.setText("");
contrastLevelLabel.setVisible(false);
return;
}
double contrastRatio = 1 / getContrastRatioOpacityAware(bgFill, fgFill, bgBaseColor.get());
colorBox.pseudoClassStateChanged(PASSED, contrastRatio >= 4.5);
double contrastRatio = getContrastRatioOpacityAware(bgFill, fgFill, bgBaseColor.get());
colorRectangle.pseudoClassStateChanged(PASSED, ContrastLevel.AA_NORMAL.satisfies(contrastRatio));
wsagIcon.setIconCode(contrastRatio >= 4.5 ? Material2AL.CHECK : Material2AL.CLOSE);
wsagLabel.setVisible(true);
wsagLabel.setText(contrastRatio >= 7 ? "AAA" : "AA");
fgText.setText(String.format("%.2f", contrastRatio));
contrastRatioText.setText(String.format("%.2f", contrastRatio));
contrastLevelIcon.setIconCode(ContrastLevel.AA_NORMAL.satisfies(contrastRatio) ? Material2AL.CHECK : Material2AL.CLOSE);
contrastLevelLabel.setVisible(true);
contrastLevelLabel.setText(ContrastLevel.AAA_NORMAL.satisfies(contrastRatio) ? "AAA" : "AA");
}
public Color getFgColor() {
return (Color) fgText.getFill();
return (Color) contrastRatioText.getFill();
}
public Color getBgColor() {
return colorBox.getBackground() != null && !colorBox.getBackground().isEmpty() ?
(Color) colorBox.getBackground().getFills().get(0).getFill() : null;
return colorRectangle.getBackground() != null && !colorRectangle.getBackground().isEmpty() ?
(Color) colorRectangle.getBackground().getFills().get(0).getFill() : null;
}
public String getFgColorName() {
@ -150,7 +139,22 @@ class ColorPaletteBlock extends VBox {
return borderColorName;
}
public void setOnAction(Consumer<ColorPaletteBlock> actionHandler) {
this.actionHandler = actionHandler;
private void toggleHover(boolean state) {
NodeUtils.toggleVisibility(editIcon, state);
contrastRatioText.setOpacity(state ? 0.5 : 1);
contrastLevelLabel.setOpacity(state ? 0.5 : 1);
}
private Text colorNameText(String text) {
var t = new Text(text);
t.getStyleClass().addAll("color-name", Styles.TEXT_SMALL);
return t;
}
static String validateColorName(String colorName) {
if (colorName == null || !colorName.startsWith("-color")) {
throw new IllegalArgumentException("Invalid color name: '" + colorName + "'.");
}
return colorName;
}
}

@ -11,12 +11,14 @@ import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import java.util.Arrays;
import java.util.List;
public class ColorScale extends VBox {
class ColorScale extends VBox {
private final ReadOnlyObjectWrapper<Color> bgBaseColor = new ReadOnlyObjectWrapper<>(Color.WHITE);
private final List<ColorScaleBlock> blocks = Arrays.asList(
@ -42,6 +44,10 @@ public class ColorScale extends VBox {
headerBox.setAlignment(Pos.CENTER_LEFT);
headerBox.getStyleClass().add("header");
var noteText = new TextFlow(
new Text("Avoid referencing scale variables directly when building UI that needs to adapt to different color themes. Instead, use the functional variables listed above.")
);
backgroundProperty().addListener((obs, old, val) -> bgBaseColor.set(
val != null && !val.getFills().isEmpty() ? (Color) val.getFills().get(0).getFill() : Color.WHITE
));
@ -49,6 +55,7 @@ public class ColorScale extends VBox {
setId("color-scale");
getChildren().setAll(
headerBox,
noteText,
colorTable()
);
}

@ -8,10 +8,10 @@ import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import static atlantafx.sampler.util.ContrastLevel.getColorLuminance;
import static atlantafx.sampler.util.JColorUtils.flattenColor;
import static atlantafx.sampler.util.JColorUtils.getColorLuminance;
public class ColorScaleBlock extends VBox {
class ColorScaleBlock extends VBox {
private static final double BLOCK_WIDTH = 250;
private static final double BLOCK_HEIGHT = 50;

@ -5,6 +5,7 @@ import atlantafx.base.controls.CustomTextField;
import atlantafx.base.controls.Spacer;
import atlantafx.base.theme.Styles;
import atlantafx.sampler.theme.ThemeManager;
import atlantafx.sampler.util.ContrastLevel;
import atlantafx.sampler.util.JColor;
import atlantafx.sampler.util.JColorUtils;
import javafx.beans.binding.Bindings;
@ -34,16 +35,19 @@ import org.kordamp.ikonli.material2.Material2AL;
import java.util.Objects;
import static atlantafx.sampler.page.general.ColorPaletteBlock.validateColorName;
import static atlantafx.sampler.util.ContrastLevel.getColorLuminance;
import static atlantafx.sampler.util.ContrastLevel.getContrastRatioOpacityAware;
import static atlantafx.sampler.util.JColorUtils.flattenColor;
import static atlantafx.sampler.util.JColorUtils.getColorLuminance;
// Inspired by the https://colourcontrast.cc/
public class ContrastChecker extends GridPane {
class ContrastChecker extends GridPane {
public static final double CONTRAST_RATIO_THRESHOLD = 1.5;
public static final double LUMINANCE_THRESHOLD = 0.55;
public static final PseudoClass PASSED = PseudoClass.getPseudoClass("passed");
private static final String STATE_PASS = "PASS";
private static final String STATE_FAIL = "FAIL";
private static final int SLIDER_WIDTH = 300;
private String bgColorName;
@ -70,7 +74,7 @@ public class ContrastChecker extends GridPane {
this.bgBaseColor = bgBaseColor;
this.contrastRatio = Bindings.createDoubleBinding(
() -> 1 / getContrastRatioOpacityAware(bgColor.getColor(), fgColor.getColor(), bgBaseColor.get()),
() -> getContrastRatioOpacityAware(bgColor.getColor(), fgColor.getColor(), bgBaseColor.get()),
bgColor.colorProperty(),
fgColor.colorProperty(),
bgBaseColor
@ -112,60 +116,42 @@ public class ContrastChecker extends GridPane {
}
private void createView() {
var textLabel = new Label("Aa");
textLabel.getStyleClass().add("text");
var largeFontLabel = new Label("Aa");
largeFontLabel.getStyleClass().add("large-font");
var ratioLabel = new Label("0.0");
ratioLabel.getStyleClass().add("ratio");
ratioLabel.textProperty().bind(Bindings.createStringBinding(
var contrastRatioLabel = new Label("0.0");
contrastRatioLabel.getStyleClass().add("ratio");
contrastRatioLabel.textProperty().bind(Bindings.createStringBinding(
() -> String.format("%.2f", contrastRatio.get()), contrastRatio
));
var fontBox = new HBox(20, textLabel, ratioLabel);
fontBox.getStyleClass().add("font-box");
fontBox.setAlignment(Pos.BASELINE_LEFT);
var contrastRatioBox = new HBox(20, largeFontLabel, contrastRatioLabel);
contrastRatioBox.getStyleClass().add("contrast-ratio");
contrastRatioBox.setAlignment(Pos.BASELINE_LEFT);
// !
var aaNormalLabel = wsagLabel();
var aaNormalBox = wsagBox(aaNormalLabel, "AA Normal");
var aaNormalLabel = contrastLevelLabel();
var aaNormalBox = contrastLevelBox(aaNormalLabel, "AA Normal");
var aaLargeLabel = wsagLabel();
var aaLargeBox = wsagBox(aaLargeLabel, "AA Large");
var aaLargeLabel = contrastLevelLabel();
var aaLargeBox = contrastLevelBox(aaLargeLabel, "AA Large");
var aaaNormalLabel = wsagLabel();
var aaaNormalBox = wsagBox(aaaNormalLabel, "AAA Normal");
var aaaNormalLabel = contrastLevelLabel();
var aaaNormalBox = contrastLevelBox(aaaNormalLabel, "AAA Normal");
var aaaLargeLabel = wsagLabel();
var aaaLargeBox = wsagBox(aaaLargeLabel, "AAA Large");
var aaaLargeLabel = contrastLevelLabel();
var aaaLargeBox = contrastLevelBox(aaaLargeLabel, "AAA Large");
var wsagBox = new HBox(20, aaNormalBox, aaLargeBox, aaaNormalBox, aaaLargeBox);
wsagBox.getStyleClass().add("wsag-box");
var contrastLevels = new HBox(20, aaNormalBox, aaLargeBox, aaaNormalBox, aaaLargeBox);
contrastRatio.addListener((obs, old, val) -> {
if (val == null) { return; }
float ratio = val.floatValue();
if (ratio >= 7) {
updateWsagLabel(aaNormalLabel, true);
updateWsagLabel(aaLargeLabel, true);
updateWsagLabel(aaaNormalLabel, true);
updateWsagLabel(aaaLargeLabel, true);
} else if (ratio < 7 && ratio >= 4.5) {
updateWsagLabel(aaNormalLabel, true);
updateWsagLabel(aaLargeLabel, true);
updateWsagLabel(aaaNormalLabel, false);
updateWsagLabel(aaaLargeLabel, true);
} else if (ratio < 4.5 && ratio >= 3) {
updateWsagLabel(aaNormalLabel, false);
updateWsagLabel(aaLargeLabel, true);
updateWsagLabel(aaaNormalLabel, false);
updateWsagLabel(aaaLargeLabel, false);
} else {
updateWsagLabel(aaNormalLabel, false);
updateWsagLabel(aaLargeLabel, false);
updateWsagLabel(aaaNormalLabel, false);
updateWsagLabel(aaaLargeLabel, false);
}
updateWsagLabel(aaNormalLabel, ContrastLevel.AA_NORMAL.satisfies(ratio));
updateWsagLabel(aaLargeLabel, ContrastLevel.AA_LARGE.satisfies(ratio));
updateWsagLabel(aaaNormalLabel, ContrastLevel.AAA_NORMAL.satisfies(ratio));
updateWsagLabel(aaaLargeLabel, ContrastLevel.AAA_LARGE.satisfies(ratio));
});
// ~
@ -296,7 +282,7 @@ public class ContrastChecker extends GridPane {
getStyleClass().add("contrast-checker");
// column 0
add(new HBox(fontBox, new Spacer(), wsagBox), 0, 0, REMAINING, 1);
add(new HBox(contrastRatioBox, new Spacer(), contrastLevels), 0, 0, REMAINING, 1);
add(new Label("Background Color"), 0, 1);
add(bgColorNameLabel, 0, 2);
add(bgTextField, 0, 3);
@ -359,25 +345,26 @@ public class ContrastChecker extends GridPane {
private void updateWsagLabel(Label label, boolean success) {
FontIcon icon = Objects.requireNonNull((FontIcon) label.getGraphic());
if (success) {
label.setText("PASS");
label.setText(STATE_PASS);
icon.setIconCode(Material2AL.CHECK);
} else {
label.setText("FAIL");
label.setText(STATE_FAIL);
icon.setIconCode(Material2AL.CLOSE);
}
label.pseudoClassStateChanged(PASSED, success);
}
private Label wsagLabel() {
var label = new Label("FAIL");
label.getStyleClass().add("wsag-label");
private Label contrastLevelLabel() {
var label = new Label(STATE_FAIL);
label.getStyleClass().add("state");
label.setContentDisplay(ContentDisplay.RIGHT);
label.setGraphic(new FontIcon(Material2AL.CLOSE));
return label;
}
private VBox wsagBox(Label label, String description) {
private VBox contrastLevelBox(Label label, String description) {
var box = new VBox(10, label, new Label(description));
box.getStyleClass().add("contrast-level");
box.setAlignment(Pos.CENTER);
return box;
}
@ -392,12 +379,6 @@ public class ContrastChecker extends GridPane {
return slider;
}
static double getContrastRatioOpacityAware(Color bgColor, Color fgColor, Color bgBaseColor) {
double luminance1 = getColorLuminance(flattenColor(bgBaseColor, bgColor));
double luminance2 = getColorLuminance(flattenColor(bgBaseColor, fgColor));
return JColorUtils.getContrastRatio(luminance1, luminance2);
}
///////////////////////////////////////////////////////////////////////////
private static class ObservableHSLAColor {

@ -10,12 +10,17 @@ import javafx.geometry.HPos;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Label;
import javafx.scene.layout.GridPane;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import javafx.util.StringConverter;
import java.net.URI;
import java.util.Objects;
import java.util.function.Consumer;
import static atlantafx.sampler.util.Controls.hyperlink;
public class ThemePage extends AbstractPage {
public static final String NAME = "Theme";
@ -59,11 +64,21 @@ public class ThemePage extends AbstractPage {
}
private void createView() {
var noteText = new TextFlow(
new Text("AtlantaFX follows "),
hyperlink("Github Primer interface guidelines",
URI.create("https://primer.style/design/foundations/color")
),
new Text(" and color system.")
);
userContent.getChildren().addAll(
optionsGrid(),
noteText,
colorPalette,
colorScale
);
// if you want to enable quick menu don't forget that
// theme selection choice box have to be updated accordingly
NodeUtils.toggleVisibility(quickConfigBtn, false);

@ -0,0 +1,74 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.util;
import javafx.scene.paint.Color;
import java.util.Arrays;
import static atlantafx.sampler.util.JColorUtils.flattenColor;
/**
* The WCAG contrast measures the difference in brightness (luminance) between two colours.
* It ranges from 1:1 (white on white) to 21:1 (black on white). WCAG requirements are:
* <ul>
* <li>0.14285 (7.0:1) for small text in AAA-level</li>
* <li>0.22222 (4.5:1) for small text in AA-level, or large text in AAA-level</li>
* <li>0.33333 (3.0:1) for large text in AA-level</li>
* </ul>
* WCAG defines large text as text that is 18pt and larger, or 14pt and larger if it is bold.
* <br/>
* <a href="https://www.w3.org/TR/WCAG20-TECHS/G18.html">More info</a>.
*/
public enum ContrastLevel {
AA_NORMAL,
AA_LARGE,
AAA_NORMAL,
AAA_LARGE;
public boolean satisfies(double ratio) {
switch (this) {
case AA_NORMAL -> { return ratio >= 4.5; }
case AA_LARGE -> { return ratio >= 3; }
case AAA_NORMAL -> { return ratio >= 7; }
case AAA_LARGE -> { return ratio >= 4.5; }
}
return false;
}
public static double getContrastRatio(Color color1, Color color2) {
return getContrastRatio(getColorLuminance(color1), getColorLuminance(color2));
}
public static double getContrastRatio(double luminance1, double luminance2) {
return 1 / (luminance1 > luminance2 ?
(luminance2 + 0.05) / (luminance1 + 0.05) :
(luminance1 + 0.05) / (luminance2 + 0.05)
);
}
public static double getContrastRatioOpacityAware(Color bgColor, Color fgColor, Color bgBaseColor) {
double luminance1 = getColorLuminance(flattenColor(bgBaseColor, bgColor));
double luminance2 = getColorLuminance(flattenColor(bgBaseColor, fgColor));
return getContrastRatio(luminance1, luminance2);
}
/**
* Measures relative color luminance according to the
* <a href="https://www.w3.org/TR/WCAG20-TECHS/G18.html">W3C</a>.
* <br/>
* Note that JavaFX provides {@link Color#getBrightness()} which
* IS NOT the same thing as luminance.
*/
public static double getColorLuminance(double[] rgb) {
double[] tmp = Arrays.stream(rgb)
.map(v -> v <= 0.03928 ? (v / 12.92) : Math.pow((v + 0.055) / 1.055, 2.4))
.toArray();
return (tmp[0] * 0.2126) + (tmp[1] * 0.7152) + (tmp[2] * 0.0722);
}
/** @see ContrastLevel#getColorLuminance(double[]) */
public static double getColorLuminance(Color color) {
return getColorLuminance(new double[] { color.getRed(), color.getGreen(), color.getBlue() });
}
}

@ -1,14 +1,15 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.util;
import javafx.scene.control.Button;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import atlantafx.sampler.event.BrowseEvent;
import atlantafx.sampler.event.DefaultEventBus;
import javafx.scene.control.*;
import javafx.scene.input.KeyCombination;
import org.kordamp.ikonli.Ikon;
import org.kordamp.ikonli.javafx.FontIcon;
import java.net.URI;
import static atlantafx.base.theme.Styles.BUTTON_ICON;
public final class Controls {
@ -52,4 +53,12 @@ public final class Controls {
return toggleButton;
}
public static Hyperlink hyperlink(String text, URI uri) {
var hyperlink = new Hyperlink(text);
if (uri != null) {
hyperlink.setOnAction(event -> DefaultEventBus.getInstance().publish(new BrowseEvent(uri)));
}
return hyperlink;
}
}

@ -780,48 +780,6 @@ public class JColorUtils {
// Additional Utils, mkpaz (c) //
///////////////////////////////////////////////////////////////////////////
/**
* The WCAG contrast measures the difference in brightness (luminance) between two colours.
* It ranges from 1:1 (white on white) to 21:1 (black on white). WCAG requirements are:
* <ul>
* <li>0.14285 (7.0:1) for small text in AAA-level</li>
* <li>0.22222 (4.5:1) for small text in AA-level, or large text in AAA-level</li>
* <li>0.33333 (3.0:1) for large text in AA-level</li>
* </ul>
* WCAG defines large text as text that is 18pt and larger, or 14pt and larger if it is bold.
* <br/>
* <a href="https://www.w3.org/TR/WCAG20-TECHS/G18.html">More info</a>.
*/
public static double getContrastRatio(Color color1, Color color2) {
return getContrastRatio(getColorLuminance(color1), getColorLuminance(color2));
}
/** @see JColorUtils#getContrastRatio(Color, Color) */
public static double getContrastRatio(double luminance1, double luminance2) {
return luminance1 > luminance2 ?
(luminance2 + 0.05) / (luminance1 + 0.05) :
(luminance1 + 0.05) / (luminance2 + 0.05);
}
/**
* Measures relative color luminance according to the
* <a href="https://www.w3.org/TR/WCAG20-TECHS/G18.html">W3C</a>.
* <br/>
* Note that JavaFX provides {@link Color#getBrightness()} which
* IS NOT the same thing as luminance.
*/
public static double getColorLuminance(double[] rgb) {
double[] tmp = Arrays.stream(rgb)
.map(v -> v <= 0.03928 ? (v / 12.92) : Math.pow((v + 0.055) / 1.055, 2.4))
.toArray();
return (tmp[0] * 0.2126) + (tmp[1] * 0.7152) + (tmp[2] * 0.0722);
}
/** @see JColorUtils#getColorLuminance(double[]) */
public static double getColorLuminance(Color color) {
return getColorLuminance(new double[] { color.getRed(), color.getGreen(), color.getBlue() });
}
/**
* Removes given color opacity, if present.
* <br/><br/>

@ -20,6 +20,7 @@ module atlantafx.sampler {
exports atlantafx.sampler;
exports atlantafx.sampler.fake;
exports atlantafx.sampler.fake.domain;
exports atlantafx.sampler.event;
exports atlantafx.sampler.layout;
exports atlantafx.sampler.page;
exports atlantafx.sampler.page.general;

@ -13,21 +13,21 @@ $color-wsag-fg: white;
-fx-vgap: 20px;
-fx-hgap: 40px;
>.color-block {
>.block {
-fx-spacing: 5px;
>.box {
>.rectangle {
-fx-min-width: 12em;
-fx-min-height: 5em;
-fx-max-width: 12em;
-fx-max-height: 5em;
-fx-cursor: hand;
&:passed>.wsag-label {
&:passed>.contrast-level-label {
-fx-background-color: $color-wsag-bg-passed;
}
>.wsag-label {
>.contrast-level-label {
-fx-text-fill: $color-wsag-fg;
-fx-background-color: $color-wsag-bg-failed;
-fx-background-radius: 6px;

@ -2,6 +2,7 @@
@use "color-palette" as palette;
.contrast-checker {
-fx-background-color: -color-contrast-checker-bg;
-fx-hgap: 40px;
-fx-vgap: 20px;
@ -47,11 +48,6 @@
}
}
.ikonli-font-icon {
-fx-icon-color: -color-contrast-checker-fg;
-fx-fill: -color-contrast-checker-fg;
}
.slider {
>.thumb {
-fx-background-color: -color-contrast-checker-fg;
@ -63,10 +59,15 @@
}
}
.font-box {
.ikonli-font-icon {
-fx-icon-color: -color-contrast-checker-fg;
-fx-fill: -color-contrast-checker-fg;
}
.contrast-ratio {
-fx-padding: 0 40px 0 0;
>.text {
>.large-font {
-fx-font-size: 4em;
}
@ -75,7 +76,8 @@
}
}
.wsag-box>*>.wsag-label {
.contrast-level {
>.state {
-fx-padding: 0.5em 1em 0.5em 1em;
-fx-background-color: palette.$color-wsag-bg-failed;
-fx-background-radius: 4px;
@ -91,6 +93,7 @@
}
}
}
}
.contrast-checker-dialog {
-color-dialog-bg: white;