diff --git a/github_docs/de-Benutzeroberflaeche.md b/github_docs/de-Benutzeroberflaeche.md index 98d8c60..75571bc 100644 --- a/github_docs/de-Benutzeroberflaeche.md +++ b/github_docs/de-Benutzeroberflaeche.md @@ -51,15 +51,37 @@ Eingabefeld für die aktuelle Antennenrichtung. Wird für die geplante `MYQTF`-V --- +## Nachrichtentabellen + +KST4Contest zeigt Nachrichtentexte bewusst einzeilig an. So bleiben auch bei hohem Chat-Aufkommen viele Einträge gleichzeitig sichtbar. Der Nachteil liegt auf der Hand: Bei einer schmalen **Message**-Spalte passt nicht jede Nachricht vollständig in die Zeile. + +Ist ein Nachrichtentext breiter als die sichtbare Zelle, zeigt KST4Contest den vollständigen Inhalt als Tooltip an. Dazu die Maus kurz über die betreffende **Message**-Zelle halten. Passt der Text vollständig in die Spalte, wird kein zusätzlicher Volltext-Tooltip eingeblendet. + +Webadressen mit `http://`, `https://` oder dem Präfix `www.` werden innerhalb des Nachrichtentextes als Links dargestellt. Ein Klick öffnet die Adresse im Standardbrowser des Betriebssystems. Andere Protokolle werden nicht als Link behandelt. + +![Abgeschnittener Nachrichtentext mit Volltext-Tooltip und Link](message_tooltip_and_link.png) + +Damit muss der Divider nicht allein deshalb verschoben werden, um eine einzelne längere Nachricht zu lesen. Für einen dauerhaft breiteren Nachrichtenbereich kann er selbstverständlich weiterhin angepasst werden. + +--- + ## Filter -Die Filter-Leiste (ab v1.21 als Flowpane für kleine Bildschirme): +Die Filterleiste befindet sich oberhalb der Chatmember-Tabelle. Sie ist in mehrere logisch zusammengehörige Bereiche gegliedert: -- **Show only QTF**: Richtungsfilter aktivieren (Buttons N/NE/E/… oder Grad-Eingabe) -- **Show only QRB [km] <=**: Entfernungsfilter aktivieren (Toggle-Button) -- **Hide Worked [Band]**: Gearbeitete Stationen pro Band ausblenden (je ein Toggle pro Band) -- **Hide NOT-QRV [Band]**: NOT-QRV-markierte Stationen pro Band ausblenden +- **Show only QTF** begrenzt die Liste auf eine gewählte Antennenrichtung. +- **Show only QRB [km] <=** setzt eine maximale Entfernung. +- **Find** sucht nach einem Rufzeichen. +- **Hide worked** und die Band-Schaltflächen blenden bereits gearbeitete beziehungsweise nicht verfügbare Stationen aus. +- **Reachability**, **Only new grids**, **Tropo >=0dB**, **New bands** und **AS next 5m** unterstützen die Auswahl technisch interessanter Kandidaten. +Die Filterleiste besitzt keine feste Breite. QTF sowie die Worked- und Reachability-Filter nutzen zunächst den gesamten Platz ihrer jeweiligen Zeile. Wird der horizontale Divider nach rechts verschoben und die Chatmember-Ansicht dadurch schmaler, wechseln die Controls erst dann in die nächste Zeile, wenn ihre tatsächlich benötigte Breite nicht mehr zur Verfügung steht. + +![Umgebrochene Filterleiste bei schmaler Chatmember-Ansicht](filter_bar_wrapped.png) + +Im Klartext: Die Filter bestimmen weiterhin den Inhalt der Tabelle, aber nicht mehr die Mindestbreite der gesamten rechten Programmseite. In der normalen Ansicht bleibt die Leiste kompakt. Erst bei einer tatsächlich schmalen Ansicht benötigt sie mehr Höhe. Der Divider kann anschließend wieder nach links verschoben werden; die Controls ordnen sich unmittelbar neu an. + +--- --- ## Stationsinfo-Panel (Further Info) diff --git a/github_docs/filter_bar_wrapped.png b/github_docs/filter_bar_wrapped.png new file mode 100644 index 0000000..6e88607 Binary files /dev/null and b/github_docs/filter_bar_wrapped.png differ diff --git a/github_docs/message_tooltip_and_link.png b/github_docs/message_tooltip_and_link.png new file mode 100644 index 0000000..b0dac9c Binary files /dev/null and b/github_docs/message_tooltip_and_link.png differ diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index 44a8198..468a633 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -617,19 +617,43 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - HBox skedRow = new HBox(10, + HBox skedTimeGroup = new HBox( + 4, new Label("Sked in"), cbSkedMinutes, - new Label("min"), + new Label("min") + ); + skedTimeGroup.setAlignment(Pos.CENTER_LEFT); + + HBox skedModeGroup = new HBox( + 4, new Label("Mode"), - cbSkedMode, - btnCreateSked, + cbSkedMode + ); + skedModeGroup.setAlignment(Pos.CENTER_LEFT); + + HBox skedReminderGroup = new HBox( + 4, chkPmReminders, cbReminderOffsets ); - skedRow.setAlignment(Pos.CENTER_LEFT); + skedReminderGroup.setAlignment(Pos.CENTER_LEFT); - selectedCallSignDownerSiteGridPane.add(skedRow, 0, 6, 1, 1); + FlowPane skedRow = new FlowPane( + Orientation.HORIZONTAL, + 10, + 5 + ); + skedRow.setAlignment(Pos.CENTER_LEFT); + skedRow.getChildren().addAll( + skedTimeGroup, + skedModeGroup, + btnCreateSked, + skedReminderGroup + ); + + selectedCallSignDownerSiteGridPane.add(skedRow, 0, 6, 2, 1); + GridPane.setHgrow(skedRow, Priority.ALWAYS); @@ -2597,7 +2621,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private TableView initFurtherInfoAbtCallsignMSGTable() { TableView tbl_furtherInfoAbtCallsignMSGTable = new TableView(); - tbl_furtherInfoAbtCallsignMSGTable.setTooltip(new Tooltip("Messages of selected station are shown here")); +// tbl_furtherInfoAbtCallsignMSGTable.setTooltip(new Tooltip("Messages of selected station are shown here")); TableColumn timeCol = new TableColumn("Time"); timeCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -2727,6 +2751,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }); msgCol.prefWidthProperty().bind(tbl_furtherInfoAbtCallsignMSGTable.widthProperty().divide(2)); + msgCol.setCellFactory(column -> + new MessageTextTableCell<>(getHostServices()::showDocument) + ); + TableColumn workedRXCol = new TableColumn("wkd RX?"); workedRXCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -2795,7 +2823,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL Label selectedCallSignInfoLblQRBInfo, Label lblDetectedRxBands, HBox priorityRow, - HBox skedRow, + FlowPane skedRow, HBox selectedCallSignPathAndMapButtons, Button selectedCallSignTurnAntBtn, Button selectedCallSignShowQRZprofile, @@ -2941,7 +2969,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private TableView initChatGeneralMSGTable() { TableView tbl_generalMSGTable = new TableView(); - tbl_generalMSGTable.setTooltip(new Tooltip("General messages are shown here (handle it like CQ messages)")); +// tbl_generalMSGTable.setTooltip(new Tooltip("General messages are shown here (handle it like CQ messages)")); TableColumn timeCol = new TableColumn("Time"); timeCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -3035,37 +3063,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }); msgCol.prefWidthProperty().bind(tbl_generalMSGTable.widthProperty().divide(2)); - msgCol.setCellFactory(new Callback, TableCell>() { - public TableCell call(TableColumn param) { - return new TableCell() { + msgCol.setCellFactory(column -> + new MessageTextTableCell<>( + getHostServices()::showDocument, + messageText -> { + String ownCallsign = chatcontroller + .getChatPreferences() + .getStn_loginCallSign(); - @Override - public void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - if (!isEmpty()) { - -// System.out.println("style\n\n" + this.getStyleClass()); - this.getStyleClass().clear(); //clear css reference - this.getStyleClass().add("table-cell"); //set old reference - this.getStyleClass().add("defaultText-column"); //add new special colored css reference - -// this.setTextFill(Color.BLACK); //old colouring mechanic - // Get fancy and change color based on data - - - if (item.toUpperCase() - .contains(chatcontroller.getChatPreferences().getStn_loginCallSign().toUpperCase())) { - this.getStyleClass().clear(); - this.getStyleClass().add("table-cell"); //set old reference - this.getStyleClass().add("messageToMe-column"); //add new special colored css reference -// this.setTextFill(Color.GREEN); //old colouring mechanic - } - setText(item); - } - } - }; - } - }); + return messageText != null + && ownCallsign != null + && !ownCallsign.isBlank() + && messageText + .toUpperCase(Locale.ROOT) + .contains(ownCallsign.toUpperCase(Locale.ROOT)); + }, + "defaultText-column", + "messageToMe-column" + ) + ); TableColumn categoryCol = new TableColumn("Category"); categoryCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -3110,7 +3126,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private TableView initChatprivateMSGTable() { TableView tbl_privateMSGTable = new TableView(); - tbl_privateMSGTable.setTooltip(new Tooltip("Private messages to you are shown here")); +// tbl_privateMSGTable.setTooltip(new Tooltip("Private messages to you are shown here")); TableColumn timeCol = new TableColumn("Time"); @@ -3261,6 +3277,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }); msgCol.prefWidthProperty().bind(tbl_privateMSGTable.widthProperty().divide(2.5)); + msgCol.setCellFactory(column -> + new MessageTextTableCell<>(getHostServices()::showDocument) + ); + TableColumn airScoutCol = new TableColumn("AP [minutes / pot%]"); airScoutCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -3476,7 +3496,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private TableView initDXClusterTable() { TableView tbl_DXCTable = new TableView(); - tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here")); +// tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here")); TableColumn timeCol = new TableColumn("Time"); timeCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -3605,6 +3625,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); + msgCol.setCellFactory(column -> + new MessageTextTableCell<>(getHostServices()::showDocument) + ); + TableColumn workedCol = new TableColumn("wkd"); workedCol .setCellValueFactory(new Callback, ObservableValue>() { @@ -3637,7 +3661,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private TableView initChatToOtherMSGTable() { TableView tbl_toOtherMSGTable = new TableView(); - tbl_toOtherMSGTable.setTooltip(new Tooltip("Messages between other member are shown here")); +// tbl_toOtherMSGTable.setTooltip(new Tooltip("Messages between other member are shown here")); TableColumn timeCol = new TableColumn("Time"); timeCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -3767,6 +3791,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }); msgCol.prefWidthProperty().bind(tbl_toOtherMSGTable.widthProperty().divide(2)); + msgCol.setCellFactory(column -> + new MessageTextTableCell<>(getHostServices()::showDocument) + ); + + TableColumn workedRXCol = new TableColumn("wkd RX?"); workedRXCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -5464,6 +5493,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL Button btnOptionspnlConnect; ContextMenu chatMessageContextMenu; // public due need to update it on modify ContextMenu chatMemberContextMenu;// public due need to update it on modify +// FlowPane chatMemberTableFilterQTFAndQRBHbox; HBox chatMemberTableFilterQTFAndQRBHbox; TableView tbl_chatMember = new TableView(); @@ -7039,13 +7069,20 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL BorderPane chatMemberTableBorderPane = new BorderPane(); chatMemberTableBorderPane.setCenter(tbl_chatMember); - chatMemberTableFilterQTFAndQRBHbox = new HBox(); - chatMemberTableFilterQTFAndQRBHbox.setSpacing(10); + chatMemberTableBorderPane.setMinWidth(0); + tbl_chatMember.setMinWidth(0); + + chatMemberTableFilterQTFAndQRBHbox = new HBox(10); + chatMemberTableFilterQTFAndQRBHbox.setAlignment(Pos.CENTER_LEFT); + chatMemberTableFilterQTFAndQRBHbox.setMinWidth(0); // chatMemberTableFilterQTFAndQRBHbox.set VBox chatMemberTableFilterVBoxForAllFilters= new VBox(); chatMemberTableFilterVBoxForAllFilters.setSpacing(1); + + chatMemberTableFilterVBoxForAllFilters.setMinWidth(0); + chatMemberTableFilterVBoxForAllFilters.setStyle("-fx-padding: 1;" + "-fx-border-style: solid inside;" + "-fx-border-width: 1;" + @@ -7053,11 +7090,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL "-fx-border-radius: 1;" + "-fx-border-color: lightgreen;"); -// HBox chatMemberTableFilterQRBHBox = new HBox(); - FlowPane chatMemberTableFilterQRBHBox = new FlowPane(); + + HBox chatMemberTableFilterQRBHBox = new HBox(2); chatMemberTableFilterQRBHBox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterQRBHBox.setHgap(2); - chatMemberTableFilterQRBHBox.setPrefWidth(225); TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + ""); chatMemberTableFilterMaxQrbTF.setFocusTraversable(false); @@ -7223,16 +7258,34 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL "-fx-border-radius: 1;" + "-fx-border-color: lightgrey;"); - chatMemberTableFilterQTFAndQRBHbox.setFillHeight(true); - chatMemberTableFilterQTFAndQRBHbox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterQTFAndQRBHbox.getChildren().add(chatMemberTableFilterQRBHBox); +// chatMemberTableFilterQTFAndQRBHbox.setFillHeight(true); +// chatMemberTableFilterQTFAndQRBHbox.setAlignment(Pos.CENTER_LEFT); +// chatMemberTableFilterQTFAndQRBHbox.getChildren().add(chatMemberTableFilterQRBHBox); + chatMemberTableFilterQTFAndQRBHbox + .getChildren() + .add(chatMemberTableFilterQRBHBox); // HBox chatMemberTableFilterQTFHBox = new HBox(); - FlowPane chatMemberTableFilterQTFHBox = new FlowPane(); +// FlowPane chatMemberTableFilterQTFHBox = new FlowPane(); +// chatMemberTableFilterQTFHBox.setAlignment(Pos.CENTER_LEFT); +// chatMemberTableFilterQTFHBox.setPrefWidth(525); +// chatMemberTableFilterQTFHBox.setHgap(2); + + FlowPane chatMemberTableFilterQTFHBox = new FlowPane( + Orientation.HORIZONTAL, + 2, + 2 + ); chatMemberTableFilterQTFHBox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterQTFHBox.setPrefWidth(525); - chatMemberTableFilterQTFHBox.setHgap(2); + chatMemberTableFilterQTFHBox.setRowValignment(VPos.CENTER); + chatMemberTableFilterQTFHBox.setMinWidth(0); + + HBox.setHgrow( + chatMemberTableFilterQTFHBox, + Priority.ALWAYS + ); + CheckBox chatMemberTableFilterQtfEnableChkbx = new CheckBox("Show only QTF:"); TextField chatMemberTableFilterQtfTF = new TextField(chatcontroller.getChatPreferences().getStn_qtfDefault()+""); @@ -7530,7 +7583,16 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox(); +// HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox(); + + FlowPane chatMemberTableFilterWorkedBandFiltersHbx = new FlowPane( + Orientation.HORIZONTAL, + 2, + 3 + ); + chatMemberTableFilterWorkedBandFiltersHbx.setAlignment(Pos.CENTER_LEFT); + chatMemberTableFilterWorkedBandFiltersHbx.setRowValignment(VPos.CENTER); + chatMemberTableFilterWorkedBandFiltersHbx.setMinWidth(0); Button btnCalculateSelectedTropo = new Button("Calc selected"); @@ -7589,6 +7651,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (chatcontroller.getReachabilityService().getEnabledStationBands().isEmpty()) { Label bandSetupWarning = new Label("Please enable at least one active station band for New Locator/New Band filters."); bandSetupWarning.setStyle("-fx-text-fill: orange; -fx-font-weight: bold;"); + bandSetupWarning.setWrapText(true); + bandSetupWarning.setMinWidth(0); + bandSetupWarning.setMaxWidth(320); chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(bandSetupWarning); } @@ -7848,8 +7913,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglInactive); - chatMemberTableFilterWorkedBandFiltersHbx.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterWorkedBandFiltersHbx.setSpacing(5); +// chatMemberTableFilterWorkedBandFiltersHbx.setAlignment(Pos.CENTER_LEFT); +// chatMemberTableFilterWorkedBandFiltersHbx.setSpacing(5); chatMemberTableFilterWorkedBandFiltersHbx.setStyle("-fx-padding: 1;" + "-fx-border-style: solid inside;" + "-fx-border-width: 1;" + @@ -7871,11 +7936,20 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatMemberTableFilterTextFieldBox.getChildren().addAll(chatMemberTableFilterTextField); -// HBox chatMemberTableFilterTextFieldAndWorkedBandsHbx = new HBox(); - FlowPane chatMemberTableFilterTextFieldAndWorkedBandsHbx = new FlowPane(); - chatMemberTableFilterTextFieldAndWorkedBandsHbx.getChildren().addAll(chatMemberTableFilterTextFieldBox, chatMemberTableFilterWorkedBandFiltersHbx); -// chatMemberTableFilterTextFieldAndWorkedBandsHbx.setSpacing(5); - chatMemberTableFilterTextFieldAndWorkedBandsHbx.setHgap(2); + + HBox chatMemberTableFilterTextFieldAndWorkedBandsHbx = new HBox(2); + chatMemberTableFilterTextFieldAndWorkedBandsHbx.setAlignment(Pos.CENTER_LEFT); + chatMemberTableFilterTextFieldAndWorkedBandsHbx.setMinWidth(0); + + HBox.setHgrow( + chatMemberTableFilterWorkedBandFiltersHbx, + Priority.ALWAYS + ); + + chatMemberTableFilterTextFieldAndWorkedBandsHbx.getChildren().addAll( + chatMemberTableFilterTextFieldBox, + chatMemberTableFilterWorkedBandFiltersHbx + ); chatMemberTableFilterVBoxForAllFilters.getChildren().add(chatMemberTableFilterTextFieldAndWorkedBandsHbx); diff --git a/src/main/java/kst4contest/view/MessageTextTableCell.java b/src/main/java/kst4contest/view/MessageTextTableCell.java new file mode 100644 index 0000000..cc95119 --- /dev/null +++ b/src/main/java/kst4contest/view/MessageTextTableCell.java @@ -0,0 +1,327 @@ +package kst4contest.view; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.control.TableCell; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.shape.Rectangle; +import javafx.util.Duration; + +import java.net.URI; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Displays a single-line message text in a TableView. + * + *

