Meldungen können nun in die Zwischenablage kopiert werden.

This commit is contained in:
2026-04-22 11:23:44 +02:00
parent 9ba32f1bb8
commit e07b460cdd
2 changed files with 154 additions and 104 deletions
+20 -4
View File
@@ -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
@@ -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<GuiMessageEntry> 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<GuiMessageEntry> 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<GuiMessageEntry> 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<GuiMessageEntry> 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) + "]";
}