feat: first version.

This commit is contained in:
2026-07-26 18:49:07 -04:00
commit c7ec090393
66 changed files with 6170 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
### Java template
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
### Maven
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### IntelliJ IDEA
.idea/
*.iml
*.iws
*.ipr
out/
### Pholio runtime artifacts
logs/
*.mv.db
*.trace.db
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
+197
View File
@@ -0,0 +1,197 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Tu es un Architecte Logiciel Senior expert en Java (version 21+), Spring Boot et JavaFX. Ton rôle est de m'aider à concevoir et coder un logiciel desktop à forte charge dédié à la gestion de grands volumes de photos/médias.
Ce project est une application desktop de gestion de photos / images.
### 1. ARCHITECTURE, STRUCTURE DE PROJET ET DÉCOUPAGE PAR SERVICES
- Architecture sans JPMS (Pas de module-info.java) : L'application est un projet Java standard organisé en packages fonctionnels clairs.
- Clean Architecture & Séparation des Couches :
* Couche Domaine : Modèles métier (`Record` Java immuables) et contrats d'interfaces. Aucune dépendance vers JavaFX, Spring UI ou la persistance.
* Couche Services Fonctionnels :
- Métadonnées : Extraction/Écriture asynchrone (EXIF, IPTC, XMP) hors du thread graphique.
- Vignettes (Thumbnails) : Génération et chargement asynchrones avec cache RAM (`LruCache`) et cache Disque.
- Support Formats : Décodage extensible multi-formats (JPEG, PNG, WebP, RAW).
- Persistance (H2 File-based) : Base embarquée pour l'indexation, les tags, l'historique et les albums, exécutée sur un pool dédié (`DatabaseExecutor`).
- Préférences (YAML) : Sérialisation/désérialisation asynchrone des paramètres utilisateur (SnakeYAML / Jackson).
- Service i18n : Gestion des langues réactive permettant le changement de `Locale` à chaud.
* Couche Présentation : Découplée entre l'IHM JavaFX (MVVM) et la CLI PicoCLI.
### 2. INTÉGRATION SPRING BOOT & INJECTION DE DÉPENDANCES
- Cycle de vie Spring : Utilise `SpringApplicationBuilder` pour démarrer le contexte Spring au lancement.
- Injection 100% Spring : Interdiction d'instancier manuellement des composants métier ou UI. Tous les composants, ViewModels, Services et Repositories sont gérés comme des Beans Spring (`@Component`, `@Service`, `@Repository`).
- Nettoyage à la fermeture : À la fermeture de la fenêtre (`setOnCloseRequest`), ferme proprement le contexte Spring (`ConfigurableApplicationContext.close()`) pour libérer les pools de threads, connexions DB H2 et caches.
### 3. INTERFACE GRAPHIQUE (100% JAVA / INTERDICTION STRICTE DU FXML)
- Zéro FXML : N'UTILISE AUCUN FICHIER FXML, ni FXMLLoader. Toutes les vues sont construites 100% en code Java pur (composants étendant `BorderPane`, `VBox`, `StackPane`, etc.) et sont annotées `@Component` ou `@Scope("prototype")`.
- Navigation par Événements (Event Bus Spring) :
* Interdiction pour une vue ou un ViewModel de manipuler directement une autre vue pour naviguer.
* La navigation repose EXCLUSIVEMENT sur la publication d'événements Spring (ex: `publisher.publishEvent(new NavigateToViewEvent(ViewType.PHOTO_DETAIL, photoId))`).
* Un gestionnaire de vues central (`ViewSwitcher` / `StageManager`) écoute ces événements (`@EventListener`), détruit la vue courante et instancie la nouvelle depuis l'ApplicationContext Spring.
- Cycle de vie et mémoire : Chaque vue/composant doit implémenter une méthode explicite de nettoyage (`dispose()` / `cleanup()`) pour délier les listeners, détruire les bindings et libérer la mémoire lors du démontage du composant.
### 4. GESTION DE LA CONCURRENCE ET THREADING (JAVAFX)
- Thread UI (FXAT) : Le JavaFX Application Thread est réservé EXCLUSIVEMENT aux modifications du Scenegraph. Aucun I/O, décodage d'image ou requête DB ne doit s'y exécuter.
- Concurrence : Utilise `javafx.concurrent.Task<V>` et `Service<V>` pour les traitements asynchrones.
- Annulation au Scroll : Annule systématiquement les `Task` de chargement/génération de vignettes (`Task.cancel()`) lorsque les cellules de grilles ou listes virtuelles sont recyclées.
- Mises à jour UI : Les retours de threads d'arrière-plan ou les écouteurs `@EventListener` Spring modifiant l'UI doivent impérativement être rapatriés sur le FXAT via `Platform.runLater()`.
### 5. INTERNATIONALISATION RÉACTIVE (i18n)
- Source unique : Utilise `MessageSource` de Spring couplé à des fichiers `messages_*.properties`.
- Changement de langue à chaud : Encapsule les traductions dans un `I18nService` exposant des `StringBinding` réactifs liés au `Locale` courant.
- Binding dans les vues : Ne mets JAMAIS de chaînes de caractères en dur dans l'UI. Lie les propriétés textuelles directement aux bindings dynamiques :
`button.textProperty().bind(i18nService.createStringBinding("button.save"));`
- Formatage : Formatage des dates, tailles de fichiers et métadonnées respectant le `Locale` de l'utilisateur (`DateTimeFormatter`, `NumberFormat`).
### 6. CHARTE GRAPHIQUE ET THEMING : INTELLIJ NEW UI / IMMICH STYLE (ATLANTAFX & CSS)
- Direction Artistique (Look & Feel) :
* L'interface doit adopter une esthétique hybride "JetBrains New UI" et "Immich Web UI" : sobre, sombre, ultra-réactive et épurée.
* Utilise le thème AtlantaFX `NordDark` ou `PrimerDark` comme base, personnalisé avec les variables CSS globales de la palette IntelliJ/Immich.
- Palette de Couleurs & Variables CSS Globale (`theme-intellij-immich.css`) :
* Arrière-plan principal (Viewport) : `#1E1F22` (IntelliJ Main Canvas).
* Arrière-plan Panneaux (Sidebar/Header) : `#2B2D30` (IntelliJ Tool Window).
* Bordures & Séparateurs : `#393B40` (Bordures fines de 1px).
* Couleur d'Accent / Sélection : `#3574F0` (Bleu IntelliJ) ou `#6366F1` (Accent Immich).
* Cartes Photos / Thumbnails : Arrière-plan `#2B2D30`, rayon de bordure `6px`, survol avec lueur `#3574F0` ou overlay sombre `#0000004D`.
- Typographie et Icônes :
* Utilise les polices système sans-serif modernes (`Inter`, `Segoe UI`, `System`) pour l'UI, et une police à chasse fixe (`JetBrains Mono`) pour les données techniques/EXIF/IPTC dans l'inspecteur.
* Icônes vectorielles Ikonli (Pack Feather ou FontAwesome5) stylisées en blanc/gris neutre (`#A9B0B7`), devenant lumineuses au survol ou à la sélection.
- Intégration du ThemeManager :
* Un service Spring `ThemeManager` charge la feuille CSS personnalisée et permet le basculement dynamique entre les modes "IntelliJ Dark" et "IntelliJ Light / Immich Light".
### 7. INTERFACE EN LIGNE DE COMMANDE (PICOCLI / TERMINAL)
- Mode Headless / Terminal : Prends en charge l'exécution CLI autonome via PicoCLI (`picocli-spring-boot-starter` ou `IFactory` s'appuyant sur Spring).
- Découplage strict : Les classes `@Command` font partie de la couche Présentation CLI et réutilisent les mêmes Services du Domaine que l'IHM JavaFX.
- Aucune dépendance JavaFX dans la CLI : Garantis qu'aucune classe JavaFX n'est chargée lors d'une exécution CLI pour permettre le traitement batch headless sur serveur sans écran.
- Exit Codes : Gère une fermeture propre (`System.exit(code)`) après exécution d'une commande CLI sans démarrer le runtime JavaFX.
### 8. BARRES DE TITRE ET DÉCORATEURS CUSTOM (CUSTOM WINDOW DECORATIONS)
- Extension de la Zone de Titre (Client-Side Decorations) :
* Pour les fenêtres principales et secondaires, utilise `StageStyle.EXTENDED` combiné avec un composant `HeaderBar` (ou un wrapper cross-platform dédié comme `jfx-frameless`).
* Interdiction d'utiliser le `StageStyle.UNDECORATED` classique qui casse les comportements natifs de l'OS (Aero Snap Windows 11, ombres portées, redimensionnement sur les bords).
- Intégration de Composants Custom dans la Barre de Titre :
* Expose et injecte des composants applicatifs directement dans les slots du décorateur (ex: barre de recherche globale au centre `center`, sélecteur de thème ou boutons d'actions rapides à droite `right`).
* Assure le respect des conventions d'ergonomie OS : boutons de contrôle (*traffic lights*) placés à gauche sur macOS et à droite sur Windows/Linux.
### 9. GESTION DE LA POSITION ET TAILLE DES FENÊTRES (WINDOW STATE PERSISTENCE)
- Restauration et Sauvegarde de la Géométrie (Multi-Écrans) :
* Pour la fenêtre principale et chaque fenêtre secondaire, la taille (Largeur, Hauteur), la position (X, Y) et l'état maximisé (`isMaximized`) doivent être persistés dans les préférences YAML.
* Interdiction d'exposer la modification directe de ces paramètres dans la UI (pas de champs de saisie X/Y/Largeur/Hauteur dans l'écran de réglages). La modification reste possible uniquement via édition directe du fichier YAML de configuration.
- Validation Multi-Écrans à l'Ouverture :
* Lors de l'initialisation d'un `Stage`, lis les coordonnées sauvegardées et vérifie systématiquement qu'elles se situent à l'intérieur des limites visibles de l'un des écrans actuellement connectés (`Screen.getScreens()`).
* En cas de changement de configuration d'affichage (ex: écran externe déconnecté alors que la fenêtre s'y trouvait), réinitialise automatiquement la position du `Stage` au centre de l'écran principal (`Screen.getPrimary()`).
- Stratégie de Sauvegarde :
* Écoute les événements de modification de la fenêtre (`xProperty()`, `yProperty()`, `widthProperty()`, `heightProperty()`, `maximizedProperty()`) ou effectue une capture à la fermeture (`setOnCloseRequest`).
* Utilise un mécanisme de *Debounce* (temporisation asynchrone de 1s) avant d'invoquer le service de persistance YAML pour éviter les I/O excessifs.
### 10. GESTION DES DIALOGUES ET MODALES (IN-APP OVERLAYS)
- Interdiction des Dialogues Natifs JDK : Ne pas utiliser `javafx.scene.control.Dialog` ou `Alert` pour préserver le thème AtlantaFX.
- In-App Modals : Utilise AtlantaFX `ModalPane` pour incruster les fenêtres de préférences, de confirmation ou d'importation directement dans le scenegraph.
- Découplage : Les formulaires de dialogues doivent être des composants `@Component` injectés et liés réactivement aux ViewModels.
### 11. DÉTECTION ET ANALYSE D'IMAGES MULTI-PROVIDERS (PLUGGABLE IMAGE ANALYSIS)
- Architecture Pluggable (Pattern Strategy) :
* Définis une interface `ImageAnalysisProvider` implémentée par différents fournisseurs :
- Local / Edge AI : Deep Java Library (DJL) / YOLOv8 (hors-ligne par défaut).
- Cloud AI : API REST / SDK (Google Cloud Vision, AWS Rekognition, Azure, etc.).
- Remote Agent / MCP : Protocole MCP (Model Context Protocol) ou serveur d'inférence distant.
- Switch Dynamique à Chaud : Un service délégué (`ImageAnalysisService`) sélectionne le provider actif selon la préférence utilisateur sauvegardée dans le YAML (`preferences.ai.provider`).
- Concurrence et Persistence : Exécution asynchrone sur pool dédié (`ImageAnalysisExecutor`) et persistance des bounding boxes / labels dans H2.
### 12. BUILD MAVEN, PACKAGING NATIF, AOT & AUTOMATISATION GITHUB RELEASES
- Tooling Maven : Le projet est structuré autour d'un build Maven (`pom.xml`) robuste.
- Options de Packaging Natif :
* Choix 1 (Prioritaire) : Packaging et système d'auto-update via le plugin jDeploy (`ca.weblite:jdeploy-maven-plugin`).
* Choix 2 (Secondaire) : Image native distribuable via `jpackage` ou binaire compilé avec GraalVM Native Image.
- Compilation AOT (Spring Boot AOT) :
* Active l'objectif `process-aot` du `spring-boot-maven-plugin` pour pré-analyser le contexte Spring à la compilation.
* Implémente une classe `RuntimeHintsRegistrar` (annotée `@ImportRuntimeHints`) pour enregistrer la réflexion et les polices vectorielles Ikonli.
- Publication GitHub Releases & jDeploy :
* Intègre un workflow GitHub Actions (`.github/workflows/release.yml`) automatisant le build, le packaging jDeploy et la mise à jour de la section Release de GitHub lors de la publication d'un tag Git.
* Génère dans le corps de la Release un tableau contenant les icônes et les liens directs jDeploy pour chaque cible : Windows x64 (🪟), macOS Apple Silicon ARM (🍏), macOS Intel (🍏) et Linux x64 (🐧).
### 13. GESTION DE LA PHOTOTHÈQUE, IMPORTATION ET METADATAS
- Répertoire Racine : L'utilisateur définit un répertoire racine (`library.root-path`) sur disque local, SSD ou NAS.
- Module d'Importation :
* Copie physique asynchrone des photos depuis un média amovible (carte SD, appareil photo) vers la structure de la photothèque racine.
* Extraction haute performance des métadonnées (EXIF, IPTC, XMP : dates, coordonnées GPS, appareils, tags, note/rating initial) exécutée immédiatement lors de l'import et enregistrée dans la base embarquée H2 file-based.
- Historique des Imports : Conservation et affichage des $N$ derniers lots d'importation (*Import Batches*) pour permettre à l'utilisateur de retrouver ou filtrer facilement les dernières photos importées.
### 14. VISUALISATION, FLAGGAGE ET COPIE EN BATCH DE MÉTADONNÉES
- Tri et Évaluation Rapide :
* Marquage rapide (*flagging*) pour marquage à supprimer/rejeter.
* Notation rapide de 1 à 5 étoiles (*rating*) avec raccourcis clavier et binding réactif.
- Copie / Collage Sélectif de Métadonnées (Batch Metadata Copy) :
* L'utilisateur sélectionne une photo source, choisit précisément quelles métadonnées copier via une boîte de sélection (ex: uniquement les tags et le lieu, sans modifier la date).
* Application en masse (*batch*) des métadonnées sélectionnées sur un ensemble de photos cibles, avec mise à jour asynchrone en BDD H2 et écriture optionnelle dans les fichiers/sidecars.
### 15. RÉORGANISATION ET RENOMMAGE AVANCÉ (PATTERN STRATEGY)
- Vue Dédiée à la Réorganisation : Interface permettant la prévisualisation avant application du renommage, déplacement ou numérotation séquentielle de masse.
- Extensibilité des Stratégies de Nommage :
* Utilise une interface `FileRenamingStrategy` injectée via Spring.
* Permet l'ajout trivial de nouvelles règles (ex: par date EXIF, par modèle d'appareil, par localisation, numérotation auto-Incrémentée `{date}_{seq}`).
### 16. SECTION MAINTENANCE ET SANTÉ DE LA PHOTOTHÈQUE
- Module Maintenance Dédié :
* Détection de Doublons : Recherche par empreinte numérique / hash perceptuel (`pHash`), taille et métadonnées identiques.
* Régénération & Fix des Vignettes : Analyse du cache de vignettes, nettoyage des orphelins et régénération forcée des vignettes corrompues ou manquantes.
* Cohérence & Nettoyage BDD H2 : Repérage des fichiers manquants sur le disque/NAS, synchronisation des métadonnées et purge des entrées orphelines dans la base H2.
### 17. SYNCHRONISATION EN ARRIÈRE-PLAN ET WATCHDOG DU SYSTÈME DE FICHIERS
- Service de Synchronisation Périodique (Background Library Sync) :
* Un service planifié (`LibrarySynchronizationService`) analyse régulièrement la photothèque racine pour détecter :
1. Les nouvelles photos ajoutées hors de l'application (indexation & extraction rapide de métadonnées en BDD H2).
2. Les photos modifiées externes (mise à jour des métadonnées, ré-analyse IA et régénération des vignettes).
3. Les photos supprimées/déplacées hors-app (marquage/purge en BDD H2 et suppression des vignettes orphelines).
- Fréquence Réglable et Dynamique :
* La fréquence de balayage est définie dans les préférences utilisateur (`preferences.sync.interval-minutes`).
* Utilise un `ThreadPoolTaskScheduler` Spring pour permettre le changement dynamique de fréquence à chaud sans redémarrer l'application.
- Isolation et Concurrence :
* L'exécution du scan s'effectue exclusivement sur le pool de threads `BackgroundSyncExecutor`.
* La progression et les fins de synchronisation émettent des événements Spring (`LibrarySyncEvent`) pour rafraîchir silencieusement l'UI JavaFX si nécessaire (`Platform.runLater`).
### 18. GESTION DES FORMATS ET REGISTRY DE CHARGEMENT D'IMAGES (IMAGE LOADERS & EXTENSIONS)
- Architecture Pluggable des Chargeurs d'Images (Pattern Strategy / Registry) :
* Définis une interface `ImageLoader` représentant un décodeur/chargeur d'image dédié.
* Chaque implémentation de `ImageLoader` déclare explicitement :
- La liste des extensions de fichiers supportées (ex: `.jpg`, `.png`, `.webp`, `.cr2`, `.dng`).
- La méthode de chargement synchrone/asynchrone produisant une `javafx.scene.image.Image` (vignette ou pleine résolution).
- La priorité du loader (en cas de chevauchement de formats).
- Service Centralisé `ImageLoaderRegistry` :
* Injecte automatiquement la liste de tous les `ImageLoader` déclarés dans le contexte Spring.
* Maintient une carte (`Map<String, ImageLoader>`) associant chaque extension en minuscules à son loader dédié.
* Expose une méthode utilitaire `isSupportedExtension(Path path)` utilisée par les services d'importation, de scan et de réconciliation en arrière-plan.
- Fallback et Multi-Bibliothèques :
* Utilise le décodeur natif JavaFX (`Image`) pour les formats standards (JPEG, PNG, GIF, BMP).
* Intègre des wrappers dédiés pour les formats étendus (ex: TwelveMonkeys ImageIO pour WebP/TIFF, LibRaw / JNA pour les fichiers RAW).
### 19. STRUCTURE ET DISPOSITION ERGONOMIQUE DE L'INTERFACE (APPLICATION LAYOUT)
- Disposition Générale à 5 Zones (BorderPane Root) :
* Zone Haute (Header Bar / Title Bar) : Intégrée dans la zone de titre OS (`StageStyle.EXTENDED`). Contient le logo à gauche, les sélecteurs de modules principaux au centre (Importer, Trier, Réorganiser, Maintenance, Exporter) et les fenêtres/thèmes à droite.
* Zone Centre (Main Viewport) : Prends le maximum d'espace disponible (`VBox.vgrow="ALWAYS"`, `HBox.hgrow="ALWAYS"`). Héberge la galerie d'images ou la vue plein écran.
* Zone Gauche (Navigation & Actions Globale) : Panneau rétractable à deux modes :
- Mode Réduit : Rail d'icônes uniquement (Ikonli `FontIcon`).
- Mode Étendu : Arborescence de la photothèque, filtres rapides et widgets de saisie.
* Zone Droite (Inspecteur Contextuel & Tâches) : Panneau d'informations lié à l'image sélectionnée (détails EXIF/IPTC, tags, analyse IA) et vue détaillée des tâches en cours.
* Zone Basse (Barre d'État / StatusBar) : Permanente en bas de fenêtre. Contient les indicateurs de statut, la taille de la photothèque et le widget `TaskMonitor`.
- Interaction Task Monitor -> Panneau Droit :
* Le clic sur le composant `TaskMonitor` dans la StatusBar doit émettre un événement Spring (`OpenTaskManagerEvent`) pour ouvrir/sélectionner le panneau des tâches à droite.
---
FORMAT DE RÉPONSE ATTENDU :
1. Brève explication des choix d'architecture, de threading ou de pattern retenus.
2. Code source Java complet, compilable et directement prêt pour la production (pas de pseudo-code, pas de "// à implémenter").
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+190
View File
@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>io.pholio</groupId>
<artifactId>pholio</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>Pholio</name>
<description>High-volume desktop photo and media library manager</description>
<properties>
<!--
JavaFX 26 is compiled for Java 24 (class file major 68), so a JDK >= 24 is required at runtime
regardless. The local toolchain is Zulu 26.0.1.fx; drop this to 25 if a CI runner or packaging
target only has the LTS available.
-->
<java.version>26</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!--
Pinned to 26.0.1 to match the JavaFX modules bundled in the Zulu FX JDK. In classpath mode
the JDK's javafx.* modules are resolved into the boot layer and shadow these jars, so
keeping the versions identical avoids a split-version mismatch. The jars are still required
for packaging on JDKs that do not bundle JavaFX.
-->
<javafx.version>26.0.1</javafx.version>
<atlantafx.version>2.1.0</atlantafx.version>
<ikonli.version>12.4.0</ikonli.version>
<metadata-extractor.version>2.21.0</metadata-extractor.version>
<twelvemonkeys.version>3.14.0</twelvemonkeys.version>
<picocli.version>4.7.7</picocli.version>
<archunit.version>1.4.2</archunit.version>
<!-- caffeine, h2, flyway and jackson versions come from the Spring Boot 4.1.0 BOM. -->
<main.class>io.pholio.PholioApplication</main.class>
</properties>
<dependencies>
<!-- ============================ Spring ============================ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!--
spring-boot-starter-validation is deliberately absent. Hibernate Validator probes the classpath
for JavaFX at startup to register its value extractors, which loads ~25 javafx.base interfaces
into a headless CLI run and breaks the "no JavaFX in the CLI" guarantee for no benefit. Nothing
uses Bean Validation yet; if @ConfigurationProperties constraints are wanted later, add it back
knowing this side effect.
-->
<!-- ========================= Persistence ========================== -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- ===================== Preferences (YAML) ======================= -->
<!-- Jackson 3 (tools.jackson.*) — the default in Spring Boot 4.x. -->
<dependency>
<groupId>tools.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<!-- ============================ JavaFX ============================ -->
<!-- Platform classifier is resolved automatically by the openjfx POMs. -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>${javafx.version}</version>
</dependency>
<!-- javafx-swing provides SwingFXUtils, needed to bridge ImageIO <-> FX Image. -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-swing</artifactId>
<version>${javafx.version}</version>
</dependency>
<!-- ======================== UI toolkit =========================== -->
<dependency>
<groupId>io.github.mkpaz</groupId>
<artifactId>atlantafx-base</artifactId>
<version>${atlantafx.version}</version>
</dependency>
<dependency>
<groupId>org.kordamp.ikonli</groupId>
<artifactId>ikonli-javafx</artifactId>
<version>${ikonli.version}</version>
</dependency>
<dependency>
<groupId>org.kordamp.ikonli</groupId>
<artifactId>ikonli-feather-pack</artifactId>
<version>${ikonli.version}</version>
</dependency>
<!-- ==================== Imaging & metadata ======================= -->
<dependency>
<groupId>com.drewnoakes</groupId>
<artifactId>metadata-extractor</artifactId>
<version>${metadata-extractor.version}</version>
</dependency>
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>${twelvemonkeys.version}</version>
</dependency>
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-tiff</artifactId>
<version>${twelvemonkeys.version}</version>
</dependency>
<!-- =========================== Caching =========================== -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- ============================= CLI ============================= -->
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>${picocli.version}</version>
</dependency>
<!-- ============================ Tests =========================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>${main.class}</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<!--
this-escape is disabled deliberately. Every JavaFX view here extends a layout
container and configures itself in its constructor, which the check flags by
design; the pattern is idiomatic JavaFX and the alternative (two-phase init on
every view) trades a theoretical warning for real lifecycle bugs. Deprecation
warnings stay on and are suppressed case by case.
-->
<arg>-Xlint:all,-serial,-processing,-this-escape</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
+50
View File
@@ -0,0 +1,50 @@
package io.pholio;
/**
* Decides whether an invocation should start the JavaFX desktop shell or run headless on the
* terminal.
*
* <p>The rule is intentionally simple and predictable: <em>any</em> argument that is not a framework
* switch means the user is driving the application from a terminal, so the CLI takes over. Launching
* with no arguments — the desktop case — opens the window. {@code --gui} forces the window even when
* other arguments are present.
*/
public enum LaunchMode {
GUI,
CLI;
/** Forces the desktop shell even when application arguments are present. */
public static final String FORCE_GUI_FLAG = "--gui";
public static LaunchMode of(String[] args) {
if (args == null) {
return GUI;
}
boolean sawApplicationArgument = false;
for (String arg : args) {
if (FORCE_GUI_FLAG.equals(arg)) {
return GUI;
}
if (!isFrameworkArgument(arg)) {
sawApplicationArgument = true;
}
}
return sawApplicationArgument ? CLI : GUI;
}
/**
* Arguments consumed by Spring Boot or the JVM rather than by the application itself. These must
* not flip the launcher into CLI mode — {@code mvn spring-boot:run} and IDE run configurations
* routinely inject them.
*/
private static boolean isFrameworkArgument(String arg) {
return arg == null
|| arg.isBlank()
|| arg.startsWith("-D")
|| arg.startsWith("--spring.")
|| arg.startsWith("--logging.")
|| arg.startsWith("--pholio.")
|| arg.equals("--debug")
|| arg.equals("--trace");
}
}
@@ -0,0 +1,24 @@
package io.pholio;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
/**
* Application entry point.
*
* <p>This class is deliberately free of any {@code javafx.*} reference so that a headless CLI
* invocation never triggers loading of the JavaFX runtime. The two bootstrap paths live in separate
* classes ({@code io.pholio.cli.CliBootstrap} and {@code io.pholio.ui.GuiBootstrap}) which the JVM
* only resolves when the corresponding branch is taken.
*/
@SpringBootApplication
@ConfigurationPropertiesScan
public class PholioApplication {
public static void main(String[] args) {
if (LaunchMode.of(args) == LaunchMode.CLI) {
System.exit(io.pholio.cli.CliBootstrap.run(args));
}
io.pholio.ui.GuiBootstrap.launch(args);
}
}
@@ -0,0 +1,44 @@
package io.pholio;
import io.pholio.infra.support.AppDirectories;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.Banner;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
/**
* Builds the Spring context the same way for both launch modes, differing only in the active profile.
*
* <p>The H2 location cannot be expressed in {@code application.yaml} because it depends on the
* OS-specific data directory, so it is contributed here as a <em>default</em> property — lowest
* precedence, which lets {@code src/test/resources/application.yaml} and real command-line arguments
* override it.
*/
public final class PholioBootstrap {
private PholioBootstrap() {
}
public static SpringApplicationBuilder builder(String profile) {
return new SpringApplicationBuilder(PholioApplication.class)
.web(WebApplicationType.NONE)
.bannerMode(Banner.Mode.OFF)
.profiles(profile)
.properties(defaultProperties());
}
private static Map<String, Object> defaultProperties() {
String database = AppDirectories.ensureExists(AppDirectories.dataDir())
.resolve("index")
.toAbsolutePath()
.toString();
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("spring.datasource.url", "jdbc:h2:file:" + database + ";DB_CLOSE_ON_EXIT=FALSE");
properties.put("spring.datasource.driver-class-name", "org.h2.Driver");
properties.put("spring.datasource.username", "pholio");
properties.put("spring.datasource.password", "");
return properties;
}
}
+19
View File
@@ -0,0 +1,19 @@
package io.pholio;
/**
* Spring profile names.
*
* <p>The split matters: every bean that touches JavaFX is restricted to {@link #GUI} so the headless
* CLI context can start without the JavaFX runtime ever being classloaded.
*/
public final class Profiles {
/** Desktop shell. Activates all JavaFX-dependent beans. */
public static final String GUI = "gui";
/** Headless terminal execution. No JavaFX bean is eligible. */
public static final String CLI = "cli";
private Profiles() {
}
}
@@ -0,0 +1,35 @@
package io.pholio.cli;
import io.pholio.PholioBootstrap;
import io.pholio.Profiles;
import org.springframework.context.ConfigurableApplicationContext;
import picocli.CommandLine;
/**
* Entry point for the headless launch path.
*
* <p>Runs the Spring context under the {@code cli} profile, so no JavaFX-dependent bean is eligible and
* the JavaFX runtime is never loaded — which is what makes batch processing on a headless server possible.
*
* <p>The context is closed before returning so the exit code is reported after every pool has drained.
*/
public final class CliBootstrap {
private CliBootstrap() {
}
public static int run(String[] args) {
try (ConfigurableApplicationContext context = PholioBootstrap.builder(Profiles.CLI).run(args)) {
CommandLine commandLine = new CommandLine(
context.getBean(PholioCommand.class), new SpringPicocliFactory(context));
commandLine.setCaseInsensitiveEnumValuesAllowed(true);
// With no subcommand, print usage rather than silently succeeding.
commandLine.setExecutionStrategy(new CommandLine.RunLast());
if (args.length == 0) {
commandLine.usage(System.out);
return CommandLine.ExitCode.USAGE;
}
return commandLine.execute(args);
}
}
}
@@ -0,0 +1,25 @@
package io.pholio.cli;
import org.springframework.stereotype.Component;
import picocli.CommandLine.Command;
/**
* Root command. Carries {@code --help} and {@code --version} and groups the subcommands.
*
* <p>Running it with no subcommand prints usage rather than doing something surprising.
*/
@Component
@Command(
name = "pholio",
mixinStandardHelpOptions = true,
versionProvider = PholioVersionProvider.class,
description = "Pholio — high-volume photo library manager. Runs headless; "
+ "launch without arguments for the desktop interface.",
subcommands = {ScanCommand.class})
public class PholioCommand implements Runnable {
@Override
public void run() {
// No subcommand given: PicoCLI prints usage via the exit-code handling in CliBootstrap.
}
}
@@ -0,0 +1,21 @@
package io.pholio.cli;
import picocli.CommandLine.IVersionProvider;
/**
* Reports the version from the jar manifest, falling back to a development marker when running from
* classes rather than a packaged jar.
*/
public class PholioVersionProvider implements IVersionProvider {
@Override
public String[] getVersion() {
Package pkg = PholioVersionProvider.class.getPackage();
String version = pkg != null ? pkg.getImplementationVersion() : null;
return new String[] {
"pholio " + (version != null ? version : "(development build)"),
"Java " + System.getProperty("java.version") + " on "
+ System.getProperty("os.name") + " " + System.getProperty("os.arch"),
};
}
}
@@ -0,0 +1,113 @@
package io.pholio.cli;
import io.pholio.infra.preferences.Preferences;
import io.pholio.infra.preferences.PreferencesService;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.Callable;
import java.util.stream.Stream;
import org.springframework.stereotype.Component;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Walks a directory and reports what it contains, by extension.
*
* <p>The scanning phase replaces the counting here with real indexing into H2; the command's shape — a
* path argument defaulting to the configured library root, a per-extension breakdown, an exit code that
* distinguishes "nothing found" — is already what that will need.
*/
@Component
@Command(
name = "scan",
description = "Walks a directory and reports the media files found, grouped by extension.")
public class ScanCommand implements Callable<Integer> {
/** Extensions recognised until the pluggable loader registry takes over. */
private static final java.util.Set<String> KNOWN_EXTENSIONS = java.util.Set.of(
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff",
"heic", "heif", "cr2", "cr3", "nef", "arw", "dng", "orf", "rw2", "raf");
private final PreferencesService preferences;
@Parameters(
index = "0",
arity = "0..1",
paramLabel = "<directory>",
description = "Directory to scan. Defaults to the configured library root.")
private Path directory;
/**
* A plain opt-out flag rather than a {@code negatable = true} {@code --recursive}.
*
* <p>PicoCLI's negatable booleans <em>toggle</em> the field's current value rather than setting it to the
* declared default's opposite, which makes both {@code -r} and {@code --no-recursive} behave contrary to
* what their names suggest depending on how the field was initialised. A flag that is simply absent or
* present has no such ambiguity: recursion is the default, and this switches it off.
*/
@Option(names = {"-R", "--no-recursive"},
description = "Do not descend into subdirectories. Recursion is the default.")
private boolean noRecursive;
public ScanCommand(PreferencesService preferences) {
this.preferences = preferences;
}
@Override
public Integer call() throws IOException {
Path target = resolveTarget();
if (target == null) {
System.err.println("No directory given and no library root configured.");
System.err.println("Pass a path, or set it in the desktop application first.");
return 2;
}
if (!Files.isDirectory(target)) {
System.err.println("Not a directory: " + target);
return 2;
}
Map<String, Long> byExtension = new TreeMap<>();
long total = 0;
long skipped = 0;
try (Stream<Path> walk = noRecursive ? Files.list(target) : Files.walk(target)) {
for (Path path : (Iterable<Path>) walk.filter(Files::isRegularFile)::iterator) {
String extension = extensionOf(path);
if (KNOWN_EXTENSIONS.contains(extension)) {
byExtension.merge(extension, 1L, Long::sum);
total++;
} else {
skipped++;
}
}
}
System.out.println("Scanned: " + target);
if (byExtension.isEmpty()) {
System.out.println("No recognised media files found.");
return 1;
}
byExtension.forEach((extension, count) -> System.out.printf(" %-6s %d%n", extension, count));
System.out.printf("Total: %d media files (%d other files ignored)%n", total, skipped);
return 0;
}
private Path resolveTarget() {
if (directory != null) {
return directory;
}
Preferences.Library library = preferences.get().library();
return library.hasRootPath() ? Path.of(library.rootPath()) : null;
}
private static String extensionOf(Path path) {
String name = path.getFileName().toString();
int dot = name.lastIndexOf('.');
return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,34 @@
package io.pholio.cli;
import org.springframework.context.ApplicationContext;
import picocli.CommandLine;
import picocli.CommandLine.IFactory;
/**
* Lets PicoCLI instantiate commands through Spring, so {@code @Command} classes can take constructor
* dependencies on domain services.
*
* <p>Hand-written rather than pulled from {@code picocli-spring-boot-starter}: the starter predates
* Spring Boot 4 and this is fifteen lines. One fewer dependency whose auto-configuration could break on a
* framework upgrade.
*/
public class SpringPicocliFactory implements IFactory {
private final ApplicationContext context;
private final IFactory fallback = CommandLine.defaultFactory();
public SpringPicocliFactory(ApplicationContext context) {
this.context = context;
}
@Override
public <K> K create(Class<K> type) throws Exception {
try {
return context.getBean(type);
} catch (RuntimeException e) {
// PicoCLI also asks for its own infrastructure types (type converters, exception handlers)
// which are not beans; those fall through to its default instantiation.
return fallback.create(type);
}
}
}
@@ -0,0 +1,91 @@
package io.pholio.infra.config;
import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
/**
* The application's thread pools.
*
* <p>Every pool is a Spring bean so that closing the context shuts them all down — which is what the
* window's close handler relies on to release H2 connections, caches and in-flight decodes.
*
* <p>Two deliberate choices worth keeping:
* <ul>
* <li>{@code databaseExecutor} is <strong>single-threaded</strong>. H2 in file mode serialises
* writes anyway; funnelling them through one thread removes lock contention and makes batch
* import ordering deterministic.
* <li>Rejection policy is {@link ThreadPoolExecutor.AbortPolicy}, never {@code CallerRunsPolicy}.
* A caller-runs policy would execute rejected work on whichever thread submitted it — and the
* submitter is frequently the JavaFX application thread, which would freeze the UI. Callers
* handle {@link java.util.concurrent.RejectedExecutionException} instead.
* </ul>
*/
@Configuration(proxyBeanMethods = false)
public class ExecutorConfig {
public static final String DATABASE_EXECUTOR = "databaseExecutor";
public static final String THUMBNAIL_EXECUTOR = "thumbnailExecutor";
public static final String METADATA_EXECUTOR = "metadataExecutor";
public static final String IMAGE_ANALYSIS_EXECUTOR = "imageAnalysisExecutor";
public static final String BACKGROUND_SYNC_EXECUTOR = "backgroundSyncExecutor";
public static final String APPLICATION_SCHEDULER = "applicationScheduler";
@Bean(DATABASE_EXECUTOR)
ThreadPoolTaskExecutor databaseExecutor(PholioProperties properties) {
return pool("pholio-db-", 1, properties);
}
@Bean(THUMBNAIL_EXECUTOR)
ThreadPoolTaskExecutor thumbnailExecutor(PholioProperties properties) {
return pool("pholio-thumb-", properties.pools().effectiveThumbnail(), properties);
}
@Bean(METADATA_EXECUTOR)
ThreadPoolTaskExecutor metadataExecutor(PholioProperties properties) {
return pool("pholio-meta-", properties.pools().metadata(), properties);
}
@Bean(IMAGE_ANALYSIS_EXECUTOR)
ThreadPoolTaskExecutor imageAnalysisExecutor(PholioProperties properties) {
return pool("pholio-ai-", properties.pools().imageAnalysis(), properties);
}
@Bean(BACKGROUND_SYNC_EXECUTOR)
ThreadPoolTaskExecutor backgroundSyncExecutor(PholioProperties properties) {
return pool("pholio-sync-", properties.pools().backgroundSync(), properties);
}
/**
* Shared scheduler for debounced persistence and, from the library-sync phase onwards, the
* periodic photothèque scan whose interval the user can change without restarting.
*/
@Bean(APPLICATION_SCHEDULER)
ThreadPoolTaskScheduler applicationScheduler(PholioProperties properties) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(properties.pools().scheduler());
scheduler.setThreadNamePrefix("pholio-sched-");
scheduler.setDaemon(true);
scheduler.setRemoveOnCancelPolicy(true);
scheduler.setWaitForTasksToCompleteOnShutdown(false);
scheduler.setAwaitTerminationSeconds((int) properties.pools().awaitTermination().toSeconds());
return scheduler;
}
private static ThreadPoolTaskExecutor pool(String threadPrefix, int size, PholioProperties properties) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(size);
executor.setMaxPoolSize(size);
executor.setQueueCapacity(properties.pools().queueCapacity());
executor.setThreadNamePrefix(threadPrefix);
// Daemon threads guarantee the JVM can never be held alive by a pool that outlived the
// context; graceful shutdown still happens through the bean lifecycle below.
executor.setDaemon(true);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.setWaitForTasksToCompleteOnShutdown(false);
executor.setAwaitTerminationSeconds((int) properties.pools().awaitTermination().toSeconds());
return executor;
}
}
@@ -0,0 +1,49 @@
package io.pholio.infra.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Externalised tuning knobs, bound from {@code application.yaml} under the {@code pholio} prefix.
*
* <p>Distinct from user preferences: these are developer/operator settings shipped with the
* application, whereas {@code io.pholio.infra.preferences} holds what the user changes at runtime.
*/
@ConfigurationProperties(prefix = "pholio")
public record PholioProperties(
@DefaultValue Pools pools,
@DefaultValue Window window) {
/**
* Thread pool sizing. Each functional area gets its own pool so a slow NAS scan can never starve
* thumbnail decoding, and neither can ever run on the JavaFX application thread.
*/
public record Pools(
// Thumbnail decoding. 0 means "one thread per core, minus one left for the UI".
@DefaultValue("0") int thumbnail,
@DefaultValue("2") int metadata,
@DefaultValue("1") int imageAnalysis,
@DefaultValue("1") int backgroundSync,
@DefaultValue("2") int scheduler,
@DefaultValue("2000") int queueCapacity,
@DefaultValue("10s") Duration awaitTermination) {
public int effectiveThumbnail() {
return thumbnail > 0 ? thumbnail : Math.max(1, Runtime.getRuntime().availableProcessors() - 1);
}
}
/**
* Fallback window geometry, used the first time the application runs and whenever the persisted
* geometry lands off-screen.
*/
public record Window(
@DefaultValue("1440") double defaultWidth,
@DefaultValue("900") double defaultHeight,
@DefaultValue("960") double minWidth,
@DefaultValue("600") double minHeight,
// Fraction of the window that must remain visible for saved coordinates to be reused.
@DefaultValue("0.3") double minVisibleFraction) {
}
}
@@ -0,0 +1,192 @@
package io.pholio.infra.preferences;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* The complete user preference tree, serialised to {@code preferences.yaml}.
*
* <p>Immutable throughout. Every canonical constructor substitutes defaults for missing branches, so a
* hand-edited file containing only the keys the user cares about still loads — that matters because
* hand-editing is the only supported way to change window geometry.
*
* <p>Keys are written in kebab-case ({@code library.root-path}, {@code sync.interval-minutes}) to match
* the conventions used elsewhere in the configuration.
*/
public record Preferences(
Library library,
Ui ui,
Map<String, WindowGeometry> windows,
Sync sync,
Thumbnails thumbnails,
Ai ai,
Imports imports) {
public Preferences {
library = library != null ? library : Library.defaults();
ui = ui != null ? ui : Ui.defaults();
windows = windows != null ? Map.copyOf(windows) : Map.of();
sync = sync != null ? sync : Sync.defaults();
thumbnails = thumbnails != null ? thumbnails : Thumbnails.defaults();
ai = ai != null ? ai : Ai.defaults();
imports = imports != null ? imports : Imports.defaults();
}
public static Preferences defaults() {
return new Preferences(null, null, null, null, null, null, null);
}
public Preferences withLibrary(Library value) {
return new Preferences(value, ui, windows, sync, thumbnails, ai, imports);
}
public Preferences withUi(Ui value) {
return new Preferences(library, value, windows, sync, thumbnails, ai, imports);
}
public Preferences withSync(Sync value) {
return new Preferences(library, ui, windows, value, thumbnails, ai, imports);
}
public Preferences withThumbnails(Thumbnails value) {
return new Preferences(library, ui, windows, sync, value, ai, imports);
}
public Preferences withAi(Ai value) {
return new Preferences(library, ui, windows, sync, thumbnails, value, imports);
}
public Preferences withImports(Imports value) {
return new Preferences(library, ui, windows, sync, thumbnails, ai, value);
}
/** Returns a copy with {@code windowId}'s geometry replaced. */
public Preferences withWindow(String windowId, WindowGeometry geometry) {
Map<String, WindowGeometry> updated = new LinkedHashMap<>(windows);
updated.put(windowId, geometry);
return new Preferences(library, ui, updated, sync, thumbnails, ai, imports);
}
public Optional<WindowGeometry> window(String windowId) {
return Optional.ofNullable(windows.get(windowId));
}
/** Root of the photo library on local disk, SSD or NAS. */
public record Library(String rootPath) {
public static Library defaults() {
return new Library(null);
}
/**
* Named {@code hasRootPath} rather than {@code isConfigured} on purpose: Jackson treats an
* {@code is}-prefixed no-arg method as a bean property and would write a phantom
* {@code configured: true} key into the YAML alongside the real ones. The {@code has} prefix keeps
* derived state out of the serialised form, matching {@link WindowGeometry#hasPosition()}.
*/
public boolean hasRootPath() {
return rootPath != null && !rootPath.isBlank();
}
}
/**
* Appearance and panel layout.
*
* <p>The two panel flags are {@link Boolean}, not {@code boolean}, because their correct default is
* {@code true}. A primitive would read a hand-edited file that omits them as {@code false} and silently
* collapse the user's panels; boxing lets the constructor tell "absent" from "explicitly false". The
* accessors never return {@code null}. Integer fields stay primitive because they normalise a
* non-positive value to their default anyway.
*/
public record Ui(
String theme,
String locale,
int thumbnailSize,
Boolean leftPanelExpanded,
Boolean rightPanelVisible) {
public Ui {
theme = (theme == null || theme.isBlank()) ? "DARK" : theme;
locale = (locale == null || locale.isBlank()) ? "fr" : locale;
thumbnailSize = thumbnailSize > 0 ? thumbnailSize : 200;
leftPanelExpanded = leftPanelExpanded == null ? Boolean.TRUE : leftPanelExpanded;
rightPanelVisible = rightPanelVisible == null ? Boolean.TRUE : rightPanelVisible;
}
public static Ui defaults() {
return new Ui("DARK", "fr", 200, true, true);
}
public Ui withTheme(String value) {
return new Ui(value, locale, thumbnailSize, leftPanelExpanded, rightPanelVisible);
}
public Ui withLocale(String value) {
return new Ui(theme, value, thumbnailSize, leftPanelExpanded, rightPanelVisible);
}
public Ui withThumbnailSize(int value) {
return new Ui(theme, locale, value, leftPanelExpanded, rightPanelVisible);
}
public Ui withLeftPanelExpanded(boolean value) {
return new Ui(theme, locale, thumbnailSize, value, rightPanelVisible);
}
public Ui withRightPanelVisible(boolean value) {
return new Ui(theme, locale, thumbnailSize, leftPanelExpanded, value);
}
}
/**
* Background library synchronisation. The interval is re-readable at runtime.
*
* <p>{@code enabled} is boxed for the same reason as the panel flags: its default is {@code true}, so a
* file specifying only {@code interval-minutes} must not end up with sync switched off.
*/
public record Sync(Boolean enabled, int intervalMinutes) {
public Sync {
enabled = enabled == null ? Boolean.TRUE : enabled;
intervalMinutes = intervalMinutes > 0 ? intervalMinutes : 15;
}
public static Sync defaults() {
return new Sync(true, 15);
}
}
public record Thumbnails(int ramCacheMb, int diskCacheMb) {
public Thumbnails {
ramCacheMb = ramCacheMb > 0 ? ramCacheMb : 512;
diskCacheMb = diskCacheMb > 0 ? diskCacheMb : 8192;
}
public static Thumbnails defaults() {
return new Thumbnails(512, 8192);
}
}
/** Selects the active {@code ImageAnalysisProvider}; switchable without restarting. */
public record Ai(String provider, String endpoint) {
public Ai {
provider = (provider == null || provider.isBlank()) ? "NONE" : provider;
}
public static Ai defaults() {
return new Ai("NONE", null);
}
}
public record Imports(String namingPattern, boolean moveInsteadOfCopy, int historySize) {
public Imports {
namingPattern = (namingPattern == null || namingPattern.isBlank())
? "{yyyy}/{MM}/{yyyyMMdd}_{seq}"
: namingPattern;
historySize = historySize > 0 ? historySize : 20;
}
public static Imports defaults() {
return new Imports("{yyyy}/{MM}/{yyyyMMdd}_{seq}", false, 20);
}
}
}
@@ -0,0 +1,12 @@
package io.pholio.infra.preferences;
/**
* Published on the Spring event bus after preferences change in memory, before the debounced write
* reaches disk.
*
* <p>Beware: this is published on whichever thread called
* {@link PreferencesService#update(java.util.function.UnaryOperator)}. UI listeners must marshal onto
* the JavaFX application thread with {@code Platform.runLater}.
*/
public record PreferencesChangedEvent(Preferences previous, Preferences current) {
}
@@ -0,0 +1,25 @@
package io.pholio.infra.preferences;
import java.util.function.UnaryOperator;
/**
* Reads and mutates the user preference tree.
*
* <p>Mutation is always whole-tree and copy-on-write: callers hand in a function from the current
* snapshot to the next one. That keeps concurrent updates from different pools (a window drag on the
* FX thread, a sync interval change from a background task) from clobbering each other's branches.
*/
public interface PreferencesService {
/** The current snapshot. Never {@code null}. */
Preferences get();
/**
* Applies {@code mutator} atomically, publishes {@link PreferencesChangedEvent} and schedules a
* debounced write to disk. Cheap enough to call from a property listener.
*/
Preferences update(UnaryOperator<Preferences> mutator);
/** Writes any pending change to disk immediately, on the calling thread. */
void flush();
}
@@ -0,0 +1,38 @@
package io.pholio.infra.preferences;
/**
* Persisted position, size and maximised state of a single window.
*
* <p>Coordinates are boxed so that "never positioned" is representable: a first run, or a window the
* user has not yet moved, has {@code null} x/y and gets centred on the primary screen.
*
* <p>Per the project rules this is edited only by hand in {@code preferences.yaml} — the settings UI
* deliberately exposes no geometry fields.
*/
public record WindowGeometry(
Double x,
Double y,
Double width,
Double height,
boolean maximized) {
public static WindowGeometry unset() {
return new WindowGeometry(null, null, null, null, false);
}
public boolean hasPosition() {
return isUsable(x) && isUsable(y);
}
public boolean hasSize() {
return isUsable(width) && width > 0 && isUsable(height) && height > 0;
}
public WindowGeometry withMaximized(boolean value) {
return new WindowGeometry(x, y, width, height, value);
}
private static boolean isUsable(Double value) {
return value != null && !value.isNaN() && !value.isInfinite();
}
}
@@ -0,0 +1,156 @@
package io.pholio.infra.preferences;
import io.pholio.infra.support.AppDirectories;
import io.pholio.infra.support.Debouncer;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.dataformat.yaml.YAMLMapper;
import tools.jackson.dataformat.yaml.YAMLWriteFeature;
/**
* YAML-backed {@link PreferencesService}.
*
* <p>Three properties this implementation guarantees:
* <ul>
* <li><strong>Never loses the file.</strong> Writes go to a sibling temp file and are moved into
* place atomically, so a crash mid-write cannot leave a truncated {@code preferences.yaml}.
* <li><strong>Never blocks the caller.</strong> Disk writes are debounced onto a daemon thread;
* a window drag emitting hundreds of updates produces one write.
* <li><strong>Never fails to start.</strong> A malformed or partial file is logged and replaced by
* defaults rather than aborting application startup — the user's photos matter more than their
* window position.
* </ul>
*/
@Service
public class YamlPreferencesService implements PreferencesService {
private static final Logger log = LoggerFactory.getLogger(YamlPreferencesService.class);
private static final String FILE_NAME = "preferences.yaml";
private static final Duration WRITE_DELAY = Duration.ofSeconds(1);
private final ApplicationEventPublisher events;
private final ObjectMapper yaml;
private final Path file;
private final Debouncer writeDebouncer = new Debouncer("pholio-prefs-writer", WRITE_DELAY);
private final AtomicReference<Preferences> current = new AtomicReference<>();
// Explicit: the class has a second, package-private constructor, so Spring needs to be told which one
// to inject through.
@Autowired
public YamlPreferencesService(ApplicationEventPublisher events) {
this(events, AppDirectories.configDir().resolve(FILE_NAME));
}
/** Test seam: allows pointing at a temporary file without touching the real user profile. */
YamlPreferencesService(ApplicationEventPublisher events, Path file) {
this.events = events;
this.file = file;
this.yaml = YAMLMapper.builder()
.propertyNamingStrategy(PropertyNamingStrategies.KEBAB_CASE)
.disable(YAMLWriteFeature.WRITE_DOC_START_MARKER)
.enable(YAMLWriteFeature.MINIMIZE_QUOTES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
// Tolerate keys this version does not know about: a preferences file written by a
// newer build must not break an older one, and vice versa.
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
// Tolerate keys the file omits. Users hand-edit this file and will write only the branch
// they care about; without this, a partially specified section fails to map onto a record
// with primitive components. Records whose correct default is not the Java zero-value box
// the component and normalise it in their constructor.
.disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.build();
this.current.set(load());
}
@Override
public Preferences get() {
return current.get();
}
@Override
public Preferences update(UnaryOperator<Preferences> mutator) {
Preferences[] previous = new Preferences[1];
Preferences updated = current.updateAndGet(existing -> {
previous[0] = existing;
Preferences next = mutator.apply(existing);
return next != null ? next : existing;
});
if (updated.equals(previous[0])) {
return updated;
}
events.publishEvent(new PreferencesChangedEvent(previous[0], updated));
writeDebouncer.call(() -> write(updated));
return updated;
}
@Override
public void flush() {
writeDebouncer.flush();
}
@PreDestroy
void shutdown() {
// close() flushes any pending write before stopping the scheduler, so preferences changed in
// the final second before quitting still reach disk.
writeDebouncer.close();
}
private Preferences load() {
if (!Files.isRegularFile(file)) {
log.info("No preferences file at {}, starting from defaults", file);
return Preferences.defaults();
}
try {
Preferences loaded = yaml.readValue(file.toFile(), Preferences.class);
log.info("Loaded preferences from {}", file);
return loaded != null ? loaded : Preferences.defaults();
} catch (RuntimeException e) {
log.warn("Preferences file {} is unreadable, falling back to defaults", file, e);
return Preferences.defaults();
}
}
private void write(Preferences preferences) {
try {
AppDirectories.ensureExists(file.getParent());
Path temp = Files.createTempFile(file.getParent(), FILE_NAME, ".tmp");
try (OutputStream out = Files.newOutputStream(temp)) {
yaml.writeValue(out, preferences);
}
move(temp, file);
log.debug("Wrote preferences to {}", file);
} catch (IOException e) {
throw new UncheckedIOException("Unable to write preferences to " + file, e);
}
}
private static void move(Path temp, Path target) throws IOException {
try {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
// Some network filesystems (a NAS-hosted profile) cannot move atomically; a plain replace
// is still better than writing in place.
log.debug("Atomic move unavailable for {}, falling back to replace", target);
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -0,0 +1,111 @@
package io.pholio.infra.support;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
/**
* Resolves the OS-conventional locations for configuration, data and cache files.
*
* <p>Setting the {@value #HOME_OVERRIDE_PROPERTY} system property redirects every directory beneath a
* single root, which is what tests use to stay out of the real user profile.
*/
public final class AppDirectories {
/** System property redirecting all application directories under one root. */
public static final String HOME_OVERRIDE_PROPERTY = "pholio.home";
private static final String DISPLAY_NAME = "Pholio";
private static final String UNIX_NAME = "pholio";
private AppDirectories() {
}
/** User preferences ({@code preferences.yaml}). */
public static Path configDir() {
Path override = overrideRoot();
if (override != null) {
return override.resolve("config");
}
return switch (currentOs()) {
case MACOS -> userHome().resolve("Library/Application Support").resolve(DISPLAY_NAME);
case WINDOWS -> windowsBase("APPDATA", "AppData/Roaming").resolve(DISPLAY_NAME);
case LINUX -> xdgBase("XDG_CONFIG_HOME", ".config").resolve(UNIX_NAME);
};
}
/** Durable application data: the H2 index, sidecar journals, rollback logs. */
public static Path dataDir() {
Path override = overrideRoot();
if (override != null) {
return override.resolve("data");
}
return switch (currentOs()) {
case MACOS -> configDir();
case WINDOWS -> windowsBase("LOCALAPPDATA", "AppData/Local").resolve(DISPLAY_NAME).resolve("data");
case LINUX -> xdgBase("XDG_DATA_HOME", ".local/share").resolve(UNIX_NAME);
};
}
/** Regenerable data: the thumbnail disk cache. Safe for the user to delete at any time. */
public static Path cacheDir() {
Path override = overrideRoot();
if (override != null) {
return override.resolve("cache");
}
return switch (currentOs()) {
case MACOS -> userHome().resolve("Library/Caches").resolve(DISPLAY_NAME);
case WINDOWS -> windowsBase("LOCALAPPDATA", "AppData/Local").resolve(DISPLAY_NAME).resolve("cache");
case LINUX -> xdgBase("XDG_CACHE_HOME", ".cache").resolve(UNIX_NAME);
};
}
/** Creates {@code dir} and its parents if needed, returning it for chaining. */
public static Path ensureExists(Path dir) {
try {
Files.createDirectories(dir);
return dir;
} catch (IOException e) {
throw new UncheckedIOException("Unable to create directory " + dir, e);
}
}
static Os currentOs() {
String name = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (name.contains("mac") || name.contains("darwin")) {
return Os.MACOS;
}
if (name.contains("win")) {
return Os.WINDOWS;
}
return Os.LINUX;
}
private static Path overrideRoot() {
String override = System.getProperty(HOME_OVERRIDE_PROPERTY);
return (override == null || override.isBlank()) ? null : Paths.get(override);
}
private static Path userHome() {
return Paths.get(System.getProperty("user.home", "."));
}
private static Path windowsBase(String envVar, String fallbackRelativeToHome) {
String env = System.getenv(envVar);
return (env == null || env.isBlank()) ? userHome().resolve(fallbackRelativeToHome) : Paths.get(env);
}
private static Path xdgBase(String envVar, String fallbackRelativeToHome) {
String env = System.getenv(envVar);
return (env == null || env.isBlank()) ? userHome().resolve(fallbackRelativeToHome) : Paths.get(env);
}
enum Os {
MACOS,
WINDOWS,
LINUX
}
}
@@ -0,0 +1,104 @@
package io.pholio.infra.support;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Coalesces a burst of calls into a single delayed execution.
*
* <p>Used wherever a stream of cheap in-memory changes must not turn into a stream of expensive I/O:
* dragging a window emits hundreds of coordinate changes that should produce exactly one YAML write.
*
* <p>Thread-safe. The scheduling thread is a daemon, and {@link #close()} runs any pending action
* before shutting down so nothing queued is silently dropped at exit.
*/
public final class Debouncer implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(Debouncer.class);
private final ScheduledExecutorService scheduler;
private final Duration delay;
private final AtomicReference<Pending> pending = new AtomicReference<>();
public Debouncer(String threadName, Duration delay) {
this.delay = delay;
ThreadFactory factory = runnable -> {
Thread thread = new Thread(runnable, threadName);
thread.setDaemon(true);
return thread;
};
this.scheduler = Executors.newSingleThreadScheduledExecutor(factory);
}
/**
* Schedules {@code action}, replacing any action still waiting to run. Only the most recently
* submitted action executes.
*/
public void call(Runnable action) {
Pending next = new Pending(action);
Pending previous = pending.getAndSet(next);
if (previous != null) {
previous.cancel();
}
if (scheduler.isShutdown()) {
return;
}
next.future = scheduler.schedule(() -> run(next), delay.toMillis(), TimeUnit.MILLISECONDS);
}
/** Runs the pending action immediately on the calling thread, if there is one. */
public void flush() {
Pending current = pending.getAndSet(null);
if (current != null) {
current.cancel();
execute(current.action);
}
}
@Override
public void close() {
flush();
scheduler.shutdownNow();
}
private void run(Pending scheduled) {
// Only run if we are still the newest submission; a concurrent call() supersedes us.
if (pending.compareAndSet(scheduled, null)) {
execute(scheduled.action);
}
}
private void execute(Runnable action) {
try {
action.run();
} catch (RuntimeException e) {
// Swallowing here is deliberate: a debounced action runs detached from any caller that
// could handle the failure, and killing the scheduler thread would silently stop all
// future debounced work.
log.error("Debounced action failed", e);
}
}
private static final class Pending {
private final Runnable action;
private volatile ScheduledFuture<?> future;
private Pending(Runnable action) {
this.action = action;
}
void cancel() {
ScheduledFuture<?> snapshot = future;
if (snapshot != null) {
snapshot.cancel(false);
}
}
}
}
@@ -0,0 +1,35 @@
package io.pholio.ui;
import javafx.application.Application;
/**
* Entry point for the desktop launch path.
*
* <p>Separate from {@code PholioApplication} so that the {@code javafx.*} references live in a class the
* JVM only resolves when the GUI branch is actually taken. That is what keeps a headless CLI run free of
* the JavaFX runtime.
*/
public final class GuiBootstrap {
/**
* Opts into JavaFX preview APIs. Required for {@link javafx.scene.layout.HeaderBar} and
* {@link javafx.stage.StageStyle#EXTENDED}, which the custom title bar is built on: without it, simply
* constructing a {@code HeaderBar} throws {@code RuntimeException: HeaderBar is a preview feature}.
*
* <p>Set here rather than as a JVM argument so it holds however the application is started — Maven,
* IDE run configuration, {@code jpackage} bundle or jDeploy launcher. The property is read when the
* toolkit initialises, which happens inside {@link Application#launch}, so setting it immediately
* before is in time.
*/
private static final String ENABLE_PREVIEW_PROPERTY = "javafx.enablePreview";
private GuiBootstrap() {
}
public static void launch(String[] args) {
if (System.getProperty(ENABLE_PREVIEW_PROPERTY) == null) {
System.setProperty(ENABLE_PREVIEW_PROPERTY, "true");
}
Application.launch(PholioFxApplication.class, args);
}
}
@@ -0,0 +1,57 @@
package io.pholio.ui;
import io.pholio.PholioBootstrap;
import io.pholio.Profiles;
import javafx.application.Application;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ConfigurableApplicationContext;
/**
* Bridges the JavaFX and Spring lifecycles.
*
* <p>The context is built in {@link #init()}, which JavaFX calls on the launcher thread after the toolkit
* has started but before {@link #start(Stage)}. Two consequences worth knowing:
* <ul>
* <li>Bean construction happens off the FX thread. That is legal for JavaFX nodes as long as they are
* not yet attached to a live scene, which is why views may build their node tree in their
* constructor — but it is <em>not</em> legal to create a {@code Scene} or touch a {@code Stage}
* there. Those belong to {@link StageManager}, called from {@code start}.
* <li>A failure while wiring the context surfaces before any window appears, so a misconfiguration is a
* clean startup failure rather than a half-drawn window.
* </ul>
*/
public class PholioFxApplication extends Application {
private static final Logger log = LoggerFactory.getLogger(PholioFxApplication.class);
private ConfigurableApplicationContext context;
@Override
public void init() {
String[] args = getParameters().getRaw().toArray(String[]::new);
context = PholioBootstrap.builder(Profiles.GUI).run(args);
log.info("Spring context ready");
}
@Override
public void start(Stage primaryStage) {
context.getBean(StageManager.class).showMainWindow(primaryStage);
}
/**
* Called by JavaFX once the last window is gone.
*
* <p>Closing an already-closed context is a no-op, so this is safe alongside the explicit close in
* {@code StageManager}'s shutdown path — and it still covers the case where the platform exits without
* routing through it.
*/
@Override
public void stop() {
if (context != null && context.isActive()) {
context.close();
}
log.info("JavaFX runtime stopped");
}
}
@@ -0,0 +1,126 @@
package io.pholio.ui;
import io.pholio.ui.common.UiComponent;
import io.pholio.ui.event.ViewType;
import io.pholio.ui.i18n.I18nService;
import io.pholio.ui.shell.AppShellView;
import io.pholio.ui.shell.ModalService;
import io.pholio.ui.task.TaskManager;
import io.pholio.ui.theme.ThemeManager;
import io.pholio.ui.window.WindowStateManager;
import javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.layout.HeaderBar;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ConfigurableApplicationContext;
/**
* Assembles and shows application windows, and owns the shutdown sequence.
*
* <p>The window root is a {@link StackPane} holding the shell with the modal overlay above it, which is
* what lets dialogs appear inside the window instead of as separate OS windows.
*
* <p>Closing the main window closes the Spring context. That single call is what releases the five thread
* pools, the H2 connection pool and the caches — so shutdown correctness is a consequence of the bean
* lifecycle rather than a list of things to remember here.
*/
@UiComponent
public class StageManager {
private static final Logger log = LoggerFactory.getLogger(StageManager.class);
private final ConfigurableApplicationContext context;
private final AppShellView shell;
private final ModalService modalService;
private final ThemeManager themeManager;
private final WindowStateManager windowStateManager;
private final ViewSwitcher viewSwitcher;
private final TaskManager taskManager;
private final I18nService i18n;
public StageManager(
ConfigurableApplicationContext context,
AppShellView shell,
ModalService modalService,
ThemeManager themeManager,
WindowStateManager windowStateManager,
ViewSwitcher viewSwitcher,
TaskManager taskManager,
I18nService i18n) {
this.context = context;
this.shell = shell;
this.modalService = modalService;
this.themeManager = themeManager;
this.windowStateManager = windowStateManager;
this.viewSwitcher = viewSwitcher;
this.taskManager = taskManager;
this.i18n = i18n;
}
/** Builds and shows the primary window. Must be called on the JavaFX application thread. */
@SuppressWarnings("deprecation") // StageStyle.EXTENDED and HeaderBar are JavaFX preview APIs.
public void showMainWindow(Stage stage) {
themeManager.initialise();
StackPane root = new StackPane(shell, modalService.pane());
Scene scene = new Scene(root);
// EXTENDED keeps the OS decorations' behaviour (Aero Snap, shadows, edge resize, native traffic
// lights on the platform's own side) while letting the HeaderBar draw into the title area. It must
// be set before the stage is shown.
//
// It is a conditional feature: where the platform cannot provide client-side decorations the stage
// stays DECORATED and the header simply renders as a toolbar below the OS title bar. Degrading is
// far better than the alternative of an UNDECORATED window with hand-rolled decorations, which
// breaks snapping and resizing.
if (Platform.isSupported(ConditionalFeature.EXTENDED_WINDOW)) {
stage.initStyle(StageStyle.EXTENDED);
HeaderBar.setPrefButtonHeight(stage, 36);
} else {
log.warn("Extended windows are not supported on this platform; "
+ "falling back to standard OS decorations");
}
stage.titleProperty().bind(i18n.binding("app.title"));
stage.setScene(scene);
themeManager.register(scene);
windowStateManager.register(WindowStateManager.MAIN_WINDOW, stage);
stage.setOnCloseRequest(event -> {
// Consume so the window stays up until shutdown finishes; closing the context stops the FX
// toolkit through PholioFxApplication#stop.
event.consume();
shutdown(stage);
});
viewSwitcher.navigate(ViewType.GALLERY);
stage.show();
log.info("Main window shown");
}
/**
* Ordered shutdown: stop in-flight work, capture geometry, tear down the scenegraph, then close the
* context.
*
* <p>Order matters. Geometry must be captured while the stage still has real bounds, and the context
* must close last because everything above depends on beans it owns.
*/
private void shutdown(Stage stage) {
log.info("Shutting down");
taskManager.cancelAll();
windowStateManager.captureNow(WindowStateManager.MAIN_WINDOW);
if (modalService.isShowing()) {
modalService.hide();
}
themeManager.unregister(stage.getScene());
stage.titleProperty().unbind();
shell.dispose();
stage.hide();
context.close();
}
}
@@ -0,0 +1,119 @@
package io.pholio.ui;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.FxUtils;
import io.pholio.ui.common.UiComponent;
import io.pholio.ui.event.NavigateToViewEvent;
import io.pholio.ui.event.NavigationAware;
import io.pholio.ui.event.ViewType;
import io.pholio.ui.view.GalleryView;
import io.pholio.ui.view.ModulePlaceholderView;
import java.util.EnumMap;
import java.util.Map;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
/**
* Owns the main viewport and is the only component that mounts or unmounts a view.
*
* <p>Navigation is one-way: something publishes a {@link NavigateToViewEvent}, this listener disposes the
* outgoing view, resolves the incoming one from the Spring context, and swaps it in. No view ever holds a
* reference to another view, which is what stops the scenegraph from becoming a graph of mutual listeners.
*
* <p>Views are prototype-scoped, so Spring does not call their destruction callbacks; {@link #mount} calls
* {@link Disposable#dispose()} on the outgoing view explicitly. Missing that is the classic way a JavaFX
* application leaks a view per navigation.
*/
@UiComponent
public class ViewSwitcher {
private static final Logger log = LoggerFactory.getLogger(ViewSwitcher.class);
private final ApplicationContext context;
private final StackPane container = new StackPane();
private final Map<ViewType, Class<? extends Node>> viewTypes = new EnumMap<>(ViewType.class);
private final ObjectProperty<ViewType> currentType = new SimpleObjectProperty<>(this, "currentType");
private Node currentView;
public ViewSwitcher(ApplicationContext context) {
this.context = context;
container.getStyleClass().add("main-viewport");
VBox.setVgrow(container, Priority.ALWAYS);
// Destination -> view bean. Modules not yet implemented share a placeholder that renders their
// name, so the header's module selector is fully navigable from the first build.
viewTypes.put(ViewType.GALLERY, GalleryView.class);
viewTypes.put(ViewType.IMPORT, ModulePlaceholderView.class);
viewTypes.put(ViewType.CULL, ModulePlaceholderView.class);
viewTypes.put(ViewType.REORGANIZE, ModulePlaceholderView.class);
viewTypes.put(ViewType.MAINTENANCE, ModulePlaceholderView.class);
viewTypes.put(ViewType.EXPORT, ModulePlaceholderView.class);
}
/** The node to place in the centre of the shell. */
public Pane container() {
return container;
}
public ReadOnlyObjectProperty<ViewType> currentTypeProperty() {
return currentType;
}
/** Convenience for the initial navigation at startup. */
public void navigate(ViewType target) {
onNavigate(new NavigateToViewEvent(target));
}
/**
* Handles a navigation request. Spring may deliver events on any thread, so the scenegraph mutation is
* marshalled onto the JavaFX application thread.
*/
@EventListener
public void onNavigate(NavigateToViewEvent event) {
FxUtils.onFxThread(() -> mount(event));
}
private void mount(NavigateToViewEvent event) {
Class<? extends Node> viewType = viewTypes.get(event.target());
if (viewType == null) {
log.error("No view registered for destination {}", event.target());
return;
}
disposeCurrent();
Node view = context.getBean(viewType);
if (view instanceof NavigationAware aware) {
aware.onNavigate(event);
}
currentView = view;
currentType.set(event.target());
container.getChildren().setAll(view);
log.debug("Mounted {} for destination {}", viewType.getSimpleName(), event.target());
}
private void disposeCurrent() {
container.getChildren().clear();
if (currentView instanceof Disposable disposable) {
disposable.dispose();
}
currentView = null;
}
/** Disposes whatever is mounted. Called when the window closes. */
public void dispose() {
FxUtils.onFxThread(this::disposeCurrent);
}
}
@@ -0,0 +1,14 @@
package io.pholio.ui.common;
/**
* A UI component that must release resources when it leaves the scenegraph.
*
* <p>Long-lived listeners and bindings are the main way a desktop application leaks: a view that binds
* to a service-held observable stays reachable from that service forever. Implementations unbind here.
*
* <p>Implementations must tolerate being called more than once.
*/
public interface Disposable {
void dispose();
}
@@ -0,0 +1,40 @@
package io.pholio.ui.common;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.layout.Region;
/** Small helpers used across views. */
public final class FxUtils {
private FxUtils() {
}
/**
* Runs {@code action} on the JavaFX application thread, immediately if already there.
*
* <p>Spring {@code @EventListener} methods and background {@code Task} callbacks both land on
* arbitrary threads; routing every scenegraph mutation through here is what keeps them legal.
*/
public static void onFxThread(Runnable action) {
if (Platform.isFxApplicationThread()) {
action.run();
} else {
Platform.runLater(action);
}
}
/** Applies one or more style classes and returns the node, for use in fluent construction. */
public static <T extends Node> T styled(T node, String... styleClasses) {
node.getStyleClass().addAll(styleClasses);
return node;
}
/** Pins a region to an exact square, used for icon buttons and rail items. */
public static <T extends Region> T fixedSize(T region, double size) {
region.setMinSize(size, size);
region.setPrefSize(size, size);
region.setMaxSize(size, size);
return region;
}
}
@@ -0,0 +1,51 @@
package io.pholio.ui.common;
import java.util.ArrayDeque;
import java.util.Deque;
import javafx.util.Subscription;
/**
* Collects JavaFX {@link Subscription}s so a view can release all of them in one call.
*
* <p>This replaces manual listener bookkeeping. Instead of keeping a field per listener so it can be
* removed later, a view writes:
*
* <pre>{@code
* scope.add(i18n.localeProperty().subscribe(locale -> refresh()));
* scope.add(prefs.thumbnailSizeProperty().subscribe(this::resizeCells));
* }</pre>
*
* <p>and its {@code dispose()} calls {@link #close()}. Nothing can be forgotten because there is only
* ever one thing to remember.
*
* <p>Not thread-safe: expected to be used from the JavaFX application thread only.
*/
public final class SubscriptionScope implements AutoCloseable {
private final Deque<Subscription> subscriptions = new ArrayDeque<>();
/** Registers {@code subscription} and returns it, so calls can be chained or inspected. */
public Subscription add(Subscription subscription) {
if (subscription != null) {
subscriptions.push(subscription);
}
return subscription;
}
/** Registers an arbitrary teardown action as if it were a subscription. */
public void addTeardown(Runnable action) {
add(action::run);
}
public int size() {
return subscriptions.size();
}
/** Unsubscribes everything, in reverse registration order. Safe to call repeatedly. */
@Override
public void close() {
while (!subscriptions.isEmpty()) {
subscriptions.pop().unsubscribe();
}
}
}
@@ -0,0 +1,29 @@
package io.pholio.ui.common;
import io.pholio.Profiles;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Component;
/**
* A singleton Spring bean that touches JavaFX.
*
* <p>The {@link Profile} restriction is the mechanism that keeps the headless CLI free of JavaFX: these
* beans are never eligible outside the {@code gui} profile, so their classes are never loaded. Component
* scanning reads bytecode without classloading, so merely having them on the classpath costs nothing.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
@Profile(Profiles.GUI)
public @interface UiComponent {
@AliasFor(annotation = Component.class)
String value() default "";
}
@@ -0,0 +1,33 @@
package io.pholio.ui.common;
import io.pholio.Profiles;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.context.annotation.Scope;
import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Component;
/**
* A navigable view: prototype-scoped, so every navigation gets a fresh instance whose predecessor was
* disposed.
*
* <p>Views are expected to implement {@link Disposable} and release their bindings there — a
* prototype-scoped bean receives no destruction callback from Spring, so the {@code ViewSwitcher} is
* what calls {@code dispose()}.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Profile(Profiles.GUI)
public @interface UiView {
@AliasFor(annotation = Component.class)
String value() default "";
}
@@ -0,0 +1,23 @@
package io.pholio.ui.event;
/**
* Request to show another view.
*
* <p>The only sanctioned way to navigate. A view or view-model must never reach for another view
* directly; it publishes this event and the {@code ViewSwitcher} decides what to tear down and what to
* build:
*
* <pre>{@code
* publisher.publishEvent(new NavigateToViewEvent(ViewType.CULL, photoId));
* }</pre>
*
* @param target destination
* @param payload optional context for the destination (a photo id, a filter, an import batch); may be
* {@code null}
*/
public record NavigateToViewEvent(ViewType target, Object payload) {
public NavigateToViewEvent(ViewType target) {
this(target, null);
}
}
@@ -0,0 +1,12 @@
package io.pholio.ui.event;
/**
* Implemented by views that need the navigation payload.
*
* <p>Called by the {@code ViewSwitcher} on the JavaFX application thread, after the view bean is created
* and before it is attached to the scenegraph.
*/
public interface NavigationAware {
void onNavigate(NavigateToViewEvent event);
}
@@ -0,0 +1,10 @@
package io.pholio.ui.event;
/**
* Request to reveal the running-tasks panel.
*
* <p>Published by the status bar's task monitor. The inspector panel on the right listens and selects its
* Tasks tab — neither component holds a reference to the other.
*/
public record OpenTaskManagerEvent() {
}
@@ -0,0 +1,31 @@
package io.pholio.ui.event;
/**
* The navigable destinations of the application.
*
* <p>Kept free of view classes on purpose: the enum lives in the event package, which every layer may
* depend on, while the mapping from destination to concrete view bean lives in the {@code ViewSwitcher}.
* That keeps a navigation request from having to know what will render it.
*/
public enum ViewType {
/** Thumbnail grid over the whole library — the default destination. */
GALLERY("nav.library"),
IMPORT("module.import"),
CULL("module.cull"),
REORGANIZE("module.reorganize"),
MAINTENANCE("module.maintenance"),
EXPORT("module.export");
private final String messageKey;
ViewType(String messageKey) {
this.messageKey = messageKey;
}
/** i18n key for this destination's label. */
public String messageKey() {
return messageKey;
}
}
@@ -0,0 +1,148 @@
package io.pholio.ui.i18n;
import io.pholio.infra.preferences.PreferencesService;
import io.pholio.ui.common.UiComponent;
import java.text.NumberFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.List;
import java.util.Locale;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;
/**
* Single source of translated text, with live language switching.
*
* <p>The trick that makes hot switching work is that views never receive a {@code String}; they bind to
* a {@link StringBinding} whose only dependency is {@link #localeProperty()}. Changing the locale
* invalidates every such binding at once, and JavaFX re-evaluates them on the next pulse:
*
* <pre>{@code
* button.textProperty().bind(i18n.binding("action.save"));
* }</pre>
*
* <p>Consequently no view should ever hold a hard-coded user-visible string.
*/
@UiComponent
public class I18nService {
private static final Logger log = LoggerFactory.getLogger(I18nService.class);
/** Languages shipped as {@code messages_*.properties}. */
public static final List<Locale> SUPPORTED_LOCALES = List.of(Locale.FRENCH, Locale.ENGLISH);
private static final long KIB = 1024L;
private static final String[] SIZE_UNITS = {"B", "KB", "MB", "GB", "TB", "PB"};
private final MessageSource messageSource;
private final PreferencesService preferences;
private final ObjectProperty<Locale> locale = new SimpleObjectProperty<>(this, "locale");
public I18nService(MessageSource messageSource, PreferencesService preferences) {
this.messageSource = messageSource;
this.preferences = preferences;
this.locale.set(resolveInitialLocale(preferences.get().ui().locale()));
}
public ReadOnlyObjectProperty<Locale> localeProperty() {
return locale;
}
public Locale locale() {
return locale.get();
}
/**
* Switches language and persists the choice. Every existing binding updates; no restart, no view
* rebuild.
*/
public void setLocale(Locale newLocale) {
Locale resolved = supportedOrDefault(newLocale);
if (resolved.equals(locale.get())) {
return;
}
log.info("Switching UI locale to {}", resolved.getLanguage());
locale.set(resolved);
preferences.update(current -> current.withUi(current.ui().withLocale(resolved.getLanguage())));
}
/**
* A text binding for {@code code}, re-evaluated whenever the locale changes.
*
* <p>Bind it directly to a node property; the node keeps the binding alive and drops it when
* unbound or garbage-collected.
*/
public StringBinding binding(String code, Object... args) {
return Bindings.createStringBinding(() -> get(code, args), locale);
}
/** One-shot lookup, for log lines and non-reactive contexts. */
public String get(String code, Object... args) {
try {
return messageSource.getMessage(code, args, locale.get());
} catch (NoSuchMessageException e) {
// Showing the key beats showing an empty label: a missing translation stays visible and
// reportable instead of silently blanking part of the UI.
log.warn("Missing translation for key '{}' ({})", code, locale.get());
return "!" + code + "!";
}
}
/** Localised human-readable file size, e.g. {@code 4,2 MB} in French. */
public String formatFileSize(long bytes) {
if (bytes < KIB) {
return bytes + " " + SIZE_UNITS[0];
}
double value = bytes;
int unit = 0;
while (value >= KIB && unit < SIZE_UNITS.length - 1) {
value /= KIB;
unit++;
}
NumberFormat format = NumberFormat.getNumberInstance(locale.get());
format.setMaximumFractionDigits(value < 10 ? 1 : 0);
return format.format(value) + " " + SIZE_UNITS[unit];
}
public String formatNumber(Number number) {
return NumberFormat.getNumberInstance(locale.get()).format(number);
}
public String formatDateTime(LocalDateTime dateTime) {
return dateTime == null
? ""
: DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
.withLocale(locale.get())
.format(dateTime);
}
/** Display name of {@code candidate} in its own language, for the language selector. */
public String displayName(Locale candidate) {
return candidate.getDisplayLanguage(candidate);
}
private Locale resolveInitialLocale(String persisted) {
if (persisted != null && !persisted.isBlank()) {
return supportedOrDefault(Locale.forLanguageTag(persisted));
}
return supportedOrDefault(Locale.getDefault());
}
private static Locale supportedOrDefault(Locale candidate) {
if (candidate == null) {
return SUPPORTED_LOCALES.getFirst();
}
return SUPPORTED_LOCALES.stream()
.filter(supported -> supported.getLanguage().equals(candidate.getLanguage()))
.findFirst()
.orElse(SUPPORTED_LOCALES.getFirst());
}
}
@@ -0,0 +1,173 @@
package io.pholio.ui.shell;
import io.pholio.ui.ViewSwitcher;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.SubscriptionScope;
import io.pholio.ui.common.UiComponent;
import io.pholio.ui.event.NavigateToViewEvent;
import io.pholio.ui.event.ViewType;
import io.pholio.ui.i18n.I18nService;
import io.pholio.ui.theme.ThemeManager;
import io.pholio.ui.theme.ThemeMode;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.HeaderBar;
import javafx.scene.layout.HeaderDragType;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.springframework.context.ApplicationEventPublisher;
/**
* The title bar, extended into the OS window decoration area.
*
* <p>Built on the JavaFX {@link HeaderBar}, which pairs with {@link javafx.stage.StageStyle#EXTENDED} to
* give client-side decorations <em>without</em> giving up native behaviour: Aero Snap, window shadows,
* edge resizing and the platform's own placement of the close/minimise/maximise buttons all keep working.
* An {@code UNDECORATED} stage with a hand-rolled title bar loses all of that.
*
* <p>The bar reserves space for the system buttons on whichever side the platform puts them, so the logo
* on the left and the actions on the right never collide with them.
*
* <p>{@code HeaderBar} is a JavaFX <em>preview</em> API — {@code @Deprecated(since = "25")} is how JavaFX
* marks that, not a signal it is going away. Two practical consequences: it requires
* {@code -Djavafx.enablePreview=true} (set in {@code GuiBootstrap}), and its API may shift between JavaFX
* releases, so keep the surface used here small.
*/
@SuppressWarnings("deprecation")
@UiComponent
public class AppHeaderBar extends HeaderBar implements Disposable {
/** Modules exposed in the centre selector, in display order. */
private static final List<ViewType> MODULES = List.of(
ViewType.GALLERY,
ViewType.IMPORT,
ViewType.CULL,
ViewType.REORGANIZE,
ViewType.MAINTENANCE,
ViewType.EXPORT);
private final ApplicationEventPublisher publisher;
private final I18nService i18n;
private final ThemeManager themeManager;
private final ModalService modalService;
private final ViewSwitcher viewSwitcher;
private final SubscriptionScope scope = new SubscriptionScope();
private final Map<ViewType, ToggleButton> moduleButtons = new EnumMap<>(ViewType.class);
private final FontIcon themeIcon = new FontIcon();
public AppHeaderBar(
ApplicationEventPublisher publisher,
I18nService i18n,
ThemeManager themeManager,
ModalService modalService,
ViewSwitcher viewSwitcher) {
this.publisher = publisher;
this.i18n = i18n;
this.themeManager = themeManager;
this.modalService = modalService;
this.viewSwitcher = viewSwitcher;
getStyleClass().add("app-header-bar");
setLeft(buildLogo());
setCenter(buildCenter());
setRight(buildActions());
}
private Label buildLogo() {
Label logo = new Label();
logo.getStyleClass().add("app-logo");
logo.setGraphic(FontIcon.of(Feather.IMAGE, 16));
logo.textProperty().bind(i18n.binding("app.name"));
return logo;
}
private HBox buildCenter() {
ToggleGroup group = new ToggleGroup();
HBox selector = new HBox();
selector.getStyleClass().add("module-selector");
selector.setAlignment(Pos.CENTER);
for (ViewType module : MODULES) {
ToggleButton button = new ToggleButton();
button.setToggleGroup(group);
button.textProperty().bind(i18n.binding(module.messageKey()));
button.setOnAction(event -> publisher.publishEvent(new NavigateToViewEvent(module)));
// Buttons must not be swallowed by window dragging, otherwise a click moves the window
// instead of activating the module.
HeaderBar.setDragType(button, HeaderDragType.NONE);
moduleButtons.put(module, button);
selector.getChildren().add(button);
}
TextField search = new TextField();
search.getStyleClass().add("global-search");
search.promptTextProperty().bind(i18n.binding("action.search.prompt"));
search.setPrefWidth(260);
HeaderBar.setDragType(search, HeaderDragType.NONE);
HBox centre = new HBox(16, selector, search);
centre.setAlignment(Pos.CENTER);
centre.setPadding(new Insets(0, 12, 0, 12));
// Keep the selector in step with whatever actually got mounted, including navigation triggered
// from elsewhere in the application.
scope.add(viewSwitcher.currentTypeProperty().subscribe(this::selectModule));
return centre;
}
private HBox buildActions() {
Button theme = iconButton(themeIcon, "action.theme.toggle");
theme.setOnAction(event -> themeManager.toggle());
scope.add(themeManager.modeProperty().subscribe(this::updateThemeIcon));
Button settings = iconButton(FontIcon.of(Feather.SETTINGS, 16), "action.settings");
settings.setOnAction(event -> modalService.show(SettingsForm.class));
HBox actions = new HBox(4, theme, settings);
actions.setAlignment(Pos.CENTER_RIGHT);
actions.setPadding(new Insets(0, 4, 0, 4));
return actions;
}
private Button iconButton(FontIcon icon, String tooltipKey) {
Button button = new Button();
button.setGraphic(icon);
button.getStyleClass().addAll("button-icon", "flat");
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(i18n.binding(tooltipKey));
button.setTooltip(tooltip);
HeaderBar.setDragType(button, HeaderDragType.NONE);
return button;
}
private void updateThemeIcon(ThemeMode mode) {
// Show the mode the button switches *to*, which is the convention users expect.
themeIcon.setIconCode(mode == ThemeMode.DARK ? Feather.SUN : Feather.MOON);
themeIcon.setIconSize(16);
}
private void selectModule(ViewType active) {
moduleButtons.forEach((module, button) -> button.setSelected(module == active));
}
@Override
public void dispose() {
scope.close();
moduleButtons.values().forEach(button -> {
button.textProperty().unbind();
button.setOnAction(null);
});
moduleButtons.clear();
}
}
@@ -0,0 +1,71 @@
package io.pholio.ui.shell;
import io.pholio.ui.ViewSwitcher;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.UiComponent;
import javafx.scene.layout.BorderPane;
/**
* The five-zone application layout.
*
* <pre>
* +------------------------------------------------------------+
* | AppHeaderBar (integrated into the OS title bar) |
* +--------+------------------------------------------+--------+
* | Nav | | Insp- |
* | rail | ViewSwitcher container (main viewport) | ector |
* | | | |
* +--------+------------------------------------------+--------+
* | StatusBar |
* +------------------------------------------------------------+
* </pre>
*
* <p>The centre is not a view but the {@link ViewSwitcher}'s container, so navigation never has to reach
* into this class to replace anything.
*/
@UiComponent
public class AppShellView extends BorderPane implements Disposable {
private final AppHeaderBar headerBar;
private final NavigationRail navigationRail;
private final InspectorPanel inspectorPanel;
private final StatusBar statusBar;
private final ViewSwitcher viewSwitcher;
public AppShellView(
AppHeaderBar headerBar,
NavigationRail navigationRail,
InspectorPanel inspectorPanel,
StatusBar statusBar,
ViewSwitcher viewSwitcher) {
this.headerBar = headerBar;
this.navigationRail = navigationRail;
this.inspectorPanel = inspectorPanel;
this.statusBar = statusBar;
this.viewSwitcher = viewSwitcher;
getStyleClass().add("app-shell");
setTop(headerBar);
setLeft(navigationRail);
setCenter(viewSwitcher.container());
setRight(inspectorPanel);
setBottom(statusBar);
}
public InspectorPanel inspectorPanel() {
return inspectorPanel;
}
public NavigationRail navigationRail() {
return navigationRail;
}
@Override
public void dispose() {
headerBar.dispose();
navigationRail.dispose();
inspectorPanel.dispose();
statusBar.dispose();
viewSwitcher.dispose();
}
}
@@ -0,0 +1,142 @@
package io.pholio.ui.shell;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.FxUtils;
import io.pholio.ui.common.SubscriptionScope;
import io.pholio.ui.common.UiComponent;
import io.pholio.ui.event.OpenTaskManagerEvent;
import io.pholio.ui.i18n.I18nService;
import io.pholio.ui.task.TaskManager;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.springframework.context.event.EventListener;
/**
* Right-hand contextual panel: metadata for the selected photo, and the running-task list.
*/
@UiComponent
public class InspectorPanel extends VBox implements Disposable {
private static final double PANEL_WIDTH = 300;
private final I18nService i18n;
private final TaskManager taskManager;
private final SubscriptionScope scope = new SubscriptionScope();
private final TabPane tabs = new TabPane();
private final Tab detailsTab = new Tab();
private final Tab tasksTab = new Tab();
public InspectorPanel(I18nService i18n, TaskManager taskManager) {
this.i18n = i18n;
this.taskManager = taskManager;
getStyleClass().add("inspector-panel");
setMinWidth(PANEL_WIDTH);
setPrefWidth(PANEL_WIDTH);
detailsTab.textProperty().bind(i18n.binding("inspector.tab.details"));
detailsTab.setClosable(false);
detailsTab.setContent(buildDetailsPlaceholder());
tasksTab.textProperty().bind(i18n.binding("inspector.tab.tasks"));
tasksTab.setClosable(false);
tasksTab.setContent(buildTaskList());
tabs.getTabs().addAll(detailsTab, tasksTab);
tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
VBox.setVgrow(tabs, Priority.ALWAYS);
getChildren().add(tabs);
}
/**
* Reveals the task list.
*
* <p>Reached only through the event bus: the status bar's task monitor publishes, this listens. Neither
* side knows the other exists, which is what lets the status bar live in a different part of the
* scenegraph without a back-reference.
*/
@EventListener
public void onOpenTaskManager(OpenTaskManagerEvent event) {
FxUtils.onFxThread(() -> tabs.getSelectionModel().select(tasksTab));
}
private VBox buildDetailsPlaceholder() {
Label empty = new Label();
empty.getStyleClass().add("status-label");
empty.textProperty().bind(i18n.binding("inspector.empty"));
VBox content = new VBox(empty);
content.setAlignment(Pos.CENTER);
content.setPadding(new Insets(16));
return content;
}
private ListView<Task<?>> buildTaskList() {
ListView<Task<?>> list = new ListView<>(taskManager.running());
list.setPlaceholder(taskPlaceholder());
list.setCellFactory(view -> new TaskCell());
return list;
}
private Label taskPlaceholder() {
Label label = new Label();
label.getStyleClass().add("status-label");
label.textProperty().bind(i18n.binding("inspector.tasks.empty"));
return label;
}
@Override
public void dispose() {
scope.close();
detailsTab.textProperty().unbind();
tasksTab.textProperty().unbind();
}
/**
* Renders one running task.
*
* <p>Bindings are established in {@link #updateItem} and torn down when the cell is recycled — a list
* cell is reused for different items, so a binding left in place would keep updating from a task the
* cell no longer shows.
*/
private static final class TaskCell extends ListCell<Task<?>> {
private final Label title = new Label();
private final Label message = new Label();
private final ProgressBar progress = new ProgressBar();
private final VBox layout = new VBox(2, title, message, progress);
private TaskCell() {
message.getStyleClass().add("status-label");
progress.setMaxWidth(Double.MAX_VALUE);
layout.setPadding(new Insets(6, 8, 6, 8));
}
@Override
protected void updateItem(Task<?> task, boolean empty) {
super.updateItem(task, empty);
title.textProperty().unbind();
message.textProperty().unbind();
progress.progressProperty().unbind();
if (empty || task == null) {
setGraphic(null);
return;
}
title.textProperty().bind(task.titleProperty());
message.textProperty().bind(task.messageProperty());
progress.progressProperty().bind(task.progressProperty());
setGraphic(layout);
}
}
}
@@ -0,0 +1,89 @@
package io.pholio.ui.shell;
import atlantafx.base.controls.ModalPane;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.FxUtils;
import io.pholio.ui.common.UiComponent;
import javafx.geometry.Pos;
import javafx.scene.Node;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
/**
* Shows dialogs inside the scenegraph instead of in OS windows.
*
* <p>Native {@code Alert} and {@code Dialog} are not used anywhere in this application: they open a
* separate window that the AtlantaFX theme does not reach, so they look like a different program. An
* AtlantaFX {@link ModalPane} overlays the shell instead, inheriting the theme automatically.
*
* <p>Dialog content is resolved from the Spring context, so forms are ordinary prototype-scoped views with
* injected view-models rather than hand-assembled node trees.
*/
@UiComponent
public class ModalService {
private static final Logger log = LoggerFactory.getLogger(ModalService.class);
private final ApplicationContext context;
private final ModalPane modalPane = new ModalPane();
private Node currentContent;
public ModalService(ApplicationContext context) {
this.context = context;
modalPane.setAlignment(Pos.CENTER);
// Non-persistent: clicking the scrim or pressing Escape dismisses, which is what users expect of
// a preferences panel. Confirmation dialogs that must not be dismissed set persistent per-call.
modalPane.setPersistent(false);
modalPane.displayProperty().subscribe(displayed -> {
if (!displayed) {
releaseContent();
}
});
}
/** The overlay node; the shell stacks it above its root content. */
public ModalPane pane() {
return modalPane;
}
/** Resolves {@code contentType} from the context and shows it centred over the shell. */
public void show(Class<? extends Node> contentType) {
show(contentType, false);
}
/**
* @param persistent when {@code true} the modal cannot be dismissed by clicking outside it or pressing
* Escape — for choices the user must actually make
*/
public void show(Class<? extends Node> contentType, boolean persistent) {
FxUtils.onFxThread(() -> {
releaseContent();
Node content = context.getBean(contentType);
currentContent = content;
modalPane.setPersistent(persistent);
modalPane.show(content);
log.debug("Showing modal {}", contentType.getSimpleName());
});
}
public void hide() {
FxUtils.onFxThread(() -> modalPane.hide(true));
}
public boolean isShowing() {
return modalPane.isDisplay();
}
/**
* Disposes the dismissed content. Modal forms bind to services just like views do, so skipping this
* would leak one form instance per opening of the preferences panel.
*/
private void releaseContent() {
if (currentContent instanceof Disposable disposable) {
disposable.dispose();
}
currentContent = null;
}
}
@@ -0,0 +1,161 @@
package io.pholio.ui.shell;
import io.pholio.infra.preferences.PreferencesService;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.FxUtils;
import io.pholio.ui.common.SubscriptionScope;
import io.pholio.ui.common.UiComponent;
import io.pholio.ui.i18n.I18nService;
import java.util.ArrayList;
import java.util.List;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
/**
* Left-hand navigation, in two modes.
*
* <p><strong>Collapsed</strong> is a bare icon rail, which is the default state for a photo application
* where viewport width is the scarcest resource. <strong>Expanded</strong> adds labels and will host the
* library tree and quick filters.
*
* <p>Both modes are built once and toggled by visibility rather than rebuilt on each switch: rebuilding
* would drop tooltips, bindings and any selection state each time the user collapsed the panel.
*/
@UiComponent
public class NavigationRail extends BorderPane implements Disposable {
private static final double RAIL_WIDTH = 44;
private static final double EXPANDED_WIDTH = 240;
private final I18nService i18n;
private final PreferencesService preferences;
private final SubscriptionScope scope = new SubscriptionScope();
private final BooleanProperty expanded = new SimpleBooleanProperty(this, "expanded");
private final List<Entry> entries = new ArrayList<>();
private final VBox items = new VBox(2);
private final FontIcon toggleIcon = new FontIcon();
public NavigationRail(I18nService i18n, PreferencesService preferences) {
this.i18n = i18n;
this.preferences = preferences;
getStyleClass().add("navigation-rail");
expanded.set(preferences.get().ui().leftPanelExpanded());
entries.add(new Entry(Feather.IMAGE, "nav.library"));
entries.add(new Entry(Feather.FOLDER, "nav.albums"));
entries.add(new Entry(Feather.TAG, "nav.tags"));
entries.add(new Entry(Feather.MAP_PIN, "nav.map"));
entries.add(new Entry(Feather.STAR, "nav.favorites"));
entries.add(new Entry(Feather.TRASH_2, "nav.rejected"));
items.setPadding(new Insets(8, 4, 8, 4));
items.setAlignment(Pos.TOP_CENTER);
entries.forEach(entry -> items.getChildren().add(entry.button));
setCenter(items);
setBottom(buildToggle());
// subscribe(Consumer) delivers the current value immediately, so this both initialises the mode
// and keeps it in sync.
scope.add(expanded.subscribe(this::applyMode));
}
public BooleanProperty expandedProperty() {
return expanded;
}
public void toggle() {
expanded.set(!expanded.get());
}
private HBox buildToggle() {
Button button = new Button();
button.setGraphic(toggleIcon);
button.getStyleClass().addAll("rail-button", "button-icon", "flat");
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(i18n.binding("nav.expand"));
button.setTooltip(tooltip);
button.setOnAction(event -> toggle());
scope.addTeardown(() -> button.setOnAction(null));
// Rebinding the tooltip on each mode change keeps it truthful about what the button will do, and
// keeps it translated.
scope.add(expanded.subscribe(isExpanded -> {
tooltip.textProperty().unbind();
tooltip.textProperty().bind(i18n.binding(isExpanded ? "nav.collapse" : "nav.expand"));
}));
HBox container = new HBox(button);
container.setAlignment(Pos.CENTER);
container.setPadding(new Insets(4));
return container;
}
private void applyMode(boolean isExpanded) {
FxUtils.onFxThread(() -> {
double width = isExpanded ? EXPANDED_WIDTH : RAIL_WIDTH;
setMinWidth(width);
setPrefWidth(width);
setMaxWidth(width);
toggleIcon.setIconCode(isExpanded ? Feather.CHEVRONS_LEFT : Feather.CHEVRONS_RIGHT);
toggleIcon.setIconSize(16);
entries.forEach(entry -> entry.applyMode(isExpanded));
items.setAlignment(isExpanded ? Pos.TOP_LEFT : Pos.TOP_CENTER);
});
preferences.update(current -> current.withUi(current.ui().withLeftPanelExpanded(isExpanded)));
}
@Override
public void dispose() {
scope.close();
entries.forEach(Entry::dispose);
entries.clear();
}
/** One rail destination: an icon, plus a label that only appears when expanded. */
private final class Entry {
private final Button button = new Button();
private final Tooltip tooltip = new Tooltip();
private Entry(Feather icon, String messageKey) {
button.setGraphic(FontIcon.of(icon, 16));
button.getStyleClass().addAll("rail-button", "flat");
button.setGraphicTextGap(8);
button.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(button, Priority.ALWAYS);
// The text stays bound in both modes; collapsing hides it via contentDisplay rather than by
// clearing it, so a language switch while collapsed is still picked up.
button.textProperty().bind(i18n.binding(messageKey));
tooltip.textProperty().bind(i18n.binding(messageKey));
}
private void applyMode(boolean expandedMode) {
button.setContentDisplay(expandedMode ? ContentDisplay.LEFT : ContentDisplay.GRAPHIC_ONLY);
button.setAlignment(expandedMode ? Pos.CENTER_LEFT : Pos.CENTER);
button.setPrefWidth(expandedMode ? EXPANDED_WIDTH - 16 : 32);
// A tooltip duplicating a visible label is noise; it earns its place only when collapsed.
button.setTooltip(expandedMode ? null : tooltip);
}
private void dispose() {
button.textProperty().unbind();
tooltip.textProperty().unbind();
button.setTooltip(null);
}
}
}
@@ -0,0 +1,256 @@
package io.pholio.ui.shell;
import io.pholio.infra.preferences.Preferences;
import io.pholio.infra.preferences.PreferencesService;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.SubscriptionScope;
import io.pholio.ui.common.UiView;
import io.pholio.ui.i18n.I18nService;
import io.pholio.ui.theme.ThemeManager;
import io.pholio.ui.theme.ThemeMode;
import java.util.Locale;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
/**
* The preferences panel, rendered inside the scenegraph by {@link ModalService}.
*
* <p>Every control writes straight through to {@link PreferencesService} — there is no OK/Cancel buffer,
* matching how modern desktop settings behave, and the debounced writer means typing in the interval
* spinner does not hammer the disk.
*
* <p>Note what is <em>absent</em>: no fields for window position or size. Those are persisted
* automatically and are editable only by hand in {@code preferences.yaml}; the note at the bottom of the
* form tells the user so.
*/
@UiView
public class SettingsForm extends VBox implements Disposable {
private final I18nService i18n;
private final ThemeManager themeManager;
private final PreferencesService preferences;
private final ModalService modalService;
private final SubscriptionScope scope = new SubscriptionScope();
private final ChoiceBox<ThemeMode> themeChoice = new ChoiceBox<>();
private final ChoiceBox<Locale> localeChoice = new ChoiceBox<>();
private final CheckBox syncEnabled = new CheckBox();
private final Spinner<Integer> syncInterval = new Spinner<>();
private final Label rootPathValue = new Label();
private final Label titleLabel = new Label();
private final Label geometryNote = new Label();
private final Button closeButton = new Button();
/** Guards against write-back while controls are being populated from preferences. */
private boolean populating;
public SettingsForm(
I18nService i18n,
ThemeManager themeManager,
PreferencesService preferences,
ModalService modalService) {
this.i18n = i18n;
this.themeManager = themeManager;
this.preferences = preferences;
this.modalService = modalService;
getStyleClass().add("modal-card");
setSpacing(14);
setPadding(new Insets(20));
setMaxWidth(520);
setMaxHeight(Region.USE_PREF_SIZE);
titleLabel.getStyleClass().add("modal-title");
titleLabel.textProperty().bind(i18n.binding("settings.title"));
geometryNote.getStyleClass().add("modal-note");
geometryNote.setWrapText(true);
geometryNote.textProperty().bind(i18n.binding("settings.note.windowGeometry"));
getChildren().addAll(
titleLabel,
section("settings.appearance", appearanceGrid()),
section("settings.library", libraryGrid()),
section("settings.sync", syncGrid()),
geometryNote,
footer());
populate();
wire();
}
private VBox section(String titleKey, Region content) {
Label heading = new Label();
heading.getStyleClass().add("section-title");
heading.textProperty().bind(i18n.binding(titleKey));
scope.addTeardown(() -> heading.textProperty().unbind());
VBox box = new VBox(6, heading, content);
return box;
}
private GridPane appearanceGrid() {
themeChoice.getItems().setAll(ThemeMode.values());
themeChoice.setConverter(converter(mode -> i18n.get(mode.messageKey())));
localeChoice.getItems().setAll(I18nService.SUPPORTED_LOCALES);
localeChoice.setConverter(converter(i18n::displayName));
GridPane grid = grid();
addRow(grid, 0, "settings.appearance.theme", themeChoice);
addRow(grid, 1, "settings.appearance.language", localeChoice);
return grid;
}
private GridPane libraryGrid() {
rootPathValue.getStyleClass().add("exif-value");
GridPane grid = grid();
addRow(grid, 0, "settings.library.rootPath", rootPathValue);
return grid;
}
private GridPane syncGrid() {
syncEnabled.textProperty().bind(i18n.binding("settings.sync.enabled"));
scope.addTeardown(() -> syncEnabled.textProperty().unbind());
syncInterval.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 1440, 15));
syncInterval.setEditable(true);
syncInterval.setPrefWidth(100);
GridPane grid = grid();
grid.add(syncEnabled, 0, 0, 2, 1);
addRow(grid, 1, "settings.sync.interval", syncInterval);
return grid;
}
private HBox footer() {
closeButton.textProperty().bind(i18n.binding("action.close"));
closeButton.getStyleClass().add("accent");
closeButton.setOnAction(event -> modalService.hide());
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox footer = new HBox(8, spacer, closeButton);
footer.setAlignment(Pos.CENTER_RIGHT);
return footer;
}
/** Loads current values into the controls without triggering the write-back listeners. */
private void populate() {
populating = true;
try {
Preferences current = preferences.get();
themeChoice.setValue(themeManager.mode());
localeChoice.setValue(i18n.locale());
syncEnabled.setSelected(current.sync().enabled());
syncInterval.getValueFactory().setValue(current.sync().intervalMinutes());
rootPathValue.setText(current.library().hasRootPath()
? current.library().rootPath()
: i18n.get("settings.library.rootPath.unset"));
} finally {
populating = false;
}
}
private void wire() {
scope.add(themeChoice.valueProperty().subscribe(mode -> {
if (!populating && mode != null) {
themeManager.setMode(mode);
}
}));
scope.add(localeChoice.valueProperty().subscribe(locale -> {
if (!populating && locale != null) {
i18n.setLocale(locale);
// Converters render through i18n.get(), which is not reactive; refresh their display.
refreshConverters();
rootPathValue.setText(preferences.get().library().hasRootPath()
? preferences.get().library().rootPath()
: i18n.get("settings.library.rootPath.unset"));
}
}));
scope.add(syncEnabled.selectedProperty().subscribe(enabled -> {
if (!populating) {
preferences.update(current -> current.withSync(
new Preferences.Sync(enabled, current.sync().intervalMinutes())));
}
}));
scope.add(syncInterval.valueProperty().subscribe(minutes -> {
if (!populating && minutes != null) {
preferences.update(current -> current.withSync(
new Preferences.Sync(current.sync().enabled(), minutes)));
}
}));
}
/**
* Forces both choice boxes to re-render their labels.
*
* <p>{@link StringConverter} is not observable, so a language switch leaves already-rendered items
* showing the old language until the value is re-set.
*/
private void refreshConverters() {
ThemeMode theme = themeChoice.getValue();
Locale locale = localeChoice.getValue();
boolean wasPopulating = populating;
populating = true;
try {
themeChoice.setConverter(converter(mode -> i18n.get(mode.messageKey())));
localeChoice.setConverter(converter(i18n::displayName));
themeChoice.setValue(theme);
localeChoice.setValue(locale);
} finally {
populating = wasPopulating;
}
}
private GridPane grid() {
GridPane grid = new GridPane();
grid.setHgap(12);
grid.setVgap(8);
return grid;
}
private void addRow(GridPane grid, int row, String labelKey, Region control) {
Label label = new Label();
label.textProperty().bind(i18n.binding(labelKey));
scope.addTeardown(() -> label.textProperty().unbind());
grid.add(label, 0, row);
grid.add(control, 1, row);
}
private <T> StringConverter<T> converter(java.util.function.Function<T, String> toString) {
return new StringConverter<>() {
@Override
public String toString(T value) {
return value == null ? "" : toString.apply(value);
}
@Override
public T fromString(String string) {
// Both choice boxes are non-editable; nothing ever converts back.
throw new UnsupportedOperationException("read-only converter");
}
};
}
@Override
public void dispose() {
scope.close();
titleLabel.textProperty().unbind();
geometryNote.textProperty().unbind();
closeButton.textProperty().unbind();
closeButton.setOnAction(null);
}
}
@@ -0,0 +1,74 @@
package io.pholio.ui.shell;
import io.pholio.infra.preferences.Preferences;
import io.pholio.infra.preferences.PreferencesChangedEvent;
import io.pholio.infra.preferences.PreferencesService;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.FxUtils;
import io.pholio.ui.common.SubscriptionScope;
import io.pholio.ui.common.UiComponent;
import io.pholio.ui.i18n.I18nService;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import org.springframework.context.event.EventListener;
/**
* Permanent bottom bar: library status on the left, task monitor on the right.
*/
@UiComponent
public class StatusBar extends HBox implements Disposable {
private final I18nService i18n;
private final PreferencesService preferences;
private final TaskMonitorWidget taskMonitor;
private final SubscriptionScope scope = new SubscriptionScope();
private final Label libraryLabel = new Label();
public StatusBar(I18nService i18n, PreferencesService preferences, TaskMonitorWidget taskMonitor) {
this.i18n = i18n;
this.preferences = preferences;
this.taskMonitor = taskMonitor;
getStyleClass().add("status-bar");
setAlignment(Pos.CENTER_LEFT);
setSpacing(12);
libraryLabel.getStyleClass().add("status-label");
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
getChildren().addAll(libraryLabel, spacer, taskMonitor);
// The label depends on the locale as well as on preferences. subscribe delivers the current value
// immediately, so this also performs the initial render.
scope.add(i18n.localeProperty().subscribe(locale -> renderLibraryStatus()));
}
/** Refreshes when the library root changes. Spring may deliver this off the FX thread. */
@EventListener
public void onPreferencesChanged(PreferencesChangedEvent event) {
FxUtils.onFxThread(this::renderLibraryStatus);
}
private void renderLibraryStatus() {
Preferences.Library library = preferences.get().library();
libraryLabel.textProperty().unbind();
if (library.hasRootPath()) {
// Replaced by a real count once the index exists; the key already takes the argument.
libraryLabel.textProperty().bind(i18n.binding("status.library.photos", 0));
} else {
libraryLabel.textProperty().bind(i18n.binding("status.library.notConfigured"));
}
}
@Override
public void dispose() {
scope.close();
libraryLabel.textProperty().unbind();
taskMonitor.dispose();
}
}
@@ -0,0 +1,72 @@
package io.pholio.ui.shell;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.SubscriptionScope;
import io.pholio.ui.common.UiComponent;
import io.pholio.ui.event.OpenTaskManagerEvent;
import io.pholio.ui.i18n.I18nService;
import io.pholio.ui.task.TaskManager;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.HBox;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.springframework.context.ApplicationEventPublisher;
/**
* Status-bar indicator for background work.
*
* <p>Clicking it publishes {@link OpenTaskManagerEvent} rather than manipulating the inspector directly —
* the widget has no idea a task panel exists, let alone where it lives in the scenegraph.
*/
@UiComponent
public class TaskMonitorWidget extends HBox implements Disposable {
private final I18nService i18n;
private final TaskManager taskManager;
private final ApplicationEventPublisher publisher;
private final SubscriptionScope scope = new SubscriptionScope();
private final Label label = new Label();
private final ProgressIndicator spinner = new ProgressIndicator();
private final FontIcon idleIcon = FontIcon.of(Feather.ACTIVITY, 13);
public TaskMonitorWidget(I18nService i18n, TaskManager taskManager, ApplicationEventPublisher publisher) {
this.i18n = i18n;
this.taskManager = taskManager;
this.publisher = publisher;
getStyleClass().add("task-monitor");
setAlignment(Pos.CENTER_LEFT);
setSpacing(6);
label.getStyleClass().add("status-label");
spinner.setPrefSize(12, 12);
spinner.setMaxSize(12, 12);
getChildren().addAll(idleIcon, label);
setOnMouseClicked(event -> publisher.publishEvent(new OpenTaskManagerEvent()));
scope.add(taskManager.runningCountBinding().subscribe(this::update));
}
private void update(Number runningCount) {
int count = runningCount.intValue();
label.textProperty().unbind();
if (count == 0) {
label.textProperty().bind(i18n.binding("status.tasks.idle"));
getChildren().setAll(idleIcon, label);
} else {
label.textProperty().bind(i18n.binding("status.tasks.running", count));
getChildren().setAll(spinner, label);
}
}
@Override
public void dispose() {
scope.close();
label.textProperty().unbind();
setOnMouseClicked(null);
}
}
@@ -0,0 +1,99 @@
package io.pholio.ui.task;
import io.pholio.ui.common.FxUtils;
import io.pholio.ui.common.UiComponent;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.IntegerBinding;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Registry of the background work currently in flight, and the single place work gets submitted from the
* UI.
*
* <p>Deliberately built on {@link Task} rather than a bespoke descriptor type: {@code Task} already
* exposes observable title, message, progress and state, which is exactly what the status bar and the
* task panel bind to. Nothing needs to be mirrored or kept in sync.
*
* <p>Lives in the UI layer, not {@code infra}, because {@link ObservableList} is a JavaFX type and the
* infrastructure layer must stay loadable in a headless CLI run. Background services report progress by
* publishing Spring events instead.
*/
@UiComponent
public class TaskManager {
private static final Logger log = LoggerFactory.getLogger(TaskManager.class);
private final ObservableList<Task<?>> running = FXCollections.observableArrayList();
private final ObservableList<Task<?>> unmodifiableRunning = FXCollections.unmodifiableObservableList(running);
private final IntegerBinding runningCount = Bindings.size(running);
/** Live view of running tasks, for the task panel to render. */
public ObservableList<Task<?>> running() {
return unmodifiableRunning;
}
public IntegerBinding runningCountBinding() {
return runningCount;
}
/**
* Registers {@code task}, runs it on {@code executor} and removes it once it finishes, however it
* finishes.
*
* @return the same task, so callers can attach their own handlers
* @throws RejectedExecutionException if the target pool's queue is full — pools use an abort policy so
* that rejected work never runs on the JavaFX application thread
*/
public <T> Task<T> submit(Executor executor, Task<T> task) {
register(task);
try {
executor.execute(task);
} catch (RejectedExecutionException e) {
unregister(task);
throw e;
}
return task;
}
/** Tracks a task started elsewhere. */
public void register(Task<?> task) {
FxUtils.onFxThread(() -> {
if (task.isDone()) {
return;
}
running.add(task);
// Task state transitions are published on the FX thread, so removal is already on-thread.
task.stateProperty().subscribe(state -> {
if (isTerminal(state)) {
running.remove(task);
log.debug("Task '{}' finished with state {}", task.getTitle(), state);
}
});
});
}
public void unregister(Task<?> task) {
FxUtils.onFxThread(() -> running.remove(task));
}
/** Cancels every running task. Called when the window closes, before the context shuts down. */
public void cancelAll() {
FxUtils.onFxThread(() -> {
for (Task<?> task : List.copyOf(running)) {
task.cancel();
}
});
}
private static boolean isTerminal(Worker.State state) {
return state == Worker.State.SUCCEEDED || state == Worker.State.FAILED || state == Worker.State.CANCELLED;
}
}
@@ -0,0 +1,161 @@
package io.pholio.ui.theme;
import io.pholio.infra.preferences.PreferencesService;
import io.pholio.ui.common.FxUtils;
import io.pholio.ui.common.UiComponent;
import java.net.URL;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.WeakHashMap;
import javafx.application.Application;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Scene;
import javafx.scene.text.Font;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Applies and switches the application appearance.
*
* <p>Two stylesheet layers are in play. The AtlantaFX base theme is installed globally through
* {@link Application#setUserAgentStylesheet(String)} so it reaches popups and dialogs that are not part
* of any registered scene. The palette override is added per-scene, because user-agent stylesheets have
* the lowest precedence and could not override the base theme's own variables.
*
* <p>Scenes are tracked weakly: a closed secondary window must not be kept alive by this singleton.
*/
@UiComponent
public class ThemeManager {
private static final Logger log = LoggerFactory.getLogger(ThemeManager.class);
/** Fonts loaded if present on the classpath; the CSS degrades to system fonts when they are not. */
private static final String[] OPTIONAL_FONTS = {
"/fonts/Inter-Regular.ttf",
"/fonts/Inter-Medium.ttf",
"/fonts/Inter-SemiBold.ttf",
"/fonts/JetBrainsMono-Regular.ttf",
};
private final PreferencesService preferences;
private final Set<Scene> scenes = Collections.newSetFromMap(new WeakHashMap<>());
private final ObjectProperty<ThemeMode> mode = new SimpleObjectProperty<>(this, "mode");
private boolean fontsLoaded;
public ThemeManager(PreferencesService preferences) {
this.preferences = preferences;
this.mode.set(ThemeMode.parse(preferences.get().ui().theme()));
}
public ReadOnlyObjectProperty<ThemeMode> modeProperty() {
return mode;
}
public ThemeMode mode() {
return mode.get();
}
/**
* Installs the base theme and loads bundled fonts. Must run on the JavaFX application thread,
* before the first scene is shown.
*/
public void initialise() {
loadFonts();
applyBaseTheme(mode.get());
}
/** Starts styling {@code scene} and keeps it in sync with later theme switches. */
public void register(Scene scene) {
if (scene == null || !scenes.add(scene)) {
return;
}
applyOverride(scene, mode.get());
}
public void unregister(Scene scene) {
scenes.remove(scene);
}
public void toggle() {
setMode(mode.get().opposite());
}
/** Switches appearance across every registered scene and persists the choice. */
public void setMode(ThemeMode newMode) {
if (newMode == null || newMode == mode.get()) {
return;
}
log.info("Switching theme to {}", newMode);
ThemeMode previous = mode.get();
mode.set(newMode);
FxUtils.onFxThread(() -> {
applyBaseTheme(newMode);
for (Scene scene : Set.copyOf(scenes)) {
removeOverride(scene, previous);
applyOverride(scene, newMode);
}
});
preferences.update(current -> current.withUi(current.ui().withTheme(newMode.name())));
}
/** Number of live scenes currently being styled. Exposed for diagnostics and tests. */
public int registeredSceneCount() {
return scenes.size();
}
private void applyBaseTheme(ThemeMode themeMode) {
Application.setUserAgentStylesheet(themeMode.baseTheme().getUserAgentStylesheet());
}
private void applyOverride(Scene scene, ThemeMode themeMode) {
resolve(themeMode.overrideStylesheet()).ifPresent(url -> {
if (!scene.getStylesheets().contains(url)) {
scene.getStylesheets().add(url);
}
});
}
private void removeOverride(Scene scene, ThemeMode themeMode) {
resolve(themeMode.overrideStylesheet()).ifPresent(scene.getStylesheets()::remove);
}
private Optional<String> resolve(String classpathLocation) {
URL url = ThemeManager.class.getResource(classpathLocation);
if (url == null) {
log.error("Stylesheet {} is missing from the classpath", classpathLocation);
return Optional.empty();
}
return Optional.of(url.toExternalForm());
}
/**
* Registers bundled font files with JavaFX so the CSS can name them.
*
* <p>Absence is not an error: the stylesheets list Inter and JetBrains Mono first but fall through
* to platform fonts, so the application looks correct on a machine without them and no font binary
* has to be vendored to build.
*/
private void loadFonts() {
if (fontsLoaded) {
return;
}
fontsLoaded = true;
for (String location : OPTIONAL_FONTS) {
URL url = ThemeManager.class.getResource(location);
if (url == null) {
log.debug("Optional font {} not bundled, using the system fallback", location);
continue;
}
Font font = Font.loadFont(url.toExternalForm(), 12);
if (font == null) {
log.warn("Font {} could not be loaded", location);
} else {
log.debug("Loaded font {}", font.getName());
}
}
}
}
@@ -0,0 +1,60 @@
package io.pholio.ui.theme;
import atlantafx.base.theme.PrimerDark;
import atlantafx.base.theme.PrimerLight;
import atlantafx.base.theme.Theme;
import java.util.function.Supplier;
/**
* The two shipped appearances.
*
* <p>Each mode pairs an AtlantaFX base theme — which supplies the full control stylesheet — with an
* override sheet that repaints its colour variables in the IntelliJ New UI / Immich palette. Primer is
* the base rather than Nord because its neutral greys are closer to the IntelliJ canvas, so the
* override has less to fight.
*/
public enum ThemeMode {
DARK("theme.dark", PrimerDark::new, "/css/theme-intellij-immich.css"),
LIGHT("theme.light", PrimerLight::new, "/css/theme-intellij-light.css");
private final String messageKey;
private final Supplier<Theme> baseTheme;
private final String overrideStylesheet;
ThemeMode(String messageKey, Supplier<Theme> baseTheme, String overrideStylesheet) {
this.messageKey = messageKey;
this.baseTheme = baseTheme;
this.overrideStylesheet = overrideStylesheet;
}
/** i18n key for the theme's display name. */
public String messageKey() {
return messageKey;
}
public Theme baseTheme() {
return baseTheme.get();
}
/** Classpath location of the palette override sheet. */
public String overrideStylesheet() {
return overrideStylesheet;
}
public ThemeMode opposite() {
return this == DARK ? LIGHT : DARK;
}
/** Parses a persisted value, falling back to {@link #DARK} for anything unrecognised. */
public static ThemeMode parse(String value) {
if (value != null) {
for (ThemeMode mode : values()) {
if (mode.name().equalsIgnoreCase(value)) {
return mode;
}
}
}
return DARK;
}
}
@@ -0,0 +1,101 @@
package io.pholio.ui.view;
import io.pholio.infra.preferences.Preferences;
import io.pholio.infra.preferences.PreferencesService;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.SubscriptionScope;
import io.pholio.ui.common.UiView;
import io.pholio.ui.i18n.I18nService;
import java.io.File;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.DirectoryChooser;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Main viewport: the thumbnail grid over the library.
*
* <p>At this stage it renders the empty state and lets the user pick a library root, which is the
* precondition for everything the indexing phase adds. The virtualised grid replaces the placeholder
* without changing this class's contract with the {@code ViewSwitcher}.
*/
@UiView
public class GalleryView extends StackPane implements Disposable {
private static final Logger log = LoggerFactory.getLogger(GalleryView.class);
private final I18nService i18n;
private final PreferencesService preferences;
private final SubscriptionScope scope = new SubscriptionScope();
private final Label title = new Label();
private final Label subtitle = new Label();
private final Button chooseRoot = new Button();
public GalleryView(I18nService i18n, PreferencesService preferences) {
this.i18n = i18n;
this.preferences = preferences;
getStyleClass().add("empty-state");
title.getStyleClass().add("empty-title");
title.textProperty().bind(i18n.binding("gallery.empty.title"));
subtitle.getStyleClass().add("empty-subtitle");
subtitle.textProperty().bind(i18n.binding("gallery.empty.subtitle"));
chooseRoot.textProperty().bind(i18n.binding("gallery.chooseRoot"));
chooseRoot.setGraphic(FontIcon.of(Feather.FOLDER, 14));
chooseRoot.getStyleClass().add("accent");
chooseRoot.setOnAction(event -> chooseLibraryRoot());
VBox content = new VBox(10, FontIcon.of(Feather.IMAGE, 40), title, subtitle, chooseRoot);
content.setAlignment(Pos.CENTER);
content.setPadding(new Insets(24));
getChildren().add(content);
}
/**
* Picks the library root.
*
* <p>A native {@link DirectoryChooser} is used deliberately, and is not a violation of the
* "no OS dialogs" rule: that rule is about application dialogs, which must stay themed and in-scene.
* File and directory pickers are OS integration points where users expect their platform's own
* browser, with its sidebar, network volumes and permissions handling.
*/
private void chooseLibraryRoot() {
DirectoryChooser chooser = new DirectoryChooser();
chooser.setTitle(i18n.get("settings.library.rootPath"));
Preferences.Library library = preferences.get().library();
if (library.hasRootPath()) {
File existing = new File(library.rootPath());
if (existing.isDirectory()) {
chooser.setInitialDirectory(existing);
}
}
File selected = chooser.showDialog(getScene() != null ? getScene().getWindow() : null);
if (selected == null) {
return;
}
log.info("Library root set to {}", selected);
preferences.update(current -> current.withLibrary(new Preferences.Library(selected.getAbsolutePath())));
}
@Override
public void dispose() {
scope.close();
title.textProperty().unbind();
subtitle.textProperty().unbind();
chooseRoot.textProperty().unbind();
chooseRoot.setOnAction(null);
}
}
@@ -0,0 +1,58 @@
package io.pholio.ui.view;
import io.pholio.ui.common.Disposable;
import io.pholio.ui.common.UiView;
import io.pholio.ui.event.NavigateToViewEvent;
import io.pholio.ui.event.NavigationAware;
import io.pholio.ui.event.ViewType;
import io.pholio.ui.i18n.I18nService;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
/**
* Stands in for modules not yet implemented.
*
* <p>Exists so the header's module selector is fully navigable from the first build: every destination
* mounts something, disposal is exercised on every switch, and each module can be replaced by its real
* view independently. It reads the destination from the navigation payload, so one bean serves all of
* them.
*/
@UiView
public class ModulePlaceholderView extends StackPane implements Disposable, NavigationAware {
private final I18nService i18n;
private final Label title = new Label();
private final Label subtitle = new Label();
public ModulePlaceholderView(I18nService i18n) {
this.i18n = i18n;
getStyleClass().add("empty-state");
title.getStyleClass().add("empty-title");
subtitle.getStyleClass().add("empty-subtitle");
VBox content = new VBox(8, FontIcon.of(Feather.TOOL, 32), title, subtitle);
content.setAlignment(Pos.CENTER);
content.setPadding(new Insets(24));
getChildren().add(content);
}
@Override
public void onNavigate(NavigateToViewEvent event) {
ViewType target = event.target();
title.textProperty().unbind();
title.textProperty().bind(i18n.binding(target.messageKey()));
subtitle.setText(target.name());
}
@Override
public void dispose() {
title.textProperty().unbind();
subtitle.textProperty().unbind();
}
}
@@ -0,0 +1,68 @@
package io.pholio.ui.window;
import java.util.List;
import javafx.geometry.Rectangle2D;
/**
* Pure geometry checks behind multi-monitor window restoration.
*
* <p>Deliberately free of {@link javafx.stage.Screen} and any other toolkit type: it operates on plain
* {@link Rectangle2D} values from {@code javafx.base}, which makes it unit-testable without starting the
* JavaFX runtime — the reason the rule "reset to the primary screen when the external monitor is gone"
* can be covered by a fast test rather than a manual check.
*/
public final class ScreenBoundsValidator {
private ScreenBoundsValidator() {
}
/**
* Whether enough of {@code window} falls inside the union of {@code screens} to be worth restoring.
*
* <p>A window whose saved coordinates belonged to a now-disconnected monitor scores 0 and must be
* repositioned. A window nudged slightly off the edge of a still-connected monitor scores high and is
* left where the user put it.
*
* @param minVisibleFraction fraction of the window's area that must be visible, in {@code (0, 1]}
*/
public static boolean isVisibleEnough(Rectangle2D window, List<Rectangle2D> screens, double minVisibleFraction) {
if (window == null || screens == null || screens.isEmpty()) {
return false;
}
double windowArea = window.getWidth() * window.getHeight();
if (windowArea <= 0) {
return false;
}
return visibleArea(window, screens) / windowArea >= minVisibleFraction;
}
/**
* Total area of {@code window} covered by at least one screen.
*
* <p>Contributions are summed because a window may legitimately straddle two monitors; the result is
* clamped to the window's own area so overlapping screen configurations cannot report more than
* 100% visibility.
*/
public static double visibleArea(Rectangle2D window, List<Rectangle2D> screens) {
double total = 0;
for (Rectangle2D screen : screens) {
total += intersectionArea(window, screen);
}
return Math.min(total, window.getWidth() * window.getHeight());
}
static double intersectionArea(Rectangle2D a, Rectangle2D b) {
double width = Math.min(a.getMaxX(), b.getMaxX()) - Math.max(a.getMinX(), b.getMinX());
double height = Math.min(a.getMaxY(), b.getMaxY()) - Math.max(a.getMinY(), b.getMinY());
return (width <= 0 || height <= 0) ? 0 : width * height;
}
/** Coordinates that centre a {@code width} x {@code height} window on {@code screen}. */
public static Rectangle2D centredOn(Rectangle2D screen, double width, double height) {
double clampedWidth = Math.min(width, screen.getWidth());
double clampedHeight = Math.min(height, screen.getHeight());
double x = screen.getMinX() + (screen.getWidth() - clampedWidth) / 2;
double y = screen.getMinY() + (screen.getHeight() - clampedHeight) / 2;
return new Rectangle2D(x, y, clampedWidth, clampedHeight);
}
}
@@ -0,0 +1,225 @@
package io.pholio.ui.window;
import io.pholio.infra.config.PholioProperties;
import io.pholio.infra.preferences.PreferencesService;
import io.pholio.infra.preferences.WindowGeometry;
import io.pholio.infra.support.Debouncer;
import io.pholio.ui.common.UiComponent;
import jakarta.annotation.PreDestroy;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import javafx.geometry.Rectangle2D;
import javafx.stage.Screen;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Persists and restores window geometry across restarts and display changes.
*
* <p>Geometry is stored per window id so secondary windows keep their own position. It is never exposed
* in the settings UI — editing {@code preferences.yaml} by hand is the only supported way to change it.
*
* <p>Three details that are easy to get wrong and are handled here:
* <ul>
* <li><strong>Maximised windows.</strong> While maximised, {@code Stage#getX/getWidth} report the
* maximised bounds. Saving those would lose the user's restored size forever, so the last
* non-maximised bounds are tracked separately and it is those that get written.
* <li><strong>Disconnected monitors.</strong> Coordinates are validated against the screens actually
* connected <em>now</em>; a window saved on an external display that is gone is re-centred on the
* primary screen rather than opening off-screen where it cannot be reached.
* <li><strong>One debouncer per window.</strong> A shared one would let a drag of window B cancel the
* pending save of window A.
* </ul>
*/
@UiComponent
public class WindowStateManager {
/** Window id of the primary application window. */
public static final String MAIN_WINDOW = "main";
private static final Logger log = LoggerFactory.getLogger(WindowStateManager.class);
/**
* Coalescing delay for geometry changes: long enough that a window drag produces one update instead
* of hundreds, short enough to survive an abrupt quit. The YAML write is debounced again downstream
* by the preferences service.
*/
private static final Duration CAPTURE_DELAY = Duration.ofMillis(250);
private final PreferencesService preferences;
private final PholioProperties properties;
private final Map<String, Tracker> trackers = new ConcurrentHashMap<>();
public WindowStateManager(PreferencesService preferences, PholioProperties properties) {
this.preferences = preferences;
this.properties = properties;
}
/**
* Restores {@code stage}'s geometry and starts tracking changes to it.
*
* <p>Call before showing the stage: sizing an already-visible window produces a visible jump.
*/
public void register(String windowId, Stage stage) {
Tracker tracker = trackers.computeIfAbsent(windowId, id -> new Tracker(id, stage));
tracker.restore();
tracker.attach();
}
/** Writes {@code windowId}'s current geometry immediately. Used from the close handler. */
public void captureNow(String windowId) {
Tracker tracker = trackers.get(windowId);
if (tracker != null) {
tracker.captureNow();
}
}
/** Stops tracking a window and flushes its pending geometry. For secondary windows being closed. */
public void unregister(String windowId) {
Tracker tracker = trackers.remove(windowId);
if (tracker != null) {
tracker.close();
}
}
@PreDestroy
void shutdown() {
trackers.values().forEach(Tracker::close);
trackers.clear();
}
private static List<Rectangle2D> connectedScreenBounds() {
List<Rectangle2D> bounds = new ArrayList<>();
for (Screen screen : Screen.getScreens()) {
bounds.add(screen.getVisualBounds());
}
return bounds;
}
/** Per-window state: its own debouncer, listeners, and remembered non-maximised bounds. */
private final class Tracker implements AutoCloseable {
private final String windowId;
private final Stage stage;
private final Debouncer debouncer;
/** Bounds to persist. Updated only while the window is neither maximised nor iconified. */
private Rectangle2D restoredBounds;
/** Suppresses capture while {@link #restore()} is mutating the stage. */
private boolean restoring;
private boolean attached;
private Tracker(String windowId, Stage stage) {
this.windowId = windowId;
this.stage = stage;
this.debouncer = new Debouncer("pholio-window-" + windowId, CAPTURE_DELAY);
}
private void restore() {
restoring = true;
try {
PholioProperties.Window defaults = properties.window();
Optional<WindowGeometry> saved = preferences.get().window(windowId);
Optional<WindowGeometry> sized = saved.filter(WindowGeometry::hasSize);
double width = sized.map(WindowGeometry::width).orElse(defaults.defaultWidth());
double height = sized.map(WindowGeometry::height).orElse(defaults.defaultHeight());
stage.setMinWidth(defaults.minWidth());
stage.setMinHeight(defaults.minHeight());
stage.setWidth(width);
stage.setHeight(height);
Optional<WindowGeometry> positioned = saved.filter(WindowGeometry::hasPosition);
Optional<Rectangle2D> usablePosition = positioned
.map(geometry -> new Rectangle2D(geometry.x(), geometry.y(), width, height))
.filter(rect -> ScreenBoundsValidator.isVisibleEnough(
rect, connectedScreenBounds(), defaults.minVisibleFraction()));
if (usablePosition.isPresent()) {
stage.setX(usablePosition.get().getMinX());
stage.setY(usablePosition.get().getMinY());
log.debug("Restored window '{}' to {}", windowId, usablePosition.get());
} else {
Rectangle2D centred = ScreenBoundsValidator.centredOn(
Screen.getPrimary().getVisualBounds(), width, height);
stage.setX(centred.getMinX());
stage.setY(centred.getMinY());
if (positioned.isPresent()) {
log.info("Saved position for window '{}' is not on any connected screen; "
+ "centring on the primary display", windowId);
}
}
restoredBounds = new Rectangle2D(stage.getX(), stage.getY(), width, height);
if (saved.map(WindowGeometry::maximized).orElse(false)) {
stage.setMaximized(true);
}
} finally {
restoring = false;
}
}
private void attach() {
if (attached) {
return;
}
attached = true;
// Single-argument lambdas rather than method references: `subscribe` is overloaded on arity,
// and an inexact method reference cannot disambiguate it.
stage.xProperty().subscribe(value -> onGeometryChanged());
stage.yProperty().subscribe(value -> onGeometryChanged());
stage.widthProperty().subscribe(value -> onGeometryChanged());
stage.heightProperty().subscribe(value -> onGeometryChanged());
stage.maximizedProperty().subscribe(value -> onGeometryChanged());
}
private void onGeometryChanged() {
if (restoring) {
return;
}
rememberBounds();
debouncer.call(this::capture);
}
/**
* Snapshots the current bounds if they represent a real, user-visible window. A maximised or
* iconified stage reports bounds that must not overwrite the restored ones.
*/
private void rememberBounds() {
if (stage.isMaximized() || stage.isIconified() || stage.getWidth() <= 0 || stage.getHeight() <= 0) {
return;
}
restoredBounds = new Rectangle2D(stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight());
}
private void captureNow() {
rememberBounds();
debouncer.flush();
capture();
}
private void capture() {
Rectangle2D bounds = restoredBounds;
if (bounds == null) {
return;
}
WindowGeometry geometry = new WindowGeometry(
bounds.getMinX(), bounds.getMinY(), bounds.getWidth(), bounds.getHeight(), stage.isMaximized());
preferences.update(current -> current.withWindow(windowId, geometry));
}
@Override
public void close() {
debouncer.close();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
# Headless profile.
#
# A command-line tool's stdout belongs to its output, not to framework startup chatter: anything printed
# here ends up in whatever the user pipes the command into. Warnings and errors still surface.
logging:
level:
root: WARN
io.pholio: WARN
pattern:
console: "%-5level %msg%n"
+50
View File
@@ -0,0 +1,50 @@
spring:
application:
name: pholio
main:
web-application-type: none
jmx:
enabled: false
# The H2 URL is contributed by PholioBootstrap because it depends on the OS-specific data
# directory. Everything else about the datasource lives here.
datasource:
hikari:
pool-name: pholio-h2
# H2 in file mode serialises writes; a small pool is plenty and keeps the file lock simple.
maximum-pool-size: 4
minimum-idle: 1
sql:
init:
mode: never
flyway:
# Enabled from the persistence phase onwards, once db/migration contains the schema.
enabled: false
locations: classpath:db/migration
messages:
basename: messages
encoding: UTF-8
fallback-to-system-locale: false
# Reload bundles on disk during development; harmless in a packaged build.
cache-duration: 1h
pholio:
pools:
# 0 => availableProcessors() - 1
thumbnail: 0
metadata: 2
image-analysis: 1
background-sync: 1
scheduler: 2
queue-capacity: 2000
await-termination: 10s
window:
default-width: 1440
default-height: 900
min-width: 960
min-height: 600
min-visible-fraction: 0.3
logging:
level:
root: INFO
io.pholio: DEBUG
@@ -0,0 +1,307 @@
/*
* Pholio — IntelliJ New UI x Immich, dark.
*
* Layered on top of the AtlantaFX Primer Dark base theme: this sheet only redefines colour variables
* and adds application-specific classes. It never restyles individual controls, so upgrading AtlantaFX
* cannot break it.
*
* Palette source: IntelliJ IDEA New UI dark canvas + Immich accent.
*/
.root {
/* ---- Surfaces ---------------------------------------------------------- */
-color-bg-default: #1E1F22; /* main viewport / canvas */
-color-bg-subtle: #2B2D30; /* tool windows: sidebar, header, inspector */
-color-bg-inset: #18191B; /* recessed areas, scrollbar tracks */
-color-bg-overlay: #2B2D30; /* popups, menus, modal surfaces */
/* ---- Borders ----------------------------------------------------------- */
-color-border-default: #393B40;
-color-border-muted: #303236;
-color-border-subtle: #2B2D30;
/* ---- Foreground -------------------------------------------------------- */
-color-fg-default: #DFE1E5;
-color-fg-muted: #A9B0B7;
-color-fg-subtle: #6F737A;
-color-fg-emphasis: #FFFFFF;
/* ---- Accent (IntelliJ blue, Immich indigo for emphasis) ---------------- */
-color-accent-fg: #548AF7;
-color-accent-emphasis: #3574F0;
-color-accent-muted: rgba(53, 116, 240, 0.45);
-color-accent-subtle: rgba(53, 116, 240, 0.16);
-color-neutral-emphasis: #4E5157;
-color-neutral-emphasis-plus: #6F737A;
-color-neutral-muted: rgba(169, 176, 183, 0.28);
-color-neutral-subtle: rgba(169, 176, 183, 0.10);
-color-shadow-default: #000000;
/* ---- Pholio-specific tokens ------------------------------------------- */
-pholio-accent-immich: #6366F1;
-pholio-card-bg: #2B2D30;
-pholio-card-radius: 6px;
-pholio-hover-overlay: #0000004D;
-pholio-header-height: 40px;
-pholio-rail-width: 44px;
-pholio-panel-width: 300px;
-pholio-statusbar-height: 26px;
/*
* Inter first, then the platform UI font. The fallback chain is what lets the application ship
* without vendoring font binaries while still looking right when they are present.
*/
-fx-font-family: "Inter", "SF Pro Text", "Segoe UI Variable Text", "Segoe UI", "Noto Sans", sans-serif;
-fx-font-size: 13px;
}
/* ========================================================================== */
/* Structure */
/* ========================================================================== */
.app-shell {
-fx-background-color: -color-bg-default;
}
/* Title bar, integrated into the OS window decoration area. */
.app-header-bar {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent transparent -color-border-default transparent;
-fx-border-width: 0 0 1 0;
-fx-padding: 0 8 0 8;
}
.app-header-bar .app-logo {
-fx-font-size: 14px;
-fx-font-weight: bold;
-fx-text-fill: -color-fg-default;
-fx-padding: 0 10 0 4;
}
.module-selector {
-fx-spacing: 2;
}
.module-selector .toggle-button {
-fx-background-color: transparent;
-fx-background-radius: 5px;
-fx-text-fill: -color-fg-muted;
-fx-padding: 4 12 4 12;
-fx-border-width: 0;
}
.module-selector .toggle-button:hover {
-fx-background-color: -color-neutral-subtle;
-fx-text-fill: -color-fg-default;
}
.module-selector .toggle-button:selected {
-fx-background-color: -color-accent-subtle;
-fx-text-fill: -color-accent-fg;
}
.global-search {
-fx-background-color: -color-bg-inset;
-fx-background-radius: 5px;
-fx-border-color: -color-border-default;
-fx-border-radius: 5px;
-fx-border-width: 1px;
-fx-prompt-text-fill: -color-fg-subtle;
-fx-padding: 3 8 3 8;
}
.global-search:focused {
-fx-border-color: -color-accent-emphasis;
}
/* Left navigation: icon rail when collapsed, tree when expanded. */
.navigation-rail {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent -color-border-default transparent transparent;
-fx-border-width: 0 1 0 0;
}
.navigation-rail .rail-button {
-fx-background-color: transparent;
-fx-background-radius: 6px;
-fx-padding: 6;
}
.navigation-rail .rail-button:hover {
-fx-background-color: -color-neutral-subtle;
}
.navigation-rail .rail-button:selected,
.navigation-rail .rail-button.active {
-fx-background-color: -color-accent-subtle;
}
.navigation-rail .rail-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-padding: 8 12 4 12;
}
/* Right inspector: EXIF/IPTC details and the task list. */
.inspector-panel {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent transparent transparent -color-border-default;
-fx-border-width: 0 0 0 1;
}
.inspector-panel .section-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-padding: 10 0 4 0;
}
/* Technical metadata is monospaced so numeric columns line up. */
.exif-key {
-fx-text-fill: -color-fg-muted;
-fx-font-family: "JetBrains Mono", "SF Mono", "Cascadia Mono", "Consolas", monospace;
-fx-font-size: 11px;
}
.exif-value {
-fx-text-fill: -color-fg-default;
-fx-font-family: "JetBrains Mono", "SF Mono", "Cascadia Mono", "Consolas", monospace;
-fx-font-size: 11px;
}
.status-bar {
-fx-background-color: -color-bg-subtle;
-fx-border-color: -color-border-default transparent transparent transparent;
-fx-border-width: 1 0 0 0;
-fx-padding: 0 10 0 10;
-fx-min-height: -pholio-statusbar-height;
-fx-pref-height: -pholio-statusbar-height;
}
.status-bar .status-label {
-fx-text-fill: -color-fg-muted;
-fx-font-size: 11px;
}
.task-monitor {
-fx-background-color: transparent;
-fx-background-radius: 4px;
-fx-padding: 2 6 2 6;
-fx-cursor: hand;
}
.task-monitor:hover {
-fx-background-color: -color-neutral-subtle;
}
/* ========================================================================== */
/* Photo cards */
/* ========================================================================== */
.photo-card {
-fx-background-color: -pholio-card-bg;
-fx-background-radius: -pholio-card-radius;
-fx-border-radius: -pholio-card-radius;
-fx-border-color: transparent;
-fx-border-width: 1px;
-fx-padding: 0;
}
.photo-card:hover {
-fx-border-color: -color-accent-emphasis;
-fx-effect: dropshadow(gaussian, -color-accent-muted, 8, 0, 0, 0);
}
.photo-card:selected,
.photo-card.selected {
-fx-border-color: -color-accent-emphasis;
-fx-border-width: 2px;
}
.photo-card .hover-overlay {
-fx-background-color: -pholio-hover-overlay;
-fx-background-radius: -pholio-card-radius;
}
/* ========================================================================== */
/* Icons */
/* ========================================================================== */
.ikonli-font-icon {
-fx-icon-color: -color-fg-muted;
-fx-fill: -color-fg-muted;
}
.rail-button:hover .ikonli-font-icon,
.task-monitor:hover .ikonli-font-icon {
-fx-icon-color: -color-fg-default;
-fx-fill: -color-fg-default;
}
.rail-button:selected .ikonli-font-icon,
.rail-button.active .ikonli-font-icon {
-fx-icon-color: -color-accent-fg;
-fx-fill: -color-accent-fg;
}
/* ========================================================================== */
/* Modals (AtlantaFX ModalPane — never an OS dialog) */
/* ========================================================================== */
.modal-card {
-fx-background-color: -color-bg-overlay;
-fx-background-radius: 8px;
-fx-border-color: -color-border-default;
-fx-border-radius: 8px;
-fx-border-width: 1px;
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.55), 24, 0, 0, 8);
}
.modal-card .modal-title {
-fx-font-size: 15px;
-fx-font-weight: bold;
-fx-text-fill: -color-fg-default;
}
.modal-card .modal-note {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-wrap-text: true;
}
/* ========================================================================== */
/* Empty states */
/* ========================================================================== */
.empty-state {
-fx-background-color: -color-bg-default;
}
.empty-state .empty-title {
-fx-font-size: 16px;
-fx-text-fill: -color-fg-default;
}
.empty-state .empty-subtitle {
-fx-font-size: 12px;
-fx-text-fill: -color-fg-subtle;
}
/* Thin, unobtrusive scrollbars in the IntelliJ manner. */
.scroll-bar {
-fx-background-color: transparent;
}
.scroll-bar .track {
-fx-background-color: transparent;
}
.scroll-bar .thumb {
-fx-background-color: -color-neutral-muted;
-fx-background-radius: 4px;
}
.scroll-bar .thumb:hover {
-fx-background-color: -color-neutral-emphasis-plus;
}
@@ -0,0 +1,295 @@
/*
* Pholio — IntelliJ New UI x Immich, light.
*
* Structural rules are identical to the dark sheet; only the palette differs. Kept as a separate file
* rather than a `.light` modifier class so switching is a stylesheet swap with no restyle pass over the
* whole scenegraph.
*/
.root {
/* ---- Surfaces ---------------------------------------------------------- */
-color-bg-default: #FFFFFF; /* main viewport / canvas */
-color-bg-subtle: #F7F8FA; /* tool windows: sidebar, header, inspector */
-color-bg-inset: #EBECF0;
-color-bg-overlay: #FFFFFF;
/* ---- Borders ----------------------------------------------------------- */
-color-border-default: #DFE1E5;
-color-border-muted: #EBECF0;
-color-border-subtle: #F2F3F5;
/* ---- Foreground -------------------------------------------------------- */
-color-fg-default: #1E1F22;
-color-fg-muted: #5A5D63;
-color-fg-subtle: #8C8F96;
-color-fg-emphasis: #FFFFFF;
/* ---- Accent ------------------------------------------------------------ */
-color-accent-fg: #2E62D4;
-color-accent-emphasis: #3574F0;
-color-accent-muted: rgba(53, 116, 240, 0.40);
-color-accent-subtle: rgba(53, 116, 240, 0.12);
-color-neutral-emphasis: #A9ACB2;
-color-neutral-emphasis-plus: #8C8F96;
-color-neutral-muted: rgba(90, 93, 99, 0.24);
-color-neutral-subtle: rgba(90, 93, 99, 0.08);
-color-shadow-default: #C9CCD1;
/* ---- Pholio-specific tokens ------------------------------------------- */
-pholio-accent-immich: #4F46E5;
-pholio-card-bg: #F2F3F5;
-pholio-card-radius: 6px;
-pholio-hover-overlay: #00000026;
-pholio-header-height: 40px;
-pholio-rail-width: 44px;
-pholio-panel-width: 300px;
-pholio-statusbar-height: 26px;
-fx-font-family: "Inter", "SF Pro Text", "Segoe UI Variable Text", "Segoe UI", "Noto Sans", sans-serif;
-fx-font-size: 13px;
}
/* ========================================================================== */
/* Structure */
/* ========================================================================== */
.app-shell {
-fx-background-color: -color-bg-default;
}
.app-header-bar {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent transparent -color-border-default transparent;
-fx-border-width: 0 0 1 0;
-fx-padding: 0 8 0 8;
}
.app-header-bar .app-logo {
-fx-font-size: 14px;
-fx-font-weight: bold;
-fx-text-fill: -color-fg-default;
-fx-padding: 0 10 0 4;
}
.module-selector {
-fx-spacing: 2;
}
.module-selector .toggle-button {
-fx-background-color: transparent;
-fx-background-radius: 5px;
-fx-text-fill: -color-fg-muted;
-fx-padding: 4 12 4 12;
-fx-border-width: 0;
}
.module-selector .toggle-button:hover {
-fx-background-color: -color-neutral-subtle;
-fx-text-fill: -color-fg-default;
}
.module-selector .toggle-button:selected {
-fx-background-color: -color-accent-subtle;
-fx-text-fill: -color-accent-fg;
}
.global-search {
-fx-background-color: -color-bg-default;
-fx-background-radius: 5px;
-fx-border-color: -color-border-default;
-fx-border-radius: 5px;
-fx-border-width: 1px;
-fx-prompt-text-fill: -color-fg-subtle;
-fx-padding: 3 8 3 8;
}
.global-search:focused {
-fx-border-color: -color-accent-emphasis;
}
.navigation-rail {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent -color-border-default transparent transparent;
-fx-border-width: 0 1 0 0;
}
.navigation-rail .rail-button {
-fx-background-color: transparent;
-fx-background-radius: 6px;
-fx-padding: 6;
}
.navigation-rail .rail-button:hover {
-fx-background-color: -color-neutral-subtle;
}
.navigation-rail .rail-button:selected,
.navigation-rail .rail-button.active {
-fx-background-color: -color-accent-subtle;
}
.navigation-rail .rail-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-padding: 8 12 4 12;
}
.inspector-panel {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent transparent transparent -color-border-default;
-fx-border-width: 0 0 0 1;
}
.inspector-panel .section-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-padding: 10 0 4 0;
}
.exif-key {
-fx-text-fill: -color-fg-muted;
-fx-font-family: "JetBrains Mono", "SF Mono", "Cascadia Mono", "Consolas", monospace;
-fx-font-size: 11px;
}
.exif-value {
-fx-text-fill: -color-fg-default;
-fx-font-family: "JetBrains Mono", "SF Mono", "Cascadia Mono", "Consolas", monospace;
-fx-font-size: 11px;
}
.status-bar {
-fx-background-color: -color-bg-subtle;
-fx-border-color: -color-border-default transparent transparent transparent;
-fx-border-width: 1 0 0 0;
-fx-padding: 0 10 0 10;
-fx-min-height: -pholio-statusbar-height;
-fx-pref-height: -pholio-statusbar-height;
}
.status-bar .status-label {
-fx-text-fill: -color-fg-muted;
-fx-font-size: 11px;
}
.task-monitor {
-fx-background-color: transparent;
-fx-background-radius: 4px;
-fx-padding: 2 6 2 6;
-fx-cursor: hand;
}
.task-monitor:hover {
-fx-background-color: -color-neutral-subtle;
}
/* ========================================================================== */
/* Photo cards */
/* ========================================================================== */
.photo-card {
-fx-background-color: -pholio-card-bg;
-fx-background-radius: -pholio-card-radius;
-fx-border-radius: -pholio-card-radius;
-fx-border-color: transparent;
-fx-border-width: 1px;
}
.photo-card:hover {
-fx-border-color: -color-accent-emphasis;
-fx-effect: dropshadow(gaussian, -color-accent-muted, 8, 0, 0, 0);
}
.photo-card:selected,
.photo-card.selected {
-fx-border-color: -color-accent-emphasis;
-fx-border-width: 2px;
}
.photo-card .hover-overlay {
-fx-background-color: -pholio-hover-overlay;
-fx-background-radius: -pholio-card-radius;
}
/* ========================================================================== */
/* Icons */
/* ========================================================================== */
.ikonli-font-icon {
-fx-icon-color: -color-fg-muted;
-fx-fill: -color-fg-muted;
}
.rail-button:hover .ikonli-font-icon,
.task-monitor:hover .ikonli-font-icon {
-fx-icon-color: -color-fg-default;
-fx-fill: -color-fg-default;
}
.rail-button:selected .ikonli-font-icon,
.rail-button.active .ikonli-font-icon {
-fx-icon-color: -color-accent-fg;
-fx-fill: -color-accent-fg;
}
/* ========================================================================== */
/* Modals */
/* ========================================================================== */
.modal-card {
-fx-background-color: -color-bg-overlay;
-fx-background-radius: 8px;
-fx-border-color: -color-border-default;
-fx-border-radius: 8px;
-fx-border-width: 1px;
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.18), 24, 0, 0, 8);
}
.modal-card .modal-title {
-fx-font-size: 15px;
-fx-font-weight: bold;
-fx-text-fill: -color-fg-default;
}
.modal-card .modal-note {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-wrap-text: true;
}
/* ========================================================================== */
/* Empty states */
/* ========================================================================== */
.empty-state {
-fx-background-color: -color-bg-default;
}
.empty-state .empty-title {
-fx-font-size: 16px;
-fx-text-fill: -color-fg-default;
}
.empty-state .empty-subtitle {
-fx-font-size: 12px;
-fx-text-fill: -color-fg-subtle;
}
.scroll-bar {
-fx-background-color: transparent;
}
.scroll-bar .track {
-fx-background-color: transparent;
}
.scroll-bar .thumb {
-fx-background-color: -color-neutral-muted;
-fx-background-radius: 4px;
}
.scroll-bar .thumb:hover {
-fx-background-color: -color-neutral-emphasis-plus;
}
+70
View File
@@ -0,0 +1,70 @@
# Root bundle (English). Resolved for every locale that has no dedicated messages_xx.properties,
# and for Locale.ENGLISH itself. French lives in messages_fr.properties.
app.name=Pholio
app.title=Pholio — Photo library manager
# --- Modules (header bar) ---
module.import=Import
module.cull=Cull
module.reorganize=Reorganize
module.maintenance=Maintenance
module.export=Export
# --- Navigation rail ---
nav.library=Library
nav.albums=Albums
nav.tags=Tags
nav.map=Map
nav.favorites=Favorites
nav.rejected=Rejected
nav.expand=Expand panel
nav.collapse=Collapse panel
nav.filters=Quick filters
# --- Header bar actions ---
action.search.prompt=Search the library…
action.settings=Preferences
action.theme.toggle=Switch theme
action.language=Language
action.close=Close
action.save=Save
action.cancel=Cancel
action.apply=Apply
# --- Inspector ---
inspector.tab.details=Details
inspector.tab.tasks=Tasks
inspector.empty=No photo selected
inspector.tasks.empty=No running task
# --- Status bar ---
status.library.photos={0} photos
status.library.notConfigured=No library configured
status.tasks.idle=Idle
status.tasks.running={0} task(s) running
# --- Gallery ---
gallery.empty.title=Your library is empty
gallery.empty.subtitle=Set a root directory, then import or scan your photos.
gallery.chooseRoot=Choose a root directory…
# --- Settings modal ---
settings.title=Preferences
settings.appearance=Appearance
settings.appearance.theme=Theme
settings.appearance.language=Language
settings.library=Library
settings.library.rootPath=Root directory
settings.library.rootPath.unset=Not set
settings.sync=Synchronisation
settings.sync.enabled=Periodic background scan
settings.sync.interval=Interval (minutes)
settings.thumbnails=Thumbnails
settings.thumbnails.ramCache=RAM cache (MB)
settings.thumbnails.diskCache=Disk cache (MB)
settings.note.windowGeometry=Window position and size are saved automatically and can only be changed \
by editing preferences.yaml.
# --- Themes ---
theme.dark=IntelliJ Dark
theme.light=IntelliJ Light
+69
View File
@@ -0,0 +1,69 @@
# Reference bundle. Every user-visible string in the application must have a key here.
app.name=Pholio
app.title=Pholio — Gestion de photothèque
# --- Modules (header bar) ---
module.import=Importer
module.cull=Trier
module.reorganize=Réorganiser
module.maintenance=Maintenance
module.export=Exporter
# --- Navigation rail ---
nav.library=Photothèque
nav.albums=Albums
nav.tags=Mots-clés
nav.map=Carte
nav.favorites=Favoris
nav.rejected=Rejetées
nav.expand=Développer le panneau
nav.collapse=Réduire le panneau
nav.filters=Filtres rapides
# --- Header bar actions ---
action.search.prompt=Rechercher dans la photothèque…
action.settings=Préférences
action.theme.toggle=Changer de thème
action.language=Langue
action.close=Fermer
action.save=Enregistrer
action.cancel=Annuler
action.apply=Appliquer
# --- Inspector ---
inspector.tab.details=Détails
inspector.tab.tasks=Tâches
inspector.empty=Aucune photo sélectionnée
inspector.tasks.empty=Aucune tâche en cours
# --- Status bar ---
status.library.photos={0} photos
status.library.notConfigured=Aucune photothèque configurée
status.tasks.idle=Inactif
status.tasks.running={0} tâche(s) en cours
# --- Gallery ---
gallery.empty.title=Votre photothèque est vide
gallery.empty.subtitle=Définissez un répertoire racine, puis importez ou analysez vos photos.
gallery.chooseRoot=Choisir un répertoire racine…
# --- Settings modal ---
settings.title=Préférences
settings.appearance=Apparence
settings.appearance.theme=Thème
settings.appearance.language=Langue
settings.library=Photothèque
settings.library.rootPath=Répertoire racine
settings.library.rootPath.unset=Non défini
settings.sync=Synchronisation
settings.sync.enabled=Analyse périodique en arrière-plan
settings.sync.interval=Intervalle (minutes)
settings.thumbnails=Vignettes
settings.thumbnails.ramCache=Cache mémoire (Mo)
settings.thumbnails.diskCache=Cache disque (Mo)
settings.note.windowGeometry=La position et la taille des fenêtres sont enregistrées automatiquement et \
ne sont modifiables que dans le fichier preferences.yaml.
# --- Themes ---
theme.dark=IntelliJ Dark
theme.light=IntelliJ Light
@@ -0,0 +1,125 @@
package io.pholio;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
/**
* Executable form of the project's architectural rules.
*
* <p>The project is a single Maven module organised by package, so nothing in the compiler stops a UI class
* from reaching into persistence or a CLI command from importing JavaFX. These rules are that enforcement:
* they fail the build the moment a boundary is crossed, which is the only reason the boundaries survive
* contact with a growing codebase.
*/
@AnalyzeClasses(packages = "io.pholio", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureRulesTest {
/**
* The headless CLI must be able to run on a server with no display. If a {@code javafx.*} class is
* reachable from a command, the JavaFX runtime gets loaded and batch processing dies on a machine
* without a graphics stack.
*/
@ArchTest
static final ArchRule cliIsFreeOfJavaFx = noClasses()
.that()
.resideInAPackage("io.pholio.cli..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("javafx..")
.because("the CLI must run headless on a server with no graphics stack");
/**
* The launcher decides between CLI and GUI. If it referenced JavaFX itself, resolving the class would
* load the toolkit before the decision was even made.
*/
@ArchTest
static final ArchRule launcherIsFreeOfJavaFx = noClasses()
.that()
.haveNameMatching(".*\\.(PholioApplication|LaunchMode|PholioBootstrap|Profiles)")
.should()
.dependOnClassesThat()
.resideInAnyPackage("javafx..")
.because("the entry point must not load the JavaFX runtime before choosing the launch mode");
/**
* The domain layer stays plain Java: no UI toolkit, no framework, no persistence.
*
* <p>{@code allowEmptyShould} because {@code io.pholio.domain} arrives with the persistence phase. The
* rule is written now so the constraint is in force from the first class placed there rather than being
* retrofitted onto a layer that already leaks.
*/
@ArchTest
static final ArchRule domainIsIndependent = noClasses()
.that()
.resideInAPackage("io.pholio.domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("javafx..", "org.springframework..", "atlantafx..", "org.kordamp..", "java.sql..")
.because("domain models and contracts must not depend on the UI, the framework or persistence")
.allowEmptyShould(true);
/** Infrastructure is shared with the headless CLI, so it cannot depend on the toolkit either. */
@ArchTest
static final ArchRule infrastructureIsFreeOfJavaFxControls = noClasses()
.that()
.resideInAPackage("io.pholio.infra..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("javafx.scene..", "javafx.stage..", "javafx.application..", "atlantafx..")
.because("infrastructure services are used by the headless CLI as well as the desktop shell");
/** Views are built in Java. FXML would put layout outside the type system and outside refactoring. */
@ArchTest
static final ArchRule noFxml = noClasses()
.should()
.dependOnClassesThat()
.haveNameMatching("javafx\\.fxml\\..*")
.because("all views are constructed in plain Java; FXML is not used anywhere");
/**
* Native dialogs open an OS window the AtlantaFX theme cannot reach, so they look like a different
* application. {@code ModalService} renders dialogs inside the scenegraph instead.
*/
@ArchTest
static final ArchRule noNativeDialogs = noClasses()
.should()
.dependOnClassesThat()
.haveNameMatching("javafx\\.scene\\.control\\.(Alert|Dialog|ChoiceDialog|TextInputDialog)")
.because("in-app ModalPane overlays are used so dialogs stay themed and inside the window");
/** Presentation talks to services, never to repositories: that is what keeps queries off the FX thread. */
@ArchTest
static final ArchRule uiDoesNotReachPersistence = noClasses()
.that()
.resideInAPackage("io.pholio.ui..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("io.pholio.infra.persistence..", "java.sql..", "javax.sql..")
.because("views must go through services so database work stays off the JavaFX thread");
/** Preferences are reached through the interface, so tests and alternative stores stay possible. */
@ArchTest
static final ArchRule preferencesAccessedThroughInterface = noClasses()
.that()
.resideInAPackage("io.pholio.ui..")
.should()
.dependOnClassesThat()
.haveNameMatching(".*YamlPreferencesService")
.because("callers depend on PreferencesService, not on its YAML implementation");
/** Views must clean up their bindings; the switcher calls dispose() but cannot invent one. */
@ArchTest
static final ArchRule viewsAreDisposable = classes()
.that()
.resideInAPackage("io.pholio.ui.view..")
.and()
.haveSimpleNameEndingWith("View")
.should()
.implement(io.pholio.ui.common.Disposable.class)
.because("the ViewSwitcher disposes the outgoing view to release its listeners and bindings");
}
@@ -0,0 +1,55 @@
package io.pholio;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Launch-mode dispatch.
*
* <p>Getting this wrong is user-visible in both directions: a false CLI reading means double-clicking the
* application prints usage instead of opening a window, and a false GUI reading means a headless server tries
* to start a graphics stack.
*/
class LaunchModeTest {
@Test
void noArgumentsOpensTheDesktopShell() {
assertThat(LaunchMode.of(new String[0])).isEqualTo(LaunchMode.GUI);
assertThat(LaunchMode.of(null)).isEqualTo(LaunchMode.GUI);
}
@Test
void aSubcommandRunsHeadless() {
assertThat(LaunchMode.of(new String[] {"scan"})).isEqualTo(LaunchMode.CLI);
assertThat(LaunchMode.of(new String[] {"scan", "/photos"})).isEqualTo(LaunchMode.CLI);
assertThat(LaunchMode.of(new String[] {"--version"})).isEqualTo(LaunchMode.CLI);
assertThat(LaunchMode.of(new String[] {"--help"})).isEqualTo(LaunchMode.CLI);
}
/**
* Maven, IDE run configurations and packaged launchers all inject framework switches. Treating those as
* application arguments would silently break the desktop launch.
*/
@Test
void frameworkArgumentsAloneStillOpenTheShell() {
assertThat(LaunchMode.of(new String[] {"--spring.profiles.active=dev"})).isEqualTo(LaunchMode.GUI);
assertThat(LaunchMode.of(new String[] {"--logging.level.io.pholio=TRACE"})).isEqualTo(LaunchMode.GUI);
assertThat(LaunchMode.of(new String[] {"--pholio.pools.thumbnail=4"})).isEqualTo(LaunchMode.GUI);
assertThat(LaunchMode.of(new String[] {"-Dfoo=bar"})).isEqualTo(LaunchMode.GUI);
assertThat(LaunchMode.of(new String[] {"--debug"})).isEqualTo(LaunchMode.GUI);
assertThat(LaunchMode.of(new String[] {""})).isEqualTo(LaunchMode.GUI);
}
@Test
void frameworkArgumentsMixedWithACommandStillRunHeadless() {
assertThat(LaunchMode.of(new String[] {"--spring.profiles.active=dev", "scan"}))
.isEqualTo(LaunchMode.CLI);
}
@Test
void forceGuiFlagWinsOverAnyCommand() {
assertThat(LaunchMode.of(new String[] {"scan", LaunchMode.FORCE_GUI_FLAG})).isEqualTo(LaunchMode.GUI);
assertThat(LaunchMode.of(new String[] {LaunchMode.FORCE_GUI_FLAG})).isEqualTo(LaunchMode.GUI);
}
}
@@ -0,0 +1,82 @@
package io.pholio.cli;
import static org.assertj.core.api.Assertions.assertThat;
import io.pholio.Profiles;
import io.pholio.infra.config.ExecutorConfig;
import io.pholio.infra.preferences.PreferencesService;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ActiveProfiles;
/**
* Proves the headless context is genuinely headless.
*
* <p>The load itself is half the assertion: if any JavaFX-dependent bean were eligible under the {@code cli}
* profile, constructing it would drag in the toolkit and this would fail on a machine without a display.
*/
@SpringBootTest
@ActiveProfiles(Profiles.CLI)
class CliContextTest {
@Autowired
private ConfigurableApplicationContext context;
@Test
void commandsAreAvailableAsBeans() {
assertThat(context.getBean(PholioCommand.class)).isNotNull();
assertThat(context.getBean(ScanCommand.class)).isNotNull();
}
@Test
void sharedInfrastructureIsAvailableHeadless() {
// The CLI reuses exactly the services the desktop shell uses.
assertThat(context.getBean(PreferencesService.class)).isNotNull();
assertThat(context.getBean(DataSource.class)).isNotNull();
}
@Test
void allThreadPoolsArePresent() {
assertThat(context.containsBean(ExecutorConfig.DATABASE_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.THUMBNAIL_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.METADATA_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.IMAGE_ANALYSIS_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.BACKGROUND_SYNC_EXECUTOR)).isTrue();
assertThat(context.containsBean(ExecutorConfig.APPLICATION_SCHEDULER)).isTrue();
}
/**
* No presentation bean may be registered.
*
* <p>Bean class names are compared as strings, read from the bean definitions, precisely so the check
* itself does not classload any UI type — asserting the absence of JavaFX by loading JavaFX would be
* self-defeating.
*/
@Test
void noPresentationBeansAreRegistered() {
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
for (String name : beanFactory.getBeanDefinitionNames()) {
String className = beanFactory.getBeanDefinition(name).getBeanClassName();
if (className == null) {
continue;
}
assertThat(className)
.as("bean '%s' belongs to the presentation layer and must not exist headless", name)
.doesNotStartWith("io.pholio.ui.");
assertThat(className)
.as("bean '%s' is a JavaFX type and must not exist headless", name)
.doesNotStartWith("javafx.");
}
}
@Test
void guiProfileIsNotActive() {
assertThat(context.getEnvironment().getActiveProfiles()).containsExactly(Profiles.CLI);
assertThat(context.getEnvironment().acceptsProfiles(profiles -> profiles.test(Profiles.GUI))).isFalse();
}
}
@@ -0,0 +1,153 @@
package io.pholio.cli;
import static org.assertj.core.api.Assertions.assertThat;
import io.pholio.infra.preferences.Preferences;
import io.pholio.infra.preferences.PreferencesService;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.UnaryOperator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import picocli.CommandLine;
class ScanCommandTest {
@TempDir
Path library;
private Preferences preferences = Preferences.defaults();
@BeforeEach
void createFixture() throws IOException {
Files.createDirectories(library.resolve("sub/deeper"));
for (String name : new String[] {"a.jpg", "b.JPG", "c.png", "d.webp", "e.CR2", "notes.txt"}) {
Files.createFile(library.resolve(name));
}
Files.createFile(library.resolve("sub/f.jpeg"));
Files.createFile(library.resolve("sub/deeper/g.tif"));
}
@Test
void countsMediaFilesRecursivelyByDefault() {
Result result = run(library.toString());
assertThat(result.exitCode).isZero();
assertThat(result.output).contains("Total: 7 media files (1 other files ignored)");
}
/** Extensions differ in case on every real photo library; matching must not. */
@Test
void matchesExtensionsCaseInsensitively() {
Result result = run(library.toString());
assertThat(result.output).contains("jpg 2");
assertThat(result.output).contains("cr2 1");
}
/**
* Guards the flag semantics. An earlier {@code negatable = true} spelling silently ignored
* {@code --no-recursive}, and then inverted {@code -r}; a plain opt-out flag cannot do either.
*/
@Test
void noRecursiveStaysInTheTopDirectory() {
Result result = run(library.toString(), "--no-recursive");
assertThat(result.exitCode).isZero();
assertThat(result.output).contains("Total: 5 media files (1 other files ignored)");
assertThat(result.output).doesNotContain("tif");
}
@Test
void shortFormMatchesLongForm() {
assertThat(run(library.toString(), "-R").output)
.isEqualTo(run(library.toString(), "--no-recursive").output);
}
/** Distinct exit codes let a shell script tell "nothing to do" from "you gave me a bad path". */
@Test
void reportsAnEmptyDirectoryWithADistinctExitCode() throws IOException {
Path empty = Files.createDirectory(library.resolve("empty"));
Result result = run(empty.toString(), "--no-recursive");
assertThat(result.exitCode).isEqualTo(1);
assertThat(result.output).contains("No recognised media files found.");
}
@Test
void rejectsAPathThatIsNotADirectory() {
Result result = run(library.resolve("a.jpg").toString());
assertThat(result.exitCode).isEqualTo(2);
}
@Test
void fallsBackToTheConfiguredLibraryRootWhenNoPathIsGiven() {
preferences = preferences.withLibrary(new Preferences.Library(library.toString()));
Result result = run();
assertThat(result.exitCode).isZero();
assertThat(result.output).contains("Total: 7 media files");
}
@Test
void failsWhenNeitherAPathNorALibraryRootIsAvailable() {
Result result = run();
assertThat(result.exitCode).isEqualTo(2);
}
/**
* Executes the command with {@code System.out} and {@code System.err} redirected.
*
* <p>The command reports through {@code System.out} rather than PicoCLI's writer — the right call for a
* tool whose output gets piped — so capturing it means swapping the stream. Swapping global streams makes
* this class unsafe to run in parallel with others, which matches Surefire's sequential default.
*/
private Result run(String... args) {
PrintStream originalOut = System.out;
PrintStream originalErr = System.err;
ByteArrayOutputStream captured = new ByteArrayOutputStream();
PrintStream stream = new PrintStream(captured, true, StandardCharsets.UTF_8);
try {
System.setOut(stream);
System.setErr(stream);
int exitCode = new CommandLine(new ScanCommand(new StubPreferences())).execute(args);
stream.flush();
return new Result(exitCode, captured.toString(StandardCharsets.UTF_8));
} finally {
System.setOut(originalOut);
System.setErr(originalErr);
}
}
private record Result(int exitCode, String output) {
}
/** Minimal stand-in: the command only ever reads the library branch. */
private final class StubPreferences implements PreferencesService {
@Override
public Preferences get() {
return preferences;
}
@Override
public Preferences update(UnaryOperator<Preferences> mutator) {
preferences = mutator.apply(preferences);
return preferences;
}
@Override
public void flush() {
// Nothing to write: this stub holds preferences in memory only.
}
}
}
@@ -0,0 +1,225 @@
package io.pholio.infra.preferences;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.context.ApplicationEventPublisher;
class YamlPreferencesServiceTest {
@TempDir
Path tempDir;
private final List<Object> publishedEvents = new ArrayList<>();
private final ApplicationEventPublisher publisher = publishedEvents::add;
@Test
void startsFromDefaultsWhenNoFileExists() {
YamlPreferencesService service = newService();
Preferences preferences = service.get();
assertThat(preferences.ui().theme()).isEqualTo("DARK");
assertThat(preferences.ui().locale()).isEqualTo("fr");
assertThat(preferences.library().hasRootPath()).isFalse();
assertThat(preferences.windows()).isEmpty();
}
@Test
void writesAndReloadsTheFullTree() {
Path file = tempDir.resolve("preferences.yaml");
YamlPreferencesService service = new YamlPreferencesService(publisher, file);
service.update(current -> current
.withLibrary(new Preferences.Library("/photos"))
.withUi(current.ui().withTheme("LIGHT").withLocale("en").withThumbnailSize(320))
.withSync(new Preferences.Sync(false, 45))
.withWindow("main", new WindowGeometry(10.0, 20.0, 800.0, 600.0, true)));
service.flush();
Preferences reloaded = new YamlPreferencesService(publisher, file).get();
assertThat(reloaded.library().rootPath()).isEqualTo("/photos");
assertThat(reloaded.ui().theme()).isEqualTo("LIGHT");
assertThat(reloaded.ui().locale()).isEqualTo("en");
assertThat(reloaded.ui().thumbnailSize()).isEqualTo(320);
assertThat(reloaded.sync().enabled()).isFalse();
assertThat(reloaded.sync().intervalMinutes()).isEqualTo(45);
assertThat(reloaded.window("main")).contains(new WindowGeometry(10.0, 20.0, 800.0, 600.0, true));
}
/**
* Derived state must not reach the file. {@code hasRootPath()} is named to avoid Jackson's bean-property
* detection; an {@code isConfigured()} would silently add a phantom key to a file users hand-edit.
*/
@Test
void doesNotSerialiseDerivedProperties() throws IOException {
Path file = tempDir.resolve("preferences.yaml");
YamlPreferencesService service = new YamlPreferencesService(publisher, file);
service.update(current -> current.withLibrary(new Preferences.Library("/photos")));
service.flush();
String yaml = Files.readString(file);
assertThat(yaml).contains("root-path: /photos");
assertThat(yaml).doesNotContain("configured");
assertThat(yaml).doesNotContain("hasRootPath");
}
/** Keys are kebab-case so the file matches the conventions used in application.yaml. */
@Test
void usesKebabCaseKeys() throws IOException {
Path file = tempDir.resolve("preferences.yaml");
YamlPreferencesService service = new YamlPreferencesService(publisher, file);
service.update(current -> current.withSync(new Preferences.Sync(true, 30)));
service.flush();
String yaml = Files.readString(file);
assertThat(yaml).contains("interval-minutes: 30");
assertThat(yaml).contains("thumbnail-size:");
assertThat(yaml).doesNotContain("intervalMinutes");
}
/** A hand-edited file with only the keys the user cares about must still load. */
@Test
void toleratesPartialFiles() throws IOException {
Path file = tempDir.resolve("preferences.yaml");
Files.writeString(file, """
library:
root-path: /nas/photos
sync:
interval-minutes: 5
""");
Preferences preferences = new YamlPreferencesService(publisher, file).get();
assertThat(preferences.library().rootPath()).isEqualTo("/nas/photos");
assertThat(preferences.sync().intervalMinutes()).isEqualTo(5);
// Branches the file never mentioned fall back to defaults rather than being null.
assertThat(preferences.ui().theme()).isEqualTo("DARK");
assertThat(preferences.thumbnails().ramCacheMb()).isEqualTo(512);
}
/**
* A key omitted <em>within</em> a branch the file does mention must still get its real default, not the
* Java zero-value. Editing {@code sync.interval-minutes} by hand must not silently disable sync, and
* mentioning {@code ui.theme} must not collapse both panels.
*/
@Test
void omittedKeysInsideABranchKeepTheirRealDefaults() throws IOException {
Path file = tempDir.resolve("preferences.yaml");
Files.writeString(file, """
sync:
interval-minutes: 5
ui:
theme: LIGHT
""");
Preferences preferences = new YamlPreferencesService(publisher, file).get();
assertThat(preferences.sync().enabled()).isTrue();
assertThat(preferences.sync().intervalMinutes()).isEqualTo(5);
assertThat(preferences.ui().theme()).isEqualTo("LIGHT");
assertThat(preferences.ui().leftPanelExpanded()).isTrue();
assertThat(preferences.ui().rightPanelVisible()).isTrue();
assertThat(preferences.ui().thumbnailSize()).isEqualTo(200);
}
/** An explicit {@code false} must survive, or the "absent means default" rule would swallow it. */
@Test
void explicitFalseIsNotOverwrittenByTheDefault() throws IOException {
Path file = tempDir.resolve("preferences.yaml");
Files.writeString(file, """
sync:
enabled: false
ui:
left-panel-expanded: false
""");
Preferences preferences = new YamlPreferencesService(publisher, file).get();
assertThat(preferences.sync().enabled()).isFalse();
assertThat(preferences.ui().leftPanelExpanded()).isFalse();
assertThat(preferences.ui().rightPanelVisible()).isTrue();
}
/** Unknown keys are ignored so a file written by a newer build does not break an older one. */
@Test
void ignoresUnknownKeys() throws IOException {
Path file = tempDir.resolve("preferences.yaml");
Files.writeString(file, """
library:
root-path: /photos
someFutureSection:
whatever: 1
""");
Preferences preferences = new YamlPreferencesService(publisher, file).get();
assertThat(preferences.library().rootPath()).isEqualTo("/photos");
}
/** Corruption must not stop the application from starting. */
@Test
void fallsBackToDefaultsOnCorruptFile() throws IOException {
Path file = tempDir.resolve("preferences.yaml");
Files.writeString(file, "this: is: not: valid: yaml:\n\t\tbroken");
Preferences preferences = new YamlPreferencesService(publisher, file).get();
assertThat(preferences).isEqualTo(Preferences.defaults());
}
@Test
void publishesAnEventOnlyWhenSomethingChanged() {
YamlPreferencesService service = newService();
service.update(current -> current.withUi(current.ui().withTheme("LIGHT")));
assertThat(publishedEvents).hasSize(1);
// Same value again: no event, and no disk write scheduled.
service.update(current -> current.withUi(current.ui().withTheme("LIGHT")));
assertThat(publishedEvents).hasSize(1);
PreferencesChangedEvent event = (PreferencesChangedEvent) publishedEvents.getFirst();
assertThat(event.previous().ui().theme()).isEqualTo("DARK");
assertThat(event.current().ui().theme()).isEqualTo("LIGHT");
}
/** A mutator returning null must not wipe the tree. */
@Test
void ignoresNullFromMutator() {
YamlPreferencesService service = newService();
Preferences result = service.update(current -> null);
assertThat(result).isEqualTo(Preferences.defaults());
assertThat(publishedEvents).isEmpty();
}
/** Writes are atomic, so no temp file may be left behind. */
@Test
void leavesNoTemporaryFilesBehind() throws IOException {
Path file = tempDir.resolve("preferences.yaml");
YamlPreferencesService service = new YamlPreferencesService(publisher, file);
service.update(current -> current.withUi(current.ui().withThumbnailSize(256)));
service.flush();
try (var entries = Files.list(tempDir)) {
assertThat(entries.map(Path::getFileName).map(Path::toString))
.containsExactly("preferences.yaml");
}
}
private YamlPreferencesService newService() {
return new YamlPreferencesService(publisher, tempDir.resolve("preferences.yaml"));
}
}
@@ -0,0 +1,113 @@
package io.pholio.ui.window;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import javafx.geometry.Rectangle2D;
import org.junit.jupiter.api.Test;
/**
* The multi-monitor restoration rules, covered without starting the JavaFX toolkit.
*
* <p>These cases are the reason the geometry maths was factored out of {@code WindowStateManager}: the
* "external monitor was unplugged" scenario is the one that strands a window off-screen, and it is
* impractical to test by hand.
*/
class ScreenBoundsValidatorTest {
private static final Rectangle2D LAPTOP = new Rectangle2D(0, 0, 1728, 1080);
private static final Rectangle2D EXTERNAL_RIGHT = new Rectangle2D(1728, 0, 2560, 1440);
private static final double MIN_VISIBLE = 0.3;
@Test
void acceptsAWindowFullyOnTheOnlyScreen() {
Rectangle2D window = new Rectangle2D(100, 100, 1440, 900);
assertThat(ScreenBoundsValidator.isVisibleEnough(window, List.of(LAPTOP), MIN_VISIBLE)).isTrue();
}
@Test
void rejectsAWindowSavedOnAMonitorThatIsNoLongerConnected() {
// Saved while on the external display, which has since been unplugged.
Rectangle2D window = new Rectangle2D(2000, 300, 1440, 900);
assertThat(ScreenBoundsValidator.isVisibleEnough(window, List.of(LAPTOP), MIN_VISIBLE)).isFalse();
assertThat(ScreenBoundsValidator.isVisibleEnough(window, List.of(LAPTOP, EXTERNAL_RIGHT), MIN_VISIBLE))
.isTrue();
}
@Test
void acceptsAWindowStraddlingTwoScreens() {
Rectangle2D window = new Rectangle2D(1400, 200, 1000, 700);
assertThat(ScreenBoundsValidator.isVisibleEnough(window, List.of(LAPTOP, EXTERNAL_RIGHT), MIN_VISIBLE))
.isTrue();
}
@Test
void rejectsAWindowOnlyMarginallyOnScreen() {
// Just 128px of a 1440px-wide window remain visible: under 10%.
Rectangle2D window = new Rectangle2D(1600, 100, 1440, 900);
assertThat(ScreenBoundsValidator.isVisibleEnough(window, List.of(LAPTOP), MIN_VISIBLE)).isFalse();
}
@Test
void toleratesAWindowNudgedSlightlyOffTheEdge() {
Rectangle2D window = new Rectangle2D(-100, -20, 1440, 900);
assertThat(ScreenBoundsValidator.isVisibleEnough(window, List.of(LAPTOP), MIN_VISIBLE)).isTrue();
}
@Test
void rejectsWhenNoScreensAreReported() {
Rectangle2D window = new Rectangle2D(0, 0, 800, 600);
assertThat(ScreenBoundsValidator.isVisibleEnough(window, List.of(), MIN_VISIBLE)).isFalse();
assertThat(ScreenBoundsValidator.isVisibleEnough(window, null, MIN_VISIBLE)).isFalse();
}
@Test
void rejectsADegenerateWindow() {
assertThat(ScreenBoundsValidator.isVisibleEnough(new Rectangle2D(0, 0, 0, 0), List.of(LAPTOP), MIN_VISIBLE))
.isFalse();
}
/** Overlapping screen reports must not add up to more than the window's own area. */
@Test
void clampsVisibleAreaToTheWindowArea() {
Rectangle2D window = new Rectangle2D(0, 0, 100, 100);
List<Rectangle2D> overlapping = List.of(LAPTOP, LAPTOP, LAPTOP);
assertThat(ScreenBoundsValidator.visibleArea(window, overlapping)).isEqualTo(100 * 100);
}
@Test
void centresOnTheGivenScreen() {
Rectangle2D centred = ScreenBoundsValidator.centredOn(LAPTOP, 1440, 900);
assertThat(centred.getMinX()).isEqualTo((1728 - 1440) / 2.0);
assertThat(centred.getMinY()).isEqualTo((1080 - 900) / 2.0);
assertThat(centred.getWidth()).isEqualTo(1440);
assertThat(centred.getHeight()).isEqualTo(900);
}
/** A window larger than the target screen is shrunk to fit rather than centred with negative offsets. */
@Test
void clampsAnOversizedWindowToTheScreen() {
Rectangle2D centred = ScreenBoundsValidator.centredOn(LAPTOP, 4000, 3000);
assertThat(centred.getWidth()).isEqualTo(LAPTOP.getWidth());
assertThat(centred.getHeight()).isEqualTo(LAPTOP.getHeight());
assertThat(centred.getMinX()).isEqualTo(LAPTOP.getMinX());
assertThat(centred.getMinY()).isEqualTo(LAPTOP.getMinY());
}
@Test
void centresOnASecondaryScreenUsingItsOwnOrigin() {
Rectangle2D centred = ScreenBoundsValidator.centredOn(EXTERNAL_RIGHT, 1440, 900);
assertThat(centred.getMinX()).isEqualTo(1728 + (2560 - 1440) / 2.0);
assertThat(centred.getMinY()).isEqualTo((1440 - 900) / 2.0);
}
}
+17
View File
@@ -0,0 +1,17 @@
# Test overrides.
#
# PholioBootstrap normally contributes a file-based H2 URL derived from the OS data directory. Tests bypass
# that builder, and this in-memory URL keeps them from touching the real user profile either way.
spring:
datasource:
url: jdbc:h2:mem:pholio-test;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password: ""
flyway:
enabled: false
logging:
level:
root: WARN
io.pholio: INFO