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..b1721cbe 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 @@ -10,9 +10,12 @@ import javafx.scene.control.Label; import javafx.scene.layout.HBox; import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; 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.ArrayList; import java.util.Comparator; @@ -35,9 +38,11 @@ public void start(Stage primaryStage) throws Exception { field.getEditor().setPrefColumnCount(30); field.setOnCommit(country -> System.out.println("on commit listener in demo was invoked, country = " + country)); - Region regionLeft = new Region(); + FontIcon fontIcon = new FontIcon(MaterialDesign.MDI_HISTORY); + StackPane regionLeft = new StackPane(fontIcon); regionLeft.setPrefWidth(30); - regionLeft.setStyle("-fx-background-color: red;"); + field.setLeft(regionLeft); +// regionLeft.setStyle("-fx-background-color: red;"); Region regionRight = new Region(); regionRight.setPrefWidth(30); @@ -80,8 +85,8 @@ public void start(Stage primaryStage) throws Exception { CheckBox autoCommitOnFocusLostBox = new CheckBox("Auto commit on field lost focus."); autoCommitOnFocusLostBox.selectedProperty().bindBidirectional(field.autoCommitOnFocusLostProperty()); - field.leftProperty().bind(Bindings.createObjectBinding(() -> showLeftRightNodes.isSelected() ? regionLeft : null, showLeftRightNodes.selectedProperty())); - field.rightProperty().bind(Bindings.createObjectBinding(() -> showLeftRightNodes.isSelected() ? regionRight : null, showLeftRightNodes.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.setPadding(new Insets(20)); 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 f06fc6b2..e0bba99f 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,22 +1,81 @@ package com.dlsc.gemsfx.demo; +import com.dlsc.gemsfx.RemovableListCell; import com.dlsc.gemsfx.SearchTextField; import javafx.application.Application; +import javafx.beans.binding.Bindings; import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; import javafx.scene.control.Label; +import javafx.scene.control.Separator; +import javafx.scene.control.Spinner; +import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.stage.Stage; +import java.time.LocalTime; +import java.util.List; + public class SearchTextFieldApp extends Application { + private SearchTextField field1; + @Override public void start(Stage primaryStage) throws Exception { - SearchTextField field1 = new SearchTextField(); + field1 = new SearchTextField(); + field1.setCellFactory(param -> new RemovableListCell<>((listView, item) -> field1.removeHistory(item))); + SearchTextField field2 = new SearchTextField(true); - VBox vbox = new VBox(20, new Label("Standard"), field1, new Label("Round"), field2); + Label label = new Label("Max History Size:"); + Spinner maxHistorySizeSpinner = new Spinner<>(5, 50, 10, 5); + field1.maxHistorySizeProperty().bind(maxHistorySizeSpinner.valueProperty()); + maxHistorySizeSpinner.setMaxWidth(Double.MAX_VALUE); + HBox maxHistorySizeBox = new HBox(5, label, maxHistorySizeSpinner); + maxHistorySizeBox.setAlignment(Pos.CENTER_LEFT); + + CheckBox enableHistoryPopupBox = new CheckBox("Enable History Popup"); + enableHistoryPopupBox.setSelected(true); + field1.enableHistoryPopupProperty().bindBidirectional(enableHistoryPopupBox.selectedProperty()); + field2.enableHistoryPopupProperty().bindBidirectional(enableHistoryPopupBox.selectedProperty()); + + CheckBox addHistoryOnActionBox = new CheckBox("Add History on Enter"); + addHistoryOnActionBox.setSelected(true); + field1.addHistoryOnEnterProperty().bind(addHistoryOnActionBox.selectedProperty()); + field2.addHistoryOnEnterProperty().bind(addHistoryOnActionBox.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"); + field1.setHistory(list); + field2.setHistory(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()); + }); + + Button removeStandardHistoryButton = createRemoveHistoryButton("Standard Field Remove First History Item", field1); + Button removeRoundHistoryButton = createRemoveHistoryButton("Round Field Remove First History Item", field2); + + Button clearButton = new Button("Clear History"); + clearButton.setMaxWidth(Double.MAX_VALUE); + clearButton.setOnAction(e -> { + field1.clearHistory(); + field2.clearHistory(); + }); + + VBox vbox = new VBox(20, new Label("Standard"), field1, new Label("Round"), field2, + new Separator(), maxHistorySizeBox, enableHistoryPopupBox, addHistoryOnActionBox, + setHistoryButton, addHistoryButton, removeStandardHistoryButton, removeRoundHistoryButton, clearButton); vbox.setPadding(new Insets(20)); Scene scene = new Scene(vbox); @@ -27,6 +86,14 @@ public void start(Stage primaryStage) throws Exception { primaryStage.show(); } + private Button createRemoveHistoryButton(String text, SearchTextField field) { + Button removeHistoryButton2 = new Button(text); + removeHistoryButton2.disableProperty().bind(Bindings.createObjectBinding(() -> field.getUnmodifiableHistory().isEmpty(), field.getUnmodifiableHistory())); + removeHistoryButton2.setMaxWidth(Double.MAX_VALUE); + removeHistoryButton2.setOnAction(e -> field.removeHistory(field.getUnmodifiableHistory().get(0))); + return removeHistoryButton2; + } + public static void main(String[] args) { launch(args); } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/CustomPopupControl.java b/gemsfx/src/main/java/com/dlsc/gemsfx/CustomPopupControl.java new file mode 100644 index 00000000..ce7c5963 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/CustomPopupControl.java @@ -0,0 +1,52 @@ +package com.dlsc.gemsfx; + +import javafx.css.PseudoClass; +import javafx.geometry.NodeOrientation; +import javafx.scene.Node; +import javafx.scene.control.PopupControl; +import javafx.stage.Screen; +import javafx.stage.Window; + +/** + * A custom popup control that extends PopupControl. + *

+ * The popup can be displayed above or below the anchor node depending on the available space. + */ +public class CustomPopupControl extends PopupControl { + + private static final PseudoClass ABOVE_PSEUDO_CLASS = PseudoClass.getPseudoClass("above"); + private static final PseudoClass BELOW_PSEUDO_CLASS = PseudoClass.getPseudoClass("below"); + + public void show(Node node) { + if (node.getScene() != null && node.getScene().getWindow() != null) { + Window parent = node.getScene().getWindow(); + getScene().setNodeOrientation(node.getEffectiveNodeOrientation()); + if (node.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) { + setAnchorLocation(AnchorLocation.CONTENT_TOP_RIGHT); + } else { + setAnchorLocation(AnchorLocation.CONTENT_TOP_LEFT); + } + + double nodeTopY = parent.getY() + node.localToScene(0.0D, 0.0D).getY() + node.getScene().getY(); + + double anchorX = parent.getX() + node.localToScene(0.0D, 0.0D).getX() + node.getScene().getX(); + double anchorY = nodeTopY + node.getBoundsInParent().getHeight(); + + double bridgeHeight = bridge.getHeight(); + double popupHeight = bridgeHeight == 0 ? getSkin().getNode().prefHeight(-1) : bridgeHeight; + double screenHeight = Screen.getPrimary().getVisualBounds().getHeight(); + + boolean isShowAbove = anchorY + popupHeight > screenHeight; + if (isShowAbove) { + anchorY = nodeTopY - popupHeight; + } + this.pseudoClassStateChanged(ABOVE_PSEUDO_CLASS, isShowAbove); + this.pseudoClassStateChanged(BELOW_PSEUDO_CLASS, !isShowAbove); + + show(node, anchorX, anchorY); + } else { + throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window."); + } + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/RemovableListCell.java b/gemsfx/src/main/java/com/dlsc/gemsfx/RemovableListCell.java new file mode 100644 index 00000000..5e377873 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/RemovableListCell.java @@ -0,0 +1,106 @@ +package com.dlsc.gemsfx; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import org.kordamp.ikonli.javafx.FontIcon; +import org.kordamp.ikonli.materialdesign.MaterialDesign; + +import java.util.Objects; +import java.util.function.BiConsumer; + +/** + * A list cell that displays a remove button on the right side. The remove button is only + * visible when the mouse hovers over the cell. When the remove button is clicked, the + * onRemove callback is invoked. + * + * @param the type of the list cell item + */ +public class RemovableListCell extends ListCell { + + private final HBox containerBox; + private final Label label; + + public RemovableListCell() { + getStyleClass().add("removable-list-cell"); + + label = new Label(); + + StackPane removeBtn = new StackPane(new FontIcon(MaterialDesign.MDI_CLOSE)); + removeBtn.getStyleClass().add("remove-button"); + removeBtn.setOnMouseClicked(this::onRemoveAction); + + containerBox = new HBox(label, new Spacer(), removeBtn); + containerBox.getStyleClass().add("container-box"); + containerBox.setAlignment(Pos.CENTER_LEFT); + } + + public RemovableListCell(BiConsumer, T> onRemove) { + this(); + setOnRemove(onRemove); + } + + @Override + protected void updateItem(T item, boolean empty) { + super.updateItem(item, empty); + + if (item == null || empty) { + label.setText(null); + + setText(null); + setGraphic(null); + } else { + label.setText(item.toString()); + + setText(null); + setGraphic(containerBox); + } + } + + public void onRemoveAction(MouseEvent event) { + if (getOnRemove() != null) { + + // clear selection if the item is selected + if (isSelected()) { + getListView().getSelectionModel().clearSelection(); + } + + getOnRemove().accept(getListView(), getItem()); + } + } + + private ObjectProperty, T>> onRemove; + + /** + * A callback that is invoked when the remove button is clicked. + * + * @return the onRemoveProperty + */ + public final ObjectProperty, T>> onRemoveProperty() { + if (onRemove == null) { + onRemove = new SimpleObjectProperty<>(this, "onRemove"); + } + return onRemove; + } + + public final BiConsumer, T> getOnRemove() { + return onRemove == null ? null : onRemoveProperty().get(); + } + + public final void setOnRemove(BiConsumer, T> onRemove) { + onRemoveProperty().set(onRemove); + } + + @Override + public String getUserAgentStylesheet() { + return Objects.requireNonNull(RemovableListCell.class.getResource("removable-list-cell.css")).toExternalForm(); + } +} + + diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java index 4327451b..eba8bd59 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/SearchTextField.java @@ -1,15 +1,65 @@ package com.dlsc.gemsfx; +import com.dlsc.gemsfx.skins.SearchTextFieldHistoryPopup; +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.geometry.Pos; import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +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; +/** + * 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. + *

+ * By default, when 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 #addHistoryOnEnterProperty()}. + *
+ * 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. + */ public class SearchTextField extends CustomTextField { + private static final int DEFAULT_MAX_HISTORY_SIZE = 30; + private static final boolean ENABLE_HISTORY_POPUP = true; + private static final boolean DEFAULT_ADD_HISTORY_ON_ENTER = 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 Logger LOG = Logger.getLogger(SearchTextField.class.getName()); + private SearchTextFieldHistoryPopup historyPopup; + private final StackPane searchIconWrapper; + public SearchTextField() { this(false); } @@ -22,15 +72,63 @@ public SearchTextField(boolean round) { getStyleClass().add("search-text-field"); setPromptText("Search..."); + Label placeholder = new Label("No history available."); + placeholder.getStyleClass().add("default-placeholder"); + setHistoryPlaceholder(placeholder); + setEnableHistoryPopup(false); - FontIcon searchIcon = new FontIcon(MaterialDesign.MDI_MAGNIFY); - searchIcon.getStyleClass().add("search-icon"); + searchIconWrapper = createLeftNode(); + setLeft(searchIconWrapper); - StackPane searchIconWrapper = new StackPane(searchIcon); - searchIconWrapper.getStyleClass().addAll("wrapper", "search-icon-wrapper"); + setRight(createRightNode()); - setLeft(searchIconWrapper); + addEventHandlers(); + addPropertyListeners(); + } + + private void addEventHandlers() { + // On Action event, add the text to the history + addEventHandler(ActionEvent.ANY, e -> { + if (getAddHistoryOnEnter()) { + addHistory(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 -> { + 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. "); + } + if (history.size() > getSafetyMaxHistorySize()) { + history.remove(getSafetyMaxHistorySize(), history.size()); + } + }); + } + + private StackPane createRightNode() { FontIcon clearIcon = new FontIcon(MaterialDesign.MDI_CLOSE); clearIcon.getStyleClass().add("clear-icon"); clearIcon.setCursor(Cursor.DEFAULT); @@ -40,12 +138,292 @@ public SearchTextField(boolean round) { StackPane clearIconWrapper = new StackPane(clearIcon); clearIconWrapper.getStyleClass().addAll("wrapper", "clear-icon-wrapper"); + return clearIconWrapper; + } + + private StackPane createLeftNode() { + FontIcon searchIcon = new FontIcon(MaterialDesign.MDI_MAGNIFY); + searchIcon.getStyleClass().add("search-icon"); + + Region arrow = new Region(); + arrow.getStyleClass().add("arrow"); + arrow.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + arrow.managedProperty().bind(arrow.visibleProperty()); + StackPane.setAlignment(arrow, Pos.CENTER_RIGHT); - setRight(clearIconWrapper); + StackPane searchIconWrapper = new StackPane(searchIcon, arrow); + 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. + * + * @param event the mouse event triggered by the click + */ + 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. + * + * @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. + * + * @param item the item to add + */ + public final void addHistory(String item) { + if (StringUtils.isNotEmpty(item)) { + history.remove(item); + history.add(0, item); + } + if (history.size() > getSafetyMaxHistorySize()) { + history.remove(getSafetyMaxHistorySize(), 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); + } + + 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>> cellFactory; + + public final Callback, ListCell> getCellFactory() { + return cellFactory == null ? null : cellFactory.get(); + } + + /** + * The cell factory for the history popup list view. + * + * @return the cell factory + */ + public final ObjectProperty, ListCell>> cellFactoryProperty() { + if (cellFactory == null) { + cellFactory = new SimpleObjectProperty<>(this, "cellFactory"); + } + return cellFactory; + } + + public final void setCellFactory(Callback, ListCell> cellFactory) { + cellFactoryProperty().set(cellFactory); + } + + 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 BooleanProperty addHistoryOnEnter; + + /** + * Indicates 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 addHistoryOnEnterProperty() { + if (addHistoryOnEnter == null) { + addHistoryOnEnter = new SimpleBooleanProperty(this, "addHistoryOnEnter", DEFAULT_ADD_HISTORY_ON_ENTER); + } + return addHistoryOnEnter; + } + + public final boolean getAddHistoryOnEnter() { + return addHistoryOnEnter == null ? DEFAULT_ADD_HISTORY_ON_ENTER : addHistoryOnEnter.get(); + } + + public final void setAddHistoryOnEnter(boolean addHistoryOnEnter) { + addHistoryOnEnterProperty().set(addHistoryOnEnter); + } + + 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(); + } + + /** + * 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(getSafetyMaxHistorySize()).toList(); + } + + /** + * Returns the maximum size of the history list that ensures safety. + * If the value of maxHistorySize is negative, 0 is returned. + * Otherwise, the value of maxHistorySize is returned. + * + * @return the safety maximum history size + */ + public final int getSafetyMaxHistorySize() { + return Math.max(0, getMaxHistorySize()); + } + } diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchFieldPopup.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchFieldPopup.java index 5ff9bdc4..73dd8698 100644 --- a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchFieldPopup.java +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchFieldPopup.java @@ -5,27 +5,20 @@ package com.dlsc.gemsfx.skins; +import com.dlsc.gemsfx.CustomPopupControl; import com.dlsc.gemsfx.SearchField; import javafx.collections.FXCollections; import javafx.collections.MapChangeListener; import javafx.collections.ObservableList; -import javafx.css.PseudoClass; -import javafx.geometry.NodeOrientation; -import javafx.scene.Node; import javafx.scene.control.ListView; -import javafx.scene.control.PopupControl; import javafx.scene.control.Skin; -import javafx.stage.Screen; -import javafx.stage.Window; import org.apache.commons.lang3.StringUtils; import java.util.Objects; -public class SearchFieldPopup extends PopupControl { +public class SearchFieldPopup extends CustomPopupControl { public static final String DEFAULT_STYLE_CLASS = "search-field-popup"; - private static final PseudoClass ABOVE_PSEUDO_CLASS = PseudoClass.getPseudoClass("above"); - private static final PseudoClass BELOW_PSEUDO_CLASS = PseudoClass.getPseudoClass("below"); private final ObservableList suggestions = FXCollections.observableArrayList(); private final SearchField searchField; @@ -92,38 +85,6 @@ public ObservableList getSuggestions() { return suggestions; } - public void show(Node node) { - if (node.getScene() != null && node.getScene().getWindow() != null) { - Window parent = node.getScene().getWindow(); - getScene().setNodeOrientation(node.getEffectiveNodeOrientation()); - if (node.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) { - setAnchorLocation(AnchorLocation.CONTENT_TOP_RIGHT); - } else { - setAnchorLocation(AnchorLocation.CONTENT_TOP_LEFT); - } - - double nodeTopY = parent.getY() + node.localToScene(0.0D, 0.0D).getY() + node.getScene().getY(); - - double anchorX = parent.getX() + node.localToScene(0.0D, 0.0D).getX() + node.getScene().getX(); - double anchorY = nodeTopY + node.getBoundsInParent().getHeight(); - - double bridgeHeight = bridge.getHeight(); - double popupHeight = bridgeHeight == 0 ? getSkin().getNode().prefHeight(-1) : bridgeHeight; - double screenHeight = Screen.getPrimary().getVisualBounds().getHeight(); - - boolean isShowAbove = anchorY + popupHeight > screenHeight; - if (isShowAbove) { - anchorY = nodeTopY - popupHeight; - } - this.pseudoClassStateChanged(ABOVE_PSEUDO_CLASS, isShowAbove); - this.pseudoClassStateChanged(BELOW_PSEUDO_CLASS, !isShowAbove); - - show(node, anchorX, anchorY); - } else { - throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window."); - } - } - /** * Selects the first suggestion (if any), so the user can choose it * by pressing enter immediately. diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopup.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopup.java new file mode 100644 index 00000000..70ddb62e --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopup.java @@ -0,0 +1,35 @@ +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 new file mode 100644 index 00000000..6895b678 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/SearchTextFieldHistoryPopupSkin.java @@ -0,0 +1,85 @@ +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.cellFactoryProperty()); + 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/resources/com/dlsc/gemsfx/removable-list-cell.css b/gemsfx/src/main/resources/com/dlsc/gemsfx/removable-list-cell.css new file mode 100644 index 00000000..67139a34 --- /dev/null +++ b/gemsfx/src/main/resources/com/dlsc/gemsfx/removable-list-cell.css @@ -0,0 +1,36 @@ +.removable-list-cell .container-box { + -fx-padding: 2px 3px 2px 5px; +} + +.removable-list-cell .remove-button { + visibility: hidden; + -fx-padding: 2px; + -fx-cursor: hand; +} + +.removable-list-cell:hover { + -fx-background-color: #c9c9c9; +} + +.removable-list-cell:hover .remove-button { + visibility: visible; +} + +.removable-list-cell:empty { + -fx-background-color: transparent; +} + +.removable-list-cell .remove-button .ikonli-font-icon { + -fx-icon-size: 1em; +} + +.removable-list-cell:hover .remove-button .ikonli-font-icon, +.removable-list-cell:selected .remove-button .ikonli-font-icon, +.removable-list-cell .remove-button:pressed .ikonli-font-icon { + -fx-icon-color: -fx-text-background-color; +} + +.removable-list-cell .remove-button:hover .ikonli-font-icon { + -fx-scale-x: 1.2; + -fx-scale-y: 1.2; +} \ No newline at end of file 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 077ca636..389442ff 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 @@ -11,8 +11,111 @@ -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 { + -fx-cursor: hand; + -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 { + -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 { + -fx-cursor: text; + -fx-background-color: transparent; +} + +.search-text-field > .left-pane > .search-icon-wrapper:pressed { + -fx-background-color: derive(-fx-base, -10%); +} + .search-text-field > .left-pane > .search-icon-wrapper > .ikonli-font-icon, .search-text-field > .right-pane > .clear-icon-wrapper > .ikonli-font-icon { -fx-icon-size: 1em; -fx-icon-color: -fx-text-inner-color; -} \ No newline at end of file +} + +.search-text-field > .left-pane > .search-icon-wrapper > .arrow { + -fx-shape: "M 0 0 h 7 l -3.5 4 z"; + -fx-background-color: -fx-text-inner-color; + -fx-padding: 0.25em 0.333em 0 0; + -fx-translate-x: 0.08em; +} + +.search-text-field:disabled-popup > .left-pane > .search-icon-wrapper > .arrow { + visibility: hidden; +} + +/* ----------------------------------------------------------------------- + * 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 { +}