feat(gallery): filter the grid by folder from the library tree
Clicking a FolderTreeItem narrows Photos to that folder's descendants; re-clicking the already-selected row deselects it and clears the filter, and picking any other sidebar destination clears it too. New "path:<folderId>:<url-encoded relative path>" SearchQueryParser prefix backs it, resolved via PathChildOfFilter. The folder id disambiguates two library roots that happen to share an identical subfolder name, since MediaFile#path() is relative to its own root and carries no root identity of its own. AppHeaderBar now syncs the search field both ways so the token is visible and editable, not just active underneath.
This commit is contained in:
@@ -309,6 +309,16 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
|
||||
// (and re-chunk) of ThumbnailGalleryPane's grid, too costly to redo on every character typed.
|
||||
searchDebounce.setOnFinished(event -> gallerySearchState.setQuery(search.getText()));
|
||||
scope.add(search.textProperty().subscribe((oldValue, newValue) -> searchDebounce.playFromStart()));
|
||||
// The other direction: LibraryFolderTree/NavigationSidebar write a "path:" token straight to
|
||||
// GallerySearchState on tree selection, bypassing this field entirely — without this, the filter
|
||||
// would be active with nothing in the search bar to show for it, or to let a user clear by hand.
|
||||
// The guard against an already-matching text is what keeps this from re-triggering the debounce
|
||||
// above into a redundant (if harmless) no-op re-set of the exact same query it just received.
|
||||
scope.add(gallerySearchState.queryProperty().subscribe(newQuery -> {
|
||||
if (!search.getText().equals(newQuery)) {
|
||||
search.setText(newQuery);
|
||||
}
|
||||
}));
|
||||
// Enter applies the query immediately (no need to wait out the debounce) and jumps to Photos —
|
||||
// the one destination a search result actually means anything in today.
|
||||
search.setOnAction(event -> {
|
||||
|
||||
@@ -9,6 +9,7 @@ import javafx.geometry.Orientation;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
@@ -25,6 +26,8 @@ import org.icroco.pholio.ui.common.FxThread;
|
||||
import org.icroco.pholio.ui.common.SubscriptionScope;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.icroco.pholio.ui.library.LibraryImportCoordinator;
|
||||
import org.icroco.pholio.ui.view.gallery.search.SearchQueryParser;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.kordamp.ikonli.feather.Feather;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
import org.kordamp.ikonli.materialdesign2.MaterialDesignF;
|
||||
@@ -55,9 +58,11 @@ import java.util.stream.Collectors;
|
||||
* click alone: {@link #removeFolder} first shows {@link RemoveFolderConfirmView}, unless a previous
|
||||
* confirmation was answered "don't ask again" ({@code library.skip-remove-confirmation}).
|
||||
*
|
||||
* <p><strong>Selecting a folder does nothing yet.</strong> There is no index to filter, so wiring an event
|
||||
* would mean publishing something nobody listens to; the selection is logged and that is all. Filtering the
|
||||
* grid by folder belongs with the indexing phase.
|
||||
* <p>Selecting a folder narrows the gallery to it — a {@code path:} token ({@link SearchQueryParser})
|
||||
* written to {@link GallerySearchState}, cleared the same way {@link #onFolderSelected} sees the selection
|
||||
* itself go back to {@code null}: either re-clicking the already-selected row ({@link FolderCell}'s own
|
||||
* press filter toggles the selection model off instead of leaving TreeView's default no-op reselect), or
|
||||
* {@link NavigationSidebar} clearing it once a destination other than Photos becomes active.
|
||||
*/
|
||||
@UiComponent
|
||||
public class LibraryFolderTree extends StackPane implements Disposable {
|
||||
@@ -97,6 +102,7 @@ public class LibraryFolderTree extends StackPane implements Disposable {
|
||||
private final LibraryImportCoordinator coordinator;
|
||||
private final AppPreferences preferences;
|
||||
private final ModalService modalService;
|
||||
private final GallerySearchState searchState;
|
||||
|
||||
private final SubscriptionScope scope = new SubscriptionScope();
|
||||
private final TreeView<Path> tree = new TreeView<>();
|
||||
@@ -109,19 +115,21 @@ public class LibraryFolderTree extends StackPane implements Disposable {
|
||||
TaskService taskService,
|
||||
LibraryImportCoordinator coordinator,
|
||||
AppPreferences preferences,
|
||||
ModalService modalService) {
|
||||
ModalService modalService,
|
||||
GallerySearchState searchState) {
|
||||
this.i18n = i18n;
|
||||
this.libraryFolderService = libraryFolderService;
|
||||
this.taskService = taskService;
|
||||
this.coordinator = coordinator;
|
||||
this.preferences = preferences;
|
||||
this.modalService = modalService;
|
||||
this.searchState = searchState;
|
||||
|
||||
getStyleClass().add("library-folder-tree");
|
||||
|
||||
tree.setShowRoot(false);
|
||||
tree.setRoot(new TreeItem<>());
|
||||
tree.setCellFactory(view -> new FolderCell(this::removeFolder));
|
||||
tree.setCellFactory(view -> new FolderCell(view, this::removeFolder));
|
||||
// Uniform row height across depths: FolderCell's row content (icon + label) is centered within
|
||||
// whatever height the cell ends up with, and a root row is naturally taller than a subfolder row
|
||||
// (it reserves space for the remove button, invisible until hovered). Without a fixed size the two
|
||||
@@ -154,6 +162,11 @@ public class LibraryFolderTree extends StackPane implements Disposable {
|
||||
// comment for why a size passed here would silently lose to AtlantaFX's theme stylesheet.
|
||||
addFolderIcon.getStyleClass().add("library-add-folder-icon");
|
||||
addFolder.setGraphic(addFolderIcon);
|
||||
// NOT capped with setMinSize/PrefSize/MaxSize here: that was tried and made it worse — a Button
|
||||
// sized larger than the row's own tallest existing content (NavigationSidebar's destination icon
|
||||
// and label) becomes the title graphic's tallest child itself, stretching the whole "Photos" row
|
||||
// past every sibling entry. pholio.css's own .library-add-folder-button padding is what keeps this
|
||||
// button no taller than that content instead.
|
||||
addFolder.setTooltip(new Tooltip());
|
||||
addFolder.getTooltip().textProperty().bind(i18n.binding("library.addFolder.tooltip"));
|
||||
addFolder.setOnAction(_ -> chooseFolder());
|
||||
@@ -342,10 +355,36 @@ public class LibraryFolderTree extends StackPane implements Disposable {
|
||||
removeFolder(item);
|
||||
}
|
||||
|
||||
private void onFolderSelected(TreeItem<Path> item) {
|
||||
if (item != null) {
|
||||
log.debug("Library folder selected: {}", item.getValue());
|
||||
/**
|
||||
* Drives {@link GallerySearchState}'s {@code path:} token straight from the tree's own selection —
|
||||
* fired with {@code null} both when {@link FolderCell}'s press filter clears the selection itself (a
|
||||
* re-click toggling it off) and by JavaFX's own selection model whenever a still-loading tree swaps
|
||||
* one {@link FolderTreeItem} for another (see {@link #replaceRoot}). Either way, "nothing selected"
|
||||
* means "no path filter" — the query is cleared, not left pointing at a row that no longer exists.
|
||||
*/
|
||||
private static boolean isSameOrDescendantOf(Node node, Node ancestor) {
|
||||
for (Node n = node; n != null; n = n.getParent()) {
|
||||
if (n == ancestor) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void onFolderSelected(@Nullable TreeItem<Path> item) {
|
||||
if (item == null) {
|
||||
searchState.setQuery("");
|
||||
return;
|
||||
}
|
||||
log.debug("Library folder selected: {}", item.getValue());
|
||||
libraryFolderService.list().stream()
|
||||
.filter(folder -> item.getValue().startsWith(folder.path()))
|
||||
.findFirst()
|
||||
.ifPresentOrElse(
|
||||
folder -> searchState.setQuery(
|
||||
SearchQueryParser.pathToken(Objects.requireNonNull(folder.id()),
|
||||
folder.path().relativize(item.getValue()))),
|
||||
() -> log.warn("Selected folder {} matches no known library root", item.getValue()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -384,7 +423,7 @@ public class LibraryFolderTree extends StackPane implements Disposable {
|
||||
|
||||
private final Consumer<TreeItem<Path>> onRemove;
|
||||
|
||||
private FolderCell(Consumer<TreeItem<Path>> onRemove) {
|
||||
private FolderCell(TreeView<Path> tree, Consumer<TreeItem<Path>> onRemove) {
|
||||
this.onRemove = onRemove;
|
||||
|
||||
actions.getStyleClass().addAll("folder-actions", "icon-toolbar");
|
||||
@@ -397,6 +436,24 @@ public class LibraryFolderTree extends StackPane implements Disposable {
|
||||
|
||||
setOnMouseEntered(event -> pseudoClassStateChanged(ROW_HOVER, true));
|
||||
setOnMouseExited(event -> pseudoClassStateChanged(ROW_HOVER, false));
|
||||
|
||||
// Re-clicking the already-selected row toggles it off instead of TreeView's default no-op
|
||||
// reselect — a filter, not a handler: it must run (and, when it applies, consume) before the
|
||||
// cell's own selection-on-press behaviour ever sees the event, the same way NavigationSidebar's
|
||||
// basePane keeps a title click from also toggling collapse. Unlike that case there is no
|
||||
// Button underneath needing its own chance to run first — consuming here is meant to fully
|
||||
// replace the default behaviour, not race it — except for the remove button itself: a click
|
||||
// landing on it (or its icon) must reach it untouched, so it is excluded by target ancestry.
|
||||
addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
|
||||
if (event.getTarget() instanceof Node target && isSameOrDescendantOf(target, actions)) {
|
||||
return;
|
||||
}
|
||||
TreeItem<Path> item = getTreeItem();
|
||||
if (item != null && item.equals(tree.getSelectionModel().getSelectedItem())) {
|
||||
event.consume();
|
||||
tree.getSelectionModel().clearSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -442,7 +499,7 @@ public class LibraryFolderTree extends StackPane implements Disposable {
|
||||
private Button removeButton() {
|
||||
Button remove = new Button();
|
||||
|
||||
remove.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT, Styles.BUTTON_CIRCLE, "library-add-folder-button");
|
||||
remove.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT, Styles.BUTTON_CIRCLE, Styles.WARNING, "library-add-folder-button");
|
||||
remove.setGraphic(FontIcon.of(MaterialDesignF.FOLDER_REMOVE_OUTLINE));
|
||||
remove.setOnAction(event -> onRemove.accept(getTreeItem()));
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ public class NavigationSidebar extends VBox implements Disposable {
|
||||
private final I18nService i18n;
|
||||
private final ApplicationEventPublisher publisher;
|
||||
private final ViewSwitcher viewSwitcher;
|
||||
private final GallerySearchState searchState;
|
||||
|
||||
private final SubscriptionScope scope = new SubscriptionScope();
|
||||
private final List<Entry> entries = new ArrayList<>();
|
||||
@@ -84,11 +85,13 @@ public class NavigationSidebar extends VBox implements Disposable {
|
||||
private final LibraryFolderTree libraryFolderTree;
|
||||
|
||||
public NavigationSidebar(I18nService i18n, ApplicationEventPublisher publisher,
|
||||
ViewSwitcher viewSwitcher, LibraryFolderTree libraryFolderTree) {
|
||||
ViewSwitcher viewSwitcher, LibraryFolderTree libraryFolderTree,
|
||||
GallerySearchState searchState) {
|
||||
this.i18n = i18n;
|
||||
this.publisher = publisher;
|
||||
this.viewSwitcher = viewSwitcher;
|
||||
this.libraryFolderTree = libraryFolderTree;
|
||||
this.searchState = searchState;
|
||||
|
||||
getStyleClass().add("navigation-sidebar");
|
||||
setMinWidth(WIDTH + VIEWPORT_GAP);
|
||||
@@ -267,6 +270,14 @@ public class NavigationSidebar extends VBox implements Disposable {
|
||||
entry.pane().getStyleClass().add("active");
|
||||
}
|
||||
});
|
||||
// A path filter only means anything while looking at Photos — LibraryFolderTree's own tree
|
||||
// selection survives navigating away (it is not itself reset), so this is what stops the filter
|
||||
// it wrote from silently narrowing a since-forgotten-about gallery next time Photos comes back.
|
||||
// Guarded by the token's own prefix, not cleared unconditionally: a query the user actually typed
|
||||
// (unrelated to the tree) must survive switching destinations and back.
|
||||
if (active != ENavigationDestination.PHOTOS && searchState.queryProperty().get().startsWith("path:")) {
|
||||
searchState.setQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.icroco.pholio.ui.view.gallery.filter;
|
||||
|
||||
import org.icroco.pholio.domain.library.MediaFile;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Matches a {@link MediaFile} that belongs to library folder {@code libraryFolderId} and whose
|
||||
* {@link MediaFile#path()} — relative to that folder's own root, never absolute, see that accessor's own
|
||||
* javadoc — is {@code relativeRoot} itself or a descendant of it.
|
||||
*
|
||||
* <p>{@code libraryFolderId} disambiguates between two roots that happen to share an identical subfolder
|
||||
* name (e.g. both containing a "2024" folder) — {@link MediaFile#path()} alone carries no root of its own
|
||||
* to tell them apart.
|
||||
*
|
||||
* @param relativeRoot relative to the owning library folder — the same frame of reference as
|
||||
* {@link MediaFile#path()} — never absolute; an empty path matches every file under
|
||||
* that root (a library folder's own root itself was selected, not a subfolder)
|
||||
*/
|
||||
public record PathChildOfFilter(Long libraryFolderId, Path relativeRoot) implements IMediaFileFilter {
|
||||
|
||||
@Override
|
||||
public boolean test(MediaFile file) {
|
||||
return libraryFolderId.equals(file.libraryFolderId())
|
||||
&& (relativeRoot.toString().isEmpty() || file.path().startsWith(relativeRoot));
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,15 @@ package org.icroco.pholio.ui.view.gallery.search;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.ERatingComparison;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.FreeTextFilter;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.IMediaFileFilter;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.PathChildOfFilter;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.RatingFilter;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.TagContainsFilter;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -24,6 +29,10 @@ import java.util.regex.Pattern;
|
||||
* less than 2.</li>
|
||||
* <li>{@code p:John} — a tag containing "John". There is no dedicated person/face model yet, so this
|
||||
* is aliased to a tag search until one exists — see {@link TagContainsFilter}.</li>
|
||||
* <li>{@code path:<folderId>:<url-encoded relative path>} — every file under one library folder's
|
||||
* subfolder; see {@link #pathToken}, the only intended producer of this token (the encoding is not
|
||||
* meant to be hand-typed). Wired from {@code LibraryFolderTree}'s own tree selection, not the search
|
||||
* bar directly — see {@link PathChildOfFilter}.</li>
|
||||
* </ul>
|
||||
* An unrecognised or malformed prefixed token (e.g. {@code r:abc}) is treated as a literal free-text term
|
||||
* instead of being dropped, so a typo still searches for something rather than silently matching nothing.
|
||||
@@ -68,10 +77,35 @@ public final class SearchQueryParser {
|
||||
return switch (prefix) {
|
||||
case "r" -> ratingFilter(value);
|
||||
case "p" -> new TagContainsFilter(value);
|
||||
case "path" -> pathFilter(value);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code path:} search token {@code LibraryFolderTree} sets on tree selection — the only
|
||||
* intended producer, see the class javadoc. {@code relativeRoot} is URL-encoded since it can contain
|
||||
* spaces or other characters the query tokenizer would otherwise split on (see {@link #TOKEN_PATTERN}
|
||||
* — a bare, unquoted run of non-whitespace).
|
||||
*/
|
||||
public static String pathToken(Long libraryFolderId, Path relativeRoot) {
|
||||
return "path:" + libraryFolderId + ":" + URLEncoder.encode(relativeRoot.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static @Nullable IMediaFileFilter pathFilter(String value) {
|
||||
int separator = value.indexOf(':');
|
||||
if (separator <= 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Long libraryFolderId = Long.valueOf(value.substring(0, separator));
|
||||
Path relativeRoot = Path.of(URLDecoder.decode(value.substring(separator + 1), StandardCharsets.UTF_8));
|
||||
return new PathChildOfFilter(libraryFolderId, relativeRoot);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable RatingFilter ratingFilter(String value) {
|
||||
Matcher matcher = RATING_PATTERN.matcher(value);
|
||||
if (!matcher.matches()) {
|
||||
|
||||
@@ -76,7 +76,7 @@ class LibraryFolderTreeTest {
|
||||
taskService = new TaskService(inert, inert, inert, inert, counting, inert, event -> {});
|
||||
section = onFxThread(() -> new LibraryFolderTree(i18n, libraryFolderService, taskService,
|
||||
mock(LibraryImportCoordinator.class),
|
||||
preferences, modalService));
|
||||
preferences, modalService, new GallerySearchState()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -284,7 +284,7 @@ class LibraryFolderTreeTest {
|
||||
when(service.list()).thenReturn(folders);
|
||||
return onFxThread(() -> new LibraryFolderTree(i18n, service, taskService,
|
||||
mock(LibraryImportCoordinator.class),
|
||||
preferences, modalService));
|
||||
preferences, modalService, new GallerySearchState()));
|
||||
}
|
||||
|
||||
private static LibraryFolder folder(long id, Path path) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.icroco.pholio.ui.view.gallery.filter;
|
||||
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.domain.library.MediaFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.BitSet;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PathChildOfFilterTest {
|
||||
|
||||
@Test
|
||||
void matchesTheRootItselfAndAnyDescendant() {
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
PathChildOfFilter filter = new PathChildOfFilter(1L, Path.of("2024", "été"));
|
||||
|
||||
softly.assertThat(filter.test(file(1L, "2024/été/beach.jpg"))).isTrue();
|
||||
softly.assertThat(filter.test(file(1L, "2024/été/nested/beach.jpg"))).isTrue();
|
||||
softly.assertThat(filter.test(file(1L, "2024/hiver/ski.jpg"))).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyRelativeRootMatchesEveryFileUnderThatLibraryFolder() {
|
||||
PathChildOfFilter filter = new PathChildOfFilter(1L, Path.of(""));
|
||||
|
||||
assertThat(filter.test(file(1L, "2024/été/beach.jpg"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverMatchesAFileFromADifferentLibraryFolderEvenWithTheSameRelativePath() {
|
||||
PathChildOfFilter filter = new PathChildOfFilter(1L, Path.of("2024"));
|
||||
|
||||
assertThat(filter.test(file(2L, "2024/beach.jpg"))).isFalse();
|
||||
}
|
||||
|
||||
private static MediaFile file(Long libraryFolderId, String path) {
|
||||
return new MediaFile(1L, libraryFolderId, Path.of(path), Instant.now(), Instant.now(), "hash", null, null, new BitSet());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.ERatingComparison;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.FreeTextFilter;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.IMediaFileFilter;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.PathChildOfFilter;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.RatingFilter;
|
||||
import org.icroco.pholio.ui.view.gallery.filter.TagContainsFilter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -44,6 +46,25 @@ class SearchQueryParserTest {
|
||||
assertThat(SearchQueryParser.parse("p:John")).containsExactly(new TagContainsFilter("John"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathTokenRoundTripsThroughAFolderIdAndAUrlEncodedRelativePath() {
|
||||
String token = SearchQueryParser.pathToken(3L, Path.of("2024", "été"));
|
||||
|
||||
assertThat(SearchQueryParser.parse(token)).containsExactly(new PathChildOfFilter(3L, Path.of("2024", "été")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyRelativePathMatchesTheWholeLibraryFolder() {
|
||||
String token = SearchQueryParser.pathToken(7L, Path.of(""));
|
||||
|
||||
assertThat(SearchQueryParser.parse(token)).containsExactly(new PathChildOfFilter(7L, Path.of("")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedPathTokenFallsBackToFreeText() {
|
||||
assertThat(SearchQueryParser.parse("path:notAnId:2024")).containsExactly(new FreeTextFilter(List.of("path:notAnId:2024")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefixedTokensAndFreeTextCombineIntoSeparateFiltersAndAllMustMatch() {
|
||||
List<IMediaFileFilter> filters = SearchQueryParser.parse("beach r:3+ p:John");
|
||||
|
||||
Reference in New Issue
Block a user