From 5520446818d65141224889467c04ea48db7c81ce Mon Sep 17 00:00:00 2001 From: leewyatt Date: Sat, 18 May 2024 05:20:59 +0900 Subject: [PATCH 1/3] Add HistoryManager for record management and update SearchField with history support - Introduce HistoryManager class to facilitate the management of historical records. - Implement history management functionality in SearchField to enhance user interaction. - Update SearchTextField with improvements for consistency and performance. --- .../dlsc/gemsfx/demo/HistoryManagerApp.java | 261 +++++++++++++ .../com/dlsc/gemsfx/demo/SearchFieldApp.java | 3 + .../dlsc/gemsfx/demo/SearchTextFieldApp.java | 36 +- .../java/com/dlsc/gemsfx/HistoryButton.java | 297 ++++++++++++++ .../com/dlsc/gemsfx/RemovableListCell.java | 4 +- .../java/com/dlsc/gemsfx/SearchField.java | 275 ++++++++++++- .../java/com/dlsc/gemsfx/SearchTextField.java | 364 ++++-------------- .../com/dlsc/gemsfx/skins/HistoryPopup.java | 215 +++++++++++ .../dlsc/gemsfx/skins/HistoryPopupSkin.java | 129 +++++++ .../skins/SearchTextFieldHistoryPopup.java | 35 -- .../SearchTextFieldHistoryPopupSkin.java | 85 ---- .../com/dlsc/gemsfx/util/HistoryManager.java | 82 ++++ .../util/PreferencesHistoryManager.java | 262 +++++++++++++ .../gemsfx/util/StringHistoryManager.java | 52 +++ .../com/dlsc/gemsfx/history-popup.css | 72 ++++ .../com/dlsc/gemsfx/search-field.css | 38 ++ .../com/dlsc/gemsfx/search-text-field.css | 110 ++---- 17 files changed, 1817 insertions(+), 503 deletions(-) create mode 100644 gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/HistoryManagerApp.java create mode 100644 gemsfx/src/main/java/com/dlsc/gemsfx/HistoryButton.java create mode 100644 gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopup.java create mode 100644 gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopupSkin.java delete mode 100644 gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopup.java delete mode 100644 gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopupSkin.java create mode 100644 gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java create mode 100644 gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java create mode 100644 gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java create mode 100644 gemsfx/src/main/resources/com/dlsc/gemsfx/history-popup.css diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/HistoryManagerApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/HistoryManagerApp.java new file mode 100644 index 00000000..d0f3d221 --- /dev/null +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/HistoryManagerApp.java @@ -0,0 +1,261 @@ +package com.dlsc.gemsfx.demo; + +import com.dlsc.gemsfx.HistoryButton; +import com.dlsc.gemsfx.Spacer; +import com.dlsc.gemsfx.util.HistoryManager; +import com.dlsc.gemsfx.util.PreferencesHistoryManager; +import com.dlsc.gemsfx.util.StringHistoryManager; +import javafx.application.Application; +import javafx.beans.binding.Bindings; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextField; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import javafx.util.StringConverter; +import org.kordamp.ikonli.javafx.FontIcon; +import org.kordamp.ikonli.materialdesign.MaterialDesign; + +import java.util.List; +import java.util.Objects; +import java.util.prefs.Preferences; + +/** + * A demo application that shows how to use {@link HistoryButton} and {@link HistoryManager}. + */ +public class HistoryManagerApp extends Application { + + @Override + public void start(Stage primaryStage) throws Exception { + + TabPane tabPane = new TabPane(); + tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + tabPane.getTabs().addAll( + new Tab("Basic", basicDemo()), + new Tab("Advanced", advancedDemo()), + new Tab("Other", otherDemo()) + ); + + primaryStage.setScene(new Scene(tabPane, 800, 600)); + primaryStage.setTitle("History Manager Demo"); + primaryStage.show(); + } + + private Node basicDemo() { + TextField textField = new TextField(); + StringHistoryManager historyManager = new StringHistoryManager(); + // historyManager.setPreferences(Preferences.userNodeForPackage(HistoryManagerApp.class).node("simpleTextField")); + + HistoryButton historyButton = new HistoryButton<>(textField, historyManager); + historyButton.setConfigureHistoryPopup(historyPopup -> { + // When choosing a history item, replace the text in the text field. + historyPopup.setOnHistoryItemConfirmed(item -> { + if (item != null) { + textField.setText(item); + } + historyPopup.hide(); + }); + }); + + // Add history item to the history when the enter key is pressed. + textField.setOnKeyPressed(e -> { + historyButton.hideHistoryPopup(); + if (e.getCode() == KeyCode.ENTER) { + historyManager.add(textField.getText()); + } + }); + + HBox box = new HBox(5, textField, historyButton); + box.setAlignment(Pos.CENTER); + Label label = new Label(""" + 1. Tips: Press Enter to add the text to the history. + 2. Click the history button to show the history popup. + 3. This is a simple case, since the preferencesKey is not set, it will not be persisted, just saved in memory. + """); + label.setStyle("-fx-text-fill: #666;"); + + VBox vbox = new VBox(50, label, box); + vbox.setAlignment(Pos.CENTER); + return vbox; + } + + /** + * Creates a text field with a history button. + */ + private Node advancedDemo() { + TextField textField = new TextField(); + + StringHistoryManager historyManager = new StringHistoryManager(); + // Tips: You can set the delimiter and preferencesKey when creating, otherwise use the default value. + // PreferencesHistoryManager historyManager = new PreferencesHistoryManager(";", "save-items"); + + // Tips: If you want to persist the history after the application restarts, Please set the preferences. + historyManager.setPreferences(Preferences.userNodeForPackage(HistoryManagerApp.class).node("textField")); + // Optional: Set the maximum history size.default is 30. + historyManager.setMaxHistorySize(10); + // Optional: if the history is empty, set some default values + if (historyManager.getAll().isEmpty()) { + historyManager.set(List.of("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten")); + } + + HistoryButton historyButton = new HistoryButton<>(textField, historyManager); + + // add history item to the history when the enter key is pressed. + textField.setOnKeyPressed(e -> { + historyButton.hideHistoryPopup(); + if (e.getCode() == KeyCode.ENTER) { + historyManager.add(textField.getText()); + } + }); + + // Optional: true means the popup owner will be focused when the history popup is opened. + // historyButton.setFocusPopupOwnerOnOpen(true); + + // Optional: Configure the history popup + historyButton.setConfigureHistoryPopup(historyPopup -> { + + historyPopup.setHistoryPlaceholder(new Label("Tips: No history items available.")); + + historyPopup.setOnHistoryItemConfirmed(item -> { + if (item != null) { + int length = textField.textProperty().getValueSafe().length(); + textField.replaceText(0, length, item); + } + historyPopup.hide(); + }); + + // create the left node; + VBox leftBox = new VBox(); + Label label = new Label("History"); + label.setRotate(90); + Group group = new Group(label); + + Button clearAll = new Button("", new FontIcon(MaterialDesign.MDI_DELETE)); + clearAll.setPadding(new Insets(2, 4, 2, 4)); + clearAll.setOnAction(e -> { + historyManager.clear(); + historyPopup.hide(); + }); + clearAll.managedProperty().bind(clearAll.visibleProperty()); + clearAll.visibleProperty().bind(Bindings.isNotEmpty(historyManager.getAll())); + + leftBox.getChildren().addAll(group, new Spacer(), clearAll); + leftBox.setAlignment(Pos.CENTER); + leftBox.setPadding(new Insets(10, 5, 5, 5)); + leftBox.setPrefWidth(35); + leftBox.setStyle("-fx-background-color: #f4f4f4;"); + historyPopup.setLeft(leftBox); + } + ); + + + HBox box = new HBox(5, textField, historyButton); + box.setAlignment(Pos.CENTER); + + Label label = new Label(""" + 1. Tips: Press Enter to add the text to the history. + 2. Click the history button to show the history popup. + """); + label.setStyle("-fx-text-fill: #666;"); + + VBox vbox = new VBox(50, label, box); + vbox.setAlignment(Pos.CENTER); + return vbox; + } + + /** + * Creates a list view with a history button. + */ + private Node otherDemo() { + ListView listView = new ListView<>(); + listView.getItems().addAll( + new Student("John", 90), + new Student("Doe", 95), + new Student("Jane", 85), + new Student("Smith", 92), + new Student("Alice", 72), + new Student("Bob", 68), + new Student("Eve", 91), + new Student("Mallory", 66), + new Student("Charlie", 79), + new Student("David", 83) + ); + + PreferencesHistoryManager historyManager = new PreferencesHistoryManager<>( + new StringConverter<>() { + @Override + public String toString(Student object) { + return object.name() + " : " + object.score(); + } + + @Override + public Student fromString(String string) { + String[] parts = string.split(" : "); + return new Student(parts[0], Integer.parseInt(parts[1])); + } + } + ); + + listView.setOnMouseClicked(e -> { + if (e.getClickCount() == 2) { + historyManager.add(listView.getSelectionModel().getSelectedItem()); + } + }); + + historyManager.setPreferences(Preferences.userNodeForPackage(HistoryManagerApp.class).node("list")); + + HistoryButton historyButton = new HistoryButton<>(null, historyManager); + historyButton.setText("History"); + + historyButton.setConfigureHistoryPopup(historyPopup -> { + historyPopup.setOnHistoryItemConfirmed(item -> { + if (item != null) { + listView.getSelectionModel().select(item); + } + historyPopup.hide(); + }); + }); + + Label label = new Label(""" + 1. Tips: Double-click the item to add it to the history. + 2. Click the history button to show the history popup. + 3. Click the item in the history popup to select it in the list view. + """); + label.setStyle("-fx-text-fill: #666;"); + + VBox vBox = new VBox(15, label, listView, historyButton); + vBox.setAlignment(Pos.CENTER); + vBox.setPadding(new Insets(30)); + return vBox; + } + + public record Student(String name, int score) { + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Student student = (Student) o; + return score == student.score && Objects.equals(name, student.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, score); + } + } + + public static void main(String[] args) { + launch(args); + } + +} diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchFieldApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchFieldApp.java index a3a47522..f8ba5f02 100644 --- a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchFieldApp.java +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchFieldApp.java @@ -18,6 +18,7 @@ import java.util.Comparator; import java.util.List; import java.util.StringTokenizer; +import java.util.prefs.Preferences; import java.util.stream.Collectors; public class SearchFieldApp extends Application { @@ -122,6 +123,8 @@ public Country fromString(String string) { setMatcher((broker, searchText) -> broker.getName().toLowerCase().startsWith(searchText.toLowerCase())); setComparator(Comparator.comparing(Country::getName)); getEditor().setPromptText("Start typing country name ..."); + // If not setPreferences() history records are only stored temporarily in memory and are not persisted locally. + getHistoryManager().setPreferences(Preferences.userNodeForPackage(SearchFieldApp.class).node("field")); } } diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchTextFieldApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchTextFieldApp.java index df4794df..1487cb9e 100644 --- a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchTextFieldApp.java +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchTextFieldApp.java @@ -1,6 +1,7 @@ package com.dlsc.gemsfx.demo; import com.dlsc.gemsfx.SearchTextField; +import com.dlsc.gemsfx.util.StringHistoryManager; import javafx.application.Application; import javafx.beans.binding.Bindings; import javafx.geometry.Insets; @@ -21,20 +22,21 @@ public class SearchTextFieldApp extends Application { - private SearchTextField field1; - @Override public void start(Stage primaryStage) throws Exception { - field1 = new SearchTextField(); - field1.setPreferences(Preferences.userNodeForPackage(SearchTextFieldApp.class).node("field1")); + SearchTextField field1 = new SearchTextField(); + StringHistoryManager historyManager1 = field1.getHistoryManager(); + historyManager1.setPreferences(Preferences.userNodeForPackage(SearchTextFieldApp.class).node("field1")); SearchTextField field2 = new SearchTextField(true); - field2.setPreferences(Preferences.userNodeForPackage(SearchTextFieldApp.class).node("field2")); + StringHistoryManager historyManager2 = field2.getHistoryManager(); + historyManager2.setPreferences(Preferences.userNodeForPackage(SearchTextFieldApp.class).node("field2")); Label label = new Label("Max History Size:"); Spinner maxHistorySizeSpinner = new Spinner<>(5, 50, 10, 5); - field1.maxHistorySizeProperty().bind(maxHistorySizeSpinner.valueProperty()); + historyManager1.maxHistorySizeProperty().bind(maxHistorySizeSpinner.valueProperty()); + historyManager2.maxHistorySizeProperty().bind(maxHistorySizeSpinner.valueProperty()); maxHistorySizeSpinner.setMaxWidth(Double.MAX_VALUE); HBox maxHistorySizeBox = new HBox(5, label, maxHistorySizeSpinner); maxHistorySizeBox.setAlignment(Pos.CENTER_LEFT); @@ -58,25 +60,25 @@ public void start(Stage primaryStage) throws Exception { setHistoryButton.setMaxWidth(Double.MAX_VALUE); setHistoryButton.setOnAction(e -> { List list = List.of("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"); - field1.setHistory(list); - field2.setHistory(list); + historyManager1.set(list); + historyManager2.set(list); }); Button addHistoryButton = new Button("Add History"); addHistoryButton.setMaxWidth(Double.MAX_VALUE); addHistoryButton.setOnAction(e -> { - field1.addHistory("New " + LocalTime.now()); - field2.addHistory("New" + LocalTime.now()); + historyManager1.add("New " + LocalTime.now()); + historyManager2.add("New" + LocalTime.now()); }); - Button removeStandardHistoryButton = createRemoveHistoryButton("Standard Field Remove First History Item", field1); - Button removeRoundHistoryButton = createRemoveHistoryButton("Round Field Remove First History Item", field2); + Button removeStandardHistoryButton = createRemoveHistoryButton("Standard Field Remove First History Item", historyManager1); + Button removeRoundHistoryButton = createRemoveHistoryButton("Round Field Remove First History Item", historyManager2); Button clearButton = new Button("Clear History"); clearButton.setMaxWidth(Double.MAX_VALUE); clearButton.setOnAction(e -> { - field1.clearHistory(); - field2.clearHistory(); + historyManager1.clear(); + historyManager2.clear(); }); VBox vbox = new VBox(20, new Label("Standard"), field1, new Label("Round"), field2, @@ -92,11 +94,11 @@ public void start(Stage primaryStage) throws Exception { primaryStage.show(); } - private Button createRemoveHistoryButton(String text, SearchTextField field) { + private Button createRemoveHistoryButton(String text, StringHistoryManager historyManager) { Button removeHistoryButton2 = new Button(text); - removeHistoryButton2.disableProperty().bind(Bindings.createObjectBinding(() -> field.getUnmodifiableHistory().isEmpty(), field.getUnmodifiableHistory())); + removeHistoryButton2.disableProperty().bind(Bindings.createObjectBinding(() -> historyManager.getAll().isEmpty(), historyManager.getAll())); removeHistoryButton2.setMaxWidth(Double.MAX_VALUE); - removeHistoryButton2.setOnAction(e -> field.removeHistory(field.getUnmodifiableHistory().get(0))); + removeHistoryButton2.setOnAction(e -> historyManager.remove(historyManager.getAll().get(0))); return removeHistoryButton2; } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/HistoryButton.java b/gemsfx/src/main/java/com/dlsc/gemsfx/HistoryButton.java new file mode 100644 index 00000000..b20f2a86 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/HistoryButton.java @@ -0,0 +1,297 @@ +package com.dlsc.gemsfx; + +import com.dlsc.gemsfx.skins.HistoryPopup; +import com.dlsc.gemsfx.util.HistoryManager; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.css.CssMetaData; +import javafx.css.PseudoClass; +import javafx.css.Styleable; +import javafx.css.StyleableBooleanProperty; +import javafx.css.StyleableProperty; +import javafx.css.converter.BooleanConverter; +import javafx.event.ActionEvent; +import javafx.scene.control.Button; +import javafx.scene.layout.Region; +import org.kordamp.ikonli.javafx.FontIcon; +import org.kordamp.ikonli.materialdesign.MaterialDesign; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +/** + * A custom button that manages and displays a history of entries based on a generic type {@code T}. + * This button integrates directly with a {@link HistoryManager} to provide a UI component that allows users + * to interact with their input history through a popup. + * + *

The button can be configured to manage the focus behavior of the popup when it is opened, + * enabling the popup owner to gain focus immediately. This functionality is useful for ensuring + * that the popup behaves intuitively in various UI contexts.

+ * + *

Additional customization includes enabling or disabling the history popup, and configuring + * how the history items are displayed and managed within the popup. The button's style and behavior + * can be extensively customized through CSS properties and dynamic property bindings.

+ * + *

Usage scenarios include search fields, form inputs, or any other component where users might benefit + * from being able to see and interact with their previous entries. The generic type {@code T} allows for + * flexibility, making it suitable for various data types that can represent user input history.

+ * + *

Key Features:

+ *
    + *
  • Direct integration with a {@link HistoryManager} to manage history data.
  • + *
  • Customizable focus behavior on popup opening.
  • + *
  • Support for CSS styling and dynamic properties to control visual and functional aspects.
  • + *
  • Optional configuration for history management operations such as adding or removing items.
  • + *
+ * + * @param the type of the objects that this button manages in its history + */ +public class HistoryButton extends Button { + + private static final String DEFAULT_STYLE_CLASS = "history-button"; + private static final boolean ENABLE_HISTORY_POPUP = true; + private static final boolean DEFAULT_FOCUS_POPUP_OWNER_ON_OPEN = false; + + private static final PseudoClass DISABLED_POPUP_PSEUDO_CLASS = PseudoClass.getPseudoClass("disabled-popup"); + private static final PseudoClass HISTORY_POPUP_SHOWING_PSEUDO_CLASS = PseudoClass.getPseudoClass("history-popup-showing"); + + private final Region popupOwner; + private final HistoryManager historyManager; + private HistoryPopup historyPopup; + + /** + * Creates a new instance of the history button. + * + * @param popupOwner The owner of the popup. can be null. if null, the button will be the popup owner. + * @param historyManager The history manager. + */ + public HistoryButton(Region popupOwner, HistoryManager historyManager) { + getStyleClass().addAll(DEFAULT_STYLE_CLASS); + this.popupOwner = popupOwner; + this.historyManager = historyManager; + + setGraphic(new FontIcon(MaterialDesign.MDI_HISTORY)); + setOnAction(this::onActionHandler); + } + + protected void onActionHandler(ActionEvent event) { + Region popupOwner = getPopupOwner(); + HistoryManager historyManager = getHistoryManager(); + + if (popupOwner != null && popupOwner != this && !popupOwner.isFocused() && getFocusPopupOwnerOnOpen()) { + popupOwner.requestFocus(); + } + + if (!isEnableHistoryPopup()) { + return; + } + + if (historyPopup == null) { + historyPopup = new HistoryPopup<>(historyManager); + // basic settings + historyPopup.setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> historyManager.remove(item))); + historyPopupShowing.bind(historyPopup.showingProperty()); + + // Set up the popup + if (getConfigureHistoryPopup() != null) { + getConfigureHistoryPopup().accept(historyPopup); + } + } + if (historyPopup.isShowing()) { + historyPopup.hide(); + } else { + historyPopup.show(popupOwner == null ? this : popupOwner); + } + } + + private BooleanProperty focusPopupOwnerOnOpen; + + /** + * Controls whether the Popup Owner should gain focus when the popup is displayed after a button click. + *

+ * This property determines whether the Popup Owner, which is the reference component for the popup's position, + * should receive focus when the popup is opened. If set to true, the Popup Owner will be focused + * when the popup becomes visible. If set to false, the Popup Owner will retain its current focus state. + *

+ * The default value is false. + * + * @return the BooleanProperty that enables or disables focus on the Popup Owner when the popup opens + */ + public final BooleanProperty focusPopupOwnerOnOpenProperty() { + if (focusPopupOwnerOnOpen == null) { + focusPopupOwnerOnOpen = new StyleableBooleanProperty(DEFAULT_FOCUS_POPUP_OWNER_ON_OPEN) { + @Override + public Object getBean() { + return this; + } + + @Override + public String getName() { + return "popupOwnerFocusOnClick"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.FOCUS_POPUP_OWNER_ON_OPEN; + } + }; + } + return focusPopupOwnerOnOpen; + } + + public final boolean getFocusPopupOwnerOnOpen() { + return focusPopupOwnerOnOpen == null ? DEFAULT_FOCUS_POPUP_OWNER_ON_OPEN : focusPopupOwnerOnOpen.get(); + } + + public final void setFocusPopupOwnerOnOpen(boolean focusPopupOwnerOnOpen) { + focusPopupOwnerOnOpenProperty().set(focusPopupOwnerOnOpen); + } + + private ObjectProperty>> configureHistoryPopup; + + public final Consumer> getConfigureHistoryPopup() { + return configureHistoryPopup == null ? null : configureHistoryPopup.get(); + } + + public final ObjectProperty>> configureHistoryPopupProperty() { + if (configureHistoryPopup == null) { + configureHistoryPopup = new SimpleObjectProperty<>(this, "configureHistoryPopup"); + } + return configureHistoryPopup; + } + + public final void setConfigureHistoryPopup(Consumer> configureHistoryPopup) { + configureHistoryPopupProperty().set(configureHistoryPopup); + } + + private BooleanProperty enableHistoryPopup; + + /** + * Indicates whether the history popup should be enabled. + * + * @return true if the history popup should be enabled, false otherwise + */ + public final BooleanProperty enableHistoryPopupProperty() { + if (enableHistoryPopup == null) { + enableHistoryPopup = new SimpleBooleanProperty(this, "enableHistoryPopup", ENABLE_HISTORY_POPUP) { + @Override + protected void invalidated() { + pseudoClassStateChanged(DISABLED_POPUP_PSEUDO_CLASS, !get()); + } + }; + } + return enableHistoryPopup; + } + + public final boolean isEnableHistoryPopup() { + return enableHistoryPopup == null ? ENABLE_HISTORY_POPUP : enableHistoryPopup.get(); + } + + public final void setEnableHistoryPopup(boolean enableHistoryPopup) { + enableHistoryPopupProperty().set(enableHistoryPopup); + } + + private final ReadOnlyBooleanWrapper historyPopupShowing = new ReadOnlyBooleanWrapper(this, "historyPopupShowing") { + @Override + protected void invalidated() { + pseudoClassStateChanged(HISTORY_POPUP_SHOWING_PSEUDO_CLASS, get()); + } + }; + + public final boolean isHistoryPopupShowing() { + return historyPopupShowing.get(); + } + + /** + * Indicates whether the history popup is showing. This is a read-only property. + * + * @return true if the history popup is showing, false otherwise + */ + public final ReadOnlyBooleanProperty historyPopupShowingProperty() { + return historyPopupShowing.getReadOnlyProperty(); + } + + private static class StyleableProperties { + + private static final CssMetaData FOCUS_POPUP_OWNER_ON_OPEN = new CssMetaData<>( + "-fx-focus-popup-owner-on-open", BooleanConverter.getInstance(), DEFAULT_FOCUS_POPUP_OWNER_ON_OPEN) { + + @Override + public StyleableProperty getStyleableProperty(HistoryButton control) { + return (StyleableProperty) control.focusPopupOwnerOnOpenProperty(); + } + + @Override + public boolean isSettable(HistoryButton control) { + return control.focusPopupOwnerOnOpen == null || !control.focusPopupOwnerOnOpen.isBound(); + } + }; + + private static final List> STYLEABLES; + + static { + final List> styleables = new ArrayList<>(Button.getClassCssMetaData()); + styleables.add(FOCUS_POPUP_OWNER_ON_OPEN); + STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + @Override + public List> getControlCssMetaData() { + return getClassCssMetaData(); + } + + public static List> getClassCssMetaData() { + return HistoryButton.StyleableProperties.STYLEABLES; + } + + /** + * Returns the HistoryManager instance used by this control. + * + * @return the history manager + */ + public final HistoryManager getHistoryManager() { + return historyManager; + } + + /** + * Returns the popup owner node. + * + * @return the popup owner node + */ + public final Region getPopupOwner() { + return popupOwner; + } + + /** + * Returns the history popup. + */ + public final HistoryPopup getHistoryPopup() { + return historyPopup; + } + + /** + * Hides the history popup. + */ + public final void hideHistoryPopup() { + if (historyPopup != null) { + historyPopup.hide(); + } + } + + /** + * Shows the history popup. + */ + public final void showHistoryPopup() { + if (historyPopup != null) { + historyPopup.show(popupOwner == null ? this : popupOwner); + } + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/RemovableListCell.java b/gemsfx/src/main/java/com/dlsc/gemsfx/RemovableListCell.java index 5e377873..3107311d 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/RemovableListCell.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/RemovableListCell.java @@ -32,9 +32,10 @@ public RemovableListCell() { label = new Label(); + setPrefWidth(0); StackPane removeBtn = new StackPane(new FontIcon(MaterialDesign.MDI_CLOSE)); removeBtn.getStyleClass().add("remove-button"); - removeBtn.setOnMouseClicked(this::onRemoveAction); + removeBtn.addEventHandler(MouseEvent.MOUSE_PRESSED, this::onRemoveAction); containerBox = new HBox(label, new Spacer(), removeBtn); containerBox.getStyleClass().add("container-box"); @@ -64,6 +65,7 @@ protected void updateItem(T item, boolean empty) { } public void onRemoveAction(MouseEvent event) { + event.consume(); if (getOnRemove() != null) { // clear selection if the item is selected diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchField.java b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchField.java index cfbead3d..179889ca 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchField.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchField.java @@ -2,6 +2,7 @@ import com.dlsc.gemsfx.skins.SearchFieldPopup; import com.dlsc.gemsfx.skins.SearchFieldSkin; +import com.dlsc.gemsfx.util.StringHistoryManager; import javafx.animation.Animation; import javafx.animation.RotateTransition; import javafx.beans.binding.Bindings; @@ -23,9 +24,11 @@ import javafx.collections.ObservableList; import javafx.concurrent.Service; import javafx.concurrent.Task; +import javafx.css.PseudoClass; import javafx.event.Event; import javafx.event.EventHandler; import javafx.event.EventType; +import javafx.scene.Group; import javafx.scene.Node; import javafx.scene.control.ContentDisplay; import javafx.scene.control.Control; @@ -37,6 +40,8 @@ import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyEvent; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; import javafx.util.Callback; @@ -78,6 +83,14 @@ *

  • Comparator - a standard comparator used to perform a first sorting of the suggested items. However, internally the field wraps this comparator to place some items higher up in the dropdown list as they are better matches for the current search text.
  • * * + *

    + * History management is enabled by default and can be accessed and controlled through a history button integrated into the search field. + * Users can interact with their search history, revisit previous queries, or clear historical entries. The history functionality is managed by + * a {@link StringHistoryManager}, accessible via {@code getHistoryManager()}, allowing programmatic manipulation of history records. + * If {@code setPreferences(Preferences preferences)} is not set on the {@link StringHistoryManager}, history records are only stored temporarily in memory + * and are not persisted locally. This means that history data will not be retained after the application is closed. + *

    + * * @param the type of objects to work on * @see #setSuggestionProvider(Callback) * @see #setConverter(StringConverter) @@ -90,27 +103,64 @@ public class SearchField extends Control { private static final String DEFAULT_STYLE_CLASS = "search-field"; + private static final boolean DEFAULT_ENABLE_HISTORY_POPUP = true; + private static final boolean DEFAULT_ADDING_ITEM_TO_HISTORY_ON_ENTER = true; + private static final boolean DEFAULT_ADDING_ITEM_TO_HISTORY_ON_COMMIT = true; + private static final boolean DEFAULT_ADDING_ITEM_TO_HISTORY_ON_FOCUS_LOST = true; + + private static final PseudoClass DISABLED_POPUP_PSEUDO_CLASS = PseudoClass.getPseudoClass("disabled-popup"); + private final SearchService searchService = new SearchService(); + private final StringHistoryManager historyManager; private final TextField editor = new TextField(); private final SearchFieldPopup popup; + private final HistoryButton historyButton; /** * Constructs a new spotlight field. The field will set defaults for the * matcher, the converter, the cell factory, and the comparator. It will * not set a default for the "new item" producer. + *

    + * The history manager is initialized with default values. * * @see #setNewItemProducer(Callback) */ public SearchField() { + this(new StringHistoryManager()); + } + + /** + * Constructs a new spotlight field. The field will set defaults for the + * matcher, the converter, the cell factory, and the comparator. It will + * not set a default for the "new item" producer. + *

    + * The history manager is initialized with the given preferences. + * + * @see #setNewItemProducer(Callback) + */ + public SearchField(StringHistoryManager historyManager) { getStyleClass().add(DEFAULT_STYLE_CLASS); + this.historyManager = historyManager; + historyButton = createHistorySupportedButton(); + setGraphic(historyButton); + popup = new SearchFieldPopup<>(this); editor.textProperty().bindBidirectional(textProperty()); editor.promptTextProperty().bindBidirectional(promptTextProperty()); + // history listCell factory + setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> historyManager.remove(item))); + + // history listView placeholder + Label placeholder = new Label("No history available."); + placeholder.getStyleClass().add("history-placeholder"); + setHistoryPlaceholder(placeholder); + + // suggestion listView placeholder setPlaceholder(new Label("No items found")); focusedProperty().addListener(it -> { @@ -128,6 +178,10 @@ public SearchField() { } if (!editor.isFocused()) { + // Add the current text to the history if the editor lost focus. + if (isAddingItemToHistoryOnFocusLost()) { + historyManager.add(editor.getText()); + } commit(); if (getSelectedItem() == null) { editor.setText(""); @@ -138,13 +192,30 @@ public SearchField() { }); addEventFilter(KeyEvent.KEY_RELEASED, evt -> { - if (evt.getCode().equals(KeyCode.RIGHT) || evt.getCode().equals(KeyCode.ENTER)) { + KeyCode keyCode = evt.getCode(); + + // record the history popup showing status before hide. + boolean lastHistoryPopupShowing = historyButton.isHistoryPopupShowing(); + + // On key pressed, hide the history popup if the user pressed keys other than UP or DOWN. + if (keyCode != KeyCode.UP && keyCode != KeyCode.DOWN) { + historyButton.hideHistoryPopup(); + } + + boolean releasedEnter = keyCode.equals(KeyCode.ENTER); + // Add the current text to the history if the user pressed the ENTER key. + if (releasedEnter && isAddingItemToHistoryOnEnter() && !lastHistoryPopupShowing) { + historyManager.add(editor.getText()); + } + + if ((keyCode.equals(KeyCode.RIGHT) || releasedEnter) && !lastHistoryPopupShowing) { commit(); evt.consume(); invokeCommitHandler(); - } else if (evt.getCode().equals(KeyCode.LEFT)) { + } else if (keyCode.equals(KeyCode.LEFT)) { editor.positionCaret(Math.max(0, editor.getCaretPosition() - 1)); - } else if (evt.getCode().equals(KeyCode.ESCAPE)) { + } else if (keyCode.equals(KeyCode.ESCAPE)) { + historyButton.hideHistoryPopup(); cancel(); evt.consume(); } else if (KeyCombination.keyCombination("shortcut+a").match(evt)) { @@ -279,6 +350,46 @@ public T fromString(String s) { searching.bind(searchService.runningProperty()); } + private void onHistoryItemConfirmed(String historyItem) { + if (historyItem != null) { + int oldLen = editor.textProperty().getValueSafe().length(); + editor.replaceText(0, oldLen, historyItem); + } + historyButton.hideHistoryPopup(); + } + + private HistoryButton createHistorySupportedButton() { + HistoryButton historyButton = new HistoryButton(this, historyManager); + + // Create the graphic + Region graphic = new Region(); + graphic.getStyleClass().add("icon"); + historyButton.setGraphic(graphic); + + // Configure the history button + historyButton.setFocusTraversable(false); + historyButton.setFocusPopupOwnerOnOpen(true); + historyButton.enableHistoryPopupProperty().bind(enableHistoryPopupProperty()); + + historyButton.setConfigureHistoryPopup(historyPopup -> { + + historyPopup.maxWidthProperty().bind(this.widthProperty()); + historyPopup.historyPlaceholderProperty().bind(historyPlaceholderProperty()); + historyPopup.historyCellFactoryProperty().bind(historyCellFactoryProperty()); + historyPopup.setOnHistoryItemConfirmed(this::onHistoryItemConfirmed); + + Label label = new Label("Search History"); + label.setRotate(90); + Group group = new Group(label); + + VBox left = new VBox(group); + left.getStyleClass().add("popup-left"); + historyPopup.setLeft(left); + }); + + return historyButton; + } + private void invokeCommitHandler() { T selectedItem = getSelectedItem(); if (selectedItem != null) { @@ -306,6 +417,11 @@ public void commit() { if (text != null) { editor.setText(text); editor.positionCaret(text.length()); + + // add on commit + if (isAddingItemToHistoryOnCommit()) { + historyManager.add(text); + } } else { clear(); } @@ -1075,6 +1191,149 @@ public final void setShowSearchIcon(boolean showSearchIcon) { this.showSearchIcon.set(showSearchIcon); } + private ObjectProperty historyPlaceholder = new SimpleObjectProperty<>(this, "historyPlaceholder"); + + /** + * Returns the property representing the history placeholder node. + * + * @return the property representing the history placeholder node + */ + public final ObjectProperty historyPlaceholderProperty() { + if (historyPlaceholder == null) { + historyPlaceholder = new SimpleObjectProperty<>(this, "historyPlaceholder"); + } + return historyPlaceholder; + } + + public final Node getHistoryPlaceholder() { + return historyPlaceholder == null ? null : historyPlaceholder.get(); + } + + public final void setHistoryPlaceholder(Node historyPlaceholder) { + historyPlaceholderProperty().set(historyPlaceholder); + } + + private ObjectProperty, ListCell>> historyCellFactory; + + public final Callback, ListCell> getHistoryCellFactory() { + return historyCellFactory == null ? null : historyCellFactory.get(); + } + + /** + * The cell factory for the history popup list view. + * + * @return the cell factory + */ + public final ObjectProperty, ListCell>> historyCellFactoryProperty() { + if (historyCellFactory == null) { + historyCellFactory = new SimpleObjectProperty<>(this, "historyCellFactory"); + } + return historyCellFactory; + } + + public final void setHistoryCellFactory(Callback, ListCell> historyCellFactory) { + historyCellFactoryProperty().set(historyCellFactory); + } + + // add on enter + + private BooleanProperty addingItemToHistoryOnEnter; + + /** + * Determines whether the text of the text field should be added to the history when the user presses the Enter key. + * + * @return true if the text should be added to the history on Enter, false otherwise + */ + public final BooleanProperty addingItemToHistoryOnEnterProperty() { + if (addingItemToHistoryOnEnter == null) { + addingItemToHistoryOnEnter = new SimpleBooleanProperty(this, "addingItemToHistoryOnEnter", DEFAULT_ADDING_ITEM_TO_HISTORY_ON_ENTER); + } + return addingItemToHistoryOnEnter; + } + + public final boolean isAddingItemToHistoryOnEnter() { + return addingItemToHistoryOnEnter == null ? DEFAULT_ADDING_ITEM_TO_HISTORY_ON_ENTER : addingItemToHistoryOnEnter.get(); + } + + public final void setAddingItemToHistoryOnEnter(boolean addingItemToHistoryOnEnter) { + addingItemToHistoryOnEnterProperty().set(addingItemToHistoryOnEnter); + } + + // add on focus lost + + private BooleanProperty addingItemToHistoryOnFocusLost; + + /** + * Determines whether the text of the text field should be added to the history when the field losses its focus. + * + * @return true if the text should be added to the history on focus lost, false otherwise + */ + public final BooleanProperty addingItemToHistoryOnFocusLostProperty() { + if (addingItemToHistoryOnFocusLost == null) { + addingItemToHistoryOnFocusLost = new SimpleBooleanProperty(this, "addingItemToHistoryOnFocusLost", DEFAULT_ADDING_ITEM_TO_HISTORY_ON_FOCUS_LOST); + } + return addingItemToHistoryOnFocusLost; + } + + public final boolean isAddingItemToHistoryOnFocusLost() { + return addingItemToHistoryOnFocusLost == null ? DEFAULT_ADDING_ITEM_TO_HISTORY_ON_FOCUS_LOST : addingItemToHistoryOnFocusLost.get(); + } + + public final void setAddingItemToHistoryOnFocusLost(boolean addingItemToHistoryOnFocusLost) { + addingItemToHistoryOnFocusLostProperty().set(addingItemToHistoryOnFocusLost); + } + + // add on commit + + private BooleanProperty addingItemToHistoryOnCommit; + + /** + * Determines whether the text of the text field should be added to the history when the user commits to a value. + * + * @return true if the text should be added to the history on commit, false otherwise + */ + public final BooleanProperty addingItemToHistoryOnCommitProperty() { + if (addingItemToHistoryOnCommit == null) { + addingItemToHistoryOnCommit = new SimpleBooleanProperty(this, "addingItemToHistoryOnCommit", DEFAULT_ADDING_ITEM_TO_HISTORY_ON_COMMIT); + } + return addingItemToHistoryOnCommit; + } + + public final boolean isAddingItemToHistoryOnCommit() { + return addingItemToHistoryOnCommit == null ? DEFAULT_ADDING_ITEM_TO_HISTORY_ON_COMMIT : addingItemToHistoryOnCommit.get(); + } + + public final void setAddingItemToHistoryOnCommit(boolean addingItemToHistoryOnCommit) { + addingItemToHistoryOnCommitProperty().set(addingItemToHistoryOnCommit); + } + + private BooleanProperty enableHistoryPopup; + + /** + * Indicates whether the history popup should be enabled. + * + * @return true if the history popup should be enabled, false otherwise + */ + public final BooleanProperty enableHistoryPopupProperty() { + if (enableHistoryPopup == null) { + enableHistoryPopup = new SimpleBooleanProperty(this, "enableHistoryPopup", DEFAULT_ENABLE_HISTORY_POPUP) { + @Override + protected void invalidated() { + pseudoClassStateChanged(DISABLED_POPUP_PSEUDO_CLASS, !get()); + } + }; + } + return enableHistoryPopup; + } + + public final boolean isEnableHistoryPopup() { + return enableHistoryPopup == null ? DEFAULT_ENABLE_HISTORY_POPUP : enableHistoryPopup.get(); + } + + public final void setEnableHistoryPopup(boolean enableHistoryPopup) { + enableHistoryPopupProperty().set(enableHistoryPopup); + } + /** * A custom list cell implementation that is capable of underlining the part * of the text that matches the user-typed search text. The cell uses a text flow @@ -1136,4 +1395,14 @@ protected void updateItem(T item, boolean empty) { public final SearchFieldPopup getPopup() { return popup; } + + /** + * If we want to manually add history records, delete history records, clear history records, then please get the HistoryManager object through this method. + * + * @return the history manager + */ + public final StringHistoryManager getHistoryManager() { + return historyManager; + } + } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java index 84c63de3..da9caf12 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java @@ -1,21 +1,12 @@ package com.dlsc.gemsfx; -import com.dlsc.gemsfx.skins.SearchTextFieldHistoryPopup; -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; +import com.dlsc.gemsfx.util.StringHistoryManager; import javafx.beans.property.BooleanProperty; -import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ReadOnlyBooleanProperty; -import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.event.ActionEvent; -import javafx.event.EventTarget; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.Label; @@ -23,20 +14,14 @@ import javafx.scene.control.ListView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.util.Callback; -import org.apache.commons.lang3.StringUtils; import org.controlsfx.control.textfield.CustomTextField; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign.MaterialDesign; -import java.util.List; import java.util.Objects; -import java.util.logging.Logger; -import java.util.prefs.Preferences; /** * A custom text field specifically designed for search functionality. This class enhances a text field with features @@ -47,44 +32,64 @@ * and / or the {@link #addingItemToHistoryOnEnterProperty()} to false. *
    * Additionally, history can be manually added based on user actions, such as after typing text and selecting an item - * from a ListView or TableView that displays results, or through other interactions, by calling the {@link #addHistory} - * method to add the current text to the history. + * from a ListView or TableView that displays results, or through other interactions, by calling the {@link #getHistoryManager()} + * method to access the {@link StringHistoryManager} instance. then calling the {@link StringHistoryManager#add(String)} method. + * + *

    + * History management is enabled by default and can be accessed and controlled through a history button integrated into the search text field. + * Users can interact with their search history, revisit previous queries, or clear historical entries. The history functionality is managed by + * a {@link StringHistoryManager}, accessible via {@code getHistoryManager()}, allowing programmatic manipulation of history records. + * If {@code setPreferences(Preferences preferences)} is not set on the {@link StringHistoryManager}, history records are only stored temporarily in memory + * and are not persisted locally. This means that history data will not be retained after the application is closed. + *

    + * */ public class SearchTextField extends CustomTextField { - private static final Logger LOG = Logger.getLogger(SearchTextField.class.getName()); - - private static final int DEFAULT_MAX_HISTORY_SIZE = 30; - private static final boolean ENABLE_HISTORY_POPUP = true; + private static final boolean DEFAULT_ENABLE_HISTORY_POPUP = true; private static final boolean DEFAULT_ADDING_ITEM_TO_HISTORY_ON_ENTER = true; private static final boolean DEFAULT_ADDING_ITEM_TO_HISTORY_ON_FOCUS_LOST = true; - /** - * Using Unicode Record Separator as a delimiter. This separator is utilized for separating history items when storing. - * This character is unlikely to be utilized within the history items. - */ - private static final String DELIMITER = "␞"; - private static final PseudoClass DISABLED_POPUP_PSEUDO_CLASS = PseudoClass.getPseudoClass("disabled-popup"); - private static final PseudoClass HISTORY_POPUP_SHOWING_PSEUDO_CLASS = PseudoClass.getPseudoClass("history-popup-showing"); - private SearchTextFieldHistoryPopup historyPopup; - private final StackPane searchIconWrapper; + private final StringHistoryManager historyManager; + private final HistoryButton historyButton; /** * Constructs a new text field customized for search operations. + *

    + * The history manager is initialized with default values. */ public SearchTextField() { - this(false); + this(false, new StringHistoryManager()); + } + + /** + * Constructs a new text field customized for search operations. + */ + public SearchTextField(StringHistoryManager historyManager) { + this(false, historyManager); } /** * Constructs a new text field customized for search operations. The look and feel can be * adjusted to feature rounded corners / sides. + *

    + * The history manager is initialized with default values. * * @param round if true the sides of the field will be round */ public SearchTextField(boolean round) { + this(round, new StringHistoryManager()); + } + + /** + * Constructs a new text field customized for search operations. The look and feel can be + * adjusted to feature rounded corners / sides. + * + * @param round if true the sides of the field will be round + */ + public SearchTextField(boolean round, StringHistoryManager historyManager) { if (round) { getStyleClass().add("round"); } @@ -97,94 +102,73 @@ public SearchTextField(boolean round) { placeholder.getStyleClass().add("default-placeholder"); setHistoryPlaceholder(placeholder); - searchIconWrapper = createLeftNode(); + this.historyManager = historyManager; + setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> historyManager.remove(item))); + + historyButton = createLeftNode(round); + setLeft(historyButton); - setLeft(searchIconWrapper); setRight(createRightNode()); addEventHandlers(); - addPropertyListeners(); - - setHistoryCellFactory(param -> new RemovableListCell<>((listView, item) -> removeHistory(item))); focusedProperty().addListener(it -> { if (!isFocused() && isAddingItemToHistoryOnFocusLost()) { - addHistory(getText()); + historyManager.add(getText()); } + historyButton.hideHistoryPopup(); }); + } - getUnmodifiableHistory().addListener((Observable it) -> { - if (getPreferences() != null) { - storeHistory(); - } - }); + private HistoryButton createLeftNode(boolean round) { + HistoryButton historyButton = new HistoryButton<>(this, historyManager); + + // Create the graphic + Region graphic = new Region(); + graphic.getStyleClass().add("icon"); + historyButton.setGraphic(graphic); - InvalidationListener loadHistoryListener = it -> { - if (getPreferences() != null) { - loadHistory(); + // Configure the history button + historyButton.setFocusTraversable(false); + historyButton.setFocusPopupOwnerOnOpen(true); + historyButton.enableHistoryPopupProperty().bind(enableHistoryPopupProperty()); + historyButton.setConfigureHistoryPopup(historyPopup -> { + if (round) { + historyPopup.getStyleClass().add("round"); } - }; - preferencesProperty().addListener(loadHistoryListener); - } + historyPopup.historyPlaceholderProperty().bind(historyPlaceholderProperty()); + historyPopup.historyCellFactoryProperty().bind(historyCellFactoryProperty()); - private void storeHistory() { - Preferences preferences = getPreferences(); - if (preferences != null) { - preferences.put("history-items", String.join(DELIMITER, getUnmodifiableHistory())); - } - } + historyPopup.setOnHistoryItemConfirmed(history -> { + if (history != null) { + // replace text + int oldTextLen = textProperty().getValueSafe().length(); + replaceText(0, oldTextLen, history); + } - private void loadHistory() { - Preferences preferences = getPreferences(); - if (preferences != null) { - String items = preferences.get("history-items", ""); - if (StringUtils.isNotEmpty(items)) { - history.setAll(items.split(DELIMITER)); - } - // else { history.clear(); } - } + // hide popup + historyPopup.hide(); + }); + }); + + return historyButton; } private void addEventHandlers() { // On Action event, add the text to the history addEventHandler(ActionEvent.ANY, e -> { if (isAddingItemToHistoryOnEnter()) { - addHistory(getText()); + historyManager.add(getText()); } }); - // On mouse pressed, hide the history popup - addEventHandler(MouseEvent.MOUSE_PRESSED, e -> { - EventTarget target = e.getTarget(); - boolean clickHistoryButton = (target instanceof Node && searchIconWrapper.getChildren().contains(target)) || searchIconWrapper.equals(target); - if (!clickHistoryButton) { - hideHistoryPopup(); - } - }); - - // On key pressed, hide the history popup. Consume the UP and DOWN key events. - addEventHandler(KeyEvent.ANY, e -> { + // On key released, hide the history popup if the up or down key is pressed + addEventHandler(KeyEvent.KEY_RELEASED, e -> { if (e.getCode() == KeyCode.UP || e.getCode() == KeyCode.DOWN) { e.consume(); } else { - hideHistoryPopup(); - } - }); - } - - private void addPropertyListeners() { - focusedProperty().addListener(it -> hideHistoryPopup()); - - maxHistorySizeProperty().addListener(it -> { - // Check if the max history size is negative. If so, log a warning. - if (getMaxHistorySize() < 0) { - LOG.warning("Max history size must be greater than or equal to 0. "); - } - - int max = Math.max(0, getMaxHistorySize()); - if (history.size() > max) { - history.remove(max, history.size()); + historyButton.hideHistoryPopup(); } }); } @@ -202,150 +186,9 @@ private StackPane createRightNode() { return clearIconWrapper; } - private StackPane createLeftNode() { - Region searchIcon = new Region(); - searchIcon.getStyleClass().add("search-icon"); - searchIcon.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); - - StackPane searchIconWrapper = new StackPane(searchIcon); - searchIconWrapper.getStyleClass().addAll("wrapper", "search-icon-wrapper"); - searchIconWrapper.setOnMouseClicked(this::clickIconWrapperHandler); - return searchIconWrapper; - } - - /* - * Handles the click event on the icon wrapper of the search text field. - */ - private void clickIconWrapperHandler(MouseEvent event) { - if (!isFocused()) { - requestFocus(); - } - - if (event.getButton() != MouseButton.PRIMARY || !isEnableHistoryPopup()) { - return; - } - - if (historyPopup == null) { - historyPopup = new SearchTextFieldHistoryPopup(this); - historyPopupShowing.bind(historyPopup.showingProperty()); - } - - if (historyPopup.isShowing()) { - historyPopup.hide(); - } else { - historyPopup.show(this); - } - - positionCaret(textProperty().getValueSafe().length()); - } - - private void hideHistoryPopup() { - if (historyPopup != null && historyPopup.isShowing()) { - historyPopup.hide(); - } - } - @Override public String getUserAgentStylesheet() { - return Objects.requireNonNull(SearchTextField.class.getResource("search-text-field.css")).toExternalForm(); - } - - private final ObservableList history = FXCollections.observableArrayList(); - - /** - * Sets the history of the search text field. The given list of Strings will be processed to guarantee unique - * entries. - * - * @param history the list of strings representing the history - */ - public final void setHistory(List history) { - this.history.setAll(convertToUniqueList(history)); - } - - /** - * Adds the given item to the history. The method ensures that duplicates will not be added. - * - * @param item the item to add - */ - public final void addHistory(String item) { - if (StringUtils.isNotEmpty(item)) { - history.remove(item); - history.add(0, item); - } - - int max = Math.max(0, getMaxHistorySize()); - if (history.size() > max) { - history.remove(max, history.size()); - } - } - - /** - * Adds the given items to the history. - * - * @param items the items to add - */ - public final void addHistory(List items) { - List uniqueItems = convertToUniqueList(items); - for (String item : uniqueItems) { - addHistory(item); - } - } - - /** - * Removes the given item from the history. - * - * @param item the item to remove - * @return true if the item was removed, false otherwise - */ - public final boolean removeHistory(String item) { - return history.remove(item); - } - - /** - * Removes the given items from the history. - * - * @param items the items to remove - */ - public final void removeHistory(List items) { - history.removeAll(items); - } - - /** - * Clears the history. - */ - public final void clearHistory() { - history.clear(); - } - - private final ObservableList unmodifiableHistory = FXCollections.unmodifiableObservableList(history); - - /** - * Returns an unmodifiable list of the history. - */ - public final ObservableList getUnmodifiableHistory() { - return unmodifiableHistory; - } - - private IntegerProperty maxHistorySize; - - /** - * Returns the property representing the maximum history size of the search text field. - * - * @return the maximum history size property - */ - public final IntegerProperty maxHistorySizeProperty() { - if (maxHistorySize == null) { - maxHistorySize = new SimpleIntegerProperty(this, "maxHistorySize", DEFAULT_MAX_HISTORY_SIZE); - } - return maxHistorySize; - } - - public final int getMaxHistorySize() { - return maxHistorySize == null ? DEFAULT_MAX_HISTORY_SIZE : maxHistorySize.get(); - } - - public final void setMaxHistorySize(int maxHistorySize) { - maxHistorySizeProperty().set(maxHistorySize); + return Objects.requireNonNull(SearchTextField2.class.getResource("search-text-field.css")).toExternalForm(); } private ObjectProperty historyPlaceholder = new SimpleObjectProperty<>(this, "historyPlaceholder"); @@ -401,7 +244,7 @@ public final void setHistoryCellFactory(Callback, ListCell preferences = new SimpleObjectProperty<>(this, "preferences"); - /** - * Stores a preferences object that will be used for persisting the search history of the field. + * If we want to manually add history records, delete history records, clear history records, then please get the HistoryManager object through this method. * - * @return the preferences used for persisting the search history + * @return the history manager */ - public final ObjectProperty preferencesProperty() { - return preferences; + public StringHistoryManager getHistoryManager() { + return historyManager; } - public final Preferences getPreferences() { - return preferences.get(); - } - - public final void setPreferences(Preferences preferences) { - this.preferences.set(preferences); - } - - /** - * Converts a given list of strings to a unique list of strings. Filters out empty strings. - * - * @param history the list of strings to convert - * @return the converted unique list of strings - */ - private List convertToUniqueList(List history) { - return history.stream().distinct().filter(StringUtils::isNotEmpty).limit(Math.max(0, getMaxHistorySize())).toList(); - } } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopup.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopup.java new file mode 100644 index 00000000..9607e94e --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopup.java @@ -0,0 +1,215 @@ +package com.dlsc.gemsfx.skins; + +import com.dlsc.gemsfx.CustomPopupControl; +import com.dlsc.gemsfx.util.HistoryManager; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.Skin; +import javafx.util.Callback; + +import java.util.function.Consumer; + +/** + * Represents a custom popup control tailored to display and manage history items of type T. + * This control integrates with a {@link HistoryManager} to provide a user interface for viewing, + * selecting, and managing historical entries directly through a popup window. + * + *

    The popup is highly customizable, supporting the addition of custom nodes to its top, bottom, + * left, and right regions. It also allows setting a placeholder for situations where no history items + * are available. The appearance and behavior of the history items can be customized via a cell factory.

    + * + *

    Key features include:

    + *
      + *
    • Automatic binding to a {@link HistoryManager} for dynamic history item management.
    • + *
    • Customizable regions (top, bottom, left, right) for additional UI components or decorations.
    • + *
    • Configurable callbacks for item selection and confirmation actions, enhancing interactive capabilities.
    • + *
    • Support for CSS styling to match the application's design requirements.
    • + *
    + * + * @param the type of the items managed in the history + */ +public class HistoryPopup extends CustomPopupControl { + + public static final String DEFAULT_STYLE_CLASS = "history-popup"; + private final HistoryManager historyManager; + + public HistoryPopup(HistoryManager historyManager) { + this.historyManager = historyManager; + getStyleClass().addAll(DEFAULT_STYLE_CLASS); + + setAutoFix(true); + setAutoHide(true); + setHideOnEscape(true); + + setHistoryPlaceholder(new Label("No history items available.")); + } + + protected Skin createDefaultSkin() { + return new HistoryPopupSkin<>(this); + } + + public final HistoryManager getHistoryManager() { + return historyManager; + } + + private final ObjectProperty left = new SimpleObjectProperty<>(this, "left"); + + public final ObjectProperty leftProperty() { + return left; + } + + public final Node getLeft() { + return leftProperty().get(); + } + + public final void setLeft(Node left) { + leftProperty().set(left); + } + + private final ObjectProperty right = new SimpleObjectProperty<>(this, "right"); + + public final ObjectProperty rightProperty() { + return right; + } + + public final Node getRight() { + return rightProperty().get(); + } + + public final void setRight(Node right) { + rightProperty().set(right); + } + + private final ObjectProperty top = new SimpleObjectProperty<>(this, "top"); + + public final ObjectProperty topProperty() { + return top; + } + + public final Node getTop() { + return topProperty().get(); + } + + public final void setTop(Node top) { + topProperty().set(top); + } + + private final ObjectProperty bottom = new SimpleObjectProperty<>(this, "bottom"); + + public final ObjectProperty bottomProperty() { + return bottom; + } + + public final Node getBottom() { + return bottomProperty().get(); + } + + public final void setBottom(Node bottom) { + bottomProperty().set(bottom); + } + + private ObjectProperty historyPlaceholder = new SimpleObjectProperty<>(this, "historyPlaceholder"); + + /** + * Returns the property representing the history placeholder node. + * + * @return the property representing the history placeholder node + */ + public final ObjectProperty historyPlaceholderProperty() { + if (historyPlaceholder == null) { + historyPlaceholder = new SimpleObjectProperty<>(this, "historyPlaceholder"); + } + return historyPlaceholder; + } + + public final Node getHistoryPlaceholder() { + return historyPlaceholder == null ? null : historyPlaceholder.get(); + } + + public final void setHistoryPlaceholder(Node historyPlaceholder) { + historyPlaceholderProperty().set(historyPlaceholder); + } + + private ObjectProperty, ListCell>> historyCellFactory; + + public final Callback, ListCell> getHistoryCellFactory() { + return historyCellFactory == null ? null : historyCellFactory.get(); + } + + /** + * The cell factory for the history popup list view. + * + * @return the cell factory + */ + public final ObjectProperty, ListCell>> historyCellFactoryProperty() { + if (historyCellFactory == null) { + historyCellFactory = new SimpleObjectProperty<>(this, "historyCellFactory"); + } + return historyCellFactory; + } + + public final void setHistoryCellFactory(Callback, ListCell> historyCellFactory) { + historyCellFactoryProperty().set(historyCellFactory); + } + + private ObjectProperty> onHistoryItemConfirmed; + + public final Consumer getOnHistoryItemConfirmed() { + return onHistoryItemConfirmed == null ? null : onHistoryItemConfirmed.get(); + } + + /** + * Returns the property representing the callback function to be executed + * when a history item within the ListView is either clicked directly or selected via the ENTER key press. + * This property enables setting a custom callback function that will be invoked with the text of the + * clicked or selected history item as the argument. + *

    + * This callback is specifically designed to handle actions that confirm the selection of a history item, + * differentiating it from other interactions such as mere selection or highlighting within the list. + * It is particularly useful for integrating user-initiated actions that imply a final decision on a list item. + *

    + * + * @return the property representing the onHistoryItemConfirmed callback function. + */ + public final ObjectProperty> onHistoryItemConfirmedProperty() { + if (onHistoryItemConfirmed == null) { + onHistoryItemConfirmed = new SimpleObjectProperty<>(this, "onHistoryItemConfirmed"); + } + return onHistoryItemConfirmed; + } + + public final void setOnHistoryItemConfirmed(Consumer onHistoryItemConfirmed) { + onHistoryItemConfirmedProperty().set(onHistoryItemConfirmed); + } + + + private ObjectProperty> onHistoryItemSelected; + + public final Consumer getOnHistoryItemSelected() { + return onHistoryItemSelected == null ? null : onHistoryItemSelected.get(); + } + + /** + * Returns the property representing the callback function to be executed + * when a history item within the ListView is selected. + * This property enables setting a custom callback function that will be invoked with the text of the + * selected history item as the argument. + * + * @return the property representing the onSelectedHistoryItem callback function. + */ + public final ObjectProperty> onHistoryItemSelectedProperty() { + if (onHistoryItemSelected == null) { + onHistoryItemSelected = new SimpleObjectProperty<>(this, "onHistoryItemSelected"); + } + return onHistoryItemSelected; + } + + public final void setOnHistoryItemSelected(Consumer onHistoryItemSelected) { + onHistoryItemSelectedProperty().set(onHistoryItemSelected); + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopupSkin.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopupSkin.java new file mode 100644 index 00000000..37e861d7 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopupSkin.java @@ -0,0 +1,129 @@ +package com.dlsc.gemsfx.skins; + +import com.dlsc.gemsfx.SearchField; +import javafx.beans.binding.Bindings; +import javafx.scene.Node; +import javafx.scene.control.ListView; +import javafx.scene.control.Skin; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; + +import java.util.Objects; +import java.util.Optional; + +/** + * Provides a concrete implementation of a skin for {@link HistoryPopup}, defining the visual representation + * and interaction handling of the popup. This skin layout includes a {@link ListView} that displays the history + * items, which can be interacted with via mouse or keyboard. + * + *

    The skin binds various properties from the {@link HistoryPopup} to configure and customize the layout + * and behavior of the popup elements, including the arrangement of nodes around the central list view (top, + * bottom, left, right).

    + * + *

    Interactions such as mouse clicks and keyboard inputs are handled to select and confirm history items, + * allowing for a seamless user experience. The history items are displayed using a configurable cell factory, + * and the skin reacts to changes in the popup's properties to update the UI accordingly.

    + * + *

    This skin ensures that the popup's visual structure is maintained in alignment with the popup's configuration, + * supporting dynamic changes to the content and layout.

    + * + * @param the type of the items displayed in the history popup + */ +public class HistoryPopupSkin implements Skin> { + + private final HistoryPopup control; + private final BorderPane root; + private final ListView listView; + + public HistoryPopupSkin(HistoryPopup popup) { + this.control = popup; + + root = new BorderPane() { + @Override + public String getUserAgentStylesheet() { + return Objects.requireNonNull(SearchField.class.getResource("history-popup.css")).toExternalForm(); + } + }; + + root.getStyleClass().add("content-pane"); + + listView = createHistoryListView(); + root.setCenter(listView); + + root.leftProperty().bind(control.leftProperty()); + root.rightProperty().bind(control.rightProperty()); + root.topProperty().bind(control.topProperty()); + root.bottomProperty().bind(control.bottomProperty()); + } + + private ListView createHistoryListView() { + ListView listView = new ListView<>(); + listView.getStyleClass().add("history-list-view"); + + Bindings.bindContent(listView.getItems(), control.getHistoryManager().getAll()); + + listView.cellFactoryProperty().bind(control.historyCellFactoryProperty()); + listView.placeholderProperty().bind(control.historyPlaceholderProperty()); + + // handle mouse clicks on the listView item + listView.addEventHandler(MouseEvent.MOUSE_PRESSED, mouseEvent -> { + if (isPrimarySingleClick(mouseEvent) && !mouseEvent.isConsumed()) { + handlerHistoryItemConfirmed(listView); + mouseEvent.consume(); + } + }); + + // handle keyboard events on the listView + listView.addEventFilter(KeyEvent.KEY_RELEASED, keyEvent -> { + if (keyEvent.getCode() == KeyCode.ENTER) { + handlerHistoryItemConfirmed(listView); + keyEvent.consume(); + } + }); + + // handle selection changes + listView.getSelectionModel().selectedItemProperty().addListener(it -> + Optional.ofNullable(control.getOnHistoryItemSelected()) + .ifPresent(onItemSelected -> onItemSelected.accept(listView.getSelectionModel().getSelectedItem())) + ); + + return listView; + } + + private void handlerHistoryItemConfirmed(ListView listView) { + T historyItem = listView.getSelectionModel().getSelectedItem(); + Optional.ofNullable(control.getOnHistoryItemConfirmed()) + .ifPresent(onClickHistoryItem -> onClickHistoryItem.accept(historyItem)); + } + + private boolean isPrimarySingleClick(MouseEvent mouseEvent) { + return mouseEvent.getButton() == MouseButton.PRIMARY && mouseEvent.getClickCount() == 1; + } + + public final ListView getListView() { + return listView; + } + + public Node getNode() { + return root; + } + + public HistoryPopup getSkinnable() { + return control; + } + + public void dispose() { + Bindings.unbindContent(listView.getItems(), control.getHistoryManager().getAll()); + + listView.prefWidthProperty().unbind(); + listView.maxWidthProperty().unbind(); + listView.minWidthProperty().unbind(); + + listView.cellFactoryProperty().unbind(); + listView.placeholderProperty().unbind(); + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopup.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopup.java deleted file mode 100644 index 70ddb62e..00000000 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopup.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.dlsc.gemsfx.skins; - -import com.dlsc.gemsfx.CustomPopupControl; -import com.dlsc.gemsfx.SearchTextField; -import javafx.scene.control.Skin; - -import java.util.Objects; - -public class SearchTextFieldHistoryPopup extends CustomPopupControl { - - public static final String DEFAULT_STYLE_CLASS = "search-text-field-history-popup"; - - private final SearchTextField searchTextField; - - public SearchTextFieldHistoryPopup(SearchTextField searchTextField) { - this.searchTextField = Objects.requireNonNull(searchTextField); - - getStyleClass().add(DEFAULT_STYLE_CLASS); - - maxWidthProperty().bind(searchTextField.widthProperty()); - - setAutoFix(true); - setAutoHide(true); - setHideOnEscape(true); - } - - protected Skin createDefaultSkin() { - return new SearchTextFieldHistoryPopupSkin(this); - } - - public SearchTextField getSearchTextField() { - return searchTextField; - } - -} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopupSkin.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopupSkin.java deleted file mode 100644 index 65620fb3..00000000 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopupSkin.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.dlsc.gemsfx.skins; - -import com.dlsc.gemsfx.SearchField; -import com.dlsc.gemsfx.SearchTextField; -import javafx.beans.binding.Bindings; -import javafx.scene.Node; -import javafx.scene.control.ListView; -import javafx.scene.control.Skin; -import javafx.scene.input.MouseButton; - -import java.util.Objects; - -public class SearchTextFieldHistoryPopupSkin implements Skin { - - private final SearchTextFieldHistoryPopup control; - private final SearchTextField searchTextField; - private ListView listView; - - public SearchTextFieldHistoryPopupSkin(SearchTextFieldHistoryPopup control) { - this.control = control; - searchTextField = control.getSearchTextField(); - - initListView(); - } - - private void initListView() { - listView = new ListView<>() { - @Override - public String getUserAgentStylesheet() { - return Objects.requireNonNull(SearchField.class.getResource("search-text-field.css")).toExternalForm(); - } - }; - listView.getStyleClass().add("search-history-list-view"); - - Bindings.bindContent(listView.getItems(), searchTextField.getUnmodifiableHistory()); - - listView.cellFactoryProperty().bind(searchTextField.historyCellFactoryProperty()); - listView.placeholderProperty().bind(searchTextField.historyPlaceholderProperty()); - - listView.setOnMouseClicked(mouseEvent -> { - if (mouseEvent.getButton() == MouseButton.PRIMARY && mouseEvent.getClickCount() == 1) { - selectHistoryItem(); - } - }); - - listView.setOnKeyPressed(keyEvent -> { - switch (keyEvent.getCode()) { - case ENTER -> selectHistoryItem(); - case ESCAPE -> control.hide(); - } - }); - } - - private void selectHistoryItem() { - String selectedHistory = listView.getSelectionModel().getSelectedItem(); - if (selectedHistory != null) { - // replace text - int oldTextLen = control.getSearchTextField().textProperty().getValueSafe().length(); - searchTextField.replaceText(0, oldTextLen, selectedHistory); - - // hide popup - control.hide(); - } - } - - public Node getNode() { - return listView; - } - - public SearchTextFieldHistoryPopup getSkinnable() { - return control; - } - - public void dispose() { - Bindings.unbindContent(listView.getItems(), searchTextField.getUnmodifiableHistory()); - - listView.prefWidthProperty().unbind(); - listView.maxWidthProperty().unbind(); - listView.minWidthProperty().unbind(); - - listView.cellFactoryProperty().unbind(); - listView.placeholderProperty().unbind(); - } - -} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java new file mode 100644 index 00000000..48794f07 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java @@ -0,0 +1,82 @@ +package com.dlsc.gemsfx.util; + +import javafx.beans.property.IntegerProperty; +import javafx.collections.ObservableList; + +import java.util.List; + +/** + * The HistoryManager interface defines the standard operations to manage history storage + * for any type of items, allowing for implementation of various data storage mechanisms. + * + * @param the type of items stored in the history + */ +public interface HistoryManager { + + /** + * Adds a single item to the history storage. + * If the item already exists, its position is updated. + * + * @param item The history item to be added. + */ + void add(T item); + + /** + * Adds multiple items to the history storage. + * Duplicates in the input list are not added twice. + * + * @param items The list of history items to be added. + */ + void add(List items); + + /** + * Removes a single item from the history storage. + * + * @param item The history item to be removed. + * @return true if the item was successfully removed, false if the item was not found. + */ + boolean remove(T item); + + /** + * Removes multiple items from the history storage. + * + * @param items The list of history items to be removed. + */ + void remove(List items); + + /** + * Clears all items from the history storage. + */ + void clear(); + + /** + * Retrieves all stored history items. + * + * @return A list of all history items. + */ + ObservableList getAll(); + + /** + * Returns the property object for the maximum history size. This property can be + * used to bind the history size limit to UI components or to observe changes. + * + * @return The IntegerProperty representing the maximum number of history items allowed. + */ + IntegerProperty maxHistorySizeProperty(); + + /** + * Gets the current maximum size of the history list. + * + * @return The current maximum number of items that can be stored in the history. + */ + int getMaxHistorySize(); + + /** + * Sets the maximum size of the history list. If the current number of items + * exceeds the specified size, items will be removed from the end of the list. + * + * @param maxHistorySize The maximum number of items to retain in the history. + */ + void setMaxHistorySize(int maxHistorySize); + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java new file mode 100644 index 00000000..a8629353 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java @@ -0,0 +1,262 @@ +package com.dlsc.gemsfx.util; + +import javafx.beans.Observable; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.util.StringConverter; +import org.apache.commons.lang3.StringUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; +import java.util.prefs.Preferences; +import java.util.stream.Collectors; + +/** + * Manages a history of items of type T, storing them in a Java Preferences backend. + * This class supports generic types T, allowing for flexible usage with any object type that + * can be converted to and from a string representation using a provided {@link StringConverter}. + * + *

    The history is maintained in a list that is limited by a specified maximum size. If the limit is exceeded, + * the oldest items are removed. The history management includes functionalities to add, remove, clear, and retrieve + * history items. Changes to the history are automatically persisted to the Preferences store using the specified + * delimiter and preferences key.

    + * + *

    This class is particularly useful for applications needing to maintain a persistent, manageable history + * of user actions or data entries, where entries must be stored across sessions in a simple and effective manner.

    + * + *

    Instances of this class are not thread-safe. If concurrent access is required, it should be managed externally.

    + * + * @param the type of objects managed in the history + * @see HistoryManager + * @see StringConverter + */ +public class PreferencesHistoryManager implements HistoryManager { + + private static final Logger LOG = Logger.getLogger(PreferencesHistoryManager.class.getName()); + + private static final int DEFAULT_MAX_HISTORY_SIZE = 30; + + /** + * Using Unicode Record Separator as delimiter. + * This character is not likely to be used in the history items. + */ + private static final String DEFAULT_DELIMITER = "␞"; + + /** + * Default preferences key used to store history items. + */ + private static final String DEFAULT_PREFERENCES_KEY = "history-items"; + + private final String delimiter; + private final String preferencesKey; + private final StringConverter converter; + + public PreferencesHistoryManager(StringConverter converter) { + this(DEFAULT_DELIMITER, DEFAULT_PREFERENCES_KEY, converter); + } + + public PreferencesHistoryManager(String delimiter, String preferencesKey, StringConverter converter) { + this.delimiter = Objects.requireNonNull(delimiter); + this.preferencesKey = Objects.requireNonNull(preferencesKey); + this.converter = Objects.requireNonNull(converter); + + loadHistory(); + + maxHistorySizeProperty().addListener(it -> { + // Check if the max history size is negative. If so, log a warning. + if (getMaxHistorySize() < 0) { + LOG.warning("Max history size must be greater than or equal to 0. "); + } + trimHistory(); + }); + + unmodifiableHistory.addListener((Observable it) -> storeHistory()); + preferencesProperty().addListener(it -> loadHistory()); + } + + /** + * Stores the history items in the preferences. + */ + private void storeHistory() { + Preferences preferences = getPreferences(); + if (preferences != null) { + String result = unmodifiableHistory.stream() + .map(converter::toString) + .collect(Collectors.joining(delimiter)); + preferences.put(preferencesKey, result); + } + } + + /** + * Loads the history items from the preferences. + */ + private void loadHistory() { + Preferences preferences = getPreferences(); + if (preferences != null) { + String items = preferences.get(preferencesKey, ""); + if (StringUtils.isNotEmpty(items)) { + String[] ary = items.split(delimiter); + Arrays.stream(ary) + .map(converter::fromString) + .forEach(history::add); + } + } + } + + private final ObservableList history = FXCollections.observableArrayList(); + + /** + * Sets the history of the HistoryManager with the provided list of strings. + * The method ensures that duplicates are removed from the list. + * + * @param history the list of strings representing the history + */ + public final void set(List history) { + this.history.setAll(convertToUniqueList(history)); + } + + /** + * Adds the given item to the history. The method ensures that duplicates will not be added. + * + * @param item the item to add + */ + public final void add(T item) { + if (item != null) { + history.remove(item); + history.add(0, item); + trimHistory(); + } + } + + /** + * Adds the given items to the history. + * + * @param items the items to add + */ + public final void add(List items) { + List uniqueItems = convertToUniqueList(items); + if (!uniqueItems.isEmpty()) { + history.removeAll(uniqueItems); + history.addAll(0, uniqueItems); + trimHistory(); + } + } + + /** + * Removes the given item from the history. + * + * @param item the item to remove + * @return true if the item was removed, false otherwise + */ + public final boolean remove(T item) { + return history.remove(item); + } + + /** + * Removes the given items from the history. + * + * @param items the items to remove + */ + public final void remove(List items) { + history.removeAll(items); + } + + /** + * Clears the history. + */ + public final void clear() { + history.clear(); + } + + private final ObservableList unmodifiableHistory = FXCollections.unmodifiableObservableList(history); + + /** + * Returns an unmodifiable list of the history. + */ + public final ObservableList getAll() { + return unmodifiableHistory; + } + + private final IntegerProperty maxHistorySize = new SimpleIntegerProperty(this, "maxHistorySize", DEFAULT_MAX_HISTORY_SIZE); + + /** + * The maximum number of items that the history will store. If the number of items exceeds this value, the oldest + * items will be removed. + * + * @return the maximum number of items in the history + */ + public final IntegerProperty maxHistorySizeProperty() { + return maxHistorySize; + } + + public final int getMaxHistorySize() { + return maxHistorySize.get(); + } + + public final void setMaxHistorySize(int maxHistorySize) { + maxHistorySizeProperty().set(maxHistorySize); + } + + private final ObjectProperty preferences = new SimpleObjectProperty<>(this, "preferences"); + + /** + * Returns the property object representing the preferences used for persisting history records. + * This property can be used to set or get the `Preferences` instance for storing history items. + * + * @return the property object representing the preferences + */ + public final ObjectProperty preferencesProperty() { + return preferences; + } + + public final Preferences getPreferences() { + return preferences.get(); + } + + public final void setPreferences(Preferences preferences) { + this.preferences.set(preferences); + } + + /** + * Trims the history list to ensure it does not exceed the maximum allowed size. + * If the current history size is greater than the maximum size, the method removes + * the extra elements from the history list. + */ + private void trimHistory() { + int max = Math.max(0, getMaxHistorySize()); + if (history.size() > max) { + history.remove(max, history.size()); + } + } + + /** + * Converts a given list of strings to a unique list of strings. Filters out empty strings. + * + * @param history the list of strings to convert + * @return the converted unique list of strings + */ + private List convertToUniqueList(List history) { + return history.stream().distinct().filter(Objects::nonNull).limit(Math.max(0, getMaxHistorySize())).toList(); + } + + /** + * @return the delimiter used to separate history items + */ + public final String getDelimiter() { + return delimiter; + } + + /** + * @return the preferences key used to store history items + */ + public final String getPreferencesKey() { + return preferencesKey; + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java new file mode 100644 index 00000000..21b29f62 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java @@ -0,0 +1,52 @@ +package com.dlsc.gemsfx.util; + +import javafx.util.StringConverter; + +/** + * Manages a history of string records using the Java Preferences API. This class specializes + * the generic {@link PreferencesHistoryManager} for strings, providing simple and efficient + * history management functionalities such as adding, removing, and clearing history items. + *

    + * It is designed for use cases where history items are plain strings, making it ideal for + * applications requiring simple, persistent storage of string data like recent user inputs + * or configurations. This class uses the {@code Preferences} API to persist history records, + * ensuring that they are maintained across application restarts. + *

    + *

    + * Due to the limitations of the {@code Preferences} API, this manager is not suitable for + * scenarios involving large-scale data or complex data structures. It is optimized for + * lightweight history management tasks where history items are stored and retrieved + * straightforwardly without transformation. + *

    + *

    + * Key features include: + * - Persisting history in a simple, local manner using {@code Preferences} + * - Adding items while ensuring uniqueness + * - Removing specific history items or clearing all history + * - Providing a read-only view of history items to external components + * - Observability of updates, suitable for UI integration + *

    + */ +public class StringHistoryManager extends PreferencesHistoryManager { + + private static final StringConverter DEFAULT_STRING_CONVERTER = new StringConverter<>() { + @Override + public String toString(String object) { + return object; + } + + @Override + public String fromString(String string) { + return string; + } + }; + + public StringHistoryManager() { + super(DEFAULT_STRING_CONVERTER); + } + + public StringHistoryManager(String delimiter, String preferencesKey) { + super(delimiter, preferencesKey, DEFAULT_STRING_CONVERTER); + } + +} diff --git a/gemsfx/src/main/resources/com/dlsc/gemsfx/history-popup.css b/gemsfx/src/main/resources/com/dlsc/gemsfx/history-popup.css new file mode 100644 index 00000000..f1a54cd4 --- /dev/null +++ b/gemsfx/src/main/resources/com/dlsc/gemsfx/history-popup.css @@ -0,0 +1,72 @@ +.history-popup.round > .content-pane { + -fx-background-radius: 10px; +} + +.history-popup.round > .content-pane > .history-list-view { + -fx-background-radius: 10px; + -fx-padding: 5px; +} + +.history-popup > .content-pane { + -fx-background-color: linear-gradient(to bottom, + derive(-fx-color, -17%), + derive(-fx-color, -30%) + ), + -fx-control-inner-background; + -fx-background-insets: 0, 1; + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.2), 12, 0.0, 0, 8); + -fx-pref-height: 200px; +} + +.history-popup:above > .content-pane { + -fx-translate-y: -6; +} + +.history-popup > .content-pane .popup-left { + -fx-background-color: linear-gradient(to right, #e0e0e0, #f8f8f8); + -fx-padding: 10px 5px 5px 5px; + -fx-alignment: top-center; +} + +/* ----------------------------------------------------------------------- + * Style based on Modena.css combo-box-popup style + */ +.history-list-view > .virtual-flow > .clipped-container > .sheet > .list-cell { + -fx-padding: 4 0 4 5; + /* No alternate highlighting */ + -fx-background: -fx-control-inner-background; +} + +.history-list-view > .virtual-flow > .clipped-container > .sheet > .list-cell:filled:hover { + -fx-background: -fx-accent; + -fx-background-color: #c9c9c9; +} + +.history-list-view > .virtual-flow > .clipped-container > .sheet > .list-cell:filled:selected, +.history-list-view > .virtual-flow > .clipped-container > .sheet > .list-cell:filled:selected:hover { + -fx-background: -fx-accent; + -fx-background-color: -fx-background, -fx-cell-focus-inner-border, -fx-background; + -fx-background-insets: 0, 1, 2; +} + +.history-list-view > .placeholder > .label { + -fx-text-fill: derive(-fx-control-inner-background, -30%); + -fx-padding: 0 10px; +} + +.history-list-view .search-field-list-cell { +} + +.history-list-view .search-field-list-cell .text { + -fx-fill: -fx-selection-bar-text; +} + +.history-list-view .search-field-list-cell .text.start { +} + +.history-list-view .search-field-list-cell .text.middle { + -fx-underline: true; +} + +.history-list-view .search-field-list-cell .text.end { +} diff --git a/gemsfx/src/main/resources/com/dlsc/gemsfx/search-field.css b/gemsfx/src/main/resources/com/dlsc/gemsfx/search-field.css index 84211d2c..f00a5cc8 100644 --- a/gemsfx/src/main/resources/com/dlsc/gemsfx/search-field.css +++ b/gemsfx/src/main/resources/com/dlsc/gemsfx/search-field.css @@ -13,6 +13,44 @@ -fx-icon-color: -fx-text-background-color; } +.search-field .graphic-wrapper .history-button { + -fx-background-color: transparent; + -fx-padding: 2px 3px; + -fx-background-insets: 0px; + -fx-background-radius: 2px; + -fx-cursor: hand; +} + +.search-field .graphic-wrapper .history-button:hover, +.search-field .graphic-wrapper .history-button:pressed, +.search-field .graphic-wrapper .history-button:history-popup-showing { + -fx-background-color: -fx-base; +} + +.search-field .graphic-wrapper .history-button:disabled-popup, +.search-field .graphic-wrapper .history-button:disabled-popup:hover, +.search-field .graphic-wrapper .history-button:disabled-popup:pressed { + -fx-cursor: text; + -fx-background-color: transparent; +} + +.search-field .graphic-wrapper .history-button:pressed { + -fx-background-color: derive(-fx-base, -10%); +} + +.search-field .graphic-wrapper .history-button > .icon { + -fx-padding: 0.43995em 0.5em; + -fx-max-height: 0.8799em; + -fx-background-color: -fx-text-inner-color; + -fx-shape: "M8,2.2c4.1,0,7.4,3.3,7.4,7.4c0,1.8-0.7,3.5-1.8,4.8l0.3,0.3h0.9l5.7,5.7l-1.7,1.7l-5.7-5.7v-0.9l-0.3-0.3 C11.5,16.3,9.8,17,8,17c-4.1,0-7.4-3.3-7.4-7.4S3.9,2.2,8,2.2 M8,4.5c-2.8,0-5.1,2.3-5.1,5.1s2.3,5.1,5.1,5.1s5.1-2.3,5.1-5.1 S10.9,4.5,8,4.5zM16.2,10.7h7.1l-3.5,6.1"; +} + +.search-field .graphic-wrapper .history-button:disabled-popup > .icon { + -fx-padding: 0.5em; + -fx-max-height: 1em; + -fx-shape: "M9.5,3C13.1,3,16,5.9,16,9.5c0,1.6-0.6,3.1-1.6,4.2l0.3,0.3h0.8l5,5L19,20.5l-5-5v-0.8l-0.3-0.3c-1.1,1-2.6,1.6-4.2,1.6 C5.9,16,3,13.1,3,9.5S5.9,3,9.5,3 M9.5,5C7,5,5,7,5,9.5S7,14,9.5,14S14,12,14,9.5S12,5,9.5,5z"; +} + /* ----------------------------------------------------------------------- * Style based on Modena.css combo-box-popup style */ diff --git a/gemsfx/src/main/resources/com/dlsc/gemsfx/search-text-field.css b/gemsfx/src/main/resources/com/dlsc/gemsfx/search-text-field.css index 0e4f0173..273fbcb2 100644 --- a/gemsfx/src/main/resources/com/dlsc/gemsfx/search-text-field.css +++ b/gemsfx/src/main/resources/com/dlsc/gemsfx/search-text-field.css @@ -6,115 +6,61 @@ -fx-background-radius: 1000px; } -.search-text-field > .left-pane > .search-icon-wrapper, .search-text-field > .right-pane > .clear-icon-wrapper { -fx-padding: 0px 5px; } -.search-text-field:history-popup-showing > .left-pane > .search-icon-wrapper, -.search-text-field > .left-pane > .search-icon-wrapper:hover, -.search-text-field > .left-pane > .search-icon-wrapper:pressed { +.search-text-field > .left-pane { + -fx-padding: 0 0 0 2px; +} + +.search-text-field > .left-pane > .history-button { + -fx-background-color: transparent; + -fx-padding: 2px 3px; + -fx-background-insets: 0px; + -fx-background-radius: 2px 0 0 2px; -fx-cursor: hand; +} + +.search-text-field > .left-pane > .history-button:hover, +.search-text-field > .left-pane > .history-button:pressed, +.search-text-field > .left-pane > .history-button:history-popup-showing { -fx-background-color: -fx-base; - -fx-background-insets: 2px; - -fx-background-radius: 2px 0 0 2px; } -.search-text-field.round:history-popup-showing > .left-pane > .search-icon-wrapper, -.search-text-field.round > .left-pane > .search-icon-wrapper:hover, -.search-text-field.round > .left-pane > .search-icon-wrapper:pressed { +.search-text-field.round > .left-pane > .history-button, +.search-text-field.round > .left-pane > .history-button:hover, +.search-text-field.round > .left-pane > .history-button:pressed, +.search-text-field.round > .left-pane > .history-button:focused, +.search-text-field.round > .left-pane > .history-button:history-popup-showing { -fx-background-radius: 1000px 0 0 1000px; } -.search-text-field:disabled-popup > .left-pane > .search-icon-wrapper, -.search-text-field:disabled-popup > .left-pane > .search-icon-wrapper:hover, -.search-text-field:disabled-popup > .left-pane > .search-icon-wrapper:pressed { +.search-text-field > .left-pane > .history-button:disabled-popup, +.search-text-field > .left-pane > .history-button:disabled-popup:hover, +.search-text-field > .left-pane > .history-button:disabled-popup:pressed { -fx-cursor: text; -fx-background-color: transparent; } -.search-text-field > .left-pane > .search-icon-wrapper:pressed { +.search-text-field > .left-pane > .history-button:pressed { -fx-background-color: derive(-fx-base, -10%); } -.search-text-field > .left-pane > .search-icon-wrapper > .search-icon { +.search-text-field > .left-pane > .history-button > .icon { -fx-padding: 0.43995em 0.5em; + -fx-max-height: 0.8799em; -fx-background-color: -fx-text-inner-color; -fx-shape: "M8,2.2c4.1,0,7.4,3.3,7.4,7.4c0,1.8-0.7,3.5-1.8,4.8l0.3,0.3h0.9l5.7,5.7l-1.7,1.7l-5.7-5.7v-0.9l-0.3-0.3 C11.5,16.3,9.8,17,8,17c-4.1,0-7.4-3.3-7.4-7.4S3.9,2.2,8,2.2 M8,4.5c-2.8,0-5.1,2.3-5.1,5.1s2.3,5.1,5.1,5.1s5.1-2.3,5.1-5.1 S10.9,4.5,8,4.5zM16.2,10.7h7.1l-3.5,6.1"; } -.search-text-field:disabled-popup > .left-pane > .search-icon-wrapper > .search-icon { +.search-text-field > .left-pane > .history-button:disabled-popup > .icon { -fx-padding: 0.5em; + -fx-max-height: 1em; -fx-shape: "M9.5,3C13.1,3,16,5.9,16,9.5c0,1.6-0.6,3.1-1.6,4.2l0.3,0.3h0.8l5,5L19,20.5l-5-5v-0.8l-0.3-0.3c-1.1,1-2.6,1.6-4.2,1.6 C5.9,16,3,13.1,3,9.5S5.9,3,9.5,3 M9.5,5C7,5,5,7,5,9.5S7,14,9.5,14S14,12,14,9.5S12,5,9.5,5z"; } .search-text-field > .right-pane > .clear-icon-wrapper > .ikonli-font-icon { -fx-icon-size: 1em; -fx-icon-color: -fx-text-inner-color; -} - -/* ----------------------------------------------------------------------- - * Style based on Modena.css combo-box-popup style - */ -.search-text-field.round .search-history-list-view { - -fx-background-radius: 10px; - -fx-padding: 5px; -} - -.search-history-list-view { - -fx-background-color: linear-gradient(to bottom, - derive(-fx-color, -17%), - derive(-fx-color, -30%) - ), - -fx-control-inner-background; - -fx-background-insets: 0, 1; - -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.2), 12, 0.0, 0, 8); - -fx-pref-height: 200px; -} - -.search-history-list-view .default-placeholder { - -fx-padding: 0 10px; -} - -.search-text-field-history-popup:above .search-history-list-view { - -fx-translate-y: -6; -} - -.search-history-list-view > .virtual-flow > .clipped-container > .sheet > .list-cell { - -fx-padding: 4 0 4 5; - /* No alternate highlighting */ - -fx-background: -fx-control-inner-background; -} - -.search-history-list-view > .virtual-flow > .clipped-container > .sheet > .list-cell:filled:hover { - -fx-background: -fx-accent; - -fx-background-color: #c9c9c9; -} - -.search-history-list-view > .virtual-flow > .clipped-container > .sheet > .list-cell:filled:selected, -.search-history-list-view > .virtual-flow > .clipped-container > .sheet > .list-cell:filled:selected:hover { - -fx-background: -fx-accent; - -fx-background-color: -fx-background, -fx-cell-focus-inner-border, -fx-background; - -fx-background-insets: 0, 1, 2; -} - -.search-history-list-view > .placeholder > .label { - -fx-text-fill: derive(-fx-control-inner-background, -30%); -} - -.search-history-list-view .search-field-list-cell { -} - -.search-history-list-view .search-field-list-cell .text { - -fx-fill: -fx-selection-bar-text; -} - -.search-history-list-view .search-field-list-cell .text.start { -} - -.search-history-list-view .search-field-list-cell .text.middle { - -fx-underline: true; -} - -.search-history-list-view .search-field-list-cell .text.end { -} +} \ No newline at end of file From a86c264deda760f2e4e64e7648279609315bfe1b Mon Sep 17 00:00:00 2001 From: leewyatt Date: Sat, 18 May 2024 19:29:33 +0900 Subject: [PATCH 2/3] Add history filtering and fix stylesheet reference issue Introduced filter functionality in HistoryManager to allow only the values passing the filter to be saved in history. In addition, corrected the misdirected reference to the user agent stylesheet in SearchTextField. --- .../java/com/dlsc/gemsfx/SearchTextField.java | 2 +- .../com/dlsc/gemsfx/util/HistoryManager.java | 24 ++++++++++++++++++ .../util/PreferencesHistoryManager.java | 25 +++++++++++++++++-- .../gemsfx/util/StringHistoryManager.java | 3 +++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java index da9caf12..0a329a24 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java @@ -188,7 +188,7 @@ private StackPane createRightNode() { @Override public String getUserAgentStylesheet() { - return Objects.requireNonNull(SearchTextField2.class.getResource("search-text-field.css")).toExternalForm(); + return Objects.requireNonNull(SearchTextField.class.getResource("search-text-field.css")).toExternalForm(); } private ObjectProperty historyPlaceholder = new SimpleObjectProperty<>(this, "historyPlaceholder"); diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java index 48794f07..a3248ff0 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java @@ -1,9 +1,11 @@ package com.dlsc.gemsfx.util; import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ObjectProperty; import javafx.collections.ObservableList; import java.util.List; +import java.util.function.Predicate; /** * The HistoryManager interface defines the standard operations to manage history storage @@ -79,4 +81,26 @@ public interface HistoryManager { */ void setMaxHistorySize(int maxHistorySize); + /** + * Returns the property that holds the filter used when adding items to the history. + * Only items that pass the filter will be added to the history. + * + * @return the property containing the filter + */ + ObjectProperty> filterProperty(); + + /** + * Sets a filter to be used when adding items to the history. Only items that pass the + * filter will be added to the history. + * + * @param filter The filter to apply. + */ + void setFilter(Predicate filter); + + /** + * Gets the current filter used for adding items to the history. + * + * @return The current filter. + */ + Predicate getFilter(); } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java index a8629353..6869401f 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java @@ -13,6 +13,7 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.function.Predicate; import java.util.logging.Logger; import java.util.prefs.Preferences; import java.util.stream.Collectors; @@ -127,7 +128,7 @@ public final void set(List history) { * @param item the item to add */ public final void add(T item) { - if (item != null) { + if (item != null && getFilter().test(item)) { history.remove(item); history.add(0, item); trimHistory(); @@ -203,6 +204,26 @@ public final void setMaxHistorySize(int maxHistorySize) { maxHistorySizeProperty().set(maxHistorySize); } + private final ObjectProperty> filter = new SimpleObjectProperty<>(this, "filter", it -> true); + + /** + * Returns the property object for the filter used when adding items to the history. + * Only items that pass the filter will be added to the history. + * + * @return the property object for the filter + */ + public final ObjectProperty> filterProperty() { + return filter; + } + + public final Predicate getFilter() { + return filter.get(); + } + + public final void setFilter(Predicate filter) { + this.filter.set(filter); + } + private final ObjectProperty preferences = new SimpleObjectProperty<>(this, "preferences"); /** @@ -242,7 +263,7 @@ private void trimHistory() { * @return the converted unique list of strings */ private List convertToUniqueList(List history) { - return history.stream().distinct().filter(Objects::nonNull).limit(Math.max(0, getMaxHistorySize())).toList(); + return history.stream().distinct().filter(Objects::nonNull).filter(getFilter()).limit(Math.max(0, getMaxHistorySize())).toList(); } /** diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java index 21b29f62..5cfd6c22 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java @@ -1,6 +1,7 @@ package com.dlsc.gemsfx.util; import javafx.util.StringConverter; +import org.apache.commons.lang3.StringUtils; /** * Manages a history of string records using the Java Preferences API. This class specializes @@ -43,10 +44,12 @@ public String fromString(String string) { public StringHistoryManager() { super(DEFAULT_STRING_CONVERTER); + setFilter(StringUtils::isNotEmpty); } public StringHistoryManager(String delimiter, String preferencesKey) { super(delimiter, preferencesKey, DEFAULT_STRING_CONVERTER); + setFilter(StringUtils::isNotEmpty); } } From 239019eeb22584eec481d5ed0ff6a7b49f0d8850 Mon Sep 17 00:00:00 2001 From: leewyatt Date: Tue, 28 May 2024 02:17:45 +0900 Subject: [PATCH 3/3] Update HistoryManager handling and refactor code Refactored the handling of HistoryManager across several classes for better encapsulation and flexibility. Also, updated 'UIUtil.java', replacing Node parameter with Styleable in all utility methods to handle a wider range of JavaFX styleable components. Lastly, added a 'set' method in the HistoryManager interface and implementations to allow setting a new history list. --- .../dlsc/gemsfx/demo/HistoryManagerApp.java | 19 +- .../com/dlsc/gemsfx/demo/SearchFieldApp.java | 51 ++++- .../dlsc/gemsfx/demo/SearchTextFieldApp.java | 96 +++++----- .../java/com/dlsc/gemsfx/HistoryButton.java | 117 ++++++------ .../java/com/dlsc/gemsfx/SearchField.java | 100 +++++----- .../java/com/dlsc/gemsfx/SearchTextField.java | 175 +++++++++--------- .../com/dlsc/gemsfx/skins/HistoryPopup.java | 26 ++- .../dlsc/gemsfx/skins/HistoryPopupSkin.java | 20 +- .../com/dlsc/gemsfx/util/HistoryManager.java | 8 + .../util/PreferencesHistoryManager.java | 4 +- .../gemsfx/util/StringHistoryManager.java | 12 ++ .../java/com/dlsc/gemsfx/util/UIUtil.java | 33 +++- 12 files changed, 403 insertions(+), 258 deletions(-) diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/HistoryManagerApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/HistoryManagerApp.java index d0f3d221..75564759 100644 --- a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/HistoryManagerApp.java +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/HistoryManagerApp.java @@ -53,10 +53,21 @@ public void start(Stage primaryStage) throws Exception { private Node basicDemo() { TextField textField = new TextField(); + + HistoryButton historyButton = new HistoryButton<>(textField); + + // Tips: We can set the delimiter and preferencesKey when creating, otherwise use the default value. + // StringHistoryManager historyManager = new StringHistoryManager(";", "history-records", + // Preferences.userNodeForPackage(HistoryManagerApp.class).node("simpleTextField")); + StringHistoryManager historyManager = new StringHistoryManager(); + + // Tips: If we want to persist the history after the application restarts, we need to set the preferences. // historyManager.setPreferences(Preferences.userNodeForPackage(HistoryManagerApp.class).node("simpleTextField")); - HistoryButton historyButton = new HistoryButton<>(textField, historyManager); + // Tips: If we want to enable the history function, we need to set the history manager. + historyButton.setHistoryManager(historyManager); + historyButton.setConfigureHistoryPopup(historyPopup -> { // When choosing a history item, replace the text in the text field. historyPopup.setOnHistoryItemConfirmed(item -> { @@ -108,7 +119,8 @@ private Node advancedDemo() { historyManager.set(List.of("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten")); } - HistoryButton historyButton = new HistoryButton<>(textField, historyManager); + HistoryButton historyButton = new HistoryButton<>(textField); + historyButton.setHistoryManager(historyManager); // add history item to the history when the enter key is pressed. textField.setOnKeyPressed(e -> { @@ -214,7 +226,8 @@ public Student fromString(String string) { historyManager.setPreferences(Preferences.userNodeForPackage(HistoryManagerApp.class).node("list")); - HistoryButton historyButton = new HistoryButton<>(null, historyManager); + HistoryButton historyButton = new HistoryButton<>(null); + historyButton.setHistoryManager(historyManager); historyButton.setText("History"); historyButton.setConfigureHistoryPopup(historyPopup -> { diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchFieldApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchFieldApp.java index f8ba5f02..8a57cb96 100644 --- a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchFieldApp.java +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchFieldApp.java @@ -1,6 +1,8 @@ package com.dlsc.gemsfx.demo; import com.dlsc.gemsfx.SearchField; +import com.dlsc.gemsfx.util.HistoryManager; +import com.dlsc.gemsfx.util.StringHistoryManager; import fr.brouillard.oss.cssfx.CSSFX; import javafx.application.Application; import javafx.beans.binding.Bindings; @@ -8,6 +10,7 @@ import javafx.scene.Scene; import javafx.scene.control.CheckBox; import javafx.scene.control.Label; +import javafx.scene.control.Separator; import javafx.scene.layout.HBox; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; @@ -21,8 +24,15 @@ import java.util.prefs.Preferences; import java.util.stream.Collectors; +/** + * This demo shows how to use the {@link SearchField} control. + *

    + * About the HistoryManager, you can refer to: {@link HistoryManager} {@link SearchTextFieldApp}, {@link HistoryManagerApp} + */ public class SearchFieldApp extends Application { + private StringHistoryManager historyManager; + private final List countries = new ArrayList<>(); @Override @@ -81,10 +91,45 @@ public void start(Stage primaryStage) throws Exception { CheckBox autoCommitOnFocusLostBox = new CheckBox("Auto commit on field lost focus."); autoCommitOnFocusLostBox.selectedProperty().bindBidirectional(field.autoCommitOnFocusLostProperty()); + CheckBox enableHistoryBox = new CheckBox("Enable History"); + enableHistoryBox.selectedProperty().addListener((obs, oldVal, newVal) -> { + if (newVal) { + if (field.getHistoryManager() == null) { + historyManager = new StringHistoryManager(Preferences.userNodeForPackage(SearchFieldApp.class).node("field")); + // Optional: Set the maximum history size. default is 30. + historyManager.setMaxHistorySize(20); + // Optional: If the history items is empty, we can set a default history list. + if (historyManager.getAll().isEmpty()) { + historyManager.set(List.of("United Kingdom", "Switzerland")); + } + } + // If the history manager is not null, the search field will have a history feature. + field.setHistoryManager(historyManager); + } else { + // If the history manager is null, the search field will not have a history feature. + field.setHistoryManager(null); + } + primaryStage.sizeToScene(); + }); + enableHistoryBox.setSelected(true); + + CheckBox addHistoryOnActionBox = new CheckBox("Add History on Enter"); + addHistoryOnActionBox.setSelected(true); + field.addingItemToHistoryOnEnterProperty().bind(addHistoryOnActionBox.selectedProperty()); + + CheckBox addHistoryOnFocusLossBox = new CheckBox("Add History on Focus Loss"); + addHistoryOnFocusLossBox.setSelected(true); + field.addingItemToHistoryOnFocusLostProperty().bind(addHistoryOnFocusLossBox.selectedProperty()); + + VBox historyControls = new VBox(10, new Separator(), addHistoryOnActionBox, addHistoryOnFocusLossBox); + historyControls.managedProperty().bind(enableHistoryBox.selectedProperty()); + historyControls.visibleProperty().bind(enableHistoryBox.selectedProperty()); + field.leftProperty().bind(Bindings.createObjectBinding(() -> showLeftRightNodes.isSelected() ? regionLeft : null, showLeftRightNodes.selectedProperty())); field.rightProperty().bind(Bindings.createObjectBinding(() -> showLeftRightNodes.isSelected() ? regionRight : null, showLeftRightNodes.selectedProperty())); - VBox vbox = new VBox(20, createNewItemBox, showPromptText, usePlaceholder, hideWithSingleChoiceBox, hideWithNoChoiceBox, showSearchIconBox, showLeftRightNodes, autoCommitOnFocusLostBox, hBox, hBox2, field); + VBox vbox = new VBox(20, createNewItemBox, showPromptText, usePlaceholder, hideWithSingleChoiceBox, hideWithNoChoiceBox, showSearchIconBox, showLeftRightNodes, + autoCommitOnFocusLostBox, hBox, hBox2, enableHistoryBox, historyControls, field); vbox.setPadding(new Insets(20)); Scene scene = new Scene(vbox); @@ -123,8 +168,8 @@ public Country fromString(String string) { setMatcher((broker, searchText) -> broker.getName().toLowerCase().startsWith(searchText.toLowerCase())); setComparator(Comparator.comparing(Country::getName)); getEditor().setPromptText("Start typing country name ..."); - // If not setPreferences() history records are only stored temporarily in memory and are not persisted locally. - getHistoryManager().setPreferences(Preferences.userNodeForPackage(SearchFieldApp.class).node("field")); + // Tips: If we don't set a HistoryManager, the search field will not have a history feature. + // setHistoryManager(new StringHistoryManager(Preferences.userNodeForPackage(SearchFieldApp.class).node("field"))); } } diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchTextFieldApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchTextFieldApp.java index 1487cb9e..c57bac2c 100644 --- a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchTextFieldApp.java +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/SearchTextFieldApp.java @@ -1,9 +1,10 @@ package com.dlsc.gemsfx.demo; import com.dlsc.gemsfx.SearchTextField; +import com.dlsc.gemsfx.Spacer; +import com.dlsc.gemsfx.util.HistoryManager; import com.dlsc.gemsfx.util.StringHistoryManager; import javafx.application.Application; -import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; @@ -18,72 +19,85 @@ import java.time.LocalTime; import java.util.List; +import java.util.Optional; import java.util.prefs.Preferences; public class SearchTextFieldApp extends Application { + private StringHistoryManager stringHistoryManager; + @Override public void start(Stage primaryStage) throws Exception { - SearchTextField field1 = new SearchTextField(); - StringHistoryManager historyManager1 = field1.getHistoryManager(); - historyManager1.setPreferences(Preferences.userNodeForPackage(SearchTextFieldApp.class).node("field1")); - - SearchTextField field2 = new SearchTextField(true); - StringHistoryManager historyManager2 = field2.getHistoryManager(); - historyManager2.setPreferences(Preferences.userNodeForPackage(SearchTextFieldApp.class).node("field2")); + SearchTextField field = new SearchTextField(); + + CheckBox roundBox = new CheckBox("Round"); + field.roundProperty().bind(roundBox.selectedProperty()); + + CheckBox enableHistoryBox = new CheckBox("Enable History"); + enableHistoryBox.selectedProperty().addListener((obs, oldVal, newVal) -> { + if (newVal) { + if (stringHistoryManager == null) { + Preferences preferences = Preferences.userNodeForPackage(SearchTextFieldApp.class).node("field1"); + stringHistoryManager = new StringHistoryManager(preferences); + } + field.setHistoryManager(stringHistoryManager); + } else { + field.setHistoryManager(null); + } + primaryStage.sizeToScene(); + }); + enableHistoryBox.setSelected(true); Label label = new Label("Max History Size:"); - Spinner maxHistorySizeSpinner = new Spinner<>(5, 50, 10, 5); - historyManager1.maxHistorySizeProperty().bind(maxHistorySizeSpinner.valueProperty()); - historyManager2.maxHistorySizeProperty().bind(maxHistorySizeSpinner.valueProperty()); - maxHistorySizeSpinner.setMaxWidth(Double.MAX_VALUE); - HBox maxHistorySizeBox = new HBox(5, label, maxHistorySizeSpinner); - maxHistorySizeBox.setAlignment(Pos.CENTER_LEFT); + Spinner maxHistorySizeSpinner = new Spinner<>(5, 50, 30, 5); + maxHistorySizeSpinner.valueProperty().addListener((obs, oldVal, newVal) -> { + HistoryManager historyManager = field.getHistoryManager(); + if (newVal != null && historyManager != null) { + historyManager.setMaxHistorySize(newVal); + } + }); - CheckBox enableHistoryPopupBox = new CheckBox("Enable History Popup"); - enableHistoryPopupBox.setSelected(true); - field1.enableHistoryPopupProperty().bindBidirectional(enableHistoryPopupBox.selectedProperty()); - field2.enableHistoryPopupProperty().bindBidirectional(enableHistoryPopupBox.selectedProperty()); + maxHistorySizeSpinner.setMaxWidth(140); + HBox maxHistorySizeBox = new HBox(5, label, new Spacer(), maxHistorySizeSpinner); + maxHistorySizeBox.setAlignment(Pos.CENTER_LEFT); CheckBox addHistoryOnActionBox = new CheckBox("Add History on Enter"); addHistoryOnActionBox.setSelected(true); - field1.addingItemToHistoryOnEnterProperty().bind(addHistoryOnActionBox.selectedProperty()); - field2.addingItemToHistoryOnEnterProperty().bind(addHistoryOnActionBox.selectedProperty()); + field.addingItemToHistoryOnEnterProperty().bind(addHistoryOnActionBox.selectedProperty()); CheckBox addHistoryOnFocusLossBox = new CheckBox("Add History on Focus Loss"); addHistoryOnFocusLossBox.setSelected(true); - field1.addingItemToHistoryOnFocusLostProperty().bind(addHistoryOnFocusLossBox.selectedProperty()); - field2.addingItemToHistoryOnFocusLostProperty().bind(addHistoryOnFocusLossBox.selectedProperty()); + field.addingItemToHistoryOnFocusLostProperty().bind(addHistoryOnFocusLossBox.selectedProperty()); Button setHistoryButton = new Button("Set History"); setHistoryButton.setMaxWidth(Double.MAX_VALUE); setHistoryButton.setOnAction(e -> { List list = List.of("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"); - historyManager1.set(list); - historyManager2.set(list); + Optional.ofNullable(field.getHistoryManager()).ifPresent(historyManager -> { + historyManager.set(list); + System.out.println("History set to: " + list); + }); }); Button addHistoryButton = new Button("Add History"); addHistoryButton.setMaxWidth(Double.MAX_VALUE); - addHistoryButton.setOnAction(e -> { - historyManager1.add("New " + LocalTime.now()); - historyManager2.add("New" + LocalTime.now()); - }); + addHistoryButton.setOnAction(e -> Optional.ofNullable(field.getHistoryManager()).ifPresent(historyManager -> historyManager.add("New " + LocalTime.now()))); - Button removeStandardHistoryButton = createRemoveHistoryButton("Standard Field Remove First History Item", historyManager1); - Button removeRoundHistoryButton = createRemoveHistoryButton("Round Field Remove First History Item", historyManager2); + Button removeHistoryButton = new Button("Remove First History Item"); + removeHistoryButton.setMaxWidth(Double.MAX_VALUE); + removeHistoryButton.setOnAction(e -> Optional.ofNullable(field.getHistoryManager()).ifPresent(historyManager -> historyManager.remove(historyManager.getAll().get(0)))); Button clearButton = new Button("Clear History"); clearButton.setMaxWidth(Double.MAX_VALUE); - clearButton.setOnAction(e -> { - historyManager1.clear(); - historyManager2.clear(); - }); + clearButton.setOnAction(e -> Optional.ofNullable(field.getHistoryManager()).ifPresent(HistoryManager::clear)); + + VBox historyControls = new VBox(5, new Separator(), maxHistorySizeBox, addHistoryOnActionBox, addHistoryOnFocusLossBox, + setHistoryButton, addHistoryButton, clearButton); + historyControls.managedProperty().bind(enableHistoryBox.selectedProperty()); + historyControls.visibleProperty().bind(enableHistoryBox.selectedProperty()); - VBox vbox = new VBox(20, new Label("Standard"), field1, new Label("Round"), field2, - new Separator(), maxHistorySizeBox, enableHistoryPopupBox, addHistoryOnActionBox, addHistoryOnFocusLossBox, - setHistoryButton, addHistoryButton, removeStandardHistoryButton, removeRoundHistoryButton, clearButton); + VBox vbox = new VBox(20, new Label("Standard"), field, roundBox, enableHistoryBox, historyControls); vbox.setPadding(new Insets(20)); Scene scene = new Scene(vbox); @@ -94,14 +108,6 @@ public void start(Stage primaryStage) throws Exception { primaryStage.show(); } - private Button createRemoveHistoryButton(String text, StringHistoryManager historyManager) { - Button removeHistoryButton2 = new Button(text); - removeHistoryButton2.disableProperty().bind(Bindings.createObjectBinding(() -> historyManager.getAll().isEmpty(), historyManager.getAll())); - removeHistoryButton2.setMaxWidth(Double.MAX_VALUE); - removeHistoryButton2.setOnAction(e -> historyManager.remove(historyManager.getAll().get(0))); - return removeHistoryButton2; - } - public static void main(String[] args) { launch(args); } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/HistoryButton.java b/gemsfx/src/main/java/com/dlsc/gemsfx/HistoryButton.java index b20f2a86..24a5f1dc 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/HistoryButton.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/HistoryButton.java @@ -6,7 +6,6 @@ import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyBooleanWrapper; -import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.css.CssMetaData; import javafx.css.PseudoClass; @@ -15,6 +14,7 @@ import javafx.css.StyleableProperty; import javafx.css.converter.BooleanConverter; import javafx.event.ActionEvent; +import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.layout.Region; import org.kordamp.ikonli.javafx.FontIcon; @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.function.Consumer; /** @@ -55,48 +56,55 @@ public class HistoryButton extends Button { private static final String DEFAULT_STYLE_CLASS = "history-button"; - private static final boolean ENABLE_HISTORY_POPUP = true; private static final boolean DEFAULT_FOCUS_POPUP_OWNER_ON_OPEN = false; private static final PseudoClass DISABLED_POPUP_PSEUDO_CLASS = PseudoClass.getPseudoClass("disabled-popup"); private static final PseudoClass HISTORY_POPUP_SHOWING_PSEUDO_CLASS = PseudoClass.getPseudoClass("history-popup-showing"); - private final Region popupOwner; - private final HistoryManager historyManager; private HistoryPopup historyPopup; /** * Creates a new instance of the history button. - * - * @param popupOwner The owner of the popup. can be null. if null, the button will be the popup owner. - * @param historyManager The history manager. */ - public HistoryButton(Region popupOwner, HistoryManager historyManager) { + public HistoryButton() { getStyleClass().addAll(DEFAULT_STYLE_CLASS); - this.popupOwner = popupOwner; - this.historyManager = historyManager; setGraphic(new FontIcon(MaterialDesign.MDI_HISTORY)); setOnAction(this::onActionHandler); } + /** + * Creates a new instance of the history button. + * + * @param popupOwner The owner of the popup. can be null. if null, the button will be the popup owner. + */ + public HistoryButton(Region popupOwner) { + this(); + setPopupOwner(popupOwner); + } + protected void onActionHandler(ActionEvent event) { - Region popupOwner = getPopupOwner(); - HistoryManager historyManager = getHistoryManager(); + Node popupOwner = getPopupOwner(); if (popupOwner != null && popupOwner != this && !popupOwner.isFocused() && getFocusPopupOwnerOnOpen()) { popupOwner.requestFocus(); } - if (!isEnableHistoryPopup()) { + if (getHistoryManager() == null) { return; } if (historyPopup == null) { - historyPopup = new HistoryPopup<>(historyManager); + historyPopup = new HistoryPopup<>(); // basic settings - historyPopup.setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> historyManager.remove(item))); + historyPopup.historyManagerProperty().bind(historyManagerProperty()); historyPopupShowing.bind(historyPopup.showingProperty()); + historyPopup.setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> { + HistoryManager historyManager = getHistoryManager(); + if (historyManager != null) { + historyManager.remove(item); + } + })); // Set up the popup if (getConfigureHistoryPopup() != null) { @@ -170,42 +178,63 @@ public final void setConfigureHistoryPopup(Consumer> configureHi configureHistoryPopupProperty().set(configureHistoryPopup); } - private BooleanProperty enableHistoryPopup; + private final ReadOnlyBooleanWrapper historyPopupShowing = new ReadOnlyBooleanWrapper(this, "historyPopupShowing") { + @Override + protected void invalidated() { + pseudoClassStateChanged(HISTORY_POPUP_SHOWING_PSEUDO_CLASS, get()); + } + }; + + public final boolean isHistoryPopupShowing() { + return historyPopupShowing.get(); + } + + private ObjectProperty> historyManager; /** - * Indicates whether the history popup should be enabled. + * The history manager that is used to manage the history of the HistoryButton. + *

    + * If its value is null, clicking the button will not display the history popup. + *

    + * If its value is not null, clicking the button will display the history popup. * - * @return true if the history popup should be enabled, false otherwise + * @return the property representing the history manager */ - public final BooleanProperty enableHistoryPopupProperty() { - if (enableHistoryPopup == null) { - enableHistoryPopup = new SimpleBooleanProperty(this, "enableHistoryPopup", ENABLE_HISTORY_POPUP) { + public final ObjectProperty> historyManagerProperty() { + if (historyManager == null) { + historyManager = new SimpleObjectProperty<>(this, "historyManager") { @Override protected void invalidated() { - pseudoClassStateChanged(DISABLED_POPUP_PSEUDO_CLASS, !get()); + pseudoClassStateChanged(DISABLED_POPUP_PSEUDO_CLASS, get() == null); } }; } - return enableHistoryPopup; + return historyManager; } - public final boolean isEnableHistoryPopup() { - return enableHistoryPopup == null ? ENABLE_HISTORY_POPUP : enableHistoryPopup.get(); + public final void setHistoryManager(HistoryManager historyManager) { + historyManagerProperty().set(historyManager); } - public final void setEnableHistoryPopup(boolean enableHistoryPopup) { - enableHistoryPopupProperty().set(enableHistoryPopup); + public final HistoryManager getHistoryManager() { + return historyManager == null ? null : historyManager.get(); } - private final ReadOnlyBooleanWrapper historyPopupShowing = new ReadOnlyBooleanWrapper(this, "historyPopupShowing") { - @Override - protected void invalidated() { - pseudoClassStateChanged(HISTORY_POPUP_SHOWING_PSEUDO_CLASS, get()); + private ObjectProperty popupOwner; + + public final ObjectProperty popupOwnerProperty() { + if (popupOwner == null) { + popupOwner = new SimpleObjectProperty<>(this, "popupOwner"); } - }; + return popupOwner; + } - public final boolean isHistoryPopupShowing() { - return historyPopupShowing.get(); + public final Node getPopupOwner() { + return popupOwner == null ? null : popupOwner.get(); + } + + public final void setPopupOwner(Node popupOwner) { + popupOwnerProperty().set(popupOwner); } /** @@ -251,24 +280,6 @@ public boolean isSettable(HistoryButton control) { return HistoryButton.StyleableProperties.STYLEABLES; } - /** - * Returns the HistoryManager instance used by this control. - * - * @return the history manager - */ - public final HistoryManager getHistoryManager() { - return historyManager; - } - - /** - * Returns the popup owner node. - * - * @return the popup owner node - */ - public final Region getPopupOwner() { - return popupOwner; - } - /** * Returns the history popup. */ @@ -290,7 +301,7 @@ public final void hideHistoryPopup() { */ public final void showHistoryPopup() { if (historyPopup != null) { - historyPopup.show(popupOwner == null ? this : popupOwner); + historyPopup.show(Optional.ofNullable(getPopupOwner()).orElse(this)); } } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchField.java b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchField.java index 179889ca..2e80ba7d 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchField.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchField.java @@ -2,6 +2,7 @@ import com.dlsc.gemsfx.skins.SearchFieldPopup; import com.dlsc.gemsfx.skins.SearchFieldSkin; +import com.dlsc.gemsfx.util.HistoryManager; import com.dlsc.gemsfx.util.StringHistoryManager; import javafx.animation.Animation; import javafx.animation.RotateTransition; @@ -84,11 +85,11 @@ * * *

    - * History management is enabled by default and can be accessed and controlled through a history button integrated into the search field. - * Users can interact with their search history, revisit previous queries, or clear historical entries. The history functionality is managed by - * a {@link StringHistoryManager}, accessible via {@code getHistoryManager()}, allowing programmatic manipulation of history records. - * If {@code setPreferences(Preferences preferences)} is not set on the {@link StringHistoryManager}, history records are only stored temporarily in memory - * and are not persisted locally. This means that history data will not be retained after the application is closed. + * The history manager is disabled by default, but it can be enabled using the {@link #setHistoryManager(HistoryManager)} method. + * We have implemented a local history manager, {@link StringHistoryManager}, which uses the Java Preferences API to store history records. + * You can enable it via the {@link #setHistoryManager(HistoryManager)} method. + * If you want to persistently store history records, you also need to provide a {@link java.util.prefs.Preferences} instance to the {@link StringHistoryManager}. + * Otherwise, the history records will be lost after the application restarts. *

    * * @param the type of objects to work on @@ -111,7 +112,6 @@ public class SearchField extends Control { private static final PseudoClass DISABLED_POPUP_PSEUDO_CLASS = PseudoClass.getPseudoClass("disabled-popup"); private final SearchService searchService = new SearchService(); - private final StringHistoryManager historyManager; private final TextField editor = new TextField(); @@ -128,22 +128,8 @@ public class SearchField extends Control { * @see #setNewItemProducer(Callback) */ public SearchField() { - this(new StringHistoryManager()); - } - - /** - * Constructs a new spotlight field. The field will set defaults for the - * matcher, the converter, the cell factory, and the comparator. It will - * not set a default for the "new item" producer. - *

    - * The history manager is initialized with the given preferences. - * - * @see #setNewItemProducer(Callback) - */ - public SearchField(StringHistoryManager historyManager) { getStyleClass().add(DEFAULT_STYLE_CLASS); - this.historyManager = historyManager; historyButton = createHistorySupportedButton(); setGraphic(historyButton); @@ -153,7 +139,12 @@ public SearchField(StringHistoryManager historyManager) { editor.promptTextProperty().bindBidirectional(promptTextProperty()); // history listCell factory - setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> historyManager.remove(item))); + setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> { + HistoryManager historyManager = getHistoryManager(); + if (historyManager != null) { + historyManager.remove(item); + } + })); // history listView placeholder Label placeholder = new Label("No history available."); @@ -180,7 +171,7 @@ public SearchField(StringHistoryManager historyManager) { if (!editor.isFocused()) { // Add the current text to the history if the editor lost focus. if (isAddingItemToHistoryOnFocusLost()) { - historyManager.add(editor.getText()); + addToHistory(editor.getText()); } commit(); if (getSelectedItem() == null) { @@ -205,7 +196,7 @@ public SearchField(StringHistoryManager historyManager) { boolean releasedEnter = keyCode.equals(KeyCode.ENTER); // Add the current text to the history if the user pressed the ENTER key. if (releasedEnter && isAddingItemToHistoryOnEnter() && !lastHistoryPopupShowing) { - historyManager.add(editor.getText()); + addToHistory(editor.getText()); } if ((keyCode.equals(KeyCode.RIGHT) || releasedEnter) && !lastHistoryPopupShowing) { @@ -359,7 +350,8 @@ private void onHistoryItemConfirmed(String historyItem) { } private HistoryButton createHistorySupportedButton() { - HistoryButton historyButton = new HistoryButton(this, historyManager); + HistoryButton historyButton = new HistoryButton<>(this); + historyButton.historyManagerProperty().bind(historyManagerProperty()); // Create the graphic Region graphic = new Region(); @@ -369,7 +361,6 @@ private HistoryButton createHistorySupportedButton() { // Configure the history button historyButton.setFocusTraversable(false); historyButton.setFocusPopupOwnerOnOpen(true); - historyButton.enableHistoryPopupProperty().bind(enableHistoryPopupProperty()); historyButton.setConfigureHistoryPopup(historyPopup -> { @@ -420,7 +411,7 @@ public void commit() { // add on commit if (isAddingItemToHistoryOnCommit()) { - historyManager.add(text); + addToHistory(text); } } else { clear(); @@ -435,6 +426,13 @@ public void commit() { } } + private void addToHistory(String text) { + HistoryManager historyManager = getHistoryManager(); + if (historyManager != null) { + historyManager.add(text); + } + } + private final ObjectProperty> onCommit = new SimpleObjectProperty<>(this, "onCommit"); public final Consumer getOnCommit() { @@ -1307,31 +1305,48 @@ public final void setAddingItemToHistoryOnCommit(boolean addingItemToHistoryOnCo addingItemToHistoryOnCommitProperty().set(addingItemToHistoryOnCommit); } - private BooleanProperty enableHistoryPopup; + private ObjectProperty> historyManager; /** - * Indicates whether the history popup should be enabled. + * The history manager that is used to manage the history of the SearchField. + *

    + * If its value is null, the history feature will not be enabled, which means only + * the magnifying glass icon will be displayed, and the dropdown arrow next to the + * magnifying glass will not be shown. + *

    + * If its value is not null, the history feature will be enabled, meaning that both + * the magnifying glass icon and the dropdown arrow will be displayed. Clicking the + * magnifying glass icon button will display the history popup. + *

    + * To enable the history feature, you need to set an instance of {@link HistoryManager}. + * Typically, you would use an instance of {@link StringHistoryManager}, which is an + * implementation of {@link HistoryManager} that manages string-type history records. + *

    + * Please note that if you do not set the {@code StringHistoryManager#setPreferences(Preferences)} + * method for the {@link StringHistoryManager} instance, the history records will only be saved + * in memory and will not be persisted locally. This means that the history data will not be + * retained after the application is closed. * - * @return true if the history popup should be enabled, false otherwise + * @return the property representing the history manager */ - public final BooleanProperty enableHistoryPopupProperty() { - if (enableHistoryPopup == null) { - enableHistoryPopup = new SimpleBooleanProperty(this, "enableHistoryPopup", DEFAULT_ENABLE_HISTORY_POPUP) { + public final ObjectProperty> historyManagerProperty() { + if (historyManager == null) { + historyManager = new SimpleObjectProperty<>(this, "historyManager") { @Override protected void invalidated() { - pseudoClassStateChanged(DISABLED_POPUP_PSEUDO_CLASS, !get()); + pseudoClassStateChanged(DISABLED_POPUP_PSEUDO_CLASS, get() == null); } }; } - return enableHistoryPopup; + return historyManager; } - public final boolean isEnableHistoryPopup() { - return enableHistoryPopup == null ? DEFAULT_ENABLE_HISTORY_POPUP : enableHistoryPopup.get(); + public final HistoryManager getHistoryManager() { + return historyManager == null ? null : historyManager.get(); } - public final void setEnableHistoryPopup(boolean enableHistoryPopup) { - enableHistoryPopupProperty().set(enableHistoryPopup); + public final void setHistoryManager(HistoryManager historyManager) { + historyManagerProperty().set(historyManager); } /** @@ -1396,13 +1411,4 @@ public final SearchFieldPopup getPopup() { return popup; } - /** - * If we want to manually add history records, delete history records, clear history records, then please get the HistoryManager object through this method. - * - * @return the history manager - */ - public final StringHistoryManager getHistoryManager() { - return historyManager; - } - } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java index 0a329a24..b60a1916 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java @@ -1,6 +1,8 @@ package com.dlsc.gemsfx; +import com.dlsc.gemsfx.util.HistoryManager; import com.dlsc.gemsfx.util.StringHistoryManager; +import com.dlsc.gemsfx.util.UIUtil; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -27,6 +29,12 @@ * A custom text field specifically designed for search functionality. This class enhances a text field with features * such as a history of search terms, an optional history popup, and custom icons for search and clear operations. *

    + * The history manager is disabled by default, but it can be enabled using the {@link #setHistoryManager(HistoryManager)} method. + * We have implemented a local history manager, {@link StringHistoryManager}, which uses the Java Preferences API to store history records. + * You can enable it via the {@link #setHistoryManager(HistoryManager)} method. + * If you want to persistently store history records, you also need to provide a {@link java.util.prefs.Preferences} instance to the {@link StringHistoryManager}. + * Otherwise, the history records will be lost after the application restarts. + *

    * By default, when the field loses its focus or the user presses the "enter" key (triggering the onAction event), the * text is added to the history. This behavior can be disabled by setting the {@link #addingItemToHistoryOnEnterProperty()} * and / or the {@link #addingItemToHistoryOnEnterProperty()} to false. @@ -34,25 +42,15 @@ * Additionally, history can be manually added based on user actions, such as after typing text and selecting an item * from a ListView or TableView that displays results, or through other interactions, by calling the {@link #getHistoryManager()} * method to access the {@link StringHistoryManager} instance. then calling the {@link StringHistoryManager#add(String)} method. - * - *

    - * History management is enabled by default and can be accessed and controlled through a history button integrated into the search text field. - * Users can interact with their search history, revisit previous queries, or clear historical entries. The history functionality is managed by - * a {@link StringHistoryManager}, accessible via {@code getHistoryManager()}, allowing programmatic manipulation of history records. - * If {@code setPreferences(Preferences preferences)} is not set on the {@link StringHistoryManager}, history records are only stored temporarily in memory - * and are not persisted locally. This means that history data will not be retained after the application is closed. - *

    - * */ public class SearchTextField extends CustomTextField { - private static final boolean DEFAULT_ENABLE_HISTORY_POPUP = true; private static final boolean DEFAULT_ADDING_ITEM_TO_HISTORY_ON_ENTER = true; private static final boolean DEFAULT_ADDING_ITEM_TO_HISTORY_ON_FOCUS_LOST = true; + private static final boolean DEFAULT_ROUND = false; private static final PseudoClass DISABLED_POPUP_PSEUDO_CLASS = PseudoClass.getPseudoClass("disabled-popup"); - private final StringHistoryManager historyManager; private final HistoryButton historyButton; /** @@ -61,40 +59,8 @@ public class SearchTextField extends CustomTextField { * The history manager is initialized with default values. */ public SearchTextField() { - this(false, new StringHistoryManager()); - } - - /** - * Constructs a new text field customized for search operations. - */ - public SearchTextField(StringHistoryManager historyManager) { - this(false, historyManager); - } - - /** - * Constructs a new text field customized for search operations. The look and feel can be - * adjusted to feature rounded corners / sides. - *

    - * The history manager is initialized with default values. - * - * @param round if true the sides of the field will be round - */ - public SearchTextField(boolean round) { - this(round, new StringHistoryManager()); - } - - /** - * Constructs a new text field customized for search operations. The look and feel can be - * adjusted to feature rounded corners / sides. - * - * @param round if true the sides of the field will be round - */ - public SearchTextField(boolean round, StringHistoryManager historyManager) { - if (round) { - getStyleClass().add("round"); - } - getStyleClass().add("search-text-field"); + UIUtil.toggleClassBasedOnObservable(this, "round", roundProperty()); setPromptText("Search..."); @@ -102,10 +68,14 @@ public SearchTextField(boolean round, StringHistoryManager historyManager) { placeholder.getStyleClass().add("default-placeholder"); setHistoryPlaceholder(placeholder); - this.historyManager = historyManager; - setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> historyManager.remove(item))); + setHistoryCellFactory(view -> new RemovableListCell<>((listView, item) -> { + HistoryManager historyManager = getHistoryManager(); + if (historyManager != null) { + historyManager.remove(item); + } + })); - historyButton = createLeftNode(round); + historyButton = createLeftNode(); setLeft(historyButton); setRight(createRightNode()); @@ -114,14 +84,22 @@ public SearchTextField(boolean round, StringHistoryManager historyManager) { focusedProperty().addListener(it -> { if (!isFocused() && isAddingItemToHistoryOnFocusLost()) { - historyManager.add(getText()); + addToHistory(); } historyButton.hideHistoryPopup(); }); } - private HistoryButton createLeftNode(boolean round) { - HistoryButton historyButton = new HistoryButton<>(this, historyManager); + private void addToHistory() { + HistoryManager historyManager = getHistoryManager(); + if (historyManager != null) { + historyManager.add(getText()); + } + } + + private HistoryButton createLeftNode() { + HistoryButton historyButton = new HistoryButton<>(this); + historyButton.historyManagerProperty().bind(historyManagerProperty()); // Create the graphic Region graphic = new Region(); @@ -131,11 +109,8 @@ private HistoryButton createLeftNode(boolean round) { // Configure the history button historyButton.setFocusTraversable(false); historyButton.setFocusPopupOwnerOnOpen(true); - historyButton.enableHistoryPopupProperty().bind(enableHistoryPopupProperty()); historyButton.setConfigureHistoryPopup(historyPopup -> { - if (round) { - historyPopup.getStyleClass().add("round"); - } + UIUtil.toggleClassBasedOnObservable(historyPopup, "round", roundProperty()); historyPopup.historyPlaceholderProperty().bind(historyPlaceholderProperty()); historyPopup.historyCellFactoryProperty().bind(historyCellFactoryProperty()); @@ -159,7 +134,7 @@ private void addEventHandlers() { // On Action event, add the text to the history addEventHandler(ActionEvent.ANY, e -> { if (isAddingItemToHistoryOnEnter()) { - historyManager.add(getText()); + addToHistory(); } }); @@ -235,33 +210,6 @@ public final void setHistoryCellFactory(Callback, ListCell> historyManager; + + /** + * The history manager that is used to manage the history of the search text field. + *

    + * If its value is null, the history feature will not be enabled, which means only + * the magnifying glass icon will be displayed, and the dropdown arrow next to the + * magnifying glass will not be shown. + *

    + * If its value is not null, the history feature will be enabled, meaning that both + * the magnifying glass icon and the dropdown arrow will be displayed. Clicking the + * magnifying glass icon button will display the history popup. + *

    + * To enable the history feature, you need to set an instance of {@link HistoryManager}. + * Typically, you would use an instance of {@link StringHistoryManager}, which is an + * implementation of {@link HistoryManager} that manages string-type history records. + *

    + * Please note that if you do not set the {@code StringHistoryManager#setPreferences(Preferences)} + * method for the {@link StringHistoryManager} instance, the history records will only be saved + * in memory and will not be persisted locally. This means that the history data will not be + * retained after the application is closed. + * + * @return the property representing the history manager + */ + public final ObjectProperty> historyManagerProperty() { + if (historyManager == null) { + historyManager = new SimpleObjectProperty<>(this, "historyManager") { + @Override + protected void invalidated() { + pseudoClassStateChanged(DISABLED_POPUP_PSEUDO_CLASS, get() == null); + } + }; + } return historyManager; } + public final HistoryManager getHistoryManager() { + return historyManager == null ? null : historyManager.get(); + } + + public final void setHistoryManager(HistoryManager historyManager) { + historyManagerProperty().set(historyManager); + } + } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopup.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopup.java index 9607e94e..0d370fb7 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopup.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopup.java @@ -35,10 +35,8 @@ public class HistoryPopup extends CustomPopupControl { public static final String DEFAULT_STYLE_CLASS = "history-popup"; - private final HistoryManager historyManager; - public HistoryPopup(HistoryManager historyManager) { - this.historyManager = historyManager; + public HistoryPopup() { getStyleClass().addAll(DEFAULT_STYLE_CLASS); setAutoFix(true); @@ -52,10 +50,6 @@ protected Skin createDefaultSkin() { return new HistoryPopupSkin<>(this); } - public final HistoryManager getHistoryManager() { - return historyManager; - } - private final ObjectProperty left = new SimpleObjectProperty<>(this, "left"); public final ObjectProperty leftProperty() { @@ -186,7 +180,6 @@ public final void setOnHistoryItemConfirmed(Consumer onHistoryItemConfirmed) onHistoryItemConfirmedProperty().set(onHistoryItemConfirmed); } - private ObjectProperty> onHistoryItemSelected; public final Consumer getOnHistoryItemSelected() { @@ -212,4 +205,21 @@ public final void setOnHistoryItemSelected(Consumer onHistoryItemSelected) { onHistoryItemSelectedProperty().set(onHistoryItemSelected); } + private ObjectProperty> historyManager; + + public final HistoryManager getHistoryManager() { + return historyManager == null ? null : historyManager.get(); + } + + public final ObjectProperty> historyManagerProperty() { + if (historyManager == null) { + historyManager = new SimpleObjectProperty<>(this, "historyManager"); + } + return historyManager; + } + + public final void setHistoryManager(HistoryManager historyManager) { + historyManagerProperty().set(historyManager); + } + } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopupSkin.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopupSkin.java index 37e861d7..db12b3cd 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopupSkin.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/HistoryPopupSkin.java @@ -1,6 +1,7 @@ package com.dlsc.gemsfx.skins; import com.dlsc.gemsfx.SearchField; +import com.dlsc.gemsfx.util.HistoryManager; import javafx.beans.binding.Bindings; import javafx.scene.Node; import javafx.scene.control.ListView; @@ -63,7 +64,19 @@ private ListView createHistoryListView() { ListView listView = new ListView<>(); listView.getStyleClass().add("history-list-view"); - Bindings.bindContent(listView.getItems(), control.getHistoryManager().getAll()); + HistoryManager historyManager = control.getHistoryManager(); + if (historyManager != null) { + Bindings.bindContent(listView.getItems(), historyManager.getAll()); + } + + control.historyManagerProperty().addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + Bindings.unbindContent(listView.getItems(), oldValue.getAll()); + } + if (newValue != null) { + Bindings.bindContent(listView.getItems(), newValue.getAll()); + } + }); listView.cellFactoryProperty().bind(control.historyCellFactoryProperty()); listView.placeholderProperty().bind(control.historyPlaceholderProperty()); @@ -116,7 +129,10 @@ public HistoryPopup getSkinnable() { } public void dispose() { - Bindings.unbindContent(listView.getItems(), control.getHistoryManager().getAll()); + HistoryManager historyManager = control.getHistoryManager(); + if (historyManager != null) { + Bindings.unbindContent(listView.getItems(), historyManager.getAll()); + } listView.prefWidthProperty().unbind(); listView.maxWidthProperty().unbind(); diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java index a3248ff0..37a622ad 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/HistoryManager.java @@ -31,6 +31,14 @@ public interface HistoryManager { */ void add(List items); + /** + * Sets the history of the HistoryManager with the provided list of items. + * The method ensures that duplicates are removed from the list. + * + * @param items the list of items representing the history + */ + void set(List items); + /** * Removes a single item from the history storage. * diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java index 6869401f..4ae465b0 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/PreferencesHistoryManager.java @@ -47,12 +47,12 @@ public class PreferencesHistoryManager implements HistoryManager { * Using Unicode Record Separator as delimiter. * This character is not likely to be used in the history items. */ - private static final String DEFAULT_DELIMITER = "␞"; + public static final String DEFAULT_DELIMITER = "␞"; /** * Default preferences key used to store history items. */ - private static final String DEFAULT_PREFERENCES_KEY = "history-items"; + public static final String DEFAULT_PREFERENCES_KEY = "history-items"; private final String delimiter; private final String preferencesKey; diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java index 5cfd6c22..eae92523 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/StringHistoryManager.java @@ -3,6 +3,8 @@ import javafx.util.StringConverter; import org.apache.commons.lang3.StringUtils; +import java.util.prefs.Preferences; + /** * Manages a history of string records using the Java Preferences API. This class specializes * the generic {@link PreferencesHistoryManager} for strings, providing simple and efficient @@ -47,9 +49,19 @@ public StringHistoryManager() { setFilter(StringUtils::isNotEmpty); } + public StringHistoryManager(Preferences preferences) { + this(); + setPreferences(preferences); + } + public StringHistoryManager(String delimiter, String preferencesKey) { super(delimiter, preferencesKey, DEFAULT_STRING_CONVERTER); setFilter(StringUtils::isNotEmpty); } + public StringHistoryManager(String delimiter, String preferencesKey, Preferences preferences) { + this(delimiter, preferencesKey); + setPreferences(preferences); + } + } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/util/UIUtil.java b/gemsfx/src/main/java/com/dlsc/gemsfx/util/UIUtil.java index f4247575..b9a43456 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/util/UIUtil.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/util/UIUtil.java @@ -1,8 +1,8 @@ package com.dlsc.gemsfx.util; - +import javafx.beans.value.ObservableValue; +import javafx.css.Styleable; import javafx.geometry.Insets; -import javafx.scene.Node; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; import javafx.scene.input.MouseButton; @@ -24,7 +24,7 @@ private UIUtil() { * @param node The node to add the style class to. * @param styleClass The style class to add. */ - public static void addClassIfAbsent(Node node, String styleClass) { + public static void addClassIfAbsent(Styleable node, String styleClass) { Optional.ofNullable(node).ifPresent(n -> { if (!n.getStyleClass().contains(styleClass)) { n.getStyleClass().add(styleClass); @@ -38,7 +38,7 @@ public static void addClassIfAbsent(Node node, String styleClass) { * @param node The node to add the style classes to. * @param styleClasses The style classes to add. */ - public static void addClassesIfAbsent(Node node, String... styleClasses) { + public static void addClassesIfAbsent(Styleable node, String... styleClasses) { List list = Arrays.stream(styleClasses) .filter(styleClass -> !node.getStyleClass().contains(styleClass)) .toList(); @@ -53,7 +53,7 @@ public static void addClassesIfAbsent(Node node, String... styleClasses) { * @param node The node to toggle the style on. * @param styleClass The style class to add or remove. */ - public static void toggleClass(Node node, String styleClass) { + public static void toggleClass(Styleable node, String styleClass) { if (node.getStyleClass().contains(styleClass)) { node.getStyleClass().remove(styleClass); } else { @@ -70,7 +70,7 @@ public static void toggleClass(Node node, String styleClass) { * @param styleClass The style class to add or remove. * @param condition The condition that determines whether to add or remove the style. */ - public static void toggleClassOnCondition(Node node, String styleClass, boolean condition) { + public static void toggleClassOnCondition(Styleable node, String styleClass, boolean condition) { if (condition) { addClassIfAbsent(node, styleClass); } else { @@ -78,6 +78,19 @@ public static void toggleClassOnCondition(Node node, String styleClass, boolean } } + /** + * Toggles a style class on a node based on an observable value. + * If the observable value is true, the style class is added. + * If the observable value is false, the style class is removed. + * + * @param node The node to toggle the style on. + * @param styleClass The style class to add or remove. + * @param booleanObservableValue The observable value that determines whether to add or remove the style. + */ + public static void toggleClassBasedOnObservable(Styleable node, String styleClass, ObservableValue booleanObservableValue) { + toggleClassOnCondition(node, styleClass, booleanObservableValue.getValue()); + booleanObservableValue.addListener((obs, oldVal, newVal) -> toggleClassOnCondition(node, styleClass, newVal)); + } /** * Optimizes style updates for a given node by first adding a specified style to ensure it's present, @@ -88,7 +101,7 @@ public static void toggleClassOnCondition(Node node, String styleClass, boolean * @param stylesToRemove A list of styles to be removed from the node, except for the styleToAdd. * @param styleToAdd The style to be added to the node, if it's not already present. */ - public static void updateStyles(Node node, List stylesToRemove, String styleToAdd) { + public static void updateStyles(Styleable node, List stylesToRemove, String styleToAdd) { // Add the style if it's not already present addClassIfAbsent(node, styleToAdd); @@ -107,7 +120,7 @@ public static void updateStyles(Node node, List stylesToRemove, String s * @param stylesToRemove An array of styles to be removed from the node, except for the styleToAdd. * @param styleToAdd The style to be added to the node, if it's not already present. */ - public static void updateStyles(Node node, String[] stylesToRemove, String styleToAdd) { + public static void updateStyles(Styleable node, String[] stylesToRemove, String styleToAdd) { updateStyles(node, Arrays.asList(stylesToRemove), styleToAdd); } @@ -118,7 +131,7 @@ public static void updateStyles(Node node, String[] stylesToRemove, String style * @param enumValue The enum value determining the style to apply. *

    Example If Dir.UP is passed, add "up" style and removes {"down", "left", "right"} styles. */ - public static > void updateStyleFromEnum(Node node, T enumValue) { + public static > void updateStyleFromEnum(Styleable node, T enumValue) { updateStyles(node, EnumUtil.convertAllToStylesClassName(enumValue.getClass()), EnumUtil.convertToStyleClassName(enumValue)); } @@ -129,7 +142,7 @@ public static > void updateStyleFromEnum(Node node, T enumValu * @param enumClass The enum class whose associated styles will be removed. *

    Example If Dir.class is passed, removes all styles {"up","down","left", "right"}. */ - public static > void clearStylesByEnum(Node node, Class enumClass) { + public static > void clearStylesByEnum(Styleable node, Class enumClass) { node.getStyleClass().removeAll(EnumUtil.convertAllToStylesClassName(enumClass)); }