mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-10 19:25:32 +02:00
Compare commits
6
Commits
6b29ffe3ba
...
v1.43.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75fe45b50b | ||
|
|
53555cbe69 | ||
|
|
595fb84362 | ||
|
|
9eb0550106 | ||
|
|
51aa04bfb5 | ||
|
|
6b0d699a98 |
+26
-1
@@ -1,6 +1,6 @@
|
||||
# KST4Contest Project Context
|
||||
|
||||
Last reviewed: 2026-08-28
|
||||
Last reviewed: 2026-09-03
|
||||
|
||||
This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous.
|
||||
|
||||
@@ -34,6 +34,7 @@ KST4Contest is a Java/JavaFX desktop client for ON4KST chat focused on VHF/UHF/m
|
||||
- `NOT-QRV` overrides positive inferred band-availability hints.
|
||||
- Unknown/missing frequency, QRB, QTF or similar external data must remain unavailable rather than becoming a fabricated zero/default.
|
||||
- Features that depend on frequency should use the current/actual QRG according to current implemented rules; do not silently revert to a fixed 144 MHz default.
|
||||
- Complete digit-only frequencies use their final three digits as the kHz part and are accepted only when the resulting MHz value lies within a supported `Band` range. The same full-frequency parser is used for station names and public or directed chat messages. Relative QRG rules and bare three-digit context handling remain separate.
|
||||
|
||||
### JavaFX/threading
|
||||
|
||||
@@ -49,6 +50,17 @@ JavaFX ObservableList / UI state
|
||||
|
||||
`MessageBusManagementThread` must not directly iterate or mutate UI-bound JavaFX collections. UI-visible changes should cross the controller/UI boundary and run on the JavaFX Application Thread.
|
||||
|
||||
## Configuration and Layout Persistence
|
||||
|
||||
- The current `preferences.xml` configuration version is 7. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
|
||||
- `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility.
|
||||
- Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value.
|
||||
- Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores.
|
||||
- Window sizes and positions, relevant divider positions and managed column widths are selectively autosaved after a short debounce. A pending write is flushed during application shutdown.
|
||||
- Selective layout writes update the XML already on disk, preserve unknown XML nodes and must not persist unconfirmed functional settings from the current UI. **Save Settings** remains the full settings writer and includes the current layout.
|
||||
- Full and selective writes are synchronized and replace `preferences.xml` atomically. Missing, unknown or malformed width entries do not prevent loading and fall back to initial sizing.
|
||||
- Older configuration files require no migration. Older KST4Contest versions can ignore the additional elements; a complete rewrite by such a version may discard column widths without invalidating the remaining file.
|
||||
|
||||
## External Interfaces
|
||||
|
||||
Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface.
|
||||
@@ -65,6 +77,15 @@ Known integration areas include:
|
||||
|
||||
CR/LF framing, XML framing, ports/transports, callsign normalization and frequency formatting are protocol behaviour and must not be changed as incidental cleanup.
|
||||
|
||||
### Local DX Cluster output
|
||||
|
||||
- Local spots use a fixed 75-character, DXSpider-compatible payload line followed by two BEL characters and CRLF.
|
||||
- The DX callsign begins in column 27 and occupies up to 12 characters. The 30-character comment begins in column 40, and the five-character UTC time begins in column 71.
|
||||
- Spotter and frequency padding is calculated dynamically so frequencies from 50 MHz through 24 GHz do not shift the following fields.
|
||||
- Comments are padded or truncated to exactly 30 characters. Automatic AirScout comments retain the locator first and use the compact form `JO51HK AP 1m/100%;4m/75%`.
|
||||
- A DX callsign longer than 12 characters is rejected and logged rather than truncated.
|
||||
- Trigger conditions, QRG recognition and normalisation, login, keepalive, multi-client delivery and the local-only trust boundary remain separate from line formatting.
|
||||
|
||||
### Logging and Worked-state persistence
|
||||
|
||||
- The Simplelogfile interpreter reads the selected text file after connection startup and then once per minute using a fixed built-in callsign pattern.
|
||||
@@ -73,6 +94,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
||||
- The interpreter only adds positive runtime marks. It does not remove existing marks during the current session and does not reset automatically when a new contest starts. A database reset does not modify the file; callsigns contained in it are marked as worked again during the next periodic evaluation.
|
||||
- A missing selected file is created. Read, path and creation failures are contained so the periodic timer remains alive; successful creation triggers a one-time, non-blocking UI notice with the exact path and setup/contest checks.
|
||||
- Network-derived and manually assigned Worked, NOT-QRV and worked-grid state continues to use SQLite with its established lifetime and reset behaviour.
|
||||
- Each completed initial ON4KST user list loads one SQLite Worked/NOT-QRV snapshot. `ChatController` applies that snapshot by normalized base callsign to every new category and suffix variant before the completed category is published. The same event-driven path runs again after a reconnect; startup synchronization does not depend on a fixed-delay timer.
|
||||
- Automatic QRG updates require both an enabled source and valid incoming `RadioInfo` or Win-Test `STATUS` data. Merely enabling a source does not provide or validate a current QRG.
|
||||
- UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state.
|
||||
- A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener.
|
||||
@@ -105,6 +127,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
||||
- Contest operating speed and low-friction interaction are primary goals.
|
||||
- Incidental code changes must not unexpectedly change selection, focus, sorting, tab state, map zoom or prefilled text.
|
||||
- Map reset clears the selected target without changing zoom unless explicitly redesigned.
|
||||
- **Group nearby stations** re-renders only the existing station-marker layer from JavaScript `stationData`. It must not reload the WebView, tiles or station data, request a new controller snapshot, or change zoom, viewport or selection.
|
||||
- Base-callsign aggregation into one geographical marker happens before screen-based clustering. Disabling clustering displays each resulting positionable map station individually but never splits active variants of the same normalised base callsign into separate geographical markers.
|
||||
- Station selection preserves the established `/cq callsign` prefill behaviour.
|
||||
- Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.
|
||||
|
||||
@@ -163,6 +187,7 @@ Before implementing planned items, re-check current decisions and obtain a fresh
|
||||
- Historical project context is useful but may be stale; current code/tests win.
|
||||
- External service/API behaviour must be verified against current upstream documentation when uncertain.
|
||||
- Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them.
|
||||
- `station_map_path_analysis.png` and `station_map_reset.png` predate the **Group nearby stations** checkbox in the map header. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`.
|
||||
|
||||
## Recent Significant Changes
|
||||
|
||||
|
||||
@@ -152,7 +152,15 @@ Der Wert beeinflusst unter anderem:
|
||||
|
||||
---
|
||||
|
||||
## Nachrichtentabellen
|
||||
## Tabellenbreiten und gekürzte Zellinhalte
|
||||
|
||||
Beim ersten brauchbaren Datenbestand richtet KST4Contest die Spalten der Benutzerliste, der Nachrichtenansichten, der DXCluster- und QSO-Tabellen sowie der Worked-Datenbank einmalig nach Überschrift und vorhandenem Inhalt aus. Bereits gespeicherte Breiten haben Vorrang. **Name**, **AP** und **NOT QRV @** werden dabei begrenzt, damit einzelne lange Werte nicht den restlichen Tabellenbereich verdrängen. **Message** und vergleichbare Freitextspalten bleiben flexibel und richten sich nicht nach der längsten Nachricht.
|
||||
|
||||
Manuell geänderte Spaltenbreiten werden automatisch gespeichert und beim nächsten Start wiederhergestellt. Spätere Nachrichten oder Stationsaktualisierungen überschreiben diese Auswahl nicht.
|
||||
|
||||
Passt ein normaler Textwert nicht vollständig in seine Zelle, zeigt ein Tooltip den vollständigen Wert. Der Tooltip erscheint nur bei tatsächlich gekürztem Text. Funktionale Tooltips, etwa für QRA-, Worked- oder Bandzustände, bleiben erhalten; bei gekürztem Zelltext stehen Volltext und Erklärung gemeinsam im Tooltip.
|
||||
|
||||
### Nachrichtentext und Links
|
||||
|
||||
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.
|
||||
|
||||
@@ -327,7 +335,11 @@ Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest:
|
||||
3. aktualisiert den **Further Info**-Bereich und
|
||||
4. bereitet das vollständige sichtbare Rufzeichen als `/cq`-Empfänger vor.
|
||||
|
||||
Marker, die bei der aktuellen Zoomstufe zu dicht beieinanderliegen, werden als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus.
|
||||
Ist **Group nearby stations** ausgewählt, werden Marker, die bei niedrigen Zoomstufen zu dicht beieinanderliegen, als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus. Wird die Checkbox ausgeschaltet, zeigt die Karte unabhängig von der Zoomstufe alle positionierbaren Stationen als einzelne Marker.
|
||||
|
||||
Das Umschalten wirkt sofort und verändert weder Zoom noch Kartenausschnitt oder Stationsauswahl. Die Einstellung wird automatisch gespeichert und beim nächsten Programmstart wiederhergestellt. Ohne gespeicherte Einstellung bleibt **Group nearby stations** ausgewählt, damit bestehende Installationen zunächst das bisherige Verhalten behalten.
|
||||
|
||||
Der Schalter betrifft nur die räumlichen Cluster auf dem Bildschirm. Aktive Chatvarianten desselben normalisierten Basisrufzeichens können weiterhin einen gemeinsamen geografischen Marker verwenden und bleiben unabhängig davon getrennte Nachrichtenziele.
|
||||
|
||||
Die Kopfzeile ergänzt bei ausgewählter Station:
|
||||
|
||||
@@ -432,7 +444,7 @@ Zusätzlich öffnet KST4Contest das Fenster **Cluster & QSO of the other**. Es z
|
||||
|
||||

