2

I have a node which looks like this:

enter image description here

Using only css, I want the label to overlay its parent border color, so the portion of the border color under the label gets invisible.

Css code I used to make this border:

-fx-border-color: black;
-fx-border-width: 3;
-fx-border-radius: 8;

Additional info:

1 -> It's possible to achieve the same effect by placing -background-color: white to the label, although in my case it's not a possibility.

2 -> For performance/arch reasons, it's not possible to achieve this effect through styled BorderPane or TitledPane nodes as suggested in the commends. Hence the importance of only using CSS or at least the less java code as possible.

FARS
  • 313
  • 6
  • 20
  • 2
    Does `.label { -fx-background-color: inherit; }` work in your case (assuming your label's parent has a background color)? – Slaw Jul 23 '23 at 23:15
  • There is a duplicate to this question out there somewhere. I could not find it. – SedJ601 Jul 24 '23 at 00:29
  • @Slaw good shot, but my parent do not has this css property (and I can't add it since it will mess up with my application styling ) – FARS Jul 24 '23 at 02:03
  • 1
    I found it! https://stackoverflow.com/questions/14860960/groupbox-titledborder-in-javafx-2 – SedJ601 Jul 24 '23 at 04:23
  • @SedJ601 unbelievable how WEAK stack overflow is. I question myself every single week how there's nothing better than it. Your suggested post is just a hack to achieve what I was looking for, which is nothing else than a very poor workaround (titled pane? border pane? I have thousands of nodes which needs that styling, adding a huge parent node to it is crazy bad). I invite you to read my post again as your suggested "answer" does not meets what I asked for. And please unmark my post as duplicate. It is not. – FARS Jul 24 '23 at 13:38
  • @FARS, I did not close this as a duplicate, even though I still believe it's a duplicate. I am not sure how you got "hack" out of that. Lol, you have a choice. You can use `TitledPane` or do what @jewelsea suggested. You can literally create your own custom node using @jewelsea's ideas. You can then use your custom node to produce your 1000+ nodes for your project. – SedJ601 Jul 24 '23 at 13:51
  • @SedJ601 sure, what supports this idea is that all computers has infinite ram to store all these nodes and infinite cpu to load it all very quickly. And yes, indeed it is a hack because ideally you should not add extra logic and java code for very basic visual changes, unless strictly necessary which clearly is not our case here. – FARS Jul 24 '23 at 13:59
  • @SedJ601 just a hint that points out that almost for sure it is possible to achieve the same effect using css only: https://jojorabbitjavafxblog.wordpress.com/2011/07/11/javafx-2-0-css-styling-part-1/ – FARS Jul 24 '23 at 14:01
  • In @jewelsea's answer, he uses three nodes to create `BorderedTitledPane`. I haven't looked at the code, but I am willing to be `TitledPane` uses at least three nodes too. I am not sure what the issue is here. ???? – SedJ601 Jul 24 '23 at 14:03
  • Hey, you seem to be the expert here. Good luck! – SedJ601 Jul 24 '23 at 14:04
  • Also, How are you going to display 1000+ of these nodes at once? It sounds like you will need a `TableView`, `ListView`, or `GridView`. Using one of these nodes will help your app be light on memory and loading. – SedJ601 Jul 24 '23 at 14:31
  • @SedJ601 sure, that's correct. Although it does improve performance, it impacts memory. The "unloaded" nodes -nodes out of the scroll- in these Pane extension nodes you mentioned stores all its nodes in memory, but only request to render then in the graphics context if showing inside the current scroll selection, that's where it saves memory. I'm more concerned with the unloaded nodes, BorderPane and TitledPane are one of the most heaviest Pane nodes memory wise. Please collaborate improving JFX community by unsetting this question as duplicate. It would greatly benefit if answered. – FARS Jul 24 '23 at 14:40
  • 4
    @FARS, You are incorrect. If you use one of the views I listed above, memory will only be allocated for the nodes that show on the screen. Those views use [`VirtualFlow`](https://openjfx.io/javadoc/12/javafx.controls/javafx/scene/control/skin/VirtualFlow.html). That's assuming that you are using the views correctly. – SedJ601 Jul 24 '23 at 18:57
  • 3
    @FARS, If you are really more specific with the implementation, at least show us how your code will look like. I mean you can provide a simple [Minimal Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) with the structure you are expecting. This will help others to save their time by not providing answers which are not suitable for you. – Sai Dandem Jul 24 '23 at 23:47
  • @SaiDandem "at least show us how your code will look like." neither I do, how would I? Besides that, I made it very clear - No costy nodes, only CSS and at the very least, less java code as possible.. sry if that confuses someone, but I really think MRE is not necessary here. Thinking of it, I wonder what MRE I can even place in here. FXML file (a hbox under a label.. which is like 1 minute in Scene Builder)? – FARS Jul 25 '23 at 12:49
  • @SedJ601 It has to be something more than that, try populating a tableView with 10.000 rows, all columns with some cellFactory logic. I guarantee you the nodes showing in the tableView are not the only ones consuming memory. The ones that don't show up definitely do consumes memory as well, but much less. Any profiler can tell you that – FARS Jul 25 '23 at 12:53
  • 2
    That simply means you are not using `setCellFactory` correctly. It could also mean you are confused about what's going on behind the scene. Your `TableView's` model should only contain data. You should not be passing any nodes to the `TableView's` cell factory. – SedJ601 Jul 25 '23 at 15:21
  • @SedJ601 sure, how then would I access TableView cells setGraphic()? Also, you're pretending table cells don't has any images and/or buttons whatsoever? Your comment just don't make any sense – FARS Jul 25 '23 at 15:28
  • 4
    @FARS you are absolutely misunderstanding/misusing table cells if your profiler is showing thousands of cell nodes. VirtualFlow instantiates only enough cells to fill the ViewPort plus a couple extra. Then the cells are reused with new content via your custom setItem() method. In a properly built TableView, if you put some sort of println() statement in the cell constructor, you should only see slightly more output lines than rows in the viewport. If you see thousands, then you're doing it wrong. – DaveB Jul 25 '23 at 18:32
  • I'm struggling to find a way to do this without at least three Nodes. You need the container (with the border), the Label (or at least a Text), and you need the content. That's three. – DaveB Jul 25 '23 at 18:42
  • 2
    And what's with the "in my case it's not a possibility" to set the background colour of the label the same as the background colour of the container??? – DaveB Jul 25 '23 at 18:43
  • @DaveB somehow I might be failing in communication. Let's clear up things. "misunderstanding/misusing table cells if your profiler is showing thousands of cell nodes." it is not, I never said so. I have thousands of nodes is a ListView, tableView was mentioned as an example of how nodes behave when they're not showing up in scene graph. My profiler is showing thousands of nodes from the ListView, which is correct as my entity class has thousands of entries, so that totally makes sense. No problem here. Second in next comment – FARS Jul 25 '23 at 21:59
  • @DaveB second, I haven't mentioned anywhere I have a node limit (like 3 nodes), I do have mentioned that it is a necessity to avoid costy nodes. And finally, I just can't set a background color to my label because the background of my app is an animated css gradient. That's why setting my label a background color is just not possible. Let me know if I can clarify anything else – FARS Jul 25 '23 at 22:02
  • 2
    @FARS It doesn't matter how minimal the code is. What it matters is, what is your exact use case. If in first place, you created a minimal example with gradient background, and shown the basic layout (your so called `a hbox under a label`) or what you are trying, I believe half of this discussion would not even existed. – Sai Dandem Jul 25 '23 at 22:31
  • @SaiDandem sorry I might be misunderstanding something this time. I though I made it very clear - setting a background color to my label is not an option. Wasn't that enough? Also you could literally just have asked why, and *that* is pretty much be the reason for our discussion have ever existed. Btw, you would have found another way to point out something wrong in my question instead of trying to do some coding anyways (as most of the people in this platform do nowadays, I don't blame you - it's stack overflow fault) – FARS Jul 25 '23 at 23:41
  • 2
    `setting a background color to my label is not an option. Wasn't that enough?` Yeah definitely not enough. You are seeking help in this platform and you need to provide as much information as possible in first case. So that there will be minimal to and fros. – Sai Dandem Jul 26 '23 at 00:12
  • And BTW, no question is asked without attempting. I believe no one is that brilliant to ask you by just seeing a screenshot and three lines of css code. – Sai Dandem Jul 26 '23 at 00:17
  • As mentioned, this is my [output](https://i.stack.imgur.com/hQeLH.png) I am so far at. Everytime you provide an info, the approach is changed.. so far I have 3 different approaches for this solution. The one in this screenshot is done with 4 nodes and using clipping effect. – Sai Dandem Jul 26 '23 at 00:42
  • 5
    "_I have thousands of nodes is a ListView_" -- If that means you have a `ListView`, then that is the wrong way to use a `ListView`. You should have a _model_, and then use a custom cell factory to create the view. However, if you're instead saying your model is so complex that **each _individual_ cell** requires dozens to hundreds of nodes to render it, well... that's a different issue. – Slaw Jul 26 '23 at 02:09
  • @FARS TableView or ListView, it's the same thing. If your viewport has, say, 10 rows you should have no more than about 12 cells instantiated. They get recycled as you scroll. – DaveB Jul 26 '23 at 23:13
  • 1
    The animated gradient bit changes everything. Now the problem is how to make the border disappear while keeping the background visible. To do that you'll need to have a container without a border, but with the background. Another the same size, but just the border, and then use another black box the same size and location as the label to use as an opacity mask on the border. – DaveB Jul 26 '23 at 23:18

2 Answers2

4

This is just a code dump of stuff you can choose to study or ignore. I may or may not provide additional explanation or answer further questions about it.

import javafx.scene.Group;
import javafx.scene.paint.Color;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;

public class CustomLabel extends Group {
    public static final double
            LABEL_WIDTH = 200,
            LABEL_HEIGHT = 60;

    private static final double
            FONT_SIZE = 15,
            TEXT_BASELINE_LEFT_X = 15,
            TEXT_BASELINE_LEFT_Y = 15,
            BORDER_INSETS = 9,
            BORDER_WIDTH = 2;

    private static final Font font = Font.font(
            "monospace",
            FontWeight.BOLD,
            FONT_SIZE
    );

    public CustomLabel(String labelText) {
        Text text = createText(
                labelText
        );
        final double textWidth = text.getLayoutBounds().getWidth();

        Path border = createBorder(
                textWidth
        );

        Rectangle background = new Rectangle(
                LABEL_WIDTH,
                LABEL_HEIGHT
        );
        background.setFill(Color.TRANSPARENT);

        getChildren().addAll(
                background,
                border,
                text
        );
    }

    private static Path createBorder(double textWidth) {
        Path border = new Path(
                new MoveTo(BORDER_INSETS, BORDER_INSETS),
                new LineTo(TEXT_BASELINE_LEFT_X, BORDER_INSETS),
                new MoveTo(TEXT_BASELINE_LEFT_X + textWidth, BORDER_INSETS),
                new LineTo(LABEL_WIDTH - BORDER_INSETS, BORDER_INSETS),
                new LineTo(LABEL_WIDTH - BORDER_INSETS, LABEL_HEIGHT - BORDER_INSETS),
                new LineTo(BORDER_INSETS, LABEL_HEIGHT - BORDER_INSETS),
                new LineTo(BORDER_INSETS, BORDER_INSETS)
        );
        border.setStrokeWidth(BORDER_WIDTH);
        return border;
    }

    private static Text createText(String labelText) {
        Text text = new Text(
                TEXT_BASELINE_LEFT_X,
                TEXT_BASELINE_LEFT_Y,
                " " + labelText + " "
        );

        text.setFont(font);
        return text;
    }
}

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class SingleLabelApp extends Application {
    @Override
    public void start(Stage stage) {
        stage.setScene(new Scene(new CustomLabel("hello, world")));
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}

import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.scene.Scene;
import javafx.scene.layout.Background;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.paint.CycleMethod;
import javafx.scene.paint.RadialGradient;
import javafx.scene.paint.Stop;
import javafx.stage.Stage;
import javafx.util.Duration;

public class SingleLabelAppWithAnimatedGradient extends Application {
    private static final double GRADIENT_RADIUS = 40;
    private static final Duration ANIMATION_DURATION = Duration.seconds(10);

    private DoubleProperty offset = new SimpleDoubleProperty(-GRADIENT_RADIUS);

    @Override
    public void start(Stage stage) {
        Pane pane = new Pane(
                new CustomLabel("hello, world")
        );

        animateBackground(pane);

        stage.setScene(new Scene(pane));
        stage.show();
    }

    private void animateBackground(Pane pane) {
        offset.addListener(o -> refreshBackground(pane));

        Timeline gradientAnimator = new Timeline(
                new KeyFrame(
                        Duration.seconds(0),
                        new KeyValue(
                                offset,
                                - GRADIENT_RADIUS
                        )
                ),
                new KeyFrame(
                        ANIMATION_DURATION,
                        new KeyValue(
                                offset,
                                GRADIENT_RADIUS + CustomLabel.LABEL_WIDTH
                        )
                )
        );
        gradientAnimator.setCycleCount(Animation.INDEFINITE);

        gradientAnimator.play();
    }

    private void refreshBackground(Pane pane) {
        pane.setBackground(
                Background.fill(
                        createGradient(
                                offset.get()
                        )
                )
        );
    }

    private static RadialGradient createGradient(double offset) {
        RadialGradient gradient = new RadialGradient(
                30,
                .2,
                offset,
                GRADIENT_RADIUS / 2,
                GRADIENT_RADIUS,
                false,
                CycleMethod.NO_CYCLE,
                new Stop(0, Color.SKYBLUE),
                new Stop(GRADIENT_RADIUS, Color.PINK)
        );

        return gradient;
    }

    public static void main(String[] args) {
        launch();
    }
}

import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class ManyLabelApp extends Application {
    private static final int NUM_LABELS = 1_000;

    @Override
    public void start(Stage stage) {
        Group lotsaLabels = new Group();

        for (int i = 0; i < NUM_LABELS; i++) {
            CustomLabel customLabel = new CustomLabel(
                    "Item %06d".formatted(i)
            );
            lotsaLabels.getChildren().add(customLabel);

            customLabel.setLayoutY(i * CustomLabel.LABEL_HEIGHT);
        }

        stage.setScene(new Scene(lotsaLabels));
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}

import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.scene.Scene;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.stage.Stage;

import java.util.ArrayList;
import java.util.List;

public class LabelListAppWithCreatedNodes extends Application {
    private static final int NUM_LABELS = 1_000;

    @Override
    public void start(Stage stage) {
        List<String> labelStrings = new ArrayList<>(NUM_LABELS);
        for (int i = 0; i < NUM_LABELS; i++) {
            labelStrings.add("Item %06d".formatted(i));
        }

        ListView<String> labelListView = new ListView<>(
                FXCollections.observableList(labelStrings)
        );
        labelListView.setCellFactory(param -> new ListCell<>() {
            @Override
            protected void updateItem(String item, boolean empty) {
                super.updateItem(item, empty);

                if (item == null || empty) {
                    setGraphic(null);
                    return;
                }

                setGraphic(new CustomLabel(item)); // <- not good.
            }
        });

        stage.setScene(new Scene(labelListView));
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}

import javafx.scene.Group;
import javafx.scene.paint.Color;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;

public class ChangeableCustomLabel extends Group {
    public static final double
            LABEL_WIDTH = 200,
            LABEL_HEIGHT = 60;

    private static final double
            FONT_SIZE = 15,
            TEXT_BASELINE_LEFT_X = 15,
            TEXT_BASELINE_LEFT_Y = 15,
            BORDER_INSETS = 9,
            BORDER_WIDTH = 2;

    private static final Font font = Font.font(
            "monospace",
            FontWeight.BOLD,
            FONT_SIZE
    );

    private final Text text = new Text();
    private final Path border = new Path();
    private final MoveTo textSkipper = new MoveTo();

    private static int numCreated = 0;

    public ChangeableCustomLabel(String labelText) {
        initText(
                labelText
        );
        final double textWidth = calculateTextWidth();

        initBorder(
                textWidth
        );

        Rectangle background = new Rectangle(
                LABEL_WIDTH,
                LABEL_HEIGHT
        );
        background.setFill(Color.TRANSPARENT);

        getChildren().addAll(
                background,
                border,
                text
        );

        System.out.println("Num custom labels created: " + ++numCreated);
    }

    public void setLabelText(String labelText) {
        text.setText(" " + labelText + " ");
        textSkipper.setX(TEXT_BASELINE_LEFT_X + calculateTextWidth());
    }

    private void initBorder(double textWidth) {
        textSkipper.setX(TEXT_BASELINE_LEFT_X + textWidth);
        textSkipper.setY(BORDER_INSETS);

        border.getElements().addAll(
                new MoveTo(BORDER_INSETS, BORDER_INSETS),
                new LineTo(TEXT_BASELINE_LEFT_X, BORDER_INSETS),
                textSkipper,
                new LineTo(LABEL_WIDTH - BORDER_INSETS, BORDER_INSETS),
                new LineTo(LABEL_WIDTH - BORDER_INSETS, LABEL_HEIGHT - BORDER_INSETS),
                new LineTo(BORDER_INSETS, LABEL_HEIGHT - BORDER_INSETS),
                new LineTo(BORDER_INSETS, BORDER_INSETS)
        );
        border.setStrokeWidth(BORDER_WIDTH);
    }

    private void initText(String labelText) {
        text.setX(TEXT_BASELINE_LEFT_X);
        text.setY(TEXT_BASELINE_LEFT_Y);
        text.setText(labelText);
        text.setFont(font);
    }

    private double calculateTextWidth() {
        return text.getLayoutBounds().getWidth();
    }
}

import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.scene.Scene;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.stage.Stage;

import java.util.ArrayList;
import java.util.List;

public class LabelListAppWithCachedNodes extends Application {
    private static final int NUM_LABELS = 100_000;

    @Override
    public void start(Stage stage) {
        List<String> labelStrings = new ArrayList<>(NUM_LABELS);
        for (int i = 0; i < NUM_LABELS; i++) {
            labelStrings.add("Item %06d".formatted(i));
        }

        ListView<String> labelListView = new ListView<>(
                FXCollections.observableList(labelStrings)
        );
        labelListView.setCellFactory(param -> new ListCell<>() {
            final ChangeableCustomLabel changeableCustomLabel = new ChangeableCustomLabel(
                    ""
            );

            @Override
            protected void updateItem(String item, boolean empty) {
                super.updateItem(item, empty);

                if (item == null || empty) {
                    setGraphic(null);
                    return;
                }

                changeableCustomLabel.setLabelText(item);
                setGraphic(changeableCustomLabel);
            }
        });

        stage.setScene(new Scene(labelListView));
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}
jewelsea
  • 150,031
  • 14
  • 366
  • 406
  • Thank you a lot for your input. I'm not taking it for the problem I have right now (overkill), but it would definitely help me with some other stuff. – FARS Jul 26 '23 at 13:33
4

+1 for @jewelsea approach. More or less I do also have a similar approach to build the border dynamically using Path (including corner radius). I also have other two approaches, one using inverse clipping technique and the other using border segments technique.

I included all three approaches in the below demo. You can choose or ignore. But my main intension to provide a direction if someone is interested with this.

enter image description here

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import javafx.scene.shape.*;
import javafx.stage.Stage;

public class TitledBorderDemo extends Application {
    String sampleText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";

    @Override
    public void start(final Stage stage) throws Exception {
        VBox root = new VBox(20);
        root.setStyle("-fx-background-color:linear-gradient(to bottom, pink, yellow);");
        root.setAlignment(Pos.CENTER);
        root.setPadding(new Insets(15));

        buildWithPath(root);
        buildWithClip(root);
        buildWithStyle(root);

        Scene scene = new Scene(root, 600, 600);
        scene.getStylesheets().add(getClass().getResource("titledborder.css").toExternalForm());
        stage.setScene(scene);
        stage.setTitle("Titled Border Demo");
        stage.show();
    }

    private void buildWithPath(VBox root) {
        Label content = new Label();
        content.setWrapText(true);
        content.setText(sampleText);

        TitledBorderWithPath pane = new TitledBorderWithPath();
        pane.setTitle("With Path Approach");
        pane.setContent(content);
        root.getChildren().add(pane);
    }

    private void buildWithClip(VBox root) {
        Label content = new Label();
        content.setWrapText(true);
        content.setText(sampleText);

        TitledBorderWithClip pane = new TitledBorderWithClip();
        pane.setTitle("With Clip Approach");
        pane.setContent(content);
        root.getChildren().add(pane);
    }

    private void buildWithStyle(VBox root) {
        Label content = new Label();
        content.setWrapText(true);
        content.setText(sampleText);

        TitledBorderWithSegment pane = new TitledBorderWithSegment();
        pane.setTitle("With Segment Approach");
        pane.setContent(content);
        root.getChildren().add(pane);
    }

    /**
     * Approach by using border segment.
     */
    class TitledBorderWithSegment extends StackPane {
        private final Label titleLabel = new Label();

        public TitledBorderWithSegment() {
            getStyleClass().add("titled-border-segment");
            titleLabel.getStyleClass().add("title-label");
            getChildren().addAll(titleLabel);
            // Position the title label on the top border
            titleLabel.translateYProperty().bind(titleLabel.heightProperty().divide(2).add(titleLabel.layoutYProperty()).multiply(-1));

            titleLabel.needsLayoutProperty().addListener((obs, old, needsLayout) -> {
                if (!needsLayout) {
                    buildStyle();
                }
            });

            widthProperty().addListener(p -> buildStyle());
            heightProperty().addListener(p -> buildStyle());
        }

        private void buildStyle() {
            double buffer = 8;
            double r = getBorder().getStrokes().get(0).getRadii().getTopLeftHorizontalRadius();
            double arcLength = Math.round(Math.toRadians(90) * r);
            double w = getWidth();
            double h = getHeight();
            double titleX = titleLabel.getLayoutX() + titleLabel.getTranslateX();
            double titleLength = titleLabel.getWidth();
            double t = (h - (2 * r)) +
                    arcLength +
                    (w - (2 * r)) +
                    arcLength +
                    (h - (2 * r)) +
                    arcLength +
                    (w - titleX - titleLength - r) - buffer;

            setStyle("-fx-border-style: segments(" + t + ", " + titleLength + ") line-cap round;");
        }

        public void setTitle(final String title) {
            this.titleLabel.setText(title);
        }

        public void setContent(Node node) {
            getChildren().clear();
            getChildren().addAll(titleLabel, node);
        }
    }

    /**
     * Approach by using Path.
     */
    class TitledBorderWithPath extends StackPane {
        private final Path border = new Path();
        private final StackPane container = new StackPane();
        private final Label titleLabel = new Label();

        // Can configure this as a CSS styleable property.
        private final double borderRadius = 8;

        public TitledBorderWithPath() {
            getStyleClass().add("titled-border-path");
            getChildren().addAll(border, container);

            border.getStyleClass().add("border");
            border.setManaged(false);

            titleLabel.getStyleClass().add("title-label");
            // Position the title label on the top border
            titleLabel.translateYProperty().bind(titleLabel.heightProperty().divide(2).add(titleLabel.layoutYProperty()).multiply(-1));
            titleLabel.needsLayoutProperty().addListener((obs, old, needsLayout) -> {
                if (!needsLayout) {
                    drawBorder();
                }
            });

            container.getStyleClass().add("container");
            container.widthProperty().addListener(p -> drawBorder());
            container.heightProperty().addListener(p -> drawBorder());
        }

        private void drawBorder() {
            double w = container.getWidth();
            double h = container.getHeight();
            double r = borderRadius;
            double x = titleLabel.getLayoutX() + titleLabel.getTranslateX();

            border.getElements().clear();
            border.getElements().addAll(new MoveTo(x, 0),
                    new LineTo(r, 0),
                    arc(0, r), // Top left
                    new LineTo(0, h - r),
                    arc(r, h),  // Bottom left
                    new LineTo(w - r, h),
                    arc(w, h - r), // Bottom right
                    new LineTo(w, r),
                    arc(w - r, 0), // Top right
                    new LineTo(x + titleLabel.getWidth(), 0));
        }

        private ArcTo arc(double x, double y) {
            return new ArcTo(borderRadius, borderRadius, 0, x, y, false, false);
        }

        public void setTitle(final String title) {
            this.titleLabel.setText(title);
        }

        public void setContent(Node node) {
            container.getChildren().clear();
            container.getChildren().addAll(titleLabel, node);
        }
    }

    /**
     * Approach by using clipping.
     */
    class TitledBorderWithClip extends StackPane {
        private Label titleLabel;

        final Rectangle clip = new Rectangle();
        final Rectangle inverse = new Rectangle();

        final Pane border = new Pane();
        final StackPane container = new StackPane();

        public TitledBorderWithClip() {
            getStyleClass().add("titled-border-clip");
            titleLabel = new Label();
            titleLabel.getStyleClass().add("title-label");

            container.getChildren().add(titleLabel);
            container.getStyleClass().add("container");

            border.getStyleClass().add("border");

            getChildren().addAll(border, container);


            border.widthProperty().addListener(p -> setInverseClip(border, clip));
            border.heightProperty().addListener(p -> setInverseClip(border, clip));

            // Position the title label on the top border
            titleLabel.translateYProperty().bind(titleLabel.heightProperty().divide(2).add(titleLabel.layoutYProperty()).multiply(-1));
            titleLabel.needsLayoutProperty().addListener((obs, old, needsLayout) -> {
                if (!needsLayout) {
                    setInverseClip(border, clip);
                }
            });
        }

        public void setTitle(final String title) {
            this.titleLabel.setText(title);
        }

        public void setContent(Node node) {
            container.getChildren().clear();
            container.getChildren().addAll(titleLabel, node);
        }

        private void setInverseClip(final Pane node, final Rectangle clip) {
            clip.setWidth(titleLabel.getWidth());
            clip.setHeight(titleLabel.getHeight());
            clip.setX(titleLabel.getLayoutX() + titleLabel.getTranslateX());
            clip.setY(titleLabel.getLayoutY() + titleLabel.getTranslateY());

            inverse.setWidth(node.getWidth());
            inverse.setHeight(node.getHeight());
            node.setClip(Shape.subtract(inverse, clip));
        }
    }
}

titledborder.css

.title-label{
    -fx-padding: 0px 5px 0px 5px;
    -fx-font-weight: bold;
    -fx-font-size: 14px;
    -fx-translate-x: 5px; /* To adjust the title placement horizontally */
}

/* With Clip Approach */
.titled-border-clip {
    -fx-alignment: TOP_LEFT;
}

.titled-border-clip > .container{
    -fx-padding:10px 5px 5px 5px;
    -fx-alignment: TOP_LEFT;
}

.titled-border-clip > .border{
    -fx-border-color:red;
    -fx-border-width: 2px;
    -fx-border-radius: 5px;
}

/* With Path Approach */
.titled-border-path {
    -fx-alignment: TOP_LEFT;
}

.titled-border-path > .container{
    -fx-padding:10px 5px 5px 5px;
    -fx-alignment: TOP_LEFT;
}

.titled-border-path > .border{
    -fx-stroke:red;
    -fx-stroke-width:2px;
}

/* With Simple Approach */
.titled-border-simple{
    -fx-background-color:inherit;
    -fx-border-color:red;
    -fx-border-width: 2px;
    -fx-border-radius: 5px;
    -fx-padding:10px 5px 5px 5px;
    -fx-alignment: TOP_LEFT;
}

.titled-border-simple > .title-label{
    -fx-background-color:inherit;
    -fx-border-color:inherit;
    -fx-border-width:inherit;
    -fx-border-radius:inherit;
}

/* With Segment Approach */
.titled-border-segment{
    -fx-border-color:red;
    -fx-border-width: 2px;
    -fx-border-radius: 10px;
    -fx-padding:10px 5px 5px 5px;
    -fx-alignment: TOP_LEFT;
}

For Simple layouts

Ok, the above approaches may be an overkill for the layouts which doesn't have a dynamic background (like gradients, images.. etc). For simple layouts, as @Slaw mentioned in the first comment, setting -fx-background-color: inherit; on both the parent and label should do the trick.

enter image description here

class SimpleTitledBorder extends StackPane {
        private final Label titleLabel = new Label();

        public SimpleTitledBorder() {
            getStyleClass().add("titled-border-simple");
            titleLabel.getStyleClass().add("title-label");
            getChildren().addAll(titleLabel);
            // Position the title label on the top border
            titleLabel.translateYProperty().bind(titleLabel.heightProperty().divide(2).add(titleLabel.layoutYProperty()).multiply(-1));
        }
        public void setTitle(final String title) {
            this.titleLabel.setText(title);
        }

        public void setContent(Node node) {
            getChildren().clear();
            getChildren().addAll(titleLabel, node);
        }
    }

CSS Code:

/* With Simple Approach */
.title-label{
    -fx-padding: 0px 5px 0px 5px;
    -fx-font-weight: bold;
    -fx-font-size: 14px;
    -fx-translate-x: 5px; /* To adjust the title placement horizontally */
}
.titled-border-simple{
    -fx-background-color:inherit;
    -fx-border-color:red;
    -fx-border-width: 2px;
    -fx-border-radius: 5px;
    -fx-padding:10px 5px 5px 5px;
    -fx-alignment: TOP_LEFT;
}

.titled-border-simple > .title-label{
    -fx-background-color:inherit;
}

And inheriting the border properties to label, gives another look to the layout :)

enter image description here

.titled-border-simple > .title-label{
    -fx-background-color:inherit;
    -fx-border-color:inherit;
    -fx-border-width:inherit;
    -fx-border-radius:inherit;
}
Sai Dandem
  • 8,229
  • 11
  • 26
  • Outstanding. Your second approach is what I was looking for. Simple, precise, and mainly, do the trick. Thank you! (just in case you enjoyed the question, please don't forget to upvote it!). – FARS Jul 26 '23 at 13:27
  • 1
    You mean using clip approach? – Sai Dandem Jul 26 '23 at 13:31
  • that's it. I do have a dynamic background but besides that, the clip approach is such a simple and out of the box solution – FARS Jul 26 '23 at 13:34
  • Ok. I thought you will be more inclined to segment approach :) (as it involves less nodes). Anyway its a nice excersice for me. – Sai Dandem Jul 26 '23 at 13:38
  • A stackPane and a Label sounds ideal. Also, what brings my attention to your approach is simplicity. As my application uses dozens of technologies (including full support with spring lastest versions), it`s getting increasingly bigger, barely having 1M code lines. So the less code to maintain, the better it is (ofc, not using any shortcut is important as well)! – FARS Jul 26 '23 at 13:46