Feature #21/#20: Anwendungs-Icon und System-Tray einbinden

Schließt Issue #21: Alle vier Icon-Größen (16/32/64/128 px) werden beim
Start am primären Stage gesetzt; JavaFX wählt automatisch die passende
Größe je nach Kontext (Titelleiste, Taskleiste, Alt+Tab).

Schließt Issue #20: Beim Klick auf den X-Button wird das Fenster in den
Windows System-Tray minimiert (stage.hide()) statt die Anwendung zu
beenden. Platform.setImplicitExit(false) hält die JavaFX-Runtime aktiv.
Das Tray-Icon zeigt ein Kontextmenü mit "Öffnen" und "Beenden";
Doppelklick öffnet das Fenster ebenfalls. Beim Beenden über das Tray-Menü
wird das Icon sauber entfernt.

Die gesamte AWT-Tray-Logik ist in SystemTrayManager gekapselt. Der
Headless-Betrieb bleibt unberührt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 08:24:54 +02:00
parent 234b3461b7
commit 0e20f93c0d
2 changed files with 195 additions and 3 deletions
@@ -4,8 +4,12 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
/**
* JavaFX application entry point for the PDF-Umbenenner GUI inbound adapter.
@@ -18,6 +22,9 @@ import javafx.stage.Stage;
* {@code titleUpdateListener} hook. The close-request handler is installed through
* {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that
* unsaved changes are protected when the user tries to close the window.
*
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
* Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden.
*/
public class PdfUmbenennerGuiApplication extends Application {
@@ -25,6 +32,8 @@ public class PdfUmbenennerGuiApplication extends Application {
private static final double DEFAULT_WIDTH = 1100;
private static final double DEFAULT_HEIGHT = 800;
private SystemTrayManager trayManager;
/**
* Creates a new instance of the JavaFX application.
*/
@@ -35,9 +44,10 @@ public class PdfUmbenennerGuiApplication extends Application {
/**
* Initializes and shows the primary stage.
* <p>
* Lädt die Anwendungs-Icons in allen verfügbaren Größen und setzt sie am Fenster.
* Wires the workspace title-update listener to the stage title so any dirty-state change
* causes an immediate window-title refresh. Also installs the close-request handler that
* guards unsaved changes before the window is closed.
* causes an immediate window-title refresh. Installs the close-request handler that
* guards unsaved changes and minimizes the window to the system tray instead of closing.
*
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/
@@ -45,6 +55,14 @@ public class PdfUmbenennerGuiApplication extends Application {
public void start(Stage primaryStage) {
LOG.info("GUI: JavaFX-Oberfläche wird initialisiert.");
// Anwendungs-Icons laden; JavaFX wählt je nach Kontext automatisch die passende Größe
primaryStage.getIcons().addAll(
new Image(getClass().getResourceAsStream("/icons/Icon16.png")),
new Image(getClass().getResourceAsStream("/icons/Icon32.png")),
new Image(getClass().getResourceAsStream("/icons/Icon64.png")),
new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
);
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
@@ -58,6 +76,13 @@ public class PdfUmbenennerGuiApplication extends Application {
// Install the close-request handler that protects unsaved changes.
workspace.installCloseRequestHandler(primaryStage);
// System-Tray aktivieren: JavaFX-Runtime nicht beenden wenn Fenster versteckt wird
Platform.setImplicitExit(false);
trayManager = new SystemTrayManager(primaryStage);
if (trayManager.install()) {
installTrayCloseHandler(primaryStage, workspace);
}
primaryStage.setMaximized(true);
primaryStage.show();
@@ -70,10 +95,40 @@ public class PdfUmbenennerGuiApplication extends Application {
/**
* Called by the JavaFX runtime when the application is stopping.
* <p>
* Logs the GUI shutdown event. No additional cleanup is required.
* Entfernt das System-Tray-Icon und loggt das Beenden.
*/
@Override
public void stop() {
LOG.info("GUI: JavaFX-Anwendung wird beendet.");
if (trayManager != null) {
trayManager.remove();
}
}
/**
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
* System-Tray minimiert statt es zu schließen.
* <p>
* Der vom Workspace installierte Handler wird dabei vorrangig aufgerufen. Nur wenn
* er das Event nicht konsumiert (sauberer Zustand, keine laufenden Operationen),
* greift dieser Handler und versteckt das Fenster.
*
* @param stage das primäre Fenster
* @param workspace der Workspace-Handler, der bereits installiert wurde
*/
private void installTrayCloseHandler(Stage stage, GuiConfigurationEditorWorkspace workspace) {
EventHandler<WindowEvent> workspaceHandler = stage.getOnCloseRequest();
stage.setOnCloseRequest(event -> {
// Workspace-Handler zuerst: prüft Dirty-State, laufende Operationen usw.
if (workspaceHandler != null) {
workspaceHandler.handle(event);
}
// Wurde das Event nicht konsumiert, ist der Zustand sauber: Fenster in Tray verstecken
if (!event.isConsumed()) {
event.consume();
LOG.info("GUI: Fenster wird in den System-Tray minimiert.");
stage.hide();
}
});
}
}
@@ -0,0 +1,137 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.awt.AWTException;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.ImageIO;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import javafx.application.Platform;
import javafx.stage.Stage;
/**
* Verwaltet das Windows System-Tray-Icon für den PDF-Umbenenner.
* <p>
* Wird das Hauptfenster geschlossen, bleibt die Anwendung im Hintergrund aktiv und zeigt
* ein Tray-Icon in der Windows-Taskleiste. Über das Kontextmenü kann das Fenster wieder
* geöffnet oder die Anwendung vollständig beendet werden.
* <p>
* Alle Stage-Operationen werden auf dem JavaFX Application Thread ausgeführt, da AWT-Events
* auf dem AWT Event Dispatch Thread eintreffen.
*/
class SystemTrayManager {
private static final Logger LOG = LogManager.getLogger(SystemTrayManager.class);
private final Stage stage;
private TrayIcon trayIcon;
private boolean installed;
/**
* Erstellt einen neuen {@code SystemTrayManager} für die angegebene Stage.
*
* @param stage das primäre Fenster; darf nicht {@code null} sein
*/
SystemTrayManager(Stage stage) {
this.stage = stage;
}
/**
* Installiert das System-Tray-Icon.
* <p>
* Schlägt die Installation fehl (System-Tray nicht unterstützt oder Icon-Bild nicht ladbar),
* wird {@code false} zurückgegeben und kein Tray-Icon angezeigt.
*
* @return {@code true} wenn das Icon erfolgreich installiert wurde, sonst {@code false}
*/
boolean install() {
if (!SystemTray.isSupported()) {
LOG.warn("GUI: System-Tray wird auf diesem System nicht unterstützt.");
return false;
}
BufferedImage image = loadTrayImage();
if (image == null) {
return false;
}
PopupMenu menu = buildContextMenu();
trayIcon = new TrayIcon(image, "PDF-Umbenenner", menu);
trayIcon.setImageAutoSize(true);
// Doppelklick öffnet das Fenster
trayIcon.addActionListener(e -> Platform.runLater(this::showWindow));
try {
SystemTray.getSystemTray().add(trayIcon);
installed = true;
LOG.info("GUI: System-Tray-Icon erfolgreich installiert.");
return true;
} catch (AWTException e) {
LOG.warn("GUI: System-Tray-Icon konnte nicht installiert werden: {}", e.getMessage(), e);
return false;
}
}
/**
* Entfernt das Tray-Icon aus dem System-Tray.
* Ist kein Icon installiert, wird der Aufruf ignoriert.
*/
void remove() {
if (installed && trayIcon != null) {
SystemTray.getSystemTray().remove(trayIcon);
installed = false;
LOG.info("GUI: System-Tray-Icon entfernt.");
}
}
/**
* Gibt an, ob das Tray-Icon aktiv installiert ist.
*
* @return {@code true} wenn das Icon im System-Tray sichtbar ist
*/
boolean isInstalled() {
return installed;
}
private BufferedImage loadTrayImage() {
try (InputStream stream = getClass().getResourceAsStream("/icons/Icon16.png")) {
if (stream == null) {
LOG.warn("GUI: Tray-Icon-Ressource '/icons/Icon16.png' nicht gefunden.");
return null;
}
return ImageIO.read(stream);
} catch (IOException e) {
LOG.warn("GUI: Tray-Icon-Bild konnte nicht geladen werden: {}", e.getMessage(), e);
return null;
}
}
private PopupMenu buildContextMenu() {
PopupMenu menu = new PopupMenu();
MenuItem openItem = new MenuItem("Öffnen");
openItem.addActionListener(e -> Platform.runLater(this::showWindow));
MenuItem exitItem = new MenuItem("Beenden");
exitItem.addActionListener(e -> {
remove();
Platform.exit();
System.exit(0);
});
menu.add(openItem);
menu.addSeparator();
menu.add(exitItem);
return menu;
}
private void showWindow() {
stage.show();
stage.toFront();
}
}