javajavafxtableview

JavaFX's TableView with highlighted text and ellipsis functionality


What I am trying to achieve is to make it possible to highlight certain portions of the text inside TableView and at the same time preserve ellipsis functionality that is present when pure Label is used to render the cell content.

So what I am after is this:

enter image description here

  1. As you can see the "how" keyword is highlighted.
  2. Ellipsis are nicely rendered if text is too long.

My first idea was to simply use Label for this and pass in a formatted html - this is possible in Swing but (on my surprise) not in JavaFX so there is no way of achieving this with Label class.

My second try was with the use of TextFlow - but obviously the ellipsis functionality to fit labels into columns nicely is lost that way (I've also found few other issues not relevant to this question).

This feels to me like a quite a basic problem so I was rather surprised given the fact that first JavaFX version was release 9 years ago. I really wonder whether anybody has found some solution/workaround of how to achieve this.


Solution

  • It's not too hard to write a reasonably-performing custom layout that works like a simplified HBox and lays some labels out left to right. If you provide a text property and highlightedText property, you can just create the appropriate labels when one changes. This is not intended to be production quality, but should give you a good starting point:

    import javafx.beans.property.StringProperty;
    import javafx.beans.property.StringPropertyBase;
    import javafx.css.PseudoClass;
    import javafx.scene.Node;
    import javafx.scene.control.Label;
    import javafx.scene.layout.Pane;
    
    public class HighlightingLabelLayout extends Pane {
    
        private static final PseudoClass HIGHLIGHTED = PseudoClass.getPseudoClass("highlighted");
    
        private boolean needsRebuild = true ;
    
        private final Label ellipsis = new Label("...");
    
        private final StringProperty text = new StringPropertyBase() {
    
            @Override
            public String getName() {
                return "text" ;
            }
    
            @Override
            public Object getBean() {
                return HighlightingLabelLayout.this ;
            }
    
            @Override
            protected void invalidated() {
                super.invalidated();
                needsRebuild = true ;
                requestLayout();
            }
        };
    
        private final StringProperty highlightText = new StringPropertyBase() {
    
            @Override
            public String getName() {
                return "highlightText" ;
            }
    
            @Override
            public Object getBean() {
                return HighlightingLabelLayout.this ;
            }
    
            @Override
            protected void invalidated() {
                super.invalidated();
                needsRebuild = true ;
                requestLayout();
            }
        };
    
        public final StringProperty textProperty() {
            return this.text;
        }
    
    
        public final String getText() {
            return this.textProperty().get();
        }
    
    
        public final void setText(final String text) {
            this.textProperty().set(text);
        }
    
    
        public final StringProperty highlightTextProperty() {
            return this.highlightText;
        }
    
    
        public final String getHighlightText() {
            return this.highlightTextProperty().get();
        }
    
    
        public final void setHighlightText(final String highlightText) {
            this.highlightTextProperty().set(highlightText);
        }
    
        public HighlightingLabelLayout() {
            ellipsis.getStyleClass().add("ellipsis");
            getStylesheets().add(getClass().getResource("highlighting-label-layout.css").toExternalForm());
        }
    
        @Override
        protected void layoutChildren() {
            if (needsRebuild) {
                rebuild() ;
            }
            double width = getWidth();
            double x = snappedLeftInset() ;
            double y = snappedTopInset() ;
            boolean truncated = false ;
            for (Node label : getChildren()) {
                double labelWidth = label.prefWidth(-1);
                double labelHeight = label.prefHeight(labelWidth);
                if (label == ellipsis) {
                    label.resizeRelocate(width - labelWidth - snappedRightInset(), y, labelWidth, labelHeight);
                    continue ;
                }
                if (truncated) {
                    label.setVisible(false);
                    continue ;
                }
                if (labelWidth + x > width - snappedLeftInset() - snappedRightInset()) {
                    label.resizeRelocate(x, y, width - snappedLeftInset() - snappedRightInset() - x, labelHeight);
                    truncated = true ;
                    label.setVisible(true);
                    x = width - snappedRightInset();
                    continue ;
                }
                label.resizeRelocate(x, y, labelWidth, labelHeight);
                x+=labelWidth ;
            }
            ellipsis.setVisible(truncated);
        }
    
        @Override
        protected double computePrefWidth(double height) {
            if (needsRebuild) {
                rebuild();
            }
            double width = 0 ;
            for (Node label : getChildren()) {
                if (label != ellipsis) {
                    width += label.prefWidth(height);
                }
            }
            return width ;
        }
    
        @Override
        protected double computeMaxWidth(double height) {
            return computePrefWidth(height);
        }
    
        @Override
        protected double computeMinWidth(double height) {
            return Math.min(ellipsis.minWidth(height), computePrefWidth(height));
        }
    
        @Override
        protected double computePrefHeight(double width) {
            if (needsRebuild) {
                rebuild();
            }
            double height = 0 ;
            for (Node label : getChildren()) {
                if (label != ellipsis) {
                    double labelWidth = label.prefWidth(-1);
                    double labelHeight = label.prefHeight(labelWidth);
                    if (labelHeight > height) {
                        height = labelHeight ;
                    }
                }
            }
            return height ;
        }
    
        @Override
        protected double computeMinHeight(double width) {
            return Math.min(computePrefHeight(width), ellipsis.prefHeight(ellipsis.prefWidth(-1)));
        }
    
        @Override
        protected double computeMaxHeight(double width) {
            return computePrefHeight(width);
        }
    
        // Performance could probably be improved by caching and reusing the labels...
        private void rebuild() {
            String[] words = text.get().split("\\s");
            String highlight = highlightText.get();
            getChildren().clear();
            StringBuffer buffer = new StringBuffer();
            boolean addLeadingSpace = false ;
            for (int i = 0 ; i < words.length ; i++) {
                if (words[i].equals(highlight)) {
                    if ( i > 0) {
                        getChildren().add(new Label(buffer.toString()));
                        buffer.setLength(0);
                    }
                    Label label = new Label(words[i]);
                    label.pseudoClassStateChanged(HIGHLIGHTED, true);
                    addLeadingSpace = true ;
                    getChildren().add(label);
                } else {
                    if (addLeadingSpace) {
                        buffer.append(' ');
                    }
                    buffer.append(words[i]);
                    if (i < words.length - 1) {
                        buffer.append(' ');
                    }
                    addLeadingSpace = false ;
                }
            }
            if (buffer.length() > 0) {
                getChildren().add(new Label(buffer.toString()));
            }
            getChildren().add(ellipsis);
    
            needsRebuild = false ;
        }
    
    }
    

    and the corresponding CSS file

    .label {
        -highlight-color: yellow ;
        -fx-background-color: -fx-background ;
        -fx-ellipsis-string: "" ;
    }
    .label:highlighted {
        -fx-background: -highlight-color ;
    }
    

    As commented, there are some performance improvements that could be leveraged if needed.

    Here's a quick test, using this in table cells:

    import java.util.ArrayList;
    import java.util.List;
    import java.util.Random;
    
    import javafx.application.Application;
    import javafx.beans.property.SimpleStringProperty;
    import javafx.beans.property.StringProperty;
    import javafx.scene.Scene;
    import javafx.scene.control.TableCell;
    import javafx.scene.control.TableColumn;
    import javafx.scene.control.TableView;
    import javafx.scene.control.TextField;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    
    public class HighlightingLabelLayoutTest extends Application {
    
        @Override
        public void start(Stage primaryStage) {
    
            TextField searchField = new TextField();
    
            TableView<Item> table = new TableView<>();
            TableColumn<Item, String> itemCol = new TableColumn<>("Item");
            itemCol.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
            table.getColumns().add(itemCol);
    
            TableColumn<Item, String> dataCol = new TableColumn<>("Data");
            dataCol.setCellValueFactory(cellData -> cellData.getValue().dataProperty());
            dataCol.setPrefWidth(200);
            table.getColumns().add(dataCol);
    
            dataCol.setCellFactory(col -> new TableCell<Item, String>() {
                private final HighlightingLabelLayout layout = new HighlightingLabelLayout();
    
                {
                    layout.highlightTextProperty().bind(searchField.textProperty());
                }
    
                @Override
                protected void updateItem(String data, boolean empty) {
                    super.updateItem(data, empty);
                    if (empty) {
                        setGraphic(null);
                    } else {
                        layout.setText(data);
                        setGraphic(layout);
                    }
                }
            });
    
            table.getItems().setAll(generateData(200, 10));
    
    
    
            VBox root = new VBox(5, searchField, table);
            primaryStage.setScene(new Scene(root));
            primaryStage.show();
        }
    
        private List<Item> generateData(int numItems, int wordsPerItem) {
            List<Item> items = new ArrayList<>();
            Random rng = new Random();
            for (int i = 1 ; i <= numItems ; i++) {
                String name = "Item "+i;
                List<String> words = new ArrayList<>();
                for (int j = 0 ; j < wordsPerItem ; j++) {
                    words.add(WORDS[rng.nextInt(WORDS.length)].toLowerCase());
                }
                String data = String.join(" ", words);
                items.add(new Item(name, data));
            }
            return items ;
        }
    
        public static class Item {
            private final StringProperty name = new SimpleStringProperty();
            private final StringProperty data = new SimpleStringProperty();
    
            public Item(String name, String data) {
                setName(name);
                setData(data);
            }
    
            public final StringProperty nameProperty() {
                return this.name;
            }
    
    
            public final String getName() {
                return this.nameProperty().get();
            }
    
    
            public final void setName(final String name) {
                this.nameProperty().set(name);
            }
    
    
            public final StringProperty dataProperty() {
                return this.data;
            }
    
    
            public final String getData() {
                return this.dataProperty().get();
            }
    
    
            public final void setData(final String data) {
                this.dataProperty().set(data);
            }
    
    
    
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    
        private static final String[] WORDS = ("Lorem ipsum dolor sit amet, "
                + "consectetur adipiscing elit. Sed pulvinar massa at arcu ultrices, "
                + "nec elementum velit vestibulum. Integer eget elit justo. "
                + "Orci varius natoque penatibus et magnis dis parturient montes, "
                + "nascetur ridiculus mus. Duis ultricies diam turpis, eget accumsan risus convallis a. "
                + "Pellentesque rhoncus viverra sem, sed consequat lorem.").split("\\W") ;
    }
    

    enter image description here