diff --git a/sampler/src/main/java/atlantafx/sampler/Launcher.java b/sampler/src/main/java/atlantafx/sampler/Launcher.java index 25dcddc..804f051 100755 --- a/sampler/src/main/java/atlantafx/sampler/Launcher.java +++ b/sampler/src/main/java/atlantafx/sampler/Launcher.java @@ -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()); + } } diff --git a/sampler/src/main/java/atlantafx/sampler/event/BrowseEvent.java b/sampler/src/main/java/atlantafx/sampler/event/BrowseEvent.java new file mode 100644 index 0000000..f3b288a --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/event/BrowseEvent.java @@ -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 + + '}'; + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/event/DefaultEventBus.java b/sampler/src/main/java/atlantafx/sampler/event/DefaultEventBus.java new file mode 100644 index 0000000..30286ac --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/event/DefaultEventBus.java @@ -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. + *

+ * Subscribe and publish events. Events are published in channels distinguished by event type. + * Channels can be grouped using an event type hierarchy. + *

+ * 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, Set> subscribers = new ConcurrentHashMap<>(); + + @Override + public void subscribe(Class eventType, Consumer subscriber) { + Objects.requireNonNull(eventType); + Objects.requireNonNull(subscriber); + + Set eventSubscribers = getOrCreateSubscribers(eventType); + eventSubscribers.add(subscriber); + } + + private Set getOrCreateSubscribers(Class eventType) { + Set eventSubscribers = subscribers.get(eventType); + if (eventSubscribers == null) { + eventSubscribers = new CopyOnWriteArraySet<>(); + subscribers.put(eventType, eventSubscribers); + } + return eventSubscribers; + } + + @Override + public void unsubscribe(Consumer subscriber) { + Objects.requireNonNull(subscriber); + + subscribers.values().forEach(eventSubscribers -> eventSubscribers.remove(subscriber)); + } + + @Override + public void unsubscribe(Class eventType, Consumer subscriber) { + Objects.requireNonNull(eventType); + Objects.requireNonNull(subscriber); + + subscribers.keySet().stream() + .filter(eventType::isAssignableFrom) + .map(subscribers::get) + .forEach(eventSubscribers -> eventSubscribers.remove(subscriber)); + } + + @Override + public 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 void publish(E event, Consumer 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; + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/event/Event.java b/sampler/src/main/java/atlantafx/sampler/event/Event.java new file mode 100644 index 0000000..0ce42cb --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/event/Event.java @@ -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 + + '}'; + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/event/EventBus.java b/sampler/src/main/java/atlantafx/sampler/event/EventBus.java new file mode 100644 index 0000000..71f6186 --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/event/EventBus.java @@ -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 the event type class. + */ + void subscribe(Class eventType, Consumer subscriber); + + /** + * Unsubscribe from all event types. + * + * @param subscriber the subscriber to unsubscribe. + */ + void unsubscribe(Consumer 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 the event type class. + */ + void unsubscribe(Class eventType, Consumer subscriber); + + /** + * Publish an event to all subscribers. + *

+ * The event type is the class of event. The event is published to all consumers which subscribed to + * this event type or any super class. + * + * @param event the event. + */ + void publish(T event); + +} \ No newline at end of file diff --git a/sampler/src/main/java/atlantafx/sampler/event/Listener.java b/sampler/src/main/java/atlantafx/sampler/event/Listener.java new file mode 100644 index 0000000..ef5336b --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/event/Listener.java @@ -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 {} \ No newline at end of file diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/ColorPalette.java b/sampler/src/main/java/atlantafx/sampler/page/general/ColorPalette.java index 7579c9b..fb04864 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/general/ColorPalette.java +++ b/sampler/src/main/java/atlantafx/sampler/page/general/ColorPalette.java @@ -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 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; } diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/ColorPaletteBlock.java b/sampler/src/main/java/atlantafx/sampler/page/general/ColorPaletteBlock.java index 028d73b..635b4d1 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/general/ColorPaletteBlock.java +++ b/sampler/src/main/java/atlantafx/sampler/page/general/ColorPaletteBlock.java @@ -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 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 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 ? - Color.WHITE : Color.BLACK + 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 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 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; } } diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/ColorScale.java b/sampler/src/main/java/atlantafx/sampler/page/general/ColorScale.java index 71be1c4..1a519e0 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/general/ColorScale.java +++ b/sampler/src/main/java/atlantafx/sampler/page/general/ColorScale.java @@ -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 bgBaseColor = new ReadOnlyObjectWrapper<>(Color.WHITE); private final List 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() ); } diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/ColorScaleBlock.java b/sampler/src/main/java/atlantafx/sampler/page/general/ColorScaleBlock.java index 2e82921..ec1ffa3 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/general/ColorScaleBlock.java +++ b/sampler/src/main/java/atlantafx/sampler/page/general/ColorScaleBlock.java @@ -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; @@ -34,8 +34,8 @@ public class ColorScaleBlock extends VBox { if (c instanceof Label label) { String colorName = (String) label.getUserData(); label.setStyle(String.format("-fx-background-color:%s;-fx-text-fill:%s;", - colorName, - JColorUtils.toHexWithAlpha(getSafeFgColor(label)) + colorName, + JColorUtils.toHexWithAlpha(getSafeFgColor(label)) )); } }); diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/ContrastChecker.java b/sampler/src/main/java/atlantafx/sampler/page/general/ContrastChecker.java index a96e18a..39a6cf3 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/general/ContrastChecker.java +++ b/sampler/src/main/java/atlantafx/sampler/page/general/ContrastChecker.java @@ -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); @@ -335,8 +321,8 @@ public class ContrastChecker extends GridPane { private void updateStyle() { setStyle(String.format("-color-contrast-checker-bg:%s;-color-contrast-checker-fg:%s;", - JColorUtils.toHexWithAlpha(bgColor.getColor()), - JColorUtils.toHexWithAlpha(getSafeFgColor()) + JColorUtils.toHexWithAlpha(bgColor.getColor()), + JColorUtils.toHexWithAlpha(getSafeFgColor()) )); } @@ -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 { diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/ThemePage.java b/sampler/src/main/java/atlantafx/sampler/page/general/ThemePage.java index 0ed0688..804f3ef 100755 --- a/sampler/src/main/java/atlantafx/sampler/page/general/ThemePage.java +++ b/sampler/src/main/java/atlantafx/sampler/page/general/ThemePage.java @@ -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"; @@ -23,9 +28,9 @@ public class ThemePage extends AbstractPage { private final Consumer colorBlockActionHandler = colorBlock -> { ContrastCheckerDialog dialog = getOrCreateContrastCheckerDialog(); dialog.getContent().setValues(colorBlock.getFgColorName(), - colorBlock.getFgColor(), - colorBlock.getBgColorName(), - colorBlock.getBgColor() + colorBlock.getFgColor(), + colorBlock.getBgColorName(), + colorBlock.getBgColor() ); overlay.setContent(dialog, HPos.CENTER); overlay.toFront(); @@ -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); diff --git a/sampler/src/main/java/atlantafx/sampler/util/ContrastLevel.java b/sampler/src/main/java/atlantafx/sampler/util/ContrastLevel.java new file mode 100644 index 0000000..76379dd --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/util/ContrastLevel.java @@ -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: + *

+ * WCAG defines large text as text that is 18pt and larger, or 14pt and larger if it is bold. + *
+ * More info. + */ +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 + * W3C. + *
+ * 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() }); + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/util/Controls.java b/sampler/src/main/java/atlantafx/sampler/util/Controls.java index 43d89a0..b0e4cc1 100644 --- a/sampler/src/main/java/atlantafx/sampler/util/Controls.java +++ b/sampler/src/main/java/atlantafx/sampler/util/Controls.java @@ -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 { @@ -40,10 +41,10 @@ public final class Controls { } public static ToggleButton toggleButton(String text, - Ikon icon, - ToggleGroup group, - boolean selected, - String... styleClasses) { + Ikon icon, + ToggleGroup group, + boolean selected, + String... styleClasses) { var toggleButton = new ToggleButton(text); if (icon != null) { toggleButton.setGraphic(new FontIcon(icon)); } if (group != null) { toggleButton.setToggleGroup(group); } @@ -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; + } } diff --git a/sampler/src/main/java/atlantafx/sampler/util/JColorUtils.java b/sampler/src/main/java/atlantafx/sampler/util/JColorUtils.java index be2607e..254bd6c 100644 --- a/sampler/src/main/java/atlantafx/sampler/util/JColorUtils.java +++ b/sampler/src/main/java/atlantafx/sampler/util/JColorUtils.java @@ -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: - * - * WCAG defines large text as text that is 18pt and larger, or 14pt and larger if it is bold. - *
- * More info. - */ - 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 - * W3C. - *
- * 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. *

