feat: first version.
This commit is contained in:
+44
@@ -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
@@ -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
|
||||
@@ -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").
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user