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