refactor(gallery): move the detail-mode info panel into PhotoDetailPane

MediaInfoPane used to be a GalleryView-owned HBox sibling for detail
mode, its open state tracked separately from PhotoDetailPane's own
visibility. PhotoDetailPane now owns and docks its own MediaInfoPane
instance directly, so it can never be shown once PhotoDetailPane
itself has closed. photoStack (imageView+overlay) is now the sizing
reference for fitting/hero-transform/slide-in math, since the pane's
own width also includes the docked info panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 22:31:32 -04:00
co-authored by Claude Sonnet 5
parent 0304b4ac41
commit 55c0fd71a6
4 changed files with 229 additions and 156 deletions
@@ -78,11 +78,10 @@ import java.util.function.UnaryOperator;
*
* <p>Two independent {@link MediaInfoPane} instances, not one shared between modes: {@link #mediaInfoPaneGrid}
* floats as an overlay inside {@link #viewportStack} (anchored right, on top of the grid, never resizing
* it) while browsing thumbnails, and {@link #mediaInfoPaneDetail} is this view's own {@code HBox} sibling —
* exactly the docked, width-animated panel this class always had — shown only in detail mode. An
* {@code HBox}, not a plain {@code StackPane}, because of that second instance: {@link #viewportStack} sits
* beside it rather than under it, so opening it shrinks the stack's own width instead of covering it — see
* {@link MediaInfoPane}'s own javadoc for why that matters.
* it) while browsing thumbnails, and the detail-mode one is {@link PhotoDetailPane}'s own — see
* {@link PhotoDetailPane#infoPane()} — docked inside {@link #detailPane} itself rather than a sibling here,
* exactly so it can never linger open once {@link #detailPane} itself has closed (see that class's own
* javadoc for the bug this ownership avoids).
*/
@UiView
public class GalleryView extends HBox implements Disposable, SelectionSource, NavigationAware {
@@ -93,7 +92,6 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
private final ThumbnailGalleryPane galleryPane;
private final PhotoDetailPane detailPane;
private final MediaInfoPane mediaInfoPaneGrid;
private final MediaInfoPane mediaInfoPaneDetail;
private final StackPane viewportStack = new StackPane();
private final LibraryFolderService libraryFolderService;
private final AppPreferences preferences;
@@ -116,16 +114,7 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
*/
private final BooleanProperty mediaInfoOpenGrid = new SimpleBooleanProperty(this, "mediaInfoOpenGrid", false);
/**
* {@code true} while {@link #mediaInfoPaneDetail} is open (or animating open) — toggled by the
* {@code I} shortcut whenever {@link #photoOpen} is true, and by {@link PhotoDetailPane}'s own info
* button. Persisted to {@code ui.right-panel-expanded} on every change, exactly as the single shared
* panel this replaces used to be.
*/
private final BooleanProperty mediaInfoOpenDetail = new SimpleBooleanProperty(this, "mediaInfoOpenDetail", false);
private @Nullable Subscription mediaInfoOpenPersistSubscription;
private @Nullable Subscription mediaInfoOpenDetailEffectiveSubscription;
private @Nullable Subscription selectedFileSubscription;
private @Nullable Subscription searchQuerySubscription;
@@ -182,10 +171,9 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
this.gallerySearchState = gallerySearchState;
emptyLibraryPane = new EmptyLibraryPane(i18n, coordinator);
galleryPane = new ThumbnailGalleryPane(mediaFileService, taskService, mediaLibraryState, preferences, i18n, imageCache);
detailPane = new PhotoDetailPane(fullImageCache);
detailPane = new PhotoDetailPane(fullImageCache, i18n, faceRegionQueryService);
mediaInfoPaneGrid = new MediaInfoPane(i18n, faceRegionQueryService);
mediaInfoPaneGrid.getStyleClass().add("media-info-pane-overlay");
mediaInfoPaneDetail = new MediaInfoPane(i18n, faceRegionQueryService);
galleryPane.setOnOpenRequest((file, sourceThumbnail) -> openDetail(file, sourceThumbnail, null));
galleryPane.setOnRegenerateThumbnails(this::regenerateThumbnails);
galleryPane.setOnSetGpsForFiles(this::openLocationDialogForFiles);
@@ -193,7 +181,6 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
detailPane.setOnClose(this::closeDetail);
detailPane.setOnPrevious(() -> navigate(galleryPane::previous, PhotoDetailPane.ESlideDirection.LEFT));
detailPane.setOnNext(() -> navigate(galleryPane::next, PhotoDetailPane.ESlideDirection.RIGHT));
detailPane.setOnInfo(() -> mediaInfoOpenDetail.set(!mediaInfoOpenDetail.get()));
detailPane.setOnToggleFavorite(this::toggleFavorite);
mediaInfoPaneGrid.setOnClose(() -> {
mediaInfoOpenGrid.set(false);
@@ -203,17 +190,12 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
mediaInfoPaneGrid.setOnEditLocation(this::openLocationDialog);
mediaInfoPaneGrid.setOnEditRating(this::updateRating);
mediaInfoPaneGrid.setOnShowDetails(this::openMetadataDetailsDialog);
// Its own close button is a Button inside the panel, so clicking it grabs focus there — hand it
// straight back to the detail pane, the same as PhotoDetailPane now does for any click back into
// itself (see its own javadoc).
mediaInfoPaneDetail.setOnClose(() -> {
mediaInfoOpenDetail.set(false);
detailPane.requestFocus();
});
mediaInfoPaneDetail.setOnEditDate(this::openDateDialog);
mediaInfoPaneDetail.setOnEditLocation(this::openLocationDialog);
mediaInfoPaneDetail.setOnEditRating(this::updateRating);
mediaInfoPaneDetail.setOnShowDetails(this::openMetadataDetailsDialog);
// detailPane wires its own info panel's close button (and I shortcut/info button) internally —
// this only needs to hook the dialogs/edits it owns, same four callbacks as mediaInfoPaneGrid above.
detailPane.infoPane().setOnEditDate(this::openDateDialog);
detailPane.infoPane().setOnEditLocation(this::openLocationDialog);
detailPane.infoPane().setOnEditRating(this::updateRating);
detailPane.infoPane().setOnShowDetails(this::openMetadataDetailsDialog);
emptyLibraryPane.visibleProperty().bind(statusBar.libraryConfiguredProperty().not());
emptyLibraryPane.managedProperty().bind(emptyLibraryPane.visibleProperty());
@@ -225,23 +207,14 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
StackPane.setAlignment(mediaInfoPaneGrid, Pos.CENTER_RIGHT);
viewportStack.getChildren().addAll(galleryPane, emptyLibraryPane, mediaInfoPaneGrid, detailPane);
HBox.setHgrow(viewportStack, Priority.ALWAYS);
getChildren().addAll(viewportStack, mediaInfoPaneDetail);
getChildren().add(viewportStack);
// subscribe(Consumer) fires once immediately with the current value, seeding each panel's own
// open/closed state — no separate setOpen call needed alongside it. The grid overlay always starts
// closed (see its own field javadoc); only the detail one is persisted.
// closed (see its own field javadoc); detailPane's own info panel is persisted instead, below.
mediaInfoOpenGrid.subscribe(mediaInfoPaneGrid::setOpen);
mediaInfoOpenDetail.set(preferences.getValueOr("ui", "right-panel-expanded", Boolean.class, false));
// mediaInfoPaneDetail is only ever meant to show alongside detailPane — gating its own open state
// on photoOpen too (not just mediaInfoOpenDetail alone) is what actually collapses it back to width
// 0 the moment detailPane closes; mediaInfoOpenDetail itself stays whatever it was (persisted below)
// so reopening detailPane brings the panel straight back, matching the old single-pane behaviour.
// Without this, closeDetail() leaves mediaInfoPaneDetail sitting open as this view's own HBox
// sibling while mediaInfoPaneGrid (never touched by closeDetail) reappears over the grid underneath
// it — two panels shown side by side on the right, from two different, independently-tracked panes.
mediaInfoOpenDetailEffectiveSubscription = mediaInfoOpenDetail.and(photoOpen)
.subscribe(mediaInfoPaneDetail::setOpen);
mediaInfoOpenPersistSubscription = mediaInfoOpenDetail.subscribe(
detailPane.setInfoOpen(preferences.getValueOr("ui", "right-panel-expanded", Boolean.class, false));
mediaInfoOpenPersistSubscription = detailPane.infoOpenProperty().subscribe(
(previous, current) -> preferences.setValue("ui", "right-panel-expanded", current));
selectedFileSubscription = galleryPane.selectedFileProperty().subscribe(this::refreshMediaInfoPanes);
// subscribe(Consumer) fires once immediately with the current value (see the seeding note above),
@@ -315,16 +288,15 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
private void refreshMediaInfoPanes(@Nullable MediaFile file) {
mediaInfoPaneGrid.setFile(file);
mediaInfoPaneDetail.setFile(file);
detailPane.infoPane().setFile(file);
}
/** Only the grid overlay's own {@code I} toggling lives here — {@link PhotoDetailPane} holds focus and
* handles {@code I} for its own info panel itself while {@link #photoOpen} is true (see its own
* javadoc), so this must not consume the event in that case. */
private void onKeyPressed(KeyEvent event) {
if (event.getCode() == KeyCode.I) {
if (photoOpen.get()) {
mediaInfoOpenDetail.set(!mediaInfoOpenDetail.get());
} else {
mediaInfoOpenGrid.set(!mediaInfoOpenGrid.get());
}
if (event.getCode() == KeyCode.I && !photoOpen.get()) {
mediaInfoOpenGrid.set(!mediaInfoOpenGrid.get());
event.consume();
}
}
@@ -336,13 +308,6 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
return mediaInfoPaneGrid;
}
/**
* Exposed for tests, the same way {@link #mediaInfoPaneGrid()} is.
*/
MediaInfoPane mediaInfoPaneDetail() {
return mediaInfoPaneDetail;
}
/**
* Exposed for tests, the same way {@link #mediaInfoPaneGrid()} is.
*/
@@ -540,8 +505,8 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
/**
* {@link PhotoDetailPane#setOnToggleFavorite}'s target — the top bar's star button, toggling between no
* rating and {@link #FAVORITE_RATING} rather than opening {@link #mediaInfoPaneGrid}/{@link #mediaInfoPaneDetail}'s own fine-grained
* {@link StarRatingControl}; that finer control stays reachable from there for whoever wants an actual
* rating and {@link #FAVORITE_RATING} rather than opening {@link #mediaInfoPaneGrid}/{@link PhotoDetailPane#infoPane()}'s
* own fine-grained {@link StarRatingControl}; that finer control stays reachable from there for whoever wants an actual
* 1-4 value.
*/
private void toggleFavorite() {
@@ -595,8 +560,8 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
/**
* {@link ThumbnailGalleryPane#setOnSetGpsForFiles}'s target — the date header's own "set location" menu
* entry. Same dialog and search hookup as {@link #openLocationDialog}, applied to every one of
* {@code files} once a place is picked, instead of to a single currently-viewed one; {@link #mediaInfoPaneGrid}/{@link #mediaInfoPaneDetail}
* is never touched here, unlike {@link #openLocationDialog}, since none of these files is necessarily
* {@code files} once a place is picked, instead of to a single currently-viewed one; {@link #mediaInfoPaneGrid}/
* {@link PhotoDetailPane#infoPane()} is never touched here, unlike {@link #openLocationDialog}, since none of these files is necessarily
* the one it's currently showing. A no-op if {@code files} is empty — the menu entry that triggers this
* is only ever visible when at least one file is checked, but a checkbox toggled off between opening the
* menu and clicking the entry could still land here empty.
@@ -668,9 +633,6 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
if (mediaInfoOpenPersistSubscription != null) {
mediaInfoOpenPersistSubscription.unsubscribe();
}
if (mediaInfoOpenDetailEffectiveSubscription != null) {
mediaInfoOpenDetailEffectiveSubscription.unsubscribe();
}
if (selectedFileSubscription != null) {
selectedFileSubscription.unsubscribe();
}
@@ -681,6 +643,5 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na
galleryPane.dispose();
detailPane.dispose();
mediaInfoPaneGrid.dispose();
mediaInfoPaneDetail.dispose();
}
}
@@ -1,13 +1,9 @@
package org.icroco.pholio.ui.view.gallery;
import atlantafx.base.controls.Spacer;
import javafx.animation.FadeTransition;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.PauseTransition;
import javafx.animation.Timeline;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -19,6 +15,7 @@ import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.transform.Rotate;
import javafx.scene.transform.Scale;
@@ -26,6 +23,9 @@ import javafx.util.Duration;
import org.icroco.pholio.domain.library.MediaFile;
import org.icroco.pholio.domain.media.MediaMetadata;
import org.icroco.pholio.domain.media.Orientation;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.infra.library.MediaMetadataEditService;
import org.icroco.pholio.infra.recognition.FaceRegionQueryService;
import org.icroco.pholio.ui.common.Disposable;
import org.icroco.pholio.ui.common.SubscriptionScope;
import org.icroco.pholio.ui.util.EasingFX;
@@ -70,37 +70,71 @@ import java.util.function.Consumer;
* "nothing more this way" feedback.
*
* <p>Stateless about what any of the six actions actually do — {@link #setOnClose} and friends are the
* only contract with {@link GalleryView}, which owns navigation order, favourite/trash state and the
* info panel, none of which exist here.
* only contract with {@link GalleryView}, which owns navigation order and favourite/trash state.
*
* <p>Owns its own docked {@link #infoPane} — an {@code HBox} of {@link #photoStack} (grown) and
* {@link #infoPane} (fixed-width, collapsible), not a separate sibling {@link GalleryView} has to keep in
* sync with whether this pane is even open: the previous split-ownership design (one {@code MediaInfoPane}
* living in {@code GalleryView}'s own {@code HBox}, its open state tracked independently of this pane's)
* could leave that instance open — and rendered — after this pane itself had already closed, since nothing
* tied its width collapse to this pane's own visibility. Nesting it here instead means it can never be
* shown while this pane isn't: it is simply one of this pane's own children, gone the moment this one is.
* {@link #photoStack}, not this pane's own full bounds, is what {@link #fitImageView}/{@link #heroTransformFor}/
* {@link #slideIn} size themselves against — the actual photo-viewing area, excluding whatever width
* {@link #infoPane} currently occupies alongside it.
*/
public class PhotoDetailPane extends StackPane implements Disposable {
private final ImageView imageView = new ImageView();
private final Button closeButton = overlayButton(Feather.X);
private final Button infoButton = overlayButton(Feather.INFO);
/**
* Holds {@link #imageView} and {@link #overlay} — the actual photo-viewing area, sized independently
* of {@link #infoPane} occupying the rest of this pane's own width; see this class's own javadoc.
*/
private final StackPane photoStack = new StackPane();
/** Outline by default, swapped to a filled glyph by {@link #refreshFavorite}/{@link #setFavorite} — both draw white, same as every other overlay icon (see {@code pholio.css}'s {@code .photo-detail-icon-button}), the shape alone carries the favorite state. */
/**
* This pane's own docked info panel — see this class's own javadoc for why it lives here rather than
* as a {@link GalleryView} sibling.
*/
private final MediaInfoPane infoPane;
private final Button closeButton = overlayButton(Feather.X);
private final Button infoButton = overlayButton(Feather.INFO);
/**
* Outline by default, swapped to a filled glyph by {@link #refreshFavorite}/{@link #setFavorite} — both draw white, same as every other overlay icon (see {@code pholio.css}'s {@code .photo-detail-icon-button}), the shape alone carries the favorite state.
*/
private final FontIcon favoriteIcon = new FontIcon(Material2MZ.STAR_BORDER);
private final Button favoriteButton = overlayIconButton(favoriteIcon);
private final Button deleteButton = overlayButton(Feather.TRASH_2);
private final Button previousButton = overlayButton(Feather.CHEVRON_LEFT);
private final Button nextButton = overlayButton(Feather.CHEVRON_RIGHT);
private final Button deleteButton = overlayButton(Feather.TRASH_2);
private final Button previousButton = overlayButton(Feather.CHEVRON_LEFT);
private final Button nextButton = overlayButton(Feather.CHEVRON_RIGHT);
/** Every close/prev/next/info/favorite/delete button, floating on top of {@link #imageView} — see {@link #showOverlay}. */
/**
* Every close/prev/next/info/favorite/delete button, floating on top of {@link #imageView} — see {@link #showOverlay}.
*/
private final BorderPane overlay = new BorderPane();
/** {@link #overlay}'s own fade in/out — a plain {@code setOpacity}/{@code setVisible} flip is what causes the
* flicker this is meant to avoid; a short animated cross-fade reads as an intentional reveal instead. */
/**
* {@link #overlay}'s own fade in/out — a plain {@code setOpacity}/{@code setVisible} flip is what causes the
* flicker this is meant to avoid; a short animated cross-fade reads as an intentional reveal instead.
*/
private final FadeTransition overlayFade = new FadeTransition(Duration.millis(200), overlay);
/** Restarted on every mouse move over this pane; {@link #hideOverlay} once 3s pass with none — see {@link #showOverlay}. */
/**
* Restarted on every mouse move over this pane; {@link #hideOverlay} once 3s pass with none — see {@link #showOverlay}.
*/
private final PauseTransition overlayIdle = new PauseTransition(Duration.seconds(3));
/** How long {@link #playOpenAnimation}/{@link #playCloseAnimation}'s grow/shrink takes. */
/**
* How long {@link #playOpenAnimation}/{@link #playCloseAnimation}'s grow/shrink takes.
*/
private static final Duration HERO_DURATION = Duration.millis(260);
/** In-flight grow or shrink transition, if any — stopped before a new one starts (see {@link #playHero}). */
/**
* In-flight grow or shrink transition, if any — stopped before a new one starts (see {@link #playHero}).
*/
private @Nullable Timeline heroTransition;
/**
@@ -112,12 +146,15 @@ public class PhotoDetailPane extends StackPane implements Disposable {
*/
private @Nullable Bounds openOrigin;
/** How long {@link #slideIn} takes to bring the incoming photo fully into place. */
/**
* How long {@link #slideIn} takes to bring the incoming photo fully into place.
*/
private static final Duration SLIDE_DURATION = Duration.millis(320);
/**
* {@link #slideIn}'s travel distance is this fraction of this pane's own current width (capped at
* {@link #SLIDE_DISTANCE_MAX}) — never the pane's full width, let alone the window's. {@link #imageView}
* {@link #slideIn}'s travel distance is this fraction of {@link #photoStack}'s own current width (capped
* at {@link #SLIDE_DISTANCE_MAX}) — never this whole pane's width (which also includes {@link #infoPane}
* when open), let alone the window's. {@link #imageView}
* itself can be considerably narrower than the pane once letterboxed (see its own javadoc), so a
* distance derived from the incoming photo's own fitted size would risk starting further out than this
* pane's own edge, right back where the previous attempt at this went wrong: with a wide enough pane
@@ -131,10 +168,14 @@ public class PhotoDetailPane extends StackPane implements Disposable {
*/
private static final double SLIDE_DISTANCE_FRACTION = 0.35;
/** See {@link #SLIDE_DISTANCE_FRACTION}. */
/**
* See {@link #SLIDE_DISTANCE_FRACTION}.
*/
private static final double SLIDE_DISTANCE_MAX = 320;
/** {@link #show(MediaFile, Path, ESlideDirection)}'s own in-flight transition, if any. */
/**
* {@link #show(MediaFile, Path, ESlideDirection)}'s own in-flight transition, if any.
*/
private @Nullable Timeline slideTransition;
/**
@@ -153,16 +194,24 @@ public class PhotoDetailPane extends StackPane implements Disposable {
*/
private Orientation currentOrientation = Orientation.NORMAL;
/** {@link #incomingImageView}'s own EXIF orientation while a {@link #slideIn} is in flight — see {@link #currentOrientation}'s javadoc for why this can't just be read off {@link #currentFile}. */
/**
* {@link #incomingImageView}'s own EXIF orientation while a {@link #slideIn} is in flight — see {@link #currentOrientation}'s javadoc for why this can't just be read off {@link #currentFile}.
*/
private Orientation incomingOrientation = Orientation.NORMAL;
/** How far {@link #bounceEdge} nudges {@link #imageView}, in pixels, before springing back. */
/**
* How far {@link #bounceEdge} nudges {@link #imageView}, in pixels, before springing back.
*/
private static final double BOUNCE_DISTANCE = 24;
/** How long {@link #bounceEdge}'s whole nudge-then-spring-back takes. */
/**
* How long {@link #bounceEdge}'s whole nudge-then-spring-back takes.
*/
private static final Duration BOUNCE_DURATION = Duration.millis(450);
/** {@link #bounceEdge}'s own in-flight transition, if any — a rapid run of repeated presses at either end of the list restarts it rather than stacking nudges. */
/**
* {@link #bounceEdge}'s own in-flight transition, if any — a rapid run of repeated presses at either end of the list restarts it rather than stacking nudges.
*/
private @Nullable Timeline bounceTransition;
/**
@@ -178,30 +227,35 @@ public class PhotoDetailPane extends StackPane implements Disposable {
private @Nullable MediaFile currentFile;
/** Serves already-decoded full-resolution {@link Image}s (a cache hit) or decodes off the FX thread
* (a miss) — see {@link #loadInto}. {@code GalleryView} prefetches the files either side of whichever
* one is showing, so a plain Left/Right press usually is a hit. */
/**
* Serves already-decoded full-resolution {@link Image}s (a cache hit) or decodes off the FX thread
* (a miss) — see {@link #loadInto}. {@code GalleryView} prefetches the files either side of whichever
* one is showing, so a plain Left/Right press usually is a hit.
*/
private final FullImageCache imageCache;
private Runnable onClose = () -> {};
private Runnable onPrevious = () -> {};
private Runnable onNext = () -> {};
private Runnable onInfo = () -> {};
private Runnable onToggleFavorite = () -> {};
private Runnable onDelete = () -> {};
public PhotoDetailPane(FullImageCache imageCache) {
public PhotoDetailPane(FullImageCache imageCache, I18nService i18n, FaceRegionQueryService faceRegionQueryService) {
this.imageCache = imageCache;
this.infoPane = new MediaInfoPane(i18n, faceRegionQueryService);
getStyleClass().add("photo-detail-pane");
// ImageView reports minWidth/minHeight equal to its current fitWidth/fitHeight (it never shrinks
// itself for a parent's sake) — left alone, that inflates this whole StackPane's own computed
// minimum to whatever the photo was last fit at, which then becomes a hard floor a shrinking
// sibling (MediaInfoPane, inside GalleryView's HBox) can't push below. Pinning both to 0 here
// removes that floor: this pane must always accept whatever width/height its parent gives it,
// never dictate growth, and refitImageViews (already wired below) shrinks the image to match.
// itself for a parent's sake) — left alone, that inflates photoStack's own computed minimum to
// whatever the photo was last fit at, which then becomes a hard floor infoPane, its HBox sibling,
// can't push below. Pinning both to 0 here removes that floor: photoStack must always accept
// whatever width/height the HBox gives it, never dictate growth, and refitImageViews (already
// wired below) shrinks the image to match. Pinned on this pane itself too, for the same reason one
// level up — a shrinking GalleryView must never be floored by whatever this pane last grew to.
setMinWidth(0);
setMinHeight(0);
photoStack.setMinWidth(0);
photoStack.setMinHeight(0);
HBox topBar = new HBox(closeButton, new Spacer(), infoButton, favoriteButton, deleteButton);
topBar.setAlignment(Pos.CENTER);
@@ -216,17 +270,25 @@ public class PhotoDetailPane extends StackPane implements Disposable {
BorderPane.setMargin(previousButton, new Insets(0, 0, 0, 16));
BorderPane.setMargin(nextButton, new Insets(0, 16, 0, 0));
getChildren().addAll(imageView, overlay);
photoStack.getChildren().addAll(imageView, overlay);
HBox.setHgrow(photoStack, Priority.ALWAYS);
HBox root = new HBox(photoStack, infoPane);
getChildren().add(root);
infoPane.setOnClose(() -> {
setInfoOpen(false);
requestFocus();
});
closeButton.setOnAction(event -> onClose.run());
previousButton.setOnAction(event -> onPrevious.run());
nextButton.setOnAction(event -> onNext.run());
infoButton.setOnAction(event -> onInfo.run());
infoButton.setOnAction(event -> setInfoOpen(!infoPane.openProperty().get()));
favoriteButton.setOnAction(event -> onToggleFavorite.run());
deleteButton.setOnAction(event -> onDelete.run());
scope.add(widthProperty().subscribe(width -> refitImageViews()));
scope.add(heightProperty().subscribe(height -> refitImageViews()));
scope.add(photoStack.widthProperty().subscribe(width -> refitImageViews()));
scope.add(photoStack.heightProperty().subscribe(height -> refitImageViews()));
overlayIdle.setOnFinished(event -> hideOverlay());
// A filter, not a handler: fires regardless of which descendant (imageView, or one of the overlay
@@ -247,7 +309,11 @@ public class PhotoDetailPane extends StackPane implements Disposable {
addEventFilter(KeyEvent.KEY_PRESSED, this::onKeyPressed);
}
/** Left/Right mirror {@link #previousButton}/{@link #nextButton}; Escape mirrors {@link #closeButton}. */
/**
* Left/Right mirror {@link #previousButton}/{@link #nextButton}; Escape mirrors {@link #closeButton};
* I mirrors {@link #infoButton}. {@code GalleryView}'s own top-level {@code I} handling only ever
* fires while this pane isn't open (see its own javadoc), so there is no conflict to arbitrate here.
*/
private void onKeyPressed(KeyEvent event) {
switch (event.getCode()) {
case LEFT -> {
@@ -262,11 +328,39 @@ public class PhotoDetailPane extends StackPane implements Disposable {
onClose.run();
event.consume();
}
case I -> {
setInfoOpen(!infoPane.openProperty().get());
event.consume();
}
default -> {
}
}
}
/**
* Whether this pane's own {@link #infoPane} is open — mirrors {@link MediaInfoPane#openProperty()}.
*/
public ReadOnlyBooleanProperty infoOpenProperty() {
return infoPane.openProperty();
}
/**
* Opens or closes {@link #infoPane} — the {@code I} shortcut, {@link #infoButton} and {@code GalleryView}'s
* own persistence binding all go through this rather than touching {@link #infoPane} directly.
*/
public void setInfoOpen(boolean open) {
infoPane.setOpen(open);
}
/**
* {@code GalleryView}'s hook to wire this pane's own info panel callbacks (edit date/location/rating,
* show details) and keep it fed with the currently-selected file — package-private, the same as every
* other pane {@code GalleryView} exposes for tests.
*/
MediaInfoPane infoPane() {
return infoPane;
}
/**
* Cancels any hide in flight and (only if it isn't already fully shown — repeated calls happen on
* every pixel of mouse movement, and re-triggering the fade each time is exactly the flicker this
@@ -286,7 +380,9 @@ public class PhotoDetailPane extends StackPane implements Disposable {
overlayIdle.playFromStart();
}
/** Cross-fades {@link #overlay} to fully transparent, then disables its mouse picking so an invisible button can't still eat a click. */
/**
* Cross-fades {@link #overlay} to fully transparent, then disables its mouse picking so an invisible button can't still eat a click.
*/
private void hideOverlay() {
overlayFade.stop();
overlayFade.setFromValue(overlay.getOpacity());
@@ -301,21 +397,23 @@ public class PhotoDetailPane extends StackPane implements Disposable {
* viewer's own pane is always at least as big as the thumbnail it grew from in practice) plus a
* translate, since {@link #setScaleX}/{@link #setScaleY} pivot around this node's own centre: the
* scaled node's centre only needs shifting by {@code targetCentre - paneCentre} to land exactly on
* {@code screenBounds}' centre. {@code null} if this pane has no laid-out size yet, or isn't part of a
* showing window at all (so {@link #screenToLocal} has nothing to resolve against).
* {@code screenBounds}' centre. Computed against {@link #photoStack}, not this pane's own full bounds
* — see this class's own javadoc for why — so {@link #infoPane} being open never skews the scale.
* {@code null} if {@link #photoStack} has no laid-out size yet, or isn't part of a showing window at
* all (so {@link Node#screenToLocal} has nothing to resolve against).
*/
private @Nullable HeroTransform heroTransformFor(Bounds screenBounds) {
double width = getWidth();
double height = getHeight();
double width = photoStack.getWidth();
double height = photoStack.getHeight();
if (width <= 0 || height <= 0) {
return null;
}
Bounds local = screenToLocal(screenBounds);
Bounds local = photoStack.screenToLocal(screenBounds);
if (local == null) {
return null;
}
double scaleX = local.getWidth() / width;
double scaleY = local.getHeight() / height;
double scaleX = local.getWidth() / width;
double scaleY = local.getHeight() / height;
double translateX = local.getMinX() + local.getWidth() / 2 - width / 2;
double translateY = local.getMinY() + local.getHeight() / 2 - height / 2;
return new HeroTransform(scaleX, scaleY, translateX, translateY);
@@ -325,20 +423,25 @@ public class PhotoDetailPane extends StackPane implements Disposable {
private static final HeroTransform IDENTITY = new HeroTransform(1, 1, 0, 0);
}
/** Animates {@link #scaleXProperty()}/{@link #scaleYProperty()}/{@link #translateXProperty()}/{@link #translateYProperty()} from {@code from} to {@code to}, stopping whatever hero transition was already in flight first. */
/**
* Animates {@link #photoStack}'s own {@code scaleX}/{@code scaleY}/{@code translateX}/{@code translateY}
* from {@code from} to {@code to}, stopping whatever hero transition was already in flight first —
* {@link #photoStack}, not this whole pane, so {@link #infoPane} (if open) never scales/moves along
* with the photo growing off its thumbnail.
*/
private void playHero(HeroTransform from, HeroTransform to, @Nullable Runnable onFinished) {
if (heroTransition != null) {
heroTransition.stop();
}
setScaleX(from.scaleX());
setScaleY(from.scaleY());
setTranslateX(from.translateX());
setTranslateY(from.translateY());
photoStack.setScaleX(from.scaleX());
photoStack.setScaleY(from.scaleY());
photoStack.setTranslateX(from.translateX());
photoStack.setTranslateY(from.translateY());
heroTransition = new Timeline(new KeyFrame(HERO_DURATION,
new KeyValue(scaleXProperty(), to.scaleX(), Interpolator.EASE_BOTH),
new KeyValue(scaleYProperty(), to.scaleY(), Interpolator.EASE_BOTH),
new KeyValue(translateXProperty(), to.translateX(), Interpolator.EASE_BOTH),
new KeyValue(translateYProperty(), to.translateY(), Interpolator.EASE_BOTH)));
new KeyValue(photoStack.scaleXProperty(), to.scaleX(), Interpolator.EASE_BOTH),
new KeyValue(photoStack.scaleYProperty(), to.scaleY(), Interpolator.EASE_BOTH),
new KeyValue(photoStack.translateXProperty(), to.translateX(), Interpolator.EASE_BOTH),
new KeyValue(photoStack.translateYProperty(), to.translateY(), Interpolator.EASE_BOTH)));
heroTransition.setOnFinished(event -> {
if (onFinished != null) {
onFinished.run();
@@ -357,9 +460,9 @@ public class PhotoDetailPane extends StackPane implements Disposable {
* <p>Made invisible ({@link #setOpacity}, not {@link #setVisible} — the latter would undo the managed
* flag {@code GalleryView} just set, and with it the real layout size this whole computation needs)
* until a pulse after that: this pane has only just become managed this same pulse, so
* {@link #getWidth()}/{@link #getHeight()} still report whatever they were before, not the real size
* the very next layout pass is about to assign — computing the starting transform against them now
* would place it wrong for exactly one frame, a flash this hides.
* {@link #photoStack}'s own {@code getWidth()}/{@code getHeight()} still report whatever they were
* before, not the real size the very next layout pass is about to assign — computing the starting
* transform against them now would place it wrong for exactly one frame, a flash this hides.
*
* <p>{@code sourceScreenBounds} being {@code null} (called from previous/next, which have no clicked
* thumbnail — see {@code GalleryView.openDetail}'s {@code firstOpen} guard, though, means this is
@@ -400,12 +503,13 @@ public class PhotoDetailPane extends StackPane implements Disposable {
onFinished.run();
return;
}
HeroTransform start = new HeroTransform(getScaleX(), getScaleY(), getTranslateX(), getTranslateY());
HeroTransform start = new HeroTransform(photoStack.getScaleX(), photoStack.getScaleY(),
photoStack.getTranslateX(), photoStack.getTranslateY());
playHero(start, end, () -> {
setScaleX(1);
setScaleY(1);
setTranslateX(0);
setTranslateY(0);
photoStack.setScaleX(1);
photoStack.setScaleY(1);
photoStack.setTranslateX(0);
photoStack.setTranslateY(0);
onFinished.run();
});
}
@@ -441,7 +545,9 @@ public class PhotoDetailPane extends StackPane implements Disposable {
showOverlay();
}
/** {@code favoriteIcon}'s own reflection of {@code file}'s current rating — see {@link MediaMetadata#isFavorite()}. */
/**
* {@code favoriteIcon}'s own reflection of {@code file}'s current rating — see {@link MediaMetadata#isFavorite()}.
*/
private void refreshFavorite(MediaFile file) {
MediaMetadata metadata = file.metadata();
setFavorite(metadata != null && metadata.isFavorite());
@@ -488,7 +594,9 @@ public class PhotoDetailPane extends StackPane implements Disposable {
showOverlay();
}
/** The file {@link #show} was last called with, or {@code null} before it ever has been. */
/**
* The file {@link #show} was last called with, or {@code null} before it ever has been.
*/
public @Nullable MediaFile currentFile() {
return currentFile;
}
@@ -566,20 +674,22 @@ public class PhotoDetailPane extends StackPane implements Disposable {
ImageView incoming = new ImageView(incomingImage);
incoming.setPreserveRatio(true);
incomingImageView = incoming;
getChildren().add(getChildren().indexOf(imageView) + 1, incoming);
photoStack.getChildren().add(photoStack.getChildren().indexOf(imageView) + 1, incoming);
fitImageView(incoming, incomingImage, orientation);
double distance = Math.min(getWidth() * SLIDE_DISTANCE_FRACTION, SLIDE_DISTANCE_MAX);
double distance = Math.min(photoStack.getWidth() * SLIDE_DISTANCE_FRACTION, SLIDE_DISTANCE_MAX);
incoming.setTranslateX(direction == ESlideDirection.LEFT ? -distance : distance);
slideTransition = new Timeline(new KeyFrame(SLIDE_DURATION,
new KeyValue(incoming.translateXProperty(), 0, EasingFX.MATERIAL_EASE_OUT)));
new KeyValue(incoming.translateXProperty(), 0, EasingFX.MATERIAL_EASE_OUT)));
slideTransition.setOnFinished(event -> completeSlide(incomingImage));
slideTransition.play();
}
/** {@link #slideIn}'s own {@code onFinished}: the incoming photo has fully arrived, so it becomes {@link #imageView}'s content and the transient overlay view is torn down. */
/**
* {@link #slideIn}'s own {@code onFinished}: the incoming photo has fully arrived, so it becomes {@link #imageView}'s content and the transient overlay view is torn down.
*/
private void completeSlide(@Nullable Image incomingImage) {
setImage(incomingImage, incomingOrientation);
removeIncomingImageView();
@@ -604,7 +714,7 @@ public class PhotoDetailPane extends StackPane implements Disposable {
private void removeIncomingImageView() {
if (incomingImageView != null) {
getChildren().remove(incomingImageView);
photoStack.getChildren().remove(incomingImageView);
incomingImageView = null;
}
}
@@ -629,7 +739,9 @@ public class PhotoDetailPane extends StackPane implements Disposable {
bounceTransition.play();
}
/** {@link #fitImageView} for {@link #imageView} and, if a slide is in flight, {@link #incomingImageView} too — this pane's own resize handler. */
/**
* {@link #fitImageView} for {@link #imageView} and, if a slide is in flight, {@link #incomingImageView} too — this pane's own resize handler.
*/
private void refitImageViews() {
fitImageView(imageView, imageView.getImage(), currentOrientation);
if (incomingImageView != null) {
@@ -638,11 +750,11 @@ public class PhotoDetailPane extends StackPane implements Disposable {
}
/**
* Shrinks {@code view} to fit within this pane's current width/height, preserving aspect ratio, capped
* at {@code scale <= 1} so a photo smaller than the available space is shown at its own resolution
* rather than blown up to fill it. Shared by {@link #imageView} and {@link #incomingImageView} — both
* are fit against this same pane's size, just possibly at different times relative to their own image
* finishing its background decode.
* Shrinks {@code view} to fit within {@link #photoStack}'s current width/height — not this whole pane's,
* which also includes {@link #infoPane} — preserving aspect ratio, capped at {@code scale <= 1} so a
* photo smaller than the available space is shown at its own resolution rather than blown up to fill
* it. Shared by {@link #imageView} and {@link #incomingImageView} — both are fit against this same
* area, just possibly at different times relative to their own image finishing its background decode.
*
* <p>{@code image}'s own width/height are the raw, un-rotated decode — {@link #imageCache} never bakes
* EXIF orientation in (unlike a generated thumbnail), so a 90°/270° {@code orientation} is applied here
@@ -664,7 +776,7 @@ public class PhotoDetailPane extends StackPane implements Disposable {
double rawHeight = image.getHeight();
double displayWidth = orientation.swapsDimensions() ? rawHeight : rawWidth;
double displayHeight = orientation.swapsDimensions() ? rawWidth : rawHeight;
double scale = Math.min(1, Math.min(getWidth() / displayWidth, getHeight() / displayHeight));
double scale = Math.min(1, Math.min(photoStack.getWidth() / displayWidth, photoStack.getHeight() / displayHeight));
if (scale <= 0 || Double.isNaN(scale) || Double.isInfinite(scale)) {
return;
}
@@ -693,10 +805,6 @@ public class PhotoDetailPane extends StackPane implements Disposable {
this.onNext = handler;
}
public void setOnInfo(Runnable handler) {
this.onInfo = handler;
}
public void setOnToggleFavorite(Runnable handler) {
this.onToggleFavorite = handler;
}
@@ -719,6 +827,7 @@ public class PhotoDetailPane extends StackPane implements Disposable {
bounceTransition.stop();
}
removeIncomingImageView();
infoPane.dispose();
scope.close();
}
}
@@ -138,7 +138,7 @@ class GalleryViewTest {
/**
* With no photo open, {@code I} toggles the grid's own overlay panel — an always-fresh, never-persisted
* overlay, unlike the docked detail panel ({@code mediaInfoPaneDetail}, persisted to
* overlay, unlike {@link PhotoDetailPane}'s own docked info panel (persisted to
* {@code ui.right-panel-expanded}, exercised once a photo is open).
*/
@Test
@@ -264,9 +264,8 @@ class GalleryViewTest {
}
/**
* Walks the whole scene graph rooted at {@code view}, not just its direct children — {@code view} is
* now an {@code HBox} of {@code (viewportStack, mediaInfoPane)}, so the four panes this looks for live
* one level deeper, inside {@code viewportStack}.
* Walks the whole scene graph rooted at {@code view}, not just its direct children — the four panes
* this looks for live one level deeper, inside {@code viewportStack}, {@code view}'s own sole child.
*/
private <T extends StackPane> T paneOf(Class<T> type) {
return find(view, type)
@@ -2,6 +2,9 @@ package org.icroco.pholio.ui.view.gallery;
import org.icroco.pholio.domain.library.MediaFile;
import org.icroco.pholio.domain.media.MediaMetadata;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.infra.recognition.FaceRegionQueryService;
import org.icroco.pholio.infra.preferences.PreferencesFixture;
import org.icroco.pholio.ui.FxTestToolkit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -32,7 +35,8 @@ class PhotoDetailPaneTest {
@BeforeEach
void setUp() {
FxTestToolkit.requireToolkit();
pane = onFxThread(() -> new PhotoDetailPane(mock(FullImageCache.class)));
I18nService i18n = FxTestToolkit.i18n(PreferencesFixture.fromBundledSchema());
pane = onFxThread(() -> new PhotoDetailPane(mock(FullImageCache.class), i18n, mock(FaceRegionQueryService.class)));
}
@Test