Skip to content

Structured builders #5

Open
Open

Description

@crschnick

I like to create builders for more than just single nodes, e.g. if you are building a more complex group of nodes like an HBox of multiple buttons. For those types of builders, I like to ensure that I have references to all included nodes, e.g. a reference to all buttons. I know this can be done with something like ids or style class lookups, but having a typed reference makes the job easier.

I created a subclass for the AbstractRegionBuilder in my project: https://github.com/xpipe-io/xpipe/blob/21-release/app/src/main/java/io/xpipe/app/comp/RegionStructureBuilder.java. An implementation of this looks like this: https://github.com/xpipe-io/xpipex/blob/023de8ba2a210ca60259e6ebc0a39434d2f3ea4f/app/src/main/java/io/xpipe/app/comp/base/LeftSplitPaneComp.java#L112

It essentially gives you a choice to build the structure if you're interested in having references to all child nodes. If not, then you can just build it normally like other builders. It would be nice if this had proper support because my implementation is a bit shaky.

Activity

  1. hjohn commented on Jan 24, 2026

    @hjohn
    Member

    The last link is broken, so not entirely sure what you are doing.

    There is nothing stopping you from making a group of nodes with a single builder AFAIK; I mean the pane builders do exactly that, except the nodes are supplied. You can easily make a method that accepts a count, which then adds that number of children for example. Builders can also store information locally in fields if they want to (most however don't).

    When it comes to references after building completes (ie. for the user that defines the UI), they will either have to use a variable and break from the structured style themselves, or be satisfied with things like NodeEventHandlers or Triggers.

    I may be missing something though, as I can't see your last link.

  2. crschnick commented on Jan 24, 2026

    @crschnick
    Author

    Actually, I think the SplitPane one isn't the best showcase for this as the SplitPane itself has getters for the content. This is more representative of the advantages: https://github.com/xpipe-io/xpipe/blob/21-release/app/src/main/java/io/xpipe/app/comp/base/TileButtonComp.java. It gives me an easy way to get a certain subnode in a built structure without having to use ids and class name lookups

  3. crschnick commented on Jan 24, 2026

    @crschnick
    Author

    I created a quick demo for what I thought of: crschnick@82e1855

    Maybe calling it views instead of structures makes more sense

  4. hjohn commented on Jan 24, 2026

    @hjohn
    Member

    This is more representative of the advantages: https://github.com/xpipe-io/xpipe/blob/21-release/app/src/main/java/io/xpipe/app/comp/base/TileButtonComp.java. It gives me an easy way to get a certain subnode in a built structure without having to use ids and class name lookups

    Can you explain what those advantages are and why you need that? I've never had the need to manipulate a sub-node directly. In my view, a sub-node is owned by its parent (or skin) and manipulating it directly in any way is a violation of encapsulation. Instead the container should selectively expose actions, properties and events, and not allow access to its internals (exposing a Node basically exposes everything).

    I've been trying to see what you are doing here, and it seems you are creating a fancy button. The closest analogy in a code base I use is this:

    private Node createTitledPane(String title, Node content, Node contentSummary) {
      return FX.titledPane("node")
        .graphic(titleAndControl(title, Panes.hbox("trailing-region").nodes(
          contentSummary,  // automatically elided if null
          Panes.hbox("controls").nodes(
            FX.menuButton().style("gear").text("\u2699").onShowing((c, event) -> {
              // add menu items here
            }),
            FX.button().text("\u274C").onAction(e -> remove(indexOfChild(content)))))  // an "X" close button
          )
        )
        .content(content);
    }
    
    // Helper to create a graphic for a titled pane that has a title followed by a gap followed by a control
    private static Node titleAndControl(String title, Object control) {
      Label label = new Label(title);
    
      HBox.setGrow(label, Priority.ALWAYS);
      label.setMaxWidth(10000);
    
      return Panes.hbox("node").alignment(Pos.CENTER_LEFT).nodes(label, control);
    }
    

    This creates the titled pane you see in this image:

    Image
  5. crschnick commented on Jan 24, 2026

    @crschnick
    Author

    I am doing that because I extended the builder use to be more than just simple controls. It's more like compound controls that are made up of multiple individual nodes.

    In theory, you could wire everything up so that everything can be manipulated directly, yes. But in practice it turned out to be much easier to just expose it via a view / structure. It's the same problem with skins in JavaFX. If you want to modify some internal thing of a skin, but it's not exposed properly, you hope that there is at least a style class associated with it to somehow look it up. Otherwise, you need an entire new skin or weird hack to just to get some small Label behave a bit differently than intended by the original skin.

    In your example, let's say I want to have the title pane title be an ObservableValue. You could add that as an overloaded method or just modify the original one. After you do that, you might also want to have a graphic for the title pane label. Ok, you add a way to pass a graphic as well. Then, someone wants to change the graphic layout, etc. At some point, using views becomes much easier than to just add every possibility of customization.

  6. crschnick commented on Jan 30, 2026

    @crschnick
    Author

    Right now, the primary use of builders is to build individual nodes. You configure certain properties like text, graphic, etc. for a label builder and that works out fine. A structure builder primarily takes other builders as parameters that make up the larger structure.

    If you want to create larger structures that are made out of many nodes/builders, the current system doesn't work that well. You have the same issue in your example, you have to create helper methods to build the node structures you want. I wanted to avoid these helper methods and instead primarily use class instances for that. Because helper methods don't scale very well when you add more and more parameters over time. And if you use custom builder class instances to replace the helper methods, you still lose the information about the individual nodes that make up the generated structure.

  7. hjohn commented on Jan 30, 2026

    @hjohn
    Member

    Right now, the primary use of builders is to build individual nodes. You configure certain properties like text, graphic, etc. for a label builder and that works out fine. A structure builder primarily takes other builders as parameters that make up the larger structure.

    That's the primary use case for a builder. A builder that has other builders as parameters is a pane, and panes do exactly that:

     Panes.hbox(builder, builder, builder)
    

    There is no need to call build.

    If you want to create larger structures that are made out of many nodes/builders, the current system doesn't work that well. You have the same issue in your example, you have to create helper methods to build the node structures you want. I wanted to avoid these helper methods and instead primarily use class instances for that. Because helper methods don't scale very well when you add more and more parameters over time. And if you use custom builder class instances to replace the helper methods, you still lose the information about the individual nodes that make up the generated structure.

    It's only a helper method because I didn't deem that pressing enough to convert to its own builder or custom control. The only reason this exists at all is because TitledPane is bugged and doesn't render the graphic correctly if you use a label and graphic separately (so I had to instead make my own label + graphic inside graphic of TitledPane). This issue is fixed in later FX versions as I fixed it myself.

    As for creating larger structures, you can do that easily. Here's a larger structure:

    import javafx.scene.control.ListCell;
    import javafx.scene.layout.VBox;
    
    import org.int4.fx.builders.FX;
    import org.int4.fx.builders.Panes;
    import org.int4.sdui.invokeai.Model;
    
    public class UpscalingPane extends VBox {
      public final UpscalingModel model = new UpscalingModel();
    
      public UpscalingPane() {
        getStyleClass().add("upscaling-pane");
    
        getChildren().addAll(
          Panes.hbox("form", "control-group").nodes(
            Panes.hbox().nodes(
              "Method",
              FX.comboBox()
                .model(model.upscalingModel)
                .cellFactory(ModelFieldCell::new)
                .buttonCell(new ModelFieldCell())
            ),
            Panes.hbox().nodes(
              "Factor",
              Panes.hbox().nodes(
                FX.slider().showTickMarks(2.0, 0).model(model.upscalingFactor),
                FX.textField().style("upscaling-factor", "sz1").model(model.upscalingFactor)
              )
            )
          )
          .build()
        );
      }
    
      private static final class ModelFieldCell extends ListCell<Model> {
        @Override
        protected void updateItem(Model model, boolean empty) {
          super.updateItem(model, empty);
    
          setText(empty ? "" : model.name());
        }
      }
    }
    

    Note that it is encapsulated, and the outside world can create as many of these as they want, and all they have to do is link up the public field. This can be converted to a builder as well, but haven't gotten around to it. Another example of a nicely encapsulated control (partially done):

    /**
     * A specialized marker overlay pane that displays validation markers on nodes.
     * <p>
     * This pane listens for {@link ValidationEvent#VALIDATION_CHANGED} events
     * and automatically adds or removes markers on target nodes depending on their
     * validation state.
     * <p>
     * Each marker consists of a small red circle with a white "X" drawn on top,
     * automatically scaled according to the node's font size.
     */
    public class ValidationMarkerPane extends AbstractMarkerPane {
    
      /**
       * Creates a builder for a {@link ValidationMarkerPane}, initialising
       * it with the given style classes.
       *
       * @param styleClasses the style classes, cannot be {@code null} but can be empty
       * @return a fluent builder, never {@code null}
       * @throws NullPointerException if any argument is {@code null}
       */
      public static ValidationMarkerPaneBuilder of(String... styleClasses) {
        return new ValidationMarkerPaneBuilder(FXContext.global(), styleClasses);
      }
    
      /**
       * Creates a new validation marker overlay pane.
       */
      public ValidationMarkerPane() {
        getStyleClass().add("validation-marker-pane");
    
        addEventFilter(ValidationEvent.VALIDATION_CHANGED, e -> {
          if(e.getTarget() instanceof Node node) {
            if(e.isValid()) {
              removeMarker(node);
            }
            else {
              FontGroup marker = new FontGroup();
    
              marker.setPickOnBounds(false);
              marker.getStyleClass().add("marker");
    
              marker.getChildren().addAll(
                Panes.stack("background").ignoreBounds().build(),
                Panes.stack("foreground").ignoreBounds().build()
              );
    
              marker.setFont(resolveFont(node));
    
              String message = switch(e.getDescriptor().key()) {
                case "domain.notNull" -> "Cannot be empty";
                case "domain.bounded" -> "Must be between {0} and {1}";
                case "domain.invalid" -> "Incorrect type";  
                default -> "Invalid";
              };
    
              putMarker(
                node,
                marker,
                new Tooltip(MessageFormat.format(message, e.getDescriptor().args()))
              );
            }
          }
        });
      }
    
      @Override
      public String getUserAgentStylesheet() {
        return StyleSheets.inline(
          """
          .validation-marker-pane {
            -fx-padding: 0;
            -fx-background-color: rgba(255,0,0,0.2);
            -fx-margin: 0;
          }
    
          .validation-marker-pane .overlay .marker {
            -fx-opacity: 0.8;
            -fx-scale-x: 1em;
            -fx-scale-y: 1em;
            -fx-translate-x: -0.25em;
            -fx-translate-y: 0.25em;
          }
    
          .validation-marker-pane .overlay .marker .foreground {
            -fx-min-width: 0.7;
            -fx-min-height: 0.7;
            -fx-translate-x: 0.15;
            -fx-translate-y: 0.15;
            -fx-background-color: white;
            // a thick cross:
            -fx-shape: "
              M -0.35,-0.5
              L -0.5,-0.35
              L -0.15,0
              L -0.5,0.35
              L -0.35,0.5
              L 0,0.15
              L 0.35,0.5
              L 0.5,0.35
              L 0.15,0
              L 0.5,-0.35
              L 0.35,-0.5
              L 0,-0.15
              Z
            ";
          }
    
          .validation-marker-pane .overlay .marker .background {
            -fx-min-width: 1;
            -fx-min-height: 1;
            -fx-background-color: red;
            // a circle:
            -fx-shape: "
              M 0,0
              m -0.5,0
              a 0.5,0.5 0 1,0 1,0
              a 0.5,0.5 0 1,0 -1,0
            ";
            -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 1.0), 0.35, 0.4, 0.1, 0.1);
          }
          """
        );
      }
    
      @Override
      protected Point2D computeMarkerPosition(Node node) {
        Bounds b = node.getLayoutBounds();
    
        return new Point2D(b.getMaxX(), b.getMinY());
      }
    
      private static Font resolveFont(Node node) {
        Node current = node;
    
        while(current != null) {
          @SuppressWarnings("unchecked")  // bit dirty, assume all CssMetaData properties are fonts :)
          List<CssMetaData<Node, Font>> cssMetaDataList = (List<CssMetaData<Node, Font>>)(List<?>)current.getCssMetaData();
    
          for(CssMetaData<Node, Font> md : cssMetaDataList) {
            if(md.getProperty().equals("-fx-font")) {  // this is a real font property
              return md.getStyleableProperty(current).getValue();
            }
          }
    
          current = current.getParent();
        }
    
        return Font.getDefault();
      }
    }
    
  8. crschnick commented on Jan 30, 2026

    @crschnick
    Author

    Ok thinking about it again, technically I don't need the actual structure. Just an simpler way to maybe query and call apply() on a certain subnode of the structure.

    I can just implement my own helper methods of doing a more ergonomic handling for the Node::lookup method, but I thought a dedicated library for this would be a better place.

    Maybe a variant of the apply() method that takes some kind of NodeSelector as an additional argument and will call the consumer with all matched nodes, e.g. by class name or id. An a sample use case, if I wanted to modify the behavior of your ValidationMarkerPane background, I could just call .apply(NodeSelector.ofStyleClass("background"), pane -> { ... })

  9. hjohn commented on Feb 1, 2026

    @hjohn
    Member

    Maybe a variant of the apply() method that takes some kind of NodeSelector as an additional argument and will call the consumer with all matched nodes, e.g. by class name or id. An a sample use case, if I wanted to modify the behavior of your ValidationMarkerPane background, I could just call .apply(NodeSelector.ofStyleClass("background"), pane -> { ... })

    Hm, well, apply receives an instance of the constructed node (so it is already a ValidationMarkerPane). Any public method or field will be accessible at that point. lookup would work for sure, but perhaps nicer would be if the class itself just offer access via a normal method or field. I often just make certain sub-nodes public final if I don't care about encapsulation too much. So for example:

      public class MyPane extends Region {
            // accessible internal structure here:
            public final Label label;
            public final Button button;
    
            // rest of class
      }
    

    And then with apply:

      MyPane.of()   // <-- assuming that gets you a builder, otherwise use FX.custom()
          .apply(myPane -> myPane.label.setText("a different text"));
    

    Is that workable?

  10. hjohn commented on Feb 1, 2026

    @hjohn
    Member

    I added a snapshots now (on github, they're visible in packages).

    I also now added the system that makes it possible to override builder defaults, with custom strategies. We'll see how well that works in practice. As an example, this overrides two strategies to create a new BuildContext:

        ChoiceModel<Locale> locales = ChoiceModel.of(Locale.ENGLISH, Locale.GERMAN);
    
        BuildContext context = BuildContext
          .of(TextStrategy.class, (node, input, setter) -> {
            if(locales.get().equals(Locale.GERMAN)) {
              setter.accept(Objects.toString(input).toUpperCase());
            }
            else {
              setter.accept(Objects.toString(input));
            }
          })
          .with(ChildrenStrategy.class, (node, children, setter) -> {
            setter.accept(children.stream()
              .filter(Objects::nonNull)
              .map(obj -> switch(obj) {
                case String s when s.equals("Hi") -> FX.label().text("Bye").build();
                case String s -> FX.label().text(s).build();
                case Node n -> n;
                default -> throw new IllegalStateException("Unsupported type: " + obj);
              })
              .toList()
            );
          });

    The first one applies to any text, promptText, accessibleText or accessibleHelpText, allowing you localize this. These now also accepts any Object instead of just Strings (so you could do localization by calling say FX.label().text(MyEnum.ENUM_KEY) and receive that key in the strategy).

    The 2nd one is what is called when you call a method like nodes(). By default, the builders already do simple stuff like filtering out nulls here and translating Strings to Labels (see the default in ChildrenStrategy) but now you can customize this fully (ie. enum keys could be turned into controls, or strings could become something other than labels, or you could treat the values you get as pairs, etc).

    There is also a ContentStrategy for single nodes (ie. graphic, content of titled pane/scroll pane, etc).

    To apply a BuildContext at a certain point there are a few options:

    • Pass it to Scenes.create()
    • Set it on any node in the hierarchy, using the new context() builder method
    • Set it temporarily (via ThreadLocal) using BuildContexts.with()
    • Set it globally at the start of your application (before using any builders) using BuildContexts.root()

    It should be available in the snapshot release.

  11. crschnick commented on Feb 1, 2026

    @crschnick
    Author

    Hm, well, apply receives an instance of the constructed node (so it is already a ValidationMarkerPane). Any public method or field will be accessible at that point. lookup would work for sure, but perhaps nicer would be if the class itself just offer access via a normal method or field. I often just make certain sub-nodes public final if I don't care about encapsulation too much. So for example:

    I usually try to replace creating custom control classes with custom builder subclasses. In an ideal world, I only create builder subclasses and no subclasses of Region for example. (Unless when implementing a completely new custom control with custom skinning, but that is an exception)

  12. crschnick commented on Feb 2, 2026

    @crschnick
    Author

    I think the TextStrategy should be able to differentiate between translations keys as strings and actual string values. In my use case, I'm referencing translated strings using a string identifier (creating enums for that is just way too much work and inflexible). But for some text entries, it should not be translated and the text should be used as-is. I would say that most applications handle translations this way in some form or another.

    If the strategy approach is kept, there should be at least the basic classes included for this translation approach as that will cover most of the use cases for people using the text strategy. Like two classes Translated and Fixed and easy creation methods for those.

  13. hjohn commented on Feb 2, 2026

    @hjohn
    Member

    I think the TextStrategy should be able to differentiate between translations keys as strings and actual string values. In my use case, I'm referencing translated strings using a string identifier (creating enums for that is just way too much work and inflexible). But for some text entries, it should not be translated and the text should be used as-is. I would say that most applications handle translations this way in some form or another.

    If the strategy approach is kept, there should be at least the basic classes included for this translation approach as that will cover most of the use cases for people using the text strategy. Like two classes Translated and Fixed and easy creation methods for those.

    That's work for the user of the framework, as they can choose their own approach that suits them. So if you want to wrap all your texts with two classes, then you can create this. Alternatively, you could go the FXML approach (where Strings starting with % are interpreted as keys) or you can think of your own unique approach (ie. lower case string = key, anything else = text).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions