fix(gallery): fix hashRow's SelectableTextFlow reporting a 0 preferred width

Found the real cause behind MediaInfoPane's vertical scrollbar: not a
phantom estimate at all, a genuinely too-tall row. SelectableTextFlow's
own constructor sets prefWidth to Region.USE_PREF_SIZE — a sentinel
Region's own doc says applies to minWidth/maxWidth only ("this bound
equals whatever prefWidth computes to"); Region.prefWidth(height) has
no special case for it, so it falls through its own "negative override
-> 0" branch (confirmed against javafx-graphics' own Region source).
Every layout pass reading this flow's preferred width then sees a flat
0, and the 64-character hash wraps one/two characters per line instead
of the 2-3 lines the row was actually designed around.

hashRow now resets it to the ordinary USE_COMPUTED_SIZE right after
construction, restoring real natural-width computation; the existing
minWidth(0) + HGrow.ALWAYS on the same node is what then shrinks it
back down to fit, same as originally intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
This commit is contained in:
2026-09-22 23:03:28 -04:00
co-authored by Claude Sonnet 5
parent 4394d1ae3c
commit 0a5d176a47
@@ -18,11 +18,7 @@ import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.layout.*;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.util.Duration;
@@ -30,15 +26,16 @@ import org.icroco.pholio.domain.library.MediaFile;
import org.icroco.pholio.domain.media.GeoLocation;
import org.icroco.pholio.domain.media.MediaMetadata;
import org.icroco.pholio.domain.recognition.EEntityKind;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.infra.recognition.FaceRegionQueryService;
import org.icroco.pholio.ui.common.Disposable;
import org.icroco.pholio.ui.control.StarRatingControl;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.ui.util.EasingFX;
import org.jspecify.annotations.Nullable;
import org.kordamp.ikonli.Ikon;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.materialdesign2.MaterialDesignT;
import java.time.ZoneOffset;
import java.util.ArrayList;
@@ -81,10 +78,14 @@ import java.util.function.Consumer;
*/
public class MediaInfoPane extends StackPane implements Disposable {
/** Fixed content width — not user-resizable, unlike the old {@code InspectorRail}'s drag handle. */
/**
* Fixed content width — not user-resizable, unlike the old {@code InspectorRail}'s drag handle.
*/
private static final double WIDTH = 300;
/** How long {@link #setOpen} takes to grow/shrink this pane's own width. */
/**
* How long {@link #setOpen} takes to grow/shrink this pane's own width.
*/
private static final Duration SLIDE_DURATION = Duration.millis(240);
private final I18nService i18n;
@@ -95,7 +96,9 @@ public class MediaInfoPane extends StackPane implements Disposable {
private final VBox rows = new VBox(24);
private final ScrollPane scroll = new ScrollPane(rows);
/** Fixed at {@link #WIDTH} regardless of this pane's own (instantly-committed) width — see {@link #setOpen}; what actually slides via {@link #revealTransition}. */
/**
* Fixed at {@link #WIDTH} regardless of this pane's own (instantly-committed) width — see {@link #setOpen}; what actually slides via {@link #revealTransition}.
*/
private final VBox body;
private final Rectangle clip = new Rectangle();
@@ -130,16 +133,18 @@ public class MediaInfoPane extends StackPane implements Disposable {
*/
private @Nullable StarRatingControl ratingControl;
/** The file {@link #buildActionsBar}'s details button opens a dialog for — {@code null} when {@link #setFile} was last called with {@code null}. */
/**
* The file {@link #buildActionsBar}'s details button opens a dialog for — {@code null} when {@link #setFile} was last called with {@code null}.
*/
private @Nullable MediaFile currentFile;
private final Button detailsButton = new Button();
private Runnable onClose = () -> {};
private Consumer<MediaFile> onEditDate = file -> {};
private Consumer<MediaFile> onEditLocation = file -> {};
private BiConsumer<MediaFile, Integer> onEditRating = (file, rating) -> {};
private Consumer<MediaFile> onShowDetails = file -> {};
private Runnable onClose = () -> {};
private Consumer<MediaFile> onEditDate = file -> {};
private Consumer<MediaFile> onEditLocation = file -> {};
private BiConsumer<MediaFile, Integer> onEditRating = (file, rating) -> {};
private Consumer<MediaFile> onShowDetails = file -> {};
public MediaInfoPane(I18nService i18n, FaceRegionQueryService faceRegionQueryService) {
this.i18n = i18n;
@@ -217,9 +222,9 @@ public class MediaInfoPane extends StackPane implements Disposable {
* that decision on the next pass, never flip it back to hidden.
*/
private void refreshScrollPolicy() {
var bounds = scroll.getViewportBounds();
double viewportHeight = bounds == null ? 0 : bounds.getHeight();
boolean overflows = rows.getHeight() > viewportHeight + 1; // +1: guards float rounding, not a real threshold
var bounds = scroll.getViewportBounds();
double viewportHeight = bounds == null ? 0 : bounds.getHeight();
boolean overflows = rows.getHeight() > viewportHeight + 1; // +1: guards float rounding, not a real threshold
scroll.setVbarPolicy(overflows ? ScrollPane.ScrollBarPolicy.AS_NEEDED : ScrollPane.ScrollBarPolicy.NEVER);
}
@@ -229,7 +234,7 @@ public class MediaInfoPane extends StackPane implements Disposable {
* right by the leading {@link Spacer} — room for more actions later, same row.
*/
private HBox buildActionsBar() {
detailsButton.setGraphic(FontIcon.of(Feather.INFO, 16));
detailsButton.setGraphic(FontIcon.of(MaterialDesignT.TEXT_BOX_SEARCH_OUTLINE, 16));
detailsButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
detailsButton.getStyleClass().add("photo-detail-icon-button");
detailsButton.setDisable(true);
@@ -246,7 +251,9 @@ public class MediaInfoPane extends StackPane implements Disposable {
return bar;
}
/** Whether the panel is (or is animating toward being) open — the target state, not the animation's current progress. */
/**
* Whether the panel is (or is animating toward being) open — the target state, not the animation's current progress.
*/
public ReadOnlyBooleanProperty openProperty() {
return open;
}
@@ -268,10 +275,10 @@ public class MediaInfoPane extends StackPane implements Disposable {
setPrefWidth(WIDTH);
setMaxWidth(WIDTH);
revealTransition = new Timeline(new KeyFrame(SLIDE_DURATION,
new KeyValue(body.translateXProperty(), 0, EasingFX.MATERIAL_EASE_OUT)));
new KeyValue(body.translateXProperty(), 0, EasingFX.MATERIAL_EASE_OUT)));
} else {
revealTransition = new Timeline(new KeyFrame(SLIDE_DURATION,
new KeyValue(body.translateXProperty(), WIDTH, EasingFX.MATERIAL_EASE_IN)));
new KeyValue(body.translateXProperty(), WIDTH, EasingFX.MATERIAL_EASE_IN)));
// Only once body has fully slid out of the clipped view does collapsing the outer width
// itself become invisible rather than an abrupt jump underneath a still-visible slide.
revealTransition.setOnFinished(event -> {
@@ -283,27 +290,37 @@ public class MediaInfoPane extends StackPane implements Disposable {
revealTransition.play();
}
/** The header's own close button — mirrors the example's "X" that closes the panel from inside it. */
/**
* The header's own close button — mirrors the example's "X" that closes the panel from inside it.
*/
public void setOnClose(Runnable handler) {
this.onClose = handler;
}
/** {@link #dateRow}'s pencil — {@code handler} receives the file currently shown, to open an edit dialog for. */
/**
* {@link #dateRow}'s pencil — {@code handler} receives the file currently shown, to open an edit dialog for.
*/
public void setOnEditDate(Consumer<MediaFile> handler) {
this.onEditDate = handler;
}
/** {@link #locationRow}'s pencil (and its "add a place" label when there is no location yet). */
/**
* {@link #locationRow}'s pencil (and its "add a place" label when there is no location yet).
*/
public void setOnEditLocation(Consumer<MediaFile> handler) {
this.onEditLocation = handler;
}
/** {@link #ratingRow}'s stars — {@code handler} receives the file currently shown and the newly clicked rating (0-5). */
/**
* {@link #ratingRow}'s stars — {@code handler} receives the file currently shown and the newly clicked rating (0-5).
*/
public void setOnEditRating(BiConsumer<MediaFile, Integer> handler) {
this.onEditRating = handler;
}
/** {@link #detailsButton} — {@code handler} receives the file currently shown, to list every raw metadata entry for. */
/**
* {@link #detailsButton} — {@code handler} receives the file currently shown, to list every raw metadata entry for.
*/
public void setOnShowDetails(Consumer<MediaFile> handler) {
this.onShowDetails = handler;
}
@@ -442,8 +459,8 @@ public class MediaInfoPane extends StackPane implements Disposable {
private Label personChip(FaceRegionQueryService.DisplayRegion region) {
String text = region.kind() == EEntityKind.PERSON
? region.personName() != null ? region.personName() : i18n.get("gallery.info.unknownPerson")
: region.label() != null ? region.label() : i18n.get("gallery.info.unknownPerson");
? region.personName() != null ? region.personName() : i18n.get("gallery.info.unknownPerson")
: region.label() != null ? region.label() : i18n.get("gallery.info.unknownPerson");
Label chip = new Label(text);
chip.getStyleClass().add("media-info-person-chip");
return chip;
@@ -451,15 +468,15 @@ public class MediaInfoPane extends StackPane implements Disposable {
private static String shutterLabel(double seconds) {
return seconds >= 1
? String.format(Locale.ROOT, "%.1fs", seconds)
: "1/" + Math.round(1 / seconds) + "s";
? String.format(Locale.ROOT, "%.1fs", seconds)
: "1/" + Math.round(1 / seconds) + "s";
}
private Node fileRow(MediaFile file, MediaMetadata metadata) {
String primary = file.path().getFileName().toString();
String primary = file.path().getFileName().toString();
String secondary = metadata.hasDimensions()
? metadata.displayWidth() + " × " + metadata.displayHeight() + " " + metadata.format()
: null;
? metadata.displayWidth() + " × " + metadata.displayHeight() + " " + metadata.format()
: null;
return iconRow(Feather.IMAGE, primary, secondary);
}
@@ -472,8 +489,8 @@ public class MediaInfoPane extends StackPane implements Disposable {
return metadata.geoLocation()
.map(location -> {
String primary = location.placeName() != null
? location.placeName()
: String.format(Locale.ROOT, "%.5f, %.5f", location.latitude(), location.longitude());
? location.placeName()
: String.format(Locale.ROOT, "%.5f, %.5f", location.latitude(), location.longitude());
return editableRow(Feather.MAP_PIN, textBox(primary, null), onEdit);
})
.orElseGet(() -> editableRow(Feather.MAP_PIN, addPlaceLabel(onEdit), onEdit));
@@ -506,12 +523,16 @@ public class MediaInfoPane extends StackPane implements Disposable {
hashText.getStyleClass().add("exif-value");
SelectableTextFlow hashValue = new SelectableTextFlow(hashText);
hashValue.setTextSelectionOnMouseClick(true);
// SelectableTextFlow's own constructor pins prefWidth to its unwrapped natural width (fitting the
// whole 64-character hash on one line) — the Label this replaced instead wrapped via setWrapText,
// shrinking to whatever width this fixed 300px pane actually gave it. minWidth(0) + HGrow.ALWAYS is
// the HBox-side equivalent: once copyButton's own width is spoken for, HBox shrinks this flow down
// to whatever is left rather than leaving it at its full preferred width, and being resizable, it
// wraps to match.
// SelectableTextFlow's own constructor sets prefWidth to Region.USE_PREF_SIZE — meant, going by its
// own doc, only for minWidth/maxWidth ("this bound equals whatever prefWidth computes to"); Region's
// actual prefWidth(height) getter has no special case for it at all, so it falls through to
// "negative override -> 0". Every layout pass downstream (this row's own HBox included) then reads
// this flow's preferred width as a flat 0, sizes it accordingly, and the 64-character hash wraps one
// (or a couple) characters per line instead of the 2-3 lines the row was actually designed around —
// a MediaInfoPane-wide vertical scrollbar, from just this one row's real, not phantom, height. Reset
// to the ordinary USE_COMPUTED_SIZE so prefWidth actually reports the flow's real natural (unwrapped)
// width again; minWidth(0) + HGrow.ALWAYS below is what then shrinks it back down to fit.
hashValue.setPrefWidth(Region.USE_COMPUTED_SIZE);
hashValue.setMinWidth(0);
HBox.setHgrow(hashValue, Priority.ALWAYS);