Changed ui, used flowpanes to make center slider compatible to smaller screens. URLs are now clickable. Tooltips are shown in case of too small table drawings. Added documentation in the gethub manual.

This commit is contained in:
Marc Froehlich
2026-07-26 18:10:39 +02:00
parent 333063f350
commit 182de0526b
7 changed files with 521 additions and 67 deletions
+27 -5
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

@@ -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<ChatMessage> initFurtherInfoAbtCallsignMSGTable() {
TableView<ChatMessage> tbl_furtherInfoAbtCallsignMSGTable = new TableView<ChatMessage>();
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<ChatMessage, String> timeCol = new TableColumn<ChatMessage, String>("Time");
timeCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
@@ -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<ChatMessage, String> workedRXCol = new TableColumn<ChatMessage, String>("wkd RX?");
workedRXCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
@@ -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<ChatMessage> tbl_generalMSGTable = new TableView<ChatMessage>();
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<ChatMessage, String> timeCol = new TableColumn<ChatMessage, String>("Time");
timeCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
@@ -3035,37 +3063,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
});
msgCol.prefWidthProperty().bind(tbl_generalMSGTable.widthProperty().divide(2));
msgCol.setCellFactory(new Callback<TableColumn<ChatMessage, String>, TableCell<ChatMessage, String>>() {
public TableCell call(TableColumn param) {
return new TableCell<ChatMessage, String>() {
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<ChatMessage, String> categoryCol = new TableColumn<ChatMessage, String>("Category");
categoryCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
@@ -3110,7 +3126,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private TableView<ChatMessage> initChatprivateMSGTable() {
TableView<ChatMessage> tbl_privateMSGTable = new TableView<ChatMessage>();
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<ChatMessage, String> timeCol = new TableColumn<ChatMessage, String>("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<ChatMessage, String> airScoutCol = new TableColumn<ChatMessage, String>("AP [minutes / pot%]");
airScoutCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
@@ -3476,7 +3496,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private TableView<ClusterMessage> initDXClusterTable() {
TableView<ClusterMessage> tbl_DXCTable = new TableView<ClusterMessage>();
tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here"));
// tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here"));
TableColumn<ClusterMessage, String> timeCol = new TableColumn<ClusterMessage, String>("Time");
timeCol.setCellValueFactory(new Callback<CellDataFeatures<ClusterMessage, String>, ObservableValue<String>>() {
@@ -3605,6 +3625,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
});
msgCol.setCellFactory(column ->
new MessageTextTableCell<>(getHostServices()::showDocument)
);
TableColumn<ClusterMessage, String> workedCol = new TableColumn<ClusterMessage, String>("wkd");
workedCol
.setCellValueFactory(new Callback<CellDataFeatures<ClusterMessage, String>, ObservableValue<String>>() {
@@ -3637,7 +3661,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private TableView<ChatMessage> initChatToOtherMSGTable() {
TableView<ChatMessage> tbl_toOtherMSGTable = new TableView<ChatMessage>();
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<ChatMessage, String> timeCol = new TableColumn<ChatMessage, String>("Time");
timeCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
@@ -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<ChatMessage, String> workedRXCol = new TableColumn<ChatMessage, String>("wkd RX?");
workedRXCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
@@ -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<ChatMember> tbl_chatMember = new TableView<ChatMember>();
@@ -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);
@@ -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.
*
* <p>The cell provides two functions that JavaFX's default text cell cannot
* combine:
* <ul>
* <li>a tooltip containing the complete text, but only while the visible
* cell is too narrow for that text;</li>
* <li>clickable HTTP, HTTPS and www links inside otherwise normal text.</li>
* </ul>
*
* <p>The row type is generic because the same implementation is used by the
* ChatMessage tables and the DX-cluster table.</p>
*
* @param <S> row type of the surrounding TableView
*/
public final class MessageTextTableCell<S> extends TableCell<S, String> {
private static final Pattern WEB_LINK_PATTERN = Pattern.compile(
"(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+"
);
private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}";
private final Consumer<String> linkOpener;
private final Predicate<String> 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<String> linkOpener) {
this(linkOpener, text -> false, null, null);
}
/**
* Creates a message cell with optional CSS highlighting.
*
* <p>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.</p>
*
* @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<String> linkOpener,
Predicate<String> 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.
*
* <p>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.</p>
*/
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);
}
}
}
@@ -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;
}
@@ -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;
}