Persist table layouts just after changing the real used sizes and show truncated cell tooltips in the whole applications tables

This commit is contained in:
Marc Froehlich
2026-09-02 23:45:19 +02:00
parent 51aa04bfb5
commit 9eb0550106
16 changed files with 1132 additions and 96 deletions
@@ -1,11 +1,16 @@
package kst4contest.model;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.OptionalDouble;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
@@ -50,7 +55,7 @@ public class ChatPreferences {
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
*/
// private static final int CONFIG_VERSION = 2;
public static final int CONFIG_VERSION = 5;
public static final int CONFIG_VERSION = 6;
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
private static final String TAG_CONFIG_VERSION = "configVersion";
@@ -341,6 +346,11 @@ public class ChatPreferences {
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
private boolean GUIstationMapPathAnalysisVisible = true;
private final Map<String, Double> tableColumnWidths = new LinkedHashMap<>();
private static final String TAG_TABLE_COLUMN_WIDTH = "tableColumnWidth";
private static final double MIN_TABLE_COLUMN_WIDTH = 16.0;
private static final double MAX_TABLE_COLUMN_WIDTH = 10_000.0;
/*********************************************************************************
@@ -645,6 +655,31 @@ public class ChatPreferences {
this.GUIstationMapPathAnalysisVisible = GUIstationMapPathAnalysisVisible;
}
/**
* Returns a stored width for one stable table/leaf-column identity.
*
* @param tableId stable table layout identifier
* @param columnId stable leaf-column identifier
* @return stored pixel width, or empty when no usable value exists
*/
public synchronized OptionalDouble getTableColumnWidth(String tableId, String columnId) {
if (!isValidTableColumnIdentity(tableId, columnId)) {
return OptionalDouble.empty();
}
Double width = tableColumnWidths.get(tableColumnWidthKey(tableId, columnId));
return isValidTableColumnWidth(width) ? OptionalDouble.of(width) : OptionalDouble.empty();
}
/**
* Updates one table leaf-column width in memory. Persistence is coordinated by
* the layout autosave layer.
*/
public synchronized void setTableColumnWidth(String tableId, String columnId, double width) {
if (isValidTableColumnIdentity(tableId, columnId) && isValidTableColumnWidth(width)) {
tableColumnWidths.put(tableColumnWidthKey(tableId, columnId), width);
}
}
public boolean isGuiOptions_defaultFilterNothing() {
return guiOptions_defaultFilterNothing;
}
@@ -1389,7 +1424,7 @@ public class ChatPreferences {
*
* @return true if the file writing was successful, else false
*/
public boolean writePreferencesToXmlFile() {
public synchronized boolean writePreferencesToXmlFile() {
DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
try {
@@ -2085,27 +2120,19 @@ public class ChatPreferences {
);
guiOptions.appendChild(GUIstationMapPathAnalysisVisible);
appendTableColumnWidths(doc, guiOptions);
/****************************************************************************************
****************************** now write this XML! *************************************
****************************************************************************************/
writeXml(doc, System.out);
// write dom document to a file
try (FileOutputStream output =
new FileOutputStream(storeAndRestorePreferencesFileName)) {
writeXml(doc, output);
} catch (IOException e) {
e.printStackTrace();
} catch (TransformerException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
writeDocumentAtomically(doc);
} catch (ParserConfigurationException | TransformerException e1) {
} catch (ParserConfigurationException | TransformerException | IOException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
return false;
}
return true;
@@ -2117,6 +2144,111 @@ public class ChatPreferences {
}
/**
* Writes only layout values into the existing preferences document. Functional
* settings are deliberately read from disk and left untouched.
*/
public synchronized boolean writeLayoutPreferencesToXmlFile() {
Path preferencesPath = Path.of(storeAndRestorePreferencesFileName).toAbsolutePath();
if (!Files.isRegularFile(preferencesPath)) {
System.out.println("[ChatPreferences, Warning]: Cannot autosave layout because preferences.xml does not exist.");
return false;
}
try {
DocumentBuilder documentBuilder = createSecureDocumentBuilderFactory().newDocumentBuilder();
Document document = documentBuilder.parse(preferencesPath.toFile());
Element root = document.getDocumentElement();
if (root == null) {
return false;
}
upsertDirectChildText(document, root, TAG_CONFIG_VERSION, String.valueOf(CONFIG_VERSION));
Element guiOptions = getDirectChildElement(root, "guiOptions");
if (guiOptions == null) {
guiOptions = document.createElement("guiOptions");
root.appendChild(guiOptions);
}
updateLayoutElements(document, guiOptions);
removeDirectChildren(guiOptions, TAG_TABLE_COLUMN_WIDTH);
appendTableColumnWidths(document, guiOptions);
writeDocumentAtomically(document);
return true;
} catch (ParserConfigurationException | SAXException | IOException | TransformerException exception) {
exception.printStackTrace();
return false;
}
}
private void updateLayoutElements(Document document, Element guiOptions) {
upsertDirectChildText(document, guiOptions, "GUIscn_ChatwindowMainSceneSizeHW",
getGUIscn_ChatwindowMainSceneSizeHW()[0] + ";" + getGUIscn_ChatwindowMainSceneSizeHW()[1]);
upsertDirectChildText(document, guiOptions, "GUIclusterAndQSOMonStage_SceneSizeHW",
getGUIclusterAndQSOMonStage_SceneSizeHW()[0] + ";" + getGUIclusterAndQSOMonStage_SceneSizeHW()[1]);
upsertDirectChildText(document, guiOptions, "GUIstage_updateStage_SceneSizeHW",
getGUIstage_updateStage_SceneSizeHW()[0] + ";" + getGUIstage_updateStage_SceneSizeHW()[1]);
upsertDirectChildText(document, guiOptions, "GUIsettingsStageSceneSizeHW",
getGUIsettingsStageSceneSizeHW()[0] + ";" + getGUIsettingsStageSceneSizeHW()[1]);
upsertDirectChildText(document, guiOptions, "GUIselectedCallSignSplitPane_dividerposition",
doubleArrayToCSVString(getGUIselectedCallSignSplitPane_dividerposition()));
upsertDirectChildText(document, guiOptions, "GUImainWindowLeftSplitPane_dividerposition",
doubleArrayToCSVString(getGUImainWindowLeftSplitPane_dividerposition()));
upsertDirectChildText(document, guiOptions, "GUImessageSectionSplitpane_dividerposition",
doubleArrayToCSVString(getGUImessageSectionSplitpane_dividerposition()));
upsertDirectChildText(document, guiOptions, "GUImainWindowRightSplitPane_dividerposition",
doubleArrayToCSVString(getGUImainWindowRightSplitPane_dividerposition()));
upsertDirectChildText(document, guiOptions, "GUIpnl_directedMSGWin_dividerpositionDefault",
doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault()));
upsertDirectChildText(document, guiOptions, "GUIstationMapStageSceneSizeHW",
getGUIstationMapStageSceneSizeHW()[0] + ";" + getGUIstationMapStageSceneSizeHW()[1]);
upsertDirectChildText(document, guiOptions, "GUIstationMapStagePositionXY",
getGUIstationMapStagePositionXY()[0] + ";" + getGUIstationMapStagePositionXY()[1]);
}
private void appendTableColumnWidths(Document document, Element guiOptions) {
for (Map.Entry<String, Double> entry : tableColumnWidths.entrySet()) {
if (!isValidTableColumnWidth(entry.getValue())) {
continue;
}
int separatorIndex = entry.getKey().indexOf('\u0000');
if (separatorIndex <= 0 || separatorIndex >= entry.getKey().length() - 1) {
continue;
}
Element widthElement = document.createElement(TAG_TABLE_COLUMN_WIDTH);
widthElement.setAttribute("tableId", entry.getKey().substring(0, separatorIndex));
widthElement.setAttribute("columnId", entry.getKey().substring(separatorIndex + 1));
widthElement.setAttribute("pixels", String.valueOf(entry.getValue()));
guiOptions.appendChild(widthElement);
}
}
private void writeDocumentAtomically(Document document) throws IOException, TransformerException {
Path target = Path.of(storeAndRestorePreferencesFileName).toAbsolutePath();
Path parent = target.getParent();
Path fileName = target.getFileName();
if (parent == null || fileName == null) {
throw new IOException("Preferences path has no parent directory: " + target);
}
Files.createDirectories(parent);
Path temporary = Files.createTempFile(parent, fileName.toString(), ".tmp");
try {
try (OutputStream output = Files.newOutputStream(temporary)) {
writeXml(document, output);
}
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
// write doc to output stream
private static void writeXml(Document doc, OutputStream output) throws TransformerException {
@@ -2920,6 +3052,22 @@ public class ChatPreferences {
if (s5 != null) {
this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(s5));
}
tableColumnWidths.clear();
NodeList widthElements = element.getElementsByTagName(TAG_TABLE_COLUMN_WIDTH);
for (int widthIndex = 0; widthIndex < widthElements.getLength(); widthIndex++) {
Node widthNode = widthElements.item(widthIndex);
if (!(widthNode instanceof Element widthElement)) {
continue;
}
String tableId = widthElement.getAttribute("tableId");
String columnId = widthElement.getAttribute("columnId");
double width = parseDoubleOrDefault(widthElement.getAttribute("pixels"), Double.NaN);
if (isValidTableColumnIdentity(tableId, columnId) && isValidTableColumnWidth(width)) {
tableColumnWidths.put(tableColumnWidthKey(tableId, columnId), width);
}
}
}
}
}
@@ -3100,6 +3248,63 @@ public class ChatPreferences {
return (n instanceof Element) ? (Element) n : null;
}
private static DocumentBuilderFactory createSecureDocumentBuilderFactory()
throws ParserConfigurationException {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
return factory;
}
private static Element getDirectChildElement(Element parent, String tagName) {
for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) {
if (child instanceof Element element && tagName.equals(element.getTagName())) {
return element;
}
}
return null;
}
private static void upsertDirectChildText(
Document document,
Element parent,
String tagName,
String value
) {
Element element = getDirectChildElement(parent, tagName);
if (element == null) {
element = document.createElement(tagName);
parent.appendChild(element);
}
element.setTextContent(value);
}
private static void removeDirectChildren(Element parent, String tagName) {
for (Node child = parent.getFirstChild(); child != null; ) {
Node next = child.getNextSibling();
if (child instanceof Element element && tagName.equals(element.getTagName())) {
parent.removeChild(child);
}
child = next;
}
}
private static String tableColumnWidthKey(String tableId, String columnId) {
if (!isValidTableColumnIdentity(tableId, columnId)) {
return "";
}
return tableId + '\u0000' + columnId;
}
private static boolean isValidTableColumnIdentity(String tableId, String columnId) {
return tableId != null && !tableId.isBlank() && tableId.indexOf('\u0000') < 0
&& columnId != null && !columnId.isBlank() && columnId.indexOf('\u0000') < 0;
}
private static boolean isValidTableColumnWidth(Double width) {
return width != null && Double.isFinite(width)
&& width >= MIN_TABLE_COLUMN_WIDTH && width <= MAX_TABLE_COLUMN_WIDTH;
}
/**
* Returns the text content of the first matching child tag (directly under {@code parent})
* or {@code defaultValue} if the tag does not exist or is empty.
@@ -119,6 +119,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private StationMapView stationMapView; //view class for the avl stn map
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
private LayoutAutosave layoutAutosave;
private final Button btnConnectionStateIndicator = new Button("LINK");
private final Tooltip tipConnectionStateIndicator = new Tooltip();
@@ -186,7 +187,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return;
}
stationMapView = new StationMapView(chatcontroller.getChatPreferences());
stationMapView = new StationMapView(
chatcontroller.getChatPreferences(),
this::requestLayoutSave
);
stationMapBridge = new StationMapBridge(
chatcontroller,
tbl_chatMember,
@@ -537,7 +541,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
* Builds the tooltip shown on a band-status cell: a fixed legend plus this row's
* resolved status for the given band.
*/
private Tooltip buildBandCellStatusTooltip(ChatMember chatMember, Band band, String status) {
private String buildBandCellStatusTooltipText(ChatMember chatMember, Band band, String status) {
StringBuilder tooltip = new StringBuilder("Band status:\n")
.append("X = worked on this band\n")
.append("B+ = band available, not worked on this band yet (call already worked on another band)\n")
@@ -551,30 +555,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
.append("Status: ").append(status == null || status.isBlank() ? "-" : status);
}
return new Tooltip(tooltip.toString());
return tooltip.toString();
}
/**
* Creates a shared cell factory for one band-status column, attaching the
* {@link #buildBandCellStatusTooltip(ChatMember, Band, String)} tooltip.
* {@link #buildBandCellStatusTooltipText(ChatMember, Band, String)} tooltip.
*/
private Callback<TableColumn<ChatMember, String>, TableCell<ChatMember, String>> createBandStatusCellFactory(Band band) {
return column -> new TableCell<ChatMember, String>() {
return column -> new TruncatedTextTableCell<ChatMember>(
java.util.function.Function.identity(),
(member, status) -> buildBandCellStatusTooltipText(member, band, status)
) {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setTooltip(null);
setStyle("");
return;
}
ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
setText(item);
setTooltip(buildBandCellStatusTooltip(member, band, item));
setAlignment(Pos.CENTER);
setStyle("-fx-font-weight: bold;");
}
@@ -1374,6 +1374,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
// System.out.println("<<<<<<<<<<<<<<<<<<< devider " + selectedCallSignSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + selectedCallSignSplitPane.getDividers().size());
chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition()[selectedCallSignSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
requestLayoutSave();
}
});
@@ -1649,9 +1650,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
});
tbl_chatMemberTable.setTooltip(new Tooltip(
"Stations available \n\nUse right click to a station to select predefined texts\nor hit <strg> + <1> ... <9> to write textsnippet to selected station\n\nHit <enter> to send"));
TableColumn<ChatMember, String> callSignCol =
new TableColumn<ChatMember, String>("Callsign");
@@ -1669,7 +1667,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return new SimpleStringProperty(displayedCallsign);
});
callSignCol.setCellFactory(column -> new TableCell<ChatMember, String>() {
callSignCol.setCellFactory(column -> new TruncatedTextTableCell<ChatMember>() {
@Override
protected void updateItem(String item, boolean empty) {
@@ -1757,14 +1755,24 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
* <p>No font weight is changed here. The compact worked/grid status such as
* {@code xo} is emphasized only in the worked-any column.</p>
*/
qraCol.setCellFactory(column -> new TableCell<ChatMember, String>() {
qraCol.setCellFactory(column -> new TruncatedTextTableCell<ChatMember>(
java.util.function.Function.identity(),
(member, value) -> {
String grossField = WorkedGrossFieldCache.extractGrossField(value);
boolean gridWorked = member != null
&& chatcontroller != null
&& chatcontroller.isGridSquareWorkedAny(member);
return "Grid status: "
+ (grossField == null ? "unknown" : grossField)
+ "\nGrid worked any: "
+ (gridWorked ? "yes" : "no");
}
) {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setTooltip(null);
setStyle("");
return;
}
@@ -1774,16 +1782,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
&& chatcontroller != null
&& chatcontroller.isGridSquareWorkedAny(member);
String grossField = WorkedGrossFieldCache.extractGrossField(item);
setText(item);
setTooltip(new Tooltip(
"Grid status: "
+ (grossField == null ? "unknown" : grossField)
+ "\nGrid worked any: "
+ (gridWorked ? "yes" : "no")
));
/*
* Important:
* Do not style the cell unless the Grid color button is active AND the
@@ -1929,7 +1927,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
*/
airScoutCol.setCellFactory(new Callback<TableColumn<ChatMember, String>, TableCell<ChatMember, String>>() {
public TableCell call(TableColumn param) {
return new TableCell<ChatMember, String>() {
return new TruncatedTextTableCell<ChatMember>() {
@Override
public void updateItem(String item, boolean empty) {
@@ -1953,7 +1951,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
this.getStyleClass().add("table-cell-50PercentAP");
}
setText(item);
}
}
};
@@ -2038,22 +2035,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
/**
* Shows the compact worked/grid status and explains it by tooltip.
*/
wkdAny_subcol.setCellFactory(column -> new TableCell<ChatMember, String>() {
wkdAny_subcol.setCellFactory(column -> new TruncatedTextTableCell<ChatMember>(
java.util.function.Function.identity(),
(member, value) -> buildWorkedAnyGridStatusTooltip(member)
) {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setTooltip(null);
setStyle("");
return;
}
ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
setText(item);
setTooltip(new Tooltip(buildWorkedAnyGridStatusTooltip(member)));
setAlignment(Pos.CENTER);
setStyle("-fx-font-weight: bold;");
}
@@ -2389,7 +2382,38 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// }, new Date(), 5000);
tbl_chatMemberTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY);
tbl_chatMemberTable.autosize();
applyTruncatedTextCells(
nameCol, qrBCol, qtfCol, tropoCol, priorityScoreCol,
lastActCol, notQRVCol, chatCategoryCol
);
TableLayoutManager.install(
tbl_chatMemberTable,
"chat-members",
chatcontroller.getChatPreferences(),
layoutAutosave,
TableLayoutManager.column("callsign", callSignCol),
TableLayoutManager.column("name", nameCol).maximumInitialWidth(220),
TableLayoutManager.column("qra", qraCol),
TableLayoutManager.column("qrb", qrBCol),
TableLayoutManager.column("qtf", qtfCol),
TableLayoutManager.column("qrg", qrgCol),
TableLayoutManager.column("tropo", tropoCol),
TableLayoutManager.column("score", priorityScoreCol),
TableLayoutManager.column("activity", lastActCol),
TableLayoutManager.column("airscout", airScoutCol).maximumInitialWidth(190),
TableLayoutManager.column("worked-any", wkdAny_subcol),
TableLayoutManager.column("band-50", sixMCol_subcol),
TableLayoutManager.column("band-70", fourMCol_subcol),
TableLayoutManager.column("band-144", vhfCol_subcol),
TableLayoutManager.column("band-432", uhfCol_subcol),
TableLayoutManager.column("band-1296", shf23_subcol),
TableLayoutManager.column("band-2320", shf13_subcol),
TableLayoutManager.column("band-3400", shf9_subcol),
TableLayoutManager.column("band-5760", shf6_subcol),
TableLayoutManager.column("band-10g", shf3_subcol),
TableLayoutManager.column("not-qrv", notQRVCol).maximumInitialWidth(180),
TableLayoutManager.column("category", chatCategoryCol)
);
/**
@@ -3120,6 +3144,24 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ObservableList<ChatMessage> toOtherMSGList = chatcontroller.getLst_toOtherMessageList();
tbl_furtherInfoAbtCallsignMSGTable.setItems(chatcontroller.getLst_selectedCallSignInfofilteredMessageList());
applyTruncatedTextCells(
timeCol, callSignTRCVCol, callSignRCVRCol, qrgTXerCol,
qrgRXerCol, workedRXCol, workedTXCol
);
TableLayoutManager.install(
tbl_furtherInfoAbtCallsignMSGTable,
"selected-station-messages",
chatcontroller.getChatPreferences(),
layoutAutosave,
TableLayoutManager.column("time", timeCol),
TableLayoutManager.column("call-tx", callSignTRCVCol),
TableLayoutManager.column("call-rx", callSignRCVRCol),
TableLayoutManager.column("last-qrg-tx", qrgTXerCol),
TableLayoutManager.column("last-qrg-rx", qrgRXerCol),
TableLayoutManager.column("message", msgCol).flexible(360),
TableLayoutManager.column("worked-rx", workedRXCol),
TableLayoutManager.column("worked-tx", workedTXCol)
);
return tbl_furtherInfoAbtCallsignMSGTable;
}
@@ -3281,11 +3323,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
Tab dxClusterMessagesTab = new Tab("DXCluster messages");
dxClusterMessagesTab.setTooltip(new Tooltip("DXCluster spots."));
dxClusterMessagesTab.setContent(initDXClusterTable());
dxClusterMessagesTab.setContent(initDXClusterTable("dx-cluster-main"));
Tab qsoOfTheOtherTab = new Tab("QSO of the other");
qsoOfTheOtherTab.setTooltip(new Tooltip("Messages between other stations. This view is not tied to the selected ChatMember."));
qsoOfTheOtherTab.setContent(initChatToOtherMSGTable());
qsoOfTheOtherTab.setContent(initChatToOtherMSGTable("qso-other-main"));
bottomMessageTabs.getTabs().addAll(publicMessagesTab, dxClusterMessagesTab, qsoOfTheOtherTab);
bottomMessageTabs.getSelectionModel().select(publicMessagesTab);
@@ -3437,6 +3479,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ObservableList<ChatMessage> generalMSGList = chatcontroller.getLst_toAllMessageList();
tbl_generalMSGTable.setItems(generalMSGList);
applyTruncatedTextCells(timeCol, callSignCol, nameCol, categoryCol);
TableLayoutManager.install(
tbl_generalMSGTable,
"public-messages",
chatcontroller.getChatPreferences(),
layoutAutosave,
TableLayoutManager.column("time", timeCol),
TableLayoutManager.column("callsign", callSignCol),
TableLayoutManager.column("name", nameCol).maximumInitialWidth(220),
TableLayoutManager.column("message", msgCol).flexible(360),
TableLayoutManager.column("last-qrg", qrgCol),
TableLayoutManager.column("category", categoryCol)
);
tbl_generalMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
@Override
@@ -3660,7 +3715,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
*/
airScoutCol.setCellFactory(new Callback<TableColumn<ChatMessage, String>, TableCell<ChatMessage, String>>() {
public TableCell call(TableColumn param) {
return new TableCell<ChatMessage, String>() {
return new TruncatedTextTableCell<ChatMessage>() {
@Override
public void updateItem(String item, boolean empty) {
@@ -3682,7 +3737,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
setText(item);
}
}
};
@@ -3741,6 +3795,22 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ObservableList<ChatMessage> privateMSGList = chatcontroller.getLst_toMeMessageList();
tbl_privateMSGTable.setItems(privateMSGList);
applyTruncatedTextCells(timeCol, callSignCol, nameCol, qraCol, qrbCol, categoryCol);
TableLayoutManager.install(
tbl_privateMSGTable,
"private-messages",
chatcontroller.getChatPreferences(),
layoutAutosave,
TableLayoutManager.column("time", timeCol),
TableLayoutManager.column("callsign", callSignCol),
TableLayoutManager.column("name", nameCol).maximumInitialWidth(220),
TableLayoutManager.column("qra", qraCol),
TableLayoutManager.column("qrb", qrbCol),
TableLayoutManager.column("message", msgCol).flexible(360),
TableLayoutManager.column("last-qrg", qrgCol),
TableLayoutManager.column("airscout", airScoutCol).maximumInitialWidth(190),
TableLayoutManager.column("category", categoryCol)
);
tbl_privateMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
@Override
@@ -3812,7 +3882,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return tbl_privateMSGTable;
}
private TableView<ClusterMessage> initDXClusterTable() {
private TableView<ClusterMessage> initDXClusterTable(String layoutId) {
TableView<ClusterMessage> tbl_DXCTable = new TableView<ClusterMessage>();
// tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here"));
@@ -3973,11 +4043,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ObservableList<ClusterMessage> clusterMSGList = chatcontroller.getLst_clusterMemberList();
tbl_DXCTable.setItems(clusterMSGList);
applyTruncatedTextCells(
timeCol, callSignCol, locTXCol, callSignRXCol,
locRXCol, workedCol
);
TableLayoutManager.install(
tbl_DXCTable,
layoutId,
chatcontroller.getChatPreferences(),
layoutAutosave,
TableLayoutManager.column("time", timeCol),
TableLayoutManager.column("call-tx", callSignCol),
TableLayoutManager.column("locator-tx", locTXCol),
TableLayoutManager.column("call-rx", callSignRXCol),
TableLayoutManager.column("locator-rx", locRXCol),
TableLayoutManager.column("qrg", qrgCol),
TableLayoutManager.column("message", msgCol).flexible(360),
TableLayoutManager.column("worked", workedCol)
);
return tbl_DXCTable;
}
private TableView<ChatMessage> initChatToOtherMSGTable() {
private TableView<ChatMessage> initChatToOtherMSGTable(String layoutId) {
TableView<ChatMessage> tbl_toOtherMSGTable = new TableView<ChatMessage>();
// tbl_toOtherMSGTable.setTooltip(new Tooltip("Messages between other member are shown here"));
@@ -4175,6 +4263,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ObservableList<ChatMessage> toOtherMSGList = chatcontroller.getLst_toOtherMessageList();
tbl_toOtherMSGTable.setItems(toOtherMSGList);
applyTruncatedTextCells(
timeCol, callSignTRCVCol, qrgTXerCol, workedTXCol,
callSignRCVRCol, qrgRXerCol, workedRXCol, categoryCol
);
TableLayoutManager.install(
tbl_toOtherMSGTable,
layoutId,
chatcontroller.getChatPreferences(),
layoutAutosave,
TableLayoutManager.column("time", timeCol),
TableLayoutManager.column("call-tx", callSignTRCVCol),
TableLayoutManager.column("last-qrg-tx", qrgTXerCol),
TableLayoutManager.column("worked-tx", workedTXCol),
TableLayoutManager.column("call-rx", callSignRCVRCol),
TableLayoutManager.column("last-qrg-rx", qrgRXerCol),
TableLayoutManager.column("worked-rx", workedRXCol),
TableLayoutManager.column("message", msgCol).flexible(360),
TableLayoutManager.column("category", categoryCol)
);
return tbl_toOtherMSGTable;
}
@@ -5261,6 +5368,28 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
tbl_chatMemberWkdDBTable.getColumns().addAll(callSignCol, workedCol);
tbl_chatMemberWkdDBTable.setItems(chatcontroller.getLst_DBBasedWkdCallSignList());
applyTruncatedTextCells(
callSignCol, wkdAny_subcol, sixMCol_subcol, fourMCol_subcol,
vhfCol_subcol, uhfCol_subcol, shf23_subcol, shf13_subcol,
shf9_subcol, shf6_subcol, shf3_subcol
);
TableLayoutManager.install(
tbl_chatMemberWkdDBTable,
"worked-database",
chatcontroller.getChatPreferences(),
layoutAutosave,
TableLayoutManager.column("callsign", callSignCol),
TableLayoutManager.column("worked-any", wkdAny_subcol),
TableLayoutManager.column("band-50", sixMCol_subcol),
TableLayoutManager.column("band-70", fourMCol_subcol),
TableLayoutManager.column("band-144", vhfCol_subcol),
TableLayoutManager.column("band-432", uhfCol_subcol),
TableLayoutManager.column("band-1296", shf23_subcol),
TableLayoutManager.column("band-2320", shf13_subcol),
TableLayoutManager.column("band-3400", shf9_subcol),
TableLayoutManager.column("band-5760", shf6_subcol),
TableLayoutManager.column("band-10g", shf3_subcol)
);
// TODO: https://www.youtube.com/watch?v=M_kp20qrtLw = tutorial dafuer
@@ -6100,6 +6229,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public void stop() {
System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
if (layoutAutosave != null) {
layoutAutosave.flushPending();
}
timer_buildWindowTitle.purge();
timer_buildWindowTitle.cancel();
@@ -6119,6 +6251,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
System.exit(0);
}
private void requestLayoutSave() {
if (layoutAutosave != null) {
layoutAutosave.requestSave();
}
}
private Queue<Media> musicList = new LinkedList<Media>();
private MediaPlayer mediaPlayer ;
@@ -6544,6 +6682,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ChatMember ownChatMemberObject = new ChatMember();
chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object
layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences());
messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences());
chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual
@@ -6687,6 +6826,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newWidthValue) {
chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[1] = newWidthValue.doubleValue();
requestLayoutSave();
}
});
@@ -6694,6 +6834,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newHeightValue) {
chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[0] = newHeightValue.doubleValue();
requestLayoutSave();
}
});
@@ -7399,6 +7540,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
System.out.println("<<<<<<<<<<<<<<<<<<<<<<<<< devider>>>>>> " + messageSectionSplitpane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + messageSectionSplitpane.getDividers().size());
chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition()[messageSectionSplitpane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
requestLayoutSave();
}
});
@@ -8582,6 +8724,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
System.out.println("<<<<<<<<<<<<<<<<<<< mainWindowLeftSplitPanedevider " + mainWindowLeftSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + mainWindowLeftSplitPane.getDividers().size());
chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()[mainWindowLeftSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
requestLayoutSave();
}
});
@@ -8615,6 +8758,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (dividerIndex >= 0 && dividerIndex < storedPositions.length) {
storedPositions[dividerIndex] = newDividerPosition.doubleValue();
requestLayoutSave();
} else {
// Avoid crashes if preferences are older than the current UI layout.
System.out.println("WARN: cannot store mainWindowRightSplitPane divider position: index="
@@ -8663,7 +8807,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
SplitPane pnl_directedMSGWin = new SplitPane();
pnl_directedMSGWin.setOrientation(Orientation.VERTICAL);
pnl_directedMSGWin.setDividerPositions(chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault());
pnl_directedMSGWin.getItems().addAll(initDXClusterTable(), initChatToOtherMSGTable());
pnl_directedMSGWin.getItems().addAll(
initDXClusterTable("dx-cluster-monitor"),
initChatToOtherMSGTable("qso-other-monitor")
);
/**
@@ -8676,6 +8823,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
System.out.println("<<<<<<<<<<<<<<<<<<<|||||||||||||||||||| devider " + pnl_directedMSGWin.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + pnl_directedMSGWin.getDividers().size());
chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault()[pnl_directedMSGWin.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
requestLayoutSave();
}
});
@@ -8689,6 +8837,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newHeightValue) {
chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1] = newHeightValue.doubleValue();
requestLayoutSave();
}
});
@@ -8696,6 +8845,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newWidthValue) {
chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0] = newWidthValue.doubleValue();
requestLayoutSave();
}
});
@@ -8809,6 +8959,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
System.out.println("SRVR Version: " + chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() + " // installed version " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER);
stage_updateStage.setScene(new Scene(vbxUpdateWindow, chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1]));
stage_updateStage.getScene().widthProperty().addListener((observable, oldValue, newValue) -> {
chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = newValue.doubleValue();
requestLayoutSave();
});
stage_updateStage.getScene().heightProperty().addListener((observable, oldValue, newValue) -> {
chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1] = newValue.doubleValue();
requestLayoutSave();
});
// if (chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() > ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER) {
@@ -11762,7 +11920,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
System.out.println("saved");
chatcontroller.getChatPreferences().writePreferencesToXmlFile();
if (chatcontroller.getChatPreferences().writePreferencesToXmlFile()
&& layoutAutosave != null) {
layoutAutosave.cancelPending();
}
Alert a = new Alert(AlertType.INFORMATION);
a.setTitle("Info");
@@ -11800,6 +11961,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// VBox vBox = new VBox(tabPaneOptions);
settingsScene = new Scene(optionsPanel, chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1]);
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
settingsScene.widthProperty().addListener((observable, oldValue, newValue) -> {
chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0] = newValue.doubleValue();
requestLayoutSave();
});
settingsScene.heightProperty().addListener((observable, oldValue, newValue) -> {
chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1] = newValue.doubleValue();
requestLayoutSave();
});
settingsStage.setScene(settingsScene);
@@ -12515,13 +12684,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
private static <T> void applyQrgUiFormatting(TableColumn<T, String> col) {
col.setCellFactory(tc -> new TableCell<T, String>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
setText(empty ? "" : formatQrgForUi(item));
}
});
col.setCellFactory(tc -> new TruncatedTextTableCell<>(Kst4ContestApplication::formatQrgForUi));
}
@SafeVarargs
private static <T> void applyTruncatedTextCells(TableColumn<T, String>... columns) {
for (TableColumn<T, String> column : columns) {
column.setCellFactory(ignored -> new TruncatedTextTableCell<>());
}
}
/**
@@ -0,0 +1,50 @@
package kst4contest.view;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.util.Duration;
import kst4contest.model.ChatPreferences;
import java.util.Objects;
/**
* Coalesces JavaFX layout changes into selective preferences writes.
*/
public final class LayoutAutosave {
private static final Duration SAVE_DELAY = Duration.millis(750);
private final ChatPreferences preferences;
private final PauseTransition saveDelay = new PauseTransition(SAVE_DELAY);
private boolean pending;
public LayoutAutosave(ChatPreferences preferences) {
this.preferences = Objects.requireNonNull(preferences, "preferences");
saveDelay.setOnFinished(event -> flushPending());
}
public void requestSave() {
if (!Platform.isFxApplicationThread()) {
Platform.runLater(this::requestSave);
return;
}
pending = true;
saveDelay.playFromStart();
}
public void flushPending() {
if (!pending) {
return;
}
saveDelay.stop();
pending = false;
preferences.writeLayoutPreferencesToXmlFile();
}
public void cancelPending() {
saveDelay.stop();
pending = false;
}
}
@@ -0,0 +1,264 @@
package kst4contest.view;
import javafx.application.Platform;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.scene.Node;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.input.MouseEvent;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import kst4contest.model.ChatPreferences;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalDouble;
/**
* Applies persisted leaf-column widths and performs one content-based initial
* sizing pass when no width has been stored yet.
*/
public final class TableLayoutManager {
private static final double CELL_HORIZONTAL_PADDING = 28.0;
private static final double DEFAULT_MINIMUM_WIDTH = 42.0;
private TableLayoutManager() {
}
public static ColumnSpec column(String id, TableColumn<?, String> column) {
return new ColumnSpec(id, column);
}
public static <S> void install(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
ColumnSpec... columnSpecs
) {
Objects.requireNonNull(table, "table");
Objects.requireNonNull(tableId, "tableId");
Objects.requireNonNull(preferences, "preferences");
Objects.requireNonNull(autosave, "autosave");
Map<TableColumn<?, String>, ColumnState> states = new IdentityHashMap<>();
for (ColumnSpec spec : columnSpecs) {
if (spec == null || !spec.column.getColumns().isEmpty()) {
continue;
}
if (spec.column.prefWidthProperty().isBound()) {
spec.column.prefWidthProperty().unbind();
}
spec.column.setId(tableId + "." + spec.id);
OptionalDouble storedWidth = preferences.getTableColumnWidth(tableId, spec.id);
ColumnState state = new ColumnState(spec, storedWidth.isEmpty());
states.put(spec.column, state);
if (storedWidth.isPresent()) {
setWidth(state, storedWidth.getAsDouble());
state.initialized = true;
}
spec.column.widthProperty().addListener((observable, oldWidth, newWidth) -> {
if (state.adjusting || !state.initialized || newWidth == null) {
return;
}
storeWidth(preferences, autosave, tableId, state.spec.id, newWidth.doubleValue());
});
}
installEarlyManualResizeDetection(table, tableId, preferences, autosave, states);
scheduleInitialSizingWhenUsable(table, tableId, preferences, autosave, states);
}
private static <S> void scheduleInitialSizingWhenUsable(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
if (states.values().stream().noneMatch(state -> !state.initialized)) {
return;
}
if (table.getItems() != null && !table.getItems().isEmpty()) {
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
return;
}
@SuppressWarnings("unchecked")
final ListChangeListener<S>[] holder = new ListChangeListener[1];
holder[0] = change -> {
if (table.getItems() == null || table.getItems().isEmpty()) {
return;
}
table.getItems().removeListener(holder[0]);
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
};
table.getItems().addListener(holder[0]);
}
private static <S> void sizePendingColumns(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
for (ColumnState state : states.values()) {
if (state.initialized) {
continue;
}
double width = state.spec.flexible
? flexibleInitialWidth(table, state.spec)
: contentInitialWidth(table, state.spec);
setWidth(state, width);
state.initialized = true;
storeWidth(preferences, autosave, tableId, state.spec.id, width);
}
}
private static <S> double contentInitialWidth(TableView<S> table, ColumnSpec spec) {
Text measurement = new Text();
measurement.setFont(Font.getDefault());
double requiredWidth = measure(spec.column.getText(), measurement);
for (int rowIndex = 0; rowIndex < table.getItems().size(); rowIndex++) {
ObservableValue<?> value = spec.column.getCellObservableValue(rowIndex);
if (value == null || value.getValue() == null) {
continue;
}
requiredWidth = Math.max(
requiredWidth,
measure(String.valueOf(value.getValue()), measurement)
);
}
return clamp(requiredWidth + CELL_HORIZONTAL_PADDING, spec.minimumWidth, spec.maximumInitialWidth);
}
private static <S> double flexibleInitialWidth(TableView<S> table, ColumnSpec spec) {
double tableShare = table.getWidth() > 1.0 ? table.getWidth() * 0.42 : spec.flexibleFallbackWidth;
Text measurement = new Text();
measurement.setFont(Font.getDefault());
double headerWidth = measure(spec.column.getText(), measurement) + CELL_HORIZONTAL_PADDING;
return clamp(Math.max(headerWidth, tableShare), spec.minimumWidth, spec.maximumInitialWidth);
}
private static <S> void installEarlyManualResizeDetection(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
Map<TableColumn<?, String>, Double> widthsAtHeaderPress = new IdentityHashMap<>();
table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
if (!isColumnHeaderEvent(event)) {
return;
}
widthsAtHeaderPress.clear();
states.forEach((column, state) -> widthsAtHeaderPress.put(column, column.getWidth()));
});
table.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> {
if (widthsAtHeaderPress.isEmpty()) {
return;
}
states.forEach((column, state) -> {
Double oldWidth = widthsAtHeaderPress.get(column);
if (oldWidth == null || Math.abs(oldWidth - column.getWidth()) <= 0.5) {
return;
}
state.initialized = true;
storeWidth(preferences, autosave, tableId, state.spec.id, column.getWidth());
});
widthsAtHeaderPress.clear();
});
}
private static boolean isColumnHeaderEvent(MouseEvent event) {
Object target = event.getTarget();
Node node = target instanceof Node ? (Node) target : null;
while (node != null && node.getParent() != null) {
if (node.getStyleClass().contains("column-header")
|| node.getStyleClass().contains("nested-column-header")) {
return true;
}
node = node.getParent();
}
return false;
}
private static void setWidth(ColumnState state, double width) {
state.adjusting = true;
try {
state.spec.column.setPrefWidth(width);
} finally {
state.adjusting = false;
}
}
private static void storeWidth(
ChatPreferences preferences,
LayoutAutosave autosave,
String tableId,
String columnId,
double width
) {
preferences.setTableColumnWidth(tableId, columnId, width);
autosave.requestSave();
}
private static double measure(String value, Text measurement) {
measurement.setText(value == null ? "" : value);
return measurement.getLayoutBounds().getWidth();
}
private static double clamp(double value, double minimum, double maximum) {
return Math.max(minimum, Math.min(value, maximum));
}
public static final class ColumnSpec {
private final String id;
private final TableColumn<?, String> column;
private double minimumWidth = DEFAULT_MINIMUM_WIDTH;
private double maximumInitialWidth = Double.MAX_VALUE;
private double flexibleFallbackWidth = 320.0;
private boolean flexible;
private ColumnSpec(String id, TableColumn<?, String> column) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Column id must not be blank");
}
this.id = id;
this.column = Objects.requireNonNull(column, "column");
}
public ColumnSpec maximumInitialWidth(double maximumInitialWidth) {
this.maximumInitialWidth = maximumInitialWidth;
return this;
}
public ColumnSpec flexible(double fallbackWidth) {
flexible = true;
flexibleFallbackWidth = fallbackWidth;
return this;
}
}
private static final class ColumnState {
private final ColumnSpec spec;
private boolean adjusting;
private boolean initialized;
private ColumnState(ColumnSpec spec, boolean awaitingInitialSizing) {
this.spec = spec;
initialized = !awaitingInitialSizing;
}
}
}
@@ -0,0 +1,109 @@
package kst4contest.view;
import javafx.scene.control.TableCell;
import javafx.scene.control.Tooltip;
import javafx.scene.text.Text;
import javafx.util.Duration;
import java.util.function.Function;
import java.util.function.BiFunction;
/**
* Displays text normally and exposes the full value only when it is clipped.
* An optional functional explanation remains available and is combined with
* the full value when both are needed.
*/
public class TruncatedTextTableCell<S> extends TableCell<S, String> {
private final Function<String, String> formatter;
private final BiFunction<S, String, String> functionalTooltipProvider;
private final Tooltip tooltip = new Tooltip();
private final Text textMeasurement = new Text();
private String fullText = "";
public TruncatedTextTableCell() {
this(Function.identity(), null);
}
public TruncatedTextTableCell(Function<String, String> formatter) {
this(formatter, null);
}
public TruncatedTextTableCell(
Function<String, String> formatter,
BiFunction<S, String, String> functionalTooltipProvider
) {
this.formatter = formatter == null ? Function.identity() : formatter;
this.functionalTooltipProvider = functionalTooltipProvider;
tooltip.setWrapText(true);
tooltip.setMaxWidth(800);
tooltip.setShowDelay(Duration.millis(250));
tooltip.setShowDuration(Duration.seconds(30));
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
fullText = "";
setText(null);
setGraphic(null);
setTooltip(null);
return;
}
String formatted = formatter.apply(item);
fullText = formatted == null ? "" : formatted;
setText(fullText);
setGraphic(null);
updateTooltip();
}
@Override
protected void layoutChildren() {
super.layoutChildren();
updateTooltip();
}
private void updateTooltip() {
if (isEmpty()) {
setTooltip(null);
return;
}
String functionalText = resolveFunctionalTooltip();
boolean clipped = isTextClipped();
String tooltipText = TruncatedTextTooltipSupport.buildTooltipText(
fullText,
clipped,
functionalText
);
if (tooltipText == null) {
setTooltip(null);
return;
}
tooltip.setText(tooltipText);
setTooltip(tooltip);
}
private String resolveFunctionalTooltip() {
if (functionalTooltipProvider == null || getTableRow() == null) {
return null;
}
return functionalTooltipProvider.apply(getTableRow().getItem(), fullText);
}
private boolean isTextClipped() {
if (fullText.isEmpty()) {
return false;
}
textMeasurement.setText(fullText);
textMeasurement.setFont(getFont());
double requiredWidth = textMeasurement.getLayoutBounds().getWidth();
double availableWidth = Math.max(0.0,
getWidth() - snappedLeftInset() - snappedRightInset() - 2.0);
return TruncatedTextTooltipSupport.isTextClipped(requiredWidth, availableWidth);
}
}
@@ -0,0 +1,28 @@
package kst4contest.view;
/**
* Pure tooltip decisions kept separate from JavaFX controls for unit testing.
*/
final class TruncatedTextTooltipSupport {
private TruncatedTextTooltipSupport() {
}
static boolean isTextClipped(double requiredWidth, double availableWidth) {
return requiredWidth > availableWidth + 1.0;
}
static String buildTooltipText(String fullText, boolean clipped, String functionalText) {
boolean hasFunctionalText = functionalText != null && !functionalText.isBlank();
if (!clipped && !hasFunctionalText) {
return null;
}
if (!clipped) {
return functionalText;
}
if (!hasFunctionalText) {
return fullText;
}
return fullText + "\n\n" + functionalText;
}
}
@@ -59,6 +59,7 @@ public final class StationMapView {
private final Label detailPathModeValue = new Label("-");
private final ChatPreferences chatPreferences;
private final Runnable layoutSaveRequester;
private final Stage stage = new Stage();
private final WebView webView = new WebView();
@@ -180,7 +181,12 @@ public final class StationMapView {
public StationMapView(ChatPreferences chatPreferences) {
this(chatPreferences, () -> { });
}
public StationMapView(ChatPreferences chatPreferences, Runnable layoutSaveRequester) {
this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences");
this.layoutSaveRequester = Objects.requireNonNull(layoutSaveRequester, "layoutSaveRequester");
GuiUtils.applyApplicationIcon(stage);
try {
@@ -461,17 +467,25 @@ public final class StationMapView {
stage.setY(pos[1]);
}
stage.widthProperty().addListener((obs, oldValue, newValue) ->
chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue());
stage.widthProperty().addListener((obs, oldValue, newValue) -> {
chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue();
layoutSaveRequester.run();
});
stage.heightProperty().addListener((obs, oldValue, newValue) ->
chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue());
stage.heightProperty().addListener((obs, oldValue, newValue) -> {
chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue();
layoutSaveRequester.run();
});
stage.xProperty().addListener((obs, oldValue, newValue) ->
chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue());
stage.xProperty().addListener((obs, oldValue, newValue) -> {
chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue();
layoutSaveRequester.run();
});
stage.yProperty().addListener((obs, oldValue, newValue) ->
chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue());
stage.yProperty().addListener((obs, oldValue, newValue) -> {
chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue();
layoutSaveRequester.run();
});
stage.setOnShown(event -> Platform.runLater(() -> {
webView.requestFocus();
@@ -0,0 +1,130 @@
package kst4contest.test;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatPreferencesLayoutPersistenceTest {
@TempDir
Path temporaryDirectory;
@Test
void columnWidthsSurviveFullXmlRoundTripAndLayoutsStayIndependent() {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences written = preferencesAt(preferencesFile);
written.setTableColumnWidth("dx-cluster-main", "message", 410.5);
written.setTableColumnWidth("dx-cluster-monitor", "message", 275.25);
assertTrue(written.writePreferencesToXmlFile());
ChatPreferences restored = preferencesAt(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertEquals(410.5,
restored.getTableColumnWidth("dx-cluster-main", "message").orElseThrow());
assertEquals(275.25,
restored.getTableColumnWidth("dx-cluster-monitor", "message").orElseThrow());
assertFalse(restored.getTableColumnWidth("qso-other-main", "message").isPresent());
}
@Test
void legacyXmlWithoutColumnWidthsKeepsWidthsAbsent() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("legacy.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>5</configVersion>
<guiOptions>
<GUIscn_ChatwindowMainSceneSizeHW>768;1234</GUIscn_ChatwindowMainSceneSizeHW>
</guiOptions>
</praktiKST>
""");
ChatPreferences restored = preferencesAt(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.getTableColumnWidth("public-messages", "time").isPresent());
}
@Test
void invalidColumnWidthEntriesAreIgnored() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("invalid.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>6</configVersion>
<guiOptions>
<tableColumnWidth tableId="public-messages" columnId="callsign" pixels="NaN"/>
<tableColumnWidth tableId="public-messages" columnId="name" pixels="-20"/>
<tableColumnWidth tableId="public-messages" columnId="category" pixels="999999"/>
<tableColumnWidth tableId="public-messages" columnId="time" pixels="88.5"/>
</guiOptions>
</praktiKST>
""");
ChatPreferences restored = preferencesAt(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.getTableColumnWidth("public-messages", "callsign").isPresent());
assertFalse(restored.getTableColumnWidth("public-messages", "name").isPresent());
assertFalse(restored.getTableColumnWidth("public-messages", "category").isPresent());
assertEquals(88.5,
restored.getTableColumnWidth("public-messages", "time").orElseThrow());
}
@Test
void selectiveLayoutWritePreservesDiskSettingsAndUnknownXml() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("selective.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>5</configVersion>
<station>
<LoginCallSign>SAVED-CALL</LoginCallSign>
</station>
<futureExtension mode="keep-me"><value>42</value></futureExtension>
<guiOptions>
<GUIscn_ChatwindowMainSceneSizeHW>700;1100</GUIscn_ChatwindowMainSceneSizeHW>
<futureLayoutValue>untouched</futureLayoutValue>
</guiOptions>
</praktiKST>
""");
ChatPreferences preferences = preferencesAt(preferencesFile);
assertTrue(preferences.readPreferencesFromXmlFile());
preferences.setStn_loginCallSign("UNSAVED-CALL");
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[0] = 812;
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[1] = 1340;
preferences.setTableColumnWidth("qso-other-monitor", "call-tx", 123.75);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains("<LoginCallSign>SAVED-CALL</LoginCallSign>"));
assertFalse(writtenXml.contains("UNSAVED-CALL"));
assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">"));
assertTrue(writtenXml.contains("<futureLayoutValue>untouched</futureLayoutValue>"));
assertTrue(writtenXml.contains("<configVersion>6</configVersion>"));
assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0"));
ChatPreferences restored = preferencesAt(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertEquals("SAVED-CALL", restored.getStn_loginCallSign());
assertEquals(123.75,
restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow());
}
private ChatPreferences preferencesAt(Path preferencesFile) {
ChatPreferences preferences = new ChatPreferences();
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
return preferences;
}
}
@@ -0,0 +1,32 @@
package kst4contest.view;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TruncatedTextTableCellTest {
@Test
void plainFullTextTooltipIsShownOnlyForClippedText() {
assertNull(TruncatedTextTooltipSupport.buildTooltipText("complete", false, null));
assertEquals("complete",
TruncatedTextTooltipSupport.buildTooltipText("complete", true, null));
}
@Test
void functionalTooltipRemainsAndCombinesWithClippedValue() {
assertEquals("Worked status",
TruncatedTextTooltipSupport.buildTooltipText("X", false, "Worked status"));
assertEquals("Long value\n\nWorked status",
TruncatedTextTooltipSupport.buildTooltipText("Long value", true, "Worked status"));
}
@Test
void clippingComparisonUsesAvailableRenderedWidth() {
assertFalse(TruncatedTextTooltipSupport.isTextClipped(100, 100));
assertTrue(TruncatedTextTooltipSupport.isTextClipped(102, 100));
}
}