cssjavafx

JavaFX custom CSS property with multiple values


I try to create a custom css style property for my component, with multiple color values, like the -fx-background-color. But I'm stuck, despite I define my CSS property similar to the -fx-background-color property, only the first color value is parsed from the CSS declaration. This is the CSS declaration:

.default-chart-theme {
   -jfc-default-paint-sequence: red,white,green,blue,yellow,orange,gray;
}

And this is the CssMetadata declaration in the java class:

public final CssMetaData<StyleableChartViewer, Paint[]> DEFAULT_PAINT_SEQUENCE = new CssMetaData<>("-jfc-default-paint-sequence", PaintConverter.SequenceConverter.getInstance(), new Paint[] { Color.RED } )
    {
        @Override
        public boolean isSettable(StyleableChartViewer styleable)
        {
            return !cssDefaultPaintSequence.isBound();
        }

        @Override
        public StyleableProperty<Paint[]> getStyleableProperty(StyleableChartViewer styleable)
        {
            return cssDefaultPaintSequence;
        }
    };

    public final SimpleStyleableObjectProperty<Paint[]> cssDefaultPaintSequence =
            new SimpleStyleableObjectProperty<>(DEFAULT_PAINT_SEQUENCE, this, "cssDefaultPaintSequence", new Paint[] { Color.RED } );

in the getCssMetaData I also return this property and it is also parsed, but instead of a paint sequence, it is parsed only as a single color value.

I also get a warning when processing the css property:

WARNING: Caught 'java.lang.ClassCastException: class javafx.scene.paint.Color cannot be cast to class [Ljavafx.css.ParsedValue; (javafx.scene.paint.Color and [Ljavafx.css.ParsedValue; are in unnamed module of loader 'app')' while converting value for '-jfc-default-paint-sequence' from rule '*.default-chart-theme' in stylesheet

Any advice how to create such a CSS property is welcome. I tried to google and also tried to get some info using Perplexity about this issue, but I did not find anything useful to overcome this issue.

Thanks!


