Etappen 3 to 6a of the migration (129 commits). Every window the operator sees is Compose; the JavaFX ones still exist beside them so the two could be compared against a live session, which is the only way the differences came to light. The toolchain first, then the settings window with its eleven tabs and 72 real preferences; then the multi-column table as a component, and with it the rules it needs -- stable row identity, a selection that survives a refresh, column order the operator can drag, and a repaint when the rows change rather than when the list instance does. Then the update and monitor windows, and the main window: 22 station columns, the filter bar, the selected-station panel, the message tabs, the priority bar, the sked controls, the status indicators and the timeline, assembled behind a splitter because Compose has none. The map stops being a WebView. A Compose canvas projects WebMercator tiles itself, with the clustering in core and an asynchronous fetcher in place of the local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a reflection call into com.sun.javafx. Most of the volume here is parity the operator would notice rather than new behaviour: desktop density instead of Material's phone minimums, colours taken from the JavaFX stylesheets instead of Material's palette, row dividers, band columns, grid-square labels and their colours, the home marker and antenna beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower than the chart they stack. Two lessons are worth more than the diff. A control bound to a value Compose cannot observe writes through and never redraws -- that one shipped repeatedly with a green build, because no test drew anything. And a layout that clips instead of wrapping is worse than one that is merely too narrow: half a word is the worst available outcome.
12 KiB
Umsetzungsplan Etappe 4 — Update-Fenster und Monitor-Fenster
Spezifikation: docs/superpowers/specs/2026-09-25-compose-migration-design.md, Etappe 4.
Was diese Etappe wirklich ist
Die Spezifikation nennt zwei Fenster. Beim Lesen des Quelltextes zeigt sich, dass das untertreibt:
| Fenster | Stelle | braucht |
|---|---|---|
| Update | :9458 |
ein Textgitter, einen Verweis, einen Baum mit Änderungsprotokoll und bekannten Fehlern, Größenspeicherung |
| Monitor | :9396 |
initDXClusterTable mit 8 Spalten über ClusterMessage, initChatToOtherMSGTable mit 10 Spalten über ChatMessage |
DataTable aus Etappe 3b kann eine Spalte Text — ListEntry(key, text). Die
mehrspaltige Tabelle existiert nicht. Das Monitor-Fenster ist also nicht der Abnehmer
einer fertigen Komponente, sondern ihr Anlass.
Etappe 5 braucht dieselbe Komponente für die Stationsliste mit Filterleiste und Prioritätenspalten. Was hier entsteht, trägt also drei Tabellen, nicht zwei. Der Zuschnitt wird danach entschieden, nicht nach dem, was für diese zwei Fenster gerade reicht — aber auch nicht darüber hinaus: die Stationsliste wird gelesen, bevor die Schnittstelle festgelegt wird, nicht danach.
Reihenfolge
Update-Fenster zuerst, wie besprochen. Es ist klein und klärt die Fenster-Mechanik
aus einem Hintergrundfaden — dieselbe Stelle, an der in Etappe 3b der Profilwechsel
scheiterte, weil closeOwnedStages ein Compose-Fenster nicht erreichte. Das Monitor-
Fenster folgt mit dieser Gewissheit im Rücken, sonst käme die Fenster-Mechanik als
zweite Fehlerquelle zu den Live-Daten dazu.
Aufgaben
1. UpdateWindowState mit Tests — erledigt
Umgesetzt in UpdateWindowState.kt, neun Testfälle. Beim Lesen kam heraus, dass das
Fenster mehr enthält als die Spezifikation andeutet: neben dem Textgitter steht eine
TreeView mit verborgener Wurzel und zwei Zweigen, „ChangeLog" und „Known bugs".
Beide haben dieselbe Form — erstes Feld ist die Überschrift, der Rest sind Zeilen —
also trägt ein Typ UpdateSection beide. Eine allgemeine Baumkomponente braucht es
nicht: die Tiefe ist fest zwei.
Zwei Entscheidungen sind im Quelltext begründet: fehlende Update-Informationen gelten als „kein Update" (die Prüfung läuft beim Start, ein gescheiterter Abruf darf den Start nicht aufhalten), und eine Überschrift ohne Zeilen bleibt erhalten, statt weggefiltert zu werden.
Die ursprüngliche Beschreibung der Aufgabe:
Reine Fachlogik, ohne Oberfläche: Was wird angezeigt, und wann überhaupt. Die
Bedingung steht heute bei :9583: getLatestVersionNumberOnServer() > APPLICATION_CURRENTVERSIONNUMBER. Dazu die vier Textfelder aus
getUpdateInformation() und der Verweis auf die Veröffentlichungsseite.
Zu prüfen: gleiche Version zeigt nichts, ältere Serverversion zeigt nichts, fehlende Update-Information führt nicht zu einer Ausnahme beim Start.
2. Update-Fenster in Compose — erledigt und abgenommen
Titel „Update information", die sechs Beschriftungen wörtlich. Der Verweis öffnet den
Systembrowser. Größe aus GUIstage_updateStage_SceneSizeHW.
Es folgt dem Design zur Laufzeit, wie SettingsWindow seit Etappe 3b, und es
lässt sich von außen schließen. Beides ist keine Kür: ohne das erste steht ein helles
Fenster neben einem dunklen, ohne das zweite überlebt es den Profilwechsel.
Abgenommen am laufenden Programm, Tag- und Abenddesign, einschließlich Umschalten
zur Laufzeit über „Windows → Use dark mode design". Damit ist auch die neue
applyDarkMode-Verdrahtung des Update-Fensters belegt.
3. Den JavaFX-Block entfernen und nachmessen — erledigt
stage_updateStage samt Feld, closeOwnedStages-Eintrag und
registerThemedScene-Aufruf. Zeilenzahl von Kst4ContestApplication.java festhalten
(Stand jetzt: 10 904).
Ergebnis: 10 904 → 10 802. Der Block war 145 Zeilen; zurück kamen 36 für
openUpdateWindowIfAvailable, das nur noch die Entscheidung abfragt und den
Systembrowser erreicht.
Die Fenstermechanik ist herausgezogen. ComposeWindowHost trägt jetzt das, was
jedes Compose-Fenster neben JavaFX braucht: nicht den Prozess beenden, höchstens
einmal öffnen, von außen schließbar sein, dem Design folgen. SettingsWindow wurde
darauf umgestellt und behält seine Schnittstelle zur Java-Seite unverändert. Das war
kein Aufräumen um seiner selbst willen: dieselbe Mechanik zum dritten Mal abzuschreiben
hätte bedeutet, die close()-Verdrahtung zum dritten Mal von Hand zu treffen — und
genau deren Fehlen war der Profilwechsel-Fehler aus Etappe 3b.
UpdateWindow.close() und applyDarkMode sind in closeOwnedStages und applyTheme
eingetragen, nicht nachgereicht.
4. Die Stationsliste lesen, bevor die Tabellenschnittstelle steht — erledigt
Ergebnis: die drei Tabellen sind ungleich schwer.
| Tabelle | Zeilen | Zellfabriken | Zeilenfabrik | Vergleicher |
|---|---|---|---|---|
| Monitor: DX-Cluster | 183 | 1 | 0 | 0 |
| Monitor: QSO der anderen | 222 | 1 | 0 | 0 |
| Stationsliste (Etappe 5) | 854 | 13 | 1 | 4 |
Die beiden Monitor-Tabellen sind schlicht: Text in Spalten, keine Zeilenfärbung, keine
eigene Sortierung. Nach ihnen allein zugeschnitten entstünde eine Komponente, die die
Stationsliste nicht trägt — derselbe Fehler wie bei der DataTable in Etappe 3b.
Was die Stationsliste zusätzlich verlangt, aus dem Quelltext gelesen:
- 22 Spalten: Callsign, Name, QRA, QRB, QTF, QRG, „AP [minutes / pot%]", Tropo, Score, Act, worked, wkdany, die Bänder 50/70/144/432/23/13/9/6/3, „NOT QRV @", Category.
- Zellinhalt ist nicht gleich Zellenanzeige. Eine Zellfabrik liest den Wert und
das Zeilenobjekt und Controllerzustand — Beispiel
qraCol: Kürzen des Textes, Kurzhinweis aus Rasterfeld und „schon gearbeitet", Einfärbung nur wenn der Raster-Knopf aktiv ist. - Zeilenfärbung nach
getCurrentPriorityScore()mit vier Schwellen: über 1000 rot („imminent sked"), ab 200 orange, ab 100 gelb, 0 und darunter ausgegraut. Die Farben unterscheiden sich nach Design — im Quelltext steht heute einisDark-Zweig je Schwelle. - Eigene Vergleicher für vier Spalten; die übrigen sortieren nach Text.
- 23 Spaltenbreiten über
TableLayoutManager, entprellt gespeichert.
Daraus der Zuschnitt: die Schnittstelle wird für die Stationsliste entworfen, die Umsetzung nur so weit gebaut, wie Etappe 4 sie braucht. Eine Spalte ist damit:
- eine feste Kennung (für die Breitenspeicherung),
- eine Überschrift,
- eine Funktion Zeile → angezeigter Text,
- wahlweise ein Kurzhinweis aus (Zeile, Wert),
- wahlweise eine Zellenfärbung aus (Zeile, Wert),
- wahlweise ein Vergleicher.
Die Tabelle nimmt zusätzlich wahlweise eine Zeilenfärbung aus der Zeile. So braucht die Stationsliste in Etappe 5 keinen zweiten Zuschnitt, und die Monitor-Tabellen benutzen schlicht die einfachen Fälle.
Die ursprüngliche Beschreibung der Aufgabe:
initChatMemberTable und die Filterleiste aus Etappe 5 durchsehen und aufschreiben,
was sie verlangt: Spaltentypen, Sortierung, Zeilenfärbung, Auswahlverhalten,
Spaltenbreitenspeicherung. Erst danach die Schnittstelle festlegen.
Begründung: In Etappe 3b wurde die DataTable nach dem Einstellungsfenster
zugeschnitten und kann deshalb keine zweite Spalte. Derselbe Fehler ein zweites Mal
wäre vermeidbar gewesen.
5. Mehrspaltige Tabelle: Zustand und Regeln, mit Tests — erledigt
DataTableState mit zehn Testfällen. Die Zeilenidentität war die Kernentscheidung,
und beim Lesen der drei Zeilentypen zeigte sich: es gibt zwei Sorten Liste, und eine
Antwort reicht nicht.
| Sorte | Beispiele | Identität |
|---|---|---|
| Entitätsliste | Stationsliste, gearbeitete Stationen | eigener Schlüssel, RowKeys.byValue mit dem rohen Rufzeichen |
| Ereignisliste | DX-Cluster, QSO der anderen | RowKeys.byReference |
ClusterMessage und ChatMessage haben gar keinen Schlüssel. Einer aus Zeit,
Absender und Text kollidiert, sobald jemand dasselbe Makro zweimal in derselben
Sekunde schickt — genau die Falle „Text lässt Dubletten verschmelzen" aus Etappe 2.
Zwei gleiche Nachrichten sind zwei Nachrichten. byReference vergibt Schlüssel beim
ersten Sehen und merkt sie sich an der Objektreferenz; das trägt, weil die Roster bei
jedem Schnappschuss dieselben Instanzen zurückgeben.
Es gibt bewusst keine Vorgabe. In Etappe 2 kamen die sichtbaren Fehler aus der Zeilenidentität; der Aufrufer zu zwingen, sie zu benennen, hält die Entscheidung dort, wo das Wissen sitzt.
Zwei weitere Regeln sind festgeschrieben:
- Die Auswahl folgt der Zeile, nicht der Position. Kommt oben etwas dazu, bleibt die gewählte Station dieselbe. Verschwindet sie, wird die Auswahl gelöscht statt auf den Nachbarn zu rutschen — sonst zeigt der Operateur auf jemanden, den er nie gewählt hat.
- Die Sortierung ist stabil. Gleiche Zeilen behalten ihre Ankunftsreihenfolge. Die
JavaFX-
SortedListfügte per Binärsuche ein und legte Gleichstände irgendwohin, weshalb Zeilen sprangen.
Die ursprüngliche Beschreibung der Aufgabe:
Ohne Oberfläche prüfbar: Spaltendefinition, Sortierung, Auswahl, Zeilenidentität.
Die Zeilenidentität ist der kritische Punkt. In Etappe 2 kamen die Fehler nicht aus der Tabelle, sondern aus der Kopplung zwischen Roster und Anzeige: ein Listenschlüssel darf nicht die Position sein (verschiebt sich beim Einfügen oben) und nicht der Text (fällt bei Dubletten zusammen). Das ist hier erneut zu entscheiden und festzuhalten.
Der Zustand hält alles, womit gemalt wird, in Compose-Zustand. Aus Etappe 3b: gewöhnliche Eigenschaften melden nichts, und bei Live-Daten fällt das nicht als „reagiert nicht" auf, sondern als veraltete Anzeige.
6. Die Tabelle als Compose-Komponente
Spaltenbreiten werden über die bestehende TableLayoutManager-Kennung entprellt
gespeichert. Die Kennungen dx-cluster-monitor und qso-other-monitor bleiben
getrennt von denen des Hauptfensters, wie PROJECT_CONTEXT.md festlegt.
7. Monitor-Fenster in Compose
Titel „Cluster & QSO of the other", geteilte Fläche mit den zwei Tabellen, Größe aus
GUIclusterAndQSOMonStage_SceneSizeHW. Die Teilerposition wird gespeichert wie
bisher.
8. Lasttest mit dem Simulator
Wie in Etappe 2, denn dort hat genau das die Sortier- und Fokusfehler gefunden — kein Test hat sie gesehen. Eine Stunde Dauerlast. Beobachtet wird: springt die Auswahl, kippt die Sortierung, bleibt die Anzeige aktuell, wächst der Speicherbedarf.
9. Den JavaFX-Monitorblock entfernen und nachmessen
Erst wenn der Lasttest steht.
Was unverändert bleiben muss
- Layout-Kennungen getrennt von denen des Hauptfensters.
GUIclusterAndQSOMonStage_SceneSizeHWundGUIstage_updateStage_SceneSizeHWbehalten Namen und Bedeutung;configVersionbleibt 7.- Die Update-Prüfung selbst wird nicht angefasst — nur ihre Anzeige.
Offene Punkte
-
Die macOS-Systemmenüleiste.Geklärt. Compose kenntMenuBar()im Geltungsbereich vonWindow(), und auf macOS erscheint sie in der Systemmenüleiste am oberen Bildschirmrand, sobald das Fenster aktiv ist — genau das Verhalten, dassetUseSystemMenuBar(true)heute erzeugt. Eine gemeinsame Leiste über mehrere Fenster gibt es nicht: jedes Fenster erklärt seine eigene.Das passt zum heutigen Vorgehen, nur anders herum.
installSharedSystemMenuBarspiegelt die Menüs des Hauptfensters überBindings.bindContentin eine unsichtbare Leiste jedes Nebenfensters. In Compose wird daraus eine gemeinsame Composable-Funktion, die jedes Fenster aufruft — eine Quelle, fünf Aufrufstellen.Ein Punkt bleibt zu beachten: die Menüeinträge werden heute aus
onConnectionStateChangedaktiviert und deaktiviert (menuItemFileDisconnect,menuItemOptionsAwayBackund weitere). Deren Zustand muss beobachtbar sein, sonst wiederholt sich der Fehler aus Etappe 3b an einer Stelle, an der er besonders schlecht auffällt: ein Menüeintrag, der falsch aktiviert aussieht. -
Entprellte Spaltenbreiten.
TableLayoutManagerhängt an JavaFX-TableColumn. Ob die Compose-Tabelle ihn benutzt oder ein Gegenstück bekommt, entscheidet sich mit Aufgabe 4.