Hi folks, I have noticed an issue when combining TreeTableView and FilteredLists, where a wrong node is "selected" (I believe during some shift selection functionality in TreeTableView). Currently using JavaFX 21-ea+5 on Windows, but occurs in later builds too.
First noticed in a much more complex scenario with many components, I narrowed it down quite a bit, and created the simplest example I could, to demonstrate what I think is a bug. Let's say you have a tree (TableTreeView) displayed like this (as per code below): root (invisible) | ggg1 | ggg1.1 | xxx1.2 | ggg1.3 | bbb2 | bbb2.1 | bbb2.2 | bbb2.3 | aaa3 | aaa3.1 | aaa3.2 | aaa3.3 If you select leaf node "aaa3.2", for example, and then filter using a string "ggg", the node "bbb2", is being selected unexpectedly/incorrectly in the process, where it shouldn't. This is the bug. Here's a simple way to reproduce the issue. Run the code, and look at the tree first. Observe that a leaf node "aaa3.2" is selected for you (the code selects this as a shortcut for you). Hit the button to filter with string "ggg", and notice the logging showing that "bbb2" -- the leaf node's parent's sibling, is incorrectly momentarily selected, before "null" is settled as the final selected value (null being correct). Why is this happening? Sample output of running the below code: Value of aaa3.2 from tree (for verification): aaa3.2 <---- printed to show the node about to be selected is the correct node Selecting item: aaa3.2 <---- printed to show the code is about to select it Selected item (as per listener): aaa3.2 <---- printed by the listener, showing it was selected About to filter on "ggg" <---- printed to show you hit the button, now the list is filtering which will change the tree Selected item (as per listener): bbb2 <---- printed by the listener, showing bbb2 is selected , why is this happening along the way? This seems like a bug. Maybe it's part of some "let's select the closest sibling" logic, but...why? And if so, it's not a consistent pattern/logic that I can understand. Selected item (as per listener): null <---- printed by the listener, showing null is "selected", which is fine / expected, as the *real* selected item has been filtered out Runnable code: import javafx.application.Application; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.transformation.FilteredList; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.layout.VBox; import javafx.stage.Stage; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; public class TreeTableSelectBug extends Application { private final TreeTableView<String> tree = new TreeTableView<>(); private final ObjectProperty<Predicate<String>> filterPredicate = new SimpleObjectProperty<>(); @Override public void start(Stage primaryStage) throws Exception { final VBox outer = new VBox(); tree.setShowRoot(false); tree.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); tree.setRoot(createTree()); addColumn(); // Print selection changes: there should only be two (initial selection, then final selection to "null" when nodes are filtered), but there is an extra one ("bbb2") in the middle. tree.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> System.out.println("Selected item (as per listener): " + (tree.getSelectionModel().getSelectedItem() == null ? "null" : tree.getSelectionModel().getSelectedItem().getValue()))); final Button filterButton = new Button("Filter on \"ggg\""); outer.getChildren().addAll(filterButton, tree); final Scene scene = new Scene(outer, 640, 480); primaryStage.setScene(scene); primaryStage.show(); // Select a lead node: aaa3 -> aaa3.2 (as an example) final TreeItem<String> aaa32 = tree.getRoot().getChildren().get(2).getChildren().get(1); System.out.println("Value of aaa3.2 from tree (for verification): " + aaa32.getValue()); // Expand it -- without expanding it, the bug won't occur aaa32.getParent().setExpanded(true); System.out.println("Selecting item: " + aaa32.getValue()); // Select an item, note it is printed. Same as a user clicking the row. tree.getSelectionModel().select(aaa32); filterButton.setOnAction(event -> { System.out.println("About to filter on \"ggg\""); // Filter based on "ggg" (the top parent node) filterPredicate.set(string -> string.toLowerCase().trim().contains("ggg")); // BUG: The output is the below. Note that "bbb2" gets selected along the way, for some reason. This is the bug. // // Output: // Value of aaa3.2 from tree (for verification): aaa3.2 // Selecting item: aaa3.2 // Selected item (as per listener): aaa3.2 // About to filter on "ggg": aaa3.2 // Selected item (as per listener): bbb2 // Selected item (as per listener): null }); } private SimpleTreeItem<String> createTree() { // So, we have a tree like this: // ggg1 // | ggg1.1 // | xxx1.2 // | ggg1.3 // bbb2 // | bbb2.1 // | bbb2.2 // | bbb2.3 // aaa3 // | children // | aaa3.1 // | aaa3.2 // | aaa3.3 final List<SimpleTreeItem<String>> gggChildren = new ArrayList<>(); gggChildren.add(new SimpleTreeItem<>("ggg1.1", null, filterPredicate)); gggChildren.add(new SimpleTreeItem<>("xxx1.2", null, filterPredicate)); gggChildren.add(new SimpleTreeItem<>("ggg1.3", null, filterPredicate)); final SimpleTreeItem<String> gggTree = new SimpleTreeItem<>("ggg1", gggChildren, filterPredicate); final List<SimpleTreeItem<String>> bbbChildren = new ArrayList<>(); bbbChildren.add(new SimpleTreeItem<>("bbb2.1", null, filterPredicate)); bbbChildren.add(new SimpleTreeItem<>("bbb2.2", null, filterPredicate)); bbbChildren.add(new SimpleTreeItem<>("bbb2.3", null, filterPredicate)); final SimpleTreeItem<String> bbbTree = new SimpleTreeItem<>("bbb2", bbbChildren, filterPredicate); final List<SimpleTreeItem<String>> aaaChildren = new ArrayList<>(); aaaChildren.add(new SimpleTreeItem<>("aaa3.1", null, filterPredicate)); aaaChildren.add(new SimpleTreeItem<>("aaa3.2", null, filterPredicate)); aaaChildren.add(new SimpleTreeItem<>("aaa3.3", null, filterPredicate)); final SimpleTreeItem<String> aaaTree = new SimpleTreeItem<>("aaa3", aaaChildren, filterPredicate); final List<SimpleTreeItem<String>> rootChildren = new ArrayList<>(); rootChildren.add(gggTree); rootChildren.add(bbbTree); rootChildren.add(aaaTree); return new SimpleTreeItem<>("root", rootChildren, filterPredicate); } static class SimpleTreeItem<T> extends TreeItem<T> { private final ObjectProperty<Predicate<T>> filter = new SimpleObjectProperty<>(); private FilteredList<SimpleTreeItem<T>> children; public SimpleTreeItem(final T value, List<SimpleTreeItem<T>> children, ObservableValue<Predicate<T>> filter) { super(value, null); if (filter != null) { this.filter.bind(filter); } if (children != null) { addChildren(children); } } private void addChildren(List<SimpleTreeItem<T>> childrenParam) { children = new FilteredList<>(FXCollections.observableArrayList(childrenParam)); children.predicateProperty().bind(Bindings.createObjectBinding(() -> SimpleTreeItem.this::showNode, filter)); Bindings.bindContent(getChildren(), children); } private boolean showNode(SimpleTreeItem<T> node) { if (filter.get() == null) { return true; } if (filter.get().test(node.getValue())) { // Node is directly matched -> so show it return true; } if (node.children != null) { // Are there children (or children of children...) that are matched? If yes we also need to show this node return node.children.getSource().stream().anyMatch(this::showNode); } return false; } } protected void addColumn() { TreeTableColumn<String, String> column = new TreeTableColumn<>("Some column"); column.setPrefWidth(150); column.setCellFactory(param -> new TreeTableCell<>() { @Override protected void updateItem(String item, boolean empty) { super.updateItem(item, empty); if (empty || item == null) { setText(null); } else { setText(item); } } }); column.setCellValueFactory( param -> param.getValue().valueProperty() ); tree.getColumns().add(column); } public static void main(String[] args) { launch(args); } } Kind Regards, Cormac