diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/AggregatedListBindingApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/AggregatedListBindingApp.java new file mode 100644 index 00000000..9ce3da0d --- /dev/null +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/AggregatedListBindingApp.java @@ -0,0 +1,212 @@ +package com.dlsc.gemsfx.demo.binding; + +import com.dlsc.gemsfx.binding.AggregatedListBinding; +import com.dlsc.gemsfx.binding.GeneralAggregatedListBinding; +import javafx.application.Application; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import javafx.util.Pair; + +import java.util.Random; +import java.util.stream.Collectors; + +/** + * This demo shows how to use the {@link AggregatedListBinding} and {@link GeneralAggregatedListBinding} classes to create + * bindings that aggregate values from a list of objects. In this example, we have a list of students where each student + * has a list of scores. We create bindings that calculate the total and average score of all students, as well as the + * number of students who have at least one failing score. + */ +public class AggregatedListBindingApp extends Application { + + private final ObservableList students = FXCollections.observableArrayList(); + private final TableView tableView = new TableView<>(); + private final Random random = new Random(); + private int index; + + @Override + public void start(Stage primaryStage) { + students.add(new Student("Alice", FXCollections.observableArrayList(80, 90, 88))); + students.add(new Student("Bob", FXCollections.observableArrayList(75, 82, 91))); + initTableView(); + + Label averageLabel = new Label(); + // Creates a binding to calculate the average score of all students. Each student has a list of scores, + // and this binding computes the average of all these scores across all students. This is achieved by mapping each + // student's scores to their integer values, calculating the average of these values, and handling cases where + // there are no scores (using orElse(0.0) to return 0.0 if no scores are present). + AggregatedListBinding averageBinding = new AggregatedListBinding<>( + students, // the list of students + Student::getScores, // function to get scores from each student + scores -> scores.stream().mapToInt(Integer::intValue).average().orElse(0.0) // function to calculate average of scores + ); + averageLabel.textProperty().bind(averageBinding.asString("Average Score: %.2f")); + + Label sumLabel = new Label(); + // Creates a binding to calculate the total score of all students. This binding sums up all scores + // from all students. It uses a mapping function to convert scores to integers and then sums them up. + AggregatedListBinding sumBinding = new AggregatedListBinding<>( + students, // the list of students + Student::getScores, // function to get scores from each student + scores -> scores.stream().mapToInt(Integer::intValue).sum() // function to sum all scores + ); + sumLabel.textProperty().bind(sumBinding.asString("Total Score: %d")); + + Label averageLabel2 = new Label(); + // This binding calculates a more complex form of average using a pair to hold the sum of scores and the count of scores, + // allowing the calculation of the average in a subsequent step. It demonstrates a more advanced use of aggregation + // with intermediary transformations. + GeneralAggregatedListBinding, Double> averageBinding2 = new GeneralAggregatedListBinding<>( + students, // the list of students + Student::getScores, // function to extract scores from each student + scores -> new Pair<>(scores.stream().mapToInt(Integer::intValue).sum(), scores.size()), // maps scores to a pair of sum and count + results -> results.stream().mapToDouble(Pair::getKey).sum() / results.stream().mapToDouble(Pair::getValue).sum() // computes average from sum and count + ); + averageLabel2.textProperty().bind(averageBinding2.asString("Average Score: %.2f")); + + Label sumLabel2 = new Label(); + // Creates a binding to calculate the total score of all students using a generalized binding approach that sums up individual results. + GeneralAggregatedListBinding sumBinding2 = new GeneralAggregatedListBinding<>( + students, // the list of students + Student::getScores, // function to extract scores from each student + scores -> scores.stream().mapToInt(Integer::intValue).sum(), // function to sum scores of a single student + results -> results.stream().reduce(0, Integer::sum) // reduces all individual sums into one sum + ); + sumLabel2.textProperty().bind(sumBinding2.asString("Total Score: %d")); + + // This label displays the number of students who have at least one failing score (<60). + Label failingCountLabel = new Label(); + // This binding counts how many students have at least one failing score (<60). It demonstrates using a conditional aggregation + // where each student's score list is evaluated for failing scores, and each failing student contributes '1' to the total count. + GeneralAggregatedListBinding failingCountBinding = new GeneralAggregatedListBinding<>( + students, // the list of students + Student::getScores, // function to extract scores from each student + scores -> scores.stream().anyMatch(score -> score < 60) ? 1L : 0L, // checks if any score is below 60, returns 1 if true, else 0 + results -> results.stream().reduce(0L, Long::sum) // sums up all '1's representing failing students + ); + failingCountLabel.textProperty().bind(failingCountBinding.asString("Number of Failing Students: %d")); + + Node statistics1 = createStatisticBox("AggregatedListBinding", averageLabel, sumLabel); + Node statistics2 = createStatisticBox("GeneralAggregatedListBinding", averageLabel2, sumLabel2, failingCountLabel); + + VBox root = new VBox(10, tableView, statistics1, statistics2, createButtonBox()); + root.setStyle("-fx-padding: 10px;"); + Scene scene = new Scene(root); + primaryStage.setTitle("Student Scores Management"); + primaryStage.setScene(scene); + primaryStage.sizeToScene(); + primaryStage.show(); + } + + private Node createStatisticBox(String title, Node... children) { + Label titleLabel = new Label(title); + titleLabel.setStyle(" -fx-font-size: 15px;-fx-text-fill: #9a9999"); + HBox box = new HBox(10, children); + VBox wrapper = new VBox(15, titleLabel, box); + wrapper.setStyle("-fx-border-color: lightgray; -fx-border-width: 1px; -fx-padding: 10px; -fx-border-radius: 5px;-fx-background-color: white;"); + return wrapper; + } + + private HBox createButtonBox() { + Button addStudentButton = new Button("Add New Student"); + addStudentButton.setOnAction(event -> addNewStudent()); + + Button removeStudentButton = new Button("Remove Selected Student"); + removeStudentButton.setOnAction(event -> removeSelectedStudent()); + removeStudentButton.disableProperty().bind(tableView.getSelectionModel().selectedItemProperty().isNull()); + + Button updateStudentButton = new Button("Update Selected Student"); + updateStudentButton.setOnAction(event -> updateSelectedStudentScores()); + updateStudentButton.disableProperty().bind(tableView.getSelectionModel().selectedItemProperty().isNull()); + return new HBox(10, addStudentButton, removeStudentButton, updateStudentButton); + } + + private void initTableView() { + TableColumn nameColumn = new TableColumn<>("Name"); + nameColumn.setPrefWidth(100); + nameColumn.setCellValueFactory(new PropertyValueFactory<>("name")); + + TableColumn scoresColumn = new TableColumn<>("Scores"); + scoresColumn.setPrefWidth(280); + scoresColumn.setCellValueFactory(cellData -> new SimpleStringProperty( + cellData.getValue().getScores().stream().map(String::valueOf).collect(Collectors.joining(", ")) + )); + + tableView.getColumns().addAll(nameColumn, scoresColumn); + tableView.setItems(students); + } + + private void addNewStudent() { + ObservableList newScores = FXCollections.observableArrayList(randomScore(), randomScore(), randomScore()); + students.add(new Student("Student " + (++index), newScores)); + } + + private void removeSelectedStudent() { + Student selected = tableView.getSelectionModel().getSelectedItem(); + if (selected != null) { + students.remove(selected); + } + } + + private void updateSelectedStudentScores() { + Student selected = tableView.getSelectionModel().getSelectedItem(); + if (selected != null) { + double operation = random.nextInt(0, 3); + if (operation == 0) { + ObservableList newScores = FXCollections.observableArrayList(randomScore(), randomScore(), randomScore()); + selected.scores.setAll(newScores); + } else if (operation == 1) { + selected.scores.add(randomScore()); + } else { + if (!selected.scores.isEmpty()) { + selected.scores.remove(selected.scores.size() - 1); + } else { + ObservableList newScores = FXCollections.observableArrayList(randomScore()); + selected.scores.setAll(newScores); + } + } + tableView.refresh(); + } + } + + private int randomScore() { + return random.nextInt(0, 50) + 50; + } + + public static class Student { + private final SimpleStringProperty name = new SimpleStringProperty(); + private final ObservableList scores; + + public Student(String name, ObservableList scores) { + this.name.set(name); + this.scores = scores; + } + + public String getName() { + return name.get(); + } + + public SimpleStringProperty nameProperty() { + return name; + } + + public ObservableList getScores() { + return scores; + } + } + + public static void main(String[] args) { + launch(args); + } + +} diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/NestedListBindingApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/NestedListBindingApp.java new file mode 100644 index 00000000..7c8a1f35 --- /dev/null +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/NestedListBindingApp.java @@ -0,0 +1,120 @@ +package com.dlsc.gemsfx.demo.binding; + +import com.dlsc.gemsfx.binding.NestedListBinding; +import javafx.application.Application; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +import java.util.Random; +import java.util.stream.Collectors; + +/** + * This demo shows how to use the {@link NestedListBinding} class to create a binding that aggregates + * values from a nested list structure. In this example, we have a list of student scores where each + * student has a list of scores. We create a binding that calculates the total and average score of + * all students. + */ +public class NestedListBindingApp extends Application { + + private final Random random = new Random(); + private ListView> listView; + private ObservableList> scores; + + @Override + public void start(Stage primaryStage) { + scores = FXCollections.observableArrayList(); + scores.addAll(FXCollections.observableArrayList(80, 90, 85), FXCollections.observableArrayList(70, 75, 60)); + + listView = createListView(); + HBox buttonBox = createButtonBox(); + + Label totalLabel = new Label(); + // Create a binding that calculates the total sum of all scores + NestedListBinding totalSumBinding = new NestedListBinding<>( + scores, + list -> list.stream().flatMapToInt(innerList -> innerList.stream().mapToInt(Number::intValue)).sum() + ); + totalLabel.textProperty().bind(totalSumBinding.asString("Total: %d")); + + Label averageLabel = new Label(); + // Create a binding that calculates the average of all scores + NestedListBinding averageBinding = new NestedListBinding<>( + scores, + list -> list.stream().flatMapToDouble(innerList -> innerList.stream().mapToDouble(Number::doubleValue)).average().orElse(0) + ); + averageLabel.textProperty().bind(averageBinding.asString("Average: %.2f")); + + HBox statsBox = new HBox(20, totalLabel, averageLabel); + VBox root = new VBox(10, listView, statsBox, buttonBox); + root.setPadding(new Insets(15)); + Scene scene = new Scene(root); + + primaryStage.setTitle("Student Score History"); + primaryStage.setScene(scene); + primaryStage.sizeToScene(); + primaryStage.show(); + } + + private HBox createButtonBox() { + // Button to add scores + Button addButton = new Button("Add Scores"); + addButton.setOnAction(e -> scores.add(FXCollections.observableArrayList(randomScore(), randomScore(), randomScore()))); + + // Button to remove selected scores + Button removeButton = new Button("Remove Selected Scores"); + removeButton.disableProperty().bind(listView.getSelectionModel().selectedItemProperty().isNull()); + removeButton.setOnAction(e -> { + ObservableList selected = listView.getSelectionModel().getSelectedItem(); + if (selected != null) scores.remove(selected); + }); + + // Button to update selected scores + Button updateButton = new Button("Update Selected Scores"); + updateButton.disableProperty().bind(listView.getSelectionModel().selectedItemProperty().isNull()); + updateButton.setOnAction(e -> { + ObservableList selected = listView.getSelectionModel().getSelectedItem(); + if (selected != null) { + int itemCount = random.nextInt(1, 6); + selected.setAll(FXCollections.observableArrayList( + random.ints(itemCount, 50, 100).boxed().collect(Collectors.toList()) + )); + listView.refresh(); + } + }); + return new HBox(10, addButton, removeButton, updateButton); + } + + private ListView> createListView() { + ListView> listView = new ListView<>(scores); + listView.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(ObservableList item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + setText(item.stream().map(Object::toString).collect(Collectors.joining(", "))); + } + } + }); + return listView; + } + + private int randomScore() { + return random.nextInt(51) + 50; + } + + public static void main(String[] args) { + launch(args); + } + +} diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/ObservableListBindingApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/ObservableListBindingApp.java new file mode 100644 index 00000000..7dc4de11 --- /dev/null +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/binding/ObservableListBindingApp.java @@ -0,0 +1,88 @@ +package com.dlsc.gemsfx.demo.binding; + +import com.dlsc.gemsfx.binding.ObservableValuesListBinding; +import javafx.application.Application; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +import java.util.Random; + +/** + * This demo shows how to use the {@link ObservableValuesListBinding} class to create a binding that + * calculates the sum and average of a list of observable values. + */ +public class ObservableListBindingApp extends Application { + + private final Random random = new Random(); + + @Override + public void start(Stage primaryStage) { + ObservableList> observableValues = FXCollections.observableArrayList(); + + ListView> listView = new ListView<>(observableValues); + + Label sumLabel = new Label(); + // Create a binding that calculates the sum of all numbers + ObservableValuesListBinding sumBinding = new ObservableValuesListBinding<>( + observableValues, + list -> "Sum: " + list.stream().mapToInt(Number::intValue).sum() + ); + sumLabel.textProperty().bind(sumBinding); + + Label averageLabel = new Label(); + // Create a binding that calculates the average of all numbers + ObservableValuesListBinding averageBinding = new ObservableValuesListBinding<>( + observableValues, + list -> "Average: " + String.format("%.2f", list.stream().mapToInt(Number::intValue).average().orElse(0)) + ); + averageLabel.textProperty().bind(averageBinding); + + // Layout setup + HBox statsBox = new HBox(20, sumLabel, averageLabel); + VBox root = new VBox(10, listView, statsBox, createButtons(observableValues, listView)); + root.setPadding(new javafx.geometry.Insets(15)); + Scene scene = new Scene(root); + + primaryStage.setTitle("Observable Values List View Demo"); + primaryStage.setScene(scene); + primaryStage.sizeToScene(); + primaryStage.show(); + } + + private HBox createButtons(ObservableList> observableValues, ListView> listView) { + Button addButton = new Button("Add Random Number"); + addButton.setOnAction(e -> observableValues.add(new SimpleIntegerProperty(random.nextInt(100) + 1))); + + Button updateButton = new Button("Update Selected Number"); + updateButton.disableProperty().bind(listView.getSelectionModel().selectedItemProperty().isNull()); + updateButton.setOnAction(e -> { + ObservableValue selected = listView.getSelectionModel().getSelectedItem(); + if (selected != null) { + ((SimpleIntegerProperty) selected).set(random.nextInt(100) + 1); + } + }); + + Button removeButton = new Button("Remove Selected Number"); + removeButton.disableProperty().bind(listView.getSelectionModel().selectedItemProperty().isNull()); + removeButton.setOnAction(e -> { + ObservableValue selected = listView.getSelectionModel().getSelectedItem(); + if (selected != null) { + observableValues.remove(selected); + } + }); + return new HBox(10, addButton, updateButton, removeButton); + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/gemsfx-demo/src/main/java/module-info.java b/gemsfx-demo/src/main/java/module-info.java index 13d8396a..cb7c00bb 100644 --- a/gemsfx-demo/src/main/java/module-info.java +++ b/gemsfx-demo/src/main/java/module-info.java @@ -16,4 +16,5 @@ requires net.synedra.validatorfx; exports com.dlsc.gemsfx.demo; + exports com.dlsc.gemsfx.demo.binding; } \ No newline at end of file diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/binding/AggregatedListBinding.java b/gemsfx/src/main/java/com/dlsc/gemsfx/binding/AggregatedListBinding.java new file mode 100644 index 00000000..35e1fcc6 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/binding/AggregatedListBinding.java @@ -0,0 +1,107 @@ +package com.dlsc.gemsfx.binding; + +import javafx.beans.binding.ObjectBinding; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.WeakListChangeListener; + +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Binds an {@link ObservableList} of items to a computed value based on the elements of their associated nested {@link ObservableList}s. + * This binding listens for changes not only in the top-level list but also in the nested lists of each item. + * It is useful for aggregating or computing values dynamically as the lists change. + * + * @param the type of the elements in the source list + * @param the type of the elements in the nested lists + * @param the type of the result computed from the nested lists + */ +public class AggregatedListBinding extends ObjectBinding { + + private final Function> itemToListFunction; + private final ObservableList sourceList; + private final Function, R> aggregationFunction; + + private final ListChangeListener nestedListChangeListener = change -> { + while (change.next()) { + if (change.wasAdded() || change.wasRemoved()) { + invalidate(); + break; + } + } + }; + + private final WeakListChangeListener weakNestedListChangeListener = new WeakListChangeListener<>(nestedListChangeListener); + + private final ListChangeListener sourceListChangeListener = change -> { + while (change.next()) { + if (change.wasRemoved()) { + change.getRemoved().forEach(this::safeRemoveListener); + } + if (change.wasAdded()) { + change.getAddedSubList().forEach(this::safeAddListener); + } + invalidate(); + } + }; + + private final WeakListChangeListener weakSourceListChangeListener = new WeakListChangeListener<>(sourceListChangeListener); + + /** + * Constructs a new AggregatedListBinding. + * + * @param source the observable list of source items + * @param itemToListFunction a function to retrieve the observable list from each source item + * @param aggregationFunction a function to compute a result from all elements in the nested lists + */ + public AggregatedListBinding(ObservableList source, final Function> itemToListFunction, Function, R> aggregationFunction) { + this.sourceList = source; + this.itemToListFunction = itemToListFunction; + this.aggregationFunction = aggregationFunction; + + sourceList.stream() + .map(itemToListFunction) + .filter(Objects::nonNull) + .forEach(list -> list.addListener(weakNestedListChangeListener)); + + source.addListener(weakSourceListChangeListener); + } + + private void safeAddListener(T item) { + ObservableList list = itemToListFunction.apply(item); + if (list != null) { + list.addListener(weakNestedListChangeListener); + } + } + + private void safeRemoveListener(T item) { + ObservableList list = itemToListFunction.apply(item); + if (list != null) { + list.removeListener(weakNestedListChangeListener); + } + } + + @Override + protected R computeValue() { + return aggregationFunction.apply( + sourceList.stream() + .map(itemToListFunction) + .filter(Objects::nonNull) + .flatMap(List::stream) + .collect(Collectors.toList()) + ); + } + + @Override + public void dispose() { + sourceList.stream() + .map(itemToListFunction) + .filter(Objects::nonNull) + .forEach(list -> list.removeListener(weakNestedListChangeListener)); + sourceList.removeListener(weakSourceListChangeListener); + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/binding/GeneralAggregatedListBinding.java b/gemsfx/src/main/java/com/dlsc/gemsfx/binding/GeneralAggregatedListBinding.java new file mode 100644 index 00000000..09ef6672 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/binding/GeneralAggregatedListBinding.java @@ -0,0 +1,112 @@ +package com.dlsc.gemsfx.binding; + +import javafx.beans.binding.ObjectBinding; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.WeakListChangeListener; + +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Represents a generic binding that aggregates results from a nested collection structure. + * It listens to changes in an observable list of type T, and each T element is associated with an observable list of type S. + * The class performs aggregation operations over the lists and produces a final result of type R. + * + * @param the type of elements in the source list + * @param the type of elements in the nested lists + * @param the intermediate aggregation type + * @param the final result type + */ +public class GeneralAggregatedListBinding extends ObjectBinding { + + private final Function> itemToListFunction; + private final ObservableList sourceList; + private final Function, U> aggregationFunction; + private final Function, R> finalAggregationFunction; + + private final ListChangeListener nestedListChangeListener = change -> { + while (change.next()) { + if (change.wasAdded() || change.wasRemoved()) { + invalidate(); + } + } + }; + + private final WeakListChangeListener weakNestedListChangeListener = new WeakListChangeListener<>(nestedListChangeListener); + + private final ListChangeListener sourceListChangeListener = change -> { + while (change.next()) { + if (change.wasRemoved()) { + change.getRemoved().forEach(this::safeRemoveListener); + } + if (change.wasAdded()) { + change.getAddedSubList().forEach(this::safeAddListener); + } + invalidate(); + } + }; + + private final WeakListChangeListener weakSourceListChangeListener = new WeakListChangeListener<>(sourceListChangeListener); + + /** + * Constructs a new GeneralAggregatedListBinding. + * + * @param source the source observable list of T + * @param itemToListFunction a function mapping T to an observable list of S + * @param aggregationFunction a function to aggregate a list of S into U + * @param finalAggregationFunction a function to aggregate a list of U into R + */ + public GeneralAggregatedListBinding(ObservableList source, Function> itemToListFunction, Function, U> aggregationFunction, Function, R> finalAggregationFunction) { + this.sourceList = source; + this.itemToListFunction = itemToListFunction; + this.aggregationFunction = aggregationFunction; + this.finalAggregationFunction = finalAggregationFunction; + + sourceList.stream() + .map(itemToListFunction) + .filter(Objects::nonNull) + .forEach(list -> list.addListener(weakNestedListChangeListener)); + + source.addListener(weakSourceListChangeListener); + bind(source); + } + + private void safeAddListener(T item) { + ObservableList list = itemToListFunction.apply(item); + if (list != null) { + list.addListener(weakNestedListChangeListener); + } + } + + private void safeRemoveListener(T item) { + ObservableList list = itemToListFunction.apply(item); + if (list != null) { + list.removeListener(weakNestedListChangeListener); + } + } + + @Override + protected R computeValue() { + List individualResults = sourceList.stream() + .map(itemToListFunction) + .map(aggregationFunction) + .collect(Collectors.toList()); + + return finalAggregationFunction.apply(individualResults); + } + + @Override + public void dispose() { + sourceList.stream() + .map(itemToListFunction) + .filter(Objects::nonNull) + .forEach(list -> list.removeListener(weakNestedListChangeListener)); + sourceList.removeListener(weakSourceListChangeListener); + unbind(sourceList); + super.dispose(); + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/binding/NestedListBinding.java b/gemsfx/src/main/java/com/dlsc/gemsfx/binding/NestedListBinding.java new file mode 100644 index 00000000..3e8e1eeb --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/binding/NestedListBinding.java @@ -0,0 +1,74 @@ +package com.dlsc.gemsfx.binding; + +import javafx.beans.binding.ObjectBinding; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.WeakListChangeListener; + +import java.util.function.Function; + +/** + * This class represents a binding on an observable list of observable lists. It listens to changes + * within both the outer list and any of the inner lists, and recalculates its value when any change is detected. + * The value is computed based on a transformation function applied to the entire structure of the list. + * + * @param the type of elements within the inner observable lists + * @param the type of the computed value based on the entire nested list structure + */ +public class NestedListBinding extends ObjectBinding { + + private final ObservableList> source; + private final Function>, U> transformer; + private final ListChangeListener innerListChangeListener = change -> invalidate(); + private final WeakListChangeListener weakInnerListChangeListener = new WeakListChangeListener<>(innerListChangeListener); + private final ListChangeListener> outerListChangeListener = change -> { + while (change.next()) { + if (change.wasRemoved()) { + change.getRemoved().forEach(this::safeRemoveListener); + } + if (change.wasAdded()) { + change.getAddedSubList().forEach(this::safeAddListener); + } + } + invalidate(); + }; + private final WeakListChangeListener> weakOuterListChangeListener = new WeakListChangeListener<>(outerListChangeListener); + + /** + * Constructs a new NestedListBinding. + * + * @param source the observable list of observable lists that is the source of the binding + * @param transformer a function that transforms the nested list structure into a computed value of type U + */ + public NestedListBinding(ObservableList> source, Function>, U> transformer) { + this.source = source; + this.transformer = transformer; + + source.forEach(this::safeAddListener); + source.addListener(weakOuterListChangeListener); + } + + private void safeAddListener(ObservableList list) { + if (list != null) { + list.addListener(weakInnerListChangeListener); + } + } + + private void safeRemoveListener(ObservableList list) { + if (list != null) { + list.removeListener(weakInnerListChangeListener); + } + } + + @Override + protected U computeValue() { + return transformer.apply(source); + } + + @Override + public void dispose() { + source.forEach(this::safeRemoveListener); + source.removeListener(weakOuterListChangeListener); + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/binding/ObservableValuesListBinding.java b/gemsfx/src/main/java/com/dlsc/gemsfx/binding/ObservableValuesListBinding.java new file mode 100644 index 00000000..e59d6d56 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/binding/ObservableValuesListBinding.java @@ -0,0 +1,82 @@ +package com.dlsc.gemsfx.binding; + +import javafx.beans.InvalidationListener; +import javafx.beans.WeakInvalidationListener; +import javafx.beans.binding.ObjectBinding; +import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.WeakListChangeListener; + +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * This class binds to an ObservableList of ObservableValue objects and updates its value based on + * the current values of these ObservableValues. It reevaluates its value whenever any of the + * ObservableValues change. The computed value is determined by applying a transformation function + * to the list of current values. + * + * @param the type held by the ObservableValues in the source list + * @param the type of the output value after applying the transformation function + */ +public class ObservableValuesListBinding extends ObjectBinding { + + private final ObservableList> source; + private final Function, U> transformer; + private final InvalidationListener elementInvalidationListener = obs -> invalidate(); + private final WeakInvalidationListener weakElementInvalidationListener = new WeakInvalidationListener(elementInvalidationListener); + private final ListChangeListener> listChangeListener = change -> { + while (change.next()) { + if (change.wasRemoved()) { + change.getRemoved().forEach(this::safeRemoveListener); + } + if (change.wasAdded()) { + change.getAddedSubList().forEach(this::safeAddListener); + } + } + invalidate(); + }; + private final WeakListChangeListener> weakListChangeListener = new WeakListChangeListener<>(listChangeListener); + + /** + * Constructs a new ObservableValuesListBinding. + * + * @param source the observable list of ObservableValue objects that is the source of the binding + * @param transformer a function that transforms the list of current values into a computed value of type U + */ + public ObservableValuesListBinding(ObservableList> source, Function, U> transformer) { + this.source = source; + this.transformer = transformer; + + source.forEach(this::safeAddListener); + + source.addListener(weakListChangeListener); + } + + private void safeAddListener(ObservableValue item) { + if (item != null) { + item.addListener(weakElementInvalidationListener); + } + } + + private void safeRemoveListener(ObservableValue item) { + if (item != null) { + item.removeListener(weakElementInvalidationListener); + } + } + + @Override + protected U computeValue() { + return transformer.apply(source.stream().map(ObservableValue::getValue).collect(Collectors.toCollection(FXCollections::observableArrayList))); + } + + @Override + public void dispose() { + source.forEach(this::safeRemoveListener); + source.removeListener(weakListChangeListener); + super.dispose(); + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/infocenter/InfoCenterView.java b/gemsfx/src/main/java/com/dlsc/gemsfx/infocenter/InfoCenterView.java index 1bf1c929..6d7fa8da 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/infocenter/InfoCenterView.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/infocenter/InfoCenterView.java @@ -16,9 +16,15 @@ import javafx.css.StyleableDoubleProperty; import javafx.css.StyleableProperty; import javafx.css.converter.SizeConverter; +import javafx.scene.Node; import javafx.scene.control.Control; +import javafx.scene.control.Label; import javafx.scene.control.Skin; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; import javafx.util.Duration; +import org.kordamp.ikonli.javafx.FontIcon; +import org.kordamp.ikonli.materialdesign.MaterialDesign; import java.util.ArrayList; import java.util.Collections; @@ -43,6 +49,7 @@ public class InfoCenterView extends Control { private static final Duration DEFAULT_SLIDE_IN_DURATION = Duration.millis(500); private static final boolean DEFAULT_AUTO_OPEN_GROUP = false; private static final boolean DEFAULT_TRANSPARENT = false; + private static final Node DEFAULT_PLACEHOLDER = createDefaultPlaceholder(); private final InvalidationListener updateNotificationsListener = it -> updateNotificationsList(); @@ -365,6 +372,40 @@ public final void setTransparent(boolean transparent) { transparentProperty().set(transparent); } + private ObjectProperty placeholder; + + /** + * A placeholder node that is shown when the info is empty. + * + * @return the placeholder node + */ + public final ObjectProperty placeholderProperty() { + if (placeholder == null) { + placeholder = new SimpleObjectProperty<>(this, "placeholder", DEFAULT_PLACEHOLDER); + } + return placeholder; + } + + public final void setPlaceholder(Node placeholder) { + placeholderProperty().set(placeholder); + } + + public final Node getPlaceholder() { + return placeholder == null ? DEFAULT_PLACEHOLDER : placeholder.get(); + } + + private static StackPane createDefaultPlaceholder() { + FontIcon graphic = new FontIcon(MaterialDesign.MDI_CREATION); + graphic.setIconSize(20); + graphic.setIconColor(Color.WHITE); + + Label noNotifications = new Label("No notifications", graphic); + + StackPane placeholder = new StackPane(noNotifications); + placeholder.getStyleClass().add("default-placeholder"); + return placeholder; + } + private DoubleProperty notificationSpacing; /** diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/InfoCenterViewSkin.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/InfoCenterViewSkin.java index a1a406cf..8320ae2e 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/InfoCenterViewSkin.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/InfoCenterViewSkin.java @@ -1,5 +1,6 @@ package com.dlsc.gemsfx.skins; +import com.dlsc.gemsfx.binding.AggregatedListBinding; import com.dlsc.gemsfx.infocenter.InfoCenterEvent; import com.dlsc.gemsfx.infocenter.InfoCenterView; import com.dlsc.gemsfx.infocenter.Notification; @@ -18,6 +19,7 @@ import javafx.beans.WeakInvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; +import javafx.beans.binding.ObjectBinding; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleDoubleProperty; @@ -264,6 +266,19 @@ public void selectNext() { getChildren().add(mainPane); + AggregatedListBinding, ? extends Notification, Boolean> emptyBinding = new AggregatedListBinding<>( + view.getGroups(), + NotificationGroup::getNotifications, + List::isEmpty + ); + + addPlaceholderIfNotNull(view.getPlaceholder(), emptyBinding); + + view.placeholderProperty().addListener((obs, oldVal, newVal) -> { + removePlaceholderIfNotNull(oldVal); + addPlaceholderIfNotNull(newVal, emptyBinding); + }); + InvalidationListener invalidationListener = (Observable it) -> updateView(); view.getUnmodifiablePinnedGroups().addListener(invalidationListener); view.getUnmodifiableUnpinnedGroups().addListener(invalidationListener); @@ -306,6 +321,23 @@ public void handle(long now) { view.showAllGroupProperty().addListener(it -> updateVisibilities()); } + + private void addPlaceholderIfNotNull(Node placeholder, ObjectBinding emptyBing) { + if (placeholder != null) { + placeholder.managedProperty().bind(emptyBing); + placeholder.visibleProperty().bind(emptyBing); + mainPane.getChildren().add(placeholder); + } + } + + private void removePlaceholderIfNotNull(Node placeholder) { + if (placeholder != null) { + placeholder.managedProperty().unbind(); + placeholder.visibleProperty().unbind(); + mainPane.getChildren().remove(placeholder); + } + } + private void updateVisibilities() { if (getSkinnable().getShowAllGroup() != null) { singleGroupContainer.setVisible(true); diff --git a/gemsfx/src/main/java/module-info.java b/gemsfx/src/main/java/module-info.java index ac638f74..d7fd49df 100644 --- a/gemsfx/src/main/java/module-info.java +++ b/gemsfx/src/main/java/module-info.java @@ -33,6 +33,7 @@ exports com.dlsc.gemsfx.incubator.templatepane; exports com.dlsc.gemsfx.util; exports com.dlsc.gemsfx.skins; + exports com.dlsc.gemsfx.binding; exports com.dlsc.gemsfx.infocenter; exports com.dlsc.gemsfx.treeview; exports com.dlsc.gemsfx.treeview.link; diff --git a/gemsfx/src/main/resources/com/dlsc/gemsfx/infocenter/info-center-view.css b/gemsfx/src/main/resources/com/dlsc/gemsfx/infocenter/info-center-view.css index cc3861d3..e940ce36 100644 --- a/gemsfx/src/main/resources/com/dlsc/gemsfx/infocenter/info-center-view.css +++ b/gemsfx/src/main/resources/com/dlsc/gemsfx/infocenter/info-center-view.css @@ -8,6 +8,24 @@ -fx-padding: 10px 10px 10px 0px; } +.info-center-view > .main-pane > .default-placeholder { + -fx-background-color: rgba(0, 0, 0, .3); + -fx-background-radius: 2px; +} + +.info-center-view > .main-pane > .default-placeholder > .label { + -fx-text-fill: white; + -fx-font-size: 15px; + -fx-font-weight: bold; + -fx-padding: 5px 0; + -fx-graphic-text-gap: 15px; +} + +.info-center-view > .main-pane > .default-placeholder > .label > .ikonli-font-icon { + -fx-icon-size: 20px; + -fx-icon-color: white; +} + .info-center-view .notification-view .default-icon { -fx-icon-size: 42px; -fx-icon-color: -fx-accent;