feat(theme): switch to atlantafx themes and remove custom stylesheets

Removes the IntelliJ-inspired dark/light CSS overrides in favor of
AtlantaFX's built-in theme catalogue. ThemeMode enum is replaced by
AppTheme, which wraps the full set of shipped themes (Primer, Nord,
Cupertino, Dracula, seasonal palettes, GitHub variants, and Autumn
through Yacht).

The theme preference now accepts any AppTheme value and renders the
theme name from Theme.getName() instead of translating it. Settings
view gains a ComboBox with all 29 themes; theme-intellij-immich.css
and theme-intellij-light.css are deleted. Application-specific CSS
classes (.navigation-drawer, .inspector-panel, .photo-card, etc.)
migrate to AtlantaFX style constants (Styles.TITLE_3, Styles.TEXT_MUTED,
Tweaks.*) where equivalents exist.

Updates PreferenceSchemaCouplingTest to verify theme options match the
AppTheme enum rather than the old two-value set. Adds test exclusion
for the theme preference in the translation coverage check since theme
names are proper nouns.
This commit is contained in:
2026-08-09 13:07:33 -04:00
parent d74c4bdc76
commit 0f6b574ea4
31 changed files with 1362 additions and 1091 deletions
+102
View File
@@ -0,0 +1,102 @@
import hashlib
import json
import os
import sys
import tempfile
from datetime import datetime, timezone
from http.client import HTTPConnection, HTTPException
from pathlib import Path
import traceback
from contextlib import closing
from typing import Optional
import argparse
WEBSERVER_HOST = "localhost"
WEBSERVER_ENDPOINT = "/api/provenance/call"
PORT_FILE_SUFFIX = "-provenance-port.txt"
class ProvenanceHookError(RuntimeError):
pass
def http_request(method, host, port, location, *, body: Optional[bytes] = None, headers={}, timeout=None, wait_for_response=False) -> bytes:
with closing(HTTPConnection(host, port, timeout=timeout)) as connection:
connection.request(method, location, body=body, headers=headers)
if wait_for_response:
response = connection.getresponse()
responseText = response.read()
def get_server_port():
claude_root = os.getenv("CLAUDE_PROJECT_DIR")
path_hash = hashlib.md5(claude_root.encode('utf-8')).hexdigest()
port_file = Path(tempfile.gettempdir()) / (path_hash + PORT_FILE_SUFFIX)
return int(port_file.read_text("utf-8").strip())
def send_diff_to_webserver(file_path, timestamp_ms, wait_for_response):
try:
port = get_server_port()
except FileNotFoundError as e:
raise ProvenanceHookError(
f"Could not determine API port: {e.filename} does not exist") from e
except Exception as e:
raise ProvenanceHookError("Could not determine API port") from e
url = f"http://{WEBSERVER_HOST}:{port}{WEBSERVER_ENDPOINT}"
try:
payload = {"file_path": file_path, "timestamp": timestamp_ms}
return http_request(
"POST",
WEBSERVER_HOST,
port=port,
location=WEBSERVER_ENDPOINT,
body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={'Content-Type': 'application/json'},
timeout=0.5,
wait_for_response=wait_for_response
)
except (HTTPException, OSError, ConnectionError) as e:
raise ProvenanceHookError(
f"Network error while sending diff to {url}") from e
except Exception as e:
raise ProvenanceHookError(
f"Unknown error while sending diff to {url}") from e
def extract_file_path(tool_name, tool_input):
if tool_name in ["Write", "Edit", "MultiEdit"]:
return tool_input.get('file_path', 'unknown')
if tool_name == "NotebookEdit":
return tool_input.get('notebook_path', 'unknown')
return 'unknown'
def excepthook(type, value, traceback_):
traceback.print_exception(type, value, traceback_, file=sys.stderr)
sys.exit(1)
def main():
data = json.load(sys.stdin)
tool_name = data.get('tool_name', 'unknown')
p = argparse.ArgumentParser()
p.add_argument("--wait_for_response", default=False)
args = p.parse_args()
modification_tools = [
"Write", "Edit", "MultiEdit", "NotebookEdit"
]
if tool_name in modification_tools:
tool_input = data.get('tool_input', {})
file_path = extract_file_path(tool_name, tool_input)
if file_path:
timestamp_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
send_diff_to_webserver(file_path, timestamp_ms, args.wait_for_response)
if __name__ == "__main__":
sys.excepthook = excepthook
sys.exit(main())
+269
View File
@@ -0,0 +1,269 @@
# Abandon des thèmes maison, catalogue AtlantaFX complet, styles standard
## Contexte
L'apparence de Pholio repose aujourd'hui sur **deux couches** : un thème AtlantaFX de base (`PrimerDark` / `PrimerLight`) et, par-dessus, une feuille maison qui
**redéfinit toutes les variables
`-color-*`** dans la palette IntelliJ New UI / Immich — `css/theme-intellij-immich.css` et
`css/theme-intellij-light.css`, 385 et 373 lignes, une par mode.
Cette seconde couche est ce qui bloque tout : elle écrase la palette de n'importe quel thème installé sous elle, si bien que `NordDark`, `Dracula` ou
`CupertinoLight` donneraient exactement le même écran que `PrimerDark`. Choisir un thème n'aurait aucun effet visible.
Les trois décisions prises :
1. **Abandonner les deux thèmes maison.** Une seule feuille reste, `css/pholio.css`, et elle *consomme* les variables `-color-*` au lieu de les redéfinir. Elle
n'a donc plus de jumelle claire/sombre : chaque thème fournit ses propres valeurs pour les deux modes.
2. **Ouvrir le catalogue complet** : les 7 thèmes d'`atlantafx-base` plus les 25 de
`com.dlsc.atlantafx:themes`, soit **32**, offerts dans une `ComboBox` des préférences.
3. **Styliser les composants avec les constantes d'AtlantaFX** — `atlantafx.base.theme.Styles` et
`atlantafx.base.theme.Tweaks` — plutôt qu'avec des classes CSS maison, partout où une constante dit déjà la même chose.
Conséquence à assumer d'emblée : **l'application ne ressemblera plus à IntelliJ.** Par défaut elle prend l'allure de Primer Dark, thème AtlantaFX nu. C'est le
prix — et le but — de l'abandon de la surcharge.
## 1. La dépendance
```xml
<dependency>
<groupId>com.dlsc.atlantafx</groupId>
<artifactId>themes</artifactId>
<version>${atlantafx-themes.version}</version>
<exclusions>
<!-- Ramène atlantafx-base 2.0.1 ; la déclaration directe en 2.1.0 gagne déjà par
proximité, l'exclusion rend l'intention explicite plutôt qu'accidentelle. -->
<exclusion>
<groupId>io.github.mkpaz</groupId>
<artifactId>atlantafx-base</artifactId>
</exclusion>
</exclusions>
</dependency>
```
**Version : `1.5.0`**, la seule présente dans le dépôt local — les builds de ce projet tournent en
`-o`. Central annonce une `1.9.0` ; à passer dessus lors d'un build en ligne, en vérifiant que le test
`AppThemeTest` ci-dessous reste vert.
Le point de vigilance est là : `themes` 1.5.0 est compilé contre `atlantafx-base` **2.0.1**, alors que Pholio est en **2.1.0**. Si l'interface `Theme` avait
bougé, l'erreur serait un `AbstractMethodError`
au premier chargement d'un thème DLSC. `AppThemeTest`, qui instancie les 32 thèmes et demande leur feuille, *est* ce contrôle de compatibilité — il n'y a rien
de plus à écrire pour le couvrir.
## 2. `AppTheme` — le catalogue
`ui/theme/ThemeMode.java` disparaît : il n'existait que pour apparier un thème de base avec une surcharge, et il n'y a plus de surcharge. Il est remplacé par
`ui/theme/AppTheme.java`, énumération des 32 thèmes.
AtlantaFX n'expose **aucun registre** — `Theme` est une interface (`getName()`,
`getUserAgentStylesheet()`, `isDarkMode()`) et les implémentations sont des classes finales sans fichier de service. Les constantes sont donc écrites à la main,
et un test de couplage garde le schéma en accord.
```java
public enum AppTheme {
// atlantafx-base
PRIMER_LIGHT(PrimerLight::new), PRIMER_DARK(PrimerDark::new),
NORD_LIGHT(NordLight::new), NORD_DARK(NordDark::new),
CUPERTINO_LIGHT(CupertinoLight::new), CUPERTINO_DARK(CupertinoDark::new),
DRACULA(Dracula::new),
// com.dlsc.atlantafx:themes
ARMY_LIGHT, ARMY_DARK, BLUE_LIGHT, BLUE_DARK, NAVY_LIGHT, NAVY_DARK,
SPRING_LIGHT, SPRING_DARK, SUMMER_LIGHT, SUMMER_DARK,
FALL_LIGHT, FALL_DARK, WINTER_LIGHT, WINTER_DARK,
GITHUB_LIGHT_DEFAULT, GITHUB_SOFT_DARK,
GITHUB_LIGHT_COLORBLIND, GITHUB_DARK_COLORBLIND,
GITHUB_LIGHT_TRITANOPIA, GITHUB_DARK_TRITANOPIA,
AUTUMN, BLACKY, BROWNY, NEWS, YACHT;
public Theme theme() // instance neuve
public String displayName() // theme().getName() — « Primer Dark », « Nord Light »…
public boolean isDark() // theme().isDarkMode()
public AppTheme counterpart() // le jumeau de l'autre mode
public static AppTheme parse(String value) // repli sur DEFAULT, comme ThemeMode.parse
}
```
`DEFAULT = PRIMER_DARK`, ce que `preferences.yaml` déclare aussi.
**`counterpart()` par convention plutôt que par table.** Le bouton soleil/lune de l'en-tête doit survivre : il bascule vers le jumeau du thème courant, obtenu
en échangeant le jeton `LIGHT` ↔ `DARK`
dans le nom de la constante — `PRIMER_DARK` → `PRIMER_LIGHT`, `GITHUB_DARK_COLORBLIND` →
`GITHUB_LIGHT_COLORBLIND`. Quand le nom résultant n'existe pas (`GITHUB_SOFT_DARK`) ou qu'il n'y a pas de jeton du tout (`DRACULA`, `BLACKY`, `NEWS`…), on
retombe sur `PRIMER_LIGHT` / `PRIMER_DARK` selon
`isDark()`. Huit lignes, et un test qui les parcourt toutes.
## 3. La préférence
`ui.theme` garde son nom et son rôle ; seule la liste d'options change.
```yaml
theme:
type: STRING
label: settings.appearance.theme
default-value: PRIMER_DARK
# Doit correspondre à AppTheme — PreferenceSchemaCouplingTest le vérifie. Les libellés affichés
# viennent de Theme.getName(), pas des bundles : ce sont des noms propres.
options: [ PRIMER_LIGHT, PRIMER_DARK, … ] # les 32
```
Aucune préférence supplémentaire : le catalogue complet *est* la liste de thèmes, il n'y a plus de distinction entre « mode » et « thème de base » à faire tenir
dans deux réglages.
**Traduction.** `PreferenceSchemaCouplingTest.everyOptionIsTranslatedInBothBundles` exigerait 64 clés
`settings.appearance.theme.primer_dark=Primer Dark` — des noms propres, identiques dans les deux bundles. À la place, `optionConverter` de `SettingsView` reçoit
un repli : `i18n.resolveOr(clé, repli)`
où le repli est `AppTheme.parse(option).displayName()` au lieu de la constante brute. Le test exclut alors explicitement `ui.theme`, avec la raison écrite dans
le javadoc du test — c'est la seule exception, et elle est nommée.
Les clés `theme.dark` / `theme.light` et `settings.appearance.theme.dark` / `.light` des deux bundles disparaissent avec `ThemeMode`.
## 4. `ThemeManager` — une seule couche
La classe se simplifie plus qu'elle ne grossit :
- `ObjectProperty<AppTheme> theme` remplace `ObjectProperty<ThemeMode> mode` ; `modeProperty()`
devient `themeProperty()`, et `AppHeaderBar.updateThemeIcon` lit `theme.isDark()` ;
- `applyBaseTheme` installe `theme.theme().getUserAgentStylesheet()` ;
- `applyOverride` / `removeOverride` **disparaissent**. `register(Scene)` ajoute une seule fois
`/css/pholio.css`, qui n'est jamais retiré : la feuille est la même pour les 32 thèmes. Elle reste attachée à la scène, et non installée en *user-agent*,
parce que c'est ce qui lui donne la priorité sur le thème ;
- `toggle()` devient `setTheme(theme.get().counterpart())`.
`OsThemeDetector` et `onApplicationReady` ne bougent pas — ils ne font que journaliser aujourd'hui.
## 5. `css/pholio.css` — ce qui reste, et ce qui part
Les deux fichiers fusionnent en un seul. Sur les ~55 sélecteurs actuels :
| Sort | Sélecteurs | Raison |
|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
| **supprimé** | `.root` (le bloc `-color-*`) | c'est *le* thème maison ; chaque thème AtlantaFX fournit ces variables |
| **supprimé** | `.scroll-bar`, `.scroll-bar .track`, `.thumb`, `.thumb:hover` | AtlantaFX les style déjà, et mieux |
| **supprimé** | `.app-logo`, `.status-label`, `.section-title`, `.modal-title`, `.modal-note`, `.empty-title`, `.empty-subtitle`, `.global-search`, `.library-selector*` | remplacés par des constantes `Styles` ou par le rendu par défaut du contrôle (§6) |
| **conservé** | `.app-header-bar`, `.module-selector*`, `.navigation-rail*`, `.navigation-drawer`, `.drawer-resizer*`, `.inspector-dock*`, `.task-panel`, `.status-bar`, `.photo-card*`, `.modal-card`, `.empty-state` | structure : hauteurs fixes, marges, rayons, états `:hover` / `:selected` / `.active` qu'aucune constante n'exprime |
Les règles conservées **lisent** `-color-bg-subtle`, `-color-border-default`, `-color-accent-emphasis`
et consorts — noms que les 32 thèmes définissent — donc elles suivent le thème sans une ligne de plus.
Le bloc `.root` garde uniquement les jetons structurels `-pholio-*` (`-pholio-header-height`,
`-pholio-rail-width`, `-pholio-panel-width`, `-pholio-card-radius`, `-pholio-statusbar-height`,
`-pholio-hover-overlay`). Deux exceptions à traiter :
- `-pholio-accent-immich: #6366F1` est une couleur de marque en dur : remplacée par
`-color-accent-emphasis`, sans quoi elle jurerait avec 31 thèmes sur 32 ;
- `-fx-font-family` / `-fx-font-size` sortent du `.root` : AtlantaFX pose les siens, et aucune police n'est actuellement livrée (`ThemeManager.loadFonts`
journalise « not bundled » pour les quatre).
`ThemeManager.OPTIONAL_FONTS` et `loadFonts()` restent : ils sont sans effet aujourd'hui mais redeviennent utiles dès qu'une police est vendue, et ils ne
coûtent rien.
## 6. Les constantes `Styles` et `Tweaks`
Règle à retenir pour la suite : **une classe de style maison ne se crée que si aucune constante AtlantaFX ne dit la même chose.** Les remplacements concrets :
| Aujourd'hui | Devient |
|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------|
| `"button-icon"`, `"flat"` | `Styles.BUTTON_ICON`, `Styles.FLAT` |
| `"accent"` | `Styles.ACCENT` |
| `"status-label"` (7 usages) | `Styles.TEXT_MUTED` |
| `"section-title"` | `Styles.TITLE_4` |
| `"modal-title"` / `"modal-note"` | `Styles.TITLE_3` / `Styles.TEXT_SUBTLE` |
| `"empty-title"` / `"empty-subtitle"` | `Styles.TITLE_2` / `Styles.TEXT_MUTED` |
| `"app-logo"` | `Styles.TEXT_BOLD` + `.app-logo` conservé pour la seule marge |
| `"modal-card"` | `+ Styles.BG_DEFAULT`, `Styles.ELEVATED_2` |
| `TreeView` du tiroir, `ListView` du panneau de tâches | `Tweaks.EDGE_TO_EDGE`, ce qui retire aussi `.navigation-drawer .tree-cell` et `.task-panel .list-cell` |
`FxUtils.styled(node, …)` reste le moyen d'application ; il prend déjà des `String...`, donc les constantes s'y passent telles quelles.
**Deux classes maison sont gardées volontairement** : `"active"` sur le bouton de rail — `Styles` n'a pas de notion d'« élément de rail courant », et
`NavigationRailTest` s'appuie dessus sur treize cas — et `"library-selector"`, qui ne sert plus qu'à porter la largeur maximale du sélecteur de photothèque.
Fichiers touchés : `AppHeaderBar`, `StatusBar`, `TaskPanel`, `TaskMonitorWidget`, `InspectorPanel`,
`InspectorDock`, `NavigationDrawer`, `LibraryFolderTree`, `ModalService`, `GalleryView`,
`ModulePlaceholderView`, `SettingsView`.
## 7. `SettingsView` — `ChoiceBox` → `ComboBox`
La branche « options » de `stringControl` construit une `ChoiceBox` : mauvais contrôle pour 32 entrées, il déroule un menu sans hauteur maximale ni molette.
Toute la branche passe à `ComboBox`
(`setVisibleRowCount(12)`), ce dont `locale` et `ai.provider` profitent aussi.
Piège unique de la conversion : `refreshRenderedLabels` teste `instanceof ChoiceBox<?>` pour forcer le re-rendu après un changement de langue. Sans mise à jour
vers `ComboBox`, le contournement cesse d'agir en silence. Deux commentaires nomment aussi « ChoiceBox » et doivent suivre :
`preferences.yaml` et le javadoc de `PreferenceItem.options`.
## Fichiers
**Nouveaux**
```
ui/theme/AppTheme.java
resources/css/pholio.css (fusion des deux feuilles, palette retirée)
```
**Supprimés**
```
ui/theme/ThemeMode.java
resources/css/theme-intellij-immich.css
resources/css/theme-intellij-light.css
```
**Modifiés**
```
ui/theme/ThemeManager.java AppTheme, plus de couche de surcharge
ui/shell/SettingsView.java ComboBox + repli displayName() dans optionConverter
ui/shell/*.java, ui/view/*.java constantes Styles / Tweaks (12 fichiers, §6)
resources/preferences.yaml ui.theme : 32 options, défaut PRIMER_DARK
resources/messages*.properties retrait des clés theme.dark / theme.light et dérivées
pom.xml com.dlsc.atlantafx:themes + propriété de version
```
## Vérification
**Tests** (AssertJ + `SoftAssertions`, conformément à `CLAUDE.md`) :
1. **`AppThemeTest`** — le test qui porte le plus de valeur ici. Pour les 32 constantes : `theme()`
n'est pas nul, `getUserAgentStylesheet()` renvoie une URL **résolvable sur le classpath**, et
`displayName()` est distinct des autres. C'est ce test qui prouve que les thèmes DLSC compilés contre `atlantafx-base` 2.0.1 fonctionnent sous 2.1.0. Plus :
`counterpart()` d'un thème apparié fait l'aller-retour, celui d'un thème sans jumeau donne un thème du mode opposé, et `parse` accepte la casse libre et
retombe sur `PRIMER_DARK`.
2. **`PreferenceSchemaCouplingTest`** — `themeOptionsMatchTheThemeModes` devient
`themeOptionsMatchTheCatalogue` (options == `AppTheme.values()`), et
`everyOptionIsTranslatedInBothBundles` exclut `ui.theme` avec la raison en javadoc.
3. **`ThemeManagerTest`** (nouveau, `FxTestToolkit.requireToolkit()`) — une `Scene` enregistrée porte
`pholio.css` exactement une fois ; changer de thème ne l'ajoute ni ne la retire ; `themeProperty()`
suit la préférence, et `toggle()` renvoie un thème de mode opposé.
4. **Tests existants à ajuster** — `TaskPanelTest` cherche `.section-title` (devient `.title-4`) ;
`NavigationRailTest` s'appuie sur `"active"`, conservé, donc inchangé.
```
./mvnw -o test
```
**À l'écran :**
```
./mvnw -o -DskipTests package
java -Dpholio.home=/tmp/pholio-themes -jar target/pholio.jar
```
Au premier lancement, l'application est en Primer Dark nu — plus d'IntelliJ. Ouvrir ⚙ → *Apparence* :
la `ComboBox` liste 32 thèmes sous leurs vrais noms. Parcourir au clavier flèche-bas et vérifier que chaque thème repeint l'ensemble — barre d'en-tête, rail,
tiroir, barre d'état — sans zone restée à l'ancienne palette : une zone qui ne bouge pas est une règle de `pholio.css` qui a gardé une couleur en dur. Le bouton
soleil/lune bascule vers le jumeau (`NORD_DARK` → `NORD_LIGHT`) et, sur un thème sans jumeau (`DRACULA`), vers `PRIMER_LIGHT`. Le choix survit au redémarrage.
## Points laissés ouverts
- **Aucune police n'est livrée.** `pholio.css` cessant d'imposer `Inter`, l'application prend la police par défaut du thème. Si l'allure ne convient pas, c'est
un binaire à vendre dans
`resources/fonts/`, pas une règle CSS à réintroduire.
- **La version de `com.dlsc.atlantafx:themes`** est épinglée à 1.5.0 pour rester constructible hors ligne. Une 1.9.0 existe sur Central ; le passage se fait en
une propriété, `AppThemeTest` faisant foi.
- **Les 32 noms sont écrits deux fois** — dans l'énumération et dans `preferences.yaml`. C'est le schéma existant (`options` est une métadonnée statique) et le
test de couplage rend la dérive impossible ; un mécanisme d'options fournies à l'exécution serait une fonctionnalité à part.
+33 -3
View File
@@ -17,7 +17,12 @@
<description>High-volume desktop photo and media library manager</description>
<properties>
<archunit.version>1.4.2</archunit.version>
<archunit.version>1.5.0</archunit.version>
<!--
Extra themes for AtlantaFx, from DLSC. Pinned to a release present in the local
repository so an offline build keeps working; Maven Central carries newer ones.
-->
<atlantafx-themes.version>1.5.0</atlantafx-themes.version>
<atlantafx.version>2.1.0</atlantafx.version>
<!--
@@ -136,9 +141,27 @@
<version>${atlantafx.version}</version>
</dependency>
<dependency>
<!-- Twenty-five more AtlantaFx themes, all implementing atlantafx.base.theme.Theme. -->
<groupId>com.dlsc.atlantafx</groupId>
<artifactId>themes</artifactId>
<version>1.5.0</version> <!-- Check Maven Central for the latest release -->
<version>${atlantafx-themes.version}</version>
<exclusions>
<!--
This artifact is built against atlantafx-base 2.0.1. The direct declaration above already
wins by proximity, but excluding it states the intent and makes a future version bump fail
loudly rather than silently downgrade the base. AppThemeTest loads all 32 themes, which is
what proves the cross-version binary compatibility this relies on.
-->
<exclusion>
<groupId>io.github.mkpaz</groupId>
<artifactId>atlantafx-base</artifactId>
</exclusion>
<!-- SASS sources, used to build those themes and of no use at runtime. -->
<exclusion>
<groupId>io.github.mkpaz</groupId>
<artifactId>atlantafx-styles</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.github.mkpaz</groupId>
@@ -338,7 +361,14 @@
<sortDependencyExclusions>groupId,artifactId</sortDependencyExclusions>
<createBackupFile>false</createBackupFile>
<expandEmptyElements>false</expandEmptyElements>
<indentSize>4</indentSize>
<!--
nrOfIndentSpace, not indentSize: the latter is not a parameter of this plugin, so it was
silently ignored and every `mvn package` reflowed the file to the two-space default,
undoing 071d29b. Keeping the schemaLocation attribute on its own line is part of the same
formatting.
-->
<nrOfIndentSpace>4</nrOfIndentSpace>
<indentAttribute>schemaLocation</indentAttribute>
</configuration>
<executions>
<execution>
@@ -38,10 +38,12 @@ import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
// Field order in the written file. Without it Jackson emits its own order, which puts `value` — the one key
// a user is likely to want to change — in the middle of the metadata.
@JsonPropertyOrder({"type", "label", "value", "default-value", "options", "min", "max", "visible", "editable"})
@JsonPropertyOrder({ "type", "label", "value", "default-value", "options", "min", "max", "visible", "editable" })
public class PreferenceItem<T> {
/** Selects the control and drives coercion. Defaults to {@code STRING} so a terse schema still loads. */
/**
* Selects the control and drives coercion. Defaults to {@code STRING} so a terse schema still loads.
*/
private PreferenceType type = PreferenceType.STRING;
/**
@@ -50,16 +52,24 @@ public class PreferenceItem<T> {
*/
private String label;
/** Value restored by "Reset". {@code null} is legitimate and means "unset". */
/**
* Value restored by "Reset". {@code null} is legitimate and means "unset".
*/
private T defaultValue;
/** When {@code false} the settings view builds no row for this item at all. */
/**
* When {@code false} the settings view builds no row for this item at all.
*/
private boolean visible = true;
/** When {@code false} the row is built but its control is disabled. Never blocks programmatic writes. */
/**
* When {@code false} the row is built but its control is disabled. Never blocks programmatic writes.
*/
private boolean editable = true;
/** Allowed values for a {@code STRING}; present turns the control into a {@code ChoiceBox}. */
/**
* Allowed values for a {@code STRING}; present turns the control into a {@code ComboBox}.
*/
private List<String> options;
/**
@@ -100,13 +110,17 @@ public class PreferenceItem<T> {
property.set(value);
}
/** The observable behind {@link #getValue()}. Bind controls to this. */
/**
* The observable behind {@link #getValue()}. Bind controls to this.
*/
@JsonIgnore
public ObjectProperty<T> property() {
return property;
}
/** Restores {@link #getDefaultValue()}. Callers decide whether invisible items are in scope. */
/**
* Restores {@link #getDefaultValue()}. Callers decide whether invisible items are in scope.
*/
public void reset() {
property.set(defaultValue);
}
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.geometry.Insets;
@@ -17,8 +18,8 @@ import org.icroco.pholio.ui.common.UiComponent;
import org.icroco.pholio.ui.event.NavigateToViewEvent;
import org.icroco.pholio.ui.event.ViewType;
import org.icroco.pholio.ui.i18n.I18nService;
import org.icroco.pholio.ui.theme.AppTheme;
import org.icroco.pholio.ui.theme.ThemeManager;
import org.icroco.pholio.ui.theme.ThemeMode;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.springframework.context.ApplicationEventPublisher;
@@ -114,7 +115,8 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
private Label buildLogo() {
Label logo = new Label();
logo.getStyleClass().add("app-logo");
// TEXT_BOLD carries the weight; the class is kept only for the padding around the wordmark.
logo.getStyleClass().addAll("app-logo", Styles.TEXT_BOLD);
logo.setGraphic(FontIcon.of(Feather.IMAGE, 16));
logo.textProperty().bind(i18n.binding("app.name"));
return logo;
@@ -197,7 +199,6 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
}
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);
@@ -215,7 +216,7 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
private HBox buildActions() {
Button theme = iconButton(themeIcon, "action.theme.toggle");
theme.setOnAction(event -> themeManager.toggle());
scope.add(themeManager.modeProperty().subscribe(this::updateThemeIcon));
scope.add(themeManager.themeProperty().subscribe(this::updateThemeIcon));
Button settings = iconButton(FontIcon.of(Feather.SETTINGS, 16), "action.settings");
settings.setOnAction(event -> modalService.show(SettingsView.class));
@@ -229,7 +230,7 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
private Button iconButton(FontIcon icon, String tooltipKey) {
Button button = new Button();
button.setGraphic(icon);
button.getStyleClass().addAll("button-icon", "flat");
button.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT);
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(i18n.binding(tooltipKey));
button.setTooltip(tooltip);
@@ -237,9 +238,9 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
return button;
}
private void updateThemeIcon(ThemeMode mode) {
private void updateThemeIcon(AppTheme theme) {
// Show the mode the button switches *to*, which is the convention users expect.
themeIcon.setIconCode(mode == ThemeMode.DARK ? Feather.SUN : Feather.MOON);
themeIcon.setIconCode(theme.isDark() ? Feather.SUN : Feather.MOON);
themeIcon.setIconSize(16);
}
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
@@ -39,7 +40,7 @@ public class InspectorPanel extends VBox implements Disposable {
setMinWidth(PANEL_WIDTH);
setPrefWidth(PANEL_WIDTH);
title.getStyleClass().add("section-title");
title.getStyleClass().add(Styles.TITLE_4);
title.textProperty().bind(i18n.binding("inspector.details.title"));
VBox header = new VBox(title);
@@ -52,7 +53,7 @@ public class InspectorPanel extends VBox implements Disposable {
}
private VBox buildDetailsPlaceholder() {
empty.getStyleClass().add("status-label");
empty.getStyleClass().add(Styles.TEXT_MUTED);
empty.textProperty().bind(i18n.binding("inspector.empty"));
VBox content = new VBox(empty);
@@ -1,5 +1,7 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import atlantafx.base.theme.Tweaks;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
@@ -64,9 +66,12 @@ public class LibraryFolderTree extends StackPane implements NavigationDrawerSect
tree.setShowRoot(true);
tree.setCellFactory(view -> new FolderCell());
// The tree fills the drawer, which already has its own border; EDGE_TO_EDGE drops the control's
// so the two do not stack into a double line.
tree.getStyleClass().add(Tweaks.EDGE_TO_EDGE);
scope.add(tree.getSelectionModel().selectedItemProperty().subscribe(this::onFolderSelected));
notConfigured.getStyleClass().add("status-label");
notConfigured.getStyleClass().add(Styles.TEXT_MUTED);
notConfigured.setWrapText(true);
notConfigured.textProperty().bind(i18n.binding("status.library.notConfigured"));
StackPane.setAlignment(notConfigured, Pos.CENTER);
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
@@ -50,10 +51,10 @@ public class NavigationDrawer extends VBox implements Disposable {
getStyleClass().add("navigation-drawer");
title.getStyleClass().add("drawer-title");
title.getStyleClass().add(Styles.TITLE_3);
title.setMaxWidth(Double.MAX_VALUE);
placeholder.getStyleClass().add("status-label");
placeholder.getStyleClass().add(Styles.TEXT_MUTED);
placeholder.textProperty().bind(i18n.binding("nav.drawer.empty"));
placeholder.setWrapText(true);
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import javafx.beans.property.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -277,7 +278,7 @@ public class NavigationRail extends BorderPane implements Disposable {
private Entry(NavigationDestination destination) {
this.destination = destination;
button.setGraphic(FontIcon.of(destination.icon(), 16));
button.getStyleClass().addAll("rail-button", "flat");
button.getStyleClass().addAll("rail-button", Styles.FLAT);
button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
button.setAlignment(Pos.CENTER);
button.setPrefWidth(BUTTON_WIDTH);
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.Property;
import javafx.geometry.Insets;
@@ -16,6 +17,7 @@ import org.icroco.pholio.ui.common.Disposable;
import org.icroco.pholio.ui.common.SubscriptionScope;
import org.icroco.pholio.ui.common.UiView;
import org.icroco.pholio.ui.i18n.I18nService;
import org.icroco.pholio.ui.theme.AppTheme;
import java.util.*;
@@ -42,31 +44,35 @@ public class SettingsView extends BorderPane implements Disposable {
private static final double SIDEBAR_WIDTH = 220;
private static final double CONTENT_WIDTH = 640;
private static final double PREF_HEIGHT = 560;
private static final double PREF_HEIGHT = 560;
/** Message key prefix for group titles: {@code settings.group.<group>}. */
/**
* Message key prefix for group titles: {@code settings.group.<group>}.
*/
private static final String GROUP_LABEL_PREFIX = "settings.group.";
private final I18nService i18n;
private final AppPreferences preferences;
private final I18nService i18n;
private final AppPreferences preferences;
private final PreferenceService preferenceService;
private final ModalService modalService;
private final ModalService modalService;
private final SubscriptionScope scope = new SubscriptionScope();
private final TextField search = new TextField();
private final ListView<String> groupList = new ListView<>();
private final VBox sections = new VBox(10);
private final ScrollPane scroll = new ScrollPane(sections);
private final Label titleLabel = new Label();
private final Label emptyResult = new Label();
private final Label geometryNote = new Label();
private final Button resetButton = new Button();
private final Button saveButton = new Button();
private final Button closeButton = new Button();
private final SubscriptionScope scope = new SubscriptionScope();
private final TextField search = new TextField();
private final ListView<String> groupList = new ListView<>();
private final VBox sections = new VBox(10);
private final ScrollPane scroll = new ScrollPane(sections);
private final Label titleLabel = new Label();
private final Label emptyResult = new Label();
private final Label geometryNote = new Label();
private final Button resetButton = new Button();
private final Button saveButton = new Button();
private final Button closeButton = new Button();
/** Sections and rows, kept so the filter can hide them without walking the scenegraph. */
/**
* Sections and rows, kept so the filter can hide them without walking the scenegraph.
*/
private final Map<String, TitledPane> panes = new LinkedHashMap<>();
private final List<Row> rows = new ArrayList<>();
private final List<Row> rows = new ArrayList<>();
public SettingsView(
I18nService i18n,
@@ -78,7 +84,7 @@ public class SettingsView extends BorderPane implements Disposable {
this.preferenceService = preferenceService;
this.modalService = modalService;
getStyleClass().add("modal-card");
getStyleClass().addAll("modal-card", Styles.BG_DEFAULT, Styles.ELEVATED_2);
setPrefSize(SIDEBAR_WIDTH + CONTENT_WIDTH, PREF_HEIGHT);
setMaxSize(SIDEBAR_WIDTH + CONTENT_WIDTH, PREF_HEIGHT);
@@ -95,7 +101,7 @@ public class SettingsView extends BorderPane implements Disposable {
// ---------------------------------------------------------------- layout
private Node header() {
titleLabel.getStyleClass().add("modal-title");
titleLabel.getStyleClass().add(Styles.TITLE_3);
titleLabel.textProperty().bind(i18n.binding("settings.title"));
VBox box = new VBox(titleLabel);
@@ -105,7 +111,6 @@ public class SettingsView extends BorderPane implements Disposable {
private Node sidebar() {
search.promptTextProperty().bind(i18n.binding("settings.search.prompt"));
search.getStyleClass().add("global-search");
groupList.getItems().setAll(panes.keySet());
groupList.setCellFactory(view -> new GroupCell());
@@ -120,12 +125,12 @@ public class SettingsView extends BorderPane implements Disposable {
}
private Node content() {
emptyResult.getStyleClass().add("modal-note");
emptyResult.getStyleClass().add(Styles.TEXT_SUBTLE);
emptyResult.textProperty().bind(i18n.binding("settings.search.noResults"));
emptyResult.setVisible(false);
emptyResult.setManaged(false);
geometryNote.getStyleClass().add("modal-note");
geometryNote.getStyleClass().add(Styles.TEXT_SUBTLE);
geometryNote.setWrapText(true);
geometryNote.textProperty().bind(i18n.binding("settings.note.windowGeometry"));
@@ -143,7 +148,7 @@ public class SettingsView extends BorderPane implements Disposable {
private Node footer() {
resetButton.textProperty().bind(i18n.binding("action.reset"));
saveButton.textProperty().bind(i18n.binding("action.applyAndSave"));
saveButton.getStyleClass().add("accent");
saveButton.getStyleClass().add(Styles.ACCENT);
closeButton.textProperty().bind(i18n.binding("action.close"));
Region spacer = new Region();
@@ -168,7 +173,7 @@ public class SettingsView extends BorderPane implements Disposable {
private void buildSections() {
for (String group : preferences.visibleGroupNames()) {
GridPane grid = grid();
int row = 0;
int row = 0;
for (Map.Entry<String, PreferenceItem<?>> entry : preferences.group(group).entrySet()) {
PreferenceItem<?> item = entry.getValue();
if (!item.isVisible()) {
@@ -217,11 +222,11 @@ public class SettingsView extends BorderPane implements Disposable {
yield box;
}
case INT -> {
Spinner<Integer> spinner = new Spinner<>();
ObjectProperty<Integer> value = preferences.property(group, key, Integer.class);
int min = item.getMin() != null ? item.getMin().intValue() : Integer.MIN_VALUE;
int max = item.getMax() != null ? item.getMax().intValue() : Integer.MAX_VALUE;
int initial = value.get() != null ? value.get() : Math.max(min, 0);
Spinner<Integer> spinner = new Spinner<>();
ObjectProperty<Integer> value = preferences.property(group, key, Integer.class);
int min = item.getMin() != null ? item.getMin().intValue() : Integer.MIN_VALUE;
int max = item.getMax() != null ? item.getMax().intValue() : Integer.MAX_VALUE;
int initial = value.get() != null ? value.get() : Math.max(min, 0);
spinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(min, max, initial));
spinner.setEditable(true);
spinner.setPrefWidth(120);
@@ -229,11 +234,11 @@ public class SettingsView extends BorderPane implements Disposable {
yield spinner;
}
case DOUBLE -> {
Spinner<Double> spinner = new Spinner<>();
ObjectProperty<Double> value = preferences.property(group, key, Double.class);
double min = item.getMin() != null ? item.getMin().doubleValue() : -Double.MAX_VALUE;
double max = item.getMax() != null ? item.getMax().doubleValue() : Double.MAX_VALUE;
double initial = value.get() != null ? value.get() : Math.max(min, 0);
Spinner<Double> spinner = new Spinner<>();
ObjectProperty<Double> value = preferences.property(group, key, Double.class);
double min = item.getMin() != null ? item.getMin().doubleValue() : -Double.MAX_VALUE;
double max = item.getMax() != null ? item.getMax().doubleValue() : Double.MAX_VALUE;
double initial = value.get() != null ? value.get() : Math.max(min, 0);
spinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(min, max, initial));
spinner.setEditable(true);
spinner.setPrefWidth(120);
@@ -270,18 +275,21 @@ public class SettingsView extends BorderPane implements Disposable {
bind(field.textProperty(), value);
return field;
}
ChoiceBox<String> choice = new ChoiceBox<>();
// A ComboBox rather than a ChoiceBox: ui.theme offers thirty-two entries, and only a ComboBox
// caps the popup and scrolls it.
ComboBox<String> choice = new ComboBox<>();
choice.getItems().setAll(item.getOptions());
choice.setConverter(optionConverter(item.getLabel()));
choice.setPrefWidth(200);
choice.setVisibleRowCount(12);
bind(choice.valueProperty(), value);
return choice;
}
/**
* Renders an option through {@code <label>.<option>} when such a key exists, so {@code DARK} shows as
* "IntelliJ Dark" and {@code fr} as "français", falling back to the raw value otherwise. Keeps
* enumerated options translatable without a second metadata field per option.
* Renders an option through {@code <label>.<option>} when such a key exists, so {@code fr} shows as
* "Français", falling back to {@link #optionFallback} otherwise. Keeps enumerated options
* translatable without a second metadata field per option.
*/
private StringConverter<String> optionConverter(String labelKey) {
return new StringConverter<>() {
@@ -290,18 +298,34 @@ public class SettingsView extends BorderPane implements Disposable {
if (option == null) {
return "";
}
return i18n.resolveOr(labelKey + "." + option.toLowerCase(Locale.ROOT), option);
return i18n.resolveOr(labelKey + "." + option.toLowerCase(Locale.ROOT), optionFallback(option));
}
@Override
public String fromString(String text) {
// Choice boxes here are never editable, so nothing converts back.
// Combo boxes here are never editable, so nothing converts back.
throw new UnsupportedOperationException("read-only converter");
}
};
}
/** Binds a control property to a preference property, unbinding again on {@link #dispose()}. */
/**
* What an untranslated option shows as.
*
* <p>Normally the raw value, which is what makes a newly added option usable before anyone has
* written its label. Themes are the exception and the reason this method exists: their names are
* proper nouns — "Nord Dark", "GitHub Light Colorblind" — so translating thirty-two of them into two
* bundles would be thirty-two pairs of identical lines. {@code Theme.getName()} already holds the
* name the theme's author chose, so it is used directly.
*/
private static String optionFallback(String option) {
AppTheme theme = AppTheme.parse(option);
return theme.name().equals(option) ? theme.displayName() : option;
}
/**
* Binds a control property to a preference property, unbinding again on {@link #dispose()}.
*/
private <T> void bind(Property<T> control, Property<T> preference) {
control.bindBidirectional(preference);
scope.addTeardown(() -> control.unbindBidirectional(preference));
@@ -325,7 +349,9 @@ public class SettingsView extends BorderPane implements Disposable {
scope.add(i18n.localeProperty().subscribe(locale -> refreshRenderedLabels()));
}
/** Expands the selected group and scrolls it into view. A search in progress takes precedence. */
/**
* Expands the selected group and scrolls it into view. A search in progress takes precedence.
*/
private void revealGroup(String group) {
if (group == null || !search.getText().isBlank()) {
return;
@@ -365,7 +391,7 @@ public class SettingsView extends BorderPane implements Disposable {
* its grid line and the section keeps a hole in it.
*/
private void applyFilter(String query) {
String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
boolean filtering = !needle.isEmpty();
for (Row row : rows) {
@@ -375,8 +401,8 @@ public class SettingsView extends BorderPane implements Disposable {
boolean anyMatch = false;
for (Map.Entry<String, TitledPane> entry : panes.entrySet()) {
boolean hasVisibleRow = rows.stream()
.filter(row -> row.group.equals(entry.getKey()))
.anyMatch(Row::isShown);
.filter(row -> row.group.equals(entry.getKey()))
.anyMatch(Row::isShown);
TitledPane pane = entry.getValue();
pane.setVisible(hasVisibleRow);
pane.setManaged(hasVisibleRow);
@@ -397,13 +423,15 @@ public class SettingsView extends BorderPane implements Disposable {
}
}
/** Re-renders the sidebar and every choice box so non-observable labels follow a language switch. */
/**
* Re-renders the sidebar and every combo box so non-observable labels follow a language switch.
*/
private void refreshRenderedLabels() {
groupList.refresh();
for (Row row : rows) {
if (row.control instanceof ChoiceBox<?> box) {
if (row.control instanceof ComboBox<?> box) {
@SuppressWarnings("unchecked")
ChoiceBox<String> choice = (ChoiceBox<String>) box;
ComboBox<String> choice = (ComboBox<String>) box;
String selected = choice.getValue();
choice.setConverter(optionConverter(row.item.getLabel()));
// Re-setting the value is what forces the button cell to re-render; replacing the converter
@@ -446,13 +474,15 @@ public class SettingsView extends BorderPane implements Disposable {
panes.clear();
}
/** One label/control pair, with what the filter needs to decide whether to show it. */
/**
* One label/control pair, with what the filter needs to decide whether to show it.
*/
private static final class Row {
private final String group;
private final String key;
private final Label label;
private final Region control;
private final String group;
private final String key;
private final Label label;
private final Region control;
private final PreferenceItem<?> item;
private Row(String group, String key, Label label, Region control, PreferenceItem<?> item) {
@@ -465,8 +495,8 @@ public class SettingsView extends BorderPane implements Disposable {
private boolean matches(String needle, I18nService i18n) {
return contains(i18n.resolveOr(item.getLabel(), key), needle)
|| contains(key, needle)
|| contains(group, needle);
|| contains(key, needle)
|| contains(group, needle);
}
private static boolean contains(String haystack, String needle) {
@@ -485,7 +515,9 @@ public class SettingsView extends BorderPane implements Disposable {
}
}
/** Renders a group name through its {@code settings.group.*} key, falling back to the raw name. */
/**
* Renders a group name through its {@code settings.group.*} key, falling back to the raw name.
*/
private final class GroupCell extends ListCell<String> {
@Override
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
@@ -38,7 +39,7 @@ public class StatusBar extends HBox implements Disposable {
setAlignment(Pos.CENTER_LEFT);
setSpacing(12);
libraryLabel.getStyleClass().add("status-label");
libraryLabel.getStyleClass().add(Styles.TEXT_MUTED);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
@@ -43,7 +44,7 @@ public class TaskMonitorWidget extends HBox implements Disposable {
setAlignment(Pos.CENTER_LEFT);
setSpacing(6);
label.getStyleClass().add("status-label");
label.getStyleClass().add(Styles.TEXT_MUTED);
spinner.setPrefSize(12, 12);
spinner.setMaxSize(12, 12);
@@ -1,5 +1,7 @@
package org.icroco.pholio.ui.shell;
import atlantafx.base.theme.Styles;
import atlantafx.base.theme.Tweaks;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -55,12 +57,12 @@ public class TaskPanel extends VBox implements Disposable {
}
private HBox buildHeader() {
title.getStyleClass().add("section-title");
title.getStyleClass().add(Styles.TITLE_4);
title.textProperty().bind(i18n.binding("tasks.title"));
closeHint.textProperty().bind(i18n.binding("tasks.close"));
closeButton.setGraphic(FontIcon.of(Feather.X, 13));
closeButton.getStyleClass().addAll("button-icon", "flat");
closeButton.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT);
closeButton.setTooltip(closeHint);
closeButton.setOnAction(event -> publisher.publishEvent(new ToggleTaskPanelEvent()));
@@ -78,12 +80,14 @@ public class TaskPanel extends VBox implements Disposable {
ListView<Task<?>> list = new ListView<>(taskManager.running());
list.setPlaceholder(taskPlaceholder());
list.setCellFactory(view -> new TaskCell());
// Docked panel: the panel owns the border, the list must not draw a second one.
list.getStyleClass().add(Tweaks.EDGE_TO_EDGE);
return list;
}
private Label taskPlaceholder() {
Label label = new Label();
label.getStyleClass().add("status-label");
label.getStyleClass().add(Styles.TEXT_MUTED);
label.textProperty().bind(i18n.binding("tasks.empty"));
return label;
}
@@ -112,7 +116,7 @@ public class TaskPanel extends VBox implements Disposable {
private final VBox layout = new VBox(2, title, message, progress);
private TaskCell() {
message.getStyleClass().add("status-label");
message.getStyleClass().add(Styles.TEXT_MUTED);
progress.setMaxWidth(Double.MAX_VALUE);
layout.setPadding(new Insets(6, 8, 6, 8));
}
@@ -0,0 +1,141 @@
package org.icroco.pholio.ui.theme;
import atlantafx.base.theme.*;
import com.dlsc.atlantafx.themes.*;
import java.util.Locale;
import java.util.function.Supplier;
/**
* Every appearance the application can wear: the seven themes of {@code atlantafx-base} and the
* twenty-five of {@code com.dlsc.atlantafx:themes}.
*
* <p>Written out by hand because there is nothing to discover from. {@link Theme} is a plain interface,
* its implementations are final classes, and neither library ships a {@code ServiceLoader} descriptor —
* so a registry would have to scan the classpath for a marker that does not exist. The list is instead
* kept honest by {@code PreferenceSchemaCouplingTest}, which fails the build when it and the
* {@code ui.theme} options in {@code preferences.yaml} disagree.
*
* <p>There is no longer a house palette layered on top: {@code css/pholio.css} reads the
* {@code -color-*} variables that every theme here defines rather than redefining them, which is what
* makes all thirty-two actually look different.
*/
public enum AppTheme {
// ---------------------------------------------------------------- atlantafx-base
PRIMER_LIGHT(PrimerLight::new),
PRIMER_DARK(PrimerDark::new),
NORD_LIGHT(NordLight::new),
NORD_DARK(NordDark::new),
CUPERTINO_LIGHT(CupertinoLight::new),
CUPERTINO_DARK(CupertinoDark::new),
DRACULA(Dracula::new),
// ------------------------------------------------- com.dlsc.atlantafx:themes
ARMY_LIGHT(ArmyLight::new),
ARMY_DARK(ArmyDark::new),
BLUE_LIGHT(BlueLight::new),
BLUE_DARK(BlueDark::new),
NAVY_LIGHT(NavyLight::new),
NAVY_DARK(NavyDark::new),
SPRING_LIGHT(SpringLight::new),
SPRING_DARK(SpringDark::new),
SUMMER_LIGHT(SummerLight::new),
SUMMER_DARK(SummerDark::new),
FALL_LIGHT(FallLight::new),
FALL_DARK(FallDark::new),
WINTER_LIGHT(WinterLight::new),
WINTER_DARK(WinterDark::new),
GITHUB_LIGHT_DEFAULT(GithubLightDefault::new),
GITHUB_SOFT_DARK(GithubSoftDark::new),
GITHUB_LIGHT_COLORBLIND(GithubLightColorblind::new),
GITHUB_DARK_COLORBLIND(GithubDarkColorblind::new),
GITHUB_LIGHT_TRITANOPIA(GithubLightTritanopia::new),
GITHUB_DARK_TRITANOPIA(GithubDarkTritanopia::new),
AUTUMN(Autumn::new),
BLACKY(Blacky::new),
BROWNY(Browny::new),
NEWS(News::new),
YACHT(Yacht::new);
/**
* Applied when nothing has been chosen, and the fallback of {@link #parse}.
*/
public static final AppTheme DEFAULT = PRIMER_DARK;
private static final String LIGHT_TOKEN = "LIGHT";
private static final String DARK_TOKEN = "DARK";
private final Supplier<Theme> factory;
/**
* Resolved on first use rather than in the constructor. The DLSC themes are compiled against an older
* {@code atlantafx-base}; were that ever to break, an eager field would turn one unusable theme into
* an {@link ExceptionInInitializerError} that takes the whole enum — and the application — with it.
*/
private volatile Theme resolved;
AppTheme(Supplier<Theme> factory) {
this.factory = factory;
}
public Theme theme() {
Theme instance = resolved;
if (instance == null) {
// Benign race: the themes are immutable and cheap, so two threads may each build one and the
// last write wins with no observable difference.
instance = factory.get();
resolved = instance;
}
return instance;
}
/**
* The theme's own name — "Primer Dark", "Nord Light" — which is what the selector shows.
*/
public String displayName() {
return theme().getName();
}
public boolean isDark() {
return theme().isDarkMode();
}
/**
* The same theme in the opposite mode, for the header's light/dark button.
*
* <p>Found by convention rather than by a hand-written table of thirty-two entries: swapping the
* {@code LIGHT} / {@code DARK} token in the constant name pairs up everything that has a twin,
* including {@code GITHUB_DARK_COLORBLIND} ↔ {@code GITHUB_LIGHT_COLORBLIND}. Themes with no twin —
* {@code DRACULA}, {@code BLACKY}, {@code GITHUB_SOFT_DARK} — fall back to Primer, so the button
* always does something predictable.
*/
public AppTheme counterpart() {
String swapped = name().contains(DARK_TOKEN)
? name().replace(DARK_TOKEN, LIGHT_TOKEN)
: name().replace(LIGHT_TOKEN, DARK_TOKEN);
if (!swapped.equals(name())) {
for (AppTheme candidate : values()) {
if (candidate.name().equals(swapped)) {
return candidate;
}
}
}
return isDark() ? PRIMER_LIGHT : PRIMER_DARK;
}
/**
* Parses a persisted value, falling back to {@link #DEFAULT} for anything unrecognised.
*/
public static AppTheme parse(String value) {
if (value != null) {
String name = value.trim().toUpperCase(Locale.ROOT);
for (AppTheme theme : values()) {
if (theme.name().equals(name)) {
return theme;
}
}
}
return DEFAULT;
}
}
@@ -23,20 +23,29 @@ import java.util.WeakHashMap;
/**
* 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>Two stylesheets are in play, but only one of them ever changes. The chosen {@link AppTheme} is
* installed globally through {@link Application#setUserAgentStylesheet(String)} so it reaches popups and
* menus that belong to no registered scene. {@value #PHOLIO_STYLESHEET} is added to each scene and never
* removed: it styles the shell's own components — header bar, rail, drawer, status bar — and does so by
* <em>reading</em> the {@code -color-*} variables the active theme defines, so it fits all of them.
*
* <p>That per-scene placement is deliberate. A user-agent stylesheet has the lowest precedence of all,
* so a shell rule installed that way would lose to the theme it is meant to sit on top of.
*
* <p>Scenes are tracked weakly: a closed secondary window must not be kept alive by this singleton.
*/
@UiComponent
@Slf4j
public class ThemeManager {
/**
* The shell's own stylesheet. Theme-agnostic, hence added once and never swapped.
*/
public static final String PHOLIO_STYLESHEET = "/css/pholio.css";
/**
* Fonts loaded if present on the classpath; the CSS degrades to system fonts when they are not.
*/
private static final String[] OPTIONAL_FONTS = {
private static final String[] OPTIONAL_FONTS = {
"/fonts/Inter-Regular.ttf",
"/fonts/Inter-Medium.ttf",
"/fonts/Inter-SemiBold.ttf",
@@ -46,32 +55,32 @@ public class ThemeManager {
/**
* Group and key of the persisted theme, as declared in {@code preferences.yaml}.
*/
private static final String UI_GROUP = "ui";
private static final String THEME_KEY = "theme";
private final OsThemeDetector detector = OsThemeDetector.getDetector();
private final AppPreferences preferences;
private final Set<Scene> scenes = Collections.newSetFromMap(new WeakHashMap<>());
private final ObjectProperty<ThemeMode> mode = new SimpleObjectProperty<>(this, "mode");
private static final String UI_GROUP = "ui";
private static final String THEME_KEY = "theme";
private final OsThemeDetector detector = OsThemeDetector.getDetector();
private final AppPreferences preferences;
private final Set<Scene> scenes = Collections.newSetFromMap(new WeakHashMap<>());
private final ObjectProperty<AppTheme> theme = new SimpleObjectProperty<>(this, "theme");
private boolean fontsLoaded;
public ThemeManager(AppPreferences preferences) {
this.preferences = preferences;
this.mode.set(ThemeMode.parse(preferences.getValue(UI_GROUP, THEME_KEY, String.class)));
this.theme.set(AppTheme.parse(preferences.getValue(UI_GROUP, THEME_KEY, String.class)));
// Makes the theme selector in the settings view work with no wiring of its own: it writes the
// preference, and this subscription applies it. subscribe fires immediately with the current value,
// which setMode discards because it already matches. Never unsubscribed — this is a singleton that
// which setTheme discards because it already matches. Never unsubscribed — this is a singleton that
// outlives every scene.
preferences.property(UI_GROUP, THEME_KEY, String.class)
.subscribe(name -> setMode(ThemeMode.parse(name)));
.subscribe(name -> setTheme(AppTheme.parse(name)));
}
public ReadOnlyObjectProperty<ThemeMode> modeProperty() {
return mode;
public ReadOnlyObjectProperty<AppTheme> themeProperty() {
return theme;
}
public ThemeMode mode() {
return mode.get();
public AppTheme theme() {
return theme.get();
}
/**
@@ -80,45 +89,49 @@ public class ThemeManager {
*/
public void initialise() {
loadFonts();
applyBaseTheme(mode.get());
applyBaseTheme(theme.get());
}
/**
* Starts styling {@code scene} and keeps it in sync with later theme switches.
* Starts styling {@code scene}.
*
* <p>Nothing more is needed for it to follow later theme switches: those replace the global
* user-agent stylesheet, which every scene reads, and the shell sheet added here is the same
* whatever the theme.
*/
public void register(Scene scene) {
if (scene == null || !scenes.add(scene)) {
return;
}
applyOverride(scene, mode.get());
resolve(PHOLIO_STYLESHEET).ifPresent(url -> {
if (!scene.getStylesheets().contains(url)) {
scene.getStylesheets().add(url);
}
});
}
public void unregister(Scene scene) {
scenes.remove(scene);
}
/**
* Switches to the same theme in the opposite mode. @see AppTheme#counterpart()
*/
public void toggle() {
setMode(mode.get().opposite());
setTheme(theme.get().counterpart());
}
/**
* Switches appearance across every registered scene and persists the choice.
* Installs {@code newTheme} and persists the choice.
*/
public void setMode(ThemeMode newMode) {
if (newMode == null || newMode == mode.get()) {
public void setTheme(AppTheme newTheme) {
if (newTheme == null || newTheme == theme.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.setValue(UI_GROUP, THEME_KEY, newMode.name());
log.info("Switching theme to {} ({})", newTheme, newTheme.displayName());
theme.set(newTheme);
FxUtils.onFxThread(() -> applyBaseTheme(newTheme));
preferences.setValue(UI_GROUP, THEME_KEY, newTheme.name());
}
/**
@@ -128,20 +141,8 @@ public class ThemeManager {
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 void applyBaseTheme(AppTheme appTheme) {
Application.setUserAgentStylesheet(appTheme.theme().getUserAgentStylesheet());
}
private Optional<String> resolve(String classpathLocation) {
@@ -1,60 +0,0 @@
package org.icroco.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;
}
}
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.view;
import atlantafx.base.theme.Styles;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
@@ -49,15 +50,15 @@ public class GalleryView extends StackPane implements Disposable {
getStyleClass().add("empty-state");
title.getStyleClass().add("empty-title");
title.getStyleClass().add(Styles.TITLE_2);
title.textProperty().bind(i18n.binding("gallery.empty.title"));
subtitle.getStyleClass().add("empty-subtitle");
subtitle.getStyleClass().add(Styles.TEXT_MUTED);
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.getStyleClass().add(Styles.ACCENT);
chooseRoot.setOnAction(event -> chooseLibraryRoot());
VBox content = new VBox(10, FontIcon.of(Feather.IMAGE, 40), title, subtitle, chooseRoot);
@@ -1,5 +1,6 @@
package org.icroco.pholio.ui.view;
import atlantafx.base.theme.Styles;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
@@ -33,8 +34,8 @@ public class ModulePlaceholderView extends StackPane implements Disposable, Navi
this.i18n = i18n;
getStyleClass().add("empty-state");
title.getStyleClass().add("empty-title");
subtitle.getStyleClass().add("empty-subtitle");
title.getStyleClass().add(Styles.TITLE_2);
subtitle.getStyleClass().add(Styles.TEXT_MUTED);
VBox content = new VBox(8, FontIcon.of(Feather.TOOL, 32), title, subtitle);
content.setAlignment(Pos.CENTER);
+281
View File
@@ -0,0 +1,281 @@
/*
* Pholio — the shell's own stylesheet.
*
* There is no house palette any more. This sheet *reads* the -color-* variables the active AtlantaFX
* theme defines and never redefines them, which is what lets all thirty-two themes in AppTheme look
* genuinely different instead of being flattened back to one palette. It is therefore a single file,
* with no light/dark twin: each theme supplies its own values for both modes.
*
* What belongs here: layout that AtlantaFX has no opinion about — the shell's fixed heights and widths,
* its panel surfaces and separating edges, and the :hover / :selected / .active states of controls the
* toolkit does not know exist. What does not: anything atlantafx.base.theme.Styles or Tweaks already
* says. Typography, icon buttons, accent buttons, muted text and edge-to-edge lists are applied in Java
* through those constants, so a theme upgrade carries them along for free.
*
* Added per scene by ThemeManager, never as a user-agent stylesheet: a user-agent sheet has the lowest
* precedence of all and would lose to the very theme this is meant to sit on top of.
*/
.root {
/*
* Structural tokens only — sizes and radii, no colours. A colour here would be a house palette by
* another name and would stop following the theme.
*/
-pholio-card-radius: 6px;
-pholio-hover-overlay: #0000004D;
-pholio-header-height: 40px;
-pholio-rail-width: 44px;
-pholio-panel-width: 300px;
-pholio-statusbar-height: 26px;
}
/* ========================================================================== */
/* 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;
}
/* Weight and colour come from Styles.TEXT_BOLD; only the spacing is ours. */
.app-header-bar .app-logo {
-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;
}
/* Left navigation: icon rail when collapsed, drawer beside it 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;
}
/*
* `.active` marks the destination whose drawer is open. Kept as a house class because no AtlantaFX
* constant expresses "current item of a navigation rail" — Styles.ACCENT would repaint the whole
* button, not tint it.
*/
.navigation-rail .rail-button:selected,
.navigation-rail .rail-button.active {
-fx-background-color: -color-accent-subtle;
}
/*
* The drawer that opens beside the icon strip. Same surface colour and no border of its own: strip and
* drawer must read as one panel, and the edge separating navigation from the viewport is the rail's.
*/
.navigation-drawer {
-fx-background-color: -color-bg-subtle;
-fx-padding: 8 0 8 0;
}
.navigation-drawer .drawer-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-padding: 0 0 6 0;
}
/*
* Tweaks.EDGE_TO_EDGE drops the tree's border, but not its opaque background: without this the tree
* paints a lighter rectangle over the drawer surface.
*/
.navigation-drawer .tree-view,
.navigation-drawer .tree-view > .virtual-flow > .clipped-container > .sheet > .tree-cell {
-fx-background-color: transparent;
}
/* Invisible until pointed at: it is a 5px hit area, not a piece of chrome. */
.drawer-resizer {
-fx-background-color: transparent;
}
.drawer-resizer:hover {
-fx-background-color: -color-neutral-subtle;
}
/*
* Right column: the inspector, and the background-task panel docked under it. The column's outer border
* belongs to the dock, not to either panel — the panels come and go, the edge of the column does not.
*/
.inspector-dock {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent transparent transparent -color-border-default;
-fx-border-width: 0 0 0 1;
-fx-padding: 0;
}
/*
* A hairline rule inside a taller strip: 1px reads correctly but is almost impossible to grab, so the
* divider keeps a 5px hit area and draws its line on the top edge.
*/
.inspector-dock > .split-pane-divider {
-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;
-fx-pref-height: 5;
}
.inspector-dock > .split-pane-divider > .horizontal-grabber,
.inspector-dock > .split-pane-divider > .vertical-grabber {
-fx-background-color: transparent;
-fx-padding: 0;
-fx-shape: null;
}
/* Right inspector: EXIF/IPTC details for the selection. */
.inspector-panel {
-fx-background-color: -color-bg-subtle;
}
.task-panel {
-fx-background-color: -color-bg-subtle;
}
/* Same reason as the drawer's tree: EDGE_TO_EDGE removes the border, not the fill. */
.task-panel .list-view,
.task-panel .list-view .viewport {
-fx-background-color: transparent;
}
/* 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;
}
.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: -color-bg-subtle;
-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) */
/* ========================================================================== */
/* Surface and shadow come from Styles.BG_DEFAULT and Styles.ELEVATED_2; the frame is ours. */
.modal-card {
-fx-background-radius: 8px;
-fx-border-color: -color-border-default;
-fx-border-radius: 8px;
-fx-border-width: 1px;
}
/* ========================================================================== */
/* Empty states */
/* ========================================================================== */
.empty-state {
-fx-background-color: -color-bg-default;
}
@@ -1,417 +0,0 @@
/*
* 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;
}
/* Open-library selector, next to the logo. Shaped like the search field so the two controls in the bar
read as one family. Scoped under .app-header-bar so no other combo box in the application is touched. */
.app-header-bar .library-selector {
-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-padding: 0;
}
.app-header-bar .library-selector:hover {
-fx-border-color: -color-neutral-emphasis;
}
.app-header-bar .library-selector:focused {
-fx-border-color: -color-accent-emphasis;
}
.app-header-bar .library-selector > .list-cell {
-fx-background-color: transparent;
-fx-text-fill: -color-fg-default;
-fx-padding: 3 8 3 8;
}
.app-header-bar .library-selector > .arrow-button > .arrow {
-fx-background-color: -color-fg-muted;
}
/* The popup is a separate window and is styled by the AtlantaFX user-agent stylesheet, not by this
per-scene one, so only what the control itself owns can be set here. */
/* 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;
}
/*
* The drawer that opens beside the icon strip. Same surface colour and no border of its own: strip and
* drawer must read as one panel, and the edge separating navigation from the viewport is the rail's.
*/
.navigation-drawer {
-fx-background-color: -color-bg-subtle;
-fx-padding: 8 0 8 0;
}
.navigation-drawer .drawer-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-padding: 0 0 6 0;
}
/* The drawer already paints the surface; an opaque tree would draw a lighter rectangle on top of it. */
.navigation-drawer .tree-view,
.navigation-drawer .tree-view > .virtual-flow > .clipped-container > .sheet > .tree-cell,
.navigation-drawer .tree-cell {
-fx-background-color: transparent;
-fx-border-color: transparent;
}
.navigation-drawer .tree-cell:hover {
-fx-background-color: -color-neutral-subtle;
}
.navigation-drawer .tree-cell:selected {
-fx-background-color: -color-accent-subtle;
}
/* Invisible until pointed at: it is a 5px hit area, not a piece of chrome. */
.drawer-resizer {
-fx-background-color: transparent;
}
.drawer-resizer:hover {
-fx-background-color: -color-neutral-subtle;
}
/*
* Right column: the inspector, and the background-task panel docked under it. The column's outer border
* belongs to the dock, not to either panel — the panels come and go, the edge of the column does not.
*/
.inspector-dock {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent transparent transparent -color-border-default;
-fx-border-width: 0 0 0 1;
-fx-padding: 0;
}
/*
* A hairline rule inside a taller strip: 1px reads correctly but is almost impossible to grab, so the
* divider keeps a 5px hit area and draws its line on the top edge.
*/
.inspector-dock > .split-pane-divider {
-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;
-fx-pref-height: 5;
}
.inspector-dock > .split-pane-divider > .horizontal-grabber,
.inspector-dock > .split-pane-divider > .vertical-grabber {
-fx-background-color: transparent;
-fx-padding: 0;
-fx-shape: null;
}
/* Right inspector: EXIF/IPTC details for the selection. */
.inspector-panel {
-fx-background-color: -color-bg-subtle;
}
.task-panel {
-fx-background-color: -color-bg-subtle;
}
/* The dock already paints the surface; an opaque list would draw a lighter rectangle on top of it. */
.task-panel .list-view,
.task-panel .list-view .viewport,
.task-panel .list-cell {
-fx-background-color: transparent;
}
.task-panel .section-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
}
.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;
}
@@ -1,405 +0,0 @@
/*
* 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;
}
/* Open-library selector, next to the logo. Shaped like the search field so the two controls in the bar
read as one family. Scoped under .app-header-bar so no other combo box in the application is touched. */
.app-header-bar .library-selector {
-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-padding: 0;
}
.app-header-bar .library-selector:hover {
-fx-border-color: -color-neutral-emphasis;
}
.app-header-bar .library-selector:focused {
-fx-border-color: -color-accent-emphasis;
}
.app-header-bar .library-selector > .list-cell {
-fx-background-color: transparent;
-fx-text-fill: -color-fg-default;
-fx-padding: 3 8 3 8;
}
.app-header-bar .library-selector > .arrow-button > .arrow {
-fx-background-color: -color-fg-muted;
}
/* The popup is a separate window and is styled by the AtlantaFX user-agent stylesheet, not by this
per-scene one, so only what the control itself owns can be set here. */
.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;
}
/*
* The drawer that opens beside the icon strip. Same surface colour and no border of its own: strip and
* drawer must read as one panel, and the edge separating navigation from the viewport is the rail's.
*/
.navigation-drawer {
-fx-background-color: -color-bg-subtle;
-fx-padding: 8 0 8 0;
}
.navigation-drawer .drawer-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
-fx-padding: 0 0 6 0;
}
/* The drawer already paints the surface; an opaque tree would draw a lighter rectangle on top of it. */
.navigation-drawer .tree-view,
.navigation-drawer .tree-view > .virtual-flow > .clipped-container > .sheet > .tree-cell,
.navigation-drawer .tree-cell {
-fx-background-color: transparent;
-fx-border-color: transparent;
}
.navigation-drawer .tree-cell:hover {
-fx-background-color: -color-neutral-subtle;
}
.navigation-drawer .tree-cell:selected {
-fx-background-color: -color-accent-subtle;
}
/* Invisible until pointed at: it is a 5px hit area, not a piece of chrome. */
.drawer-resizer {
-fx-background-color: transparent;
}
.drawer-resizer:hover {
-fx-background-color: -color-neutral-subtle;
}
/*
* Right column: the inspector, and the background-task panel docked under it. The column's outer border
* belongs to the dock, not to either panel — the panels come and go, the edge of the column does not.
*/
.inspector-dock {
-fx-background-color: -color-bg-subtle;
-fx-border-color: transparent transparent transparent -color-border-default;
-fx-border-width: 0 0 0 1;
-fx-padding: 0;
}
/*
* A hairline rule inside a taller strip: 1px reads correctly but is almost impossible to grab, so the
* divider keeps a 5px hit area and draws its line on the top edge.
*/
.inspector-dock > .split-pane-divider {
-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;
-fx-pref-height: 5;
}
.inspector-dock > .split-pane-divider > .horizontal-grabber,
.inspector-dock > .split-pane-divider > .vertical-grabber {
-fx-background-color: transparent;
-fx-padding: 0;
-fx-shape: null;
}
.inspector-panel {
-fx-background-color: -color-bg-subtle;
}
.task-panel {
-fx-background-color: -color-bg-subtle;
}
/* The dock already paints the surface; an opaque list would draw a lighter rectangle on top of it. */
.task-panel .list-view,
.task-panel .list-view .viewport,
.task-panel .list-cell {
-fx-background-color: transparent;
}
.task-panel .section-title {
-fx-text-fill: -color-fg-subtle;
-fx-font-size: 11px;
}
.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;
}
-6
View File
@@ -96,12 +96,6 @@ settings.note.windowGeometry=Window position and size are saved automatically an
# Option labels for enumerated preferences, keyed <label>.<option in lower case>. A missing key falls
# back to the raw option value, so a new option is usable before it is translated.
settings.appearance.theme.dark=IntelliJ Dark
settings.appearance.theme.light=IntelliJ Light
settings.appearance.language.fr=Français
settings.appearance.language.en=English
settings.ai.provider.none=None
# --- Themes ---
theme.dark=IntelliJ Dark
theme.light=IntelliJ Light
@@ -95,12 +95,6 @@ settings.note.windowGeometry=La position et la taille des fenêtres sont enregis
# Libellés des options énumérées, clé <label>.<option en minuscules>. Une clé absente laisse place à la
# valeur brute, de sorte qu'une nouvelle option est utilisable avant d'être traduite.
settings.appearance.theme.dark=IntelliJ Dark
settings.appearance.theme.light=IntelliJ Light
settings.appearance.language.fr=Français
settings.appearance.language.en=English
settings.ai.provider.none=Aucun
# --- Themes ---
theme.dark=IntelliJ Dark
theme.light=IntelliJ Light
+11 -3
View File
@@ -37,9 +37,17 @@ ui:
theme:
type: STRING
label: settings.appearance.theme
default-value: DARK
# A non-empty `options` list turns the control into a ChoiceBox. Values must match ThemeMode.
options: [ DARK, LIGHT ]
default-value: PRIMER_DARK
# A non-empty `options` list turns the control into a ComboBox. Values must match AppTheme, which
# PreferenceSchemaCouplingTest verifies. These are the only options the settings view does not look
# up in the message bundles: theme names are proper nouns, rendered from Theme.getName().
options: [ PRIMER_LIGHT, PRIMER_DARK, NORD_LIGHT, NORD_DARK, CUPERTINO_LIGHT, CUPERTINO_DARK, DRACULA,
ARMY_LIGHT, ARMY_DARK, BLUE_LIGHT, BLUE_DARK, NAVY_LIGHT, NAVY_DARK,
SPRING_LIGHT, SPRING_DARK, SUMMER_LIGHT, SUMMER_DARK, FALL_LIGHT, FALL_DARK,
WINTER_LIGHT, WINTER_DARK, GITHUB_LIGHT_DEFAULT, GITHUB_SOFT_DARK,
GITHUB_LIGHT_COLORBLIND, GITHUB_DARK_COLORBLIND,
GITHUB_LIGHT_TRITANOPIA, GITHUB_DARK_TRITANOPIA,
AUTUMN, BLACKY, BROWNY, NEWS, YACHT ]
locale:
type: STRING
label: settings.appearance.language
@@ -53,7 +53,7 @@ class LibraryCatalogTest {
*/
@Test
void reportsNoLibraryWhenTheDataDirectoryIsAbsent() {
try (PholioHome absent = PholioHome.rootedAt(home.resolve("never-created"))) {
try (PholioHome ignored = PholioHome.rootedAt(home.resolve("never-created"))) {
assertThat(LibraryCatalog.discover()).isEmpty();
}
}
@@ -408,7 +408,7 @@ class PreferenceServiceTest {
softly.assertThat(preferences.visibleGroupNames())
.containsExactly("library", "ui", "sync", "thumbnails", "ai", "imports");
softly.assertThat(preferences.groupNames()).contains("window.main");
softly.assertThat(preferences.getValue("ui", "theme", String.class)).isEqualTo("DARK");
softly.assertThat(preferences.getValue("ui", "theme", String.class)).isEqualTo("PRIMER_DARK");
softly.assertThat(preferences.getValue("ui", "locale", String.class)).isEqualTo("fr");
softly.assertThat(preferences.text("library", "root-path")).isEmpty();
});
@@ -80,14 +80,14 @@ class WindowGeometryPreferencesTest {
void resetToDefaultsLeavesGeometryUntouched() {
Fixture fixture = fixture();
fixture.geometries.save("main", new WindowGeometry(10.0, 20.0, 800.0, 600.0, true));
fixture.preferences.setValue("ui", "theme", "LIGHT");
fixture.preferences.setValue("ui", "theme", "NORD_LIGHT");
fixture.service.resetToDefaults();
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(fixture.geometries.of("main"))
.isEqualTo(new WindowGeometry(10.0, 20.0, 800.0, 600.0, true));
softly.assertThat(fixture.preferences.getValue("ui", "theme", String.class)).isEqualTo("DARK");
softly.assertThat(fixture.preferences.getValue("ui", "theme", String.class)).isEqualTo("PRIMER_DARK");
});
}
@@ -6,7 +6,7 @@ import org.icroco.pholio.infra.preferences.PreferenceItem;
import org.icroco.pholio.infra.preferences.PreferenceType;
import org.icroco.pholio.infra.preferences.PreferencesFixture;
import org.icroco.pholio.ui.i18n.I18nService;
import org.icroco.pholio.ui.theme.ThemeMode;
import org.icroco.pholio.ui.theme.AppTheme;
import org.junit.jupiter.api.Test;
import org.springframework.context.support.ResourceBundleMessageSource;
@@ -17,28 +17,39 @@ import java.util.Locale;
* Guards the couplings between {@code preferences.yaml} and the code that consumes it.
*
* <p>These are the joins that a compiler cannot check and that the settings view cannot report, because it
* renders whatever the schema declares. If {@code ui.theme}'s options and {@link ThemeMode} drift apart, the
* theme selector silently offers a value that {@code ThemeMode.parse} maps to {@code DARK}; if a label key is
* renders whatever the schema declares. If {@code ui.theme}'s options and {@link AppTheme} drift apart, the
* theme selector silently offers a value that {@code AppTheme.parse} maps to its default; if a label key is
* missing from a bundle, the view shows the raw key. Both look like working software.
*/
class PreferenceSchemaCouplingTest {
/**
* Theme names are proper nouns — "Nord Dark", "GitHub Light Colorblind" — so they are rendered from
* {@code Theme.getName()} rather than translated. Excluded from
* {@link #everyOptionIsTranslatedInBothBundles()}, and the only preference that is.
*/
private static final String UNTRANSLATED_OPTIONS = "theme";
private final AppPreferences preferences = PreferencesFixture.fromBundledSchema();
/** Every option the theme selector offers must be a real {@link ThemeMode}. */
/**
* Every option the theme selector offers must be a real {@link AppTheme}.
*/
@Test
void themeOptionsMatchTheThemeModes() {
void themeOptionsMatchTheCatalogue() {
List<String> declared = preferences.item("ui", "theme").orElseThrow().getOptions();
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(declared).isNotNull();
softly.assertThat(declared)
.containsExactlyInAnyOrderElementsOf(
List.of(ThemeMode.values()).stream().map(Enum::name).toList());
.containsExactlyInAnyOrderElementsOf(
List.of(AppTheme.values()).stream().map(Enum::name).toList());
});
}
/** Every option the language selector offers must have a bundled {@code messages_*.properties}. */
/**
* Every option the language selector offers must have a bundled {@code messages_*.properties}.
*/
@Test
void localeOptionsMatchTheSupportedLocales() {
List<String> declared = preferences.item("ui", "locale").orElseThrow().getOptions();
@@ -50,18 +61,20 @@ class PreferenceSchemaCouplingTest {
});
}
/** The persisted defaults must themselves be valid options, or a fresh install starts off-list. */
/**
* The persisted defaults must themselves be valid options, or a fresh install starts off-list.
*/
@Test
void everyDefaultIsOneOfItsOwnOptions() {
SoftAssertions.assertSoftly(softly -> preferences.getGroups().forEach((group, items) ->
items.forEach((key, item) -> {
if (item.getOptions() == null || item.getOptions().isEmpty()) {
return;
}
softly.assertThat(item.getOptions())
.as("default of %s.%s", group, key)
.contains((String) item.getDefaultValue());
})));
items.forEach((key, item) -> {
if (item.getOptions() == null || item.getOptions().isEmpty()) {
return;
}
softly.assertThat(item.getOptions())
.as("default of %s.%s", group, key)
.contains((String) item.getDefaultValue());
})));
}
/**
@@ -84,8 +97,8 @@ class PreferenceSchemaCouplingTest {
// never rendered, so requiring one would mean translating a name nobody can see.
for (String group : preferences.visibleGroupNames()) {
softly.assertThat(messages.getMessage("settings.group." + group, null, group, locale))
.as("group title '%s' in %s", group, locale)
.isNotEqualTo(group);
.as("group title '%s' in %s", group, locale)
.isNotEqualTo(group);
}
// Row labels, in contrast, are checked for every group: an invisible item still has to carry
// a resolvable label for the day it is switched on.
@@ -111,45 +124,53 @@ class PreferenceSchemaCouplingTest {
SoftAssertions.assertSoftly(softly -> {
for (Locale locale : I18nService.SUPPORTED_LOCALES) {
preferences.getGroups().forEach((group, items) -> items.forEach((key, item) -> {
if (item.getOptions() == null) {
if (item.getOptions() == null || UNTRANSLATED_OPTIONS.equals(key)) {
return;
}
for (String option : item.getOptions()) {
String code = item.getLabel() + "." + option.toLowerCase(Locale.ROOT);
softly.assertThat(messages.getMessage(code, null, code, locale))
.as("option %s.%s=%s in %s", group, key, option, locale)
.isNotEqualTo(code);
.as("option %s.%s=%s in %s", group, key, option, locale)
.isNotEqualTo(code);
}
}));
}
});
}
/** Bounds only mean something on a numeric spinner; one on a checkbox or text field is a mistake. */
/**
* Bounds only mean something on a numeric spinner; one on a checkbox or text field is a mistake.
*/
@Test
void boundsAndOptionsAreDeclaredOnlyWhereTheyApply() {
SoftAssertions.assertSoftly(softly -> preferences.getGroups().forEach((group, items) ->
items.forEach((key, item) -> {
boolean numeric = item.getType() == PreferenceType.INT
|| item.getType() == PreferenceType.DOUBLE;
if (!numeric) {
softly.assertThat(item.getMin()).as("min on non-numeric %s.%s", group, key).isNull();
softly.assertThat(item.getMax()).as("max on non-numeric %s.%s", group, key).isNull();
}
if (item.getType() != PreferenceType.STRING) {
softly.assertThat(item.getOptions())
.as("options on non-string %s.%s", group, key)
.isNullOrEmpty();
}
if (item.getMin() != null && item.getMax() != null) {
softly.assertThat(item.getMax().doubleValue())
.as("range of %s.%s", group, key)
.isGreaterThan(item.getMin().doubleValue());
}
})));
items.forEach((key, item) -> {
boolean numeric = item.getType() == PreferenceType.INT
|| item.getType() == PreferenceType.DOUBLE;
if (!numeric) {
softly.assertThat(item.getMin())
.as("min on non-numeric %s.%s", group, key)
.isNull();
softly.assertThat(item.getMax())
.as("max on non-numeric %s.%s", group, key)
.isNull();
}
if (item.getType() != PreferenceType.STRING) {
softly.assertThat(item.getOptions())
.as("options on non-string %s.%s", group, key)
.isNullOrEmpty();
}
if (item.getMin() != null && item.getMax() != null) {
softly.assertThat(item.getMax().doubleValue())
.as("range of %s.%s", group, key)
.isGreaterThan(item.getMin().doubleValue());
}
})));
}
/** The keys the migrated collaborators read, named here so a rename in the schema fails a test. */
/**
* The keys the migrated collaborators read, named here so a rename in the schema fails a test.
*/
@Test
void theKeysCollaboratorsReadStillExist() {
SoftAssertions.assertSoftly(softly -> {
@@ -157,17 +178,19 @@ class PreferenceSchemaCouplingTest {
softly.assertThat(preferences.item("ui", "locale")).as("I18nService").isPresent();
softly.assertThat(preferences.item("ui", "left-panel-expanded")).as("NavigationRail").isPresent();
softly.assertThat(preferences.item("library", "root-path"))
.as("GalleryView, StatusBar and ScanCommand")
.isPresent();
.as("GalleryView, StatusBar and ScanCommand")
.isPresent();
for (String key : List.of("x", "y", "width", "height", "maximized")) {
softly.assertThat(preferences.item("window.main", key))
.as("WindowGeometryPreferences.%s", key)
.isPresent();
.as("WindowGeometryPreferences.%s", key)
.isPresent();
}
});
}
/** The rail's own toggle owns this state, so offering a second control for it would be a conflict. */
/**
* The rail's own toggle owns this state, so offering a second control for it would be a conflict.
*/
@Test
void panelFlagsAreNotOfferedInTheSettingsView() {
SoftAssertions.assertSoftly(softly -> {
@@ -76,7 +76,8 @@ class TaskPanelTest {
@Test
void hasATitleAndACloseAffordance() {
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(panel.lookupAll(".section-title")).hasSize(1);
// Styles.TITLE_4 — the panel heading is styled by AtlantaFX, not by a class of our own.
softly.assertThat(panel.lookupAll(".title-4")).hasSize(1);
softly.assertThat(panel.lookupAll(".button-icon")).hasSize(1);
softly.assertThat(panel.lookupAll(".tab-pane")).as("no tabs here any more").isEmpty();
});
@@ -0,0 +1,136 @@
package org.icroco.pholio.ui.theme;
import atlantafx.base.theme.Theme;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The catalogue of appearances.
*
* <p>{@link #everyThemeResolvesItsStylesheet()} carries the most weight here, and not only as a typo
* check on thirty-two constants: {@code com.dlsc.atlantafx:themes} is compiled against an older
* {@code atlantafx-base} than the one Pholio runs. Loading every theme and asking it for its stylesheet
* is what proves that cross-version pairing holds — a broken one shows up as a linkage error, right
* here, rather than as a blank window on a user's machine.
*/
class AppThemeTest {
@Test
void everyThemeResolvesItsStylesheet() {
SoftAssertions.assertSoftly(softly -> {
for (AppTheme appTheme : AppTheme.values()) {
Theme theme = appTheme.theme();
softly.assertThat(theme).as("theme of %s", appTheme).isNotNull();
softly.assertThat(appTheme.displayName()).as("name of %s", appTheme).isNotBlank();
// The themes return a classpath path, not a URL; a missing resource is what a renamed or
// repackaged theme would look like.
softly.assertThat(AppTheme.class.getResource(theme.getUserAgentStylesheet()))
.as("stylesheet %s of %s", theme.getUserAgentStylesheet(), appTheme)
.isNotNull();
}
});
}
/**
* Two themes sharing a display name would be indistinguishable in the selector.
*/
@Test
void displayNamesAreUnique() {
Set<String> names = new HashSet<>();
SoftAssertions.assertSoftly(softly -> {
for (AppTheme theme : AppTheme.values()) {
softly.assertThat(names.add(theme.displayName()))
.as("display name '%s' of %s is already taken", theme.displayName(), theme)
.isTrue();
}
});
}
/**
* Both libraries are represented; a dependency that failed to resolve would halve the list.
*/
@Test
void coversBothThemeLibraries() {
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(AppTheme.values()).hasSize(32);
softly.assertThat(AppTheme.PRIMER_DARK.theme().getClass().getPackageName())
.isEqualTo("atlantafx.base.theme");
softly.assertThat(AppTheme.YACHT.theme().getClass().getPackageName())
.isEqualTo("com.dlsc.atlantafx.themes");
});
}
/**
* A theme with a twin pairs up; the swap is symmetric, so it must round-trip.
*/
@Test
void pairedThemesRoundTripThroughTheirCounterpart() {
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(AppTheme.PRIMER_DARK.counterpart()).isEqualTo(AppTheme.PRIMER_LIGHT);
softly.assertThat(AppTheme.PRIMER_LIGHT.counterpart()).isEqualTo(AppTheme.PRIMER_DARK);
softly.assertThat(AppTheme.NORD_LIGHT.counterpart()).isEqualTo(AppTheme.NORD_DARK);
// Three tokens, and the mode is the middle one — the reason the swap is by token and not by
// suffix.
softly.assertThat(AppTheme.GITHUB_DARK_COLORBLIND.counterpart())
.isEqualTo(AppTheme.GITHUB_LIGHT_COLORBLIND);
softly.assertThat(AppTheme.GITHUB_LIGHT_TRITANOPIA.counterpart())
.isEqualTo(AppTheme.GITHUB_DARK_TRITANOPIA);
});
}
/**
* Themes with no twin still have to give the header button something to do — {@code DRACULA} and
* {@code BLACKY} carry no mode token at all, {@code GITHUB_SOFT_DARK} carries one whose swap names
* nothing.
*/
@Test
void unpairedThemesFallBackToPrimer() {
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(AppTheme.DRACULA.counterpart()).isEqualTo(AppTheme.PRIMER_LIGHT);
softly.assertThat(AppTheme.BLACKY.counterpart()).isEqualTo(AppTheme.PRIMER_LIGHT);
softly.assertThat(AppTheme.GITHUB_SOFT_DARK.counterpart()).isEqualTo(AppTheme.PRIMER_LIGHT);
softly.assertThat(AppTheme.GITHUB_LIGHT_DEFAULT.counterpart()).isEqualTo(AppTheme.PRIMER_DARK);
});
}
/**
* Whatever the pairing, the button must never land on a theme of the same mode.
*/
@Test
void counterpartAlwaysChangesMode() {
SoftAssertions.assertSoftly(softly -> Arrays.stream(AppTheme.values()).forEach(theme ->
softly.assertThat(theme.counterpart().isDark())
.as("%s (dark=%s) -> %s",
theme,
theme.isDark(),
theme.counterpart())
.isNotEqualTo(theme.isDark())));
}
@Test
void parseIsForgivingAboutCaseAndFallsBackToTheDefault() {
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(AppTheme.parse("nord_dark")).isEqualTo(AppTheme.NORD_DARK);
softly.assertThat(AppTheme.parse(" Dracula ")).isEqualTo(AppTheme.DRACULA);
// The value a preferences.yaml written by an earlier release still holds.
softly.assertThat(AppTheme.parse("DARK")).isEqualTo(AppTheme.DEFAULT);
softly.assertThat(AppTheme.parse("")).isEqualTo(AppTheme.DEFAULT);
softly.assertThat(AppTheme.parse(null)).isEqualTo(AppTheme.DEFAULT);
});
}
/**
* The enum caches its instance; callers may hold on to it, so it has to be the same one.
*/
@Test
void themeInstanceIsReused() {
assertThat(AppTheme.NORD_DARK.theme()).isSameAs(AppTheme.NORD_DARK.theme());
}
}
@@ -0,0 +1,110 @@
package org.icroco.pholio.ui.theme;
import javafx.scene.Scene;
import javafx.scene.layout.StackPane;
import org.assertj.core.api.SoftAssertions;
import org.icroco.pholio.infra.preferences.AppPreferences;
import org.icroco.pholio.infra.preferences.PreferencesFixture;
import org.icroco.pholio.ui.FxTestToolkit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.icroco.pholio.ui.FxTestToolkit.onFxThread;
import static org.icroco.pholio.ui.FxTestToolkit.runOnFxThread;
/**
* What survives a theme switch, and what does not.
*
* <p>The single claim worth pinning down: {@code pholio.css} is theme-agnostic, so it is added once per
* scene and never touched again. The previous design swapped a palette sheet on every switch, and a
* regression back to that would show up as a scene accumulating stylesheets, or losing the shell's.
*/
class ThemeManagerTest {
private AppPreferences preferences;
private ThemeManager manager;
@BeforeEach
void setUp() {
FxTestToolkit.requireToolkit();
preferences = PreferencesFixture.fromBundledSchema();
manager = new ThemeManager(preferences);
}
@Test
void startsOnTheThemeThePreferenceNames() {
preferences.setValue("ui", "theme", "NORD_LIGHT");
assertThat(new ThemeManager(preferences).theme()).isEqualTo(AppTheme.NORD_LIGHT);
}
@Test
void registeringASceneAddsTheShellStylesheetOnce() {
Scene scene = scene();
runOnFxThread(() -> {
manager.register(scene);
manager.register(scene);
});
assertThat(scene.getStylesheets()).hasSize(1);
}
/**
* The whole point of dropping the palette layer: switching touches the theme, nothing else.
*/
@Test
void switchingThemeLeavesTheSceneStylesheetAlone() {
Scene scene = scene();
runOnFxThread(() -> manager.register(scene));
var before = List.copyOf(scene.getStylesheets());
runOnFxThread(() -> manager.setTheme(AppTheme.DRACULA));
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(scene.getStylesheets()).isEqualTo(before);
softly.assertThat(manager.theme()).isEqualTo(AppTheme.DRACULA);
});
}
/**
* The settings selector writes the preference and nothing else; this subscription is the wiring.
*/
@Test
void followsThePreference() {
runOnFxThread(() -> preferences.setValue("ui", "theme", "CUPERTINO_LIGHT"));
assertThat(manager.theme()).isEqualTo(AppTheme.CUPERTINO_LIGHT);
}
@Test
void switchingThemeWritesThePreferenceBack() {
runOnFxThread(() -> manager.setTheme(AppTheme.NORD_DARK));
assertThat(preferences.getValue("ui", "theme", String.class)).isEqualTo("NORD_DARK");
}
/**
* The header's sun/moon button: whatever the theme, one click must change mode.
*/
@Test
void toggleAlwaysLandsOnTheOppositeMode() {
SoftAssertions.assertSoftly(softly -> {
for (AppTheme start : List.of(AppTheme.NORD_DARK, AppTheme.GITHUB_LIGHT_DEFAULT, AppTheme.DRACULA)) {
runOnFxThread(() -> manager.setTheme(start));
boolean wasDark = start.isDark();
runOnFxThread(manager::toggle);
softly.assertThat(manager.theme().isDark())
.as("toggling away from %s", start)
.isNotEqualTo(wasDark);
}
});
}
private static Scene scene() {
return onFxThread(() -> new Scene(new StackPane(), 200, 100));
}
}