The cell provides two functions that JavaFX's default text cell cannot + * combine: + *

    + *
  • a tooltip containing the complete text, but only while the visible + * cell is too narrow for that text;
  • + *
  • clickable HTTP, HTTPS and www links inside otherwise normal text.
  • + *
+ * + *

The row type is generic because the same implementation is used by the + * ChatMessage tables and the DX-cluster table.

+ * + * @param row type of the surrounding TableView + */ +public final class MessageTextTableCell extends TableCell { + + private static final Pattern WEB_LINK_PATTERN = Pattern.compile( + "(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+" + ); + + private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}"; + + private final Consumer linkOpener; + private final Predicate highlightedTextPredicate; + private final String normalStyleClass; + private final String highlightedStyleClass; + + private final HBox contentBox = new HBox(0); + private final Rectangle contentClip = new Rectangle(); + private final Tooltip fullTextTooltip = new Tooltip(); + + private String displayedText = ""; + private boolean fullTextTooltipInstalled = false; + + /** + * Creates a normal message cell without additional text highlighting. + * + * @param linkOpener callback which opens a validated HTTP or HTTPS URL + */ + public MessageTextTableCell(Consumer linkOpener) { + this(linkOpener, text -> false, null, null); + } + + /** + * Creates a message cell with optional CSS highlighting. + * + *

