diff --git a/.claude/plans/justified-gallery-pane.md b/.claude/plans/justified-gallery-pane.md new file mode 100644 index 0000000..63f538b --- /dev/null +++ b/.claude/plans/justified-gallery-pane.md @@ -0,0 +1,263 @@ +# Remplacement de `ThumbnailGalleryPane` par `JustifiedGalleryPane` + +## Contexte + +`ThumbnailGalleryPane` (1836 lignes) mélange aujourd'hui six responsabilités dans une seule classe : layout/virtualisation de la grille, regroupement chronologique par en-tête de date, sélection simple, sélection multiple par case à cocher pour des actions groupées par jour (régénérer les miniatures, GPS, note), zoom/molette/pinch, et scroll "momentum" avec rebond élastique — au-dessus d'un algorithme de packing (`GalleryRow.chunk`) qui laisse volontairement un espace résiduel non comblé à droite de chaque ligne (jamais de dépassement, mais jamais de remplissage à 100 % non plus). + +Objectif : repartir d'un composant neuf où : +1. la sélection multiple par case à cocher (et les actions groupées associées) disparaît (reportée à une itération future), +2. **le regroupement chronologique par en-tête de date est conservé** — c'est justement pourquoi le rendu reste basé sur une `ListView` mêlant lignes de contenu et lignes d'en-tête, comme aujourd'hui, +3. l'algorithme de packing garantit un remplissage horizontal **exact** : hauteur de ligne fixe, largeur de chaque photo redistribuée (priorité aux photos paysage/carrées) pour que la somme des largeurs d'une ligne occupe exactement 100 % du conteneur — jamais de barre de défilement horizontale, jamais d'espace résiduel comme aujourd'hui, +4. la couche pure de calcul (packing + regroupement par date) reste strictement séparée de la couche de rendu JavaFX, comme aujourd'hui (`GalleryRow`/`GalleryGridItem` vs `GalleryGridCell`), +5. `GalleryTimelineBar` (déjà indépendant de tout modèle de données — il ne connaît que des fractions `[0,1]`) est réutilisé tel quel plutôt que dupliqué, +6. la virtualisation reste basée sur `ListView` + `setFixedCellSize` (comme aujourd'hui), avec **exactement deux hauteurs fixes** — une pour les lignes de contenu, une (différente) pour les lignes d'en-tête, jamais une troisième hauteur variable. C'est une contrainte de conception héritée directement du code actuel : le javadoc de `GalleryGridItem` documente un bug réel (désynchronisation du recyclage de cellules de `VirtualFlow` avec le point-and-click, reproduit uniquement avec le thème AtlantaFx complet) causé par une hauteur de ligne variable selon qu'elle contient un en-tête ou non — c'est précisément ce que le split `GalleryRow`/`HeaderRow` corrige, et le nouveau composant doit reproduire ce même split plutôt que redécouvrir le problème. + +L'ancien composant n'est pas supprimé : il est déplacé tel quel (aucun changement fonctionnel) vers un sous-package `legacy`, car il porte encore le mécanisme d'actions groupées qui sera réintroduit plus tard. + +Décisions validées avec l'utilisateur : sélection unique uniquement (pas de cases à cocher, pas d'actions groupées) ; regroupement chronologique par en-tête **conservé** (date + lieu ; l'icône de menu optionnelle du cahier des charges, aujourd'hui porteuse des actions groupées, est reportée en même temps que ces actions) ; réutilisation de `GalleryTimelineBar` ; hauteur de ligne fixe avec redistribution de largeur pour remplir exactement la largeur disponible ; virtualisation `ListView`-based conservée. + +--- + +## 1. Fichiers à créer / déplacer + +### Package `org.icroco.pholio.ui.view.gallery` (racine, nouveau contenu) + +| Fichier | Rôle | +|---|---| +| `JustifiedGalleryPane.java` (nouveau) | Remplace `ThumbnailGalleryPane` comme composant instancié par `GalleryView`. | +| `JustifiedRow.java` (nouveau) | Remplace `GalleryRow` : record pur, sans JavaFX, algorithme de packing + regroupement par date + étirement de largeur. | +| `JustifiedGridItem.java` (nouveau) | Remplace `GalleryGridItem` : sealed interface `JustifiedRow \| HeaderRow`, + `itemize()`. | +| `JustifiedGridCell.java` (nouveau) | Remplace `GalleryGridCell` : `ListCell`, rendu des cartes et des en-têtes de date, sans case à cocher ni menu d'actions groupées. | +| `GalleryTimelineBar.java` (existant, **visibilité élargie seulement**) | `class` → `public class`, `record YearMark` → `public record`, constructeur et méthodes utilisées depuis l'extérieur (`setOnSeek`, `setOnPreview`, `setYearMarks`, `setDensity`, `setCurrentPosition`, `activity`, `dispose`) → `public`. Reste dans ce package, aucune duplication, aucun changement de comportement/rendu/CSS. | +| `ThumbnailImageCache.java` (existant, inchangé) | Déjà public, déjà partagé. | +| `GalleryView.java` (existant, modifié) | Voir section 6. | + +### Nouveau sous-package `org.icroco.pholio.ui.view.gallery.legacy` (déplacement, package-only) + +- `legacy/ThumbnailGalleryPane.java` — changement de `package`, plus `import` explicite de `GalleryTimelineBar` (et de son `YearMark`) et de `ThumbnailImageCache` depuis `org.icroco.pholio.ui.view.gallery`. Aucun autre changement. +- `legacy/GalleryGridCell.java`, `legacy/GalleryRow.java`, `legacy/GalleryGridItem.java` — changement de `package` uniquement. +- Tests associés déplacés à l'identique : `GalleryRowTest.java`, `ThumbnailGalleryPaneTest.java` (et tout autre test du package legacy) vers `.../gallery/legacy/`. + +Rien dans ces fichiers n'a besoin de devenir `public` : ils ne s'appellent qu'entre eux, plus les trois classes du package parent déjà (ou bientôt) publiques. + +### Tests nouveaux + +- `src/test/java/org/icroco/pholio/ui/view/gallery/JustifiedRowTest.java` (nouveau, voir section 7). +- `src/test/java/org/icroco/pholio/ui/view/gallery/JustifiedGridItemTest.java` (nouveau, voir section 7). +- `GalleryViewTest.java` (reste en place, mis à jour — voir section 6). + +--- + +## 2. Signatures publiques de `JustifiedGalleryPane` + +```java +public class JustifiedGalleryPane extends StackPane implements Disposable { + + public JustifiedGalleryPane(MediaFileService mediaFileService, + TaskService taskService, + MediaLibraryState state, + AppPreferences preferences, + I18nService i18n, + ThumbnailImageCache imageCache); + + public ObservableList filters(); + public ReadOnlyObjectProperty<@Nullable MediaFile> selectedFileProperty(); + public void setOnOpenRequest(BiConsumer handler); + public @Nullable MediaFile next(MediaFile file); + public @Nullable MediaFile previous(MediaFile file); + public @Nullable MediaFile above(MediaFile file); + public @Nullable MediaFile below(MediaFile file); + public void select(MediaFile file); + public void reveal(MediaFile file); + public void revealAndPulse(MediaFile file); + + @Override + public void dispose(); +} +``` + +Constructeur identique à l'actuel : `GalleryView` n'a qu'à changer le nom de la classe instanciée. `i18n` est utilisé cette fois (contrairement à une version sans en-têtes) pour produire le texte de date localisé passé à `JustifiedRow.justify` (voir section 3), exactement comme `ThumbnailGalleryPane` le fait aujourd'hui pour `GalleryRow.chunk`. + +**Explicitement absent** : `setOnRegenerateThumbnails`, `setOnSetGpsForFiles`, `setOnSetRatingForFiles`, `checkedFiles()` et toute la machinerie associée (`checkedByFile`, `checkedPropertyFor`, `checkedPropertiesForDay`, `checkedFilesForDay`, `toggleChecked`, `toggleCheckedForDay`). + +**Réutilisés quasi verbatim** (génériques, indépendants du type de ligne — juste `` → ``) : zoom (molette+Ctrl, pinch trackpad), scroll natif (`hookNativeScrollBar`, lookup `.scroll-bar:vertical` / `#virtual-flow`), momentum/rebond élastique (`fireMomentumTick`, `bounceGrid`), `relayout()`/`throttledRelayout()` (structure identique : filtrer → trier → `JustifiedRow.justify` → `JustifiedGridItem.itemize` → `rows.getItems().setAll(...)`). + +--- + +## 3. Algorithme de packing + regroupement par date + étirement (`JustifiedRow`) + +```java +record JustifiedRow(List entries) implements JustifiedGridItem { + record Entry(MediaFile file, double width, @Nullable DateMarker marker) {} + record DateMarker(@Nullable LocalDate date, String label, @Nullable String locality) {} + + static final double MAX_LANDSCAPE_ASPECT = 3.0; // même valeur que GalleryRow, dupliquée (packages indépendants) + static final double LAST_ROW_FILL_THRESHOLD = 0.85; // voir cas de la dernière ligne + + static double naturalWidthOf(MediaFile file, double height); // identique à GalleryRow.widthOf + static @Nullable LocalDate dayOf(MediaFile file); // identique à GalleryRow.dayOf + + static List justify(List files, double availableWidth, double thumbnailHeight, + double spacing, double dateSpacing, + Function<@Nullable LocalDate, String> headerLabel); +} +``` + +`Entry` gagne un champ `width` (largeur finale, déjà étirée) par rapport à `GalleryRow.Entry` — c'est la différence structurelle clé : dans l'ancien code, la largeur est recalculée à la demande partout où elle est utilisée (`GalleryRow.widthOf`) ; ici elle est calculée **une fois**, au moment de la fermeture de ligne, et stockée, car l'étirement dépend du contenu de toute la ligne (pas d'une formule locale par photo). + +### Phase 1 — regroupement par jour + packing glouton (reprise de `GalleryRow.chunk`, boucle externe identique) + +Reprendre tel quel l'algorithme de `GalleryRow.chunk` (fusion d'un jour entier dans la ligne ouverte quand elle est encore la première ligne de son propre jour et que le nouveau jour y tient en entier — `openRowIsFirstOfDay`/merge par `dateSpacing` ; sinon fermeture de la ligne ouverte et packing glouton du nouveau jour, avec possible éclatement en plusieurs lignes). Seule différence : chaque fermeture de ligne (`rows.add(new GalleryRow(...))` dans l'ancien code) est remplacée par un appel à `closeRow(...)` (phase 2), qui calcule les largeurs finales avant d'ajouter la ligne au résultat. Le marqueur `DateMarker` reste porté par la première entrée effectivement placée de chaque jour, exactement comme aujourd'hui. + +### Phase 2 — fermeture de ligne + étirement (`closeRow`, nouveau — n'existe pas dans `GalleryRow`) + +Pour une ligne de `n` entrées déjà choisies par la phase 1 (fichier + marqueur éventuel) : + +``` +naturalWidths[k] = naturalWidthOf(entries[k].file(), height) +spacingBefore(k) = k == 0 ? 0 : (entries[k].marker() != null ? dateSpacing : spacing) +totalSpacing = Σ_{k=1}^{n-1} spacingBefore(k) +naturalRowWidth = Σ naturalWidths[k] + totalSpacing +slack = availableWidth - naturalRowWidth + +// Cas limite : un seul fichier déjà plus large que le viewport (panorama) → clampé à +// availableWidth pour garantir ZÉRO scrollbar horizontale, au prix d'un recadrage supplémentaire. +si n == 1 et naturalWidths[0] > availableWidth: retourner [Entry(file, availableWidth, marker)] + +// Dernière ligne du résultat entier : ne l'étirer que si elle est déjà "presque pleine" +// naturellement, pour éviter de distordre 1-2 photos orphelines sur une grille large. +shouldStretch = !isLastRowOfAll || (naturalRowWidth >= LAST_ROW_FILL_THRESHOLD * availableWidth) + +si slack <= 0 ou !shouldStretch: retourner les largeurs naturelles telles quelles (alignées à gauche) + +stretchable = { k | aspect(entries[k].file()) >= 1.0 } // paysage/carré en priorité +si stretchable vide: stretchable = tous les indices // repli : que des portraits + +stretchableNaturalTotal = Σ_{k ∈ stretchable} naturalWidths[k] +pour k ∈ stretchable: width[k] = naturalWidths[k] + slack * (naturalWidths[k] / stretchableNaturalTotal) +pour k ∉ stretchable: width[k] = naturalWidths[k] // portrait non touché s'il existe au moins une paysage/carrée + +// Correction d'arrondi flottant, appliquée à la dernière entrée étirée. +residual = availableWidth - (Σ width[k] + totalSpacing) +width[dernier k ∈ stretchable] += residual +``` + +**Invariant** : pour toute ligne étirée, `Σ width[k] + totalSpacing == availableWidth` exactement — remplissage à 100 %, jamais de dépassement. Notez que `totalSpacing` distingue bien `dateSpacing` (frontière entre deux jours fusionnés dans la même ligne) de `spacing` (entre deux photos du même jour) — l'étirement ne touche jamais aux espacements, seulement aux largeurs de photo. + +**Rendu** : aucun nouveau mécanisme requis côté `JustifiedGridCell` — reprise exacte de `GalleryGridCell.imageView` (aspect réel du bitmap décodé, `setFitHeight(rowHeight)`, `setFitWidth(cardWidth)`, `setPreserveRatio(false)`, clip au `cardWidth × rowHeight`). Une largeur étirée inférieure à la largeur native réelle de l'image se traduit par moins de recadrage latéral (aucune distorsion) ; une largeur étirée supérieure force un léger étirement horizontal des pixels (`preserveRatio(false)`, déjà en place). Seule la largeur transmise change, jamais le mécanisme. + +**Dernière ligne — seuil `0.85`** : point de départ raisonnable, à ajuster visuellement en implémentation si besoin, mais garder un seuil explicite plutôt qu'un "jamais étirer" (vide disgracieux même à 1 pixel près) ou un "toujours étirer" (casse le cas d'1 photo orpheline). + +### `JustifiedGridItem` et `itemize` + +```java +sealed interface JustifiedGridItem permits JustifiedRow, JustifiedGridItem.HeaderRow { + record HeaderRow(List columns) implements JustifiedGridItem {} + record HeaderColumn(JustifiedRow.DateMarker marker, double spanWidth) {} + + static List itemize(List rows, double spacing); +} +``` + +Différence clé avec `GalleryGridItem.itemize` : comme chaque `JustifiedRow.Entry` porte déjà sa largeur finale (étirée), `spanWidth` d'une `HeaderColumn` se calcule en **sommant les `width()` déjà stockés** des entrées consécutives partageant le même jour (+ `spacing` entre elles) — pas besoin de recalculer via `widthOf`/`thumbnailHeight` comme le fait l'ancien code. Sinon, la logique de scan (une `HeaderColumn` par run d'entrées consécutives au marqueur non-null, une `HeaderRow` insérée juste avant la `JustifiedRow` qui contient au moins un marqueur, aucune pour une ligne de continuation) est identique à `GalleryGridItem.itemize`. + +--- + +## 4. Sélection unique + navigation clavier + +Portage direct de la logique de `ThumbnailGalleryPane`, avec le même besoin de sauter les `HeaderRow` que l'existant : + +- `next(file)` / `previous(file)` : index dans `sortedFiles` (`List`, indépendant des lignes), `±1` — inchangé. +- `above(file)` / `below(file)` : même colonne, ligne de contenu `±1`, clampée à la dernière entrée si la ligne cible en a moins — en sautant les `JustifiedGridItem.HeaderRow` rencontrées en chemin, exactement comme l'existant (`while (!(items.get(target) instanceof JustifiedRow)) target += rowDelta`, adapté au nouveau type). +- `select(file)`, `reveal`/`revealAndPulse` : portés à l'identique (génériques, basés sur lookup `.list-cell` / scroll fractionnaire, indépendants du type concret d'item). +- Navigation clavier (flèches + Entrée) et surlignage visuel (`refreshCardHighlighting`, pseudo-classe `selected` posée via `getUserData()`) : portés à l'identique. + +--- + +## 5. Réutilisation de `GalleryTimelineBar` + +`GalleryTimelineBar` reste dans `org.icroco.pholio.ui.view.gallery` (racine), à côté de `ThumbnailImageCache` — évite toute duplication, changement mécanique de visibilité uniquement (voir section 1). + +Câblage fraction ↔ scroll dans `JustifiedGalleryPane` : portage **quasi verbatim** de `updateTimeline`/`hookNativeScrollBar`/`setOnSeek`/`setOnPreview`/`topVisibleRowIndex`/`fractionOf`/`labelForFraction`/`seekToFraction`/`onGridScrolled` — puisque la structure `JustifiedGridItem` (Row + HeaderRow, marqueurs de date) est désormais un miroir exact de `GalleryGridItem`, ces méthodes n'ont besoin que d'un renommage de type, aucune ré-adaptation logique. + +--- + +## 6. Changements dans `GalleryView.java` + +| Ligne(s) | Changement | +|---|---| +| `92` | `ThumbnailGalleryPane galleryPane` → `JustifiedGalleryPane galleryPane` | +| `173` | `new ThumbnailGalleryPane(...)` → `new JustifiedGalleryPane(...)` (mêmes arguments) | +| `178-180` | **Supprimées** : `setOnRegenerateThumbnails`, `setOnSetGpsForFiles`, `setOnSetRatingForFiles` (n'existent plus) | +| `177`, `182-183`, `219`, `348` | Inchangées (`setOnOpenRequest`, `previous`/`next`, `selectedFileProperty().subscribe`, `revealAndPulse`) | +| `314` | `ThumbnailGalleryPane galleryPane()` → `JustifiedGalleryPane galleryPane()` | +| Javadocs citant `{@link ThumbnailGalleryPane#...}` | Mis à jour vers `JustifiedGalleryPane` où la méthode existe encore | +| Méthodes privées `setRatingForFiles` (~546), `openLocationDialogForFiles` (~569), `regenerateThumbnails` (~602) | Deviennent du code mort (aucun autre appelant confirmé par grep) — à supprimer avec leurs javadocs, ou annoter `// TODO: unused since JustifiedGalleryPane, reintroduce with grouped actions` si l'équipe préfère les garder pour la réintroduction future. | + +**À vérifier en implémentation** : confirmer qu'aucun bouton de toolbar / item de menu ailleurs que le `dateActionsMenu` de `GalleryGridCell` (déplacé avec le legacy) ne dépendait de `checkedFiles()` — non trouvé lors de l'exploration, mais à revérifier au moment du câblage final. + +`GalleryViewTest.java` : remplacer chaque référence `ThumbnailGalleryPane.class` / `ThumbnailGalleryPane galleryPane = farView.galleryPane();` par `JustifiedGalleryPane`. + +--- + +## 7. Plan de tests unitaires (style `GalleryRowTest`/`GalleryGridItemTest` — AssertJ `SoftAssertions`, zéro JavaFX) + +### `JustifiedRowTest` + +1. Fusion de deux jours dans une même ligne quand le second jour y tient en entier et que la ligne est encore la première du jour en cours (porter le cas équivalent de `GalleryRowTest`). +2. Non-fusion quand la ligne ouverte est une ligne de continuation (2e+ ligne d'un jour ayant déjà débordé) — porter le cas équivalent. +3. Remplissage exact d'une ligne non-dernière, mix paysage/portrait : `Σ width + totalSpacing == availableWidth`. +4. Étirement proportionnel réparti uniquement sur les paysages/carrés (le portrait garde sa largeur naturelle). +5. Repli sur tous les éléments quand la ligne ne contient que des portraits. +6. Fichier unique plus large que `availableWidth` (panorama) → clampé exactement. +7. Fichier sans métadonnées (aspect indéterminé) → traité comme carré, éligible à l'étirement. +8. Dernière ligne "presque pleine" (≥ 0.85) → étirée à `availableWidth` exactement. +9. Dernière ligne "clairsemée" (< 0.85) → non étirée, espace résiduel laissé vide. +10. Frontière entre deux jours fusionnés dans une ligne : vérifier que `dateSpacing` (pas `spacing`) est bien utilisé avant la première entrée du second jour, y compris après étirement (l'espacement ne doit jamais être touché par le calcul de `slack`). +11. Marqueur `DateMarker` posé exactement sur la première entrée effectivement placée de chaque jour, jamais sur les suivantes — y compris sur une ligne de continuation (aucun marqueur du tout). +12. `spacing = 0` (valeur de production actuelle) et `spacing > 0`. +13. Robustesse d'arrondi flottant (correction résiduelle ramène la somme à `availableWidth` à `1e-6` près). + +### `JustifiedGridItemTest` + +1. Une `HeaderRow` est insérée immédiatement avant toute `JustifiedRow` contenant au moins une entrée marquée ; aucune avant une ligne de continuation sans marqueur. +2. `HeaderColumn.spanWidth()` égale exactement la somme des `width()` (déjà étirés) des entrées consécutives de ce jour + `spacing` entre elles — pas la largeur naturelle. +3. Plusieurs `HeaderColumn` dans une même `HeaderRow` quand une ligne contient une fusion de deux jours. + +Follow-up hors scope v1 : un `JustifiedGalleryPaneTest` headless (façon `ThumbnailGalleryPaneTest`) pour la sélection/navigation/scroll bout-en-bout, une fois le rendu stabilisé visuellement. + +--- + +## 8. CSS (`src/main/resources/css/pholio.css`) + +Aucun sélecteur legacy à casser (le legacy continue de les utiliser tel quel). + +- **Réutilisés sans modification** : `.photo-card` / `:hover` / `:selected` (même pseudo-classe `selected` posée par `JustifiedGridCell`), `.gallery-timeline*` (même instance de `GalleryTimelineBar`), `.gallery-grid .scroll-bar:vertical` et `.gallery-grid .list-cell` (à réappliquer via la classe `"gallery-grid"` sur la nouvelle `ListView`), badges (`.thumbnail-overlay-favorite`, `.thumbnail-overlay-media-type`, `.gallery-gps-badge`) conservés à l'identique, **`.gallery-date-header` et `.gallery-date-locality`** réutilisés tels quels pour le texte de l'en-tête (purement typographiques, sans lien avec la case à cocher). +- **Non repris** (liés aux actions groupées) : `.photo-card:checked`, `.thumbnail-overlay-selection`, `.gallery-date-select-icon`, `.gallery-date-actions-menu` — laissés intacts dans le fichier pour le legacy, non utilisés par `JustifiedGridCell`. +- Pas de nouvelle classe `.justified-grid` sauf besoin visuel constaté à l'implémentation — réutiliser `.gallery-grid`/`.edge-to-edge`. +- Simplification côté Java uniquement (pas de CSS) dans `JustifiedGridCell` : + - l'inset de carte (`BORDER_BLEED`) n'a plus qu'un seul état possible (jamais "checked") — supprimer la branche `checked`/`CHECKED_CORNER_RADIUS`/l'animation `animateCheckedShrink` ; + - `dateHeaderColumn` se réduit à `HBox(8, headerLabel[, localityLabel])` pinné à `spanWidth` (`setMinWidth/setPrefWidth/setMaxWidth`), sans `selectSlot` ni `actionsMenu` — la hauteur minimale n'a plus besoin du plancher `ICON_SIZE + 14` (plus d'icône), juste la hauteur naturelle du texte. + +--- + +## Vérification + +1. Build : `mvn -q -DskipTests package` (compile le nouveau + le legacy déplacé). +2. Tests unitaires : `mvn -q test -Dtest=JustifiedRowTest,JustifiedGridItemTest` (couche pure), puis suite complète du module UI (`GalleryViewTest`, tests legacy déplacés) pour confirmer l'absence de régression. +3. Lancement manuel de l'application (mode UI, bibliothèque avec plusieurs centaines/milliers de photos) : vérifier visuellement qu'aucune ligne ne dépasse la largeur du conteneur (pas de scrollbar horizontale), que le remplissage est bien à 100 % sauf éventuellement la toute dernière ligne clairsemée, que les en-têtes de date + lieu s'affichent et restent alignés avec les cartes qu'ils annoncent, que la sélection unique + navigation clavier (flèches, Entrée) fonctionne en sautant correctement les en-têtes, et que le scrubber `GalleryTimelineBar` scrolle correctement et affiche les bons marqueurs d'année/densité. +4. Redimensionner la fenêtre pendant l'utilisation pour vérifier le recalcul du layout justifié en continu. +5. Confirmer que `GalleryView` compile et s'exécute sans référence résiduelle à `ThumbnailGalleryPane`, et que le mode legacy (packages `.legacy`) compile toujours indépendamment (même si non branché sur `GalleryView`). + +### Fichiers critiques + +- `src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailGalleryPane.java` +- `src/main/java/org/icroco/pholio/ui/view/gallery/GalleryRow.java` +- `src/main/java/org/icroco/pholio/ui/view/gallery/GalleryGridItem.java` +- `src/main/java/org/icroco/pholio/ui/view/gallery/GalleryGridCell.java` +- `src/main/java/org/icroco/pholio/ui/view/gallery/GalleryTimelineBar.java` +- `src/main/java/org/icroco/pholio/ui/view/gallery/GalleryView.java` +- `src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailImageCache.java` (référence, inchangé) +- `src/test/java/org/icroco/pholio/ui/view/gallery/GalleryRowTest.java` +- `src/test/java/org/icroco/pholio/ui/view/gallery/GalleryViewTest.java` +- `src/main/resources/css/pholio.css` diff --git a/doc/startup-and-scheduled-tasks.md b/doc/startup-and-scheduled-tasks.md new file mode 100644 index 0000000..4e7bd7f --- /dev/null +++ b/doc/startup-and-scheduled-tasks.md @@ -0,0 +1,65 @@ +# Tâches au démarrage et planifiées + +Toutes les tâches automatiques du projet passent par deux mécanismes maison (aucun `@Scheduled` Spring, +aucun cron externalisé dans `application.yaml`/`preferences.yaml`), tous deux déclenchés sur +`ApplicationReadyEvent` — après que tout le contexte Spring est up, pas `@PostConstruct` (qui tourne trop +tôt, pendant que le contexte s'assemble encore) : + +- **[`IStartupTask`](../src/main/java/org/icroco/pholio/infra/scheduling/IStartupTask.java)** / **[ + `StartupTaskRunner`](../src/main/java/org/icroco/pholio/infra/scheduling/StartupTaskRunner.java#L43)** — + exécute chaque tâche une seule fois, séquentiellement dans l'ordre `@Order`, après une attente aléatoire de + 60 à 120 secondes post-démarrage (pour ne pas concurrencer le lancement lui-même). Chaque tâche tourne sur + `TaskType.BACKGROUND_SYNC`. +- **[`IRecurringJob`](../src/main/java/org/icroco/pholio/infra/scheduling/IRecurringJob.java)** / **[ + `RecurringJobScheduler`](../src/main/java/org/icroco/pholio/infra/scheduling/RecurringJobScheduler.java#L39)** — + planifie chaque job sur `ThreadPoolTaskScheduler` (`scheduleAtFixedRate`) avec sa propre `period()` et son + propre `initialDelay()`. + +## Tâches au démarrage (`IStartupTask`) + +Une seule exécution, dans cet ordre (`@Order` croissant, non-ordonnées en dernier) : + +| Ordre | Tâche | Condition | Description | +|----------------------|------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `HIGHEST_PRECEDENCE` | [`GeoReferenceDataInitializer`](../src/main/java/org/icroco/pholio/infra/geocoding/GeoReferenceDataInitializer.java#L52) | pref `geocoding.reference-installed` == false | Importe ~171k lignes GeoNames (`geodata/cities1000.txt.gz`) dans la table de référence `place`, utilisée pour le reverse-geocoding local. Ne tourne qu'une fois dans la vie de l'app — la préférence `editable: false` empêche que quiconque la marque installée sans l'avoir réellement fait. | +| `0` | [`MediaFileProcessingFlagsBackfillTask`](../src/main/java/org/icroco/pholio/infra/library/MediaFileProcessingFlagsBackfillTask.java#L63) | toujours | Recale les flags `THUMBNAIL_GENERATED`/`METADATA_GENERATED` de chaque `media_file` de la librairie ouverte sur leur état réel — correction bon marché à chaque démarrage, sans coût si déjà à jour. | +| `1` | [`MediaFaceDetectionBackfillTask`](../src/main/java/org/icroco/pholio/infra/recognition/MediaFaceDetectionBackfillTask.java#L46) | pref `recognition.enabled` (défaut: `true`) | Rattrape la détection visage/animal sur tout fichier de la librairie encore sans `FACE_DETECTED` — les fichiers antérieurs à cette fonctionnalité, ou dont une détection précédente n'a jamais abouti. Soumis comme tâche *visible* (`TaskType.IMAGE_ANALYSIS`), avec la progression affichée dans la barre de statut. | +| `2` | [`PersonOrphanCleanupTask`](../src/main/java/org/icroco/pholio/infra/recognition/PersonOrphanCleanupTask.java#L24) | `personManagementService.hasAnonymousOrphanPersons()` | Supprime les `Person` anonymes qui se retrouvent sans aucune région associée — rattrapage pour les cas où les événements `MediaFileRemovedEvent`/`LibraryFolderRemovedEvent` n'ont pas pu être traités en temps réel (feature antérieure, ou session interrompue). | +| *(non ordonnée)* | [`LibrarySyncTask`](../src/main/java/org/icroco/pholio/infra/library/LibrarySyncTask.java#L34) | toujours | Resynchronise la librairie ouverte avec l'état réel du disque. Tourne aussi à chaque changement de librairie via son propre [`@EventListener onLibraryChanged`](../src/main/java/org/icroco/pholio/infra/library/LibrarySyncTask.java#L68), pas seulement au démarrage. Une notification n'est créée que si le rapport de synchronisation n'est pas vide. | + +Toutes, sauf `GeoReferenceDataInitializer` (qui touche la base de référence globale, pas une librairie), +sont `@DependsOn("libraryService")` : elles ont besoin que le datasource propre à la librairie ouverte existe +déjà. + +## Tâche planifiée récurrente (`IRecurringJob`) + +| Job | Période | Premier lancement | Description | +|-----------------------------------------------------------------------------------------------------------------------------------|---------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [`NotificationRetentionScheduler`](../src/main/java/org/icroco/pholio/infra/notification/NotificationRetentionScheduler.java#L23) | 1 jour | 300s après le démarrage | Supprime les notifications plus vieilles que `notifications.retention-days` (préférence, défaut 30 jours). Le délai initial de 300s (au lieu d'attendre un jour entier) évite qu'une app rouverte après plusieurs jours d'inactivité attende inutilement son premier nettoyage. | + +## Autres accroches au cycle de vie (hors des deux mécanismes ci-dessus) + +Pas des "tâches" au sens `IStartupTask`/`IRecurringJob`, mais qui s'exécutent bien automatiquement au +démarrage : + +- [`ThemeManager.onApplicationReady`](../src/main/java/org/icroco/pholio/ui/theme/ThemeManager.java#L276) — + `@EventListener(ApplicationReadyEvent.class)` — se contente de logger le mode clair/sombre détecté au + niveau OS. +- [`PreferenceService.load`](../src/main/java/org/icroco/pholio/infra/preferences/PreferenceService.java#L143) — + `@PostConstruct` — charge le schéma de préférences et les valeurs utilisateur, démarre la surveillance des + changements. +- [`LibraryService.start`](../src/main/java/org/icroco/pholio/infra/library/LibraryService.java#L77) — + `@PostConstruct` — découvre les librairies disponibles et ouvre celle résolue au démarrage (datasource + per-library). +- [`NotificationsManager.load`](../src/main/java/org/icroco/pholio/ui/notification/NotificationsManager.java#L56) — + `@PostConstruct` — charge la liste des notifications déjà en base, de façon asynchrone (`TaskType.DATABASE`). +- [`Env.init`](../src/main/java/org/icroco/pholio/util/Env.java#L19) — **ne s'exécute jamais** : son + annotation `@Component` est commentée, la classe n'est donc pas un bean Spring. + +## Ce qui n'est *pas* automatique + +[`TaskManager`](../src/main/java/org/icroco/pholio/ui/task/TaskManager.java) et +[`TaskService`](../src/main/java/org/icroco/pholio/infra/task/TaskService.java) sont le moteur d'exécution +générique (des pools de threads par `TaskType`) que toutes les tâches ci-dessus utilisent pour tourner hors +du thread JavaFX — mais ce ne sont pas eux-mêmes des tâches de démarrage ou planifiées : ils exécutent aussi +bien des actions déclenchées par l'utilisateur (import d'une librairie, régénération de miniatures, etc.). diff --git a/src/main/java/org/icroco/pholio/ui/GuiBootstrap.java b/src/main/java/org/icroco/pholio/ui/GuiBootstrap.java index 50669d2..b941a2a 100644 --- a/src/main/java/org/icroco/pholio/ui/GuiBootstrap.java +++ b/src/main/java/org/icroco/pholio/ui/GuiBootstrap.java @@ -23,13 +23,54 @@ public final class GuiBootstrap { */ private static final String ENABLE_PREVIEW_PROPERTY = "javafx.enablePreview"; + /** + * Works around a Prism dirty-region computation bug (confirmed against JavaFX 27 on macOS) that leaves + * a stale paint on screen for a virtualised {@code ListCell} descendant added/removed from its parent's + * children — reproduced with {@code JustifiedGridCell}'s own navigation-selection frame: the underlying + * scene-graph state is always correct (traced through every layer, from the click handler down to + * {@code Node.getChildren()} mutation), a real paint pass does run, yet the changed pixels never reach + * the window until an unrelated pointer event forces a larger dirty region that happens to cover them. + * Confirmed the actual cause, not a side effect of this application's own code, by ruling out every + * other layer in turn: reproduces identically regardless of {@code -Dprism.order} (software or hardware + * rasteriser alike, so not GPU/Metal-specific), disappears completely under JavaFX's own near-empty + * default stylesheet (so tied to how much CSS Prism has to account for, not a logic bug in this + * component), and vanishes outright with this exact property — the JavaFX pulse logger + * ({@code -Djavafx.pulseLogger=true}) named the responsible phase directly ("Dirty Opts Computed"). + * + *