diff --git a/sampler/src/main/java/module-info.java b/sampler/src/main/java/module-info.java index 4d5fd68..8021c15 100755 --- a/sampler/src/main/java/module-info.java +++ b/sampler/src/main/java/module-info.java @@ -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; diff --git a/sampler/src/main/resources/assets/styles/scss/widgets/_color-palette.scss b/sampler/src/main/resources/assets/styles/scss/widgets/_color-palette.scss index 11ba95c..b016623 100644 --- a/sampler/src/main/resources/assets/styles/scss/widgets/_color-palette.scss +++ b/sampler/src/main/resources/assets/styles/scss/widgets/_color-palette.scss @@ -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; diff --git a/sampler/src/main/resources/assets/styles/scss/widgets/_contrast-checker.scss b/sampler/src/main/resources/assets/styles/scss/widgets/_contrast-checker.scss index bb3ebc8..9abdd24 100644 --- a/sampler/src/main/resources/assets/styles/scss/widgets/_contrast-checker.scss +++ b/sampler/src/main/resources/assets/styles/scss/widgets/_contrast-checker.scss @@ -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,19 +76,21 @@ } } - .wsag-box>*>.wsag-label { - -fx-padding: 0.5em 1em 0.5em 1em; - -fx-background-color: palette.$color-wsag-bg-failed; - -fx-background-radius: 4px; - -fx-text-fill: palette.$color-wsag-fg; + .contrast-level { + >.state { + -fx-padding: 0.5em 1em 0.5em 1em; + -fx-background-color: palette.$color-wsag-bg-failed; + -fx-background-radius: 4px; + -fx-text-fill: palette.$color-wsag-fg; - &:passed { - -fx-background-color: palette.$color-wsag-bg-passed; - } + &:passed { + -fx-background-color: palette.$color-wsag-bg-passed; + } - >.ikonli-font-icon { - -fx-fill: palette.$color-wsag-fg; - -fx-icon-color: palette.$color-wsag-fg; + >.ikonli-font-icon { + -fx-fill: palette.$color-wsag-fg; + -fx-icon-color: palette.$color-wsag-fg; + } } } }