feat(gallery): replace ThumbnailGalleryPane with a justified grid (JustifiedGalleryPane)

New component (JustifiedRow, JustifiedGridItem, JustifiedGridCell,
JustifiedGalleryPane) replaces ThumbnailGalleryPane as the pane
GalleryView instantiates. Single navigation selection only (0 or 1
file, driven uniformly by click and arrow-key browsing) plus a
separate, independent action-selection set (0..N files, keyed by id,
for a future grouped-action trigger not wired up yet) — the old
per-file checkbox/grouped-actions machinery is not ported.

Layout: JustifiedRow.justify groups files into fixed-height rows
(GalleryRow.chunk's own day-merging rule, ported as-is), each entry
rendered at exactly its own natural aspect-ratio width — never
stretched to fill the row, and never squeezed via a growable gap
either (both were tried and reverted: image distortion and an
uneven, growing gap between thumbnails were both worse than a row
occasionally falling short of the container's full width). The one
exception is a single file alone in its row already wider than
availableWidth (an extreme panorama), clamped narrower — cropped,
never stretched. JustifiedGridItem.itemize splits date headers into
their own fixed-height grid item, ahead of the row they label,
keeping exactly two fixed item heights for the ListView (never a
third, row-dependent one) — the same VirtualFlow cell-recycling
desync class of bug legacy.GalleryGridItem's own javadoc documents.

Selection frame: JustifiedGridCell draws navigation selection as a
plain Rectangle added to/removed from its card's children — never a
CSS pseudo-class or Node.visible — after both of those were confirmed
not to reliably repaint a virtualised cell's descendant under
AtlantaFX (see the GuiBootstrap fix below for the actual root cause).

GuiBootstrap now also sets prism.dirtyopts=false before the JavaFX
toolkit starts, working around a genuine Prism dirty-region
computation bug (confirmed against JavaFX 27): a changed pixel region
inside a virtualised ListCell descendant can go unpainted until an
unrelated pointer event forces a larger repaint elsewhere. Isolated
by ruling out every other layer in turn — reproduces regardless of
-Dprism.order (software or hardware rasteriser), disappears entirely
under JavaFX's own near-empty default stylesheet, and the pulse
logger (-Djavafx.pulseLogger=true) named the responsible phase
directly ("Dirty Opts Computed"). Cost measured on a 67 000+ file
library: average paint time per pulse rises from 7.15ms to 10.26ms,
frames missing a 16ms/60Hz budget from 1.3% to 1.8% — real but
imperceptible in manual scroll testing. GuiBootstrapTest guards the
property-setting code itself (a real visual regression test for the
underlying Prism bug is impractical in a headless unit test).

ThumbnailGalleryPane/GalleryGridCell/GalleryRow/GalleryGridItem move
to a new legacy package, package declarations only, zero functional
change (diffed against HEAD to confirm) — kept because they still
carry the grouped-actions mechanism this component doesn't yet have.
GalleryTimelineBar widens to public (used by both packages now, no
duplication, zero behaviour change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sfcVC7rYy5k8XiKCbh4Tr
This commit is contained in:
2026-09-28 22:34:20 -04:00
co-authored by Claude Sonnet 5
parent bc696f9cdc
commit b6c9ab8a3f
26 changed files with 4174 additions and 147 deletions
+263
View File
@@ -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<JustifiedGridItem>`, 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<IMediaFileFilter> filters();
public ReadOnlyObjectProperty<@Nullable MediaFile> selectedFileProperty();
public void setOnOpenRequest(BiConsumer<MediaFile, Node> 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 `<GalleryGridItem>` → `<JustifiedGridItem>`) : 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<Entry> 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<JustifiedRow> justify(List<MediaFile> 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<HeaderColumn> columns) implements JustifiedGridItem {}
record HeaderColumn(JustifiedRow.DateMarker marker, double spanWidth) {}
static List<JustifiedGridItem> itemize(List<JustifiedRow> 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<MediaFile>`, 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`
+65
View File
@@ -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.).
@@ -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").
*
* <p>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 <em>can</em>
* 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");
}
}
}
@@ -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.
*
* <p>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 {
@@ -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.
*
* <p>{@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.
* <p>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.
*
* <p>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<String> 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<String> onPreview) {
public void setOnPreview(DoubleFunction<String> 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<YearMark> marks) {
public void setYearMarks(List<YearMark> 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();
@@ -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;
*
* <p>{@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.
*
* <p>{@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.
*
* <p>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.
*
* <p>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) <em>before</em> 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<MediaFile> 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<MediaFile> 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<MediaFile> 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<Path> 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();
File diff suppressed because it is too large Load Diff
@@ -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.
*
* <p>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.
*
* <p><strong>Navigation selection is drawn as a plain Java property write, never a CSS pseudo-class.</strong>
* {@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.
*
* <p>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 <em>pull</em>
* {@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<JustifiedGridItem> {
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<EThumbnailQuality> quality;
private final DoubleSupplier thumbnailSize;
private final Consumer<MediaFile> onSelect;
private final BiConsumer<MediaFile, Node> onOpen;
private final ReadOnlyObjectProperty<@Nullable MediaFile> selectedFile;
private final Function<MediaFile, ReadOnlyBooleanProperty> thumbnailReady;
private final ThumbnailImageCache imageCache;
/**
* {@code gallery.show-missing-gps-badge} — see {@link #gpsBadge}.
*/
private final Supplier<Boolean> 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<Color> 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<MediaFile, StackPane> 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<MediaFile, Subscription> 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<EThumbnailQuality> quality, DoubleSupplier thumbnailSize,
Consumer<MediaFile> onSelect,
BiConsumer<MediaFile, Node> onOpen,
ReadOnlyObjectProperty<@Nullable MediaFile> selectedFile,
Function<MediaFile, ReadOnlyBooleanProperty> thumbnailReady,
ThumbnailImageCache imageCache,
Supplier<Boolean> showMissingGpsBadge,
Supplier<Color> 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.
*
* <p>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 <em>not</em>
* 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<MediaFile, StackPane> previousCards = reusable ? new HashMap<>(cardsByFile) : new HashMap<>();
Map<MediaFile, Subscription> 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<Node> priorPhotoOrder = List.copyOf(photosBox.getChildren());
List<JustifiedRow.Entry> entries = row.entries();
List<Node> 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<Node> current, List<Node> 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<JustifiedGridItem.HeaderColumn> columns = header.columns();
List<Node> 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 <strong>navigation</strong> 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}
* <em>and</em> 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 <strong>navigation</strong> 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<Node> 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.
*
* <p>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.
*
* <p>{@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<Node> 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<Node> overlaysFor(MediaFile file, boolean showMissingGpsBadge) {
List<Node> 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<Node> 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 <em>no</em> 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<Node> 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.
*
* <p>{@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<JustifiedGridItem> 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.
*
* <p>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.
*
* <p>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));
}
}
@@ -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.
*
* <p>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<HeaderColumn> 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<JustifiedGridItem> itemize(List<JustifiedRow> rows, double spacing) {
List<JustifiedGridItem> items = new ArrayList<>(rows.size() + rows.size() / 2);
for (JustifiedRow row : rows) {
List<JustifiedRow.Entry> entries = row.entries();
List<HeaderColumn> 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);
}
}
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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<Entry> 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 <em>first</em> row (never a later continuation row
* of a day that itself needed more than one row), and the new day's <em>whole</em> 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<JustifiedRow> justify(List<MediaFile> files, double availableWidth, double thumbnailHeight,
double spacing, double dateSpacing,
Function<@Nullable LocalDate, String> headerLabel) {
double height = Math.max(1, thumbnailHeight);
List<List<RawEntry>> rawRows = new ArrayList<>();
List<RawEntry> 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<MediaFile> 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<JustifiedRow> rows = new ArrayList<>(rawRows.size());
for (List<RawEntry> 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<MediaFile> 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<RawEntry> 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<Entry> 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<MediaFile> 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);
}
}
@@ -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.
*
* <p>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 {
@@ -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.
*
* <p>{@link #imageView} is never cropped and never upscaled past the decoded bitmap's own resolution —
@@ -36,7 +36,7 @@ import java.util.function.Consumer;
*
* <p>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
@@ -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
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+13 -1
View File
@@ -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 {
@@ -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}.
*
* <p>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");
}
}
@@ -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();
});
}
@@ -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<JustifiedGridItem> items = JustifiedGridItem.itemize(List.of(), SPACING);
assertThat(items).isEmpty();
}
@Test
void aSingleRowSingleDayGetsExactlyOneHeaderRowAheadOfItWithASingleColumnSpanningTheWholeRow() {
List<MediaFile> files = List.of(file(DAY_1, 1), file(DAY_1, 2), file(DAY_1, 3));
List<JustifiedRow> rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, date -> "day");
List<JustifiedGridItem> 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<MediaFile> 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<JustifiedRow> rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, date -> "day");
List<JustifiedGridItem> 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<MediaFile> day1Files = List.of(file(DAY_1, 1), file(DAY_1, 2));
List<MediaFile> day2Files = List.of(file(DAY_2, 3), file(DAY_2, 4), file(DAY_2, 5));
List<MediaFile> files = new ArrayList<>(day1Files);
files.addAll(day2Files);
List<JustifiedRow> rows = JustifiedRow.justify(files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING,
date -> date.equals(DAY_1) ? "day1" : "day2");
List<JustifiedGridItem> 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<JustifiedRow.Entry> 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<MediaFile> day1Files = new ArrayList<>();
for (int id = 1; id <= 12; id++) {
day1Files.add(file(DAY_1, id));
}
List<JustifiedRow> 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<JustifiedGridItem> 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<MediaFile> 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<JustifiedRow> rows = JustifiedRow.justify(files, availableWidth, HEIGHT, SPACING, DATE_SPACING,
date -> "day");
List<JustifiedGridItem> 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<JustifiedRow> 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());
}
}
@@ -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.
*
* <p>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<LocalDate> requested = new ArrayList<>();
List<JustifiedRow> 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<MediaFile> day1Files = List.of(squareFile(DAY_1, 1), squareFile(DAY_1, 2));
List<MediaFile> day2Files = List.of(squareFile(DAY_2, 3), squareFile(DAY_2, 4), squareFile(DAY_2, 5));
List<MediaFile> files = new ArrayList<>(day1Files);
files.addAll(day2Files);
List<JustifiedRow> 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<JustifiedRow.Entry> 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<MediaFile> 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<MediaFile> day2Files = List.of(squareFile(DAY_2, 13), squareFile(DAY_2, 14), squareFile(DAY_2, 15));
List<MediaFile> files = new ArrayList<>(day1Files);
files.addAll(day2Files);
List<JustifiedRow> 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<JustifiedRow.Entry> 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<JustifiedRow.Entry> 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<MediaFile> day1Files = new ArrayList<>();
for (int id = 1; id <= 12; id++) {
day1Files.add(squareFile(DAY_1, id));
}
List<JustifiedRow> rows = JustifiedRow.justify(day1Files, AVAILABLE_WIDTH, HEIGHT, SPACING, DATE_SPACING, date -> "day");
SoftAssertions.assertSoftly(softly -> {
List<JustifiedRow.Entry> 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<MediaFile> 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<MediaFile> files = new ArrayList<>(row1);
files.add(squareFile(DAY_2, 4));
double availableWidth = 550;
List<JustifiedRow> 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<JustifiedRow.Entry> 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<MediaFile> 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<JustifiedRow> 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<JustifiedRow> rows = JustifiedRow.justify(List.of(panorama), availableWidth, HEIGHT, SPACING, DATE_SPACING,
date -> "day");
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(rows).hasSize(1);
List<JustifiedRow.Entry> 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<MediaFile> 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<JustifiedRow> 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<MediaFile> almostFull = List.of(squareFile(DAY_1, 1), squareFile(DAY_1, 2));
List<JustifiedRow> 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<MediaFile> sparse = List.of(squareFile(DAY_1, 1));
List<JustifiedRow> 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<MediaFile> day1Files = List.of(squareFile(DAY_1, 1));
List<MediaFile> day2Files = List.of(squareFile(DAY_2, 2), squareFile(DAY_2, 3));
List<MediaFile> 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<JustifiedRow> 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<JustifiedRow.Entry> 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<MediaFile> 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<JustifiedRow> rows = JustifiedRow.justify(files, availableWidth, HEIGHT, spacing, DATE_SPACING, date -> "day");
List<JustifiedRow.Entry> 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<MediaFile> 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<JustifiedRow> rows = JustifiedRow.justify(files, availableWidth, height, SPACING, dateSpacing,
date -> "day");
List<MediaFile> seen = new ArrayList<>();
for (JustifiedRow row : rows) {
List<JustifiedRow.Entry> 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<MediaFile> files = List.of(squareFile(DAY_1, 1), fileAt(DAY_1, 2, "Montmartre"), fileAt(DAY_1, 3, "Le Marais"));
List<JustifiedRow> 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());
}
}
@@ -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;
@@ -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;
@@ -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;