diff --git a/docs/gui-bedienanleitung.md b/docs/gui-bedienanleitung.md index c7cc0b5..40b71c9 100644 --- a/docs/gui-bedienanleitung.md +++ b/docs/gui-bedienanleitung.md @@ -78,10 +78,9 @@ verworfen werden. ### 3.2 Zentraler Meldungsbereich -Am unteren Ende der GUI befindet sich ein großer, nicht editierbarer -Meldungsbereich. Er ist dauerhaft sichtbar und zeigt Ergebnisse von -Validierungen, technischen Tests, Migrationsmeldungen und sonstige -Statusinformationen. +Am unteren Ende der GUI befindet sich ein großer Meldungsbereich. Er ist +dauerhaft sichtbar und zeigt Ergebnisse von Validierungen, technischen Tests, +Migrationsmeldungen und sonstige Statusinformationen. Der Meldungsbereich verwendet vier feste Stufen: @@ -96,6 +95,23 @@ Nur das Präfix am Zeilenanfang wird farbig dargestellt. Der eigentliche Meldungstext derselben Zeile ist immer schwarz. Die vier Stufen dienen ausschließlich der visuellen Einordnung; sie verhindern das Speichern nicht. +#### Meldungen kopieren + +Einzelne oder mehrere Meldungen können markiert und in die Zwischenablage +kopiert werden: + +- **Einzelne Zeile markieren:** Meldung anklicken +- **Mehrere Zeilen markieren:** Shift+Klick (Bereich) oder Strg+Klick (Einzelauswahl) +- **Alle Zeilen markieren:** Strg+A +- **Markierte Zeilen kopieren:** Strg+C + +Per Rechtsklick steht zusätzlich ein Kontextmenü zur Verfügung: + +| Eintrag | Wirkung | +|---------|---------| +| **Meldung kopieren** | Kopiert alle markierten Zeilen in die Zwischenablage (nur aktiv, wenn eine Auswahl besteht) | +| **Alle Meldungen kopieren** | Kopiert alle aktuell angezeigten Meldungen in die Zwischenablage | + --- ## 4. Aktionen diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 16fea93..c8a742f 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -47,17 +47,26 @@ import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; import javafx.scene.control.CheckBox; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.scene.control.ComboBox; import javafx.scene.control.ContextMenu; import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; import javafx.scene.control.MenuItem; import javafx.scene.control.ScrollPane; +import javafx.scene.control.SelectionMode; import javafx.scene.control.Separator; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TextField; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; + import javafx.scene.layout.BorderPane; import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; @@ -271,12 +280,27 @@ public final class GuiConfigurationEditorWorkspace { final List pendingMessages = new ArrayList<>(); /** - * The scrollable container node that renders the central message area. - * Rebuilt by {@link #refreshMessagesArea()} after each validation run or model-catalogue - * result. Package-private so smoke tests can assert on its child count. + * Off-screen holder that mirrors every rendered message row as a {@link TextFlow} child. + * Never added to the scene graph; kept solely so smoke tests can assert on child count and + * node structure without the display component being involved. */ final VBox messagesAreaBox = new VBox(4); + /** + * Backing data for {@link #messagesListView}. Replaced on every call to + * {@link #refreshMessagesArea()} so the list view updates atomically. + */ + private final ObservableList messagesListItems = + FXCollections.observableArrayList(); + + /** + * The visible list view that renders each message as a coloured {@link TextFlow} row. + * Supports multi-row selection ({@link SelectionMode#MULTIPLE}) and copies the selected + * rows as plain text to the system clipboard when Ctrl+C is pressed. + */ + private final ListView messagesListView = + new ListView<>(messagesListItems); + /** * Maps property-key strings to their associated field-level error {@link Label} widgets. * The map is populated when the "Pfade" section and each provider block are constructed. @@ -1477,14 +1501,11 @@ public final class GuiConfigurationEditorWorkspace { /** * Builds the "Meldungen" section containing the central message area. *

- * The central message area is a scrollable, non-editable container that displays all - * pending messages from the editor validation and model-catalogue coordinator. Each - * message is rendered as one {@link TextFlow} row in which only the severity prefix is - * coloured; the remainder of the text stays black. - *

