feat(maintenance): show file name below each region chip, double-click to preview

MaintenancePeoplePane's chips now show the photo's own file name below
the cropped thumbnail, and double-clicking a chip's image opens a
modal dialog with the whole source photo (scaled to fit, ULTRA
quality) and the detected region drawn as a highlighted rectangle at
its actual position.

Replaced the pane's hashByMediaFileId map with mediaFileById (whole
MediaFile, not just its hash) -- both the file name and the ULTRA-tier
preview need more than the hash alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
This commit is contained in:
2026-09-17 19:06:50 -04:00
co-authored by Claude Sonnet 5
parent ae5b29ac5a
commit 4bd91d7d99
2 changed files with 96 additions and 8 deletions
@@ -1,11 +1,13 @@
package org.icroco.pholio.ui.view.maintenance;
import atlantafx.base.theme.Styles;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
@@ -22,9 +24,13 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.TextAlignment;
import org.icroco.pholio.domain.library.MediaFile;
import org.icroco.pholio.domain.recognition.BoundingBox;
import org.icroco.pholio.domain.recognition.EEntityKind;
@@ -39,6 +45,7 @@ import org.icroco.pholio.infra.task.TaskType;
import org.icroco.pholio.ui.common.Disposable;
import org.icroco.pholio.ui.common.UiView;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.ui.shell.ModalService;
import org.icroco.pholio.ui.view.gallery.ThumbnailImageCache;
import org.jspecify.annotations.Nullable;
import org.kordamp.ikonli.feather.Feather;
@@ -68,6 +75,8 @@ public class MaintenancePeoplePane extends BorderPane implements Disposable {
private static final double CHIP_SIZE = 110;
/** The master list's small per-row cover thumbnail. */
private static final double COVER_SIZE = 40;
/** The region preview dialog's own bounding box, in pixels — an image larger than this is scaled down (never up) to fit. */
private static final double PREVIEW_MAX_SIZE = 900;
private final I18nService i18n;
private final PersonManagementService personManagementService;
@@ -75,6 +84,7 @@ public class MaintenancePeoplePane extends BorderPane implements Disposable {
private final ThumbnailImageCache thumbnailImageCache;
private final AppPreferences preferences;
private final TaskService taskService;
private final ModalService modalService;
private final ObservableList<PersonSummary> personRows = FXCollections.observableArrayList();
private final ListView<PersonSummary> personListView = new ListView<>();
@@ -87,18 +97,19 @@ public class MaintenancePeoplePane extends BorderPane implements Disposable {
private final VBox detailBox = new VBox(12);
private final Label detailPlaceholder = new Label();
private Map<Long, String> hashByMediaFileId = Map.of();
private Map<Long, MediaFile> mediaFileById = Map.of();
private @Nullable PersonSummary selected;
public MaintenancePeoplePane(I18nService i18n, PersonManagementService personManagementService,
MediaFileService mediaFileService, ThumbnailImageCache thumbnailImageCache,
AppPreferences preferences, TaskService taskService) {
AppPreferences preferences, TaskService taskService, ModalService modalService) {
this.i18n = i18n;
this.personManagementService = personManagementService;
this.mediaFileService = mediaFileService;
this.thumbnailImageCache = thumbnailImageCache;
this.preferences = preferences;
this.taskService = taskService;
this.modalService = modalService;
getStyleClass().add("maintenance-people-pane");
@@ -146,8 +157,8 @@ public class MaintenancePeoplePane extends BorderPane implements Disposable {
public void load() {
Long previousId = selected == null ? null : selected.id();
hashByMediaFileId = mediaFileService.list().stream()
.collect(Collectors.toMap(MediaFile::id, MediaFile::hash, (a, b) -> a));
mediaFileById = mediaFileService.list().stream()
.collect(Collectors.toMap(MediaFile::id, file -> file, (a, b) -> a));
List<PersonSummary> persons = personManagementService.listPersons();
personRows.setAll(persons);
@@ -221,6 +232,18 @@ public class MaintenancePeoplePane extends BorderPane implements Disposable {
if (!region.confirmed()) {
imageStack.getStyleClass().add("maintenance-people-chip-suggested");
}
imageStack.setCursor(Cursor.HAND);
imageStack.setOnMouseClicked(event -> {
if (event.getClickCount() == 2) {
openRegionPreview(region);
}
});
Label fileNameLabel = new Label(fileNameOf(region));
fileNameLabel.getStyleClass().add("media-info-secondary");
fileNameLabel.setWrapText(true);
fileNameLabel.setTextAlignment(TextAlignment.CENTER);
fileNameLabel.setMaxWidth(CHIP_SIZE);
ToggleButton coverButton = new ToggleButton();
coverButton.setGraphic(FontIcon.of(Feather.STAR, 13));
@@ -268,7 +291,7 @@ public class MaintenancePeoplePane extends BorderPane implements Disposable {
HBox actions = new HBox(2, coverButton, confirmButton, discardButton, reassignButton);
actions.setAlignment(Pos.CENTER);
VBox chip = new VBox(4, imageStack, actions);
VBox chip = new VBox(4, imageStack, fileNameLabel, actions);
chip.setAlignment(Pos.CENTER);
chip.getStyleClass().add("maintenance-people-chip");
return chip;
@@ -282,17 +305,23 @@ public class MaintenancePeoplePane extends BorderPane implements Disposable {
* file and leave the chip blank — exactly {@code ThumbnailGalleryPane.currentQuality()}'s own reasoning.
*/
private void loadChipImage(ImageView imageView, RegionSummary region) {
String hash = hashByMediaFileId.get(region.mediaFileId());
if (hash == null) {
MediaFile file = mediaFileById.get(region.mediaFileId());
if (file == null) {
return;
}
EThumbnailQuality quality = EThumbnailQuality.parse(preferences.getValue("thumbnails", "quality", String.class));
thumbnailImageCache.request(hash, quality, image -> {
thumbnailImageCache.request(file.hash(), quality, image -> {
imageView.setImage(image);
imageView.setViewport(viewportOf(image, region.area()));
});
}
/** {@code region}'s own file name — its containing folder is not shown, only enough to tell photos apart. */
private String fileNameOf(RegionSummary region) {
MediaFile file = mediaFileById.get(region.mediaFileId());
return file == null ? "" : file.path().getFileName().toString();
}
/**
* {@code area} is the MWG-RS center/size convention (see {@link BoundingBox}'s own javadoc), and — for a
* face in particular — rarely square itself. Every chip {@link ImageView} here is a fixed square with
@@ -310,6 +339,59 @@ public class MaintenancePeoplePane extends BorderPane implements Disposable {
return new Rectangle2D(x, y, side, side);
}
/**
* A double-click on a chip's own image — opens {@code region}'s whole source photo, scaled to fit inside
* {@link #PREVIEW_MAX_SIZE}, with its face/animal area drawn as a highlighted rectangle. Requests
* {@link EThumbnailQuality#ULTRA} regardless of the gallery's own configured tier ({@code
* loadChipImage}'s concern): a chip only ever needs a small crop, but this dialog is exactly the "let me
* actually see it" moment the largest cached tier exists for.
*/
private void openRegionPreview(RegionSummary region) {
MediaFile file = mediaFileById.get(region.mediaFileId());
if (file == null) {
return;
}
thumbnailImageCache.request(file.hash(), EThumbnailQuality.ULTRA, image -> showRegionPreviewDialog(image, region, file));
}
private void showRegionPreviewDialog(Image image, RegionSummary region, MediaFile file) {
double naturalWidth = image.getWidth();
double naturalHeight = image.getHeight();
double scale = Math.min(1.0, PREVIEW_MAX_SIZE / Math.max(naturalWidth, naturalHeight));
double displayWidth = naturalWidth * scale;
double displayHeight = naturalHeight * scale;
ImageView imageView = new ImageView(image);
imageView.setFitWidth(displayWidth);
imageView.setFitHeight(displayHeight);
imageView.setPreserveRatio(true);
BoundingBox area = region.area();
Rectangle highlight = new Rectangle(area.x() * displayWidth - area.w() * displayWidth / 2,
area.y() * displayHeight - area.h() * displayHeight / 2,
area.w() * displayWidth, area.h() * displayHeight);
highlight.setFill(Color.TRANSPARENT);
highlight.getStyleClass().add("maintenance-people-region-highlight");
Pane imageCanvas = new Pane(imageView, highlight);
imageCanvas.setPrefSize(displayWidth, displayHeight);
imageCanvas.setMaxSize(displayWidth, displayHeight);
Label fileNameLabel = new Label(file.path().getFileName().toString());
Button closeButton = new Button();
closeButton.textProperty().bind(i18n.binding("action.close"));
closeButton.getStyleClass().add(Styles.ACCENT);
closeButton.setOnAction(event -> modalService.hide());
VBox content = new VBox(12, fileNameLabel, imageCanvas, closeButton);
content.setPadding(new Insets(16));
content.setAlignment(Pos.CENTER);
content.getStyleClass().addAll("modal-card", Styles.BG_DEFAULT, Styles.ELEVATED_2);
modalService.show(content, false);
}
/** {@code MaintenanceRecognitionPane.rescan}'s exact background-task pattern, applied to a library-wide reconcile pass. */
private void recalculateAll() {
recalculateButton.setDisable(true);
+6
View File
@@ -432,6 +432,12 @@
-fx-border-color: -color-accent-emphasis;
}
/* The region preview dialog's own highlight box around the detected face/animal area — see MaintenancePeoplePane.showRegionPreviewDialog. */
.maintenance-people-region-highlight {
-fx-stroke: -color-accent-emphasis;
-fx-stroke-width: 3px;
}
/*
* Transient outcome messages, top-right. The layer itself paints nothing: it is a click-through overlay,
* and each toast is an AtlantaFX Notification carrying its own surface.