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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user