- * The internal {@link #messagesAreaBox} is populated by {@link #refreshMessagesArea()} and - * is kept stable across section rebuilds via the persistent field reference so that later - * calls to {@code refreshMessagesArea()} always update the correct node. + * The visible component is a {@link ListView} whose cells render each message entry as a + * coloured {@link TextFlow} row (severity prefix in the defined CSS colour, body text in + * black). Multiple rows can be selected with mouse or keyboard; Ctrl+C copies all selected + * rows as plain text to the system clipboard. The {@link #messagesAreaBox} VBox is populated + * in parallel but not added to the scene graph; it exists solely as a test-inspection handle. * * @return the card node for the "Meldungen" section */ @@ -1492,19 +1513,52 @@ public final class GuiConfigurationEditorWorkspace { VBox card = createCardContainer(); card.getChildren().add(sectionTitle("Meldungen")); - messagesAreaBox.setFillWidth(true); - messagesAreaBox.setStyle("-fx-padding: 4px 0 0 0;"); + messagesListView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + messagesListView.setPrefHeight(140); + messagesListView.setMaxHeight(200); + messagesListView.setStyle("-fx-border-color: #d8d8d8;"); + Label placeholder = new Label("Keine Meldungen vorhanden."); + placeholder.setStyle("-fx-text-fill: #888888;"); + messagesListView.setPlaceholder(placeholder); - ScrollPane scrollPane = new ScrollPane(messagesAreaBox); - scrollPane.setFitToWidth(true); - scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - scrollPane.setPrefViewportHeight(140); - scrollPane.setMaxHeight(200); - scrollPane.setStyle("-fx-background-color: transparent; -fx-border-color: #d8d8d8;" - + " -fx-border-radius: 4px;"); + messagesListView.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(GuiMessageEntry entry, boolean empty) { + super.updateItem(entry, empty); + if (empty || entry == null) { + setGraphic(null); + setText(null); + } else { + String ts = formatTimestamp(entry.timestamp()); + String sl = entry.severity().getPrefixLabel(); + Text prefix = new Text(ts + " " + sl + " "); + prefix.setStyle("-fx-fill: " + entry.severity().getPrefixCssColour() + + "; -fx-font-weight: bold;"); + Text body = new Text(entry.text()); + body.setStyle("-fx-fill: black;"); + TextFlow flow = new TextFlow(prefix, body); + setGraphic(flow); + setText(null); + } + } + }); - card.getChildren().add(scrollPane); + messagesListView.setOnKeyPressed(e -> { + if (new KeyCodeCombination(KeyCode.C, KeyCombination.CONTROL_DOWN).match(e)) { + copySelectedMessagesToClipboard(); + } + }); + + MenuItem copySelected = new MenuItem("Meldung kopieren"); + copySelected.setOnAction(e -> copySelectedMessagesToClipboard()); + MenuItem copyAll = new MenuItem("Alle Meldungen kopieren"); + copyAll.setOnAction(e -> copyAllMessagesToClipboard()); + ContextMenu contextMenu = new ContextMenu(copySelected, copyAll); + contextMenu.setOnShowing(e -> copySelected.setDisable( + messagesListView.getSelectionModel().getSelectedItems().isEmpty())); + messagesListView.setContextMenu(contextMenu); + + card.getChildren().add(messagesListView); // Populate immediately so the area is not blank before the first validation run. refreshMessagesArea(); @@ -1745,23 +1799,19 @@ public final class GuiConfigurationEditorWorkspace { // ========================================================================= /** - * Rebuilds the content of {@link #messagesAreaBox} from the current {@link #pendingMessages} - * list. + * Rebuilds both {@link #messagesAreaBox} (off-screen, for test assertions) and + * {@link #messagesTextArea} (the visible, user-selectable display) from the current + * {@link #pendingMessages} list. *

- * Each message is rendered as one {@link TextFlow} row. The severity prefix is coloured using - * the CSS colour from {@link GuiMessageSeverity#getPrefixCssColour()} and carries the message - * timestamp in {@code [HH:mm:ss]} form; the remainder of the message text is always black. - * The placeholder text is shown when the list is empty. - *

- * Each message row exposes a context menu with a "Kopieren" action that copies the full line - * (timestamp, severity label, body text) to the system clipboard. The container also offers - * an "Alles kopieren" menu entry that concatenates all current messages in display order. + * {@code messagesAreaBox} is populated with one {@link TextFlow} per message entry so that + * existing smoke tests can inspect the node structure. {@code messagesTextArea} receives the + * same content as plain text so that the user can select, copy and paste message lines. *

* Must be called on the JavaFX Application Thread. */ void refreshMessagesArea() { messagesAreaBox.getChildren().clear(); - attachAllCopyContextMenu(); + messagesListItems.setAll(pendingMessages); if (pendingMessages.isEmpty()) { Text placeholder = new Text("Keine Meldungen vorhanden."); placeholder.setStyle("-fx-fill: #888888;"); @@ -1778,83 +1828,67 @@ public final class GuiConfigurationEditorWorkspace { body.setStyle("-fx-fill: black;"); TextFlow row = new TextFlow(prefix, body); row.setStyle("-fx-padding: 1px 4px;"); - String fullLine = timestampLabel + " " + severityLabel + " " + entry.text(); - attachRowCopyContextMenu(row, fullLine); messagesAreaBox.getChildren().add(row); } } - /** - * Installs a context menu on the messages container that copies all rendered lines to the - * system clipboard. Each rendered row receives its own context menu; this menu handles - * clicks on empty areas of the container. - */ - private void attachAllCopyContextMenu() { - ContextMenu menu = new ContextMenu(); - MenuItem copyAll = new MenuItem("Alles kopieren"); - copyAll.setOnAction(e -> copyAllMessagesToClipboard()); - menu.getItems().add(copyAll); - messagesAreaBox.setOnContextMenuRequested(e -> { - if (!pendingMessages.isEmpty()) { - menu.show(messagesAreaBox, e.getScreenX(), e.getScreenY()); - } - }); - } - - /** - * Installs a per-row context menu that copies the row's full textual content and also - * offers the container-level "Alles kopieren" action. - * - * @param row the message row node; must not be {@code null} - * @param fullLine the full text of this row to copy when "Kopieren" is selected - */ - private void attachRowCopyContextMenu(TextFlow row, String fullLine) { - ContextMenu menu = new ContextMenu(); - MenuItem copyRow = new MenuItem("Kopieren"); - copyRow.setOnAction(e -> writeToClipboard(fullLine)); - MenuItem copyAll = new MenuItem("Alles kopieren"); - copyAll.setOnAction(e -> copyAllMessagesToClipboard()); - menu.getItems().addAll(copyRow, copyAll); - row.setOnContextMenuRequested(e -> - menu.show(row, e.getScreenX(), e.getScreenY())); - } - - /** - * Copies all current message lines to the system clipboard in display order. - */ - private void copyAllMessagesToClipboard() { - if (pendingMessages.isEmpty()) { - return; - } - StringBuilder sb = new StringBuilder(); - for (GuiMessageEntry entry : pendingMessages) { - sb.append(formatTimestamp(entry.timestamp())) - .append(" ") - .append(entry.severity().getPrefixLabel()) - .append(" ") - .append(entry.text()) - .append(System.lineSeparator()); - } - writeToClipboard(sb.toString().trim()); - } - - /** - * Writes plain text to the system clipboard. - * - * @param text the text to copy; {@code null} is treated as empty - */ - private void writeToClipboard(String text) { - ClipboardContent content = new ClipboardContent(); - content.putString(text == null ? "" : text); - Clipboard.getSystemClipboard().setContent(content); - } - /** * Formats a message timestamp as {@code [HH:mm:ss]} in the system default zone. * * @param instant the source instant; must not be {@code null} * @return the formatted prefix; never {@code null} */ + /** + * Copies all current message entries to the system clipboard in display order. + * Invoked by the "Alle Meldungen kopieren" context menu action. + */ + private void copyAllMessagesToClipboard() { + if (pendingMessages.isEmpty()) { + return; + } + StringBuilder sb = new StringBuilder(); + for (GuiMessageEntry entry : pendingMessages) { + if (!sb.isEmpty()) { + sb.append(System.lineSeparator()); + } + sb.append(formatTimestamp(entry.timestamp())) + .append(' ') + .append(entry.severity().getPrefixLabel()) + .append(' ') + .append(entry.text()); + } + ClipboardContent content = new ClipboardContent(); + content.putString(sb.toString()); + Clipboard.getSystemClipboard().setContent(content); + } + + /** + * Copies the full text of all currently selected rows in {@link #messagesListView} to the + * system clipboard. Each row is written as one line in display order (timestamp, severity + * label, body text). Invoked by the Ctrl+C key handler and the context menu. + */ + private void copySelectedMessagesToClipboard() { + List selected = + messagesListView.getSelectionModel().getSelectedItems(); + if (selected.isEmpty()) { + return; + } + StringBuilder sb = new StringBuilder(); + for (GuiMessageEntry entry : selected) { + if (!sb.isEmpty()) { + sb.append(System.lineSeparator()); + } + sb.append(formatTimestamp(entry.timestamp())) + .append(' ') + .append(entry.severity().getPrefixLabel()) + .append(' ') + .append(entry.text()); + } + ClipboardContent content = new ClipboardContent(); + content.putString(sb.toString()); + Clipboard.getSystemClipboard().setContent(content); + } + private static String formatTimestamp(java.time.Instant instant) { return "[" + TIMESTAMP_FORMATTER.format(instant) + "]"; }