Compare commits

...
Author SHA1 Message Date
Claude c43934c7ba Make the monitor window's divider draggable and remembered (#144)
The cluster and QSO monitor split its two tables at half the height, fixed:
the Compose port had left the JavaFX SplitPane out until a splitter existed.
SplitterPane exists now, so the monitor uses it and reads and writes the same
profile value the SplitPane did (GUIpnl_directedMSGWin_dividerpositionDefault,
default 0.8), so a profile gets its old position back. Each heading now sits
in the pane of its table, and the "DX cluster" heading is translated
(monitor.dxCluster).

Fixes #144.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaorTymXfkXPm8yb4Btva7
2026-10-19 11:05:22 +00:00
Rsclub2_2andClaude d38b385166 Open menu row menus flush under the bar (#143)
Material3's DropdownMenu clamps every popup to 48dp below the window's
top edge (MenuVerticalMargin in its position provider); the margin is
applied after the offset parameter and the provider is not replaceable,
so the in-window menu bar's menus all opened with a visible gap hanging
below the bar. Replace them with a plain focusable Popup in MenuDefaults
styling and an own position provider that puts the menu directly under
its anchor, flipping above only when there is no room below. The anchor
box takes the full bar height so the menu starts at the bar's bottom
edge, not at the centred title text.


Claude-Session: https://claude.ai/code/session_01BegdsGSNNxKR6VPNPNvfoc

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 00:08:51 +02:00
Rsclub2_2andClaude Fable 5 a28abe1591 Update repository URLs to HamRadioOnline organisation (#142)
* Move remaining references to the HamRadioOnline organisation

The repository moved from praktimarc/kst4contest to
HamRadioOnline/kst4contest. Document the move in the German and English
changelog, point manuals, README, website, AUR packaging and the
version-feed tooling at the new address, and keep accepting pre-move
release URLs in the update-feed validator because published feeds still
carry the old owner. Personal identities (Ko-Fi account, contact
e-mail) stay unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFGQ7wkSPo21itLMHPi8Yw

* Lowercase the owner in the generated Flatpak Pages URL

GITHUB_REPOSITORY_OWNER is now the mixed-case HamRadioOnline, and the
next build would write that spelling into the published flatpakrepo and
flatpakref files. Hostnames are case-insensitive, but the published
metadata should match the documented lowercase address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFGQ7wkSPo21itLMHPi8Yw

* Build the Flatpak against org.freedesktop.Platform 25.08

Runtime branch 24.08 is end-of-life and no longer receives security
updates, so every flatpak update now prints an EOL warning. 25.08 is
the current supported branch on Flathub. The app bundles its own Java
runtime, so the platform bump only changes the base libraries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFGQ7wkSPo21itLMHPi8Yw

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-17 23:06:01 +02:00
dependabot[bot] a0039cd52e build(deps): bump actions/setup-java (#141)
Bumps the minor-and-patch group with 1 update: [actions/setup-java](https://github.com/actions/setup-java).


Updates `actions/setup-java` from 6.0.1 to 6.1.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v6.0.1...v6.1.0)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 22:50:19 +02:00
dependabot[bot] 8dec00870b build(deps): bump com.github.spotbugs (#140)
Bumps the minor-and-patch group with 1 update: com.github.spotbugs.


Updates `com.github.spotbugs` from 6.5.12 to 6.5.13

---
updated-dependencies:
- dependency-name: com.github.spotbugs
  dependency-version: 6.5.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 22:50:00 +02:00
github-actions[bot] 7ee02fb029 chore: update AUR packages to v1.44.0 [skip ci] 2026-10-16 18:17:06 +00:00
Rsclub2_2andClaude Opus 5.5 e0c1c6b6d4 One chat-frame flow for live and history messages (#105) (#139)
Second slice of making MessageBusManagementThread testable. The CH (live)
and CR (history) chat-frame branches of processRXMessage23001 were near
copies; they are now one processChatFrame(fields, live), with behaviour
unchanged.

- InboundChatFrame, ChatRoute and classifyRoute: where a message belongs
  (public, to me, from me, between others), pure.
- ChatMessageEffects and decideChatFrameEffects: what a message triggers
  (sounds, //BELL, //VER reply, auto-answer, directional tick, in-angle
  flag, DX cluster spot), pure. History frames trigger nothing but the flag.
- The four live/history differences are kept on purpose: frequency
  detection before routing (live) or after publishing (history), effects
  for messages to me, tick and spot between others, AirScout info on the
  SERVER sender.
- MessageBusManagementThreadChatFrameTest pins both branches through the
  real thread, including the frequency order on directed frames and the
  spot entry; ChatFrameDecisionTest covers the pure functions.
- Spec: docs/superpowers/specs/2026-10-16-message-bus-second-slice-design.md

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:45:21 +02:00
Rsclub2_2andClaude Opus 5.5 797b01c748 Changelog 1.50: new interface, German and English, adjustable colours (#138)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:25:47 +02:00
Rsclub2_2andClaude Opus 5.5 5b7c774f5f SO2R: two radios in one instance (#46) (#137)
* Spec: SO2R with two radios in one instance (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Plan: SO2R implementation (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move the rotator command rules into RotatorControl (#46)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Store the SO2R switch, the second Win-Test station and rotator 2 (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Route frequency reports to radio 1 or 2 (#46)

Add the RadioSlot enum (FIRST, SECOND) with its QRG and QTF targets, and
the pure RadioSlotRouter that decides per RadioInfo packet and per
Win-Test station which radio a report belongs to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Send RadioInfo of radio 2 to SECONDQRG when SO2R is on (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Send Win-Test STATUS of the second station to SECONDQRG when SO2R is on (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run a second PSTRotator for radio 2 with SO2R (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start rotator 2 only while an ON4KST session runs (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Settings for SO2R, the second Win-Test station and rotator 2 (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Turn antenna 2 and show QTF2 with SO2R (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make the SO2R switches observable so Apply shows them (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document SO2R (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse clashing PSTRotator ports in both directions (#46)

Writing either port now checks same-or-adjacent against the drafted value of
the other; rotor 1 is only checked while SO2R and rotor 2 are on. Also fixes
a misplaced Javadoc, stale WinTest parseStatus Javadoc, docs wording and
removes an unused import and dead comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the executed SO2R plan (#46)

The plan's content lives on in the commits, the spec and PROJECT_CONTEXT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:14:27 +02:00
Rsclub2_2andClaude Opus 5.5 feb4b4e2c9 Open secondary windows on the main window's screen (#59) (#135)
* Open secondary windows on the main window's screen (#59)

Every Compose window but the main one went to the platform's default
position, which on a multi-monitor desk is the primary screen - the
station map could open on a monitor that was switched off. They now open
centred on the screen the main window is on, with their size clamped to
that screen's usable area (insets considered). A window the operator
moved during the session reopens there, as long as its title bar can
still be grabbed on a connected screen; otherwise it falls back to the
main window's screen. The position lives only in the window's host and
is never persisted. ComposeAlert dialogs and the profile picker are
centred the same way once the main window exists.

The rule is a pure function (WindowPlacement) with 18 unit tests; the
AWT side (WindowPlacementOnScreen) only reads the screens and the main
window's bounds on the event thread. The clamped opening size of a
secondary window is not written back, so the stored size survives a
smaller screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Track window moves reliably and keep remembered windows on screen (#59)

Review fixes for the window placement:

- Once a window has left the tolerance around its opening position,
  every later position is remembered. Before, the anchor was never
  re-set, so dragging away and back to near the opening spot left the
  stale far position remembered.
- A window the host did not place (opened before the main window
  exists) only trusts its anchor once two consecutive reports lie
  within the tolerance, so a provisional position followed by the
  window manager's placement jump is no longer taken as the operator's
  move.
- A remembered position is shifted the least distance that puts the
  (clamped) window wholly inside the usable area of the screen holding
  its title bar, as the KDoc promised.

The move rule moves into a pure MoveTracker with 5 tests; WindowPlacement
gains keptWithin and 2 more tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep window positions across restarts as an opt-in; fit windows to small screens (#59)

A new GUI tab option, off by default, keeps the position the operator last
moved each window to, the main window included. It is stored per window
with the bounds of the screen it was on, written by the layout autosave,
and restored only when a screen with exactly those bounds is connected
again; otherwise the session rule applies. Only operator moves are
stored, never window manager nudges, so a restored window cannot drift.

An open window that rests on a screen too small for it is now cut down
to that screen's usable area, and the station map's 900x600 minimum is
capped to the smallest connected screen, so 640x480 monitors stay usable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never store a size or position the host fitted a window to (#59)

The cut-down to a small screen was written back as the stored size
(main window via onResized, others via the tracker) and counted as an
operator move. The host now records its own fit: that size is not
written back, and that position re-anchors the move tracker instead of
being stored. A main window opened at a stored position skips its
opening size like the other windows, since it may be clamped too.
The opt-in gate moves into PreferencesWindowPositionMemory, with tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Match the fit echo with tolerance and consume it once (#59)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:58:28 +02:00
Rsclub2_2andClaude Opus 5.5 bb0e9eb327 Restore the connection line in the main window title (#136)
* Restore the connection line in the main window title

While online, the title showed the connection manager's diagnostic
"ON4KST session is authenticated and synchronized" instead of the line
the JavaFX window carried: categories, call, name, locator, users online,
stations shown and messages total. The title now builds that line from
the login preferences and the live station and message tables, so it
follows every new station, filter change and message. The diagnostic
stays where it belongs, in the status bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Title: typed categories, no empty category, review fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:50:12 +02:00
Rsclub2_2andClaude Opus 5.5 92c2576a81 Path analysis: own ground height ASL and highest point of the remote locator square (#133)
* Path analysis: own ground height ASL and highest point of the remote square

Two new settings in the path-analysis section of the Station tab:

- Own ground height ASL (stn_pathAnalysisOwnHeightAslMeters). Empty keeps
  the elevation model value at the own locator centre; a value from -500 to
  9000 m replaces the elevation of the first profile sample. The antenna
  height AGL is still added on top. The field commits on Enter or focus
  loss into the settings draft and reports a refused entry; it keeps the
  operator's spelling while that names the drafted number, because the
  draft is Compose state and would otherwise swap "25" for "25.0" under
  the cursor right after Enter.
- Target station on the highest point of its locator square
  (stn_pathAnalysisDxHighestPointInSquare, on by default). The remote square
  is sampled on a 5 x 5 grid of cell centres in one batched request against
  the Open-Meteo provider, the highest sample becomes the path end with its
  position and elevation. LocatorHighestPointResolver caches the result per
  locator for the session and backs off five minutes after a failure
  (failure list pruned and bounded); the analysis then falls back to the
  square centre and that result is not kept.

Both settings are drafted like the other path-analysis settings and take
effect with Apply/Close or Save; the reachability service reads the stored
values only.

The lookup runs on the reachability thread only. Building a request peeks
the resolver cache without blocking, so a cached square moves the endpoint
right away. The calculation key now includes the own height and the remote
ground elevation at full precision, and the switch, so changing either
recalculates.

Also assign the result of sanitizeEffectiveEarthRadiusFactor in the
PathAnalysisRequest constructor; it was called and dropped. No behaviour
change: the sanitizer is the identity for valid factors, and the analysis
service sanitizes again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document own ground height and highest-point setting of the path analysis

Changelog v1.50 (New), configuration and features pages in English and
German, and the terrain section of PROJECT_CONTEXT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:18:56 +02:00
Rsclub2_2andClaude Opus 5.5 ee4f1fe02e Minimum QRB filter; the station map follows the station filters (#21) (#132)
* Add a minimum QRB filter to the station filter bar (#21)

Next to "Show only QRB [km] <=" the bar gets "Show only QRB [km] >": it
hides every station up to the entered distance. With both switched on the
list keeps a range, min < QRB <= max. The minimum is exclusive, as the
issue asks, so the two filters split the list without overlap at the same
value. A station without a known QRB is hidden while either is on, as the
maximum already did. The value lives in the bar for the running session
only, like the maximum, and the station map follows through activeFilters.

The distance check moves from the controller predicate into
StationFilterState.acceptsQrb so it can be tested; the maximum keeps its
behaviour (null or > max is hidden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the minimum QRB filter (#21)

Changelog v1.50 under New, the filter tables of the user interface pages,
the distance filter section of the feature pages, and the station filter
page of the website.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the first filter bar row wrap instead of clipping (#21)

With both QRB filters the first row of the station filter bar no longer
fits a narrow station pane, and as a plain Row it cut the bearing buttons
off at the right edge. It is a FlowRow now. Reset, the QRB pair, the QTF
controls and the bearings each move to the next line as one group; the
QRB pair and the bearings only break up inside their group when even the
group is wider than the pane. Separators in the wrapping row have a fixed
height, because a flow row line has no intrinsic height to fill.

A screen test at 300 dp checks that every bearing button, both QRB labels
and fields and the QTF label are displayed and end inside the bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse NaN, Infinity and negatives in the QRB filter fields (#21)

toDoubleOrNull also reads "NaN" and "Infinity": a pasted "Infinity" as
the minimum hid every station, "NaN" let every one through, and the
maximum field had the same hole before. Both QRB fields now go through
parseQrbKm, which takes only finite, non-negative distances; anything
else leaves the field at its last valid value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the station map follow the station filters (#21)

The Compose map built its markers from all station rows, so no filter
reached it, although its status line reported "filtered view active".
The JavaFX map, up to beta-v1.50.0-beta-001, took the sorted, filtered
list (StationMapBridge.refreshNow), so this is a regression of the
Compose port that never shipped. The markers now come from the rows the
list shows, and the filter is a key of the remembered markers so a
filter change redraws them. A selected station that a filter hides gets
no marker, as in the JavaFX map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Filter again on redraw; no path analysis for a hidden station (#21)

DataTableState.visibleRows caches the filtered rows, and forceRedraw only
bumped contentRevision. The filter reads fields that change in place
(QRB, worked and QRV flags, last activity), so a redraw could hand back
rows filtered before the change -- in the list and, since the map draws
the same rows, on the map. forceRedraw now drops the cache, and
visibleRows reads contentRevision so a reader recomputes after it. In the
running application the user-list refresh also sets a new row filter,
which dropped the cache before; this makes the redraw itself sufficient.

A selected station without a marker -- hidden by a filter, or without a
locator -- no longer starts a path analysis: the map used to set
"Loading" with an empty target and send the request. The analysis is
cleared instead, and the effect re-runs when the station's marker
appears or disappears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:17:33 +02:00
Rsclub2_2andClaude Opus 5.5 f17ae0905f Settings take effect with Apply/Close or Save (#134)
* Add a settings draft that holds edits until they are applied

The settings window is to stage its edits and apply them with Apply/Close
or Save instead of writing each one at once. SettingsDraft holds one
pending change per setting, reads through to the stored value, and on
apply writes everything first and then runs each effect key once, in the
order the settings were first changed. Form.committed registers its typed
entry with the draft so a click on Apply cannot lose it.

The spec explains the order and the settings that stay immediate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stage the settings tabs' edits and apply them with Apply/Close or Save

Every tab state now reads through the window's draft and stages its edits
there instead of writing ChatPreferences: Station (all but the login
credentials), Log synch, TRX synch, Airscout, Notification, Shortcuts,
Beacon, Message handling and GUI (all but the language). The lists stage
their roster, the committed fields stage what their rule accepts.

Effects stay attached to their settings. The Win-Test listener and the DX
cluster server share one effect for switch and port that brings the running
service in line with the applied state, so applying both restarts it once,
on the new port. The own-QRG follower runs once for both sources.

Apply/Close applies and closes, Save applies and writes, Connect applies
before it connects, so the session uses what the tabs show. A field that
refuses its entry on the way stops the button with the reason shown.

Immediate as before: the login credentials, the language, the colours, the
file chooser, the test spot and the profile and worked-database actions.

Number fields keep the typed text while the staged value comes back as
Compose state; otherwise "1" became "1.0" before the next key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ask about unapplied settings when the window is closed

Closing the settings window with its X, or through the Windows menu, while
edits wait in the draft now asks: apply them, discard them, or go back to
the window. Without pending edits the window closes as before. Fields and
list rows still holding a typed entry are committed first, so an entry
typed just before the X counts.

The request only sets Compose state; the window decides on its next frame,
so nothing waits on the event thread. A close from outside -- a profile
switch, the shutdown -- discards the draft: the preferences it belongs to
are about to be replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document that settings take effect with Apply

PROJECT_CONTEXT records the draft and what stays immediate, the changelog
gets one entry under v1.50, and the configuration manual no longer says
that settings take effect while they are edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Repair stored settings once before the settings window is built

The fallback band of the DX cluster was repaired to 144 inside a getter
that runs during composition, and the Win-Test broadcast address was
auto-detected in a tab state's constructor. Both wrote ChatPreferences
past the draft at a moment nobody could see. repairStoredSettings now does
both once, before any tab is built; the tab states only read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ask before a profile switch or exit, and check fields before applying

A profile switch and the exit dropped pending settings edits silently. They
now go through SettingsWindow.settleThen: with edits pending, the window is
raised and asks Apply / Discard / Back; Apply applies them to the profile
still in force, Back cancels the switch or the exit. The question is Compose
state and the continuation is posted from the dialog's callback, so the
event thread never waits. Without pending edits both go on at once.

Fields are checked before anything is applied: a field still showing text
that does not parse, or that its rule refuses, refuses the apply and is
named in the notice, so the valid edits stay pending and Discard is still
possible. This covers number fields and the guarded external-interface
fields, which used to let Apply go ahead with the last valid value.

Every write and effect of an apply now runs on its own; failures are
collected and reported, a failed write stays pending and skips its effect,
and Save still writes the file when only an effect failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check every drafted setting with a real draft, and rename write-through tests

DraftedSettingsTableTest walks every drafted setter of every tab with a
real SettingsDraft: unchanged after the edit, changed after apply,
unchanged after discard. The tab tests run with a draft that applies at
once and cannot tell a drafted setting from one that bypasses the draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the question on switch and exit, the field checks and the repairs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Decide the pending-settings question on the event thread, never trap

The question is now a SettingsQuestion decided in settle() on the calling
event thread instead of in the settings window's next frame, so an exit
from the main window does not depend on frames of a minimised settings
window; the window is raised and restored before the dialog is needed.

While a question is open, further requests are ignored: an X no longer
replaces the continuation of an exit or switch with a plain close. Each
continuation of settleThen runs at most once.

A field holding text it refuses no longer traps the operator: the X, the
switch and the exit show the question anyway, with the reason, Apply
disabled and Discard and Back available.

The apply-failure notice keeps the technical detail out of the sentence,
in parentheses after a translated one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:32:31 +02:00
Rsclub2_2andClaude Opus 5.5 c64e9a9014 Open the release notes with the message of an annotated tag (#130)
The release body was built from direct commits on main and GitHub's
generated PR list only; whatever was written into the tag was lost. An
annotated tag's subject and body now open the notes, ahead of "What's
Changed", and so reach the in-app update feed too. The tag object is
fetched explicitly because actions/checkout can leave the triggering tag
as a lightweight ref; contents:subject/body leave the SSH signature out.
Lightweight tags contribute nothing, as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:22:57 +02:00
Rsclub2_2andClaude Opus 5.5 88d10259d4 Retry stapler when Apple's ticket service drops the connection (#131)
Three nightly macOS DMG builds on the Intel runner failed in a row after a
successful notarization: `xcrun stapler validate` lost its connection to
api.apple-cloudkit.com ("The network connection was lost.", exit 68). The
ticket was fine; the runner's connection to Apple was not. staple and
validate now get up to five attempts with growing pauses (20, 40, 80,
160 s) before the build fails.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:19:26 +02:00
Rsclub2_2andClaude Opus 5.5 c9b838e946 Translate the remaining hard-coded interface texts (#129)
* Translate the interface texts that stage 9 left in English

The GUI tab checkboxes, the file chooser and dropdown placeholders, the
profile dialog's Apply, the settings notice's OK, the chat clear button,
the selected-station map button and "all", six column titles, the monitor
window's title and heading, and the refusal and hint texts of the profiles,
beacon and notification tab states now come from the translation files.

Column titles go through CurrentStrings in a lambda like the existing ones,
so the id stays untranslated and the stored layout keeps its keys. The tab
states read CurrentStrings as StatusIndicatorState already does. The
English values are unchanged, so the screen tests still find every control
by its old label; a new test switches to German and checks one sample per
path: a composed label, a column title and a tab-state refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Translate the settings notices, profile labels and alert button too

Wires the settings.spot* and settings.write* keys that existed but were
never used, translates the settings window title and its notice titles,
the profile tab's "unknown", "Copy of" and database-mode labels, and lets
the shared alert use alert.ok instead of a literal. The German for
messages.qsoOfTheOther becomes "Fremd-QSOs", the word the menu already uses.

"Copy of" is only the suggested display name; the profile id is derived
from the entered name once at creation and stored, so a later language
switch does not touch it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:11:39 +02:00
Rsclub2_2andClaude Opus 5.5 a9d8c013ef Settings tabs no longer commit on open; lock login fields while connected (#128)
* Commit settings fields only on a real edit, not when a tab opens

Form.committed treated every onFocusChanged callback with "not focused"
as the operator leaving the field. Compose also fires that callback once
when the modifier is attached, so drawing a tab committed each of its
committed fields: opening Colours wrote all six roles as overrides of
themselves (shown as "changed", and after "Save settings" they hid the
profile's stylesheet file for good), opening Beacon validated both
templates and restarted the beacon timer, opening Notification
re-committed the port and the spotter callsign.

The field now commits only on a transition from focused to unfocused,
or on Enter, and only when the draft differs from the stored value.
A real edit still commits on leaving the field or on Enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Lock the Station tab's login fields while a session exists

StationTabState.loginFieldsEnabled was a plain field that nothing ever
wrote and that Compose could not observe, so callsign, names, locator,
password and both chat categories stayed editable while connected,
although the running session does not pick up a change.

The flag is now derived from the ON4KST connection state, read through
a lambda over the main window's Compose state (MainWindowSurroundings),
so the tab follows the session while it is open: the fields are locked
for every state but DISCONNECTED and unlocked again after disconnecting.

The screen-test helper fieldBeside now matches text fields by their
editable text, because a disabled field offers no set-text action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop changelog entries for defects that never shipped

The committed-field and login-field defects exist only in the Compose
settings tabs, which are in neither v1.44.0 nor beta-v1.50.0-beta-001,
so there is nothing for an operator to read about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Compare committed fields with their last commit, not a stale value

Form.committed skipped a commit when the draft equalled `stored`, the
value the caller passed at its last composition. Most callers read it
from plain preference getters, so nothing recomposes them after a
commit: after Enter, `stored` still named the old value, and leaving the
field committed the same entry again, restarting the beacon timer twice
for one edit. The field now keeps the value it last committed in its own
holder, compares the draft with that, and falls back to it on a refusal.

The comparison is pluggable. The Colours tab compares the way
PaletteOverrides normalises a colour (trimmed, case ignored), so retyping
the shown colour in lower case no longer becomes an override of itself.

Behaviour kept on purpose: Enter on an unchanged value commits nothing,
so it neither restarts the beacon timer nor reports a stored value the
rule refuses. A field removed while it holds the focus (tab switched,
window closed) still commits a typed entry once, because Compose reports
the cleared focus as a focus loss; a test now pins this, as it was before.

Also: a test drives the Station tab's lock from ConnectionStateFeed
through MainWindowSurroundings, the chain the application wires, and the
Javadoc of openSettingsWindow is back above its own method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Judge a committed field against the live stored value

be6862c8 kept the value a field last committed in a holder keyed on
`stored`. That holder could outlive the truth: the field commits 9 over
5, nothing recomposes the tab, a profile switch puts 5 back and the tab
recomposes with 5 -- the same key -- so the holder still said 9. The
field showed 9, the preference held 5, and leaving the field committed
nothing, where main would have committed 9.

Form.committed now takes `current`, the value stored right now, read
when the field commits; the draft is judged against it and falls back to
it on a refusal. The holder is gone. Right after a commit the getter
already returns the new value, so Enter followed by leaving the field
still commits once. Every caller passes its preference getter: both
beacon templates, the beacon interval, the DX cluster port, the spotter
callsign and the colour roles.

`isUnchanged` must agree with what commit stores, which the KDoc now
says. The interval and the port compare as the number stored, the
spotter callsign ignoring case and surrounding blanks, the colours as
PaletteOverrides normalises them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Require the live value in committed fields and compare the spotter call as stored

Review follow-up. `current` has no default any more, so a future caller
cannot silently bring back the stale comparison. The spotter call is
compared after the same uppercase(ROOT) the commit applies, so a typed
"X" against a stored "x" still commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:08:31 +02:00
Rsclub2_2andClaude Opus 5.5 81b16ad4c5 Keep tests out of the user profile; small socket fixes (#127)
* Keep tests out of the user profile

The no-argument ChatPreferences constructor seeds preferences.xml in
~/.praktiKST when it is missing, so 21 older test files could create a
profile file for whoever ran the build. They now use ChatPreferences(Path)
with a @TempDir. None of them reads the file: their assertions are on the
field initialisers, which both constructors share, so no assertion changes
meaning. ChatPreferencesStationMapVisibilityTest passes its file to the
constructor instead of redirecting it with the setter afterwards.

The two opt-in ON4KST practice tests keep the no-argument constructor: they
deliberately read the operator's real profile for live credentials.

As a net, the test tasks run with user.home pointed at build/test-user-home,
emptied before each run, and fail if a preferences.xml appears there. This
also keeps the sound files, the map tile cache and the Skiko native library
that tests unpacked into the real home directory out of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make DXClusterThreadPooledServer.serverSocket volatile

run() assigns the server socket on the server thread, and stop() closes it
from another thread. closeServerSocket() is synchronized, but the write in
run() is not, so nothing guaranteed that stop() saw the socket run() had
opened; it could find null and leave the socket listening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the ineffective SO_REUSEADDR from the Win-Test sked socket

WinTestSkedSender.sendUdp() set reuse address on a socket the constructor
had already bound, where it has no effect, so it was never in effect in any
shipped version. The socket binds an ephemeral port and only sends, which
gains nothing from it, and enabling it for real would change UDP multi-bind
semantics on Windows. Removing the call keeps the behaviour exactly as
shipped and stops the code from claiming an option it does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check the test user.home in a finalizer task

The stray-preferences check ran as a doLast of the test task, which Gradle
skips once a test fails, so a failing run could hide the file. It is now a
separate task, checkTestUserHome, that every test task is finalized by;
finalizers run when the test task fails too. It captures only a File, so it
stays configuration-cache safe.

Each test task now gets its own scratch home, build/test-user-home/<task
name>, so a second test task cannot wipe another's. Emptying it checks the
result of deleteRecursively(), which reports failure instead of throwing,
and stops the task, so a leftover file is never blamed on the current run.

The check stays limited to preferences.xml. Other files land in the scratch
.praktiKST by design, such as the sound files PlayAudioUtils seeds from the
bundled resources; absorbing those is the redirection's job, and a wider
check would need an exclusion list that grows with every seeded file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep generateStrings configuration-cache safe

The argument provider of generateStrings referenced the script-level
generatedI18nDirectory, which captures the build script object, so storing
the configuration cache failed for every build that included app-desktop.
The task now works on a local copy of the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Declare the test user.home as a test task output

Test tasks are cacheable, and a cache hit skips doFirst, so the scratch home
was neither emptied nor rewritten and checkTestUserHome judged whatever an
earlier run had left there: a false alarm after a violating run, or a hidden
violation when a passing result for violating inputs came from the cache.
The scratch home is now a declared output, restored and replaced together
with the cached result, so the check always sees the files belonging to the
inputs. The doFirst wipe stays for real executions.

Skiko unpacked its native library, about 30 MB, into the scratch home, which
would now land in every app-desktop cache entry. skiko.data.path points it
at build/skiko-data instead, outside the output and kept between runs.

user.home and skiko.data.path are passed through an argument provider: as
system properties they would be task inputs, and their absolute paths would
make the cache key differ between checkouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:48:33 +02:00
Rsclub2_2andClaude Opus 5.5 2898c530d9 Restore the turn-antenna button and send the PSTRotator azimuth with one decimal (#126)
* Send the PSTRotator azimuth with one decimal instead of truncating it

setAzimuth cast the azimuth to int, so 145.7 degrees turned the antenna
to 145. It is now rounded to one decimal and formatted with Locale.ROOT,
so a German default locale cannot turn the dot into a comma; a value that
rounds up to 360.0 is sent as 0.0. The loopback test pinned the old
truncation and now expects the decimal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bring back the turn-antenna button lost in the Compose migration

The JavaFX station panel had "Turn ant1 to <call>", the only caller of
ChatController.rotateTo; removing JavaFX (fe4215bc) dropped it, so the
antenna could no longer be turned from KST4Contest at all. The Compose
panel gets the button again, enabled when a rotator callback is wired and
the selected station has a finite heading.

Review follow-ups for the decimal azimuth: formatAzimuth refuses NaN and
infinite values and wraps any finite value into [0, 360). The "reached"
check before the SPID retry uses a 1.0 degree tolerance, because the
target now carries a decimal while many controllers report whole degrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep rotateTo's Javadoc on the method and word the German label as a bearing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:47:53 +02:00
Rsclub2_2andClaude Opus 5.5 e1e6fe7297 Make MessageBusManagementThread testable, first slice (#105) (#123)
* test: restore the frequency-context and Band.fromPrefix tests (#105)

The test file MessageBusManagementThreadFrequencyContextTest sat under
src/main, fully commented out, and was deleted with #62 without ever
having run. Its two concerns come back as real tests: the bare
three-digit context rule of hasExplicitBareFrequencyContext in the
controller test package, and Band.fromPrefix as BandFromPrefixTest in
the model package. The original expectations still match the current
behaviour; a few boundary cases (context window, invalid indices,
non-exact prefixes) are added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: characterise the autoanswer path of MessageBusManagementThread (#105)

Runs real CH frames through a started thread (receive queue plus poison
pill, real transmit queue, mocked controller collaborators, a real
ChatPreferences in a temporary directory) and pins the current
behaviour of the private-message branch before it is refactored:

- a QRG question addressed to the login callsign yields exactly one
  reply with the fixed marker and the QRG of the incoming category;
- a second question from the same station and category within the
  cooldown yields none, while another station or category is answered;
- a frame carrying the autoanswer marker, a public frame, a frame to
  another station and a disabled setting yield none;
- an empty QRG and a blank generic text are rejected before the
  cooldown is recorded, so a later valid reply still goes out;
- //VER, the generic reply and its precedence below the QRG reply.

One oddity is pinned, not fixed: a lower-case login callsign defeats
the "directed to me" comparison because the receiver callsign is
upper-cased by ChatMember.setCallSign but compared with equals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: extract the autoanswer decisions into pure functions (#105)

The QRG-question texts become a constant set; matchesQrgQuestion,
isAutoMessage and buildAutoAnswerMessageText are static package-private
functions with explicit parameters instead of reaching into the
preferences, and the cooldown reads an injectable millisecond clock
(default System::currentTimeMillis) through a new constructor overload.
Behaviour is unchanged: the characterisation test from the previous
commit passes untouched, the new decision and cooldown tests cover the
extracted functions without mocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: extract frequency detection into a pure function (#105)

smartFrequencyExtraction now copies the sender's band timestamps, reads
the fallback prefix and the injectable clock, and hands the text to the
static package-private detectFrequencies, which returns the detections
in message order as DetectedChatFrequency records. The thread still
stores each one through applyDetectedFrequencyToActiveMembers and logs
it as before.

Behaviour is unchanged. In production the stored detection lands in
the same map the next match of the message reads, so detectFrequencies
feeds every detection back as context with the current time; a test
pins that "10368.100 then .150" yields 10368.150. The unreachable
NumberFormatException catches around parseExplicitFrequency are gone,
since that parser already returns null instead of throwing. Also pinned:
a plain decimal between 50 and 54 (e.g. "qrb 52,3 km") is read as a
6 m frequency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: extract the directional-opportunity and spot decisions (#105)

classifyDirectionalOpportunity returns MISSING_LOCATOR, IN_ANGLE_AND_RANGE
or OUT_OF_ANGLE_OR_RANGE from the locators, the maximum QRB and the beam
width, and replaces the identical locator check plus isInAngleAndRange
call in both the live (CH) and the history (CR) branch.
shouldPlayDirectionalTick, decideDxClusterSpot (NO_FREQUENCY,
ALREADY_WORKED, SPOT) and buildDxClusterSpotEntry take over the tick
condition and the automatic DX Cluster spot of the CH branch. All are
static package-private and covered without mocks.

Behaviour is unchanged. Pinned: the spot decision only checks that the
frequency text is non-blank, so a value outside every supported band is
spotted even for a station flagged as worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: remove dead code from MessageBusManagementThread (#105)

processRXMessage23000 belonged to the long-retired raw port-23000
interface and had no caller. With it go the two helpers only it
reached, checkIfItsUserListEntry and checkIfMessageInhibitsFrequency,
plus the caller-less checkIfItsUserListEntry23001 and
checkListForChatMemberIndexByCallSign, the patterns PTRN_USERLISTENTRY,
PTRN_QRG_CAT2 and PTRN_QRG_CAT3, the unused chatMemberTable field and
the serverReady flag with its never-called accessors. A grep across core
and app-desktop finds no remaining reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: keep the sender-category fallback in the QRG auto-answer warning (#105)

Review follow-up. The extracted buildAutoAnswerMessageText logged only
the message category, while the old warning used autoAnswerCooldownKey,
which falls back to the sender's category. The caller now passes the
same fallback, so the log line is unchanged from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 10:21:37 +02:00
Rsclub2_2andClaude Opus 5.5 4b10d96091 Compose screen tests with runComposeUiTest (#107) (#125)
* test: draw and click the in-window menu row (#107)

Pins what MainMenuModelTest cannot see: every item picked through the
drawn row runs its own action, the enabled states and the away and
window-toggle labels follow their state while the menu is down, and a
palette or language change reaches the row on screen.

Named regressions: fe4215bc (menu actions hung on deleted JavaFX
MenuItems) and 83e23f55 (unthemed Swing menu bar), both in main as
2a5c89b9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: draw the station list with its real row key (#107)

The station row key moves from an inline Java lambda into
StationColumns.rowKey, unchanged in its text, so the list can be tested
with the key the application really uses.

The screen test pins three regressions on the drawn table: the same
callsign in two categories collided under one key and sorting dropped
the filters (both 21251377), and the AP column sorted by its text
(ab282162, #87). It also pins that rows replaced after the first draw
reach the screen and that the selection follows a station by key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: draw the monitor window's live tables (#107)

MonitorContent becomes internal so its body can be drawn headless.

The screen test pins the defects the port shipped in this window
(cc8c9020, in main as 2a5c89b9): rows arriving while the window is
open, also after a redraw that was not about the rows, must reach both
tables, and two messages that read alike stay two rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: draw and click the station map window's body (#107)

StationMapWindow.show now hands its body to an internal Content
composable that takes the tile fetcher, the member roster and the map
state as parameters, with the old inline values as defaults, and the
map canvas carries a test tag.

The screen test pins the map selection fixed in 21251377: a marker
click and Reset view go through the central station selector, a click
keeps the category variant already selected and prepares "/cq CALL",
and neither changes the zoom. The header switches write the profile.
Tiles never arrive: an empty temporary cache and a failing source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: draw the Station, Log synch, TRX synch and Airscout tabs (#107)

Each tab is composed headless and checked for its sections and key
controls, and editing a control through the drawn form must write
through to ChatPreferences and redraw. Refused entries stay on screen
as typed and leave the stored value alone. A small helper finds a
control by the label beside it, the way the operator finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: draw the Message handling, GUI, Beacon and Notification tabs (#107)

Each tab is composed headless with its sections and key controls, and
edits through the drawn controls reach ChatPreferences and the
application callbacks: the auto answer for both categories, the
debug-to-file switch c8ce0e98 restored, the startup design and the
language, the beacon templates and interval, the cluster server, the
fallback band, the test spot and the monitored callsign dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: draw the Shortcuts and Worked database tabs (#107)

The shortcut and snippet lists show what the profile holds, and an edit
or an added entry made in the drawn list reaches the roster and the
refresh hook the button row depends on (the hook that was called into
thin air before fe4215bc). The worked database lists its rows, re-reads
them on refresh, asks before a reset and leaves the lists alone when
the database reset fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: draw the Profiles and Colours tabs (#107)

Profiles: the default profile and every action are on screen, deleting
it is refused after the confirmation, a cancelled New dialog creates
nothing, and switching hands the selected profile to the application.
Only a temporary registry is touched.

Colours: the six roles show the resolved colour, a typed colour is
committed into the store, a mistyped one is refused and put back, the
shipped reset undoes a change, and the stylesheet export writes once
into a temporary profile directory and never over an existing file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: give the map's path analysis a seam and tag the monitoring field (#107)

StationMapWindow.Content takes a MapPathAnalysis supplier. The default
looks the running application's reachability service up on every
selection, exactly as the inline chat-controller lookup did, and null
still means: no pan, no analysis. This lets a test run the zoom rule
on selection that cc8c9020 fixed.

The callsign field of the add-to-monitoring dialog in NotificationTab
carries a test tag, so a test types into it by name rather than by
position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: isolate and harden the screen tests after review (#107)

- No screen test builds ChatPreferences() any more: that constructor
  resolves the real user profile and seeds preferences.xml there. Each
  class uses ChatPreferences(Path) under a @TempDir.
- Compose state changed from the test thread (roster refresh, language
  reset) goes through runOnIdle.
- Dialogs and dropdowns are waited for with waitUntil and found inside
  their overlay (isDialog / isPopup) instead of by tree order; the
  monitored-callsign field is found by its test tag.
- The menu test now asserts Disconnect when online and Connect when
  offline instead of skipping one.
- The map test runs the zoom-on-selection rule through the new seam,
  closes its tile fetcher after each test and keeps its cache in the
  @TempDir.
- StationColumns.rowKey is pinned directly: two categories, two keys.
- The menu colour is sampled further inside the row, and the station
  list click picks its row by position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: prove the map's selection effect ran when no analysis is available (#107)

Review follow-up. The no-analysis test only asserted that the map stayed
put, which would also hold if the selection effect never ran; it now
counts the analysis lookup. The effect reads the lookup through
rememberUpdatedState, so a caller passing a new lambda is not served a
stale one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:21:14 +02:00
Rsclub2_2andClaude Opus 5.5 2db71ee864 Loopback tests for the network threads (#106) (#124)
* test: pin the Win-Test sked frames over a loopback socket (#106)

WinTestSkedSender is pointed at the loopback address instead of a
broadcast address, and a socket on an ephemeral port receives the
LOCKSKED/ADDSKED/UNLOCKSKED sequence. The frames and their checksum
byte are written out literally, including the \260 degree escape and
the SSB fallback for an unexpected mode ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: cover the PSTRotator UDP client over loopback (#106)

A socket on an ephemeral loopback port plays PSTRotator. The tests pin
the poll queries, the <PST> command frames, the AZ:/EL:/MODE: replies
reaching the listener, an unparsable reply leaving the loop alive, and
the explicit-port constructor reporting its status after start() (#115).
The reply format is derived from the parser and the PSTRotator UDP
documentation, not from a capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: cover the AirScout UDP listener over loopback (#106)

ReadUDPbyAirScoutMessageThread gains a package-private constructor that
takes an already bound socket, as ReadUDPbyUCXMessageThread has; the
public constructor still binds the configured port in run(). The tests
pin applying an ASNEAREST response to every active variant of the
receiver, the case-sensitive server/client match with a UTF-8 client
name, the disabled listener, the poison pill, recovery from a malformed
response, and the date losing its space because the parser strips all
spaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: cover UCXLog/DXLog QSO packets over loopback (#106)

The UCX loopback test now sends contactinfo and contactreplace packets:
transport bytes before the XML are dropped, the QSO reaches the
controller with band, locator and source, the gross field is
registered, an unknown station is stored in the database and the UDP
backup file, a known one is only updated, and nothing happens while log
sync is disabled. A RadioInfo packet sent afterwards serves as an
ordering marker instead of a sleep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: cover the local DX Cluster server over loopback (#106)

DXClusterThreadPooledServer gains a package-private constructor that
takes an already bound server socket; the public constructor still binds
the configured port in run(). Plain sockets on the loopback address act
as logging programs. The tests pin the listening status, the "login: "
prompt, one DXSpider-style line with BEL BEL CR LF per spot to every
client, the rejection of spots that cannot be formatted, and stop()
closing the clients and ending the server thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the UCXLog/DXLog listener alive on bad input (#106)

An unwritable or empty backup path left the FileWriter null, and the
following BufferedWriter threw a NullPointerException; a RadioInfo
without <Freq> or <Mode> threw one as well. Neither was caught, so the
listener thread ended for good. The backup line is now written with
try-with-resources and skipped with a log line when the file cannot be
opened, an incomplete RadioInfo element is skipped, and run() drops a
packet whose processing throws any RuntimeException, reports a
non-critical status and keeps receiving.

The loopback tests cover all three paths. The interrupt test now waits
for a processed marker packet instead of sleeping and no longer claims
more than it can observe, and the marker helper states its in-order
assumption for UDP on loopback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let a second DX Cluster server run() bind again (#106)

The pre-bound-socket constructor made run() skip binding whenever a
server socket existed. A run() after the first one ended would then
accept on a closed socket. Bind whenever the socket is missing or
closed, which restores the behaviour of the public constructor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: make the ignored AirScout responses observable (#106)

The tests for responses to another client or server and for the
disabled listener only checked that nothing happened before the poison
pill, which a listener that never read the datagrams would also pass.
Each now sends a valid sentinel afterwards and waits for it, and the
ignored responses name a different receiver, so a wrongly processed
copy is detectable; the disabled case also shows that the switch was
asked once per response. The date test is renamed and documented as a
pinned known defect, so a parser fix knows to flip it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: wait for the PSTRotator listener thread after stop() (#106)

The client keeps its listener thread private, so the test finds it by
the name start() gives it and joins it after stop(), asserting that
exactly one such thread existed and that it ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the UCX listener alive when the status callback throws (#106)

Review follow-up. The safety net reported a dropped packet through the
status callback without guarding it, so a throwing callback still ended
the listener. The test for an unwritable backup file is renamed to what
it proves, and the changelog mentions that any unexpected error now
drops only the affected packet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:19:22 +02:00
Rsclub2_2andClaude Opus 5.5 4679c708bc Fix UDP listener defects (#115) (#122)
* fix: let ReadUDPbyUCXMessageThread own its DatagramSocket so interrupt() closes it (#115)

The field `socket` was a java.net.Socket that was never assigned; the real
DatagramSocket lived in a local variable of run(). interrupt() therefore
closed nothing and terminateConnection() would have thrown a
NullPointerException. The receive loop kept running in two-second timeout
steps until the user-disconnect flag was set.

The field is now the DatagramSocket, run() binds it when no pre-bound socket
was handed in, the loop ends on the interrupt flag or a closed socket, and
both interrupt() and terminateConnection() are null-safe. A package-private
constructor accepts a pre-bound socket so loopback tests can listen on an
ephemeral port; the public constructor used by ChatController is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: stop ReadUDPbyUCXMessageThread truncating datagrams after a short packet (#115)

A DatagramPacket keeps the length of the last datagram it received, so a
long RadioInfo packet following a short one was cut to the short one's
length and failed to parse. The length is reset to the buffer size before
every receive, the same way ReadUDPByWintestThread does it. The loopback
test sends a short and then a long packet on port 0 and checks that both
frequencies arrive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: give PstRotatorClient's port constructor a callback, decode AirScout as UTF-8 (#115)

The explicit-port constructor left the thread-status callback null, so
start() and the first received packet threw an NPE; the default
constructor now delegates to it. The AirScout listener decoded replies
with the platform charset although the inquirer sends UTF-8.
PROJECT_CONTEXT listed PSTRotator as TCP; the client speaks UDP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: decode UCX datagrams as UTF-8 and tighten the interrupt test (#115)

Review follow-up. UCXLog sends UTF-8 XML, but the listener decoded with
the platform charset before handing a String to the parser, so the XML
header's encoding was ignored. The port-in-use fallback now sets its
receive timeout before the first receive(). The interrupt test waits
1 s, below the 2 s receive timeout, so it fails if interrupt() stops
closing the socket; the QRG latches now count one value each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 10:12:46 +02:00
Rsclub2_2 6b0c80f71e Add Supporters/Sponsor section to README
Added a Supporters/Sponsor section to acknowledge community contributions.
2026-10-15 09:04:35 +02:00
dependabot[bot] fb38c858bf build(deps): bump actions/setup-node from 4 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 08:16:50 +02:00
dependabot[bot] 0ee29dc585 build(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 08:16:25 +02:00
dependabot[bot] 71b2d3931c build(deps): bump actions/download-artifact
Bumps the minor-and-patch group with 1 update: [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v8.0.1...v8.0.2)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 08:15:17 +02:00
Rsclub2_2andClaude Opus 5.5 dd9f87a767 Build AUR source packages against their own Gradle home, not offline
Both source PKGBUILDs ran "./gradlew --offline" against the builder's
personal ~/.gradle. That only worked as long as the local cache happened to
hold every dependency; the SpotBugs plugin 6.5.12 added in #114 broke
"yay -S kst4contest-git" with "could not resolve plugin artifact".

- drop --offline and point GRADLE_USER_HOME into $srcdir, so the package
  builds the same on any machine
- run with --no-daemon so no Gradle process outlives makepkg
- drop gradle from makedepends: the wrapper fetches its own distribution

Verified with makepkg -f on an empty Gradle home for kst4contest-git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:25:55 +02:00
Rsclub2_2andClaude Fable 5.1 7b44eb0c2e Reference cases for the path-analysis mathematics (#108) (#117)
* test: pin the Maidenhead and great-circle maths of Location against definitions (#108)

Subsquare centres from the Maidenhead definition, round trip through
toMaidenhead (lower-case subsquare pinned as is), distance and bearing
between locators against an independent spherical computation on
R = 6371.009 km, consistency with PathGeometryUtils, and the NaN bearing
for identical points pinned as current behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: pin PathGeometryUtils against ITU-R P.525/P.526 and textbook values (#108)

Great circle against the Veness worked example, Fresnel radius against
P.526-15 eq. (2) and (3), earth bulge against the 8500 km effective
radius, radio horizon against 4.12*sqrt(h), knife-edge loss J(v) against
eq. (31), the v parameter against eq. (26), free-space loss pinned at the
project's 32.44 constant with the P.525 and exact values documented,
plus wattsToDbm, the sample-count rule and the k-factor sanitiser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: pin the path-analysis pipeline on a flat profile (#108)

A flat 20 km profile with 10 m antennas at 144.3 MHz through an
in-memory terrain provider: bulge, line-of-sight clearance, first
Fresnel radius and intrusion, no knife edge and 0 dB diffraction. Pins
that a partial Fresnel intrusion without a blocked line of sight
contributes 0 dB to the link budget; P.526 would give 0 to 6 dB there,
which is tracked separately.

Fixes #108

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 18:39:48 +02:00
Rsclub2_2andClaude Fable 5.1 91863fee1f Remove Thread.sleep timing coupling from the tests (#109) (#118)
* test: add bounded awaitUntil helper for timing-sensitive tests (#109)

One small class per module (core and app-desktop share no test sources):
polls a condition every 10 ms and fails with a message when the timeout
elapses, so tests no longer guess with fixed Thread.sleep pauses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(map): let TileFetcher take its nano clock as a test seam (#109)

A package-private constructor accepts a LongSupplier for the monotonic
clock the retry pause is measured against; the public constructors
delegate with System::nanoTime, so production behaviour is unchanged.
TileFetcherTest advances a fake clock instead of sleeping 20 ms three
times and hoping the 1 ms pause has elapsed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(view): put CoalescingTrigger on a DelayTimer seam (#109)

DelayTimer is a package-private one-shot timer (restart/stop) with a
Factory; DelayTimer.SWING wraps javax.swing.Timer and is what the public
constructor uses, so production behaviour is unchanged. A package-private
constructor takes the factory. CoalescingTriggerTest fires a FakeDelayTimer
itself: the negative checks (no second run, cancel prevents the run) are
now deterministic and the four Thread.sleep calls are gone. One test keeps
the real Swing timer end to end, waiting with awaitUntil.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(view): put LayoutAutosave on the DelayTimer seam (#109)

The public constructor still builds a javax.swing.Timer via DelayTimer.SWING
with the same 750 ms delay; a package-private constructor takes the factory.
LayoutAutosaveTest fires a FakeDelayTimer on the event thread itself, so
"cancelled means not written" no longer rests on a 1.2 s sleep and the
burst test no longer sleeps 300 ms to prove the absence of a second write.
The foreign-thread test keeps the real Swing timer path with awaitUntil.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(controller): inject scheduler and clock into On4KstConnectionManager (#109)

A package-private constructor takes the ScheduledExecutorService and a
LongSupplier for wall-clock milliseconds; the existing constructor delegates
with the same single-thread supervisor and System::currentTimeMillis, so
production behaviour is unchanged. Session's idle clocks start from the
injected clock instead of a field initializer. The static
parseMessageTimestamp keeps the wall clock: it sanity-checks server
timestamps and is not part of the supervisor's timing.

On4KstConnectionProbeTest drives unansweredProbeUsesExistingReconnectFlow
through a ManualScheduler (immediate work on a real thread, delays and the
five-second tick released on demand) and a fake clock: 90 s of silence
sends the CK probe, which the test reads off the socket, 210 s fails the
session, and the released backoff opens the second connection. The
reflection-based state setup is gone because the state is now reached the
production way; the test takes well under a second instead of seven. The
two ignored await(2 s) results in the server stubs are asserted now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: close the profile picker as soon as it shows instead of after 1.5 s (#109)

The two display-bound picker tests disposed the modal dialog from a
Swing timer after a fixed 1.5 s, which was long enough on a fast machine
only and cost 3 s per run. The closer now waits with awaitUntil for the
dialog to appear and disposes it on the event thread; the suite drops
from 3.0 s to 0.26 s.

Remaining fixed sleep: TopPriorityCandidatesWindowTest waits 400 ms for
Compose's double-tap timeout; whether ImageComposeScene's time can
advance it deterministically is still to be checked.

Fixes #109

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 18:39:21 +02:00
Rsclub2_2andClaude Fable 5.1 f2b0fdd5b4 Write SpotBugs reports and publish static analysis on pull requests (#110) (#114)
* build: write SpotBugs XML and HTML reports (#110)

The SpotBugs tasks had no report configured, so findings went to stdout
only and, with ignoreFailures, scrolled past unread. Every SpotBugsTask
now writes XML (for CI to count) and HTML (for people) to the plugin's
default location, <module>/build/reports/spotbugs/main.{xml,html}.
ignoreFailures stays; the build still passes with findings present.

Fixes #110

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: run PMD and SpotBugs on pull requests and publish the reports (#110)

The compile job runs pmdMain and spotbugsMain after compiling (report
only: both tools have ignoreFailures, a crash of a tool still fails),
counts <violation> and <BugInstance> elements per module from the report
XML into the job summary, and uploads */build/reports/pmd and
*/build/reports/spotbugs as the static-analysis artifact for 14 days.
A missing report prints "no report" without failing the step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: name the PMD and SpotBugs report files and the CI artifact (#110)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:49:08 +02:00
Rsclub2_2andClaude Fable 5.1 89d37e62e1 Give beta DEB and RPM packages a pre-release number (#113) (#116)
* build: give beta DEB and RPM packages a pre-release number (#113)

composePackageVersion is the package version of every Compose target
format, so an RPM or DEB built from a beta tag carried exactly the
version of the following stable one. dnf, rpm -U and dpkg refuse to
replace a package by one of equal version, so a beta user could not
move to the stable.

Keep the version and tell the packages apart by the package release
number, following the Fedora pre-release convention: the new Gradle
property linuxAppRelease is passed to jpackage as --linux-app-release
(Compose LinuxPlatformSettings.appRelease) and defaults to 1.

  beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001
  beta-v1.50.0-rc02     -> 1.50.0-0.rc02
  v1.50.0               -> 1.50.0-1

Everything starting with "0." sorts below "1" for both rpm and dpkg.
Nightlies and local builds do not set the property and keep release 1.

Fixes #113

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: derive the Linux release number from the tag and verify the tag version (#113)

The DEB and RPM jobs now compute the package release from the tag
before calling Gradle and pass it as -PlinuxAppRelease: a stable
vX.Y.Z tag gives 1, a beta-vX.Y.Z-<suffix> tag gives 0.<suffix> with
"-" turned into ".". The result must match [A-Za-z0-9.]+, otherwise
the step fails. A workflow_dispatch run from a branch keeps release 1
like the nightlies; nightly-artifacts.yml is unchanged.

The test job, which every packaging job depends on, now checks right
after the checkout that the X.Y.Z in the tag equals
composePackageVersion in gradle.properties and fails with a clear
message otherwise, so a mistagged commit fails before an hour of
packaging rather than shipping packages whose version contradicts
their file name and upgrade order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: describe the pre-release number of beta DEB and RPM packages (#113)

The German and English installation manuals say in the Debian/Ubuntu
and Fedora sections that beta packages carry a pre-release number
below the stable one, so the stable installs as an upgrade over a
beta. The v1.50 changelogs gain the matching "Fixed" entry, and
PROJECT_CONTEXT.md records the rule next to the MSI decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:48:49 +02:00
Rsclub2_2andClaude Fable 5.1 2eb1ba22cf Windows MSI installer next to the portable ZIP (#61) (#111)
* build: configure the Windows MSI installer in the Compose windows block (#61)

Add the jpackage/WiX options for a per-user MSI next to the existing
icon: a fixed upgradeUuid so Windows Installer replaces an installed
KST4Contest instead of installing side by side, a Start menu group and
desktop shortcut, no UAC prompt, and a directory chooser. The portable
ZIP (createDistributable) is unaffected; the MSI is produced only by
the tagged-release workflow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: build and attach a Windows MSI installer for tagged releases (#61)

The Windows job of tagged-release.yml now runs packageMsi after the
portable ZIP, renames the result to KST4Contest-<tag>-windows-x64.msi
(consistent with the other platforms; the ZIP keeps its praktiKST name)
and uploads it as the windows-msi artifact, which the release job
downloads and attaches next to the ZIP. The nightly workflow stays
ZIP-only because a nightly MSI would always carry the same version.

The website download data (downloads.js, beta.js) gets an MSI card that
is shown only when the release actually has that asset, so the page
never links to a file that does not exist for an older release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: describe the Windows MSI installer next to the portable ZIP (#61)

The German and English installation manuals list the MSI in the package
table, describe both Windows forms (installer: per user, Start menu
entry, desktop shortcut, chosen directory, in-place upgrade, removal via
the Windows apps list; ZIP: portable) and the shared data directory
%USERPROFILE%\.praktiKST, and add the MSI path to the Windows update
section. Changelog entries for v1.50 and a note in PROJECT_CONTEXT.md
record the fixed upgrade UUID and the tags-only decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: build the Windows MSI for stable tags only (#61)

A beta MSI would carry the same composePackageVersion as the following
stable release. jpackage's MSI upgrade table only accepts strictly higher
versions, so Windows Installer would refuse the stable over an installed
beta and the tester would have to uninstall first. Betas therefore stay
ZIP-only like nightlies; the build, upload and download steps are skipped
for beta- tags, and the release action tolerates the missing asset path.
Manual, changelog and project context say so.

Raised by the Codex review on #111.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:27:43 +02:00
Rsclub2_2andClaude Fable 5.1 5491cbcf8b Test suite cleanup, JaCoCo coverage report and tests before packaging (#62) (#112)
* docs: add the test-coverage design spec (#62)

Records the state found for issue #62 (JUnit classes under src/main that
never run, test libraries in the shipped app image, dead 2023/2024 scratch
tests, no coverage figure), the decisions taken (delete, move to src/test,
JaCoCo report-only without a threshold, tests before packaging), what is
deliberately left to separate issues, and the definition of done.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: move the JUnit classes out of src/main and drop the dead 2023 tests (#62)

Gradle's test task scans src/test only, so the six JUnit classes under
src/main compiled on every build and were never executed. They move to
src/test of the module that owns the class under test, keeping their
package names: On4KstProtocolTest, On4KstSocketThreadTest and
ChatMemberDirectionOpportunityTest within core; Utils4KSTFrequencyTest,
FrequencyTextParserTest and ChatPreferencesStationMapVisibilityTest from
app-desktop into core, next to the sibling tests that already live there,
because they test core classes only. All 26 of them run and pass.

The fully commented-out MessageBusManagementThreadFrequencyContextTest is
deleted. Eight files under core/src/test/java/kst4contest/test from
2023/2024 go as well: they carry no assertion, five of them annotate a
static main() with @Test, which Jupiter never runs, and some write the real
preferences file, send real UDP broadcasts or open the real user database
when started by hand. The five main() scratch programs under src/main
(ReadUDPByWintestThreadTest, PatternMatcherTest, mapTest, RegexTester,
locatorUtils/TestLocatorUtils) and MockKstServer, which only an unused
import in ChatController still referenced, are removed too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* build: make JUnit and Mockito test-only dependencies (#62)

With no test class left under src/main, the implementation declarations of
junit-jupiter-api and mockito-core in core and app-desktop lose their only
justification. They put junit-jupiter-api, junit-platform-commons,
opentest4j, mockito-core, byte-buddy, byte-buddy-agent and objenesis on the
runtime classpath and into lib/app of every app image, Flatpak and
installer. The duplicate catalog entries junit-jupiter-api,
mockito-core-compile and the mockitoCompile version go with them.

Verified: `:app-desktop:dependencies --configuration runtimeClasspath` no
longer lists any of the seven artifacts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: state that test failures fail the build and tests live under src/test (#62)

AGENTS.md and docs/PROJECT_CONTEXT.md still said the build configuration
had allowed test failures to pass. That was true of the Maven build; under
Gradle a test failure fails the build, and only PMD and SpotBugs run with
ignoreFailures. Both documents now say so, note that test classes live under
src/test only and that JUnit and Mockito are test-only dependencies, and
keep the rule to read the test-result XML rather than the exit code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* build: add a JaCoCo coverage report (#62)

The jacoco plugin is applied to every module from the root build with the
tool version held in the catalog. jacocoTestReport now depends on test and
writes XML and HTML per module; a root task jacocoAggregatedReport merges
core and app-desktop into build/reports/jacoco/aggregated/ (XML plus HTML).
i18n-generator is excluded: it is a build tool and its coverage says nothing
about the shipped application. Kotlin classes are counted, because the
Kotlin plugin adds its output to the main source set the report reads.

Report only, on purpose. There is no jacocoTestCoverageVerification and no
threshold: a minimum set at today's number would trip on the next unrelated
change, and one set at a target would fail every build until tests exist for
the number's sake. The figure is published so it can be read when deciding
what to test next; the reasoning is in
docs/superpowers/specs/2026-10-11-test-coverage-design.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: run the tests before packaging and publish coverage (#62)

The nightly and tagged-release workflows built seven package formats from
commits nobody had tested on the way: pr-compile-check.yml runs the suite
for pull requests only, and main can be pushed to directly. Both workflows
now carry a copy of that test job, including the libgl1 step and the
XML-based summary, and every packaging job declares `needs: test`. Copied
rather than factored into a reusable workflow on purpose: the comments in
the job explain decisions the packaging workflows should carry with them,
and three copies read better than one indirection. build-docs-pdf runs
pandoc only and is left alone; publish-flatpak-repo and release-tag are
gated through the jobs they already need.

The PR test job additionally runs jacocoTestReport and
jacocoAggregatedReport once the tests have run (not `if: always()`: a
report from an incomplete run would mislead), prints a line/branch coverage
table per module and aggregated into the job summary from the JaCoCo XML,
and uploads the aggregated HTML report as the coverage-report artifact for
14 days whenever it exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: describe the coverage report in the project context (#62)

A short paragraph on JaCoCo in docs/PROJECT_CONTEXT.md: report only and
why, where the per-module and aggregated reports land, which Gradle tasks
write them, that i18n-generator is excluded, how the PR job summary and the
coverage-report artifact show the figure, and that the nightly and
tagged-release workflows run the tests before any packaging job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: round-trip every ChatPreferences field through the XML file (#62)

Drive all public single-argument setters by reflection to non-default
values, write preferences.xml, read it back and compare every getter.
Fields that are not persisted on purpose are listed with their evidence;
two real reader/writer gaps (second-category locator, sniffer words) are
documented with a BUG marker instead of being fixed here. Two further
tests load the bundled template and a legacy file without configVersion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: cover the DBController schema migration from older user_version values (#62)

Open the seeded template database and hand-built release 1.1 and 1.3
schema fixtures with a DBController and verify that PRAGMA user_version
reaches the current level, every column of the current CREATE TABLE
statements exists, legacy callsign keys are merged to base callsigns and
existing rows and flags survive. A further test proves that reopening an
up-to-date database changes neither schema nor rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: drop the committed JVM crash report and ignore future ones (#62)

hs_err_pid26344.log in the repository root was a HotSpot crash dump that
had been committed by accident. It carries no information about the
project and must not travel with the sources.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: drop the dead QSO sniffer word list that corrupted the callsign list on load (#103)

ChatPreferences carried a second sniffer list, lstNotify_QSOSniffer_sniffedWordsList,
that no UI, no manual and no consumer outside ChatPreferences ever touched. It was
still written to preferences.xml as <snifferWords><w>, and the matching reader block
was a copy of the <snifferCallSigns> block: it cleared the word list and then added
every word, normalised as a callsign, to lstNotify_QSOSniffer_sniffedCallSignList.
Any entry that ever reached the word list therefore leaked into QSO monitoring on
every load.

Remove the field, its accessors, the write block and the read block. A <snifferWords>
element in an older preferences file is now ignored like any unknown element; the
callsign list and its persistence are unchanged.

ChatPreferencesSnifferListTest loads a file that carries both lists and asserts that
only the <snifferCallSigns> entry is monitored. The round-trip test loses its
exclusion entry for the removed setter.

Fixes #103

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: remove the second-category locator that could never be set (#104)

ChatPreferences declared stn_loginLocatorSecondCat with the compiled-in default
"jn49fk". Nothing wrote it to or read it from preferences.xml, no settings tab
exposed it and no caller ever invoked the setter, so the field always held its
default. On4KstConnectionManager.configureMainChat nevertheless compared that
default with the station's locator and, with the second chat enabled, raised the
warning "ON4KST uses one locator per TCP session. The second-chat locator ... is
ignored" whenever the station's locator was not jn49fk, which is practically every
installation.

ON4KST uses one locator per TCP session by design, so a second-category locator can
never become meaningful. Remove the field, its accessors, the comparison and the
warning; the rest of configureMainChat is unchanged. The round-trip test loses its
exclusion entry for the removed setter.

No automated test covers configureMainChat: it is private, works on the manager's
private Session and needs a mocked ChatController plus a live socket handshake, so a
harness would far exceed the scope of this fix. The compile of core and app-desktop
and the shorter exclusion list are the evidence that nothing references the member.

Fixes #104

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:13:31 +02:00
Rsclub2_2andClaude Fable 5.1 4a90ae7d30 docs: describe worked-state check for automatic DX cluster spots (#91)
Add the new spot condition to the DX Cluster Server manual pages and the
forwarding section of the Features page in German and English, record
the fix in the v1.50 changelog, and note the rule in PROJECT_CONTEXT.

Fixes #91

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 01:12:11 +02:00
Rsclub2_2andClaude Fable 5.1 78de8b0a9c fix: suppress automatic DX cluster spots for worked stations (#91)
Automatic spots from directional opportunities were generated without
looking at the worked state, so stations already in the log kept being
spotted to the connected loggers.

The spot block in MessageBusManagementThread now asks
isAlreadyWorkedForDxClusterSpot(sender) before broadcasting. The rule:

- the band is taken from the sender's known frequency via
  FrequencyTextParser; an unknown band keeps the previous behaviour;
- the per-band worked flag of that band suppresses the spot
  (ChatMember.isWorkedOnBand maps B_1296/B_2320/B_5760 to the
  historically named worked1240/worked2300/worked5600 flags);
- the global worked flag suppresses the spot only when no per-band
  flag exists at all (Simplelogfile source without band information);
- the global flag together with per-band flags for other bands does
  not suppress, because the per-band information is authoritative.

Suppressed spots are logged at FINE. The direction check, the
inAngleAndRange marker, the tick sound and manual spots from the map
window and the settings test spot are unchanged.

Fixes #91

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 01:12:02 +02:00
Rsclub2_2andClaude Fable 5.1 ab28216280 fix: sort the AP column numerically instead of by its text (#87)
The "AP [minutes / pot%]" column of the station list and of the directed
messages had no comparator, so DataTableState fell back to the rendered
text and "13 (75%)" sorted above "3 (50%)".

AirplaneCell now owns a shared comparator, AirplaneCell.order, that both
column definitions use. The sort key is the first aeroplane of the cell,
which is the one the operator reads and the one the colour marks:
ascending by its arriving minutes, then by the better potential first for
equal minutes. A null info (nothing computed) and an empty list ("nil")
carry Int.MAX_VALUE, so they sort after every real aeroplane in ascending
order, consistent with the qrb and qtf sentinels. The second aeroplane of
a cell does not influence the order. Cell text, colours, column ids,
titles and weights are unchanged, as are DataTableState and the sort
cycle.

Fixes #87

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 01:09:49 +02:00
dependabot[bot] 456957808d build(deps): bump the minor-and-patch group across 1 directory with 5 updates
Bumps the minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [gradle-wrapper](https://github.com/gradle/gradle) | `9.7.1` | `9.8.1` |
| [org.xerial:sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) | `3.43.2.2` | `3.53.4.0` |
| [org.mockito:mockito-core](https://github.com/mockito/mockito) | `5.7.0` | `5.24.0` |
| [org.mockito:mockito-core](https://github.com/mockito/mockito) | `5.7.0` | `5.24.0` |
| [org.mockito:mockito-junit-jupiter](https://github.com/mockito/mockito) | `5.7.0` | `5.24.0` |
| com.github.spotbugs | `6.0.26` | `6.5.12` |

Updates `gradle-wrapper` from 9.7.1 to 9.8.1
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.7.1...v9.8.1)

Updates `org.xerial:sqlite-jdbc` from 3.43.2.2 to 3.53.4.0
- [Release notes](https://github.com/xerial/sqlite-jdbc/releases)
- [Changelog](https://github.com/xerial/sqlite-jdbc/blob/master/CHANGELOG)
- [Commits](https://github.com/xerial/sqlite-jdbc/compare/3.43.2.2...3.53.4.0)

Updates `org.mockito:mockito-core` from 5.7.0 to 5.24.0
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v5.7.0...v5.24.0)

Updates `org.mockito:mockito-core` from 5.7.0 to 5.24.0
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v5.7.0...v5.24.0)

Updates `org.mockito:mockito-junit-jupiter` from 5.7.0 to 5.24.0
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v5.7.0...v5.24.0)

Updates `com.github.spotbugs` from 6.0.26 to 6.5.12

---
updated-dependencies:
- dependency-name: com.github.spotbugs
  dependency-version: 6.5.12
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: gradle-wrapper
  dependency-version: 9.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: org.mockito:mockito-core
  dependency-version: 5.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: org.mockito:mockito-core
  dependency-version: 5.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: org.mockito:mockito-junit-jupiter
  dependency-version: 5.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: org.xerial:sqlite-jdbc
  dependency-version: 3.53.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] e67e2db2aa build(deps): bump junit from 5.10.1 to 6.1.3
Bumps `junit` from 5.10.1 to 6.1.3.

Updates `org.junit.jupiter:junit-jupiter` from 5.10.1 to 6.1.3
- [Release notes](https://github.com/junit-team/junit-framework/releases)
- [Commits](https://github.com/junit-team/junit-framework/compare/r5.10.1...r6.1.3)

Updates `org.junit.jupiter:junit-jupiter-api` from 5.10.1 to 6.1.3
- [Release notes](https://github.com/junit-team/junit-framework/releases)
- [Commits](https://github.com/junit-team/junit-framework/compare/r5.10.1...r6.1.3)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: org.junit.jupiter:junit-jupiter-api
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] 93c1423dcd build(deps): bump org.jetbrains:annotations from 24.0.1 to 26.1.0
Bumps [org.jetbrains:annotations](https://github.com/JetBrains/java-annotations) from 24.0.1 to 26.1.0.
- [Release notes](https://github.com/JetBrains/java-annotations/releases)
- [Changelog](https://github.com/JetBrains/java-annotations/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/java-annotations/compare/24.0.1...26.1.0)

---
updated-dependencies:
- dependency-name: org.jetbrains:annotations
  dependency-version: 26.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] eddcbc82e8 build(deps): bump actions/setup-java from 4.1.0 to 6.0.1
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4.1.0 to 6.0.1.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4.1.0...v6.0.1)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] ab42c6ef35 build(deps): bump actions/upload-artifact from 4 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] 0039ec98e3 build(deps): bump actions/download-artifact from 4.1.3 to 8.0.1
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.3 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4.1.3...v8.0.1)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:08 +02:00
dependabot[bot] 294a978c3a build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:08 +02:00
dependabot[bot] 19b9bf0fca build(deps): bump Andrew-Chen-Wang/github-wiki-action from 4 to 5
Bumps [Andrew-Chen-Wang/github-wiki-action](https://github.com/andrew-chen-wang/github-wiki-action) from 4 to 5.
- [Release notes](https://github.com/andrew-chen-wang/github-wiki-action/releases)
- [Commits](https://github.com/andrew-chen-wang/github-wiki-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: Andrew-Chen-Wang/github-wiki-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:08 +02:00
Rsclub2_2andClaude Sonnet 5.5 5b9fe0d3c1 chore: add Dependabot configuration for Gradle and GitHub Actions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 00:37:08 +02:00
Rsclub2_2andClaude Opus 5.5 2a5c89b943 Next major release/version1.50 (#89)
* Make DBController work on one database file per instance

The controller was a static singleton: an eagerly created static instance
opened the root database during class initialization, and both the connection
and the path were static fields. A second operator profile in the same process
was therefore impossible, and ChatController's own "new DBController()" never
opened anything - it silently adopted the eagerly opened root connection.

- drop the eager static instance in favour of a lazily created default instance
- turn connection and path into instance state
- add a constructor taking a database file name relative to the application
  directory plus a flag whether a missing file is seeded from the bundled
  template
- create additional profile databases empty instead of seeding them: the
  bundled template carries 3452 foreign callsigns and user_version 0, which
  would show a new operator foreign data and trigger the full callsign
  normalization rebuild. The schema is created by the existing table setup.
- remember the shutdown hook so closeDBConnection can deregister it; otherwise
  every profile switch would leave another hook holding a dead connection

All SQL statements keep referencing the plain field and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Allow preferences and chat controller to be bound to a file set

ChatPreferences gains a constructor taking a preferences file name relative to
the application directory, so "profiles/OP2/preferences.xml" is as valid as the
historic flat "preferences.xml". A missing file is still seeded from the
bundled template, which gives an additional operator the same clean defaults a
first installation gets.

ChatController gains a constructor that passes both relative file names and the
seed flag through to ChatPreferences and DBController. The existing
constructors delegate to the historic file names, so nothing changes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Introduce the operator profile model and its registry

Adds the descriptor, the path derivation and the registry persistence for
operator profiles. Nothing calls them yet, so behaviour is unchanged.

The descriptor stores only a shared/own flag, never a path. All file names are
derived in OperatorProfilePaths, so a stored path can never drift apart from
the flag that produced it. A profile identifier is a stable, file system safe
slug assigned once, so renaming a profile never moves a directory.

The registry is created lazily. An installation that only has the historic flat
layout gets no registry file and no profiles directory; the root profile is
synthesised in memory instead. That keeps a single operator installation
byte for byte the one it was before, and it keeps a downgrade to an older
release a no-op. A missing, unreadable or malformed registry is logged and
treated like an installation without additional profiles, never as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Select the operator profile at startup

Resolves the active operator profile before the chat controller is built and
passes its two file names on, so preferences, layout and worked data follow the
profile.

The resolution is deliberately quiet for existing installations. With no
registry or exactly one profile nothing is asked and nothing is written, so a
single operator start is unchanged. Only from two profiles on does a small
picker appear with the last used profile preselected, where Enter or a double
click starts immediately. A "--profile" argument, or the equivalent system
property, skips the picker; an unknown name warns and falls back to the normal
selection instead of refusing to start.

The startup decision itself lives in OperatorProfileBootstrap and contains no
user interface code, so it is covered by headless tests. The window title gains
the profile name only when a second profile exists.

Command line parsing happens in init() and is kept in a process wide holder,
because JavaFX only knows the parameters of the instance it launched itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Release the background resources a discarded runtime owns

Several resources outlived a disconnect on purpose, which was harmless while a
process ran exactly one session for its whole life. They are now released when
the controller itself is closed:

- the ON4KST connection supervisor thread, which stopByUser did not touch
- the sked reminder scheduler, which had no shutdown at all
- the reachability executor, whose shutdown method existed but was never called
- the PSTRotator retry scheduler and its pending retry
- the map tile proxy, whose stop method existed but was never called, leaving a
  server socket and a twelve thread pool behind
- the station map bridge listeners and its coalescing animation, which would
  otherwise keep firing into a dead user interface

These are real leaks today; they only become visible when a second runtime is
built in the same process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Switch the operator profile while the application is running

Splits the teardown out of stop() into a reusable, idempotent shutdownRuntime()
and adds File - Switch operator profile..., which tears the current runtime
down and builds a fresh one for the selected profile.

The new runtime is a new application instance rather than a second start() on
the existing one. Many controls are instance fields created once, so reusing
the instance would re-parent mounted nodes and register every listener twice.
A fresh instance is safe because the class keeps no mutable static state.

Closing every window during a switch would end the process under the JavaFX
default, so the application takes over the exit decision: implicit exit is
turned off, the main window gets an explicit close handler, and every exit path
runs through the launcher. That also fixes losing the window layout on exit
after a switch, because JavaFX only calls stop() on the instance it launched.

The two view timers are now cancelled null-safe; stop() used to dereference
them unguarded, which would fail if shutdown happened before they were created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Manage operator profiles from the settings window

Adds a "Profiles" tab, appended last so no established tab position shifts,
offering create, duplicate, rename, delete, a switch between shared and own
worked stations, and activation of another profile.

Duplicating copies the whole configuration except callsign and password. The
antenna, locator, layout and integration settings are exactly the work nobody
wants to enter twice, while the credentials belong to one operator only. The
root profile can neither be deleted nor moved off the common station database,
because its files are the installation itself.

The login callsign and its raw form now default to empty instead of a real
callsign. The XML reader treats an empty element as "not set" and falls back to
the field default, so without this change a profile created without credentials
would come up carrying the callsign compiled into the defaults - and an
operator could transmit under someone else's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Document operator profiles

Adds a manual section in both languages covering where profile files live, the
choice between shared and own worked stations, managing profiles, the startup
selection and the --profile argument, switching while running, and the fact
that passwords stay in clear text so profiles are not an access boundary.

Records the architecture in PROJECT_CONTEXT: lazy registry, derived paths, why
an additional profile database is created empty, why the login callsign default
is empty, and the constraints of rebuilding the runtime for a switch.

Adds a v1.50 changelog entry in both languages including the upgrade notes: no
file is moved, and going back to an older release stays possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Make the terrain profile cache multi operator safe

The cache stored a single owner identity in a meta table and dropped the whole
TerrainProfileCache table whenever the configured callsign or locator differed
from it. With several operator profiles that turns the cache into a permanent
miss: every switch between two operators with different locators would discard
every computed profile.

Entries are separated by owner identity through the primary key already, so the
wipe is replaced by an owner table that simply records which identities are in
use. A different owner now misses the cache instead of clearing everybody's.

The cache also moves out of the worked station database into its own global
terrainprofilecache.db. Terrain profiles are pure geometry derived from two
locators and a sample count; at a multi operator station both operators share
one location, so a per profile copy would only double the traffic against the
terrain service. No migration is needed, the new file refills itself, and the
old tables stay readable for older releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Fail cleanly when the registry path has no directory

SpotBugs flagged a null passed to Files.createTempFile: the null check guarded
only createDirectories, while the temporary file creation would still have
dereferenced it. The path is resolved absolute so this is practically
unreachable, but bailing out with a log line is cheaper than the latent NPE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Code Version 1.50

* Add the in-app update concept for 1.50

Captures the agreed design so it can be handed over later: extended
update feed with per-artifact metadata, a build-time package-type
marker, a testable check service replacing the blocking call in the
ChatController constructor, a non-modal hint suppressed while connected
to ON4KST, verified downloads and platform-specific execution.

Concept only, nothing implemented yet. Line anchors were verified
against this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT8R7zTMoQnmJj6dGbaqqf

* Use one base font size and a per-profile startup design

JavaFX picks a 13px default font on macOS and Linux but 12px on
Windows, so labels in fixed-size controls were cut off outside
Windows. Both stylesheets now set 12px on the root.

Each operator profile now chooses light or dark mode for startup on
the GUI settings tab. The Windows menu entries remain a quick switch
for the running session. All windows, including the update window
and the top candidates list, follow the design, and the main window
no longer starts light while the station map starts dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show a drag-to-install layout in the macOS DMG

The DMG window now has a background with an arrow from the app to
the Applications folder, in the KST4Contest colours. Finder applies
the layout through AppleScript; if that fails, e.g. on a headless
runner, the script warns and builds the plain DMG as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the macOS system menu bar in all windows

On macOS the main menus move into the system menu bar and are shared
with the secondary windows, so they stay available whichever window
has the focus. The connection state indicator becomes a read-only
LINK menu there instead of the status bar button. Other platforms
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List direct commits on main in the release notes

GitHub's generated release notes only cover merged pull requests, so
commits pushed straight to main were missing. The release job now
builds the notes itself: "What's Changed" holds "Commits on main"
(first-parent, without bot and squash-merge commits) and GitHub's PR
list as "Merged PRs".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build with Gradle in two modules, and free core of JavaFX

Etappen 1 and 2 of the Compose migration (43 commits).

The Maven build, the single source tree and the JPMS module-info went together.
core now holds the domain and app-desktop the user interface, both built by
Gradle, packaged through jpackage from Gradle, with SpotBugs and PMD wired in.
JavaFX moves to the classpath, which is why the launcher is Main and not the
Application subclass. The PR check, the nightly artifacts, the release and the
AUR packages all build from Gradle; the macOS DMG points at its output.
Verified on Linux, macOS and Windows.

core then loses JavaFX entirely. It observes through kst4contest.observe --
ObservableValue, ObservableRoster, and UiDispatcher as the one boundary a
worker thread crosses to touch the interface. The controller collections become
rosters and the visible station list a pure derivation rather than a
FilteredList/SortedList chain, which is also what fixed two focus defects only
a load test had exposed: the keyboard focus stays where the operator put it,
and the send field keeps it after a row click.

The rule that outlives this commit: ObservableRoster is the store and the list
a view is bound to is a mirror of it, never the other way round.

* Draw the whole interface in Compose

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.

* Remove JavaFX

The second half of Etappe 6 (61 commits). grep -rl "javafx\." over both main
source trees is empty, nothing imports javafx, neither classpath resolves it,
and org.openjfx is out of the build and the version catalog.

The finding that shaped the work: the JavaFX main window was built on every
start and never shown -- primaryStage's setScene and show were both commented
out. Most of those 11,655 lines were dead weight rather than a port. But not
all of them, and that is the trap this commit is really about: dead-looking
methods still fed live Compose sinks. They were cut at their seams instead of
deleted, and a sweep script that follows calls was written to find them,
because reading the code does not reveal them.

In order: the colour derivation frozen as checked-in test data, so the pin
survives JavaFX being removed instead of silently comparing against itself;
the live feeds lifted out of the never-shown window and given names and tests;
the dead scaffolding deleted in slices; then the lifecycle. UiDispatcher now
delivers to the AWT event dispatch thread, the ten Alerts are modal Compose
dialogs, HostServices gives way to java.awt.Desktop with the mailto/http split
JavaFX did not need, and extends Application, start(Stage), stop and launch are
gone -- main builds the runtime and a shutdown hook replaces stop().

An independent review found two deadlocks in that move, both the same failure
class, both invisible to the build: a blocking wait on the event thread for
work that can only advance on the event thread. The profile picker waited on a
latch and froze the client for good; ComposeWindowHost.close waited on
Thread.join and returned with the window still marked open, which made a
profile switch raise the dead runtime's window and leave the rebuilt profile
without its Connect button. A third came out of fixing them: a ComposeDialog
built off the event thread cannot create its GL context and never paints.

Three defects only a GUI run showed, never the build: an NPE on a deleted
field, a Compose menu still hanging on deleted MenuItems, and a category
bookkeeping field that looked dead but is read by the outgoing-message category
resolution -- deleting it would have sent messages in the wrong category.

The binding docs and the manual no longer describe a JavaFX client.

* Draw the menu bar in Compose, and give the drawing layer a net

What the removal left open (4 commits).

Compose's MenuBar is a javax.swing.JMenuBar set on the window, so Swing draws
it and no theme reaches it; with no look-and-feel configured that meant the
default Metal menu above a themed window. The bar is drawn in the window now,
except on macOS, where the system bar is where that platform expects it. Two
renderers read one description -- mainMenuModel is a plain function of the
state, which also gives the menus their first tests. Closing the main window
quits the application again, and the window writes its size back to the
profile; both had quietly stopped working when the last JavaFX stage went.

Then Etappe 7. Four faults shipped in the settings-window branch alongside a
green suite of 382 tests, because not one of those tests drew anything: a
control that never redrew, a tab strip that clipped mid-word, a form taller
than its window with no way down, and colours that came from Material rather
than the stylesheet. One regression test per class, and the spec's acceptance
criterion is met rather than assumed -- each fault was put back deliberately
and the matching test watched to fail.

The CI had never run the suite at all; it does now, headless. What that took
was not Xvfb: skiko's native library links against libGL and only has to be
loadable, since the drawing tests render in software. Without the package the
whole Compose half of the suite dies with a message that reads like a missing
display. Found by running the job under act rather than on GitHub.

The six tests that open a real window skip headless by their own guard. The
summary step counts from the result XML and fails on failures there, because
this project's rule is never to infer a green suite from an exit code.

* Make the colours adjustable per operator profile

Six colour roles per design, per profile: surface, window surface, field interior,
text, accent and separator line. A change applies at once in every open window and
survives a restart once the settings are saved, like any other preference.

Three sources, later wins role by role: the shipped stylesheet from the classpath,
then the profile's own stylesheet file if there is one, then the overrides stored in
the profile's preferences. resolvePalette is a pure function and reports where every
role's colour came from, which the tab shows per field -- with two sources able to
decide one colour, that is the only thing keeping "why is this colour what it is"
answerable, and the spec calls it mandatory rather than decorative.

Four things this had to get right, each learned the hard way and each pinned:

A role is a selector and a property, not a property alone. Three of the six are
stated outside .root -- text on .label, the separator on .separator *.line -- and
-fx-background-color alone appears a dozen times in the evening sheet. Measured: the
shipped daylight sheet states no colour at all, the evening sheet states five and
implies the sixth.

Kst4ContestTheme reads the palette from the store's state and not from remember.
That read is what makes a change repaint every open window; remember(darkMode) held
the palette until somebody toggled day/evening, so a change reached nothing.

A derived colour follows its source's value, never its provenance. The root
profile's own stylesheet is the one copyResourceIfRequired writes at every startup --
the shipped sheet itself -- so layer 2 declares the accent on every existing
installation with nobody having chosen anything. Keying textAccent on "does this come
from a file" repainted the evening accent from green to blue on upgrade and made
"reset to shipped" disagree with "discard my changes".

The contrast guard measures where colours are actually drawn, read off Theme.kt's
Material mapping rather than off the role names: background and surface are both the
window surface, and primary is textAccent, not accent. It reports a pair only when it
is both under WCAG AA and worse than the shipped sheet -- the shipped sheets have
three pairs under 4.5 themselves, so an absolute floor would warn on an untouched
client and teach the operator to ignore warnings.

The way back out of an unreadable palette is deliberately overbuilt, because it is
the one failure that locks itself in. "Reset to the shipped colours" takes the
shipped palette as a required parameter and reads no composition local, so there is
no path by which it could be drawn in the colours it exists to undo; it looks out of
place under a customised palette, and that is what makes it findable. Ctrl+Z, Ctrl+D
and Ctrl+R reach the three ways back even with the caret in a colour field. A
mistyped colour is refused and the field snaps back.

The profile's stylesheet is the hand-editing route the copy under ~/.praktiKST has
been promising since it was first written and never read. It is read now. A button
writes the shipped template as a starting point and never replaces a file that is
already there. An unreadable, truncated or non-CSS file does not prevent startup --
the role falls back to the shipped design. A sheet stating -fx-base and no
-fx-background states the derived window surface too, the way JavaFX does, or the
most natural hand edit would leave every window the shipped light grey.

101 new tests. All of it runs headless.

* Document the adjustable colours, and record the design and the plan

Both manuals get the Colours tab in full: the six roles, that a change applies at
once but survives a restart only after Save settings, the three ways back with their
keyboard shortcuts, what each provenance label means, and the readability notice that
warns without refusing.

Two things an operator would otherwise have to discover. The reset button is always
drawn in the shipped colours and works when nothing else can be read -- the risk
section of the design says plainly that a keyboard path only helps whoever knows it,
so the manual is where this has to be written down. And the root profile already has
both stylesheet files, because KST4Contest has been writing them at startup for
years, so most of its roles report "from file" rather than "shipped" with identical
values.

PROJECT_CONTEXT.md records the decisions that outlive this stage: the three sources
and their precedence, that a role is a selector and a property, that the theme must
read the store's state rather than remember, where the contrast pairs come from, why
the root profile's file layer always exists, and why the lifeline refuses the active
palette. Also corrects its claim that CSS stays global -- a profile now owns its
stylesheets.

The design document and the implementation plan are kept as they were written,
including the plan's own corrections where measuring the code contradicted it.

* Speak the operator's language

The interface is German or English, chosen per operator profile on the GUI tab, in force at
once in every open window, and remembered across restarts. 376 texts in both languages.

The texts are ordinary .properties files that nobody has to compile: a contributor copies the
English one, translates the values, and opens a pull request. A third module -- build tooling
that never ships -- turns them into Kotlin before anything compiles, which is what makes a
mistyped key and a wrong argument count compile errors rather than things an operator
discovers. It lives in the main build rather than buildSrc because buildSrc's tests do not run
on `./gradlew build`, measured, and a generator whose tests never run is the exact failure it
exists to prevent.

Incompleteness is allowed and incorrectness is not. A missing key warns and falls back to
English, so a partial contributed language can be submitted at all; an orphan key, a
placeholder the translation drops, a duplicate key or an unreadable file fail the build with
the generator's own message naming the file. The argument count always comes from the English
base. German is held complete by a test because it ships; a contributed language is not.

What is deliberately not translated is the harder half. Protocol frames and chat commands
would break the connection or go unread; the beacon texts, auto answers, snippets and
shortcuts go to other radio amateurs and sit in every operator's stored profile; the 273
println calls in core are diagnostics, and a German stack trace is worse for whoever reads the
bug report; and QRA, QRG, QTF, LOS, Fresnel and wkd read the same in either language. Three
tests guard the boundary by measuring behaviour rather than text: the protocol frames built
under three locales, every preference default compared by reflection, and no translation value
being a protocol command. The reflective one is the only one that covers what nobody thought
of.

One insight turned up three times. Anything built outside a composition freezes the language it
was built in: the window title, the settings tab strip and every column header are all
constructed once at startup -- the columns from Java -- so each now holds a function read when
it is drawn. That also fixed something older and unrelated to language: the main window's title
was a fixed "KST4Contest (Compose)" for the whole session, where the JavaFX window had carried
the live connection line. It carries it again.

A column's id is never translated. It is what the remembered widths and order are keyed on.

* Document the two languages, and how to contribute a third

Both manuals get the language picker in full: where it sits, that it applies per profile and
takes effect at once in every open window, and -- at more length -- what is deliberately left
alone and why. An operator who sees an English beacon text in a German client should be able
to read that it is meant that way.

The contribution route is written down in both, because plain text files are only useful if
somebody knows they are there: copy, translate, submit; a partial file is welcome; the build
reports mistakes by file name and writes a worksheet of what is still missing.

PROJECT_CONTEXT records the decisions that outlive this stage -- the four kinds of text and
the rule that the addressee decides, that core produces no display text, that incompleteness
is allowed and incorrectness is not, that anything built outside a composition needs its text
read inside one, and that a column id is never translated.

CLAUDE.md said two modules; there are three, and the third never ships. It also states the
German orthography now, which this session got wrong across a spec and two plans: ss after a
short vowel, so muss and dass.

* Remove the implementation plans now that every stage is executed

Thirteen plans, all of them step-by-step instructions for work that is committed. A plan left
behind reads to the next person like an open intention, and these describe a build that no
longer exists -- the first still explains how to introduce Gradle.

The specifications stay. They say why the architecture is what it is, which the code cannot,
and three of them are still argued from. The live-findings note stays too: CLAUDE.md sends
people to it, and its hardest lesson -- that a blocking wait on the AWT event thread deadlocks
the interface while the build stays green -- is written down nowhere else.

Its sweep script stays as well. The note argues explicitly that the script belongs beside the
finding so the next reader can measure rather than believe, and removing it would contradict
the reasoning it illustrates.

Two references in the note and one in the JavaFX-removal spec pointed at plans and now say
where the reasoning went instead.

* Point the JavaFX-removal spec at where its plan's reasoning went

* Remove the stray release-metadata patch from the working tree

A 416-line patch file that was applied long ago and has sat in the repository root since. It
predates this branch and stays in the shared history, where rewriting it would mean rewriting
commits that are already on main and on origin.

* Check the translation files in CI and say what they hold

classes already runs the generator, so an orphan key, a dropped placeholder
or a duplicate fails the compile job with the generator's own message naming
the file -- verified by breaking strings_de.properties on purpose.

A step summary counts each language against the base, so the author of a
translation PR sees what they covered without reading the build log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse a translation key that would not form a Kotlin name

The generator escaped every translation value but emitted the key verbatim as
the member identifier, and identifierFor strips only . _ - -- so a key carrying
a space, a brace, a quote or a newline from a \uXXXX escape broke out of
val <name>: String get() = lookup(...) into an init block or property
initializer that runs when Strings is instantiated. Translation files are the
contribution surface that arrives by pull request, so this was a path from a
merged .properties change to code in every client.

The generated identifier is now held to letters-then-letters-and-digits before
any source is written, alongside the existing orphan and collision refusals.
Proven by replaying a file-writing payload against the built generator: it now
throws and writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Port the rebased diagnostics and session logging onto the modules

origin/main landed replayable session logging (MessageHistoryRecorder), file
logging (ApplicationFileLogging) and a user-list refresh split on the old flat
layout. The rebase carried the new files, but three things needed the new
architecture:

- ChatController.fireUserListUpdate() used javafx Platform; core is toolkit-free
  now, so it dispatches through uiDispatcher like every other fire method.
- Two new tests were left in the old src/test/java; moved into core.
- ScoreServiceRefreshTest mocked javafx Platform statically; it now injects the
  DirectUiDispatcher the no-arg constructor installs.

The Leaflet map performance work was dropped with the HTML map it optimised.

classes testClasses and test are green: 1069 tests, 0 failures, 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the debug-to-file recording a home in the Compose settings

origin/main added a checkbox to the old JavaFX settings that recorded the whole
ON4KST session to file; the Compose redraw dropped the old settings UI, so the
toggle had no control left even though the preference and its wiring survived
the rebase.

It returns to the message-handling tab: the state writes the preference and asks
the application, through a new SettingsHost callback, to open or close the
error-log handler and the history recorder in the order the old checkbox kept.
The explanatory line and its German translation say that passwords are redacted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix Codex review findings on PR 89: station row key, map selection, sort after filter

- Station row key now includes the chat category, so the same callsign in two
  categories no longer collides.
- Map marker click and Reset view go through the central station selection.
- Sorting now operates on the filtered rows, so filters stay effective.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the map drawing test off the network and the user's tile cache

The test assumed no tile ever arrives, but it used the default TileFetcher,
which reads the real disk cache and the OSM tile server. On CI a tile now and
then landed before the frame was rendered and covered the background the
'unreachable tile source' test asserts on. An empty temporary cache and a
source that always fails make the assumption hold by construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 23:22:11 +02:00
Marc Froehlich 3e8457eede Optimize station map refresh performance 2026-10-03 22:50:43 +02:00
Marc Froehlich df70cb1943 Update diagnostics and analytics context
Silence the UTC time formatting console diagnostic and document the current production analytics status.
2026-10-03 20:50:19 +02:00
Marc Froehlich 323c249bf7 Add replayable ON4KST session logging
Record redacted RX/TX frames with session rotation and runtime debug controls.

Add targeted file diagnostics, tests, documentation, and a silent priority-score refresh path.
2026-10-03 20:50:19 +02:00
github-actions[bot] f9760bcbaa chore: update AUR packages to v1.44.0 [skip ci] 2026-09-22 21:35:13 +00:00
Marc Froehlich a8f12876ec Align private metrics with GoAccess request panel 2026-09-14 23:01:06 +02:00
Marc Froehlich 89422d18ad Fix private analytics aggregation 2026-09-14 22:40:53 +02:00
Marc Froehlich a3f3bf8872 Changes at the stats analytics 2026-09-14 20:33:50 +02:00
Marc Froehlich 393c9c49ee Changed link monitoring as described by on4kst, Alain 2026-09-13 19:50:33 +02:00
Marc Froehlich f0508f51a2 Added screenshot where thhey belongs to at the project hp 2026-09-13 16:57:36 +02:00
Marc Froehlich 923d76beb0 refactoring project website landing page 2026-09-13 10:38:53 +02:00
Marc Froehlich 821535a24f Documentation on user counter and analytics to the kst4contest page 2026-09-12 22:31:03 +02:00
Marc Froehlich 914a06cb7f Corrections on user counter and analytics to the kst4contest page 2026-09-12 03:55:27 +02:00
Marc Froehlich b23884bde1 Added user counter and analytics to the kst4contest page 2026-09-11 21:03:45 +02:00
Rsclub2_2andClaude Opus 5 4ad1cf71dd Document that a sandboxed logging program needs its own network permission
A logging program started as a Flatpak, or through a Wine environment
such as Bottles, uses the network permissions of that sandbox. Without
shared=network its 127.0.0.1 is not the loopback KST4Contest listens on,
so the connection is refused although KST4Contest reports the port
correctly.

Adds the check and the override command to the DX Cluster troubleshooting
section in both language versions, and notes that the published
KST4Contest Flatpak manifest already carries --share=network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT8R7zTMoQnmJj6dGbaqqf
2026-09-07 22:21:47 +02:00
github-actions[bot] ad212e3e71 chore: update AUR packages to v1.44.0 [skip ci] 2026-09-04 12:23:20 +00:00
CopilotandRsclub22 08d65a0e23 Serialize Flatpak repo publishes to prevent non-fast-forward push failures (#88)
* Initial plan

* Serialize Flatpak repo publish jobs

Co-authored-by: Rsclub22 <37273508+Rsclub22@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Rsclub22 <37273508+Rsclub22@users.noreply.github.com>
2026-09-04 14:07:43 +02:00
Rsclub2_2 edf71b4105 Version 1.44.0 2026-09-04 13:55:45 +02:00
Rsclub2_2 885bf83c2f add DN9APW to credit. 2026-09-04 13:47:39 +02:00
Rsclub2_2 634b88238d WT-Logsync when KST4C is not actively receiving WT Packets,
so that we can later mark QSOs as marked, even though we never received
the packet.
2026-09-04 13:42:50 +02:00
github-actions[bot] 830e4020a2 chore: update AUR packages to v1.43.1 [skip ci] 2026-09-03 21:50:38 +00:00
Marc Froehlich 7ee50267ec Document v1.43 and fix release metadata 2026-09-03 23:42:17 +02:00
Marc Froehlich 113e843111 updated version strings now corrected 2026-09-03 22:31:05 +02:00
github-actions[bot] 79161d2afa chore: update AUR packages to v1.43.0 [skip ci] 2026-09-03 20:23:04 +00:00
Marc Froehlich 3ed1cad5ab updated version strings 2026-09-03 22:20:26 +02:00
Marc Froehlich 75fe45b50b updated version string 2026-09-03 22:16:23 +02:00
Marc Froehlich 53555cbe69 Add optional station map clustering toggle (solves #79) and added documentation 2026-09-03 01:26:19 +02:00
Marc Froehlich 595fb84362 Persist table layouts just after changing the real used sizes and show truncated cell tooltips in the whole applications tables v2 2026-09-03 00:26:16 +02:00
Marc Froehlich 9eb0550106 Persist table layouts just after changing the real used sizes and show truncated cell tooltips in the whole applications tables 2026-09-02 23:45:19 +02:00
Marc Froehlich 51aa04bfb5 Fix DX Cluster spot formatting - emit fixed 75-character DXSpider-compatible lines - align callsign, comment, and UTC fields - support frequencies up to 24 GHz - reject overlong callsigns instead of truncating them - add protocol tests and update documentation Fixes #86 2026-09-02 20:03:55 +02:00
Marc Froehlich 6b0d699a98 Fix compact QRG parsing and initial worked-state restore - parse digit-only full frequencies across supported bands - restore persisted Worked state before publishing initial user lists - add regression tests and update documentation Fixes #85 2026-09-02 19:17:52 +02:00
Marc Froehlich 6b29ffe3ba refactored the ugly switch case statement in logged-band-recognition 2026-08-31 00:15:26 +02:00
Marc Froehlich 08b109106e manual: corrected behaviour description of simplelogfile, Airscout, MYQTF, corrected some typos 2026-08-29 10:07:24 +02:00
Marc Froehlich 21b2d9970a manual: decribed SimpleLogFile-parser 2026-08-29 00:02:57 +02:00
Marc Froehlich 94fc13e3c3 refactoring of the SimpleLogFile-parser 2026-08-28 23:19:56 +02:00
Marc Froehlich d475c6b2c4 manual: wording corrected for the settings at different places 2026-08-28 22:22:35 +02:00
Marc Froehlich 211be6081d manual: described dual chat 2026-08-28 22:08:31 +02:00
Marc Froehlich b550d79b4b manual: described pm catching better... 2026-08-28 01:14:46 +02:00
Marc Froehlich 352cdcceb2 manual: described pm catching 2026-08-28 01:02:49 +02:00
Marc Froehlich 905ce5766e webseite: described global message views 2026-08-28 00:46:35 +02:00
Marc Froehlich 2803f7d8e3 webseite: described qrg synch 2026-08-28 00:29:01 +02:00
Marc Froehlich 4a8e08a331 website: described filters better now 2026-08-28 00:14:31 +02:00
Marc Froehlich a24db90ad2 website: added qrg stuff to the features list and linked it to other articles 2026-08-28 00:03:04 +02:00
Marc Froehlich d91b119112 website: added map to the features and described g1ybb workflow 2026-08-27 23:50:27 +02:00
Marc Froehlich 345c4adfc3 website: added direction opportunities to the features and added neccessary picture 2026-08-27 23:27:00 +02:00
Marc Froehlich 0e8cd06e43 website: added Band opportunities as a feature 2026-08-27 23:01:04 +02:00
Marc Froehlich 7ff7248e7d Fixed KST4Contest caused disconnect on getting no activities of ON4KST chatservers if no new lines arriving 2026-08-27 01:26:50 +02:00
Marc Froehlich 3193e4ac73 manual and code: configfile path corrected 2026-08-27 00:23:09 +02:00
Marc Froehlich 3ec6cab46a manual and code: fixed colour handling of the messages and better descripted it in the manual 2026-08-27 00:02:02 +02:00
Marc Froehlich b280f1b0db manual: overhaul features, config, dxcluster server 2026-08-26 22:57:49 +02:00
Marc Froehlich 7fe33613e9 manual: overhaul cluster-server and feature list 2026-08-26 22:43:16 +02:00
Marc Froehlich 9378bf2afd manual: overhaul en-home and changelog de and en 2026-08-26 22:19:27 +02:00
Marc Froehlich 047891e109 manual: overhaul user interface de and en 2026-08-26 01:05:13 +02:00
Marc Froehlich 4f88101ab7 manual: overhaul en-variables and macros 2026-08-26 00:56:33 +02:00
Marc Froehlich 5955ba4ecf manual: added contest workflow and variables 2026-08-26 00:39:24 +02:00
Marc Froehlich a9eba266ee chore: add Codex project context and agent guidance 2026-08-25 01:18:49 +02:00
Marc Froehlich 4601199587 chore: add Codex project context and agent guidance 2026-08-25 01:00:26 +02:00
Marc Froehlich 8c6ce6b402 manual: updated user interface documentation 2026-08-25 00:52:47 +02:00
github-actions[bot] 75ab24da25 chore: update AUR packages to v1.42.0 [skip ci] 2026-08-22 16:56:23 +00:00
Rsclub2_2 8aadbb9ee0 AUR: fix PKGBUILD in Repo 2026-08-22 18:53:44 +02:00
Rsclub2_2 3b344482ba AUR: fix Packages not updating 2026-08-22 18:36:13 +02:00
Rsclub2_2 86ad2f7a43 Website: Version 1.42 released news 2026-08-22 18:29:52 +02:00
Rsclub2_2 29ce6b0a4d AUR: mark Workspace as safe for git. 2026-08-22 18:27:22 +02:00
Rsclub2_2 4d8b8aa12e v1.42 Docs updated 2026-08-22 18:24:55 +02:00
Rsclub2_2andClaude Opus 5 af30c17a0d Macos signing (#80)
* Sign and notarize macOS builds

jpackage cannot produce a distributable macOS bundle on its own. It ad-hoc
signs the embedded runtime and then re-runs codesign on the same files without
--force, which codesign rejects; and "--type dmg --app-image" re-signs the app
it is handed, replacing a Developer ID signature with an ad-hoc one. So the
build now creates an unsigned app-image, signs it from the inside out, and
wraps it with hdiutil.

Apple's notary service also unpacks JARs and checks the native libraries
inside them, which sqlite-jdbc ships for both architectures. Those are signed
before the bundle is sealed, since rewriting a JAR afterwards would invalidate
the seal. A preflight check verifies Apple's two criteria locally, so a missed
binary costs seconds rather than a round trip to the notary service.

Two long-standing defects surfaced while testing and are fixed here: the
bundle identifier defaulted to the main class's package name (kst4contest.view
instead of de.x08.KST4Contest), and every release reported version 1.0 in
Finder because --app-version was never passed. Neither affects existing users:
the app keeps its settings in ~/.praktiKST, independent of the bundle ID.

Both workflows call the same script the local Mac uses, so the two cannot
drift apart. Signing needs a keychain that can answer a UI prompt, which a
runner cannot, so ci-import-cert.sh creates a throwaway keychain whose
password is generated per job and discarded with it. Notarization goes through
an App Store Connect API key and needs no keychain at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Sign bundle contents serially

Signing the app image's Mach-O files with "xargs -P 8" passed locally but
failed on a runner: codesign reported "replacing existing signature" and then
"No such file or directory" for that same path. The two libjli.dylib copies are
separate inodes, so this is not hard links being signed twice -- concurrent
codesign runs over one bundle are simply not reliable. Serially costs about a
minute, since each call waits on Apple's timestamp server.

Also stop the matrix from cancelling the other architecture on a failure; that
throws away half the diagnostic information from a failed run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Document macOS signing from 1.42

The installation guides described the right-click workaround as the normal
first launch. That stays, but as the path for 1.41.1 and older; from 1.42 a
double-click works. Both guides also show how to verify a download with spctl,
so the claim is checkable rather than something to take on faith.

The per-channel download notes distinguish where the channels actually stand:
Nightly is built from main and is signed as of now, while Stable still points
at 1.41.1, so those notes name the version instead of claiming it outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:48:31 +02:00
Rsclub2_2 399e5f34b7 Updated KST4C Logo for Builds in Higher Resolution and added it as an
SVG for Source. Should also be done when building Package with the help
of AUR in all Version and every Linux dist.
2026-08-22 00:32:07 +02:00
Rsclub2_2 2bc7554a52 Manual Fixup & Screenshots 2026-08-21 14:17:11 +02:00
Rsclub2_2 77b7a4729f Excuse me, wir 2026 2026-08-18 17:04:42 +02:00
Rsclub2_2 56b2fae0a3 Fix Nightly Build... 2026-08-18 17:00:13 +02:00
Marc Froehlich cc991418e8 manual: updated map documentation 2026-08-17 01:19:04 +02:00
Marc Froehlich 399f7235ef manual: updated sniffer description + website 2026-08-17 00:18:18 +02:00
Marc Froehlich 720dd2560b manual: updated sniffer settings description + source: changed sniffing to callsignraw and avoid dupe entries 2026-08-16 03:40:54 +02:00
Marc Froehlich 8fa5360752 manual: updated pstrotator settings descriptin. Source: implemented plausibility checks for pstrotator port range and changed ui blocking problem on the spid workaround 2026-08-16 03:05:46 +02:00
Marc Froehlich 422e6cf4b7 manual: updated beacon settings descriptin. Source: implemented plausibility checks for beacon texts 2026-08-16 02:34:40 +02:00
Marc Froehlich f77e0bd8f9 manual: updated Shortcut and Snippet settings description 2026-08-16 02:08:10 +02:00
Marc Froehlich 641c9bdbc3 manual: updated AirScout settings description 2026-08-16 01:41:17 +02:00
Marc Froehlich 265115c9cc manual: updated synch settings description 2026-08-16 01:26:05 +02:00
Marc Froehlich 49c0c1eb37 corrected string formatting on qrgs bigger than 10 GHz in wintest parser 2026-08-16 01:19:33 +02:00
Marc Froehlich 88cb55520d corrected string formatting on qrgs bigger than 10 GHz in wintest parser 2026-08-16 01:12:23 +02:00
Rsclub2_2andClaude Opus 5 f014b4697b packaging: derive the jpackage module list from module-info.java instead of maintaining sixteen copies
The jdk.net incident was caused by duplication rather than by a single
oversight: the module list existed in module-info.java, in the jpackage Maven
plugin and in fourteen hardcoded --add-modules arguments across the workflows
and AUR PKGBUILDs. Only the path that CI does not use was kept up to date, so
every packaged build shipped a runtime image without jdk.net.

Add packaging/AddModules.java, a single file source program that reads the
requires clauses and prints the platform modules. It runs identically on the
Linux, macOS and Windows runners without a build step, and skips third party
requires such as jlayer, which is an automatic module and cannot be linked
into a runtime image at all, as well as test only requires and requires
static. All sixteen packaging call sites now resolve the list through it, so
they can no longer drift from the descriptor.

The jpackage Maven plugin takes its modules as individual XML elements and
cannot consume a generated value, so it remains a second copy. To keep it
honest the helper has a pom verification mode, bound to the validate phase
via exec-maven-plugin. Binding it to the build rather than to a workflow
trigger means it also fires on direct pushes to main, on tagged releases, in
both AUR PKGBUILDs and on local builds, none of which run the pull request
check.

The released AUR PKGBUILD builds from a tag tarball that may predate the
helper, and aur-publish.yml rewrites pkgver to the latest release, so it
falls back to the list carried in that tarball's own pom.xml.

Verified that the generated list produces a byte identical runtime image to
the previous hardcoded one, that removing a requires fails the build with a
precise diff, and that the pull request check and the push triggered nightly
AppImage job both succeed under act.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generated-By: Claude Code (Claude Opus 5)
2026-08-15 01:05:45 +02:00
Rsclub2_2andClaude Opus 5 209034724f on4kst: add the jdk.net module to every packaging module list and stop connection setup from failing silently
The ON4KST connection manager introduced in f8c04e7 uses
jdk.net.ExtendedSocketOptions to configure kernel side TCP keepalives. The
new dependency was added to module-info.java and to the jpackage Maven
plugin, but the CI and AUR builds do not use that plugin: they call jpackage
directly with a hardcoded --add-modules list that still lacked jdk.net.
Because jdk.net is not pulled in transitively, every packaged runtime image
shipped without the class while development runs against the full JDK and
kept working.

The resulting NoClassDefFoundError is an Error, so neither the catch in
configureSocket nor the surrounding catch (Exception) in openConnection
handled it. Running on a ScheduledExecutorService, the throwable was stored
in the task future and never surfaced, leaving the state machine stuck in
CONNECTING with no reconnect attempt and no user visible failure.

Add jdk.net to all 16 module lists, covering nightly artifacts, tagged
releases and both AUR PKGBUILDs, so releases are affected as well as
nightlies. Additionally catch LinkageError in configureSocket so a runtime
image without jdk.net degrades to application level heartbeats, and catch
Throwable in openConnection so an Error can no longer be swallowed by the
scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:32:45 +02:00
praktimarc f8c04e72e1 on4kst: replace the legacy connection handling with a session-scoped supervisor using bounded connect, login and synchronisation timeouts, heartbeat and stale-link detection, controlled reconnect backoff and session-safe reader/writer queues; validate outgoing protocol context and malformed inbound user frames, enforce one locator per TCP session, publish complete user lists atomically and ignore repeated UE markers, prevent failed initial connections from entering a busy loop, add a compact high-visibility LINK state indicator, and remove false unhandled-frame reports. Solves #71 (#75)
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.
2026-08-14 01:10:22 +02:00
Marc Froehlich a4475e6d12 manual update: configuration, AS, functions 2026-08-13 22:49:19 +02:00
Marc Froehlich e3b2ea725a manual links updates 2026-08-13 00:56:22 +02:00
Marc Froehlich 6966edbfff manual update: map 2026-08-12 01:39:40 +02:00
Marc Froehlich 42d4b72dd6 website: prevent incomplete version-info deployments and regenerate validated update feeds after releases 2026-08-12 01:11:34 +02:00
Marc Froehlich 15f585c938 frequency-recognition: explicit frequencdies out of the name field will be copied to the qrg field and trigger the used band spot of a station to fit for this qrg. Airscout will then use this band explicitely for the calculation 2026-08-12 00:23:17 +02:00
Marc Froehlich 663f724c98 mapview: saved some space by moving some topics to the head bar 2026-08-11 23:38:47 +02:00
Marc Froehlich 9cccdb73e9 website - updated priority score md file 2026-08-10 03:13:18 +02:00
Marc Froehlich 401f271d56 fixed prio score penalty on sked fail 2026-08-10 03:06:06 +02:00
Marc Froehlich 00aefdacd5 website - updated macros md file 2026-08-10 02:53:03 +02:00
Marc Froehlich ea4fc9c008 website - updated dxcluster md file 2026-08-10 02:44:04 +02:00
Marc Froehlich caaeebd00c Added 6+4m and 10G and up frequency detection patterns 2026-08-10 02:42:29 +02:00
Marc Froehlich 852f76b05a fixed DXCluster Server where a spot will not be sent if no AP is between the station and me 2026-08-10 02:26:37 +02:00
Marc Froehlich d6c1ffbb34 website features and airscout information updated 2026-08-10 02:22:08 +02:00
Marc Froehlich cfbb978aee updated manual link and nav structure at the website 2026-08-10 02:08:11 +02:00
Marc Froehlich 8c3ff5f07c added favicon and finished the audit of the manual 2026-08-10 02:04:54 +02:00
Marc Froehlich 926bb9daee added manual (settings) and favicon for the website 2026-08-10 01:16:34 +02:00
Marc Froehlich 2828e7ef80 fix: make AirScout and map propagation band-aware by resolving realistic per-station QRGs to canonical AirScout bands, using one shared watchlist, honoring the operator-selected band for Calc selected and map path analysis, and replacing the obsolete 430 MHz fallback with 432 MHz (fixes #67) and parts of #74 2026-08-08 01:53:04 +02:00
Marc Froehlich b0cc5ee9ba updated en-manuals for skeds, user interface and features 2026-08-07 23:59:59 +02:00
Marc Froehlich b00e4c1e3d fixed worked-flag in the qso-of-the-other-table 2026-08-07 23:43:04 +02:00
Marc Froehlich b3ca684f04 updated website for sked and timeline features 2026-08-07 23:38:13 +02:00
Marc Froehlich 1de9673d12 uploaded 2 manual pictures for sked and timeline 2026-08-07 23:34:57 +02:00
Marc Froehlich c119f28b30 Uploaded sked, timeline, priority candidates and wintest connection manuals 2026-08-07 23:13:40 +02:00
Marc Froehlich 6a7f07c62e fix(wintest): replace unreliable band-limited AUTO sked mode detection with explicit SSB/CW selection and show exact KST callsigns in the timeline and fixed (#50 ensured Filter-Reset) 2026-08-07 22:56:24 +02:00
Marc Froehlich 4f574ebec6 fix(wintest): add band-aware sked QRG resolution, preserve portable callsigns, strip KST suffixes and correct ADDSKED timestamps 2026-08-07 22:43:17 +02:00
Marc Froehlich 7ce31e110b fix(wintest): add band-aware sked QRG resolution, preserve portable callsigns, strip KST suffixes and correct ADDSKED timestamps 2026-08-07 22:28:51 +02:00
Marc Froehlich 92804a622a Corrected typo error in MessagebusmanagementThread. Solves (#48) 2026-08-07 22:08:19 +02:00
Marc Froehlich 8bd5d8877d Add persistent map analysis toggle and improve compact layout for smaller screens. Solves (#69) 2026-08-06 01:52:59 +02:00
Marc Froehlich 97a93f726b Added en-manual for priority score and preferences + website feature text changed 2026-08-06 00:53:17 +02:00
Marc Froehlich c682ecf3f9 Changed website manual navigation 2026-08-06 00:46:00 +02:00
Marc Froehlich 259d0a4916 added priority score manual 2026-08-06 00:42:44 +02:00
Marc Froehlich 6d65bc365c fixed scoreservice. Not-QRV stations and such which dont provide needed bands now never could appear at the prirotity list 2026-08-06 00:26:29 +02:00
Marc Froehlich 084923366f fix(chat): grouped calculation of priority score for equal raw callsigns with different suffixes (also fixes #73) 2026-08-06 00:18:36 +02:00
Marc Froehlich eb38268be5 fix(chat): separate active members by full callsign and category 2026-08-06 00:01:29 +02:00
Marc Froehlich 36d2bd512d updated english manuals for changes from 1.40 due to 1.42 2026-08-05 23:20:11 +02:00
Marc Froehlich ffe7343671 inserted screenshots for manual pages for wkd status infos, band settings, functions and settings 2026-08-05 22:58:34 +02:00
Marc Froehlich bdd10ee00b Created manual pages for wkd status infos, band settings, functions and settings 2026-08-05 01:55:10 +02:00
Marc Froehlich 6092ff882a added project Band enum from Win-Test band IDs 2026-08-05 01:18:41 +02:00
Rsclub2_2 e37cda8ff2 Add 50/70 MHz band support (table, NOT-QRV, station setup, loggers)
Issue #68

Adds Band.B_50/B_70 and wires them through the same band-opportunity
machinery as the other bands: X/a/B+/o table columns and filter button
in the User table and the Workedstn database table, NOT-QRV checkboxes
and propagation across callsign variants, "My station uses 6m/4m band"
toggles, station-name detection ("50", "6M", "70MHZ", "4M" - without
stealing the existing bare "70"/"6" cm-band shorthand), Win-Test sked
band IDs (10/50MHz, 11/70MHz), and UCX-logger worked-band recognition.

Persists worked50/70 and notQRV50/70 via an additive SQLite migration
(same ensureColumnExists pattern as the earlier v1.1->v1.2 migration),
verified against a real database file.

ReachabilityService.resolveAutoBand() now also falls back to 50 MHz
(then 70 MHz) for stations in the "50/70 MHz" chat category, mirroring
the existing Microwave-category fallback to 23cm.

Assisted by Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 21:10:15 +02:00
Rsclub2_2 fdec5220d1 Unify band-opportunity logic and add B+/a/o status to the station table
Issue #70, #66, #65

Introduces a shared BandOpportunityResolver so the station table's band
columns, the New bands filter, the band-upgrade hint, the priority
score, the map markers and the automatic reachability band selection
all derive band availability the same way: recent QRG detections (30
min window) plus station-name hints, evaluated across every active
callsignRaw variant, with manual NOT-QRV always taking precedence.

The per-band table cells now distinguish:
- X: worked on this band
- a: band available, call not worked on any band yet
- B+: band available, call already worked on another band
- o: grid square already worked on this band (any station) - combines
  with the others, e.g. "ao" or "B+o"

Both "a" and "o" can be toggled off in the GUI settings tab.

Assisted by Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 21:00:23 +02:00
Rsclub2_2 35f790f06b Fix stale Beta tab showing a superseded prerelease
GitHub never clears the prerelease flag once a beta ships as stable,
so the latest prerelease can be older than the latest stable release.
Compare publish dates and fall back to the empty state when stable is
newer.

Assisted by Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:49:06 +02:00
Rsclub2_2 c04117ad0a added Beta and Nightly Download Links to website using nightly.link 2026-08-03 22:31:14 +02:00
Marc Froehlich c7d1dfbe92 Refactored QRG recognition (added some band context to increase the quality of the values + changed cluster fallback-band-value to dropdown switch + normalizing qrg values) 2026-08-03 01:09:32 +02:00
Marc Froehlich e9f09b764d Refactored QRG recognition (added some band context to increase the quality of the values + changed cluster fallback-band-value to dropdown switch + normalizing qrg values) 2026-08-03 01:09:25 +02:00
Marc Froehlich b432129798 Added contactreplace packets for broadcast whole log - function of DXLog.net to the ucxlog packet listener 2026-07-28 19:30:26 +02:00
Marc Froehlich 344ca547fd Changed sked opportunity highlighting mechanism to more precision, updated the manual 2026-07-26 23:56:32 +02:00
Marc Froehlich 182de0526b Changed ui, used flowpanes to make center slider compatible to smaller screens. URLs are now clickable. Tooltips are shown in case of too small table drawings. Added documentation in the gethub manual. 2026-07-26 18:10:39 +02:00
Marc Froehlich 333063f350 Changed Autoanswer implementation (cooldown timer, message routing) and its documentation 2026-07-26 11:04:47 +02:00
Marc Froehlich ee974bbd73 Fixed beacon implementation, pstrotator settings, Variable handling, Shortcut and snipped preferences ui, beacon handling, AS Settings and all fitting manual texts 2026-07-26 09:49:50 +02:00
Marc Froehlich 00496b56e9 Fixed AS preferences implementation, DXcluster Server and the documentation 2026-07-25 03:01:49 +02:00
Rsclub2_2 5b7897c872 Remove unnecessary Website Build 2026-07-21 13:50:48 +02:00
github-actions[bot] dfc51aa0ec chore: rebuild website roadmap [skip ci] 2026-07-21 00:50:04 +00:00
github-actions[bot] 7e0e338159 chore: rebuild website roadmap [skip ci] 2026-07-21 00:49:44 +00:00
Marc Froehlich 303bb21bd1 Updated preferences functionality and texts 2026-07-21 00:36:34 +02:00
Marc Froehlich 58341902f7 added pstrotator options and fixed some ui stuff 2026-07-21 00:26:14 +02:00
Marc Froehlich f3a1a73231 host key validation fix on AUR package 2026-07-21 00:05:09 +02:00
github-actions[bot] 590ac608cd chore: rebuild website [skip ci] 2026-07-20 21:59:07 +00:00
Marc Froehlich 039e64eea0 website-manual download refactored 2026-07-20 23:58:47 +02:00
Marc Froehlich 0b6120defc another AUR build fix 2026-07-20 23:56:14 +02:00
github-actions[bot] 756f65e825 chore: rebuild website [skip ci] 2026-07-20 21:49:55 +00:00
Marc Froehlich 1de8b77c52 Repaired picture displaying at the website 2026-07-20 23:49:26 +02:00
github-actions[bot] 6b15b98c12 chore: rebuild website [skip ci] 2026-07-20 21:40:39 +00:00
Marc Froehlich b0c04d2238 Installation manual refactoring 2026-07-20 23:40:16 +02:00
Marc Froehlich 01a2d95a8a AUR build fix 2026-07-20 23:24:44 +02:00
github-actions[bot] db844ef4f7 chore: rebuild website [skip ci] 2026-07-20 21:18:32 +00:00
Marc Froehlich 8ee9d59d7a manual refactoring + versioninfo xml refactoring 2026-07-20 23:18:13 +02:00
github-actions[bot] e7631ef688 chore: rebuild website [skip ci] 2026-07-20 21:02:13 +00:00
Marc Froehlich 08dff3e60d manual refactoring + versioninfo xml refactoring 2026-07-20 23:01:53 +02:00
github-actions[bot] b6214fb893 chore: rebuild website [skip ci] 2026-07-20 20:34:49 +00:00
Marc Froehlich 823af70caa manual refactoring 2026-07-20 22:34:31 +02:00
Marc Froehlich 334e5b03a8 manual refactoring 2026-07-20 22:27:53 +02:00
github-actions[bot] 273becf793 chore: rebuild website roadmap [skip ci] 2026-07-16 21:58:51 +00:00
github-actions[bot] 32e28f8797 chore: rebuild website roadmap [skip ci] 2026-07-16 21:58:31 +00:00
github-actions[bot] b3ff39a365 chore: rebuild website roadmap [skip ci] 2026-07-16 21:47:10 +00:00
github-actions[bot] 84ac5eb479 chore: rebuild website roadmap [skip ci] 2026-07-16 21:46:53 +00:00
github-actions[bot] 31bc42982d chore: rebuild website [skip ci] 2026-07-14 23:12:55 +00:00
Rsclub2_2 7acdfca4f5 Fix AUR not liking the .git links 2026-07-15 01:12:37 +02:00
github-actions[bot] c3fd931fee chore: rebuild website [skip ci] 2026-07-14 23:08:56 +00:00
Rsclub2_2 373a30933b News about AUR Packages 2026-07-15 01:08:27 +02:00
github-actions[bot] 43538a277f chore: rebuild website roadmap [skip ci] 2026-07-14 17:29:04 +00:00
github-actions[bot] 5ec16fa458 chore: rebuild website roadmap [skip ci] 2026-07-14 17:28:46 +00:00
github-actions[bot] 9ef0aeef1c chore: rebuild website roadmap [skip ci] 2026-07-14 17:28:29 +00:00
Rsclub2_2 46d722967a fix and Pipeline AUR nightly 2026-07-14 17:19:29 +02:00
Rsclub2_2 fc246a5607 Pipeline AUR 2026-07-14 17:04:29 +02:00
github-actions[bot] 8cdeb2ad63 chore: rebuild website [skip ci] 2026-07-14 14:54:47 +00:00
Rsclub2_2 df0e535fb3 docs: add AUR installation instructions to Wiki 2026-07-14 16:54:19 +02:00
Rsclub2_2 e85d4c2fe2 fix: revert AUR url back to GitHub upstream [skip ci] 2026-07-14 16:47:18 +02:00
Rsclub2_2 360e55633b chore: set AUR package url to kst4contest.hamradioonline.de [skip ci] 2026-07-14 16:43:36 +02:00
Rsclub2_2 367967518e chore: fix kst4contest-git pkgver to 1.42.0 base [skip ci] 2026-07-14 16:41:33 +02:00
github-actions[bot] d8859247c6 chore: update AUR packages to v1.41.1 [skip ci] 2026-07-14 11:35:24 +00:00
github-actions[bot] bf1b72a6b9 chore: rebuild website roadmap [skip ci] 2026-07-13 18:35:32 +00:00
github-actions[bot] 9545e708fd chore: rebuild website roadmap [skip ci] 2026-07-13 18:35:16 +00:00
github-actions[bot] 37121949e4 chore: rebuild website roadmap [skip ci] 2026-07-13 18:22:24 +00:00
github-actions[bot] d70d8d7a24 chore: rebuild website roadmap [skip ci] 2026-07-13 18:20:50 +00:00
github-actions[bot] 6a715567dd chore: rebuild website roadmap [skip ci] 2026-07-13 18:20:37 +00:00
github-actions[bot] a5f96324e6 chore: rebuild website [skip ci] 2026-07-12 22:31:40 +00:00
Marc Froehlich a3e9581c4b modify interactive aircraft scatter hero effect 2026-07-13 00:31:18 +02:00
github-actions[bot] 3285960ee3 chore: rebuild website [skip ci] 2026-07-12 22:27:20 +00:00
Marc Froehlich d532f935d6 modify interactive aircraft scatter hero effect 2026-07-13 00:26:51 +02:00
github-actions[bot] 3988a6d456 chore: rebuild website [skip ci] 2026-07-12 21:52:34 +00:00
Marc Froehlich 598be64306 modify interactive aircraft scatter hero effect 2026-07-12 23:52:11 +02:00
github-actions[bot] 7fcea718ec chore: rebuild website [skip ci] 2026-07-12 21:23:10 +00:00
praktimarc fd7d0a6807 Bugfix/51 selectionmodel fix (#60)
* bugfix for #51. ManagebusManagementThread now never accesses or changes the JAVAFX view-backing ObservableList but using a concurrenthasmap which drives the Tableview backing list with snapshots

* Extending work at #51 for not adding users on UM3 message

* Add interactive aircraft scatter hero effect
2026-07-12 23:22:53 +02:00
github-actions[bot] e424b44cb2 chore: rebuild website [skip ci] 2026-07-11 21:21:26 +00:00
Marc Froehlich dc703fca9e Changes at the website 2026-07-11 23:21:02 +02:00
Marc Froehlich 77c9d34d64 Introduced clustering in the map 2026-07-10 00:30:15 +02:00
github-actions[bot] efd6ed6299 chore: rebuild website roadmap [skip ci] 2026-07-09 21:24:39 +00:00
github-actions[bot] de8e8ba471 chore: rebuild website roadmap [skip ci] 2026-07-09 21:24:26 +00:00
github-actions[bot] eaf611b203 chore: rebuild website roadmap [skip ci] 2026-07-09 16:48:10 +00:00
Rsclub2_2 e339b6fccf implement #49 2026-07-09 18:47:29 +02:00
github-actions[bot] bd9b42d4a5 chore: rebuild website roadmap [skip ci] 2026-07-09 16:46:38 +00:00
github-actions[bot] 275ce48769 chore: rebuild website [skip ci] 2026-07-09 14:12:59 +00:00
Rsclub2_2 d201997587 added automatic Changelog XML to new Website.
also now the XML gets pulled from new Website
2026-07-09 16:12:29 +02:00
github-actions[bot] 250290a1eb chore: rebuild website [skip ci] 2026-07-08 23:14:29 +00:00
Rsclub2_2 052cfea7ad News about Hotfix 2026-07-09 01:14:13 +02:00
github-actions[bot] d930b57196 chore: rebuild website roadmap [skip ci] 2026-07-08 23:03:34 +00:00
github-actions[bot] eb29d56f4e chore: rebuild website roadmap [skip ci] 2026-07-08 23:03:13 +00:00
github-actions[bot] eb2a7f88a4 chore: rebuild website roadmap [skip ci] 2026-07-08 23:02:52 +00:00
github-actions[bot] 8c3e4c15af chore: rebuild website roadmap [skip ci] 2026-07-08 21:55:02 +00:00
github-actions[bot] c8b1f97872 chore: rebuild website roadmap [skip ci] 2026-07-08 21:07:12 +00:00
github-actions[bot] b47fd21dd0 chore: rebuild website roadmap [skip ci] 2026-07-08 21:06:59 +00:00
github-actions[bot] f009ed4b3d chore: rebuild website roadmap [skip ci] 2026-07-08 20:48:02 +00:00
Rsclub2_2 47126f4ae1 Push website rebuild commits via deploy key instead of GITHUB_TOKEN 2026-07-08 22:44:39 +02:00
7d3177ba8a Add selected-band activity indicator for stations in map and details (#53)
* Initial plan

* Add map indicator for selected-band activity hints

* Add selected-band marker to main station table

* Move band-offer star from callsign to worked status cell

* Move band-offer star from wkdany column to per-band worked cells

Fixes a bad merge that had nested a method definition inside another
method (broke compilation), and relocates the new-band-opportunity
star so it appears on the specific band cell it applies to instead of
a single generic wkdany column, since a station can offer several
bands at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Changed Indicator to B+ instead of star for better understandability

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Philipp Wagner <philipp@wagnersnetz.de>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:32:54 +02:00
Rsclub2_2 ff71623b90 Website: Version Dynamic and Roadmap Padding 2026-07-08 22:03:38 +02:00
Rsclub2_2 ead985a741 Website Build with Roadmap. 2026-07-08 21:35:15 +02:00
Rsclub2_2 ef1eeeed36 Bump version to 1.42 now that v1.41.1 hotfix is tagged and released 2026-07-08 21:27:30 +02:00
894 changed files with 105963 additions and 38586 deletions

No files matched your search

@@ -0,0 +1,90 @@
---
name: kst4contest-change
description: Analyze or implement KST4Contest Java/JavaFX changes, bug fixes, refactorings, protocol handling, contest workflow behaviour, callsign/band logic, AirScout/logging/rotor/DXCluster integrations, threading and state management. Use current code as source of truth and follow the mandatory concept-and-question gate before edits.
---
# KST4Contest change workflow
Read the relevant reference files before proposing a concept.
## Phase 1: read-only analysis
- Inspect the current code and tests.
- Identify the current data flow and thread ownership.
- Identify user-visible and protocol-visible behaviour.
- Check relevant `docs/PROJECT_CONTEXT.md` sections when they exist.
- Check whether the task overlaps a known invariant in the references.
- Do not modify files.
## Phase 2: report understanding in German
Explain:
- what Marc wants changed;
- what must remain unchanged;
- which components appear affected;
- what evidence in the current code supports that understanding;
- any conflict between current code and historical project context.
Never resolve a conflict by guessing.
## Phase 3: questions and final concept in German
Before finalizing the concept:
- identify all material implementation choices;
- ask Marc questions that are not already answered by code, project instructions or prior confirmed decisions;
- wait for answers when needed.
Then present:
- intended data/control flow;
- exact behavioural changes;
- compatibility impact;
- thread/UI impact;
- persistence impact;
- test strategy;
- likely documentation impact;
- likely durable project-context impact;
- related-project impact when relevant.
Ask for explicit concept approval and wait before editing.
## Phase 4: implementation
After approval:
- implement the smallest coherent change;
- preserve unrelated behaviour;
- keep comments/Javadoc in English;
- add/update focused tests;
- keep external protocol parsing defensive;
- avoid hidden defaulting for unknown values.
## Phase 5: verification and documentation impact
Run focused checks, then appropriate broader checks.
Because KST4Contest build configuration may ignore failures/findings, inspect summaries and reports rather than only command exit status.
Then use `$software-project-context`:
- do a low-cost documentation-impact classification;
- inspect only likely affected manual/README/website sections;
- update targeted documentation when clearly required by the approved implementation;
- update `docs/PROJECT_CONTEXT.md` for significant durable technical decisions/state changes;
- do not perform a full manual audit unless there is a specific trigger.
## Phase 6: report
Report in German:
- files changed;
- implementation summary;
- tests/checks;
- warnings/findings;
- documentation-impact result;
- documentation/context updates or why none were required;
- related-project impact when relevant;
- unresolved issues;
- no Git publication action unless explicitly requested.
@@ -0,0 +1,7 @@
interface:
display_name: "KST4Contest Change"
short_description: "Plan and implement KST4Contest changes safely"
default_prompt: "Analyze the requested KST4Contest change, explain your understanding and concept in German, ask implementation questions, and wait for approval before editing."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,102 @@
# Architecture context
## Current package shape
The repository currently contains major packages under `src/main/java/kst4contest/` including:
- `controller`
- `locatorUtils`
- `logic`
- `model`
- `service`
- `test`
- `utils`
- `view`
Do not treat package names alone as proof of clean MVC boundaries. Inspect actual dependencies.
## Preferred message/member data flow
The established target architecture for active chat members is:
```text
ON4KST / network
|
v
MessageBusManagementThread
|
v
ChatController
|
v
thread-safe active-member domain state
(ConcurrentMap; identity includes callsign + category)
|
v
JavaFX ObservableList UI mirror
|
v
FilteredList / SortedList / TableView / selection
```
Key rule:
`ObservableList` is a JavaFX UI projection, not the canonical store for worker-thread logic.
`MessageBusManagementThread` must not directly read or modify the UI list.
## JavaFX boundary
UI-visible mutations belong on the JavaFX Application Thread.
Prefer controller-owned helpers such as an existing `runOnFxThread` abstraction when available; otherwise use `Platform.runLater` consistently.
Do not move business/data access into the FX thread merely to silence a threading problem.
## Parser/service separation
For protocol receivers, the preferred direction is:
```text
Receiver (I/O only)
-> Parser (wire data -> DTO)
-> Service (domain/persistence logic)
-> Controller (UI coordination)
-> Observable UI model
-> View
```
A previous concrete example for UCXLog was:
```text
UcxUdpReceiver
-> UcxPacketParser
-> DTO
-> UcxLogService
-> ChatController
-> UI projection
```
This is architectural guidance, not permission for a broad refactor. Apply only when it is in scope and approved.
## DTO preference
Prefer explicit DTO classes over records when introducing protocol/transport data structures in this project, unless the approved concept intentionally changes that convention.
## Null safety
Chat members can be incomplete, especially:
- fallback members;
- historic message senders;
- server-derived partial members.
Values such as QRB and QTF can be absent.
Rules:
- absence stays absence;
- do not map `null` to numeric zero;
- UI must render unavailable state safely;
- sorting/filtering/calculation code must tolerate missing values;
- unexpected missing values must not terminate worker or UI threads.
@@ -0,0 +1,42 @@
# Automated messaging, beacons and skeds
## General
Automated replies/beacons must be conservative because they interact with the live ON4KST service.
## Established safety behaviour
Historical confirmed rules include:
- beacon/autoanswer scheduling shares controlled timing rather than spawning uncontrolled independent timers;
- minimum interval has been tightened to avoid spam;
- automated text length is bounded;
- invalid or incomplete replies are rejected before transmission;
- a cooldown is not consumed unless a complete valid reply enters the TX queue;
- QRG/frequency requests take precedence where the current implementation defines that;
- automated-message markers are ignored to prevent response loops.
Recent logic used a two-minute cooldown keyed by complete callsign plus chat category and ignored the project's own automated-message marker.
Treat exact marker strings and timing constants as current-code facts to verify, not values to recreate from memory.
## Monitoring
Station monitoring is intentionally base-call-wide:
Entering a variant such as:
```text
DN9APW-2
DN9APW-70
```
monitors:
```text
DN9APW
```
This reduces manual configuration for sked monitoring.
Keep this separate from chat-member identity, which may require full suffix + category.
@@ -0,0 +1,87 @@
# Domain, callsigns and bands
## Chat-member identity
Full callsign variants can be distinct chat identities.
Examples:
```text
DN9APW
DN9APW-2
DN9APW-70
```
Do not globally strip suffixes when identifying chat members.
Category is also part of identity. A practical key is conceptually equivalent to:
```text
FULL_CALLSIGN|CATEGORY
```
Do not allow messages from one category to attach to a same-looking member in another category.
## Base-call operations
Some features intentionally operate on the base callsign.
Confirmed examples:
### Worked state
Worked status is shared across suffix variants of the same base call.
If the base station has been worked on the relevant basis, variants such as `CALL-2`, `CALL-70`, `CALL-144`, `CALL-432` should not become independent worked identities merely because of the suffix.
### Monitoring
Monitoring a station entered as `DN9APW-2` or `DN9APW-70` should monitor the base call `DN9APW`.
This is intentional: a user monitoring another station's skeds should not have to create one monitor entry per SSID.
Do not extend base-call matching to unrelated features without approval.
## Suffix semantics
Do not assume a suffix always means a band or category.
Historical examples have shown the same base calls with different suffix conventions in different chat categories.
Therefore:
- preserve exact full-call identity where needed;
- normalize only for explicitly approved base-call features;
- never infer missing band/category semantics from the suffix alone.
## Categories
KST4Contest's central VHF/UHF usage focuses on ON4KST categories 2 and 3.
However, other categories can occur.
Rules:
- unsupported/uninteresting categories must be ignored or handled safely;
- they must not produce index, switch or null errors;
- do not let their values contaminate category-2/category-3 band logic.
## Band availability
Band activity can be derived from several signals including name/text parsing and explicit/manual information.
Confirmed invariant:
`NOT-QRV` overrides positive availability indications.
Known-active-band and `B+` handling should use one consistent interpretation across the program.
Do not implement separate slightly different parsers in multiple UI/features if a shared existing mechanism is available.
## Current QRG
Features that depend on propagation/band/frequency should use the current relevant QRG or the approved band calculation.
Never silently reintroduce a universal hardcoded 144 MHz fallback.
When a frequency is ambiguous and no approved fallback exists, ask rather than guessing.
@@ -0,0 +1,115 @@
# Known edge cases and regression patterns
This file is a regression-awareness list. Reproduce/inspect current code before deciding a historical bug still exists.
## Incomplete ChatMember
Known failure pattern:
```text
Cannot invoke "java.lang.Double.intValue()" because
ChatMember.getQrb() is null
```
Lesson:
- QRB can be absent;
- UI/calculation code must not blindly unbox/convert;
- unavailable is not zero.
Apply the same reasoning to QTF and other server/fallback-derived fields.
## Callsign suffix collisions
A historical issue caused messages/identity problems between:
```text
DN9APW
DN9APW-2
```
The corrective model is not "strip all suffixes".
Instead:
- keep full-call chat identities distinct;
- include category;
- use base call only for explicitly base-call-wide features such as worked state/monitoring.
## Unsupported chat categories
The ON4KST ecosystem can expose categories outside the two central ones.
A parser/switch/filter must not throw because the category is irrelevant to KST4Contest.
Safe ignore/fallback beats fake band assignment.
## AirScout higher-band queries
A historical observation showed aircraft visible in AirScout while API results for a 432 MHz case were empty.
Potential causes included frequency-string formatting.
Lesson:
- verify the exact upstream contract;
- compare request produced by KST4Contest with a known-working request;
- do not "fix" by guessing a string format or falling back to an unrelated band.
## Second airplane-scatter result / missing aircraft
A known UI failure involved missing/partial airplane-scatter data and a `TextInputControl` range error (`start must be <= end`).
Lesson:
- empty/partial AP results must be validated before text-range highlighting/selection;
- second-result paths need the same null/range checks as primary results.
## Historic/unknown user message
A user/message record can refer to a callsign not present in the current member list.
Do not require current login membership to render or classify historic messages.
## UM3-style handling
Historical message handling included cases that should be ignored safely if the user is not in the chat/member state.
Lesson:
- external message types must tolerate missing member references.
## CR/LF and disconnect suspicion
Do not treat line endings as harmless text formatting in socket code.
When diagnosing a disconnect:
- inspect transmitted bytes;
- inspect server response/EOF;
- compare Windows versions only after proving the application sends different bytes;
- avoid duplicated LF/CRLF terminators.
## Filter reset
A reset button that clears control values but leaves predicates active is not a valid reset.
Verify final predicate composition, not only UI state.
## Map render flicker
Leaflet/WebView render fragmentation under Java 21 was mitigated by:
```text
window.L_DISABLE_3D = true
```
before Leaflet load.
Do not remove as "obsolete CSS cleanup" without a visual regression check.
## Network start/reconnect loop
Initial connection failure must not spin indefinitely or block controlled recovery.
Connection state must be based on actual I/O lifecycle rather than only `Socket.isConnected()`-style historical state.
@@ -0,0 +1,183 @@
# Project behaviour catalog
This catalog summarizes behaviour established during prior KST4Contest work. It is context for analysis, not permission to overwrite newer code. Always inspect the current implementation before modifying a listed area.
## Core purpose
KST4Contest is an ON4KST-oriented desktop client optimized for VHF/UHF/microwave contest workflows.
Core areas developed over time include:
- simultaneous ON4KST chat handling;
- priority candidates;
- sked workflow and timeline;
- worked-state synchronization;
- logging integrations;
- DXCluster;
- AirScout / airplane-scatter assistance;
- rotor/control integrations;
- map/path visualization;
- automated replies/beacons;
- user filtering and reachability;
- documentation and website/update-feed integration.
## Two chat categories
The application is designed around two simultaneous relevant chat categories in normal operation.
Important consequences:
- same-looking calls in different categories are not automatically the same chat identity;
- category is part of message/member identity;
- category-specific QRG/band settings must not leak into the other category;
- unsupported categories must not crash shared logic.
## Priority candidates
Priority scoring has included factors such as:
- QTF match;
- recent activity;
- message count;
- positive signal indications;
- sked rate.
Do not change weighting/meaning as collateral work. Treat it as user-facing contest logic.
## Timeline / skeds
The sked timeline has used 30-minute lanes and visualized airplane-scatter probability windows.
Known historical AP strength levels:
- 100%;
- 75%;
- 50%.
Sked reminder presets have covered short contest-relevant lead times.
Do not hardcode historical display constants into new code without verifying the current view/model.
## Worked state
Worked state is loaded from persistence and updated live from supported logging inputs.
The simplified UI meaning has been "worked any" where the locator/worked indicator is concerned.
Worked state is base-call-wide across suffix variants where established.
When changing persistence or logging synchronization, verify:
- startup DB load;
- live update;
- suffix/base-call mapping;
- band mapping;
- 50/70 MHz support where applicable;
- UI projection.
## Known active bands / B+
Known-active-band information is derived consistently across the application from available hints.
Historical work unified:
- band mentions in user names;
- band mentions in text;
- manual/global band information;
- `B+`-style availability.
Explicit `NOT-QRV` overrides positive hints.
Avoid introducing a second parser with different semantics.
## Selection and send workflow
Established fast-workflow behaviour includes:
- selecting a new station prefills `/cq callsign`;
- send text is geared toward minimal contest interaction;
- if no target chat category is selected, Main is the established fallback.
These are intentional workflow decisions, not incidental UI details.
## DXCluster
DXCluster support has included:
- integrated display;
- copyable lines;
- `/cq` workflow support;
- beacon monitoring;
- QTF/bearing-related presentation.
Preserve locator semantics and avoid sender/receiver field confusion.
## Map / path view
Map work has included:
- Leaflet 1.9.4 in JavaFX WebView;
- terrain/path information;
- airplane-scatter integration;
- target-station selection;
- path-analysis visibility;
- station-count/status information;
- target reset that does not reset zoom.
A persistent right-side station-information panel has been reduced/removed in favour of more compact presentation in later UI work.
Before changing layout, inspect the current version because this area has been actively iterated.
## Reachability and filters
Filter work has separated reachability concerns from generic filters.
A Reset Filter control must reset the actual filter predicates, not just visual controls.
UI sorting/filtering must remain stable when backing data changes.
## Autoanswer and beacons
Automated messaging exists to reduce repetitive contest chat work without creating spam or feedback loops.
Important principles:
- conservative timing;
- bounded text;
- no loop on own automated markers;
- only consume cooldown after a valid queued reply;
- category/callsign-safe identity;
- QRG requests handled with the intended precedence.
## Connection handling
Network reliability is contest-critical.
Work has explicitly targeted:
- accurate connected/disconnected state;
- server disconnect detection;
- reconnect behaviour on unstable links;
- no infinite loop on initial connection failure;
- visible connection-state indication in the UI.
Do not regress connection state into "socket object exists therefore connected".
## Historic messages
Historic/non-current chat senders may not have a complete live `ChatMember`.
Highlighting, display and parsing must tolerate users not currently logged in.
## Website and documentation
The application repository also contains:
- bilingual manual content;
- documentation images;
- automated documentation PDF build;
- Eleventy website;
- download/update metadata generation;
- release-oriented website automation.
A user-visible feature change may therefore affect more than Java source.
@@ -0,0 +1,101 @@
# Protocols and external integrations
This file records stable rules plus historical context. For exact current wire formats, ports and frequency strings, inspect the current code and authoritative upstream documentation.
## ON4KST
KST4Contest depends on long-lived server communication where malformed commands or framing can lead to disconnects.
Rules:
- preserve exact protocol framing;
- treat CR/LF changes as protocol changes, not formatting cleanup;
- do not append extra line terminators without verification;
- detect actual socket/server disconnects reliably;
- initial connection failure must not create an uncontrolled infinite loop;
- reconnect logic must tolerate unstable Internet access;
- the UI should make disconnected state clearly visible where implemented.
If Windows-specific behaviour is suspected, do not assume Win10/Win11 line-ending semantics explain it without reproducing or tracing the bytes.
## UCXLog / DXLog UDP XML
`contactreplace` must be handled equivalently to `contactinfo` for whole-log broadcasts where applicable.
Historical raw-packet XML start detection included:
```text
<?xml
<contactinfo
<contactreplace
<RadioInfo
```
DOM handling also included a fallback to `contactreplace`.
Before changing this path, inspect the current parser because the code may have been refactored since this behaviour was introduced.
Preferred layering:
```text
UDP receiver -> parser -> DTO -> service/domain/DB -> controller -> UI
```
## Win-Test
Historical integration uses UDP port 8721 for Win-Test information.
Do not hardcode this fact into unrelated logic. Verify current configuration before changing listener setup or band mapping.
Changes involving 50/70 MHz, worked state or frequency mapping must be consistent with other logging inputs.
## AirScout
KST4Contest integrates with AirScout path/airplane-scatter information.
Stable principles:
- propagation/path queries must reflect the current relevant frequency/band;
- do not fall back to 144 MHz merely because older code did;
- unsupported chat categories must fail safely;
- frequency-string formatting is an external API contract and must be checked, not guessed.
Historical work included a temporary 430 MHz approximation for ambiguous higher-band handling. Treat that as historical context, not a permanent invariant. Inspect the current implementation before using or changing it.
## PSTRotator
The integration has evolved.
Historical project notes mention more than one control approach, and recent work included UDP control/feedback behaviour around a configurable control port and feedback on the next port, including SPID movement retry logic.
Therefore:
- inspect the current implementation before assuming TCP vs UDP;
- inspect current settings/defaults;
- do not copy an old port/transport assumption into new code;
- preserve asynchronous JavaFX-safe handling;
- preserve any verified retry sequence only if it still exists in current code/tests.
If current code and historical notes conflict, ask Marc after showing the conflict.
## DXCluster
DXCluster is integrated into the contest workflow.
Preserve:
- copyable/usable cluster lines;
- correct sender/receiver locator semantics;
- safe handling of missing locator data.
A historical bug copied sender and receiver locators as equal; do not reintroduce that behaviour.
## Protocol-wide error handling
External data is not trusted to be complete.
Rules:
- validate before dereferencing;
- unknown categories/bands/tags should degrade safely;
- malformed packets must not kill long-running receiver/management threads;
- logging should make the rejected input diagnosable without flooding normal operation.
@@ -0,0 +1,22 @@
# Deferred and roadmap context
This file is background only. Do not implement these items merely because they are mentioned here.
## Propagation model
After the manual audit, Marc intends to revisit and improve KST4Contest propagation modelling.
Exploration areas include:
- higher-density Copernicus GLO-30 terrain sampling;
- Fresnel-zone analysis;
- diffraction modelling;
- simplified ray tracing / multi-segment paths;
- VHF/UHF/microwave contest applicability, including around 1296 MHz and above;
- practical contest-oriented prediction rather than academic complexity for its own sake.
This requires a fresh concept before implementation.
## Rule
Roadmap context must never silently enlarge the scope of a current task.
@@ -0,0 +1,80 @@
# Settings and data context
Inspect `Config`/settings classes and current UI before using these names; this list records important settings/concepts encountered during prior work.
## Band / station settings
Important concepts have included:
- `MYQRGFirstCat`;
- `MYQRGSecondCat`;
- manual station band information;
- current/actual QTF;
- known-active bands;
- selected/current QRG.
Band-dependent features must use the correct category/station context.
## Antenna / path settings
Important concepts have included:
- `actualQTF`;
- `antennaBeamWidthDeg`;
- maximum QRB;
- AirScout/path-analysis settings.
Missing QRB/QTF must remain unknown, not numeric zero.
## UI settings
Persisted UI behaviour has included:
- dark mode;
- map/path-analysis visibility;
- filters/reachability controls;
- column visibility;
- divider/layout state where implemented.
Do not reset persisted user choices as an incidental effect of a feature change.
## Logging / worked persistence
Worked information is persisted and updated through multiple input paths.
Before changing one path, compare semantics across:
- DB load on startup;
- simple/manual log integration where present;
- UCXLog/DXLog;
- Win-Test;
- other current logging inputs.
The goal is one worked-state interpretation regardless of source.
## Chat/message automation
Configuration has included:
- beacon/autoanswer enablement;
- beacon defaults;
- message limits/timers;
- category-specific communication.
Do not duplicate timers or create per-feature scheduling that bypasses the shared safety model.
## Connection state
Connection-state UI must reflect actual ON4KST connection lifecycle.
Any new state enum/property should have a clear owner and thread boundary.
## Persistence rule
Do not change persisted keys/schema/semantics simply to make new code easier.
If a schema/key migration is required:
1. explain current and new format;
2. describe backward compatibility;
3. ask for approval before implementing.
@@ -0,0 +1,100 @@
# Build, tests, static analysis and release safety
## Maven
Use the repository Maven wrapper.
Windows:
```text
.\mvnw.cmd test
.\mvnw.cmd package
```
Run narrower tests first when possible.
## Important Surefire behaviour
The project has used:
```xml
<testFailureIgnore>true</testFailureIgnore>
```
Therefore an exit code of zero is not sufficient evidence that all tests passed.
Always inspect:
- test counts;
- failures;
- errors;
- skipped tests;
- Surefire report output when necessary.
State exact results in the completion report.
## PMD and SpotBugs
PMD and SpotBugs are integrated, but their findings have historically not always failed the build.
Do not say "static analysis clean" unless the relevant reports/output were actually checked.
## Packaging
The build contains packaging/module-list consistency logic.
Changes involving modules, JavaFX modules, jpackage or `module-info.java` must check:
- `pom.xml`;
- `packaging/` helpers;
- module requirements;
- packaging verification output.
Do not manually update only one copy of a generated/synchronized module list.
## Website
The website is Eleventy-based and has Node tests.
Inspect `website/package.json`, `website/test/` and current scripts before choosing exact commands.
Historical website validation included Node tests for generated version/update information.
## Documentation build
GitHub Actions generates documentation/PDF and site artefacts.
A local code build does not prove documentation/site CI will pass.
## Versioning
Do not change project version, semantic version, update feed, tag or release metadata unless explicitly requested.
## Git
Each of these needs separate authorization:
- stage;
- commit;
- push;
- PR;
- merge;
- tag;
- release.
When asked to commit, use a concise English commit message.
Do not stage unrelated files.
## Release communication
When a release is explicitly in scope, check:
- current changelog;
- GitHub release/tag;
- website download/update feed;
- documentation;
- HamRadioOnline download/manual destinations;
- any SourceForge publication workflow currently used.
Do not assume an older deployment pipeline is still active.
@@ -0,0 +1,73 @@
# Threading and state management
## Canonical state vs UI state
Use a thread-safe canonical state for data consumed by worker/network threads.
The active-member UI list is only a projection.
Preferred conceptual model:
```text
ConcurrentMap<MemberKey, ChatMember> activeMembers
|
| FX-thread projection/update
v
ObservableList<ChatMember> activeMembersUi
```
`MemberKey` semantics must preserve full callsign plus category unless the specific operation is intentionally base-call-wide.
## MessageBusManagementThread
Do not:
- iterate JavaFX `ObservableList` from the worker thread;
- add/remove JavaFX-list entries directly from the worker thread;
- use the FX thread as a substitute for proper domain state ownership.
Do:
- pass domain events/data to the controller/service boundary;
- modify canonical thread-safe state outside UI code as appropriate;
- project changes to JavaFX state on the FX thread.
## Controller boundary
`ChatController` is the preferred coordination boundary for UI-visible state.
Keep view-specific operations out of protocol receiver code.
## External receiver design
For receiver refactors, separate:
- socket/UDP/TCP I/O;
- parsing;
- DTO;
- domain/persistence;
- UI coordination.
## Error containment
Long-running threads must survive:
- malformed server records;
- incomplete members;
- unknown bands/categories;
- missing locators;
- null QRB/QTF;
- temporary socket failure.
Catch errors at meaningful boundaries and include enough context in English diagnostic logs/comments to trace the input and stage of failure.
Do not swallow errors silently.
## JavaFX selection/sorting
When updating backing data:
- preserve current selection where the feature expects it;
- avoid invalidating `FilteredList`/`SortedList` assumptions;
- do not create recursive UI updates;
- avoid accessing control state from worker threads.
@@ -0,0 +1,65 @@
# UI behaviour and workflow invariants
These are known user-experience decisions. Verify the current implementation before changing them.
## Selection and send text
A new station selection should prefill the established command form:
```text
/cq callsign
```
This behaviour is deliberate even when prior input text existed, according to the established workflow.
If no category is selected for sending, the established fallback is the Main category.
Do not change either behaviour as a side effect of unrelated refactoring.
## Map view
Known decisions:
- reset clears the target/station selection;
- reset does not change the current zoom level;
- selected-station information was moved toward the compact status line rather than requiring a persistent right-side detail panel;
- path-analysis visibility is user-controllable and should not become undiscoverable;
- map controls must remain usable in dark/light modes.
## Leaflet / JavaFX WebView
With Leaflet 1.9.4 under Java 21, fragmented rendering/flicker was fixed by disabling Leaflet CSS 3D transforms before Leaflet loads:
```text
window.L_DISABLE_3D = true
```
Do not remove/reorder this workaround without reproducing the rendering problem and proving the replacement.
## Filters
Known UI direction:
- Reset Filter must actually clear relevant filter predicates;
- reachability controls are conceptually separate from generic filter controls;
- reset control should remain visually discoverable;
- truncated text should remain accessible through tooltips where implemented;
- clickable links should remain functional in both themes.
## Priority / timeline
The contest workflow includes:
- priority candidate presentation;
- sked timeline;
- activity/AP windows;
- sked reminders.
Avoid UI changes that damage quick contest operation merely to make layout code simpler.
## Null display
Missing data is not `0`.
For QRB/QTF/locator/derived values, follow the current UI convention for unavailable/empty state.
Do not show a plausible-looking number when the model value is actually unknown.
@@ -0,0 +1,74 @@
---
name: kst4contest-documentation
description: Perform targeted KST4Contest documentation-impact checks and update affected German/English manuals, README, website feature text, changelog, release notes and durable project context. Avoid full audits by default; keep documentation aligned with implemented behaviour and use the praktimarc-writing-style skill.
---
# KST4Contest documentation workflow
Use `$software-project-context` and `$praktimarc-writing-style`.
## Default behaviour
Do not read the complete manual or website after every code change.
Start with a documentation-impact classification and search for the affected feature, setting, UI label, protocol/integration or operational concept.
Escalate to a broader audit only when:
- Marc explicitly requests it;
- a major release is being prepared;
- the change is broad across UI/workflows;
- multiple targeted checks reveal wider drift.
## Before editing documentation
1. Inspect the actual implementation or approved specification.
2. Explain in German what documentation is probably affected.
3. Ask only unresolved behaviour/scope questions.
4. If documentation updates are already part of an approved implementation concept, no second approval is required for obvious synchronisation.
5. If documentation reveals a new material product decision, stop and ask Marc.
## Manuals
When user-facing behaviour is affected:
- search English and German manual content under `github_docs/` for the relevant feature/labels first;
- inspect surrounding sections only;
- keep both language versions semantically equivalent;
- do not translate mechanically; English must be idiomatic;
- preserve exact UI labels, values, callsigns, ports and protocol terminology;
- document current behaviour only;
- if code and manual disagree and it is unclear which behaviour is intended, report the conflict;
- identify outdated/missing screenshots explicitly.
## README / website
Check only when the changed feature, installation, configuration, capability or compatibility is represented there or should reasonably be represented there.
- Keep feature descriptions concise.
- Explain real contest/operating benefit, not marketing slogans.
- Avoid duplicating large manual sections on the website.
- Keep the main manual/download destinations consistent with the current site strategy.
## Durable project context
Update `docs/PROJECT_CONTEXT.md` for significant:
- architectural decisions;
- threading/state ownership;
- callsign/category semantics;
- protocol/integration contracts;
- persistence/configuration changes;
- durable workarounds;
- deployment/website relationships;
- cross-project dependencies;
- planned propagation/API architecture when it becomes concrete.
Keep current-state sections current rather than using the file as a raw changelog.
## Changelog / release notes
- Compact factual bullets.
- Include user-visible changes and important reliability/compatibility fixes.
- Do not invent version scope; derive it from actual commits/changelog/release context.
- Keep release posts short and operationally relevant.
@@ -0,0 +1,7 @@
interface:
display_name: "KST4Contest Documentation"
short_description: "Keep KST4Contest manuals, website and release text aligned"
default_prompt: "Review the implemented KST4Contest behaviour, explain the documentation impact in German, propose a concept, ask questions, and wait for approval before editing."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,26 @@
# User-facing feature context
This is a documentation coverage reminder, not a canonical feature list. Verify each item in current code before documenting it.
Areas repeatedly documented or changed include:
- simultaneous ON4KST chat categories;
- priority candidates;
- sked timeline/reminders;
- known active bands / B+ / NOT-QRV;
- worked indicators;
- DXCluster;
- AirScout integration;
- map/path analysis;
- filtering and reachability;
- PSTRotator/rotor integration;
- UCXLog/DXLog and Win-Test log synchronization;
- beacon/autoanswer behaviour;
- connection status/reconnect behaviour;
- dark/light UI behaviour;
- QTF/bearing workflow;
- download/update behaviour.
When one of these changes, search both language manuals and website copy for affected references.
Do not specialize documentation beyond actual behaviour. A useful example from prior work is callsign monitoring: entering an SSID-style variant can intentionally monitor the base call rather than requiring every suffix to be configured individually.
@@ -0,0 +1,47 @@
# Manual and website context
## Manual location
KST4Contest documentation is maintained in `github_docs/` with English and German Markdown pages plus screenshots.
The documentation build is automated through repository workflows.
## Audit workflow established with Marc
The normal review method is:
1. compare documentation with actual code/behaviour;
2. propose exact changes;
3. note missing/outdated screenshots and their intended repo location;
4. if code must change to match the manual, stop and confirm that code change first;
5. keep German and English content aligned;
6. prefer one thorough update over many cosmetic iterations.
With Codex editing locally, the old copy/paste insertion-guide step is replaced by direct edits, but the approval logic remains.
## Examples and easter eggs
Deliberate examples/test strings must not be "cleaned up" merely because they are informal.
A known example uses:
```text
DO5AMF
Testing DXC-Spot: Congrats, you donated $100!
```
Preserve such deliberate easter eggs unless Marc explicitly asks to remove or replace them.
## Website
The website under `website/` uses Eleventy/Nunjucks.
Style direction:
- modern and concise;
- technically focused;
- no promotional tone;
- English primary where appropriate;
- documentation remains the detailed source; website text should not duplicate entire manual sections.
Current website architecture/scripts must be inspected before changes.
@@ -0,0 +1,35 @@
# Release communications
## Changelog
Write concise English change descriptions.
Prioritize:
- behaviour users notice;
- contest workflow impact;
- protocol/integration compatibility;
- bug/reliability fixes;
- documentation improvements.
Avoid internal refactor trivia unless it materially changes reliability or maintainability relevant to the release.
## Social release post
Use `$praktimarc-writing-style`.
Typical structure:
- version;
- short statement of what the release contains;
- compact highlights;
- operational context where relevant, e.g. preparation for a VUSHF contest or planned use at DM5M;
- one clear download/manual destination.
Do not oversell.
## Download/manual direction
Marc has preferred routing users to the HamRadioOnline/KST4Contest download/manual pages rather than scattering multiple download links.
Before publishing new text, inspect the current website URLs and release setup instead of copying an old link.
@@ -0,0 +1,52 @@
---
name: kst4contest-review
description: Review current KST4Contest local changes or a proposed diff before commit. Check regressions, null safety, JavaFX threading, callsign/category semantics, band handling, protocol compatibility, tests, targeted documentation impact, durable project context and unintended scope. Report findings in German and do not modify files unless explicitly asked after the review.
---
# KST4Contest review
Review first; do not edit during the review.
Read the relevant KST4Contest change references.
## Review priorities
1. Behaviour matches the approved concept.
2. No unrelated changes.
3. Full callsign/category identity remains correct.
4. Base-call normalization is used only where intended.
5. Null/unknown values are not converted to fake defaults.
6. Worker threads do not manipulate JavaFX UI collections.
7. FX-thread boundaries are correct.
8. Protocol framing, CR/LF, XML and frequency formatting are unchanged unless explicitly intended.
9. External malformed input cannot kill long-running threads.
10. Tests cover the changed behaviour.
11. Maven test output was interpreted correctly despite ignored-failure settings.
12. A documentation-impact assessment was performed.
13. Any likely affected manual/README/website sections match the implementation.
14. `docs/PROJECT_CONTEXT.md` is updated when the change introduces a durable architectural/protocol/state/operational/integration decision.
15. Comments/Javadoc are English.
16. No unintended dependency/version/release changes.
Do not demand a full manual audit for an internal-only change when the impact assessment reasonably concludes there is no documentation effect.
## Report format
Report in German, ordered by severity.
For each finding include:
- affected file/location;
- concrete problem;
- consequence;
- recommended correction.
Then include:
- verification gaps;
- documentation-impact result;
- durable-context gaps;
- related-project gaps when relevant;
- overall assessment.
Do not fix findings until Marc explicitly asks for implementation and the normal concept gate has been satisfied for the fixes.
@@ -0,0 +1,7 @@
interface:
display_name: "KST4Contest Review"
short_description: "Review KST4Contest diffs before commit"
default_prompt: "Review the current KST4Contest changes only. Report findings in German and do not edit files."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,61 @@
# Review checklist
## Scope
- Is every changed file necessary?
- Did unrelated formatting or refactoring slip in?
- Were user-authored local changes preserved?
## Architecture/threading
- Is canonical state owned outside JavaFX controls/lists?
- Does worker code avoid `ObservableList` access?
- Are UI mutations routed to the FX thread?
- Are parser/I/O/domain/UI responsibilities clearer or at least not more coupled?
## Domain
- Full callsign + category identity preserved?
- Base-call matching restricted to worked/monitoring or another explicitly approved feature?
- Unknown category/band values safe?
- NOT-QRV precedence preserved?
- Missing QRB/QTF remains unavailable rather than zero?
## Protocols
- ON4KST framing unchanged unless approved?
- CR/LF exact?
- UCX `contactreplace` compatibility preserved?
- Frequency strings verified rather than guessed?
- PSTRotator/AirScout transport/API assumptions checked against current code?
- Malformed input contained?
## UI
- Selection/focus/zoom/sorting preserved?
- `/cq callsign` prefill behaviour preserved where relevant?
- Main send-category fallback preserved where relevant?
- map WebView workaround preserved?
- null values displayed safely?
## Tests/build
- Focused regression test added or updated?
- Test summary checked?
- Ignored failures explicitly reported?
- PMD/SpotBugs output considered?
- packaging/module-list checks considered if modules changed?
## Documentation
- DE and EN manual both checked?
- website/README/changelog checked if user-visible?
- screenshot impact reported?
- writing style applied?
- no undocumented implementation or documented-but-unimplemented behaviour?
## Git/release
- No version bump unless requested?
- No generated release/update-feed changes by accident?
- No staging/commit/push without explicit authorization?
+4
View File
@@ -0,0 +1,4 @@
model_reasoning_effort = "high"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
web_search = "live"
+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
# Gradle dependencies, including the version catalog in gradle/libs.versions.toml
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
target-branch: "main"
ignore:
# Kotlin and Compose Multiplatform are coupled; bump them manually together
- dependency-name: "org.jetbrains.kotlin*"
- dependency-name: "org.jetbrains.compose*"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# Actions used in .github/workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
target-branch: "main"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
+2 -1
View File
@@ -95,7 +95,8 @@ $endif$
belowskip = 8pt,
literate = {→}{{\ensuremath{\rightarrow}}}1
{←}{{\ensuremath{\leftarrow}}}1
{↔}{{\ensuremath{\leftrightarrow}}}1,
{↔}{{\ensuremath{\leftrightarrow}}}1
{…}{{\ldots}}1,
}
%% ─── Blockquotes ──────────────────────────────────────────────────────────
+155
View File
@@ -0,0 +1,155 @@
name: AUR Nightly (kst4contest-git)
on:
workflow_run:
workflows: ["Nightly Runtime Artifacts"]
types: [completed]
workflow_dispatch:
permissions:
contents: read
env:
AUR_SSH_DIR: /tmp/aur-ssh
jobs:
update-aur-git:
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Sy --noconfirm git openssh base-devel
useradd -m builder
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Mark workspace as safe Git directory
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Compute pkgver
id: ver
run: |
BASE=$(grep -m1 '^version=' gradle.properties \
| cut -d= -f2 \
| sed 's/[-.]nightly//')
GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
echo "Computed pkgver: ${GIT_PKGVER}"
echo "pkgver=${GIT_PKGVER}" >> "$GITHUB_OUTPUT"
- name: Update PKGBUILD
env:
PKGVER: ${{ steps.ver.outputs.pkgver }}
run: |
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" \
packaging/aur/kst4contest-git/PKGBUILD
- name: Generate .SRCINFO
run: |
cp -r packaging/aur/kst4contest-git /tmp/kst4contest-git
chown -R builder:builder /tmp/kst4contest-git
su builder -c \
"cd /tmp/kst4contest-git && makepkg --printsrcinfo > .SRCINFO"
cp /tmp/kst4contest-git/.SRCINFO \
packaging/aur/kst4contest-git/.SRCINFO
echo "Generated .SRCINFO:"
cat packaging/aur/kst4contest-git/.SRCINFO
- name: Set up and verify AUR SSH
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
mkdir -p "${AUR_SSH_DIR}"
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" \
> "${AUR_SSH_DIR}/aur_ed25519"
sed -i 's/\r$//' "${AUR_SSH_DIR}/aur_ed25519"
chmod 600 "${AUR_SSH_DIR}/aur_ed25519"
ssh-keygen -y \
-f "${AUR_SSH_DIR}/aur_ed25519" \
> /dev/null
ssh-keyscan \
-T 10 \
-t ed25519 \
aur.archlinux.org \
> "${AUR_SSH_DIR}/known_hosts"
if [ ! -s "${AUR_SSH_DIR}/known_hosts" ]; then
echo "::error::No SSH host key was received from aur.archlinux.org."
exit 1
fi
echo "Received AUR host-key fingerprint:"
ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts"
if ! ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts" \
| grep -Fq "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4"; then
echo "::error::The AUR SSH host-key fingerprint does not match the official fingerprint."
exit 1
fi
printf '%s\n' \
"Host aur.archlinux.org" \
" HostName aur.archlinux.org" \
" User aur" \
" IdentityFile ${AUR_SSH_DIR}/aur_ed25519" \
" IdentitiesOnly yes" \
" StrictHostKeyChecking yes" \
" UserKnownHostsFile ${AUR_SSH_DIR}/known_hosts" \
> "${AUR_SSH_DIR}/config"
chmod 600 "${AUR_SSH_DIR}/config"
chmod 600 "${AUR_SSH_DIR}/known_hosts"
- name: Push package metadata to AUR
env:
GIT_SSH_COMMAND: ssh -F /tmp/aur-ssh/config
run: |
git config --global user.email "philipp@wagnersnetz.de"
git config --global user.name "Philipp Wagner"
AUR_DIR="/tmp/aur/kst4contest-git"
mkdir -p "$(dirname "${AUR_DIR}")"
git -c init.defaultBranch=master clone \
"ssh://aur@aur.archlinux.org/kst4contest-git.git" \
"${AUR_DIR}"
cp packaging/aur/kst4contest-git/PKGBUILD \
"${AUR_DIR}/PKGBUILD"
cp packaging/aur/kst4contest-git/.SRCINFO \
"${AUR_DIR}/.SRCINFO"
git -C "${AUR_DIR}" add PKGBUILD .SRCINFO
if git -C "${AUR_DIR}" diff --cached --quiet; then
echo "kst4contest-git: package metadata is already current."
else
git -C "${AUR_DIR}" commit \
-m "Update pkgver to ${{ steps.ver.outputs.pkgver }}"
git -C "${AUR_DIR}" push origin HEAD:master
echo "kst4contest-git: successfully pushed to AUR."
fi
+228
View File
@@ -0,0 +1,228 @@
name: Publish AUR Packages
on:
workflow_run:
workflows: ["Tagged Release Build"]
types: [completed]
workflow_dispatch:
inputs:
version:
description: "Release tag (e.g. v1.41.1) — defaults to latest stable release"
required: false
default: ""
dry_run:
description: "Dry run — skip AUR push and repo commit (for testing)"
type: choice
options: ["false", "true"]
default: "false"
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
AUR_SSH_DIR: /tmp/aur-ssh
jobs:
publish-aur:
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Sy --noconfirm git openssh curl base-devel nodejs
useradd -m builder
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
ssh-key: ${{ secrets.WEBSITE_DEPLOY_KEY }}
- name: Mark workspace as safe Git directory
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Resolve release version
id: ver
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [[ -n "${INPUT_VERSION}" ]]; then
TAG="${INPUT_VERSION}"
else
TAG=$(curl -sf \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/releases/latest" \
| grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
fi
PKGVER="${TAG#v}"
echo "tag=${TAG} → pkgver=${PKGVER}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "pkgver=${PKGVER}" >> "$GITHUB_OUTPUT"
- name: Compute SHA256 checksums
id: sha
run: |
TAG="${{ steps.ver.outputs.tag }}"
REPO="${{ github.repository }}"
echo "Hashing pre-built Arch package..."
SHA_BIN=$(curl -sfL \
"https://github.com/${REPO}/releases/download/${TAG}/KST4Contest-${TAG}-archlinux-x86_64.pkg.tar.zst" \
| sha256sum | awk '{print $1}')
echo "bin: ${SHA_BIN}"
echo "Hashing source tarball..."
SHA_SRC=$(curl -sfL \
"https://github.com/${REPO}/archive/refs/tags/${TAG}.tar.gz" \
| sha256sum | awk '{print $1}')
echo "src: ${SHA_SRC}"
echo "bin=${SHA_BIN}" >> "$GITHUB_OUTPUT"
echo "src=${SHA_SRC}" >> "$GITHUB_OUTPUT"
- name: Update PKGBUILDs
env:
PKGVER: ${{ steps.ver.outputs.pkgver }}
SHA_BIN: ${{ steps.sha.outputs.bin }}
SHA_SRC: ${{ steps.sha.outputs.src }}
run: |
# kst4contest-bin
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" packaging/aur/kst4contest-bin/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA_BIN}')/" packaging/aur/kst4contest-bin/PKGBUILD
# kst4contest (source build)
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" packaging/aur/kst4contest/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA_SRC}')/" packaging/aur/kst4contest/PKGBUILD
# A new pkgver supersedes any rebuild-only pkgrel bumps made in between.
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest-bin/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD
# kst4contest-git: Basis aus gradle.properties, Suffix aus git
BASE_VER=$(grep -m1 '^version=' gradle.properties \
| cut -d= -f2 | sed 's/[-.]nightly//')
GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD
echo "=== Updated PKGBUILD versions ==="
grep -H '^pkgver=\|^sha256sums=' packaging/aur/*/PKGBUILD
- name: Generate .SRCINFO files
run: |
for pkg in kst4contest-bin kst4contest kst4contest-git; do
cp -r "packaging/aur/${pkg}" "/tmp/${pkg}"
chown -R builder:builder "/tmp/${pkg}"
su builder -c "cd /tmp/${pkg} && makepkg --printsrcinfo > .SRCINFO"
cp "/tmp/${pkg}/.SRCINFO" "packaging/aur/${pkg}/.SRCINFO"
echo "=== ${pkg}/.SRCINFO ==="
cat "packaging/aur/${pkg}/.SRCINFO"
done
- name: Set up AUR SSH
if: inputs.dry_run != 'true'
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
mkdir -p "${AUR_SSH_DIR}"
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" \
> "${AUR_SSH_DIR}/aur_ed25519"
sed -i 's/\r$//' "${AUR_SSH_DIR}/aur_ed25519"
chmod 600 "${AUR_SSH_DIR}/aur_ed25519"
ssh-keygen -y \
-f "${AUR_SSH_DIR}/aur_ed25519" \
> /dev/null
ssh-keyscan \
-T 10 \
-t ed25519 \
aur.archlinux.org \
> "${AUR_SSH_DIR}/known_hosts"
if [ ! -s "${AUR_SSH_DIR}/known_hosts" ]; then
echo "::error::No SSH host key was received from aur.archlinux.org."
exit 1
fi
echo "Received AUR host-key fingerprint:"
ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts"
if ! ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts" \
| grep -Fq "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4"; then
echo "::error::The AUR SSH host-key fingerprint does not match the official fingerprint."
exit 1
fi
printf '%s\n' \
"Host aur.archlinux.org" \
" HostName aur.archlinux.org" \
" User aur" \
" IdentityFile ${AUR_SSH_DIR}/aur_ed25519" \
" IdentitiesOnly yes" \
" StrictHostKeyChecking yes" \
" UserKnownHostsFile ${AUR_SSH_DIR}/known_hosts" \
> "${AUR_SSH_DIR}/config"
chmod 600 "${AUR_SSH_DIR}/config"
chmod 600 "${AUR_SSH_DIR}/known_hosts"
- name: Push to AUR
if: inputs.dry_run != 'true'
env:
TAG: ${{ steps.ver.outputs.tag }}
GIT_SSH_COMMAND: ssh -F /tmp/aur-ssh/config
run: |
git config --global user.email "philipp@wagnersnetz.de"
git config --global user.name "Philipp Wagner"
mkdir -p /tmp/aur
push_to_aur() {
local pkg="$1"
local msg="$2"
local aur_dir="/tmp/aur/${pkg}"
git -c init.defaultBranch=master clone \
"ssh://aur@aur.archlinux.org/${pkg}.git" "${aur_dir}"
cp "packaging/aur/${pkg}/PKGBUILD" "${aur_dir}/"
cp "packaging/aur/${pkg}/.SRCINFO" "${aur_dir}/"
git -C "${aur_dir}" add PKGBUILD .SRCINFO
git -C "${aur_dir}" diff --cached --quiet \
&& echo "${pkg}: no changes, skipping push" && return 0
git -C "${aur_dir}" commit -m "${msg}"
git -C "${aur_dir}" push origin HEAD:master
echo "${pkg}: pushed to AUR"
}
push_to_aur kst4contest-bin "Update to ${TAG}"
push_to_aur kst4contest "Update to ${TAG}"
push_to_aur kst4contest-git \
"Update pkgver to $(grep '^pkgver=' packaging/aur/kst4contest-git/PKGBUILD | cut -d= -f2)"
- name: Commit updated PKGBUILDs to repo
if: inputs.dry_run != 'true'
run: |
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add packaging/aur/
git diff --cached --quiet && echo "No PKGBUILD changes to commit." && exit 0
git commit -m "chore: update AUR packages to ${{ steps.ver.outputs.tag }} [skip ci]"
if ! git push; then
echo "::warning::PKGBUILD bookkeeping commit could not be pushed to main."
echo "The AUR packages were published; only the in-repo copy stays behind."
echo "Check that WEBSITE_DEPLOY_KEY still has write access and may bypass"
echo "the branch ruleset, or commit packaging/aur/ by hand."
fi
+5 -3
View File
@@ -25,14 +25,14 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve version string
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION="${{ github.ref_name }}"
else
VERSION="$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')-${GITHUB_SHA::7}"
VERSION="$(grep -m1 '^version=' gradle.properties | cut -d= -f2)-${GITHUB_SHA::7}"
fi
echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV"
@@ -65,6 +65,7 @@ jobs:
github_docs/en-Home.md \
github_docs/en-Installation.md \
github_docs/en-Configuration.md \
github_docs/en-Contest-Workflow.md \
github_docs/en-Features.md \
github_docs/en-User-Interface.md \
github_docs/en-Macros-and-Variables.md \
@@ -91,6 +92,7 @@ jobs:
github_docs/de-Home.md \
github_docs/de-Installation.md \
github_docs/de-Konfiguration.md \
github_docs/de-Contest-Workflow.md \
github_docs/de-Funktionen.md \
github_docs/de-Benutzeroberflaeche.md \
github_docs/de-Makros-und-Variablen.md \
@@ -100,7 +102,7 @@ jobs:
github_docs/de-Changelog.md
- name: Upload PDF artifacts
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: docs-pdf
path: dist/KST4Contest-*-manual-*.pdf
+2 -2
View File
@@ -14,8 +14,8 @@ jobs:
publish-wiki:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Andrew-Chen-Wang/github-wiki-action@v4
- uses: actions/checkout@v7
- uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
path: github_docs
disable-empty-commits: true
+228 -167
View File
@@ -5,10 +5,15 @@ on:
branches:
- main
paths:
- "src/**"
- "pom.xml"
- "mvnw"
- "mvnw.cmd"
- "packaging/icons/**"
- "packaging/macos/**"
- "core/**"
- "app-desktop/**"
- "*.gradle.kts"
- "gradle.properties"
- "gradle/**"
- "gradlew"
- "gradlew.bat"
- ".github/workflows/nightly-artifacts.yml"
workflow_dispatch:
@@ -20,26 +25,120 @@ permissions:
packages: write
jobs:
build-windows-zip:
name: Build Windows ZIP
runs-on: windows-latest
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
# would be one more indirection to read, and the comments belong next to the steps they
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
# no artifact (#62). Edit all three copies together.
test:
name: Test (Java 21, headless)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
- name: Ensure gradlew is executable
run: chmod +x gradlew
# Skiko's native library links against libGL, so it has to be loadable even though
# nothing here renders through it: the drawing tests use Skia's software backend and
# runComposeUiTest never opens a window. Without this the whole Compose half of the
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
# file" -- which reads like a missing display and is not one. Found by running this
# job under act, where the container has no libGL; GitHub's own runner image happens
# to ship it, so the step is what makes the job reproducible off GitHub.
- name: Install the OpenGL client library skiko links against
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
# runComposeUiTest reaches the semantics tree headless (measured before this job was
# written), and the ImageComposeScene drawing tests render in software. The handful
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
# skip here; the summary below prints the skip count, so that is visible rather than
# assumed.
#
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
# the hardest failure in this codebase to read, and importing that class of flake
# into CI would cost more than the few window tests are worth here.
# All three modules. The translation generator is a Gradle module and not buildSrc
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
# build` -- measured, not assumed. The summary below globs */build/test-results, so
# the module was counted the moment it existed.
- name: Test
run: ./gradlew -S test
- name: Summarise the test results
if: always()
run: |
set -euo pipefail
shopt -s nullglob
files=(*/build/test-results/test/TEST-*.xml)
if [ ${#files[@]} -eq 0 ]; then
echo "No test result XML was produced -- the test task did not run." >&2
exit 1
fi
# Counted from the XML and not from Gradle's console output: this project's own
# rule is never to infer "all tests passed" from an exit code.
summary=$(grep -h "<testsuite " "${files[@]}" \
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
read -r total skipped failures errors <<< "$summary"
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
{
echo "### Tests"
echo ""
echo "| total | skipped | failures | errors |"
echo "|---|---|---|---|"
echo "| $total | $skipped | $failures | $errors |"
echo ""
echo "Skipped are the tests that need a display, plus the deliberately"
echo "disabled reference-data generator. This job runs headless on purpose."
} >> "$GITHUB_STEP_SUMMARY"
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
echo "The result XML reports failures or errors." >&2
exit 1
fi
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results
path: |
*/build/test-results/test/
*/build/reports/tests/test/
if-no-files-found: ignore
retention-days: 7
build-windows-zip:
name: Build Windows ZIP
runs-on: windows-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Resolve nightly version info
shell: pwsh
run: |
$xml = [xml](Get-Content pom.xml)
$version = $xml.project.version
$version = (Select-String -Path gradle.properties -Pattern '^version=').Line.Split('=')[1]
$shortSha = "${{ github.sha }}".Substring(0, 7)
Add-Content -Path $env:GITHUB_ENV -Value "VERSION=$version"
Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha"
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
@@ -48,32 +147,26 @@ jobs:
shell: pwsh
run: choco install wixtoolset --no-progress -y
- name: Build JAR and copy runtime dependencies
shell: pwsh
run: |
.\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $jar) {
throw "No project JAR produced"
}
Copy-Item $jar.FullName target/dist-libs/app.jar
- name: Build app-image with jpackage
- name: Build app-image
shell: pwsh
run: |
.\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null
jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec --dest dist
# The next step reads dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
- name: Create Windows ZIP
shell: pwsh
run: |
if (-not (Test-Path dist/praktiKST)) {
throw "No Windows app-image produced by jpackage"
if (-not (Test-Path dist/KST4Contest)) {
throw "No Windows app-image produced by createDistributable"
}
Compress-Archive -Path dist/praktiKST -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
Compress-Archive -Path dist/KST4Contest -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: windows-zip
path: dist/praktiKST-*-windows-x64.zip
@@ -82,46 +175,39 @@ jobs:
build-linux-appimage:
name: Build Linux AppImage
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build app-image with jpackage
- name: Build app-image
run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist
jpackage \
--type app-image \
--name KST4Contest \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist
# The following steps read dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
rm -rf dist/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Create AppDir metadata
run: |
mkdir -p target
rm -rf target/KST4Contest.AppDir
cp -a dist/KST4Contest target/KST4Contest.AppDir
@@ -148,12 +234,13 @@ jobs:
- name: Build AppImage
run: |
mkdir -p target
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x target/appimagetool.AppImage
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
- name: Upload Linux artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-appimage
path: dist/KST4Contest-*-linux-x86_64.AppImage
@@ -162,19 +249,20 @@ jobs:
build-linux-deb:
name: Build Debian package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
@@ -184,35 +272,21 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends fakeroot
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Build JAR and copy runtime dependencies
run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build Debian package
run: |
./gradlew -S :app-desktop:packageDeb
mkdir -p dist
jpackage \
--type deb \
--name KST4Contest \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist
DEB="$(ls dist/*.deb | head -n 1)"
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
echo "No DEB produced by jpackage" && exit 1
echo "No DEB produced by packageDeb" && exit 1
fi
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
- name: Upload Debian artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-debian
path: dist/KST4Contest-*-debian-amd64.deb
@@ -221,19 +295,20 @@ jobs:
build-linux-rpm:
name: Build Fedora package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
@@ -243,35 +318,21 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends rpm
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Build JAR and copy runtime dependencies
run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build Fedora package
run: |
./gradlew -S :app-desktop:packageRpm
mkdir -p dist
jpackage \
--type rpm \
--name KST4Contest \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist
RPM="$(ls dist/*.rpm | head -n 1)"
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
echo "No RPM produced by jpackage" && exit 1
echo "No RPM produced by packageRpm" && exit 1
fi
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
- name: Upload Fedora artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-fedora
path: dist/KST4Contest-*-fedora-x86_64.rpm
@@ -280,21 +341,22 @@ jobs:
build-linux-arch:
name: Build Arch Linux package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
@@ -304,29 +366,21 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends zstd
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build app-image with jpackage
- name: Build app-image
run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist
jpackage \
--type app-image \
--name KST4Contest \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist
# The following steps read dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
rm -rf dist/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Build Arch Linux package artifact
run: |
mkdir -p target
ARCH=$(uname -m)
PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g')
PKGROOT="target/archpkg"
@@ -390,7 +444,7 @@ jobs:
tar --zstd --transform 's|^\./||' -cf "dist/${ASSET_BASENAME}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
- name: Upload Arch Linux artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-arch
path: dist/KST4Contest-*-archlinux-*.pkg.tar.zst
@@ -399,57 +453,52 @@ jobs:
build-flatpak:
name: Build Flatpak
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Install Flatpak tooling
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
flatpak --user install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
- name: Build app-image with jpackage
- name: Build app-image
run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
./gradlew -S :app-desktop:createDistributable
mkdir -p target/flatpak-src
jpackage \
--type app-image \
--name KST4Contest \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest target/flatpak-src
# The manifest's source is flatpak-src/KST4Contest.
rm -rf target/flatpak-src/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
- name: Create Flatpak manifest
run: |
mkdir -p target
mkdir -p dist
cat > target/de.x08.KST4Contest.yml << 'EOF'
app-id: de.x08.KST4Contest
runtime: org.freedesktop.Platform
runtime-version: "24.08"
runtime-version: "25.08"
sdk: org.freedesktop.Sdk
command: KST4Contest
finish-args:
@@ -492,6 +541,7 @@ jobs:
- name: Build Flatpak repo (nightly)
run: |
mkdir -p target
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build nightly
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
@@ -499,7 +549,7 @@ jobs:
- name: Create flatpakref (nightly)
run: |
REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > "dist/de.x08.KST4Contest.nightly.flatpakref" << EOF
[Flatpak Ref]
@@ -513,13 +563,13 @@ jobs:
EOF
- name: Upload flatpakref
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: flatpakref
path: dist/de.x08.KST4Contest.nightly.flatpakref
- name: Upload Flatpak OSTree repo
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: flatpak-ostree-repo
path: target/flatpak-repo/
@@ -528,6 +578,9 @@ jobs:
name: Publish Flatpak OSTree Repo (nightly)
runs-on: ubuntu-latest
needs: build-flatpak
concurrency:
group: flatpak-repo-publish
cancel-in-progress: false
steps:
- name: Install Flatpak tooling
@@ -541,13 +594,13 @@ jobs:
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
- name: Download OSTree repo artifact
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: flatpak-ostree-repo
path: flatpak-ostree-repo/
- name: Checkout existing flatpak-repo branch
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
with:
ref: flatpak-repo
path: existing-flatpak-repo
@@ -563,7 +616,7 @@ jobs:
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > kst4contest.flatpakrepo << EOF
[Flatpak Repo]
@@ -585,17 +638,19 @@ jobs:
build-macos-dmg:
name: Build macOS DMG (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: test
strategy:
fail-fast: false
matrix:
os: [macos-latest, macos-15-intel]
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}"
ARCH=$(uname -m)
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
@@ -604,46 +659,52 @@ jobs:
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Build JAR and copy runtime dependencies
run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build macOS DMG with jpackage
run: |
mkdir -p dist
jpackage \
--type dmg \
--name KST4Contest \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Import signing certificate
env:
MACOSX_DEPLOYMENT_TARGET: "13.0"
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: ./packaging/macos/ci-import-cert.sh
# Builds the jar, signs the app bundle and every native library inside it,
# wraps it into a DMG and has Apple notarize the result. Same script the
# local Mac uses, so the two cannot drift apart.
- name: Build signed and notarized DMG
env:
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.MACOS_NOTARY_ISSUER }}
run: |
printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/notary.p8"
export NOTARY_KEY="$RUNNER_TEMP/notary.p8"
./packaging/macos/build-signed-dmg.sh
- name: Remove signing credentials
if: always()
run: |
rm -f "$RUNNER_TEMP/notary.p8"
if [ -n "${SIGNING_KEYCHAIN:-}" ]; then
security delete-keychain "$SIGNING_KEYCHAIN" || true
fi
- name: Rename DMG artifact
run: |
DMG=$(ls dist/*.dmg | head -n 1)
if [ -z "$DMG" ]; then
echo "No DMG produced by jpackage" && exit 1
echo "No DMG produced by the build" && exit 1
fi
mv "$DMG" "dist/${ASSET_BASENAME}-macos-${ARCH}.dmg"
- name: Upload macOS artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: macos-dmg-${{ matrix.os }}
path: dist/KST4Contest-*-macos-*.dmg
retention-days: 14
retention-days: 14
+269 -5
View File
@@ -16,16 +16,280 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Ensure gradlew is executable
run: chmod +x gradlew
# classes + testClasses rather than compileJava + compileTestJava. The old pair did
# cover Kotlin -- checked with a deliberately broken Kotlin test file, and also in
# --dry-run: the Kotlin plugin makes compileTestJava depend on compileTestKotlin,
# because Java has to see the Kotlin output. But that is the plugin's wiring and not
# this job's statement of intent, and it covered no resources. These two aggregates
# say "compile everything in all three modules" and do not depend on knowing that.
#
# They also run the translation generator, which app-desktop's compileKotlin depends
# on. That is why a pull request contributing a language needs no workflow of its own:
# an orphan key, a dropped placeholder or a duplicate fails here, with the generator's
# own message naming the file.
- name: Compile
run: ./mvnw -B -DskipTests compile
run: ./gradlew -S classes testClasses
# Report only: both tools run with ignoreFailures, so a finding cannot fail this step
# -- a crash of the tool itself still does, which is wanted. The findings used to exist
# only in the console log; since #110 they are files, and the table and the artifact
# below are what makes them readable from a pull request.
- name: Static analysis (report only)
run: ./gradlew -S pmdMain spotbugsMain
# Counted from the report XML, never from Gradle's output or exit code. A missing
# report prints "no report" and does not fail the step: that case is already visible
# in the step above.
- name: Summarise the static analysis
if: always()
run: |
set -euo pipefail
shopt -s nullglob
count() {
local file="$1" element="$2"
if [ -f "$file" ]; then
# grep exits 1 on zero matches, which pipefail would turn into a failed step.
{ grep -o "<$element " "$file" || true; } | wc -l | tr -d ' '
else
echo "no report"
fi
}
{
echo "### Static analysis"
echo ""
echo "| module | PMD violations | SpotBugs bugs |"
echo "|---|---|---|"
} >> "$GITHUB_STEP_SUMMARY"
for module in core app-desktop i18n-generator; do
pmd=$(count "$module/build/reports/pmd/main.xml" violation)
spotbugs=$(count "$module/build/reports/spotbugs/main.xml" BugInstance)
echo "$module: pmd=$pmd spotbugs=$spotbugs"
echo "| \`$module\` | $pmd | $spotbugs |" >> "$GITHUB_STEP_SUMMARY"
done
{
echo ""
echo "Report only: nothing fails on a finding. The XML and HTML reports are the"
echo "static-analysis artifact of this run. i18n-generator is Kotlin only, so PMD"
echo "has no Java source there and writes no report."
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the static analysis reports
if: always()
uses: actions/upload-artifact@v7
with:
name: static-analysis
path: |
*/build/reports/pmd/
*/build/reports/spotbugs/
if-no-files-found: ignore
retention-days: 14
# What a translation PR is actually about, put where its author will look. The counts
# come from the files rather than from Gradle's output, so an up-to-date task cannot
# make the table disappear.
- name: Summarise the interface texts
if: always()
run: |
set -euo pipefail
shopt -s nullglob
base="app-desktop/src/main/i18n/strings_en.properties"
if [ ! -f "$base" ]; then
echo "no base translation file at $base" >&2
exit 1
fi
count_keys() { grep -cE '^[^#!][^=]*=' "$1" || true; }
total=$(count_keys "$base")
{
echo "### Interface texts"
echo ""
echo "| language | translated | of | missing |"
echo "|---|---|---|---|"
} >> "$GITHUB_STEP_SUMMARY"
for file in app-desktop/src/main/i18n/strings_*.properties; do
lang=$(basename "$file" .properties); lang=${lang#strings_}
have=$(count_keys "$file")
missing=$(( total - have ))
[ "$missing" -lt 0 ] && missing=0
echo "| \`$lang\` | $have | $total | $missing |" >> "$GITHUB_STEP_SUMMARY"
done
{
echo ""
echo "A language may be incomplete: a key it leaves out falls back to English,"
echo "which is what lets a translation be contributed a piece at a time. German is"
echo "held complete by a test, because it ships."
} >> "$GITHUB_STEP_SUMMARY"
worksheets=(app-desktop/build/generated/i18n/todo/*.todo.properties)
if [ ${#worksheets[@]} -gt 0 ]; then
{
echo ""
echo "The build wrote a worksheet of what is still missing for:"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
for w in "${worksheets[@]}"; do
echo "- \`$(basename "$w")\`" >> "$GITHUB_STEP_SUMMARY"
done
fi
test:
name: Test (Java 21, headless)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: "21"
- name: Ensure gradlew is executable
run: chmod +x gradlew
# Skiko's native library links against libGL, so it has to be loadable even though
# nothing here renders through it: the drawing tests use Skia's software backend and
# runComposeUiTest never opens a window. Without this the whole Compose half of the
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
# file" -- which reads like a missing display and is not one. Found by running this
# job under act, where the container has no libGL; GitHub's own runner image happens
# to ship it, so the step is what makes the job reproducible off GitHub.
- name: Install the OpenGL client library skiko links against
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
# runComposeUiTest reaches the semantics tree headless (measured before this job was
# written), and the ImageComposeScene drawing tests render in software. The handful
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
# skip here; the summary below prints the skip count, so that is visible rather than
# assumed.
#
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
# the hardest failure in this codebase to read, and importing that class of flake
# into CI would cost more than the few window tests are worth here.
# All three modules. The translation generator is a Gradle module and not buildSrc
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
# build` -- measured, not assumed. The summary below globs */build/test-results, so
# the module was counted the moment it existed.
- name: Test
run: ./gradlew -S test
# Coverage is a report, not a gate: no threshold, nothing in it can fail on a
# percentage. Deliberately not `if: always()` -- without a completed test run the
# report would be partial, and after a failed test the result XML below is what matters.
# jacocoTestReport writes the per-module XML the summary reads; the aggregate merges
# core and app-desktop (i18n-generator is build tooling and stays out).
- name: Coverage report
run: ./gradlew -S jacocoTestReport jacocoAggregatedReport
- name: Summarise the test results
if: always()
run: |
set -euo pipefail
shopt -s nullglob
files=(*/build/test-results/test/TEST-*.xml)
if [ ${#files[@]} -eq 0 ]; then
echo "No test result XML was produced -- the test task did not run." >&2
exit 1
fi
# Counted from the XML and not from Gradle's console output: this project's own
# rule is never to infer "all tests passed" from an exit code.
summary=$(grep -h "<testsuite " "${files[@]}" \
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
read -r total skipped failures errors <<< "$summary"
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
{
echo "### Tests"
echo ""
echo "| total | skipped | failures | errors |"
echo "|---|---|---|---|"
echo "| $total | $skipped | $failures | $errors |"
echo ""
echo "Skipped are the tests that need a display, plus the deliberately"
echo "disabled reference-data generator. This job runs headless on purpose."
} >> "$GITHUB_STEP_SUMMARY"
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
echo "The result XML reports failures or errors." >&2
exit 1
fi
- name: Summarise the coverage
if: always()
run: |
set -euo pipefail
# The last LINE/BRANCH counter in a JaCoCo XML is the report-level total; the
# per-package and per-class counters come before it.
percent() {
local file="$1" type="$2"
{ grep -o "<counter type=\"$type\"[^>]*/>" "$file" || true; } | tail -1 \
| sed 's/.*missed="\([0-9]*\)".*covered="\([0-9]*\)".*/\1 \2/' \
| awk 'NF == 2 { t=$1+$2; if (t==0) print "n/a"; else printf "%.1f%% (%d/%d)", 100*$2/t, $2, t }'
}
row() {
local label="$1" file="$2"
if [ -f "$file" ]; then
echo "| $label | $(percent "$file" LINE) | $(percent "$file" BRANCH) |"
else
echo "| $label | no report | no report |"
fi
}
{
echo ""
echo "### Coverage"
echo ""
echo "| module | lines | branches |"
echo "|---|---|---|"
row "core" core/build/reports/jacoco/test/jacocoTestReport.xml
row "app-desktop" app-desktop/build/reports/jacoco/test/jacocoTestReport.xml
row "core + app-desktop" build/reports/jacoco/aggregated/jacoco.xml
echo ""
echo "Report only: nothing fails on a percentage. The aggregated HTML report is the"
echo "coverage-report artifact of this run. i18n-generator is build tooling and is"
echo "not counted."
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the coverage report
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: build/reports/jacoco/aggregated/html/
if-no-files-found: ignore
retention-days: 14
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results
path: |
*/build/test-results/test/
*/build/reports/tests/test/
if-no-files-found: ignore
retention-days: 7
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,170 @@
name: TEST publish-version-xml (local only)
on:
workflow_dispatch:
jobs:
publish-version-xml:
name: Publish Version Info XML via WebDAV
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Generate kst4ContestVersionInfo.xml from GitHub releases + history
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 << 'PYEOF'
import subprocess, json
from xml.etree.ElementTree import Element, SubElement, ElementTree, parse, indent
def safe_version(tag):
cleaned = tag.lstrip('v')
try:
float(cleaned)
except ValueError:
pass
return cleaned
import os, urllib.request
REPO = 'HamRadioOnline/kst4contest'
token = os.environ['GH_TOKEN']
def gh_get(path):
url = f'https://api.github.com/repos/{REPO}{path}'
req = urllib.request.Request(url, headers={
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def get_issues_for_release(since, until):
"""Return closed issues in (since, until] categorised by label."""
LABEL_MAP = {'enhancement': 'added', 'bug': 'fixed'}
buckets = {'added': [], 'fixed': []}
page = 1
while True:
items = gh_get(f'/issues?state=closed&per_page=100&page={page}')
if not items:
break
for issue in items:
if 'pull_request' in issue:
continue
closed = issue.get('closed_at') or ''
if not (since < closed <= until):
continue
labels = [l['name'] for l in issue['labels']]
bucket = next((LABEL_MAP[l] for l in labels if l in LABEL_MAP), None)
if bucket:
buckets[bucket].append(f'- {issue["title"]}')
if len(items) < 100:
break
page += 1
return {k: '\n'.join(v) for k, v in buckets.items()}
def clean_body(body):
"""Strip GitHub markdown noise, keep plain text."""
import re
lines = []
for line in (body or '').splitlines():
if re.match(r'^#{1,3}\s', line):
continue
if line.strip().startswith('**Full Changelog**'):
continue
line = re.sub(r'\s+by @\S+ in https://\S+', '', line)
line = re.sub(r'^\* ', '- ', line)
if line.strip():
lines.append(line)
return re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)).strip()
# --- Fetch all releases ---
raw = gh_get('/releases?per_page=100')
gh_releases = [
{
'tagName': r['tag_name'],
'publishedAt': r['published_at'] or '',
'name': r['name'] or r['tag_name'],
'body': r['body'] or '',
'isPrerelease': r['prerelease'],
}
for r in raw
]
# --- Simulate a stable 1.41 release for testing ---
gh_releases.insert(0, {
'tagName': '1.41',
'publishedAt': '2026-06-30T10:00:00Z',
'name': 'Release 1.41',
'body': 'Release 1.41',
'isPrerelease': False,
})
gh_releases.sort(key=lambda r: r['publishedAt'], reverse=True)
stable_releases = [r for r in gh_releases if not r['isPrerelease']]
stable = stable_releases[0] if stable_releases else None
gh_versions = {safe_version(r['tagName']) for r in stable_releases}
history_tree = parse('.github/version-history.xml')
history_entries = history_tree.getroot().findall('changeLog')
root = Element('praktiKST')
lv = SubElement(root, 'latestVersion')
latest_tag = stable['tagName'] if stable else 'unknown'
SubElement(lv, 'versionNumber').text = safe_version(latest_tag)
SubElement(lv, 'adminMessage').text = ''
latest_issues = get_issues_for_release('1970-01-01T00:00:00Z', stable['publishedAt']) if stable else {}
SubElement(lv, 'majorChanges').text = latest_issues.get('added', '')[:300]
win_filename = f'praktiKST-{latest_tag}-windows-x64.zip'
SubElement(lv, 'latestVersionPathOnWebserver').text = (
f'https://github.com/{REPO}/releases/download/{latest_tag}/{win_filename}'
)
nuf = SubElement(root, 'needUpdateSinceLastVersion')
SubElement(nuf, 'filename').text = win_filename
# Build changelog for each stable release using closed issues in its window
for i, rel in enumerate(stable_releases):
until = rel['publishedAt']
since = stable_releases[i + 1]['publishedAt'] if i + 1 < len(stable_releases) else '1970-01-01T00:00:00Z'
s = get_issues_for_release(since, until)
cl = SubElement(root, 'changeLog')
SubElement(cl, 'changedVersionNumber').text = safe_version(rel['tagName'])
SubElement(cl, 'date').text = until[:10]
SubElement(cl, 'description').text = rel['name']
SubElement(cl, 'added').text = s['added']
SubElement(cl, 'changed').text = clean_body(rel['body'])
SubElement(cl, 'fixed').text = s['fixed']
SubElement(cl, 'removed').text = ''
for entry in history_entries:
ver_node = entry.find('changedVersionNumber')
if ver_node is not None and ver_node.text and ver_node.text.strip() in gh_versions:
continue
root.append(entry)
indent(root, space=' ')
tree = ElementTree(root)
with open('kst4ContestVersionInfo.xml', 'w', encoding='utf-8') as f:
f.write('<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n')
tree.write(f, encoding='unicode', xml_declaration=False)
print('Generated kst4ContestVersionInfo.xml')
PYEOF
- name: Show generated XML (first 40 lines)
run: head -40 kst4ContestVersionInfo.xml
- name: Upload to WebDAV
run: |
curl --fail --silent --show-error \
--user "${{ secrets.WEBDAV_USER }}:${{ secrets.WEBDAV_PASSWORD }}" \
--upload-file kst4ContestVersionInfo.xml \
"${{ secrets.WEBDAV_URL }}/kst4ContestVersionInfo.xml"
-59
View File
@@ -1,59 +0,0 @@
name: Build Website
on:
push:
branches:
- main
paths:
- website/**
- github_docs/**
- .github/workflows/website-build.yml
pull_request:
branches:
- main
paths:
- website/**
- github_docs/**
- .github/workflows/website-build.yml
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: website-build-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build-website:
name: Build 11ty Website
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: website/package-lock.json
- name: Install dependencies
working-directory: website
run: npm ci
- name: Build website
working-directory: website
run: npm run build
- name: Upload website artifact
uses: actions/upload-artifact@v4.3.4
with:
name: kst4contest-website
path: website/_site/
retention-days: 7
+30
View File
@@ -20,6 +20,12 @@ debug.out
SimpleLogFile.txt
udpReaderBackup.txt
# Runtime/debug logs
Messagehistory.raw
Messagehistory-*.raw
kst4contest-errors.log
kst4contest-errors.log.lck
#tempfiles
.idea/
out/
@@ -38,3 +44,27 @@ dist/
# node Modules in website
website/node_modules/
# built website output (rebuilt on the server)
website/_site/
# Local secrets for act testing
.secrets
# Apple notarization private keys - never commit these
*.p8
# Gradle
.gradle/
build/
core/build/
app-desktop/build/
# Agent scratch area - screenshots, notes, throwaway scripts
scratch/
# Kotlin incremental build data
.kotlin/
# JVM crash reports
hs_err_pid*.log
BIN
View File
Binary file not shown.
-18
View File
@@ -1,18 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.2/apache-maven-3.9.2-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
+181
View File
@@ -0,0 +1,181 @@
# KST4Contest agent instructions
These project instructions extend Marc's global Codex working agreements.
## Project identity
KST4Contest is a Java/Kotlin desktop client for ON4KST chat, with a Compose Multiplatform user interface with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence.
Primary repository areas:
- `core/src/main/java/kst4contest/` and `core/src/test/`
- `app-desktop/src/main/java/kst4contest/` and `app-desktop/src/test/`
- `github_docs/`
- `website/`
- `docs/`
- `packaging/`
- `.github/`
- `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/`
Inspect the current tree before assuming an exact class/path still exists.
## Mandatory interaction rule
For every planned code or documentation implementation:
1. inspect first;
2. explain the task understanding in German;
3. identify and ask all relevant implementation questions;
4. wait for answers when needed;
5. present the final concept in German;
6. state what must remain unchanged;
7. request explicit concept approval;
8. wait;
9. implement only after approval.
If an answer is uncertain, do not interpolate it. Check current code/tests/docs/project context first and ask Marc when the uncertainty can affect behaviour.
## Language
- Communicate with Marc in German.
- Write source-code comments and Javadoc exclusively in English.
- Keep log/protocol/API literals in their canonical form.
- Commit messages are concise English when a commit is explicitly requested.
- User-facing DE/EN documentation follows `$praktimarc-writing-style`.
## Java and user-interface architecture
- Preserve or improve separation between network/parsing/service/controller/UI responsibilities.
- Do not solve architecture problems by letting worker/model code directly manipulate user-interface collections.
- Active chat-member domain state is conceptually thread-safe state and lives in an `ObservableRoster` in `core`; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store.
- `MessageBusManagementThread` must not directly read or mutate the row lists the user interface draws from; it works on snapshots.
- Route UI-visible mutations through the controller and the `UiDispatcher` (`AwtUiDispatcher` hands them to the AWT event dispatch thread, where Compose draws). Do not reach for a toolkit thread call such as `Platform.runLater` or `EventQueue.invokeLater` in `core`; it has no toolkit dependency.
- Prefer explicit DTOs over records when introducing transport/parser DTOs in this codebase unless the approved concept says otherwise.
- Handle incomplete external/historical data defensively.
- `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero.
- Unexpected input must not terminate message-processing or UI threads.
## Callsign and category identity
- Preserve full callsign variants as distinct chat-member identities where the server exposes them separately.
- Category is part of chat identity. Do not merge messages across categories.
- Base-call normalization may be used only for explicitly base-call-wide features such as worked status or monitoring rules.
- Worked status is shared across suffix variants of the same base call.
- Monitoring a callsign variant such as `DN9APW-2` or `DN9APW-70` is intended to monitor the base call `DN9APW`, so users do not need to enter every SSID.
- Do not generalize suffix semantics beyond behaviour explicitly established by the current code/specification.
## Bands and availability
- ON4KST categories 2 and 3 are central to the normal VHF/UHF workflow, but other category values can occur and must fail safely.
- Do not let unsupported categories produce exceptions.
- Known-active-band logic and `B+` interpretation must remain consistent across the application.
- Band information parsed from names/text must respect explicit `NOT-QRV` information; NOT-QRV overrides positive availability hints.
- Do not silently fall back to a fixed band/frequency when a required decision is ambiguous unless an approved fallback exists.
- Manual band settings and actual current QRG must remain consistent with features that depend on frequency.
## External protocols and integrations
Before changing ON4KST, AirScout, UCXLog/DXLog, Win-Test, PSTRotator or DXCluster handling:
- inspect the current implementation;
- preserve exact framing and compatibility;
- inspect current tests;
- check authoritative upstream documentation when the protocol detail is uncertain;
- ask Marc if more than one behaviour is plausible.
Specific invariants and historical context are in `$kst4contest-change` references.
Never change CR/LF, XML framing, callsign normalization, frequency formatting or port/transport assumptions casually.
## UI behaviour
- Preserve contest workflow speed and discoverability.
- Do not change zoom, selection, focus, sorting, tab choice or prefilled text as an incidental side effect.
- Map reset behaviour should clear the selected target without changing the zoom unless a new task explicitly changes this.
- New station selection should preserve the established `/cq callsign` prefill behaviour.
- If no send category is selected, preserve the established Main-category fallback unless explicitly changed.
- Null/unknown data must render as unavailable/empty according to current UI conventions, not as fake zero values.
## Map
- The station map is a Compose canvas. It fetches its tiles directly; the former JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the `TileProxyServer` that worked around the WebView's TLS behaviour in AppImage and Flatpak packaging are all gone.
- Map reset clears the selected target without changing the zoom. Offline DEM, terrain packages and the profile cache must keep working unchanged.
## Autoanswer / beacon safety
- Prevent automated-message loops.
- Respect the established minimum interval/cooldown logic.
- Do not consume a cooldown for a reply that is rejected before a complete valid TX item is queued.
- Preserve priority of frequency/QRG requests where established.
- Cooldown identity must not accidentally collapse unrelated callsign/category identities.
- Treat the current implementation/tests as the source of truth for exact message markers and timer details.
## Build and verification
Use the Gradle wrapper.
Windows:
```text
.\gradlew.bat ...
```
Read the current `gradle.properties` and `gradle/libs.versions.toml` before relying on version numbers.
At this context snapshot the project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x, with JUnit 5/Mockito, PMD and SpotBugs integrated. JavaFX is gone; there is no `org.openjfx` dependency left.
Important: a test failure fails the build, but PMD and SpotBugs run with `ignoreFailures`, so static-analysis findings do not. Test classes live under `src/test` only (since #62; before that a few sat under `src/main` and were never run), and JUnit/Mockito are test-only dependencies. Therefore:
- inspect the Gradle test summary;
- inspect the test-results XML under `*/build/test-results/` when needed;
- do not infer "all tests passed" from exit code 0 -- read the XML;
- report PMD/SpotBugs findings that are visible in the relevant build. Both tools write XML and HTML: `<module>/build/reports/pmd/main.{xml,html}` and `<module>/build/reports/spotbugs/main.{xml,html}` (since #110; before that SpotBugs printed to the console only). Count from the XML (`<violation`, `<BugInstance`), not from the console. The PR workflow runs `pmdMain spotbugsMain`, prints the counts per module into the job summary and publishes the reports as the `static-analysis` artifact.
Run focused tests first, then normally the relevant broader test/build command for the scope.
## Documentation and durable project context
Use `$software-project-context`, `$kst4contest-documentation`, and `$praktimarc-writing-style` as relevant.
Do not perform a full manual or website audit after every implementation.
After a completed change:
1. perform a short documentation-impact classification;
2. if user-visible behaviour is plausibly affected, search only the relevant German and English manual sections under `github_docs/`;
3. keep both language versions semantically aligned when an update is required;
4. check README and website feature text only when the changed feature/configuration is represented there or is likely to need representation;
5. identify screenshots that are likely stale instead of fabricating replacements;
6. update `docs/PROJECT_CONTEXT.md` for significant architectural, protocol, state/persistence, operational, integration, deployment, workaround, or long-lived behavioural decisions;
7. keep `Related Projects / Integration Points` current when KST4Contest, its website, hamradioonline infrastructure or planned propagation services affect one another.
A full documentation audit is reserved for explicit audit requests, major release preparation, broad UI/workflow changes, or evidence that documentation is broadly stale.
## Website
The repository contains an Eleventy-based website under `website/` with its own tests/build logic.
Do not assume website deployment/update-feed details; inspect current scripts/workflows before changing them.
## Change scope and Git
- No unrelated refactoring.
- No production dependency without prior approval.
- No automatic version bump.
- No commit/push/merge/tag/release/deploy without separate explicit authorization.
- Preserve deliberate test data and easter eggs unless explicitly changed.
- Never overwrite unrelated working-tree changes.
## Completion
After implementation, report in German:
- understanding fulfilled;
- changed files;
- important design decisions;
- tests/builds and exact results;
- documentation-impact classification;
- manual/website/README/context updates made or why none were necessary;
- related-project impact when relevant;
- remaining uncertainty;
- suggested next action, without performing it automatically.
+51
View File
@@ -0,0 +1,51 @@
# CLAUDE.md
Diese Datei gilt für Claude Code in diesem Repository.
## Projektregeln
Die fachlichen und stilistischen Regeln stehen in **[AGENTS.md](AGENTS.md)** und
**[docs/PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md)**. Beide sind verbindlich und
werden hier nicht wiederholt. Besonders wichtig: Kommunikation auf Deutsch,
Quelltextkommentare und Javadoc auf Englisch, und das Konzept-vor-Umsetzung-Vorgehen
aus dem Abschnitt „Mandatory interaction rule" in `AGENTS.md`.
## Subagenten
Subagenten (Agent-Tool) dürfen ohne Rückfrage genutzt werden, wenn eine Aufgabe
davon profitiert — insbesondere für Abschlussprüfungen ganzer Zweige und für
umfangreiche Umbauten, bei denen ein frischer Blick pro Teilaufgabe etwas wert ist.
Nicht sinnvoll bei kleinen, aufeinander aufbauenden Schritten: dort liest jeder
neue Subagent die Codebasis von null und kostet mehr, als er beiträgt.
## Build
Gradle, nicht Maven. `./gradlew clean build`, Java 21. Das Projekt besteht aus
drei Modulen: `core` (Fachlichkeit), `app-desktop` (Oberfläche) und
`i18n-generator` (reines Bauwerkzeug, das aus den Übersetzungsdateien Kotlin
erzeugt und **nicht ausgeliefert** wird). Paketnamen sind über `core` und
`app-desktop` hinweg dieselben.
## Sprache
Kommunikation auf Deutsch, in **neuer Rechtschreibung** — `muss` und `dass`,
nicht `muß` und `daß`. Quelltextkommentare und Javadoc bleiben ausschließlich Englisch.
Oberflächentexte stehen seit Etappe 9 in `app-desktop/src/main/i18n/strings_<code>.properties`
und nicht mehr im Quelltext. Wer dort etwas ändert: der Erzeuger prüft beim Bau, und die
Regel, was übersetzt wird, steht in `docs/PROJECT_CONTEXT.md` unter „Important Decisions".
## Laufende Arbeit
Spezifikationen liegen unter `docs/superpowers/specs/` und bleiben: sie sagen, **warum** die
Architektur so ist, und das steht nirgends sonst. Umsetzungspläne werden nach der Ausführung
entfernt — sie sind Arbeitsanweisungen für erledigte Arbeit, und ein liegengebliebener Plan
liest sich für den Nächsten wie eine offene Absicht. Was aus ihnen zu behalten war, steht in
den Commit-Nachrichten und in `docs/PROJECT_CONTEXT.md`. Die Ablösung von JavaFX durch Compose Multiplatform ist
**abgeschlossen**: unter `src/main` steht kein `javafx.` mehr, und `org.openjfx` ist
aus `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml` entfernt. Die
Begründungen und die harten Lehren stehen in
`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`; die wichtigste: ein
blockierendes Warten auf dem AWT-Ereignisfaden verklemmt die Oberfläche, und der grüne
Build sagt über Fadenzugehörigkeit nichts.
Binary file not shown.
Binary file not shown.
+83 -20
View File
@@ -1,42 +1,105 @@
# KST4Contest
KST4Contest (also known as pratiKST) is a Java-based chat client for [ON4KST](http://www.on4kst.com/chat), focused on VHF/UHF/SHF contest operation.
KST4Contest is a Java-based client for the [ON4KST chat](https://www.on4kst.org/chat/login.php), developed for coordinated VHF, UHF and microwave contest operation.
## Website
The application is developed by Marc Fröhlich (DO5AMF) and since 2026 Philipp Wagner (DN9APW).
The offical Website of KST4Contest is now instead of [do5amf.funkerportal.de](https://do5amf.funkerportal.de) the new website [here](https://kst4contest.hamradioonline.de) [https://kst4contest.hamradioonline.de](https://kst4contest.hamradioonline.de)
## Start here
- [Project website](https://kst4contest.hamradioonline.de/)
- [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/)
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
- [GitHub wiki](https://github.com/HamRadioOnline/kst4contest/wiki)
- [Issues and bug reports](https://github.com/HamRadioOnline/kst4contest/issues)
- [Development roadmap](https://kst4contest.hamradioonline.de/roadmap/)
## What KST4Contest does
KST4Contest combines the ON4KST chat with information that is useful when coordinating contacts during a contest.
Among other things, it can:
- display and filter stations from the supported ON4KST chat categories;
- derive band and frequency information from chat messages and station names;
- maintain Worked and NOT QRV information for the available bands;
- calculate station priorities from distance, activity and other available information;
- manage internal skeds and received Win-Test skeds;
- use AirScout information when evaluating possible aircraft-scatter contacts;
- display stations, paths and additional propagation information on maps;
- exchange information with supported logging programs, Win-Test, PSTRotator and a local DX Cluster interface;
- provide configurable automatic replies for recurring chat requests.
Calculated scores, aircraft-scatter information and path assessments are operating aids. They depend on the available data and should not be treated as guarantees that a contact is possible.
## Installation
Ready-to-use packages are available for Windows, Linux and macOS. These packages include the required Java runtime, so a separate Java installation is normally not necessary.
Use the central download page to select the appropriate build:
- **Stable** is intended for normal contest operation.
- **Beta** contains changes that are being prepared for a stable release.
- **Nightly** contains the latest automated development build and is mainly intended for testing.
[Open the download page](https://kst4contest.hamradioonline.de/download/)
## Documentation
The full user documentation is maintained in the project wiki:
The documentation is available in German and English:
- https://github.com/praktimarc/kst4contest/wiki
- [German manual](https://github.com/HamRadioOnline/kst4contest/wiki/de-Home)
- [English manual](https://github.com/HamRadioOnline/kst4contest/wiki/en-Home)
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
Direct entry points:
The Markdown sources used for the wiki and the generated PDF manuals are stored in [`github_docs`](github_docs/).
- German start page: https://github.com/praktimarc/kst4contest/wiki/de-Home
- English start page: https://github.com/praktimarc/kst4contest/wiki/en-Home
Changes to operating behaviour should be documented together with their purpose and limitations. This is especially important for functions whose result depends on external data, heuristics or information derived from chat messages.
## Build
## Building from source
Compile locally with Maven Wrapper:
Building KST4Contest requires JDK 21. The Gradle Wrapper included in the repository should be used, so a separate Gradle installation is not required.
Linux and macOS:
```bash
./mvnw -B -DskipTests compile
./gradlew clean build
```
## Notes
Windows:
- Source code is under `src/`.
- Documentation markdown pages for wiki/PDF are under `github_docs/`.
```powershell
.\gradlew.bat clean build
```
## Status of the latest CI:
Wiki Publishing:
## Repository structure
[![Publish wiki](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml)
- `core/` – domain, controller and network code, free of user-interface technology
- `app-desktop/` – the Compose Multiplatform user interface and the packaging tasks
- `github_docs/` – German and English manual sources
- `website/` – project website sources
- `packaging/` – platform-specific packaging files
[![Docs PDF](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml)
## CI status
Builds:
### Documentation
[![Nightly Runtime Artifacts](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml)
[![Publish wiki](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml)
[![Docs PDF](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml)
### Builds
[![Nightly Runtime Artifacts](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml)
## License
KST4Contest is distributed under the [GNU General Public License v3.0](LICENSE).
## Supporters/Sponsor
This Project is possible due to donations of the community.
You can sponsor KST4Contest here: [Support on Ko-Fi](https://ko-fi.com/praktimarc). If you add an Display Name there you will be mentioned here.
The following people are sponsoring KST4Contest:
- Marco (DJ5KW)
+199
View File
@@ -0,0 +1,199 @@
plugins {
java
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
}
kotlin {
jvmToolchain(21)
}
dependencies {
// JUnit and Mockito are test-only. Until #62 a few test classes sat under src/main/java
// and dragged both -- plus byte-buddy and objenesis -- into lib/app of every app image.
implementation(libs.jetbrains.annotations)
implementation(project(":core"))
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(libs.sqlite.jdbc)
implementation(libs.jlayer)
/*
* The Compose UI test harness, used through runComposeUiTest { } from ordinary Jupiter
* tests. Deliberately NOT ui-test-junit4: that one brings the vintage engine, and two
* engines on one classpath is how half a suite silently stops being run. Checked after
* adding this: the only engine resolved is junit-jupiter-engine. JUnit 4 classes are on
* the test classpath, but they were before this too — something else pulls them
* transitively — and with no vintage engine nothing executes them.
*
* The accessor is marked experimental by the Compose Gradle plugin, so the opt-in sits
* here — one place, for one dependency. What it buys is the only mechanism that can
* answer "did the value reach the screen": the state-level tests and the two settings
* coverage nets check that a setter is called, which is a different question.
*/
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
testImplementation(compose.uiTest)
testImplementation(libs.junit.jupiter)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.junit.jupiter)
// Gradle 9 no longer adds the launcher implicitly.
testRuntimeOnly(libs.junit.platform.launcher)
}
/*
* The translation files are turned into Kotlin before anything compiles, by the
* :i18n-generator module. Two consequences worth naming:
*
* - A broken translation fails `./gradlew classes`, which is what the PR workflow already
* runs -- so a contributor's mistake is reported in their pull request without a new
* workflow existing for it.
* - The generator is reached through its own configuration, not through `implementation`, so
* it stays out of the application's runtime classpath and out of the jar.
*/
val i18nGenerator: Configuration by configurations.creating
dependencies {
i18nGenerator(project(":i18n-generator"))
}
val generatedI18nDirectory = layout.buildDirectory.dir("generated/i18n")
val generateStrings by tasks.registering(JavaExec::class) {
description = "Generates Strings.kt from app-desktop/src/main/i18n"
group = "build"
classpath = i18nGenerator
mainClass.set("kst4contest.i18n.GeneratorMain")
val input = layout.projectDirectory.dir("src/main/i18n")
// A local copy: the provider below must not capture the script-level val, which
// drags the build script object into the configuration cache and fails to serialize.
val output = generatedI18nDirectory
inputs.dir(input)
outputs.dir(output)
argumentProviders.add(CommandLineArgumentProvider {
listOf(input.asFile.absolutePath, output.get().asFile.absolutePath)
})
}
kotlin.sourceSets["main"].kotlin.srcDir(generatedI18nDirectory)
tasks.named("compileKotlin") { dependsOn(generateStrings) }
compose.desktop {
application {
// Main forwards to Kst4ContestApplication.main. It was a workaround for
// launching a JavaFX Application subclass from the classpath; it stays because
// the packaged launchers and the AUR/Flatpak wrappers name this class.
mainClass = "kst4contest.view.Main"
nativeDistributions {
/*
* Only the formats the building machine can actually produce. Compose rejects a
* foreign one while CONFIGURING the project, not while packaging it, so a list
* naming all five made every Gradle task fail on macOS and Windows — including
* `run` and `test`, which package nothing. The build was Linux-only without
* anyone having decided that.
*/
val host = org.gradle.internal.os.OperatingSystem.current()
targetFormats(
*when {
host.isMacOsX -> arrayOf(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
)
host.isWindows -> arrayOf(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
)
else -> arrayOf(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.AppImage,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm,
)
}
)
/*
* jlink must come from JDK 21, not from whatever the Gradle JVM is.
* jdk.jsobject was removed after 21, and this machine has 26 on PATH;
* Etappe 1 hit the same wall with jpackage.
*/
javaHome = javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
}.get().metadata.installationPath.asFile.absolutePath
/*
* KST4Contest and not praktiKST: this one name drives the app-image
* directory, the launcher, the .app bundle, the bundled icon file
* and -- lowercased -- the deb/rpm package name. Every packaging
* consumer already uses KST4Contest there (AppRun, the Arch and
* Flatpak wrappers, the AUR PKGBUILDs, build-signed-dmg.sh), and the
* shipped deb/rpm must keep the package identity "kst4contest" so an
* installed copy still upgrades.
*/
packageName = "KST4Contest"
packageVersion = providers.gradleProperty("composePackageVersion").get()
/*
* Kept from the former module-info.java. jdk.jsobject went with the map
* bridge: it carried netscape.javascript, and nothing imports that any
* more. jdk.unsupported stays — its justification used to be the JavaFX
* Marlin renderer, but sun.misc.Unsafe is reached by other dependencies
* too, and dropping it is a separate decision from removing JavaFX.
*/
modules(
"java.desktop", "java.net.http", "java.sql",
"jdk.crypto.ec", "jdk.net",
"jdk.xml.dom", "jdk.unsupported",
)
linux {
iconFile.set(rootProject.file("packaging/icons/kst4contest.png"))
/*
* Package release number behind the version (1.50.0-<release>).
* The version itself is composePackageVersion for every target
* format, so a beta DEB/RPM would otherwise carry exactly the
* version of the following stable one -- and dnf, rpm -U and
* dpkg refuse to replace a package by one of equal version, so
* a beta user could not move to the stable (#113). The tagged
* release workflow therefore passes -PlinuxAppRelease following
* the Fedora pre-release convention:
*
* beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001
* beta-v1.50.0-rc02 -> 1.50.0-0.rc02
* v1.50.0 -> 1.50.0-1
*
* Everything starting with "0." sorts below "1" for both rpm
* and dpkg. Nightlies and local builds do not set the property
* and keep release 1.
*/
appRelease = providers.gradleProperty("linuxAppRelease").orElse("1").get()
}
windows {
iconFile.set(rootProject.file("packaging/icons/kst4contest.ico"))
/*
* The MSI installer is built for tagged releases only; nightlies ship
* the portable ZIP alone, because an MSI always carries the plain
* composePackageVersion and two nightly MSIs could not be told apart.
* It installs per user, so it needs neither administrator rights nor
* a UAC prompt, and it adds a Start menu group and a desktop shortcut.
*
* upgradeUuid must NEVER change: Windows Installer uses it to
* recognise an installed KST4Contest and replace it in place when a
* newer MSI runs. A different value would install the new release
* side by side with the old one instead of upgrading it.
*/
upgradeUuid = "3375071a-25ba-468e-9ae5-8296bc535462"
menu = true
menuGroup = "KST4Contest"
shortcut = true
perUserInstall = true
dirChooser = true
}
macOS {
iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
// Must stay de.x08.KST4Contest: build-signed-dmg.sh signs against it.
bundleID = "de.x08.KST4Contest"
}
}
}
}
@@ -0,0 +1,455 @@
# German. Held complete by a test, because this is a shipped language.
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen.
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
airscout.enabled=AirScout-UDP-Anbindung aktivieren (AirScout 0.9.9.5 oder neuer)
airscout.forcedBand=Fester AirScout-Bandwert [1440000 = 144 MHz]:
airscout.frequencySection=Abgefragte Frequenz
airscout.serverIdentifier=AirScout-Serverkennung [AS]:
airscout.udpPort=AirScout-UDP-Port [9872] — nach Änderung neu verbinden:
airscout.udpSection=AirScout-UDP-Kommunikation
alert.ok=OK
beacon.enabled=Die Bake in dieser Kategorie senden
beacon.intervalMinutes=Abstand in ganzen Minuten
beacon.intervalSection=Gemeinsamer Abstand
beacon.message=Bakentext
beacon.sectionFor=CQ-Bake für {0}
connection.connect=Verbinden
connection.connectTo=Mit {0} verbinden
connection.disconnect=Trennen
connection.disconnectAndCloseChat=Trennen & Chat schließen
dialog.cancel=Abbrechen
dialog.ok=OK
gui.bandColumnHints=Hinweise in den Bandspalten
gui.language=Sprache
gui.language.system=Systemsprache
gui.startupDesign=Design beim Start
gui.startupFilters=Beim Start aktive Nachrichtenfilter
gui.windows=Fenster
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
logSynch.fileLabel=Zu überwachende Logdatei:
logSynch.fileSection=Dateiabfrage für gearbeitete Rufzeichen
logSynch.networkEnabled=QSO-Nachrichten von N1MM+, QARTEST, UCXLog und DXLog.net verarbeiten
logSynch.networkSection=Netzbasierter QSO-Abgleich (N1MM/QARTEST/UCXLog/DXLog)
logSynch.sharedPort=Gemeinsamer UDP-Port für QSO- und TRX-Nachrichten [Vorgabe 12060]:
logSynch.winTestBroadcast=UDP-Broadcast-Adresse für Win-Test (Vorgabe = Broadcast der Internetschnittstelle)
logSynch.winTestEnabled=Win-Test-UDP-Lognachrichten aus dem Netz empfangen
logSynch.winTestHint=Die Broadcast-Adresse wird einmal ermittelt, solange sie noch {0} ist. Eine Portänderung wirkt auf den laufenden Empfänger, sobald das Feld den Fokus verliert.
logSynch.winTestPort=UDP-Port für den Win-Test-Empfänger (Vorgabe ist 9871)
logSynch.winTestSection=Win-Test-Netzwerkempfänger
logSynch.winTestStationName=KST-Stationsname im Win-Test-Netz (Quelle der SKED-Pakete)
menu.about=Über...
menu.contactAuthor=Den Autor per Standard-Mailprogramm anschreiben
menu.disconnect=Trennen
menu.donateOv3t=OV3Ts Flugzeug-Datendienst unterstützen
menu.donatePayPal=Entwicklung von kst4Contest per PayPal unterstützen
menu.exitAndDisconnect=Beenden + trennen
menu.file=Datei
menu.hideMonitor=Cluster- / Fremd-QSOs ausblenden
menu.hideOptions=Optionen ausblenden
menu.homepage=DARC-X08-Homepage besuchen
menu.info=Info
menu.newsgroup=kst4Contest-Newsgroup beitreten
menu.options=Optionen
menu.setQrgAsNameInChat=QRG als Namen im Chat setzen (Hauptkategorie)
menu.showMeAsActive=Mich als AKTIV im Chat zeigen!
menu.showMeAsAway=Mich als ABWESEND vom Chat zeigen!
menu.showMonitor=Cluster- / Fremd-QSOs anzeigen
menu.showOptions=Optionen anzeigen
menu.switchOperatorProfile=Operatorprofil wechseln...
menu.toggleStationMap=Stationskarte ein-/ausblenden
menu.useDarkDesign=Abenddesign verwenden
menu.useDefaultDesign=Tagdesign verwenden
menu.windows=Fenster
messageHandling.autoAnswerEnabled=Privatnachrichten automatisch beantworten
messageHandling.autoAnswerSection=Automatische Antwort auf Privatnachrichten
messageHandling.autoAnswerText=Antworttext
messageHandling.qrgAnswerEnabled=Eine QRG-Anfrage mit der aktuellen Frequenz beantworten
messageHandling.qrgAnswerSection=Automatische Antwort auf eine QRG-Anfrage
messageHandling.debugToFileEnabled=Die vollständige Sitzung in eine Datei aufzeichnen
messageHandling.debugToFileHint=Zeichnet den kompletten ON4KST-Sende- und Empfangsverkehr in Messagehistory.raw auf und schreibt zusätzliche Diagnosedetails in kst4contest-errors.log. Anmeldepasswörter werden geschwärzt. Warnungen und Fehler werden immer protokolliert.
messageHandling.diagnosticsSection=Diagnose
notification.addCallsignToList=Ein Rufzeichen zur Überwachungsliste hinzufügen
notification.addMonitored=Überwachtes Rufzeichen hinzufügen
notification.bandUpgradeHint=Auf einen möglichen Bandwechsel hinweisen, wenn ein QSO geloggt wird
notification.bandUpgradePriority=Die Priorität einer Station mit möglichem Bandwechsel anheben
notification.bandUpgradeSection=Hinweise auf Bandwechsel
notification.callsign=Rufzeichen
notification.clusterEnabled=Den DX-Cluster-Server betreiben
notification.clusterPort=TCP-Port
notification.clusterSection=Lokaler DX-Cluster-Server
notification.cwCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht in CW geben
notification.fallbackBand=Ersatzband für ein Spot ohne Bandangabe
notification.monitoringSection=QSO-Überwachung
notification.sendTestSpot=Ein Testspot senden
notification.simpleSounds=Einfache Töne abspielen
notification.soundsSection=Töne
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
notification.spotterCallsign=Spotter-Rufzeichen
settings.applyAndClose=Übernehmen/Schließen
settings.applyFailed=Einige Einstellungen konnten nicht übernommen werden oder haben die laufende Sitzung nicht erreicht.\n\n(Technische Details: {0})
settings.pendingApply=Übernehmen
settings.pendingBack=Zurück zu den Einstellungen
settings.pendingDiscard=Verwerfen
settings.pendingQuestion=Einige Einstellungen wurden geändert, aber noch nicht übernommen. Zuerst übernehmen?
settings.pendingTitle=Nicht übernommene Änderungen
settings.invalidEntry={0}: „{1}" ist keine gültige Eingabe.
settings.save=Einstellungen speichern
shortcuts.addButton=Kurztaste hinzufügen
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
shortcuts.buttonsSection=Kurztastenknöpfe über dem Nachrichtenfeld
shortcuts.snippetsSection=Textbausteine (die ersten 10 über Strg+1 bis Strg+0)
station.bandsSection=Aktive Bänder
station.beamWidth=Öffnungswinkel der Antenne, Grad
station.callsign=Rufzeichen
station.chatSection=Chat-Kategorie
station.defaultsSection=Vorgaben
station.demDirectory=DEM-Stammverzeichnis
station.invalidOwnHeightAsl=„{0}" ist keine gültige Höhe. Meter von -500 bis 9000 eingeben oder das Feld leer lassen, dann gilt das Höhenmodell.
station.locator=Locator
station.mainCategory=Hauptkategorie
station.maxQrb=Maximaler QRB
station.nameMain=Name, Hauptkategorie
station.nameSecond=Name, zweite Kategorie
station.ownAntennaGain=Eigener Antennengewinn, dBi
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
station.ownHeightAsl=Eigene Standorthöhe über NN, Meter
station.ownSection=Eigene Station
station.ownTxPower=Eigene Sendeleistung, Watt
station.password=Passwort
station.pathSection=Vorgaben für die Streckenanalyse
station.qtf=QTF
station.rotatorEnabled=Rotorabgleich aktiv
station.rotatorHost=Rotor-Host
station.rotatorPort=Rotor-Port
station.rotatorSection=PSTRotator
station.rotator2Enabled=Rotor-2-Abgleich aktiv
station.rotator2Host=Rotor-2-Host
station.rotator2Port=Rotor-2-Port (antwortet auf Port + 1)
station.rotator2PortClash=Port {0} ist derselbe oder ein benachbarter Port wie beim anderen PSTRotator
station.rotator2Section=PSTRotator 2 (SO2R)
station.secondCategory=Zweite Kategorie
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
station.serverHost=Servername
station.serverPort=Serverport
station.serverSection=ON4KST-Server
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfelds
station.targetTxPower=Sendeleistung der Gegenstation, Watt
trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde.
trxSynch.hintSection=Was eine aktivierte Quelle bewirkt
trxSynch.so2rEnabled=SO2R: zwei Geräte getrennt führen (Gerät 2 schreibt SECONDQRG)
trxSynch.so2rHint=N1MM, DXLog und UCXLog werden über RadioNr zugeordnet; dafür ist nichts einzustellen. Win-Test braucht je Gerät einen Stationsnamen.
trxSynch.so2rSection=SO2R
trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz)
trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
trxSynch.winTestSection=Win-Test-TRX-Abgleich
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
trxSynch.winTestStationRadio2=Win-Test-Stationsname von Gerät 2 (z. B. STN2)
worked.empty=Die Datenbank enthält keine Rufzeichen.
worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig.
worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
worked.refresh=Datenbank neu einlesen
worked.reset=Worked-, NOT-QRV- und Feldangaben zurücksetzen...
worked.resetConfirm=Daten zurücksetzen
worked.resetDialogBody=Alle Worked-, NOT-QRV- und Feldangaben zurücksetzen?\n\nDas löscht jede contestbezogene Markierung in der internen Datenbank. Vor jedem Contest ist das normalerweise nicht nötig, weil diese Angaben nach drei Tagen verfallen.
worked.resetDialogTitle=Contestdaten zurücksetzen
worked.resetFailed=Die Contestdaten konnten nicht zurückgesetzt werden. Die interne Datenbank ist möglicherweise nicht erreichbar oder inkonsistent. Es wurde kein erfolgreiches Zurücksetzen bestätigt.
worked.section=Datenbank gearbeiteter Stationen
tab.station=Station
tab.logSynch=Log-Abgleich
tab.trxSynch=TRX-Abgleich
tab.airscout=Airscout
tab.notification=Hinweise
tab.shortcuts=Kurztasten
tab.beacon=Bake
tab.messageHandling=Nachrichten
tab.workedDatabase=Worked-Datenbank
tab.gui=GUI
tab.profiles=Profile
tab.colours=Farben
settings.categoryMainFallback=Haupt-Chat-Kategorie
settings.categorySecondFallback=Zweite Chat-Kategorie
settings.spotNeedsConnection=Zuerst KST4Contest verbinden und den lokalen DX-Cluster-Server einschalten.
settings.spotNoClient=Es ist kein DX-Cluster-Client mit KST4Contest verbunden.
settings.spotNotDelivered=Das Testspot konnte nicht zugestellt werden.
settings.writeFailed=Die Konfigurationsdatei konnte nicht geschrieben werden. Die Einstellungen bleiben für diese Sitzung aktiv.
settings.writtenTo=Die Einstellungen sind als Vorgabe in der XML-Konfigurationsdatei gespeichert:\n{0}
profiles.active=Aktives Profil:
profiles.settingsFile=Einstellungsdatei:
profiles.workedStations=Gearbeitete Stationen:
profiles.new=Neues Profil...
profiles.duplicate=Duplizieren...
profiles.rename=Umbenennen...
profiles.delete=Löschen...
profiles.changeWorked=Gearbeitete Stationen ändern...
profiles.switchTo=Zum gewählten Profil wechseln...
profiles.explanation=Jedes Profil hat eigene Einstellungen und ein eigenes Fensterlayout. Ein Profil kann entweder die gemeinsamen gearbeiteten Stationen der Station mitbenutzen — was eine Mehrmann-Station will — oder eigene führen. Beim Duplizieren wird alles übernommen außer Rufzeichen und Passwort, und gearbeitete Stationen nie.
profiles.dialogTitle=Operatorprofile
profiles.none=Kein Operatorprofil eingerichtet.
profiles.columnProfile=Profil
profiles.columnWorked=Gearbeitete Stationen
profiles.columnLastUsed=Zuletzt benutzt
profiles.inUse= (in Benutzung)
profiles.newTitle=Neues Operatorprofil
profiles.newBody=Name und gearbeitete Stationen des neuen Profils
profiles.nameLabel=Profilname, zum Beispiel DN9APW
profiles.create=Profil anlegen
profiles.duplicateTitle=Operatorprofil duplizieren
profiles.duplicateBody=Name des neuen Profils
profiles.duplicateHint=Alles wird übernommen außer Rufzeichen und Passwort. Gearbeitete Stationen werden nie kopiert.
profiles.renameTitle=Operatorprofil umbenennen
profiles.renameBody=Neuer Name des Profils
profiles.renameHint=Dateien und Ordner des Profils werden nicht angefasst.
profiles.deleteTitle=Operatorprofil löschen
profiles.deleteBody=Das Profil "{0}" löschen?
profiles.deleteConfirm=Profil löschen
profiles.workedTitle=Gearbeitete Stationen
profiles.workedBody=Gearbeitete Stationen von "{0}"
profiles.workedHint=Das Umschalten verschiebt keine Daten. Bereits unter der anderen Einstellung gesammelte gearbeitete Stationen bleiben, wo sie sind.
colours.section=Farben dieses Entwurfs
colours.roleWithSource={0} ({1})
colours.readabilitySection=Lesbarkeit
colours.warning={0} liest sich jetzt mit {1} zu 1, wo dieser Entwurf {2} zu 1 ausliefert.
colours.warningKept=Die Farben werden trotzdem behalten — eine harte Kombination kann gewollt sein.
colours.keepingSection=Diese Farben behalten
colours.keepingHint=Eine Farbe gilt sofort und überall. Den Neustart überlebt sie erst, wenn „Einstellungen speichern" gedrückt wurde.
colours.waysBackSection=Wege zurück
colours.backToPrevious=Zurück zum Stand von vorher ({0})
colours.discardChanges=Meine Änderungen verwerfen ({0})
colours.resetToShipped=Auf Auslieferung zurücksetzen ({0})
colours.fileSection=Die Stilvorlage von Hand bearbeiten
colours.fileReads=Dieser Entwurf liest {0}. Eine oben gesetzte Farbe überstimmt für diese Rolle, was die Datei sagt.
colours.fileAbsent=Dieser Entwurf hat keine eigene Stilvorlage. Eine kann als Ausgangspunkt nach {0} geschrieben werden.
colours.exportButton=Die Stilvorlage dieses Entwurfs in mein Profil schreiben
colours.exportWritten=Nach {0} geschrieben. Bearbeiten und neu starten, um das Ergebnis zu sehen.
colours.exportAlreadyThere={0} ist schon vorhanden und wurde nicht angefasst.
colours.exportFailed=Die Stilvorlage konnte nicht geschrieben werden: {0}
colours.sourceShipped=Auslieferung
colours.sourceFile=Datei
colours.sourceChanged=geändert
colours.roleSurface=Fläche (Menüzeile, ausgewählte Zeilen)
colours.roleWindowSurface=Fensterfläche (die Fenster selbst)
colours.roleFieldInterior=Feldinneres (Felder, Listen, Knöpfe)
colours.roleText=Text
colours.roleAccent=Akzent
colours.roleSeparator=Trennlinie
colours.notAColour='{0}' ist keine Farbe. Sechs Hexadezimalziffern mit führendem Rautezeichen, zum Beispiel #3C7A4B. {1} bleibt unverändert.
column.callsign=Rufzeichen
column.name=Name
column.message=Nachricht
column.time=Zeit
column.lastQrgRx=Letzte QRG RX
column.lastQrgTx=Letzte QRG TX
filter.find=Suchen...
filter.reset=Filter zurücksetzen
filter.showOnlyQrb=Nur QRB [km] <=
filter.showOnlyQrbAbove=Nur QRB [km] >
filter.showOnlyQtf=Nur QTF:
filter.hideWorked=Gearbeitete ausblenden:
filter.inactiveStations=Inaktive Stationen
filter.onlyNewGrids=Nur neue Felder
filter.gridColour=Feldfarbe
filter.newBands=Neue Bänder
filter.tropo=Tropo >=0dB
selected.none=Keine Station gewählt.
selected.priority=Priorität:
selected.more=mehr
selected.showMessages=Nachrichten zeigen
selected.createSked=Sked anlegen
selected.sked=Sked
selected.skedInMinutes=Sked in (Min.)
selected.skedFail=Sked fehlgeschlagen
selected.remindPmIn=Erinnerungs-PM in
selected.reminderOffsets=Erinnerungsabstände
selected.resetFail=Zurücksetzen fehlgeschlagen
selected.tagNotQrv=Als NOT QRV markieren
selected.band=Band
selected.mode=Mode
selected.lookUpAndShow=Nachschlagen und zeigen
selected.lookupQrz=Auf qrz.com nachschlagen
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
selected.showPathInAs=Strecke in AS zeigen
selected.turnAntenna=Antenne 1 Richtung {0} drehen
selected.turnAntenna2=Antenne 2 Richtung {0} drehen
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
candidates.title=Stationen mit höchster Priorität
candidates.none=Noch keine Kandidaten.
candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus.
update.title=Aktualisierungsinformationen
update.available=Aktualisierung verfügbar!
update.installedVersion=Installierte Version:
update.latestVersion=Neueste stabile Version:
update.mainChanges=Wesentliche Änderungen:
update.additionalInformation=Weitere Informationen:
update.download=Herunterladen:
update.openReleasePage=Release-Seite öffnen
update.changeLog=Änderungsprotokoll
update.knownBugs=Bekannte Fehler
update.versions=Versionen
map.title=Stationskarte
map.tab=Karte
map.resetView=Ansicht zurücksetzen
map.groupNearby=Nahe Stationen gruppieren
map.triggerClusterSpot=Cluster-Spot auslösen
map.showPathAnalysis=Streckenanalyse zeigen
map.hidePathAnalysis=Streckenanalyse ausblenden
map.pathAnalysisHidden=Die Streckenanalyse ist ausgeblendet.
map.tilesUnavailable=Kartenkacheln nicht verfügbar — die Netzverbindung prüfen.
path.title=Strecken- und Geländeanalyse
path.selectStation=Eine Station wählen, um Strecken- und Geländeanalyse vorzubereiten.
path.fromLocator=Vom Locator:
path.toLocator=Zum Locator:
path.endpoints=Endpunkte:
path.distanceQtf=Entfernung/QTF:
path.frequency=Frequenz:
path.status=Status:
path.source=Quelle:
path.los=LOS:
path.fresnel=Fresnel:
path.obstruction=Hindernis:
path.radioHorizon=Funkhorizont:
path.terrainHorizon=Geländehorizont:
path.refraction=Refraktion:
path.mechanisms=Mechanismen:
path.assessment=Bewertung:
path.linkBudget=Streckenbilanz:
path.rxPower=Empfangsleistung:
path.cwHint=CW-Hinweis:
chart.noSamples=Keine Profilpunkte verfügbar.
chart.distanceAxis=Entfernung [km]
chart.heightAxis=Höhe [m]
chart.legend=Legende:
chart.terrain=Gelände
chart.los=LOS
chart.fresnel=Fresnel
chart.critical=Kritisch
chart.diffraction=Beugung
chart.home=Eigene
chart.radioHorizonShort=Funkhor.
chart.terrainHorizonShort=Geländehor.
chart.homeRadioHorizon=Eigener Funkhorizont
chart.homeTerrainHorizon=Eigener Geländehorizont
chart.dxRadioHorizon=Funkhorizont der Gegenstation
chart.dxTerrainHorizon=Geländehorizont der Gegenstation
chart.frequency=f = {0} MHz
chart.criticalFresnel=Kritischer Punkt: Fresnel-Eingriff {0} m bei {1} km
chart.criticalLos=Kritischer Punkt: LOS-Freiraum {0} m bei {1} km
chart.diffractionCandidate=Beugungskandidat {0} km, KE ≈ {1} dB
title.connectionLost=VERBINDUNG VERLOREN – neuer Versuch eingeplant
title.connecting=Verbindung zu ON4KST wird aufgebaut…
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
title.disconnected=NICHT VERBUNDEN!
title.online=Verbunden mit: {0} als {1} ({2}) in {3} ({4} Benutzer online, {5} angezeigt), {6} Nachrichten insgesamt.
title.bothCategories={0} und {1}
status.noConnection=Keine ON4KST-Verbindung
status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
status.linkConnected=🟢 LINK: Verbunden
status.linkConnecting=🟡 LINK: Verbindet…
status.linkDisconnecting=🟡 LINK: Trennt…
status.linkReconnecting=🔴 LINK: Verbindet neu…
status.linkDisconnected=🔴 LINK: Nicht verbunden
picker.title=Operatorprofil wählen
picker.moreThanOne=Es ist mehr als ein Operatorprofil eingerichtet.
picker.start=Starten
picker.quit=Beenden
dialog.yes=Ja
dialog.switchProfile=Profil wechseln
alert.profileTitle=Operatorprofil
alert.profileNotFound=Das angeforderte Operatorprofil wurde nicht gefunden.
alert.profilesTitle=Operatorprofile
alert.onlyOneProfile=Es ist nur ein Operatorprofil eingerichtet.
alert.onlyOneProfileBody=Weitere Profile werden im Einstellungsfenster im Reiter „Profile" angelegt. Jedes Profil behält eigene Einstellungen und ein eigenes Layout und kann die Worked-Datenbank der Station entweder mitbenutzen oder eine eigene führen.
alert.switchProfileTitle=Operatorprofil wechseln
alert.switchProfileQuestion=Zu „{0}" wechseln?
alert.switchProfileBody=Die ON4KST-Verbindung wird geschlossen und alle Fenster werden mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut.\n\nNicht gespeicherte Einstellungen des aktuellen Profils gehen verloren. Fenstergrößen sowie Teiler- und Spaltenbreiten werden automatisch gesichert.
alert.quitTitle=Anwendung beenden
alert.quitQuestion=Soll die Verbindung zum Chat getrennt werden?
alert.simpleLogTitle=Simplelogfile angelegt
alert.simpleLogHeader=Das gewählte Simplelogfile gab es nicht und wurde angelegt
alert.simpleLogBody=Datei: {0}\n\nPrüfe zuerst, ob du die Simplelogfile-Anbindung überhaupt brauchst. Bietet dein Logprogramm eine unterstützte Netzschnittstelle, nutze diese für Band- und Locatorangaben.\n\nWenn du Simplelogfile benutzt, stelle dein Logprogramm so ein, dass es sein laufendes Log in diese Datei schreibt. Logge dann ein Test-QSO und prüfe, ob das Rufzeichen in KST4Contest binnen einer Minute als gearbeitet markiert wird.\n\nPrüfe vor jedem Contest, dass das Logprogramm das aktuelle Contestlog in genau diese Datei schreibt. KST4Contest setzt aus Simplelogfile stammende Worked-Markierungen zum Contestbeginn nicht von selbst zurück.
alert.simpleLogLink=Das Simplelogfile-Handbuch öffnen
tooltip.bandsNone=Bänder: -
tooltip.bandsPrefix=Bänder:
tooltip.bandStatusLegend=Bandstatus:\nX = auf diesem Band gearbeitet\nB+ = Band verfügbar, auf diesem Band noch nicht gearbeitet (Rufzeichen auf einem anderen Band bereits gearbeitet)\na = Band verfügbar, Rufzeichen auf keinem Band gearbeitet (in den GUI-Einstellungen abschaltbar; fällt dann auf B+ zurück)\no = Großfeld auf diesem Band bereits gearbeitet, von einer beliebigen Station (in den GUI-Einstellungen abschaltbar)\n(leer) = keine Angaben zu diesem Band\no kann mit den übrigen zusammentreffen, zum Beispiel „ao" oder „B+o"
tooltip.bandStatusThisStation=\n\nDiese Station ({0}):\nStatus: {1}
tooltip.workedStatusLegend=Worked-Status:\nleer = Rufzeichen nicht gearbeitet, Feld nicht gearbeitet\nx = Rufzeichen gearbeitet\no = Feld gearbeitet\nxo = Rufzeichen und Feld gearbeitet
tooltip.workedStatusThisStation=\n\nDiese Station:\nRufzeichen gearbeitet: {0}\nFeld gearbeitet: {1} ({2})
tooltip.yes=ja
tooltip.no=nein
tooltip.unknownGrid=unbekanntes Feld
tooltip.grid=Feld {0}
messages.public=Öffentliche Nachrichten
messages.dxCluster=DXCluster-Nachrichten
messages.qsoOfTheOther=Fremd-QSOs
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
chat.qtf2Tooltip=Richtung von Antenne 2 (Rotor 2, SO2R); nur Anzeige
chat.secondQrgTooltipSo2r=QRG von Gerät 2 (aus RadioNr 2 bzw. Win-Test-Station 2)
list.moveDown=Auswahl nach unten
list.moveUp=Auswahl nach oben
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
column.lastKnownQrg=Letzte bekannte QRG
alert.cannotConnectTitle=Verbinden nicht möglich
alert.credentialsMissing=Anmeldedaten fehlen
alert.credentialsMissingBody=Bitte zuerst Rufzeichen und Passwort in den Einstellungen eintragen.
alert.connectionFailedTitle=Verbindung fehlgeschlagen
alert.connectionFailedBody=Verbinden nicht möglich: {0}
contrast.textOnWindowSurface=Text auf der Fensterfläche
contrast.textInsideField=Text im Feld
contrast.accentOnWindowSurface=Akzent auf der Fensterfläche
contrast.textOnMenuStrip=Text auf der Menüzeile
gui.startInEveningDesign=Im Abenddesign starten
gui.filterPublic=Öffentliche Nachrichten
gui.filterPmToMe=Privatnachrichten an mich
gui.filterPmToOthers=Privatnachrichten an andere
gui.filterNothing=Kein Filter
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren
gui.rememberWindowPositions=Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden (nur auf demselben Bildschirm)
form.choose=Wählen...
form.notSelected=nicht gewählt
dialog.apply=Übernehmen
chat.clear=leeren
selected.showOnMap=🗺 Auf der Karte zeigen
selected.notQrvAll=alle
column.category=Kategorie
column.sender=Absender
column.receiver=Empfänger
column.lastQrg=Letzte QRG
column.airplane=AP [Minuten / Pot%]
column.score=Wertung
monitor.title=Cluster & Fremd-QSOs
monitor.dxCluster=DX-Cluster
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
profiles.rootNotDeletable=Das Standardprofil verwendet die Dateien der Installation selbst und kann nicht gelöscht werden.
profiles.activeNotDeletable=Das gerade benutzte Profil kann nicht gelöscht werden. Zuerst zu einem anderen Profil wechseln.
profiles.deleteFailed=Das Profil konnte nicht gelöscht werden.
profiles.deletionFolder=Der folgende Ordner wird endgültig entfernt:\n{0}
profiles.deletionKeepsShared=Die gemeinsamen gearbeiteten Stationen der Station werden nicht angefasst.
profiles.deletionRemovesOwn=Die gearbeiteten Stationen dieses Profils werden ebenfalls gelöscht.
profiles.rootAlwaysShared=Das Standardprofil verwendet immer die gemeinsamen gearbeiteten Stationen der Station, weil diese Datenbank die der Installation selbst ist.
profiles.changeAfterSwitch=Die Änderung wirkt erst nach einem erneuten Wechsel zu diesem Profil.
profiles.alreadyActive=Dieses Profil ist bereits aktiv.
profiles.selectFirst=Zuerst ein Profil in der Tabelle wählen.
beacon.invalidMessage=Der Bakentext ist ungültig: {0}
beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minute eingeben.
notification.invalidPort=„{0}" ist kein gültiger TCP-Port. Einen Wert von 1 bis 65535 eingeben.
notification.invalidSpotter=„{0}" ist kein gültiges Spotter-Rufzeichen.
notification.invalidCallsign=Bitte ein gültiges Rufzeichen eingeben.
notification.alreadyMonitored=Dieses Grundrufzeichen steht bereits in der Überwachungsliste.
settings.windowTitle=Client-Einstellungen ändern
settings.noticeInfo=Info
settings.noticeProblem=Einstellungen
profiles.unknown=unbekannt
profiles.copyOf=Kopie von {0}
profiles.sharedDatabase=gemeinsame Stationsdatenbank
profiles.ownDatabase=eigene Datenbank
@@ -0,0 +1,464 @@
# The base language. Every key and every argument count comes from this file.
#
# To contribute a language: copy this file to strings_<code>.properties and translate the
# values to the right of the equals sign. An untranslated key falls back to English, so a
# partial file is welcome. `./gradlew :app-desktop:classes` builds and reports any mistake,
# and writes a worksheet of what is still missing to
# app-desktop/build/generated/i18n/todo/.
#
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file --
# none of it is here, and three tests keep it that way.
airscout.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
airscout.automaticFrequency=Select AirScout frequency automatically per station
airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station.
airscout.clientIdentifier=KST4Contest client identifier [KST]:
airscout.enabled=Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)
airscout.forcedBand=Forced AirScout band value [1440000 = 144 MHz]:
airscout.frequencySection=Queried frequency
airscout.serverIdentifier=AirScout server identifier [AS]:
airscout.udpPort=AirScout UDP port [9872] — reconnect after changing:
airscout.udpSection=AirScout UDP communication
alert.ok=OK
beacon.enabled=Send the beacon in this category
beacon.intervalMinutes=Interval in whole minutes
beacon.intervalSection=Shared interval
beacon.message=Beacon message
beacon.sectionFor=CQ beacon for {0}
connection.connect=Connect
connection.connectTo=Connect to {0}
connection.disconnect=Disconnect
connection.disconnectAndCloseChat=Disconnect & close Chat
dialog.cancel=Cancel
dialog.ok=OK
gui.bandColumnHints=Hints in the band columns
gui.language=Language
gui.language.system=System language
gui.startupDesign=Startup design
gui.startupFilters=Message filters active at startup
gui.windows=Windows
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
logSynch.fileLabel=Log file to be monitored:
logSynch.fileSection=File polling for worked callsigns
logSynch.networkEnabled=Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net
logSynch.networkSection=Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)
logSynch.sharedPort=Shared UDP port for QSO and TRX messages [default 12060]:
logSynch.winTestBroadcast=UDP broadcast address for Win-Test (default = internet interface broadcast)
logSynch.winTestEnabled=Receive Win-Test network based UDP log messages
logSynch.winTestHint=The broadcast address is detected once while it is still {0}. A port change takes effect on the running listener when the field loses focus.
logSynch.winTestPort=UDP-Port for Win-Test listener (default is 9871)
logSynch.winTestSection=Win-Test Network-Listener
logSynch.winTestStationName=KST station name in Win-Test network (src of SKED packets)
menu.about=About...
menu.contactAuthor=Contact the author using default mail app
menu.disconnect=Disconnect
menu.donateOv3t=Donate for OV3T´s plane feed service
menu.donatePayPal=Donate for kst4Contest development via PayPal
menu.exitAndDisconnect=Exit + disconnect
menu.file=File
menu.hideMonitor=Hide cluster / stranger QSOs
menu.hideOptions=hide options
menu.homepage=Visit DARC X08-Homepage
menu.info=Info
menu.newsgroup=Join kst4Contest newsgroup
menu.options=Options
menu.setQrgAsNameInChat=Set QRG as name in Chat (main category)
menu.showMeAsActive=Show me as ACTIVE in chat!
menu.showMeAsAway=Show me as AWAY FROM chat!
menu.showMonitor=Show cluster / stranger QSOs
menu.showOptions=Show options
menu.switchOperatorProfile=Switch operator profile...
menu.toggleStationMap=Show / hide station map
menu.useDarkDesign=Use dark mode design
menu.useDefaultDesign=Use default mode design
menu.windows=Windows
messageHandling.autoAnswerEnabled=Answer private messages automatically
messageHandling.autoAnswerSection=Automatic answer to private messages
messageHandling.autoAnswerText=Answer text
messageHandling.qrgAnswerEnabled=Answer a QRG request with the current frequency
messageHandling.qrgAnswerSection=Automatic answer to a QRG request
messageHandling.debugToFileEnabled=Record the whole session to file
messageHandling.debugToFileHint=Records complete ON4KST RX/TX session traffic in Messagehistory.raw and adds diagnostic details to kst4contest-errors.log. Login passwords are redacted. Warnings and errors are always logged.
messageHandling.diagnosticsSection=Diagnostics
notification.addCallsignToList=Add a callsign to the monitoring list
notification.addMonitored=Add monitored callsign
notification.bandUpgradeHint=Hint at a band upgrade when a QSO is logged
notification.bandUpgradePriority=Raise the priority of a station worth upgrading
notification.bandUpgradeSection=Band upgrade hints
notification.callsign=Callsign
notification.clusterEnabled=Run the DX Cluster server
notification.clusterPort=TCP port
notification.clusterSection=Local DX Cluster server
notification.cwCallsign=Play the callsign in CW on a received private message
notification.fallbackBand=Fallback band for a spot without band information
notification.monitoringSection=QSO monitoring
notification.sendTestSpot=Send a test spot
notification.simpleSounds=Play simple sounds
notification.soundsSection=Sounds
notification.speakCallsign=Speak the callsign on a received private message
notification.spotterCallsign=Spotter callsign
settings.applyAndClose=Apply/Close prefs
settings.applyFailed=Some settings could not be applied, or their change did not reach the running session.\n\n(Technical details: {0})
settings.pendingApply=Apply
settings.pendingBack=Back to the settings
settings.pendingDiscard=Discard
settings.pendingQuestion=Some settings have been changed but not applied yet. Apply them first?
settings.pendingTitle=Changes not applied
settings.invalidEntry={0}: "{1}" is not a valid entry.
settings.save=Save settings
shortcuts.addButton=Add shortcut
shortcuts.addSnippet=Add new snippet
shortcuts.buttonsSection=Shortcut buttons above the message field
shortcuts.snippetsSection=Text snippets (the first 10 use Ctrl+1 through Ctrl+0)
station.bandsSection=Active bands
station.beamWidth=Antenna beam width, degrees
station.callsign=Callsign
station.chatSection=Chat category
station.defaultsSection=Defaults
station.demDirectory=DEM root directory
station.invalidOwnHeightAsl="{0}" is not a valid height. Enter metres from -500 to 9000, or leave the field empty to use the elevation model.
station.locator=Locator
station.mainCategory=Main category
station.maxQrb=Maximum QRB
station.nameMain=Name, main category
station.nameSecond=Name, second category
station.ownAntennaGain=Own antenna gain, dBi
station.ownAntennaHeight=Own antenna height, metres
station.ownHeightAsl=Own ground height ASL, metres
station.ownSection=Own station
station.ownTxPower=Own TX power, watts
station.password=Password
station.pathSection=Path analysis defaults
station.qtf=QTF
station.rotatorEnabled=Rotator sync enabled
station.rotatorHost=Rotator host
station.rotatorPort=Rotator port
station.rotatorSection=PSTRotator
station.rotator2Enabled=Rotator 2 sync enabled
station.rotator2Host=Rotator 2 host
station.rotator2Port=Rotator 2 port (answers on port + 1)
station.rotator2PortClash=Port {0} is the same as or next to the port of the other PSTRotator
station.rotator2Section=PSTRotator 2 (SO2R)
station.secondCategory=Second category
station.secondChatEnabled=2nd chat: log in to a second category
station.serverHost=Server host name
station.serverPort=Server port
station.serverSection=ON4KST server
station.targetAntennaGain=Target antenna gain, dBi
station.targetHighestPoint=Target station on the highest point of its locator square
station.targetTxPower=Target TX power, watts
trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand.
trxSynch.hintSection=What an enabled source does
trxSynch.so2rEnabled=SO2R: keep two radios apart (radio 2 writes SECONDQRG)
trxSynch.so2rHint=N1MM, DXLog and UCXLog are assigned by RadioNr; nothing to set. Win-Test needs one station name per radio.
trxSynch.so2rSection=SO2R
trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
trxSynch.ucxSection=Receive UCXLog TRX info
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)
trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
trxSynch.winTestSection=Win-Test TRX sync
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
trxSynch.winTestStationRadio2=Win-Test station name of radio 2 (e.g. STN2)
worked.empty=The worked database holds no callsigns.
worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required.
worked.lastResetAffected=Last reset affected {0} rows.
worked.refresh=Refresh worked database
worked.reset=Reset worked, NOT-QRV and grid data...
worked.resetConfirm=Reset data
worked.resetDialogBody=Reset all worked, NOT-QRV and grid data?\n\nThis clears every contest-related flag in the internal database. A reset before every contest is normally not required because these data expire after three days.
worked.resetDialogTitle=Reset contest data
worked.resetFailed=The contest data could not be reset. The internal database may be unavailable or inconsistent. No successful reset was confirmed.
worked.section=Worked station database
tab.station=Station
tab.logSynch=Log synch
tab.trxSynch=TRX synch
tab.airscout=Airscout
tab.notification=Notification
tab.shortcuts=Shortcuts
tab.beacon=Beacon
tab.messageHandling=Messagehandling
tab.workedDatabase=Workedstn database
tab.gui=GUI
tab.profiles=Profiles
tab.colours=Colours
settings.categoryMainFallback=Main chat category
settings.categorySecondFallback=Second chat category
settings.spotNeedsConnection=Connect KST4Contest and enable the local DX Cluster server first.
settings.spotNoClient=No DX Cluster client is connected to KST4Contest.
settings.spotNotDelivered=The test spot could not be delivered.
settings.writeFailed=The configuration file could not be written. The settings stay active for this session.
settings.writtenTo=Settings are stored as default to the xml config file:\n{0}
profiles.active=Active profile:
profiles.settingsFile=Settings file:
profiles.workedStations=Worked stations:
profiles.new=New profile...
profiles.duplicate=Duplicate...
profiles.rename=Rename...
profiles.delete=Delete...
profiles.changeWorked=Change worked stations...
profiles.switchTo=Switch to selected profile...
profiles.explanation=Each profile has its own settings and window layout. A profile can either share the common station worked stations, which is what a multi operator station wants, or keep its own. Duplicating a profile copies everything except callsign and password, and never copies worked stations.
profiles.dialogTitle=Operator profiles
profiles.none=No operator profile configured.
profiles.columnProfile=Profile
profiles.columnWorked=Worked stations
profiles.columnLastUsed=Last used
profiles.inUse= (in use)
profiles.newTitle=New operator profile
profiles.newBody=Name and worked stations of the new profile
profiles.nameLabel=Profile name, for example DN9APW
profiles.create=Create profile
profiles.duplicateTitle=Duplicate operator profile
profiles.duplicateBody=Name of the new profile
profiles.duplicateHint=Everything is copied except callsign and password. Worked stations are never copied.
profiles.renameTitle=Rename operator profile
profiles.renameBody=New name of the profile
profiles.renameHint=Files and folders of the profile are not touched.
profiles.deleteTitle=Delete operator profile
profiles.deleteBody=Delete the profile "{0}"?
profiles.deleteConfirm=Delete profile
profiles.workedTitle=Worked stations
profiles.workedBody=Worked stations of "{0}"
profiles.workedHint=Switching does not move any data. Worked stations already collected under the other setting stay where they are.
colours.section=Colours of this design
colours.roleWithSource={0} ({1})
colours.readabilitySection=Readability
colours.warning={0} now reads at {1} to 1, where this design ships {2} to 1.
colours.warningKept=The colours are kept either way — a hard combination can be deliberate.
colours.keepingSection=Keeping these colours
colours.keepingHint=A colour applies at once, everywhere. It survives a restart only once Save settings has been pressed.
colours.waysBackSection=Ways back
colours.backToPrevious=Back to how it was ({0})
colours.discardChanges=Discard my changes ({0})
colours.resetToShipped=Reset to the shipped colours ({0})
colours.fileSection=Editing the stylesheet by hand
colours.fileReads=This design reads {0}. A colour set above overrides what the file says for that role.
colours.fileAbsent=This design has no stylesheet of its own. One can be written to {0} as a starting point.
colours.exportButton=Write this design's stylesheet into my profile
colours.exportWritten=Written to {0}. Edit it and restart to see the result.
colours.exportAlreadyThere={0} is already there and was left untouched.
colours.exportFailed=Could not write the stylesheet: {0}
colours.sourceShipped=shipped
colours.sourceFile=from file
colours.sourceChanged=changed
colours.roleSurface=Surface (menu strip, selected rows)
colours.roleWindowSurface=Window surface (the windows themselves)
colours.roleFieldInterior=Field interior (fields, lists, buttons)
colours.roleText=Text
colours.roleAccent=Accent
colours.roleSeparator=Separator line
colours.notAColour='{0}' is not a colour. Six hexadecimal digits with a leading hash, for example #3C7A4B. {1} is unchanged.
column.callsign=Callsign
column.name=Name
column.message=Message
column.time=Time
column.lastQrgRx=Last QRG RX
column.lastQrgTx=Last QRG TX
filter.find=Find...
filter.reset=Reset filters
filter.showOnlyQrb=Show only QRB [km] <=
filter.showOnlyQrbAbove=Show only QRB [km] >
filter.showOnlyQtf=Show only QTF:
filter.hideWorked=Hide worked:
filter.inactiveStations=Inactive stations
filter.onlyNewGrids=Only new grids
filter.gridColour=Grid color
filter.newBands=New bands
filter.tropo=Tropo >=0dB
selected.none=No station selected.
selected.priority=Priority:
selected.more=more
selected.showMessages=Show messages
selected.createSked=Create sked
selected.sked=Sked
selected.skedInMinutes=Sked in (min)
selected.skedFail=Sked fail
selected.remindPmIn=Remind-PM in
selected.reminderOffsets=Reminder offsets
selected.resetFail=Reset fail
selected.tagNotQrv=Tag not QRV
selected.band=Band
selected.mode=Mode
selected.lookUpAndShow=Look up and show
selected.lookupQrz=Lookup on qrz.com
selected.lookupQrzcq=Lookup on qrzcq.com
selected.showPathInAs=Show path in AS
selected.turnAntenna=Turn ant1 to {0}
selected.turnAntenna2=Turn ant2 to {0}
selected.lastActivity=Last activity: {0} ({1} min ago)
candidates.title=Top priority candidates
candidates.none=No candidates yet.
candidates.doubleClick=Double-click a candidate to select it.
update.title=Update information
update.available=Update available!
update.installedVersion=Installed version:
update.latestVersion=Latest stable version:
update.mainChanges=Main changes:
update.additionalInformation=Additional information:
update.download=Download:
update.openReleasePage=Open release page
update.changeLog=ChangeLog
update.knownBugs=Known bugs
update.versions=Versions
map.title=Station Map
map.tab=Map
map.resetView=Reset view
map.groupNearby=Group nearby stations
map.triggerClusterSpot=Trigger cluster spot
map.showPathAnalysis=Show path analysis
map.hidePathAnalysis=Hide path analysis
map.pathAnalysisHidden=Path analysis is hidden.
map.tilesUnavailable=Map tiles unavailable — check the network connection.
path.title=Path / terrain analysis
path.selectStation=Select a station to prepare path and terrain analysis.
path.fromLocator=From locator:
path.toLocator=To locator:
path.endpoints=Endpoints:
path.distanceQtf=Distance/QTF:
path.frequency=Frequency:
path.status=Status:
path.source=Source:
path.los=LOS:
path.fresnel=Fresnel:
path.obstruction=Obstruction:
path.radioHorizon=Radio horizon:
path.terrainHorizon=Terrain horizon:
path.refraction=Refraction:
path.mechanisms=Mechanisms:
path.assessment=Assessment:
path.linkBudget=Link budget:
path.rxPower=RX power:
path.cwHint=CW hint:
chart.noSamples=No profile samples available.
chart.distanceAxis=Distance [km]
chart.heightAxis=Height [m]
chart.legend=Legend:
chart.terrain=Terrain
chart.los=LOS
chart.fresnel=Fresnel
chart.critical=Critical
chart.diffraction=Diffraction
chart.home=Home
chart.radioHorizonShort=Radio hor.
chart.terrainHorizonShort=Terr. hor.
chart.homeRadioHorizon=Home radio horizon
chart.homeTerrainHorizon=Home terrain horizon
chart.dxRadioHorizon=DX radio horizon
chart.dxTerrainHorizon=DX terrain horizon
chart.frequency=f = {0} MHz
chart.criticalFresnel=Critical point: Fresnel intrusion {0} m @ {1} km
chart.criticalLos=Critical point: LOS clearance {0} m @ {1} km
chart.diffractionCandidate=Diffraction candidate {0} km, KE ≈ {1} dB
title.connectionLost=CONNECTION LOST – reconnect scheduled
title.connecting=Connecting to ON4KST…
title.authenticating=Connected – authenticating with ON4KST…
title.synchronizing=Connected – synchronizing ON4KST chat data…
title.disconnected=DISCONNECTED!
title.online=Connected to: {0} as {1} ({2}) in {3} ({4} users online, {5} shown), {6} messages total.
title.bothCategories={0} and {1}
status.noConnection=No ON4KST connection
status.linkTooltip=ON4KST link: {0}\n{1}
status.linkConnected=🟢 LINK: Connected
status.linkConnecting=🟡 LINK: Connecting…
status.linkDisconnecting=🟡 LINK: Disconnecting…
status.linkReconnecting=🔴 LINK: Reconnecting…
status.linkDisconnected=🔴 LINK: Disconnected
picker.title=Select operator profile
picker.moreThanOne=More than one operator profile is configured.
picker.start=Start
picker.quit=Quit
dialog.yes=Yes
dialog.switchProfile=Switch profile
alert.profileTitle=Operator profile
alert.profileNotFound=The requested operator profile was not found.
alert.profilesTitle=Operator profiles
alert.onlyOneProfile=Only one operator profile is configured.
alert.onlyOneProfileBody=Additional profiles are created in the settings window on the "Profiles" tab. Each profile keeps its own settings and layout, and can either share the station worked database or use its own.
alert.switchProfileTitle=Switch operator profile
alert.switchProfileQuestion=Switch to "{0}"?
alert.switchProfileBody=The ON4KST connection is closed and all windows are rebuilt with the settings and layout of the selected profile.\n\nUnsaved settings of the current profile are lost. Window sizes, divider and column widths are saved automatically.
alert.quitTitle=Quit application
alert.quitQuestion=Do you want to disconnect from the Chat?
alert.simpleLogTitle=Simplelogfile created
alert.simpleLogHeader=The selected Simplelogfile did not exist and has been created
alert.simpleLogBody=File: {0}\n\nFirst check whether you need the Simplelogfile integration. If your logging application provides a supported network interface, use that interface for band and locator information.\n\nIf you use Simplelogfile, configure your logging application to write its live log to this file. Then log a test QSO and verify that the callsign is marked as worked in KST4Contest within one minute.\n\nBefore each contest, verify that the logging application writes the current contest log to this exact file. KST4Contest does not reset Simplelogfile-derived Worked marks automatically when a new contest starts.
alert.simpleLogLink=Open the Simplelogfile manual
tooltip.bandsNone=Bands: -
tooltip.bandsPrefix=Bands:
tooltip.bandStatusLegend=Band status:\nX = worked on this band\nB+ = band available, not worked on this band yet (call already worked on another band)\na = band available, call not worked on any band yet (can be turned off in GUI settings; falls back to B+)\no = grid square already worked on this band, by any station (can be turned off in GUI settings)\n(empty) = no information for this band\no can combine with the others, e.g. "ao" or "B+o"
tooltip.bandStatusThisStation=\n\nThis station ({0}):\nStatus: {1}
tooltip.workedStatusLegend=Worked status:\nempty = call not worked, grid not worked\nx = call worked\no = grid worked\nxo = call and grid worked
tooltip.workedStatusThisStation=\n\nThis station:\nCall worked: {0}\nGrid worked: {1} ({2})
tooltip.yes=yes
tooltip.no=no
tooltip.unknownGrid=unknown grid
tooltip.grid=grid {0}
messages.public=Public messages
messages.dxCluster=DXCluster messages
messages.qsoOfTheOther=QSO of the other
chat.ownQrgTooltip=Your own frequency in the main category
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced
chat.qtf2Tooltip=Heading of antenna 2 (rotator 2, SO2R); display only
chat.secondQrgTooltipSo2r=QRG of radio 2 (from RadioNr 2 or Win-Test station 2)
list.moveDown=Move selected down
list.moveUp=Move selected up
list.clearToRemove=Clear the text of an entry to remove it.
column.lastKnownQrg=Last known QRG
alert.cannotConnectTitle=Cannot connect
alert.credentialsMissing=Login credentials missing
alert.credentialsMissingBody=Please configure your callsign and password in Settings first.
alert.connectionFailedTitle=Connection failed
alert.connectionFailedBody=Could not connect: {0}
contrast.textOnWindowSurface=Text on the window surface
contrast.textInsideField=Text inside a field
contrast.accentOnWindowSurface=Accent on the window surface
contrast.textOnMenuStrip=Text on the menu strip
gui.startInEveningDesign=Start in the evening design
gui.filterPublic=Public messages
gui.filterPmToMe=Private messages to me
gui.filterPmToOthers=Private messages to others
gui.filterNothing=No filter
gui.hintFreshCall=Mark a callsign seen for the first time
gui.hintLargeGridWorked=Mark a worked large grid field
gui.rememberWindowPositions=Reopen windows across restarts where they were last moved to (same screen only)
form.choose=Choose...
form.notSelected=not selected
dialog.apply=Apply
chat.clear=clear
selected.showOnMap=🗺 Show on map
selected.notQrvAll=all
column.category=Category
column.sender=Sender
column.receiver=Receiver
column.lastQrg=Last QRG
column.airplane=AP [minutes / pot%]
column.score=Score
monitor.title=Cluster & QSO of the other
monitor.dxCluster=DX cluster
profiles.createFailed=The profile could not be created. The profile registry could not be written.
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
profiles.duplicateFailed=The profile could not be duplicated.
profiles.rootNotDeletable=The default profile uses the files of the installation itself and cannot be deleted.
profiles.activeNotDeletable=The profile currently in use cannot be deleted. Switch to another profile first.
profiles.deleteFailed=The profile could not be deleted.
profiles.deletionFolder=The following folder is removed permanently:\n{0}
profiles.deletionKeepsShared=The common station worked stations are not touched.
profiles.deletionRemovesOwn=The worked stations of this profile are deleted as well.
profiles.rootAlwaysShared=The default profile always uses the common station worked stations, because that database is the one of the installation itself.
profiles.changeAfterSwitch=The change takes effect after switching to this profile again.
profiles.alreadyActive=This profile is already active.
profiles.selectFirst=Select a profile in the table first.
beacon.invalidMessage=The beacon message is invalid: {0}
beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minute.
notification.invalidPort="{0}" is not a valid TCP port. Enter a value from 1 to 65535.
notification.invalidSpotter="{0}" is not a valid spotter callsign.
notification.invalidCallsign=Please enter a valid callsign.
notification.alreadyMonitored=This base callsign is already in the monitoring list.
settings.windowTitle=Change Client Settings
settings.noticeInfo=Info
settings.noticeProblem=Settings
profiles.unknown=unknown
profiles.copyOf=Copy of {0}
profiles.sharedDatabase=common station database
profiles.ownDatabase=own database
@@ -0,0 +1,101 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Owns the lifecycle of the running application.
*
* <p>Switching the operator profile tears the current runtime down completely and builds
* a fresh one in the same process. Rebinding the existing windows is not an option: the
* user interface is built from the chat controller outwards, with several hundred
* references to the active preferences, and many controls are instance fields created
* once. A new {@link Kst4ContestApplication} instance gets fresh controls, which is safe
* here because the class keeps no mutable static state.</p>
*/
public final class ApplicationRuntimeLauncher {
private static final Logger LOGGER = Logger.getLogger(ApplicationRuntimeLauncher.class.getName());
private static Kst4ContestApplication currentRuntime;
private ApplicationRuntimeLauncher() {
// Utility class.
}
/**
* Registers the runtime that is currently live.
*
* @param runtime the running application instance
*/
public static void setCurrent(final Kst4ContestApplication runtime) {
currentRuntime = runtime;
}
/**
* Returns the runtime that is currently live.
*
* @return the running application instance, or null before the first startup
*/
public static Kst4ContestApplication getCurrent() {
return currentRuntime;
}
/**
* Shuts the application down.
*
* <p>Releases the live runtime's resources explicitly: after a profile switch that is
* no longer the instance the process started with.</p>
*/
public static void exitApplication() {
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
System.exit(0);
}
/**
* Replaces the running runtime with one bound to another operator profile.
*
* @param targetProfile profile to activate
* @return true if the new runtime was built
*/
public static boolean switchProfile(final OperatorProfile targetProfile) {
if (targetProfile == null) {
return false;
}
new OperatorProfileStore().recordLastUsed(targetProfile.getProfileId());
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
ActiveOperatorProfile.set(OperatorProfilePaths.resolve(targetProfile));
Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
try {
nextRuntime.startRuntime();
} catch (Exception e) {
// The previous runtime is already gone, so there is nothing left to return to.
LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
Kst4ContestApplication.alertWindowEvent(
"The operator profile could not be started: " + e.getMessage()
+ "\n\nKST4Contest has to be closed.");
System.exit(1);
return false;
}
setCurrent(nextRuntime);
return true;
}
}
@@ -0,0 +1,41 @@
package kst4contest.view;
import kst4contest.observe.UiDispatcher;
import java.awt.EventQueue;
/**
* The {@link UiDispatcher} backed by the AWT event dispatch thread.
*
* <p>That is the thread Compose Desktop composes on, so this is where user-interface
* state belongs. The contract is the one {@code JavaFxUiDispatcher} had, down to the
* inline execution: a task handed over from the user-interface thread runs before
* {@link #runOnUi(Runnable)} returns rather than queueing behind work that is already
* pending. Several callers in {@code ChatController} ask {@link #isUiThread()} first and
* rely on exactly that.</p>
*/
public class AwtUiDispatcher implements UiDispatcher {
@Override
public void runOnUi(Runnable task) {
if (EventQueue.isDispatchThread()) {
runGuarded(task);
} else {
EventQueue.invokeLater(() -> runGuarded(task));
}
}
@Override
public boolean isUiThread() {
return EventQueue.isDispatchThread();
}
private static void runGuarded(Runnable task) {
try {
task.run();
} catch (RuntimeException e) {
// Must not tear down the AWT event dispatch thread.
System.err.println("[observe] UI task failed: " + e);
}
}
}
@@ -0,0 +1,45 @@
package kst4contest.view;
import java.util.Objects;
/**
* Collapses a burst of requests into one run, a fixed delay after the last of them.
*
* <p>Replaces the JavaFX pause transition the station list used. A
* {@code javax.swing.Timer} fires on the AWT event thread, which is where the user
* interface lives, and {@code restart()} means exactly what that class's
* {@code playFromStart()} meant. The old one needed a running JavaFX toolkit, which the
* application no longer starts.</p>
*/
public final class CoalescingTrigger {
private final DelayTimer timer;
/**
* @param delayMs how long to wait after the last request
* @param action runs on the user-interface thread once the delay elapses
*/
public CoalescingTrigger(final int delayMs, final Runnable action) {
this(delayMs, action, DelayTimer.SWING);
}
/**
* Test seam: the same trigger on a timer whose firing the caller controls.
*
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
*/
CoalescingTrigger(final int delayMs, final Runnable action, final DelayTimer.Factory timers) {
Objects.requireNonNull(action, "action");
this.timer = timers.create(delayMs, action);
}
/** Requests a run, restarting the delay. */
public void trigger() {
timer.restart();
}
/** Drops a pending run. Idempotent, and the trigger stays usable afterwards. */
public void cancel() {
timer.stop();
}
}
@@ -0,0 +1,133 @@
package kst4contest.view;
import java.util.List;
/**
* Command line options of the application.
*
* <p>The parsed value is additionally kept in a process wide holder. JavaFX only knows
* the parameters of the {@code Application} instance it launched itself, so an instance
* created during a profile switch would see no parameters at all. Parsing once at
* startup and remembering the result avoids that entirely.</p>
*/
public class CommandLineOptions {
/**
* Command line switch selecting the operator profile to start with.
*/
public static final String PROFILE_ARGUMENT = "--profile";
/**
* System property used as an alternative to the command line switch.
*/
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
/**
* Opens the Compose main window beside the JavaFX one.
*
* <p>Off unless asked for. The two are meant to run side by side while the operator
* compares them against a live session; until that comparison is done, an ordinary start
* must behave exactly as it did.</p>
*/
public static final String COMPOSE_MAIN_WINDOW_ARGUMENT = "--compose-main-window";
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
private final String requestedProfileName;
private final boolean composeMainWindowRequested;
public CommandLineOptions(final String requestedProfileName) {
this(requestedProfileName, false);
}
public CommandLineOptions(
final String requestedProfileName,
final boolean composeMainWindowRequested) {
this.composeMainWindowRequested = composeMainWindowRequested;
this.requestedProfileName = requestedProfileName;
}
/**
* Parses the raw application arguments.
*
* <p>Unknown arguments are ignored on purpose. A typo in a command line must never
* keep an operator out of the application shortly before a contest.</p>
*
* @param rawArguments raw arguments, may be null
* @return the parsed options
*/
public static CommandLineOptions parse(final List<String> rawArguments) {
String requestedProfileName = null;
boolean composeMainWindow = false;
if (rawArguments != null) {
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
String currentArgument = rawArguments.get(argumentIndex);
if (currentArgument == null) {
continue;
}
if (COMPOSE_MAIN_WINDOW_ARGUMENT.equals(currentArgument)) {
composeMainWindow = true;
} else if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
} else if (PROFILE_ARGUMENT.equals(currentArgument)
&& argumentIndex + 1 < rawArguments.size()) {
requestedProfileName = rawArguments.get(argumentIndex + 1);
argumentIndex++;
}
}
}
if (requestedProfileName == null || requestedProfileName.isBlank()) {
requestedProfileName = System.getProperty(PROFILE_SYSTEM_PROPERTY);
}
if (requestedProfileName != null && requestedProfileName.isBlank()) {
requestedProfileName = null;
}
return new CommandLineOptions(
requestedProfileName == null ? null : requestedProfileName.trim(),
composeMainWindow);
}
/**
* Stores the parsed options for the lifetime of the process.
*
* @param options options to remember
*/
public static void remember(final CommandLineOptions options) {
rememberedOptions = options == null ? new CommandLineOptions(null) : options;
}
/**
* Returns the options parsed at application startup.
*
* @return the remembered options, never null
*/
public static CommandLineOptions remembered() {
return rememberedOptions;
}
/**
* Returns the operator profile requested on the command line.
*
* @return the requested profile name, or null when none was given
*/
/**
* Whether the Compose main window should be opened beside the JavaFX one.
*
* @return {@code true} only when --compose-main-window was passed
*/
public boolean isComposeMainWindowRequested() {
return composeMainWindowRequested;
}
public String getRequestedProfileName() {
return requestedProfileName;
}
}
@@ -0,0 +1,100 @@
package kst4contest.view;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.view.compose.ChatInputActions;
/**
* What the Compose send line needs from the runtime.
*
* <p>Every method forwards to the same code the JavaFX send button runs, so that while the two
* windows sit side by side a message sent from either takes exactly one path. The order the
* steps are called in lives in {@code ChatInputState}, where it is tested against the
* original; this class only answers questions.</p>
*/
public final class ComposeChatInputActions implements ChatInputActions {
private final Kst4ContestApplication application;
public ComposeChatInputActions(final Kst4ContestApplication application) {
this.application = application;
}
@Override
public ChatMember panelSelection() {
return application.selectedCallSignInfoStageChatMember;
}
@Override
public ChatMember tableSelection() {
/*
* The JavaFX station table is gone. Kept rather than dropped from the interface
* because ChatInputState's three-step lookup is the documented selection order, and
* a Compose station selection may fill this in again.
*/
return null;
}
@Override
public ChatMember scoreSelection() {
if (application.chatcontroller == null
|| application.chatcontroller.getScoreService() == null) {
return null;
}
return application.chatcontroller.getScoreService().getSelectedChatMember();
}
@Override
public String resolveVariables(final String template, final ChatMember member) {
if (application.messageVariableResolver == null) {
return template;
}
return application.messageVariableResolver.resolveForSelectedStation(template, member);
}
@Override
public boolean isAddressedToOwnCallsign(final String text) {
return application.isMessageAddressedToOwnCallsignFromCompose(text);
}
@Override
public ChatCategory resolveCategory(final String text, final ChatMember member) {
return application.resolveOutgoingChatCategoryFromCompose(text, member);
}
@Override
public ChatCategory mainCategory() {
return application.chatcontroller.getChatCategoryMain();
}
@Override
public void recordOutboundCq(final String text) {
application.chatcontroller.getStationMetricsService()
.tryRecordOutboundCq(text, System.currentTimeMillis());
}
@Override
public void requestRecompute(final String reason) {
if (application.chatcontroller.getScoreService() != null) {
application.chatcontroller.getScoreService().requestRecompute(reason);
}
}
@Override
public void queue(final ChatMessage message) {
application.chatcontroller.getMessageTXBus().add(message);
}
@Override
public String ownQrgMain() {
String value = application.chatcontroller.getChatPreferences().getMYQRGFirstCat().get();
return value == null ? "" : value;
}
@Override
public String ownQrgSecond() {
String value = application.chatcontroller.getChatPreferences().getMYQRGSecondCat().get();
return value == null ? "" : value;
}
}
@@ -0,0 +1,115 @@
package kst4contest.view;
import kst4contest.view.compose.MainMenuActions;
/**
* The Compose menu bar's actions, delegated to methods on {@link Kst4ContestApplication}.
*
* <p>These used to fire the {@code MenuItem}s of the old JavaFX menu bar,
* which ran side by side with the Compose bar so the two could be compared. The JavaFX bar is
* gone (part 3), so the handlers now live in {@code menuAction…} methods of the application and
* this class calls those directly. It stays a thin bridge: no behaviour of its own, one call per
* menu entry.</p>
*/
public final class ComposeMenuActions implements MainMenuActions {
private final Kst4ContestApplication application;
public ComposeMenuActions(final Kst4ContestApplication application) {
this.application = application;
}
@Override
public void connect() {
application.runOnUi(application::menuActionConnect);
}
@Override
public void disconnect() {
application.runOnUi(application::menuActionDisconnect);
}
@Override
public void switchOperatorProfile() {
application.runOnUi(application::showOperatorProfileSwitchDialogFromCompose);
}
@Override
public void exitApplication() {
application.runOnUi(application::menuActionExit);
}
@Override
public void setQrgAsNameInChat() {
application.runOnUi(application::menuActionSetQrgAsNameInChat);
}
@Override
public void toggleAwayState() {
application.runOnUi(application::menuActionToggleAwayState);
}
@Override
public void toggleSettingsWindow() {
application.runOnUi(application::menuActionToggleSettingsWindow);
}
@Override
public void toggleMonitorWindow() {
application.runOnUi(application::menuActionToggleMonitorWindow);
}
@Override
public void toggleStationMap() {
application.runOnUi(application::menuActionToggleStationMap);
}
@Override
public void useDarkDesign() {
application.runOnUi(application::menuActionUseDarkDesign);
}
@Override
public void useDefaultDesign() {
application.runOnUi(application::menuActionUseDefaultDesign);
}
@Override
public void openDonationPage() {
application.runOnUi(application::menuActionOpenDonationPage);
}
@Override
public void openHomepage() {
application.runOnUi(application::menuActionOpenHomepage);
}
@Override
public void openNewsgroup() {
application.runOnUi(application::menuActionOpenNewsgroup);
}
@Override
public void openChangelog() {
/*
* The JavaFX item and its handler were both commented out, so there is nothing to do.
* The method stays because the interface is shared and an empty body is honest about
* there being no such feature.
*/
}
@Override
public void openOv3tDonationPage() {
application.runOnUi(application::menuActionOpenOv3tDonationPage);
}
@Override
public void contactAuthor() {
application.runOnUi(application::menuActionContactAuthor);
}
@Override
public void showAbout() {
application.runOnUi(application::menuActionShowAbout);
}
}
@@ -0,0 +1,58 @@
package kst4contest.view;
import javax.swing.Timer;
import java.util.Objects;
/**
* A one-shot delay that can be restarted and stopped.
*
* <p>The thin seam between the two coalescing classes in this package and the
* {@code javax.swing.Timer} they run on. In production the Swing timer is the only
* implementation; tests substitute one whose firing they control, so "nothing ran" can
* be asserted without waiting for a delay that might not have elapsed yet.</p>
*/
interface DelayTimer {
/** Starts the delay, or starts it over if it is already running. */
void restart();
/** Drops a pending run. Idempotent. */
void stop();
/** Creates one-shot timers for a fixed delay and action. */
interface Factory {
/**
* @param delayMs how long the timer waits after a {@link #restart()}
* @param onFire what runs once the delay elapses
*/
DelayTimer create(int delayMs, Runnable onFire);
}
/**
* The production factory: a {@code javax.swing.Timer}, which fires on the AWT event
* thread where the user interface lives.
*/
Factory SWING = (delayMs, onFire) -> new Swing(delayMs, onFire);
/** {@code javax.swing.Timer} behind the interface. */
final class Swing implements DelayTimer {
private final Timer timer;
Swing(final int delayMs, final Runnable onFire) {
Objects.requireNonNull(onFire, "onFire");
this.timer = new Timer(delayMs, event -> onFire.run());
this.timer.setRepeats(false);
}
@Override
public void restart() {
timer.restart();
}
@Override
public void stop() {
timer.stop();
}
}
}
@@ -0,0 +1,75 @@
package kst4contest.view;
import java.awt.Desktop;
import java.net.URI;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Opens an address in whatever the desktop uses for it.
*
* <p>Replaces {@code Application.getHostServices().showDocument(...)}, which went away
* with {@code extends Application}. JavaFX treated web and mail addresses alike; AWT does
* not, so the scheme is decided here: {@code Desktop.mail} for mail, {@code Desktop.browse}
* for everything else. A {@code browse("mailto:...")} silently opens nothing on Linux.</p>
*/
public final class ExternalDocuments {
private static final Logger LOGGER = Logger.getLogger(ExternalDocuments.class.getName());
private static final String MAIL_SCHEME = "mailto:";
private ExternalDocuments() {
// Utility class.
}
/**
* Whether the address names a mail recipient rather than a document.
*
* @param address the address to classify, may be null
* @return true when the address carries the mailto scheme
*/
public static boolean isMailAddress(final String address) {
if (address == null) {
return false;
}
return address.trim().toLowerCase(Locale.ROOT).startsWith(MAIL_SCHEME);
}
/**
* Opens the address, or logs why it could not be opened.
*
* <p>A failure here is never worth terminating anything: the operator asked for a web
* page, not for a state change.</p>
*
* @param address an http/https address or a mailto address, may be null
*/
public static void open(final String address) {
if (address == null || address.isBlank()) {
return;
}
try {
URI target = URI.create(address.trim());
if (!Desktop.isDesktopSupported()) {
LOGGER.log(Level.WARNING, "No desktop integration available for {0}", address);
return;
}
Desktop desktop = Desktop.getDesktop();
if (isMailAddress(address)) {
desktop.mail(target);
} else {
desktop.browse(target);
}
} catch (Exception e) {
LOGGER.log(Level.WARNING, "Could not open " + address, e);
}
}
}
@@ -1,14 +1,19 @@
package kst4contest.view;
import kst4contest.controller.ChatController;
import kst4contest.model.ChatMember;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.awt.EventQueue;
public class GuiUtils {
/*
* The application icon used to live here as a JavaFX image applied to a Stage. There is
* no Stage left: every window is a Compose window and loads the same resource itself in
* ComposeWindowHost.applicationIcon().
*/
private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$";
/**
* Checks wheter the input value of the String is numeric or not, true if yes
@@ -25,7 +30,7 @@ public class GuiUtils {
* @param maybeCallSignValue
* @return true if yes
*/
static boolean isCallSignSyntax(String maybeCallSignValue) {
public static boolean isCallSignSyntax(String maybeCallSignValue) {
Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(maybeCallSignValue);
@@ -44,10 +49,10 @@ public class GuiUtils {
public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) {
if (javafx.application.Platform.isFxApplicationThread()) {
if (EventQueue.isDispatchThread()) {
triggerUpdate(chatController);
} else{
javafx.application.Platform.runLater(() -> triggerUpdate(chatController));
} else {
EventQueue.invokeLater(() -> triggerUpdate(chatController));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,83 @@
package kst4contest.view;
import kst4contest.observe.UiDispatcher;
import java.util.Objects;
/**
* Coalesces layout changes into selective preferences writes.
*
* <p>Dragging a column divider produces a stream of change events; writing the
* preferences file on each would make the drag stutter. The write is therefore delayed
* and restarted on every request, so a burst ends in exactly one write.</p>
*
* <p>The class knows nothing about where the layout goes: it takes the write action, not
* the preferences. That is what lets the debounce be tested without touching the
* operator's profile on disk.</p>
*/
public final class LayoutAutosave {
private static final int SAVE_DELAY_MS = 750;
private final Runnable writeLayout;
private final UiDispatcher uiDispatcher;
private final DelayTimer saveDelay;
private boolean pending;
/**
* @param writeLayout persists the layout; called on the user-interface thread
* @param uiDispatcher confines the pending state to one thread
*/
public LayoutAutosave(final Runnable writeLayout, final UiDispatcher uiDispatcher) {
/*
* A javax.swing.Timer and not the former JavaFX PauseTransition: it fires on the
* AWT event thread, which is where the user interface lives now, and restart()
* has the same meaning playFromStart() had. A PauseTransition would need the
* JavaFX toolkit to be running, which it no longer is.
*/
this(writeLayout, uiDispatcher, DelayTimer.SWING);
}
/**
* Test seam: the same debounce on a timer whose firing the caller controls.
*
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
*/
LayoutAutosave(
final Runnable writeLayout,
final UiDispatcher uiDispatcher,
final DelayTimer.Factory timers
) {
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
this.saveDelay = timers.create(SAVE_DELAY_MS, this::flushPending);
}
/** Asks for a save, restarting the delay. Safe to call from any thread. */
public void requestSave() {
if (!uiDispatcher.isUiThread()) {
uiDispatcher.runOnUi(this::requestSave);
return;
}
pending = true;
saveDelay.restart();
}
/** Writes a pending layout now. Does nothing when nothing is pending. */
public void flushPending() {
if (!pending) {
return;
}
saveDelay.stop();
pending = false;
writeLayout.run();
}
/** Drops a pending layout without writing it. */
public void cancelPending() {
saveDelay.stop();
pending = false;
}
}
@@ -0,0 +1,107 @@
package kst4contest.view;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import java.util.List;
import java.util.Optional;
/**
* Decides which operator profile the application starts with.
*
* <p>The class contains no user interface code so the decision can be tested headless.
* Asking the operator is delegated to an {@link OperatorProfileChoiceRequester}, and a
* problem worth telling the operator about is reported through
* {@link #getStartupWarning()} instead of being shown here.</p>
*
* <p>The most important property of this logic is what it does <em>not</em> do: an
* installation with no or exactly one profile is resolved without asking anything and
* without touching a single file, so a single operator start stays exactly as fast and
* as quiet as it was before profiles existed.</p>
*/
public class OperatorProfileBootstrap {
private String startupWarning;
/**
* Resolves the operator profile to start with.
*
* @param store registry to read the profiles from
* @param commandLineOptions parsed command line options
* @param choiceRequester requester used when the operator has to choose
* @return the resolved selection, or null when the operator chose to quit
*/
public OperatorProfileSelection resolveAtStartup(final OperatorProfileStore store,
final CommandLineOptions commandLineOptions,
final OperatorProfileChoiceRequester choiceRequester) {
startupWarning = null;
List<OperatorProfile> availableProfiles = store.loadProfiles();
String requestedProfileName = commandLineOptions == null
? null
: commandLineOptions.getRequestedProfileName();
if (requestedProfileName != null) {
OperatorProfile requestedProfile = findProfile(availableProfiles, requestedProfileName);
if (requestedProfile != null) {
return OperatorProfilePaths.resolve(requestedProfile);
}
startupWarning = "The operator profile \"" + requestedProfileName
+ "\" is unknown. KST4Contest continues with the normal profile selection.";
}
if (availableProfiles.isEmpty()) {
// No registry at all: the historic flat installation is the only profile.
return OperatorProfilePaths.resolve(store.buildImplicitRootProfile());
}
if (availableProfiles.size() == 1) {
return OperatorProfilePaths.resolve(availableProfiles.get(0));
}
String preselectedProfileId = store.loadLastUsedProfileId().orElse(null);
Optional<OperatorProfile> chosenProfile =
choiceRequester.requestProfileChoice(availableProfiles, preselectedProfileId);
return chosenProfile.map(OperatorProfilePaths::resolve).orElse(null);
}
/**
* Returns a message that should be shown to the operator after startup.
*
* @return the warning text, or null when startup was unremarkable
*/
public String getStartupWarning() {
return startupWarning;
}
/**
* Finds a profile by identifier or display name, ignoring case.
*
* @param availableProfiles profiles to search
* @param requestedName identifier or display name entered by the operator
* @return the matching profile, or null
*/
private static OperatorProfile findProfile(final List<OperatorProfile> availableProfiles,
final String requestedName) {
for (OperatorProfile currentProfile : availableProfiles) {
if (requestedName.equalsIgnoreCase(currentProfile.getProfileId())) {
return currentProfile;
}
}
for (OperatorProfile currentProfile : availableProfiles) {
if (requestedName.equalsIgnoreCase(currentProfile.getDisplayName())) {
return currentProfile;
}
}
return null;
}
}
@@ -0,0 +1,26 @@
package kst4contest.view;
import kst4contest.model.OperatorProfile;
import java.util.List;
import java.util.Optional;
/**
* Asks the operator which profile to start with.
*
* <p>The startup logic depends on this interface rather than on a dialog, so the
* decision which profile to use can be tested without a JavaFX runtime.</p>
*/
@FunctionalInterface
public interface OperatorProfileChoiceRequester {
/**
* Requests a profile choice.
*
* @param selectableProfiles profiles to choose from, never empty
* @param preselectedProfileId identifier to preselect, may be null
* @return the chosen profile, or empty when the operator wants to quit
*/
Optional<OperatorProfile> requestProfileChoice(List<OperatorProfile> selectableProfiles,
String preselectedProfileId);
}
@@ -0,0 +1,83 @@
package kst4contest.view;
import java.util.List;
/**
* Selects the CSS style class used for a private-message table row.
*/
public final class PrivateMessageRowStyleResolver {
/** Style used for messages sent by the local station. */
public static final String OWN_STYLE_CLASS =
"messageHighlightOwn-column";
/** Upper inclusive age bounds for the private-message color levels. */
private static final List<Long> AGE_LIMITS = List.of(
30L,
60L,
90L,
120L,
180L,
300L
);
/** Style classes corresponding to the configured age bounds. */
private static final List<String> AGE_STYLES = List.of(
"messageHighlight30-column",
"messageHighlight60-column",
"messageHighlight90-column",
"messageHighlight120-column",
"messageHighlight180-column",
"messageHighlight300-column"
);
/** All private-message row classes managed by the row factory. */
private static final List<String> MANAGED_STYLES = List.of(
OWN_STYLE_CLASS,
AGE_STYLES.get(0),
AGE_STYLES.get(1),
AGE_STYLES.get(2),
AGE_STYLES.get(3),
AGE_STYLES.get(4),
AGE_STYLES.get(5)
);
private PrivateMessageRowStyleResolver() {
}
/**
* Returns the complete set of private-message row classes managed by the
* row factory.
*
* @return immutable list of managed style classes
*/
public static List<String> knownStyleClasses() {
return MANAGED_STYLES;
}
/**
* Selects the private-message row class for the supplied message age.
*
* @param ownMessage whether the message was sent by the local station
* @param ageSeconds message age in seconds
* @return managed CSS class, or {@code null} after the five-minute window
*/
public static String resolveStyleClass(
final boolean ownMessage,
final long ageSeconds
) {
String styleClass = null;
if (ownMessage) {
styleClass = OWN_STYLE_CLASS;
} else {
for (int index = 0; index < AGE_LIMITS.size(); index++) {
if (ageSeconds <= AGE_LIMITS.get(index)) {
styleClass = AGE_STYLES.get(index);
break;
}
}
}
return styleClass;
}
}
@@ -0,0 +1,162 @@
package kst4contest.view;
import kst4contest.observe.ObservableRoster;
import kst4contest.observe.UiDispatcher;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* Mirrors an {@link ObservableRoster} into a plain list the user interface can read.
*
* <p>The mirror instance never changes: callers hold on to the list they were handed, so
* {@link #list()} returns the same unmodifiable view every time. {@link #dispose()}
* unregisters the listener, which is what keeps a discarded runtime from being held alive
* across a profile switch.</p>
*
* <p>Besides the plain one-to-one mirror there is a derived form: a supplier computes the
* visible content (filtered, ordered) and one or more rosters act as the change sources.
* That is what replaced the former {@code FilteredList}/{@code SortedList} chains, which
* recomputed themselves whenever their source list changed.</p>
*
* <p>Named a binding and not a mirror on purpose: {@code kst4contest.view.compose.RosterMirror}
* already exists and mirrors a {@code SimpleRoster} into Compose state. Two classes of the same
* name doing the same job in sibling packages is a reader's trap.</p>
*
* <p>This was an {@code ObservableList} while a {@code TableView} was bound to it. The
* Compose panes take a snapshot instead — {@code SelectedStationMessagesFeed.push} copies
* what it is given — so the only thing the observable collection still provided was the
* change notification, which is now {@link #onChanged(Consumer)}.</p>
*/
public final class RosterListBinding<T> {
private final List<T> mirror = new ArrayList<>();
/**
* Handed out by {@link #list()}. A field and not a fresh wrapper per call, so the
* instance a caller kept stays the instance it keeps seeing.
*/
private final List<T> readOnlyView = Collections.unmodifiableList(mirror);
private final List<Consumer<List<T>>> changeListeners = new ArrayList<>();
private final List<Runnable> unsubscribes = new ArrayList<>();
private final Supplier<List<T>> derivation;
private final UiDispatcher dispatcher;
private volatile boolean disposed;
private RosterListBinding(Supplier<List<T>> derivation, UiDispatcher dispatcher) {
this.derivation = derivation;
this.dispatcher = dispatcher;
}
/** Mirrors the roster one to one. */
public static <T> RosterListBinding<T> mirror(ObservableRoster<T> source, UiDispatcher dispatcher) {
RosterListBinding<T> binding = new RosterListBinding<>(source::snapshot, dispatcher);
binding.subscribe(source);
binding.applyNow();
return binding;
}
/**
* Mirrors whatever {@code derivation} computes and recomputes it whenever one of the
* given rosters changes.
*
* @param dispatcher hands the mirror update to the user-interface thread
* @param derivation computes the visible content; called on the UI thread
* @param sources rosters whose changes trigger a recomputation
*/
public static <T> RosterListBinding<T> derived(UiDispatcher dispatcher,
Supplier<List<T>> derivation,
ObservableRoster<?>... sources) {
RosterListBinding<T> binding = new RosterListBinding<>(derivation, dispatcher);
for (ObservableRoster<?> source : sources) {
binding.subscribe(source);
}
binding.applyNow();
return binding;
}
/**
* Registers a listener for real content changes.
*
* <p>Replaces the {@code ListChangeListener} a {@code TableView} used to attach. It is
* called on the user-interface thread, with an immutable snapshot, and only when the
* content actually changed — see {@link #applyNow()}.</p>
*/
public void onChanged(Consumer<List<T>> listener) {
changeListeners.add(Objects.requireNonNull(listener, "listener"));
}
/**
* Recomputes the mirror. Needed when something other than a source roster changed the
* result, for example a newly set filter predicate.
*/
public void refresh() {
if (disposed) {
return;
}
dispatcher.runOnUi(this::applyNow);
}
/** The list to read. Always the same instance, and not modifiable by the caller. */
public List<T> list() {
return readOnlyView;
}
/** Unregisters from every source and empties the mirror. Idempotent. */
public void dispose() {
if (disposed) {
return;
}
disposed = true;
for (Runnable unsubscribe : unsubscribes) {
unsubscribe.run();
}
unsubscribes.clear();
changeListeners.clear();
mirror.clear();
}
private <S> void subscribe(ObservableRoster<S> source) {
Consumer<List<S>> listener = snapshot -> refresh();
source.addListener(listener);
unsubscribes.add(() -> source.removeListener(listener));
}
private void applyNow() {
if (disposed) {
return;
}
List<T> computed = derivation.get();
/*
* An unchanged result must not notify. The selected-station message pane replaces
* its rows on every notification, and the periodic station-list refresh would
* otherwise fight the operator for the selected row on every tick.
*
* This comparison also breaks a cycle: a consumer that reacts by asking for a
* refresh would land here again, and notifying unconditionally would ask it to
* react again. Once the content is equal the chain stops.
*
* It rests on element identity. ChatMember declares equals(ChatMember) — an
* overload, not an override of equals(Object) — so List.equals falls back to
* reference comparison. Turning that overload into a real override would make two
* different rows with the same callsign compare equal here, and a genuine change
* could be dropped. See ChatMember.
*/
if (mirror.equals(computed)) {
return;
}
mirror.clear();
mirror.addAll(computed);
List<T> snapshot = List.copyOf(mirror);
for (Consumer<List<T>> listener : changeListeners) {
listener.accept(snapshot);
}
}
}
@@ -0,0 +1,60 @@
package kst4contest.view;
import java.awt.GraphicsEnvironment;
import java.awt.Rectangle;
/**
* The screen area a window may actually use.
*
* <p>Replaces the former JavaFX screen lookup, which asked the primary screen for its
* visual bounds. {@code GraphicsEnvironment.getMaximumWindowBounds()} is the AWT
* equivalent: it excludes task bars and docks, which is exactly why the old code used the
* visual bounds rather than the raw bounds.</p>
*/
public final class ScreenBounds {
/** Used when there is no display at all, so a window still gets a usable size. */
public static final double FALLBACK_WIDTH = 1234.0;
/** Used when there is no display at all, so a window still gets a usable size. */
public static final double FALLBACK_HEIGHT = 768.0;
private ScreenBounds() {
// Utility class.
}
/**
* The usable width of the primary screen.
*
* @return the width in pixels, or {@link #FALLBACK_WIDTH} without a display
*/
public static double availableWidth() {
return widthOf(maximumWindowBounds());
}
/**
* The usable height of the primary screen.
*
* @return the height in pixels, or {@link #FALLBACK_HEIGHT} without a display
*/
public static double availableHeight() {
return heightOf(maximumWindowBounds());
}
static double widthOf(final Rectangle bounds) {
return bounds == null || bounds.width <= 0 ? FALLBACK_WIDTH : bounds.width;
}
static double heightOf(final Rectangle bounds) {
return bounds == null || bounds.height <= 0 ? FALLBACK_HEIGHT : bounds.height;
}
private static Rectangle maximumWindowBounds() {
if (GraphicsEnvironment.isHeadless()) {
return null;
}
return GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
}
}
@@ -0,0 +1,45 @@
package kst4contest.view;
/**
* The column-width rule the station and monitor tables are sized by.
*
* <p>What is left of the class that applied persisted leaf-column widths to a
* {@code TableView} and performed one content-based initial sizing pass. The tables are
* Compose now: {@code DataTableState} applies the stored widths and
* {@code ColumnWidthStore} persists them, so the installation, the resize detection and
* the measuring all went with the JavaFX table.</p>
*
* <p>The sizing rule itself did not. It survives because it is the one piece that decides
* what the operator sees on a first run, and because several Compose column definitions
* still name this class as the place their column ids were registered — a reader who
* follows that reference should find the rule, not a gap.</p>
*/
public final class TableLayoutManager {
/** Room for the cell's left and right padding, on top of the measured text. */
private static final double CELL_HORIZONTAL_PADDING = 16.0;
private TableLayoutManager() {
}
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.
*
* @param measuredWidth width the content needs without padding
* @param minimum narrowest acceptable column width
* @param maximum widest width an initial sizing pass may choose
* @return the width to use, padding included and clamped into range
*/
@SuppressWarnings("PMD.CommentDefaultAccessModifier")
static double calculateInitialContentWidth(
final double measuredWidth,
final double minimum,
final double maximum
) {
return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum);
}
}
@@ -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;
}
}
@@ -0,0 +1,18 @@
package kst4contest.view
/**
* Build identity shown in window titles and in the about text.
*
* Reads the version the Gradle build stamped into the jar manifest and falls
* back to a marker that is obviously not a release, so an unstamped build can
* never be mistaken for one.
*/
object BuildInfo {
const val UNKNOWN_VERSION: String = "dev-unstamped"
val version: String
get() = BuildInfo::class.java.`package`?.implementationVersion ?: UNKNOWN_VERSION
fun windowTitle(base: String): String = "$base $version"
}
@@ -0,0 +1,83 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
/**
* The aircraft-reflection cell, shared by the station list and the directed messages.
*
* Both JavaFX tables built this text by hand and identically, which is why it lives in
* one place now: minutes until the aeroplane is in the path, and how promising that path
* is, for at most the first two.
*/
object AirplaneCell {
/** How many fit in the column before it stops being readable. */
private const val SHOWN_AIRPLANES = 2
fun format(info: AirPlaneReflectionInfo?): String {
// Nothing computed for this station yet. Both JavaFX cells ended up blank here, by
// different routes: the directed-message one catches the NullPointerException, the
// station-list one has no guard at all and lets it escape the value factory.
val planes = info?.risingAirplanes ?: return if (info == null) "" else NOTHING_COMING
if (planes.isEmpty()) return NOTHING_COMING
return planes.take(SHOWN_AIRPLANES).joinToString(" / ") { plane ->
"${plane.arrivingDurationMinutes} (${plane.potential}%)"
}
}
/**
* Not an empty cell: the operator has to tell "no aeroplane is coming" apart from
* "nothing has been computed for this station yet".
*/
private const val NOTHING_COMING = "nil"
// ---- the order ----------------------------------------------------------
/** Unknown or no aeroplane sorts after every real one. */
private const val NO_PLANE = Int.MAX_VALUE
/** The aeroplane the cell shows first; the parser already put the best one there. */
private fun first(info: AirPlaneReflectionInfo?): AirPlane? = info?.risingAirplanes?.firstOrNull()
/**
* Ascending: the minutes the operator reads first, then the better potential first.
*
* Issue #87: without a comparator the table fell back to the rendered text, which put
* "13 (75%)" above "3 (50%)". Only the first aeroplane counts, as it is the one the
* operator reads and the one the colour marks; the second is in the cell for context.
* Empty and "nil" cells carry [NO_PLANE]: like qrb and qtf, an absent value gets a
* sentinel instead of a null check, here the maximum so the cell sorts last ascending.
*/
val order: Comparator<AirPlaneReflectionInfo?> =
compareBy<AirPlaneReflectionInfo?> { first(it)?.arrivingDurationMinutes ?: NO_PLANE }
.thenByDescending { first(it)?.potential ?: 0 }
/** `.table-cell-100PercentAP`, identical in both stylesheets: a certain path. */
val PURPLE_100 = Color(0xFFF98AFF)
/** `.table-cell-75PercentAP`: a good one. */
val RED_75 = Color(0xFFFA6666)
/** `.table-cell-50PercentAP`: a fair one. */
val ORANGE_50 = Color(0xFFFA9F66)
/**
* The colour of the cell, which is what an aircraft-scatter chance is spotted by — the
* numbers are read afterwards.
*
* Pinned as found: the JavaFX cell factories matched the RENDERED STRING, so only
* exactly 100, 75 and 50 are coloured and 85% gets nothing. Colouring by threshold
* instead would light up cells the operator has never seen lit, which changes what the
* column means at a glance.
*/
fun accentFor(renderedValue: String): CellAccent? = when {
renderedValue.contains("100%") -> CellAccent(foreground = PURPLE_100)
renderedValue.contains("75%") -> CellAccent(foreground = RED_75)
renderedValue.contains("50%") -> CellAccent(foreground = ORANGE_50)
else -> null
}
}
@@ -0,0 +1,325 @@
package kst4contest.view.compose
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatPreferences
/**
* The line the operator types into, and the shortcut buttons above it.
*
* This is the busiest strip of the window: everything that leaves this station passes
* through it. Two details matter more here than anywhere else — the field keeps the
* focus, and the caret stays at the end. A button that swallows the focus mid sentence
* costs a QSO.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ShortcutButtonRow(
shortcuts: List<String>,
state: ChatInputState,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp),
horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
shortcuts.forEach { shortcut ->
when (shortcut) {
// The two frequency buttons take their text straight from the field beside
// them rather than through the variable resolver, which is why they are
// not ordinary shortcuts.
MYQRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
state.appendOwnQrg(mainCategory = true)
}
SECOND_QRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
state.appendOwnQrg(mainCategory = false)
}
else -> ShortcutButton(
label = shortcut,
accented = shortcut == SET_NAME_MYQRG_SHORTCUT,
) {
state.appendShortcut(shortcut)
}
}
}
}
}
/** The two names the shortcut roster gives special meaning to. */
const val MYQRG_SHORTCUT = "MYQRG"
const val SECOND_QRG_SHORTCUT = "SECONDQRG"
/** Accented like the two frequency buttons, but an ordinary appending shortcut otherwise. */
const val SET_NAME_MYQRG_SHORTCUT = "/SETNAME MYQRG"
/**
* A shortcut button.
*
* Three shortcuts wear `.buttonMyQrg1` in both stylesheets — the two frequency buttons and
* "/SETNAME MYQRG" — the same green-to-lightgreen gradient the palette already reads as the
* accent, so they are told apart by colour rather than by position. Everything else is an
* ordinary button.
*/
@Composable
private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit) {
if (!accented) {
Form.button(text = label, onClick = onClick)
return
}
val palette = LocalJavaFxPalette.current
Box(
modifier = Modifier
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
.background(
brush = if (palette.windowBackground.luminance() < 0.5f) {
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
)
} else {
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
)
},
shape = Density.BUTTON_SHAPE
)
.clickable { onClick() }
.padding(Density.BUTTON_CONTENT_PADDING),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = MYQRG_LABEL_COLOR,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
}
/** `-fx-text-fill: #395306` from `.buttonMyQrg1`; a dark olive that reads on the green. */
private val MYQRG_LABEL_COLOR = Color(0xFF395306)
/**
* The send row: the message field, TX, clear, and the three small fields the operator
* keeps their own frequencies and antenna heading in.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatInputRow(
state: ChatInputState,
prefs: ChatPreferences,
/**
* The three fields' live values. Mirrors and not preference reads: the rotator and the
* frequency follower move these while the operator is looking at them, and a plain read
* would draw the value once and then never again.
*/
ownQrgMain: ObservedValue<String>,
ownQrgSecond: ObservedValue<String>,
antennaQtf: ObservedValue<Double>,
antennaQtf2: ObservedValue<Double>,
so2rEnabled: Boolean,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
// Every insertion bumps focusRequests; this is requestFocus() + selectEnd().
LaunchedEffect(state.focusRequests) {
if (state.focusRequests > 0) {
runCatching { focusRequester.requestFocus() }
}
}
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
MessageField(
state = state,
focusRequester = focusRequester,
modifier = Modifier.weight(1f),
)
Form.button(text = "TX", onClick = { state.send() })
Form.button(text = kst4contest.view.i18n.LocalStrings.current.chatClear, onClick = { state.clear() })
VerticalRule()
// Commit on focus loss, as in JavaFX: typing a frequency should not write the
// profile on every keystroke.
InlineCommittedField(
stored = ownQrgMain.value.orEmpty(),
onCommit = { prefs.getMYQRGFirstCat().set(it) },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatOwnQrgTooltip,
)
InlineCommittedField(
stored = ownQrgSecond.value.orEmpty(),
onCommit = { prefs.getMYQRGSecondCat().set(it) },
tooltip = if (so2rEnabled) {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
} else {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip
},
)
InlineCommittedField(
stored = antennaQtf.value?.toString().orEmpty(),
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
)
if (so2rEnabled) {
// Display only: rotator 2 writes it, the operator does not.
InlineCommittedField(
stored = antennaQtf2.value?.takeIf { it.isFinite() }?.toString().orEmpty(),
onCommit = { },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
editable = false,
)
}
}
}
/**
* One of the three narrow fields beside the send controls.
*
* Written back when the focus leaves, not on every keystroke — the JavaFX fields hung
* their write on focusedProperty for the same reason: a half-typed frequency is not a
* frequency, and the profile is a file.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun InlineCommittedField(
stored: String,
onCommit: (String) -> Unit,
tooltip: String,
editable: Boolean = true,
) {
var draft by remember(stored) { mutableStateOf(stored) }
TooltipArea(tooltip = { TooltipChip(tooltip) }) {
CompactTextField(
value = draft,
onValueChange = { draft = it },
enabled = editable,
modifier = Modifier
.width(QRG_FIELD_WIDTH)
.onFocusChanged { focus ->
if (!focus.isFocused && draft != stored) onCommit(draft)
},
)
}
}
@Composable
private fun TooltipChip(text: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
shape = Density.BUTTON_SHAPE,
tonalElevation = 4.dp,
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
private val QRG_FIELD_WIDTH = 70.dp
/**
* The message field.
*
* Enter sends and is consumed, so the newline never reaches the text. Escape clears —
* both were on the scene in JavaFX, which means they worked wherever the focus was; here
* they are on the field, and the window handles them for the rest of the surface.
*/
@Composable
private fun MessageField(
state: ChatInputState,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.heightIn(min = Density.FIELD_MIN_HEIGHT)
.border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
.background(scheme.surface, Density.BUTTON_SHAPE)
.padding(horizontal = 6.dp, vertical = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = state.value,
onValueChange = { state.value = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.Enter, Key.NumPadEnter -> { state.send(); true }
Key.Escape -> { state.clear(); true }
else -> false
}
},
singleLine = true,
textStyle = LocalTextStyle.current.copy(color = scheme.onSurface),
cursorBrush = SolidColor(scheme.primary),
)
}
}
/** The vertical separator that sets the frequency fields apart from the send controls. */
@Composable
private fun VerticalRule() {
Box(
Modifier
.width(Density.HAIRLINE)
.height(Density.FIELD_MIN_HEIGHT)
.background(MaterialTheme.colorScheme.outline),
)
}
@@ -0,0 +1,199 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
/**
* Everything the send line needs from the rest of the application.
*
* An interface rather than a handful of lambdas because there are twelve of them and
* they belong together; the implementation is the runtime, the test implements it with
* recording doubles.
*/
interface ChatInputActions {
/** The station shown in the info panel, set by clicking one. */
fun panelSelection(): ChatMember?
fun tableSelection(): ChatMember?
fun scoreSelection(): ChatMember?
/** Replaces %variables% in a template; may legitimately return blank. */
fun resolveVariables(template: String, member: ChatMember?): String?
/** Whether an outgoing private message targets the local station. */
fun isAddressedToOwnCallsign(text: String): Boolean
/** Which chat the message belongs in; the /cq target in the text outranks the selection. */
fun resolveCategory(text: String, member: ChatMember?): ChatCategory?
fun mainCategory(): ChatCategory?
fun recordOutboundCq(text: String)
fun requestRecompute(reason: String)
/** Hands the message to the TX queue; the existing mechanism sends it. */
fun queue(message: ChatMessage)
fun ownQrgMain(): String
fun ownQrgSecond(): String
}
/**
* The line the operator types into, and the buttons around it.
*
* Three things go into this field, and they behave differently on purpose. A shortcut
* button APPENDS, so several can be strung together into one message. A snippet
* REPLACES, because it builds a whole directed message and a half-typed remainder would
* only corrupt it. Typing is typing.
*/
class ChatInputState(private val actions: ChatInputActions) {
/** Text and caret together, because every insertion also moves the caret. */
var value: TextFieldValue by mutableStateOf(TextFieldValue(""))
/**
* Bumped whenever the field should take the focus back. The Compose counterpart of
* requestFocus() + selectEnd(): the operator is mid sentence, and a field that lost
* the focus to a button turns the next keystroke into nothing.
*/
var focusRequests: Int by mutableStateOf(0)
private set
/**
* Resolves, checks and queues the message, then empties the field.
*
* The order is the original's, including one inconsistency that is kept because
* changing it would change what gets transmitted: the score service's selection is
* consulted only after the variables have been resolved, so a station only it knows
* about reaches the category decision but not the resolver.
*/
fun send() {
var member = actions.panelSelection() ?: actions.tableSelection()
val resolved = actions.resolveVariables(value.text, member)
// A variable may legitimately resolve to an empty string; then there is nothing
// to send, and the field is emptied anyway so the operator sees it was consumed.
if (resolved.isNullOrBlank()) {
clear()
return
}
if (actions.isAddressedToOwnCallsign(resolved)) {
clear()
return
}
if (member == null) member = actions.scoreSelection()
val category = actions.resolveCategory(resolved, member) ?: actions.mainCategory()
val message = ChatMessage()
message.chatCategory = category
message.messageText = resolved
message.isMessageDirectedToServer = false
// A /cq starts the reply clock the no-reply tracking runs on.
actions.recordOutboundCq(resolved)
actions.requestRecompute("outbound-tx")
actions.queue(message)
clear()
}
fun clear() {
value = TextFieldValue("")
}
/**
* Appends a shortcut's resolved text. A template that resolves to nothing leaves the
* field — and the focus — alone.
*/
fun appendShortcut(template: String) {
val resolved = actions.resolveVariables("$template ", effectiveStation())
if (resolved.isNullOrBlank()) return
appendAndFocus(resolved)
}
/**
* Appends a snippet chosen from a right-click menu, resolved, with no trailing space
* and no /cq prefix.
*
* The same roster reaches the field three ways and all three differ: Ctrl+1..Ctrl+0
* replaces the field with a whole directed message, a right-click appends just the
* snippet, and a shortcut button appends its own roster's entry followed by a space.
* Collapsing any two of them would change what goes out over the air.
*/
fun appendSnippet(snippet: String) {
val resolved = actions.resolveVariables(snippet, effectiveStation())
if (resolved.isNullOrBlank()) return
appendAndFocus(resolved)
}
/**
* Appends the operator's own frequency for one of the two chats. These two buttons
* take the value straight from its field rather than through the resolver, which is
* why they are not ordinary shortcuts.
*/
fun appendOwnQrg(mainCategory: Boolean) {
val qrg = if (mainCategory) actions.ownQrgMain() else actions.ownQrgSecond()
appendAndFocus("$qrg ")
}
/**
* Replaces the field with "/cq CALL <snippet>", resolved.
*
* Without a selected station there is nobody to direct it at, so nothing happens —
* silently, as before, because this runs from Ctrl+1..Ctrl+0 while the operator is
* typing and a dialog would be worse than nothing.
*/
fun insertSnippet(snippets: List<String>, index: Int) {
val station = effectiveStation() ?: return
val callSign = station.callSign ?: return
if (index < 0 || index >= snippets.size) return
val resolved = actions.resolveVariables("/cq $callSign ${snippets[index]}", station)
if (resolved.isNullOrBlank()) return
setAndFocus(resolved)
}
private var lastAutoPreparedSendText: String = ""
/**
* Replaces the field with "/cq CALL ", if the field is empty or contains a previous auto-generated CQ.
*/
fun prepareCq(callSign: String, forceOverwrite: Boolean = false) {
if (callSign.isBlank()) return
val preparedText = "/cq ${callSign.trim()} "
val canOverwrite = value.text.isEmpty() || value.text == lastAutoPreparedSendText
if (!forceOverwrite && !canOverwrite) {
return
}
lastAutoPreparedSendText = preparedText
setAndFocus(preparedText)
}
/** The three-step lookup the shortcuts and snippets use. */
private fun effectiveStation(): ChatMember? =
actions.panelSelection() ?: actions.tableSelection() ?: actions.scoreSelection()
private fun appendAndFocus(text: String) = setAndFocus(value.text + text)
private fun setAndFocus(text: String) {
value = TextFieldValue(text = text, selection = TextRange(text.length))
focusRequests++
}
}
@@ -0,0 +1,436 @@
package kst4contest.view.compose
import kst4contest.view.i18n.CurrentStrings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.awt.ComposeDialog
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import java.awt.Dialog
import java.awt.Dimension
import java.awt.EventQueue
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.WindowConstants
import kst4contest.view.ExternalDocuments
/** One button of an alert, and whether pressing it means "go ahead". */
internal data class AlertButton(val text: String, val confirming: Boolean)
/**
* Which JavaFX button type the confirming button stood for.
*
* Not a detail: JavaFX ordered `YES` and `OK_DONE` differently, and the two dialogs this
* class replaces used one each. Flattening them put the button that gives up a running
* contest session where the cancel button used to be.
*/
enum class ConfirmKind(internal val code: Char) { YES('Y'), OK_DONE('O') }
/*
* The JavaFX ButtonBar orders, read off JavaFX 21.0.5 on a real machine rather than from
* memory. Each letter is a ButtonData type code; only three matter here: Y = YES,
* C = CANCEL_CLOSE, O = OK_DONE. Keeping the whole strings rather than a hand-made table
* means the rule below is the same rule JavaFX applied, and can be checked against it.
*/
internal const val BUTTON_ORDER_LINUX = "L_HE+UNYACBXIO_R"
internal const val BUTTON_ORDER_MAC_OS = "L_HE+U+FBIX_NCYOA_R"
internal const val BUTTON_ORDER_WINDOWS = "L_E+U+FBXI_YNOCAH_R"
private const val CANCEL_CODE = 'C'
/** The order string for an operating system name, as JavaFX chose one. */
internal fun buttonOrderFor(osName: String): String = when {
osName.startsWith("mac", ignoreCase = true) -> BUTTON_ORDER_MAC_OS
osName.startsWith("darwin", ignoreCase = true) -> BUTTON_ORDER_MAC_OS
osName.startsWith("windows", ignoreCase = true) -> BUTTON_ORDER_WINDOWS
else -> BUTTON_ORDER_LINUX
}
/**
* The button row of an alert, in the order JavaFX would have shown it.
*
* The operator reaches for a position, not a word. Both confirmations this class shows
* give something up — the running ON4KST session, or the whole client — so a row in the
* wrong order is a wrong action, not a cosmetic complaint.
*/
internal fun alertButtons(
confirmText: String,
cancelText: String?,
confirmKind: ConfirmKind,
buttonOrder: String,
): List<AlertButton> {
val confirm = AlertButton(confirmText, confirming = true)
if (cancelText == null) {
return listOf(confirm)
}
val cancel = AlertButton(cancelText, confirming = false)
return if (buttonOrder.indexOf(confirmKind.code) < buttonOrder.indexOf(CANCEL_CODE)) {
listOf(confirm, cancel)
} else {
listOf(cancel, confirm)
}
}
/**
* The button Enter presses: always the confirming one, as the JavaFX Alert's default
* button was.
*/
internal fun buttonForEnter(buttons: List<AlertButton>): AlertButton? =
buttons.firstOrNull { it.confirming }
/**
* The button ESC presses.
*
* The cancelling one when there is one. On an acknowledgement there is not, and dismissing
* an acknowledgement is acknowledging it — which is what ESC did to a JavaFX Alert holding
* a lone OK button.
*/
internal fun buttonForEscape(buttons: List<AlertButton>): AlertButton? =
buttons.firstOrNull { !it.confirming } ?: buttons.firstOrNull()
/**
* Builds and shows a modal dialog on the AWT event thread, blocking the caller.
*
* Both halves matter and neither is optional.
*
* A `ComposeDialog` is a heavyweight Swing component wrapping a Skia layer, so it has to
* be created and realised on the event thread. Doing it on another thread is not merely
* against the Swing rule: skiko then tries to create its OpenGL context off that thread
* and fails outright — `Failed to create Skia OpenGL context! Can't wrap nullptr`,
* followed by `RenderException: Cannot init graphic context`. The dialog appears as an
* empty frame that never paints, and because nothing can be clicked, a modal wait on it
* never ends. That was reproducible three runs out of three from `main`.
*
* `invokeAndWait` is what keeps the blocking contract while moving the work: it returns
* only once [open] returns, and [open] returns only once the modal dialog closes. So the
* caller still blocks exactly as `Stage.showAndWait()` made it block, and it also gives
* the happens-before edge that makes the answer written on the event thread visible here.
*/
internal fun openOnEventThread(open: () -> Unit) {
if (EventQueue.isDispatchThread()) {
open()
} else {
EventQueue.invokeAndWait(open)
}
}
/**
* What closing the window without pressing a button means.
*
* The JavaFX Alert returned an empty Optional and every caller read that as "do not
* proceed"; `closeWindowEvent` would otherwise quit the client when the operator
* dismisses the question rather than answering it.
*/
internal fun dismissalConfirms(): Boolean = false
/**
* The replacement for the JavaFX `Alert`.
*
* A modal [ComposeDialog] rather than a Compose `application { }` window on its own
* thread: a modal AWT dialog runs a nested event pump, so it blocks its caller *and*
* keeps drawing. Measured before this was written — from the event thread and from a
* worker thread alike, the call blocks for the dialog's lifetime and the content
* composes. The thread-plus-latch pattern [OperatorProfilePickerWindow] uses cannot do
* that: once the dispatcher hands menu actions to the event thread, blocking it with a
* latch would deadlock against the window trying to draw on that very thread.
*/
object ComposeAlert {
/** The font size the JavaFX dialogs had; the settings windows use the same value. */
private const val DIALOG_FONT_SIZE_SP = 12f
private val DIALOG_SIZE = Dimension(520, 260)
/** Shows the alert and returns at once. For notices nothing waits on. */
@JvmStatic
@JvmOverloads
fun show(title: String, header: String?, body: String, darkMode: Boolean = false) {
EventQueue.invokeLater {
open(
title = title,
header = header,
body = body,
confirmText = CurrentStrings.get().alertOk,
cancelText = null,
confirmKind = ConfirmKind.OK_DONE,
darkMode = darkMode,
modal = false,
)
}
}
/** Shows the alert and blocks until the operator acknowledges it. */
@JvmStatic
@JvmOverloads
fun acknowledge(title: String, header: String?, body: String, darkMode: Boolean = false) {
open(
title = title,
header = header,
body = body,
confirmText = CurrentStrings.get().alertOk,
cancelText = null,
confirmKind = ConfirmKind.OK_DONE,
darkMode = darkMode,
modal = true,
)
}
/**
* Asks a yes/no question and blocks until it is answered.
*
* @return true only when the operator pressed the confirming button; dismissing the
* window counts as a no, the way an empty Optional did.
*/
@JvmStatic
@JvmOverloads
fun confirm(
title: String,
header: String?,
body: String,
confirmText: String,
cancelText: String,
darkMode: Boolean = false,
/**
* Which JavaFX button type the confirming button stood for. It decides the button
* order per platform, so it is the caller's to state rather than a default to
* forget: the quit dialog used YES, the profile switch used OK_DONE, and on Linux
* those two order the row differently.
*/
confirmKind: ConfirmKind = ConfirmKind.OK_DONE,
): Boolean = open(
title = title,
header = header,
body = body,
confirmText = confirmText,
cancelText = cancelText,
confirmKind = confirmKind,
darkMode = darkMode,
modal = true,
)
/**
* Shows a notice with one clickable line below the text, and returns at once.
*
* Only `onSimpleLogFileCreated` needs this; the JavaFX version put a Hyperlink into
* the dialog pane. Kept as its own entry point rather than a nullable parameter on
* [show], so the ordinary notice stays a three-argument call.
*/
/*
* The key-handling overload of ComposeDialog.setContent is marked experimental. Opted
* into the way the rest of this package already opts into Compose's experimental
* surface; if it changes, the compiler says so and the fallback is an AWT key listener.
*/
@OptIn(ExperimentalComposeUiApi::class)
@JvmStatic
@JvmOverloads
fun showWithLink(
title: String,
header: String?,
body: String,
linkText: String,
linkTarget: String,
darkMode: Boolean = false,
) {
EventQueue.invokeLater {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
dialog.title = title
dialog.size = DIALOG_SIZE
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* Disposed and not hidden, for the reason spelled out in open(). */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
dialog.isAlwaysOnTop = true
dialog.setContent(
onKeyEvent = { event ->
if (event.type == KeyEventType.KeyDown
&& (event.key == Key.Enter || event.key == Key.NumPadEnter
|| event.key == Key.Escape)) {
dialog.dispose()
true
} else {
false
}
},
) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
AlertContent(
header = header,
body = body,
buttons = alertButtons(CurrentStrings.get().alertOk, null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
onPressed = { dialog.dispose() },
link = linkText to linkTarget,
)
}
}
dialog.isVisible = true
}
}
@OptIn(ExperimentalComposeUiApi::class)
private fun open(
title: String,
header: String?,
body: String,
confirmText: String,
cancelText: String?,
confirmKind: ConfirmKind,
darkMode: Boolean,
modal: Boolean,
): Boolean {
/*
* Atomic rather than a captured var. Every confirm() caller happens to press and
* read on the same thread today, so a plain var would work by an unwritten AWT
* detail; this makes it correct on purpose, and the cost is nothing.
*/
val answer = AtomicBoolean(dismissalConfirms())
if (!EventQueue.isDispatchThread() && modal) {
/*
* A modal alert asked for from a non-event thread — startup warnings and the
* failure notice in main do this. The dialog must still be built on the event
* thread, so the whole open is handed over and this call blocks until it
* returns, which is the behaviour the caller expects.
*/
openOnEventThread {
answer.set(open(title, header, body, confirmText, cancelText, confirmKind, darkMode, true))
}
return answer.get()
}
val dialog = ComposeDialog(
owner = null,
modalityType =
if (modal) Dialog.ModalityType.APPLICATION_MODAL else Dialog.ModalityType.MODELESS,
)
dialog.title = title
dialog.size = DIALOG_SIZE
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/*
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
* window and its Skia layer alive behind the operator's back — one per dismissal,
* and a reconnect storm shows this dialog repeatedly.
*/
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
/*
* Above everything else on purpose. These dialogs are asked for from Compose
* windows, which are not their owner; without this the question opens behind the
* window that asked it and the operator sees nothing happen at all. The JavaFX
* profile-switch confirmation needed the same and said so.
*/
dialog.isAlwaysOnTop = true
val buttons = alertButtons(
confirmText,
cancelText,
confirmKind,
buttonOrderFor(System.getProperty("os.name") ?: ""),
)
val press: (AlertButton) -> Unit = { pressed ->
answer.set(pressed.confirming)
dialog.dispose()
}
dialog.setContent(
/*
* Enter and ESC, which the JavaFX Alert bound for free. Without them the quit
* question ignored both keys, and a dialog one cannot dismiss from the keyboard
* is an obstacle in a contest.
*/
onKeyEvent = { event ->
if (event.type != KeyEventType.KeyDown) {
false
} else {
when (event.key) {
Key.Enter, Key.NumPadEnter -> {
buttonForEnter(buttons)?.let(press)
true
}
Key.Escape -> {
buttonForEscape(buttons)?.let(press)
true
}
else -> false
}
}
},
) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
AlertContent(
header = header,
body = body,
buttons = buttons,
onPressed = press,
)
}
}
dialog.isVisible = true
return answer.get()
}
}
@Composable
private fun AlertContent(
header: String?,
body: String,
buttons: List<AlertButton>,
onPressed: (AlertButton) -> Unit,
link: Pair<String, String>? = null,
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (header != null) {
Text(header, style = MaterialTheme.typography.titleMedium)
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
/* Scrolls rather than clips: onSimpleLogFileCreated shows five paragraphs. */
Text(body, style = MaterialTheme.typography.bodyMedium)
if (link != null) {
Text(
link.first,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 8.dp)
.clickable { ExternalDocuments.open(link.second) },
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
buttons.forEach { button ->
Form.button(button.text) { onPressed(button) }
}
}
}
}
}
@@ -0,0 +1,624 @@
package kst4contest.view.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.isSpecified
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.loadImageBitmap
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowPlacement as ComposeWindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import androidx.compose.ui.awt.ComposeWindow
import java.awt.EventQueue
import java.awt.Frame
import java.awt.Point
import java.awt.Toolkit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
/**
* A Compose window that lives beside the JavaFX application.
*
* Every such window needs the same four things, and getting any of them wrong has
* already cost a defect:
*
* - **It must not end the process.** `application` defaults to calling `exitProcess`
* when its last window closes, which would quit the client when the operator closes
* a settings window.
* - **It must open at most once, and pressing the opener again must raise it.** Two
* windows writing the same preferences would work against each other, so a second
* press cannot build a second window — but it must not do nothing either. The JavaFX
* originals built a fresh Stage per press and were raised as a side effect of that;
* refusing silently leaves the operator pressing a button that appears dead while the
* window sits behind the main one.
* - **It must be closable from outside.** A profile switch tears every window down and
* rebuilds it. `closeOwnedStages` walks JavaFX Stages and cannot reach a Compose
* window; when the settings window survived a switch, its "already open" flag stayed
* set and the rebuilt profile silently got no window at all — and that window carries
* the Connect button.
* - **It must follow a design switch.** The operator can change the design from the
* main window's menu while this window is open.
*
* Every window but the main one also opens on the screen the operator is working on
* (issue #59): centred on the main window's screen, or where the operator moved it to
* earlier in the session — see [WindowPlacement] for the rule.
*
* @param threadName names the thread in a stack dump, which is where one looks when a
* window will not close. It is also the key of the window's stored position, so
* renaming it forgets where the operator put the window.
* @param isMainWindow true for the main window only. It keeps the platform's placement
* and publishes its frame, which is what every other window is placed against.
*/
class ComposeWindowHost(
private val threadName: String,
private val isMainWindow: Boolean = false,
) {
private val open = AtomicBoolean(false)
private val darkModeState = mutableStateOf(false)
private val visible = mutableStateOf(true)
@Volatile
private var windowThread: Thread? = null
/** The AWT frame behind the Compose window, held only so a second press can raise it. */
@Volatile
private var frame: ComposeWindow? = null
/**
* Where the operator moved this window to, for this session. Not persisted here: a
* stored position outlives the monitor layout it was made on, which is the very problem
* of issue #59. Keeping positions across restarts is the operator's opt-in, handled by
* [WindowPlacementOnScreen.memory] and used only on the very screen it was made on. Lives as long as the host, and the hosts are
* singletons, so it survives closing and reopening the window and a profile switch.
*/
@Volatile
private var movedTo: Point? = null
/** Whether the window is currently open, so a caller can tell without opening one. */
val isOpen: Boolean
get() = open.get()
/** Follows a design switch made in the main window. */
fun applyDarkMode(darkMode: Boolean) {
darkModeState.value = darkMode
}
/**
* Closes the window and waits for it to be gone.
*
* The wait is not politeness: a profile switch opens the replacement window
* immediately afterwards, and it must not be refused by the old one still closing.
*
* How it waits depends on the calling thread, and the distinction is not cosmetic.
* This window's `application { }` advances only as continuations that skiko's
* SwingDispatcher posts to the AWT event thread — unconditionally, with no
* `isDispatchNeeded` shortcut. So on that thread a plain `join` can never be
* satisfied: nothing drains the queue the teardown is sitting in. Every Windows-menu
* toggle and the whole of `shutdownRuntime` call this from there, and before the fix
* the interface froze for the timeout and then returned with [isOpen] still true —
* which made a profile switch raise the dead runtime's window and leave the rebuilt
* profile with no settings window, the one carrying the Connect button.
*
* A secondary loop is the way out: it blocks this caller while continuing to dispatch
* events, which is the same mechanism a modal dialog uses.
*/
fun close() {
if (!open.get()) {
return
}
visible.value = false
val thread = windowThread ?: return
if (!EventQueue.isDispatchThread()) {
thread.join(CLOSE_TIMEOUT_MS)
return
}
val loop = Toolkit.getDefaultToolkit().systemEventQueue.createSecondaryLoop()
Thread({
thread.join(CLOSE_TIMEOUT_MS)
/*
* Posted rather than called directly, and that is what closes the documented
* race in SecondaryLoop: an exit() that lands before enter() has begun returns
* false and leaves enter() blocked forever. Posting it means the running loop
* itself dispatches the exit, so it cannot arrive too early. The join's
* timeout also bounds the whole wait, so a window that refuses to close costs
* the timeout rather than the session.
*/
EventQueue.invokeLater { loop.exit() }
}, "$threadName-close").start()
loop.enter()
}
/**
* Raises the open window to the front, de-iconified if the operator had minimised it.
*
* Hops to the AWT event queue because the caller is the JavaFX thread. Both decisions
* live beside this method as plain functions — [raiseSteps] for what to do and
* [deiconifiedState] for what to write — so the only thing left here that no headless
* test can observe is the single call to `toFront`.
*/
fun raise() {
val frame = this.frame ?: return
EventQueue.invokeLater {
raiseSteps(frame.isDisplayable, frame.extendedState and Frame.ICONIFIED != 0)
.forEach { step ->
when (step) {
RaiseStep.DEICONIFY ->
frame.extendedState = deiconifiedState(frame.extendedState)
RaiseStep.TO_FRONT -> frame.toFront()
}
}
}
}
/**
* Opens the window, or raises it when it is already open.
*
* @param content receives the action that closes the window; only the window itself
* can end its own Compose application
* @param onResized called with the new size whenever the operator resizes the
* window. The JavaFX scenes had width and height listeners writing the
* stored size, and the spec's acceptance criterion for this stage names
* window sizes explicitly.
*/
fun show(
/**
* Read during composition, so a window whose title says what the chat is doing keeps
* saying it. A captured string would freeze at whatever was true when the window
* opened -- which for the main window is "disconnected", for the whole session.
*/
title: () -> String,
darkMode: Boolean,
baseFontSizeSp: Float,
widthDp: Float,
heightDp: Float,
onResized: ((Float, Float) -> Unit)? = null,
/**
* Raised above the other windows. The update window needs it: it opens during
* startup, and behind the main window it goes unnoticed, which is the one thing
* it exists not to do.
*/
alwaysOnTop: Boolean = false,
/**
* The palette of the active profile. Null draws the shipped palette, which is right
* for a window that opens before a profile exists.
*/
paletteStore: PaletteStore? = null,
/**
* The interface language of the active profile. Null draws the base language, which
* is right for a window that opens before a profile exists.
*/
languageStore: kst4contest.view.i18n.LanguageStore? = null,
/**
* What a close request from outside should do — the title bar's X, and a tiling
* window manager's close, which is the same WM_DELETE_WINDOW either way.
*
* Null means close this window and leave the process alone, which is right for the
* settings, monitor and update windows. The MAIN window must pass one: closing it
* has to run the application's quit flow. Without it Compose's default merely ends
* this window's `application { }`, and the client lives on with the chat connected
* and no window to see it in — a tiling window manager shows that plainly, because
* the window disappears while the launching Gradle task keeps running.
*/
onCloseRequest: (() -> Unit)? = null,
/**
* The window's content. A FrameWindowScope receiver, because that is the only scope
* a MenuBar can be declared in — without it no window but the main one could ever
* carry the shared menu bar.
*/
content: @Composable FrameWindowScope.(close: () -> Unit) -> Unit,
) {
darkModeState.value = darkMode
if (!open.compareAndSet(false, true)) {
raise()
return
}
visible.value = true
val thread = Thread({
try {
application(exitProcessOnExit = false) {
if (!visible.value) {
/* Nothing rendered means no window, and the application ends. */
return@application
}
/*
* Decided once per opening, on the event thread this composition runs
* on — which is where the screens and the main window may be read.
* Null keeps the platform's placement: always for the main window, and
* for any window that opens before the main window exists.
*/
val placed = remember {
val width = widthDp.roundToInt()
val height = heightDp.roundToInt()
when {
/*
* The main window only ever uses a stored position; without
* one the platform places it, as before.
*/
isMainWindow -> WindowPlacementOnScreen.restore(threadName, width, height)
/* This session's move is newer than anything stored. */
movedTo != null -> WindowPlacementOnScreen.place(movedTo, width, height)
else -> WindowPlacementOnScreen.restore(threadName, width, height)
?: WindowPlacementOnScreen.place(null, width, height)
}
}
val windowState = rememberWindowState(
position = placed
?.let { WindowPosition(it.x.dp, it.y.dp) }
?: WindowPosition.PlatformDefault,
width = placed?.width?.dp ?: widthDp.dp,
height = placed?.height?.dp ?: heightDp.dp,
)
/*
* Written back on every change. The JavaFX scenes did this through
* width and height listeners, and the size matters here beyond
* comfort: these tables are wider than the default window, so an
* operator who widens it once must not have to do it again.
*/
/*
* The last size and position this host cut the window down to. Neither
* is the operator's choice: the size must not be written back as the
* stored size, and the position must not count as a move.
*/
val fit = remember { FitRecord() }
if (onResized != null) {
LaunchedEffect(windowState) {
/*
* A secondary window skips the opening size: it may be the
* screen-clamped one, and writing that back would shrink the
* stored size for good because the window once opened on a
* smaller screen. The main window keeps writing it, as before,
* unless it was opened at a stored position, where the size may
* be clamped the same way. A size this host fitted the window to
* is skipped for the same reason.
*/
snapshotFlow { windowState.size }
.drop(if (isMainWindow && placed == null) 0 else 1)
.collect { size ->
if (size.width.isSpecified && size.height.isSpecified
&& !fit.takeSize(size.width.value.roundToInt(), size.height.value.roundToInt())
) {
onResized(size.width.value, size.height.value)
}
}
}
}
LaunchedEffect(windowState) {
/*
* One tracker per opening; the rule is in MoveTracker. An operator
* move is remembered for the session (secondary windows) and handed
* to the stored positions, which keep it only when the operator
* opted in. Only moves are stored, never the window manager's
* nudges, so a restored window cannot drift a little on every start.
*/
var tracker = MoveTracker(placed?.location)
snapshotFlow { windowState.position }.collect { position ->
if (position is WindowPosition.Absolute
&& windowState.placement == ComposeWindowPlacement.Floating
&& !windowState.isMinimized
) {
val now = Point(
position.x.value.roundToInt(),
position.y.value.roundToInt(),
)
val size = windowState.size
/*
* The host's own fit is a new anchor, not a move; the window
* manager may still nudge it within the tolerance.
*/
if (fit.takePosition(now)) {
tracker = MoveTracker(now)
return@collect
}
tracker.observe(now)?.let { moved ->
if (!isMainWindow) {
movedTo = moved
}
WindowPlacementOnScreen.store(
threadName,
moved,
if (size.width.isSpecified) size.width.value.roundToInt() else widthDp.roundToInt(),
if (size.height.isSpecified) size.height.value.roundToInt() else heightDp.roundToInt(),
)
}
}
}
}
LaunchedEffect(windowState) {
/*
* A window moved onto a smaller screen -- a laptop panel, a 640x480
* field monitor -- is cut down to it, so no edge or button ends up out
* of reach. Only once it has rested: resizing in the middle of a drag
* would fight the window manager for the window.
*/
snapshotFlow { windowState.position to windowState.size }.collectLatest { (position, size) ->
delay(FIT_SETTLE_MILLIS)
if (position is WindowPosition.Absolute
&& size.width.isSpecified && size.height.isSpecified
&& windowState.placement == ComposeWindowPlacement.Floating
&& !windowState.isMinimized
) {
WindowPlacementOnScreen.fitted(
java.awt.Rectangle(
position.x.value.roundToInt(),
position.y.value.roundToInt(),
size.width.value.roundToInt(),
size.height.value.roundToInt(),
),
)?.let { fitted ->
fit.record(fitted)
windowState.size = DpSize(fitted.width.dp, fitted.height.dp)
windowState.position = WindowPosition(fitted.x.dp, fitted.y.dp)
}
}
}
}
Window(
onCloseRequest = { onCloseRequest?.invoke() ?: exitApplication() },
state = windowState,
title = title(),
alwaysOnTop = alwaysOnTop,
icon = applicationIcon(),
) {
/*
* The frame is the only handle that can raise this window later,
* and it must go when the window does: a stale frame would make a
* second press raise a window that is no longer there.
*/
DisposableEffect(window) {
frame = window
if (isMainWindow) {
WindowPlacementOnScreen.mainWindow = window
}
/*
* The only place the handle is dropped. Clearing it again in
* the thread's finally block looks like a belt to this brace,
* but that block runs after open.set(false) has already let
* another thread open a new window and publish its frame — so
* the late write would wipe a live handle and bring back the
* dead button this whole method exists to remove.
*/
onDispose {
frame = null
if (WindowPlacementOnScreen.mainWindow === window) {
WindowPlacementOnScreen.mainWindow = null
}
}
}
CompositionLocalProvider(
LocalPaletteStore provides paletteStore,
/*
* Read inside the composition, not outside: that state read is
* what makes a language change recompose this window. Etappe 8
* shipped the outside version for the palette.
*/
kst4contest.view.i18n.LocalStrings provides
(languageStore?.strings
?: kst4contest.view.i18n.LocalStrings.current),
) {
Kst4ContestTheme(
darkMode = darkModeState.value,
baseFontSizeSp = baseFontSizeSp,
) {
content(::exitApplication)
}
}
}
}
} finally {
open.set(false)
windowThread = null
}
}, threadName)
windowThread = thread
thread.start()
}
private companion object {
const val CLOSE_TIMEOUT_MS = 2_000L
}
}
/**
* How far a window must have gone from where it was opened to count as moved by the
* operator.
*
* Not zero, because window managers nudge a new window on their own — by the decoration,
* or to keep it clear of a panel — and that nudge would otherwise be remembered as the
* operator's choice. The window would then stop following the main window to another
* screen, and on X11 reopening it would drift by the decoration each time.
*/
internal const val MOVE_TOLERANCE_PX = 48
/** How long a window must rest after a move before it is cut down to a smaller screen. */
private const val FIT_SETTLE_MILLIS = 500L
/**
* What the host last cut a window down to, so the size and position it set itself are not
* taken for the operator's. Each is consumed by the first report that follows: a matching
* report is the fit echoing back, any other report is the operator's and clears it, so a
* stale record cannot swallow a later resize or move. Only touched on the window's event
* thread.
*/
internal class FitRecord {
private var pendingPosition: Point? = null
private var pendingWidth = 0
private var pendingHeight = 0
private var sizePending = false
fun record(bounds: java.awt.Rectangle) {
pendingPosition = bounds.location
pendingWidth = bounds.width
pendingHeight = bounds.height
sizePending = true
}
/**
* Whether [now] is the fitted position reported back. Compared with the move
* tolerance, because dp rounding under display scaling and a window manager's nudge
* both shift it slightly.
*/
fun takePosition(now: Point): Boolean {
val pending = pendingPosition ?: return false
pendingPosition = null
return !movedAway(pending, now)
}
/** Whether this size is the fitted one reported back, allowing for dp rounding. */
fun takeSize(width: Int, height: Int): Boolean {
if (!sizePending) {
return false
}
sizePending = false
return abs(width - pendingWidth) <= SIZE_ROUNDING_PX && abs(height - pendingHeight) <= SIZE_ROUNDING_PX
}
private companion object {
const val SIZE_ROUNDING_PX = 2
}
}
/** Whether [now] is far enough from [opened] to be the operator's move rather than a nudge. */
internal fun movedAway(opened: Point, now: Point): Boolean =
abs(now.x - opened.x) > MOVE_TOLERANCE_PX || abs(now.y - opened.y) > MOVE_TOLERANCE_PX
/**
* Tells the operator's moves of one opened window from the window manager's, fed with every
* floating position the window reports.
*
* - **Placed by the host** ([placedAt] not null): the anchor is that placement. Reports
* within [MOVE_TOLERANCE_PX] of it are the window manager's nudge and are ignored. This
* also holds when the window was reopened at its remembered spot, so a decoration nudge
* cannot make it drift a little further on every opening.
* - **Once a report has left the tolerance**, the operator is moving the window, and from
* then on every report is taken — including a move back to near where it opened, which
* would otherwise leave the stale far-away spot remembered.
* - **Not placed by the host** (opened before the main window existed): the platform picks
* the spot, and it may report a provisional position before its real placement. So the
* anchor is not trusted until it has settled: every report replaces it until one lands
* within the tolerance of the previous one. A window manager's placement jump is
* therefore a new anchor, not a move. The price: a single far jump by the operator
* before any second report — a keyboard "move to other monitor" right after opening —
* is taken as the anchor too, and the window then simply opens on the main window's
* screen next time, which is the default anyway. A time-based settle was the
* alternative; it would need a clock in a rule that is otherwise pure, and a drag
* produces a stream of close reports that settles the anchor on its own.
*
* @param placedAt where the host placed the window, or null when the platform did
*/
internal class MoveTracker(placedAt: Point?) {
private var anchor: Point? = placedAt
private var settled = placedAt != null
private var moving = false
/**
* @param now the position the window reports
* @return the position to remember as the operator's choice, or null to remember nothing
*/
fun observe(now: Point): Point? {
if (moving) {
return now
}
val current = anchor
if (!settled || current == null) {
if (current != null && !movedAway(current, now)) {
settled = true
} else {
anchor = now
}
return null
}
if (movedAway(current, now)) {
moving = true
return now
}
return null
}
}
/**
* The application icon, so these windows are not the only ones in the taskbar showing
* the default Java cup. Null when the resource is missing rather than failing: a window
* without an icon still works.
*/
@Composable
private fun applicationIcon(): Painter? = remember {
runCatching {
ComposeWindowHost::class.java.getResourceAsStream(APPLICATION_ICON)?.use {
BitmapPainter(loadImageBitmap(it))
}
}.getOrNull()
}
private const val APPLICATION_ICON = "/icons/kst4contest.png"
/** One thing raising a window has to do. */
internal enum class RaiseStep { DEICONIFY, TO_FRONT }
/**
* The frame state that restores a minimised window without disturbing anything else.
*
* `extendedState` is a bit set, so this clears one bit rather than assigning a value.
* Writing `Frame.NORMAL` would be an assignment of zero: a window the operator maximised
* and then minimised holds `MAXIMIZED_BOTH or ICONIFIED` — 7 — and raising it would hand
* it back restored-down, dropping a maximisation nobody asked to drop.
*/
internal fun deiconifiedState(current: Int): Int = current and Frame.ICONIFIED.inv()
/**
* What raising an already-open window has to do, decided without touching AWT.
*
* Split out so the rule can be tested at all: the three calls it describes need a real
* display and a real window, while the rule itself is the part that can be wrong.
*
* A minimised window has to be restored before it is raised — `toFront` on an iconified
* frame leaves it iconified on every desktop this client runs on, which would be the
* same dead button in a different disguise. A frame that is no longer displayable gets
* nothing: the window is on its way out and raising it would fight the close.
*
* There is no focus step. `toFront` is the documented way to ask for a top-level window
* to come forward, while `Component.requestFocus` on a window is a focus-cycle-root
* request that most window managers ignore — and under X11 focus-stealing prevention has
* the last word either way. It was in the first version of this list without evidence
* that it ever changed an outcome, which is not a reason to pin a third step.
*/
internal fun raiseSteps(displayable: Boolean, iconified: Boolean): List<RaiseStep> = when {
!displayable -> emptyList()
iconified -> listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT)
else -> listOf(RaiseStep.TO_FRONT)
}
@@ -0,0 +1,114 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import kst4contest.view.i18n.LocalStrings
import kst4contest.view.i18n.Strings
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
/**
* The connection state the button bar shows. Derived from the controller rather than
* held, because the session also connects and drops outside this window.
*/
enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
/**
* The button bar at the bottom of the settings window.
*
* This bar is the reason the settings window exists at startup: in the JavaFX client
* it carried the Connect button, which is the one place where `ChatController.execute`
* is called and therefore the only way into the chat. Saving and closing sit next to
* it because that is where the operator has always found them.
*/
class ConnectionBarState(
private val prefs: ChatPreferences,
private val controller: ChatController,
) {
val connectionState: ConnectionState
get() = when {
controller.isConnectedAndLoggedIn -> ConnectionState.LOGGED_IN
controller.isConnectedAndNOTLoggedIn -> ConnectionState.CONNECTED_NOT_LOGGED_IN
else -> ConnectionState.DISCONNECTED
}
/**
* The label of the Connect button, which names the chat the station will land in —
* and both chats when the second one is enabled, so the operator sees before
* pressing it what they are logging into.
*
* Takes the texts as a parameter because this is a plain property holder and not a
* composable: it cannot read the composition local itself, and the caller can.
*/
fun connectButtonText(strings: Strings): String {
val main = prefs.getLoginChatCategoryMain()
?: return strings.connectionConnect
/*
* Formatted rather than concatenated: in German the chat name comes first ("Mit
* 144 MHz verbinden"), which no amount of "Connect to " + name can produce.
*/
var label = strings.connectionConnectTo(main.getChatCategoryName(main.categoryNumber))
val second = prefs.getLoginChatCategorySecond()
if (prefs.isLoginToSecondChatEnabled() && second != null) {
label += " & " + second.getChatCategoryName(second.categoryNumber)
}
return label
}
/** Only a disconnected session can connect; the JavaFX button locked itself. */
val canConnect: Boolean
get() = connectionState != ConnectionState.LOGGED_IN
/** Disconnecting needs a session that is actually logged in. */
val canDisconnect: Boolean
get() = connectionState == ConnectionState.LOGGED_IN
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ConnectionBar(
state: ConnectionBarState,
onConnect: () -> Unit,
onSave: () -> Unit,
onApplyAndClose: () -> Unit,
onDisconnectAndCloseChat: () -> Unit,
onDisconnectOnly: () -> Unit,
) {
/*
* Order as in the JavaFX HBox: connect, save, apply, disconnect and close, and
* disconnect only. Operators reach for these by position.
*/
/*
* A wrapping row, not a plain one. Five buttons do not fit a narrow window, and a
* plain Row does not wrap: it squeezes the last children instead. A button squeezed
* to a few pixels wraps its own label to one letter per line and grows into a tall
* coloured bar that eats the whole window height — which is exactly what happened.
*/
val strings = LocalStrings.current
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
) {
Form.button(
state.connectButtonText(strings),
enabled = state.canConnect,
onClick = onConnect,
)
Form.button(strings.settingsSave, onClick = onSave)
Form.button(strings.settingsApplyAndClose, onClick = onApplyAndClose)
Form.button(strings.connectionDisconnectAndCloseChat, onClick = onDisconnectAndCloseChat)
Form.button(
strings.connectionDisconnect,
enabled = state.canDisconnect,
onClick = onDisconnectOnly,
)
}
}
@@ -0,0 +1,96 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kotlin.math.pow
/** WCAG AA for body text. Below this a pair is reported, never refused. */
const val CONTRAST_FLOOR = 4.5
/** The pairs that actually sit on top of each other, as the tab names them. */
enum class ContrastPair { TEXT_ON_WINDOW_SURFACE, TEXT_INSIDE_FIELD, ACCENT_ON_WINDOW_SURFACE, TEXT_ON_MENU_STRIP }
/**
* One pair that reads badly.
*
* Carries the pair itself and not its name: this is a pure function, and a translated string
* here would make every test that filters on it depend on whichever language ran last in the
* shared JVM. The tab turns it into words.
*
* @param ratio what the pair measures now
* @param shippedRatio what the same pair measures in the shipped sheet, so the tab can say
* how far the change moved it
*/
data class ContrastWarning(val what: ContrastPair, val ratio: Double, val shippedRatio: Double)
/**
* The WCAG contrast ratio of two colours, between 1.0 and 21.0.
*
* Relative luminance and not brightness: the two are easy to confuse, and the JavaFX colour
* derivation in this same package is a standing reminder that guessing a colour formula
* produces values that look plausible and are wrong.
*/
fun contrastRatio(a: Color, b: Color): Double {
val lighter = maxOf(relativeLuminance(a), relativeLuminance(b))
val darker = minOf(relativeLuminance(a), relativeLuminance(b))
return (lighter + 0.05) / (darker + 0.05)
}
private fun relativeLuminance(colour: Color): Double {
fun channel(value: Float): Double {
val v = value.toDouble()
return if (v <= 0.03928) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4)
}
return 0.2126 * channel(colour.red) +
0.7152 * channel(colour.green) +
0.0722 * channel(colour.blue)
}
/**
* The pairs that actually sit on top of each other and read worse than they shipped.
*
* Two conditions, both required: under [CONTRAST_FLOOR], and worse than the same pair in the
* shipped sheet. The second is there because the shipped sheets do not meet the floor
* themselves — the evening accent measures 2.26 on its own surface — so an absolute rule
* would warn about a client nobody has touched, which is how an operator learns to ignore
* warnings. With this rule the shipped state never warns and an improvement never warns.
*
* Four pairs, and each one is read off `Theme.kt`'s Material mapping rather than off the role
* names. The names mislead: `background` and `surface` are both `windowBackground`, with
* `onBackground` and `onSurface` both `labelTextFill`, so almost every label in every window
* sits on the window surface and not on `base` — `base` reaches the screen only through the
* menu strip and the selection tint. And `primary` is `textAccent`, not `accent`, drawn as
* text and as tab underlines. Measuring `labelTextFill` against `base` alone let an operator
* black out every window and hear nothing.
*
* Four and not fifteen, deliberately: most pairs of six roles never meet on screen, and a
* warning about a pair that cannot occur costs the same attention as a real one.
*/
fun contrastWarnings(resolved: JavaFxPalette, shipped: JavaFxPalette): List<ContrastWarning> {
fun pairsOf(palette: JavaFxPalette) = listOf(
/* onBackground/onSurface on background/surface: nearly every label in every window. */
ContrastPair.TEXT_ON_WINDOW_SURFACE to (palette.labelTextFill to palette.windowBackground),
/* onSurfaceVariant on surfaceVariant: inside a field, a list, and on every button. */
ContrastPair.TEXT_INSIDE_FIELD to (palette.labelTextFill to palette.controlInnerBackground),
/* primary as text and as a tab underline, on the window surface. */
ContrastPair.ACCENT_ON_WINDOW_SURFACE to (palette.textAccent to palette.windowBackground),
/* The menu strip paints base behind its titles; narrow, but it is real. */
ContrastPair.TEXT_ON_MENU_STRIP to (palette.labelTextFill to palette.base),
)
val shippedRatios = pairsOf(shipped).associate { (what, colours) ->
what to contrastRatio(colours.first, colours.second)
}
return pairsOf(resolved).mapNotNull { (what, colours) ->
val ratio = contrastRatio(colours.first, colours.second)
val shippedRatio = shippedRatios.getValue(what)
if (ratio < CONTRAST_FLOOR && ratio < shippedRatio) {
ContrastWarning(what, ratio, shippedRatio)
} else {
null
}
}
}
@@ -0,0 +1,400 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.ui.Alignment
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kst4contest.observe.SimpleRoster
/**
* One row of an [EditableListState].
*
* The key is what a LazyColumn is keyed by, and it must survive an insertion
* above the row: keying by list index makes every row below an insertion change
* identity, which moves the selection. Keying by text would collapse duplicates,
* and the shortcut and snippet lists can hold the same text twice.
*/
data class ListEntry(val key: Long, val text: String)
/**
* What committing an edit did to the list.
*
* A commit is the counterpart of the JavaFX `setOnEditCommit` handler, which ran
* when the operator left the cell — not on every keystroke. That distinction is
* what makes a rule such as "fold a callsign to its base call" usable: applying
* it per keystroke would fight the typist.
*/
sealed interface CommitOutcome {
/** The entry now holds this text, which a rule may have rewritten. */
data class Stored(val text: String) : CommitOutcome
/** The entry is gone: clearing the text was how the operator removed one. */
data object Removed : CommitOutcome
/**
* The edit was refused and the stored text stands. The message is the one the
* JavaFX table showed in an alert before calling `refresh()` on itself.
*/
data class Refused(val message: String) : CommitOutcome
/** The row no longer exists: a pending edit can outlive its row. */
data object Ignored : CommitOutcome
}
/**
* Decides what a committed edit becomes. The default accepts the text as typed
* and treats a blank one as a removal; the monitored-callsign list replaces it
* with the normalize-and-reject-duplicates rule the JavaFX table had.
*/
fun interface EntryRule {
/**
* @param candidate text as the operator typed it
* @param index row being committed
* @param others texts of the other rows in order, the edited row excluded, so a
* duplicate check does not compare a row against itself
*/
fun decide(candidate: String, index: Int, others: List<String>): CommitOutcome
companion object {
/** Accept as typed; a blank text removes the entry. */
val asTyped: EntryRule = EntryRule { candidate, _, _ ->
if (candidate.isBlank()) CommitOutcome.Removed else CommitOutcome.Stored(candidate)
}
}
}
/**
* Editing state of a single-column string list, free of Compose so the rules are
* testable without a toolkit.
*
* Reproduces what the JavaFX tables in the settings window did: a new entry goes
* to the top and becomes the selection, and an entry edited to blank disappears —
* that was how the operator removed one
* (Kst4ContestApplication, ShortCol.setOnEditCommit).
*/
class EditableListState(
initial: List<String>,
private val rule: EntryRule = EntryRule.asTyped,
) {
private var nextKey: Long = 0
/*
* Compose state, so a row redraws when the selection moves off it or its text
* changes. Plain collections left the user interface guessing: select() is called
* on a focus change without any other notification, and the row that lost the
* selection kept its accent border.
*/
private val items: SnapshotStateList<ListEntry> =
mutableStateListOf<ListEntry>().apply {
initial.forEach { add(ListEntry(nextKey++, it)) }
}
val entries: List<ListEntry>
get() = items.toList()
val texts: List<String>
get() = items.map { it.text }
var selectedIndex: Int? by mutableStateOf(null)
private set
fun select(index: Int?) {
selectedIndex = index?.takeIf { it in items.indices }
}
fun addAtTop(entry: String) {
items.add(0, ListEntry(nextKey++, entry))
selectedIndex = 0
}
/**
* Appends an entry. The monitoring list grew this way: its Add button asked for
* a callsign in a dialog and called `add` on the roster, which appends.
*/
fun addAtBottom(entry: String) {
items.add(ListEntry(nextKey++, entry))
selectedIndex = items.lastIndex
}
/** An index outside the list is ignored: a pending edit can outlive its row. */
fun updateAt(index: Int, text: String) {
if (index !in items.indices) {
return
}
if (text.isBlank()) {
removeAt(index)
return
}
items[index] = items[index].copy(text = text)
}
/**
* Commits an edit through the list's [EntryRule]. This is what the user interface
* calls when the operator leaves a row or presses Enter, mirroring
* `setOnEditCommit`; [updateAt] stays the unguarded path.
*/
fun commitAt(index: Int, candidate: String): CommitOutcome {
if (index !in items.indices) {
return CommitOutcome.Ignored
}
val others = items.filterIndexed { position, _ -> position != index }.map { it.text }
return when (val verdict = rule.decide(candidate, index, others)) {
is CommitOutcome.Stored -> {
items[index] = items[index].copy(text = verdict.text)
verdict
}
CommitOutcome.Removed -> {
removeAt(index)
verdict
}
else -> verdict
}
}
fun removeAt(index: Int) {
if (index !in items.indices) {
return
}
items.removeAt(index)
selectedIndex = selectedIndex?.let { current ->
if (current >= items.size) items.indices.lastOrNull() else current
}
}
/**
* Moves the selected entry by [offset] positions and keeps it selected, the way
* `moveSelectedTableEntry` did. Returns false when there is no selection or the
* target position is outside the list, so the caller can skip its follow-up work.
*/
fun moveSelected(offset: Int): Boolean {
val current = selectedIndex ?: return false
val target = current + offset
if (current !in items.indices || target !in items.indices) {
return false
}
items.add(target, items.removeAt(current))
selectedIndex = target
return true
}
/**
* Fills the given roster. Deliberately setAll and not a field swap: replacing
* the roster would orphan every listener registered on it, which is the rule
* the ChatPreferences setters have followed since Etappe 2.
*/
fun commitTo(roster: SimpleRoster<String>) {
roster.setAll(texts)
}
}
/**
* An editable single-column list, the Compose counterpart of the three
* TableView<String> tables in the settings window.
*
* Column widths, sorting and row styling are deliberately absent: no consumer in
* the settings window needs them. They belong with the main-window tables, where
* TableLayoutManager persists widths against stable column ids.
*
* Editing is committed when the operator leaves a row or presses Enter, not on
* every keystroke. That is what the JavaFX `TextFieldTableCell` did: the typed
* text lived in the cell editor until it was committed, which is why a rule could
* reject it and put the stored value back.
*
* @param onChanged called after every committed change — add, edit, removal, move.
* The settings tabs stage the list here; applying the draft fills the roster.
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
* tables opened an alert here.
* @param onAdd replaces the default "insert the template at the top" behaviour.
* The monitoring list needs it: its Add button asked for a callsign first.
* @param listHeight the list is a bounded, scrolling area like the TableView it
* replaces. It also keeps a LazyColumn out of an unbounded parent scroll,
* which Compose rejects.
*/
@Composable
fun DataTable(
state: EditableListState,
addButtonText: String,
newEntryTemplate: String,
onChanged: () -> Unit,
modifier: Modifier = Modifier,
onRefused: (String) -> Unit = {},
onAdd: (() -> Unit)? = null,
showMoveButtons: Boolean = false,
listHeight: Dp = 220.dp,
) {
var revision by remember { mutableStateOf(0) }
val focusOnNewRow = remember { FocusRequester() }
fun changed() {
revision++
onChanged()
}
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
@Suppress("UNUSED_EXPRESSION") revision // read so edits recompose the list
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(listHeight)
.padding(vertical = 4.dp),
) {
itemsIndexed(state.entries, key = { _, entry -> entry.key }) { index, entry ->
/*
* Keyed on the revision too, so a refused edit is visibly put back:
* the stored text did not change, and without the revision the draft
* would keep the text the rule just rejected.
*/
var draft by remember(entry.key, entry.text, revision) { mutableStateOf(entry.text) }
var hadFocus by remember(entry.key) { mutableStateOf(false) }
fun commit() {
when (val outcome = state.commitAt(index, draft)) {
is CommitOutcome.Refused -> {
changed()
onRefused(outcome.message)
}
CommitOutcome.Ignored -> Unit
else -> changed()
}
}
/*
* Inside the settings window a row still being edited is committed before the
* draft is applied: a click on "Apply/Close prefs" leaves the focus in the row,
* see SettingsDraft.registerTypedEntry. Only an edited row; committing an
* untouched one would report a change that was never made. A refusal is reported
* by commit() through onRefused, so the registration answers null.
*/
val settingsDraft = LocalSettingsDraft.current
val pendingEdit = rememberUpdatedState { if (draft != entry.text) commit() }
DisposableEffect(settingsDraft, entry.key) {
val registration = settingsDraft?.registerTypedEntry {
pendingEdit.value()
null
}
onDispose { registration?.close() }
}
val isSelected = state.selectedIndex == index
/*
* The selected row is marked on the field's own outline rather than by a
* second frame around it: every row here already is a bordered text
* field, and a frame inside a frame reads as noise instead of as a
* selection. The tinted background carries it the rest of the way.
*/
CompactTextField(
value = draft,
onValueChange = { draft = it },
borderColor = if (isSelected) MaterialTheme.colorScheme.primary else null,
borderThickness = if (isSelected) 2.dp else null,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp)
.background(
if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
}
)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
state.select(index)
} else if (hadFocus) {
commit()
}
hadFocus = focusState.isFocused
}
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown &&
(event.key == Key.Enter || event.key == Key.NumPadEnter)
) {
commit()
true
} else {
false
}
}
.let { if (index == 0) it.focusRequester(focusOnNewRow) else it },
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
Form.button(addButtonText) {
if (onAdd != null) {
onAdd()
revision++
return@button
}
state.addAtTop(newEntryTemplate)
changed()
/*
* Focus is requested explicitly. The JavaFX table opened the new row
* for editing; hoping Compose does the same would repeat the focus
* defects of Etappe 2, where two of three bugs were focus bugs.
*/
runCatching { focusOnNewRow.requestFocus() }
}
if (showMoveButtons) {
Form.button(kst4contest.view.i18n.CurrentStrings.get().listMoveDown) { if (state.moveSelected(1)) changed() }
Form.button(kst4contest.view.i18n.CurrentStrings.get().listMoveUp) { if (state.moveSelected(-1)) changed() }
}
Text(
kst4contest.view.i18n.CurrentStrings.get().listClearToRemove,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,414 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import java.util.IdentityHashMap
/**
* How a cell is painted beyond its text. Both colours are optional; null keeps the
* table's own.
*/
data class CellAccent(val background: Color? = null, val foreground: Color? = null)
/**
* One column of a data table.
*
* The shape is taken from what the station list of Etappe 5 actually does, not from
* what the two monitor tables of Etappe 4 need. Those use the simple cases only —
* cutting the interface to fit them would produce one the station list cannot use,
* which is how the single-column DataTable of Etappe 3b came about.
*
* @param id stable across releases; the stored column widths are keyed by it, so
* renaming one loses the operator's layout
* @param value what the cell shows
* @param tooltip built from the row **and** the displayed value, because the JavaFX
* cells do exactly that — the QRA column explains its grid status that way
* @param accent colouring per cell, again from row and value together
* @param comparator sorts this column; without one the displayed text decides
*/
class DataColumn<T>(
/**
* Never translated: this is what the stored column layout is keyed on, so a language
* change must leave it alone or every operator's remembered widths and order would be
* read under names that no longer match.
*/
val id: String,
/**
* Read when the header is drawn, not stored as text. The column lists are built once at
* startup, outside any composition; a stored title would stay in the language that was
* in force then while everything around it switched.
*/
val title: () -> String,
val value: (T) -> String,
val tooltip: ((T, String) -> String)? = null,
val accent: ((T, String) -> CellAccent?)? = null,
val comparator: Comparator<T>? = null,
val weight: Float = 1f,
/**
* Columns sharing a group are drawn under one heading, as the JavaFX table drew
* its worked columns. Without it ten columns headed "wkdany 50 70 144 432 23 13
* 9 6 3" say nothing about what they mean.
*/
val group: String? = null,
) {
/** For a title that genuinely never changes -- a ham abbreviation, or a test. */
constructor(
id: String,
title: String,
value: (T) -> String,
tooltip: ((T, String) -> String)? = null,
accent: ((T, String) -> CellAccent?)? = null,
comparator: Comparator<T>? = null,
weight: Float = 1f,
group: String? = null,
) : this(id, { title }, value, tooltip, accent, comparator, weight, group)
}
/**
* Gives every row a key that survives a refresh.
*
* There are two kinds of list here and they need different answers:
*
* - **Entity lists** — the station list, the worked-stations list. A row *is* a
* station, and the same station comes back updated. Its own key is the identity:
* [RowKeys.byValue] with the raw callsign.
* - **Event lists** — the DX cluster and the QSO monitor. A row is something that
* happened. Two identical messages a second apart are genuinely two messages, so a
* key built from their fields would merge them: [RowKeys.byReference].
*
* There is deliberately no default. In Etappe 2 the visible defects came from row
* identity — a key that was the position shifted on every insert at the top, and one
* that was the text collapsed duplicates. Making the caller decide keeps that choice
* where the knowledge is.
*/
interface RowKeySource<T> {
fun keyOf(row: T): Any
/**
* Lets a source that remembers keys forget the rows that have gone.
*
* Called whenever the rows are replaced. Both stores behind these tables are
* capped on purpose — ten thousand cluster messages, thirty thousand chat messages
* — so that memory cannot grow without bound. A key map holding a strong reference
* to every row it ever saw would undo that cap from the outside.
*/
fun retainOnly(rows: List<T>) {}
/** How many keys are being remembered. Zero for a source that remembers none. */
fun size(): Int = 0
}
object RowKeys {
/** For rows that carry their own identity; nothing is remembered. */
fun <T> byValue(key: (T) -> String): RowKeySource<T> = object : RowKeySource<T> {
override fun keyOf(row: T): Any = key(row)
}
/**
* For append-only lists whose rows carry no identity.
*
* Keys are handed out on first sight and remembered by object reference, which
* holds because the core rosters hand back the same instances on every snapshot.
* A row that really is a new object gets a new key — which is the point.
*
* Synchronised throughout: the rows are replaced from the JavaFX thread while
* Compose draws on its own, and an IdentityHashMap torn between two threads hands
* out a duplicate key (the list then throws) or loses one (the row jumps). Both
* were seen in Etappe 2.
*/
fun <T> byReference(): RowKeySource<T> = object : RowKeySource<T> {
private val keys = IdentityHashMap<T, Long>()
private var next = 0L
override fun keyOf(row: T): Any = synchronized(keys) {
keys.getOrPut(row) { next++ }
}
override fun retainOnly(rows: List<T>) {
synchronized(keys) {
val surviving = IdentityHashMap<T, Boolean>()
rows.forEach { surviving[it] = true }
keys.keys.retainAll { surviving.containsKey(it) }
}
}
override fun size(): Int = synchronized(keys) { keys.size }
}
}
/**
* Where column widths are kept between sessions.
*
* An interface rather than a direct call to ChatPreferences, so the rules can be
* tested without one. The real implementation is two lines over
* `getTableColumnWidth`/`setTableColumnWidth` plus `LayoutAutosave.requestSave`:
* that storage is toolkit-free and stays as it is, while the JavaFX glue around it
* does not come across.
*/
interface ColumnWidthStore {
/** The stored width, or null when this column was never sized. */
fun width(tableId: String, columnId: String): Double?
fun setWidth(tableId: String, columnId: String, width: Double)
/** Asks for a debounced save; widths must not be written per pixel of a drag. */
fun requestSave()
}
/**
* The state of a multi-column table: its rows, its sorting and its selection.
*
* Free of Compose UI so the rules can be tested, but the three mutable properties are
* Compose state: a table bound to plain properties does not repaint, and with live
* data that shows up as a stale display rather than as an unresponsive one — harder
* to notice and worse in effect.
*/
class DataTableState<T>(
val columns: List<DataColumn<T>>,
private val rowKey: RowKeySource<T>,
/** Part of the stored layout: renaming it loses the operator's column sizing. */
private val tableId: String = "",
private val widths: ColumnWidthStore? = null,
) {
/**
* The order the columns are shown in, by id.
*
* Within the session only. The JavaFX TableView let the operator drag a column
* elsewhere but never stored where they put it — TableLayoutManager persists widths
* and ChatPreferences has no column order at all — and 1.50 is a functionally equal
* port, not an improvement on it.
*/
private var order: List<String> by mutableStateOf(columns.map { it.id })
/** The columns in the order they are shown. */
val orderedColumns: List<DataColumn<T>>
get() = order.mapNotNull { id -> columns.firstOrNull { it.id == id } }
/**
* Moves a column to another position.
*
* A target outside the table is clamped rather than refused: a drag that overshoots
* the last header means "put it at the end", which is what the operator did with
* the pointer.
*/
fun moveColumn(columnId: String, toIndex: Int) {
val from = order.indexOf(columnId)
if (from < 0) {
return
}
val target = toIndex.coerceIn(0, order.size - 1)
if (target == from) {
return
}
order = order.toMutableList().apply { add(target, removeAt(from)) }
}
/** Bumped on a resize so a table reading widths repaints. */
private var widthRevision: Int by mutableStateOf(0)
/**
* The stored width of a column, or null when it was never sized.
*
* Null rather than a made-up number: the caller falls back to the column's own
* weight, which is where the sensible default lives.
*/
fun widthOf(columnId: String): Double? {
@Suppress("UNUSED_EXPRESSION") widthRevision
return widths?.width(tableId, columnId)
}
/**
* Stores a new width for a column of this table.
*
* A width of zero or less is refused. A drag past the left edge would otherwise
* store a column the operator cannot grab again, and the sizing survives restarts.
*/
fun resizeColumn(columnId: String, width: Double) {
if (width <= 0.0 || columns.none { it.id == columnId }) {
return
}
widths?.let { store ->
store.setWidth(tableId, columnId, width)
store.requestSave()
widthRevision++
}
}
var rows: List<T> by mutableStateOf(emptyList())
private set
var sortedColumnId: String? by mutableStateOf(null)
private set
var sortAscending: Boolean by mutableStateOf(true)
private set
private var selectedKey: Any? by mutableStateOf(null)
private var sortedCache: List<T>? = null
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
/** Optional filter predicate. If set, [visibleRows] will only include matching rows. */
var rowFilter: ((T) -> Boolean)?
get() = _rowFilter
set(value) {
_rowFilter = value
invalidateOrder()
}
/** Incremented to force Compose to redraw visible rows when mutable backend objects change without list structural changes. */
var contentRevision by mutableStateOf(0)
private set
/**
* Redraws the rows and filters them again. The filter reads mutable row fields (a
* station's QRB, worked and QRV flags, its last activity), so a cached [visibleRows]
* from before the change could keep a row the filter now excludes, or hide one it
* now lets through -- in the list and in the station map, which draws the same rows.
*/
fun forceRedraw() {
invalidateOrder()
contentRevision++
}
/**
* The rows in the order they are shown.
*
* Computed once per change and then handed back. This is read on every
* recomposition, and sorting here would mean sorting up to thirty thousand rows
* per inbound message on the thread that draws — with a column that has no
* comparator calling its value function once per comparison on top.
*/
val visibleRows: List<T>
get() {
/*
* Read before the cache is consulted, and that order is the whole point.
* Compose repaints a composable when state it *read* changes; a cache that
* returned early stopped reading these, so the next recomposition left the
* table subscribed to nothing and later rows never reached the screen. It
* showed as a monitor window sitting empty beside a filling JavaFX tab,
* redrawing only when an unrelated click forced it.
*/
val currentRows = rows
val currentFilter = rowFilter
contentRevision // read for the subscription: forceRedraw drops the cache
val currentColumn = sortedColumnId
val ascending = sortAscending
sortedCache?.let { return it }
val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
val column = columns.firstOrNull { it.id == currentColumn }
val sorted = if (column == null) {
filteredRows
} else {
/*
* The displayed text is read once per row rather than once per
* comparison, and sortedBy is stable, so rows that compare equal keep
* the order they arrived in. Etappe 2 lost that: the JavaFX SortedList
* inserted by binary search and put ties wherever it landed, which made
* rows jump.
*/
val order = column.comparator
?: compareBy(filteredRows.associateWith { column.value(it) }::getValue)
if (ascending) {
filteredRows.sortedWith(order)
} else {
filteredRows.sortedWith(order.reversed())
}
}
sortedCache = sorted
return sorted
}
private fun invalidateOrder() {
sortedCache = null
}
/** The selected row, or null when nothing is selected or it has gone. */
val selectedRow: T?
get() = selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
/**
* Replaces the rows.
*
* The selection is kept **by key**, not by position: a station that moved because
* something above it was inserted is still the station the operator picked. When
* the selected row is gone the selection is dropped rather than sliding onto its
* neighbour, which would silently point the operator at someone else.
*/
fun replaceRows(newRows: List<T>) {
rows = newRows
rowKey.retainOnly(newRows)
invalidateOrder()
val key = selectedKey
if (key != null && newRows.none { rowKey.keyOf(it) == key }) {
selectedKey = null
}
}
fun select(row: T?) {
selectedKey = row?.let(rowKey::keyOf)
}
/**
* The key a row is drawn under. Its identity, never its position: a positional key
* reshuffles every row whenever something arrives at the top, which in a live list
* is constant.
*/
fun keyOf(row: T): Any = rowKey.keyOf(row)
fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey
/**
* Sorts by a column, cycling ascending, descending, unsorted.
*
* The third click is not a flourish: JavaFX TableView gives that cycle for free, and
* without it an operator who sorted the message list once could never get back to
* arrival order — newest at the bottom — short of restarting.
*
* An unknown column is ignored rather than clearing the sorting: it can only come
* from a stored layout that names a column this release no longer has.
*/
fun toggleSort(columnId: String) {
if (columns.none { it.id == columnId }) {
return
}
when {
sortedColumnId != columnId -> {
sortedColumnId = columnId
sortAscending = true
}
sortAscending -> sortAscending = false
// Third click: back to the order the rows arrived in.
else -> {
sortedColumnId = null
sortAscending = true
}
}
invalidateOrder()
}
}
@@ -0,0 +1,440 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.HorizontalScrollbar
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.material3.Surface
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.style.TextOverflow
import java.awt.Cursor
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* A multi-column table.
*
* The rules — row identity, selection across a refresh, stable sorting, stored widths
* — live in [DataTableState] and are tested there. What is here is only the drawing
* and the gestures, because nothing that draws is covered by a test in this release.
*
* Rows are keyed by the state's own row key, never by their position: the lists this
* table serves are live, and a positional key reshuffles every row whenever something
* arrives at the top.
*
* @param rowStyle colours a whole row, as the station list does from its priority
* score. Not used by the monitor tables, and deliberately still present: the
* interface is cut for the station list of Etappe 5.
*/
/*
* TooltipArea is experimental in Compose foundation. The opt-in is deliberate: it is
* the supported way to reach the hover text the JavaFX cells had, and losing that
* loses the only way to read a value that does not fit its column.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> DataTableView(
state: DataTableState<T>,
modifier: Modifier = Modifier,
rowStyle: ((T) -> CellAccent?)? = null,
onRowClick: ((T) -> Unit)? = null,
) {
val density = LocalDensity.current
val horizontal = rememberScrollState()
val vertical = rememberLazyListState()
/*
* These lists are newest-first, and a LazyColumn anchors to the item it was
* showing — so every arrival at the top pushes the view down and the operator
* drifts away from the newest line without touching anything.
*
* The rule: stay at the top while the operator is at the top, and leave them alone
* once they have scrolled down to read something. Yanking a reader back to the top
* on every inbound message would be worse than drifting.
*/
var stickToTop by remember { mutableStateOf(true) }
LaunchedEffect(vertical) {
snapshotFlow { vertical.isScrollInProgress }.collect { scrolling ->
/* Only a scroll the operator made ends; a prepend does not scroll. */
if (!scrolling) {
stickToTop = vertical.firstVisibleItemIndex == 0 &&
vertical.firstVisibleItemScrollOffset == 0
}
}
}
val newestKey = state.visibleRows.firstOrNull()?.let { state.keyOf(it) }
LaunchedEffect(newestKey) {
if (stickToTop && newestKey != null) {
vertical.scrollToItem(0)
}
}
/*
* Both directions scroll. The columns of these two tables need about 890dp and
* 1000dp, and the stored window width starts at 700: without this the last columns
* are simply unreachable, and so are the handles that would narrow them. The JavaFX
* tables had a horizontal scrollbar.
*/
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val slack = unusedWidth(state, maxWidth)
val lastColumnId = state.orderedColumns.lastOrNull()?.id
Column(modifier = Modifier.fillMaxSize()) {
/*
* A second header row above the first, spanning the columns that share a
* group. The station list needs it: ten columns headed "wkdany 50 70 144 432
* 23 13 9 6 3" say nothing without the word "worked" over them.
*/
val groups = state.orderedColumns.map { it.group }
if (groups.any { it != null }) {
Row(
modifier = Modifier
.horizontalScroll(horizontal)
.background(MaterialTheme.colorScheme.surfaceVariant)
.heightIn(min = Density.ROW_MIN_HEIGHT),
) {
var index = 0
while (index < state.orderedColumns.size) {
val group = groups[index]
var span = 1
while (index + span < groups.size && groups[index + span] == group && group != null) {
span++
}
val width = (index until index + span).sumOf { position ->
val column = state.orderedColumns[position]
val stored = state.widthOf(column.id)
val own = stored ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
own + Density.COLUMN_HANDLE_WIDTH.value
}
Box(
modifier = Modifier.width(width.dp).padding(horizontal = 4.dp),
contentAlignment = Alignment.Center,
) {
if (group != null) {
Text(
group,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
index += span
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
}
/*
* IntrinsicSize.Min, so the fillMaxHeight of the cells and the grab handles
* resolves against the header's own content instead of against the whole
* table. Without it the header took the entire height and the handles became
* full-length bars down the window.
*/
Row(
modifier = Modifier
.horizontalScroll(horizontal)
.background(MaterialTheme.colorScheme.surfaceVariant)
.height(IntrinsicSize.Min)
.heightIn(min = Density.ROW_MIN_HEIGHT),
) {
state.orderedColumns.forEachIndexed { index, column ->
val sorted = state.sortedColumnId == column.id
/*
* Click to sort, drag sideways to reorder — the two gestures the JavaFX
* header had. The drag is measured in whole column steps rather than
* pixels, so a column moves once per column crossed instead of
* flickering between two places.
*/
var dragged by remember(column.id) { mutableStateOf(0f) }
Box(
modifier = Modifier
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
.fillMaxHeight()
.clickable { state.toggleSort(column.id) }
.pointerInput(column.id, index) {
detectHorizontalDragGestures(
onDragEnd = { dragged = 0f },
onDragCancel = { dragged = 0f },
) { _, dragAmount ->
dragged += dragAmount
val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f
if (dragged > step) {
state.moveColumn(column.id, index + 1)
dragged = 0f
} else if (dragged < -step) {
state.moveColumn(column.id, index - 1)
dragged = 0f
}
}
}
.pointerHoverIcon(PointerIcon(Cursor(Cursor.MOVE_CURSOR)))
.padding(horizontal = 4.dp, vertical = 3.dp),
contentAlignment = Alignment.CenterStart,
) {
Text(
column.title() + if (sorted) (if (state.sortAscending) " ▲" else " ▼") else "",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
/*
* The grab handle between two headers. Two pixels wide on purpose: the
* JavaFX table put the same gesture on the column border, and operators
* resize these tables constantly during a contest.
*/
/*
* A hairline to look at, a wider area to grab. The JavaFX column border
* was a thin line; a 4dp bar in the outline colour reads as a wall
* between the columns.
*/
Box(
modifier = Modifier
.width(Density.COLUMN_HANDLE_WIDTH)
.fillMaxHeight()
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
.pointerInput(column.id) {
detectHorizontalDragGestures { _, dragAmount ->
/*
* The drag is in pixels, the stored width is in dp, and
* the stored value survives a restart. Mixing the two
* doubles a column on its first touch on a HiDPI screen
* and writes that into preferences.xml.
*/
val deltaDp = with(density) { dragAmount.toDp().value }
val current = state.widthOf(column.id)
?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
state.resizeColumn(column.id, current + deltaDp)
}
},
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outline),
)
}
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
state = vertical,
modifier = Modifier
.fillMaxSize()
.horizontalScroll(horizontal)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
itemsIndexed(state.visibleRows, key = { _, row -> state.keyOf(row) }) { index, row ->
val selected = state.isSelected(row)
val accent = rowStyle?.invoke(row)
val isEven = index % 2 == 0
val _forceRedraw = state.contentRevision
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.background(
when {
selected -> MaterialTheme.colorScheme.secondaryContainer
accent?.background != null -> accent.background
isEven -> Color.Transparent
else -> MaterialTheme.colorScheme.surfaceVariant
}
)
.clickable {
state.select(row)
onRowClick?.invoke(row)
}
.heightIn(min = Density.ROW_MIN_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
state.orderedColumns.forEach { column ->
val value = column.value(row)
val cell = column.accent?.invoke(row, value)
Box(
modifier = Modifier
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
.fillMaxHeight()
.background(cell?.background ?: Color.Transparent)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
TooltipArea(
tooltip = {
val hint = column.tooltip?.invoke(row, value) ?: value
if (hint.isNotBlank()) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 2.dp,
) {
Text(
hint,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(4.dp),
)
}
}
},
) {
Text(
value,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = cell?.foreground
?: accent?.foreground
?: MaterialTheme.colorScheme.onSurface,
)
}
}
Box(
modifier = Modifier
.width(Density.COLUMN_HANDLE_WIDTH)
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outlineVariant)
)
}
}
}
HorizontalDivider(
thickness = Density.HAIRLINE,
color = MaterialTheme.colorScheme.outline
)
}
}
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(vertical),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
HorizontalScrollbar(
adapter = rememberScrollbarAdapter(horizontal),
modifier = Modifier.fillMaxWidth(),
style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
}
/**
* A stored width when the operator has sized this column, otherwise the column's own
* weight. The stored value wins because it is the operator's decision.
*
* @param extra added to the last column so the table fills the window instead of
* leaving a band of empty background to the right. The JavaFX table did the
* same through `flexibleInitialWidth`.
*/
@Composable
private fun <T> Modifier.columnWidth(
state: DataTableState<T>,
column: DataColumn<T>,
extra: Dp = 0.dp,
): Modifier {
val stored = state.widthOf(column.id)
val own = if (stored != null) stored.dp else DEFAULT_COLUMN_WIDTH * column.weight
return this.width(own + extra)
}
/**
* How much is left over once every column has its width. Zero when the columns are
* already wider than the window, in which case the table scrolls.
*/
@Composable
private fun <T> unusedWidth(state: DataTableState<T>, available: Dp): Dp {
val used = state.orderedColumns.sumOf { column ->
val stored = state.widthOf(column.id)
val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
own + Density.COLUMN_HANDLE_WIDTH.value
}
return (available.value - used).coerceAtLeast(0.0).dp
}
private val DEFAULT_COLUMN_WIDTH = 85.dp
@@ -0,0 +1,68 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
/**
* The measurements of the settings windows, in one place so the tabs cannot drift
* apart.
*
* Material 3 ships phone densities: a text field is 56dp tall, a button 40dp and
* fully rounded, a checkbox reserves a 48dp touch target. The Station tab alone has
* over thirty fields, so those defaults turn a settings window into a scrolling
* exercise and make a desktop application look like a phone.
*
* Every height here is a **minimum**, never a fixed size. The configured base font
* size scales all text through Typography.scaledTo, and a fixed height would clip a
* larger font instead of growing with it.
*/
internal object Density {
/** Text fields, pickers and anything else on the field column. */
val FIELD_MIN_HEIGHT = 30.dp
/** Inside a text field. The vertical value is what buys back the 56dp. */
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
val BUTTON_MIN_HEIGHT = 24.dp
val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 1.dp)
/** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
val BUTTON_SHAPE = RoundedCornerShape(4.dp)
/** One row of a list or table. */
val ROW_MIN_HEIGHT = 26.dp
val TAB_MIN_HEIGHT = 30.dp
/**
* Left and right of a tab title. Material reserves 90dp minimum width per tab,
* which eleven tabs cannot live with; this is what a tab costs beyond its text.
*/
val TAB_HORIZONTAL_PADDING = 10.dp
/** The box itself; the 48dp touch target around it is switched off separately. */
val CHECK_SIZE = 18.dp
/** The label column of the forms, wide enough for the longest label in use. */
val LABEL_COLUMN_WIDTH = 260.dp
/** Between a label and its field, and between two fields. */
val FIELD_GAP = 3.dp
/** Between buttons in a row. */
val BUTTON_GAP = 6.dp
/** Around a selected row, and the hairline between rows. */
val SELECTION_BORDER = 1.5.dp
/** Kept free at the right edge so the scrollbar never sits on a control. */
val SCROLLBAR_GUTTER = 12.dp
/** Grab area between two columns; the line drawn inside it is a hairline. */
val COLUMN_HANDLE_WIDTH = 5.dp
val HAIRLINE = 1.dp
}
@@ -0,0 +1,89 @@
package kst4contest.view.compose
import kst4contest.model.ChatMessage
/**
* The columns of the directed-message table: what was said to this station.
*
* Its own pane above the send line rather than one of the tabs, because an operator must
* be able to see a private call without switching away from whatever they are reading.
*
* The ids are the keys the operator's stored column widths hang on, exactly as
* TableLayoutManager registered them under "private-messages".
*
* @param ownCallSign the local station, which gets no distance to itself
* @param formatMessage the controller's display formatting for a message text
*/
object DirectedMessageColumns {
fun all(
ownCallSign: () -> String? = { null },
formatMessage: (ChatMessage) -> String,
): List<DataColumn<ChatMessage>> = listOf(
DataColumn(
id = "time",
title = { kst4contest.view.i18n.CurrentStrings.get().columnTime },
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
weight = 0.7f,
),
DataColumn(
id = "callsign",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign },
value = { MonitorColumns.callSignOf(it.sender) },
),
DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
DataColumn(id = "qra", title = "QRA", value = { MonitorColumns.qraOf(it.sender) }),
DataColumn(
id = "qrb",
title = "QRB",
value = { message -> qrbOf(message, ownCallSign()) },
comparator = compareBy { it.sender?.qrb ?: 0.0 },
weight = 0.9f,
),
DataColumn(
id = "message",
title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage },
value = formatMessage,
weight = 3f,
),
DataColumn(
id = "last-qrg",
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastKnownQrg },
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
weight = 0.9f,
),
DataColumn(
id = "airscout",
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
weight = 1.4f,
),
DataColumn(
id = "category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
.orEmpty()
},
weight = 1.2f,
),
)
/**
* Distance and bearing as one cell, both cut to whole numbers to save width.
*
* The degree sign sits outside the bracket — "342 km (88)°" — which is how JavaFX
* wrote it and therefore how operators read it.
*/
private fun qrbOf(message: ChatMessage, ownCallSign: String?): String {
val sender = message.sender ?: return ""
if (sender.callSign == null || sender.callSign == ownCallSign) return ""
val qrb = sender.qrb ?: return ""
val bearing = sender.getQTFdirection() ?: return ""
return "${qrb.toInt()} km (${bearing.toInt()})°"
}
}
@@ -0,0 +1,68 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.view.PrivateMessageRowStyleResolver
/**
* The row colouring of the directed-message table.
*
* A new private message is highlighted and the highlight fades over five minutes, so a call
* that arrived while the operator was looking at the station list is still findable — being
* noticed is what that pane exists for. Own messages are blue at any age: they are context,
* not news.
*
* The steps come from [PrivateMessageRowStyleResolver], which the JavaFX row factory uses, so
* the two cannot drift apart. The colours are the stylesheets' `.messageHighlight*-column`,
* identical in both designs.
*/
object DirectedMessageRowStyle {
/** `.messageHighlightOwn-column`. */
val OWN = Color(0xFF00FFFF)
/** `.messageHighlight30-column`: just arrived. */
val FRESH = Color(0xFF33CC33)
/**
* The fading green of `.messageHighlight30` through `.messageHighlight300`, in the order
* the resolver's age limits name them.
*/
private val FADE = listOf(
FRESH,
Color(0xFF40BF40),
Color(0xFF4DB34D),
Color(0xFF59A659),
Color(0xFF669966),
Color(0xFF738C73),
)
/** The resolver's style class names, in the same order as [FADE]. */
private val FADE_CLASSES = listOf(
"messageHighlight30-column",
"messageHighlight60-column",
"messageHighlight90-column",
"messageHighlight120-column",
"messageHighlight180-column",
"messageHighlight300-column",
)
/**
* How a row is coloured, or null once the message is no longer news.
*
* The style class is asked of the resolver rather than recomputed here, so a change to
* the fade steps reaches both views at once.
*/
fun accentFor(ownMessage: Boolean, ageSeconds: Long): CellAccent? {
val styleClass = PrivateMessageRowStyleResolver.resolveStyleClass(ownMessage, ageSeconds)
?: return null
if (styleClass == PrivateMessageRowStyleResolver.OWN_STYLE_CLASS) {
return CellAccent(background = OWN, foreground = Color.Black)
}
val index = FADE_CLASSES.indexOf(styleClass)
if (index < 0) return null
return CellAccent(background = FADE[index], foreground = Color.White)
}
}
@@ -0,0 +1,643 @@
package kst4contest.view.compose
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.FlowRowScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.ui.graphics.Brush
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Form controls shared by the settings tabs.
*
* Numeric fields keep the operator's text while it is being typed and only hand a
* parsed value upwards when it parses. Rejecting keystrokes instead would make a
* field impossible to clear, and replacing an unparsable entry with a default would
* silently change a setting — PROJECT_CONTEXT is explicit that an absent or malformed
* value must not become a fabricated zero.
*
* Every control here is built to desktop density; see [Density] for why and for the
* measurements.
*/
object Form {
@Composable
fun text(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
val draft = rememberDraft(value ?: "")
labelled(label) {
CompactTextField(
value = draft.value,
onValueChange = { typed ->
draft.value = typed
onChange(typed)
},
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun password(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
val draft = rememberDraft(value ?: "")
labelled(label) {
CompactTextField(
value = draft.value,
onValueChange = { typed ->
draft.value = typed
onChange(typed)
},
enabled = enabled,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* A text field that is validated when it is committed, not while it is typed.
*
* This is the Compose counterpart of the JavaFX fields which hung their rule on
* `focusedProperty` — the DX cluster port, the spotter callsign, the beacon
* templates and the beacon interval. Committing on every keystroke would refuse
* every prefix of a valid entry, so the draft lives here until the operator leaves
* the field or presses Enter.
*
* Only an edited draft is committed: drawing the field, or entering and leaving it
* without typing, commits nothing, and neither does Enter on an unchanged value.
* A field removed while it holds the focus -- its tab switched away, its window
* closed -- commits a typed entry once: Compose reports the cleared focus to the
* field as a focus loss.
*
* @param stored the value stored when the caller composed; the draft starts from it
* @param commit returns null when the value was accepted, otherwise the reason
* @param onRefused shown to the operator; the JavaFX fields opened an alert
* @param isUnchanged whether the draft names the stored value. Textual by default,
* looser where two spellings mean one value, as with colours. It must agree
* with what [commit] stores: a draft that commit would store as `c` has to count
* as unchanged against `c`, or leaving the field after Enter commits it again.
* @param current the value in force right now, read when the field commits: inside the
* settings window the value staged in the draft, otherwise the stored one. The
* typed text is judged against it, and falls back to it on a refusal (what the
* JavaFX fields did with setText). Required, because [stored] alone goes stale
* when it comes from a plain getter: nothing need recompose the caller after a
* commit, so after Enter it can still name the old value.
*/
@Composable
fun committed(
label: String,
stored: String,
commit: (String) -> String?,
onRefused: (String) -> Unit,
isUnchanged: (draft: String, stored: String) -> Boolean = { typed, now -> typed == now },
current: () -> String,
) {
var draft by remember(stored) { mutableStateOf(stored) }
/*
* Whether the field has held the focus since it was last committed. onFocusChanged
* also fires when the modifier is first attached, with "not focused"; reading that
* as a focus loss committed every such field the moment its tab was opened. Only a
* change from focused to unfocused is the operator leaving the field. A plain holder,
* not state: nothing is drawn from it.
*/
val hadFocus = remember { booleanArrayOf(false) }
fun commitDraft() {
/*
* An untouched draft is no edit. Committing it anyway would turn a shipped colour
* into an override of itself, restart the beacon timer, or report a stored value
* the rule refuses although the operator only passed through the field.
*/
if (isUnchanged(draft, current())) return
val refusal = commit(draft)
if (refusal != null) {
onRefused(refusal)
draft = current()
}
}
/*
* Inside the settings window the draft can commit this field without a focus change:
* a click on "Apply/Close prefs" may leave the focus in the field, and the entry would
* otherwise reach the draft only when the closing window removed the field. Through
* an updated state, because commitDraft closes over this composition's parameters.
* A refusal is reported by commitDraft itself through onRefused, as on focus loss, so
* the registration answers null and the caller sees the refusal as the notice.
*/
val settingsDraft = LocalSettingsDraft.current
val latestCommit = rememberUpdatedState(::commitDraft)
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry {
latestCommit.value()
null
}
onDispose { entry?.close() }
}
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focus ->
if (focus.isFocused) {
hadFocus[0] = true
} else if (hadFocus[0]) {
hadFocus[0] = false
commitDraft()
}
}
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
commitDraft()
true
} else {
false
}
},
)
}
}
/**
* Picks one item from a fixed list, the counterpart of the JavaFX ChoiceBox.
*
* A null selection is possible and shown as such: the second chat category has no
* value until the operator picks one, and inventing one would log the station into
* a chat it never chose.
*/
@Composable
fun <T> choice(
label: String,
items: List<T>,
selected: T?,
describe: (T) -> String,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val draft = rememberDraft(selected)
labelled(label) {
Box {
OutlinedButton(
onClick = { expanded = true },
enabled = enabled,
shape = Density.BUTTON_SHAPE,
contentPadding = Density.FIELD_CONTENT_PADDING,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
items.forEach { item ->
DropdownMenuItem(
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
onClick = {
expanded = false
draft.value = item
onSelect(item)
},
)
}
}
}
}
}
@Composable
fun <T> inlineChoice(
label: String,
items: List<T>,
selected: T?,
describe: (T) -> String,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val draft = rememberDraft(selected)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp))
Box {
OutlinedButton(
onClick = { expanded = true },
enabled = enabled,
shape = Density.BUTTON_SHAPE,
contentPadding = Density.FIELD_CONTENT_PADDING,
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
style = MaterialTheme.typography.bodyMedium,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
items.forEach { item ->
DropdownMenuItem(
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
onClick = {
expanded = false
draft.value = item
onSelect(item)
},
)
}
}
}
}
}
@Composable
fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
val draft = rememberDraft(value)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
) {
CompactCheckbox(draft.value) { ticked ->
draft.value = ticked
onChange(ticked)
}
Text(label, modifier = Modifier.padding(start = 6.dp))
}
}
@Composable
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
number(label, value, { it.trim().toIntOrNull() }, onChange)
@Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange)
/**
* The number field behind [int] and [decimal].
*
* The text follows the stored value only when that value is not what the operator's own
* text already says. A settings draft is Compose state, so the value an entry stages comes
* straight back as [value]; resetting the text to `value.toString()` then would turn a
* typed "1" into "1.0" before the next digit, and "135,5" could never be typed.
*/
@Composable
private fun <N> number(label: String, value: N, parse: (String) -> N?, onChange: (N) -> Unit) {
// The last text typed. A plain holder: it only seeds the text below, nothing draws it.
val typed = remember { arrayOf(value.toString()) }
var draft by remember(value) {
mutableStateOf(if (parse(typed[0]) == value) typed[0] else value.toString())
}
/*
* Text that does not parse stages nothing, so the draft still holds the last valid
* value while the field shows something else in red. Applying then would put a value
* into effect the operator no longer sees; the field refuses instead, naming itself.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (parse(draft) == null) strings.settingsInvalidEntry(label, draft) else null
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { entered ->
typed[0] = entered
draft = entered
parse(entered)?.let(onChange)
},
isError = parse(draft) == null,
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* A titled section.
*
* The JavaFX window drew `generateLabeledSeparator`: a rule, the label, another
* rule. Here the label leads and one rule runs to the right edge, which sits
* better above the left-aligned label column than a centred title does. Either
* way it is a visible division, which a bare line of text was not.
*/
@Composable
fun section(title: String, content: @Composable () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp),
) {
Text(title, style = MaterialTheme.typography.titleSmall)
HorizontalDivider(modifier = Modifier.weight(1f).padding(start = 8.dp))
}
content()
}
}
/**
* A section whose controls flow across the width instead of stacking.
*
* For short, like-for-like options — the nine band checkboxes, the startup filters.
* Stacked they cost nine rows of height for nine words; flowing they fill the width
* the window actually has and re-flow when it changes.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun flowingSection(title: String, content: @Composable FlowRowScope.() -> Unit) {
section(title) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
}
/**
* A labelled row holding a control this object does not provide, so a tab can add
* one without losing the shared label column.
*/
@Composable
fun labelledRow(label: String, field: @Composable () -> Unit) {
labelled(label, field)
}
/**
* The button of the settings windows: square-ish corners and desktop padding.
*
* Material's own Button is a 40dp pill, which is the single strongest reason the
* window looked like a phone application.
*/
/**
* The button of the settings windows.
*
* Material's own Button is a 40dp pill; this one is square-ish and at desktop
* height. It also carries the two states the JavaFX stylesheets give every button:
* a gradient while the pointer is over it and a red border while it is held down.
*/
@Composable
fun button(text: String, enabled: Boolean = true, onClick: () -> Unit) {
val palette = LocalJavaFxPalette.current
val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState()
val pressed by interactions.collectIsPressedAsState()
val hoverGradient = palette.buttonHoverGradient
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides 0.dp
) {
Button(
onClick = onClick,
enabled = enabled,
shape = Density.BUTTON_SHAPE,
contentPadding = Density.BUTTON_CONTENT_PADDING,
interactionSource = interactions,
border = if (pressed) {
BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
} else {
null
},
colors = if (hovered && enabled && hoverGradient.isNotEmpty()) {
/*
* Compose paints a solid container, so the gradient is applied as the
* background below and the container itself is cleared. The text turns
* dark because the gradient the sheets use is a bright lime.
*/
ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = Color.Black,
)
} else {
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = Modifier
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
.then(
if (hovered && enabled && hoverGradient.size >= 2) {
Modifier.background(
brush = Brush.verticalGradient(hoverGradient),
shape = Density.BUTTON_SHAPE,
)
} else {
Modifier
}
),
) {
/*
* One line, always. A label left to wrap turns a squeezed button into a tall
* bar instead of simply being too narrow, and that bar then pushes the rest
* of the window out of view.
*/
Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
}
}
/**
* Holds what the operator sees while they change it.
*
* The settings controls are bound to plain ChatPreferences values, and those are not
* Compose state: writing one triggers no recomposition, so a field bound straight to
* the stored value keeps painting the value from the last composition. Typing then
* writes through correctly and shows nothing, a checkbox never appears to tick, and a
* picker never appears to change — which is exactly how it behaved.
*
* Keyed on the stored value, so an external change still reaches the control, and so
* a setter that normalises its input (a trimmed space, an upper-cased callsign) puts
* the normalised value back. This is the pattern GuardedTextField already used for
* the external-interface fields.
*/
@Composable
private fun <T> rememberDraft(stored: T): MutableState<T> =
remember(stored) { mutableStateOf(stored) }
@Composable
private fun labelled(label: String, field: @Composable () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(Density.LABEL_COLUMN_WIDTH),
)
field()
}
}
}
/**
* A text field at desktop density.
*
* Built from BasicTextField and the outlined decoration box rather than from
* OutlinedTextField, because that composable enforces a 56dp minimum height which
* cannot be passed a smaller one. The decoration box takes the content padding, and
* that is what buys the height back.
*
* The decoration box and its container are marked experimental in Material 3. The
* opt-in is deliberate and there is no alternative: it is the only supported way to
* build a text field below the enforced minimum height. Should the signature change,
* the two calls below are the whole surface to fix, and the tests around the tab states
* stay unaffected because none of them render.
*
* @param borderColor overrides the outline, so a caller can mark a selected row
* @param borderThickness overrides the outline width for the same reason
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CompactTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
borderColor: Color? = null,
borderThickness: Dp? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
/*
* The container is the JavaFX -fx-control-inner-background, which both sheets set
* apart from the window background. Material leaves an outlined field transparent,
* which made every field vanish into the surface.
*/
val colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = borderColor ?: MaterialTheme.colorScheme.outline,
focusedBorderColor = borderColor ?: MaterialTheme.colorScheme.primary,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
)
/*
* The colour has to be carried in the text style: BasicTextField has no theme of
* its own, so without this the text would stay black in the evening design.
*/
val onField = MaterialTheme.colorScheme.surfaceVariant.let { field ->
val brightness = 0.3f * field.red + 0.59f * field.green + 0.11f * field.blue
if (brightness > 0.5f) Color.Black else Color.White
}
val textColor = if (enabled) onField else onField.copy(alpha = 0.38f)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = visualTransformation,
interactionSource = interactionSource,
modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
) { innerTextField ->
OutlinedTextFieldDefaults.DecorationBox(
value = value,
innerTextField = innerTextField,
enabled = enabled,
singleLine = true,
visualTransformation = visualTransformation,
interactionSource = interactionSource,
isError = isError,
colors = colors,
contentPadding = Density.FIELD_CONTENT_PADDING,
container = {
OutlinedTextFieldDefaults.Container(
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
shape = Density.BUTTON_SHAPE,
focusedBorderThickness = borderThickness ?: 2.dp,
unfocusedBorderThickness = borderThickness ?: 1.dp,
)
},
)
}
}
/**
* A checkbox without the 48dp touch target Material reserves around it. On a desktop
* form that target is dead space between every two rows.
*/
@Composable
internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = Modifier.size(Density.CHECK_SIZE),
)
}
}
@@ -0,0 +1,447 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.ApplicationConstants
import kst4contest.model.PaletteRole
/**
* The colours the Compose windows take from the JavaFX stylesheets.
*
* @param fontSizePx the `-fx-font-size` of `.root`; both shipped sheets set 12px
*/
data class JavaFxPalette(
val base: Color,
/**
* What a window is actually painted with. Modena's `-fx-background` is
* `derive(-fx-base, 26.4%)`, not `-fx-base` itself: taking the base raw makes a
* Compose window visibly darker than the JavaFX one beside it.
*/
val windowBackground: Color,
val accent: Color,
/**
* The colour the operator actually reads as the accent.
*
* `-fx-accent` only drives JavaFX's own selection bar, which barely shows. What is
* seen instead is `.text-field .text`: both sheets fill the text inside every field
* — and the table column headers of the evening sheet — with a green gradient. A
* solid colour is needed here, so the gradient is collapsed to its midpoint.
*/
val textAccent: Color,
val controlInnerBackground: Color,
val labelTextFill: Color,
val separatorLine: Color,
val buttonHoverGradient: List<Color>,
val buttonPressedBorder: Color,
val fontSizePx: Float,
)
/**
* Reads the colours of a JavaFX stylesheet.
*
* This is deliberately not a CSS engine. It looks up the handful of declarations the
* Compose windows need and understands only what those declarations actually use:
* colour literals, named colours, references to another `-fx-` value in the same
* sheet, `derive(colour, percent)` and the stops of a `linear-gradient`.
*
* **The source is the classpath resource, the same one JavaFX loads.** The copies
* under the application directory are written at startup but never read back:
* `applyThemeStylesheet` passes the bare file name to `Scene.getStylesheets()`, and
* JavaFX resolves a relative stylesheet name against the classpath. Reading the copy
* here would make the two windows disagree exactly when an operator edits it.
*
* The daylight sheet declares almost nothing and relies on JavaFX's own Modena
* defaults, so every lookup falls back to the matching Modena value.
*/
object JavaFxStylesheet {
/*
* The classpath paths of the two shipped sheets, derived from the one place that owns
* their file names. They used to be spelled out in four places -- here, the theme, the
* store factory and the startup copy -- and two of those had to stay equal for the root
* profile's own stylesheet to be read at all: the copy is written under one name and read
* under the other. A rename on one side would silently restore the very defect this stage
* was commissioned to fix, with a green build.
*/
val DAYLIGHT_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT
val EVENING_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING
/**
* The shipped palette of one design.
*
* @param darkMode true for the evening design, false for the daylight one
* @return the complete palette, Modena fallbacks included
*/
fun shipped(darkMode: Boolean): JavaFxPalette =
read(if (darkMode) EVENING_RESOURCE else DAYLIGHT_RESOURCE)
/** Modena's own `-fx-background: derive(-fx-base, 26.4%)`. */
private const val MODENA_BACKGROUND_DERIVE = 26.4
/* Modena's defaults, used wherever a sheet declares nothing. */
private val MODENA_BASE = Color(0xFFECECEC)
private val MODENA_ACCENT = Color(0xFF0096C9)
private val MODENA_CONTROL_INNER = Color(0xFFFFFFFF)
private val MODENA_TEXT = Color(0xFF000000)
private val NAMED_COLORS = mapOf(
"white" to Color.White,
"black" to Color.Black,
"lightgray" to Color(0xFFD3D3D3),
"lightgrey" to Color(0xFFD3D3D3),
"gray" to Color(0xFF808080),
"grey" to Color(0xFF808080),
"darkgray" to Color(0xFFA9A9A9),
"green" to Color(0xFF008000),
"lightgreen" to Color(0xFF90EE90),
"red" to Color(0xFFFF0000),
"blue" to Color(0xFF0000FF),
"yellow" to Color(0xFFFFFF00),
"orange" to Color(0xFFFFA500),
"transparent" to Color.Transparent,
)
/**
* @param resourcePath classpath path of the sheet, for example
* `/KST4ContestDefaultEvening.css`
*/
fun read(resourcePath: String): JavaFxPalette {
val source = JavaFxStylesheet::class.java.getResource(resourcePath)?.readText().orEmpty()
val blocks = parseBlocks(source)
val root = blocks[".root"].orEmpty()
val base = colorOf(root["-fx-base"], root) ?: MODENA_BASE
val accent = colorOf(root["-fx-accent"], root) ?: MODENA_ACCENT
return JavaFxPalette(
base = base,
windowBackground = colorOf(root["-fx-background"], root)
?: derive(base, MODENA_BACKGROUND_DERIVE),
textAccent = midpointOf(
gradientStops(blocks[".text-field .text"]?.get("-fx-fill"), root)
) ?: accent,
accent = accent,
controlInnerBackground = colorOf(root["-fx-control-inner-background"], root)
?: MODENA_CONTROL_INNER,
labelTextFill = colorOf(blocks[".label"]?.get("-fx-text-fill"), root) ?: MODENA_TEXT,
/* Modena draws the line one fifth darker than the base. */
separatorLine = colorOf(blocks[".separator *.line"]?.get("-fx-background-color"), root)
?: derive(base, -20.0),
buttonHoverGradient = gradientStops(
blocks[".button:hover"]?.get("-fx-background-color"), root
),
buttonPressedBorder = colorOf(blocks[".button:pressed"]?.get("-fx-border-color"), root)
?: accent,
fontSizePx = root["-fx-font-size"]?.removeSuffix("px")?.trim()?.toFloatOrNull() ?: 12f,
)
}
/**
* The roles a stylesheet source actually names.
*
* Deliberately not [read]: that one fills a missing role from the Modena defaults, which
* is right for the shipped sheets and wrong for a layer meant to override one role at a
* time. A sheet saying only `-fx-base` must declare exactly one role.
*
* Each role is looked up at its own selector, because three of the six are stated
* outside `.root` and one of those properties -- `-fx-background-color` -- appears a
* dozen times elsewhere in the evening sheet. `.root` stays the scope in which a `-fx-`
* reference or a `derive(...)` is resolved, exactly as [read] resolves them.
*
* A value this reader does not understand -- a gradient, say -- leaves the role
* undeclared, so it falls through to the layer below rather than becoming a guess.
*/
fun declaredRoles(source: String): Map<PaletteRole, Color> {
val blocks = parseBlocks(source)
val root = blocks[".root"].orEmpty()
val stated = PaletteRole.values().mapNotNull { role ->
val value = blocks[role.selector()]?.get(role.cssProperty())
?: return@mapNotNull null
val colour = colorOf(value, root) ?: return@mapNotNull null
role to colour
}.toMap()
/*
* A sheet that states `-fx-base` and no `-fx-background` has stated the window surface
* too, because that is what JavaFX does with it: Modena's `-fx-background` is
* `derive(-fx-base, 26.4%)`, and [read] applies the same fallback. Without this the
* most natural hand edit is nearly inert -- `-fx-base: #303030` would leave every
* window the shipped light grey, since the base itself reaches the screen only through
* the menu strip and the selection tint.
*
* Only here, in the file layer. The settings tab keeps the two roles independent,
* which is why the spec separates them at all: six fields are visible there, so an
* operator who wants both changed can say so.
*
* A consequence worth stating rather than discovering: the tab then reports the window
* surface as coming *from file* for a colour the file never spells out. That is the
* right answer. The provenance exists to answer "why is this colour what it is", not
* "which line names it", and the file's `-fx-base` is why.
*/
val base = stated[PaletteRole.SURFACE]
if (base == null || stated.containsKey(PaletteRole.WINDOW_SURFACE)) {
return stated
}
return stated + (PaletteRole.WINDOW_SURFACE to derive(base, MODENA_BACKGROUND_DERIVE))
}
/**
* The roles a stylesheet file names, or none when it cannot be read.
*
* Never throws. A client that will not start because of a broken colour file is worse
* than a client in the wrong colours, and this file is one the operator edited by hand.
*/
fun declaredRolesOfFile(file: java.io.File): Map<PaletteRole, Color> =
runCatching {
if (!file.isFile) emptyMap() else declaredRoles(file.readText())
}.getOrDefault(emptyMap())
/**
* JavaFX `derive(colour, percent)`, reimplemented.
*
* No JavaFX call and no reflection: this is the algorithm itself. The brightness it
* derives from is not the HSB value but `0.3R + 0.59G + 0.11B`, and the positive
* branch follows a staircase rather than a formula, which is why an earlier
* approximation here was measurably wrong.
*
* Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this against
* values the real JavaFX colour-derivation routine produced, frozen into
* `javafx-derive-reference.txt` so the check survives JavaFX being removed. That
* file's header names the exact class the values came from.
* Every grey and both stylesheet bases match. One saturated blue drifts by up to
* three steps per channel. That is tolerable only because no saturated colour
* reaches here: three of the four call sites pass the window background or
* `-fx-base`, and the fourth is [parseColour]'s own `derive(...)` branch below,
* which passes whatever a sheet names — and every `derive(...)` in both shipped
* sheets derives from `-fx-base` alone. A hand-edited sheet can break that: an
* operator who sets `-fx-base` to a saturated colour sends every `derive(...)` of it
* through the inaccurate part. Revisited when the colours became operator-editable and
* deliberately left alone — a drift of three steps per channel is not perceptible in a
* colour somebody chose themselves, and what the settings tab sets does not pass
* through here at all: the resolver substitutes finished colours.
*/
fun derive(color: Color, percent: Double): Color {
val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue
var calcBrightness = percent / 100.0
if (calcBrightness > 0) {
if (baseBrightness > 0.85) {
calcBrightness *= 1.6
} else if (baseBrightness > 0.6) {
// no change
} else if (baseBrightness > 0.5) {
calcBrightness *= 0.9
} else if (baseBrightness > 0.4) {
calcBrightness *= 0.8
} else if (baseBrightness > 0.3) {
calcBrightness *= 0.7
} else {
calcBrightness *= 0.6
}
} else {
if (baseBrightness < 0.2) {
calcBrightness *= 0.6
}
}
calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)
// RGBtoHSB
val r = color.red.toDouble()
val g = color.green.toDouble()
val b = color.blue.toDouble()
val cmax = maxOf(r, g, b)
val cmin = minOf(r, g, b)
val brightness = cmax
val saturation = if (cmax != 0.0) (cmax - cmin) / cmax else 0.0
var hue = 0.0
if (saturation != 0.0) {
val redc = (cmax - r) / (cmax - cmin)
val greenc = (cmax - g) / (cmax - cmin)
val bluec = (cmax - b) / (cmax - cmin)
hue = when {
r == cmax -> bluec - greenc
g == cmax -> 2.0 + redc - bluec
else -> 4.0 + greenc - redc
}
hue /= 6.0
if (hue < 0) {
hue += 1.0
}
}
hue *= 360.0
var derivedSat = saturation
var derivedBri = brightness
if (calcBrightness > 0) {
derivedSat *= (1.0 - calcBrightness)
derivedBri += (1.0 - derivedBri) * calcBrightness
} else {
derivedBri *= (calcBrightness + 1.0)
}
derivedSat = derivedSat.coerceIn(0.0, 1.0)
derivedBri = derivedBri.coerceIn(0.0, 1.0)
return Color.hsv(hue.toFloat(), derivedSat.toFloat(), derivedBri.toFloat(), color.alpha)
}
/** `selector { property: value; ... }`, comments removed. */
private fun parseBlocks(source: String): Map<String, Map<String, String>> {
val withoutComments = source.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "")
val blocks = mutableMapOf<String, MutableMap<String, String>>()
Regex("([^{}]+)\\{([^{}]*)\\}").findAll(withoutComments).forEach { match ->
val selector = match.groupValues[1].trim().replace(Regex("\\s+"), " ")
val declarations = blocks.getOrPut(selector) { mutableMapOf() }
splitOnTopLevel(match.groupValues[2], ';').forEach { declaration ->
val separator = declaration.indexOf(':')
if (separator > 0) {
declarations[declaration.take(separator).trim()] =
declaration.substring(separator + 1).trim()
}
}
}
return blocks
}
/**
* Resolves one value to a colour, or null when it is absent or is something this
* reader does not need to understand.
*
* @param variables the `.root` declarations, where `-fx-` references are looked up
*/
private fun colorOf(value: String?, variables: Map<String, String>, depth: Int = 0): Color? {
val text = value?.trim()?.removeSuffix(";")?.trim() ?: return null
/* A malformed sheet must not spin here: -fx-a: -fx-b; -fx-b: -fx-a; */
if (depth > 8 || text.isEmpty()) {
return null
}
NAMED_COLORS[text.lowercase()]?.let { return it }
if (text.startsWith("#")) {
return hexColor(text)
}
if (text.startsWith("-fx-")) {
return colorOf(variables[text], variables, depth + 1)
}
if (text.startsWith("derive(")) {
val arguments = splitOnTopLevel(text.removePrefix("derive(").dropLast(1), ',')
if (arguments.size == 2) {
val source = colorOf(arguments[0], variables, depth + 1) ?: return null
val percent = arguments[1].removeSuffix("%").trim().toDoubleOrNull() ?: return null
return derive(source, percent)
}
return null
}
if (text.contains("linear-gradient(")) {
return gradientStops(text, variables).firstOrNull()
}
return null
}
/**
* The colour stops of the first `linear-gradient` in a value. A stop may carry a
* position (`green 0%`) or a direction (`from 0% 0% to 100% 200%`); only the
* colours are wanted here.
*/
private fun gradientStops(value: String?, variables: Map<String, String>): List<Color> {
val text = value?.trim() ?: return emptyList()
val start = text.indexOf("linear-gradient(")
if (start < 0) {
return emptyList()
}
var depth = 0
var end = -1
for (index in start + "linear-gradient".length until text.length) {
when (text[index]) {
'(' -> depth++
')' -> {
depth--
if (depth == 0) {
end = index
break
}
}
}
}
if (end < 0) {
return emptyList()
}
return splitOnTopLevel(text.substring(start + "linear-gradient(".length, end), ',')
.filterNot { it.startsWith("from ") || it.startsWith("to ") }
.mapNotNull { colorOf(it.split(Regex("\\s+")).first(), variables) }
}
/**
* Collapses a gradient to one colour by averaging its ends. Compose paints solid
* where JavaFX paints a gradient across the glyphs, and the average is what the eye
* takes from the gradient anyway.
*/
private fun midpointOf(stops: List<Color>): Color? {
if (stops.isEmpty()) {
return null
}
val first = stops.first()
val last = stops.last()
return Color(
red = (first.red + last.red) / 2f,
green = (first.green + last.green) / 2f,
blue = (first.blue + last.blue) / 2f,
alpha = 1f,
)
}
/** Splits on a separator that is not inside brackets. */
private fun splitOnTopLevel(text: String, separator: Char): List<String> {
val parts = mutableListOf<String>()
var depth = 0
var start = 0
text.forEachIndexed { index, character ->
when (character) {
'(' -> depth++
')' -> depth--
separator -> if (depth == 0) {
parts.add(text.substring(start, index))
start = index + 1
}
}
}
parts.add(text.substring(start))
return parts.map { it.trim() }.filter { it.isNotEmpty() }
}
private fun hexColor(text: String): Color? {
val digits = text.removePrefix("#")
val expanded = when (digits.length) {
3 -> digits.toCharArray().joinToString("") { "$it$it" }
6 -> digits
else -> return null
}
val value = expanded.toLongOrNull(16) ?: return null
return Color(0xFF000000L or value)
}
}
@@ -0,0 +1,305 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kst4contest.controller.On4KstConnectionState
import kst4contest.utils.PlatformUtils
import kst4contest.view.i18n.LocalStrings
/**
* What the menu bar does when something is picked.
*
* An interface rather than a dozen lambdas: every window is meant to carry this same bar, and a
* parameter list that long is misread sooner or later.
*/
interface MainMenuActions {
fun connect()
fun disconnect()
fun switchOperatorProfile()
fun exitApplication()
fun setQrgAsNameInChat()
fun toggleAwayState()
fun toggleSettingsWindow()
fun toggleMonitorWindow()
fun toggleStationMap()
fun useDarkDesign()
fun useDefaultDesign()
fun openDonationPage()
fun openHomepage()
fun openNewsgroup()
/** The JavaFX item and its handler are both commented out; kept so the bar can say so. */
fun openChangelog()
/** OV3T runs the aeroplane feed the AirScout predictions come from. */
fun openOv3tDonationPage()
fun contactAuthor()
fun showAbout()
}
/**
* The menu bar in the system's own bar — macOS only.
*
* Compose's `MenuBar` is a `javax.swing.JMenuBar` set on the window, so Swing draws it and
* no Compose theme reaches it. On macOS that is what one wants: the bar belongs at the top
* of the screen, drawn by the system. Everywhere else it meant an unthemed Metal menu
* sitting above a themed window, which is why [Kst4ContestMenuRow] exists.
*
* Both renderers read [mainMenuModel]. Declaring the menus twice is how one of them quietly
* loses an item the other gained.
*/
@Composable
fun FrameWindowScope.Kst4ContestMenuBar(
state: MainMenuState,
actions: MainMenuActions,
settingsWindowOpen: Boolean,
monitorWindowOpen: Boolean,
/**
* The connection state, for the read-only macOS menu. On macOS the status bar hides its
* badge because the indicator lives in the system menu bar, so without this menu a
* macOS operator would have no connection indicator at all.
*/
connectionState: On4KstConnectionState? = null,
connectionDetail: String? = null,
) {
val strings = LocalStrings.current
MenuBar {
mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen, strings)
.forEach { menu ->
Menu(menu.title) {
menu.entries.forEach { entry ->
when (entry) {
is MenuEntry.Item ->
Item(entry.label, enabled = entry.enabled, onClick = entry.onClick)
MenuEntry.Separator -> Separator()
}
}
}
}
/*
* The connection indicator, for macOS only: the system menu bar is where it lives
* there, because the status bar's badge is hidden on that platform. Read-only — the
* single item is disabled, exactly as menuItemConnectionStateDetailMacOs was.
*/
if (PlatformUtils.isMacOs()) {
Menu(ConnectionIndicator.macOsMenuTitle(connectionState)) {
Item(
ConnectionIndicator.detailFor(connectionState, connectionDetail),
enabled = false,
onClick = {},
)
}
}
}
}
/**
* The menu bar drawn inside the window, by Compose.
*
* Used everywhere except macOS. It exists because Compose's `MenuBar` hands the job to
* Swing, which knows nothing of the stylesheet palette: under Linux that produced the
* default cross-platform Metal menu above a themed window. This row reads the same palette
* as every other control, so a design switch reaches it.
*
* A menu title is shaped like a tab — flat, highlighted while active — so it takes the tab
* measurements rather than the button ones, and it highlights with the accent colour, which
* is what a JavaFX menu did.
*
* @param rowState which menu is down. Owned by the caller because `MainWindow` has to ask
* [MainMenuRowState.anyOpen] before letting the window-wide keys run.
*/
@Composable
fun Kst4ContestMenuRow(
state: MainMenuState,
actions: MainMenuActions,
settingsWindowOpen: Boolean,
monitorWindowOpen: Boolean,
rowState: MainMenuRowState,
modifier: Modifier = Modifier,
) {
val palette = LocalJavaFxPalette.current
val strings = LocalStrings.current
val menus = mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen, strings)
Row(
modifier = modifier
.fillMaxWidth()
.background(palette.base)
.heightIn(min = Density.TAB_MIN_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
menus.forEach { menu ->
MenuTitle(menu = menu, rowState = rowState)
}
}
}
@Composable
private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
val palette = LocalJavaFxPalette.current
val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState()
val open = rowState.openMenu == menu.title
/*
* Full bar height, so the popup anchored to this box starts at the bar's bottom edge
* rather than at the centred title text a few pixels above it.
*/
Box(
modifier = Modifier.heightIn(min = Density.TAB_MIN_HEIGHT),
contentAlignment = Alignment.Center,
) {
Text(
text = menu.title,
style = MaterialTheme.typography.bodyMedium,
/*
* contrastingText and not textAccent: the accent is the background right behind
* this title, and once an operator can set the accent the two can be the same
* colour -- an invisible menu title. Black or white by brightness is readable by
* construction.
*/
color = if (open) contrastingText(palette.accent) else palette.labelTextFill,
modifier = Modifier
.background(
when {
open -> palette.accent
/*
* Hover is a hint, not a selection: the accent at full strength on
* mere pointer travel makes the bar flicker as the operator crosses
* it on the way to the chat field.
*/
hovered -> palette.accent.copy(alpha = HOVER_ALPHA)
else -> Color.Transparent
}
)
.hoverable(interactions)
.clickable { rowState.toggle(menu.title) }
.padding(
horizontal = Density.TAB_HORIZONTAL_PADDING,
vertical = MENU_TITLE_VERTICAL_PADDING,
),
)
/*
* A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open
* flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside
* click and Escape come with a focusable popup), same MenuDefaults look; only the
* expand animation is gone, which a menu bar does not have anyway.
*/
if (open) {
Popup(
popupPositionProvider = MenuRowPopupPositionProvider,
onDismissRequest = rowState::close,
properties = PopupProperties(focusable = true),
) {
Surface(
shape = MenuDefaults.shape,
color = MenuDefaults.containerColor,
tonalElevation = MenuDefaults.TonalElevation,
shadowElevation = MenuDefaults.ShadowElevation,
) {
Column(
modifier = Modifier
.padding(vertical = MENU_POPUP_VERTICAL_PADDING)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),
) {
menu.entries.forEach { entry ->
when (entry) {
is MenuEntry.Item -> DropdownMenuItem(
text = {
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
},
enabled = entry.enabled,
onClick = { rowState.pick(entry.onClick) },
)
MenuEntry.Separator ->
HorizontalDivider(
thickness = Density.HAIRLINE,
color = palette.separatorLine,
)
}
}
}
}
}
}
}
}
/**
* Places a menu-bar menu: flush under its anchor, leading edges aligned.
*
* Material3's DropdownMenu cannot be told to do this. Its position provider refuses any
* position closer than 48 dp (`MenuVerticalMargin`) to the window's top edge, and a menu
* bar sits exactly there, so every menu opened pushed down to that margin — a visible gap
* below the bar. The margin is applied after the `offset` parameter and the provider is
* not replaceable, hence this one and the hand-rolled popup in [MenuTitle].
*/
internal object MenuRowPopupPositionProvider : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val preferredX = when (layoutDirection) {
LayoutDirection.Ltr -> anchorBounds.left
LayoutDirection.Rtl -> anchorBounds.right - popupContentSize.width
}
val x = preferredX.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width))
val below = anchorBounds.bottom
val above = anchorBounds.top - popupContentSize.height
val y = when {
below + popupContentSize.height <= windowSize.height -> below
above >= 0 -> above
else -> maxOf(0, windowSize.height - popupContentSize.height)
}
return IntOffset(x, y)
}
}
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */
private const val HOVER_ALPHA = 0.35f
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
/** Material3's DropdownMenuVerticalPadding, which is internal; keep the menus looking alike. */
private val MENU_POPUP_VERTICAL_PADDING = 8.dp
@@ -0,0 +1,136 @@
package kst4contest.view.compose
import kst4contest.view.i18n.Strings
/**
* One line of a menu.
*
* Toolkit-free on purpose: the bar is drawn two different ways — Compose's `MenuBar`,
* which is a `javax.swing.JMenuBar` under the covers and only used on macOS, and the
* in-window Compose row everywhere else. Declaring the menus twice is how one renderer
* quietly loses an item that the other gained, so they both read this instead.
*/
sealed interface MenuEntry {
/**
* @param enabled whether it can be pressed; a menu item that merely looks usable is
* worse than a disabled one, because the operator presses it mid-contest and
* either nothing happens or the wrong thing does
*/
data class Item(
val label: String,
val enabled: Boolean,
val onClick: () -> Unit,
) : MenuEntry
data object Separator : MenuEntry
}
/** One menu of the bar: its title and what drops down from it. */
data class MenuSpec(val title: String, val entries: List<MenuEntry>)
/**
* The whole bar, as data.
*
* A plain function of the state rather than a composable, which is what finally makes the
* structure, the dynamic labels and the enabled states testable: while this lived inside
* Compose's `MenuBar` it took a running window to look at, and none of it was covered.
*
* @param settingsWindowOpen asked for at build time, not held in [MainMenuState]: a Compose
* window can be closed from its own frame without telling the menu
* @param monitorWindowOpen the same for the cluster / stranger-QSO window
*/
fun mainMenuModel(
state: MainMenuState,
actions: MainMenuActions,
settingsWindowOpen: Boolean,
monitorWindowOpen: Boolean,
/*
* Passed in rather than read from the composition, because this model is deliberately not
* a composable -- both renderers build from it and the two menu tests assert over it
* without a composition in sight.
*/
strings: Strings,
): List<MenuSpec> = listOf(
MenuSpec(
strings.menuFile,
listOf(
MenuEntry.Item(state.connectLabel, state.canConnect, actions::connect),
MenuEntry.Item(strings.menuDisconnect, state.canDisconnect, actions::disconnect),
MenuEntry.Item(
strings.menuSwitchOperatorProfile,
state.canSwitchProfile,
actions::switchOperatorProfile,
),
MenuEntry.Item(strings.menuExitAndDisconnect, state.canExit, actions::exitApplication),
),
),
MenuSpec(
strings.menuOptions,
listOf(
MenuEntry.Item(
strings.menuSetQrgAsNameInChat,
state.canUseChatActions,
actions::setQrgAsNameInChat,
),
/*
* This label is the only place the AFK state is visible anywhere in the
* application, so it follows the state rather than being flipped inside the
* click handler as JavaFX did.
*/
MenuEntry.Item(
state.awayMenuLabel(strings),
state.canUseChatActions,
actions::toggleAwayState,
),
MenuEntry.Item(
if (settingsWindowOpen) strings.menuHideOptions else strings.menuShowOptions,
enabled = true,
actions::toggleSettingsWindow,
),
),
),
MenuSpec(
strings.menuWindows,
listOf(
MenuEntry.Item(
if (monitorWindowOpen) strings.menuHideMonitor else strings.menuShowMonitor,
enabled = true,
actions::toggleMonitorWindow,
),
MenuEntry.Item(
if (settingsWindowOpen) strings.menuHideOptions else strings.menuShowOptions,
enabled = true,
actions::toggleSettingsWindow,
),
MenuEntry.Separator,
MenuEntry.Item(strings.menuToggleStationMap, enabled = true, actions::toggleStationMap),
MenuEntry.Separator,
MenuEntry.Item(strings.menuUseDarkDesign, enabled = true, actions::useDarkDesign),
MenuEntry.Item(strings.menuUseDefaultDesign, enabled = true, actions::useDefaultDesign),
),
),
MenuSpec(
strings.menuInfo,
listOf(
MenuEntry.Item(
strings.menuDonatePayPal,
enabled = true,
actions::openDonationPage,
),
MenuEntry.Item(
strings.menuDonateOv3t,
enabled = true,
actions::openOv3tDonationPage,
),
MenuEntry.Item(strings.menuHomepage, enabled = true, actions::openHomepage),
MenuEntry.Item(strings.menuNewsgroup, enabled = true, actions::openNewsgroup),
MenuEntry.Item(
strings.menuContactAuthor,
enabled = true,
actions::contactAuthor,
),
MenuEntry.Separator,
MenuEntry.Item(strings.menuAbout, enabled = true, actions::showAbout),
),
),
)
@@ -0,0 +1,48 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Which menu of the in-window menu row is currently down.
*
* Compose state, because the row redraws when it changes. One menu at a time: two dropped
* at once is a thing no menu bar does.
*
* [anyOpen] is the part that is not cosmetic. `MainWindow` hangs `WindowShortcuts.handle`
* on the window's `onPreviewKeyEvent`, and a preview runs *before* its children — so
* without asking this first, pressing Escape to dismiss a menu would also clear the
* operator's half-typed message.
*/
class MainMenuRowState {
/** The title of the open menu, or null when none is. */
var openMenu: String? by mutableStateOf(null)
private set
/** Whether a menu is down, and the window keys should therefore stay out of the way. */
val anyOpen: Boolean
get() = openMenu != null
/** Opens the named menu, or closes it when it is the one already open. */
fun toggle(title: String) {
openMenu = if (openMenu == title) null else title
}
/** Closes whatever is open. Idempotent; a dismissal from outside lands here too. */
fun close() {
openMenu = null
}
/**
* Runs a picked item's action and closes the menu it came from.
*
* Closing first would be wrong in the one case that matters: the quit item puts up a
* modal dialog, and the row must not still be drawing a dropdown underneath it.
*/
fun pick(action: () -> Unit) {
openMenu = null
action()
}
}
@@ -0,0 +1,71 @@
package kst4contest.view.compose
import kst4contest.view.i18n.Strings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.controller.On4KstConnectionState
/**
* Which menu items can be used.
*
* Compose state, not plain properties, and deliberately so: a menu item that merely
* looks usable is a worse place for a stale value than a form field. The operator
* presses it in the middle of a contest and nothing happens, or the wrong thing does.
* The JavaFX window drove the same flags from onConnectionStateChanged.
*/
class MainMenuState {
/** Before the first callback there is no session, which reads as disconnected. */
var connectionState: On4KstConnectionState by mutableStateOf(On4KstConnectionState.DISCONNECTED)
private val attemptRunning: Boolean
get() = connectionState.isConnectionAttemptActive
/**
* The Connect item's label, which names the chat the station will land in — and both when
* the second one is on, so the operator sees before pressing it what they are logging
* into. JavaFX built the same string in initMenuBar.
*/
var connectLabel: String by mutableStateOf("Connect")
val canConnect: Boolean
get() = !attemptRunning
/** Also while an attempt is still running: it can be given up. */
val canDisconnect: Boolean
get() = attemptRunning
/**
* Sending to the chat — the away state, the QRG as name — needs a session that is
* really online. Logging in is not being in the chat, and a command sent then goes
* nowhere.
*/
val canUseChatActions: Boolean
get() = connectionState.isOnline
/**
* Whether the operator is marked away in the chat.
*
* Compose state because the away item's own label is the ONLY place this is visible —
* there is no separate indicator anywhere. JavaFX flipped the label inside the click
* handler, which left it wrong whenever the state changed from elsewhere.
*/
var awayFromChat: Boolean by mutableStateOf(false)
/**
* What the away item says. It names the next action rather than the current state,
* which is what the JavaFX wording did.
*
* Takes the texts as a parameter: this is a plain state holder, not a composable, so the
* caller reads the composition local and hands them over.
*/
fun awayMenuLabel(strings: Strings): String =
if (awayFromChat) strings.menuShowMeAsActive else strings.menuShowMeAsAway
/** Never blocked: switching profiles tears the session down on its own. */
val canSwitchProfile: Boolean = true
/** Never blocked: an operator must always be able to leave. */
val canExit: Boolean = true
}
@@ -0,0 +1,287 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.window.FrameWindowScope
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.utils.PlatformUtils
import java.util.function.Consumer
/**
* The main window, assembled.
*
* The JavaFX original built this in 2164 lines inside start(). What is left here is the
* arrangement — every part was built and tested on its own in 5a, 5b and tasks 10 to 13d.
* That is the point of the split: this file says how the window is put together and
* nothing else, so a change to the arrangement cannot break a table.
*
* The window is a pure function of [MainWindowState]. That is what makes the profile
* switch a state swap instead of a restart.
*/
@Composable
fun FrameWindowScope.MainWindow(
state: MainWindowState,
actions: MainMenuActions,
skedBands: List<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
onStationSelected: (ChatMember) -> Unit,
/**
* A clicked private message prepares a reply to it. JavaFX resolved the receiver out of
* the message text when the sender was the local station, so replying to one's own
* message aims at whoever it was addressed to rather than at oneself.
*/
onDirectedMessageClicked: (ChatMessage) -> Unit,
/** Whether a message came from this station; decides its row colour. */
isOwnMessage: (ChatMessage) -> Boolean,
/** How old a message is, for the fading highlight. */
messageAgeSeconds: (ChatMessage) -> Long,
onFiltersChanged: () -> Unit,
onShowMorePriorities: () -> Unit,
onCandidateClicked: (TimelineCandidate) -> Unit,
) {
/*
* macOS gets the system menu bar at the top of the screen, which is where that platform
* expects it and which Swing draws natively. Everywhere else Compose's MenuBar meant an
* unthemed Metal menu above a themed window, so the bar is drawn in the window instead —
* see Kst4ContestMenuRow.
*/
if (PlatformUtils.isMacOs()) {
Kst4ContestMenuBar(
state = state.menu,
actions = actions,
settingsWindowOpen = state.surroundings.settingsWindowOpen,
monitorWindowOpen = state.surroundings.monitorWindowOpen,
connectionState = state.surroundings.connectionState,
connectionDetail = state.surroundings.connectionDetail,
)
}
val menuRow = remember { MainMenuRowState() }
/*
* The window-wide keys, where JavaFX had them: on the scene. Enter sends and Escape
* clears wherever the focus is, and Ctrl+1..Ctrl+0 inserts a snippet — on the field alone
* they would be gone the moment the operator clicked a station or a message row, which is
* most of the time.
*/
Surface(
Modifier
.fillMaxSize()
.onPreviewKeyEvent { event ->
/*
* Not while a menu is down. This is a PREVIEW handler, so it runs before the
* dropdown sees the key — without the guard, Escape to dismiss a menu would
* first clear the operator's half-typed message.
*/
if (menuRow.anyOpen) {
false
} else {
WindowShortcuts.handle(
event = event,
send = state.chatInput::send,
clear = state.chatInput::clear,
insertSnippet = { index ->
state.chatInput.insertSnippet(state.snippets.entries, index)
},
)
}
},
) {
Column(Modifier.fillMaxSize()) {
if (!PlatformUtils.isMacOs()) {
Kst4ContestMenuRow(
state = state.menu,
actions = actions,
settingsWindowOpen = state.surroundings.settingsWindowOpen,
monitorWindowOpen = state.surroundings.monitorWindowOpen,
rowState = menuRow,
)
HorizontalDivider(thickness = Density.HAIRLINE)
}
StatusBar(
connectionState = state.surroundings.connectionState,
connectionDetail = state.surroundings.connectionDetail,
skedNotice = state.skedNotice,
bandUpgradeNotice = state.bandUpgradeNotice,
threadButtons = state.threadButtons,
showConnectionBadge = !PlatformUtils.isMacOs(),
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(thickness = Density.HAIRLINE)
SplitterPane(
state = state.outerSplitter,
orientation = SplitterOrientation.HORIZONTAL,
modifier = Modifier.fillMaxSize(),
panes = listOf(
{
MessageColumn(
state = state,
onCandidateClicked = onCandidateClicked,
onDirectedMessageClicked = onDirectedMessageClicked,
isOwnMessage = isOwnMessage,
messageAgeSeconds = messageAgeSeconds,
)
},
{
StationColumn(
state = state,
skedBands = skedBands,
openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout,
onStationSelected = onStationSelected,
onFiltersChanged = onFiltersChanged,
onShowMorePriorities = onShowMorePriorities,
)
},
),
)
}
}
}
/**
* The left column: what was said, and what this station says back.
*
* Five panes in the order the operator has looked at them for years — directed messages
* at the top, then the shortcut buttons, the timeline, the send line, and the public
* message tabs at the bottom.
*/
@Composable
private fun MessageColumn(
state: MainWindowState,
onCandidateClicked: (TimelineCandidate) -> Unit,
onDirectedMessageClicked: (ChatMessage) -> Unit,
isOwnMessage: (ChatMessage) -> Boolean,
messageAgeSeconds: (ChatMessage) -> Long,
) {
SplitterPane(
state = state.messageSplitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize(),
panes = listOf(
{
SnippetContextMenu(state.snippets.entries, state.chatInput) {
DataTableView(
state = state.directedMessages,
modifier = Modifier.fillMaxSize(),
rowStyle = { message ->
DirectedMessageRowStyle.accentFor(
ownMessage = isOwnMessage(message),
ageSeconds = messageAgeSeconds(message),
)
},
onRowClick = onDirectedMessageClicked,
)
}
},
{ ShortcutButtonRow(state.shortcuts.entries, state.chatInput) },
{
TimelineView(
state = state.timeline,
onCandidateClicked = onCandidateClicked,
modifier = Modifier.fillMaxWidth(),
)
},
{
ChatInputRow(
state = state.chatInput,
prefs = state.prefs,
ownQrgMain = state.ownQrgMain,
ownQrgSecond = state.ownQrgSecond,
antennaQtf = state.antennaQtf,
antennaQtf2 = state.antennaQtf2,
so2rEnabled = state.surroundings.so2rEnabled,
modifier = Modifier.fillMaxWidth(),
)
},
{
SnippetContextMenu(state.snippets.entries, state.chatInput) {
MessageTabs(
publicMessages = state.publicMessages,
clusterMessages = state.clusterMessages,
qsoOfTheOther = state.qsoOfTheOther,
modifier = Modifier.fillMaxSize(),
)
}
},
),
)
}
/**
* The right column: who is on, who to work next, and everything about the one selected.
*/
@Composable
private fun StationColumn(
state: MainWindowState,
skedBands: List<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
onStationSelected: (ChatMember) -> Unit,
onFiltersChanged: () -> Unit,
onShowMorePriorities: () -> Unit,
) {
SplitterPane(
state = state.rightSplitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize(),
panes = listOf(
{
Column(Modifier.fillMaxSize()) {
StationFilterBar(
filters = state.stationFilter,
activeBands = state.activeBands,
onChanged = {
onFiltersChanged()
},
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(thickness = Density.HAIRLINE)
SnippetContextMenu(state.snippets.entries, state.chatInput) {
DataTableView(
state = state.stations,
modifier = Modifier.fillMaxSize(),
onRowClick = onStationSelected,
)
}
}
},
{
TopPriorityBar(
state = state.topPriority,
onShowMore = onShowMorePriorities,
modifier = Modifier.fillMaxWidth(),
)
},
{
SelectedStationPanel(
state = state.selectedStation,
messages = state.selectedStationMessages,
skedBands = skedBands,
activeBands = state.activeBands,
openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout,
modifier = Modifier.fillMaxSize(),
)
},
),
)
}
@@ -0,0 +1,139 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
/** How big the main window opens. */
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
/**
* The frame around the main window: the size it opens at and what its title says.
*
* Both are arithmetic and string building rather than drawing, which is why they live
* apart from the window itself and are covered by tests instead of by looking.
*/
object MainWindowFrame {
private const val FALLBACK_HEIGHT = 768.0
private const val FALLBACK_WIDTH = 1234.0
/**
* Room left for the native window decoration and the screen edge. The JavaFX scene
* size did not include the decoration, and Compose's window size does not include
* everything a tiling compositor puts around it either.
*/
private const val SCREEN_MARGIN = 40.0
/**
* The remembered size, never larger than the screen it is opening on.
*
* The clamp is the point: a window remembered from a larger monitor opens with its
* buttons past the edge and no way to reach them. Each dimension falls back on its
* own, because half a stored size is still half a usable one.
*/
fun startupSize(stored: DoubleArray?, screenHeight: Double, screenWidth: Double): MainWindowSize {
var height = FALLBACK_HEIGHT
var width = FALLBACK_WIDTH
if (stored != null && stored.size >= 2) {
if (stored[0].isFinite() && stored[0] > 0) height = stored[0]
if (stored[1].isFinite() && stored[1] > 0) width = stored[1]
}
val availableHeight = (screenHeight - SCREEN_MARGIN).coerceAtLeast(1.0)
val availableWidth = (screenWidth - SCREEN_MARGIN).coerceAtLeast(1.0)
return MainWindowSize(
heightDp = minOf(height, availableHeight),
widthDp = minOf(width, availableWidth),
)
}
/**
* The window title: what the chat is doing, and which profile, when it is not the
* only one. Two profiles open side by side have to be tellable apart from the
* taskbar.
*/
fun title(chatState: String, profileName: String?): String =
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
/**
* What the title's first half says.
*
* The live connection line while the chat is usable -- exactly what the JavaFX title
* carried -- and a wording about the attempt otherwise. The connection manager's own
* detail ("session is authenticated and synchronized") is a diagnostic for the status
* bar, not a title: it says nothing about category, call or traffic.
*
* @param state the connection state, null before the first push
* @param onlineLine the line built by [onlineChatState], asked for only while online
* @return the chat half of the title
*/
fun chatState(state: On4KstConnectionState?, onlineLine: () -> String): String =
if (state == On4KstConnectionState.ONLINE) onlineLine() else offlineChatState(state)
/**
* The connection line while the chat is usable, as the JavaFX title had it:
* "Connected to: 2: 144/432 MHz as DO1JKS (Johannes 70/2) in JN59MU (106 users online,
* 106 shown), 14 messages total."
*
* Counts are passed as text so MessageFormat does not group them by locale. Without a
* main category there is nothing to name, so the line falls back to the offline wording
* for ONLINE instead of reading "Connected to: as ...".
*
* @param mainCategory the main chat category, null when none is configured
* @param secondCategory the second category, null when it is not logged in
* @param callSign the login callsign
* @param name the login name of the main category
* @param locator the login locator
* @param usersOnline every station in the chat
* @param shown the stations the filters let through
* @param messagesTotal every chat message held, the global list behind the public tab
*/
fun onlineChatState(
mainCategory: ChatCategory?,
secondCategory: ChatCategory?,
callSign: String?,
name: String?,
locator: String?,
usersOnline: Int,
shown: Int,
messagesTotal: Int,
): String {
if (mainCategory == null) {
return offlineChatState(On4KstConnectionState.ONLINE)
}
val strings = kst4contest.view.i18n.CurrentStrings.get()
val categories = if (secondCategory == null) {
mainCategory.toString()
} else {
strings.titleBothCategories(mainCategory.toString(), secondCategory.toString())
}
return strings.titleOnline(
categories,
callSign.orEmpty(),
name.orEmpty(),
locator.orEmpty(),
usersOnline.toString(),
shown.toString(),
messagesTotal.toString(),
)
}
/**
* What the title says while the chat is not usable.
*
* The wording separates a scheduled reconnect from a dead session, which is the
* difference between waiting and doing something about it.
*/
fun offlineChatState(state: On4KstConnectionState?): String =
when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
On4KstConnectionState.CONNECTING -> kst4contest.view.i18n.CurrentStrings.get().titleConnecting
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING -> kst4contest.view.i18n.CurrentStrings.get().titleAuthenticating
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT -> kst4contest.view.i18n.CurrentStrings.get().titleSynchronizing
else -> kst4contest.view.i18n.CurrentStrings.get().titleDisconnected
}
}
@@ -0,0 +1,168 @@
package kst4contest.view.compose
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import java.util.function.Consumer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
/**
* Opens the Compose main window.
*
* Deliberately a sibling of the JavaFX window rather than its replacement for now: the two
* are meant to run side by side so the operator can compare them against a live session,
* which is the only way the differences of the last three Etappen came to light.
*/
object MainWindowHost {
private val host = ComposeWindowHost("main-window", isMainWindow = true)
@JvmStatic
val isOpen: Boolean
get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun close() = host.close()
/**
* @param state everything the window draws; replacing it is what a profile switch will be
* @param profileName the profile part of the title; the chat part is read from the state
*/
private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null)
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(null)
private var currentSkedBands by androidx.compose.runtime.mutableStateOf<(() -> List<Band>)?>(null)
private var currentOpenInBrowser by androidx.compose.runtime.mutableStateOf<Consumer<String>?>(null)
private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnShowPathInAirScout by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnStationSelected by androidx.compose.runtime.mutableStateOf<((ChatMember) -> Unit)?>(null)
private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
private var currentIsOwnMessage by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Boolean)?>(null)
private var currentMessageAgeSeconds by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Long)?>(null)
private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnShowMorePriorities by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnCandidateClicked by androidx.compose.runtime.mutableStateOf<((TimelineCandidate) -> Unit)?>(null)
@JvmStatic
fun show(
state: MainWindowState,
actions: MainMenuActions,
/**
* The profile name for the title, or null for the only profile. The rest of the
* title is what the chat is doing, and that is read from the state during
* composition -- as the JavaFX window did, where the title carried the whole
* connection line.
*/
profileName: String?,
darkMode: Boolean,
baseFontSizeSp: Float,
/**
* The palette of the active profile, handed to the theme. Null draws the shipped
* palette.
*/
paletteStore: PaletteStore? = null,
/**
* The interface language of the active profile, handed to the window. Null draws the
* base language.
*/
languageStore: kst4contest.view.i18n.LanguageStore? = null,
widthDp: Float,
heightDp: Float,
onResized: (Float, Float) -> Unit,
/**
* Runs the application's quit flow. This is the main window: its X — and a tiling
* window manager's close, the same request — has to ask whether to disconnect and
* then end the process, the way the File menu's Exit does. Closing it must not
* leave the client running with the chat connected and nothing to see it in.
*/
onCloseRequest: () -> Unit,
skedBands: () -> List<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
onStationSelected: (ChatMember) -> Unit,
onDirectedMessageClicked: (ChatMessage) -> Unit,
isOwnMessage: (ChatMessage) -> Boolean,
messageAgeSeconds: (ChatMessage) -> Long,
onFiltersChanged: () -> Unit,
onShowMorePriorities: () -> Unit,
onCandidateClicked: (TimelineCandidate) -> Unit,
) {
currentState = state
currentActions = actions
currentSkedBands = skedBands
currentOpenInBrowser = openInBrowser
currentOnShowOnMap = onShowOnMap
currentOnShowPathInAirScout = onShowPathInAirScout
currentOnStationSelected = onStationSelected
currentOnDirectedMessageClicked = onDirectedMessageClicked
currentIsOwnMessage = isOwnMessage
currentMessageAgeSeconds = messageAgeSeconds
currentOnFiltersChanged = onFiltersChanged
currentOnShowMorePriorities = onShowMorePriorities
currentOnCandidateClicked = onCandidateClicked
host.show(
title = {
/*
* Read inside the composition, so the title follows the connection the way
* the JavaFX one did: "Connected to: 2: 144/432 MHz as DN5PW in JO50JP
* (73 users online, 73 shown), 29 messages total." A captured string would
* say "disconnected" for the life of the session. The counts are Compose
* state, so the title moves with every station and message. visibleRows
* costs nothing extra: it is cached per change, and the station table
* reads the same list anyway.
*/
val state = currentState
MainWindowFrame.title(
chatState = if (state == null) {
MainWindowFrame.offlineChatState(null)
} else MainWindowFrame.chatState(state.surroundings.connectionState) {
val prefs = state.prefs
MainWindowFrame.onlineChatState(
mainCategory = prefs.getLoginChatCategoryMain(),
secondCategory = prefs.getLoginChatCategorySecond()
.takeIf { prefs.isLoginToSecondChatEnabled() },
callSign = prefs.getStn_loginCallSign(),
name = prefs.getStn_loginNameMainCat(),
locator = prefs.getStn_loginLocatorMainCat(),
usersOnline = state.stations.rows.size,
shown = state.stations.visibleRows.size,
messagesTotal = state.publicMessages.rows.size,
)
},
profileName = profileName,
)
},
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
languageStore = languageStore,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
onCloseRequest = onCloseRequest,
) { _ ->
val s = currentState ?: return@show
val a = currentActions ?: return@show
MainWindow(
state = s,
actions = a,
skedBands = currentSkedBands?.invoke() ?: emptyList(),
openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
onShowOnMap = currentOnShowOnMap ?: {},
onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
onStationSelected = currentOnStationSelected ?: {},
onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
isOwnMessage = currentIsOwnMessage ?: { false },
messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
onFiltersChanged = currentOnFiltersChanged ?: {},
onShowMorePriorities = currentOnShowMorePriorities ?: {},
onCandidateClicked = currentOnCandidateClicked ?: {},
)
}
}
}
@@ -0,0 +1,175 @@
package kst4contest.view.compose
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
import kst4contest.model.ChatMember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
/**
* Everything the main window draws, in one object.
*
* This is the shape the profile switch needs. Today a switch tears the runtime down and
* builds a new Kst4ContestApplication, because the controls are inline-created instance
* fields and there is no other way to be rid of them. Once the window is a pure function
* of this object, switching profiles is replacing it — the reason the JavaFX version had
* to restart itself is gone.
*
* Nothing here holds a window or a thread; those belong to the runtime that builds this. It
* does hold three preference listeners, which is why [release] exists and has to be called
* when the state is dropped.
*/
class MainWindowState(
val activeBands: Set<kst4contest.model.Band>,
val prefs: ChatPreferences,
/** The station list with its filters: the operator's main working surface. */
val stations: DataTableState<ChatMember>,
val stationFilter: StationFilterState,
/** Messages addressed to this station, in their own pane above the send line. */
val directedMessages: DataTableState<ChatMessage>,
/** The three tabs at the bottom. */
val publicMessages: DataTableState<ChatMessage>,
val clusterMessages: DataTableState<ClusterMessage>,
val qsoOfTheOther: DataTableState<ChatMessage>,
val selectedStationMessages: DataTableState<ChatMessage>,
val selectedStation: SelectedStationState,
val topPriority: TopPriorityState,
val timeline: TimelineState,
val chatInput: ChatInputState,
val menu: MainMenuState,
/** The link and the other windows, pushed in because none of it is Compose state at source. */
val surroundings: MainWindowSurroundings,
/** The status indicators along the top. */
val skedNotice: BlinkingNotice,
val bandUpgradeNotice: BlinkingNotice,
val threadButtons: ThreadStatusButtons,
/** The three splitters, whose positions the operator has arranged and stored. */
val outerSplitter: SplitterState,
val messageSplitter: SplitterState,
val rightSplitter: SplitterState,
) {
/** The shortcut buttons above the send line. */
/**
* The one path that selects a station for the whole application (panel, table, score
* service, send line). Set by the host after construction; other windows, such as the
* map, go through it instead of touching [selectedStation] alone. Null clears.
*/
var stationSelector: ((ChatMember?) -> Unit)? = null
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
/** The snippets behind Ctrl+1..Ctrl+0 and the right-click menus. */
val snippets = RosterMirror(prefs.getLst_txtSnipList())
/**
* The four small fields beside the send controls. Mirrored rather than read straight
* from the preferences, because all of them move without the operator touching them: the
* rotator writes the heading, and the frequency follower writes the own QRG.
*/
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
val antennaQtf = ObservedValue(prefs.getActualQTF())
val antennaQtf2 = ObservedValue(prefs.getActualQTF2())
/**
* Stops following the preferences. Called when this state is dropped — a profile switch
* builds a new window over the same preferences, and a leaked follower keeps the old
* one alive and writing.
*/
fun release() {
ownQrgMain.release()
ownQrgSecond.release()
antennaQtf.release()
antennaQtf2.release()
}
companion object {
/** The five panes of the message column, in the order they are stacked. */
const val MESSAGE_PANE_COUNT = 5
/** Station list, top priorities, selected station. */
const val RIGHT_PANE_COUNT = 3
}
}
/**
* A roster from the preferences, mirrored into Compose state.
*
* SimpleRoster notifies its own listeners, none of which Compose can see, so a view drawn
* straight from one would render once and then go stale — the failure mode that produced
* most of this migration's invisible defects. The mirror is what the settings window's
* refresh hooks have been calling into thin air since Etappe 3b.
*/
class RosterMirror(private val roster: SimpleRoster<String>?) {
private val live = mutableStateListOf<String>().apply {
addAll(roster?.snapshot().orEmpty())
}
/** What a view reads; a read here is a subscription. */
val entries: List<String> get() = live
/** Catches the mirror up with the roster. Called after every committed edit. */
fun refresh() {
val current = roster?.snapshot().orEmpty()
if (current == live.toList()) return
live.clear()
live.addAll(current)
}
}
/**
* What the window shows about its surroundings: the link, and which other windows are open.
*
* None of this is Compose state at its source — the connection state lives in the controller,
* the window flags in AtomicBooleans inside the hosts — so it is pushed in here instead of
* being read through a lambda at composition time. Read that way it would be drawn once: the
* badge frozen on whatever the link was when the window opened, and the menu's "hide options"
* saying the wrong thing for the rest of the session.
*
* @param menu kept in step with the connection state, because the badge and the menu are two
* renderings of one fact and an operator comparing them must not have to choose
*/
class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState()) {
private val liveConnectionState =
mutableStateOf(On4KstConnectionState.DISCONNECTED)
var connectionState: On4KstConnectionState
get() = liveConnectionState.value
set(value) {
liveConnectionState.value = value
menu.connectionState = value
}
var connectionDetail: String by mutableStateOf(kst4contest.view.i18n.CurrentStrings.get().statusNoConnection)
var settingsWindowOpen: Boolean by mutableStateOf(false)
var monitorWindowOpen: Boolean by mutableStateOf(false)
/**
* Whether SO2R is switched on. Compose state, because the preference is not: the
* application pushes the stored value in at start-up and after every settings Apply.
*/
var so2rEnabled: Boolean by mutableStateOf(false)
/** SO2R with rotator 2 enabled, i.e. antenna 2 can be turned; pushed in like [so2rEnabled]. */
var secondAntennaOffered: Boolean by mutableStateOf(false)
}
@@ -0,0 +1,52 @@
package kst4contest.view.compose
import kst4contest.controller.Utils4KST
/**
* How the message tables show what arrives off the wire.
*
* Shared rather than repeated: the same value appears in the monitor window and in the
* tabs below the station list, and two tables of the same data disagreeing about how
* they show it is worse than either format on its own.
*/
internal object MessageFormats {
/**
* The epoch seconds off the wire as a clock time, the way the JavaFX cells showed
* them. An unreadable value is shown as it came: an operator seeing the raw text
* learns more than one seeing an empty cell.
*/
fun clockTime(epochFromServer: String?): String {
val raw = epochFromServer?.trim().orEmpty()
if (raw.isEmpty()) {
return ""
}
return runCatching { Utils4KST().time_convertEpochToReadable(raw) }.getOrDefault(raw)
}
/**
* Three decimal places, as `formatQrgForUi` pads them in the main window. The same
* column appears in both windows, and two tables of the same data must not
* disagree about how they show it.
*/
fun paddedQrg(raw: String): String {
val text = raw.trim().replace(',', '.')
if (text.isEmpty() || text.toDoubleOrNull() == null) {
return text
}
val dot = text.indexOf('.')
if (dot < 0) {
return text + ".000"
}
val decimals = text.length - dot - 1
return if (decimals >= 3) text else text + "0".repeat(3 - decimals)
}
}
@@ -0,0 +1,96 @@
package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
/**
* The three tabs below the station list.
*
* Two of them are the monitor window's tables under their own layout ids — the same
* columns, separate stored widths, which is what PROJECT_CONTEXT requires. Only the
* public message tab is its own.
*/
@Composable
fun MessageTabs(
publicMessages: DataTableState<ChatMessage>,
clusterMessages: DataTableState<ClusterMessage>,
qsoOfTheOther: DataTableState<ChatMessage>,
modifier: Modifier = Modifier,
) {
var selected by remember { mutableStateOf(0) }
val strings = LocalStrings.current
val titles = listOf(
strings.messagesPublic,
strings.messagesDxCluster,
strings.messagesQsoOfTheOther,
)
Column(modifier = modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
titles.forEachIndexed { index, title ->
val isSelected = index == selected
val underline = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.heightIn(min = Density.TAB_MIN_HEIGHT)
.background(
if (isSelected) MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent
)
.clickable { selected = index }
.drawBehind {
if (isSelected) {
val thickness = 2.dp.toPx()
drawRect(
color = underline,
topLeft = Offset(0f, size.height - thickness),
size = Size(size.width, thickness),
)
}
}
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 5.dp),
contentAlignment = Alignment.Center,
) {
Text(title, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
Box(modifier = Modifier.fillMaxSize()) {
when (selected) {
0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize())
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize())
}
}
}
}
@@ -0,0 +1,72 @@
package kst4contest.view.compose
import kst4contest.controller.Utils4KST
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
/**
* The columns of the two monitor tables.
*
* The ids are not free to choose: they are part of the operator's stored layout, so
* renaming one loses their column widths without saying so. They are kept exactly as
* `TableLayoutManager.column(...)` registered them.
*
* Every value survives a half-filled message. These tables are fed straight from the
* wire, a sender or receiver can be missing, and a NullPointerException in a live
* table during a contest is the worst possible moment for one.
*/
object MonitorColumns {
fun dxCluster(): List<DataColumn<ClusterMessage>> = listOf(
DataColumn(id = "time", title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.timeGenerated) }, weight = 0.7f),
DataColumn(id = "call-tx", title = "Call tx", value = { callSignOf(it.sender) }),
DataColumn(id = "locator-tx", title = "LOC tx", value = { qraOf(it.sender) }, weight = 0.7f),
DataColumn(id = "call-rx", title = "Call rx", value = { callSignOf(it.receiver) }),
DataColumn(id = "locator-rx", title = "LOC rx", value = { qraOf(it.receiver) }, weight = 0.7f),
DataColumn(id = "qrg", title = "QRG", value = { MessageFormats.paddedQrg(frequencyOf(it.receiver)) }, weight = 0.8f),
DataColumn(id = "message", title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = { it.messageInhibited.orEmpty() }, weight = 2.5f),
/* "X" and not "true": the JavaFX cell printed a cross. */
DataColumn(id = "worked", title = "wkd", value = { if (it.isReceiverWkd) "X" else "" }, weight = 0.4f),
)
/**
* Nine columns, not ten. The JavaFX method built a "Name" column and never added
* it to the table; porting it would put a column on screen that release 1.49
* never showed.
*/
fun qsoOfTheOther(): List<DataColumn<ChatMessage>> = listOf(
DataColumn(id = "time", title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f),
DataColumn(id = "call-tx", title = "Call TX", value = { callSignOf(it.sender) }),
DataColumn(id = "last-qrg-tx", title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgTx }, value = { frequencyOf(it.sender) }, weight = 0.8f),
DataColumn(id = "worked-tx", title = "wkd TX?", value = { workedMark(it.sender) }, weight = 0.4f),
DataColumn(id = "call-rx", title = "Call RX", value = { callSignOf(it.receiver) }),
DataColumn(id = "last-qrg-rx", title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgRx }, value = { frequencyOf(it.receiver) }, weight = 0.8f),
DataColumn(id = "worked-rx", title = "wkd RX?", value = { workedMark(it.receiver) }, weight = 0.4f),
DataColumn(id = "message", title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = { it.messageText.orEmpty() }, weight = 2.5f),
/*
* From the sender, not from the message. Both carry a field of that name and
* they are set on different parsing paths, so they are not always the same
* value; the JavaFX cell read the sender's.
*/
DataColumn(
id = "category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
.orEmpty()
},
weight = 1.2f,
),
)
internal fun callSignOf(member: ChatMember?): String = member?.callSign.orEmpty()
internal fun qraOf(member: ChatMember?): String = member?.qra.orEmpty()
internal fun frequencyOf(member: ChatMember?): String = member?.frequency?.get().orEmpty()
private fun workedMark(member: ChatMember?): String =
if (member != null && member.isWorked) "X" else ""
}
@@ -0,0 +1,112 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
/**
* The cluster and QSO monitor.
*
* Two live tables, one above the other, with a divider the operator can drag. This
* is the first window in which the table carries data that changes while the operator
* watches, which is why the row identity rules in DataTableState matter here and not in
* the settings window: in Etappe 2 the visible defects all came from that coupling, not
* from the table itself.
*/
object MonitorWindow {
private val host = ComposeWindowHost("monitor-window")
@JvmStatic
val isOpen: Boolean
get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun close() = host.close()
/**
* @param clusterTable fed from `getLst_clusterMemberList`
* @param qsoTable fed from `toOtherMessages` over the global chat message list
*/
@JvmStatic
fun show(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
/** The divider between the two tables, remembered in the profile (#144). */
splitter: SplitterState,
darkMode: Boolean,
baseFontSizeSp: Float,
/**
* The palette of the active profile, handed to the theme. Null draws the shipped
* palette.
*/
paletteStore: PaletteStore? = null,
/**
* The interface language of the active profile, handed to the window. Null draws the
* base language.
*/
languageStore: kst4contest.view.i18n.LanguageStore? = null,
widthDp: Float,
heightDp: Float,
onResized: (Float, Float) -> Unit,
) {
host.show(
title = { kst4contest.view.i18n.CurrentStrings.get().monitorTitle },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
languageStore = languageStore,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
) { _ ->
MonitorContent(clusterTable, qsoTable, splitter)
}
}
}
/** Internal rather than private so the window's body can be drawn headless in a test. */
@Composable
internal fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
splitter: SplitterState,
) {
val strings = kst4contest.view.i18n.LocalStrings.current
Surface(modifier = Modifier.fillMaxSize()) {
/*
* Each heading sits in the pane of its table, so dragging the divider moves the
* heading with the table it names rather than leaving it stranded above the other.
*/
SplitterPane(
state = splitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize().padding(4.dp),
panes = listOf(
{ MonitorPane(strings.monitorDxCluster, clusterTable) },
{ MonitorPane(strings.messagesQsoOfTheOther, qsoTable) },
),
)
}
}
@Composable
private fun <T> MonitorPane(heading: String, table: DataTableState<T>) {
Column(modifier = Modifier.fillMaxSize()) {
Text(heading, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp))
DataTableView(table, modifier = Modifier.weight(1f).fillMaxWidth())
}
}
@@ -0,0 +1,37 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.observe.ObservableValue
import java.util.function.Consumer
/**
* An [ObservableValue] from the domain layer, mirrored into Compose state.
*
* The domain's values notify their own listeners, none of which Compose can see, so a view
* drawn straight from one renders once and then silently goes stale. Three of them move on
* their own while the operator watches: the rotator writes the antenna heading, and the
* frequency follower writes the operator's own QRG.
*
* The JavaFX window did exactly this with attachQtfFollower() and attachOwnQrgFollower(),
* ending in a TextField; this one ends in Compose state.
*
* [release] must be called when the mirror is dropped. A profile switch builds a new window
* over the same preferences, and a leaked listener keeps the old one alive and writing.
*/
class ObservedValue<T>(private val source: ObservableValue<T>) {
var value: T? by mutableStateOf(source.get())
private set
private val follower = Consumer<T> { updated -> value = updated }
init {
source.addListener(follower)
}
fun release() {
source.removeListener(follower)
}
}
@@ -0,0 +1,222 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.awt.ComposeDialog
import kst4contest.model.OperatorProfile
import java.awt.Dialog
import java.awt.Dimension
import java.awt.EventQueue
import java.util.Optional
import java.util.concurrent.atomic.AtomicReference
import javax.swing.WindowConstants
/**
* The text the JavaFX dialog showed below a profile name. Kept as a function so
* the wording and the rule behind it stay covered by a test: the root profile and
* a sharing profile both use the installation's shared worked database.
*/
internal fun workedDatabaseDescription(profile: OperatorProfile): String =
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
"shared station worked database"
} else {
"own worked database"
}
/**
* Selection state of the operator profile picker, free of Compose so the rules
* are testable without a toolkit.
*/
class OperatorProfilePickerState(
val profiles: List<OperatorProfile>,
preselectedProfileId: String?,
) {
var selected: OperatorProfile? =
profiles.firstOrNull { it.profileId == preselectedProfileId } ?: profiles.firstOrNull()
private set
val canConfirm: Boolean
get() = selected != null
fun select(profile: OperatorProfile) {
if (profiles.any { it.profileId == profile.profileId }) {
selected = profiles.first { it.profileId == profile.profileId }
}
}
}
/**
* Asks the operator which profile to start with.
*
* Replaces the JavaFX dialog of the same name and keeps its contract: blocks
* until the operator decides, returns the chosen profile, or empty when they
* want to quit. Kst4ContestApplication uses this as a method reference, so the
* signature must not drift.
*/
object OperatorProfilePickerWindow {
/**
* The font size the JavaFX dialog hardcoded. It runs before any profile is
* loaded, so there is no configured size to read; using a different one here
* would make the dialog look unlike the windows that follow it.
*/
private const val DIALOG_FONT_SIZE_SP = 12f
private val DIALOG_SIZE = Dimension(380, 300)
/**
* Opens the picker and blocks until the operator decides.
*
* A modal [ComposeDialog] and NOT the former dedicated thread plus `CountDownLatch`.
* That construction deadlocked once the UiDispatcher moved to the AWT event thread:
* `ComposeMenuActions.switchOperatorProfile` delivers the File menu's profile switch
* there, and skiko's SwingDispatcher posts *every* continuation of `application { }`
* — composition, the button callbacks, even the return that would release the latch —
* to that same thread, with no `isDispatchNeeded` shortcut. A latch does not pump
* events, so the window never appeared and the client froze with no timeout anywhere
* in the path. A modal AWT dialog runs a nested event pump instead: it blocks its
* caller *and* keeps drawing, which is what `Stage.showAndWait()` did.
*
* Works from any thread. The startup path calls this from `main` before any window
* exists; the File menu calls it from the event thread. Either way the dialog itself is
* built and shown ON the event thread — see [openOnEventThread].
*/
@JvmStatic
fun showAndSelect(
selectableProfiles: List<OperatorProfile>,
preselectedProfileId: String?,
): Optional<OperatorProfile> {
val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId)
val chosen = AtomicReference<OperatorProfile?>(null)
openOnEventThread { openPicker(state, chosen) }
return Optional.ofNullable(chosen.get())
}
private fun openPicker(
state: OperatorProfilePickerState,
chosen: AtomicReference<OperatorProfile?>,
) {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
dialog.size = DIALOG_SIZE
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
/*
* Raised above everything else. At startup there is nothing to hide behind, but
* the File menu opens this over the main window, which is not its owner.
*/
dialog.isAlwaysOnTop = true
dialog.setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
PickerContent(
state = state,
onStart = { chosen.set(state.selected); dialog.dispose() },
/*
* Quit and the window X agree: no choice. Every caller reads an empty
* Optional as "the operator does not want to continue", which at
* startup ends the process.
*/
onQuit = { chosen.set(null); dialog.dispose() },
)
}
}
dialog.isVisible = true
}
}
@Composable
private fun PickerContent(
state: OperatorProfilePickerState,
onStart: () -> Unit,
onQuit: () -> Unit,
) {
var selectedId by mutableStateOf(state.selected?.profileId)
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(kst4contest.view.i18n.CurrentStrings.get().pickerMoreThanOne)
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
items(state.profiles, key = { it.profileId }) { profile ->
val isSelected = profile.profileId == selectedId
Column {
/*
* Marked the same way as the profiles tab: a frame in the accent
* colour over a tinted row. Colouring only the name left the
* operator guessing which profile they were about to start.
*/
Column(
modifier = Modifier
.fillMaxWidth()
.background(
if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
}
)
.then(
if (isSelected) {
Modifier.border(
Density.SELECTION_BORDER,
MaterialTheme.colorScheme.primary,
)
} else {
Modifier
}
)
.selectable(selected = isSelected, onClick = {
state.select(profile)
selectedId = profile.profileId
})
.padding(horizontal = 6.dp, vertical = 4.dp),
) {
Text(profile.displayName)
Text(
workedDatabaseDescription(profile),
style = MaterialTheme.typography.bodySmall,
)
}
if (!isSelected) {
HorizontalDivider(thickness = Density.HAIRLINE)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(kst4contest.view.i18n.CurrentStrings.get().pickerStart, enabled = state.canConfirm, onClick = onStart)
Form.button(kst4contest.view.i18n.CurrentStrings.get().pickerQuit, onClick = onQuit)
}
}
}
}
@@ -0,0 +1,88 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.PaletteRole
/** Which of the three layers a colour in force came from. */
enum class PaletteSource { SHIPPED, FILE, CHANGED }
/** One role's colour, and where it came from. */
data class ResolvedRole(val colour: Color, val source: PaletteSource)
/**
* The palette in force, and the provenance of every role in it.
*
* @param roles always all six, so the settings tab can show a source for each
* @param palette what the windows draw
*/
data class ResolvedPalette(
val roles: Map<PaletteRole, ResolvedRole>,
val palette: JavaFxPalette,
)
/**
* Resolves the three layers, role by role: the later layer wins.
*
* The shipped sheet is the floor and always complete. A file and a change each carry only
* the roles they name — which is why [JavaFxStylesheet.declaredRoles] exists rather than
* `read`: a file saying only `-fx-base` must override exactly one role.
*
* Fields that are not roles — the hover gradient, the pressed border, the font size — stay
* with the shipped sheet. They are derived values rather than decisions an operator wants to
* make one by one.
*
* The exception is `textAccent`, which follows the accent role whenever that role's colour
* actually differs from the shipped one: Material's primary is mapped onto it rather than onto
* `accent`, because `-fx-accent` paints only JavaFX's selection bar while the green of
* `.text-field .text` is what an operator reads as the accent. Left on the sheet's own value,
* a chosen accent would change almost nothing visible; keyed on provenance instead of on the
* value, an accent nobody chose would repaint it.
*/
fun resolvePalette(
shipped: JavaFxPalette,
fromFile: Map<PaletteRole, Color>,
changed: Map<PaletteRole, Color>,
): ResolvedPalette {
val shippedColour = mapOf(
PaletteRole.SURFACE to shipped.base,
PaletteRole.WINDOW_SURFACE to shipped.windowBackground,
PaletteRole.FIELD_INTERIOR to shipped.controlInnerBackground,
PaletteRole.TEXT to shipped.labelTextFill,
PaletteRole.ACCENT to shipped.accent,
PaletteRole.SEPARATOR to shipped.separatorLine,
)
val roles = PaletteRole.values().associateWith { role ->
when {
changed.containsKey(role) -> ResolvedRole(changed.getValue(role), PaletteSource.CHANGED)
fromFile.containsKey(role) -> ResolvedRole(fromFile.getValue(role), PaletteSource.FILE)
else -> ResolvedRole(shippedColour.getValue(role), PaletteSource.SHIPPED)
}
}
fun colourOf(role: PaletteRole) = roles.getValue(role).colour
/*
* Keyed on the value and deliberately not on the provenance. The root profile's own
* stylesheet is the one copyResourceIfRequired writes at startup -- the shipped sheet
* itself -- so on every existing installation layer 2 declares the accent without anybody
* having chosen anything. Asking "does it come from a file" repainted the evening primary
* from green to blue on upgrade, and made resetToShipped disagree with discardChanges
* about what shipped looks like.
*/
val chosenAccent = roles.getValue(PaletteRole.ACCENT).colour.takeIf { it != shipped.accent }
return ResolvedPalette(
roles = roles,
palette = shipped.copy(
base = colourOf(PaletteRole.SURFACE),
windowBackground = colourOf(PaletteRole.WINDOW_SURFACE),
controlInnerBackground = colourOf(PaletteRole.FIELD_INTERIOR),
labelTextFill = colourOf(PaletteRole.TEXT),
accent = colourOf(PaletteRole.ACCENT),
textAccent = chosenAccent ?: shipped.textAccent,
separatorLine = colourOf(PaletteRole.SEPARATOR),
),
)
}
@@ -0,0 +1,211 @@
package kst4contest.view.compose
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import kst4contest.model.PaletteOverrides
import kst4contest.model.PaletteRole
/**
* The palette of both designs for the active profile, held as Compose state.
*
* **This is what makes a colour change reach every open window.** `Kst4ContestTheme` used to
* take its palette from `remember(darkMode)`, so a change reached nothing until somebody
* toggled day/evening. Reading [resolved] inside the theme turns every palette change into a
* recomposition of every window that draws it.
*
* Built over four lambdas rather than over `ChatPreferences` so it can be tested without a
* profile on disk: that constructor copies resources into the operator's home directory.
*
* **Where "immediately" and "survives a restart" part company:** every change writes through
* to the preferences in memory at once, which is what recolours the windows, but the XML is
* written by the existing `savePreferencesFromSettings`. That is deliberately the same
* contract every other settings tab has — the JavaFX controls wrote as they changed and
* "Save settings" only persisted — but it means a change made and never saved is gone after a
* restart.
*
* @param shippedOf layer 1: the shipped sheet for a design, always complete
* @param fileRolesOf layer 2: the roles the profile's own stylesheet names, if any
* @param storedOverridesOf layer 3 as stored, encoded by [PaletteOverrides]
* @param storeOverrides writes layer 3 back
*/
class PaletteStore(
shippedOf: (darkMode: Boolean) -> JavaFxPalette,
fileRolesOf: (darkMode: Boolean) -> Map<PaletteRole, Color>,
storedOverridesOf: (darkMode: Boolean) -> String,
private val storeOverrides: (darkMode: Boolean, encoded: String) -> Unit,
) {
/*
* Layers 1 and 2 are read once per design, on first use, and then held. They do not change
* while the application runs -- the shipped sheet is a classpath resource, and both manuals
* promise that edits to the profile's stylesheet take effect after a restart. Re-reading
* them inside resolved() meant two CSS parses and a disk read per recomposition of every
* window, on the UI thread, which in this codebase is never a detail.
*
* Only the overrides below stay live. The state read is the mechanism that repaints the
* windows; re-parsing immutable input was never part of it.
*/
private val shippedDaylight by lazy { shippedOf(false) }
private val shippedEvening by lazy { shippedOf(true) }
private val fileRolesDaylight by lazy { fileRolesOf(false) }
private val fileRolesEvening by lazy { fileRolesOf(true) }
/*
* The encoded overrides per design, as state. Everything else derives from these two, so
* there is exactly one place a change has to land for the windows to follow.
*/
private val daylightOverrides = mutableStateOf(storedOverridesOf(false))
private val eveningOverrides = mutableStateOf(storedOverridesOf(true))
/** What [restoreSnapshot] goes back to, or null while nothing has been remembered. */
private var snapshot: Pair<String, String>? = null
/**
* The palette in force for a design, with every role's provenance.
*
* Reads the override state, so a composable calling this recomposes when a colour
* changes. That state read is the mechanism and must not be cached; the two immutable
* layers under it are held, which is a different thing.
*/
fun resolved(darkMode: Boolean): ResolvedPalette = resolvePalette(
shipped = shipped(darkMode),
fromFile = if (darkMode) fileRolesEvening else fileRolesDaylight,
changed = changedRoles(darkMode),
)
/**
* The shipped palette for a design, untouched by file or change.
*
* The lifeline reads this. A control drawn from [resolved] would disappear in exactly the
* palette it exists to undo.
*
* @param darkMode the design
* @return layer 1
*/
fun shipped(darkMode: Boolean): JavaFxPalette =
if (darkMode) shippedEvening else shippedDaylight
/**
* Sets one role of one design.
*
* An unusable colour is ignored rather than stored: an operator mistyping a hex value
* must not end up with a black client. The tab reports the refusal.
*
* @param darkMode the design to change
* @param role the role to set
* @param colour a six-digit hex colour; anything else is ignored
*/
fun set(darkMode: Boolean, role: PaletteRole, colour: String) {
if (!PaletteOverrides.isValidColour(colour)) {
return
}
val overrides = PaletteOverrides.parse(encoded(darkMode)).toMutableMap()
overrides[role] = colour
write(darkMode, PaletteOverrides.format(overrides))
}
/**
* Sets every role of a design to its shipped colour, as an explicit change.
*
* Not the same as [discardChanges]: it has to beat a stylesheet file too, and only an
* explicit override on all six roles does that. This is the action the stage's acceptance
* criterion names — "exactly the shipped colours", file or no file.
*
* @param darkMode the design to reset
*/
fun resetToShipped(darkMode: Boolean) {
val shipped = shipped(darkMode)
val overrides = PaletteRole.values().associateWith { role ->
hexOf(shippedColourOf(shipped, role))
}
write(darkMode, PaletteOverrides.format(overrides))
}
/**
* Empties layer 3, so the file or the shipped sheet applies again.
*
* @param darkMode the design to clear
*/
fun discardChanges(darkMode: Boolean) {
write(darkMode, "")
}
/** Remembers the overrides of both designs, for [restoreSnapshot]. */
fun takeSnapshot() {
snapshot = daylightOverrides.value to eveningOverrides.value
}
/**
* Restores the overrides remembered by [takeSnapshot], including a change that was
* already there when it was taken. Does nothing when nothing was remembered.
*
* Both designs, not only the one in force. [takeSnapshot] captures both, and an operator
* who changed a colour, switched day/evening from the main window's menu, changed another
* and then asked to go back to how it was means both -- restoring half of it would leave
* a state they never chose and cannot name.
*/
fun restoreSnapshot() {
val remembered = snapshot ?: return
write(darkMode = false, encoded = remembered.first)
write(darkMode = true, encoded = remembered.second)
}
private fun changedRoles(darkMode: Boolean): Map<PaletteRole, Color> =
PaletteOverrides.parse(encoded(darkMode)).mapValues { (_, hex) -> colourOf(hex) }
private fun encoded(darkMode: Boolean): String =
if (darkMode) eveningOverrides.value else daylightOverrides.value
private fun write(darkMode: Boolean, encoded: String) {
if (darkMode) {
eveningOverrides.value = encoded
} else {
daylightOverrides.value = encoded
}
storeOverrides(darkMode, encoded)
}
private fun shippedColourOf(shipped: JavaFxPalette, role: PaletteRole): Color = when (role) {
PaletteRole.SURFACE -> shipped.base
PaletteRole.WINDOW_SURFACE -> shipped.windowBackground
PaletteRole.FIELD_INTERIOR -> shipped.controlInnerBackground
PaletteRole.TEXT -> shipped.labelTextFill
PaletteRole.ACCENT -> shipped.accent
PaletteRole.SEPARATOR -> shipped.separatorLine
}
}
/**
* A six-digit hex colour as a drawable colour.
*
* Only ever called with a value [PaletteOverrides.isValidColour] has already accepted, which
* is why it does not have to answer what an unparsable value means.
*/
internal fun colourOf(hex: String): Color =
Color(("ff" + hex.trim().removePrefix("#")).toLong(16))
/**
* A colour as the six-digit hex form the preferences store.
*
* Alpha is dropped on purpose: the roles are opaque surfaces and text, and a stored colour
* the operator cannot type back in would be a colour they cannot edit.
*/
internal fun hexOf(colour: Color): String = "#%02X%02X%02X".format(
(colour.red * 255).toInt(),
(colour.green * 255).toInt(),
(colour.blue * 255).toInt(),
)
/**
* The store for the windows below, or null where there is none.
*
* Null is not caution but a real case: the operator profile picker draws before a profile —
* and therefore before a store — exists, and so do the tests of individual controls. Those
* get the shipped palette.
*/
val LocalPaletteStore = staticCompositionLocalOf<PaletteStore?> { null }
@@ -0,0 +1,95 @@
package kst4contest.view.compose
import kst4contest.ApplicationConstants
import kst4contest.controller.OperatorProfilePaths
import kst4contest.model.ChatPreferences
import kst4contest.model.OperatorProfile
import kst4contest.utils.ApplicationFileUtils
import java.io.File
/**
* Where one profile's own stylesheets live — layer 2 of the palette, one file per design.
*
* A value rather than a path derived inside the factory, so the resolution can be tested
* without writing into the operator's home directory.
*/
data class OperatorProfilePaletteFiles(val dayFile: File, val eveningFile: File) {
/** The file for a design. */
fun fileFor(darkMode: Boolean): File = if (darkMode) eveningFile else dayFile
companion object {
/**
* The files of a profile, by the project's own path convention.
*
* @param profile the profile whose stylesheets are wanted
* @return both files, which need not exist
*/
@JvmStatic
fun of(profile: OperatorProfile): OperatorProfilePaletteFiles =
OperatorProfilePaletteFiles(
dayFile = absoluteFile(OperatorProfilePaths.paletteRelativeFileName(profile, false)),
eveningFile = absoluteFile(OperatorProfilePaths.paletteRelativeFileName(profile, true)),
)
private fun absoluteFile(relativeName: String): File =
File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, relativeName))
}
}
/**
* Builds the [PaletteStore] of the active profile.
*
* The store itself takes four lambdas so it can be tested without a profile on disk; this is
* where those lambdas are tied to the real preferences and the real files, and it is the only
* place that knows which preference belongs to which design.
*/
object PaletteStoreFactory {
/**
* Creates the store for one profile.
*
* Changes write through to [prefs] immediately, which is what recolours the open windows.
* The XML is written by the existing "save settings" path, exactly like every other
* setting in this application.
*
* @param prefs the active profile's preferences
* @param files the active profile's own stylesheets, which need not exist
* @return a store for both designs
*/
@JvmStatic
fun create(prefs: ChatPreferences, files: OperatorProfilePaletteFiles): PaletteStore =
PaletteStore(
shippedOf = { darkMode -> JavaFxStylesheet.shipped(darkMode) },
fileRolesOf = { darkMode ->
JavaFxStylesheet.declaredRolesOfFile(files.fileFor(darkMode))
},
storedOverridesOf = { darkMode ->
if (darkMode) {
prefs.guiOptions_paletteOverridesEvening
} else {
prefs.guiOptions_paletteOverridesDay
}
},
storeOverrides = { darkMode, encoded ->
if (darkMode) {
prefs.guiOptions_paletteOverridesEvening = encoded
} else {
prefs.guiOptions_paletteOverridesDay = encoded
}
},
)
/**
* Creates the store for the active profile, or for the root profile when no profile has
* been resolved yet.
*
* @param prefs the active profile's preferences
* @param profile the active profile
* @return a store for both designs
*/
@JvmStatic
fun create(prefs: ChatPreferences, profile: OperatorProfile): PaletteStore =
create(prefs, OperatorProfilePaletteFiles.of(profile))
}
@@ -0,0 +1,39 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import kst4contest.model.ChatPreferences
/**
* The stored window positions of one profile, gated by the operator's opt-in.
*
* The opt-in is read on every call, so applying the setting takes effect without a
* restart: off means nothing is restored and nothing is written.
*
* @param requestSave asks the layout autosave to write the preferences file
*/
class PreferencesWindowPositionMemory(
private val prefs: ChatPreferences,
private val requestSave: Runnable,
) : WindowPositionMemory {
override fun load(windowId: String): StoredWindowPosition? {
if (!prefs.isGuiOptions_rememberWindowPositions()) {
return null
}
val p = prefs.getWindowPosition(windowId) ?: return null
return StoredWindowPosition(Point(p[0], p[1]), Rectangle(p[2], p[3], p[4], p[5]))
}
override fun save(windowId: String, stored: StoredWindowPosition) {
if (!prefs.isGuiOptions_rememberWindowPositions()) {
return
}
prefs.setWindowPosition(
windowId,
stored.position.x, stored.position.y,
stored.screen.x, stored.screen.y, stored.screen.width, stored.screen.height,
)
requestSave.run()
}
}
@@ -0,0 +1,50 @@
package kst4contest.view.compose
import kst4contest.model.ChatMessage
/**
* The columns of the public message tab.
*
* The other two tabs below the station list reuse the monitor tables under their own
* layout ids; this one is its own. Its ids are the operator's stored column widths,
* kept exactly as TableLayoutManager registered them.
*/
object PublicMessageColumns {
fun all(): List<DataColumn<ChatMessage>> = listOf(
DataColumn(
id = "time",
title = { kst4contest.view.i18n.CurrentStrings.get().columnTime },
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
weight = 0.7f,
),
DataColumn(
id = "callsign",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign },
value = { MonitorColumns.callSignOf(it.sender) },
),
DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
DataColumn(
id = "last-qrg",
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrg },
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
weight = 0.8f,
),
DataColumn(
id = "message",
title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage },
value = { it.messageText.orEmpty() },
weight = 3f,
),
DataColumn(
id = "category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
.orEmpty()
},
weight = 1.2f,
),
)
}
@@ -0,0 +1,60 @@
package kst4contest.view.compose
import kst4contest.model.ChatMessage
/**
* The columns of the selected station message tab.
*/
object SelectedStationMessageColumns {
fun all(): List<DataColumn<ChatMessage>> = listOf(
DataColumn(
id = "time",
title = { kst4contest.view.i18n.CurrentStrings.get().columnTime },
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
weight = 0.7f,
),
DataColumn(
id = "call-tx",
title = { kst4contest.view.i18n.CurrentStrings.get().columnSender },
value = { MonitorColumns.callSignOf(it.sender) },
weight = 0.8f,
),
DataColumn(
id = "call-rx",
title = { kst4contest.view.i18n.CurrentStrings.get().columnReceiver },
value = { MonitorColumns.callSignOf(it.receiver) },
weight = 0.8f,
),
DataColumn(
id = "qrg-tx",
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgTx },
value = { it.sender?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
weight = 0.8f,
),
DataColumn(
id = "qrg-rx",
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgRx },
value = { it.receiver?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
weight = 0.8f,
),
DataColumn(
id = "message",
title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage },
value = { it.messageText.orEmpty() },
weight = 3f,
),
DataColumn(
id = "wkd-rx",
title = "wkd RX?",
value = { if (it.receiver?.isWorked == true) "wkd" else "" },
weight = 0.6f,
),
DataColumn(
id = "wkd-tx",
title = "wkd TX?",
value = { if (it.sender?.isWorked == true) "wkd" else "" },
weight = 0.6f,
),
)
}
Loaded 100 of 894 files, more files were not shown because too many files have changed in this diff. Show more