Cost measured, not assumed: two matched pulse-logger traces (same 67 000+ file library, same + * scroll pattern, {@code -Djavafx.pulseLogger=true} either with or without this property) showed average + * paint time per pulse rising from 7.15ms to 10.26ms, and pulses missing a 16ms/60Hz budget rising from + * 1.3% to 1.8% of all pulses — a real but modest cost, imperceptible in manual testing against the same + * library, and worth paying application-wide to fix a correctness bug rather than only in the one + * component that happened to surface it first. + */ + private static final String DISABLE_PRISM_DIRTY_OPTS_PROPERTY = "prism.dirtyopts"; + private GuiBootstrap() { } public static void launch(String[] args) { + configureSystemProperties(); + Application.launch(PholioFxApplication.class, args); + } + + /** + * Split out from {@link #launch} purely so {@code GuiBootstrapTest} can assert on these two properties + * without going anywhere near {@link Application#launch}, which would actually start the JavaFX toolkit + * — not something a fast, headless unit test should do, and not what would even exercise the bug + * {@link #DISABLE_PRISM_DIRTY_OPTS_PROPERTY} works around regardless (see that field's own javadoc for + * why a real visual regression test for that specific bug is impractical here). What a test can + * usefully guard is this method itself: that it keeps setting the property, and to the right value, so a + * future edit here — accidentally dropping the line, flipping the value, or losing the "don't overwrite + * an explicit choice" guard — is caught immediately rather than only rediscovered by hand, the same way + * the underlying rendering bug originally was. + */ + static void configureSystemProperties() { if (System.getProperty(ENABLE_PREVIEW_PROPERTY) == null) { System.setProperty(ENABLE_PREVIEW_PROPERTY, "true"); } - Application.launch(PholioFxApplication.class, args); + if (System.getProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY) == null) { + System.setProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY, "false"); + } } } diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/EmptyLibraryPane.java b/src/main/java/org/icroco/pholio/ui/view/gallery/EmptyLibraryPane.java index 6fb6d1a..da56d1b 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/EmptyLibraryPane.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/EmptyLibraryPane.java @@ -17,7 +17,7 @@ import org.kordamp.ikonli.javafx.FontIcon; * {@link GalleryView}'s first pane: shown only while the open library has no folder at all. * *

