feat(shell): add search/clear icons and Escape-to-cancel to search bar

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
This commit is contained in:
2026-09-16 21:17:10 -04:00
co-authored by Claude Sonnet 5
parent f739d762a9
commit e861318d78
2 changed files with 58 additions and 7 deletions
@@ -1,18 +1,21 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import com.dlsc.gemsfx.CustomTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.HeaderBar;
import javafx.scene.layout.HeaderDragType;
import javafx.scene.input.KeyCode;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.icroco.pholio.infra.i18n.I18nService;
@@ -29,6 +32,8 @@ import org.icroco.pholio.ui.theme.EColorMode;
import org.icroco.pholio.ui.theme.ThemeManager;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.materialdesign2.MaterialDesignC;
import org.kordamp.ikonli.materialdesign2.MaterialDesignM;
import org.springframework.context.ApplicationEventPublisher;
import java.io.File;
@@ -79,7 +84,9 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
private final MenuButton librarySelector = new MenuButton();
private final Button deleteLibraryButton = new Button();
private final Button importButton = new Button();
private final TextField search = new TextField();
private final CustomTextField search = new CustomTextField();
private final FontIcon searchIcon = new FontIcon(MaterialDesignM.MAGNIFY);
private final FontIcon searchClearIcon = new FontIcon(MaterialDesignC.CLOSE_CIRCLE);
private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(250));
public AppHeaderBar(ApplicationEventPublisher publisher,
@@ -98,8 +105,8 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
this.gallerySearchState = gallerySearchState;
getStyleClass().add("app-header-bar");
HBox brand = buildBrand();
TextField search = buildSearch();
HBox brand = buildBrand();
CustomTextField search = buildSearch();
setLeft(brand);
setCenter(search);
@@ -120,7 +127,9 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
// ever starts, and how much varies by platform (and disappears in fullscreen). boundsInParent
// already reflects all of that, whatever it happens to be, plus app.name's own translated width.
Runnable alignSearchWithGallery = () -> HeaderBar.setMargin(search, new Insets(0, 12, 0,
Math.max(0, NavigationSidebar.TOTAL_WIDTH - brand.getBoundsInParent().getMaxX())));
Math.max(0,
NavigationSidebar.TOTAL_WIDTH -
brand.getBoundsInParent().getMaxX())));
alignSearchWithGallery.run();
brand.boundsInParentProperty().addListener((obs, oldBounds, newBounds) -> alignSearchWithGallery.run());
@@ -321,7 +330,7 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
* alignment/margin that actually positions it) — no wrapping {@code HBox} needed, since a
* {@code CENTER_LEFT}-aligned centre child is resized to fill its whole span on its own.
*/
private TextField buildSearch() {
private CustomTextField buildSearch() {
search.promptTextProperty().bind(i18n.binding("action.search.prompt"));
// Unstyled, a TextField's own padding makes it taller than the header bar comfortably fits.
search.getStyleClass().addAll(Styles.TEXT_NORMAL, Styles.ROUNDED);
@@ -330,6 +339,17 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
searchTooltip.textProperty().bind(i18n.binding("action.search.tooltip"));
search.setTooltip(searchTooltip);
scope.addTeardown(() -> searchTooltip.textProperty().unbind());
// Left/right graphics via CustomTextField (gemsfx), not a plain TextField setGraphic — TextField
// is a TextInputControl, not a Labeled, so it has no graphic slot of its own to hijack.
searchIcon.getStyleClass().add("search-icon");
search.setLeft(searchIcon);
searchClearIcon.getStyleClass().add("search-clear-icon");
searchClearIcon.setCursor(Cursor.HAND);
// Only worth showing (and clicking) once there is something to clear.
searchClearIcon.visibleProperty().bind(search.textProperty().isNotEmpty());
searchClearIcon.managedProperty().bind(searchClearIcon.visibleProperty());
searchClearIcon.setOnMouseClicked(event -> search.clear());
search.setRight(searchClearIcon);
// Debounced, not applied on every keystroke: GallerySearchState.setQuery drives a full re-filter
// (and re-chunk) of ThumbnailGalleryPane's grid, too costly to redo on every character typed.
searchDebounce.setOnFinished(event -> gallerySearchState.setQuery(search.getText()));
@@ -351,6 +371,16 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
gallerySearchState.setQuery(search.getText());
publisher.publishEvent(new NavigateToDestinationEvent(ENavigationDestination.PHOTOS));
});
// Escape cancels whatever is being typed — only consumed when there is text to cancel, so an
// empty field still lets Escape bubble up to any other shortcut listening for it.
search.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ESCAPE && !search.getText().isEmpty()) {
searchDebounce.stop();
search.clear();
gallerySearchState.setQuery("");
event.consume();
}
});
return search;
}
@@ -361,7 +391,7 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
* matching the sidebar's own visual order of "library concerns first".
*/
private HBox buildActions() {
importButton.setGraphic(FontIcon.of(Feather.PLUS, 18));
importButton.setGraphic(FontIcon.of(Feather.PLUS, 20));
importButton.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT);
Tooltip importTooltip = new Tooltip();
importTooltip.textProperty().bind(i18n.binding("header.import.tooltip"));
@@ -375,7 +405,7 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
theme.setOnAction(event -> themeManager.cycle());
scope.add(themeManager.colorModeProperty().subscribe(this::updateThemeIcon));
Button settings = iconButton(FontIcon.of(Feather.SETTINGS, 18), "action.settings");
Button settings = iconButton(FontIcon.of(Feather.SETTINGS, 20), "action.settings");
settings.setOnAction(event -> modalService.show(SettingsView.class));
HBox actions = new HBox(4, buildLibrarySelector(), importButton, theme, settings);
@@ -418,6 +448,10 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
search.promptTextProperty().unbind();
search.setTooltip(null);
search.setOnAction(null);
search.setOnKeyPressed(null);
searchClearIcon.visibleProperty().unbind();
searchClearIcon.managedProperty().unbind();
searchClearIcon.setOnMouseClicked(null);
librarySelector.visibleProperty().unbind();
librarySelector.managedProperty().unbind();
librarySelector.setTooltip(null);
+17
View File
@@ -59,6 +59,23 @@
-fx-padding: 0 10 0 4;
}
/* Search bar's magnifier/clear glyphs — muted so neither outweighs the typed query itself. */
.app-header-bar .search-icon {
-fx-icon-color: -color-fg-muted;
-fx-icon-size: 20px;
-fx-padding: 0 0 0 6;
}
.app-header-bar .search-clear-icon {
-fx-icon-color: -color-fg-muted;
-fx-icon-size: 18px;
-fx-padding: 0 6 0 0;
}
.app-header-bar .search-clear-icon:hover {
-fx-icon-color: -color-fg-default;
}
/*
* The open-library selector: reads as text-in-the-header, not a boxed control. A MenuButton fakes its
* border the same way ComboBoxBase does, painting a second background layer under a 1px inset (see