Improve utility methods in Styles class

- Added more methods to manipulate style properties.
- Added unit tests.
This commit is contained in:
mkpaz 2023-05-21 13:39:57 +04:00
parent c97142e9bf
commit 561bd78d23
8 changed files with 440 additions and 80 deletions

@ -2,6 +2,9 @@
package atlantafx.base.theme;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.util.Base64;
import java.util.Objects;
import javafx.css.PseudoClass;
import javafx.scene.Node;
@ -14,6 +17,8 @@ import javafx.scene.control.TabPane;
@SuppressWarnings("unused")
public final class Styles {
public static final String DATA_URI_PREFIX = "data:base64,";
// Colors
public static final String ACCENT = "accent";
@ -92,6 +97,10 @@ public final class Styles {
/**
* Adds given style class to the node if it's not present, otherwise
* removes it.
*
* @param node the target node
* @param styleClass the style class to be toggled
* @throws NullPointerException if node or style class is null
*/
public static void toggleStyleClass(Node node, String styleClass) {
if (node == null) {
@ -102,7 +111,7 @@ public final class Styles {
}
int idx = node.getStyleClass().indexOf(styleClass);
if (idx > 0) {
if (idx >= 0) {
node.getStyleClass().remove(idx);
} else {
node.getStyleClass().add(styleClass);
@ -113,6 +122,11 @@ public final class Styles {
* Adds given style class to the node and removes the excluded classes.
* This method is supposed to be used when only one from a set of classes
* have to be present at once.
*
* @param node the target node
* @param styleClass the style class to be toggled
* @param excludes the style classes to be excluded
* @throws NullPointerException if node or styleClass is null
*/
public static void addStyleClass(Node node, String styleClass, String... excludes) {
if (node == null) {
@ -125,13 +139,21 @@ public final class Styles {
if (excludes != null && excludes.length > 0) {
node.getStyleClass().removeAll(excludes);
}
if (!node.getStyleClass().contains(styleClass)) {
node.getStyleClass().add(styleClass);
}
}
/**
* Activates given pseudo-class to the node and deactivates the excluded pseudo-classes.
* This method is supposed to be used when only one from a set of pseudo-classes
* have to be present at once.
*
* @param node the node to activate the pseudo-class on
* @param pseudoClass the pseudo-class to be activated
* @param excludes the pseudo-classes to be deactivated
* @throws NullPointerException if node or pseudo-class is null
*/
public static void activatePseudoClass(Node node, PseudoClass pseudoClass, PseudoClass... excludes) {
if (node == null) {
@ -149,17 +171,88 @@ public final class Styles {
node.pseudoClassStateChanged(pseudoClass, true);
}
/**
* Appends CSS style declaration to the specified node.
* There's no check for duplicates, so the CSS declarations with the same property
* name can be appended multiple times.
*
* @param node the node to append the new style declaration
* @param prop CSS property name
* @param value CSS property value
* @throws NullPointerException if node is null
*/
public static void appendStyle(Node node, String prop, String value) {
if (node == null) {
throw new NullPointerException("Node cannot be null!");
}
if (prop == null || prop.isBlank() || value == null || value.isBlank()) {
System.err.printf("Ignoring invalid style: property='%s', value='%s'%n", prop, value);
System.err.printf("Ignoring invalid style: property = '%s', value = '%s'%n", prop, value);
return;
}
var style = Objects.requireNonNullElse(node.getStyle(), "");
if (!style.endsWith(";")) {
if (!style.isEmpty() && !style.endsWith(";")) {
style += ";";
}
style = style + prop.trim() + ":" + value.trim() + ";";
node.setStyle(style);
}
/**
* Removes the specified CSS style declaration from the specified node.
*
* @param node the node to remove the style from
* @param prop the name of the style property to remove
* @throws NullPointerException if node is null
*/
public static void removeStyle(Node node, String prop) {
if (node == null) {
throw new NullPointerException("Node cannot be null!");
}
var currentStyle = node.getStyle();
if (currentStyle == null || currentStyle.isBlank()) {
return;
}
if (prop == null || prop.isBlank()) {
System.err.printf("Ignoring invalid property = '%s'%n", prop);
return;
}
String[] stylePairs = currentStyle.split(";");
var newStyle = new StringBuilder();
for (var s : stylePairs) {
String[] styleParts = s.split(":");
if (!styleParts[0].trim().equals(prop)) {
newStyle.append(s);
newStyle.append(";");
}
}
node.setStyle(newStyle.toString());
}
/**
* Converts a CSS string to a Base64-encoded data URI. The resulting string is
* an inline data URI that can be applied to any node in the following manner:
*
* <pre>{@code}
* var dataUri = Styles.toDataURI();
* node.getStylesheets().add(dataUri);
* node.getStylesheets().contains(dataUri);
* node.getStylesheets().remove(dataUri);
* </pre>
*
* @param css the CSS string to encode
* @return the resulting data URI string
*/
public static String toDataURI(String css) {
if (css == null) {
throw new NullPointerException("CSS string cannot be null!");
}
return DATA_URI_PREFIX + new String(Base64.getEncoder().encode(css.getBytes(UTF_8)), UTF_8);
}
}

@ -0,0 +1,332 @@
package atlantafx.base.theme;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import java.util.Base64;
import javafx.css.PseudoClass;
import javafx.scene.layout.Region;
import org.junit.jupiter.api.Test;
public class StylesTest {
PseudoClass pcFirst = PseudoClass.getPseudoClass("first");
PseudoClass pcSecond = PseudoClass.getPseudoClass("second");
PseudoClass pcExcluded = PseudoClass.getPseudoClass("excluded");
@Test
@SuppressWarnings("DataFlowIssue")
public void testToggleStyleClassNPE() {
assertThatNullPointerException().isThrownBy(
() -> Styles.toggleStyleClass(new Region(), null)
);
assertThatNullPointerException().isThrownBy(
() -> Styles.toggleStyleClass(null, "any")
);
}
@Test
public void testToggleStyleClassOn() {
var node = new Region();
node.getStyleClass().add("first");
assertThat(node.getStyleClass()).containsExactly("first");
Styles.toggleStyleClass(node, "second");
assertThat(node.getStyleClass()).containsExactly("first", "second");
}
@Test
public void testToggleStyleClassMultipleOn() {
var node = new Region();
node.getStyleClass().addAll("first", "second", "third");
assertThat(node.getStyleClass()).containsExactly("first", "second", "third");
Styles.toggleStyleClass(node, "fourth");
assertThat(node.getStyleClass()).containsExactly("first", "second", "third", "fourth");
}
@Test
public void testToggleStyleClassOff() {
var node = new Region();
node.getStyleClass().add("sole");
assertThat(node.getStyleClass()).containsExactly("sole");
Styles.toggleStyleClass(node, "sole");
assertThat(node.getStyleClass()).isEmpty();
}
@Test
public void testToggleStyleClassMultipleOff() {
var node = new Region();
node.getStyleClass().addAll("first", "second", "third");
assertThat(node.getStyleClass()).containsExactly("first", "second", "third");
Styles.toggleStyleClass(node, "second");
assertThat(node.getStyleClass()).containsExactly("first", "third");
}
///////////////////////////////////////////////////////////////////////////
@Test
@SuppressWarnings("DataFlowIssue")
public void testAddStyleClassClassNPE() {
assertThatNullPointerException().isThrownBy(
() -> Styles.addStyleClass(new Region(), null)
);
assertThatNullPointerException().isThrownBy(
() -> Styles.addStyleClass(null, "any")
);
}
@Test
public void testAddStyleClassAdds() {
var node = new Region();
node.getStyleClass().addAll("first");
assertThat(node.getStyleClass()).containsExactly("first");
Styles.addStyleClass(node, "second");
assertThat(node.getStyleClass()).containsExactly("first", "second");
}
@Test
public void testAddStyleClassExcludes() {
var node = new Region();
node.getStyleClass().addAll("first", "excluded");
assertThat(node.getStyleClass()).containsExactly("first", "excluded");
Styles.addStyleClass(node, "second", "excluded");
assertThat(node.getStyleClass()).containsExactly("first", "second");
}
@Test
public void testAddStyleClassIgnoresDuplicate() {
var node = new Region();
node.getStyleClass().addAll("first", "second", "excluded");
assertThat(node.getStyleClass()).containsExactly("first", "second", "excluded");
Styles.addStyleClass(node, "second", "excluded");
assertThat(node.getStyleClass()).containsExactly("first", "second");
}
///////////////////////////////////////////////////////////////////////////
@Test
@SuppressWarnings("DataFlowIssue")
public void testActivatePseudoClassNPE() {
assertThatNullPointerException().isThrownBy(
() -> Styles.activatePseudoClass(new Region(), null)
);
assertThatNullPointerException().isThrownBy(
() -> Styles.activatePseudoClass(null, pcFirst)
);
}
@Test
public void testActivatePseudoClassActivates() {
var node = new Region();
node.pseudoClassStateChanged(pcFirst, true);
assertThat(node.getPseudoClassStates()).containsExactly(pcFirst);
Styles.activatePseudoClass(node, pcSecond);
assertThat(node.getPseudoClassStates()).containsExactly(pcFirst, pcSecond);
}
@Test
public void testActivatePseudoClassExcludes() {
var node = new Region();
node.pseudoClassStateChanged(pcFirst, true);
node.pseudoClassStateChanged(pcExcluded, true);
assertThat(node.getPseudoClassStates()).containsExactly(pcFirst, pcExcluded);
Styles.activatePseudoClass(node, pcSecond, pcExcluded);
assertThat(node.getPseudoClassStates()).containsExactly(pcFirst, pcSecond);
}
@Test
public void testActivatePseudoClassIgnoresDuplicate() {
var node = new Region();
node.pseudoClassStateChanged(pcFirst, true);
node.pseudoClassStateChanged(pcSecond, true);
node.pseudoClassStateChanged(pcExcluded, true);
assertThat(node.getPseudoClassStates()).containsExactly(pcFirst, pcSecond, pcExcluded);
Styles.activatePseudoClass(node, pcSecond, pcExcluded);
assertThat(node.getPseudoClassStates()).containsExactly(pcFirst, pcSecond);
}
///////////////////////////////////////////////////////////////////////////
@Test
@SuppressWarnings("DataFlowIssue")
void testAppendStyleNullNode() {
assertThatNullPointerException().isThrownBy(
() -> Styles.appendStyle(null, "-fx-background-color", "red")
);
}
@Test
void testAppendStyleValid() {
var node = new Region();
Styles.appendStyle(node, "-fx-background-color", "red");
assertThat(node.getStyle()).isEqualTo("-fx-background-color:red;");
}
@Test
void testAppendStyleEmptyProperty() {
var node = new Region();
Styles.appendStyle(node, "", "red");
assertThat(node.getStyle()).isEmpty();
}
@Test
void testAppendStyleEmptyValue() {
var node = new Region();
Styles.appendStyle(node, "-fx-background-color", "");
assertThat(node.getStyle()).isEmpty();
}
@Test
void testAppendStyleNullProperty() {
var node = new Region();
Styles.appendStyle(node, null, "red");
assertThat(node.getStyle()).isEmpty();
}
@Test
void testAppendStyleNullValue() {
var node = new Region();
Styles.appendStyle(node, "-fx-background-color", null);
assertThat(node.getStyle()).isEmpty();
}
@Test
void testAppendStyleMultipleProperties() {
var node = new Region();
Styles.appendStyle(node, "-fx-background-color", "red");
Styles.appendStyle(node, "-fx-text-fill", "white");
assertThat(node.getStyle()).isEqualTo("-fx-background-color:red;-fx-text-fill:white;");
}
@Test
void testAppendStyleDuplicateProperty() {
var node = new Region();
Styles.appendStyle(node, "-fx-background-color", "red");
Styles.appendStyle(node, "-fx-background-color", "blue");
// that's it, "append" appends, no check for duplicates
assertThat(node.getStyle()).isEqualTo("-fx-background-color:red;-fx-background-color:blue;");
}
///////////////////////////////////////////////////////////////////////////
@Test
void testRemoveStyleValidProperty() {
var node = new Region();
node.setStyle("-fx-background-color:red;-fx-text-fill:white;");
Styles.removeStyle(node, "-fx-background-color");
assertThat(node.getStyle())
.contains("-fx-text-fill:white;")
.doesNotContain("-fx-background-color:red;");
}
@Test
void testRemoveStyleNonexistentProperty() {
var node = new Region();
node.setStyle("-fx-background-color:red;");
Styles.removeStyle(node, "-fx-text-fill");
assertThat(node.getStyle()).contains("-fx-background-color:red;");
}
@Test
void testRemoveStyleNullProperty() {
var node = new Region();
node.setStyle("-fx-background-color:red;");
Styles.removeStyle(node, null);
assertThat(node.getStyle()).contains("-fx-background-color:red;");
}
@Test
void testRemoveStyleEmptyProperty() {
var node = new Region();
node.setStyle("-fx-background-color:red;");
Styles.removeStyle(node, "");
assertThat(node.getStyle()).contains("-fx-background-color:red;");
}
@Test
@SuppressWarnings("DataFlowIssue")
void testRemoveStyleNullNode() {
assertThatNullPointerException().isThrownBy(
() -> Styles.removeStyle(null, "-fx-background-color")
);
}
@Test
void testRemoveStyleFromEmptyNode() {
var node = new Region();
Styles.removeStyle(node, "-fx-background-color");
assertThat(node.getStyle()).isEmpty();
}
@Test
void testRemoveMultipleStyles() {
var node = new Region();
node.setStyle("-fx-background-color:red;-fx-text-fill:white;");
Styles.removeStyle(node, "-fx-background-color");
Styles.removeStyle(node, "-fx-text-fill");
assertThat(node.getStyle()).isEmpty();
}
///////////////////////////////////////////////////////////////////////////
@Test
void testToDataURIWithValidCSS() {
String css = "body { font-size: 16px; }";
String dataUri = Styles.toDataURI(css);
byte[] decodedBytes = Base64.getDecoder().decode(dataUri.substring(dataUri.indexOf(",") + 1));
assertThat(dataUri).startsWith(Styles.DATA_URI_PREFIX);
assertThat(new String(decodedBytes)).isEqualTo(css);
}
@Test
void testToDataURIWithEmptyCSS() {
String css = "";
String dataUri = Styles.toDataURI(css);
byte[] decodedBytes = Base64.getDecoder().decode(dataUri.substring(dataUri.indexOf(",") + 1));
assertThat(dataUri).startsWith(Styles.DATA_URI_PREFIX);
assertThat(new String(decodedBytes)).isEmpty();
}
@Test
@SuppressWarnings("DataFlowIssue")
void testToDataURIWithNullCSS() {
assertThatNullPointerException().isThrownBy(
() -> Styles.toDataURI(null)
);
}
@Test
void testToDataURIWithWhitespaceCSS() {
String css = " ";
String dataUri = Styles.toDataURI(css);
byte[] decodedBytes = Base64.getDecoder().decode(dataUri.substring(dataUri.indexOf(",") + 1));
assertThat(dataUri).startsWith(Styles.DATA_URI_PREFIX);
assertThat(new String(decodedBytes)).isEqualTo(css);
}
@Test
void testToDataURIWithSpecialCharactersCSS() {
String css = "#id { background-image: url('https://example.com/bg.png'); }";
String dataUri = Styles.toDataURI(css);
byte[] decodedBytes = Base64.getDecoder().decode(dataUri.substring(dataUri.indexOf(",") + 1));
assertThat(dataUri).startsWith(Styles.DATA_URI_PREFIX);
assertThat(new String(decodedBytes)).isEqualTo(css);
}
}

@ -7,7 +7,6 @@ import atlantafx.base.util.BBCodeParser;
import atlantafx.sampler.page.ExampleBox;
import atlantafx.sampler.page.OutlinePage;
import atlantafx.sampler.page.Snippet;
import atlantafx.sampler.theme.CSSFragment;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
@ -395,7 +394,7 @@ public final class ButtonPage extends OutlinePage {
// -fx-font-size: 32px;
// -fx-icon-size: 32px;
// }
new CSSFragment(dataClass).addTo(iconBtn);
iconBtn.getStylesheets().add(Styles.toDataURI(dataClass));
//snippet_8:end
var box = new HBox(HGAP_20, btn, iconBtn);

@ -8,7 +8,6 @@ import atlantafx.base.util.BBCodeParser;
import atlantafx.sampler.page.ExampleBox;
import atlantafx.sampler.page.OutlinePage;
import atlantafx.sampler.page.Snippet;
import atlantafx.sampler.theme.CSSFragment;
import java.net.URI;
import java.time.LocalDate;
import java.time.LocalTime;
@ -148,7 +147,7 @@ public final class CalendarPage extends OutlinePage {
}
private ExampleBox styleExample() {
var style = """
var dataClass = """
.date-picker-popup {
-color-date-border: -color-accent-emphasis;
-color-date-month-year-bg: -color-accent-emphasis;
@ -161,7 +160,7 @@ public final class CalendarPage extends OutlinePage {
// -color-date-border: -color-accent-emphasis;
// -color-date-month-year-bg: -color-accent-emphasis;
// -color-date-month-year-fg: -color-fg-emphasis;
new CSSFragment(style).addTo(dp);
dp.getStylesheets().add(Styles.toDataURI(dataClass));
//snippet_4:end
var box = new HBox(dp);

@ -5,11 +5,11 @@ package atlantafx.sampler.page.components;
import atlantafx.base.controls.InlineDatePicker;
import atlantafx.base.controls.Popover;
import atlantafx.base.controls.Popover.ArrowLocation;
import atlantafx.base.theme.Styles;
import atlantafx.base.util.BBCodeParser;
import atlantafx.sampler.page.ExampleBox;
import atlantafx.sampler.page.OutlinePage;
import atlantafx.sampler.page.Snippet;
import atlantafx.sampler.theme.CSSFragment;
import java.net.URI;
import java.time.LocalDate;
import java.time.ZoneId;
@ -51,7 +51,7 @@ public final class PopoverPage extends OutlinePage {
}
private ExampleBox usageExample() {
var datePickerStyle = """
var dataClass = """
.popover .date-picker-popup {
-color-date-border: transparent;
-color-date-bg: transparent;
@ -84,7 +84,7 @@ public final class PopoverPage extends OutlinePage {
// -color-date-day-bg: transparent;
// -color-date-month-year-bg: transparent;
// -color-date-day-bg-hover: -color-bg-subtle;
new CSSFragment(datePickerStyle).addTo(datePicker);
datePicker.getStylesheets().add(Styles.toDataURI(dataClass));
var pop2 = new Popover(datePicker);
pop2.setHeaderAlwaysVisible(false);

@ -8,7 +8,6 @@ import atlantafx.base.util.BBCodeParser;
import atlantafx.sampler.page.ExampleBox;
import atlantafx.sampler.page.OutlinePage;
import atlantafx.sampler.page.Snippet;
import atlantafx.sampler.theme.CSSFragment;
import javafx.beans.binding.Bindings;
import javafx.concurrent.Task;
import javafx.geometry.HPos;
@ -316,7 +315,7 @@ public final class ProgressIndicatorPage extends OutlinePage {
// .example:danger .label {
// -fx-text-fill: -color-fg-emphasis;
// }
new CSSFragment(dataClass).addTo(content);
content.getStylesheets().add(Styles.toDataURI(dataClass));
bar.progressProperty().addListener((obs, old, val) -> {
if (val == null) {

@ -8,7 +8,6 @@ import atlantafx.base.util.BBCodeParser;
import atlantafx.sampler.page.ExampleBox;
import atlantafx.sampler.page.OutlinePage;
import atlantafx.sampler.page.Snippet;
import atlantafx.sampler.theme.CSSFragment;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
@ -80,7 +79,7 @@ public final class IconsPage extends OutlinePage {
}
private ExampleBox stackingExample() {
var style1 = """
var dataClass1 = """
.stacked-ikonli-font-icon > .outer-icon {
-fx-icon-size: 48px;
-fx-icon-color: -color-danger-emphasis;
@ -90,7 +89,7 @@ public final class IconsPage extends OutlinePage {
}
""";
var style2 = """
var dataClass2 = """
.stacked-ikonli-font-icon > .outer-icon {
-fx-icon-size: 48px;
}
@ -115,7 +114,7 @@ public final class IconsPage extends OutlinePage {
// .stacked-ikonli-font-icon > .inner-icon {
// -fx-icon-size: 24px;
// }
new CSSFragment(style1).addTo(stackIcon1);
stackIcon1.getStylesheets().add(Styles.toDataURI(dataClass1));
var outerIcon2 = new FontIcon(
Material2OutlinedAL.CHECK_BOX_OUTLINE_BLANK
@ -133,7 +132,7 @@ public final class IconsPage extends OutlinePage {
// .stacked-ikonli-font-icon > .inner-icon {
// -fx-icon-size: 24px;
// }
new CSSFragment(style2).addTo(stackIcon2);
stackIcon2.getStylesheets().add(Styles.toDataURI(dataClass2));
//snippet_2:end
var box = new HBox(HGAP_20, stackIcon1, stackIcon2);

@ -1,61 +0,0 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.theme;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.util.Base64;
import java.util.Objects;
import javafx.scene.layout.Region;
public final class CSSFragment {
private static final String DATA_URI_PREFIX = "data:base64,";
private final String css;
public CSSFragment(String css) {
this.css = Objects.requireNonNull(css);
}
public void addTo(Region region) {
Objects.requireNonNull(region);
region.getStylesheets().add(toDataURI());
}
public void removeFrom(Region region) {
Objects.requireNonNull(region);
region.getStylesheets().remove(toDataURI());
}
public boolean existsIn(Region region) {
Objects.requireNonNull(region);
return region.getStylesheets().contains(toDataURI());
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CSSFragment cssFragment = (CSSFragment) o;
return css.equals(cssFragment.css);
}
@Override
public int hashCode() {
return Objects.hash(css);
}
@Override
public String toString() {
return css;
}
public String toDataURI() {
return DATA_URI_PREFIX + new String(Base64.getEncoder().encode(css.getBytes(UTF_8)), UTF_8);
}
}