KST4Contest uses this variant for public messages which contain the + * operator's own callsign. Only the supplied CSS classes are added or + * removed. The standard {@code table-cell} class and all other JavaFX + * state remain untouched.

+ * + * @param linkOpener callback which opens a validated URL + * @param highlightedTextPredicate identifies highlighted messages + * @param normalStyleClass CSS class for normal messages, may be null + * @param highlightedStyleClass CSS class for highlighted messages, may be null + */ + public MessageTextTableCell( + Consumer linkOpener, + Predicate highlightedTextPredicate, + String normalStyleClass, + String highlightedStyleClass + ) { + this.linkOpener = Objects.requireNonNull(linkOpener, "linkOpener"); + this.highlightedTextPredicate = highlightedTextPredicate == null + ? text -> false + : highlightedTextPredicate; + this.normalStyleClass = normalStyleClass; + this.highlightedStyleClass = highlightedStyleClass; + + contentBox.setAlignment(Pos.CENTER_LEFT); + contentBox.setFillHeight(false); + contentBox.setClip(contentClip); + + fullTextTooltip.setWrapText(true); + fullTextTooltip.setMaxWidth(800); + fullTextTooltip.setShowDelay(Duration.millis(250)); + fullTextTooltip.setShowDuration(Duration.seconds(30)); + + setContentDisplay(javafx.scene.control.ContentDisplay.GRAPHIC_ONLY); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + + removeOptionalStyleClasses(); + contentBox.getChildren().clear(); + + if (empty || item == null || item.isEmpty()) { + displayedText = ""; + fullTextTooltip.setText(""); + setText(null); + setGraphic(null); + setClippedTooltipActive(false); + return; + } + + displayedText = item; + fullTextTooltip.setText(item); + applyOptionalStyleClass(item); + buildContent(item); + + setText(null); + setGraphic(contentBox); + updateClipAndTooltip(); + } + + @Override + protected void layoutChildren() { + super.layoutChildren(); + updateClipAndTooltip(); + } + + /** + * Splits the displayed text into ordinary labels and clickable links. + */ + private void buildContent(String text) { + Matcher matcher = WEB_LINK_PATTERN.matcher(text); + int nextPlainTextStart = 0; + + while (matcher.find()) { + String rawMatch = matcher.group(); + String linkText = removeTrailingPunctuation(rawMatch); + + if (linkText.isEmpty()) { + continue; + } + + appendPlainText(text.substring(nextPlainTextStart, matcher.start())); + appendLink(linkText); + + /* + * Punctuation removed from the URL remains ordinary message text. + * Starting here ensures it is included by the next substring. + */ + nextPlainTextStart = matcher.start() + linkText.length(); + } + + appendPlainText(text.substring(nextPlainTextStart)); + } + + private void appendPlainText(String text) { + if (text == null || text.isEmpty()) { + return; + } + + Label textFragment = new Label(text); + textFragment.setPadding(Insets.EMPTY); + textFragment.setMinWidth(Region.USE_PREF_SIZE); + textFragment.setMaxWidth(Region.USE_PREF_SIZE); + textFragment.setMouseTransparent(true); + textFragment.textFillProperty().bind(textFillProperty()); + textFragment.getStyleClass().add("message-text-fragment"); + + contentBox.getChildren().add(textFragment); + } + + private void appendLink(String linkText) { + Hyperlink hyperlink = new Hyperlink(linkText); + hyperlink.setPadding(Insets.EMPTY); + hyperlink.setMinWidth(Region.USE_PREF_SIZE); + hyperlink.setMaxWidth(Region.USE_PREF_SIZE); + hyperlink.setFocusTraversable(false); + hyperlink.getStyleClass().add("message-table-link"); + hyperlink.setOnAction(event -> { + openLink(linkText); + event.consume(); + }); + + contentBox.getChildren().add(hyperlink); + } + + /** + * Opens only HTTP and HTTPS targets. A visible www address receives an + * HTTPS scheme before it is passed to the application. + */ + private void openLink(String linkText) { + try { + String normalizedLink = linkText.toLowerCase(Locale.ROOT).startsWith("www.") + ? "https://" + linkText + : linkText; + + URI uri = URI.create(normalizedLink); + String scheme = uri.getScheme(); + + if (scheme == null + || (!scheme.equalsIgnoreCase("http") + && !scheme.equalsIgnoreCase("https"))) { + return; + } + + linkOpener.accept(uri.toASCIIString()); + } catch (RuntimeException exception) { + System.out.println( + "[MessageTextTableCell] Cannot open malformed link: " + + linkText + + " / " + + exception.getMessage() + ); + } + } + + /** + * Removes punctuation which commonly follows a link in normal prose. + */ + private String removeTrailingPunctuation(String rawLink) { + String result = rawLink; + + while (!result.isEmpty() + && TRAILING_LINK_PUNCTUATION.indexOf( + result.charAt(result.length() - 1) + ) >= 0) { + result = result.substring(0, result.length() - 1); + } + + return result; + } + + /** + * Clips the one-line content and installs the full-text tooltip only if + * the rendered nodes are wider than the usable cell area. + */ + private void updateClipAndTooltip() { + if (displayedText.isEmpty() || getGraphic() == null) { + setClippedTooltipActive(false); + return; + } + + double availableWidth = Math.max( + 0, + getWidth() - snappedLeftInset() - snappedRightInset() + ); + double availableHeight = Math.max( + 0, + getHeight() - snappedTopInset() - snappedBottomInset() + ); + + contentClip.setWidth(availableWidth); + contentClip.setHeight(availableHeight); + + boolean textIsClipped = contentBox.prefWidth(-1) > availableWidth + 1; + setClippedTooltipActive(textIsClipped); + } + + /** + * Installs the tooltip on the actual graphic node below the mouse pointer. + * + *

Installing it on the TableCell itself is not reliable when the cell + * displays an HBox containing labels and hyperlinks. The graphic node is + * the effective mouse target. Installation is tracked explicitly so + * repeated layout passes neither add duplicate handlers nor leave a + * tooltip attached to a reused empty cell.

+ */ + private void setClippedTooltipActive(boolean active) { + /* + * The tooltip is handled exclusively by the graphic node. Keeping a + * second tooltip on the TableCell would allow two competing tooltip + * targets for the same visible content. + */ + setTooltip(null); + + if (active == fullTextTooltipInstalled) { + return; + } + + if (active) { + Tooltip.install(contentBox, fullTextTooltip); + } else { + fullTextTooltip.hide(); + Tooltip.uninstall(contentBox, fullTextTooltip); + } + + fullTextTooltipInstalled = active; + } + + private void applyOptionalStyleClass(String item) { + boolean highlighted; + + try { + highlighted = highlightedTextPredicate.test(item); + } catch (RuntimeException exception) { + highlighted = false; + } + + String styleClass = highlighted + ? highlightedStyleClass + : normalStyleClass; + + if (styleClass != null + && !styleClass.isBlank() + && !getStyleClass().contains(styleClass)) { + getStyleClass().add(styleClass); + } + } + + private void removeOptionalStyleClasses() { + if (normalStyleClass != null) { + getStyleClass().remove(normalStyleClass); + } + if (highlightedStyleClass != null) { + getStyleClass().remove(highlightedStyleClass); + } + } +} \ No newline at end of file diff --git a/src/main/resources/KST4ContestDefaultDay.css b/src/main/resources/KST4ContestDefaultDay.css index e4cb534..cbe2ba0 100644 --- a/src/main/resources/KST4ContestDefaultDay.css +++ b/src/main/resources/KST4ContestDefaultDay.css @@ -198,4 +198,20 @@ .table-cell-50PercentAP { -fx-text-fill: #fa9f66; -fx-font-weight: bold; +} + + +/* + * Clickable web links inside message-table cells. + * Text fragments keep the current table-cell color; links remain visible. + */ +.message-table-link { + -fx-padding: 0; + -fx-border-color: transparent; + -fx-underline: true; + -fx-text-fill: #005eb8; +} + +.message-table-link:visited { + -fx-text-fill: #6b3fa0; } \ No newline at end of file diff --git a/src/main/resources/KST4ContestDefaultEvening.css b/src/main/resources/KST4ContestDefaultEvening.css index db34147..14419f6 100644 --- a/src/main/resources/KST4ContestDefaultEvening.css +++ b/src/main/resources/KST4ContestDefaultEvening.css @@ -240,4 +240,19 @@ .table-cell-50PercentAP { -fx-text-fill: #fa9f66; -fx-font-weight: bold; +} + +/* + * Clickable web links inside message-table cells. + * Brighter colors preserve contrast on the dark table background. + */ +.message-table-link { + -fx-padding: 0; + -fx-border-color: transparent; + -fx-underline: true; + -fx-text-fill: #73b7ff; +} + +.message-table-link:visited { + -fx-text-fill: #c59cff; } \ No newline at end of file