Solution

  • Partial Solution

    One solution is to create your own StyleConverter implementation and quote the colors list in the CSS. By quoting the colors list, the converter will receive the value as a single string which can then be processed any way you want. Unfortunately, one significant downside to this approach is that variables (i.e., looked-up colors) won't be resolved and there's no way for you to resolve them from inside a style converter.

    There may be a better approach. If there is, hopefully someone else will post an answer.

    Example

    This example is only capable of parsing Color, not any arbitrary Paint. That's why this is only a "partial solution", though it should be possible to modify it to parse gradients and image patterns similar to how it works natively (not necessarily a trivial modification). In other words, it should be possible to make this approach as flexible as properties like -fx-background-color, though without any variables being resolved.

    Main.java

    package com.example;
    
    import java.util.Arrays;
    import javafx.application.Application;
    import javafx.scene.Scene;
    import javafx.stage.Stage;
    
    public class Main extends Application {
    
      private static final String STYLESHEET =
          """
          .colors-region {
            -fx-colors: 'red,blue,green';
          }
          """;
    
      @Override
      public void start(Stage primaryStage) {
        var region = new ColorsRegion();
        region.colorsProperty().subscribe(val -> System.out.println(Arrays.toString(val)));
    
        var scene = new Scene(region, 500, 300);
        scene.getStylesheets().add("data:text/css," + STYLESHEET);
    
        primaryStage.setScene(scene);
        primaryStage.show();
      }
    }
    

    ColorSequenceConverter.java

    package com.example;
    
    import java.util.stream.Stream;
    import javafx.css.ParsedValue;
    import javafx.css.StyleConverter;
    import javafx.scene.paint.Color;
    import javafx.scene.text.Font;
    
    public class ColorSequenceConverter extends StyleConverter<String, Color[]> {
    
      @Override
      public Color[] convert(ParsedValue<String, Color[]> value, Font font) {
        var string = value.getValue();
        return Stream.of(string.split(",")).map(Color::web).toArray(Color[]::new);
      }
    }
    

    ColorsRegion.java

    package com.example;
    
    import java.util.ArrayList;
    import java.util.Collections;
    import java.util.List;
    import javafx.beans.property.ObjectProperty;
    import javafx.css.CssMetaData;
    import javafx.css.SimpleStyleableObjectProperty;
    import javafx.css.StyleableObjectProperty;
    import javafx.css.StyleableProperty;
    import javafx.geometry.HPos;
    import javafx.geometry.VPos;
    import javafx.scene.layout.Background;
    import javafx.scene.layout.Region;
    import javafx.scene.paint.Color;
    
    public class ColorsRegion extends Region {
    
      public ColorsRegion() {
        getStyleClass().add("colors-region");
      }
    
      @Override
      protected void layoutChildren() {
        if (getChildren().isEmpty()) return;
    
        double x = getInsets().getLeft();
        double y = getInsets().getTop();
        double w = getWidth() - getInsets().getRight() - x;
        double h = getHeight() - getInsets().getBottom() - y;
    
        double ry = y;
        double rh = h / getChildren().size();
    
        for (var child : getChildren()) {
          layoutInArea(child, x, ry, w, rh, -1, HPos.CENTER, VPos.CENTER);
          ry += rh;
        }
      }
    
      private void updateRegions() {
        getChildren().clear();
    
        var colors = getColors();
        if (colors != null) {
          for (var color : colors) {
            var region = new Region();
            region.setBackground(Background.fill(color));
            getChildren().add(region);
          }
        }
      }
    
      /* **************************************************************************
       *                                                                          *
       * Properties                                                               *
       *                                                                          *
       ****************************************************************************/
    
      // -- colors property
    
      private final StyleableObjectProperty<Color[]> colors =
          new SimpleStyleableObjectProperty<>(Css.COLORS, this, "colors") {
            @Override
            protected void invalidated() {
              updateRegions();
            }
          };
    
      public final void setColors(Color[] colors) {
        this.colors.set(colors);
      }
    
      public final Color[] getColors() {
        return colors.get();
      }
    
      public final ObjectProperty<Color[]> colorsProperty() {
        return colors;
      }
    
      /* **************************************************************************
       *                                                                          *
       * CSS Handling                                                             *
       *                                                                          *
       ****************************************************************************/
    
      public static List<CssMetaData<?, ?>> getClassCssMetaData() {
        return Css.META_DATA;
      }
    
      @Override
      public List<CssMetaData<?, ?>> getCssMetaData() {
        return getClassCssMetaData();
      }
    
      private static class Css {
    
        private static final CssMetaData<ColorsRegion, Color[]> COLORS =
            new CssMetaData<>("-fx-colors", new ColorSequenceConverter()) {
    
              @Override
              public boolean isSettable(ColorsRegion styleable) {
                return !styleable.colors.isBound();
              }
    
              @Override
              public StyleableProperty<Color[]> getStyleableProperty(ColorsRegion styleable) {
                return styleable.colors;
              }
            };
    
        private static final List<CssMetaData<?, ?>> META_DATA;
    
        static {
          var regionMetadata = Region.getClassCssMetaData();
          var metadata = new ArrayList<CssMetaData<?, ?>>(regionMetadata.size() + 1);
          metadata.addAll(Region.getClassCssMetaData());
          metadata.add(COLORS);
          META_DATA = Collections.unmodifiableList(metadata);
        }
      }
    }
    

    Why Your Attempt Failed

    A StyleConverter is responsible for taking a ParsedValue and converting it into the type used by the JavaFX property. But a ParsedValue represents the value as it was already parsed by the CSS parser. In other words, the value has already been parsed into an intermediate object. Additionally, a ParsedValue may have its own StyleConverter associated with it. That converter, if I'm not mistaken, always takes precedence over the one associated with a styleable property's CssMetaData.

    Which intermediate object the value is parsed into and which converter, if any, is associated with the parsed value is not customizable. The behavior is hard-coded within the parser. Examining the linked code, you can see what is going wrong in your attempt.

    Since this behavior is hard-coded you unfortunately can't change it. It wouldn't really matter in this case if the parser handled arrays of paints, but it doesn't (interestingly, the parser does handle arrays of numbers for sizes).

    Now, if the value cannot be parsed into a Color then you get a ParsedValue<String, String> with no StyleConverter. That's what the "partial solution" above is taking advantage of. By quoting the value the CSS parser treats the entire thing as a single "term". That "term" cannot be parsed into a color. This results in the CSS metadata's StyleConverter basically having full control over how the value is parsed.