Not a Spring bean — {@link GalleryView} is prototype-scoped and owns this as a plain child, the same - * way it owns {@link ThumbnailGalleryPane} and {@link PhotoDetailPane}. + * way it owns {@link JustifiedGalleryPane} and {@link PhotoDetailPane}. */ public class EmptyLibraryPane extends StackPane implements Disposable { diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryTimelineBar.java b/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryTimelineBar.java index 0833a97..a8c6945 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryTimelineBar.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryTimelineBar.java @@ -22,7 +22,7 @@ import java.util.function.DoubleConsumer; import java.util.function.DoubleFunction; /** - * The floating, Google-Photos-style scrubber painted over {@link ThumbnailGalleryPane}'s grid in place of a + * The floating, Google-Photos-style scrubber painted over a gallery grid's own {@code ListView} in place of a * plain vertical scrollbar: a translucent overlay that fades in on mouse activity and fades back out once * idle (see {@link #activity()} / {@link #IDLE_DELAY}), drawing the whole library's captured-date span as a * label per year that actually has photos ({@link #setYearMarks} — a year with none is simply never passed @@ -37,22 +37,25 @@ import java.util.function.DoubleFunction; * ({@link #setOnMousePressed}) and actually scrolls via {@link #setOnSeek}, while leaving without pressing * snaps the pill straight back home ({@link #endHoverReverting}) as if the hover had never happened. * - *

{@link ThumbnailGalleryPane} owns every date computation — this class only ever receives already-resolved - * fractions (0 at the newest end of the bar, 1 at the oldest) and reports fractions back out through - * {@link #setOnSeek}/{@link #setOnPreview}; it has no notion of {@code LocalDate} or row indices of its own, - * which is what keeps it independently testable and reusable regardless of how rows/dates are modelled above - * it. + *

Whichever gallery pane owns an instance of this bar (today, {@code JustifiedGalleryPane} and, in the + * legacy package, {@code org.icroco.pholio.ui.view.gallery.legacy.ThumbnailGalleryPane}) owns every date + * computation — this class only ever receives already-resolved fractions (0 at the newest end of the bar, 1 + * at the oldest) and reports fractions back out through {@link #setOnSeek}/{@link #setOnPreview}; it has no + * notion of {@code LocalDate} or row indices of its own, which is what keeps it independently testable and + * reusable regardless of how rows/dates are modelled above it — including by two unrelated gallery panes at + * once. * *

This region's own layout bounds — what {@link #setOnMousePressed} etc. actually hit-test against — are - * kept exactly as wide as {@code ThumbnailGalleryPane.SCROLLBAR_ALLOWANCE}, the dead margin that pane already - * reserves for a scrollbar and never places a thumbnail into; nothing here is new to click. The canvas and - * the pill are both mouse-transparent, and are free to paint wider ({@link #VISUAL_WIDTH}) and spill leftward - * over the grid's rightmost column — a Region never clips a child to its own bounds unless told to — which is - * what makes the labels legible without shrinking the actually-clickable strip. + * kept exactly as wide as the {@code clickableWidth} constructor argument (each owning pane passes its own + * {@code SCROLLBAR_ALLOWANCE}, the dead margin that pane already reserves for a scrollbar and never places a + * thumbnail into); nothing here is new to click. The canvas and the pill are both mouse-transparent, and are + * free to paint wider ({@link #VISUAL_WIDTH}) and spill leftward over the grid's rightmost column — a Region + * never clips a child to its own bounds unless told to — which is what makes the labels legible without + * shrinking the actually-clickable strip. */ -class GalleryTimelineBar extends Region { +public class GalleryTimelineBar extends Region { - record YearMark(double fraction, String label) { + public record YearMark(double fraction, String label) { } private static final double PAD = 12; @@ -108,7 +111,7 @@ class GalleryTimelineBar extends Region { private @Nullable DoubleConsumer onSeek; private @Nullable DoubleFunction onPreview; - GalleryTimelineBar(double clickableWidth) { + public GalleryTimelineBar(double clickableWidth) { getStyleClass().add("gallery-timeline"); setPrefWidth(clickableWidth); setMinWidth(clickableWidth); @@ -186,12 +189,12 @@ class GalleryTimelineBar extends Region { }); } - void setOnSeek(DoubleConsumer onSeek) { + public void setOnSeek(DoubleConsumer onSeek) { this.onSeek = onSeek; } /** Resolves a hovered fraction to a preview label, without touching the grid's actual scroll position. */ - void setOnPreview(DoubleFunction onPreview) { + public void setOnPreview(DoubleFunction onPreview) { this.onPreview = onPreview; } @@ -199,14 +202,14 @@ class GalleryTimelineBar extends Region { * {@code marks} must already be sorted by {@link YearMark#fraction()}. An empty list hides the whole * bar — nothing dated to scrub through, so there is nothing for it to do. */ - void setYearMarks(List marks) { + public void setYearMarks(List marks) { this.yearMarks = marks; setVisible(!marks.isEmpty()); redraw(); } /** Each value is one month-with-photos' fraction along the line, ascending. */ - void setDensity(double[] fractions) { + public void setDensity(double[] fractions) { this.density = fractions; redraw(); } @@ -218,7 +221,7 @@ class GalleryTimelineBar extends Region { * {@code label == null} hides the pill — there is no group to report a date for yet (e.g. right after a * reload, before the grid has laid out any cell). */ - void setCurrentPosition(double fraction, @Nullable String label) { + public void setCurrentPosition(double fraction, @Nullable String label) { this.currentFraction = fraction; this.currentLabel = label; if (!hovering) { @@ -228,11 +231,11 @@ class GalleryTimelineBar extends Region { /** * Shows the bar and (re)starts the idle-hide countdown. Unlike Google Photos, moving the mouse alone - * never reveals this bar: {@link ThumbnailGalleryPane} only calls this once an actual scroll has moved - * the grid by enough rows to not be noise (see its {@code SCROLL_REVEAL_ROW_THRESHOLD}), or when the + * never reveals this bar: the owning gallery pane only calls this once an actual scroll has moved the + * grid by enough rows to not be noise (see its own {@code SCROLL_REVEAL_ROW_THRESHOLD}), or when the * pointer enters/moves over the bar itself ({@link #beginHover}/{@code setOnMouseMoved}). */ - void activity() { + public void activity() { if (yearMarks.isEmpty()) { return; } @@ -243,7 +246,7 @@ class GalleryTimelineBar extends Region { restartIdleTimer(); } - void dispose() { + public void dispose() { idleTimer.stop(); fadeIn.stop(); fadeOut.stop(); diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryView.java b/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryView.java index ca5e257..379f5f9 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryView.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryView.java @@ -54,7 +54,6 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.UnaryOperator; @@ -65,7 +64,7 @@ import java.util.function.UnaryOperator; * *

{@link EmptyLibraryPane} shows only while the library has no folder at all — it tracks * {@link StatusBar#libraryConfiguredProperty()} rather than counting photos itself, so a library with - * folders but nothing indexed yet stays on {@link ThumbnailGalleryPane} (that grid's own empty state, still + * folders but nothing indexed yet stays on {@link JustifiedGalleryPane} (that grid's own empty state, still * to be built) instead of falling back to this one. * *

{@link PhotoDetailPane} replaces the grid once a thumbnail is double-clicked ({@link #photoOpen}); @@ -73,7 +72,7 @@ import java.util.function.UnaryOperator; * while open), via the {@link PhotoDetailPane#setOnClose} callback wired in this constructor. * *

Also a {@link SelectionSource}: both {@link #hasSelection()} and {@link #selectedMediaFile()} simply - * delegate to {@link ThumbnailGalleryPane#selectedFileProperty()}, the one place a click actually lands. + * delegate to {@link JustifiedGalleryPane#selectedFileProperty()}, the one place a click actually lands. * {@code StatusBar}'s breadcrumb trail and the inspector are wired to react the moment it changes. * *

Two independent {@link MediaInfoPane} instances, not one shared between modes: {@link #mediaInfoPaneGrid} @@ -89,7 +88,7 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na private static final Logger log = LoggerFactory.getLogger(GalleryView.class); private final EmptyLibraryPane emptyLibraryPane; - private final ThumbnailGalleryPane galleryPane; + private final JustifiedGalleryPane galleryPane; private final PhotoDetailPane detailPane; private final MediaInfoPane mediaInfoPaneGrid; private final StackPane viewportStack = new StackPane(); @@ -121,7 +120,7 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na /** * The filter {@link #onNavigate} applies for {@link ENavigationDestination#FAVORITES} — {@code null} * for {@link ENavigationDestination#PHOTOS}. Combined with whatever the search bar itself contributes - * in {@link #applyFilters}, rather than the two fighting over {@link ThumbnailGalleryPane#filters()}. + * in {@link #applyFilters}, rather than the two fighting over {@link JustifiedGalleryPane#filters()}. */ private @Nullable IMediaFileFilter presetFilter; @@ -170,14 +169,11 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na this.mediaAnalysisService = mediaAnalysisService; this.gallerySearchState = gallerySearchState; emptyLibraryPane = new EmptyLibraryPane(i18n, coordinator); - galleryPane = new ThumbnailGalleryPane(mediaFileService, taskService, mediaLibraryState, preferences, i18n, imageCache); + galleryPane = new JustifiedGalleryPane(mediaFileService, taskService, mediaLibraryState, preferences, i18n, imageCache); detailPane = new PhotoDetailPane(fullImageCache, i18n, faceRegionQueryService); mediaInfoPaneGrid = new MediaInfoPane(i18n, faceRegionQueryService); mediaInfoPaneGrid.getStyleClass().add("media-info-pane-overlay"); galleryPane.setOnOpenRequest((file, sourceThumbnail) -> openDetail(file, sourceThumbnail, null)); - galleryPane.setOnRegenerateThumbnails(this::regenerateThumbnails); - galleryPane.setOnSetGpsForFiles(this::openLocationDialogForFiles); - galleryPane.setOnSetRatingForFiles(this::setRatingForFiles); detailPane.setOnClose(this::closeDetail); detailPane.setOnPrevious(() -> navigate(galleryPane::previous, PhotoDetailPane.ESlideDirection.LEFT)); detailPane.setOnNext(() -> navigate(galleryPane::next, PhotoDetailPane.ESlideDirection.RIGHT)); @@ -302,7 +298,7 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na } /** - * Exposed for tests, the same way {@code ThumbnailGalleryPane.rows()} is. + * Exposed for tests, the same way {@code JustifiedGalleryPane.rows()} is. */ MediaInfoPane mediaInfoPaneGrid() { return mediaInfoPaneGrid; @@ -311,7 +307,7 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na /** * Exposed for tests, the same way {@link #mediaInfoPaneGrid()} is. */ - ThumbnailGalleryPane galleryPane() { + JustifiedGalleryPane galleryPane() { return galleryPane; } @@ -335,7 +331,7 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na /** * {@link PhotoDetailPane#setOnClose}'s target — fired by its close button or its own Escape handling * alike. The grid is revealed (and re-synced/scrolled to whatever file the detail pane was last - * showing, via {@link ThumbnailGalleryPane#revealAndPulse} — browsing via previous/next moves that file + * showing, via {@link JustifiedGalleryPane#revealAndPulse} — browsing via previous/next moves that file * without ever touching the grid's own selection or scroll position) before the shrink * animation plays, not after, so it is already showing correctly underneath for the whole shrink — * only once that finishes does {@link #photoOpen} actually hide the detail pane and hand focus back. @@ -354,9 +350,9 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na } /** - * Shared by {@link ThumbnailGalleryPane#setOnOpenRequest} and the detail pane's previous/next buttons — + * Shared by {@link JustifiedGalleryPane#setOnOpenRequest} and the detail pane's previous/next buttons — * every path into {@link PhotoDetailPane} goes through here, so the grid's own single-select ({@link - * ThumbnailGalleryPane#select}) always tracks whatever the detail pane is currently showing. + * JustifiedGalleryPane#select}) always tracks whatever the detail pane is currently showing. * {@code sourceThumbnail} — the double-clicked card, {@code null} when called from previous/next * instead — is only ever used to grow the pane open from that card's own on-screen position the very * first time it opens; its bounds are read before {@link #photoOpen} flips, since a card about to be @@ -392,8 +388,8 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na /** * Warms {@link #fullImageCache} for the files either side of {@code current} — {@code * photo-detail.preload-neighbors} deep in each direction — so a Left/Right press from here usually - * finds its target already decoded. Walks {@link ThumbnailGalleryPane#previous}/ - * {@link ThumbnailGalleryPane#next} rather than a fixed window of {@code sortedFiles}, so it stops + * finds its target already decoded. Walks {@link JustifiedGalleryPane#previous}/ + * {@link JustifiedGalleryPane#next} rather than a fixed window of {@code sortedFiles}, so it stops * naturally at either end of the list instead of prefetching nothing (or throwing) past it. */ private void prefetchNeighbors(MediaFile current) { @@ -414,7 +410,7 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na } /** - * {@code neighbourOf} is {@link ThumbnailGalleryPane#previous} or {@link ThumbnailGalleryPane#next}; + * {@code neighbourOf} is {@link JustifiedGalleryPane#previous} or {@link JustifiedGalleryPane#next}; * {@code direction} is the matching {@link PhotoDetailPane.ESlideDirection} for whichever of the two * it is. A neighbour slides in; none left in that direction instead * {@link PhotoDetailPane#bounceEdge bounces} the current photo toward it, the "nothing more this way" @@ -538,90 +534,6 @@ public class GalleryView extends HBox implements Disposable, SelectionSource, Na })); } - /** - * {@link ThumbnailGalleryPane#setOnSetRatingForFiles}'s target — the date header's own "set rating" - * submenu. Unlike {@link #openLocationDialogForFiles}, no modal to pick a value from: the submenu entry - * clicked already carries it. A no-op on an empty {@code files} for the same reason as that method. - */ - private void setRatingForFiles(List files, int rating) { - if (files.isEmpty()) { - return; - } - CompletableFuture[] updates = files.stream() - .map(file -> metadataEditService.updateRating(file, rating)) - .toArray(CompletableFuture[]::new); - CompletableFuture.allOf(updates).whenComplete((ignored, error) -> Platform.runLater(() -> { - if (error != null) { - log.warn("Could not update rating for {} media file(s)", files.size(), error); - } - })); - } - - /** - * {@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 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. - */ - private void openLocationDialogForFiles(List files) { - if (files.isEmpty()) { - return; - } - GeoLocationEditView view = new GeoLocationEditView(i18n, recentLocationService); - modalService.show(view, true); - view.show( - (query, onMatches) -> taskService.execute(TaskType.DATABASE, () -> { - var matches = placeSearchService.search(query, 20); - Platform.runLater(() -> onMatches.accept(matches)); - }), - (GeoLocation location) -> { - CompletableFuture[] updates = files.stream() - .map(file -> metadataEditService.updateGeoLocation(file, location)) - .toArray(CompletableFuture[]::new); - CompletableFuture.allOf(updates).whenComplete((ignored, error) -> Platform.runLater(() -> { - modalService.hide(); - if (error != null) { - log.warn("Could not update location for {} media file(s)", files.size(), error); - } - })); - }, - modalService::hide); - } - - /** - * {@link ThumbnailGalleryPane#setOnRegenerateThumbnails}'s target — the date header's own "regenerate - * thumbnails" menu entry. Deletes and redecodes each of {@code files}' cached thumbnail - * ({@link MediaAnalysisService#regenerateThumbnail}), tracked as a single {@code TaskManager} entry - * ({@link TaskService#submitBatch}) since a whole date's worth of files can take a moment. A file - * without a resolvable absolute path (its library folder was removed since the checkbox was ticked) - * still counts toward the batch total so the progress bar reaches completion regardless. - */ - private void regenerateThumbnails(List files) { - if (files.isEmpty()) { - return; - } - TaskService.BatchTask batch = taskService.submitBatch(TaskType.THUMBNAIL, - i18n.get("gallery.date.actions.regenerateThumbnails.taskTitle"), - files.size(), true); - for (MediaFile file : files) { - Optional absolute = libraryFolderService.absolutePathOf(file); - if (absolute.isEmpty()) { - log.warn("No library folder for media file {}; skipping thumbnail regeneration", file.id()); - batch.completedOne(); - continue; - } - // Evicted up front, not after the regenerate completes: MediaAnalysisService.regenerateThumbnail - // deletes and rewrites the same on-disk path this cache already has a decode of, and the hash - // itself never changes, so nothing else would ever tell this cache its entry is stale. - thumbnailImageCache.invalidate(file.hash()); - Path absolutePath = absolute.get(); - taskService.execute(TaskType.THUMBNAIL, () -> mediaAnalysisService.regenerateThumbnail(file, absolutePath, batch)); - } - } - @Override public void dispose() { emptyLibraryPane.visibleProperty().unbind(); diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGalleryPane.java b/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGalleryPane.java new file mode 100644 index 0000000..f7c2b3c --- /dev/null +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGalleryPane.java @@ -0,0 +1,1802 @@ +package org.icroco.pholio.ui.view.gallery; + +import atlantafx.base.util.Animations; +import javafx.animation.*; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Bounds; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.ScrollBar; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.scene.input.ZoomEvent; +import javafx.scene.layout.Background; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.util.Duration; +import org.icroco.pholio.domain.library.MediaFile; +import org.icroco.pholio.infra.i18n.EDateHeaderFormat; +import org.icroco.pholio.infra.i18n.I18nService; +import org.icroco.pholio.infra.library.MediaFileService; +import org.icroco.pholio.infra.media.EThumbnailQuality; +import org.icroco.pholio.infra.preferences.AppPreferences; +import org.icroco.pholio.infra.preferences.PreferenceItem; +import org.icroco.pholio.infra.task.TaskService; +import org.icroco.pholio.infra.task.TaskType; +import org.icroco.pholio.ui.common.Disposable; +import org.icroco.pholio.ui.common.FxThread; +import org.icroco.pholio.ui.common.SubscriptionScope; +import org.icroco.pholio.ui.library.MediaLibraryState; +import org.icroco.pholio.ui.util.EasingFX; +import org.icroco.pholio.ui.view.gallery.filter.IMediaFileFilter; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.*; +import java.util.function.BiConsumer; + +import static java.util.Objects.requireNonNull; + +/** + * {@code GalleryView}'s second pane: the media grid — {@link MediaFile}s ordered most-recently-captured + * first ({@link MediaFile#byCapturedAtDesc()}, undated last), packed by day into {@link JustifiedRow}s by + * {@link JustifiedRow#justify} — continuously across calendar-day boundaries the same way + * {@code legacy.GalleryRow#chunk} always did, a day only ever sharing a row with its neighbour when its + * whole set of photos fits in what's left of it — and then, unlike that legacy algorithm, stretched: + * every row's entries are widened (never squeezed) so the row's total content width lands exactly on this + * pane's own available width, never short of it and never over — see {@link JustifiedRow#justify}'s own + * javadoc for the exact formula. {@link JustifiedGridItem#itemize} then expands that into {@link #rows}' own + * item sequence: a {@link JustifiedGridItem.HeaderRow} — one or more date labels, its own fixed height — + * immediately before whichever {@link JustifiedRow} of thumbnails it labels, never extra content rendered + * inside that row's own cell — see {@link JustifiedGridItem}'s own javadoc for the {@code VirtualFlow} + * cell-recycling bug a varying row height once caused, and why every {@link JustifiedRow} item here is + * exactly the same height as every other one regardless of which date(s) its own entries belong to. + * + *

What makes it virtualised: JavaFX only virtualises a single column of cells, so a grid needs each + * {@link ListCell} to render a whole item at once — {@link #rows} holds one {@link JustifiedGridItem} per + * cell, {@link JustifiedRow#justify}ed and {@link JustifiedGridItem#itemize}d from the flat list + * {@link MediaFileService} returns. + * + *

Never wider than its parent, by construction, not by accident — and never a partial + * thumbnail. {@code gallery.thumbnail-size} (in pixels) fixes every thumbnail's height + * only; its width follows the file's own aspect ratio, then gets stretched (see {@link JustifiedRow#justify}) + * so each row's cards collectively reach exactly this pane's own available width. A file is never squeezed + * below its native aspect to make it fit — the stretch only ever widens (crops further into) a landscape or + * square photo, portraits kept at their own aspect whenever the row has at least one landscape/square entry + * to absorb the slack instead. + * + *

Row membership therefore does change with this pane's own width (throttled the same way + * {@link #onFilesChanged} already throttles import churn, so a resize drag re-chunks a few times a second, + * not once a pixel) and with the {@code gallery.thumbnail-size} preference — but never with any individual + * row cell's own width, which a pooled cell's width during {@code VirtualFlow} recycling is not always + * trustworthy for the instant a row is assigned to it, so nothing here reads it for sizing purposes at all. + * Thumbnail height is a single, pane-level constant for the whole grid at any given moment, imperatively set + * once per slot at creation — there is nothing reactive left to size that could disagree with any other slot. + * + *

Thumbnails are decoded once and cached — see {@link ThumbnailImageCache} — and each + * {@link JustifiedGridCell} warms its own neighbours' cache entries as it scrolls into view, so flicking + * through a large library mostly hits already-decoded bitmaps. This is orthogonal to + * {@link MediaLibraryState#thumbnailReadyProperty(Long)}, which still means exactly what it always has — + * "Task C wrote a thumbnail file to disk for this id" — the cache is a second, independent "is it decoded + * into memory yet" signal layered on top, not a replacement for it. + * + *

Unlike {@code legacy.ThumbnailGalleryPane}, there is no per-file selection checkbox and no per-date + * "regenerate thumbnails"/"set GPS"/"set rating" actions menu — this component supports single selection + * only ({@link #selectedFileProperty()}); the legacy grouped-actions mechanism stays available, unmodified, + * under {@code org.icroco.pholio.ui.view.gallery.legacy} for whenever it gets reintroduced here. + * + *

Not a Spring bean; see {@code legacy.EmptyLibraryPane}'s javadoc for why. + */ +public class JustifiedGalleryPane extends StackPane implements Disposable { + + private static final Logger log = LoggerFactory.getLogger(JustifiedGalleryPane.class); + + /** + * Gap between adjacent same-day thumbnail cards, in pixels — both across a row + * ({@link JustifiedGridCell}'s {@code photosBox}) and between rows ({@link JustifiedRow#justify}'s + * row-width packing). Zero: card bounds touch directly. The visible gap between two images is + * never actually zero even so — each card already reserves {@link JustifiedGridCell#BORDER_BLEED} of its + * own on every side for its selection/hover border ring, so two adjacent images end up exactly + * {@code 2 * BORDER_BLEED} apart regardless of this value. + */ + static final double SPACING = 0; + + /** + * Extra gap, beyond {@link #SPACING}, before the one entry that starts a new date when that date's + * photos begin mid-row rather than at a row's own start — see {@link JustifiedRow#justify}'s + * {@code dateSpacing} parameter and {@link JustifiedGridItem.HeaderColumn#spanWidth()}. Deliberately + * bigger than {@link #SPACING} (currently zero) so a date boundary mid-row still reads as a seam rather + * than just another card — and so the header item's own columns, above, stay aligned with it. + */ + static final double DATE_SPACING = 16; + + /** + * Used only until {@code gallery.thumbnail-size} has ever been read; the preference itself defaults + * to the same value in {@code preferences.yaml}. + */ + private static final int DEFAULT_THUMBNAIL_SIZE = 160; + + /** + * Reserved unconditionally from {@link #getWidth()} before laying out rows — whether a vertical + * scrollbar ends up showing depends on total row count, which itself depends on how wide each row's own + * cards end up, so there is no way to know in advance whether one will appear; reserving its width every + * time, needed or not, is what makes the answer correct either way instead of only until enough rows + * accumulate to trigger one. A real scrollbar is typically 12-16px; this reserves comfortably more, at + * the cost of a little always-unused margin on a short library or a very wide window — the trade this + * pane's "no horizontal scrollbar, ever" requirement calls for. + */ + static final double SCROLLBAR_ALLOWANCE = 20; + + /** + * How many rows around a cell's own index get their thumbnails warmed in the cache as it scrolls into + * view — see {@link JustifiedGridCell#prefetchNeighbours()}. + */ + static final int PREFETCH_ROWS = 2; + + /** + * {@link #pulseCard}'s own shake distance, in pixels — see {@link Animations#shakeX(Node, double)}. + */ + private static final double REVEAL_SHAKE_OFFSET = 8; + + /** + * How many extra layout pulses {@link #reveal} waits, past the one {@code scrollTo} itself needs, for + * {@code VirtualFlow} to actually realise the target row's cell before giving up on {@link #pulseCard} + * — see {@link #pulseCardOnceSettled}. Usually settles within 1-2 pulses, but a jump landing while + * {@code PhotoDetailPane}'s own close animation is still playing concurrently (both share the same pulse + * queue) was observed needing well over a dozen — generous on purpose, since giving up merely skips a + * cosmetic cue, while a budget too tight silently drops it on exactly the runs under the most contention. + */ + private static final int REVEAL_SETTLE_ATTEMPTS = 60; + + /** + * How far {@link #bounceGrid} nudges {@link #rows}, in pixels, before springing back. + */ + private static final double GRID_BOUNCE_DISTANCE = 18; + + /** + * How long {@link #bounceGrid}'s whole nudge-then-spring-back takes. + */ + private static final Duration GRID_BOUNCE_DURATION = Duration.millis(420); + + /** + * Treats {@link #verticalScrollBar}'s value as "at that end" within this tolerance of its min/max. + */ + private static final double SCROLLBAR_EDGE_EPSILON = 1e-6; + + /** + * How much {@code gallery.thumbnail-size} changes per mouse-wheel "notch" while the platform's own + * zoom-shortcut modifier is held (Cmd on macOS, Ctrl elsewhere — see {@link ScrollEvent#isShortcutDown()}) + * — see {@link #adjustThumbnailSize} — small enough that a handful of notches reads as one deliberate, + * moderate zoom rather than a jump. Scroll up (wheel away from the user) zooms in, the same convention + * that modifier+scroll uses to zoom in browsers and other photo viewers. + */ + private static final double WHEEL_ZOOM_STEP = 6; + + /** + * How hard a trackpad pinch's own {@link ZoomEvent#getTotalZoomFactor()} — the ratio versus the + * gesture's start, not a per-tick delta — gets exaggerated (raising it to a power) before scaling + * {@link #pinchGestureStartSize} by it, whenever that ratio is {@code >= 1} (fingers spreading, + * growing the thumbnails); see {@link #onPinchZoom}. High enough that one comfortable, ordinary + * pinch-out reaches (or clamps at) {@link #maxThumbnailSize} rather than needing an uncomfortably wide + * finger spread to get there — unlike {@link #WHEEL_ZOOM_STEP}'s flat per-notch step, which is + * deliberately much gentler since a wheel has no equivalent "gesture is basically over" moment to aim + * a whole range across. + */ + private static final double PINCH_ZOOM_GROW_AMPLIFICATION = 3.5; + + /** + * {@link #PINCH_ZOOM_GROW_AMPLIFICATION}'s own counterpart for a ratio {@code < 1} (fingers pinching + * together, shrinking the thumbnails) — deliberately much higher, not just the same exponent mirrored. + * A trackpad's own reported {@code totalZoomFactor} has far less room to shrink through than to grow + * through (fingers can spread wide but can only pinch together until they touch), so applying the same + * exponent to both sides left pinching-in feeling far less sensitive than pinching-out — several + * gestures to reach {@link #minThumbnailSize} against one to reach {@link #maxThumbnailSize}. This + * compensates so either direction reaches its own end of the range in about the same one ordinary + * gesture. + */ + private static final double PINCH_ZOOM_SHRINK_AMPLIFICATION = 12; + + /** + * Caps how often {@link #setPendingThumbnailSize} actually commits to the {@code gallery.thumbnail-size} + * preference during a fast, continuous shortcut+scroll or pinch gesture. What this is really rationing is + * {@link #relayout} — a full re-chunk of every row, not the preference write itself, which + * {@code PreferenceService} already coalesces to at most one disk write per second on its own — so a + * pinch, whose {@code ZoomEvent}s can arrive far faster than a wheel's own notches ever do, was + * re-chunking (and so calling {@code setValue}, which read as "saving repeatedly") dozens of times a + * second at a tighter interval; a wheel notch stays well under even this looser one regardless, so + * raising it costs that path nothing. + */ + private static final long THUMBNAIL_ZOOM_COMMIT_INTERVAL_MILLIS = 150; + + private final MediaFileService mediaFileService; + private final TaskService taskService; + private final MediaLibraryState state; + private final AppPreferences preferences; + private final I18nService i18n; + private final ThumbnailImageCache imageCache; + private final SubscriptionScope scope = new SubscriptionScope(); + + private final ListView rows = new ListView<>(); + private final ReadOnlyObjectWrapper<@Nullable MediaFile> selectedFile = + new ReadOnlyObjectWrapper<>(this, "selectedFile", null); + + /** + * {@code GalleryView}'s hook for "a thumbnail was double-clicked, open it" — see {@link #setOnOpenRequest}. + * A plain callback rather than a property: unlike {@link #selectedFile}, opening must fire on every + * double-click, including a second one on the file already selected, which an unchanged {@code Object} + * property would not raise a change for. The {@link Node} is the clicked thumbnail's own card, so + * {@code PhotoDetailPane}'s open animation knows where on screen to grow from. + */ + private BiConsumer onOpenRequest = (file, node) -> {}; + + /** + * The same list {@link #relayout()} just packed into rows, in the same order — what {@link #next} and + * {@link #previous} walk for {@code PhotoDetailPane}'s own navigation buttons. Never {@code null}; + * empty until the first {@link #relayout()}. + */ + private List sortedFiles = List.of(); + + /** + * Set once {@link #relayout()} first finds a non-empty {@link #sortedFiles} — every {@code relayout} + * after that leaves an existing selection alone, whatever it is. Highlights the first photo the + * moment the grid has anything to show, rather than leaving it unselected until the first click. + */ + private boolean initialSelectionMade; + + /** + * Live, extensible narrowing of {@link MediaLibraryState#files()} — see {@link #filters()}. Combined + * with AND ({@link IMediaFileFilter#allOf}) and re-applied on every {@link #relayout()}; a listener + * below re-chunks immediately whenever this list itself changes, the same "rare, deliberate, should + * feel instant" treatment as a preference edit, not the throttled one file-import churn gets. + */ + private final ObservableList filters = FXCollections.observableArrayList(); + + /** + * Coalesces a burst of {@link MediaLibraryState#files()} adds — one per file identified during an + * import, potentially thousands in a row — into a {@link #relayout()} roughly every 100ms instead of + * one per add, and (see the class javadoc) every resize-driven re-chunk too, for the same reason: a + * window drag fires far more width changes than a grid this size needs to reflow for. Restarted, not + * merely (re)scheduled, on every request still pending: the same "only the most recent request matters" + * shape as {@code Debouncer}, except this one only ever needs to run on the FX thread — every trigger + * below is already on it — so a plain {@link PauseTransition} does the job without a background + * scheduler to manage or shut down. + */ + private final PauseTransition relayoutThrottle = new PauseTransition(Duration.millis(100)); + private boolean relayoutPending; + + /** + * How many extra pulses {@link #pulseRowsLayout} retries for after a {@link #relayout()} — see its own javadoc. + */ + private static final int RELAYOUT_SETTLE_PULSES = 3; + + /** + * The Google-Photos-style overlay scrubber replacing {@link #rows}' own vertical scrollbar — see + * {@link GalleryTimelineBar}'s javadoc. {@link #updateTimeline} rebuilds {@link #ascendingEntries} (and + * feeds the bar its year marks / density dots) every {@link #relayout()}; {@link #updateCurrentPosition} + * moves its pill whenever the grid actually scrolls, hooked up once the native scrollbar exists in + * {@link #hookNativeScrollBar}. + */ + private final GalleryTimelineBar timeline = new GalleryTimelineBar(SCROLLBAR_ALLOWANCE); + + /** + * Invisible, unmanaged — added purely so {@link #accentColor} can read back whatever colour the active + * theme's {@code -color-accent-emphasis} currently resolves to, the same probe trick + * {@link GalleryTimelineBar#resolveBackgroundColor} already uses (same CSS class, {@code + * gallery-timeline-accent-probe} — deliberately reused rather than declaring a second, identical rule). + */ + private final Region accentColorProbe = new Region(); + + /** + * {@link #accentColor}'s own cache — resolved at most once per pane instance, see that method's own javadoc. + */ + private @Nullable Color cachedAccentColor; + + /** + * {@link #pulseCard}'s own in-flight animation, if any — stopped before a new one starts. + */ + private @Nullable Timeline revealPulseTransition; + + /** + * One entry per day-with-photos currently in {@link #rows}, ascending by {@link TimelineEntry#epochDay()} + * — built from every {@link JustifiedRow.Entry#marker()} found across all rows (newest day first as + * scanned, then reversed) so {@link #nearestRowIndex} can binary-search it. Empty whenever nothing in + * the library has a captured date, in which case {@link #timeline} hides itself entirely. + */ + private List ascendingEntries = List.of(); + private long minEpochDay; + private long maxEpochDay; + + private record TimelineEntry(long epochDay, int rowIndex) { + } + + private record LabelledDate(LocalDate date, String label) { + } + + /** + * Re-chunks (throttled — see {@link #relayoutThrottle}) on any structural change to + * {@link MediaLibraryState#files()} — not just the ones this pane's own {@link #reload()} writes. That + * list is shared across every mounted gallery, so another instance, or a future background sync task, + * writing into it must repaint this one too. + */ + private final ListChangeListener onFilesChanged = change -> throttledRelayout(); + + public JustifiedGalleryPane(MediaFileService mediaFileService, TaskService taskService, + MediaLibraryState state, AppPreferences preferences, + I18nService i18n, ThumbnailImageCache imageCache) { + this.mediaFileService = mediaFileService; + this.taskService = taskService; + this.state = state; + this.preferences = preferences; + this.i18n = i18n; + this.imageCache = imageCache; + + rows.setCellFactory(view -> new JustifiedGridCell(this::currentQuality, this::currentThumbnailSize, this::selectAndFocus, + (file, node) -> onOpenRequest.accept(file, node), + selectedFile.getReadOnlyProperty(), + // Never actually null: only a fully-persisted file — + // via MediaLibraryState.onMediaFileIdentified — ever + // lands in state.files() for a cell to render. + file -> state.thumbnailReadyProperty(requireNonNull(file.id())), + imageCache, + this::showMissingGpsBadge, + this::accentColor)); + // "edge-to-edge" (an AtlantaFX utility class, not one of ours) drops the ListView's own default 1px + // border — 2px of horizontal width row-width packing would otherwise never know about. "gallery-grid" + // is ours, see pholio.css: it drops the theme's default ~1em of left+right .list-cell padding for + // the same reason. Both are real, measured pixels a cell's content area loses beneath what + // getWidth() reports — without stripping them here, the row-width packing overflows the cell's + // *actual* usable width by exactly that amount on every single row. + // "justified-grid" scopes the hover-effect override in pholio.css to this component alone — see + // that rule's own comment for why legacy.ThumbnailGalleryPane (sharing "gallery-grid" for its own, + // unrelated padding/scrollbar rules) must keep its own hover glow untouched. + rows.getStyleClass().addAll("edge-to-edge", "gallery-grid", "justified-grid"); + getChildren().add(rows); + + // Invisible, unmanaged — see accentColorProbe's own javadoc. + accentColorProbe.getStyleClass().add("gallery-timeline-accent-probe"); + accentColorProbe.setManaged(false); + accentColorProbe.setVisible(false); + getChildren().add(accentColorProbe); + + // On top of rows, right-aligned and stretched to the pane's full height — see GalleryTimelineBar's + // javadoc for why its own layout bounds stay exactly SCROLLBAR_ALLOWANCE wide despite painting wider. + timeline.setMaxHeight(Double.MAX_VALUE); + StackPane.setAlignment(timeline, Pos.CENTER_RIGHT); + timeline.setOnSeek(this::seekToFraction); + timeline.setOnPreview(this::labelForFraction); + getChildren().add(timeline); + // Unlike Google Photos, the bar only reveals on an actual scroll — not on any mouse move over the + // grid — and only once that scroll has moved the viewport by SCROLL_REVEAL_ROW_THRESHOLD rows or + // more (see onGridScrolled): a wheel nudge of 1-2 rows stays silent. + // Deferred one pulse: a wheel scroll changes the native ScrollBar's value (see hookNativeScrollBar) + // on the same pulse the cells it moved are still laying out, so reading them synchronously here would + // see last frame's positions — runLater lands after that layout pass completes. + addEventFilter(ScrollEvent.SCROLL, this::onScroll); + // A click or trackpad tap mid-glide is the same "stop it now" signal as touching the pad to scroll + // again — a filter, not a handler, so it stops the glide even when the press lands on a thumbnail + // card underneath rather than bare pane background. + addEventFilter(MouseEvent.MOUSE_PRESSED, event -> stopMomentumScroll()); + // A trackpad pinch — its own gesture, distinct from a two-finger scroll (already covered by the + // shortcut+scroll branch above regardless of whether it came from a wheel or a trackpad). + addEventFilter(ZoomEvent.ZOOM_STARTED, event -> pinchGestureStartSize = currentThumbnailSize()); + addEventFilter(ZoomEvent.ZOOM, event -> { + onPinchZoom(event); + event.consume(); + }); + addEventFilter(ZoomEvent.ZOOM_FINISHED, event -> pinchGestureStartSize = -1); + thumbnailZoomSettle.setOnFinished(event -> commitThumbnailSize()); + scrollBurstIdle.setOnFinished(e -> scrollBaselineRowIndex = -1); + scrollGestureIdle.setOnFinished(event -> { + double velocity = currentFlingVelocity(); + recentScrollSamples.clear(); + if (Math.abs(velocity) >= MOMENTUM_MIN_FLING_VELOCITY) { + startMomentumScroll(velocity); + } + }); + + // Arrow-key browsing needs this pane to actually hold focus — see selectAndFocus (a thumbnail + // click) and GalleryView, which hands focus back here when PhotoDetailPane closes. + setFocusTraversable(true); + addEventFilter(KeyEvent.KEY_PRESSED, this::onKeyPressed); + // The native vertical ScrollBar is a skin-internal child of rows, created lazily; wait for the skin + // to exist, then one more pulse for that child to actually be realised, before looking it up. + scope.add(rows.skinProperty().subscribe(skin -> { + if (skin != null) { + Platform.runLater(this::hookNativeScrollBar); + } + })); + + // Throttled: the column count now depends on this pane's own width (see the class javadoc), so a + // resize drag must re-chunk — but not once a pixel. + scope.add(widthProperty().subscribe(width -> throttledRelayout())); + // Immediate, not throttled: both are rare, user-driven changes (a settings edit, a language + // switch), not the per-file/per-pixel churn the throttle above exists for, and should feel instant. + scope.add(preferences.property("gallery", "thumbnail-size", Integer.class).subscribe(size -> relayout())); + scope.add(preferences.property("gallery", "date-header-format", String.class).subscribe(format -> relayout())); + scope.add(preferences.property("gallery", "show-missing-gps-badge", Boolean.class).subscribe(shown -> relayout())); + scope.add(i18n.localeProperty().subscribe(locale -> relayout())); + filters.addListener((ListChangeListener) change -> relayout()); + state.files().addListener(onFilesChanged); + scope.addTeardown(() -> state.files().removeListener(onFilesChanged)); + // The index changed somewhere — a folder was removed, or the library switched — so the shared list + // itself is stale, not just the layout; re-fetch rather than just re-chunking what is already held. + // `subscribe(Consumer)` fires once immediately with the current value, which is also this pane's + // initial load — no separate reload() call needed alongside it. + scope.add(state.revisionProperty().subscribe(revision -> reload())); + } + + private void reload() { + taskService.execute(TaskType.BACKGROUND_SYNC, () -> { + List files = mediaFileService.list(); + applyReload(files); + }); + } + + @FxThread + private void applyReload(List files) { + state.files().setAll(files); + // Immediate, not throttled: unlike the per-file churn onMediaFileIdentified drives during an + // import, a full reload only ever happens a handful of times a session (folder removed, library + // switched) and should feel instant rather than wait out onFilesChanged's throttle window. + relayout(); + } + + private void throttledRelayout() { + if (!relayoutPending) { + relayoutPending = true; + relayoutThrottle.setOnFinished(event -> { + relayoutPending = false; + relayout(); + }); + // Leading edge, not just trailing: a one-off width jump — e.g. a side panel appearing/ + // disappearing elsewhere in the shell with no animation — would otherwise sit re-chunked for + // the OLD width for the full 100ms below, visibly showing rows sized for a pane that's + // already narrower before snapping to the correct chunking. Reacting immediately here fixes + // that single jump on the spot; a genuine resize drag still only fires this once per burst + // (subsequent calls see relayoutPending + // already true) and settles for real via the trailing call once it stops. + relayout(); + } + relayoutThrottle.playFromStart(); + } + + /** + * Filters (see {@link #filters()}), sorts (defensively — {@link MediaLibraryState#files()} is appended + * to in arrival order during a live import, never guaranteed sorted regardless of what + * {@link MediaFileService#list()} returned), packs+stretches into rows at whatever width currently fits + * (see {@link JustifiedRow#justify}), and replaces {@link #rows}' whole item list. Also where + * {@link #initialSelectionMade} highlights the very first file the first time this ever has one. + */ + private void relayout() { + double availableWidth = getWidth() - SCROLLBAR_ALLOWANCE; + double thumbnailHeight = currentThumbnailSize(); + EThumbnailQuality quality = currentQuality(); + IMediaFileFilter matchesAll = IMediaFileFilter.allOf(filters); + List sorted = state.files().stream() + .filter(matchesAll::test) + .sorted(MediaFile.byCapturedAtDesc()) + .toList(); + sortedFiles = sorted; + if (!initialSelectionMade && !sorted.isEmpty()) { + initialSelectionMade = true; + select(sorted.getFirst()); + } + EDateHeaderFormat headerFormat = EDateHeaderFormat.parse( + preferences.getValueOr("gallery", "date-header-format", String.class, EDateHeaderFormat.DEFAULT.name())); + List grouped = JustifiedRow.justify(sorted, availableWidth, thumbnailHeight, SPACING, DATE_SPACING, + date -> date == null ? i18n.get("gallery.unknownDate") : i18n.formatDate(date, headerFormat)); + List items = JustifiedGridItem.itemize(grouped, SPACING); + rows.getItems().setAll(items); + // A relayout that swaps in a materially different item list (a much longer one — clearing a search + // filter that had narrowed the grid down to a handful of rows — or a totally different, unrelated + // one, e.g. LibraryFolderTree's own selection swapping the path: filter from one folder to another) + // can leave some of VirtualFlow's newly realised cells laid out but not actually painted — their + // JustifiedGridCell.updateItem already ran, async thumbnail decodes already requested, but nothing + // visible until some later, unrelated pulse catches it up; moving the mouse "fixes" it only because + // Scene's own picking machinery forces a synchronous CSS+layout pass before hit-testing. + // requestLayout() on a fresh pulse (never this one, already mid-reflow) reproduces that same forced + // pass without needing real input — the same "VirtualFlow needs another pulse to settle" trick + // #reveal/#pulseCardOnceSettled and #hookNativeScrollBar already rely on Platform.runLater for + // elsewhere in this class. + pulseRowsLayout(RELAYOUT_SETTLE_PULSES); + // Only when height/quality actually changed since the last relayout: a change small enough to + // leave the column count (and so every JustifiedRow's own grouping) unchanged produces a + // structurally-equal item list — nothing for the ListView to notice on its own — while every + // visible thumbnail still needs to redraw at the new size/quality, hence forcing it explicitly here. + if (thumbnailHeight != lastRelayoutHeight || quality != lastRelayoutQuality) { + rows.refresh(); + lastRelayoutHeight = thumbnailHeight; + lastRelayoutQuality = quality; + } + updateTimeline(items); + } + + /** + * The thumbnail height/quality {@link #relayout()} last actually forced a {@link #rows} refresh for — see that method's own javadoc. + */ + private double lastRelayoutHeight = -1; + private @Nullable EThumbnailQuality lastRelayoutQuality; + + /** + * Rebuilds {@link #timeline}'s year marks and density dots, and {@link #ascendingEntries} that + * {@link #seekToFraction} binary-searches, from every {@link JustifiedGridItem.HeaderRow} found in + * {@code items} — one per day that actually has photos, so this stays cheap (bounded by distinct days, + * never by photo count) even against a very large library. A {@link JustifiedGridItem.HeaderRow} can in + * principle hold more than one {@link JustifiedGridItem.HeaderColumn}, when several short day-groups pack + * into the same content row below it — harmless here, and for {@link #nearestRowIndex}'s binary search, + * which never assumed a 1:1 item-to-day mapping. {@code TimelineEntry.rowIndex} is the header item's own + * index, not the content row's, so seeking there lands that date's own label at the top of the viewport. + */ + private void updateTimeline(List items) { + List descending = new ArrayList<>(); + for (int i = 0; i < items.size(); i++) { + if (!(items.get(i) instanceof JustifiedGridItem.HeaderRow header)) { + continue; + } + for (JustifiedGridItem.HeaderColumn column : header.columns()) { + if (column.marker().date() != null) { + descending.add(new TimelineEntry(column.marker().date().toEpochDay(), i)); + } + } + } + if (descending.isEmpty()) { + ascendingEntries = List.of(); + timeline.setYearMarks(List.of()); + timeline.setDensity(new double[0]); + return; + } + // descending is newest-first, matching MediaFile.byCapturedAtDesc() — so its head/tail are exactly + // the overall newest/oldest dated day, no separate min/max scan needed. + maxEpochDay = descending.getFirst().epochDay(); + minEpochDay = descending.getLast().epochDay(); + ascendingEntries = new ArrayList<>(descending); + Collections.reverse(ascendingEntries); + + // Only years that actually have a dated photo get a mark — distinct() on an already newest-first + // sequence keeps the result newest-first too, so no separate sort is needed. + List marks = descending.stream() + .mapToInt(entry -> LocalDate.ofEpochDay(entry.epochDay()).getYear()) + .distinct() + .mapToObj(year -> new GalleryTimelineBar.YearMark( + fractionOf(LocalDate.of(year, 1, 1).toEpochDay()), String.valueOf(year))) + .toList(); + timeline.setYearMarks(marks); + + // One dot per distinct month-with-photos, not per day-with-photos — the same number of days spread + // over decades would otherwise paint thousands of indistinguishable dots on top of each other. + double[] density = descending.stream() + .map(entry -> YearMonth.from(LocalDate.ofEpochDay(entry.epochDay()))) + .distinct() + .mapToDouble(month -> fractionOf(month.atDay(15).toEpochDay())) + .sorted() + .toArray(); + timeline.setDensity(density); + + // Deferred: rows.getItems() was just replaced above, so no cell has laid out against the new list + // yet — reading them synchronously here would find nothing (or stale positions from the old list). + Platform.runLater(this::updateCurrentPosition); + } + + /** + * 0 at {@link #maxEpochDay} (top of the bar, newest), 1 at {@link #minEpochDay} (bottom, oldest). + */ + private double fractionOf(long epochDay) { + long span = Math.max(1, maxEpochDay - minEpochDay); + return Math.max(0, Math.min(1, (double) (maxEpochDay - epochDay) / span)); + } + + /** + * The row closest to {@code fraction} along the timeline, or {@code -1} if nothing is dated at all. + */ + private int rowIndexForFraction(double fraction) { + if (ascendingEntries.isEmpty()) { + return -1; + } + long targetEpoch = Math.round(maxEpochDay - fraction * (maxEpochDay - minEpochDay)); + return nearestRowIndex(targetEpoch); + } + + /** + * {@link GalleryTimelineBar#setOnSeek} target: maps the fraction the user clicked/dragged to back to a + * calendar day, finds the row closest to it, and scrolls straight there. + */ + private void seekToFraction(double fraction) { + int index = rowIndexForFraction(fraction); + if (index >= 0) { + rows.scrollTo(index); + } + } + + /** + * {@link GalleryTimelineBar#setOnPreview} target: the label the pill would show if the user let go of the + * mouse at {@code fraction} right now, without actually touching the grid's scroll position. + */ + private @Nullable String labelForFraction(double fraction) { + int index = rowIndexForFraction(fraction); + if (index < 0) { + return null; + } + LabelledDate resolved = resolveGroupLabel(index); + return resolved == null ? null : resolved.label(); + } + + /** + * Binary search over {@link #ascendingEntries} for the entry whose day is closest to {@code targetEpoch}. + */ + private int nearestRowIndex(long targetEpoch) { + int lo = 0; + int hi = ascendingEntries.size() - 1; + while (lo < hi) { + int mid = (lo + hi) >>> 1; + if (ascendingEntries.get(mid).epochDay() < targetEpoch) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo > 0) { + long afterDiff = Math.abs(ascendingEntries.get(lo).epochDay() - targetEpoch); + long beforeDiff = Math.abs(ascendingEntries.get(lo - 1).epochDay() - targetEpoch); + if (beforeDiff < afterDiff) { + lo--; + } + } + return ascendingEntries.get(lo).rowIndex(); + } + + /** + * {@code rows}' native vertical {@link ScrollBar} is a lazily-created, skin-internal child — found once + * by CSS lookup (a widely-used, accepted way to reach a control's own skin parts from outside it) rather + * than through any public API, since {@link ListView} exposes none for "what's currently scrolled to". + * Absent (a library that never overflows a single screen) simply leaves {@link #timeline}'s pill wherever + * {@link #updateTimeline} last put it. + */ + private void hookNativeScrollBar() { + if (rows.lookup(".scroll-bar:vertical") instanceof ScrollBar vbar) { + verticalScrollBar = vbar; + // Deferred: the value changes before the cells it moved finish laying out against their new + // position, so reading them synchronously here would see last frame's positions. + scope.add(vbar.valueProperty().subscribe(value -> onGridScrolled())); + Platform.runLater(this::updateCurrentPosition); + } + // Same skin-internal lookup trick, for the same reason: VirtualFlow (unlike ListView) actually owns + // pixel-based scroll handling (see fireMomentumTick), but exposes no public API to reach it either. + virtualFlow = rows.lookup("#virtual-flow"); + } + + /** + * Common handler for every way the grid can actually scroll (wheel, native scrollbar drag, or its + * value changing programmatically): moves {@link #timeline}'s pill and, separately, decides whether + * this scroll has earned the bar itself becoming visible — see {@link #maybeRevealTimeline}. + */ + private void onGridScrolled() { + Platform.runLater(() -> { + updateCurrentPosition(); + maybeRevealTimeline(); + }); + } + + /** + * How many rows the viewport must have actually moved by, since {@link #scrollBaselineRowIndex} was + * last (re)armed, before {@link #timeline} fades in. Below this, a scroll is treated as noise — e.g. a + * single wheel notch — and the bar stays hidden. + */ + private static final int SCROLL_REVEAL_ROW_THRESHOLD = 3; + + /** + * Row index {@link #topVisibleRowIndex()} reported when the current scroll "burst" began; {@code -1} + * while no burst is tracked. Reset by {@link #scrollBurstIdle} once scrolling has actually stopped, so + * the next burst starts counting from zero rather than slowly accumulating across unrelated scrolls + * minutes apart. + */ + private int scrollBaselineRowIndex = -1; + + /** + * Rearms on every {@link #onGridScrolled}; once it fires, the current scroll burst is considered over. + */ + private final PauseTransition scrollBurstIdle = new PauseTransition(Duration.millis(500)); + + /** + * {@code rows}' own native vertical {@link ScrollBar}, kept once {@link #hookNativeScrollBar} finds + * it — {@link #maybeBounceOnScroll} reads it to tell whether a wheel scroll has hit either end. + * {@code null} until the skin actually exists. + */ + private @Nullable ScrollBar verticalScrollBar; + + /** + * {@code rows}' own skin-internal {@code VirtualFlow} node, kept once {@link #hookNativeScrollBar} finds + * it — {@link #fireMomentumTick} fires its synthetic scroll ticks directly at this node, the one place + * that actually turns a {@link ScrollEvent#getDeltaY()} into pixels scrolled. {@code null} until the + * skin actually exists. + */ + private @Nullable Node virtualFlow; + + /** + * {@link #bounceGrid}'s own in-flight animation, if any — stopped before a new one starts. + */ + private @Nullable Timeline gridBounceTransition; + + /** + * How much of a fling's speed survives per millisecond once fingers lift the trackpad, once + * {@link #startMomentumScroll} is under way — the same 0.998 decay constant iOS' own + * {@code UIScrollView} uses for its "normal" deceleration rate, picked so the tail this produces reads as + * that same familiar phone-photo-grid glide rather than a home-grown curve tuned by eye. + */ + private static final double MOMENTUM_FRICTION_PER_MILLIS = 0.998; + + /** + * {@link #startMomentumScroll}'s own stop condition — once {@link #momentumVelocity} decays below this + * many pixels per millisecond, the remaining motion is imperceptible, so the glide stops outright rather + * than ticking on forever chasing zero. + */ + private static final double MOMENTUM_MIN_VELOCITY = 0.02; + + /** + * The release speed, in pixels per millisecond, {@link #currentFlingVelocity} must clear before + * {@link #scrollGestureIdle} bothers {@link #startMomentumScroll} at all — below this, the gesture reads + * as a deliberate, unhurried scroll that should simply stop wherever the fingers left it, not coast on. + */ + private static final double MOMENTUM_MIN_FLING_VELOCITY = 0.35; + + /** + * How far back {@link #recentScrollSamples} looks, in milliseconds, to estimate a gesture's release + * velocity in {@link #currentFlingVelocity} — only the last stretch of a gesture reflects how fast the + * fingers were moving the instant they left the pad; averaging across the whole gesture would dilute a + * fast finish with however slowly it began. + */ + private static final long MOMENTUM_VELOCITY_WINDOW_MILLIS = 100; + + /** + * {@code (timestampMillis, deltaY)} pairs for every direct (non-{@link ScrollEvent#isInertia() inertia}) + * scroll event since the last time {@link #scrollGestureIdle} fired — pruned to + * {@link #MOMENTUM_VELOCITY_WINDOW_MILLIS} on every addition; {@link #currentFlingVelocity} sums these to + * estimate release velocity. + */ + private final Deque recentScrollSamples = new ArrayDeque<>(); + + /** + * Rearmed on every direct scroll event ({@link #recordScrollSample}); once it fires with nothing further + * recorded, the gesture is considered released, and {@link #startMomentumScroll} runs if + * {@link #currentFlingVelocity} clears {@link #MOMENTUM_MIN_FLING_VELOCITY}. Trackpads keep sending their + * own handful of decaying events after a real flick, but those all arrive tagged + * {@link ScrollEvent#isInertia()} (see {@link #onScroll}, which swallows them outright) rather than + * resetting this — so it still fires promptly on release rather than waiting out the device's own tail. + */ + private final PauseTransition scrollGestureIdle = new PauseTransition(Duration.millis(80)); + + /** + * {@link #startMomentumScroll}'s own in-flight glide, if any — driving {@link #rows} by firing a + * synthetic, decaying {@link ScrollEvent} at {@link #virtualFlow} every frame (see {@link #fireMomentumTick}) + * rather than computing a pixel position by hand, so it runs through exactly the same code a real + * trackpad event would: {@code VirtualFlow}'s own {@code scrollPixels}, {@link #onGridScrolled}, + * {@link #maybeBounceOnScroll}. Stopped outright by a genuine new scroll event arriving mid-glide (see + * {@link #onScroll}) — the "touch of the pad" this whole feature exists to honour — or once it decays + * past {@link #MOMENTUM_MIN_VELOCITY} or {@link #isAtScrollEdge} reports nowhere further to go. + */ + private @Nullable AnimationTimer momentumTimer; + + /** + * {@link #momentumTimer}'s current speed, in pixels per millisecond, signed the same way {@link ScrollEvent#getDeltaY()} is. + */ + private double momentumVelocity; + + /** + * {@link #momentumTimer}'s own last tick time in nanoseconds, for computing each frame's {@code dt}; {@code -1} on its very first tick, when there's no previous one to measure against. + */ + private long momentumLastFrameNanos; + + /** + * True only for the duration of {@link #fireMomentumTick} actually dispatching its own synthetic event — + * that event re-enters this exact {@link ScrollEvent#SCROLL} filter on its way down to {@link #virtualFlow} + * (this pane sits between the two), so {@link #onScroll} reads this flag to tell its own momentum tail + * apart from a genuine device event without needing to mark the event itself. + */ + private boolean isSynthesizingMomentum; + + /** + * A shortcut+scroll/pinch zoom's own live target, accumulated across ticks between commits — see + * {@link #adjustThumbnailSize}. {@code -1} outside of an active gesture, so the next one starts fresh + * from whatever {@code gallery.thumbnail-size} currently is rather than a stale target. + */ + private double pendingThumbnailSize = -1; + + /** + * {@code gallery.thumbnail-size} at the moment the current pinch gesture began — {@link #onPinchZoom} + * scales this by the gesture's own {@link ZoomEvent#getTotalZoomFactor()} rather than accumulating + * per-tick deltas the way {@link #adjustThumbnailSize} does for the wheel, since a pinch's whole point + * is "how far the fingers have moved since the gesture started," not "how much this one tick moved." + * {@code -1} outside of an active gesture. + */ + private double pinchGestureStartSize = -1; + + /** + * Wall-clock time {@link #adjustThumbnailSize} last actually committed — see {@link #THUMBNAIL_ZOOM_COMMIT_INTERVAL_MILLIS}. + */ + private long lastThumbnailZoomCommitMillis; + + /** + * Flushes any {@link #pendingThumbnailSize} left over once a shortcut+scroll/pinch gesture actually stops + * — the periodic commit gate in {@link #adjustThumbnailSize} can otherwise leave the gesture's very + * last, sub-{@link #THUMBNAIL_ZOOM_COMMIT_INTERVAL_MILLIS} increments uncommitted. + */ + private final PauseTransition thumbnailZoomSettle = new PauseTransition(Duration.millis(80)); + + private void maybeRevealTimeline() { + int index = topVisibleRowIndex(); + if (index < 0) { + return; + } + if (scrollBaselineRowIndex < 0) { + scrollBaselineRowIndex = index; + } + scrollBurstIdle.stop(); + scrollBurstIdle.playFromStart(); + if (Math.abs(index - scrollBaselineRowIndex) >= SCROLL_REVEAL_ROW_THRESHOLD) { + timeline.activity(); + } + } + + /** + * Moves {@link #timeline}'s pill to whichever day-group is currently topmost in the viewport, after any + * scroll (wheel, drag, keyboard, or {@link #seekToFraction} itself) or {@link #relayout}. + */ + private void updateCurrentPosition() { + if (ascendingEntries.isEmpty()) { + return; + } + int index = topVisibleRowIndex(); + if (index < 0) { + return; + } + LabelledDate resolved = resolveGroupLabel(index); + timeline.setCurrentPosition(resolved == null ? 0 : fractionOf(resolved.date().toEpochDay()), + resolved == null ? null : resolved.label()); + } + + /** + * The lowest-indexed, non-{@link ListCell#isEmpty() empty} rendered cell whose top edge is not scrolled + * fully past the viewport's own top — i.e. whichever row is currently topmost on screen. {@code -1} if + * nothing is rendered yet (e.g. before the first layout pass). + */ + private int topVisibleRowIndex() { + int best = -1; + double bestY = Double.MAX_VALUE; + for (Node node : rows.lookupAll(".list-cell")) { + if (node instanceof ListCell cell && !cell.isEmpty()) { + double y = cell.getBoundsInParent().getMinY(); + if (y > -cell.getHeight() && y < bestY) { + bestY = y; + best = cell.getIndex(); + } + } + } + return best; + } + + /** + * The highest-indexed, non-empty rendered cell whose top edge hasn't scrolled past the viewport's own + * bottom yet — i.e. whichever row is currently bottommost on screen, at least partially — mirroring + * {@link #topVisibleRowIndex} from the other edge. {@code -1} if nothing is rendered yet. Used by + * {@link #moveVertically} to decide whether an Up/Down press needs to actually scroll the grid, or + * whether the target row is already on screen. + */ + private int bottomVisibleRowIndex() { + int best = -1; + double bestY = -Double.MAX_VALUE; + double height = rows.getHeight(); + for (Node node : rows.lookupAll(".list-cell")) { + if (node instanceof ListCell cell && !cell.isEmpty()) { + double y = cell.getBoundsInParent().getMinY(); + if (y < height && y > bestY) { + bestY = y; + best = cell.getIndex(); + } + } + } + return best; + } + + /** + * Walks backward from {@code index}, item by item, for the nearest {@link JustifiedGridItem.HeaderRow} — + * inclusive of {@code index} itself. That header's last (rightmost) column is the one returned, + * not the first: columns are left-to-right in the same newest-to-oldest order files were fed to + * {@link JustifiedRow#justify}, so if a header straddles two or more dates, whichever column appears last + * is the date still "in effect" through to whatever comes after it — exactly the date every content item + * after it, up to and including {@code index}, should report. {@code null} for the undated tail group, + * which has nothing to place on the timeline. + */ + private @Nullable LabelledDate resolveGroupLabel(int index) { + List items = rows.getItems(); + for (int i = index; i >= 0; i--) { + if (!(items.get(i) instanceof JustifiedGridItem.HeaderRow header)) { + continue; + } + JustifiedRow.DateMarker last = header.columns().getLast().marker(); + return last.date() == null ? null : new LabelledDate(last.date(), last.label()); + } + return null; + } + + private double currentThumbnailSize() { + return Math.max(1, preferences.getValueOr("gallery", "thumbnail-size", Integer.class, DEFAULT_THUMBNAIL_SIZE)); + } + + /** + * {@code gallery.show-missing-gps-badge} — see {@link JustifiedGridCell#overlaysFor}. + */ + private boolean showMissingGpsBadge() { + return preferences.getValueOr("gallery", "show-missing-gps-badge", Boolean.class, true); + } + + /** + * shortcut+scroll (mouse wheel or a trackpad's own two-finger scroll) nudges {@code gallery.thumbnail-size} + * by {@code delta} pixels relative to wherever the gesture currently stands — see + * {@link #setPendingThumbnailSize} for clamping/throttling. + */ + private void adjustThumbnailSize(double delta) { + double base = pendingThumbnailSize >= 0 ? pendingThumbnailSize : currentThumbnailSize(); + setPendingThumbnailSize(base + delta); + } + + /** + * A trackpad pinch, unlike a wheel notch, has no natural per-tick "how far did just this tick move" — + * only {@link ZoomEvent#getTotalZoomFactor()}, the ratio versus {@link #pinchGestureStartSize} (the + * size when the gesture began). Scaling that start size directly by the total factor, amplified by + * {@link #PINCH_ZOOM_GROW_AMPLIFICATION} or {@link #PINCH_ZOOM_SHRINK_AMPLIFICATION} depending on + * which side of {@code 1.0} it's on, rather than accumulating per-event deltas the way + * {@link #adjustThumbnailSize} does, is what makes one ordinary pinch able to sweep the whole range: + * accumulating tiny per-tick deltas made an earlier version of this feel unresponsive, since how much + * a pinch "should" move per tick has no natural pixel unit the way a wheel notch does. + */ + private void onPinchZoom(ZoomEvent event) { + double start = pinchGestureStartSize >= 0 ? pinchGestureStartSize : currentThumbnailSize(); + double totalFactor = Math.max(event.getTotalZoomFactor(), 1e-3); + double amplification = totalFactor >= 1 ? PINCH_ZOOM_GROW_AMPLIFICATION : PINCH_ZOOM_SHRINK_AMPLIFICATION; + setPendingThumbnailSize(start * Math.pow(totalFactor, amplification)); + } + + /** + * Clamps {@code target} to {@link #minThumbnailSize}/{@link #maxThumbnailSize} and stores it as + * {@link #pendingThumbnailSize}, then commits it to the {@code gallery.thumbnail-size} preference (and + * so re-chunks the grid — see {@link #relayout}) at most every + * {@link #THUMBNAIL_ZOOM_COMMIT_INTERVAL_MILLIS} via {@link #commitThumbnailSize} — not on every single + * tick — so a fast, continuous gesture (wheel or pinch alike) still reads as one smooth, progressive + * zoom rather than hammering a full re-chunk dozens of times a second. + * {@link #thumbnailZoomSettle} guarantees whatever is still pending once the gesture actually stops + * lands anyway. + */ + private void setPendingThumbnailSize(double target) { + pendingThumbnailSize = Math.max(minThumbnailSize(), Math.min(maxThumbnailSize(), target)); + thumbnailZoomSettle.playFromStart(); + long now = System.currentTimeMillis(); + if (now - lastThumbnailZoomCommitMillis >= THUMBNAIL_ZOOM_COMMIT_INTERVAL_MILLIS) { + lastThumbnailZoomCommitMillis = now; + commitThumbnailSize(); + } + } + + /** + * Writes {@link #pendingThumbnailSize} (rounded) to {@code gallery.thumbnail-size} if it actually changed, and clears it — see {@link #adjustThumbnailSize}. + */ + private void commitThumbnailSize() { + if (pendingThumbnailSize < 0) { + return; + } + int rounded = (int) Math.round(pendingThumbnailSize); + pendingThumbnailSize = -1; + if (rounded != (int) currentThumbnailSize()) { + preferences.setValue("gallery", "thumbnail-size", rounded); + } + } + + /** + * {@code gallery.thumbnail-size}'s own schema minimum, or {@link #DEFAULT_THUMBNAIL_SIZE} if the schema has none. + */ + private int minThumbnailSize() { + return preferences.item("gallery", "thumbnail-size") + .map(PreferenceItem::getMin) + .map(Number::intValue) + .orElse(DEFAULT_THUMBNAIL_SIZE); + } + + /** + * {@code gallery.thumbnail-size}'s own schema maximum, or a generous multiple of {@link #DEFAULT_THUMBNAIL_SIZE} if the schema has none. + */ + private int maxThumbnailSize() { + return preferences.item("gallery", "thumbnail-size") + .map(PreferenceItem::getMax) + .map(Number::intValue) + .orElse(DEFAULT_THUMBNAIL_SIZE * 3); + } + + /** + * Re-read on every cell render rather than cached, so a mid-session quality change is picked up + * without needing its own event — a preference lookup is cheap next to decoding an image. + */ + private EThumbnailQuality currentQuality() { + return EThumbnailQuality.parse(preferences.getValue("thumbnails", "quality", String.class)); + } + + /** + * Exposed for tests, the same way {@code LibraryFolderTree.tree()} is. + */ + ListView rows() { + return rows; + } + + /** + * The live, mutable set of {@link IMediaFileFilter}s narrowing this grid down, combined with AND. Add + * or remove one — from a future filter bar, or anywhere else — and the grid re-chunks immediately; no + * other call is needed. Empty by default, matching "no filtering" exactly, since + * {@link IMediaFileFilter#allOf} is vacuously true over an empty collection. + */ + public ObservableList filters() { + return filters; + } + + /** + * The file last clicked, or {@code null} before anything has been — what {@code GalleryView} binds its + * own {@link org.icroco.pholio.ui.selection.SelectionSource#selectedMediaFile()} to. + */ + public ReadOnlyObjectProperty<@Nullable MediaFile> selectedFileProperty() { + return selectedFile.getReadOnlyProperty(); + } + + /** + * {@code GalleryView}'s hook for double-clicking a thumbnail open — see {@link #onOpenRequest}'s javadoc + * for why this is a callback rather than a property. + */ + public void setOnOpenRequest(BiConsumer handler) { + this.onOpenRequest = handler; + } + + /** + * {@code file}'s successor in {@link #sortedFiles}, or {@code null} at the end (or if not found at all). + */ + public @Nullable MediaFile next(MediaFile file) { + int index = sortedFiles.indexOf(file); + return index < 0 || index + 1 >= sortedFiles.size() ? null : sortedFiles.get(index + 1); + } + + /** + * {@code file}'s predecessor in {@link #sortedFiles}, or {@code null} at the start (or if not found at all). + */ + public @Nullable MediaFile previous(MediaFile file) { + int index = sortedFiles.indexOf(file); + return index <= 0 ? null : sortedFiles.get(index - 1); + } + + /** + * {@code file}'s counterpart one row up — same column, i.e. same index within that row's own entries, + * clamped to its last entry if it has fewer — or {@code null} if {@code file} is already in the grid's + * first row (or isn't in the grid at all). Rows can hold a different number of entries than their + * neighbours (see {@link JustifiedRow#justify}), so "same column" is only ever approximate past the + * narrowest of two adjacent rows — the same trade-off any variable-width photo grid's up/down arrow + * keys make. + */ + public @Nullable MediaFile above(MediaFile file) { + return verticalNeighbour(file, -1); + } + + /** + * {@code file}'s counterpart one row down — see {@link #above} for exactly how "counterpart" is picked. + */ + public @Nullable MediaFile below(MediaFile file) { + return verticalNeighbour(file, 1); + } + + private @Nullable MediaFile verticalNeighbour(MediaFile file, int rowDelta) { + List items = rows.getItems(); + for (int i = 0; i < items.size(); i++) { + if (!(items.get(i) instanceof JustifiedRow row)) { + continue; + } + List entries = row.entries(); + for (int column = 0; column < entries.size(); column++) { + if (!entries.get(column).file().equals(file)) { + continue; + } + // "The row above/below" skips over any JustifiedGridItem.HeaderRow in between — a header item + // holds no columns of its own to match against, and itemize() never inserts more than one + // between two content rows, so this can never walk past a second content row while doing so. + int target = i + rowDelta; + while (target >= 0 && target < items.size() && !(items.get(target) instanceof JustifiedRow)) { + target += rowDelta; + } + if (target < 0 || target >= items.size()) { + return null; + } + List targetEntries = ((JustifiedRow) items.get(target)).entries(); + return targetEntries.isEmpty() ? null + : targetEntries.get(Math.min(column, targetEntries.size() - 1)).file(); + } + } + return null; + } + + /** + * Sets {@link #selectedFile} the same way clicking {@code file}'s own thumbnail would — the single + * entry point for the grid's navigation selection (0 or 1 file), reached uniformly from + * a card click ({@link #selectAndFocus}) and arrow-key browsing ({@link #onKeyPressed}/ + * {@link #reveal}), which is exactly why the visual hand-off below lives here rather than only in the + * click path — see {@link #actionSelection} for the other, independent kind this pane also tracks + * (0..N files, for group actions — never coupled to this one: the same file can be in both at once). + * + *

Finds whichever {@link JustifiedGridCell} currently renders the previous selection (if + * any) and the new one (if currently realised at all — it may not be, e.g. mid-scroll to a + * far-off target) via {@link #cellShowing}, and tells each directly + * ({@link JustifiedGridCell#notifyNavigationDeselected}/{@link JustifiedGridCell#notifyNavigationSelected}) + * — a plain, logged scan over currently-realised cells only, run once per actual selection change, never + * per render/per scroll frame. No-op, including no lookup at all, when {@code file} already is the + * selection. + */ + public void select(MediaFile file) { + MediaFile previous = selectedFile.getValue(); + selectedFile.set(file); + if (previous != null && previous.equals(file)) { + return; + } + log.info("Navigation selection: file={} fileName={}", file.id(), file.path().getFileName()); + if (previous != null) { + JustifiedGridCell previousCell = cellShowing(previous); + if (previousCell != null) { + previousCell.notifyNavigationDeselected(previous); + } + } + JustifiedGridCell cell = cellShowing(file); + if (cell != null) { + cell.notifyNavigationSelected(file); + } + } + + /** + * The currently-realised {@link JustifiedGridCell} rendering {@code file}, or {@code null} if none is + * (scrolled out of {@code VirtualFlow}'s own realised window, or the grid hasn't laid out yet) — see + * {@link #select}'s own javadoc for the one place this is used. Scoped to {@code JustifiedGridCell} + * instances specifically (not every {@code .list-cell}, which would also match a header item's own + * cell): {@link JustifiedGridCell#isShowing} is what actually confirms {@code file} rather than just the + * cell's own type. + */ + private @Nullable JustifiedGridCell cellShowing(MediaFile file) { + for (Node node : rows.lookupAll(".list-cell")) { + if (node instanceof JustifiedGridCell cell && cell.isShowing(file)) { + return cell; + } + } + return null; + } + + /** + * {@link #select} plus grabbing keyboard focus — a thumbnail click's own {@code onSelect} callback, so + * arrow-key browsing (see {@link #onKeyPressed}) works right after. + */ + private void selectAndFocus(MediaFile file) { + select(file); + requestFocus(); + } + + /** + * This pane's action selection — 0..N files a future group action (regenerate + * thumbnails, set GPS, set rating, ...) will apply to — kept as plain {@link MediaFile#id()}s, not + * {@link MediaFile} instances: {@link MediaLibraryState#files()} replaces a file's own record wholesale + * on every metadata/processing-flag edit, so keying on the record itself would silently orphan an + * already-selected file's entry the next time that happens (same trap {@code legacy.ThumbnailGalleryPane + * .checkedByFile}'s own javadoc documents, and the same fix). Never cleaned up when a file leaves + * {@link MediaLibraryState#files()} (a folder removed, say) — {@link #actionSelectedFiles()} resolving + * against the live list is what makes that harmless, exactly as {@code checkedFiles()} already did. + * + *

Deliberately not keyed by, or pushed to, any {@link JustifiedGridCell} — unlike + * {@link #select}'s own navigation selection, this selection must be able to include files that are not + * currently rendered at all (e.g. a future "select this whole day" action from a date header), so there + * is no cell reference to hand off to at the moment a file enters or leaves it. A cell instead + * pulls {@link #isActionSelected} whenever it renders a file, once it has something to show for + * it. + */ + private final Set actionSelection = new LinkedHashSet<>(); + + /** + * Whether {@code file} is currently in {@link #actionSelection}. + */ + boolean isActionSelected(MediaFile file) { + return actionSelection.contains(file.id()); + } + + /** + * Adds or removes {@code file} from {@link #actionSelection} by its own id — explicit, not a toggle: + * a future bulk action ("select this whole day") wants every file it touches to land in one definite + * state, not have some of them flip the wrong way because they already happened to be selected. + */ + void setActionSelected(MediaFile file, boolean selected) { + Long id = requireNonNull(file.id()); + boolean changed = selected ? actionSelection.add(id) : actionSelection.remove(id); + if (changed) { + log.info("Action selection: file={} selected={} (size={})", id, selected, actionSelection.size()); + } + } + + /** + * {@link #setActionSelected} with the new state computed as "whatever {@code file} currently isn't". + */ + void toggleActionSelected(MediaFile file) { + setActionSelected(file, !isActionSelected(file)); + } + + void clearActionSelection() { + if (actionSelection.isEmpty()) { + return; + } + log.info("Action selection cleared ({} file(s))", actionSelection.size()); + actionSelection.clear(); + } + + /** + * {@link #actionSelection}'s own ids, resolved back to this moment's actual {@link MediaFile} instances + * from {@link MediaLibraryState#files()} — never whatever instance happened to be current when each id + * was added — the same reason {@code legacy.ThumbnailGalleryPane.checkedFiles()} re-resolves rather than + * caching. A file whose id lingers in {@link #actionSelection} after leaving the library simply isn't + * found here any more, which is what makes never explicitly pruning that id harmless. + */ + Set actionSelectedFiles() { + Set resolved = new LinkedHashSet<>(); + for (MediaFile file : state.files()) { + if (actionSelection.contains(file.id())) { + resolved.add(file); + } + } + return Set.copyOf(resolved); + } + + /** + * Resolved once, lazily, the same probe trick {@link GalleryTimelineBar#resolveBackgroundColor} uses — + * see {@link #accentColorProbe}'s own javadoc. Cached after the first successful resolution and never + * re-queried after that: every {@code JustifiedGridCell} this pane's {@code rows} ever creates reads + * this exactly once, at its own construction, for its own {@code selectionFrame}'s stroke — re-resolving + * on every later selection toggle would reintroduce the exact CSS-engine dependency that whole scheme + * exists to avoid (see that class' own javadoc). A one-off theme switch mid-session simply won't reach + * an already-built card's frame until this pane itself is recreated (e.g. navigating away and back) — + * an accepted trade-off for never touching the CSS engine on a selection toggle. + */ + private Color accentColor() { + if (cachedAccentColor == null) { + accentColorProbe.applyCss(); + Background background = accentColorProbe.getBackground(); + if (background != null && !background.getFills().isEmpty() + && background.getFills().getFirst().getFill() instanceof Color color) { + cachedAccentColor = color; + } else { + return Color.web("#4098fc"); + } + } + return cachedAccentColor; + } + + /** + * Makes sure the grid's own selection (the accent border — see {@link #select}) matches {@code file}, + * scrolling only if its row isn't already on screen — {@link #onKeyPressed}'s own Left/Right/Up/Down + * browsing calls this for every single step. Never pulses {@code file}'s own card on arrival; a pulse on + * every keystroke would be noise, not a cue — see {@link #revealAndPulse} for the one caller that + * actually wants one. + */ + public void reveal(MediaFile file) { + reveal(file, false); + } + + /** + * {@link #reveal}, plus {@link #pulseCard} once the file's own card is actually on screen — + * {@code GalleryView.closeDetail}'s own target: a closed {@code PhotoDetailPane} is always coming back + * to a grid the user hasn't looked at for a while, so the "here it is" cue matters just as much when + * nothing had to scroll (the file was already visible) as when it did. {@link #pulseCardOnceSettled} + * accounts for a jump needing more than one layout pass to actually realise the target row's cell. + */ + public void revealAndPulse(MediaFile file) { + reveal(file, true); + } + + /** + * Shared by {@link #reveal} and {@link #revealAndPulse} — three cases, checked against {@code file}'s + * row's own actual rendered bounds ({@link #boundsOfRenderedRow}) rather than trusting + * {@link ListView#scrollTo} to itself be a no-op for an already-visible row — it isn't: {@code VirtualFlow} + * recomputes its scroll position from the target index regardless of whether that index is already on + * screen, which is what made this jump the grid to a row the user could already see up to the very top. + * + *

    + *
  1. Fully within the viewport already (not clipped top or bottom) — no scroll at all. + *
  2. Rendered, but straddling the top edge (from a previous scroll or resize) — nudged + * ({@code scrollTo(index)}, which top-aligns whatever index it's given) up just enough to bring that + * same row fully into view, the same "scroll the minimum needed" a file explorer's own keyboard + * navigation does, rather than recentring the whole grid around it. + *
  3. Rendered, but straddling the bottom edge — {@code scrollTo(index)} would top-align it + * instead, which for a row already near the bottom of the viewport scrolls it almost a whole + * viewport's worth further than needed, all the way up. {@link #bottomAlignedScrollTarget} finds + * whichever earlier index top-aligning that instead leaves {@code index} as the last fully + * visible row — the same small downward nudge as the top-edge case, just aimed at the other edge. + *
  4. Not rendered at all — jumped ({@code scrollTo(centeredIndexFor(index))}) to roughly the middle of + * the viewport instead of whichever edge a plain {@code scrollTo(index)} would land it on, so a + * reveal from genuinely off-screen reads as "bring it into view", not "snap it to an edge". + *
+ */ + private void reveal(MediaFile file, boolean pulse) { + select(file); + int index = rowIndexOf(file); + if (index < 0) { + return; + } + Bounds rowBounds = boundsOfRenderedRow(index); + if (rowBounds != null && rowBounds.getMinY() >= 0 && rowBounds.getMaxY() <= rows.getHeight()) { + if (pulse) { + pulseCard(file); + } + return; + } + if (rowBounds != null && rowBounds.getMinY() < 0) { + rows.scrollTo(index); + } else if (rowBounds != null && rowBounds.getMaxY() > rows.getHeight()) { + rows.scrollTo(bottomAlignedScrollTarget(index, rowBounds.getMaxY() - rows.getHeight())); + } else { + rows.scrollTo(centeredIndexFor(index)); + } + if (pulse) { + pulseCardOnceSettled(file, REVEAL_SETTLE_ATTEMPTS); + } + } + + /** + * {@code scrollTo(index)} always top-aligns whichever index it's given — there is no + * {@code scrollToBottom(index)} counterpart — so bottom-aligning {@code index} instead means finding the + * smallest number of rows to drop off the top ({@link #topVisibleRowIndex} onward) whose combined real + * rendered height clears {@code overflow}, the exact number of pixels {@code index}'s own row currently + * spills past the bottom edge — {@code scrollTo} can only land on a row boundary, so the very next row + * boundary that clears the overflow is the smallest nudge actually available, even if that's not + * pixel-exact. Real per-item heights, not a uniform {@link #currentThumbnailSize} assumed for every one, + * because they aren't all the same: a {@link JustifiedGridItem.HeaderRow} between two content rows has its + * own, different fixed height (every {@link JustifiedRow} content item's own height is otherwise uniform — + * see this class's own javadoc; every item up to {@code index} is already realised, since {@code index} + * itself is — see the sole caller). Never returns past {@code index} itself. + */ + private int bottomAlignedScrollTarget(int index, double overflow) { + int top = topVisibleRowIndex(); + if (top < 0 || top >= index) { + return index; + } + double accumulated = 0; + for (int i = top; i < index; i++) { + Bounds bounds = boundsOfRenderedRow(i); + accumulated += bounds != null ? bounds.getHeight() : currentThumbnailSize(); + if (accumulated >= overflow) { + return i + 1; + } + } + return index; + } + + /** + * {@code index}'s own rendered {@link ListCell#getBoundsInParent()}, or {@code null} if it isn't + * currently realised at all (scrolled far enough away that {@code VirtualFlow} has recycled it, or the + * grid hasn't laid out yet) — see {@link #reveal(MediaFile, boolean)}'s own three cases. + */ + private @Nullable Bounds boundsOfRenderedRow(int index) { + for (Node node : rows.lookupAll(".list-cell")) { + if (node instanceof ListCell cell && !cell.isEmpty() && cell.getIndex() == index) { + return cell.getBoundsInParent(); + } + } + return null; + } + + /** + * {@code targetIndex} shifted up by half of {@link #estimateVisibleRowCount}, clamped to + * {@link #rows}' own item range — passing this (rather than {@code targetIndex} itself) to + * {@code scrollTo} is what lands the target row near the middle of the viewport instead of at whichever + * edge a plain {@code scrollTo(targetIndex)} would place it on. + */ + private int centeredIndexFor(int targetIndex) { + int itemCount = rows.getItems().size(); + int half = estimateVisibleRowCount() / 2; + return Math.max(0, Math.min(itemCount - 1, targetIndex - half)); + } + + /** + * How many rows currently fit in the viewport at once — from {@link #topVisibleRowIndex}/ + * {@link #bottomVisibleRowIndex} when something is actually rendered, or a rough estimate from this + * pane's own height divided by the current thumbnail size otherwise (e.g. right after construction, + * before the first layout pass). + */ + private int estimateVisibleRowCount() { + int top = topVisibleRowIndex(); + int bottom = bottomVisibleRowIndex(); + if (top >= 0 && bottom >= top) { + return bottom - top + 1; + } + return Math.max(1, (int) Math.floor(getHeight() / currentThumbnailSize())); + } + + /** + * {@link #relayout()}'s own follow-up: forces a fresh layout pass on each of the next + * {@code attemptsLeft} pulses, the same "needs another pulse to settle" retry {@link #pulseCardOnceSettled} + * uses for a {@code scrollTo} target. + * + *

{@code rows.requestLayout()} only asks {@code VirtualFlow} itself to reconsider its own layout; + * whether that actually walks back down into any one already-realised {@link JustifiedGridCell} is + * {@code VirtualFlow}'s own internal per-cell dirty tracking to decide, not something a request at the + * {@code ListView} level can force — a newly-realised cell whose position/size VirtualFlow already + * considers settled can sit with content genuinely never having gone through a real + * {@code layoutChildren()} pass at all, which is exactly what leaves its thumbnail decoded but never + * actually painted until something else (a mouse move — see {@link #relayout()}'s own javadoc) forces + * one. Calling {@code requestLayout()} directly on every currently-realised cell, not merely on + * {@code rows} itself, marks each one's own {@code needsLayout} regardless of what {@code VirtualFlow}'s + * dirty tracking otherwise concluded, which is what actually reaches whichever of {@code photosBox}/ + * {@code headerStrip} that cell currently shows, and each card's {@code ImageView}, on the very next pulse. + */ + private void pulseRowsLayout(int attemptsLeft) { + rows.requestLayout(); + for (Node node : rows.lookupAll(".list-cell")) { + if (node instanceof ListCell cell) { + cell.requestLayout(); + } + } + if (attemptsLeft > 0) { + Platform.runLater(() -> pulseRowsLayout(attemptsLeft - 1)); + } + } + + /** + * {@link #reveal}'s own continuation once it has asked {@code rows} to jump to a file not already on + * screen: {@code scrollTo} only requests that jump — {@code VirtualFlow} may need more than one + * layout pass to actually realise a target cell far from whatever it currently has, so this retries + * {@link #renderedCardFor} across up to {@code attemptsLeft} further pulses (via {@link Platform#runLater}) + * before giving up silently — there is then nothing left on screen to pulse. + */ + private void pulseCardOnceSettled(MediaFile file, int attemptsLeft) { + if (renderedCardFor(file) != null) { + pulseCard(file); + return; + } + if (attemptsLeft <= 0) { + return; + } + Platform.runLater(() -> pulseCardOnceSettled(file, attemptsLeft - 1)); + } + + /** + * The "here it is" cue {@link #reveal} always plays on {@code file}'s own card once it's actually on + * screen — {@link Animations#shakeX(Node, double)}, AtlantaFX's own side-to-side shake animation, rather + * than anything hand-rolled here. Silently does nothing if the card isn't actually rendered by the time + * this runs ({@link #renderedCardFor} returns {@code null}) — scrolled back out of view already, or the + * row hasn't laid out yet — since there is then nothing on screen left to shake. + */ + private void pulseCard(MediaFile file) { + Node card = renderedCardFor(file); + if (card == null) { + return; + } + if (revealPulseTransition != null) { + revealPulseTransition.stop(); + } + revealPulseTransition = Animations.shakeX(card, REVEAL_SHAKE_OFFSET); + revealPulseTransition.play(); + } + + /** + * Every scroll gesture funnels through here: shortcut+scroll still zooms exactly as before; a real + * device's own momentum tail ({@link ScrollEvent#isInertia()}) is swallowed outright, since + * {@link #startMomentumScroll} drives that tail itself instead (see its own javadoc for why); everything + * else is a direct, finger-driven scroll — {@link #recordScrollSample}d for {@link #currentFlingVelocity} + * and, if a glide was already running, stopped ({@link #stopMomentumScroll}) since a new touch on the pad + * is exactly the "stop it now" signal the user asked for. {@link #isSynthesizingMomentum} tells our own + * synthetic ticks apart from a genuine {@code isInertia()} event from the device — both look the same to + * {@link ScrollEvent}, but only the latter should ever be swallowed. + */ + private void onScroll(ScrollEvent event) { + // isShortcutDown(), not isControlDown(): Cmd on macOS, Ctrl everywhere else — the platform's own + // zoom-shortcut modifier, the same one browsers and photo apps already use for this. + if (event.isShortcutDown()) { + adjustThumbnailSize(event.getDeltaY() > 0 ? WHEEL_ZOOM_STEP : -WHEEL_ZOOM_STEP); + event.consume(); + return; + } + if (event.isInertia() && !isSynthesizingMomentum) { + event.consume(); + return; + } + if (!isSynthesizingMomentum) { + stopMomentumScroll(); + recordScrollSample(event.getDeltaY()); + } + onGridScrolled(); + maybeBounceOnScroll(); + } + + /** + * Appends {@code deltaY} to {@link #recentScrollSamples}, drops whatever has aged past + * {@link #MOMENTUM_VELOCITY_WINDOW_MILLIS}, and rearms {@link #scrollGestureIdle} — every direct scroll + * event {@link #onScroll} sees calls this, so the idle timer only ever fires once fingers actually stop. + */ + private void recordScrollSample(double deltaY) { + long now = System.currentTimeMillis(); + recentScrollSamples.addLast(new double[]{ now, deltaY }); + while (!recentScrollSamples.isEmpty() && now - recentScrollSamples.peekFirst()[0] > MOMENTUM_VELOCITY_WINDOW_MILLIS) { + recentScrollSamples.removeFirst(); + } + scrollGestureIdle.stop(); + scrollGestureIdle.playFromStart(); + } + + /** + * {@link #recentScrollSamples}' total {@code deltaY} divided by the time it spans — the gesture's own + * release speed in pixels per millisecond, signed the same way {@link ScrollEvent#getDeltaY()} is. Zero + * with fewer than two samples (nothing to compute a span from) or a zero span (samples all landed on the + * same millisecond). + */ + private double currentFlingVelocity() { + if (recentScrollSamples.size() < 2) { + return 0; + } + double totalDeltaY = 0; + for (double[] sample : recentScrollSamples) { + totalDeltaY += sample[1]; + } + double spanMillis = recentScrollSamples.peekLast()[0] - recentScrollSamples.peekFirst()[0]; + return spanMillis <= 0 ? 0 : totalDeltaY / spanMillis; + } + + /** + * Starts {@link #momentumTimer} at {@code velocity} pixels/millisecond, decaying it by + * {@link #MOMENTUM_FRICTION_PER_MILLIS} every frame and {@link #fireMomentumTick}ing {@link #virtualFlow} + * with however far that frame's now-slower speed covers, until the speed drops below + * {@link #MOMENTUM_MIN_VELOCITY} or {@link #isAtScrollEdge} — whichever comes first. + */ + private void startMomentumScroll(double velocity) { + stopMomentumScroll(); + momentumVelocity = velocity; + momentumLastFrameNanos = -1; + momentumTimer = new AnimationTimer() { + @Override + public void handle(long now) { + if (momentumLastFrameNanos < 0) { + momentumLastFrameNanos = now; + return; + } + double dtMillis = (now - momentumLastFrameNanos) / 1_000_000.0; + momentumLastFrameNanos = now; + momentumVelocity *= Math.pow(MOMENTUM_FRICTION_PER_MILLIS, dtMillis); + if (Math.abs(momentumVelocity) < MOMENTUM_MIN_VELOCITY || isAtScrollEdge()) { + stopMomentumScroll(); + return; + } + fireMomentumTick(momentumVelocity * dtMillis); + } + }; + momentumTimer.start(); + } + + /** + * Stops {@link #momentumTimer} if one is running — a new touch on the pad, either scroll edge, or {@link #dispose}. + */ + private void stopMomentumScroll() { + if (momentumTimer != null) { + momentumTimer.stop(); + momentumTimer = null; + } + } + + /** + * Whether {@link #verticalScrollBar} already sits at either end — {@link #startMomentumScroll}'s own reason to stop rather than keep ticking a glide with nowhere left to go. + */ + private boolean isAtScrollEdge() { + if (verticalScrollBar == null) { + return false; + } + double value = verticalScrollBar.getValue(); + return value <= verticalScrollBar.getMin() + SCROLLBAR_EDGE_EPSILON + || value >= verticalScrollBar.getMax() - SCROLLBAR_EDGE_EPSILON; + } + + /** + * Fires a synthetic {@link ScrollEvent} carrying {@code deltaY} pixels at {@link #virtualFlow} — the one + * skin-internal node whose own {@code setOnScroll} handler actually turns a delta into pixels scrolled + * (see its own javadoc) — flagged {@link ScrollEvent#isInertia()} so it reads the same as a device's own + * momentum tail to anything else observing it, and wrapped in {@link #isSynthesizingMomentum} so + * {@link #onScroll} (which this re-enters on the way down to {@link #virtualFlow}) lets it through + * instead of swallowing it the way a real one would be. Silently does nothing before the skin exists. + */ + private void fireMomentumTick(double deltaY) { + if (virtualFlow == null) { + stopMomentumScroll(); + return; + } + ScrollEvent tick = new ScrollEvent(ScrollEvent.SCROLL, 0, 0, 0, 0, + false, false, false, false, + false, true, + 0, deltaY, 0, deltaY, + ScrollEvent.HorizontalTextScrollUnits.NONE, 0, + ScrollEvent.VerticalTextScrollUnits.NONE, 0, + 0, null); + isSynthesizingMomentum = true; + try { + virtualFlow.fireEvent(tick); + } + finally { + isSynthesizingMomentum = false; + } + } + + /** + * A wheel scroll that lands exactly on {@link #verticalScrollBar}'s own min or max — nowhere further + * to go — {@link #bounceGrid}s toward whichever end that is. Checked by comparing the value just + * before the event to the value a pulse later rather than trusting {@link ScrollEvent#getDeltaY()}'s + * sign (a scroll that's still free to move away from an edge must never bounce, and the direction a + * positive/negative delta actually scrolls is a convention this reads back from the scrollbar itself + * instead of assuming). + */ + private void maybeBounceOnScroll() { + if (verticalScrollBar == null) { + return; + } + double before = verticalScrollBar.getValue(); + boolean atTop = before <= verticalScrollBar.getMin() + SCROLLBAR_EDGE_EPSILON; + boolean atBottom = before >= verticalScrollBar.getMax() - SCROLLBAR_EDGE_EPSILON; + if (!atTop && !atBottom) { + return; + } + Platform.runLater(() -> { + if (verticalScrollBar != null && Math.abs(verticalScrollBar.getValue() - before) < SCROLLBAR_EDGE_EPSILON) { + bounceGrid(atTop); + } + }); + } + + /** + * Nudges {@link #rows} toward {@code towardTop ? top : bottom} and springs it back with + * {@link EasingFX#ELASTIC_OUT} — feedback that a scroll attempt, wheel ({@link #maybeBounceOnScroll}) + * or an Up/Down key press already on the first/last row ({@link #moveVertically}), has nowhere + * further to go that way. Restarts, rather than stacks onto, any bounce already in flight. + */ + private void bounceGrid(boolean towardTop) { + if (gridBounceTransition != null) { + gridBounceTransition.stop(); + } + rows.setTranslateY(0); + double distance = towardTop ? GRID_BOUNCE_DISTANCE : -GRID_BOUNCE_DISTANCE; + gridBounceTransition = new Timeline( + new KeyFrame(Duration.ZERO, new KeyValue(rows.translateYProperty(), 0)), + new KeyFrame(Duration.millis(110), new KeyValue(rows.translateYProperty(), distance, Interpolator.EASE_OUT)), + new KeyFrame(GRID_BOUNCE_DURATION, new KeyValue(rows.translateYProperty(), 0, EasingFX.ELASTIC_OUT))); + gridBounceTransition.play(); + } + + /** + * The row in {@link #rows}' current items holding {@code file}, or {@code -1} if it isn't in the grid at all. + */ + private int rowIndexOf(MediaFile file) { + List items = rows.getItems(); + for (int i = 0; i < items.size(); i++) { + if (!(items.get(i) instanceof JustifiedRow row)) { + continue; + } + for (JustifiedRow.Entry entry : row.entries()) { + if (entry.file().equals(file)) { + return i; + } + } + } + return -1; + } + + /** + * Left/Right move {@link #selectedFile} to the corresponding neighbour ({@link #previous}/ + * {@link #next}) and always {@link #reveal} (scroll to) it — crossing a row boundary sideways can + * land anywhere relative to the viewport, so there's no "still visible" case worth special-casing the + * way {@link #moveVertically} does for Up/Down. With nothing selected yet, either instead just + * selects/reveals {@link #sortedFiles}' own first file, the same "start somewhere reasonable" + * behaviour a fresh arrow press ought to have. A neighbour that doesn't exist (start/end of the list) + * leaves the current selection exactly as it was. + * + *

Enter opens {@link #selectedFile} in {@code PhotoDetailPane} the same way double-clicking its + * card would — including handing along that same card, via {@link #renderedCardFor}, so the open + * animation still grows from it — but only when something is actually selected; with no selection + * there's nothing to open, and unlike the arrows this never invents one. + */ + private void onKeyPressed(KeyEvent event) { + MediaFile current = selectedFile.get(); + switch (event.getCode()) { + case ENTER -> { + if (current != null) { + onOpenRequest.accept(current, renderedCardFor(current)); + event.consume(); + } + } + case LEFT -> { + MediaFile target = current == null ? firstFile() : previous(current); + if (target != null) { + reveal(target); + event.consume(); + } + } + case RIGHT -> { + MediaFile target = current == null ? firstFile() : next(current); + if (target != null) { + reveal(target); + event.consume(); + } + } + case UP -> moveVertically(current, true, event); + case DOWN -> moveVertically(current, false, event); + default -> { + } + } + } + + /** + * Up/Down's own move: {@code up ? above(current) : below(current)}, or {@link #firstFile()} with + * nothing selected yet. Always goes through {@link #reveal}, same as Left/Right — {@code reveal} itself + * already no-ops the scroll when the target row is fully within the viewport already, so this needs no + * edge check of its own; an earlier version tried to shortcut that by only revealing when {@code current} + * sat exactly on {@link #topVisibleRowIndex}/{@link #bottomVisibleRowIndex}, but those two count a row as + * "visible" the moment any pixel of it is on screen — landing on a row only half-visible at the bottom + * (or top) then read as already at the edge, so the half-visible target never got nudged fully into view. + * A neighbour that doesn't exist (first/last row already) leaves the current selection exactly as it was. + */ + private void moveVertically(@Nullable MediaFile current, boolean up, KeyEvent event) { + MediaFile target = current == null ? firstFile() : (up ? above(current) : below(current)); + if (target == null) { + // Already in the first/last row — the same "nowhere further to go" a wheel scroll gets + // bounced for (see maybeBounceOnScroll), so an Up/Down key at either end reads the same way. + if (current != null) { + bounceGrid(up); + event.consume(); + } + return; + } + reveal(target); + event.consume(); + } + + private @Nullable MediaFile firstFile() { + return sortedFiles.isEmpty() ? null : sortedFiles.getFirst(); + } + + /** + * The currently rendered {@code .photo-card} for {@code file}, tagged with it via + * {@code JustifiedGridCell.cardFor}'s own {@link Node#setUserData}, or {@code null} if it isn't currently + * realised (scrolled out of view, or the grid hasn't laid out yet) — {@link #onKeyPressed}'s Enter + * shortcut degrades gracefully to opening with no source card in that case, the same as previous/next + * navigation already does. + */ + private @Nullable Node renderedCardFor(MediaFile file) { + for (Node node : rows.lookupAll(".photo-card")) { + if (file.equals(node.getUserData())) { + return node; + } + } + return null; + } + + @Override + public void dispose() { + scope.close(); + relayoutThrottle.stop(); + scrollBurstIdle.stop(); + thumbnailZoomSettle.stop(); + scrollGestureIdle.stop(); + stopMomentumScroll(); + if (gridBounceTransition != null) { + gridBounceTransition.stop(); + } + if (revealPulseTransition != null) { + revealPulseTransition.stop(); + } + timeline.dispose(); + } +} diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGridCell.java b/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGridCell.java new file mode 100644 index 0000000..529e257 --- /dev/null +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGridCell.java @@ -0,0 +1,930 @@ +package org.icroco.pholio.ui.view.gallery; + +import atlantafx.base.theme.Styles; +import javafx.animation.PauseTransition; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.collections.ObservableList; +import javafx.css.PseudoClass; +import javafx.event.Event; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; +import javafx.scene.shape.StrokeType; +import javafx.util.Duration; +import javafx.util.Subscription; +import org.icroco.pholio.domain.library.MediaFile; +import org.icroco.pholio.domain.media.ImageFormat; +import org.icroco.pholio.domain.media.MediaMetadata; +import org.icroco.pholio.infra.media.EThumbnailQuality; +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.material2.Material2AL; +import org.kordamp.ikonli.material2.Material2MZ; +import org.kordamp.ikonli.material2.Material2OutlinedAL; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.function.*; + +/** + * One item of {@code JustifiedGalleryPane}'s {@code rows} grid: either a {@link JustifiedRow} (a fixed-height + * strip of photo cards, {@link #renderContent}) or a {@link JustifiedGridItem.HeaderRow} (a different, fixed + * height of its own, one or more date labels, {@link #renderHeader}) — see {@link JustifiedGridItem}'s own + * javadoc for why these are two separate {@code ListView} items rather than a date's header being extra + * content rendered inside its own first content row. {@code updateItem} branches on which kind + * {@code getItem()} currently is; {@link #mode} tracks which half's state ({@link #cardsByFile}/ + * {@link #thumbnailSubscriptions} for content) this pooled, recycled cell currently holds, so a cell asked to + * switch kind tears down whichever half it no longer needs before rendering the other. + * + *

Unlike {@code legacy.GalleryGridCell}, there is no per-file checkbox, no per-date "select all"/actions + * menu, and no {@code i18n}-bound menu text — single selection only, no grouped actions in this component + * (see {@code JustifiedGalleryPane}'s own class javadoc for why). What remains is otherwise a close port. + * + *

Navigation selection is drawn as a plain Java property write, never a CSS pseudo-class. + * {@code legacy.GalleryGridCell} toggles a {@code :selected} pseudo-class and relies on a stylesheet rule to + * actually paint it, which only takes effect once the CSS engine gets around to resolving that rule — a pass + * that can lag behind the flip itself on a virtualised cell under a heavy stylesheet. {@link #selectionFrame} + * sidesteps that: {@link #notifyNavigationSelected}/{@link #notifyNavigationDeselected} — {@code + * JustifiedGalleryPane.select}'s own hand-off, see that method's javadoc — just call {@link Node#setVisible} + * on it directly. No rounding, no drop-shadow: a plain rectangular ring, painted entirely inside this card's + * own bounds, in the same {@link #BORDER_BLEED} margin the thumbnail itself is already inset by — see + * {@link #selectionFrame}'s own javadoc for the exact sizing. + * + *

The grid's other, independent selection — {@code JustifiedGalleryPane}'s own {@code actionSelection}, + * 0..N files for a future group action — has no visual yet; a cell will pull + * {@code JustifiedGalleryPane#isActionSelected} for it once it does, rather than being pushed to like the + * navigation selection is, since that selection can include files this cell isn't currently even showing. + */ +final class JustifiedGridCell extends ListCell { + + private static final Logger log = LoggerFactory.getLogger(JustifiedGridCell.class); + + /** + * JavaFX's own {@code :hover} tracking (pairing a {@code MOUSE_ENTERED}/{@code MOUSE_EXITED} per node + * via {@code Scene}'s picking) is a known source of stale state for a node that gets repositioned + * within a virtualised container without an intervening real mouse move — the exit for its old spot + * never fires, since by the time picking next runs the node is no longer even there. A reused card + * moved to a different column (see {@link #renderContent}'s own reuse tracking) is repositioned exactly + * that way, which is what let {@code .photo-card:hover}'s accent glow get stuck showing on a card the + * pointer had already left. Forcibly clearing this pseudo-class on a reused-and-moved card (see + * {@link #clearStaleHoverIfMoved}) doesn't fight JavaFX's own tracking permanently — the very next real + * pointer move re-syncs it correctly either way — it only forecloses the window where a moved card keeps + * showing whatever hover state it happened to have before moving. + */ + private static final PseudoClass HOVER = PseudoClass.getPseudoClass("hover"); + + /** + * Which half of this cell's own state — content or header — is currently populated; see this class's + * own javadoc for why {@code updateItem} only tears down the OTHER half on an actual kind switch. + */ + private enum Mode {EMPTY, HEADER, CONTENT} + + private Mode mode = Mode.EMPTY; + + private final HBox photosBox = new HBox(JustifiedGalleryPane.SPACING); + + /** + * One column per date, left-to-right — see {@link #renderHeader}. Rebuilt from scratch on every header + * render (cheap: a handful of labels, at most a couple of dates sharing one row). + */ + private final HBox headerStrip = new HBox(JustifiedGalleryPane.SPACING); + + private final Supplier quality; + private final DoubleSupplier thumbnailSize; + private final Consumer onSelect; + private final BiConsumer onOpen; + private final ReadOnlyObjectProperty<@Nullable MediaFile> selectedFile; + private final Function thumbnailReady; + private final ThumbnailImageCache imageCache; + + /** + * {@code gallery.show-missing-gps-badge} — see {@link #gpsBadge}. + */ + private final Supplier showMissingGpsBadge; + + /** + * Resolved once, lazily, from the active theme's {@code -color-accent-emphasis} — see + * {@code JustifiedGalleryPane#accentColor()} — and reused verbatim for every {@link #selectionFrame} + * this cell ever builds. Read exactly once per card, at construction time, never again on a later + * selection toggle — {@link #notifyNavigationSelected}/{@link #notifyNavigationDeselected} only ever + * flip {@link Node#setVisible}, never re-resolve a colour. + */ + private final Supplier accentColor; + + /** + * The card currently shown for each file this cell is rendering — the {@code .photo-card} clickable + * region, and the {@link StackPane} the thumbnail image plus every corner overlay icon are layered + * into. Also checked by {@link #renderThumbnail} before painting a decode that finished asynchronously, + * in case this pooled cell has since been recycled to a different item mid-scroll, and by + * {@link #notifyNavigationDeselected}/{@link #notifyNavigationSelected} to check a file it's told about + * is still actually one of this cell's own. + */ + private final Map cardsByFile = new HashMap<>(); + + /** + * One subscription per file currently held by this cell, torn down before it is repurposed — + * {@link ListCell}s are pooled and reused as the list scrolls, so a subscription left running would + * keep writing into a thumbnail slot that now shows a different file. + */ + private final Map thumbnailSubscriptions = new HashMap<>(); + + /** + * Everything is subscribed/wired once, in the constructor, never per {@link #updateItem}: + * {@link ListCell}s are pooled and reused across items as the list scrolls, so a cell created once + * must keep tracking selection for whichever content item it is handed next. + */ + JustifiedGridCell(Supplier quality, DoubleSupplier thumbnailSize, + Consumer onSelect, + BiConsumer onOpen, + ReadOnlyObjectProperty<@Nullable MediaFile> selectedFile, + Function thumbnailReady, + ThumbnailImageCache imageCache, + Supplier showMissingGpsBadge, + Supplier accentColor) { + this.quality = quality; + this.thumbnailSize = thumbnailSize; + this.onSelect = onSelect; + this.onOpen = onOpen; + this.selectedFile = selectedFile; + this.thumbnailReady = thumbnailReady; + this.imageCache = imageCache; + this.showMissingGpsBadge = showMissingGpsBadge; + this.accentColor = accentColor; + // ListCellBehavior installs its own MOUSE_PRESSED handler directly on this ListCell, so + // consuming the event here (on "this") would not stop it: JavaFX still runs every handler + // registered on the SAME node regardless of an earlier one having called consume() — consuming + // only ever stops propagation to an ANCESTOR node. photosBox/headerStrip are genuine descendants, + // so consuming there does work — but only across the area each actually occupies. + // setMaxWidth(MAX_VALUE) is what extends each one's own pickable bounds across the whole item's + // width regardless of its actual content width — Region hit-testing uses layout bounds regardless + // of paint, so any unfilled trailing area becomes part of it too — and the handler here then + // catches every press across the whole item. + photosBox.setMaxWidth(Double.MAX_VALUE); + photosBox.setOnMousePressed(Event::consume); + headerStrip.setMaxWidth(Double.MAX_VALUE); + headerStrip.setOnMousePressed(Event::consume); + } + + @Override + protected void updateItem(JustifiedGridItem item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + clearContentState(); + clearHeaderState(); + mode = Mode.EMPTY; + setGraphic(null); + setStyle(null); + return; + } + switch (item) { + case JustifiedGridItem.HeaderRow header -> { + if (mode == Mode.CONTENT) { + clearContentState(); + } + mode = Mode.HEADER; + renderHeader(header); + } + case JustifiedRow row -> { + if (mode == Mode.HEADER) { + clearHeaderState(); + } + mode = Mode.CONTENT; + renderContent(row); + prefetchNeighbours(); + } + } + } + + /** + * Extra top padding {@link #renderHeader} adds, beyond the normal inter-item {@code JustifiedGalleryPane#SPACING}, + * for a clearer break above a fresh date's header than the plain {@code SPACING} gap between two plain + * content items gets. + */ + private static final double NEW_DATE_TOP_GAP = 16; + + private void clearContentState() { + thumbnailSubscriptions.values().forEach(Subscription::unsubscribe); + thumbnailSubscriptions.clear(); + cardsByFile.clear(); + } + + private void clearHeaderState() { + headerStrip.getChildren().clear(); + } + + /** + * {@code height}/{@code quality} this cell's current {@link #cardsByFile} were last built for; {@code -1}/{@code null} until the first render. + */ + private double cardsHeight = -1; + private @Nullable EThumbnailQuality cardsQuality; + + /** + * The very first item in the whole grid gets no extra top padding; every later one gets one matching + * {@code JustifiedGalleryPane#SPACING}, the same gap already used between thumbnails horizontally, so + * items don't run straight into each other. Reset on every render: {@link ListCell}s are pooled, so a + * padding set for one item must not linger onto whichever item this recycled cell is handed next. + * + *

Rebuilds {@link #photosBox} for {@code row}, but reuses whichever of {@link #cardsByFile}'s + * existing cards are still wanted — same file, same {@code height}/{@code quality} as last time this + * cell rendered content — rather than tearing down and recreating every card on every call. Unlike + * {@code legacy.GalleryGridCell}, a reused card's {@link JustifiedRow.Entry#width()} is not + * guaranteed to be the same as last time even though {@code height}/{@code quality} are unchanged — a + * justified row's per-file width also depends on how much slack the rest of its row had to distribute + * (see {@link JustifiedRow#justify}), which a resize can change independently of the thumbnail-size + * preference. {@link #resizeCard} reapplies the current width to a reused card unconditionally — cheap, + * layout-only, no re-decode — rather than trying to detect whether it actually changed. + */ + private void renderContent(JustifiedRow row) { + double topPadding = getIndex() == 0 ? 0 : JustifiedGalleryPane.SPACING; + setStyle("-fx-padding: " + topPadding + " 0 0 0;"); + double height = thumbnailSize.getAsDouble(); + EThumbnailQuality currentQuality = quality.get(); + // A size/quality change invalidates every existing card's dimensions/decode alike — nothing + // salvageable, so this render starts from a clean slate exactly as it always used to. + boolean reusable = height == cardsHeight && currentQuality == cardsQuality; + cardsHeight = height; + cardsQuality = currentQuality; + + Map previousCards = reusable ? new HashMap<>(cardsByFile) : new HashMap<>(); + Map previousThumbnailSubs = reusable ? new HashMap<>(thumbnailSubscriptions) : new HashMap<>(); + if (!reusable) { + thumbnailSubscriptions.values().forEach(Subscription::unsubscribe); + } + cardsByFile.clear(); + thumbnailSubscriptions.clear(); + + // Snapshot BEFORE this render moves anything, purely to detect a reused card landing at a + // different column than before — see clearStaleHoverIfMoved's own javadoc for why that specific + // case needs its stale :hover cleared explicitly. + List priorPhotoOrder = List.copyOf(photosBox.getChildren()); + + List entries = row.entries(); + List children = new ArrayList<>(entries.size()); + double extraDateGap = JustifiedGalleryPane.DATE_SPACING - JustifiedGalleryPane.SPACING; + for (int idx = 0; idx < entries.size(); idx++) { + JustifiedRow.Entry entry = entries.get(idx); + MediaFile file = entry.file(); + double width = entry.width(); + + StackPane existing = previousCards.remove(file); + final StackPane card; + if (existing != null) { + card = existing; + // Skipped entirely, not just made idempotent, when the width is unchanged from last time — + // the common case for any relayout not driven by an actual resize/re-chunk: avoids rebuilding + // a Rectangle clip and re-fitting this card's ImageView for every visible card on every such + // relayout. + if (card.getMinWidth() != width) { + resizeCard(card, width, height); + } + thumbnailSubscriptions.put(file, previousThumbnailSubs.remove(file)); + clearStaleHoverIfMoved(existing, priorPhotoOrder, idx); + } else { + StackPane fresh = cardFor(file, width, height); + + // Registered — and left to fire — before the overlays are added below: subscribe(Consumer), + // not subscribe(Runnable), fires SYNCHRONOUSLY with the current value on registration, so + // this call itself paints the thumbnail at index 0 while fresh is still genuinely empty (see + // setImageContent's own contract: adds at 0 only while empty, otherwise REPLACES whatever + // already sits at 0). Adding overlays first would make that first fire overwrite the first + // overlay badge with the image instead of landing at index 0 as intended. + Subscription subscription = thumbnailReady.apply(file).subscribe( + ready -> renderThumbnail(fresh, file, currentQuality, width, height)); + thumbnailSubscriptions.put(file, subscription); + + fresh.getChildren().addAll(overlaysFor(file, showMissingGpsBadge.get())); + // Built once, here, and never rebuilt afterwards (see selectionFrame's own javadoc), but + // NOT unconditionally added to fresh's children — see FRAME_KEY's own javadoc for why + // membership in the children list, not Node.visible, is what actually drives whether it + // paints. Stashed in fresh's own properties map so it can be found again regardless of + // whether it's currently attached. Starts attached only if the grid's navigation selection + // already is this file right now, so a card freshly realised for the file that's already + // selected (e.g. scrolling it back into view) doesn't need a separate notification to catch + // up. + Rectangle frame = selectionFrame(fresh); + fresh.getProperties().put(FRAME_KEY, frame); + if (file.equals(selectedFile.getValue())) { + fresh.getChildren().add(frame); + } + card = fresh; + } + + HBox.setMargin(card, (idx > 0 && entry.marker() != null) ? new Insets(0, 0, 0, extraDateGap) : Insets.EMPTY); + + cardsByFile.put(file, card); + children.add(card); + } + // Whatever's left belonged to a file no longer held by this cell (scrolled elsewhere, or + // re-chunked out of it) — never reused, so it needs unsubscribing same as the full-rebuild path + // always did. + previousThumbnailSubs.values().forEach(Subscription::unsubscribe); + + // Reconciled in place, never setAll: ObservableList.setAll is a blind clear()+addAll(), so it would + // detach and reattach every card — including ones reused unchanged above — the instant even a + // single file entered or left it, which is the common case for a relayout triggered by a width + // change rather than a thumbnail-size edit. Detaching and reattaching a Node resets whatever the + // CSS engine had computed for it (its :hover included) — reconcileChildren only ever touches a + // card that is actually new to this item or has actually moved position within it. + reconcileChildren(photosBox.getChildren(), children); + setGraphic(photosBox); + } + + /** + * Reconciles {@code current} to exactly {@code desired}, in order, touching as few elements as + * possible — unlike {@link ObservableList#setAll(java.util.Collection)}, which unconditionally does + * {@code clear()} then {@code addAll()} regardless of how much of the two lists already agree, + * detaching and reattaching every single element even when most of them are the exact same {@link Node} + * reference at the exact same position. First drops whatever {@code current} holds that isn't wanted at + * all any more, then walks {@code desired} left to right: a node already sitting at its correct index is + * skipped outright — it never leaves the scene graph — anything else (new to this item, or just shifted + * position because a neighbour entered/left) is removed from wherever it currently sits (a no-op for one + * that was never in {@code current}) and reinserted at its correct index. + */ + private static void reconcileChildren(ObservableList current, List desired) { + if (current.equals(desired)) { + return; + } + current.removeIf(node -> !desired.contains(node)); + for (int i = 0; i < desired.size(); i++) { + Node wanted = desired.get(i); + if (i < current.size() && current.get(i) == wanted) { + continue; + } + current.remove(wanted); + current.add(i, wanted); + } + } + + /** + * Renders {@code header}'s columns into {@link #headerStrip}, left-to-right, each pinned to its own + * already-computed {@link JustifiedGridItem.HeaderColumn#spanWidth()} — see {@link JustifiedGridItem}'s + * own javadoc for why that width, computed once at {@code relayout()} time from the same already- + * stretched widths the content item below renders its own cards at, is what keeps the two aligned + * across their now-separate cells with no further synchronisation needed here. {@link #headerStrip} is + * rebuilt from scratch every time (cheap — a handful of labels, never more than a couple of dates + * sharing one item). + */ + private void renderHeader(JustifiedGridItem.HeaderRow header) { + double topPadding = getIndex() == 0 ? 0 : JustifiedGalleryPane.SPACING + NEW_DATE_TOP_GAP; + setStyle("-fx-padding: " + topPadding + " 0 0 0;"); + + double extraDateGap = JustifiedGalleryPane.DATE_SPACING - JustifiedGalleryPane.SPACING; + List columns = header.columns(); + List headerColumns = new ArrayList<>(columns.size()); + for (int idx = 0; idx < columns.size(); idx++) { + JustifiedGridItem.HeaderColumn column = columns.get(idx); + Node built = dateHeaderColumn(column.marker(), column.spanWidth()); + HBox.setMargin(built, idx > 0 ? new Insets(0, 0, 0, extraDateGap) : Insets.EMPTY); + headerColumns.add(built); + } + headerStrip.getChildren().setAll(headerColumns); + setGraphic(headerStrip); + } + + /** + * The header row's own fixed height — pinned via {@code setMin/Pref/MaxHeight} in {@link #dateHeaderColumn}, + * never left to a {@link Label}'s own natural preferred height. This is not just cosmetic headroom: a + * header item's height feeds directly into {@code JustifiedGalleryPane}'s {@code ListView} row layout, + * and a {@link Label}'s reported preferred height is not perfectly stable across every single CSS pass — + * confirmed (see {@code legacy.GalleryGridItem}'s own javadoc for the fuller history) to drift by a pixel + * or so specifically under a heavy stylesheet (AtlantaFX's), never JavaFX's own near-empty default one. + * Pinning every header item to this exact same numeric constant, rather than trusting text metrics to + * settle identically every time, is what keeps every header item's height byte-for-byte identical to + * every other one — the same "exactly two fixed heights, never a third, drifting one" invariant + * {@link JustifiedGridItem}'s own split between {@link JustifiedRow} and {@code HeaderRow} exists for in + * the first place. Matches {@code legacy.GalleryGridCell}'s own {@code ICON_SIZE + 14} floor value, + * simply no longer justified by an icon that no longer exists here. + */ + private static final double HEADER_ROW_HEIGHT = 40; + + /** + * One date's own column in {@link #headerStrip}: the date label, plus its locality when resolved — the + * exact same look {@code legacy.GalleryGridCell} used for a date's own header text, minus the "select + * all"/actions-menu slots that came with the checkbox mechanism this component doesn't have. + * + * @param spanWidth this date's own already-computed {@link JustifiedGridItem.HeaderColumn#spanWidth()} — + * see that record's own javadoc for why it always matches this date's own cards in the + * content item below exactly. + */ + private static Node dateHeaderColumn(JustifiedRow.DateMarker marker, double spanWidth) { + Label headerLabel = new Label(marker.label()); + headerLabel.getStyleClass().addAll(Styles.TITLE_4, "gallery-date-header"); + HBox box = new HBox(8, headerLabel); + if (marker.locality() != null) { + Label localityLabel = new Label(marker.locality()); + localityLabel.getStyleClass().addAll(Styles.TEXT_MUTED, "gallery-date-locality"); + box.getChildren().add(localityLabel); + } + box.setAlignment(Pos.BASELINE_LEFT); + box.getStyleClass().add("gallery-date-header-column"); + // Pinned to this date's own full span width — not left to size itself from its own content: HBox + // positions each later column right after this one's own ALLOCATED width, so if this box reported + // its natural (text-only) width instead, every column after it in the same header item would drift + // out of alignment with its own photo the moment that content is narrower (or wider) than the cards + // it marks. HBox never clips its own children by default, so content genuinely wider than this span + // still spills rightward rather than being cut off — the box is just no longer allowed to lay claim + // to that extra width itself. + box.setMinWidth(spanWidth); + box.setPrefWidth(spanWidth); + box.setMaxWidth(spanWidth); + // See HEADER_ROW_HEIGHT's own javadoc: pinned, not left to the labels' own natural height. + box.setMinHeight(HEADER_ROW_HEIGHT); + box.setPrefHeight(HEADER_ROW_HEIGHT); + box.setMaxHeight(HEADER_ROW_HEIGHT); + return box; + } + + /** + * Whether this cell currently has a card for {@code file} — i.e. {@code file} is one of the entries its + * own last {@link #renderContent} rendered. {@code JustifiedGalleryPane.cellShowing}'s own filter, used + * to find the exact cell to hand a navigation-selection change off to (see {@link #notifyNavigationSelected}/ + * {@link #notifyNavigationDeselected}) without a {@link Node#lookup(String)} scan into each cell's own + * children. + */ + boolean isShowing(MediaFile file) { + return cardsByFile.containsKey(file); + } + + /** + * {@code JustifiedGalleryPane.select}'s own hand-off — tells this cell that {@code file}, one it may + * currently be showing, IS now the grid's navigation selection: shows its + * {@link #selectionFrame}. Counterpart to {@link #notifyNavigationDeselected}; same guard — a no-op, + * logged as such, unless {@code file} is still genuinely one of this cell's own {@link #cardsByFile} + * and that specific card is still actually attached to the scene (it can have been recycled to + * a completely different row between the moment {@code JustifiedGalleryPane} looked this cell up and + * this call actually running, pooled {@link ListCell}s being reused for arbitrary content as the grid + * scrolls — in practice never, since both happen synchronously in the same call chain, but cheap to + * check regardless). + */ + void notifyNavigationSelected(MediaFile file) { + StackPane card = cardsByFile.get(file); + log.info("Cell {} card {} notified SELECTED for file {} (stillDisplayed={}) fileName={}", + System.identityHashCode(this), card == null ? null : System.identityHashCode(card), + file.id(), card != null && card.getScene() != null, file.path().getFileName()); + if (card == null || card.getScene() == null) { + return; + } + setFrameVisible(card, true); + } + + /** + * {@code JustifiedGalleryPane.select}'s own hand-off — tells this cell that {@code file}, one it may + * currently be showing, is no longer the grid's navigation selection: hides its + * {@link #selectionFrame}. See {@link #notifyNavigationSelected}'s own javadoc for the "still displayed" + * guard, identical here. + */ + void notifyNavigationDeselected(MediaFile file) { + StackPane card = cardsByFile.get(file); + log.info("Cell {} card {} notified DESELECTED for file {} (stillDisplayed={}) fileName={}", + System.identityHashCode(this), card == null ? null : System.identityHashCode(card), + file.id(), card != null && card.getScene() != null, file.path().getFileName()); + if (card == null || card.getScene() == null) { + return; + } + setFrameVisible(card, false); + } + + /** + * {@code card.getProperties()} key {@link #selectionFrame}'s own {@link Rectangle} is stashed under — + * see that field's own javadoc for why this, and not {@link Node#setVisible}, is what actually drives + * whether it paints: {@code Node.visible} demonstrably does not reliably repaint a virtualised cell's + * child under AtlantaFX's own (much heavier than JavaFX's default) stylesheet — confirmed by exhausting + * every remedy that class of bug has ever needed elsewhere in this codebase (an immediate + * {@code applyCss()}, then retrying that same flip across several more {@link Platform#runLater} pulses, + * both ported near-verbatim from {@code legacy.GalleryGridCell}'s own confirmed-working fix for the + * unrelated {@code :selected}/{@code :checked} pseudo-class version of this same underlying staleness) + * with zero visible effect — a repeatedly reported stale border only ever actually clearing once some + * unrelated pointer event forced a real pick/CSS pass elsewhere in the scene. Membership in + * {@code card}'s own children list, not a property flip buried within an unchanged child, is a + * structurally different — and far more heavily exercised — invalidation path in JavaFX (any + * {@link ObservableList} mutation on a {@link Parent}'s children unconditionally marks that parent's + * layout and render state dirty), so {@link #setFrameVisible} adds/removes the actual node instead of + * ever touching its {@code visible} property. + */ + private static final Object FRAME_KEY = new Object(); + + /** + * Adds or removes {@code card}'s own {@link #selectionFrame} (stashed under {@link #FRAME_KEY}, see that + * field's own javadoc for why membership, not {@link Node#setVisible}) from {@code card}'s children — + * idempotent, a no-op if {@code card} is already in the wanted state. + */ + private void setFrameVisible(StackPane card, boolean visible) { + Object stashed = card.getProperties().get(FRAME_KEY); + if (!(stashed instanceof Rectangle frame)) { + log.warn("Card {} has no selectionFrame stashed under FRAME_KEY — cannot set frame visible={}", + System.identityHashCode(card), visible); + return; + } + boolean attached = card.getChildren().contains(frame); + log.info("Card {} frame {} attached: {} -> {}", System.identityHashCode(card), + System.identityHashCode(frame), attached, visible); + if (visible && !attached) { + card.getChildren().add(frame); + } else if (!visible && attached) { + card.getChildren().remove(frame); + } + // Neither this children-list mutation nor an earlier Node.visible flip (see FRAME_KEY's own + // javadoc) reliably repaints on its own under AtlantaFX — both are confirmed dirty/repaint-request + // paths for a plain Node, so the missing sync is one level up: VirtualFlow (ListView's own + // virtualisation engine) apparently doesn't always resync a cell it doesn't otherwise think changed, + // even when one of its own descendants just did. Explicitly requesting layout on the ListView + // itself — not just this card, already covered above — forces VirtualFlow's own layout pass to run, + // which is what actually walks and re-syncs its visible cells. + if (getListView() != null) { + getListView().requestLayout(); + } + } + + /** + * {@code card} is being reused (same {@link StackPane} instance) but may have landed at a different + * column than it occupied before this render — {@code priorPhotoOrder} is {@link #photosBox}'s own + * children snapshotted before anything in this render touched them, {@code newIndex} is where {@code + * card} lands in the freshly-built content this time. If those differ, {@code card}'s own {@code :hover} + * may still be reflecting wherever the pointer was relative to its OLD position — {@link #HOVER}'s own + * field javadoc explains why JavaFX's own {@code MOUSE_EXITED} pairing can miss this exact case. + * Clearing it here, then forcing this card through an immediate {@code applyCss()}, repaints the + * corrected state right away rather than leaving a stale accent glow until some later, unrelated pointer + * move happens to resync it. A no-op for a card that stayed put. + */ + private static void clearStaleHoverIfMoved(StackPane card, List priorPhotoOrder, int newIndex) { + int priorIndex = priorPhotoOrder.indexOf(card); + if (priorIndex == newIndex) { + return; + } + card.pseudoClassStateChanged(HOVER, false); + card.applyCss(); + } + + /** + * Paints an already-cached decode immediately, or shows the placeholder and asks + * {@link ThumbnailImageCache#request} to decode one. That request's callback can fire after this + * exact {@code card} has already been discarded by a later render (the cell recycled to a different + * item mid-scroll) — {@code cardsByFile.get(file) == card} skips painting in that case. + * + *

Always replaces exactly {@code card}'s child index 0 (adding it there first if the card is still + * empty) and never touches indices 1+ — those are this file's corner overlay icons ({@link #overlaysFor}) + * and its {@link #selectionFrame}, added once per {@link #renderContent} and otherwise untouched by + * decode churn. + */ + private void renderThumbnail(StackPane card, MediaFile file, EThumbnailQuality quality, double width, double height) { + Image cached = imageCache.getIfPresent(file.hash(), quality); + if (cached != null) { + setImageContent(card, imageView(cached, width, height)); + } else { + setImageContent(card, pendingThumbnail(width, height)); + requestThumbnailWithRetry(card, file, quality, width, height, 0); + } + } + + /** + * {@link ThumbnailImageCache#request} never calls its callback at all when the on-disk thumbnail + * doesn't exist yet — see that method's own javadoc, "never called at all if the file has no thumbnail + * on disk yet". That's the right contract for a file that will genuinely never get one, but a file + * whose thumbnail generation simply hasn't finished writing to disk yet looks identical from here: the + * placeholder this cell just painted is then never replaced, permanently, since nothing else ever + * re-triggers a render for it — until an unrelated full relayout (e.g. re-selecting the same folder) + * rebuilds this card from scratch and gets a synchronous cache hit the second time around. + * + *

{@link com.github.benmanes.caffeine.cache.AsyncLoadingCache} auto-evicts an entry whose future + * completed with {@code null} (Caffeine's own documented behaviour), so simply asking again later is a + * genuine retry, not a cache hit on the same failure — cheap when the file still isn't there (one + * {@code Files.exists} check), and it succeeds outright the moment generation actually finishes writing + * it. + */ + private static final int THUMBNAIL_MISSING_RETRY_LIMIT = 8; + private static final Duration THUMBNAIL_MISSING_RETRY_DELAY = Duration.millis(500); + + private void requestThumbnailWithRetry(StackPane card, MediaFile file, EThumbnailQuality quality, double width, double height, int retriesSoFar) { + boolean[] delivered = { false }; + imageCache.request(file.hash(), quality, image -> { + delivered[0] = true; + deliverThumbnail(card, file, image, width, height); + }); + if (retriesSoFar >= THUMBNAIL_MISSING_RETRY_LIMIT) { + return; + } + PauseTransition retry = new PauseTransition(THUMBNAIL_MISSING_RETRY_DELAY); + retry.setOnFinished(event -> { + if (delivered[0] || cardsByFile.get(file) != card) { + return; + } + requestThumbnailWithRetry(card, file, quality, width, height, retriesSoFar + 1); + }); + retry.play(); + } + + /** + * Paints unconditionally, without regard to {@code card.getScene()} — a detached card (recycled to a + * different item mid-scroll, or simply scrolled off-screen) is painted anyway rather than deferred or + * dropped, matching {@link #renderThumbnail}'s own synchronous cache-hit branch, which never checked + * scene attachment either. + */ + private void deliverThumbnail(StackPane card, MediaFile file, Image image, double width, double height) { + if (cardsByFile.get(file) != card) { + return; + } + setImageContent(card, imageView(image, width, height)); + } + + private static void setImageContent(StackPane card, Node content) { + ObservableList children = card.getChildren(); + if (children.isEmpty()) { + children.add(content); + } else { + children.set(0, content); + } + } + + /** + * Every corner badge this file's card earns — each only present when the underlying data says so. + * Order here is also z-order: harmless, since these corners never overlap. {@code showMissingGpsBadge} + * is {@code gallery.show-missing-gps-badge} — turning it off drops {@link #gpsBadge} entirely rather + * than just hiding it visually. + */ + private static List overlaysFor(MediaFile file, boolean showMissingGpsBadge) { + List overlays = new ArrayList<>(3); + mediaTypeIcon(file).ifPresent(overlays::add); + if (isFavorite(file)) { + overlays.add(cornerIcon(Feather.STAR, Pos.BOTTOM_LEFT, "thumbnail-overlay-favorite")); + } + if (showMissingGpsBadge) { + gpsBadge(file).ifPresent(overlays::add); + } + return overlays; + } + + /** + * Corner inset every badge is pinned at — see {@link #cornerIcon}. + */ + private static final double ICON_MARGIN = 4; + + /** + * The generic corner-overlay primitive every badge above is built from: a small icon pinned to one + * {@link StackPane} corner with a uniform inset, tagged with the shared {@code thumbnail-overlay-icon} + * class plus whatever {@code extraStyleClasses} a badge needs for its own conditional CSS. + */ + private static FontIcon cornerIcon(Ikon glyph, Pos corner, String... extraStyleClasses) { + FontIcon icon = new FontIcon(glyph); + icon.getStyleClass().add("thumbnail-overlay-icon"); + icon.getStyleClass().addAll(extraStyleClasses); + StackPane.setAlignment(icon, corner); + StackPane.setMargin(icon, new Insets(ICON_MARGIN)); + return icon; + } + + /** + * A positive EXIF/Windows {@code Rating} — see {@link MediaMetadata#isFavorite()} — the only signal + * this application has for "favorite"; read-only, sourced from the file itself, never an app-side + * toggle. + */ + private static boolean isFavorite(MediaFile file) { + MediaMetadata metadata = file.metadata(); + return metadata != null && metadata.isFavorite(); + } + + /** + * A real video file ({@link ImageFormat.Kind#VIDEO}) gets the film glyph; a still image that also + * carries Google's Motion Photo/MicroVideo marker ({@link MediaMetadata#motionPhoto()}) gets the + * "mini-video" glyph instead — the two are mutually exclusive, so at most one ever applies. + */ + private static Optional mediaTypeIcon(MediaFile file) { + MediaMetadata metadata = file.metadata(); + if (metadata == null) { + return Optional.empty(); + } + if (metadata.format().kind() == ImageFormat.Kind.VIDEO) { + return Optional.of(cornerIcon(Feather.FILM, Pos.TOP_RIGHT, "thumbnail-overlay-media-type")); + } + if (metadata.motionPhoto()) { + return Optional.of(cornerIcon(Material2MZ.MOTION_PHOTOS_ON, Pos.TOP_RIGHT, "thumbnail-overlay-media-type")); + } + return Optional.empty(); + } + + /** + * A warning overlay when {@link MediaFile#metadata()} carries no GPS + * {@link org.icroco.pholio.domain.media.GeoLocation} — a file with coordinates gets no icon at all (the + * common case), while one missing them is flagged instead. + */ + private static Optional gpsBadge(MediaFile file) { + MediaMetadata metadata = file.metadata(); + if (metadata != null && metadata.location() != null) { + return Optional.empty(); + } + return Optional.of(cornerIcon(Material2OutlinedAL.LOCATION_OFF, Pos.BOTTOM_RIGHT, "gallery-gps-badge")); + } + + /** + * A fixed-size wrapper, exactly {@code width}/{@code height} shrunk on every side by {@link #BORDER_BLEED} + * — never the raw {@link ImageView} directly — so {@code card} (the {@link StackPane} this is added + * into) centres it with that same margin all round, left empty for {@link #selectionFrame}'s own ring. + * The wrapper's own clip, not one on {@code card} itself, is what crops the image. + * + *

{@code height} is always exact, never cropped: the card's height is the one dimension every row + * shares. Width instead "covers": scaled at least as wide as the wrapper even if the decoded image's + * own aspect doesn't exactly match {@link JustifiedRow.Entry#width()} — centered and cropped left/right + * by the wrapper's clip, never squeezed. {@link #resizeCard} recomputes this same "cover" fit whenever a + * reused card's width changes across renders. + */ + private static StackPane imageView(Image image, double width, double height) { + double fullTargetHeight = Math.max(1, height - 2 * BORDER_BLEED); + double fullTargetWidth = Math.max(1, width - 2 * BORDER_BLEED); + double naturalWidth = image.getWidth() * fullTargetHeight / image.getHeight(); + + ImageView view = new ImageView(image); + view.setFitHeight(fullTargetHeight); + view.setFitWidth(Math.max(fullTargetWidth, naturalWidth)); + view.setPreserveRatio(false); + view.setSmooth(true); + + return fixedSize(new StackPane(view), fullTargetWidth, fullTargetHeight); + } + + /** + * Pins {@code region} to exactly {@code width}×{@code height} and clips it to the same rectangle. + */ + private static StackPane fixedSize(StackPane region, double width, double height) { + region.setMinSize(width, height); + region.setPrefSize(width, height); + region.setMaxSize(width, height); + region.setClip(new Rectangle(width, height)); + return region; + } + + /** + * Unlike {@code legacy.GalleryGridCell} (a per-file width there is a pure function of height alone, so + * a reused card's width can never actually change across renders that keep the same height/quality), a + * {@link JustifiedRow} entry's width also depends on the rest of its row (see {@link JustifiedRow#justify}'s + * stretching step) — so even a render that reuses this exact card for the exact same file can still need + * a different width than last time, whenever a resize shifted how much slack this row's stretch had to + * distribute. This is layout-only (no re-decode): the wrapper's own size/clip, and — since the + * {@link ImageView}'s {@code fitWidth} is "cover"-computed from the image's own natural aspect, see + * {@link #imageView} — the {@link ImageView}'s own {@code fitWidth} too, so a widened card never leaves a + * gap of unfilled background down one side. Called unconditionally on every reused card; harmless (a + * same-size re-application) whenever the width in fact didn't change. Never touches + * {@link #selectionFrame} — its width/height are bound to {@code card}'s own, and it carries no rounding + * tied to any state, so there is nothing here for it to fall out of sync with. + */ + private static void resizeCard(StackPane card, double width, double height) { + card.setMinSize(width, height); + card.setPrefSize(width, height); + card.setMaxSize(width, height); + if (card.getChildren().isEmpty() || !(card.getChildren().getFirst() instanceof StackPane content)) { + return; + } + double targetWidth = Math.max(1, width - 2 * BORDER_BLEED); + double targetHeight = Math.max(1, height - 2 * BORDER_BLEED); + fixedSize(content, targetWidth, targetHeight); + if (!content.getChildren().isEmpty() && content.getChildren().getFirst() instanceof ImageView imageView) { + Image image = imageView.getImage(); + double naturalWidth = image.getWidth() * targetHeight / image.getHeight(); + imageView.setFitHeight(targetHeight); + imageView.setFitWidth(Math.max(targetWidth, naturalWidth)); + } + } + + /** + * Warms {@link #imageCache} for the {@code JustifiedGalleryPane#PREFETCH_ROWS} items on either side + * of this cell's own index — called every time an item scrolls into JavaFX's own cell-recycling + * buffer around the viewport, which is what turns this into "prefetch ahead of and behind what's + * currently visible" without needing to hand-roll any {@code VirtualFlow}/scrollbar introspection. + * {@code getIndex()} is set by JavaFX before {@link #updateItem} is dispatched, but can be + * transiently {@code -1} for a cell that is currently a recycling "spare" — guarded here the same way + * {@link #updateItem} already guards {@code empty}. Only ever called from the {@link JustifiedRow} + * branch, and skips any {@link JustifiedGridItem.HeaderRow} items the window happens to include — they + * hold no files of their own to prefetch. + */ + private void prefetchNeighbours() { + int index = getIndex(); + if (index < 0) { + return; + } + List items = getListView().getItems(); + EThumbnailQuality currentQuality = quality.get(); + int from = Math.max(0, index - JustifiedGalleryPane.PREFETCH_ROWS); + int to = Math.min(items.size() - 1, index + JustifiedGalleryPane.PREFETCH_ROWS); + for (int i = from; i <= to; i++) { + if (i == index) { + continue; + } + if (items.get(i) instanceof JustifiedRow row) { + for (JustifiedRow.Entry entry : row.entries()) { + imageCache.prefetch(entry.file().hash(), currentQuality); + } + } + } + } + + /** + * Reserved ring, in pixels, every card leaves empty around its own thumbnail on every side (via + * {@link #fixedSize}) — this is what {@link #selectionFrame} paints its own stroke into, and exactly + * how thick that stroke is (see that method's own javadoc for why they must match exactly). Bumped from + * this component's original {@code 2} to {@code 3}: the earlier value was only ever a card's own share + * of the gap between two side-by-side thumbnails ({@code 2 * BORDER_BLEED}, since + * {@code JustifiedGalleryPane.SPACING} is {@code 0} — cards touch directly, this ring is the only thing + * separating them) — {@code 3} keeps the selection frame visibly thicker than that per-card share was, + * without needing to touch {@code SPACING} or grow the gap between UNselected neighbours any further + * than this one extra pixel already does. + * + *

Package-private: {@code JustifiedGalleryPane.SPACING} being {@code 0} is exactly why this ring + * matters — it is the only thing keeping two adjacent images from touching directly. + */ + static final double BORDER_BLEED = 3; + + /** + * The Google-Photos-style navigation-selection frame: a plain rectangular ring, no rounding, no + * drop-shadow — {@code accentColor}, resolved once at construction (see that field's own javadoc), for + * the stroke; {@link javafx.scene.paint.Color#TRANSPARENT} fill so it never obscures the photo. + * {@link StrokeType#INSIDE} at exactly {@link #BORDER_BLEED} wide is what keeps the whole ring painted + * strictly within {@code card}'s own bounds — inside the same margin {@link #imageView}/ + * {@link #pendingThumbnail} already leave the thumbnail short of on every side, touching its outer edge + * without ever overlapping it, and never spilling into a neighbouring card's own territory (impossible + * regardless, since {@code card}'s bounds are what this is clipped to, but by design as much as by + * construction). Bound to {@code card}'s own live {@code width}/{@code height}, not a fixed snapshot, so + * {@link #resizeCard} moving a reused card to a new width needs no separate step to keep it in sync. + * + *

Built once, in {@link #renderContent}'s fresh-card branch, never rebuilt afterwards — and stashed + * under {@link #FRAME_KEY} in {@code card}'s own properties map regardless of whether it is currently + * one of {@code card}'s children, since {@link #setFrameVisible} drives whether it paints by adding or + * removing it from that list rather than by {@link Node#setVisible} — see {@link #FRAME_KEY}'s own + * javadoc for why. Whenever it is attached, it's the topmost child (last added), above the corner + * badges too, so it's never occluded by them. + */ + private Rectangle selectionFrame(StackPane card) { + Rectangle frame = new Rectangle(); + frame.widthProperty().bind(card.widthProperty()); + frame.heightProperty().bind(card.heightProperty()); + frame.setFill(Color.TRANSPARENT); + frame.setStroke(accentColor.get()); + frame.setStrokeWidth(BORDER_BLEED); + frame.setStrokeType(StrokeType.INSIDE); + frame.setMouseTransparent(true); + return frame; + } + + /** + * @param width this file's own thumbnail width — see {@link JustifiedRow.Entry#width()} — imposed as + * this card's exact min/pref/max width; a {@link StackPane} with nothing but a child + * capped to that same size never needs to negotiate a size of its own. + * @param height the item's fixed thumbnail height, shared by every card in the grid regardless of width. + */ + private StackPane cardFor(MediaFile file, double width, double height) { + StackPane card = new StackPane(); + card.getStyleClass().add("photo-card"); + // Lets JustifiedGalleryPane.renderedCardFor find this card back from just the file, for the + // Enter-key shortcut's own open-animation source — the same thing this card's own onMouseClicked + // handler already hands PhotoDetailPane directly on a double click. + card.setUserData(file); + card.setMinSize(width, height); + card.setPrefSize(width, height); + card.setMaxSize(width, height); + // ListCellBehavior's default row-selection gesture reacts on MOUSE_PRESSED, not MOUSE_CLICKED — + // consuming only the click (below) stops the click from bubbling up, but the press already + // reached the enclosing ListCell and selected the whole row by the time it does. Consuming the + // press here is what actually stops that. + card.setOnMousePressed(Event::consume); + card.setOnMouseClicked(event -> { + log.info("Thumbnail clicked: file={} cell={} fileName: {}", file.id(), System.identityHashCode(this), file.path().getFileName()); + onSelect.accept(file); + // A double click also opens the detail view — on top of, not instead of, the single-click + // select above, so the file is already selected by the time it opens. This card itself is + // handed along so PhotoDetailPane's open animation knows where on screen to grow from. + if (event != null && event.getClickCount() == 2) { + onOpen.accept(file, card); + } + // Consumed so the click never bubbles up to this photo-card's enclosing ListCell either — + // belt-and-braces alongside the press consumption above. Null-checked because a test + // exercises this handler directly with a null event, standing in for a real click. + if (event != null) { + event.consume(); + } + }); + return card; + } + + /** + * Stands in for a thumbnail not yet decoded into {@link #imageCache} — either because the file has no + * thumbnail on disk yet, or because it does but hasn't been requested/decoded this session yet. Static, + * not animated: a large import can have hundreds of these visible at once. Sized via {@link #fixedSize} + * the same as {@link #imageView}'s wrapper. + */ + private static StackPane pendingThumbnail(double width, double height) { + FontIcon icon = new FontIcon(Material2AL.IMAGE); + icon.getStyleClass().add("thumbnail-placeholder-icon"); + StackPane box = new StackPane(icon); + box.getStyleClass().add("thumbnail-placeholder"); + return fixedSize(box, Math.max(1, width - 2 * BORDER_BLEED), Math.max(1, height - 2 * BORDER_BLEED)); + } +} diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGridItem.java b/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGridItem.java new file mode 100644 index 0000000..56d6912 --- /dev/null +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedGridItem.java @@ -0,0 +1,78 @@ +package org.icroco.pholio.ui.view.gallery; + +import java.util.ArrayList; +import java.util.List; + +/** + * One item of {@code JustifiedGalleryPane}'s {@code rows} {@code ListView}: either a {@link JustifiedRow} + * (pure thumbnails, always the same fixed height) or a {@link HeaderRow} (one or more date labels, always a + * different fixed height of its own). {@link #itemize} is what expands {@link JustifiedRow#justify}'s output — + * where a date's own {@link JustifiedRow.DateMarker} sits embedded in whichever entry is that date's first — + * into this flat, ordered sequence, inserting a {@link HeaderRow} immediately before whichever + * {@link JustifiedRow} it labels. + * + *

This split exists to give every item in the grid's virtualised {@code ListView} one of exactly two + * fixed heights, never a third, row-dependent one — see {@code legacy.GalleryGridItem}'s own javadoc for the + * real {@code VirtualFlow} cell-recycling bug that a row-dependent third height once caused, and which this + * same split (kept identical here) avoids. + */ +sealed interface JustifiedGridItem permits JustifiedRow, JustifiedGridItem.HeaderRow { + + /** + * One or more date headers, left-to-right in the same order their dates appear in the {@link JustifiedRow} + * immediately following this item — see {@link #itemize}. Always exactly one {@code HeaderRow} per + * calendar day, immediately before that day's own first {@link JustifiedRow}: a day spanning two or more + * rows (it outgrew a single one) never gets a second header ahead of its continuation row, the same + * "marker only on the day's first-placed entry" invariant {@link JustifiedRow#justify} already guarantees. + */ + record HeaderRow(List columns) implements JustifiedGridItem { + } + + /** + * @param marker the date this column is for — {@link JustifiedRow.DateMarker#date()} is {@code null} + * exactly for the trailing "undated" group, same as {@link JustifiedRow.Entry#marker()}. + * @param spanWidth the combined width of every one of this date's own entries in the {@link JustifiedRow} + * this column's own {@link HeaderRow} precedes — not just its first entry's width, so + * the column reaches all the way to wherever the next date's own header starts, exactly + * as wide as that date's own run of cards in the row below. Computed once here by + * summing those entries' own natural {@link JustifiedRow.Entry#width()} (plus + * {@code spacing} between them) — not recomputed from scratch — which is what keeps a + * header column and its date's cards aligned across the two separate cells that now + * render them, with no further synchronisation needed at render time. + */ + record HeaderColumn(JustifiedRow.DateMarker marker, double spanWidth) { + } + + /** + * Expands {@code rows} — {@link JustifiedRow#justify}'s own output, markers and final widths intact — + * into this flat item sequence: a {@link HeaderRow} immediately before any {@link JustifiedRow} that + * contains at least one marked entry, then that {@link JustifiedRow} itself, unmodified; a markerless + * {@link JustifiedRow} (a continuation row of a date that outgrew a single row) passes through with no + * {@link HeaderRow} ahead of it. + */ + static List itemize(List rows, double spacing) { + List items = new ArrayList<>(rows.size() + rows.size() / 2); + for (JustifiedRow row : rows) { + List entries = row.entries(); + List columns = new ArrayList<>(); + for (int idx = 0; idx < entries.size(); idx++) { + JustifiedRow.DateMarker marker = entries.get(idx).marker(); + if (marker == null) { + continue; + } + double spanWidth = entries.get(idx).width(); + int end = idx + 1; + while (end < entries.size() && entries.get(end).marker() == null) { + spanWidth += spacing + entries.get(end).width(); + end++; + } + columns.add(new HeaderColumn(marker, spanWidth)); + } + if (!columns.isEmpty()) { + items.add(new HeaderRow(List.copyOf(columns))); + } + items.add(row); + } + return List.copyOf(items); + } +} diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedRow.java b/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedRow.java new file mode 100644 index 0000000..88f94c4 --- /dev/null +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/JustifiedRow.java @@ -0,0 +1,237 @@ +package org.icroco.pholio.ui.view.gallery; + +import org.icroco.pholio.domain.library.MediaFile; +import org.icroco.pholio.domain.media.GeoLocation; +import org.icroco.pholio.domain.media.MediaMetadata; +import org.jspecify.annotations.Nullable; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * One row of {@code JustifiedGalleryPane}'s grid: a fixed row height, with every {@link Entry#width} always + * exactly {@link #naturalWidthOf(MediaFile, double)} — a photo's own true aspect ratio at this row's fixed + * height, never anything wider (only narrower, in the one clamp case {@link #closeRow} documents) — so no + * entry is ever squeezed or stretched out of its own proportions. Unlike an earlier version of this class, + * the row's own gap between entries is always the plain {@code spacing}/{@code dateSpacing} {@link #justify} + * was given too, never grown to close whatever slack is left at the row's trailing edge — filling every row + * to exactly {@code availableWidth} was tried (first by stretching entry widths, distorting a landscape + * photo's aspect the wider a row needed it stretched; then by growing the gap between entries instead, never + * touching a photo's own proportions) and dropped both times: a photo's aspect ratio, and now a row's own + * rhythm of evenly-spaced gaps, matter more here than reaching the container's exact edge on every single + * row. A row simply sits at whatever its entries' own natural widths add up to, aligned left, any leftover + * width past that left empty — same as {@code legacy.GalleryRow} already did. + * + *

Packing (which files land in which row, and where a calendar day's own {@link DateMarker} goes) mirrors + * {@code legacy.GalleryRow#chunk} exactly: continuous across day boundaries, a day only ever merging into a + * row already in progress when that row is still the day's own first row and the new day's whole set of + * photos fits what's left of it. See {@link #justify}'s own javadoc for the full rule. + * + *

Deliberately free of JavaFX and {@code I18nService}: {@link #justify} takes its header text as a plain + * {@link Function}, so the grouping algorithm stays a pure function of already-sorted {@link MediaFile}s, + * cheaply unit-testable with no toolkit and no message bundle. + * + *

Implements {@link JustifiedGridItem} purely so {@code JustifiedGalleryPane}'s {@code rows} can hold both + * this and a {@link JustifiedGridItem.HeaderRow} in one {@code ListView} — nothing about this record's own + * shape, or {@link #justify}'s algorithm, changes for it. + */ +record JustifiedRow(List entries) implements JustifiedGridItem { + + /** + * @param file the photo this entry renders. + * @param width this entry's on-screen width — always exactly {@link #naturalWidthOf(MediaFile, double)}, + * its own true aspect ratio at this row's fixed height, with the sole exception of a single + * file alone in its row and already wider than {@code availableWidth} (an extreme + * panorama), which {@link #closeRow} clamps narrower — cropped further, never stretched. + * Never recomputed elsewhere. + * @param marker non-null exactly for the one entry — wherever it lands — that is the first photo of a + * new calendar day actually placed; {@code null} for every other entry, including later + * files of that same day that spill into a following row. + */ + record Entry(MediaFile file, double width, @Nullable DateMarker marker) { + } + + /** + * @param locality the resolved place ({@link GeoLocation#placeName()}) of the first photo in this day's + * group that has one, {@code null} when none does — a day's photos are assumed to share + * roughly one location, so the first hit stands in for the whole group rather than + * listing every distinct place. See {@link #localityOf}. + */ + record DateMarker(@Nullable LocalDate date, String label, @Nullable String locality) { + } + + /** + * A landscape file never renders wider than this many times the row height, no matter how wide its own + * aspect ratio — otherwise a single panorama would claim a whole row (or more) to itself. Its thumbnail + * still renders uncropped up to this ratio; only a wider original gets its edges cropped, never + * squeezed — see {@code JustifiedGridCell}'s image view sizing. + */ + static final double MAX_LANDSCAPE_ASPECT = 3.0; + + /** + * The natural (unstretched) width {@code file}'s thumbnail would render at when the row's fixed height is + * {@code height}. Portrait (aspect {@code < 1}) renders at its exact native aspect — no bands either + * side. Landscape/square renders at its native aspect too, up to {@link #MAX_LANDSCAPE_ASPECT}. A file + * whose metadata isn't resolved yet (or has no dimensions at all) falls back to a square. + */ + static double naturalWidthOf(MediaFile file, double height) { + double aspect = aspectRatioOf(file); + return aspect < 1 ? height * aspect : height * Math.min(aspect, MAX_LANDSCAPE_ASPECT); + } + + private static double aspectRatioOf(MediaFile file) { + MediaMetadata metadata = file.metadata(); + if (metadata == null || !metadata.hasDimensions()) { + return 1.0; + } + return metadata.displayWidth() / (double) metadata.displayHeight(); + } + + static @Nullable LocalDate dayOf(MediaFile file) { + return file.capturedAt() == null ? null : file.capturedAt().toLocalDate(); + } + + /** One entry of a row not yet closed: a file plus its date marker, before {@link #closeRow} settles widths. */ + private record RawEntry(MediaFile file, @Nullable DateMarker marker) { + } + + /** + * Groups {@code files} — already sorted by {@link MediaFile#byCapturedAtDesc()}, most recent first, + * undated last — into rows of as many entries as fit within {@code availableWidth} at the fixed + * {@code thumbnailHeight}, each entry kept at its own natural width (see {@link #closeRow}). Same-day + * entries are {@code spacing} apart, two merged days {@code dateSpacing} apart. A new calendar day joins + * the row already in progress + * only when BOTH: that row is the currently-open day's first row (never a later continuation row + * of a day that itself needed more than one row), and the new day's whole set of photos — + * measured at natural width, {@code dateSpacing} added as its own leading gap — still fits within what's + * left of it. Either condition failing means none of it merges (never a partial spillover), and it + * instead starts a fresh row of its own, packed greedily by natural width, splitting into further rows if + * it doesn't fit in one — the single exception being one file wider than {@code availableWidth} on its + * own, which still gets a row (clamped to {@code availableWidth}, see {@link #closeRow}) rather than + * being dropped. + * + * @param headerLabel renders a group's day (or {@code null} for the undated group) into its marker text + */ + static List justify(List files, double availableWidth, double thumbnailHeight, + double spacing, double dateSpacing, + Function<@Nullable LocalDate, String> headerLabel) { + double height = Math.max(1, thumbnailHeight); + List> rawRows = new ArrayList<>(); + List openRow = new ArrayList<>(); + double openRowWidth = 0; + // Whether openRow is the FIRST row of whichever day currently occupies it — false for a + // continuation row (a day's 2nd, 3rd, ... row after it outgrew a single row), which must never + // accept a merge from a following day even when it still has room left in it. + boolean openRowIsFirstOfDay = false; + + int i = 0; + while (i < files.size()) { + LocalDate day = dayOf(files.get(i)); + int j = i + 1; + while (j < files.size() && Objects.equals(dayOf(files.get(j)), day)) { + j++; + } + List group = files.subList(i, j); + DateMarker marker = new DateMarker(day, headerLabel.apply(day), localityOf(group)); + double groupWidth = naturalWidthOf(group, height, spacing); + + if (openRowIsFirstOfDay && !openRow.isEmpty() && openRowWidth + dateSpacing + groupWidth <= availableWidth) { + boolean first = true; + for (MediaFile file : group) { + openRow.add(new RawEntry(file, first ? marker : null)); + first = false; + } + openRowWidth += dateSpacing + groupWidth; + // The merged-in day fit whole, in this same single row — still that day's own first (and + // only) row, so it stays eligible for a further day to merge onto next. + } + else { + if (!openRow.isEmpty()) { + rawRows.add(List.copyOf(openRow)); + openRow = new ArrayList<>(); + openRowWidth = 0; + } + boolean first = true; + boolean brokeIntoAnotherRow = false; + for (MediaFile file : group) { + double width = naturalWidthOf(file, height); + double additional = openRow.isEmpty() ? width : width + spacing; + if (!openRow.isEmpty() && openRowWidth + additional > availableWidth) { + rawRows.add(List.copyOf(openRow)); + openRow = new ArrayList<>(); + openRowWidth = 0; + additional = width; + brokeIntoAnotherRow = true; + } + openRow.add(new RawEntry(file, first ? marker : null)); + openRowWidth += additional; + first = false; + } + // openRow now holds this group's own trailing row (full or partial), left open so the NEXT + // day still gets a chance to merge onto it — but only if this WAS the group's first row + // (brokeIntoAnotherRow false): a day that needed 2+ rows leaves its own last, continuation + // row behind, and that one is never merge-eligible regardless of how much room is left in it. + openRowIsFirstOfDay = !brokeIntoAnotherRow; + } + i = j; + } + if (!openRow.isEmpty()) { + rawRows.add(List.copyOf(openRow)); + } + + List rows = new ArrayList<>(rawRows.size()); + for (List rawRow : rawRows) { + rows.add(closeRow(rawRow, availableWidth, height)); + } + return rows; + } + + /** {@code group} packed onto one row by itself: every file's natural width, {@code spacing} apart. */ + private static double naturalWidthOf(List group, double height, double spacing) { + double total = 0; + for (int k = 0; k < group.size(); k++) { + total += naturalWidthOf(group.get(k), height); + if (k > 0) { + total += spacing; + } + } + return total; + } + + /** + * Turns one already-packed group of entries into a {@link JustifiedRow} whose entries keep exactly their + * own {@link #naturalWidthOf(MediaFile, double)} — see this class's own javadoc for why that must never + * change, and why nothing here tries to reach {@code availableWidth} exactly any more. The sole exception + * is a single entry alone in its row and already wider than {@code availableWidth} (an extreme panorama): + * clamped to {@code availableWidth} instead — cropped further, never stretched, to avoid a horizontal + * scrollbar. + */ + private static JustifiedRow closeRow(List group, double availableWidth, double height) { + if (group.size() == 1) { + RawEntry only = group.get(0); + double width = Math.min(naturalWidthOf(only.file(), height), availableWidth); + return new JustifiedRow(List.of(new Entry(only.file(), width, only.marker()))); + } + + List entries = new ArrayList<>(group.size()); + for (RawEntry raw : group) { + entries.add(new Entry(raw.file(), naturalWidthOf(raw.file(), height), raw.marker())); + } + return new JustifiedRow(List.copyOf(entries)); + } + + private static @Nullable String localityOf(List group) { + return group.stream() + .map(MediaFile::metadata) + .filter(Objects::nonNull) + .map(MediaMetadata::location) + .filter(Objects::nonNull) + .map(GeoLocation::placeName) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/MediaInfoPane.java b/src/main/java/org/icroco/pholio/ui/view/gallery/MediaInfoPane.java index afa976c..cd0dd47 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/MediaInfoPane.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/MediaInfoPane.java @@ -47,7 +47,7 @@ import java.util.function.Consumer; /** * {@link GalleryView}'s fifth pane: a fixed-width, non-resizable detail panel for whichever - * {@link MediaFile} is currently shown by {@link ThumbnailGalleryPane} or {@link PhotoDetailPane} — the + * {@link MediaFile} is currently shown by {@link JustifiedGalleryPane} or {@link PhotoDetailPane} — the * same selection, since {@code GalleryView.openDetail} keeps {@code ThumbnailGalleryPane.selectedFile} * mirroring the detail pane's own current photo. * @@ -66,7 +66,7 @@ import java.util.function.Consumer; * window-resize drag settling; only {@link #body}'s own {@code translateX} animates, sliding the panel's * content into (or out of) the space that single reflow already reserved (or released) — the same * {@link Timeline}+{@link EasingFX} idiom {@link PhotoDetailPane#playHero} and - * {@link ThumbnailGalleryPane#bounceGrid} use elsewhere for a slide, just aimed at this pane's own + * {@link JustifiedGalleryPane#bounceGrid} use elsewhere for a slide, just aimed at this pane's own * content rather than its layout size. Toggling {@code managed}/{@code visible} outright instead, with no * animation at all, is what made the {@code InspectorRail} this replaces flicker the grid on every * selection change (see {@code AppShellView}'s javadoc) — for a different reason (cards rebuilt from @@ -74,7 +74,7 @@ import java.util.function.Consumer; * a shared-layout dimension should change in one clean step, not dozens of small ones. * *

Not a Spring bean — {@link GalleryView} is prototype-scoped and owns this as a plain child, the same - * way it owns {@link EmptyLibraryPane}, {@link ThumbnailGalleryPane} and {@link PhotoDetailPane}. + * way it owns {@link EmptyLibraryPane}, {@link JustifiedGalleryPane} and {@link PhotoDetailPane}. */ public class MediaInfoPane extends StackPane implements Disposable { diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/PhotoDetailPane.java b/src/main/java/org/icroco/pholio/ui/view/gallery/PhotoDetailPane.java index be10d40..ae00bf3 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/PhotoDetailPane.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/PhotoDetailPane.java @@ -40,7 +40,7 @@ import java.util.Objects; import java.util.function.Consumer; /** - * {@link GalleryView}'s third pane: a single-photo viewer replacing {@link ThumbnailGalleryPane} once a + * {@link GalleryView}'s third pane: a single-photo viewer replacing {@link JustifiedGalleryPane} once a * thumbnail is opened. * *

{@link #imageView} is never cropped and never upscaled past the decoded bitmap's own resolution — diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailImageCache.java b/src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailImageCache.java index 8a43e4c..79a1cd7 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailImageCache.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailImageCache.java @@ -36,7 +36,7 @@ import java.util.function.Consumer; * *

Built on {@link AsyncLoadingCache} rather than a plain {@code Cache} with manual * {@code getIfPresent}/{@code put}: {@code get(key)} de-duplicates concurrent misses for the same key for - * free, which matters once {@link ThumbnailGalleryPane}'s scroll-driven prefetch and on-demand rendering can + * free, which matters once {@link JustifiedGalleryPane}'s scroll-driven prefetch and on-demand rendering can * race for the same neighbouring file. Decoding itself runs on {@link TaskType#THUMBNAIL} — sized for this * exact shape of work (Task C's own decode/resize/encode already runs there) — never on * {@link TaskType#BACKGROUND_SYNC}, whose single thread would serialise every decode and defeat smooth diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/filter/IMediaFileFilter.java b/src/main/java/org/icroco/pholio/ui/view/gallery/filter/IMediaFileFilter.java index dad9f21..7a96378 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/filter/IMediaFileFilter.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/filter/IMediaFileFilter.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.function.Predicate; /** - * One criterion {@link org.icroco.pholio.ui.view.gallery.ThumbnailGalleryPane} narrows its grid down by — + * One criterion {@link org.icroco.pholio.ui.view.gallery.JustifiedGalleryPane} narrows its grid down by — * see that class's {@code filters()}. Deliberately a single-method contract with no metadata (label, icon, * removability): those belong to whatever UI ends up building filters (a future filter bar), not to the * filter itself, so a new criterion is always exactly one class away — implement this interface, add an diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryGridCell.java b/src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridCell.java similarity index 99% rename from src/main/java/org/icroco/pholio/ui/view/gallery/GalleryGridCell.java rename to src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridCell.java index 3c2ce7b..aca1eed 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryGridCell.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridCell.java @@ -1,4 +1,4 @@ -package org.icroco.pholio.ui.view.gallery; +package org.icroco.pholio.ui.view.gallery.legacy; import atlantafx.base.theme.Styles; import atlantafx.base.util.Animations; @@ -36,6 +36,7 @@ import org.icroco.pholio.domain.media.MediaMetadata; import org.icroco.pholio.infra.media.EThumbnailQuality; import org.icroco.pholio.ui.control.StarRatingControl; import org.icroco.pholio.infra.i18n.I18nService; +import org.icroco.pholio.ui.view.gallery.ThumbnailImageCache; import org.jspecify.annotations.Nullable; import org.kordamp.ikonli.Ikon; import org.kordamp.ikonli.feather.Feather; diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryGridItem.java b/src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridItem.java similarity index 99% rename from src/main/java/org/icroco/pholio/ui/view/gallery/GalleryGridItem.java rename to src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridItem.java index c42218b..742c447 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryGridItem.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridItem.java @@ -1,4 +1,4 @@ -package org.icroco.pholio.ui.view.gallery; +package org.icroco.pholio.ui.view.gallery.legacy; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryRow.java b/src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryRow.java similarity index 99% rename from src/main/java/org/icroco/pholio/ui/view/gallery/GalleryRow.java rename to src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryRow.java index 5754298..68a1c19 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/GalleryRow.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryRow.java @@ -1,4 +1,4 @@ -package org.icroco.pholio.ui.view.gallery; +package org.icroco.pholio.ui.view.gallery.legacy; import org.icroco.pholio.domain.library.MediaFile; import org.icroco.pholio.domain.media.GeoLocation; diff --git a/src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailGalleryPane.java b/src/main/java/org/icroco/pholio/ui/view/gallery/legacy/ThumbnailGalleryPane.java similarity index 99% rename from src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailGalleryPane.java rename to src/main/java/org/icroco/pholio/ui/view/gallery/legacy/ThumbnailGalleryPane.java index 08f4147..ee2d923 100644 --- a/src/main/java/org/icroco/pholio/ui/view/gallery/ThumbnailGalleryPane.java +++ b/src/main/java/org/icroco/pholio/ui/view/gallery/legacy/ThumbnailGalleryPane.java @@ -1,4 +1,4 @@ -package org.icroco.pholio.ui.view.gallery; +package org.icroco.pholio.ui.view.gallery.legacy; import atlantafx.base.util.Animations; import javafx.animation.*; @@ -34,6 +34,8 @@ import org.icroco.pholio.infra.i18n.EDateHeaderFormat; import org.icroco.pholio.infra.i18n.I18nService; import org.icroco.pholio.ui.library.MediaLibraryState; import org.icroco.pholio.ui.util.EasingFX; +import org.icroco.pholio.ui.view.gallery.GalleryTimelineBar; +import org.icroco.pholio.ui.view.gallery.ThumbnailImageCache; import org.icroco.pholio.ui.view.gallery.filter.IMediaFileFilter; import org.jspecify.annotations.Nullable; diff --git a/src/main/resources/css/pholio.css b/src/main/resources/css/pholio.css index 6b0ca28..28b80d4 100644 --- a/src/main/resources/css/pholio.css +++ b/src/main/resources/css/pholio.css @@ -557,11 +557,23 @@ */ } -/* Lift only, no border — the accent border is reserved for :selected, so hover alone never shows one. */ +/* Lift only, no border — the accent border is reserved for :selected, so hover alone never shows one. + legacy.ThumbnailGalleryPane's own cards only — see .justified-grid's own override right below. */ .photo-card:hover { -fx-effect: dropshadow(gaussian, -color-accent-muted, 8, 0, 0, 0); } +/* + * JustifiedGalleryPane wants no hover effect at all for now (selection will get its own icon later, + * independent of hover) — scoped to its own ListView ("justified-grid", added alongside the shared + * "gallery-grid" class both grids use for their padding/scrollbar rules) so legacy's own hover glow above + * is untouched. Higher specificity (two ancestor classes vs. one) wins the cascade over the rule above for + * any card actually inside this grid. + */ +.justified-grid .photo-card:hover { + -fx-effect: none; +} + /* Glow, not just the border: a thin 2px ring alone can get lost against a bright or busy photo. */ .photo-card:selected, .photo-card.selected { diff --git a/src/test/java/org/icroco/pholio/ui/GuiBootstrapTest.java b/src/test/java/org/icroco/pholio/ui/GuiBootstrapTest.java new file mode 100644 index 0000000..d0d16b7 --- /dev/null +++ b/src/test/java/org/icroco/pholio/ui/GuiBootstrapTest.java @@ -0,0 +1,102 @@ +package org.icroco.pholio.ui; + +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link GuiBootstrap#configureSystemProperties()} — the two JVM system properties it sets before the + * JavaFX toolkit starts, in particular {@code prism.dirtyopts}. + * + *

Does not, and cannot usefully, reproduce the actual rendering bug {@code prism.dirtyopts=false} works + * around (see that constant's own javadoc): the defect lives in Prism's dirty-region computation feeding + * the real window's presentation step, a layer no headless unit test reaches, and a {@code Node.snapshot()} + * would likely render through a different path that never shows the bug either — a green test would not + * mean the bug is actually gone. What this guards instead is the one thing that actually broke it last + * time it will break again: this method quietly no longer setting the property, or setting the wrong + * value, on some future edit. + */ +@ExtendWith(SoftAssertionsExtension.class) +class GuiBootstrapTest { + + private static final String ENABLE_PREVIEW_PROPERTY = "javafx.enablePreview"; + private static final String DISABLE_PRISM_DIRTY_OPTS_PROPERTY = "prism.dirtyopts"; + + // Saved, not just cleared: the pom sets javafx.enablePreview=true as a real JVM system property for the + // whole Surefire fork (other test classes, e.g. AppHeaderBarTest, need it for real HeaderBar + // construction) — Surefire reuses forks across test classes by default, so leaving these cleared after + // this class's own tests run would silently break every later test class in the same fork. + private @Nullable String originalEnablePreview; + private @Nullable String originalDirtyOpts; + + @BeforeEach + void captureAndClearProperties() { + originalEnablePreview = System.getProperty(ENABLE_PREVIEW_PROPERTY); + originalDirtyOpts = System.getProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY); + System.clearProperty(ENABLE_PREVIEW_PROPERTY); + System.clearProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY); + } + + @AfterEach + void restoreProperties() { + restore(ENABLE_PREVIEW_PROPERTY, originalEnablePreview); + restore(DISABLE_PRISM_DIRTY_OPTS_PROPERTY, originalDirtyOpts); + } + + private static void restore(String property, @Nullable String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } + + @Test + void setsBothPropertiesByDefault(SoftAssertions softly) { + GuiBootstrap.configureSystemProperties(); + + softly.assertThat(System.getProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY)).isEqualTo("false"); + softly.assertThat(System.getProperty(ENABLE_PREVIEW_PROPERTY)).isEqualTo("true"); + } + + /** + * The regression this test exists for: {@code prism.dirtyopts=false} must keep being set on every + * plain launch, or the stale-selection-frame bug ({@code JustifiedGridCell}'s own navigation-selection + * frame under AtlantaFX) comes straight back. + */ + @Test + void disablesPrismDirtyRegionOptimisationByDefault() { + GuiBootstrap.configureSystemProperties(); + + assertThat(System.getProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY)).isEqualTo("false"); + } + + /** + * An explicit choice — e.g. re-enabling it to reproduce the original bug, or to re-measure its + * performance cost — must survive: this is what lets {@code -Dprism.dirtyopts=true} on the command + * line actually mean something instead of being silently overwritten. + */ + @Test + void doesNotOverrideAnExplicitPrismDirtyOptsChoice() { + System.setProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY, "true"); + + GuiBootstrap.configureSystemProperties(); + + assertThat(System.getProperty(DISABLE_PRISM_DIRTY_OPTS_PROPERTY)).isEqualTo("true"); + } + + @Test + void doesNotOverrideAnExplicitEnablePreviewChoice() { + System.setProperty(ENABLE_PREVIEW_PROPERTY, "false"); + + GuiBootstrap.configureSystemProperties(); + + assertThat(System.getProperty(ENABLE_PREVIEW_PROPERTY)).isEqualTo("false"); + } +} diff --git a/src/test/java/org/icroco/pholio/ui/view/gallery/GalleryViewTest.java b/src/test/java/org/icroco/pholio/ui/view/gallery/GalleryViewTest.java index 3788ced..b5d0c97 100644 --- a/src/test/java/org/icroco/pholio/ui/view/gallery/GalleryViewTest.java +++ b/src/test/java/org/icroco/pholio/ui/view/gallery/GalleryViewTest.java @@ -93,7 +93,7 @@ class GalleryViewTest { void showsTheEmptyLibraryPaneWhileNothingIsConfigured() { SoftAssertions.assertSoftly(softly -> { softly.assertThat(paneOf(EmptyLibraryPane.class).isVisible()).isTrue(); - softly.assertThat(paneOf(ThumbnailGalleryPane.class).isVisible()).isFalse(); + softly.assertThat(paneOf(JustifiedGalleryPane.class).isVisible()).isFalse(); }); } @@ -103,7 +103,7 @@ class GalleryViewTest { SoftAssertions.assertSoftly(softly -> { softly.assertThat(paneOf(EmptyLibraryPane.class).isVisible()).isFalse(); - softly.assertThat(paneOf(ThumbnailGalleryPane.class).isVisible()).isTrue(); + softly.assertThat(paneOf(JustifiedGalleryPane.class).isVisible()).isTrue(); }); } @@ -114,7 +114,7 @@ class GalleryViewTest { SoftAssertions.assertSoftly(softly -> { softly.assertThat(paneOf(EmptyLibraryPane.class).isVisible()).isTrue(); - softly.assertThat(paneOf(ThumbnailGalleryPane.class).isVisible()).isFalse(); + softly.assertThat(paneOf(JustifiedGalleryPane.class).isVisible()).isFalse(); }); } @@ -131,7 +131,7 @@ class GalleryViewTest { SoftAssertions.assertSoftly(softly -> { softly.assertThat(paneOf(EmptyLibraryPane.class).isVisible()).isTrue(); - softly.assertThat(paneOf(ThumbnailGalleryPane.class).isVisible()).isFalse(); + softly.assertThat(paneOf(JustifiedGalleryPane.class).isVisible()).isFalse(); softly.assertThat(scene).isNotNull(); }); } @@ -163,7 +163,7 @@ class GalleryViewTest { * The regression this guards: closing {@link PhotoDetailPane} after browsing far enough that the grid * has to scroll to bring the current file back into view must reliably land on it — not merely most of * the time. Two independent bugs hid behind an apparently-random failure here before this test could - * catch either: {@link ThumbnailGalleryPane#reveal} first read {@code VirtualFlow}'s scrollbar value + * catch either: {@link JustifiedGalleryPane#reveal} first read {@code VirtualFlow}'s scrollbar value * synchronously right after asking it to jump, which for a distant target is only ever a stale estimate * (fixed by jumping first, then settling); and once that was fixed, the "wait for the target row's cell * to actually render" retry budget was too tight to reliably survive {@link PhotoDetailPane}'s own close @@ -209,7 +209,7 @@ class GalleryViewTest { return stage; }); - ThumbnailGalleryPane galleryPane = farView.galleryPane(); + JustifiedGalleryPane galleryPane = farView.galleryPane(); PhotoDetailPane detailPane = farView.detailPane(); Node firstCard = onFxThread(() -> galleryPane.lookup(".photo-card")); @@ -259,7 +259,7 @@ class GalleryViewTest { SoftAssertions.assertSoftly(softly -> { softly.assertThat(paneOf(EmptyLibraryPane.class).visibleProperty().isBound()).isFalse(); - softly.assertThat(paneOf(ThumbnailGalleryPane.class).visibleProperty().isBound()).isFalse(); + softly.assertThat(paneOf(JustifiedGalleryPane.class).visibleProperty().isBound()).isFalse(); }); } diff --git a/src/test/java/org/icroco/pholio/ui/view/gallery/JustifiedGridItemTest.java b/src/test/java/org/icroco/pholio/ui/view/gallery/JustifiedGridItemTest.java new file mode 100644 index 0000000..1d3b2b6 --- /dev/null +++ b/src/test/java/org/icroco/pholio/ui/view/gallery/JustifiedGridItemTest.java @@ -0,0 +1,185 @@ +package org.icroco.pholio.ui.view.gallery; + +import org.assertj.core.api.SoftAssertions; +import org.icroco.pholio.domain.library.MediaFile; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +/** + * Pure algorithm tests — no JavaFX — for {@link JustifiedGridItem#itemize}. Fixtures mirror + * {@link JustifiedRowTest}'s own ({@link #HEIGHT}/{@link #SPACING}/{@link #DATE_SPACING}/ + * {@link #AVAILABLE_WIDTH}/{@link #ROW_CAPACITY}), reusing several of the same {@link JustifiedRow#justify} + * scenarios that class already covers — {@code itemize} is a pure function of {@code justify}'s own output, + * so these tests deliberately start from real {@code justify} results rather than hand-built + * {@link JustifiedRow}s. Every fixture file here has no metadata (square, no stretching candidates besides + * itself), so a row's entries keep their natural width and {@code spanWidth} assertions stay simple sums. + */ +class JustifiedGridItemTest { + + private static final LocalDate DAY_1 = LocalDate.of(2025, 6, 15); + private static final LocalDate DAY_2 = LocalDate.of(2025, 6, 14); + + private static final double HEIGHT = 100; + private static final double SPACING = 8; + private static final double DATE_SPACING = 20; + private static final int ROW_CAPACITY = 10; + private static final double AVAILABLE_WIDTH = ROW_CAPACITY * HEIGHT + (ROW_CAPACITY - 1) * SPACING; + + @Test + void emptyInputProducesNoItems() { + List items = JustifiedGridItem.itemize(List.of(), SPACING); + + assertThat(items).isEmpty(); + } + + @Test + void aSingleRowSingleDayGetsExactlyOneHeaderRowAheadOfItWithASingleColumnSpanningTheWholeRow() { + List files = List.of(file(DAY_1, 1), file(DAY_1, 2), file(DAY_1, 3)); + List rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, date -> "day"); + + List items = JustifiedGridItem.itemize(rows, SPACING); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(items).hasSize(2); + softly.assertThat(items.get(0)).isInstanceOf(JustifiedGridItem.HeaderRow.class); + softly.assertThat(items.get(1)).isSameAs(rows.get(0)); + JustifiedGridItem.HeaderRow header = (JustifiedGridItem.HeaderRow) items.get(0); + softly.assertThat(header.columns()).hasSize(1); + softly.assertThat(header.columns().get(0).marker()) + .isEqualTo(new JustifiedRow.DateMarker(DAY_1, "day", null)); + // spanWidth is the sum of this day's own (natural) entry widths + plain SPACING between them. + double expectedSpan = rows.get(0).entries().stream().mapToDouble(JustifiedRow.Entry::width).sum() + + 2 * SPACING; + softly.assertThat(header.columns().get(0).spanWidth()).isCloseTo(expectedSpan, offset(1e-6)); + }); + } + + @Test + void aDaySpanningTwoRowsGetsExactlyOneHeaderRowAheadOfTheFirstNeverAheadOfTheContinuation() { + List files = new ArrayList<>(); + for (int id = 1; id <= 12; id++) { + files.add(file(DAY_1, id)); // 12 > ROW_CAPACITY (10): a full row, then a 2-file continuation row. + } + List rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, date -> "day"); + + List items = JustifiedGridItem.itemize(rows, SPACING); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(items).hasSize(3); + softly.assertThat(items.get(0)).isInstanceOf(JustifiedGridItem.HeaderRow.class); + softly.assertThat(items.get(1)).as("day's full first row").isSameAs(rows.get(0)); + softly.assertThat(items.get(2)).as("day's continuation row, immediately after — no header ahead of it") + .isSameAs(rows.get(1)); + }); + } + + @Test + void twoDatesSharingOneRowGetOneHeaderRowWithTwoColumnsInReadingOrder() { + List day1Files = List.of(file(DAY_1, 1), file(DAY_1, 2)); + List day2Files = List.of(file(DAY_2, 3), file(DAY_2, 4), file(DAY_2, 5)); + List files = new ArrayList<>(day1Files); + files.addAll(day2Files); + List rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, + date -> date.equals(DAY_1) ? "day1" : "day2"); + + List items = JustifiedGridItem.itemize(rows, SPACING); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(items).as("both dates fit whole in one shared row: one header, one content item") + .hasSize(2); + softly.assertThat(items.get(0)).isInstanceOf(JustifiedGridItem.HeaderRow.class); + JustifiedGridItem.HeaderRow header = (JustifiedGridItem.HeaderRow) items.get(0); + softly.assertThat(header.columns()).hasSize(2); + List entries = rows.get(0).entries(); + softly.assertThat(header.columns().get(0).marker()) + .isEqualTo(new JustifiedRow.DateMarker(DAY_1, "day1", null)); + double day1Span = entries.get(0).width() + entries.get(1).width() + SPACING; + softly.assertThat(header.columns().get(0).spanWidth()).as("day1's own 2 entries only") + .isCloseTo(day1Span, offset(1e-6)); + softly.assertThat(header.columns().get(1).marker()) + .isEqualTo(new JustifiedRow.DateMarker(DAY_2, "day2", null)); + double day2Span = entries.get(2).width() + entries.get(3).width() + entries.get(4).width() + 2 * SPACING; + softly.assertThat(header.columns().get(1).spanWidth()).as("day2's own 3 entries only") + .isCloseTo(day2Span, offset(1e-6)); + softly.assertThat(items.get(1)).isSameAs(rows.get(0)); + }); + } + + @Test + void aRowWithNoMarkerAtAllGetsNoHeaderRowAheadOfIt() { + // A day never merges onto a continuation row (see JustifiedRowTest), so day1's continuation row here + // carries no marker anywhere in it — the plain "markerless row" case. + List day1Files = new ArrayList<>(); + for (int id = 1; id <= 12; id++) { + day1Files.add(file(DAY_1, id)); + } + List rows = JustifiedRow.justify(day1Files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, + date -> "day"); + JustifiedRow continuationRow = rows.get(1); + assertThat(continuationRow.entries()).extracting(JustifiedRow.Entry::marker).containsOnlyNulls(); + + List items = JustifiedGridItem.itemize(rows, SPACING); + + int continuationIndex = items.indexOf(continuationRow); + assertThat(items.get(continuationIndex - 1)).as("preceded by the day's own first row, not a header") + .isSameAs(rows.get(0)); + } + + /** + * {@code itemize} never reorders or drops a {@link JustifiedRow}, and inserts exactly one + * {@link JustifiedGridItem.HeaderRow} per row that has at least one marked entry — a fuzz sweep across the + * same day/group mix {@link JustifiedRowTest#noRowsEverExceedAvailableWidthAcrossAFuzzSweep} uses, at + * several widths. + */ + @Test + void neverReordersOrDropsARowAndInsertsExactlyOneHeaderPerMarkedRow() { + LocalDate base = LocalDate.of(2025, 6, 20); + int[] groupSizes = {7, 3, 9, 2, 5, 6}; + List files = new ArrayList<>(); + long id = 1; + for (int g = 0; g < groupSizes.length; g++) { + LocalDate day = base.minusDays(g); + for (int k = 0; k < groupSizes[g]; k++) { + files.add(file(day, id++)); + } + } + + SoftAssertions.assertSoftly(softly -> { + for (double availableWidth : new double[]{108, 400, 1000, 3840}) { + List rows = JustifiedRow.justify(files, availableWidth, HEIGHT, SPACING, DATE_SPACING, + date -> "day"); + List items = JustifiedGridItem.itemize(rows, SPACING); + + long markedRowCount = rows.stream() + .filter(row -> row.entries().stream().anyMatch(e -> e.marker() != null)) + .count(); + softly.assertThat(items).as("availableWidth=%s: one HeaderRow per marked row, rest unchanged", + availableWidth) + .hasSize((int) (rows.size() + markedRowCount)); + + List contentItemsInOrder = items.stream() + .filter(JustifiedRow.class::isInstance) + .map(JustifiedRow.class::cast) + .toList(); + softly.assertThat(contentItemsInOrder).as("availableWidth=%s: rows survive in order, unmodified", + availableWidth) + .containsExactlyElementsOf(rows); + } + }); + } + + private static MediaFile file(LocalDate day, long id) { + return new MediaFile(id, 1L, Path.of(id + ".jpg"), Instant.now(), Instant.now(), "hash" + id, + LocalDateTime.of(day, java.time.LocalTime.NOON), null, new BitSet()); + } +} diff --git a/src/test/java/org/icroco/pholio/ui/view/gallery/JustifiedRowTest.java b/src/test/java/org/icroco/pholio/ui/view/gallery/JustifiedRowTest.java new file mode 100644 index 0000000..f2d72c3 --- /dev/null +++ b/src/test/java/org/icroco/pholio/ui/view/gallery/JustifiedRowTest.java @@ -0,0 +1,393 @@ +package org.icroco.pholio.ui.view.gallery; + +import org.assertj.core.api.SoftAssertions; +import org.icroco.pholio.domain.library.MediaFile; +import org.icroco.pholio.domain.media.GeoLocation; +import org.icroco.pholio.domain.media.ImageFormat; +import org.icroco.pholio.domain.media.MediaMetadata; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +/** + * Pure algorithm tests — no JavaFX, no {@code I18nService} — for {@link JustifiedRow#justify}. Callers are + * responsible for pre-sorting via {@link MediaFile#byCapturedAtDesc()}; every fixture list here is already + * built in that order, most recent first, undated last, matching {@code justify}'s own documented contract. + * + *

Every fixture file below carries no metadata, so {@link JustifiedRow#naturalWidthOf} falls back to a + * square exactly {@link #HEIGHT} wide — the same shape a landscape/portrait-free fixture always used — which + * is what lets {@link #AVAILABLE_WIDTH} (sized to fit exactly {@link #ROW_CAPACITY} of them) stand in for a + * fixed "columns" count in the day-grouping tests below. Tests specifically about width stretching use + * {@link #landscapeFile}/{@link #portraitFile} instead. + */ +class JustifiedRowTest { + + private static final LocalDate DAY_1 = LocalDate.of(2025, 6, 15); + private static final LocalDate DAY_2 = LocalDate.of(2025, 6, 14); + + private static final double HEIGHT = 100; + private static final double SPACING = 8; + private static final double DATE_SPACING = 20; + private static final int ROW_CAPACITY = 10; + private static final double AVAILABLE_WIDTH = ROW_CAPACITY * HEIGHT + (ROW_CAPACITY - 1) * SPACING; + + @Test + void emptyInputProducesNoRowsAndNeverAsksForAHeaderLabel() { + List requested = new ArrayList<>(); + + List rows = JustifiedRow.justify(List.of(), AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, date -> { + requested.add(date); + return "label"; + }); + + assertThat(rows).isEmpty(); + assertThat(requested).isEmpty(); + } + + // --- Day grouping / merging, ported from legacy.GalleryRowTest ----------------------------------------- + + @Test + void twoDistinctDaysShareARowWhenTheSecondDaysWholeSetFitsInTheFirstDaysTrailingSpace() { + List day1Files = List.of(squareFile(DAY_1, 1), squareFile(DAY_1, 2)); + List day2Files = List.of(squareFile(DAY_2, 3), squareFile(DAY_2, 4), squareFile(DAY_2, 5)); + List files = new ArrayList<>(day1Files); + files.addAll(day2Files); + + List rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, + date -> date.equals(DAY_1) ? "day1" : "day2"); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(rows).as("day2's 3 files fit whole in day1's trailing space: one shared row").hasSize(1); + List entries = rows.get(0).entries(); + softly.assertThat(entries).extracting(JustifiedRow.Entry::file) + .containsExactly(day1Files.get(0), day1Files.get(1), + day2Files.get(0), day2Files.get(1), day2Files.get(2)); + softly.assertThat(entries.get(0).marker()).isEqualTo(new JustifiedRow.DateMarker(DAY_1, "day1", null)); + softly.assertThat(entries.get(2).marker()).as("day2's marker lands on its first entry, mid-row") + .isEqualTo(new JustifiedRow.DateMarker(DAY_2, "day2", null)); + }); + } + + @Test + void aDayNeverMergesOntoAContinuationRowEvenWithRoomToSpare() { + List day1Files = new ArrayList<>(); + for (int id = 1; id <= 12; id++) { + day1Files.add(squareFile(DAY_1, id)); // 12 > ROW_CAPACITY (10): row of 10, then a continuation row of 2 + } + List day2Files = List.of(squareFile(DAY_2, 13), squareFile(DAY_2, 14), squareFile(DAY_2, 15)); + List files = new ArrayList<>(day1Files); + files.addAll(day2Files); + + List rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, + date -> date.equals(DAY_1) ? "day1" : "day2"); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(rows).as("day1: 10 + 2 (continuation); day2: fresh row, not merged into the 2").hasSize(3); + softly.assertThat(rows.get(0).entries()).as("day1's first row, full").hasSize(10); + List continuationRow = rows.get(1).entries(); + softly.assertThat(continuationRow).as("day1's continuation row — none of day2 merged in, despite room") + .extracting(JustifiedRow.Entry::file).containsExactlyElementsOf(day1Files.subList(10, 12)); + List day2Row = rows.get(2).entries(); + softly.assertThat(day2Row).extracting(JustifiedRow.Entry::file).containsExactlyElementsOf(day2Files); + softly.assertThat(day2Row.get(0).marker()).as("day2 still gets its own marker, on its fresh row's first entry") + .isEqualTo(new JustifiedRow.DateMarker(DAY_2, "day2", null)); + }); + } + + @Test + void markerIsPlacedExactlyOnTheFirstEntryOfEachDayNeverOnLaterOnesIncludingOnAContinuationRow() { + List day1Files = new ArrayList<>(); + for (int id = 1; id <= 12; id++) { + day1Files.add(squareFile(DAY_1, id)); + } + + List rows = JustifiedRow.justify(day1Files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, date -> "day"); + + SoftAssertions.assertSoftly(softly -> { + List firstRow = rows.get(0).entries(); + softly.assertThat(firstRow.get(0).marker()).isEqualTo(new JustifiedRow.DateMarker(DAY_1, "day", null)); + softly.assertThat(firstRow.subList(1, 10)).extracting(JustifiedRow.Entry::marker).containsOnlyNulls(); + softly.assertThat(rows.get(1).entries()).as("continuation row carries no marker at all") + .extracting(JustifiedRow.Entry::marker).containsOnlyNulls(); + }); + } + + // --- Entries always stay at their own natural width, rows may not fill availableWidth ---------------------- + + /** + * A landscape (2:1) and a portrait (1:2) file at HEIGHT=100 sized to leave visible slack in a wide row — + * every entry, landscape or portrait alike, keeps exactly its own natural (aspect-correct) width, and the + * gap between them stays exactly the plain {@code SPACING} given — the row simply does not reach + * {@code availableWidth}, leftover space sitting empty past its trailing edge. + */ + @Test + void rowNeverGrowsAnEntryOrItsGapToReachAvailableWidth() { + List row1 = List.of(landscapeFile(DAY_1, 1), portraitFile(DAY_1, 2), landscapeFile(DAY_1, 3)); + // row1's natural width is 466 (200+8+50+8+200); availableWidth (550) is chosen just under what + // day2's whole group (dateSpacing 20 + square 100 = 120) would need to merge in, so row1 closes on + // its own, well short of availableWidth. + List files = new ArrayList<>(row1); + files.add(squareFile(DAY_2, 4)); + double availableWidth = 550; + + List rows = JustifiedRow.justify(files, availableWidth, HEIGHT, SPACING, DATE_SPACING, + date -> date.equals(DAY_1) ? "day1" : "day2"); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(rows).hasSizeGreaterThanOrEqualTo(1); + JustifiedRow first = rows.get(0); + List entries = first.entries(); + softly.assertThat(entries).extracting(JustifiedRow.Entry::file).containsExactlyElementsOf(row1); + + softly.assertThat(entries.get(0).width()).as("landscape entry stays at its own natural width") + .isCloseTo(JustifiedRow.naturalWidthOf(landscapeFile(DAY_1, 1), HEIGHT), offset(1e-6)); + softly.assertThat(entries.get(1).width()).as("portrait entry stays at its own natural width") + .isCloseTo(JustifiedRow.naturalWidthOf(portraitFile(DAY_1, 2), HEIGHT), offset(1e-6)); + softly.assertThat(entries.get(2).width()).as("second landscape entry stays at its own natural width too") + .isCloseTo(JustifiedRow.naturalWidthOf(landscapeFile(DAY_1, 3), HEIGHT), offset(1e-6)); + + double totalWidth = entries.stream().mapToDouble(JustifiedRow.Entry::width).sum(); + double totalSpacing = SPACING * (entries.size() - 1); // all same day here + softly.assertThat(totalWidth + totalSpacing).as("row falls short of availableWidth, left unfilled") + .isLessThan(availableWidth); + }); + } + + /** + * The whole point of this change: no entry, in any orientation, is ever assigned a width past its own + * natural (aspect-correct) one — a cropped photo may end up narrower than that (the single-oversized-file + * clamp), but never wider, across a range of rows that would previously have been width-stretched. + */ + @Test + void noEntryIsEverWiderThanItsOwnNaturalWidth() { + List files = List.of(landscapeFile(DAY_1, 1), portraitFile(DAY_1, 2), landscapeFile(DAY_1, 3), + squareFile(DAY_1, 4)); + SoftAssertions.assertSoftly(softly -> { + for (double availableWidth : new double[]{300, 500, 700, 1200, 3000}) { + List rows = JustifiedRow.justify(files, availableWidth, HEIGHT, SPACING, DATE_SPACING, + date -> "day"); + for (JustifiedRow row : rows) { + for (JustifiedRow.Entry entry : row.entries()) { + double natural = JustifiedRow.naturalWidthOf(entry.file(), HEIGHT); + softly.assertThat(entry.width()).as("availableWidth=%s file=%s", availableWidth, entry.file().id()) + .isLessThanOrEqualTo(natural + 1e-6); + } + } + } + }); + } + + @Test + void singleFileWiderThanAvailableWidthIsClampedExactlyToIt() { + MediaFile panorama = extremeLandscapeFile(DAY_1, 1, 10.0); // aspect 10, capped at MAX_LANDSCAPE_ASPECT=3 -> 300 natural + double availableWidth = 150; // narrower than even the capped natural width + + List rows = JustifiedRow.justify(List.of(panorama), availableWidth, HEIGHT, SPACING, DATE_SPACING, + date -> "day"); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(rows).hasSize(1); + List entries = rows.get(0).entries(); + softly.assertThat(entries).hasSize(1); + softly.assertThat(entries.get(0).width()).isEqualTo(availableWidth); + }); + } + + @Test + void fileWithNoMetadataIsTreatedAsSquareAndNeverStretchedEitherWayWhenAloneInItsRow() { + MediaFile noMetadata = squareFile(DAY_1, 1); // no MediaMetadata at all -> aspect 1.0 fallback + List files = new ArrayList<>(List.of(noMetadata)); + files.add(squareFile(DAY_2, 2)); + // day1's single-file row is natural width 100; kept just under what day2 (dateSpacing 20 + square + // 100 = 120) would need to merge in, so it closes as its own, non-last row of exactly one entry — + // no gap for it to grow into either, so it simply sits at its natural width. + double availableWidth = 150; + + List rows = JustifiedRow.justify(files, availableWidth, HEIGHT, SPACING, DATE_SPACING, + date -> date.equals(DAY_1) ? "day1" : "day2"); + + assertThat(rows.get(0).entries().get(0).width()).as("no-metadata file, treated as square, keeps its natural width") + .isCloseTo(HEIGHT, offset(1e-6)); + } + + @Test + void aRowNeverExceedsAvailableWidthWhetherItsNaturalWidthIsCloseToItOrFarBelow() { + // Row A: two squares at HEIGHT=100 each, availableWidth chosen so natural width is close (~90%) to it. + double nearlyFullWidth = (2 * HEIGHT + SPACING) / 0.9; + List almostFull = List.of(squareFile(DAY_1, 1), squareFile(DAY_1, 2)); + List rowA = JustifiedRow.justify(almostFull, nearlyFullWidth, HEIGHT, SPACING, DATE_SPACING, + date -> "day"); + + // Row B: a single square file in a much wider row -> natural width is far below availableWidth. + double sparseWidth = 5 * HEIGHT; + List sparse = List.of(squareFile(DAY_1, 1)); + List rowB = JustifiedRow.justify(sparse, sparseWidth, HEIGHT, SPACING, DATE_SPACING, + date -> "day"); + + SoftAssertions.assertSoftly(softly -> { + JustifiedRow.Entry a0 = rowA.get(0).entries().get(0); + JustifiedRow.Entry a1 = rowA.get(0).entries().get(1); + softly.assertThat(a0.width()).as("entries keep their natural width regardless of how close the fit is") + .isCloseTo(HEIGHT, offset(1e-6)); + softly.assertThat(a1.width()).isCloseTo(HEIGHT, offset(1e-6)); + softly.assertThat(a0.width() + a1.width() + SPACING).as("plain SPACING, never grown") + .isLessThan(nearlyFullWidth); + + JustifiedRow.Entry b0 = rowB.get(0).entries().get(0); + softly.assertThat(b0.width()).as("a lone entry in a much wider row is left at its natural width too") + .isCloseTo(HEIGHT, offset(1e-6)); + }); + } + + @Test + void dateSpacingAndPlainSpacingBothStayFixedEvenWhenTheRowFallsShortOfAvailableWidth() { + List day1Files = List.of(squareFile(DAY_1, 1)); + List day2Files = List.of(squareFile(DAY_2, 2), squareFile(DAY_2, 3)); + List files = new ArrayList<>(day1Files); + files.addAll(day2Files); + // The merged row's natural width is 328 (100 + 20 + 100 + 8 + 100), comfortably under 350. + double availableWidth = 350; + + List rows = JustifiedRow.justify(files, availableWidth, HEIGHT, SPACING, DATE_SPACING, + date -> date.equals(DAY_1) ? "day1" : "day2"); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(rows).hasSize(1); + List entries = rows.get(0).entries(); + softly.assertThat(entries).extracting(JustifiedRow.Entry::width) + .as("every entry keeps its exact natural width") + .allMatch(w -> Math.abs(w - HEIGHT) < 1e-6); + + double totalWidth = entries.stream().mapToDouble(JustifiedRow.Entry::width).sum(); + // entry1->entry2 is the date boundary (DATE_SPACING), entry2->entry3 is same-day (SPACING) — + // both exactly their plain constants, never grown. + double totalSpacing = DATE_SPACING + SPACING; + softly.assertThat(totalWidth + totalSpacing).as("328, comfortably short of availableWidth (350)") + .isCloseTo(328, offset(1e-6)) + .isLessThan(availableWidth); + }); + } + + @Test + void zeroSpacingAndPositiveSpacingBothKeepEntriesAtNaturalWidth() { + List files = List.of(landscapeFile(DAY_1, 1), landscapeFile(DAY_1, 2), landscapeFile(DAY_1, 3)); + double availableWidth = 700; + + for (double spacing : new double[]{0, 12}) { + List rows = JustifiedRow.justify(files, availableWidth, HEIGHT, spacing, DATE_SPACING, date -> "day"); + List entries = rows.get(0).entries(); + double totalWidth = entries.stream().mapToDouble(JustifiedRow.Entry::width).sum(); + double landscapeNatural = JustifiedRow.naturalWidthOf(landscapeFile(DAY_1, 1), HEIGHT); + assertThat(totalWidth).as("spacing=%s: entries stay at their natural width regardless", spacing) + .isCloseTo(3 * landscapeNatural, offset(1e-6)); + assertThat(totalWidth + spacing * (entries.size() - 1)) + .as("spacing=%s", spacing) + .isLessThanOrEqualTo(availableWidth); + } + } + + @Test + void noRowsEverExceedAvailableWidthAcrossAFuzzSweep() { + LocalDate base = LocalDate.of(2025, 6, 20); + int[] groupSizes = {7, 3, 9, 2, 5, 6}; + List files = new ArrayList<>(); + long id = 1; + for (int g = 0; g < groupSizes.length; g++) { + LocalDate day = base.minusDays(g); + for (int k = 0; k < groupSizes[g]; k++) { + files.add(id % 3 == 0 ? landscapeFile(day, id) : (id % 3 == 1 ? portraitFile(day, id) : squareFile(day, id))); + id++; + } + } + + SoftAssertions.assertSoftly(softly -> { + for (double availableWidth : new double[]{50, 108, 400, 1000, 3840}) { + for (double height : new double[]{80, 100, 160, 400}) { + for (double dateSpacing : new double[]{0, 8, 40}) { + List rows = JustifiedRow.justify(files, availableWidth, height, SPACING, dateSpacing, + date -> "day"); + List seen = new ArrayList<>(); + for (JustifiedRow row : rows) { + List entries = row.entries(); + double totalWidth = entries.stream().mapToDouble(JustifiedRow.Entry::width).sum(); + double totalSpacing = 0; + for (int idx = 1; idx < entries.size(); idx++) { + totalSpacing += entries.get(idx).marker() != null ? dateSpacing : SPACING; + } + boolean singleOversizedFile = entries.size() == 1 + && JustifiedRow.naturalWidthOf(entries.get(0).file(), height) > availableWidth; + softly.assertThat(singleOversizedFile || totalWidth + totalSpacing <= availableWidth + 1e-6) + .as("availableWidth=%s height=%s dateSpacing=%s row width=%s", + availableWidth, height, dateSpacing, totalWidth + totalSpacing) + .isTrue(); + for (JustifiedRow.Entry entry : entries) { + softly.assertThat(entry.width()) + .as("entry never wider than its own natural width: availableWidth=%s height=%s file=%s", + availableWidth, height, entry.file().id()) + .isLessThanOrEqualTo(JustifiedRow.naturalWidthOf(entry.file(), height) + 1e-6); + } + entries.forEach(e -> seen.add(e.file())); + } + softly.assertThat(seen).as("every input file appears exactly once, in order") + .containsExactlyElementsOf(files); + } + } + } + }); + } + + // --- Locality -------------------------------------------------------------------------------------------- + + @Test + void markerLocalityIsTheFirstResolvedPlaceNameInTheGroup() { + List files = List.of(squareFile(DAY_1, 1), fileAt(DAY_1, 2, "Montmartre"), fileAt(DAY_1, 3, "Le Marais")); + + List rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, date -> "day"); + + assertThat(rows.get(0).entries().get(0).marker()).isEqualTo(new JustifiedRow.DateMarker(DAY_1, "day", "Montmartre")); + } + + // --- Fixtures ---------------------------------------------------------------------------------------------- + + private static MediaFile squareFile(LocalDate day, long id) { + return new MediaFile(id, 1L, Path.of(id + ".jpg"), Instant.now(), Instant.now(), "hash" + id, + LocalDateTime.of(day, java.time.LocalTime.NOON), null, new BitSet()); + } + + private static MediaFile landscapeFile(LocalDate day, long id) { + return dimensionedFile(day, id, 200, 100, null); + } + + private static MediaFile portraitFile(LocalDate day, long id) { + return dimensionedFile(day, id, 100, 200, null); + } + + private static MediaFile extremeLandscapeFile(LocalDate day, long id, double aspect) { + return dimensionedFile(day, id, (int) (aspect * 100), 100, null); + } + + private static MediaFile fileAt(LocalDate day, long id, String placeName) { + return dimensionedFile(day, id, 100, 100, placeName); + } + + private static MediaFile dimensionedFile(LocalDate day, long id, int width, int height, @Nullable String placeName) { + MediaMetadata metadata = MediaMetadata.builder() + .format(ImageFormat.JPEG) + .width(width) + .height(height) + .location(placeName == null ? null : new GeoLocation(48.85, 2.35, null, placeName)) + .build(); + return new MediaFile(id, 1L, Path.of(id + ".jpg"), Instant.now(), Instant.now(), "hash" + id, + LocalDateTime.of(day, java.time.LocalTime.NOON), metadata, new BitSet()); + } +} diff --git a/src/test/java/org/icroco/pholio/ui/view/gallery/GalleryGridItemTest.java b/src/test/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridItemTest.java similarity index 99% rename from src/test/java/org/icroco/pholio/ui/view/gallery/GalleryGridItemTest.java rename to src/test/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridItemTest.java index c8b6a4c..70867f3 100644 --- a/src/test/java/org/icroco/pholio/ui/view/gallery/GalleryGridItemTest.java +++ b/src/test/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryGridItemTest.java @@ -1,4 +1,4 @@ -package org.icroco.pholio.ui.view.gallery; +package org.icroco.pholio.ui.view.gallery.legacy; import org.assertj.core.api.SoftAssertions; import org.icroco.pholio.domain.library.MediaFile; diff --git a/src/test/java/org/icroco/pholio/ui/view/gallery/GalleryRowTest.java b/src/test/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryRowTest.java similarity index 99% rename from src/test/java/org/icroco/pholio/ui/view/gallery/GalleryRowTest.java rename to src/test/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryRowTest.java index 93430cb..0e7fa7d 100644 --- a/src/test/java/org/icroco/pholio/ui/view/gallery/GalleryRowTest.java +++ b/src/test/java/org/icroco/pholio/ui/view/gallery/legacy/GalleryRowTest.java @@ -1,4 +1,4 @@ -package org.icroco.pholio.ui.view.gallery; +package org.icroco.pholio.ui.view.gallery.legacy; import org.assertj.core.api.SoftAssertions; import org.icroco.pholio.domain.library.MediaFile; diff --git a/src/test/java/org/icroco/pholio/ui/view/gallery/ThumbnailGalleryPaneTest.java b/src/test/java/org/icroco/pholio/ui/view/gallery/legacy/ThumbnailGalleryPaneTest.java similarity index 99% rename from src/test/java/org/icroco/pholio/ui/view/gallery/ThumbnailGalleryPaneTest.java rename to src/test/java/org/icroco/pholio/ui/view/gallery/legacy/ThumbnailGalleryPaneTest.java index af7b808..f2d7a4b 100644 --- a/src/test/java/org/icroco/pholio/ui/view/gallery/ThumbnailGalleryPaneTest.java +++ b/src/test/java/org/icroco/pholio/ui/view/gallery/legacy/ThumbnailGalleryPaneTest.java @@ -1,4 +1,4 @@ -package org.icroco.pholio.ui.view.gallery; +package org.icroco.pholio.ui.view.gallery.legacy; import javafx.css.PseudoClass; import javafx.scene.Node; @@ -23,6 +23,7 @@ import org.icroco.pholio.infra.task.TaskService; import org.icroco.pholio.ui.FxTestToolkit; import org.icroco.pholio.infra.i18n.I18nService; import org.icroco.pholio.ui.library.MediaLibraryState; +import org.icroco.pholio.ui.view.gallery.ThumbnailImageCache; import org.icroco.pholio.ui.view.gallery.filter.PathContainsFilter; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach;