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:
@@ -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`
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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
-1
@@ -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
-1
@@ -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;
|
||||
+3
-1
@@ -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;
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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;
|
||||
+2
-1
@@ -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;
|
||||
Reference in New Issue
Block a user