|
||||
|
||||
Die Position des vertikalen Dividers sowie die Fenstergröße werden zusammen mit den übrigen UI-Einstellungen gespeichert. Nach einer Änderung **Save Settings** verwenden.
|
||||
Die Position des vertikalen Dividers sowie die Fenstergröße werden automatisch gespeichert. Die DXCluster- und QSO-Tabellen besitzen hier eigene Spaltenbreiten; Änderungen im Monitorfenster verändern daher nicht das Tabellenlayout der Hauptfenster-Tabs.
|
||||
|
||||
Das Fenster lässt sich über das Menü aus- und wieder einblenden:
|
||||
|
||||
@@ -478,13 +490,13 @@ Die serverbezogenen Funktionen sind nur bei vollständig aufgebauter ON4KST-Verb
|
||||
|
||||
## Fenstergrößen und Divider
|
||||
|
||||
Beim Klick auf **Save Settings** speichert KST4Contest die Größen der Programmfenster und die Positionen der relevanten Divider in der Konfigurationsdatei. Diese Werte werden beim nächsten Programmstart wiederverwendet.
|
||||
KST4Contest speichert Größen und Positionen der Programmfenster, die relevanten Divider sowie manuell geänderte Tabellenbreiten automatisch nach einer kurzen Verzögerung in der Konfigurationsdatei. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben. **Save Settings** ist dafür nicht erforderlich, speichert aber weiterhin den vollständigen aktuellen Stand einschließlich Layout.
|
||||
|
||||
Das Hauptfenster wird beim Start zusätzlich gegen den sichtbaren Bereich des primären Bildschirms geprüft. Ist die gespeicherte Größe zu groß, verkleinert und verschiebt KST4Contest das Fenster so, dass es wieder erreichbar bleibt. Die genaue Herleitung ist unter [Bildschirmgerechte Größe des Hauptfensters](de-Funktionen#bildschirmgerechte-größe-des-hauptfensters-ab-v141) beschrieben.
|
||||
|
||||
Für die übrigen Programmfenster gilt diese zusätzliche Größenbegrenzung derzeit nicht. Wird beispielsweise das separate Monitorfenster nach einem Wechsel auf einen kleineren Bildschirm zu groß dargestellt, muss seine Größe manuell korrigiert und anschließend erneut mit **Save Settings** gespeichert werden.
|
||||
Für die übrigen Programmfenster gilt diese zusätzliche Größenbegrenzung derzeit nicht. Wird beispielsweise das separate Monitorfenster nach einem Wechsel auf einen kleineren Bildschirm zu groß dargestellt, genügt eine manuelle Korrektur; die neue Größe wird automatisch gespeichert.
|
||||
|
||||
Bei einer ungünstigen Aufteilung sollten zuerst die Divider an eine brauchbare Position verschoben und die Einstellungen erneut gespeichert werden. Das Löschen der Konfigurationsdatei setzt zwar die UI-Werte zurück, entfernt aber auch die übrigen gespeicherten Programmeinstellungen und sollte deshalb nur verwendet werden, wenn sich die Oberfläche auf anderem Weg nicht mehr herstellen lässt.
|
||||
Bei einer ungünstigen Aufteilung sollten zuerst die Divider und Spaltenbreiten wieder an brauchbare Positionen verschoben werden. Das Löschen der Konfigurationsdatei setzt zwar die UI-Werte zurück, entfernt aber auch die übrigen gespeicherten Programmeinstellungen und sollte deshalb nur verwendet werden, wenn sich die Oberfläche auf anderem Weg nicht mehr herstellen lässt.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,17 +38,21 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
|
||||
|
||||
- **Filter zurücksetzen:** Ein eigener Reset-Button entfernt die aktiven Filterprädikate der Benutzerliste zuverlässig.
|
||||
|
||||
- **Kartencluster:** Räumlich dicht beieinanderliegende Stationen werden bei niedrigen Zoomstufen zusammengefasst. Die ausgewählte Station und relevante Richtungsgelegenheiten bleiben einzeln sichtbar.
|
||||
- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die automatisch gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
|
||||
|
||||
- **Ausblendbare Streckenanalyse:** Geländeprofil und Analysebereich der Stationskarte können vollständig ausgeblendet werden. Die Auswahl wird gespeichert und beim nächsten Programmstart wiederhergestellt.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Tabellenlayout automatisch gesichert:** Tabellen werden beim ersten brauchbaren Inhalt sinnvoll dimensioniert. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden verzögert in `preferences.xml` gespeichert; Haupt- und Monitorfenster behalten für DXCluster und QSO of the other getrennte Layouts. Gekürzte normale Zellwerte zeigen ihren Volltext im Tooltip, ohne funktionale Tooltips oder anklickbare Links zu verdrängen.
|
||||
|
||||
- **DX-Cluster-Zeilenformat vereinheitlicht:** Lokale Spots verwenden jetzt eine feste, DXSpider-kompatible 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt auch bei Mikrowellenfrequenzen bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt still abgeschnitten zu werden; AirScout- und Testkommentare sind entsprechend kompakter.
|
||||
|
||||
- **Sessionbezogene ON4KST-Verbindungssteuerung:** Socket, Reader, Writer, Messagebus und Warteschlangen gehören jetzt zu einer eindeutig identifizierten Verbindungssession. Veraltete Threads einer abgelösten Verbindung können dadurch keine Daten mehr verarbeiten oder die neue Verbindung schließen. `ONLINE` wird erst nach bestätigtem Login und vollständig empfangenen Benutzerlisten gemeldet. Verbindungsaufbau, Login und Synchronisation besitzen feste Zeitlimits; Heartbeats, ausbleibende Eingangsdaten, EOF sowie Lese- und Schreibfehler werden überwacht und lösen bei Bedarf einen kontrollierten Neuaufbau mit Backoff aus.
|
||||
|
||||
- **ON4KST-Protokollbefehle abgesichert:** Ausgehende Befehle werden zentral aufgebaut und auf gültige Kategorien, Locatoren und unerlaubte Frame-Trennzeichen geprüft. Da ON4KST pro TCP-Session nur einen Locator verwaltet, wird für beide Chat-Kategorien der Hauptlocator verwendet und eine abweichende zweite Konfiguration protokolliert, statt widersprüchliche Befehle an den Server zu senden.
|
||||
|
||||
- **QRG-Erkennung präzisiert:** Vollständige und relative Frequenzangaben werden weiterhin erkannt. Nackte dreistellige Zahlen gelten nur noch bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
|
||||
- **QRG-Erkennung präzisiert:** Vollständige Frequenzangaben werden auch ohne Dezimaltrenner erkannt; die letzten drei Ziffern bilden dabei den kHz-Anteil. Relative Frequenzen bleiben unverändert, und nackte dreistellige Zahlen gelten weiterhin nur bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
|
||||
|
||||
- **Stationsbezogener Frequenzkontext:** Bei relativen QRGs verwendet KST4Contest zuerst einen höchstens 30 Minuten alten Bandkontext derselben Station. Erst wenn dieser fehlt, wird das global konfigurierte Fallback-Band verwendet.
|
||||
|
||||
@@ -94,6 +98,8 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
|
||||
|
||||
- **Zuverlässige Benutzerliste beim Login:** Ungültige oder unvollständige `UA0`-Teilnehmerdatensätze werden einzeln verworfen und protokolliert, ohne die Verarbeitung der alphabetisch folgenden Teilnehmer abzubrechen. Die gültigen Einträge werden zunächst pro Kategorie gesammelt und erst mit dem ersten zugehörigen `UE`-Abschlussframe vollständig veröffentlicht.
|
||||
|
||||
- **Persistierter Worked-Status beim Listenaufbau:** Beim Abschluss jeder initialen ON4KST-Benutzerliste wird der SQLite-Zustand einmal geladen und vor der Veröffentlichung auf die neuen Chatmember angewendet. Das gilt für beide Kategorien, erneute Verbindungen und alle aktiven Varianten eines Basisrufzeichens.
|
||||
|
||||
- **Benutzerliste verschwindet nach dem Login:** ON4KST kann nach Namens-, Status- oder anderen Live-Änderungen weitere `UE`-Frames für dieselbe Kategorie senden. Wiederholte Abschlussframes werden jetzt erkannt und ignoriert, damit eine bereits gefüllte Benutzerliste nicht durch eine leere Momentaufnahme ersetzt wird.
|
||||
|
||||
- **Fehlgeschlagener Erstaufbau und Verbindungsverlust:** Wenn beim Programmstart keine Verbindung zum Server hergestellt werden kann, läuft KST4Contest nicht mehr in eine Endlos- oder Busy-Wait-Schleife. Die Oberfläche bleibt bedienbar und weitere Versuche erfolgen mit begrenztem Backoff. Auch ein vom Server geschlossener oder über längere Zeit stummer Socket wird zuverlässig erkannt.
|
||||
|
||||
@@ -163,7 +163,7 @@ Die Schaltfläche **Send test spot** erzeugt folgenden Testeintrag:
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
||||
Comment: DXC test: You donated $100!
|
||||
Frequency: .300 des konfigurierten Fallback-Bandes
|
||||
```
|
||||
|
||||
@@ -191,8 +191,18 @@ Ein Spot enthält:
|
||||
- den Locator,
|
||||
- die aktuelle UTC-Zeit.
|
||||
|
||||
Die Nutzzeile folgt einem festen, DXSpider-kompatiblen 75-Zeichen-Format. Das DX-Rufzeichen beginnt in Spalte 27, das Kommentarfeld umfasst genau 30 Zeichen und die UTC-Zeit beginnt in Spalte 71. Kurze Kommentare werden mit Leerzeichen aufgefüllt, längere kontrolliert auf 30 Zeichen begrenzt. Unterschiedlich lange Spotter-Rufzeichen und Frequenzen bis 24 GHz verschieben die nachfolgenden Felder nicht.
|
||||
|
||||
Das vollständige DX-Rufzeichen wird nicht abgeschnitten. Ist es länger als zwölf Zeichen, verwirft KST4Contest den Spot stattdessen kontrolliert und protokolliert den Grund.
|
||||
|
||||
Bei automatisch erzeugten Richtungs-Spots kann KST4Contest bis zu zwei aktuelle AirScout-Einträge als zusätzliche AP-Information in den Kommentar aufnehmen. Fehlende AirScout-Daten verhindern den Spot nicht. Ein manuell über die Stationskarte ausgelöster Spot verwendet den Locator der ausgewählten Station ohne diese optionale Ergänzung.
|
||||
|
||||
Ein kompakter Kommentar mit AirScout-Information sieht beispielsweise so aus:
|
||||
|
||||
```text
|
||||
JO51HK AP 1m/100%;4m/75%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wenn kein Spot erscheint
|
||||
|
||||
@@ -84,13 +84,15 @@ KST4Contest wertet deshalb den Text jeder öffentlichen und gerichteten Chat-Nac
|
||||
|
||||
| Schreibweise | Beispiel | Verarbeitung |
|
||||
|---|---|---|
|
||||
| Vollständige Frequenz | `144.210`, `432,088`, `10368.100` | Das Band ergibt sich direkt aus der Frequenz. |
|
||||
| Vollständige Frequenz | `144.210`, `432,088`, `144307`, `10368100` | Das Band ergibt sich direkt aus der Frequenz. |
|
||||
| Relative Frequenz mit Punkt oder Komma | `.210`, `,088` | Das Band wird aus dem Stationskontext oder dem konfigurierten Fallback ergänzt. |
|
||||
| Dreistellige Frequenz mit Textkontext | `qrg 210`, `freq is 210`, `on 210`, `210 MHz` | Die Zahl wird als relative Frequenz behandelt. |
|
||||
| Dreistellige Zahl ohne Frequenzkontext | `210`, `599`, `144` | Die Zahl wird absichtlich nicht als QRG übernommen. |
|
||||
|
||||
Die letzte Einschränkung verhindert plausible, aber falsche Ergebnisse. Mit einem Fallback von `144 MHz` ließe sich ein Signalrapport `599` technisch problemlos zu `144.599 MHz` zusammensetzen. Das Ergebnis wäre formal gültig und fachlich trotzdem Unsinn.
|
||||
|
||||
Eine vollständige Frequenz kann auch ohne Punkt oder Komma geschrieben sein. KST4Contest behandelt dabei die letzten drei Ziffern als kHz-Anteil: `144307` im Namensfeld wird zu `144.307 MHz`, `10368100` in einer öffentlichen oder gerichteten Chatnachricht zu `10368.100 MHz`. Der Wert wird nur übernommen, wenn die daraus entstehende Frequenz innerhalb eines unterstützten Bandbereichs liegt.
|
||||
|
||||
### Wie wird das Band einer relativen QRG bestimmt?
|
||||
|
||||
KST4Contest verwendet folgende Reihenfolge:
|
||||
@@ -829,7 +831,11 @@ Die Bandangaben verwenden dieselbe Herleitung wie die Bandspalten, der Filter **
|
||||
|
||||
Treffen mehrere Zustände gleichzeitig zu, hat die für den Betrieb wichtigere Markierung Vorrang. Eine ausgewählte Station bleibt deshalb orange; eine Richtungsgelegenheit wird grün dargestellt, auch wenn das Rufzeichen bereits gearbeitet wurde.
|
||||
|
||||
Bei niedrigen Zoomstufen werden räumlich dicht beieinanderliegende Stationen zu einem Cluster zusammengefasst. Die Zahl im Cluster gibt die Anzahl der enthaltenen Stationen an. Ein Klick zoomt weiter hinein, wählt aber noch keine einzelne Station aus. Die aktuell ausgewählte Station und grün markierte Richtungsgelegenheiten bleiben auch bei niedriger Zoomstufe als einzelne Marker sichtbar.
|
||||
Mit der Checkbox **Group nearby stations** lässt sich die räumliche Gruppierung steuern. Ist sie ausgewählt, werden bei niedrigen Zoomstufen dicht beieinanderliegende Marker zu einem Bildschirm-Cluster zusammengefasst. Die Zahl im Cluster gibt die Anzahl der enthaltenen Stationen an. Ein Klick zoomt weiter hinein, wählt aber noch keine einzelne Station aus. Die aktuell ausgewählte Station und grün markierte Richtungsgelegenheiten bleiben auch bei niedriger Zoomstufe möglichst als einzelne Marker sichtbar.
|
||||
|
||||
Ist **Group nearby stations** nicht ausgewählt, erscheinen alle positionierbaren Stationen unabhängig von der Zoomstufe als einzelne Marker. Die Änderung wirkt sofort, ohne Stationsdaten neu zu laden oder Zoom, Kartenausschnitt und Auswahl zu verändern. KST4Contest speichert die Einstellung automatisch und stellt sie beim nächsten Start wieder her. Bestehende Installationen beginnen mit aktivierter Gruppierung und behalten damit zunächst das bisherige Verhalten.
|
||||
|
||||
Diese Bildschirm-Cluster sind nicht mit der Zusammenfassung von Chatvarianten zu verwechseln. Mehrere aktive Varianten desselben normalisierten Basisrufzeichens werden weiterhin zu einem geografischen Marker zusammengeführt. Das Ausschalten von **Group nearby stations** erzeugt daraus keine zusätzlichen Marker und verändert weder Chatidentitäten noch Filter oder Stationsdaten.
|
||||
|
||||
### Auswahl und geografische Hilfen
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist der zentrale Ausgangspunkt für alle Konfigurationen. Es empfiehlt sich, das Einstellungsfenster während des Betriebs geöffnet zu lassen (z. B. um den Beacon schnell ein- und auszuschalten).
|
||||
|
||||
> **Wichtig**: Nach jeder Änderung unbedingt **„Save Settings"** klicken! Die Einstellungen werden unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`) gespeichert. Ab v1.21 werden auch Fenstergrößen und Divider-Positionen beim Speichern gesichert.
|
||||
> **Wichtig**: Fachliche Einstellungen mit **„Save Settings"** sichern, wenn sie beim nächsten Start wieder gelten sollen. Layoutänderungen wie Fenstergrößen, Divider-Positionen und Tabellenbreiten speichert KST4Contest automatisch. Die gemeinsame Datei liegt unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`).
|
||||
|
||||
---
|
||||
|
||||
@@ -379,12 +379,14 @@ Folgende Einstellungen und Schaltflächen gehören zur lokalen DX-Cluster-Ausgab
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
||||
Comment: DXC test: You donated $100!
|
||||
Frequency: .300 des ausgewählten Fallback-Bandes
|
||||
```
|
||||
|
||||
Bei einem Fallback-Band von `144 MHz` wird daraus beispielsweise eine Frequenz von ungefähr `144.300 MHz`.
|
||||
|
||||
Alle Spots verwenden eine feste, DXSpider-kompatible 75-Zeichen-Nutzzeile mit einem 30 Zeichen breiten Kommentarfeld. Längere Kommentare werden an dieser Protokollgrenze kontrolliert gekürzt; das DX-Rufzeichen wird dagegen nicht abgeschnitten. Rufzeichen mit mehr als zwölf Zeichen führen dazu, dass der betreffende Spot verworfen und protokolliert wird.
|
||||
|
||||
Der Kommentar des Testspots ist ein bewusst beibehaltenes Easteregg. Er hat keine technische Bedeutung und löst – trotz seiner erfreulich konkreten Formulierung – keine Zahlung aus. Entscheidend ist, dass der Spot im verbundenen Logprogramm erscheint.
|
||||
|
||||
Der Test funktioniert nur, wenn
|
||||
@@ -884,8 +886,11 @@ Der Dark Mode wird über **Windows → Use dark mode design** aktiviert. Mit **W
|
||||
|
||||
## Einstellungen speichern
|
||||
|
||||
Nach **jeder** Änderung **„Save Settings"** klicken! Ohne Speichern gehen alle Änderungen beim nächsten Start verloren.
|
||||
**Save Settings** speichert die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie der Karteneinstellung **Group nearby stations** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben.
|
||||
|
||||
- Speicherort: unter Linux und macOS `~/.praktiKST/preferences.xml` und unter Windows `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`)
|
||||
- Ab v1.21: Fenstergrößen und Divider-Positionen werden ebenfalls gespeichert.
|
||||
- Der automatische Layout-Writer übernimmt keine noch nicht mit **Save Settings** bestätigten fachlichen Änderungen.
|
||||
- Die Konfigurationsversion 6 ergänzt optionale Spaltenbreiten unter `guiOptions`. Ältere `preferences.xml`-Dateien bleiben lesbar; fehlen Breiten oder sind Einträge ungültig, ermittelt KST4Contest wieder brauchbare Anfangsbreiten.
|
||||
- Die Konfigurationsversion 7 ergänzt `GUIstationMapClusteringEnabled` unter `guiOptions`. Fehlt der Eintrag oder ist sein Wert unbrauchbar, bleibt die räumliche Kartengruppierung aktiviert.
|
||||
- Ältere Programmversionen ignorieren die zusätzlichen XML-Einträge. Wenn eine ältere Version die Datei vollständig neu speichert, können die Spaltenbreiten und die gespeicherte Auswahl für **Group nearby stations** verloren gehen.
|
||||
- Bei Problemen: Konfigurationsdatei löschen → KST4Contest erstellt eine neue mit Standardwerten.
|
||||
|
||||
@@ -38,17 +38,21 @@ v1.42 brings several previously separate calculations together. Band information
|
||||
|
||||
- **Filter reset:** A dedicated reset button reliably removes the active user-list filter predicates.
|
||||
|
||||
- **Map clustering:** Stations close to each other are grouped at lower zoom levels. The selected station and relevant direction opportunities remain individually visible.
|
||||
- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The automatically stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
|
||||
|
||||
- **Hideable path analysis:** The terrain profile and analysis section of the station map can be hidden completely. The selected state is stored and restored at the next application start.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay; the main and monitor windows keep separate DXCluster and QSO-of-the-other layouts. Truncated normal cell values expose their full text in a tooltip without replacing functional tooltips or clickable links.
|
||||
|
||||
- **Unified DX Cluster line format:** Local spots now use a fixed, DXSpider-compatible 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The layout remains stable for microwave frequencies up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated; AirScout and test comments are correspondingly more compact.
|
||||
|
||||
- **Session-based ON4KST connection lifecycle:** Each socket, reader, writer, message bus and queue now belongs to an explicitly identified connection session. Delayed threads from an obsolete connection can therefore no longer process data or close its replacement. `ONLINE` is reported only after the login has been accepted and all requested user lists have been received. Connection setup, login and synchronisation use bounded timeouts, while heartbeats, missing inbound traffic, EOF and read or write failures trigger controlled reconnect attempts with backoff where appropriate.
|
||||
|
||||
- **Validated ON4KST protocol commands:** Outgoing frames are built centrally and checked for valid categories, locators and prohibited frame delimiters. Because ON4KST maintains one locator per TCP session, the main locator is used for both chat categories and a conflicting secondary configuration is logged instead of sending contradictory commands to the server.
|
||||
|
||||
- **More precise QRG recognition:** Complete and relative frequency references continue to be recognised. Bare three-digit numbers are treated as QRGs only when a frequency context is available. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
|
||||
- **More precise QRG recognition:** Complete frequencies are also recognised without a decimal separator, with the final three digits interpreted as the kHz part. Relative frequencies remain unchanged, and bare three-digit numbers still require recognisable frequency context. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
|
||||
|
||||
- **Station-specific frequency context:** For relative QRGs, KST4Contest first uses a band context for the same station which is no more than 30 minutes old. The globally configured fallback band is used only when this context is unavailable.
|
||||
|
||||
@@ -94,6 +98,8 @@ v1.42 brings several previously separate calculations together. Band information
|
||||
|
||||
- **Reliable initial user list:** Invalid or incomplete `UA0` member records are rejected and logged individually without preventing alphabetically following members from being processed. Valid entries are staged per category and published as one complete snapshot when the first corresponding `UE` end marker is received.
|
||||
|
||||
- **Persisted Worked state during initial-list setup:** At the end of each initial ON4KST user list, the SQLite state is loaded once and applied to the new chat members before publication. This covers both categories, reconnects and every active variant of a base callsign.
|
||||
|
||||
- **User list disappearing after login:** ON4KST may send additional `UE` frames for the same category after name, state or other live updates. Repeated end markers are now detected and ignored so that an already populated user list cannot be replaced by an empty snapshot.
|
||||
|
||||
- **Failed initial connection and lost sockets:** An unavailable server during startup no longer sends KST4Contest into an endless or busy-wait loop. The user interface remains responsive and further attempts use bounded reconnect backoff. Sockets closed by the server, or connections without inbound traffic for an excessive period, are also detected reliably.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
After the first start, the **settings window** opens – this is the central starting point for all configuration. It is recommended to keep the settings window open during operation (e.g. to quickly toggle the beacon on and off).
|
||||
|
||||
> **Important**: Always click **"Save Settings"** after any change! Settings are stored in `~/.praktiKST/preferences.xml` on Linux and macOS and in `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows. From v1.21 onwards, window sizes and divider positions are also saved when you click Save.
|
||||
> **Important**: Use **Save Settings** for functional settings which should remain in effect after the next start. KST4Contest saves layout changes such as window sizes, divider positions and table-column widths automatically. The shared file is `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows.
|
||||
|
||||
---
|
||||
|
||||
@@ -428,12 +428,14 @@ The following settings and controls belong to the local DX cluster output:
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
||||
Comment: DXC test: You donated $100!
|
||||
Frequency: .300 on the selected fallback band
|
||||
```
|
||||
|
||||
With `144 MHz` selected as the fallback band, the resulting frequency is approximately `144.300 MHz`.
|
||||
|
||||
All spots use a fixed, DXSpider-compatible 75-character payload line with a 30-character comment field. Longer comments are deliberately truncated at this protocol boundary; the DX callsign is not. A callsign longer than twelve characters causes the affected spot to be rejected and logged.
|
||||
|
||||
The comment is a deliberately retained Easter egg. It has no technical meaning and, despite being remarkably specific, does not initiate a payment. Its practical purpose is to make the test spot easy to identify in the logging software.
|
||||
|
||||
The test works only if
|
||||
@@ -941,8 +943,11 @@ Enable Dark Mode through **Windows → Use dark mode design**. Use **Windows →
|
||||
|
||||
## Saving Settings
|
||||
|
||||
Click **"Save Settings"** after **every** change! Without saving, all changes will be lost on the next start.
|
||||
**Save Settings** stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Group nearby stations** map setting are also saved automatically after a short delay. Any pending layout update is written when the programme exits.
|
||||
|
||||
- Storage location: `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows
|
||||
- From v1.21: Window sizes and divider positions are also saved.
|
||||
- The automatic layout writer does not copy functional changes which have not yet been confirmed with **Save Settings**.
|
||||
- Configuration version 6 adds optional column-width entries below `guiOptions`. Older `preferences.xml` files remain readable. Missing or invalid widths simply cause KST4Contest to calculate useful initial widths again.
|
||||
- Configuration version 7 adds `GUIstationMapClusteringEnabled` below `guiOptions`. If the entry is missing or unusable, spatial map clustering remains enabled.
|
||||
- Older programme versions ignore the additional XML entries. If an older version rewrites the complete file, column widths and the stored **Group nearby stations** choice may be lost.
|
||||
- If you encounter problems: delete the configuration file → KST4Contest will create a new one with default values.
|
||||
|
||||
@@ -163,7 +163,7 @@ The **Send test spot** button creates the following test entry:
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
||||
Comment: DXC test: You donated $100!
|
||||
Frequency: .300 on the configured fallback band
|
||||
```
|
||||
|
||||
@@ -191,12 +191,16 @@ A spot contains:
|
||||
- the locator; and
|
||||
- the current UTC time.
|
||||
|
||||
The payload line uses a fixed, DXSpider-compatible 75-character format. The DX callsign starts in column 27, the comment field is exactly 30 characters wide and the UTC time starts in column 71. Short comments are padded with spaces; longer ones are deliberately limited to 30 characters. Different spotter-callsign lengths and frequencies up to 24 GHz do not move the following fields.
|
||||
|
||||
KST4Contest does not truncate the complete DX callsign. If it exceeds twelve characters, the spot is rejected in a controlled manner and the reason is logged.
|
||||
|
||||
For automatically generated directional spots, KST4Contest can add up to two current AirScout entries to the comment. Missing AirScout data does not prevent the spot from being sent. A spot triggered manually from the station map uses the selected station's locator without this optional addition.
|
||||
|
||||
An automatic comment with AirScout information may look like this:
|
||||
|
||||
```text
|
||||
JN49GL , AP: 1min, 100%; 4min, 75%
|
||||
JO51HK AP 1m/100%;4m/75%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -84,13 +84,15 @@ KST4Contest therefore evaluates the text of every public and directed chat messa
|
||||
|
||||
| Notation | Example | Processing |
|
||||
|---|---|---|
|
||||
| Complete frequency | `144.210`, `432,088`, `10368.100` | The frequency determines the band directly. |
|
||||
| Complete frequency | `144.210`, `432,088`, `144307`, `10368100` | The frequency determines the band directly. |
|
||||
| Relative frequency with a dot or comma | `.210`, `,088` | The band is added from the station context or configured fallback. |
|
||||
| Three-digit frequency with text context | `qrg 210`, `freq is 210`, `on 210`, `210 MHz` | The number is treated as a relative frequency. |
|
||||
| Three-digit number without frequency context | `210`, `599`, `144` | The number is deliberately not accepted as a QRG. |
|
||||
|
||||
The final restriction prevents plausible-looking but incorrect results. With a fallback of `144 MHz`, a signal report of `599` could easily be turned into `144.599 MHz`. The result would be formally valid and operationally useless.
|
||||
|
||||
A complete frequency may also be written without a dot or comma. KST4Contest treats the final three digits as the kHz part: `144307` in a station name becomes `144.307 MHz`, while `10368100` in a public or directed chat message becomes `10368.100 MHz`. The value is accepted only if the resulting frequency falls within a supported band range.
|
||||
|
||||
### How Is the Band of a Relative QRG Determined?
|
||||
|
||||
KST4Contest uses the following order:
|
||||
@@ -823,7 +825,11 @@ Marker colours provide a compact status indication:
|
||||
|
||||
The selected state has the highest display priority, followed by the directional warning and Worked state. A selected station therefore remains orange even if it also meets one of the other conditions.
|
||||
|
||||
At lower zoom levels, nearby markers are combined into screen-based clusters. This is a display function and does not merge the underlying chat members. Selected stations and important directional candidates remain individually visible where possible.
|
||||
The **Group nearby stations** checkbox controls spatial grouping. When selected, nearby markers are combined into screen-based clusters at lower zoom levels. The number inside a cluster shows how many stations it contains. Clicking a cluster zooms in but does not select an individual station. Selected stations and important directional candidates remain individually visible where possible.
|
||||
|
||||
Clearing **Group nearby stations** displays every positionable station as an individual marker at every zoom level. The change takes effect immediately without reloading station data or changing the current zoom, viewport or selection. KST4Contest saves the setting automatically and restores it at the next start. Existing installations initially keep clustering enabled and therefore retain the previous behaviour.
|
||||
|
||||
Screen-based clustering is separate from base-callsign aggregation. Active variants of the same normalised base callsign continue to share one geographical marker where applicable. Disabling **Group nearby stations** does not split that marker and does not change chat identities, filters or station data.
|
||||
|
||||
Clicking a station marker selects the corresponding active chat member in the main window. KST4Contest scrolls to the entry in the user list, updates the **Further Info** panel and prepares the complete visible callsign as the message target. The chat suffix and category therefore remain relevant even though several variants may share one map marker.
|
||||
|
||||
|
||||
@@ -154,7 +154,15 @@ The value affects, among other things:
|
||||
|
||||
---
|
||||
|
||||
## Message Tables
|
||||
## Table Widths and Truncated Cell Values
|
||||
|
||||
When useful data first becomes available, KST4Contest sizes the columns of the user list, message views, DXCluster and QSO tables, and Worked database once from their headings and existing contents. Stored widths take precedence. The initial widths of **Name**, **AP** and **NOT QRV @** are capped so that one long value cannot displace the rest of the table. **Message** and similar free-text columns remain flexible and do not follow the longest message.
|
||||
|
||||
Manually changed column widths are saved automatically and restored at the next start. Later messages or station updates do not overwrite that choice.
|
||||
|
||||
If a normal text value does not fit in its cell, a tooltip shows the complete value. It appears only when the displayed text is actually truncated. Functional tooltips for QRA, Worked, band and similar states remain available. If such a cell is also truncated, the tooltip contains both the full value and the functional explanation.
|
||||
|
||||
### Message Text and Links
|
||||
|
||||
KST4Contest deliberately displays message text on a single line. This keeps a larger number of entries visible when chat activity is high. The disadvantage is obvious: if the **Message** column is narrow, not every message fits completely into its cell.
|
||||
|
||||
@@ -331,7 +339,11 @@ A single station marker can be selected directly. KST4Contest then:
|
||||
|
||||
Chat logins with the same normalised base callsign and position may share one marker. They nevertheless remain separate message targets inside KST4Contest.
|
||||
|
||||
Markers which are too close together at the current zoom level are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked.
|
||||
When **Group nearby stations** is selected, markers which are too close together at lower zoom levels are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked. Clearing the checkbox displays every positionable station as an individual marker at every zoom level.
|
||||
|
||||
The change takes effect immediately without changing the current zoom, viewport or station selection. It is saved automatically and restored at the next programme start. If no value has been stored yet, **Group nearby stations** remains selected so existing installations retain the previous behaviour.
|
||||
|
||||
This switch controls only the spatial clusters on screen. Active chat variants of the same normalised base callsign may still share one geographical marker and remain separate message targets regardless of this setting.
|
||||
|
||||
For a selected station, the header additionally shows:
|
||||
|
||||
@@ -436,7 +448,7 @@ KST4Contest additionally opens the **Cluster & QSO of the other** window. It sho
|
||||
|
||||

|
||||
|
||||
The vertical divider position and window size are stored together with the other UI settings. Use **Save Settings** after changing them.
|
||||
The vertical divider position and window size are saved automatically. The DXCluster and QSO tables use their own column widths in this window, so changing the monitor layout does not alter the corresponding main-window tabs.
|
||||
|
||||
The window can be hidden and restored through:
|
||||
|
||||
@@ -482,13 +494,13 @@ Functions which communicate with the server are available only after the ON4KST
|
||||
|
||||
## Window Sizes and Dividers
|
||||
|
||||
When **Save Settings** is clicked, KST4Contest stores the programme-window sizes and the positions of the relevant dividers in the configuration file. These values are reused at the next start.
|
||||
KST4Contest automatically stores programme-window sizes and positions, relevant dividers and manually changed table-column widths in the configuration file after a short delay. Any pending layout update is written when the programme exits. **Save Settings** is not required for these changes, but still stores the complete current state, including the layout.
|
||||
|
||||
The main window is additionally checked against the visible area of the primary screen during startup. If the stored size is too large, KST4Contest reduces and moves the window so that it remains accessible. The complete process is described under [Screen-Aware Main Window Sizing](en-Features#screen-aware-main-window-sizing-from-v141).
|
||||
|
||||
The other programme windows do not currently use this additional size restriction. If, for example, the separate monitor window appears too large after moving to a smaller screen, its size must be corrected manually and stored again using **Save Settings**.
|
||||
The other programme windows do not currently use this additional size restriction. If, for example, the separate monitor window appears too large after moving to a smaller screen, correcting it manually is sufficient; the new size is saved automatically.
|
||||
|
||||
If the layout has become inconvenient, first move the dividers back to usable positions and save the settings again. Deleting the configuration file also resets the UI values, but it removes the other stored programme settings as well. It should therefore be used only when the interface cannot be restored in another way.
|
||||
If the layout has become inconvenient, first move the dividers and column widths back to usable positions. Deleting the configuration file also resets the UI values, but it removes the other stored programme settings as well. It should therefore be used only when the interface cannot be restored in another way.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -20,7 +20,7 @@ public class ApplicationConstants {
|
||||
/**
|
||||
* Version shown to the user and used for semantic version comparison.
|
||||
*/
|
||||
public static final String APPLICATION_CURRENT_VERSION = "1.42";
|
||||
public static final String APPLICATION_CURRENT_VERSION = "1.43";
|
||||
|
||||
/**
|
||||
* Legacy numeric representation used only while older update feeds and
|
||||
|
||||
@@ -1848,7 +1848,7 @@ private ObservableList<String>
|
||||
* assumption which one is the current run frequency. Therefore the legacy
|
||||
* frequency property is initialized only when exactly one explicit QRG exists.</p>
|
||||
*/
|
||||
private void initializeFrequencyFromStationNameIfUnambiguous(
|
||||
/* package */ void initializeFrequencyFromStationNameIfUnambiguous(
|
||||
ChatMember member
|
||||
) {
|
||||
if (member == null) {
|
||||
@@ -1942,6 +1942,8 @@ private ObservableList<String>
|
||||
int categoryNumber = category.getCategoryNumber();
|
||||
List<ChatMember> safeMembers = completeMembers == null
|
||||
? List.of() : new ArrayList<>(completeMembers);
|
||||
Map<String, ChatMember> workedDataFromDatabase =
|
||||
loadWorkedStateForInitialUserList(safeMembers);
|
||||
for (ChatMember member : safeMembers) {
|
||||
initializeFrequencyFromStationNameIfUnambiguous(member);
|
||||
}
|
||||
@@ -1966,10 +1968,48 @@ private ObservableList<String>
|
||||
&& member.getChatCategory() != null
|
||||
&& member.getChatCategory().getCategoryNumber() == categoryNumber);
|
||||
lst_chatMemberList.addAll(safeMembers);
|
||||
if (workedDataFromDatabase != null) {
|
||||
getLst_DBBasedWkdCallSignList().setAll(
|
||||
workedDataFromDatabase.values());
|
||||
}
|
||||
fireUserListUpdate("Complete ON4KST user list received");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one database snapshot for a completed initial ON4KST user list and
|
||||
* applies it before those members are published to the active model or UI.
|
||||
* Every callsign variant receives the state stored for its normalized base
|
||||
* callsign.
|
||||
*
|
||||
* @param initialMembers completed members of one chat category
|
||||
* @return loaded snapshot, or {@code null} when the database read failed
|
||||
*/
|
||||
/* package */ Map<String, ChatMember> loadWorkedStateForInitialUserList(
|
||||
Collection<ChatMember> initialMembers
|
||||
) {
|
||||
if (dbHandler == null) {
|
||||
LOGGER.warning(
|
||||
"Cannot load Worked state for initial ON4KST user list: "
|
||||
+ "database is not initialized");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, ChatMember> workedDataFromDatabase =
|
||||
dbHandler.fetchChatMemberWkdDataFromDB();
|
||||
applyWorkedAndQrvStateFromDatabase(
|
||||
initialMembers, workedDataFromDatabase);
|
||||
return workedDataFromDatabase;
|
||||
} catch (SQLException | RuntimeException exception) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"Could not load Worked state for completed initial ON4KST user list",
|
||||
exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a member from the thread-safe active model. This avoids reading the
|
||||
* TableView backing list from MessageBusManagementThread.
|
||||
@@ -3854,15 +3894,6 @@ private ObservableList<String>
|
||||
|
||||
}
|
||||
|
||||
new Timer().schedule(new TimerTask() {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("fetchWorkedFromDBTimer");
|
||||
refreshWorkedStateAndDatabaseListFromDatabase();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// new Timer().schedule(new TimerTask() {
|
||||
// HashMap<String, ChatMember> getWorkedDataFromDb;
|
||||
//
|
||||
@@ -3966,23 +3997,34 @@ private ObservableList<String>
|
||||
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
|
||||
|
||||
Platform.runLater(() -> {
|
||||
helper_applyWorkedAndQrvStateFromDatabase(finalWorkedDataFromDatabase);
|
||||
applyWorkedAndQrvStateFromDatabase(
|
||||
activeChatMembersByCallAndCategory.values(),
|
||||
finalWorkedDataFromDatabase);
|
||||
getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values());
|
||||
fireUserListUpdate("Worked database state refreshed");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the worked and not-QRV state from the database snapshot to all active
|
||||
* chatmember objects that are currently visible in the live chat list.
|
||||
* Applies the worked and not-QRV state from a database snapshot to chat members.
|
||||
* Database rows are keyed by normalized base callsign, so all active category
|
||||
* and suffix variants receive the same persisted state.
|
||||
*
|
||||
* @param chatMembers members that should receive persisted state
|
||||
* @param workedDataFromDatabase map keyed by normalized raw callsign
|
||||
*/
|
||||
private void helper_applyWorkedAndQrvStateFromDatabase(HashMap<String, ChatMember> workedDataFromDatabase) {
|
||||
/* package */ static void applyWorkedAndQrvStateFromDatabase(
|
||||
Collection<ChatMember> chatMembers,
|
||||
Map<String, ChatMember> workedDataFromDatabase
|
||||
) {
|
||||
if (chatMembers == null || workedDataFromDatabase == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Iterator iterator = getLst_chatMemberList().iterator(); iterator.hasNext();) {
|
||||
|
||||
ChatMember activeChatMember = (ChatMember) iterator.next();
|
||||
for (ChatMember activeChatMember : chatMembers) {
|
||||
if (activeChatMember == null) {
|
||||
continue;
|
||||
}
|
||||
ChatMember storedChatMemberState = workedDataFromDatabase.get(activeChatMember.getCallSignRaw());
|
||||
|
||||
if (storedChatMemberState == null) {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Formats local DX Cluster spots using the fixed-column layout emitted by
|
||||
* DXSpider and accepted by common logging programs.
|
||||
*/
|
||||
final class DXClusterSpotFormatter {
|
||||
|
||||
/** Length of the DX Cluster line before BEL and CRLF framing. */
|
||||
/* package */
|
||||
static final int LINE_LENGTH = 75;
|
||||
/** One-based column in which the spotted callsign starts. */
|
||||
/* package */
|
||||
static final int DX_CALL_COLUMN = 27;
|
||||
/** Width of the fixed comment field. */
|
||||
/* package */
|
||||
static final int COMMENT_LENGTH = 30;
|
||||
/** One-based column in which the UTC time starts. */
|
||||
/* package */
|
||||
static final int TIME_COLUMN = 71;
|
||||
|
||||
/** Zero-based exclusive end position of the frequency field. */
|
||||
private static final int FREQUENCY_END = 24;
|
||||
/** Maximum width of the spotted callsign field. */
|
||||
private static final int DX_CALL_LENGTH = 12;
|
||||
/** Required width of the HHMMZ time field. */
|
||||
private static final int TIME_LENGTH = 5;
|
||||
/** Minimum separator width between spotter and frequency. */
|
||||
private static final int MIN_FREQUENCY_GAP = 1;
|
||||
/** Wire framing appended to every formatted line. */
|
||||
private static final String PAYLOAD_SUFFIX = "\u0007\u0007\r\n";
|
||||
|
||||
private DXClusterSpotFormatter() {
|
||||
}
|
||||
|
||||
/** Builds the fixed 75-character payload line without wire framing. */
|
||||
/* package */
|
||||
static String formatLine(
|
||||
final String spotterCallSign,
|
||||
final String frequency,
|
||||
final String dxCallSign,
|
||||
final String comment,
|
||||
final String time
|
||||
) {
|
||||
final String spotter = requireValue(
|
||||
spotterCallSign,
|
||||
"spotter callsign"
|
||||
)
|
||||
.toUpperCase(Locale.ROOT);
|
||||
final String frequencyValue = requireValue(frequency, "frequency");
|
||||
final String dxCall = requireValue(dxCallSign, "DX callsign")
|
||||
.toUpperCase(Locale.ROOT);
|
||||
final String timeValue = requireValue(time, "time");
|
||||
|
||||
validateDxCall(dxCall);
|
||||
validateTime(timeValue);
|
||||
|
||||
final String prefix = "DX de " + spotter + ":";
|
||||
final int frequencyPadding = calculateFrequencyPadding(
|
||||
prefix,
|
||||
frequencyValue
|
||||
);
|
||||
final String normalizedComment = normalizeComment(comment);
|
||||
|
||||
final String line = prefix
|
||||
+ " ".repeat(frequencyPadding)
|
||||
+ frequencyValue
|
||||
+ " "
|
||||
+ padRight(dxCall, DX_CALL_LENGTH)
|
||||
+ " "
|
||||
+ padRight(normalizedComment, COMMENT_LENGTH)
|
||||
+ " "
|
||||
+ timeValue;
|
||||
|
||||
if (line.length() != LINE_LENGTH) {
|
||||
throw new IllegalStateException(
|
||||
"DX Cluster formatter produced "
|
||||
+ line.length()
|
||||
+ " characters instead of "
|
||||
+ LINE_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
/** Builds one complete ASCII spot payload including BEL and CRLF framing. */
|
||||
/* package */
|
||||
static byte[] formatPayload(
|
||||
final String spotterCallSign,
|
||||
final String frequency,
|
||||
final String dxCallSign,
|
||||
final String comment,
|
||||
final String time
|
||||
) {
|
||||
return (formatLine(
|
||||
spotterCallSign,
|
||||
frequency,
|
||||
dxCallSign,
|
||||
comment,
|
||||
time
|
||||
) + PAYLOAD_SUFFIX).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static void validateDxCall(final String dxCall) {
|
||||
if (dxCall.length() > DX_CALL_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"DX callsign exceeds 12 characters: " + dxCall
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateTime(final String time) {
|
||||
if (time.length() != TIME_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"DX Cluster time must contain exactly five characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static int calculateFrequencyPadding(
|
||||
final String prefix,
|
||||
final String frequency
|
||||
) {
|
||||
final int padding = FREQUENCY_END
|
||||
- prefix.length()
|
||||
- frequency.length();
|
||||
|
||||
if (padding < MIN_FREQUENCY_GAP) {
|
||||
throw new IllegalArgumentException(
|
||||
"Spotter callsign and frequency do not fit the DX Cluster prefix"
|
||||
);
|
||||
}
|
||||
|
||||
return padding;
|
||||
}
|
||||
|
||||
private static String normalizeComment(final String comment) {
|
||||
final String normalized = comment == null ? "" : comment.trim();
|
||||
|
||||
return normalized.length() > COMMENT_LENGTH
|
||||
? normalized.substring(0, COMMENT_LENGTH)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static String requireValue(
|
||||
final String value,
|
||||
final String fieldName
|
||||
) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"DX Cluster " + fieldName + " is missing"
|
||||
);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String padRight(final String value, final int length) {
|
||||
return value + " ".repeat(length - value.length());
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,7 @@ public class DXClusterThreadPooledServer implements Runnable {
|
||||
public boolean broadcastSingleDXClusterEntryToLoggers(
|
||||
ChatMember chatMember
|
||||
) {
|
||||
final byte[] clusterPayload;
|
||||
final String clusterMessage;
|
||||
|
||||
try {
|
||||
@@ -162,24 +163,27 @@ public class DXClusterThreadPooledServer implements Runnable {
|
||||
.getNotify_optionalFrequencyPrefix()
|
||||
);
|
||||
|
||||
clusterMessage =
|
||||
"DX de "
|
||||
+ chatController
|
||||
clusterPayload = DXClusterSpotFormatter.formatPayload(
|
||||
chatController
|
||||
.getChatPreferences()
|
||||
.getNotify_DXCSrv_SpottersCallSign()
|
||||
.getValue()
|
||||
+ ": "
|
||||
+ frequency
|
||||
+ " "
|
||||
+ chatMember.getCallSign().toUpperCase()
|
||||
+ " "
|
||||
+ chatMember.getQra().toUpperCase()
|
||||
+ " "
|
||||
+ new Utils4KST()
|
||||
.getValue(),
|
||||
frequency,
|
||||
chatMember.getCallSign(),
|
||||
chatMember.getQra(),
|
||||
new Utils4KST()
|
||||
.time_generateCurrenthhmmZTimeStringForClusterMessage()
|
||||
+ ((char) 7)
|
||||
+ ((char) 7)
|
||||
+ "\r\n";
|
||||
);
|
||||
clusterMessage = new String(
|
||||
clusterPayload,
|
||||
StandardCharsets.US_ASCII
|
||||
);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"DX Cluster spot rejected: " + exception.getMessage()
|
||||
);
|
||||
return false;
|
||||
} catch (Exception exception) {
|
||||
LOGGER.log(
|
||||
Level.SEVERE,
|
||||
@@ -204,11 +208,7 @@ public class DXClusterThreadPooledServer implements Runnable {
|
||||
|
||||
try {
|
||||
OutputStream output = socket.getOutputStream();
|
||||
output.write(
|
||||
clusterMessage.getBytes(
|
||||
StandardCharsets.US_ASCII
|
||||
)
|
||||
);
|
||||
output.write(clusterPayload);
|
||||
output.flush();
|
||||
deliveredClients++;
|
||||
} catch (IOException exception) {
|
||||
@@ -360,4 +360,4 @@ class DXClusterServerWorkerRunnable implements Runnable {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
|
||||
public class DXClusterThreadPooledServerTest {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
ChatController client = new ChatController();
|
||||
ChatPreferences testPreferences = new ChatPreferences();
|
||||
testPreferences.setStn_loginCallSign("DM5M");
|
||||
|
||||
client.setChatPreferences(testPreferences);
|
||||
DXClusterThreadPooledServer dxClusterServer = new DXClusterThreadPooledServer(8000, client, client);
|
||||
|
||||
new Thread(dxClusterServer).start();
|
||||
|
||||
|
||||
try {
|
||||
Thread.sleep(10 * 1000);
|
||||
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>ready.....go!");
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
ChatMember test = new ChatMember();
|
||||
test.setCallSign("DL5ASG");
|
||||
test.setQra("JO51HK");
|
||||
test.setFrequency(new SimpleStringProperty("144776.0"));
|
||||
|
||||
dxClusterServer.broadcastSingleDXClusterEntryToLoggers(test);
|
||||
|
||||
|
||||
// try {
|
||||
// Thread.sleep(20 * 3333);
|
||||
// } catch (InterruptedException e) {
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
// System.out.println("Stopping Server");
|
||||
// server.stop();
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
/*
|
||||
* Frequency formats handled by the smart parser:
|
||||
*
|
||||
* Group 1: full frequencies, for example 144.210 or 10368.100
|
||||
* Group 1: full frequencies, for example 144.210, 144210 or 10368100
|
||||
* Group 2: relative frequencies with a separator, for example .210 or ,210
|
||||
* Group 3: bare three-digit values, for example 210
|
||||
*
|
||||
@@ -66,7 +66,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
* would be converted into plausible but incorrect frequencies.
|
||||
*/
|
||||
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
|
||||
"(?<![\\d])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
||||
"(?<![A-Z0-9])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})(?![A-Z0-9])"
|
||||
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
||||
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
|
||||
);
|
||||
@@ -279,7 +279,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
* @param message message whose text is inspected
|
||||
* @param prefs preferences containing the global fallback band
|
||||
*/
|
||||
private void smartFrequencyExtraction(ChatMessage message, ChatPreferences prefs) {
|
||||
/* package */ void smartFrequencyExtraction(ChatMessage message, ChatPreferences prefs) {
|
||||
if (message == null || message.getMessageText() == null) {
|
||||
return;
|
||||
}
|
||||
@@ -1847,14 +1847,14 @@ public class MessageBusManagementThread extends Thread {
|
||||
* @param sender station for which the DX Cluster spot is generated
|
||||
* @return locator with up to two optional AP entries
|
||||
*/
|
||||
private String buildDxClusterSpotComment(ChatMember sender) {
|
||||
static String buildDxClusterSpotComment(ChatMember sender) {
|
||||
if (sender == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String locator = sender.getQra() == null
|
||||
? ""
|
||||
: sender.getQra().trim();
|
||||
: sender.getQra().trim().toUpperCase(Locale.ROOT);
|
||||
|
||||
AirPlaneReflectionInfo reflectionInfo =
|
||||
sender.getAirPlaneReflectInfo();
|
||||
@@ -1882,7 +1882,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
|
||||
aircraftComments.add(
|
||||
aircraft.getArrivingDurationMinutes()
|
||||
+ "min, "
|
||||
+ "m/"
|
||||
+ aircraft.getPotential()
|
||||
+ "%"
|
||||
);
|
||||
@@ -1893,11 +1893,11 @@ public class MessageBusManagementThread extends Thread {
|
||||
}
|
||||
|
||||
String apComment =
|
||||
"AP: " + String.join("; ", aircraftComments);
|
||||
"AP " + String.join(";", aircraftComments);
|
||||
|
||||
return locator.isEmpty()
|
||||
? apComment
|
||||
: locator + " , " + apComment;
|
||||
: locator + " " + apComment;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,9 +13,10 @@ import java.util.regex.Pattern;
|
||||
* Common parser for explicit amateur-radio frequencies embedded in text.
|
||||
*
|
||||
* <p>This parser deliberately handles only complete frequencies such as
|
||||
* 144.300, 432.357 or 10368.100. Relative forms such as ".210" or ambiguous
|
||||
* bare values such as "210" require additional message context and remain the
|
||||
* responsibility of the chat-message parser.</p>
|
||||
* 144.300, 432.357, 10368.100 or their compact digit-only forms. Relative
|
||||
* forms such as ".210" or ambiguous bare values such as "210" require
|
||||
* additional message context and remain the responsibility of the
|
||||
* chat-message parser.</p>
|
||||
*/
|
||||
public final class FrequencyTextParser {
|
||||
|
||||
@@ -26,14 +27,18 @@ public final class FrequencyTextParser {
|
||||
* 432,357
|
||||
* 10368.100
|
||||
* 144.300.03
|
||||
* 144300
|
||||
* 10368100
|
||||
*
|
||||
* At least two digits are required before the decimal separator. This
|
||||
* intentionally prevents "1.2" from being interpreted as a frequency.
|
||||
* At least two digits are required before a decimal separator. Compact
|
||||
* values need at least five digits because their final three digits form
|
||||
* the kHz part. This intentionally prevents "1.2" and bare values such as
|
||||
* "210" from being interpreted as complete frequencies.
|
||||
*/
|
||||
private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile(
|
||||
"(?<![A-Z0-9])"
|
||||
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)"
|
||||
+ "(?!\\d)",
|
||||
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})"
|
||||
+ "(?![A-Z0-9])",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
@@ -93,12 +98,12 @@ public final class FrequencyTextParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalized =
|
||||
normalizeFrequencyString(
|
||||
rawFrequency
|
||||
.trim()
|
||||
.replace(',', '.')
|
||||
);
|
||||
final String trimmedFrequency = rawFrequency.trim();
|
||||
final String normalized = trimmedFrequency.matches("\\d{5,8}")
|
||||
? trimmedFrequency.substring(0, trimmedFrequency.length() - 3)
|
||||
+ "."
|
||||
+ trimmedFrequency.substring(trimmedFrequency.length() - 3)
|
||||
: normalizeFrequencyString(trimmedFrequency.replace(',', '.'));
|
||||
|
||||
try {
|
||||
double frequencyMHz =
|
||||
@@ -188,4 +193,4 @@ public final class FrequencyTextParser {
|
||||
return sourceText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = 7;
|
||||
|
||||
// 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,12 @@ public class ChatPreferences {
|
||||
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
||||
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
||||
private boolean GUIstationMapPathAnalysisVisible = true;
|
||||
private boolean GUIstationMapClusteringEnabled = 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 +656,39 @@ public class ChatPreferences {
|
||||
this.GUIstationMapPathAnalysisVisible = GUIstationMapPathAnalysisVisible;
|
||||
}
|
||||
|
||||
public boolean isGUIstationMapClusteringEnabled() {
|
||||
return GUIstationMapClusteringEnabled;
|
||||
}
|
||||
|
||||
public void setGUIstationMapClusteringEnabled(boolean GUIstationMapClusteringEnabled) {
|
||||
this.GUIstationMapClusteringEnabled = GUIstationMapClusteringEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +1433,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 +2129,25 @@ public class ChatPreferences {
|
||||
);
|
||||
guiOptions.appendChild(GUIstationMapPathAnalysisVisible);
|
||||
|
||||
Element GUIstationMapClusteringEnabled = doc.createElement("GUIstationMapClusteringEnabled");
|
||||
GUIstationMapClusteringEnabled.setTextContent(
|
||||
String.valueOf(this.isGUIstationMapClusteringEnabled())
|
||||
);
|
||||
guiOptions.appendChild(GUIstationMapClusteringEnabled);
|
||||
|
||||
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 +2159,113 @@ 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]);
|
||||
upsertDirectChildText(document, guiOptions, "GUIstationMapClusteringEnabled",
|
||||
String.valueOf(isGUIstationMapClusteringEnabled()));
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -2824,6 +2973,7 @@ public class ChatPreferences {
|
||||
* case read GUI options
|
||||
*
|
||||
***********************************************/
|
||||
this.setGUIstationMapClusteringEnabled(true);
|
||||
list = doc.getElementsByTagName("guiOptions");
|
||||
if (list.getLength() != 0) {
|
||||
|
||||
@@ -2862,6 +3012,17 @@ public class ChatPreferences {
|
||||
"GUIstationMapPathAnalysisVisible"
|
||||
));
|
||||
|
||||
/*
|
||||
* Files written before config version 7 do not contain this value.
|
||||
* Missing or malformed values keep clustering enabled so existing
|
||||
* installations retain the established map behaviour.
|
||||
*/
|
||||
this.setGUIstationMapClusteringEnabled(getBooleanOrDefault(
|
||||
element,
|
||||
true,
|
||||
"GUIstationMapClusteringEnabled"
|
||||
));
|
||||
|
||||
// Splitpane divider positions
|
||||
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
||||
if (s1 != null) {
|
||||
@@ -2920,6 +3081,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 +3277,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.
|
||||
@@ -3139,6 +3373,20 @@ public class ChatPreferences {
|
||||
return "true".equalsIgnoreCase(v) || "1".equals(v) || "yes".equalsIgnoreCase(v);
|
||||
}
|
||||
|
||||
private static boolean getBooleanOrDefault(Element parent, boolean defaultValue, String... tagNames) {
|
||||
String value = getText(parent, null, tagNames);
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if ("true".equalsIgnoreCase(value) || "1".equals(value) || "yes".equalsIgnoreCase(value)) {
|
||||
return true;
|
||||
}
|
||||
if ("false".equalsIgnoreCase(value) || "0".equals(value) || "no".equalsIgnoreCase(value)) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static int getInt(Element parent, int defaultValue, String... tagNames) {
|
||||
String v = getText(parent, null, tagNames);
|
||||
if (v == null) {
|
||||
|
||||
@@ -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) {
|
||||
@@ -10736,7 +10894,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
testSpot.setFrequency(
|
||||
new SimpleStringProperty("300")
|
||||
);
|
||||
testSpot.setQra("Testing DXC-Spot: Congrats, you donated $100!");
|
||||
testSpot.setQra("DXC test: You donated $100!");
|
||||
testSpot.setCallSign("DO5AMF");
|
||||
|
||||
if (!dxClusterServer
|
||||
@@ -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,280 @@
|
||||
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 = 16.0;
|
||||
private static final double DEFAULT_MINIMUM_WIDTH = 24.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 calculateInitialContentWidth(
|
||||
requiredWidth,
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a compact content width. Package-private for focused sizing tests.
|
||||
*/
|
||||
@SuppressWarnings("PMD.CommentDefaultAccessModifier")
|
||||
static double calculateInitialContentWidth(
|
||||
final double measuredWidth,
|
||||
final double minimum,
|
||||
final double maximum
|
||||
) {
|
||||
return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, 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;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.nio.charset.StandardCharsets;
|
||||
* - grid / beam / connection use non-interactive panes
|
||||
* - JavaScript errors are forwarded to Java through javaMapBridge
|
||||
* - setTheme(light|dark) aligns the map with the JavaFX application theme
|
||||
* - setStationClusteringEnabled(boolean) re-renders the existing station data
|
||||
*
|
||||
* Important:
|
||||
* This version intentionally uses integer Leaflet zoom levels again.
|
||||
@@ -356,6 +357,7 @@ public final class MapHtmlResources {
|
||||
*/
|
||||
let stationData = [];
|
||||
let stationsByCallsignRaw = {};
|
||||
let stationClusteringEnabled = true;
|
||||
|
||||
let clustersById = {};
|
||||
let clusterSequence = 0;
|
||||
@@ -810,13 +812,19 @@ public final class MapHtmlResources {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
|
||||
if (!stationClusteringEnabled
|
||||
|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
|
||||
renderAllStationsIndividually();
|
||||
} else {
|
||||
renderClusteredStations();
|
||||
}
|
||||
}
|
||||
|
||||
function setStationClusteringEnabled(enabled) {
|
||||
stationClusteringEnabled = Boolean(enabled);
|
||||
renderStationMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zooms into a cluster.
|
||||
*
|
||||
@@ -1271,6 +1279,7 @@ public final class MapHtmlResources {
|
||||
getViewportState: getViewportState,
|
||||
setHome: setHome,
|
||||
setStations: setStations,
|
||||
setStationClusteringEnabled: setStationClusteringEnabled,
|
||||
setBeam: setBeam,
|
||||
setConnection: setConnection,
|
||||
setProfileHoverPoint: setProfileHoverPoint,
|
||||
@@ -1284,4 +1293,4 @@ public final class MapHtmlResources {
|
||||
</html>
|
||||
""".replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -89,6 +90,9 @@ public final class StationMapView {
|
||||
|
||||
private final Button resetViewButton = new Button("Reset view");
|
||||
private final Tooltip statusTooltip = new Tooltip();
|
||||
private final CheckBox stationClusteringCheckBox = new CheckBox("Group nearby stations");
|
||||
private final Tooltip stationClusteringTooltip = new Tooltip(
|
||||
"Group nearby stations into clusters at lower zoom levels.");
|
||||
|
||||
private Runnable onResetView;
|
||||
|
||||
@@ -180,7 +184,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 {
|
||||
@@ -327,6 +336,21 @@ public final class StationMapView {
|
||||
}
|
||||
});
|
||||
|
||||
stationClusteringCheckBox.setMinWidth(Region.USE_PREF_SIZE);
|
||||
stationClusteringCheckBox.setTooltip(stationClusteringTooltip);
|
||||
stationClusteringCheckBox.setAccessibleText("Group nearby stations");
|
||||
stationClusteringCheckBox.setAccessibleHelp(stationClusteringTooltip.getText());
|
||||
stationClusteringCheckBox.setSelected(chatPreferences.isGUIstationMapClusteringEnabled());
|
||||
stationClusteringCheckBox.selectedProperty().addListener((obs, oldValue, newValue) -> {
|
||||
boolean enabled = newValue;
|
||||
chatPreferences.setGUIstationMapClusteringEnabled(enabled);
|
||||
if (mapReady) {
|
||||
executeMapScriptSafely(
|
||||
"window.kstMapApi.setStationClusteringEnabled(" + enabled + ");");
|
||||
}
|
||||
layoutSaveRequester.run();
|
||||
});
|
||||
|
||||
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
|
||||
pathAnalysisVisibilityButton.setOnAction(event ->
|
||||
@@ -461,17 +485,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();
|
||||
@@ -521,6 +553,7 @@ public final class StationMapView {
|
||||
statusLabel,
|
||||
triggerClusterSpotButton,
|
||||
resetViewButton,
|
||||
stationClusteringCheckBox,
|
||||
pathAnalysisHiddenHintLabel,
|
||||
pathAnalysisVisibilityButton
|
||||
);
|
||||
@@ -794,6 +827,9 @@ public final class StationMapView {
|
||||
window.setMember("javaMapBridge", javaMapBridge);
|
||||
|
||||
executeMapScriptSafely("window.kstMapApi.init();");
|
||||
executeMapScriptSafely(
|
||||
"window.kstMapApi.setStationClusteringEnabled("
|
||||
+ chatPreferences.isGUIstationMapClusteringEnabled() + ");");
|
||||
|
||||
mapReady = true;
|
||||
applyMapThemeToWebView(chatPreferences.isGUI_darkModeActive());
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMember;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ChatControllerInitialWorkedStateTest {
|
||||
|
||||
@Test
|
||||
void loadsEachCompletedInitialListOnceAndAppliesStateToEveryVariant()
|
||||
throws SQLException {
|
||||
DBController database = mock(DBController.class);
|
||||
ChatMember stored = member("9A0BB", 2);
|
||||
stored.setWorked(true);
|
||||
stored.setWorked144(true);
|
||||
stored.setWorked10G(true);
|
||||
stored.setQrv432(false);
|
||||
|
||||
HashMap<String, ChatMember> databaseSnapshot = new HashMap<>();
|
||||
databaseSnapshot.put(stored.getCallSignRaw(), stored);
|
||||
when(database.fetchChatMemberWkdDataFromDB())
|
||||
.thenReturn(databaseSnapshot);
|
||||
|
||||
ChatController controller = new ChatController();
|
||||
controller.setDbHandler(database);
|
||||
|
||||
ChatMember mainVariant = member("9A0BB-2", 2);
|
||||
ChatMember secondVariant = member("9A0BB-70", 3);
|
||||
ChatMember reconnectMainVariant = member("9A0BB-144", 2);
|
||||
ChatMember reconnectSecondVariant = member("9A0BB-432", 3);
|
||||
|
||||
controller.loadWorkedStateForInitialUserList(List.of(mainVariant));
|
||||
controller.loadWorkedStateForInitialUserList(List.of(secondVariant));
|
||||
controller.loadWorkedStateForInitialUserList(
|
||||
List.of(reconnectMainVariant));
|
||||
controller.loadWorkedStateForInitialUserList(
|
||||
List.of(reconnectSecondVariant));
|
||||
|
||||
verify(database, times(4)).fetchChatMemberWkdDataFromDB();
|
||||
for (ChatMember variant : List.of(
|
||||
mainVariant,
|
||||
secondVariant,
|
||||
reconnectMainVariant,
|
||||
reconnectSecondVariant
|
||||
)) {
|
||||
assertTrue(variant.isWorked());
|
||||
assertTrue(variant.isWorked144());
|
||||
assertTrue(variant.isWorked10G());
|
||||
assertFalse(variant.isQrv432());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAmbiguousStationNameFromReplacingCompatibilityFrequency() {
|
||||
ChatController controller = new ChatController();
|
||||
ChatMember member = member("DL1ABC", 2);
|
||||
member.setName("144307 and 432100");
|
||||
|
||||
controller.initializeFrequencyFromStationNameIfUnambiguous(member);
|
||||
|
||||
assertTrue(
|
||||
member.getFrequency() == null
|
||||
|| member.getFrequency().get() == null
|
||||
|| member.getFrequency().get().isBlank()
|
||||
);
|
||||
}
|
||||
|
||||
private static ChatMember member(String callSign, int categoryNumber) {
|
||||
ChatMember member = new ChatMember();
|
||||
member.setCallSign(callSign);
|
||||
member.setChatCategory(new ChatCategory(categoryNumber));
|
||||
return member;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
class DXClusterSpotFormatterTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("supportedFrequencies")
|
||||
void keepsFixedColumnsAcrossSupportedFrequencies(
|
||||
String spotter,
|
||||
String frequency
|
||||
) {
|
||||
String line = DXClusterSpotFormatter.formatLine(
|
||||
spotter,
|
||||
frequency,
|
||||
"DL5ASG",
|
||||
"JO51HK",
|
||||
"1234Z"
|
||||
);
|
||||
|
||||
assertEquals(DXClusterSpotFormatter.LINE_LENGTH, line.length());
|
||||
assertEquals(
|
||||
"DL5ASG",
|
||||
line.substring(
|
||||
DXClusterSpotFormatter.DX_CALL_COLUMN - 1,
|
||||
DXClusterSpotFormatter.DX_CALL_COLUMN - 1 + 6
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
"JO51HK",
|
||||
line.substring(39, 45)
|
||||
);
|
||||
assertEquals(
|
||||
"1234Z",
|
||||
line.substring(DXClusterSpotFormatter.TIME_COLUMN - 1)
|
||||
);
|
||||
assertEquals(
|
||||
frequency,
|
||||
line.substring(0, 24).trim().replaceFirst("^DX de .+?:\\s*", "")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void padsShortCommentsAndTruncatesLongCommentsToThirtyCharacters() {
|
||||
String shortLine = DXClusterSpotFormatter.formatLine(
|
||||
"DM5M",
|
||||
"144205.0",
|
||||
"DL5ASG",
|
||||
"JO51HK",
|
||||
"1234Z"
|
||||
);
|
||||
String longLine = DXClusterSpotFormatter.formatLine(
|
||||
"DM5M",
|
||||
"144205.0",
|
||||
"DL5ASG",
|
||||
"123456789012345678901234567890EXTRA",
|
||||
"1234Z"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
"JO51HK" + " ".repeat(24),
|
||||
shortLine.substring(39, 69)
|
||||
);
|
||||
assertEquals(
|
||||
"123456789012345678901234567890",
|
||||
longLine.substring(39, 69)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsVariableDxCallsignsWithoutMovingTheComment() {
|
||||
String twelveCharacterLine = DXClusterSpotFormatter.formatLine(
|
||||
"DO5AMF",
|
||||
"24048100.0",
|
||||
"ABCDEFGHIJKL",
|
||||
"JO51HK AP 1m/100%;4m/75%",
|
||||
"2359Z"
|
||||
);
|
||||
|
||||
assertEquals("ABCDEFGHIJKL", twelveCharacterLine.substring(26, 38));
|
||||
assertEquals(
|
||||
"JO51HK AP 1m/100%;4m/75%" + " ".repeat(6),
|
||||
twelveCharacterLine.substring(39, 69)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> DXClusterSpotFormatter.formatLine(
|
||||
"DO5AMF",
|
||||
"144205.0",
|
||||
"ABCDEFGHIJKLM",
|
||||
"JO51HK",
|
||||
"2359Z"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsExactlyTwoBellCharactersAndCrlf() {
|
||||
byte[] payload = DXClusterSpotFormatter.formatPayload(
|
||||
"DM5M",
|
||||
"50200.0",
|
||||
"DL5ASG",
|
||||
"JO51HK",
|
||||
"0000Z"
|
||||
);
|
||||
|
||||
assertEquals(DXClusterSpotFormatter.LINE_LENGTH + 4, payload.length);
|
||||
assertEquals(7, payload[75]);
|
||||
assertEquals(7, payload[76]);
|
||||
assertEquals('\r', payload[77]);
|
||||
assertEquals('\n', payload[78]);
|
||||
assertEquals(
|
||||
75,
|
||||
new String(payload, 0, 75, StandardCharsets.US_ASCII).length()
|
||||
);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> supportedFrequencies() {
|
||||
return Stream.of(
|
||||
Arguments.of("DM5M", "50200.0"),
|
||||
Arguments.of("DO5AMF", "70250.0"),
|
||||
Arguments.of("DM5M", "144205.0"),
|
||||
Arguments.of("DO5AMF", "432088.0"),
|
||||
Arguments.of("DM5M", "1296338.0"),
|
||||
Arguments.of("DO5AMF", "10368100.0"),
|
||||
Arguments.of("DO5AMF", "24048100.0")
|
||||
);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import javafx.collections.FXCollections;
|
||||
import kst4contest.model.AirPlane;
|
||||
import kst4contest.model.AirPlaneReflectionInfo;
|
||||
import kst4contest.model.ChatMember;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MessageBusManagementThreadDxClusterCommentTest {
|
||||
|
||||
@Test
|
||||
void keepsLocatorAndAddsCompactAirScoutInformation() {
|
||||
ChatMember sender = new ChatMember();
|
||||
sender.setQra("jo51hk");
|
||||
|
||||
AirPlane firstAircraft = new AirPlane();
|
||||
firstAircraft.setArrivingDurationMinutes(1);
|
||||
firstAircraft.setPotential(100);
|
||||
|
||||
AirPlane secondAircraft = new AirPlane();
|
||||
secondAircraft.setArrivingDurationMinutes(4);
|
||||
secondAircraft.setPotential(75);
|
||||
|
||||
AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo();
|
||||
reflectionInfo.setRisingAirplanes(
|
||||
FXCollections.observableArrayList(
|
||||
firstAircraft,
|
||||
secondAircraft
|
||||
)
|
||||
);
|
||||
sender.setAirPlaneReflectInfo(reflectionInfo);
|
||||
|
||||
assertEquals(
|
||||
"JO51HK AP 1m/100%;4m/75%",
|
||||
MessageBusManagementThread.buildDxClusterSpotComment(sender)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsLocatorWhenAirScoutInformationIsMissing() {
|
||||
ChatMember sender = new ChatMember();
|
||||
sender.setQra("JO51HK");
|
||||
|
||||
assertEquals(
|
||||
"JO51HK",
|
||||
MessageBusManagementThread.buildDxClusterSpotComment(sender)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatMessage;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class MessageBusManagementThreadFrequencyTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void detectsCompactMicrowaveFrequencyInPublicAndDirectedMessages(
|
||||
boolean directedMessage
|
||||
) {
|
||||
ChatController controller = mock(ChatController.class);
|
||||
ThreadStatusCallback callback = mock(ThreadStatusCallback.class);
|
||||
MessageBusManagementThread messageBus =
|
||||
new MessageBusManagementThread(
|
||||
controller,
|
||||
callback,
|
||||
1L,
|
||||
new LinkedBlockingQueue<>(),
|
||||
ignored -> true
|
||||
);
|
||||
|
||||
ChatMember sender = member("DL1ABC");
|
||||
ChatMessage message = new ChatMessage();
|
||||
message.setSender(sender);
|
||||
message.setReceiver(member(directedMessage ? "DL2XYZ" : "ALL"));
|
||||
message.setMessageText("pse try 10368100");
|
||||
|
||||
messageBus.smartFrequencyExtraction(message, null);
|
||||
|
||||
verify(controller).applyDetectedFrequencyToActiveMembers(
|
||||
sender,
|
||||
Band.B_10G,
|
||||
10368.100
|
||||
);
|
||||
}
|
||||
|
||||
private static ChatMember member(String callSign) {
|
||||
ChatMember member = new ChatMember();
|
||||
member.setCallSign(callSign);
|
||||
return member;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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.setGUIstationMapClusteringEnabled(false);
|
||||
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>7</configVersion>"));
|
||||
assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0"));
|
||||
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
|
||||
+ "</GUIstationMapClusteringEnabled>"));
|
||||
|
||||
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
assertEquals("SAVED-CALL", restored.getStn_loginCallSign());
|
||||
assertFalse(restored.isGUIstationMapClusteringEnabled());
|
||||
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,101 @@
|
||||
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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ChatPreferencesStationMapClusteringTest {
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void clusteringIsEnabledByDefault() {
|
||||
assertTrue(new ChatPreferences().isGUIstationMapClusteringEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledClusteringSurvivesFullXmlRoundTrip() throws IOException {
|
||||
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
|
||||
ChatPreferences written = preferencesAt(preferencesFile);
|
||||
written.setGUIstationMapClusteringEnabled(false);
|
||||
|
||||
assertTrue(written.writePreferencesToXmlFile());
|
||||
|
||||
String writtenXml = Files.readString(preferencesFile);
|
||||
assertTrue(writtenXml.contains("<configVersion>7</configVersion>"));
|
||||
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
|
||||
+ "</GUIstationMapClusteringEnabled>"));
|
||||
|
||||
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
assertFalse(restored.isGUIstationMapClusteringEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionSixWithoutClusteringSettingKeepsClusteringEnabled() throws IOException {
|
||||
Path preferencesFile = temporaryDirectory.resolve("version-six.xml");
|
||||
Files.writeString(preferencesFile, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<praktiKST>
|
||||
<configVersion>6</configVersion>
|
||||
<guiOptions>
|
||||
<GUIstationMapStageSceneSizeHW>1000.0;800.0</GUIstationMapStageSceneSizeHW>
|
||||
</guiOptions>
|
||||
</praktiKST>
|
||||
""");
|
||||
|
||||
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||
restored.setGUIstationMapClusteringEnabled(false);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
assertTrue(restored.isGUIstationMapClusteringEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingGuiOptionsKeepsClusteringEnabled() throws IOException {
|
||||
Path preferencesFile = temporaryDirectory.resolve("missing.xml");
|
||||
Files.writeString(preferencesFile, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<praktiKST>
|
||||
<configVersion>6</configVersion>
|
||||
</praktiKST>
|
||||
""");
|
||||
|
||||
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||
restored.setGUIstationMapClusteringEnabled(false);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
assertTrue(restored.isGUIstationMapClusteringEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidClusteringValueKeepsClusteringEnabled() throws IOException {
|
||||
Path preferencesFile = temporaryDirectory.resolve("invalid.xml");
|
||||
Files.writeString(preferencesFile, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<praktiKST>
|
||||
<configVersion>7</configVersion>
|
||||
<guiOptions>
|
||||
<GUIstationMapClusteringEnabled>sometimes</GUIstationMapClusteringEnabled>
|
||||
</guiOptions>
|
||||
</praktiKST>
|
||||
""");
|
||||
|
||||
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||
restored.setGUIstationMapClusteringEnabled(false);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
assertTrue(restored.isGUIstationMapClusteringEnabled());
|
||||
}
|
||||
|
||||
private ChatPreferences preferencesAt(Path preferencesFile) {
|
||||
ChatPreferences preferences = new ChatPreferences();
|
||||
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
|
||||
return preferences;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.logic.FrequencyTextParser;
|
||||
import kst4contest.model.Band;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class FrequencyTextParserRegressionTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("compactFrequenciesAcrossSupportedBands")
|
||||
void detectsCompactFrequenciesAcrossSupportedBands(
|
||||
String compactFrequency,
|
||||
Band expectedBand,
|
||||
double expectedFrequencyMHz
|
||||
) {
|
||||
FrequencyTextParser.DetectedFrequency detected =
|
||||
FrequencyTextParser.findExplicitFrequencies(
|
||||
"QRV " + compactFrequency
|
||||
).get(0);
|
||||
|
||||
assertEquals(expectedBand, detected.getBand());
|
||||
assertEquals(
|
||||
expectedFrequencyMHz,
|
||||
detected.getFrequencyMHz(),
|
||||
0.000_001
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsReferenceFrequencyInStationName() {
|
||||
List<FrequencyTextParser.DetectedFrequency> detected =
|
||||
FrequencyTextParser.findExplicitFrequencies(
|
||||
"Operator 144307"
|
||||
);
|
||||
|
||||
assertEquals(1, detected.size());
|
||||
assertEquals(Band.B_144, detected.get(0).getBand());
|
||||
assertEquals(
|
||||
144.307,
|
||||
detected.get(0).getFrequencyMHz(),
|
||||
0.000_001
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCompactValuesOutsideSupportedBandRanges() {
|
||||
assertTrue(
|
||||
FrequencyTextParser.findExplicitFrequencies(
|
||||
"146100 434100 99999"
|
||||
).isEmpty()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsBareThreeDigitValuesOutOfCompleteFrequencyDetection() {
|
||||
assertTrue(
|
||||
FrequencyTextParser.findExplicitFrequencies(
|
||||
"210 599 144"
|
||||
).isEmpty()
|
||||
);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> compactFrequenciesAcrossSupportedBands() {
|
||||
return Stream.of(
|
||||
Arguments.of("50278", Band.B_50, 50.278),
|
||||
Arguments.of("70200", Band.B_70, 70.200),
|
||||
Arguments.of("145500", Band.B_144, 145.500),
|
||||
Arguments.of("432100", Band.B_432, 432.100),
|
||||
Arguments.of("1296100", Band.B_1296, 1296.100),
|
||||
Arguments.of("2320100", Band.B_2320, 2320.100),
|
||||
Arguments.of("3400100", Band.B_3400, 3400.100),
|
||||
Arguments.of("5760100", Band.B_5760, 5760.100),
|
||||
Arguments.of("10368100", Band.B_10G, 10368.100),
|
||||
Arguments.of("24048100", Band.B_24G, 24048.100)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.view.map.MapHtmlResources;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MapHtmlResourcesContractTest {
|
||||
|
||||
@Test
|
||||
void stationClusteringCanBeToggledWithoutReplacingStationData() {
|
||||
String html = MapHtmlResources.createStationMapHtml(12345);
|
||||
|
||||
assertTrue(html.contains("let stationClusteringEnabled = true;"));
|
||||
assertTrue(html.contains("if (!stationClusteringEnabled"));
|
||||
assertTrue(html.contains("|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM)"));
|
||||
assertTrue(html.contains("function setStationClusteringEnabled(enabled)"));
|
||||
|
||||
int setterStart = html.indexOf("function setStationClusteringEnabled(enabled)");
|
||||
int setterEnd = html.indexOf('}', setterStart);
|
||||
String setterBody = html.substring(setterStart, setterEnd);
|
||||
int stateUpdate = setterBody.indexOf("stationClusteringEnabled = Boolean(enabled);");
|
||||
int markerRender = setterBody.indexOf("renderStationMarkers();");
|
||||
|
||||
assertTrue(stateUpdate >= 0);
|
||||
assertTrue(markerRender > stateUpdate);
|
||||
assertFalse(setterBody.contains("stationData ="));
|
||||
assertTrue(html.contains("setStationClusteringEnabled: setStationClusteringEnabled"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class TableLayoutManagerTest {
|
||||
|
||||
@Test
|
||||
void contentWidthUsesCompactPadding() {
|
||||
assertEquals(116.0, TableLayoutManager.calculateInitialContentWidth(100.0, 24.0, 200.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentWidthStillHonorsMinimumAndMaximum() {
|
||||
assertEquals(24.0, TableLayoutManager.calculateInitialContentWidth(0.0, 24.0, 200.0));
|
||||
assertEquals(200.0, TableLayoutManager.calculateInitialContentWidth(250.0, 24.0, 200.0));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2390,4 +2390,5 @@ DK0MM;Jens/Alex;JN49IU;StringProperty [value: 432.305]; wkd true; wkd144 false;
|
||||
SM6VTZ;Chris .135;JO58UJ;StringProperty [value: 144.135]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SM6VTZ;Chris .135;JO58UJ;StringProperty [value: 432.135]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
LA0BY;Stefan @ hilltop;JO59IX;StringProperty [value: 144.062]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
LA0BY;null;JO49ML;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
LA0BY;null;JO49ML;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OV3T;null;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
@@ -141,10 +141,12 @@ Every generated spot contains:
|
||||
|
||||
Automatically generated directional spots can additionally include up to two current AirScout entries. A manually triggered map spot uses the selected station's locator without this optional addition.
|
||||
|
||||
The payload is a fixed, DXSpider-compatible 75-character line. The DX callsign begins in column 27, the 30-character comment begins in column 40 and the UTC time begins in column 71. Short comments are padded; longer comments are limited to the available field. A DX callsign longer than twelve characters is rejected rather than silently truncated.
|
||||
|
||||
An example comment with AirScout information may look like this:
|
||||
|
||||
```text
|
||||
JN49GL , AP: 1min, 100%; 4min, 75%
|
||||
JO51HK AP 1m/100%;4m/75%
|
||||
```
|
||||
|
||||
AirScout information is optional. A missing AirScout response does not prevent an automatic directional spot from being sent.
|
||||
@@ -199,6 +201,8 @@ A missing password is acceptable inside the intended station network. It is not
|
||||
|
||||
Use **Send test spot** after the logger has connected.
|
||||
|
||||
The test uses `DO5AMF` with the comment `DXC test: You donated $100!` and `.300` on the configured fallback band.
|
||||
|
||||
A successful test confirms that at least one client received the generated spot. If the test works but real spots do not appear, the TCP connection is probably not the problem. In that case, check the conditions used for the actual directional opportunity:
|
||||
|
||||
- Were valid locators available?
|
||||
|
||||
@@ -37,9 +37,13 @@ Complete frequencies provide their band directly. Examples include:
|
||||
```text
|
||||
144.210
|
||||
432,088
|
||||
144307
|
||||
10368.100
|
||||
10368100
|
||||
```
|
||||
|
||||
The separator is optional for a complete frequency. For a digit-only value, KST4Contest treats the final three digits as the kHz part. This turns `144307` in a station name into `144.307 MHz` and `10368100` in a public or directed chat message into `10368.100 MHz`. The result still has to fall within one of the supported band ranges.
|
||||
|
||||
Relative forms omit the band and need additional context:
|
||||
|
||||
```text
|
||||
|
||||
@@ -44,6 +44,8 @@ The map retains the context already known by KST4Contest:
|
||||
|
||||
Only stations with a usable six-character locator can be positioned. Active chat variants of the same normalised base callsign are combined into one marker, while their actual message destinations remain separate.
|
||||
|
||||
At lower zoom levels, **Group nearby stations** keeps dense map areas readable by combining close markers into screen-based clusters. The grouping can be disabled immediately without changing the current viewport or selection, and KST4Contest remembers the choice. This setting does not split the geographical marker shared by active variants of the same base callsign.
|
||||
|
||||
Green has a specific meaning: it marks a directional opportunity derived from directed ON4KST messages. It does not merely mean that the marker happens to lie inside the local antenna sector.
|
||||
|
||||
## Selection remains connected to the chat
|
||||
|
||||
Reference in New Issue
Block a user