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>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-08 23:22:11 +02:00
1 parent 3e8457eede
commit 2a5c89b943
600 files changed
+46014 -23699

No files matched your search

+2 -2
View File
@@ -41,8 +41,8 @@ jobs:
- name: Compute pkgver - name: Compute pkgver
id: ver id: ver
run: | run: |
BASE=$(grep -m1 '<version>' pom.xml \ BASE=$(grep -m1 '^version=' gradle.properties \
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' \ | cut -d= -f2 \
| sed 's/[-.]nightly//') | sed 's/[-.]nightly//')
GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)" GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
+3 -3
View File
@@ -107,9 +107,9 @@ jobs:
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest-bin/PKGBUILD sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest-bin/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD
# kst4contest-git: Basis aus pom.xml, Suffix aus git # kst4contest-git: Basis aus gradle.properties, Suffix aus git
BASE_VER=$(grep -m1 '<version>' pom.xml \ BASE_VER=$(grep -m1 '^version=' gradle.properties \
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//') | cut -d= -f2 | sed 's/[-.]nightly//')
GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)" 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 sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
if [[ "${{ github.ref }}" == refs/tags/* ]]; then if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION="${{ github.ref_name }}" VERSION="${{ github.ref_name }}"
else 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 fi
echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV" echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV"
+63 -136
View File
@@ -5,12 +5,15 @@ on:
branches: branches:
- main - main
paths: paths:
- "src/**"
- "packaging/icons/**" - "packaging/icons/**"
- "packaging/macos/**" - "packaging/macos/**"
- "pom.xml" - "core/**"
- "mvnw" - "app-desktop/**"
- "mvnw.cmd" - "*.gradle.kts"
- "gradle.properties"
- "gradle/**"
- "gradlew"
- "gradlew.bat"
- ".github/workflows/nightly-artifacts.yml" - ".github/workflows/nightly-artifacts.yml"
workflow_dispatch: workflow_dispatch:
@@ -33,8 +36,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
shell: pwsh shell: pwsh
run: | run: |
$xml = [xml](Get-Content pom.xml) $version = (Select-String -Path gradle.properties -Pattern '^version=').Line.Split('=')[1]
$version = $xml.project.version
$shortSha = "${{ github.sha }}".Substring(0, 7) $shortSha = "${{ github.sha }}".Substring(0, 7)
Add-Content -Path $env:GITHUB_ENV -Value "VERSION=$version" 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 "SHORT_SHA=$shortSha"
@@ -50,40 +52,23 @@ jobs:
shell: pwsh shell: pwsh
run: choco install wixtoolset --no-progress -y run: choco install wixtoolset --no-progress -y
- name: Build JAR and copy runtime dependencies - name: Build app-image
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
shell: pwsh shell: pwsh
run: | run: |
.\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null New-Item -ItemType Directory -Force -Path dist | Out-Null
$addModules = & java packaging/AddModules.java # The next step reads dist/KST4Contest, so the Compose output goes
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" } # there. The module list now lives in app-desktop/build.gradle.kts.
jpackage ` if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
--type app-image ` Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
--name praktiKST `
--icon packaging/icons/kst4contest.ico `
--input target/dist-libs `
--main-jar app.jar `
--main-class kst4contest.view.Kst4ContestApplication `
--module-path target/dist-libs `
--add-modules $addModules `
--dest dist
- name: Create Windows ZIP - name: Create Windows ZIP
shell: pwsh shell: pwsh
run: | run: |
if (-not (Test-Path dist/praktiKST)) { if (-not (Test-Path dist/KST4Contest)) {
throw "No Windows app-image produced by jpackage" 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 - name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.4
@@ -102,7 +87,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | 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}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
@@ -112,31 +97,21 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - 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
- name: Build app-image with jpackage
run: | run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" # The following steps read dist/KST4Contest, so the Compose output goes
jpackage \ # there. The module list now lives in app-desktop/build.gradle.kts.
--type app-image \ rm -rf dist/KST4Contest
--name KST4Contest \ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--dest dist
- name: Create AppDir metadata - name: Create AppDir metadata
run: | run: |
mkdir -p target
rm -rf target/KST4Contest.AppDir rm -rf target/KST4Contest.AppDir
cp -a dist/KST4Contest target/KST4Contest.AppDir cp -a dist/KST4Contest target/KST4Contest.AppDir
@@ -163,6 +138,7 @@ jobs:
- name: Build AppImage - name: Build AppImage
run: | run: |
mkdir -p target
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x target/appimagetool.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" APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
@@ -184,7 +160,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | 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}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
@@ -199,32 +175,16 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends fakeroot sudo apt-get install -y --no-install-recommends fakeroot
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw 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 Debian package - name: Build Debian package
run: | run: |
./gradlew -S :app-desktop:packageDeb
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
jpackage \
--type deb \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist
DEB="$(ls dist/*.deb | head -n 1)"
if [ -z "$DEB" ]; then if [ -z "$DEB" ]; then
echo "No DEB produced by jpackage" && exit 1 echo "No DEB produced by packageDeb" && exit 1
fi fi
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb" mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
@@ -245,7 +205,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | 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}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
@@ -260,32 +220,16 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends rpm sudo apt-get install -y --no-install-recommends rpm
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw 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 Fedora package - name: Build Fedora package
run: | run: |
./gradlew -S :app-desktop:packageRpm
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
jpackage \
--type rpm \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist
RPM="$(ls dist/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then if [ -z "$RPM" ]; then
echo "No RPM produced by jpackage" && exit 1 echo "No RPM produced by packageRpm" && exit 1
fi fi
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm" mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
@@ -306,7 +250,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | 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}" SHORT_SHA="${GITHUB_SHA::7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
@@ -323,31 +267,21 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends zstd sudo apt-get install -y --no-install-recommends zstd
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - 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
- name: Build app-image with jpackage
run: | run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" # The following steps read dist/KST4Contest, so the Compose output goes
jpackage \ # there. The module list now lives in app-desktop/build.gradle.kts.
--type app-image \ rm -rf dist/KST4Contest
--name KST4Contest \ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--dest dist
- name: Build Arch Linux package artifact - name: Build Arch Linux package artifact
run: | run: |
mkdir -p target
ARCH=$(uname -m) ARCH=$(uname -m)
PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g') PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g')
PKGROOT="target/archpkg" PKGROOT="target/archpkg"
@@ -427,7 +361,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | 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}" SHORT_SHA="${GITHUB_SHA::7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
@@ -439,8 +373,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Install Flatpak tooling - name: Install Flatpak tooling
run: | run: |
@@ -449,25 +383,17 @@ jobs:
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo 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//24.08 org.freedesktop.Sdk//24.08
- name: Build app-image with jpackage - name: Build app-image
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:createDistributable
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
mkdir -p target/flatpak-src mkdir -p target/flatpak-src
ADD_MODULES="$(java packaging/AddModules.java)" # The manifest's source is flatpak-src/KST4Contest.
jpackage \ rm -rf target/flatpak-src/KST4Contest
--type app-image \ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--dest target/flatpak-src
- name: Create Flatpak manifest - name: Create Flatpak manifest
run: | run: |
mkdir -p target
mkdir -p dist mkdir -p dist
cat > target/de.x08.KST4Contest.yml << 'EOF' cat > target/de.x08.KST4Contest.yml << 'EOF'
app-id: de.x08.KST4Contest app-id: de.x08.KST4Contest
@@ -515,6 +441,7 @@ jobs:
- name: Build Flatpak repo (nightly) - name: Build Flatpak repo (nightly)
run: | run: |
mkdir -p target
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml 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-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 flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
@@ -622,7 +549,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | 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}" SHORT_SHA="${GITHUB_SHA::7}"
ARCH=$(uname -m) ARCH=$(uname -m)
echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "VERSION=$VERSION" >> "$GITHUB_ENV"
@@ -636,8 +563,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Import signing certificate - name: Import signing certificate
env: env:
+157 -6
View File
@@ -24,11 +24,162 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Verify packaging module list matches module-info.java
run: java packaging/AddModules.java --verify-pom
# 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 - name: Compile
run: ./mvnw -B -DskipTests compile run: ./gradlew -S classes testClasses
# 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@v4.1.7
- name: Set up Java 21
uses: actions/setup-java@v4.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@v4
with:
name: test-results
path: |
*/build/test-results/test/
*/build/reports/tests/test/
if-no-files-found: ignore
retention-days: 7
+135 -125
View File
@@ -34,40 +34,23 @@ jobs:
shell: pwsh shell: pwsh
run: choco install wixtoolset --no-progress -y run: choco install wixtoolset --no-progress -y
- name: Build JAR and copy runtime dependencies - name: Build app-image
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
shell: pwsh shell: pwsh
run: | run: |
.\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null New-Item -ItemType Directory -Force -Path dist | Out-Null
$addModules = & java packaging/AddModules.java # The next step reads dist/KST4Contest, so the Compose output goes
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" } # there. The module list now lives in app-desktop/build.gradle.kts.
jpackage ` if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
--type app-image ` Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
--name praktiKST `
--icon packaging/icons/kst4contest.ico `
--input target/dist-libs `
--main-jar app.jar `
--main-class kst4contest.view.Kst4ContestApplication `
--module-path target/dist-libs `
--add-modules $addModules `
--dest dist
- name: Create Windows ZIP - name: Create Windows ZIP
shell: pwsh shell: pwsh
run: | run: |
if (-not (Test-Path dist/praktiKST)) { if (-not (Test-Path dist/KST4Contest)) {
throw "No Windows app-image produced by jpackage" throw "No Windows app-image produced by createDistributable"
} }
Compress-Archive -Path dist/praktiKST -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force Compress-Archive -Path dist/KST4Contest -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
- name: Upload Windows artifact - name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.4
@@ -89,31 +72,21 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - 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
- name: Build app-image with jpackage
run: | run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" # The following steps read dist/KST4Contest, so the Compose output goes
jpackage \ # there. The module list now lives in app-desktop/build.gradle.kts.
--type app-image \ rm -rf dist/KST4Contest
--name KST4Contest \ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--dest dist
- name: Create AppDir metadata - name: Create AppDir metadata
run: | run: |
mkdir -p target
rm -rf target/KST4Contest.AppDir rm -rf target/KST4Contest.AppDir
cp -a dist/KST4Contest target/KST4Contest.AppDir cp -a dist/KST4Contest target/KST4Contest.AppDir
@@ -140,6 +113,7 @@ jobs:
- name: Build AppImage - name: Build AppImage
run: | run: |
mkdir -p target
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x target/appimagetool.AppImage chmod +x target/appimagetool.AppImage
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
@@ -169,32 +143,16 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends fakeroot sudo apt-get install -y --no-install-recommends fakeroot
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw 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 Debian package - name: Build Debian package
run: | run: |
./gradlew -S :app-desktop:packageDeb
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
jpackage \
--type deb \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist
DEB="$(ls dist/*.deb | head -n 1)"
if [ -z "$DEB" ]; then if [ -z "$DEB" ]; then
echo "No DEB produced by jpackage" && exit 1 echo "No DEB produced by packageDeb" && exit 1
fi fi
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb" mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
@@ -223,32 +181,16 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends rpm sudo apt-get install -y --no-install-recommends rpm
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw 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 Fedora package - name: Build Fedora package
run: | run: |
./gradlew -S :app-desktop:packageRpm
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
jpackage \
--type rpm \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist
RPM="$(ls dist/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then if [ -z "$RPM" ]; then
echo "No RPM produced by jpackage" && exit 1 echo "No RPM produced by packageRpm" && exit 1
fi fi
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm" mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
@@ -277,31 +219,21 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends zstd sudo apt-get install -y --no-install-recommends zstd
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - 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
- name: Build app-image with jpackage
run: | run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" # The following steps read dist/KST4Contest, so the Compose output goes
jpackage \ # there. The module list now lives in app-desktop/build.gradle.kts.
--type app-image \ rm -rf dist/KST4Contest
--name KST4Contest \ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--dest dist
- name: Build Arch Linux package artifact - name: Build Arch Linux package artifact
run: | run: |
mkdir -p target
ARCH=$(uname -m) ARCH=$(uname -m)
PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g') PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g')
PKGROOT="target/archpkg" PKGROOT="target/archpkg"
@@ -384,8 +316,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Install Flatpak tooling - name: Install Flatpak tooling
run: | run: |
@@ -394,25 +326,17 @@ jobs:
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo 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//24.08 org.freedesktop.Sdk//24.08
- name: Build app-image with jpackage - name: Build app-image
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:createDistributable
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
mkdir -p target/flatpak-src mkdir -p target/flatpak-src
ADD_MODULES="$(java packaging/AddModules.java)" # The manifest's source is flatpak-src/KST4Contest.
jpackage \ rm -rf target/flatpak-src/KST4Contest
--type app-image \ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--dest target/flatpak-src
- name: Create Flatpak manifest - name: Create Flatpak manifest
run: | run: |
mkdir -p target
mkdir -p dist mkdir -p dist
cat > target/de.x08.KST4Contest.yml << 'EOF' cat > target/de.x08.KST4Contest.yml << 'EOF'
app-id: de.x08.KST4Contest app-id: de.x08.KST4Contest
@@ -470,6 +394,7 @@ jobs:
- name: Build Flatpak repo - name: Build Flatpak repo
run: | run: |
mkdir -p target
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml 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 "$OSTREE_BRANCH" flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build "$OSTREE_BRANCH"
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
@@ -520,8 +445,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Import signing certificate - name: Import signing certificate
env: env:
@@ -718,6 +643,90 @@ jobs:
steps: steps:
- name: Checkout release source - name: Checkout release source
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
with:
# Full history and tags, needed to list the commits since the previous release.
fetch-depth: 0
# GitHub's generated release notes list merged pull requests only. Commits
# pushed directly to main are added here as their own section:
# ## What's Changed
# ### Commits on main (first-parent commits, no merges, no bots)
# ### Merged PRs (GitHub's generated PR list)
# The body also feeds the in-app update feed (website/src/_data/versionInfo.js,
# cleanReleaseBody), which drops headings and keeps the "* ..." lines.
- name: Build release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Stable releases compare against the previous stable release, betas
# against the previous tag of any kind.
if [[ "$TAG" == beta-* ]]; then
PREVIOUS_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
else
PREVIOUS_TAG="$(git describe --tags --abbrev=0 --exclude 'beta-*' "${TAG}^" 2>/dev/null || true)"
fi
echo "Previous tag: ${PREVIOUS_TAG:-<none>}"
RANGE="$TAG"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}"
# --first-parent keeps the commits made directly on main; commits inside
# pull requests are covered by the PR list. Squash-merged PRs end in
# "(#123)" and are listed by GitHub as well.
git log --first-parent --no-merges --format='%an%x09%s' "$RANGE" \
| awk -F '\t' '$1 != "github-actions[bot]" { print $2 }' \
| grep -Ev '\(#[0-9]+\)$' \
| sed 's/^/* /' > direct-commits.md || true
# Let GitHub generate the PR part for the same range.
if [ -n "$PREVIOUS_TAG" ]; then
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="$TAG" -f previous_tag_name="$PREVIOUS_TAG" --jq .body > generated-notes.md
else
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="$TAG" --jq .body > generated-notes.md
fi
python3 - <<'PY'
import re
commits = open("direct-commits.md", encoding="utf-8").read().strip()
generated = open("generated-notes.md", encoding="utf-8").read()
# GitHub's body: "## What's Changed" with the PR list, optionally
# "## New Contributors", and a closing "**Full Changelog**" line.
prs, rest = [], []
section = None
for line in generated.splitlines():
if re.match(r"^##\s+What's Changed", line):
section = "prs"
continue
if line.startswith("## ") or line.startswith("**Full Changelog**"):
section = "rest"
if section == "prs":
if line.strip():
prs.append(line)
elif line.strip() or rest:
rest.append(line)
parts = []
if commits or prs:
parts.append("## What's Changed")
if commits:
parts.append("### Commits on main\n" + commits)
if prs:
parts.append("### Merged PRs\n" + "\n".join(prs))
if rest:
parts.append("\n".join(rest).strip())
with open("release-notes.md", "w", encoding="utf-8") as out:
out.write("\n\n".join(parts) + "\n")
PY
cat release-notes.md
- name: Download Windows artifact - name: Download Windows artifact
uses: actions/download-artifact@v4.1.3 uses: actions/download-artifact@v4.1.3
@@ -778,7 +787,8 @@ jobs:
allowUpdates: false allowUpdates: false
replacesArtifacts: false replacesArtifacts: false
makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }} makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }}
generateReleaseNotes: true bodyFile: release-notes.md
generateReleaseNotes: false
artifacts: >- artifacts: >-
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip, release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage, release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
@@ -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@v4.1.7
- 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 = 'praktimarc/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"
+12
View File
@@ -53,3 +53,15 @@ website/_site/
# Apple notarization private keys - never commit these # Apple notarization private keys - never commit these
*.p8 *.p8
# Gradle
.gradle/
build/
core/build/
app-desktop/build/
# Agent scratch area - screenshots, notes, throwaway scripts
scratch/
# Kotlin incremental build data
.kotlin/
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
@@ -1,416 +0,0 @@
From afb77c6a8674ff5571a9c57cbe7108b9a5189407 Mon Sep 17 00:00:00 2001
From: Codex <codex@openai.com>
Date: Fri, 4 Sep 2026 00:17:15 +0300
Subject: [PATCH] Document v1.43 and fix release metadata
---
.github/workflows/tagged-release.yml | 84 +++++++++----------
docs/PROJECT_CONTEXT.md | 4 +
github_docs/de-Changelog.md | 80 +++++++++++++++---
github_docs/en-Changelog.md | 80 +++++++++++++++---
.../kst4contest/ApplicationConstants.java | 2 +-
website/src/news/2026-09-03-version-1-43.md | 29 +++++++
6 files changed, 212 insertions(+), 67 deletions(-)
create mode 100644 website/src/news/2026-09-03-version-1-43.md
diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml
index d9133550..be7bf536 100644
--- a/.github/workflows/tagged-release.yml
+++ b/.github/workflows/tagged-release.yml
@@ -786,45 +786,45 @@ jobs:
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
-
- # The update feed is generated only after GitHub has published the
- # release. Otherwise the Releases API cannot return the release notes
- # belonging to the tag which triggered this workflow.
- - name: Set up Node.js for website build
- uses: actions/setup-node@v4
- with:
- node-version: "24"
- cache: npm
- cache-dependency-path: website/package-lock.json
-
- - name: Build and validate website after release publication
- working-directory: website
- run: |
- npm ci
- npm test
- npm run build
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Verify Stable release in update feed
- if: ${{ !startsWith(github.ref_name, 'beta-') }}
- working-directory: website
- run: npm run validate:version-info
- env:
- EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
-
- - name: Attach verified version info to tagged release
- run: >-
- gh release upload "${GITHUB_REF_NAME}"
- website/_site/kst4ContestVersionInfo.xml
- --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Upload verified website artifact
- uses: actions/upload-artifact@v4.3.4
- with:
- name: kst4contest-website-${{ github.ref_name }}
- path: website/_site/
- if-no-files-found: error
- retention-days: 14
\ No newline at end of file
+
+ # The update feed is generated only after GitHub has published the
+ # release. Otherwise the Releases API cannot return the release notes
+ # belonging to the tag which triggered this workflow.
+ - name: Set up Node.js for website build
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: website/package-lock.json
+
+ - name: Build and validate website after release publication
+ working-directory: website
+ run: |
+ npm ci
+ npm test
+ npm run build
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Verify Stable release in update feed
+ if: ${{ !startsWith(github.ref_name, 'beta-') }}
+ working-directory: website
+ run: npm run validate:version-info
+ env:
+ EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
+
+ - name: Attach verified version info to tagged release
+ run: >-
+ gh release upload "${GITHUB_REF_NAME}"
+ website/_site/kst4ContestVersionInfo.xml
+ --clobber
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload verified website artifact
+ uses: actions/upload-artifact@v4.3.4
+ with:
+ name: kst4contest-website-${{ github.ref_name }}
+ path: website/_site/
+ if-no-files-found: error
+ retention-days: 14
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index b0ca4b63..c4d70f1f 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -162,6 +162,10 @@ The repository contains the KST4Contest website under `website/`, published sepa
Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.
+- `APPLICATION_CURRENT_VERSION` is the user-visible semantic version and must use the dotted `major.minor.patch` form. `APPLICATION_CURRENTVERSIONNUMBER` is retained only for older feeds and encodes patch releases by appending the patch digit, for example `1.43.1` as `1.431`.
+- The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because `versionInfo.js` reads the published release body through the GitHub Releases API.
+- After publication, the workflow tests and builds the website, validates the expected Stable version, attaches `kst4ContestVersionInfo.xml` to the release and uploads the complete website build as a workflow artifact.
+
## Important Decisions and Workarounds
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md
index 2022f904..622bb446 100644
--- a/github_docs/de-Changelog.md
+++ b/github_docs/de-Changelog.md
@@ -8,6 +8,72 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
---
+## v1.43.1 (2026-09-03)
+
+**Korrigierte Versionsmetadaten**
+
+v1.43.1 enthält dieselben funktionalen Änderungen wie v1.43.0. Korrigiert wurden die Anwendungs- und Build-Metadaten für Anzeige, ON4KST-Kennung und Update-Vergleich. Ein Teil der Metadaten des ersten v1.43.0-Pakets wies den Build noch als Version 1.42 aus.
+
+### Behoben
+
+- **Einheitliche semantische Version:** Die sichtbare Anwendungsversion verwendet jetzt vollständig `1.43.1`. Der kompakte Wert `1.431` bleibt ausschließlich im veralteten numerischen Feld für die Kompatibilität mit älteren Update-Feeds erhalten.
+
+- **Update-Feed im Tagged-Release-Workflow:** Website-Build, Prüfung des Versionsfeeds und Upload der Artefakte laufen jetzt tatsächlich nach der Veröffentlichung des GitHub Releases. Diese Schritte waren zuvor versehentlich in die Artefaktliste der Release-Action eingerückt und wurden deshalb übersprungen.
+
+Wer v1.43.0 installiert hat, sollte v1.43.1 verwenden. Die Funktionen bleiben unverändert; korrigiert werden nur die Versionsmetadaten und der Release-Workflow.
+
+Die korrigierte Version ist als [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1) verfügbar.
+
+---
+
+## v1.43.0 (2026-09-03)
+
+**Zuverlässigere Log-Synchronisation, gespeicherte Tabellenlayouts und bessere DX-Cluster-Kompatibilität**
+
+v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und den stabilen Betrieb während längerer Conteste. Hinzu kommen praktische Einstellmöglichkeiten für Tabellenlayouts und die Stationsgruppierung auf der Karte.
+
+### Neu
+
+- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
+
+- **Tabellenlayout automatisch gesichert:** Tabellen erhalten beim ersten brauchbaren Inhalt sinnvolle Breiten. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden nach kurzer Verzögerung in `preferences.xml` geschrieben. Haupt- und Monitorfenster behalten getrennte Layouts für DXCluster und QSO of the other.
+
+- **Tooltips für gekürzte Tabellenwerte:** Normale Tabellenzellen zeigen ihren vollständigen Inhalt, wenn die sichtbare Spalte zu schmal ist. Funktionale Tooltips und anklickbare Links bleiben davon unberührt.
+
+- **Hinweis bei neu angelegtem Simplelogfile:** Fehlt die ausgewählte Datei, legt KST4Contest sie an und zeigt einen Hinweis mit Dateipfad, konkretem Testablauf und Link zum passenden Abschnitt des Handbuchs.
+
+### Geändert
+
+- **Robuste Simplelogfile-Auswertung:** Die ausgewählte Datei wird einmal pro Minute ausgewertet und nach jedem Durchlauf geschlossen, damit das Logprogramm sie ersetzen oder rotieren kann. Erkannte Rufzeichen setzen den globalen Worked-Status für alle aktiven Suffixvarianten des Basisrufzeichens. Bei deaktivierter Funktion findet kein Dateizugriff statt; Lese- oder Erstellungsfehler beenden die periodische Aufgabe nicht mehr.
+
+- **Einheitliche Bandwerte externer Logger:** UCXLog-kompatible Pakete und Win-Test-Ereignisse verwenden eine gemeinsame Bandnormalisierung. Numerische Werte, Meter- und Zentimeterangaben sowie die vorhandenen Win-Test-IDs setzen damit dieselben Worked-Markierungen und Worked-Großfelder. Insbesondere `2320`, `5760` und `10368` werden zuverlässig verarbeitet; bei fehlendem oder unbekanntem Band wird nur der globale Worked-Status gesetzt.
+
+- **Kompakte vollständige Frequenzen erkannt:** Vollständige Frequenzangaben ohne Dezimaltrenner werden auf allen unterstützten Bändern akzeptiert; die letzten drei Ziffern bilden den kHz-Anteil. Nackte dreistellige Zahlen benötigen weiterhin einen erkennbaren Frequenzkontext, damit Signalrapporte und andere Zahlen nicht als QRG behandelt werden.
+
+- **DXSpider-kompatibles Spotformat:** Lokale DX-Cluster-Spots verwenden eine feste 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt unbemerkt abgeschnitten zu werden. Damit ist [Issue #86](https://github.com/praktimarc/kst4contest/issues/86) behoben.
+
+- **Aktive ON4KST-Verbindungsprüfung:** Ein ruhiger Chatserver wird mit einer expliziten, sitzungsweiten Abfrage geprüft, bevor die Verbindung als unterbrochen gilt. Heartbeat und Prüftelegramme behalten das erforderliche CR/LF-Framing.
+
+- **Zuverlässige Altersmarkierung privater Nachrichten:** Eingehende Privatnachrichten verwenden bis zu fünf Minuten lang die definierten grünen Altersstufen. Eigene Nachrichten behalten ihre separate Darstellung; leere oder wiederverwendete Tabellenzeilen kehren zum normalen Design zurück und behalten keine veraltete Hervorhebung.
+
+### Behoben
+
+- **Worked-Status nach dem Login:** Persistierte SQLite-Informationen werden vor der Veröffentlichung jeder initialen ON4KST-Benutzerliste geladen und angewendet. Erneute Verbindungen, beide Kategorien und alle aktiven Varianten eines Basisrufzeichens starten dadurch mit dem richtigen Status. Damit ist [Issue #85](https://github.com/praktimarc/kst4contest/issues/85) behoben.
+
+- **Fehlerhafte Trennung bei ruhigem Server:** Eine gültige ON4KST-Verbindung wird nicht mehr beendet, nur weil der Server gerade keine Aktivitätszeilen überträgt.
+
+### Dokumentation und Auslieferung
+
+- Das deutsche und englische Handbuch wurden mit der Implementierung abgeglichen und überarbeitet. Ein neues Kapitel zum Contest-Workflow verbindet die einzelnen Funktionen zu einem praktischen Betriebsablauf; außerdem wurden die Abschnitte zu Dual Chat, Privatnachrichten, QRG-Synchronisation, Simplelogfile-Auswertung und Konfiguration präzisiert.
+
+- Die Website beschreibt Band- und Richtungsgelegenheiten, Stationskarte, QRG-Verarbeitung, Filter, globale Nachrichtenansichten, Privatnachrichten und Log-Synchronisation jetzt ausführlicher.
+
+- Die AUR-Paketdefinitionen wurden auf v1.43.0 aktualisiert.
+
+Die vollständige Funktionalität von v1.43.0 steht im [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0) bereit. Wegen der inkonsistenten eingebetteten Versionsmetadaten ist v1.43.1 der empfohlene Paketstand.
+
+---
+
## v1.42.0 (2026-08-22)
**Gemeinsamer Bandkontext, sitzungsbasierte ON4KST-Verbindung und signierte macOS-Pakete**
@@ -38,21 +104,17 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **Filter zurücksetzen:** Ein eigener Reset-Button entfernt die aktiven Filterprädikate der Benutzerliste zuverlässig.
-- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die automatisch gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
+- **Kartencluster:** Räumlich dicht beieinanderliegende Stationen werden bei niedrigen Zoomstufen zusammengefasst. Die ausgewählte Station und relevante Richtungsgelegenheiten bleiben einzeln sichtbar.
- **Ausblendbare Streckenanalyse:** Geländeprofil und Analysebereich der Stationskarte können vollständig ausgeblendet werden. Die Auswahl wird gespeichert und beim nächsten Programmstart wiederhergestellt.
### Geändert
-- **Tabellenlayout automatisch gesichert:** Tabellen werden beim ersten brauchbaren Inhalt sinnvoll dimensioniert. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden verzögert in `preferences.xml` gespeichert; Haupt- und Monitorfenster behalten für DXCluster und QSO of the other getrennte Layouts. Gekürzte normale Zellwerte zeigen ihren Volltext im Tooltip, ohne funktionale Tooltips oder anklickbare Links zu verdrängen.
-
-- **DX-Cluster-Zeilenformat vereinheitlicht:** Lokale Spots verwenden jetzt eine feste, DXSpider-kompatible 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt auch bei Mikrowellenfrequenzen bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt still abgeschnitten zu werden; AirScout- und Testkommentare sind entsprechend kompakter.
-
- **Sessionbezogene ON4KST-Verbindungssteuerung:** Socket, Reader, Writer, Messagebus und Warteschlangen gehören jetzt zu einer eindeutig identifizierten Verbindungssession. Veraltete Threads einer abgelösten Verbindung können dadurch keine Daten mehr verarbeiten oder die neue Verbindung schließen. `ONLINE` wird erst nach bestätigtem Login und vollständig empfangenen Benutzerlisten gemeldet. Verbindungsaufbau, Login und Synchronisation besitzen feste Zeitlimits; Heartbeats, ausbleibende Eingangsdaten, EOF sowie Lese- und Schreibfehler werden überwacht und lösen bei Bedarf einen kontrollierten Neuaufbau mit Backoff aus.
- **ON4KST-Protokollbefehle abgesichert:** Ausgehende Befehle werden zentral aufgebaut und auf gültige Kategorien, Locatoren und unerlaubte Frame-Trennzeichen geprüft. Da ON4KST pro TCP-Session nur einen Locator verwaltet, wird für beide Chat-Kategorien der Hauptlocator verwendet und eine abweichende zweite Konfiguration protokolliert, statt widersprüchliche Befehle an den Server zu senden.
-- **QRG-Erkennung präzisiert:** Vollständige Frequenzangaben werden auch ohne Dezimaltrenner erkannt; die letzten drei Ziffern bilden dabei den kHz-Anteil. Relative Frequenzen bleiben unverändert, und nackte dreistellige Zahlen gelten weiterhin nur bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
+- **QRG-Erkennung präzisiert:** Vollständige und relative Frequenzangaben werden weiterhin erkannt. Nackte dreistellige Zahlen gelten nur noch bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
- **Stationsbezogener Frequenzkontext:** Bei relativen QRGs verwendet KST4Contest zuerst einen höchstens 30 Minuten alten Bandkontext derselben Station. Erst wenn dieser fehlt, wird das global konfigurierte Fallback-Band verwendet.
@@ -86,10 +148,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **DXLog-Gesamtlog übernommen:** Der UCXLog-kompatible UDP-Listener verarbeitet neben `contactinfo` auch `contactreplace`. Dadurch kann ein von DXLog.net als vollständiges Log ausgesendeter Datenbestand eingelesen werden.
-- **Logger-Bandwerte vereinheitlicht:** Numerische sowie Meter- und Zentimeterangaben aus UCXLog-kompatiblen QSO-Paketen und die Band-IDs von Win-Test werden einmal normalisiert und danach einheitlich für Worked-Markierungen und Worked-Großfelder verwendet. Dadurch setzen insbesondere `2320`, `5760` und `10368` zuverlässig ihre vorhandenen Bandmarkierungen. Bei einem fehlenden oder unbekannten Band bleibt es beim globalen Worked-Status.
-
-- **Simplelogfile-Verhalten präzisiert:** Die ausgewählte Textdatei wird einmal pro Minute mit einem festen Rufzeichenmuster ausgewertet. Treffer setzen den globalen Worked-Status aller aktiven Varianten des Basisrufzeichens, werden aber nicht in SQLite persistiert. Eine fehlende Datei wird angelegt; Lese- und Erstellungsfehler beenden die periodische Auswertung nicht. Ein Datenbank-Reset verändert die Datei nicht, sodass enthaltene Rufzeichen bei der nächsten Auswertung erneut als gearbeitet markiert werden.
-
- **Automatische QRG-Übernahme abgesichert:** `MYQRG` wird nur von einer aktivierten Schnittstelle aktualisiert, die tatsächlich gültige `RadioInfo`- beziehungsweise Win-Test-`STATUS`-Pakete liefert. Eine aktivierte, aber nicht liefernde Quelle ersetzt die notwendige Funktionsprüfung oder manuelle QRG-Pflege nicht.
- **Versionserkennung verbessert:** Versionsnummern werden semantisch verglichen, damit beispielsweise Patch-Versionen und Nightly-Stände nicht mehr durch eine einfache Fließkommazahl falsch eingeordnet werden.
@@ -98,8 +156,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **Zuverlässige Benutzerliste beim Login:** Ungültige oder unvollständige `UA0`-Teilnehmerdatensätze werden einzeln verworfen und protokolliert, ohne die Verarbeitung der alphabetisch folgenden Teilnehmer abzubrechen. Die gültigen Einträge werden zunächst pro Kategorie gesammelt und erst mit dem ersten zugehörigen `UE`-Abschlussframe vollständig veröffentlicht.
-- **Persistierter Worked-Status beim Listenaufbau:** Beim Abschluss jeder initialen ON4KST-Benutzerliste wird der SQLite-Zustand einmal geladen und vor der Veröffentlichung auf die neuen Chatmember angewendet. Das gilt für beide Kategorien, erneute Verbindungen und alle aktiven Varianten eines Basisrufzeichens.
-
- **Benutzerliste verschwindet nach dem Login:** ON4KST kann nach Namens-, Status- oder anderen Live-Änderungen weitere `UE`-Frames für dieselbe Kategorie senden. Wiederholte Abschlussframes werden jetzt erkannt und ignoriert, damit eine bereits gefüllte Benutzerliste nicht durch eine leere Momentaufnahme ersetzt wird.
- **Fehlgeschlagener Erstaufbau und Verbindungsverlust:** Wenn beim Programmstart keine Verbindung zum Server hergestellt werden kann, läuft KST4Contest nicht mehr in eine Endlos- oder Busy-Wait-Schleife. Die Oberfläche bleibt bedienbar und weitere Versuche erfolgen mit begrenztem Backoff. Auch ein vom Server geschlossener oder über längere Zeit stummer Socket wird zuverlässig erkannt.
diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md
index 1635ce44..af1cb47b 100644
--- a/github_docs/en-Changelog.md
+++ b/github_docs/en-Changelog.md
@@ -8,6 +8,72 @@ Published Stable versions and their application packages are available under [Gi
---
+## v1.43.1 (2026-09-03)
+
+**Corrected version metadata**
+
+v1.43.1 contains the same functional changes as v1.43.0. It corrects the application and build metadata used for display, ON4KST identification and update comparison. Some metadata in the first v1.43.0 package set still identified the build as version 1.42.
+
+### Fixed
+
+- **Consistent semantic version:** The user-visible application version now uses the complete `1.43.1` form. The compact value `1.431` remains only in the deprecated numeric field required for compatibility with older update feeds.
+
+- **Tagged-release update feed:** The website build, version-feed validation and artifact upload now run as actual workflow steps after the GitHub Release has been published. They were previously indented into the release action's artifact list and therefore skipped.
+
+Users of v1.43.0 should install v1.43.1. The functionality is unchanged; the update only corrects the version metadata and release workflow.
+
+The corrected version is available as [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
+
+---
+
+## v1.43.0 (2026-09-03)
+
+**More reliable log synchronisation, persistent layouts and better DX Cluster compatibility**
+
+v1.43 concentrates on reliability around external log data and long-running contest operation. It also adds practical control over table layouts and station grouping on the map.
+
+### Added
+
+- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
+
+- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay. The main and monitor windows keep separate DXCluster and QSO-of-the-other layouts.
+
+- **Tooltips for truncated table values:** Normal table cells expose their complete text when the visible column is too narrow, without replacing functional tooltips or clickable links.
+
+- **Simplelogfile creation notice:** When the selected file does not exist, KST4Contest creates it and displays a notice with the file path, a concrete test procedure and a link to the relevant manual section.
+
+### Changed
+
+- **Robust Simplelogfile evaluation:** The selected file is evaluated once per minute and closed after every pass so that the logging application can replace or rotate it. Detected callsigns apply the global Worked state to every active suffix variant of the base callsign. Disabling the function prevents any file access, while read or creation errors no longer terminate the periodic task.
+
+- **Consistent external logger bands:** UCXLog-compatible packets and Win-Test events use one shared band normalisation. Numeric values, metre and centimetre designators and the existing Win-Test IDs now set the same Worked flags and worked grid squares. Values including `2320`, `5760` and `10368` are handled reliably; a missing or unknown band sets only the global Worked state.
+
+- **Compact full-frequency recognition:** Complete frequencies without a decimal separator are accepted across the supported bands, with the final three digits interpreted as the kHz part. Bare three-digit numbers still require recognisable frequency context so that signal reports and unrelated numbers are not treated as QRGs.
+
+- **DXSpider-compatible spot format:** Local DX Cluster spots use a fixed 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The format remains stable up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated. This resolves [Issue #86](https://github.com/praktimarc/kst4contest/issues/86).
+
+- **Active ON4KST connection probe:** A quiet chat server is checked with an explicit session-wide probe before the connection is treated as dead. Heartbeats and probe frames retain the required CR/LF framing.
+
+- **Reliable private-message age highlighting:** Incoming private messages use the defined green age levels for up to five minutes. Locally sent messages retain their separate style, and empty or reused table rows return to the normal design instead of keeping an obsolete highlight.
+
+### Fixed
+
+- **Worked state after login:** Persisted SQLite Worked information is loaded and applied before each initial ON4KST user list is published. Reconnects, both categories and all active variants of a base callsign therefore start with the correct state. This resolves [Issue #85](https://github.com/praktimarc/kst4contest/issues/85).
+
+- **False disconnect during quiet periods:** A valid ON4KST connection is no longer closed merely because the server currently has no activity lines to send.
+
+### Documentation and packaging
+
+- The German and English manuals were revised against the implementation. A new contest-workflow chapter connects the individual functions into a practical operating sequence, while the sections on dual chat, private-message handling, QRG synchronisation, Simplelogfile evaluation and configuration were clarified.
+
+- The website feature pages now describe band and direction opportunities, the station map, QRG handling, filters, global message views, private-message handling and logger synchronisation in more detail.
+
+- The AUR package definitions were updated for v1.43.0.
+
+The complete v1.43.0 functionality is available in [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0). Because its embedded version metadata is inconsistent, v1.43.1 is the recommended package set.
+
+---
+
## v1.42.0 (2026-08-22)
**Shared band context, session-based ON4KST connection and signed macOS packages**
@@ -38,21 +104,17 @@ v1.42 brings several previously separate calculations together. Band information
- **Filter reset:** A dedicated reset button reliably removes the active user-list filter predicates.
-- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The automatically stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
+- **Map clustering:** Stations close to each other are grouped at lower zoom levels. The selected station and relevant direction opportunities remain individually visible.
- **Hideable path analysis:** The terrain profile and analysis section of the station map can be hidden completely. The selected state is stored and restored at the next application start.
### Changed
-- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay; the main and monitor windows keep separate DXCluster and QSO-of-the-other layouts. Truncated normal cell values expose their full text in a tooltip without replacing functional tooltips or clickable links.
-
-- **Unified DX Cluster line format:** Local spots now use a fixed, DXSpider-compatible 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The layout remains stable for microwave frequencies up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated; AirScout and test comments are correspondingly more compact.
-
- **Session-based ON4KST connection lifecycle:** Each socket, reader, writer, message bus and queue now belongs to an explicitly identified connection session. Delayed threads from an obsolete connection can therefore no longer process data or close its replacement. `ONLINE` is reported only after the login has been accepted and all requested user lists have been received. Connection setup, login and synchronisation use bounded timeouts, while heartbeats, missing inbound traffic, EOF and read or write failures trigger controlled reconnect attempts with backoff where appropriate.
- **Validated ON4KST protocol commands:** Outgoing frames are built centrally and checked for valid categories, locators and prohibited frame delimiters. Because ON4KST maintains one locator per TCP session, the main locator is used for both chat categories and a conflicting secondary configuration is logged instead of sending contradictory commands to the server.
-- **More precise QRG recognition:** Complete frequencies are also recognised without a decimal separator, with the final three digits interpreted as the kHz part. Relative frequencies remain unchanged, and bare three-digit numbers still require recognisable frequency context. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
+- **More precise QRG recognition:** Complete and relative frequency references continue to be recognised. Bare three-digit numbers are treated as QRGs only when a frequency context is available. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
- **Station-specific frequency context:** For relative QRGs, KST4Contest first uses a band context for the same station which is no more than 30 minutes old. The globally configured fallback band is used only when this context is unavailable.
@@ -86,10 +148,6 @@ v1.42 brings several previously separate calculations together. Band information
- **DXLog full-log import:** In addition to `contactinfo`, the UCXLog-compatible UDP listener processes `contactreplace`. This allows a complete log broadcast by DXLog.net to be imported.
-- **Consistent logger band values:** Numeric, metre and centimetre values from UCXLog-compatible QSO packets and Win-Test band IDs are normalised once and then used consistently for Worked marks and worked grid squares. In particular, `2320`, `5760` and `10368` now reliably set their existing band marks. A missing or unknown band continues to set only the global Worked status.
-
-- **Defined Simplelogfile behaviour:** The selected text file is evaluated once per minute using a fixed callsign pattern. Matches set the global Worked status for all active variants of the base callsign but are not persisted in SQLite. A missing file is created, and read or creation errors do not terminate the periodic task. A database reset does not change the file, so callsigns contained in it are marked as worked again during the next evaluation.
-
- **Guarded automatic QRG updates:** `MYQRG` is updated only by an enabled interface which actually supplies valid `RadioInfo` or Win-Test `STATUS` packets. An enabled source which provides no data does not remove the need for a functional check or manual QRG maintenance.
- **Improved version comparison:** Versions are compared semantically so that patch releases and Nightly versions are not misclassified by conversion to a floating-point number.
@@ -98,8 +156,6 @@ v1.42 brings several previously separate calculations together. Band information
- **Reliable initial user list:** Invalid or incomplete `UA0` member records are rejected and logged individually without preventing alphabetically following members from being processed. Valid entries are staged per category and published as one complete snapshot when the first corresponding `UE` end marker is received.
-- **Persisted Worked state during initial-list setup:** At the end of each initial ON4KST user list, the SQLite state is loaded once and applied to the new chat members before publication. This covers both categories, reconnects and every active variant of a base callsign.
-
- **User list disappearing after login:** ON4KST may send additional `UE` frames for the same category after name, state or other live updates. Repeated end markers are now detected and ignored so that an already populated user list cannot be replaced by an empty snapshot.
- **Failed initial connection and lost sockets:** An unavailable server during startup no longer sends KST4Contest into an endless or busy-wait loop. The user interface remains responsive and further attempts use bounded reconnect backoff. Sockets closed by the server, or connections without inbound traffic for an excessive period, are also detected reliably.
diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/src/main/java/kst4contest/ApplicationConstants.java
index 6da42ff4..71f64575 100644
--- a/src/main/java/kst4contest/ApplicationConstants.java
+++ b/src/main/java/kst4contest/ApplicationConstants.java
@@ -20,7 +20,7 @@ public class ApplicationConstants {
/**
* Version shown to the user and used for semantic version comparison.
*/
- public static final String APPLICATION_CURRENT_VERSION = "1.431";
+ public static final String APPLICATION_CURRENT_VERSION = "1.43.1";
/**
* Legacy numeric representation used only while older update feeds and
diff --git a/website/src/news/2026-09-03-version-1-43.md b/website/src/news/2026-09-03-version-1-43.md
new file mode 100644
index 00000000..30754c3d
--- /dev/null
+++ b/website/src/news/2026-09-03-version-1-43.md
@@ -0,0 +1,29 @@
+---
+title: Version 1.43.1 released
+summary: Reliable log synchronisation, persistent layouts and better DX Cluster compatibility
+date: 2026-09-03
+---
+
+## Version 1.43.1
+
+Version 1.43 focuses on the parts of KST4Contest that have to keep working during a long contest: ON4KST connection monitoring, log synchronisation, Worked information and the data sent to external logging software.
+
+The first v1.43.0 packages already contained all functional changes described below, but some embedded version metadata still identified the build as version 1.42. v1.43.1 corrects this. If you already downloaded v1.43.0, use v1.43.1 instead.
+
+### What changed
+
+- **More reliable log synchronisation:** The Simplelogfile parser now closes the selected file after every pass, handles suffix variants by their base callsign and reports when a missing file has been created. UCXLog-compatible packets and Win-Test events share the same band normalisation, including the existing microwave bands.
+- **Correct Worked state after login:** Persisted Worked information is applied before the initial ON4KST user list becomes visible. Reconnects and both chat categories therefore start with the correct state.
+- **No false disconnect on a quiet server:** KST4Contest actively checks a quiet ON4KST session before treating it as dead. A period without new activity lines no longer causes an unnecessary reconnect.
+- **DX Cluster compatibility:** Local spots now use a fixed, DXSpider-compatible 75-character line format that is also accepted reliably by DXLog. Frequencies up to 24 GHz keep the required column positions.
+- **Layouts that stay where you put them:** Table column widths, window sizes and relevant dividers are saved automatically. Truncated table values expose their full text in a tooltip.
+- **Controllable map grouping:** **Group nearby stations** switches map clustering on or off without changing the current viewport or selected station.
+- **Smaller corrections:** Complete QRGs without a decimal separator are recognised across the supported bands, and private-message age highlighting no longer remains attached to reused table rows.
+
+The complete technical list is available in the [changelog](/manual/en/changelog/) and the [GitHub release notes](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
+
+### Getting it
+
+Packages for Windows, Linux and macOS are available on the [download page](/download/) and in the [GitHub release](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). The AUR packages are updated through their normal release workflow.
+
+The German and English manuals have also been revised. The new contest-workflow chapter connects the individual functions into a practical operating sequence instead of describing them only in isolation. 73
--
2.51.1
+19 -18
View File
@@ -4,18 +4,18 @@ These project instructions extend Marc's global Codex working agreements.
## Project identity ## Project identity
KST4Contest is a Java/JavaFX desktop client for ON4KST chat with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence. 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: Primary repository areas:
- `src/main/java/kst4contest/` - `core/src/main/java/kst4contest/` and `core/src/test/`
- `src/test/` where present; - `app-desktop/src/main/java/kst4contest/` and `app-desktop/src/test/`
- `github_docs/` - `github_docs/`
- `website/` - `website/`
- `docs/` - `docs/`
- `packaging/` - `packaging/`
- `.github/` - `.github/`
- `pom.xml` - `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/`
Inspect the current tree before assuming an exact class/path still exists. Inspect the current tree before assuming an exact class/path still exists.
@@ -43,13 +43,13 @@ If an answer is uncertain, do not interpolate it. Check current code/tests/docs/
- Commit messages are concise English when a commit is explicitly requested. - Commit messages are concise English when a commit is explicitly requested.
- User-facing DE/EN documentation follows `$praktimarc-writing-style`. - User-facing DE/EN documentation follows `$praktimarc-writing-style`.
## Java and JavaFX architecture ## Java and user-interface architecture
- Preserve or improve separation between network/parsing/service/controller/UI responsibilities. - Preserve or improve separation between network/parsing/service/controller/UI responsibilities.
- Do not solve architecture problems by letting worker/model code directly manipulate JavaFX UI collections. - 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; JavaFX `ObservableList` data is a UI projection, not the canonical worker-thread store. - 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 JavaFX `ObservableList` used by the UI. - `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 JavaFX Application Thread (`Platform.runLater` or the project's equivalent helper). - 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. - 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. - Handle incomplete external/historical data defensively.
- `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero. - `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero.
@@ -96,9 +96,10 @@ Never change CR/LF, XML framing, callsign normalization, frequency formatting or
- If no send category is selected, preserve the established Main-category fallback unless explicitly changed. - 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. - Null/unknown data must render as unavailable/empty according to current UI conventions, not as fake zero values.
## WebView / map compatibility ## Map
- The Leaflet WebView workaround that disables problematic CSS 3D transforms before Leaflet loads is a known Java 21 stability measure. Do not remove or reorder it without reproducing and understanding the original rendering/flicker problem. - 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 ## Autoanswer / beacon safety
@@ -111,22 +112,22 @@ Never change CR/LF, XML framing, callsign normalization, frequency formatting or
## Build and verification ## Build and verification
Use the Maven wrapper. Use the Gradle wrapper.
Windows: Windows:
```text ```text
.\mvnw.cmd ... .\gradlew.bat ...
``` ```
Read the current `pom.xml` before relying on version numbers. Read the current `gradle.properties` and `gradle/libs.versions.toml` before relying on version numbers.
At the package creation snapshot the project uses Java 21 / JavaFX 21.x and JUnit 5/Mockito, with PMD and SpotBugs integrated. 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: Maven/Surefire configuration has historically allowed test failures to be ignored, and static-analysis findings may not fail the build. Therefore: Important: the build configuration has historically allowed test failures to be ignored, and static-analysis findings do not fail the build. Therefore:
- inspect the Maven test summary; - inspect the Gradle test summary;
- inspect Surefire results when needed; - inspect the test-results XML under `*/build/test-results/` when needed;
- do not infer "all tests passed" from exit code 0; - do not infer "all tests passed" from exit code 0;
- report PMD/SpotBugs findings that are visible in the relevant build. - report PMD/SpotBugs findings that are visible in the relevant build.
+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.
+5 -7
View File
@@ -57,26 +57,24 @@ Changes to operating behaviour should be documented together with their purpose
## Building from source ## Building from source
Building KST4Contest requires JDK 21. The Maven Wrapper included in the repository should be used, so a separate Maven installation is not required. 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: Linux and macOS:
```bash ```bash
./mvnw clean test ./gradlew clean build
./mvnw -B -DskipTests compile
``` ```
Windows: Windows:
```powershell ```powershell
mvnw.cmd clean test .\gradlew.bat clean build
mvnw.cmd -B -DskipTests compile
``` ```
## Repository structure ## Repository structure
- `src/main/java/` – application source code - `core/` – domain, controller and network code, free of user-interface technology
- `src/test/` – automated tests - `app-desktop/` – the Compose Multiplatform user interface and the packaging tasks
- `github_docs/` – German and English manual sources - `github_docs/` – German and English manual sources
- `website/` – project website sources - `website/` – project website sources
- `packaging/` – platform-specific packaging files - `packaging/` – platform-specific packaging files
+157
View File
@@ -0,0 +1,157 @@
plugins {
java
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
}
kotlin {
jvmToolchain(21)
}
dependencies {
// Mirrors the compile-scope entries in pom.xml: a few test classes still live under
// src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
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")
inputs.dir(input)
outputs.dir(generatedI18nDirectory)
argumentProviders.add(CommandLineArgumentProvider {
listOf(input.asFile.absolutePath, generatedI18nDirectory.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")) }
windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) }
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,380 @@
# German. Held complete by a test, because this is a shipped language.
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. 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
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.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.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.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.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.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.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)
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.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.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!
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=QSO der Gegenstation
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
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
@@ -0,0 +1,389 @@
# 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 are applied immediately. 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
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.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.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.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.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.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.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)
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.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.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!
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
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
@@ -1,6 +1,6 @@
package kst4contest.test; package kst4contest.test;
import javafx.beans.property.SimpleStringProperty; import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST; import kst4contest.controller.Utils4KST;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -107,25 +107,25 @@ public class PatternMatcherTest {
int i = 0; int i = 0;
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleValue<>("144")));
} }
} }
@@ -1,6 +1,6 @@
package kst4contest.test; package kst4contest.test;
import javafx.beans.property.SimpleStringProperty; import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST; import kst4contest.controller.Utils4KST;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -51,9 +51,9 @@ class Utils4KSTFrequencyTest {
} }
private String convert(String frequency, String fallbackPrefix) { private String convert(String frequency, String fallbackPrefix) {
SimpleStringProperty fallback = fallbackPrefix == null SimpleValue<String> fallback = fallbackPrefix == null
? null ? null
: new SimpleStringProperty(fallbackPrefix); : new SimpleValue<>(fallbackPrefix);
return Utils4KST.normalizeFrequencyString( return Utils4KST.normalizeFrequencyString(
frequency, frequency,
@@ -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,38 @@
package kst4contest.view;
import javax.swing.Timer;
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 Timer 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) {
Objects.requireNonNull(action, "action");
this.timer = new Timer(delayMs, event -> action.run());
this.timer.setRepeats(false);
}
/** 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,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);
}
}
}
@@ -4,62 +4,15 @@ import kst4contest.controller.ChatController;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import javafx.scene.image.Image; import java.awt.EventQueue;
import javafx.stage.Stage;
import java.net.URL;
public class GuiUtils { public class GuiUtils {
private static final String APPLICATION_ICON_RESOURCE = "/icons/kst4contest.png"; /*
* The application icon used to live here as a JavaFX image applied to a Stage. There is
private static Image applicationIcon; * no Stage left: every window is a Compose window and loads the same resource itself in
* ComposeWindowHost.applicationIcon().
/**
* Applies the common KST4Contest application icon to a JavaFX stage.
*
* <p>The icon is loaded only once and reused for all application windows.
* A missing icon resource must never prevent a window from opening.</p>
*
* @param stage stage that should receive the application icon
*/ */
public static void applyApplicationIcon(Stage stage) {
if (stage == null) {
return;
}
Image icon = getApplicationIcon();
if (icon != null && !stage.getIcons().contains(icon)) {
stage.getIcons().add(icon);
}
}
/**
* Loads and caches the common KST4Contest application icon.
*
* @return application icon or null if the resource is unavailable
*/
private static Image getApplicationIcon() {
if (applicationIcon != null) {
return applicationIcon;
}
URL iconUrl = GuiUtils.class.getResource(APPLICATION_ICON_RESOURCE);
if (iconUrl == null) {
System.err.println(
"Application icon resource not found: "
+ APPLICATION_ICON_RESOURCE
);
return null;
}
applicationIcon = new Image(iconUrl.toExternalForm());
return applicationIcon;
}
private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$"; private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$";
/** /**
@@ -77,7 +30,7 @@ public class GuiUtils {
* @param maybeCallSignValue * @param maybeCallSignValue
* @return true if yes * @return true if yes
*/ */
static boolean isCallSignSyntax(String maybeCallSignValue) { public static boolean isCallSignSyntax(String maybeCallSignValue) {
Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE); Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(maybeCallSignValue); Matcher matcher = pattern.matcher(maybeCallSignValue);
@@ -96,10 +49,10 @@ public class GuiUtils {
public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) { public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) {
if (javafx.application.Platform.isFxApplicationThread()) { if (EventQueue.isDispatchThread()) {
triggerUpdate(chatController); triggerUpdate(chatController);
} else{ } else {
javafx.application.Platform.runLater(() -> triggerUpdate(chatController)); EventQueue.invokeLater(() -> triggerUpdate(chatController));
} }
} }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,72 @@
package kst4contest.view;
import kst4contest.observe.UiDispatcher;
import javax.swing.Timer;
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 Timer 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) {
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
this.uiDispatcher = Objects.requireNonNull(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.saveDelay = new Timer(SAVE_DELAY_MS, event -> flushPending());
this.saveDelay.setRepeats(false);
}
/** 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,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,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,61 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
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"
/** `.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,308 @@
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>,
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 = "clear", 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 = 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,
)
}
}
/**
* 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,
) {
var draft by remember(stored) { mutableStateOf(stored) }
TooltipArea(tooltip = { TooltipChip(tooltip) }) {
CompactTextField(
value = draft,
onValueChange = { draft = it },
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
dialog.setLocationRelativeTo(null)
/* 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("OK", 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
dialog.setLocationRelativeTo(null)
/*
* 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,350 @@
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.dp
import androidx.compose.ui.window.FrameWindowScope
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.Toolkit
import java.util.concurrent.atomic.AtomicBoolean
/**
* 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.
*
* @param threadName names the thread in a stack dump, which is where one looks when a
* window will not close
*/
class ComposeWindowHost(private val threadName: String) {
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
/** 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`.
*/
private 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
}
val windowState = rememberWindowState(
width = widthDp.dp,
height = 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.
*/
if (onResized != null) {
LaunchedEffect(windowState) {
snapshotFlow { windowState.size }.collect { size ->
if (size.width.isSpecified && size.height.isSpecified) {
onResized(size.width.value, size.height.value)
}
}
}
}
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
/*
* 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 }
}
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
}
}
/**
* 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,381 @@
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.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 lists write through, so this is where the roster is filled.
* @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()
}
}
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,406 @@
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
fun forceRedraw() {
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
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,88 @@
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 = "AP [minutes / pot%]",
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
weight = 1.4f,
),
DataColumn(
id = "category",
title = "Category",
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,564 @@
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.MutableState
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.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.
*
* @param stored the value currently stored; the draft falls back to it when an
* entry is refused, which is what those fields did with setText
* @param commit returns null when the value was accepted, otherwise the reason
* @param onRefused shown to the operator; the JavaFX fields opened an alert
*/
@Composable
fun committed(
label: String,
stored: String,
commit: (String) -> String?,
onRefused: (String) -> Unit,
) {
var draft by remember(stored) { mutableStateOf(stored) }
fun commitDraft() {
val refusal = commit(draft)
if (refusal != null) {
onRefused(refusal)
draft = stored
}
}
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focus -> if (!focus.isFocused) 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) ?: "not selected",
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) ?: "not selected",
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) {
var draft by remember(value) { mutableStateOf(value.toString()) }
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().toIntOrNull()?.let(onChange)
},
isError = draft.trim().toIntOrNull() == null,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
},
isError = draft.trim().replace(',', '.').toDoubleOrNull() == 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,223 @@
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar
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
Box {
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,
),
)
DropdownMenu(expanded = open, onDismissRequest = rowState::close) {
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,
)
}
}
}
}
}
/** 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)
@@ -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,285 @@
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,
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,93 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
/** 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 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.
*/
/**
* What the title's first half says.
*
* The live connection detail while the chat is usable -- which is the whole status line,
* exactly what the JavaFX title carried -- and a wording about the attempt otherwise. A
* blank detail falls through to the offline wording rather than leaving the title empty.
*
* @param state the connection state, null before the first push
* @param detail the status line the controller pushes
* @return the chat half of the title
*/
fun chatState(state: On4KstConnectionState?, detail: String?): String =
if (state == On4KstConnectionState.ONLINE && !detail.isNullOrBlank()) {
detail
} else {
offlineChatState(state)
}
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,153 @@
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")
@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.
*/
val surroundings = currentState?.surroundings
MainWindowFrame.title(
chatState = MainWindowFrame.chatState(
surroundings?.connectionState,
surroundings?.connectionDetail,
),
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,164 @@
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 three small fields beside the send controls. Mirrored rather than read straight
* from the preferences, because all three 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())
/**
* 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()
}
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)
}
@@ -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 = "Category",
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,107 @@
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.HorizontalDivider
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. 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>,
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 = { "Cluster & QSO of the other" },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
languageStore = languageStore,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
) { _ ->
MonitorContent(clusterTable, qsoTable)
}
}
}
@Composable
private fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
Text("DX cluster", style = MaterialTheme.typography.titleSmall)
/*
* Half each, fixed. The JavaFX window used a SplitPane whose divider the
* operator could drag, and that position was stored; reproducing the
* gesture belongs with the station list of Etappe 5, where a split pane is
* unavoidable. Until then an even split is honest rather than a broken
* handle.
*/
DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth())
HorizontalDivider(thickness = Density.HAIRLINE)
Text(
"QSO of the other",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 4.dp),
)
DataTableView(qsoTable, 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
dialog.setLocationRelativeTo(null)
/* 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,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 = "Last QRG",
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 = "Category",
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 = "Sender",
value = { MonitorColumns.callSignOf(it.sender) },
weight = 0.8f,
),
DataColumn(
id = "call-rx",
title = "Receiver",
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,
),
)
}
@@ -0,0 +1,257 @@
package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.RadioButton
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kst4contest.model.Band
import java.util.function.Consumer
/**
* The panel below the station list, for whichever station is selected.
*
* Its rules live in [SelectedStationState] and are tested there; what is here is the
* drawing. With nothing selected it says so rather than showing dead controls — the
* JavaFX pane disabled them, which looks the same as broken.
*
* @param openInBrowser opens a lookup address; reaching the browser is the host
* application's business
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SelectedStationPanel(
state: SelectedStationState,
messages: DataTableState<kst4contest.model.ChatMessage>,
/** The bands this station is set up for; BandOpportunityResolver decides them. */
skedBands: List<Band>,
/** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */
activeBands: Set<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
modifier: Modifier = Modifier,
) {
val strings = LocalStrings.current
val member = state.selected
Column(
modifier = modifier.fillMaxWidth().padding(4.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
if (member == null) {
Text(
strings.selectedNone,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
return@Column
}
Text(
member.callSign.orEmpty() + " " + member.qra.orEmpty(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(bottom = 8.dp)
) {
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
if (category != null) {
Text(category, style = MaterialTheme.typography.bodySmall)
}
if (member.getQTFdirection() != null) {
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall)
}
if (member.qrb != null) {
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
}
val lastActSec = member.activityTimeLastInEpoch
if (lastActSec > 0L) {
val actStr = MessageFormats.clockTime(lastActSec.toString())
val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
Text(strings.selectedLastActivity(actStr, minAgo), style = MaterialTheme.typography.bodySmall)
}
val detectedBands = state.detectedBands()
if (detectedBands.isNotBlank()) {
Text(detectedBands, style = MaterialTheme.typography.bodySmall)
}
}
Form.section(strings.selectedShowMessages) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
SelectedMessageFilter.entries.forEach { filter ->
RadioChoice(
label = filter.label,
selected = state.messageFilter == filter,
onSelect = { state.messageFilter = filter },
)
}
}
}
DataTableView(
state = messages,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
Form.section(strings.selectedTagNotQrv) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
activeNotQrvBands.forEach { band ->
Form.check(band.label, state.isTaggedNotQrv(band)) {
state.tagNotQrv(band, it)
}
}
Form.check("all", activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
state.tagNotQrvAll(it)
}
}
}
Form.section("Sked") {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
) {
Form.inlineChoice(
label = strings.selectedSkedInMinutes,
items = SelectedStationState.SKED_MINUTES,
selected = state.skedMinutes,
describe = { it.toString() },
onSelect = { state.skedMinutes = it },
)
Form.inlineChoice(
label = strings.selectedBand,
items = skedBands,
selected = state.skedBand,
describe = { it.prefix + " MHz" },
onSelect = { state.skedBand = it },
)
Form.inlineChoice(
label = strings.selectedMode,
items = SelectedStationState.SKED_MODES,
selected = state.skedMode,
describe = { it },
onSelect = { state.skedMode = it },
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it }
Form.inlineChoice(
label = strings.selectedReminderOffsets,
items = SelectedStationState.REMINDER_OFFSETS,
selected = state.reminderOffsets,
describe = { it },
enabled = state.remindPm,
onSelect = { state.reminderOffsets = it },
)
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() }
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
}
}
Form.section(strings.selectedLookUpAndShow) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
Form.button("🗺 Show on map", onClick = onShowOnMap)
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
openInBrowser.accept(state.qrzComUrl())
}
Form.button(strings.selectedLookupQrzcq, enabled = state.canLookUp) {
openInBrowser.accept(state.qrzCqUrl())
}
}
}
}
}
/**
* The two priority buttons beside the station list: a shortcut to the station the
* score says to work next.
*/
@Composable
fun TopPriorityBar(
state: TopPriorityState,
onShowMore: () -> Unit,
modifier: Modifier = Modifier,
) {
val strings = LocalStrings.current
Row(
modifier = modifier.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
state.entries.forEach { entry ->
Form.button(entry.label, onClick = entry.select)
}
Form.button(strings.selectedMore, onClick = onShowMore)
}
}
/**
* One of a set of mutually exclusive choices.
*
* Material's RadioButton carries the same 48dp minimum that made every field too tall on
* the desktop, so the touch target is released the way the other compact controls do it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit) {
val strings = LocalStrings.current
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Row(
modifier = Modifier
.selectable(selected = selected, onClick = onSelect)
.padding(end = Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = selected,
onClick = onSelect,
modifier = Modifier.size(Density.CHECK_SIZE),
)
Text(
text = label,
modifier = Modifier.padding(start = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,183 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.Band
import kst4contest.model.ChatMember
/**
* The bands a station can be tagged as not being on, under the labels the panel shows.
*
* @param read whether the station is QRV on this band
* @param write sets that flag
*/
enum class NotQrvBand(
val label: String,
val band: kst4contest.model.Band,
val read: (ChatMember) -> Boolean,
val write: (ChatMember, Boolean) -> Unit,
) {
B_50("50", kst4contest.model.Band.B_50, { it.isQrv50 }, { m, qrv -> m.isQrv50 = qrv }),
B_70("70", kst4contest.model.Band.B_70, { it.isQrv70 }, { m, qrv -> m.isQrv70 = qrv }),
B_144("144", kst4contest.model.Band.B_144, { it.isQrv144 }, { m, qrv -> m.isQrv144 = qrv }),
B_432("432", kst4contest.model.Band.B_432, { it.isQrv432 }, { m, qrv -> m.isQrv432 = qrv }),
B_23("23cm", kst4contest.model.Band.B_1296, { it.isQrv1240 }, { m, qrv -> m.isQrv1240 = qrv }),
B_13("13cm", kst4contest.model.Band.B_2320, { it.isQrv2300 }, { m, qrv -> m.isQrv2300 = qrv }),
B_9("9cm", kst4contest.model.Band.B_3400, { it.isQrv3400 }, { m, qrv -> m.isQrv3400 = qrv }),
B_6("6cm", kst4contest.model.Band.B_5760, { it.isQrv5600 }, { m, qrv -> m.isQrv5600 = qrv }),
B_3("3cm", kst4contest.model.Band.B_10G, { it.isQrv10G }, { m, qrv -> m.isQrv10G = qrv }),
}
/**
* Which messages the panel lists for the selected station.
*
* One choice, not four switches: the JavaFX controls were a toggle group, so two can
* never be on at once.
*/
enum class SelectedMessageFilter(val label: String) {
PM_TO_ME("pm to me"),
PM_TO_OTHER("pm to other"),
PUBLIC("public msgs"),
NOTHING("nothing"),
}
/**
* The panel below the station list, for whichever station is selected.
*
* Its "tag not qrv" marks are not display state: they are written into the station and
* propagated to the active members, so a wrong one hides that station from every list
* filtering on QRV. That is why they go through the controller rather than being kept
* here.
*
* @param propagateNotQrv hands the changed station to the controller, which is what
* `propagateNotQrvStateToActiveMembers` does
* @param createSked takes the station, the minutes, the band and the mode; returns
* whether it was accepted
*/
class SelectedStationState(
private val propagateNotQrv: (ChatMember) -> Unit,
private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
private val markSkedFail: (ChatMember) -> Unit = { },
private val resetSkedFail: (ChatMember) -> Unit = { },
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
private val detectBands: (ChatMember) -> String = { "" },
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
) {
var selected: ChatMember? by mutableStateOf(null)
private set
/** Bumped on a tag so the panel redraws; the flags live on the station itself. */
private var tagRevision: Int by mutableStateOf(0)
var skedMinutes: Int by mutableStateOf(DEFAULT_SKED_MINUTES)
var skedBand: Band? by mutableStateOf(null)
var skedMode: String? by mutableStateOf(null)
private var _messageFilter by mutableStateOf<SelectedMessageFilter?>(null)
/**
* Which messages the panel follows, or null until the operator picks one.
*
* JavaFX has the setSelected(true) on "pm to me" commented out, so its toggle group
* starts with nothing on. Starting on a filter would silently hide messages nobody
* asked to hide.
*/
var messageFilter: SelectedMessageFilter?
get() = _messageFilter
set(value) {
_messageFilter = value
onMessageFilterChange(value)
}
var remindPm: Boolean by mutableStateOf(false)
var reminderOffsets: String by mutableStateOf(REMINDER_OFFSETS.first())
val canCreateSked: Boolean
get() = selected != null
val canLookUp: Boolean
get() = !selected?.callSign.isNullOrBlank()
fun select(member: ChatMember?) {
if (selected?.callSignRaw != member?.callSignRaw) {
skedBand = member?.let { resolveDefaultSkedBand(it) }
}
selected = member
}
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
@Suppress("UNUSED_EXPRESSION") tagRevision
val member = selected ?: return false
return !band.read(member)
}
/** Tagged means not QRV, so the station's QRV flag goes the other way. */
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
val member = selected ?: return
band.write(member, !tagged)
tagRevision++
propagateNotQrv(member)
}
fun tagNotQrvAll(tagged: Boolean) {
val member = selected ?: return
NotQrvBand.entries.forEach { it.write(member, !tagged) }
tagRevision++
propagateNotQrv(member)
}
/** @return whether a sked was created; false when nothing is selected. */
fun createSked(): Boolean {
val member = selected ?: return false
return createSked(member, skedMinutes, skedBand, skedMode)
}
/**
* Marks the path to this station as failed.
*
* Permanent until reset, and it lowers the priority score sharply — so it is an
* answer to "I called and heard nothing", not to "not now".
*/
fun markSkedFail() {
selected?.let(markSkedFail)
}
fun resetSkedFail() {
selected?.let(resetSkedFail)
}
fun detectedBands(): String {
val member = selected ?: return ""
val ignored = tagRevision
return detectBands(member)
}
fun qrzComUrl(): String = QRZ_COM + selected?.callSign.orEmpty()
fun qrzCqUrl(): String = QRZ_CQ + selected?.callSign.orEmpty()
companion object {
/** The minutes the JavaFX choice offered, and the one it started on. */
val SKED_MINUTES = listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20)
val SKED_MODES = listOf("SSB", "CW")
/** How long before a sked the reminders come, in minutes. */
val REMINDER_OFFSETS = listOf("2+1", "5+2+1", "10+5+2+1")
private const val DEFAULT_SKED_MINUTES = 5
private const val QRZ_COM = "https://www.qrz.com/db/"
private const val QRZ_CQ = "https://www.qrzcq.com/call/"
}
}
@@ -0,0 +1,332 @@
package kst4contest.view.compose
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 kst4contest.controller.ChatController
import kst4contest.controller.ActiveOperatorProfile
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.controller.OperatorProfilePaths
import kst4contest.model.ChatMember
import kst4contest.model.OperatorProfile
import kst4contest.view.compose.OperatorProfilePaletteFiles
import kst4contest.view.compose.tabs.AirscoutTab
import kst4contest.view.compose.tabs.AirscoutTabState
import kst4contest.view.compose.tabs.BeaconTab
import kst4contest.view.compose.tabs.BeaconTabState
import kst4contest.view.compose.tabs.ColoursTab
import kst4contest.view.compose.tabs.ColoursTabState
import kst4contest.view.compose.tabs.GuiOptionsTab
import kst4contest.view.compose.tabs.GuiOptionsTabState
import kst4contest.view.compose.tabs.LogSynchTab
import kst4contest.view.compose.tabs.LogSynchTabState
import kst4contest.view.compose.tabs.MessageHandlingTab
import kst4contest.view.compose.tabs.MessageHandlingTabState
import kst4contest.view.compose.tabs.NotificationTab
import kst4contest.view.compose.tabs.NotificationTabState
import kst4contest.view.compose.tabs.ProfilesTab
import kst4contest.view.compose.tabs.ProfilesTabState
import kst4contest.view.compose.tabs.ShortcutsTab
import kst4contest.view.compose.tabs.ShortcutsTabState
import kst4contest.view.compose.tabs.StationTab
import kst4contest.view.compose.tabs.StationTabState
import kst4contest.view.compose.tabs.TrxSynchTab
import kst4contest.view.compose.tabs.TrxSynchTabState
import kst4contest.view.compose.tabs.WorkedDatabaseTab
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
/**
* The work the settings window needs from the main window.
*
* These are the callbacks whose implementation lives in the JavaFX window and cannot
* be reached from a ChatController: rebuilding controls, binding the own-QRG
* follower and refreshing a table. Everything else the tabs need comes from the
* controller, which is why it is not listed here.
*/
interface SettingsHost {
/**
* Rebuilds the button row above the message field from the shortcut list.
*
* The suffix keeps this distinct from the window's own private method of the same
* purpose, which touches JavaFX directly; the implementation of this one has to hop
* onto the JavaFX thread first, because the settings window runs on its own.
*/
fun refreshShortcutButtonsFromSettings()
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
fun refreshTextSnippetContextMenusFromSettings()
/** The detected subnet broadcast address for Win-Test, or null. */
fun detectWintestBroadcastAddress(): String?
/** Attaches or detaches the follower that mirrors the own QRG into the station field. */
fun applyOwnQrgFollower(enabled: Boolean)
/**
* Turns file recording of the whole session on or off, with the history recorder.
*
* Lives here and not on the controller because enabling it opens the error-log file
* handler, which the application owns. The order — log before recorder when enabling,
* recorder before log when disabling — is the one the JavaFX checkbox kept.
*/
fun applyDebugModeToFile(enabled: Boolean)
/** Re-reads the worked-stations table for display. */
fun refreshWorkedStationsView()
/**
* Switches the application to another operator profile. The tab must not do this
* itself: the switch reloads preferences and the worked database, which only the
* application can sequence.
*/
fun switchToOperatorProfile(profile: OperatorProfile)
/**
* Starts the ON4KST session — the work of the Connect button.
*
* @return null when the session was started, otherwise the reason it was not
*/
fun connectFromSettings(): String?
/** Drops the session and keeps the client running. */
fun disconnectOnlyFromSettings()
/** Drops the session and closes the client. */
fun disconnectAndCloseChatFromSettings()
/**
* Persists the preferences.
*
* @return the file the settings went to, or null when it could not be written
*/
fun savePreferencesFromSettings(): String?
}
/**
* Builds the settings tabs in the order the JavaFX TabPane had them.
*
* The wiring lives here rather than in the Java window on purpose: every call that
* reaches past ChatPreferences into the running session is made in one readable
* place, and the foreign-write coverage test can see it. A tab that only wrote a
* preference and skipped its session call would look correct in the file and do
* nothing until the next restart.
*/
fun buildSettingsTabs(
controller: ChatController,
host: SettingsHost,
notices: SettingsNotices,
paletteStore: PaletteStore,
languageStore: kst4contest.view.i18n.LanguageStore,
): List<SettingsTab> {
val prefs = controller.chatPreferences
val stationState = StationTabState(
prefs,
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
)
val notificationState = NotificationTabState(
prefs,
controller.lstNotify_QSOSniffer_sniffedCallSignList,
applyDxClusterServerEnabled = { enabled ->
/*
* Starting a server that nothing can reach yet is pointless, so the JavaFX
* checkbox only touched the server while the session was logged in; the
* flag alone decides what happens at the next login.
*/
if (controller.isConnectedAndLoggedIn) {
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
}
},
applyDxClusterServerPort = {
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled()
}
},
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
)
val shortcutsState = ShortcutsTabState(
prefs,
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
)
val beaconState = BeaconTabState(
prefs,
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
restartBeaconTimer = { controller.restartBeaconTimer() },
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
secondCategoryName = categoryName(controller.chatCategorySecondChat, kst4contest.view.i18n.CurrentStrings.get().settingsCategorySecondFallback),
)
val logSynchState = LogSynchTabState(
prefs,
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
stopWintestListener = { controller.stopWintestUdpListener() },
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
)
val workedDatabaseState = WorkedDatabaseTabState(
resetInDatabase = { controller.dbHandler.resetWorkedDataInDB() },
refreshGuiLists = {
controller.resetWorkedAndQrvInfoInGuiLists()
controller.refreshWorkedStateAndDatabaseListFromDatabase()
},
refreshView = host::refreshWorkedStationsView,
workedRoster = controller.lst_DBBasedWkdCallSignList,
)
val profilesState = ProfilesTabState(
OperatorProfileManagementService(),
activeSelection = { ActiveOperatorProfile.get() },
requestActivation = host::switchToOperatorProfile,
)
val reportRefusal: (String) -> Unit = notices::problem
/*
* The profile whose stylesheet the colours tab offers to write. Resolved here rather than
* captured, because the settings window outlives nothing but is built once per profile.
*/
fun activeProfileOrRoot(): OperatorProfile =
ActiveOperatorProfile.get()?.profile
?: OperatorProfilePaths.buildRootProfile(prefs.stn_loginCallSign ?: "")
/*
* Order and titles are those of the JavaFX TabPane, character for character. The
* comment there is explicit that operators navigate these tabs by muscle memory,
* so a tidier order or a shorter title would be a regression, not an improvement.
*/
return listOf(
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) },
/*
* Selecting this tab re-read the database in the JavaFX window; the list is a
* snapshot and would otherwise show what was true when the window opened.
*/
SettingsTab(
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
) { WorkedDatabaseTab(workedDatabaseState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) },
/*
* Appended last on purpose so no established tab position shifts, the reason
* the JavaFX tab gave for the same placement.
*/
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabProfiles }, onSelected = profilesState::refresh) { ProfilesTab(profilesState) },
/*
* Appended after Profiles for the same reason Profiles was appended after GUI. The
* tab edits whichever design is in force, which is why it reads the flag through a
* lambda rather than taking its value here: the operator can switch day/evening from
* the main window's menu while this tab is open.
*/
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabColours }) {
/*
* Remembered rather than rebuilt: the tab recomposes on every colour change, and
* the design flag is read through a lambda precisely so the object itself does not
* have to be rebuilt to follow a day/evening switch.
*/
val coloursState = remember(paletteStore) {
ColoursTabState(
paletteStore,
{ prefs.isGUI_darkModeActive },
OperatorProfilePaletteFiles.of(activeProfileOrRoot()),
)
}
ColoursTab(coloursState, reportRefusal, notices::info)
},
)
}
/**
* Delivers the test spot and reports why it could not be delivered, in the order the
* JavaFX button checked: session first, then a connected logger, then the delivery
* itself. The messages are the ones the operator knows.
*/
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
return "Connect KST4Contest and enable the local DX Cluster server first."
}
val server = controller.dxClusterServer
?: return "Connect KST4Contest and enable the local DX Cluster server first."
if (!server.hasConnectedClients()) {
return "No DX Cluster client is connected to KST4Contest."
}
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
return "The test spot could not be delivered."
}
return null
}
/** The label a chat category carries in the beacon tab, or a fallback when unset. */
private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: String): String =
category?.getChatCategoryName(category.categoryNumber) ?: fallback
/**
* The bottom button row of the settings window.
*
* Built here because it needs the controller for its state and the host for its
* actions, and because it belongs with the tabs it sits under. The `close` action comes
* from the window itself: only the window can end its own Compose application.
*/
fun settingsButtons(
controller: ChatController,
host: SettingsHost,
notices: SettingsNotices,
): @Composable (close: () -> Unit) -> Unit {
val barState = ConnectionBarState(controller.chatPreferences, controller)
return { close ->
/*
* The bar is rebuilt after every action so its labels and enabled states follow
* the session. The JavaFX bar was told by onConnectionStateChanged instead.
*/
var revision by remember { mutableStateOf(0) }
@Suppress("UNUSED_EXPRESSION") revision
ConnectionBar(
state = barState,
onConnect = {
val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal)
revision++
},
onSave = {
val target = host.savePreferencesFromSettings()
if (target == null) {
notices.problem(
"The configuration file could not be written. The settings stay " +
"active for this session."
)
} else {
notices.info(
"Settings are stored as default to the xml config file:\n" + target
)
}
},
onApplyAndClose = close,
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
onDisconnectOnly = {
host.disconnectOnlyFromSettings()
revision++
},
)
}
}
@@ -0,0 +1,328 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Arrangement
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.foundation.background
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.unit.dp
/**
* One tab of the settings window: a title and the content to show.
*
* Tabs are handed in rather than listed here so each one can be written and tested on
* its own, and so the window does not need to know what any of them contains.
*/
class SettingsTab(
/**
* Read during composition, not stored as text, so a language change retitles the strip
* of an already open window. The tabs themselves are built once, outside any
* composition; without this the titles would only follow on the next time the window
* was opened.
*/
val title: () -> String,
/**
* Called when the operator selects this tab, before its content is shown. The
* worked-stations tab used it to re-read the database, which it has to: its list is
* a snapshot taken when the window was built.
*/
val onSelected: (() -> Unit)? = null,
val content: @Composable () -> Unit,
) {
/** For a title that genuinely never changes -- the tests, and anything not translated. */
constructor(
title: String,
onSelected: (() -> Unit)? = null,
content: @Composable () -> Unit,
) : this({ title }, onSelected, content)
}
/** Something the window has to tell the operator. */
sealed interface SettingsNotice {
val message: String
/** It happened; this is what the operator should know about it. */
data class Info(override val message: String) : SettingsNotice
/** It did not happen, and this is why. */
data class Problem(override val message: String) : SettingsNotice
}
/**
* Carries a notice from a tab or a button to the one dialog that shows it.
*
* The JavaFX window opened an Alert per field and per button. One reporter keeps that
* behaviour without every tab having to carry a dialog, and because the window opens
* at most once there is at most one of these in flight.
*/
class SettingsNotices {
var current: SettingsNotice? by mutableStateOf(null)
private set
fun info(message: String) {
current = SettingsNotice.Info(message)
}
fun problem(message: String) {
current = SettingsNotice.Problem(message)
}
fun dismiss() {
current = null
}
}
/**
* The settings window.
*
* Unlike the operator profile picker this window does **not** block: the operator keeps
* working while it is open, exactly as the JavaFX window behaved. It is also opened at
* most once — a second instance would let two windows write the same preferences
* against each other.
*
* Saving does not collect values. Every control writes into ChatPreferences as it
* changes; the button bar only persists, which is what the JavaFX "Save settings"
* button did.
*/
object SettingsWindow {
/**
* Opening, closing, the single instance and the design switch all live in
* ComposeWindowHost; this object adds only what is specific to the settings
* window. The shared part was written here first and is now on its third window,
* and its close path is exactly where the profile switch broke.
*/
private val host = ComposeWindowHost("settings-window")
/** Whether the window is currently open, so a toggle can tell. */
@JvmStatic
val isOpen: Boolean
get() = host.isOpen
/** Follows a design switch made in the main window. */
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
@JvmStatic
fun close() = host.close()
/**
* @param buttons the bottom button row; it receives the action that closes the
* window, because only the window itself can end its Compose application
*/
@JvmStatic
fun show(
tabs: List<SettingsTab>,
notices: SettingsNotices,
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,
buttons: @Composable (close: () -> Unit) -> Unit,
) {
host.show(
title = { "Change Client Settings" },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
languageStore = languageStore,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
) { close ->
SettingsContent(tabs, notices, buttons, close)
}
}
}
@Composable
internal fun SettingsContent(
tabs: List<SettingsTab>,
notices: SettingsNotices,
buttons: @Composable (close: () -> Unit) -> Unit,
close: () -> Unit,
) {
var selected by remember { mutableStateOf(0) }
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SettingsTabStrip(
tabs = tabs,
selectedIndex = selected,
onSelect = { index ->
selected = index
tabs[index].onSelected?.invoke()
},
)
/*
* One scroll region for the whole window, with a scrollbar beside it. The
* tabs used to scroll each for themselves, and none of them could show a
* bar: a tab does not know the window it sits in, so content simply ended
* mid-form with nothing to say that more was below.
*/
val scroll = rememberScrollState()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
tabs.getOrNull(selected)?.content?.invoke()
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
/* Muted on purpose: a bar in the accent colour shouts. */
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
buttons(close)
}
}
notices.current?.let { notice ->
AlertDialog(
onDismissRequest = notices::dismiss,
title = {
Text(
when (notice) {
is SettingsNotice.Info -> "Info"
is SettingsNotice.Problem -> "Settings"
}
)
},
text = { Text(notice.message) },
confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } },
)
}
}
/**
* The row of tab buttons.
*
* Material's ScrollableTabRow is not usable here: it gives every tab a minimum width
* of 90dp and a height of 48dp, both private constants. Eleven tabs then need over
* 990dp before a single letter is measured, so the row overflowed and cut the last
* title in half.
*
* Here a tab is as wide as its own title plus a small padding, which is how the
* JavaFX TabPane sized them, and the row wraps to a second line when it truly does
* not fit. Wrapping rather than clipping is deliberate: half a word is the worst of
* the available outcomes, and a scrolling strip hides that tabs exist at all.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SettingsTabStrip(
tabs: List<SettingsTab>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
FlowRow(modifier = Modifier.fillMaxWidth()) {
tabs.forEachIndexed { index, tab ->
val isSelected = index == selectedIndex
val underline = MaterialTheme.colorScheme.primary
/*
* One box per tab, and the underline is painted inside it rather than
* stacked below it. A HorizontalDivider is fillMaxWidth by default, so
* putting one under each tab made every tab demand the full row width —
* and the row then wrapped after every single tab.
*/
Box(
modifier = Modifier
.heightIn(min = Density.TAB_MIN_HEIGHT)
.background(
if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
}
)
.clickable { onSelect(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 = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
tab.title(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
color = if (isSelected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
}
}
@@ -0,0 +1,30 @@
package kst4contest.view.compose
import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.runtime.Composable
/**
* The right-click menu of the station list and the message tables: one entry per
* configured snippet, appended to the message field when chosen.
*
* The same roster is reachable three ways and all three behave differently — see
* [ChatInputState.appendSnippet]. This is the one that appends the snippet alone.
*
* @param snippets the configured snippets, in the operator's own order
*/
@Composable
fun SnippetContextMenu(
snippets: List<String>,
state: ChatInputState,
content: @Composable () -> Unit,
) {
ContextMenuArea(
items = {
snippets.map { snippet ->
ContextMenuItem(snippet) { state.appendSnippet(snippet) }
}
},
content = content,
)
}
@@ -0,0 +1,136 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import java.awt.Cursor
/** Which way the panes are stacked. */
enum class SplitterOrientation { HORIZONTAL, VERTICAL }
/**
* A splitter with draggable dividers, because Compose Multiplatform has none.
*
* The dividers look like the column handles of the tables — a hairline to see, a wider
* area to grab — so the two things an operator can drag in this window feel the same.
*
* @param panes one composable per pane; there is one divider less than there are panes.
* Must be as many as [SplitterState.paneCount] — a mismatch lays out silently
* wrong rather than failing, which is what MainWindowSplitterDefaultsTest guards
* for the three splitters of the main window.
*/
@Composable
fun SplitterPane(
state: SplitterState,
orientation: SplitterOrientation,
panes: List<@Composable () -> Unit>,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier) {
val density = LocalDensity.current
val handlePx = with(density) { Density.COLUMN_HANDLE_WIDTH.toPx() }
val fullPx = with(density) {
if (orientation == SplitterOrientation.HORIZONTAL) maxWidth.toPx() else maxHeight.toPx()
}
/*
* The handles are siblings of the panes, so what the panes share is what is left
* after them. The same span drives the drag, which keeps the divider under the
* cursor rather than drifting from it by a handle width per divider.
*/
val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount)
val sizes = state.paneSizes(totalPx)
val body: @Composable () -> Unit = {
panes.forEachIndexed { index, pane ->
val sizeDp = with(density) { sizes.getOrElse(index) { 0f }.toDp() }
Box(
if (orientation == SplitterOrientation.HORIZONTAL) {
Modifier.width(sizeDp).fillMaxHeight()
} else {
Modifier.height(sizeDp).fillMaxWidth()
},
) {
pane()
}
if (index < panes.size - 1) {
SplitterHandle(
state = state,
index = index,
orientation = orientation,
totalPx = totalPx,
)
}
}
}
if (orientation == SplitterOrientation.HORIZONTAL) {
Row(Modifier.fillMaxWidth().fillMaxHeight()) { body() }
} else {
Column(Modifier.fillMaxWidth().fillMaxHeight()) { body() }
}
}
}
/**
* One divider. The drag is converted to a fraction of the whole before it reaches the
* state, which is what keeps the stored value independent of the screen it was set on.
*/
@Composable
private fun SplitterHandle(
state: SplitterState,
index: Int,
orientation: SplitterOrientation,
totalPx: Float,
) {
val horizontal = orientation == SplitterOrientation.HORIZONTAL
val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR
Box(
modifier = Modifier
.then(
if (horizontal) Modifier.width(Density.COLUMN_HANDLE_WIDTH).fillMaxHeight()
else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(),
)
.pointerHoverIcon(PointerIcon(Cursor(cursor)))
.pointerInput(index, totalPx) {
detectDragGestures(
onDragEnd = { state.dragFinished() },
onDragCancel = { state.dragFinished() },
) { _, dragAmount ->
if (totalPx <= 0f) return@detectDragGestures
val delta = if (horizontal) dragAmount.x else dragAmount.y
val current = state.positions.getOrElse(index) { 0f }
state.drag(index, current + delta / totalPx, totalPx)
}
},
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.then(
if (horizontal) Modifier.width(Density.HAIRLINE).fillMaxHeight()
else Modifier.height(Density.HAIRLINE).fillMaxWidth(),
)
.background(MaterialTheme.colorScheme.outline),
)
}
}
@@ -0,0 +1,109 @@
package kst4contest.view.compose
import androidx.compose.runtime.mutableStateListOf
/**
* The divider positions of one splitter, as fractions of its length.
*
* Compose Multiplatform brings no SplitPane, so this is ours. The positions are a
* setting, not decoration: an operator arranges the main window once for their screen
* and expects it back at the next contest.
*
* @param paneCount how many panes the splitter holds; there is one divider less
* @param stored the remembered positions, used only when they fit and make sense
* @param minPanePx the smallest a pane may be dragged to, so nothing vanishes by accident
* @param save called on release with the positions to remember — never during a drag,
* because the JavaFX version wrote the profile to disk on every pixel of one
*/
class SplitterState(
val paneCount: Int,
stored: DoubleArray,
private val minPanePx: Float = DEFAULT_MIN_PANE_PX,
private val save: (DoubleArray) -> Unit,
) {
private val dividerCount = (paneCount - 1).coerceAtLeast(0)
private val livePositions = mutableStateListOf<Float>().apply {
addAll(sanitize(stored, dividerCount))
}
/** What the draw reads. Compose state, so a drag reaches the screen. */
val positions: List<Float> get() = livePositions
private var savedPositions: List<Float> = livePositions.toList()
/**
* Moves one divider, held off its neighbours and off the minimum pane size.
*
* @param to the wanted position as a fraction of the whole
* @param totalPx the splitter's length, needed to turn the minimum into a fraction
*/
fun drag(index: Int, to: Float, totalPx: Float) {
if (index !in 0 until dividerCount) return
val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f
val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction
val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction
// A window too narrow for the minimums would give an empty range; splitting the
// remaining room is better than laying out nothing.
livePositions[index] = if (lowerBound > upperBound) {
(lowerBound + upperBound) / 2f
} else {
to.coerceIn(lowerBound, upperBound)
}
}
/** Remembers the positions, but only if the drag actually moved something. */
fun dragFinished() {
val current = livePositions.toList()
if (current == savedPositions) return
savedPositions = current
save(DoubleArray(current.size) { current[it].toDouble() })
}
/** The pane lengths for a given splitter length; they always add up to the whole. */
fun paneSizes(totalPx: Float): List<Float> {
if (paneCount <= 1) return listOf(totalPx)
val edges = buildList {
add(0f)
livePositions.forEach { add(it * totalPx) }
add(totalPx)
}
return (0 until paneCount).map { edges[it + 1] - edges[it] }
}
companion object {
/** Enough to still see a table header and grab the divider again. */
const val DEFAULT_MIN_PANE_PX = 24f
/**
* How much length the panes may share, once the divider handles have taken theirs.
*
* The panes and the handles are laid out as siblings, so distributing the whole
* length among the panes overflows the splitter by one handle width per divider —
* 20 dp for the five-pane message column, enough to push the bottom pane off the
* edge. Never negative: a splitter smaller than its own handles hands out nothing.
*/
fun availableFor(totalPx: Float, dividerPx: Float, paneCount: Int): Float {
val dividerCount = (paneCount - 1).coerceAtLeast(0)
return (totalPx - dividerPx * dividerCount).coerceAtLeast(0f)
}
/**
* The remembered positions are used only when there are exactly as many as there
* are dividers, each inside the pane and in ascending order. Anything else is a
* profile from an older layout, and an even split beats a window whose panes have
* no size.
*/
internal fun sanitize(stored: DoubleArray, dividerCount: Int): List<Float> {
val evenSplit = (1..dividerCount).map { it.toFloat() / (dividerCount + 1) }
if (stored.size != dividerCount) return evenSplit
if (stored.any { !it.isFinite() || it <= 0.0 || it >= 1.0 }) return evenSplit
if (stored.toList() != stored.sorted()) return evenSplit
return stored.map { it.toFloat() }
}
}
}
@@ -0,0 +1,154 @@
package kst4contest.view.compose
import kst4contest.model.ChatMember
import java.util.EnumSet
/**
* The columns of the station list.
*
* The ids are not free to choose: they are the operator's stored column widths, so
* renaming one loses their layout without a word. They are kept exactly as
* `TableLayoutManager.column(...)` registered them.
*
* Every value survives a half-filled station. These rows come off the wire, a field
* can be missing, and a crash in the station list during a contest is the worst
* possible moment for one.
*
* @param tropoOf the reachability the controller computes; not carried by the row
* @param priorityScoreOf the score the row is coloured by, also computed outside
* @param formatBand formats a single band cell (worked / new / etc.)
* @param activeBands the bands the operator has enabled in settings; only these
* get a worked column. Matches the JavaFX behaviour where the column is
* only added when `isStn_bandActiveXX()` returns true.
*/
object StationColumns {
/** The heading the ten worked columns sit under. */
const val WORKED_GROUP = "worked"
fun all(
tropoOf: (ChatMember) -> String,
priorityScoreOf: (ChatMember) -> Double,
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
): List<DataColumn<ChatMember>> = buildList {
add(DataColumn(id = "callsign", title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { it.callSign.orEmpty() }))
add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.name.orEmpty() }))
add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
add(DataColumn(
id = "qrb",
title = "QRB",
value = { it.qrb?.toInt()?.toString().orEmpty() },
comparator = compareBy { it.qrb ?: 0.0 },
weight = 0.6f,
))
add(DataColumn(
id = "qtf",
title = "QTF",
value = { it.getQTFdirection()?.let { d -> String.format("%.2f°", d) }.orEmpty() },
comparator = compareBy { it.getQTFdirection() ?: 0.0 },
weight = 0.7f,
))
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
add(DataColumn(
id = "airscout",
title = "AP [minutes / pot%]",
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
weight = 1.4f,
))
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
add(DataColumn(
id = "score",
title = "Score",
value = { priorityScoreOf(it).toInt().toString() },
comparator = compareBy { priorityScoreOf(it) },
weight = 0.6f,
))
add(DataColumn(
id = "activity",
title = "Act",
value = { minutesSince(it.activityTimeLastInEpoch) },
comparator = compareBy { it.activityTimeLastInEpoch },
weight = 0.5f,
))
/* Individual band columns only when the operator has that band enabled. */
if (kst4contest.model.Band.B_50 in activeBands)
add(workedColumn("band-50", "50") { formatBand(it, kst4contest.model.Band.B_50, it.isWorked50) })
if (kst4contest.model.Band.B_70 in activeBands)
add(workedColumn("band-70", "70") { formatBand(it, kst4contest.model.Band.B_70, it.isWorked70) })
if (kst4contest.model.Band.B_144 in activeBands)
add(workedColumn("band-144", "144") { formatBand(it, kst4contest.model.Band.B_144, it.isWorked144) })
if (kst4contest.model.Band.B_432 in activeBands)
add(workedColumn("band-432", "432") { formatBand(it, kst4contest.model.Band.B_432, it.isWorked432) })
if (kst4contest.model.Band.B_1296 in activeBands)
add(workedColumn("band-1296", "23") { formatBand(it, kst4contest.model.Band.B_1296, it.isWorked1240) })
if (kst4contest.model.Band.B_2320 in activeBands)
add(workedColumn("band-2320", "13") { formatBand(it, kst4contest.model.Band.B_2320, it.isWorked2300) })
if (kst4contest.model.Band.B_3400 in activeBands)
add(workedColumn("band-3400", "9") { formatBand(it, kst4contest.model.Band.B_3400, it.isWorked3400) })
if (kst4contest.model.Band.B_5760 in activeBands)
add(workedColumn("band-5760", "6") { formatBand(it, kst4contest.model.Band.B_5760, it.isWorked5600) })
if (kst4contest.model.Band.B_10G in activeBands)
add(workedColumn("band-10g", "3") { formatBand(it, kst4contest.model.Band.B_10G, it.isWorked10G) })
/* Worked-any is always shown, and belongs under the 'worked' group header just like in JavaFX. */
add(workedColumn("worked-any", "wkdany", group = WORKED_GROUP, weight = 0.7f) { if (it.isWorked) "X" else "" })
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
add(DataColumn(
id = "category",
title = "Category",
value = { member ->
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
},
weight = 1.2f,
))
}
/** A cross or nothing, and under the shared heading. */
private fun workedColumn(
id: String,
title: String,
group: String? = WORKED_GROUP,
weight: Float = 0.35f,
worked: (ChatMember) -> String,
): DataColumn<ChatMember> = DataColumn(
id = id,
title = title,
value = worked,
weight = weight,
group = group,
)
/** How long ago the station was last heard, in whole minutes. */
private fun minutesSince(epochSeconds: Long): String {
if (epochSeconds <= 0L) {
return ""
}
val seconds = (System.currentTimeMillis() / 1000L) - epochSeconds
return if (seconds < 0) "" else (seconds / 60L).toString()
}
/**
* The bands the station is **not** on, which is what the heading says.
*
* Every QRV flag starts true — a station is assumed to be on every band until it
* says otherwise — so this column is empty for most rows and fills as the operator
* or the server marks bands off.
*/
private fun qrvBands(member: ChatMember, activeBands: Set<kst4contest.model.Band>): String = buildString {
if (!member.isQrv50 && kst4contest.model.Band.B_50 in activeBands) append("6m ")
if (!member.isQrv70 && kst4contest.model.Band.B_70 in activeBands) append("4m ")
if (!member.isQrv144 && kst4contest.model.Band.B_144 in activeBands) append("2m ")
if (!member.isQrv432 && kst4contest.model.Band.B_432 in activeBands) append("70cm ")
if (!member.isQrv1240 && kst4contest.model.Band.B_1296 in activeBands) append("23cm ")
if (!member.isQrv2300 && kst4contest.model.Band.B_2320 in activeBands) append("13cm ")
if (!member.isQrv3400 && kst4contest.model.Band.B_3400 in activeBands) append("9cm ")
if (!member.isQrv5600 && kst4contest.model.Band.B_5760 in activeBands) append("6cm ")
if (!member.isQrv10G && kst4contest.model.Band.B_10G in activeBands) append("3cm ")
}.trim()
}
@@ -0,0 +1,282 @@
package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.IntrinsicSize
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
/**
* The station filter bar above the station table.
*
* Laid out to match the JavaFX original: three tight rows of controls separated
* by thin vertical rules between logical groups.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StationFilterBar(
filters: StationFilterState,
activeBands: Set<kst4contest.model.Band>,
onChanged: () -> Unit,
modifier: Modifier = Modifier,
) {
val strings = LocalStrings.current
Column(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
// ROW 1: Reset | QRB | QTF + bearings
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Reset filters
FilterButton(strings.filterReset, on = false, isAccent = true) {
filters.reset()
onChanged()
}
VerticalSeparator()
// QRB checkbox + field
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.maxQrbKm = parsed
if (filters.maxQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.maxQrbKm = 0.0
if (filters.maxQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
VerticalSeparator()
// QTF checkbox + field + bearings
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) {
filters.qtfDegrees = 0.0
filters.bearing = null
if (filters.qtfEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
Bearing.entries.forEach { bearing ->
FilterButton(bearing.name, on = filters.bearing == bearing) {
filters.toggleBearing(bearing)
onChanged()
}
}
}
// ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CompactTextField(
value = filters.searchText,
onValueChange = {
filters.searchText = it
onChanged()
},
placeholder = strings.filterFind,
modifier = Modifier.width(90.dp),
)
VerticalSeparator()
Text(strings.filterHideWorked, style = MaterialTheme.typography.bodySmall)
FilterButton("wkdany", on = filters.hideWorkedAny) {
filters.hideWorkedAny = !filters.hideWorkedAny
onChanged()
}
WorkedBandFilter.entries.forEach { band ->
if (band.modelBand in activeBands) {
FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
onChanged()
}
}
}
VerticalSeparator()
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
filters.hideInactive = !filters.hideInactive
onChanged()
}
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
filters.onlyNewGrids = !filters.onlyNewGrids
onChanged()
}
}
// ROW 3: Grid color | Tropo | New bands | AS next 5m
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FilterButton(strings.filterGridColour, on = filters.gridColouring) {
filters.gridColouring = !filters.gridColouring
}
FilterButton(strings.filterTropo, on = filters.tropoReachable) {
filters.tropoReachable = !filters.tropoReachable
onChanged()
}
FilterButton(strings.filterNewBands, on = filters.newBands) {
filters.newBands = !filters.newBands
onChanged()
}
FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
filters.airScoutNext5Min = !filters.airScoutNext5Min
onChanged()
}
}
}
}
/**
* A compact toggle button that does NOT use Material 3 Button (which enforces 40dp).
* Instead it is a plain clickable Box with border and background, matching the tiny
* JavaFX filter buttons.
*/
@Composable
private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false, onClick: () -> Unit) {
val palette = LocalJavaFxPalette.current
val isDark = palette.windowBackground.luminance() < 0.5f
val accentBrush = if (isDark) {
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))
)
}
val bgModifier = if (isAccent) {
Modifier.background(accentBrush, Density.BUTTON_SHAPE)
} else {
val bg = if (on) MaterialTheme.colorScheme.primary else Color.Transparent
Modifier.background(bg, Density.BUTTON_SHAPE)
}
val fg = if (isAccent) Color(0xFF395306) else if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
val borderColor = if (isAccent) Color.Transparent else if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
Box(
modifier = Modifier
.heightIn(min = 20.dp)
.then(if (borderColor != Color.Transparent) Modifier.border(Density.HAIRLINE, borderColor, Density.BUTTON_SHAPE) else Modifier)
.then(bgModifier)
.clickable { onClick() }
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.bodySmall, color = fg, maxLines = 1)
}
}
/** Thin vertical separator matching JavaFX's group dividers. */
@Composable
private fun VerticalSeparator() {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outline),
)
}
@Composable
private fun CompactTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String = "",
modifier: Modifier = Modifier,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
decorationBox = { innerTextField ->
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant, Density.BUTTON_SHAPE)
.border(1.dp, MaterialTheme.colorScheme.outline, Density.BUTTON_SHAPE)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(
placeholder,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
innerTextField()
}
},
)
}
@@ -0,0 +1,161 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* The filters the station list can have switched on.
*
* One entry per predicate the JavaFX bar added to the controller. The controller does
* the filtering — it has since Etappe 2 — so this names which predicates should be
* attached, and nothing more.
*/
enum class StationFilter {
ONLY_NEW_GRIDS,
TROPO_REACHABLE,
NEW_BANDS,
AIRSCOUT_NEXT_5_MIN,
MAX_QRB,
QTF,
SEARCH_TEXT,
HIDE_INACTIVE,
HIDE_WORKED_ANY,
HIDE_WORKED_50,
HIDE_WORKED_70,
HIDE_WORKED_144,
HIDE_WORKED_432,
HIDE_WORKED_23,
HIDE_WORKED_13,
HIDE_WORKED_9,
HIDE_WORKED_6,
HIDE_WORKED_3,
}
/** The bands the "hide worked" row offers, under the labels the bar shows. */
enum class WorkedBandFilter(val label: String, val filter: StationFilter, val modelBand: kst4contest.model.Band) {
B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
B_70("70", StationFilter.HIDE_WORKED_70, kst4contest.model.Band.B_70),
B_144("144", StationFilter.HIDE_WORKED_144, kst4contest.model.Band.B_144),
B_432("432", StationFilter.HIDE_WORKED_432, kst4contest.model.Band.B_432),
B_23("23", StationFilter.HIDE_WORKED_23, kst4contest.model.Band.B_1296),
B_13("13", StationFilter.HIDE_WORKED_13, kst4contest.model.Band.B_2320),
B_9("9", StationFilter.HIDE_WORKED_9, kst4contest.model.Band.B_3400),
B_6("6", StationFilter.HIDE_WORKED_6, kst4contest.model.Band.B_5760),
B_3("3", StationFilter.HIDE_WORKED_3, kst4contest.model.Band.B_10G),
}
/**
* The eight bearing buttons, with the degrees each writes into the QTF field.
*
* They were one ToggleGroup, so only one can be picked, and picking one filters
* nothing by itself — it sets a bearing the QTF filter uses once that is switched on.
*/
enum class Bearing(val degrees: Double) {
N(0.0), NE(45.0), E(90.0), SE(135.0), S(180.0), SW(225.0), W(270.0), NW(315.0)
}
/**
* What the filter bar has switched on.
*
* Deliberately free of the drawing: its 25 toggles are not 25 states, and which of
* them mean what is worth pinning where it can be tested. Getting it wrong puts a
* station in front of the operator that their filter excludes, or hides one it does
* not — and in a contest that is a missed contact either way.
*/
class StationFilterState {
private val switched = mutableStateMapOf<StationFilter, Boolean>()
var onlyNewGrids: Boolean
get() = isOn(StationFilter.ONLY_NEW_GRIDS)
set(value) = switch(StationFilter.ONLY_NEW_GRIDS, value)
var tropoReachable: Boolean
get() = isOn(StationFilter.TROPO_REACHABLE)
set(value) = switch(StationFilter.TROPO_REACHABLE, value)
var newBands: Boolean
get() = isOn(StationFilter.NEW_BANDS)
set(value) = switch(StationFilter.NEW_BANDS, value)
var airScoutNext5Min: Boolean
get() = isOn(StationFilter.AIRSCOUT_NEXT_5_MIN)
set(value) = switch(StationFilter.AIRSCOUT_NEXT_5_MIN, value)
var hideInactive: Boolean
get() = isOn(StationFilter.HIDE_INACTIVE)
set(value) = switch(StationFilter.HIDE_INACTIVE, value)
var hideWorkedAny: Boolean
get() = isOn(StationFilter.HIDE_WORKED_ANY)
set(value) = switch(StationFilter.HIDE_WORKED_ANY, value)
var maxQrbEnabled: Boolean
get() = isOn(StationFilter.MAX_QRB)
set(value) = switch(StationFilter.MAX_QRB, value)
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
var maxQrbKm: Double by mutableStateOf(0.0)
var qtfEnabled: Boolean
get() = isOn(StationFilter.QTF)
set(value) = switch(StationFilter.QTF, value)
var qtfDegrees: Double by mutableStateOf(0.0)
var bearing: Bearing? by mutableStateOf(null)
/**
* Colours the locator cell by grid status. **Not a filter** — the JavaFX toggle
* sat among the filter buttons but only redrew the table, and a station must not
* disappear because of it.
*/
var gridColouring: Boolean by mutableStateOf(false)
private var searchTextValue: String by mutableStateOf("")
var searchText: String
get() = searchTextValue
set(value) {
searchTextValue = value
/* Blank is not a search: leaving it on would hide every station. */
switch(StationFilter.SEARCH_TEXT, value.isNotBlank())
}
/** The filters that should be attached to the controller right now. */
val activeFilters: Set<StationFilter>
get() = switched.filterValues { it }.keys
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
/** Picks one bearing and writes its degrees, or deselects if already selected.
* Mirrors JavaFX ToggleGroup behavior which also toggles the QTF filter checkbox. */
fun toggleBearing(direction: Bearing) {
if (bearing == direction) {
bearing = null
qtfEnabled = false
} else {
bearing = direction
qtfDegrees = direction.degrees
qtfEnabled = true
}
}
private fun switch(filter: StationFilter, on: Boolean) {
if (on) switched[filter] = true else switched.remove(filter)
}
fun reset() {
switched.clear()
searchText = ""
qtfDegrees = 0.0
bearing = null
gridColouring = false
}
}
@@ -0,0 +1,42 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.ChatMember
/**
* The row colouring of the station table (chat members).
*
* Mimics JavaFX Kst4ContestApplication.java:2663
*/
object StationRowStyle {
// > 1000 = NUCLEAR (Imminent Sked) -> Blinking Red (simulated here with solid red)
val SCORE_NUCLEAR_BG = Color(0xFFFF4D4D)
val SCORE_NUCLEAR_FG = Color.White
// > 200 = High Prio (AirScout / Good Sked) -> Orange
// > 100 = Medium Prio (Unworked / New Multi) -> Light Yellow
// <= 0 = Low Prio / Not Reachable -> Greyed out text
// We only hardcode light mode colors here to be safe and match JavaFX for now.
// In JavaFX it checked `isGUI_darkModeActive()`, but Compose might have its own dark mode check.
val SCORE_HIGH_BG = Color(0xFFFFCC00)
val SCORE_HIGH_FG = Color.Black
val SCORE_MEDIUM_BG = Color(0xFFFFFFCC)
val SCORE_MEDIUM_FG = Color.Black
val SCORE_LOW_FG = Color(0xFFAAAAAA)
fun accentFor(member: ChatMember, isDark: Boolean = false): CellAccent? {
val score = member.currentPriorityScore
return when {
score > 1000 -> CellAccent(background = SCORE_NUCLEAR_BG, foreground = SCORE_NUCLEAR_FG)
score >= 200 -> CellAccent(background = if (isDark) Color(0xFFCC6600) else SCORE_HIGH_BG, foreground = SCORE_HIGH_FG)
score >= 100 -> CellAccent(background = if (isDark) Color(0xFF888800) else SCORE_MEDIUM_BG, foreground = SCORE_MEDIUM_FG)
score <= 0 -> CellAccent(background = null, foreground = if (isDark) Color(0xFF666666) else SCORE_LOW_FG)
else -> null
}
}
}
@@ -0,0 +1,242 @@
package kst4contest.view.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.repeatable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
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.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kst4contest.controller.On4KstConnectionState
/**
* The status bar along the top of the main window.
*
* In JavaFX this was one FlowPane holding the menu bar and the indicators side by side.
* Compose puts the menu bar in the window's own scope (see [Kst4ContestMenuBar]), so
* only the indicators are left here — which is also what finally makes the bar wrap
* properly on a narrow window instead of pushing the badges out of sight.
*
* Nothing in this bar is clickable. The three indicators were mouse-transparent and
* non-focusable in JavaFX and stay that way: the operator is typing into the chat, and a
* status badge must never take the keyboard. The background-thread buttons were ordinary
* focusable Buttons there, with no action attached — they are drawn as plain labels here,
* which is what they always behaved like.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StatusBar(
connectionState: On4KstConnectionState?,
connectionDetail: String?,
skedNotice: BlinkingNotice,
bandUpgradeNotice: BlinkingNotice,
threadButtons: ThreadStatusButtons,
showConnectionBadge: Boolean = true,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// On macOS the connection state lives in the system menu bar instead.
if (showConnectionBadge) {
ConnectionBadgeView(connectionState, connectionDetail)
}
BlinkingNoticeView(skedNotice, SKED_BACKGROUND)
BlinkingNoticeView(bandUpgradeNotice, BAND_UPGRADE_BACKGROUND)
threadButtons.buttons.forEach { ThreadStatusButtonView(it) }
}
}
/** rgba(255,0,255,0.85) — the sked reminder, the one colour nothing else in the UI uses. */
private val SKED_BACKGROUND = Color(0xD9FF00FF)
/** rgba(255,255,0,0.85) — the band upgrade hint. */
private val BAND_UPGRADE_BACKGROUND = Color(0xD9FFFF00)
private val NOTICE_SHAPE = RoundedCornerShape(6.dp)
private val NOTICE_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
/**
* The connection badge, at least as large as JavaFX pinned it (44 × 22).
*
* A minimum and not a fixed size: JavaFX also set setMaxSize(44,22), which this does not, so
* a larger base font grows the badge rather than clipping its label. The three labels are
* within a character of each other, so the bar barely reflows either way.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ConnectionBadgeView(state: On4KstConnectionState?, detail: String?) {
val badge = ConnectionIndicator.badgeFor(state)
val colors = badgeColors(badge)
TooltipArea(tooltip = { TooltipCard(ConnectionIndicator.tooltipFor(state, detail)) }) {
Box(
modifier = Modifier
.defaultMinSize(minWidth = 44.dp, minHeight = 22.dp)
.background(colors.background, NOTICE_SHAPE)
.border(2.dp, colors.border, NOTICE_SHAPE)
.padding(horizontal = 5.dp, vertical = 1.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = badge.label,
color = colors.text,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
private class BadgeColors(val background: Color, val border: Color, val text: Color)
/**
* The badge colours are deliberately not taken from the theme. They are traffic-light
* colours and have to mean the same thing in both designs; a red that turns into the
* dark theme's accent would be unreadable as a warning.
*/
private fun badgeColors(badge: ConnectionBadge): BadgeColors = when (badge) {
ConnectionBadge.ONLINE -> BadgeColors(Color(0xFF238636), Color(0xFF56D364), Color.White)
ConnectionBadge.BUSY -> BadgeColors(Color(0xFFFFB300), Color(0xFFFFE082), Color(0xFF1B1B1B))
ConnectionBadge.OFFLINE -> BadgeColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White)
}
/**
* A notice that blinks and then removes itself.
*
* The blink is the JavaFX timeline: bright, quarter-dim after 250 ms, bright again at
* 500 ms, twenty-four times, then gone. It is long enough to catch the eye of someone
* looking at the chat, and short enough not to become furniture.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun BlinkingNoticeView(notice: BlinkingNotice, background: Color) {
if (!notice.visible) return
val alpha = remember { Animatable(1f) }
LaunchedEffect(notice.revision) {
val generation = notice.revision
try {
alpha.snapTo(1f)
alpha.animateTo(
targetValue = 1f,
animationSpec = repeatable(
iterations = BlinkingNotice.BLINK_CYCLES,
animation = keyframes {
durationMillis = BlinkingNotice.BLINK_CYCLE_MILLIS.toInt()
1f at 0
0.25f at 250
1f at BlinkingNotice.BLINK_CYCLE_MILLIS.toInt()
},
),
)
} finally {
// Also on cancellation: a blink cut short by the bar leaving the composition
// would otherwise leave the notice showing for the rest of the session.
notice.hide(generation)
}
}
TooltipArea(tooltip = { TooltipCard(notice.tooltip) }) {
Box(
modifier = Modifier
.alpha(alpha.value)
.background(background, NOTICE_SHAPE)
.padding(NOTICE_PADDING),
contentAlignment = Alignment.Center,
) {
Text(
text = notice.text,
color = Color.Black,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
}
}
/**
* One background thread's state. It flashes in the accent colour whenever a message
* arrives, which is how a running worker is told from a stuck one at a glance.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ThreadStatusButtonView(button: ThreadStatusButton) {
LaunchedEffect(button.revision) {
try {
delay(ThreadStatusButtons.HIGHLIGHT_MILLIS)
} finally {
// On cancellation too, so a button that left the composition mid flash does not
// come back still lit.
button.clearHighlight()
}
}
val scheme = MaterialTheme.colorScheme
val background = if (button.highlighted) scheme.primary else scheme.surfaceVariant
val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant
TooltipArea(tooltip = { TooltipCard(button.tooltip) }) {
Box(
modifier = Modifier
.defaultMinSize(minHeight = 22.dp)
.background(background, NOTICE_SHAPE)
.padding(NOTICE_PADDING),
contentAlignment = Alignment.Center,
) {
Text(
text = button.label,
color = foreground,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
}
}
/** The tooltips in this bar carry the long text the badges had to cut. */
@Composable
internal fun TooltipCard(text: String) {
if (text.isBlank()) return
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,
)
}
}
@@ -0,0 +1,225 @@
package kst4contest.view.compose
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.ThreadStateMessage
import java.util.Locale
/**
* How the connection indicator reads at a glance.
*
* Three badges for nine states: mid contest nobody parses "SYNCING_SECOND_CHAT". What
* the operator needs to know is whether something can be sent right now.
*/
enum class ConnectionBadge(val label: String) {
ONLINE("LINK"),
BUSY("LINK…"),
OFFLINE("LINK!"),
}
/** The badge, its tooltip and the macOS menu title, all derived from one state. */
object ConnectionIndicator {
/** No state at all is the state before the first callback, and that is not a link. */
fun badgeFor(state: On4KstConnectionState?): ConnectionBadge =
when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.ONLINE -> ConnectionBadge.ONLINE
On4KstConnectionState.CONNECTING,
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING,
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT,
On4KstConnectionState.STOPPING -> ConnectionBadge.BUSY
// A scheduled reconnect is not a working link, so it reads as offline.
On4KstConnectionState.DISCONNECTED,
On4KstConnectionState.RECONNECT_WAIT -> ConnectionBadge.OFFLINE
}
fun tooltipFor(state: On4KstConnectionState?, detail: String?): String {
val effective = state ?: On4KstConnectionState.DISCONNECTED
return kst4contest.view.i18n.CurrentStrings.get().statusLinkTooltip(effective.name, detailFor(state, detail))
}
/**
* The detail line, as its own string.
*
* The tooltip carries it as a second line; the macOS menu carries it as its single
* disabled item, which is the same information reaching the operator by the other route.
*/
fun detailFor(state: On4KstConnectionState?, detail: String?): String {
val effective = state ?: On4KstConnectionState.DISCONNECTED
return if (detail.isNullOrBlank()) effective.name else detail
}
/**
* Title of the macOS connection state menu, e.g. kst4contest.view.i18n.CurrentStrings.get().statusLinkConnected.
*
* Finer than the badge: in the menu bar there is room to distinguish shutting down
* from connecting, and a reconnect from a dead link.
*/
fun macOsMenuTitle(state: On4KstConnectionState?): String =
when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.ONLINE -> kst4contest.view.i18n.CurrentStrings.get().statusLinkConnected
On4KstConnectionState.CONNECTING,
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING,
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT -> kst4contest.view.i18n.CurrentStrings.get().statusLinkConnecting
On4KstConnectionState.STOPPING -> kst4contest.view.i18n.CurrentStrings.get().statusLinkDisconnecting
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().statusLinkReconnecting
On4KstConnectionState.DISCONNECTED -> kst4contest.view.i18n.CurrentStrings.get().statusLinkDisconnected
}
}
/**
* A non-clickable notice that blinks for a while and then disappears.
*
* Both the sked reminder and the band upgrade hint work this way. They are not
* dialogs on purpose — a dialog steals the keyboard, and the operator is typing.
*
* The blink itself belongs to the view; this holds what to draw and a revision the
* view restarts the blink on, so a second reminder blinks again instead of sitting
* there statically.
*/
class BlinkingNotice {
var text: String by mutableStateOf("")
private set
var tooltip: String by mutableStateOf("")
private set
var visible: Boolean by mutableStateOf(false)
private set
var revision: Int by mutableStateOf(0)
private set
fun show(text: String, tooltip: String = text) {
this.text = shorten(text)
this.tooltip = tooltip
visible = true
revision++
}
fun hide() {
visible = false
}
/**
* Hides the notice only if it is still the one that [generation] showed.
*
* The blink runs in the view and is cancelled if the bar leaves the composition — a
* splitter dragged shut, a profile switch — so the cleanup has to hide the notice, or
* a magenta reminder stays in the status bar for the rest of the session. But a second
* reminder arriving mid blink means the first blink's cleanup runs after the second is
* already showing, and it must not take that one down with it.
*/
fun hide(generation: Int) {
if (revision == generation) {
visible = false
}
}
companion object {
/** The badge shares a row with the menu, so the text is cut and the tooltip keeps it. */
const val MAX_LABEL_LENGTH = 38
const val BLINK_CYCLES = 24
const val BLINK_CYCLE_MILLIS = 500L
val blinkDurationMillis: Long get() = BLINK_CYCLES * BLINK_CYCLE_MILLIS
fun shorten(text: String): String =
if (text.length > MAX_LABEL_LENGTH) text.take(MAX_LABEL_LENGTH - 3) + "..." else text
}
}
/** What makes a notice appear. */
object StatusIndicators {
/** The wording the operator has learned to recognize; the double space is deliberate. */
fun reminderText(callSignRaw: String?, minutesBefore: Int): String =
"REMINDER: ${callSignRaw.orEmpty()} T-${minutesBefore}m"
/**
* The band upgrade hint rides on the ordinary thread status messages, recognized by
* name. Returns the badge text and the tooltip, or null if this message is about
* something else.
*/
fun bandUpgradeNoticeFor(key: String?, message: ThreadStateMessage?): Pair<String, String>? {
if (message == null) return null
val nick = message.threadNickName?.lowercase(Locale.ROOT).orEmpty()
val lowerKey = key?.lowercase(Locale.ROOT).orEmpty()
if (!lowerKey.contains("bandupgrade") && !nick.contains("bandupgrade")) return null
// ThreadStateMessage has its own fallback, so this rarely fires; it is here so a
// blank badge can never reach the status bar.
val badge = message.runningInformationTextDescription
?.takeUnless { it.isBlank() } ?: "BAND+"
val tooltip = message.runningInformation?.takeUnless { it.isBlank() } ?: badge
return badge to tooltip
}
}
/**
* One button per background thread that reports itself, in the order they first did.
*
* The flash on every message is the point: it is how the operator sees a worker is
* still alive without reading anything.
*/
class ThreadStatusButtons {
private val bySource = LinkedHashMap<String, ThreadStatusButton>()
private val ordered = mutableStateListOf<ThreadStatusButton>()
val buttons: List<ThreadStatusButton> get() = ordered
fun update(sourceName: String, message: ThreadStateMessage) {
val button = bySource.getOrPut(sourceName) {
ThreadStatusButton(sourceName).also { ordered.add(it) }
}
button.report(
label = "$sourceName: ${message.runningInformationTextDescription}",
tooltip = message.runningInformation.orEmpty(),
)
}
companion object {
/** How long the flash stays on; JavaFX used a 0.2 s PauseTransition. */
const val HIGHLIGHT_MILLIS = 200L
}
}
/** One background thread's button. */
class ThreadStatusButton(val sourceName: String) {
var label: String by mutableStateOf(sourceName)
private set
var tooltip: String by mutableStateOf("")
private set
/** True while the flash is on; the view clears it after HIGHLIGHT_MILLIS. */
var highlighted: Boolean by mutableStateOf(false)
private set
var revision: Int by mutableStateOf(0)
private set
internal fun report(label: String, tooltip: String) {
this.label = label
this.tooltip = tooltip
highlighted = true
revision++
}
fun clearHighlight() {
highlighted = false
}
}
@@ -0,0 +1,169 @@
package kst4contest.view.compose
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
/**
* The application theme for Compose windows.
*
* Release 1.50 gave the JavaFX side one base font size per operator profile.
* Compose windows take the same value so a Compose window next to a JavaFX one
* does not look scaled differently.
*
* @param darkMode the operator's configured day/evening design
* @param baseFontSizeSp the configured base font size, in scale-independent points
*/
@Composable
fun Kst4ContestTheme(
darkMode: Boolean,
baseFontSizeSp: Float = 0f,
content: @Composable () -> Unit,
) {
val store = LocalPaletteStore.current
val palette = if (store != null) {
/*
* Read from the store's state, not remembered: that read is what makes a palette
* change recompose every open window. remember(darkMode) kept the palette until
* somebody toggled day/evening, which is why a colour change used to reach nothing.
*/
store.resolved(darkMode).palette
} else {
/*
* No store yet. Not caution: the operator profile picker composes before a profile,
* and therefore before a store, exists.
*/
remember(darkMode) { JavaFxStylesheet.shipped(darkMode) }
}
val fontSize = if (baseFontSizeSp > 0f) baseFontSizeSp else palette.fontSizePx
val typography = MaterialTheme.typography.scaledTo(fontSize)
MaterialTheme(
colorScheme = palette.toColorScheme(darkMode),
typography = typography,
) {
CompositionLocalProvider(LocalJavaFxPalette provides palette, content = content)
}
}
/**
* The palette in force, for the few places that need a colour Material has no slot
* for — the button hover gradient and its pressed border.
*/
val LocalJavaFxPalette = staticCompositionLocalOf {
JavaFxStylesheet.shipped(darkMode = false)
}
/**
* Maps the stylesheet onto Material's colour roles.
*
* Only the roles the settings windows actually paint with are taken from the sheet;
* the rest keep Material's defaults, which nothing in these windows shows.
*/
private fun JavaFxPalette.toColorScheme(darkMode: Boolean): ColorScheme {
val defaults = if (darkMode) darkColorScheme() else lightColorScheme()
/*
* The tint behind a selected row. Mixing the accent into the base keeps it legible
* in both designs, where a fixed colour would disappear into one of them.
*/
val selection = blend(accent, base, 0.30f)
/*
* The sheet's own separator colour is #3C3C3C on a #373E43 surface — a brightness
* difference of 0.002, which is no line at all. Where the declared colour cannot be
* told apart from the surface it is replaced by a step away from that surface, so
* the section rules are actually visible. Making them visible is the point: they
* were asked for because a bare line of text did not read as a division.
*/
val declaredLine = separatorLine
val contrast = brightness(declaredLine) - brightness(windowBackground)
val visibleLine = if (contrast > -0.08f && contrast < 0.08f) {
JavaFxStylesheet.derive(windowBackground, if (darkMode) 45.0 else -25.0)
} else {
declaredLine
}
return defaults.copy(
/*
* The green of .text-field .text, not -fx-accent: the blue accent only paints
* JavaFX's selection bar, while the green is what the operator sees on column
* headers and inside every field, and therefore what reads as the accent.
*/
primary = textAccent,
onPrimary = contrastingText(textAccent),
background = windowBackground,
onBackground = labelTextFill,
surface = windowBackground,
onSurface = labelTextFill,
/* The interior of a text field: JavaFX sets -fx-control-inner-background. */
surfaceVariant = controlInnerBackground,
onSurfaceVariant = labelTextFill,
secondaryContainer = selection,
onSecondaryContainer = labelTextFill,
outline = visibleLine,
outlineVariant = visibleLine,
)
}
/** The brightness JavaFX measures with: 0.3R + 0.59G + 0.11B. */
private fun brightness(color: Color): Float =
0.3f * color.red + 0.59f * color.green + 0.11f * color.blue
/** [amount] of [foreground] over [background]. */
private fun blend(foreground: Color, background: Color, amount: Float): Color = Color(
red = foreground.red * amount + background.red * (1f - amount),
green = foreground.green * amount + background.green * (1f - amount),
blue = foreground.blue * amount + background.blue * (1f - amount),
alpha = 1f,
)
/**
* Black or white, whichever reads on the given colour. Uses the same brightness
* measure JavaFX does, so the decision matches what the rest of the application
* would make.
*
* Internal rather than private because the menu row needs it too: once the operator can
* choose the accent, a menu title drawn in any fixed colour on an accent background can
* collapse into it.
*/
internal fun contrastingText(background: Color): Color =
if (brightness(background) > 0.5f) Color.Black else Color.White
/**
* Scales every text style by the factor that turns the body size into
* [targetBodySizeSp]. A value of zero or less is ignored, so a missing or
* malformed configured size keeps the default design rather than collapsing it.
*/
internal fun Typography.scaledTo(targetBodySizeSp: Float): Typography {
if (targetBodySizeSp <= 0f) {
return this
}
val factor = targetBodySizeSp / bodyMedium.fontSize.value
if (factor == 1f) {
return this
}
fun TextStyle.scaled() = copy(fontSize = fontSize.value.times(factor).sp)
return copy(
displayLarge = displayLarge.scaled(), displayMedium = displayMedium.scaled(),
displaySmall = displaySmall.scaled(), headlineLarge = headlineLarge.scaled(),
headlineMedium = headlineMedium.scaled(), headlineSmall = headlineSmall.scaled(),
titleLarge = titleLarge.scaled(), titleMedium = titleMedium.scaled(),
titleSmall = titleSmall.scaled(), bodyLarge = bodyLarge.scaled(),
bodyMedium = bodyMedium.scaled(), bodySmall = bodySmall.scaled(),
labelLarge = labelLarge.scaled(), labelMedium = labelMedium.scaled(),
labelSmall = labelSmall.scaled(),
)
}
@@ -0,0 +1,212 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatCategory
import kst4contest.model.ContestSked
import kotlin.math.abs
/**
* How promising an aircraft reflection is, in the four steps the timeline distinguishes.
*
* The colours are the original ones and they are not theme colours: this strip is read
* like a radar screen, where magenta means "now or never" in both designs.
*/
enum class PotentialLevel {
HIGHEST,
HIGH,
MEDIUM,
LOW;
companion object {
fun forPercent(percent: Int): PotentialLevel = when {
percent >= 95 -> HIGHEST
percent >= 75 -> HIGH
percent >= 50 -> MEDIUM
else -> LOW
}
}
}
/**
* One priority candidate on the timeline: a station worth calling at a given minute,
* placed on the next minute an aeroplane is expected.
*
* The Kotlin counterpart of TimelineView.CandidateEvent, built from the score service.
*/
data class TimelineCandidate(
val callSignRaw: String,
val displayCallSign: String,
val preferredChatCategory: ChatCategory?,
val timeUntilMs: Long,
val minuteBucket: Int,
val laneIndex: Int,
val targetAzimuth: Double,
val score: Double,
val opportunityPotentialPercent: Int,
val tooltipText: String?,
)
/**
* Where things sit on the strip.
*
* Time runs left to right as it *increases*, so "now" is at the left edge and the end
* of the preview window at the right. Imminent events are therefore on the left, which
* is the opposite of a progress bar and worth knowing before reading the numbers.
*/
object TimelineGeometry {
/** How far ahead the strip looks. */
const val PREVIEW_MILLIS: Long = 30L * 60L * 1000L
/**
* The height the strip is drawn into, and the height the JavaFX call site actually
* gave it. The class itself claimed 40 and then only ever used the upper half, which
* is why the candidate labels and the sked labels landed on top of each other. The
* lanes below are laid out for the real height.
*/
const val HEIGHT: Float = 80f
/** The time axis, with the candidate lanes above it and the skeds below. */
const val AXIS_Y: Float = 44f
/** Candidates keep further from the edges than skeds do, because their labels are wider. */
const val CANDIDATE_MARGIN: Float = 30f
const val SKED_MARGIN: Float = 10f
const val LANE_BASE_Y: Float = 2f
const val LANE_HEIGHT: Float = 20f
/**
* How many candidate lanes fit above the axis. The producer keeps at most two
* candidates per minute, so this is a guard rather than a limit in practice: a third
* lane shares the second one instead of being drawn across the axis.
*/
const val LANE_COUNT: Int = 2
/** Skeds get their own lane below the axis, which is what frees the labels. */
const val SKED_Y: Float = 52f
const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0
/**
* The x for an event that many milliseconds away, or null when it falls outside the
* preview window and is not drawn at all.
*/
fun xFor(timeUntilMs: Long, width: Float, margin: Float): Float? {
if (timeUntilMs < 0 || timeUntilMs > PREVIEW_MILLIS) return null
val x = timeUntilMs.toFloat() / PREVIEW_MILLIS.toFloat() * width
// A strip narrower than two margins leaves no room to hold the marker off both
// edges, and coerceIn refuses an empty range rather than picking a side. Centring
// is the honest answer: on a strip that narrow the position carries no information
// anyway, and this window lives on a tiling compositor where that happens.
val lowerBound = margin
val upperBound = width - margin
if (lowerBound > upperBound) return width / 2f
return x.coerceIn(lowerBound, upperBound)
}
/**
* Where a label starts, given how wide it turned out. It sits to the right of its
* marker, and flips to the left rather than running off the strip — near the right
* edge, which is half an hour away, there is always room on the other side.
*/
fun labelStartX(markerX: Float, labelWidth: Float, stripWidth: Float, gap: Float): Float {
val toTheRight = markerX + gap
return if (toTheRight + labelWidth <= stripWidth) toTheRight
else (markerX - gap - labelWidth).coerceAtLeast(0f)
}
fun laneY(laneIndex: Int): Float =
LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1)
}
/**
* How strongly a marker is drawn, given where the antenna points.
*
* Off-beam markers fade rather than disappear: the operator has to be able to see that
* a chance exists in a direction they are not pointing, otherwise turning the rotator
* would never occur to them.
*/
data class AntennaEmphasis(
val iconAlpha: Float,
val glowing: Boolean,
val scale: Float,
) {
companion object {
/** Full strength, no glow — also what an unknown azimuth gets, so it stays readable. */
val PLAIN = AntennaEmphasis(iconAlpha = 1f, glowing = false, scale = 1f)
const val OFF_BEAM_ALPHA = 0.30f
const val ON_TARGET_SCALE = 1.10f
/**
* Within half a beam width the marker glows and grows; out to a full beam width
* it stays bright; beyond that it fades to a ghost.
*/
fun forAzimuth(
currentAzimuth: Double,
targetAzimuth: Double,
beamWidthDeg: Double,
): AntennaEmphasis {
// An unknown or negative azimuth is not a direction, so nothing is implied by it.
if (!targetAzimuth.isFinite() || targetAzimuth < 0) return PLAIN
var delta = abs(currentAzimuth - targetAzimuth)
if (delta > 180) delta = 360 - delta
val onTarget = delta <= beamWidthDeg / 2.0
val inBeam = delta <= beamWidthDeg
return AntennaEmphasis(
iconAlpha = if (inBeam) 1f else OFF_BEAM_ALPHA,
glowing = onTarget,
scale = if (onTarget) ON_TARGET_SCALE else 1f,
)
}
}
}
/**
* What the timeline draws.
*
* Compose state throughout: the strip is redrawn on the score service's pulse, on every
* rotator movement and on every resize, and each of those has to reach the screen on
* its own.
*/
class TimelineState {
val skeds = mutableStateListOf<ContestSked>()
val candidates = mutableStateListOf<TimelineCandidate>()
/** Where the antenna points now, from the rotator or the manually set QTF. */
var antennaAzimuth: Double by mutableStateOf(0.0)
var beamWidthDeg: Double by mutableStateOf(TimelineGeometry.DEFAULT_BEAM_WIDTH_DEG)
private set
/** A station without a sensible beam width keeps the default rather than a nonsense one. */
fun setBeamWidth(degrees: Double) {
if (degrees > 0 && degrees.isFinite()) {
beamWidthDeg = degrees
}
}
fun replaceSkeds(newSkeds: List<ContestSked>) {
skeds.clear()
skeds.addAll(newSkeds)
}
fun replaceCandidates(newCandidates: List<TimelineCandidate>) {
candidates.clear()
candidates.addAll(newCandidates)
}
fun emphasisFor(targetAzimuth: Double): AntennaEmphasis =
AntennaEmphasis.forAzimuth(antennaAzimuth, targetAzimuth, beamWidthDeg)
}
@@ -0,0 +1,311 @@
package kst4contest.view.compose
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.foundation.onClick
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kst4contest.model.ContestSked
import kotlin.math.roundToInt
/** The strip is read like a radar screen, so it stays dark in both designs. */
private val STRIP_BACKGROUND = Color(0xFF2B2B2B)
private val AXIS_COLOR = Color.Gray
/** The glow that marks a target the antenna is centred on. */
private val GLOW_COLOR = Color(0xFF32CD32)
private val MARKER_SIZE = 12.dp
private val LABEL_GAP = 4.dp
private val CHIP_SHAPE = RoundedCornerShape(6.dp)
private val CHIP_BACKGROUND = Color(0xA6000000)
/**
* The strip above the send field: what is coming in the next half hour.
*
* The one view in this client that is read as a picture rather than as text. Skeds and
* priority candidates are placed by time, coloured by how promising the aircraft
* reflection is, and faded when the antenna points elsewhere — so one glance answers
* both "what is next" and "could I even hear it right now".
*
* Time grows to the right, which means the imminent things are on the LEFT. That is the
* opposite of a progress bar and it is the original's choice, kept because operators
* have learned it.
*
* Markers are drawn rather than built from widgets: this is a diagram, with no rows and
* nothing to select. Only the candidates answer a click.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineView(
state: TimelineState,
onCandidateClicked: (TimelineCandidate) -> Unit,
skedTooltipExtra: (ContestSked) -> String? = { null },
modifier: Modifier = Modifier,
) {
val nowMillis = androidx.compose.runtime.mutableStateOf(System.currentTimeMillis())
androidx.compose.runtime.LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(1000)
nowMillis.value = System.currentTimeMillis()
}
}
val density = LocalDensity.current
val measurer = rememberTextMeasurer()
BoxWithConstraints(
modifier
.height(TimelineGeometry.HEIGHT.dp)
.background(STRIP_BACKGROUND),
) {
val widthPx = with(density) { maxWidth.toPx() }
val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
Canvas(Modifier.fillMaxSize()) {
drawLine(AXIS_COLOR, Offset(0f, axisY), Offset(size.width, axisY))
}
// The candidate lanes, above the axis.
state.candidates.forEach { candidate ->
val x = TimelineGeometry.xFor(candidate.timeUntilMs, widthPx, candidateMargin)
?: return@forEach
TimelineMarker(
centerXPx = x,
topDp = TimelineGeometry.laneY(candidate.laneIndex).dp,
stripWidthPx = widthPx,
label = candidate.displayCallSign,
tooltip = candidate.tooltipText,
level = PotentialLevel.forPercent(candidate.opportunityPotentialPercent),
emphasis = state.emphasisFor(candidate.targetAzimuth),
shape = MarkerShape.CANDIDATE,
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
onClick = { onCandidateClicked(candidate) },
)
}
// The skeds, in their own lane below the axis. In JavaFX they shared the space
// above it, which is why their labels sat on top of the candidates'.
val now = nowMillis.value
state.skeds.forEach { sked ->
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, widthPx, skedMargin)
?: return@forEach
TimelineMarker(
centerXPx = x,
topDp = TimelineGeometry.SKED_Y.dp,
stripWidthPx = widthPx,
label = "SKED: ${sked.targetChatCallsign}",
tooltip = skedTooltip(sked, skedTooltipExtra),
level = PotentialLevel.forPercent(sked.opportunityPotentialPercent),
emphasis = state.emphasisFor(sked.targetAzimuth),
shape = MarkerShape.SKED,
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
onClick = null,
)
}
}
}
/**
* The sked tooltip: who, on what band, in which direction — plus whatever the caller can
* add about the chance itself.
*/
private fun skedTooltip(sked: ContestSked, extraProvider: (ContestSked) -> String?): String {
val base = "${sked.targetChatCallsign} (${sked.band})\nAz: ${sked.targetAzimuth}"
val extra = extraProvider(sked)
return if (extra.isNullOrBlank()) base else "$base\n$extra"
}
private enum class MarkerShape { SKED, CANDIDATE }
private val CHIP_TEXT_STYLE = TextStyle(
color = Color.White,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
)
/**
* One marker: the symbol on the time axis and its callsign beside it.
*
* The label is never faded, only the symbol is. A ghost marker still has to be
* readable — that is the whole point of showing a chance in a direction the antenna is
* not pointing.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TimelineMarker(
centerXPx: Float,
topDp: Dp,
stripWidthPx: Float,
label: String,
tooltip: String?,
level: PotentialLevel,
emphasis: AntennaEmphasis,
shape: MarkerShape,
labelWidthPx: (String) -> Float,
onClick: (() -> Unit)?,
) {
val density = LocalDensity.current
val markerSizePx = with(density) { MARKER_SIZE.toPx() }
val gapPx = with(density) { LABEL_GAP.toPx() }
val labelWidth = labelWidthPx(label)
val labelX = TimelineGeometry.labelStartX(
markerX = centerXPx + markerSizePx / 2f,
labelWidth = labelWidth,
stripWidth = stripWidthPx,
gap = gapPx,
)
val symbol: @Composable () -> Unit = {
Canvas(
Modifier
.size(MARKER_SIZE)
.alpha(emphasis.iconAlpha),
) {
scale(emphasis.scale) {
if (emphasis.glowing) drawGlow()
when (shape) {
MarkerShape.SKED -> drawDiamond(markerColor(level))
MarkerShape.CANDIDATE -> drawCandidate(markerColor(level))
}
}
}
}
Box(
Modifier.offset {
IntOffset(
(centerXPx - markerSizePx / 2f).roundToInt(),
with(density) { topDp.toPx() }.roundToInt(),
)
},
) {
val clickable = if (onClick != null) Modifier.onClick { onClick() } else Modifier
if (tooltip.isNullOrBlank()) {
Box(clickable) { symbol() }
} else {
TooltipArea(tooltip = { TimelineTooltip(tooltip) }) {
Box(clickable) { symbol() }
}
}
}
// The label is placed on its own so it can flip sides without moving the symbol.
Box(
Modifier.offset {
IntOffset(
labelX.roundToInt(),
with(density) { topDp.toPx() }.roundToInt(),
)
},
) {
Box(
Modifier
.background(CHIP_BACKGROUND, CHIP_SHAPE)
.padding(horizontal = 4.dp, vertical = 1.dp),
) {
Text(text = label, style = CHIP_TEXT_STYLE, maxLines = 1)
}
}
}
/** Magenta means now or never; deep sky blue means it is barely worth turning for. */
private fun markerColor(level: PotentialLevel): Color = when (level) {
PotentialLevel.HIGHEST -> Color.Magenta
PotentialLevel.HIGH -> Color.Red
PotentialLevel.MEDIUM -> Color.Yellow
PotentialLevel.LOW -> Color(0xFF00BFFF)
}
/** The diamond of a sked: an appointment, not a chance. */
private fun DrawScope.drawDiamond(color: Color) {
val w = size.width
val h = size.height
val path = Path().apply {
moveTo(w / 2f, 0f)
lineTo(w, h / 2f)
lineTo(w / 2f, h)
lineTo(0f, h / 2f)
close()
}
drawPath(path, color)
}
/** A candidate: a downward triangle over a dot, so it reads even at this size. */
private fun DrawScope.drawCandidate(color: Color) {
val w = size.width
val h = size.height
drawCircle(color, radius = w / 3f, center = Offset(w / 2f, h / 2f))
val path = Path().apply {
moveTo(0f, 0f)
lineTo(w, 0f)
lineTo(w / 2f, h * 0.85f)
close()
}
drawPath(path, color)
}
/**
* The glow for a target the antenna is centred on. JavaFX used a drop shadow; here it is
* three fading rings, which costs nothing and reads the same at this size.
*/
private fun DrawScope.drawGlow() {
val center = Offset(size.width / 2f, size.height / 2f)
listOf(1.6f to 0.10f, 1.3f to 0.18f, 1.05f to 0.28f).forEach { (factor, alpha) ->
drawCircle(
color = GLOW_COLOR.copy(alpha = alpha),
radius = size.width / 2f * factor,
center = center,
)
}
}
@Composable
private fun TimelineTooltip(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,
)
}
}
@@ -0,0 +1,173 @@
package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.ExperimentalFoundationApi
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.HorizontalDivider
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.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.controller.ScoreService
import java.util.Locale
import java.util.function.Consumer
import java.util.function.Supplier
/**
* One line of the candidate list.
*
* Kept as a function rather than inlined into the row so the wording stays under a
* test: the JavaFX cell spelled it `callsign + " | score " + %.0f` for two releases,
* and an operator scanning the list reads the shape before the content.
*/
internal fun candidateLabel(candidate: ScoreService.TopCandidate): String =
candidate.displayCallSign +
" | score " +
String.format(Locale.US, "%.0f", candidate.score)
/**
* The full priority list, opened by the "more" button beside the two priority buttons.
*
* It exists so the main window can stay compact while the whole ranking is still
* reachable. Its JavaFX predecessor was the only Stage this application constructed
* itself, which is why it is ported before anything is deleted: no JavaFX dependency
* can be dropped while it stands.
*/
object TopPriorityCandidatesWindow {
private val host = ComposeWindowHost("TopPriorityCandidates")
@JvmStatic
val isOpen: Boolean get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun hide() = host.close()
/**
* `Supplier` and `Consumer` rather than Kotlin function types: the only caller is
* Java, and a Java lambda cannot produce Kotlin's `Unit`, so the Kotlin shape would
* force `return kotlin.Unit.INSTANCE` into the call site for nothing.
*
* @param candidates read afresh while the window is open; the ranking changes as
* scores do, and the JavaFX list was bound to the roster for that reason
* @param onPicked called with the chosen candidate, after which the window closes
*/
@JvmStatic
fun show(
candidates: Supplier<List<ScoreService.TopCandidate>>,
onPicked: Consumer<ScoreService.TopCandidate>,
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,
) {
host.show(
title = { kst4contest.view.i18n.CurrentStrings.get().candidatesTitle },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
languageStore = languageStore,
widthDp = 360f,
heightDp = 500f,
) { close ->
var ranking by remember { mutableStateOf(candidates.get()) }
/*
* Polled rather than pushed. The ranking lives in the score service, which
* is not Compose state, and this window is open for seconds at a time while
* an operator reads it — wiring a callback in and out for that is more
* machinery than the question deserves.
*/
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(POLL_INTERVAL_MS)
ranking = candidates.get()
}
}
Surface(
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.fillMaxSize(),
) {
Column(Modifier.fillMaxSize().padding(5.dp)) {
CandidateList(
ranking = ranking,
onPicked = { picked ->
onPicked.accept(picked)
close()
},
modifier = Modifier.weight(1f).fillMaxWidth(),
)
HorizontalDivider(Modifier.padding(vertical = 4.dp))
Text(
LocalStrings.current.candidatesDoubleClick,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
/** The list on its own, so a test can render it without opening a window. */
@Composable
internal fun CandidateListForTest(
ranking: List<ScoreService.TopCandidate>,
onPicked: (ScoreService.TopCandidate) -> Unit,
) = CandidateList(ranking, onPicked)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CandidateList(
ranking: List<ScoreService.TopCandidate>,
onPicked: (ScoreService.TopCandidate) -> Unit,
modifier: Modifier = Modifier,
) {
if (ranking.isEmpty()) {
Text(LocalStrings.current.candidatesNone, modifier = modifier.padding(4.dp))
return
}
LazyColumn(modifier) {
items(ranking) { candidate ->
Text(
text = candidateLabel(candidate),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { },
onDoubleClick = { onPicked(candidate) },
)
.padding(vertical = 3.dp, horizontal = 4.dp),
)
}
}
}
/** Fast enough that a changing ranking is not stale, slow enough to cost nothing. */
private const val POLL_INTERVAL_MS = 1_000L
}
@@ -0,0 +1,44 @@
package kst4contest.view.compose
import androidx.compose.runtime.mutableStateOf
import kst4contest.model.ChatMember
/** One priority button: what it says and what pressing it does. */
class TopPriorityEntry(val label: String, val select: () -> Unit)
/**
* The two priority buttons beside the station list.
*
* A shortcut to the station the score says to work next, so what they name and what
* they do when pressed is the whole of their value. Everything below the first two is
* behind the "more" button, as it was.
*
* @param topStations the ranked stations with their scores, highest first
* @param onSelect selects a station in the list, which is what pressing a button did
*/
class TopPriorityState(
private val topStations: () -> List<Pair<ChatMember, Double>>,
private val onSelect: (ChatMember) -> Unit,
) {
private val _entries = mutableStateOf<List<TopPriorityEntry>>(emptyList())
val entries: List<TopPriorityEntry>
get() = _entries.value
fun refresh() {
_entries.value = topStations()
.filter { (member, _) -> !member.callSign.isNullOrBlank() }
.take(SHOWN)
.mapIndexed { index, (member, score) ->
TopPriorityEntry(
label = "${index + 1} ${member.callSign} ${score.toInt()}",
) { onSelect(member) }
}
}
private companion object {
/** Two, as the JavaFX pane had; the rest sit behind "more". */
const val SHOWN = 2
}
}
@@ -0,0 +1,168 @@
package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings
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.HorizontalDivider
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import java.util.function.Consumer
/**
* The update window.
*
* Shown at startup and only when there is an update; [UpdateWindowState.updateAvailable]
* decides that, and it is tested. Non-blocking like the settings window: the operator
* reads it when they get round to it.
*/
object UpdateWindow {
private val host = ComposeWindowHost("update-window")
@JvmStatic
val isOpen: Boolean
get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun close() = host.close()
/**
* @param openReleasePage opens the given address in the system browser; passed in
* because reaching the browser is the host application's business. A
* Consumer rather than a Kotlin function type: this is called from Java, and
* a Kotlin lambda parameter would force the caller to return Unit by hand.
*/
@JvmStatic
fun show(
state: UpdateWindowState,
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,
openReleasePage: Consumer<String>,
onResized: (Float, Float) -> Unit,
) {
host.show(
title = { kst4contest.view.i18n.CurrentStrings.get().updateTitle },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
languageStore = languageStore,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
alwaysOnTop = true,
) { _ ->
UpdateContent(state, openReleasePage::accept)
}
}
}
@Composable
private fun UpdateContent(state: UpdateWindowState, openReleasePage: (String) -> Unit) {
val strings = LocalStrings.current
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Text(strings.updateAvailable, style = MaterialTheme.typography.titleMedium)
Form.section(strings.updateVersions) {
Form.labelledRow(strings.updateInstalledVersion) { Text(state.installedVersion) }
Form.labelledRow(strings.updateLatestVersion) { Text(state.latestVersion) }
}
Form.section(strings.updateMainChanges) { Text(state.majorChanges) }
Form.section(strings.updateAdditionalInformation) { Text(state.adminMessage) }
Form.section(strings.updateDownload) {
Text(
strings.updateOpenReleasePage,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier = Modifier.clickable { openReleasePage(state.releasePageUrl) },
)
}
/*
* The JavaFX window drew these as a TreeView with a hidden root. The depth
* is fixed at two — a heading and its lines — so two expandable sections do
* the same job without a tree component.
*/
UpdateBranch(strings.updateChangeLog, state.changeLog)
UpdateBranch(strings.updateKnownBugs, state.knownBugs)
}
}
}
/** One branch of the former tree: a heading, and sections that fold open. */
@Composable
private fun UpdateBranch(title: String, sections: List<UpdateSection>) {
val strings = LocalStrings.current
if (sections.isEmpty()) {
return
}
Form.section(title) {
sections.forEach { section ->
var expanded by remember(section.title) { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(if (expanded) "▾" else "▸")
Text(section.title, fontWeight = FontWeight.Bold)
}
if (expanded) {
section.entries.forEach { entry ->
Text(
entry,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 20.dp, bottom = 1.dp),
)
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
}
}
}
}
@@ -0,0 +1,96 @@
package kst4contest.view.compose
import kst4contest.model.UpdateInformation
import kst4contest.utils.VersionUtils
/**
* One branch of the update window's tree: a heading and the lines beneath it.
*
* Both branches of that tree have the same shape. The change log and the bug list
* arrive as arrays whose first element is the heading and whose remaining elements are
* the lines, so one type serves both.
*/
data class UpdateSection(val title: String, val entries: List<String>)
/**
* What the update window shows, and whether it opens at all.
*
* Free of any toolkit so the decision can be tested. The decision is the interesting
* part: an update window that opens when there is no update is a daily annoyance, and
* one that stays shut when there is an update defeats the whole mechanism.
*
* @param currentVersion the running version, `ApplicationConstants.APPLICATION_CURRENT_VERSION`
* @param currentVersionNumber the running version as a number, for the older
* comparison that is still used when the server reports no semantic version
*/
class UpdateWindowState(
private val updateInformation: UpdateInformation?,
private val currentVersion: String,
private val currentVersionNumber: Double,
) {
/**
* Whether an update exists.
*
* Two comparisons, as in the JavaFX window: a semantic one when the server reports
* a semantic version, and the older numeric one otherwise. Absent update
* information means no update — the check runs at startup, and a failed download
* must not stop the application from coming up.
*/
val updateAvailable: Boolean
get() {
val information = updateInformation ?: return false
return if (information.hasSemanticVersion()) {
VersionUtils.compareStableVersions(
information.latestSemanticVersionOnServer,
currentVersion,
) > 0
} else {
information.latestVersionNumberOnServer > currentVersionNumber
}
}
/** "KST4Contest 1.50.0", as the window prints it. */
val installedVersion: String
get() = DISPLAY_NAME + " " + currentVersion
val latestVersion: String
get() = DISPLAY_NAME + " " + (updateInformation?.latestVersionForDisplay ?: "")
val majorChanges: String
get() = updateInformation?.majorChanges ?: ""
val adminMessage: String
get() = updateInformation?.adminMessage ?: ""
/** Opened in the system browser by the "Open release page" link. */
val releasePageUrl: String
get() = updateInformation?.latestVersionPathOnWebserver ?: ""
val changeLog: List<UpdateSection>
get() = sectionsOf(updateInformation?.changeLog)
val knownBugs: List<UpdateSection>
get() = sectionsOf(updateInformation?.bugList)
/**
* Turns the stored arrays into sections.
*
* A row with nothing but a heading yields a section without lines rather than being
* dropped: the heading is the version, and a version that brought no listed change
* is still worth showing as released.
*/
private fun sectionsOf(rows: List<Array<String>>?): List<UpdateSection> =
rows.orEmpty()
.filter { it.isNotEmpty() }
.map { row -> UpdateSection(row.first(), row.drop(1)) }
companion object {
/**
* The window prints "KST4Contest", not `APPLICATION_NAME`. That constant is
* "praktiKST" and names the application directory, not the product.
*/
const val DISPLAY_NAME = "KST4Contest"
}
}
@@ -0,0 +1,70 @@
package kst4contest.view.compose
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
/**
* The keys that belong to the whole main window rather than to one control.
*
* JavaFX hung all of these on the Scene, and the tables even re-fired the send button from
* their own key filter. An operator who has just clicked a station, a message row or a button
* must still be able to press Enter and have the message go; and Ctrl+1…Ctrl+0 is how
* snippets are used at all, because the hands are already on the keyboard.
*/
object WindowShortcuts {
/**
* Which snippet a typed character selects, or null if it is not one of the ten digits.
*
* Ctrl+1…Ctrl+9 are slots 0 to 8 and Ctrl+0 is the tenth, because the digit row reads
* 1 to 9 and then 0.
*/
fun snippetIndexFor(typed: String): Int? = when (typed) {
"1" -> 0
"2" -> 1
"3" -> 2
"4" -> 3
"5" -> 4
"6" -> 5
"7" -> 6
"8" -> 7
"9" -> 8
"0" -> 9
else -> null
}
/**
* Handles one key event for the window. Returns true when the event was consumed, which
* is what keeps Enter out of the text and stops the key travelling further.
*
* @param send what Enter does, wherever the focus is
* @param clear what Escape does
* @param insertSnippet what Ctrl+<digit> does, with the slot it selected
*/
fun handle(
event: KeyEvent,
send: () -> Unit,
clear: () -> Unit,
insertSnippet: (Int) -> Unit,
): Boolean {
if (event.type != KeyEventType.KeyDown) return false
if (event.isCtrlPressed) {
val index = snippetIndexFor(event.utf16CodePoint.toChar().toString())
?: return false
insertSnippet(index)
return true
}
return when (event.key) {
Key.Enter, Key.NumPadEnter -> { send(); true }
Key.Escape -> { clear(); true }
else -> false
}
}
}
@@ -0,0 +1,916 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import kst4contest.view.map.MaidenheadGridRenderPlanner
import kst4contest.view.map.MaidenheadGridUtils
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.MapMarkerCluster
import kst4contest.view.map.MapMarkerClusterTooltip
import kst4contest.view.map.MapViewport
import kst4contest.view.map.StationMapClusterer
import kst4contest.view.map.TileFetcher
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.skia.Image
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log2
import kotlin.math.sign
/**
* Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
*
* Holds no canvas size: the size is whatever the canvas is measured at, and a
* [MapViewport] is built from the two together for each frame.
*/
class MapState(
zoom: Float = 6f,
centerLon: Double = 10.0,
centerLat: Double = 51.0,
) {
var zoom by mutableStateOf(zoom.coerceIn(MapViewport.MIN_ZOOM.toFloat(), MapViewport.MAX_ZOOM.toFloat()))
var centerLon by mutableStateOf(centerLon)
var centerLat by mutableStateOf(centerLat)
fun viewportFor(widthPx: Float, heightPx: Float): MapViewport =
MapViewport(centerLon, centerLat, zoom.toDouble(), widthPx.toDouble(), heightPx.toDouble())
fun applyFrom(viewport: MapViewport) {
centerLon = viewport.centerLon()
centerLat = viewport.centerLat()
zoom = viewport.zoom().toFloat()
}
}
/**
* The station map as a Compose canvas.
*
* **Everything but the tiles is drawn in canvas pixels.** A station dot is 12 px wide at
* zoom 4 and at zoom 17; so is its label, and so is a grid line. Leaflet behaved that
* way because its overlays sat in the DOM above a scaled tile layer, and the sizes here
* are the ones its stylesheet declared. [MapViewport] makes that possible by folding the
* fractional zoom into the projection.
*
* The previous version instead wrapped part of the drawing in `scale(scaleFactor)` and
* divided every size back out by hand. The geometry that came out of it was right, but
* two things were not. Text was measured at a size divided by the zoom fraction and then
* magnified by it, so glyphs were rasterised at the wrong size. And the scale block
* closed before the beam, the path line and the home marker were drawn, which left those
* three using unscaled coordinates while the markers beside them used scaled ones — so at
* any zoom with a fraction, the antenna beam did not start where the map said home was.
* Neither failure is possible here, because nothing is scaled.
*/
@Composable
fun ComposeStationMap(
markers: List<MapCallsignRawSnapshot>,
tileFetcher: TileFetcher,
mapState: MapState,
groupingEnabled: Boolean,
selectedMarkerCallsign: String?,
ownLocator6: String,
antennaAzimuthDeg: Double,
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
darkMode: Boolean = true,
modifier: Modifier = Modifier,
) {
val imageCache = remember { TileBitmapCache() }
val fetchingTiles = remember { ConcurrentHashMap.newKeySet<String>() }
val textMeasurer = rememberTextMeasurer()
var hoverPosition by remember { mutableStateOf<Offset?>(null) }
/*
* Measured, not read off the draw pass. Writing Compose state while drawing costs a
* frame at best: the first frame would group the stations against a zero-sized
* viewport and the second would correct it, which is visible as a flicker of
* wrongly grouped markers whenever the window is resized.
*/
var canvasSize by remember { mutableStateOf(Size.Zero) }
/* Bumped when a tile arrives, which is what asks for the frame that shows it. */
var tileRevision by remember { mutableStateOf(0) }
/*
* Grouping depends on where the map is looking, so it cannot be cached on the
* stations alone.
*
* It is NOT off the drawing path: derivedStateOf computes lazily at first read, and
* that read is inside the Canvas lambda. What changed against the first port is the
* cost of the pass, not when it runs — that one searched for the nearest existing
* cluster for every station, which is quadratic; this one buckets by screen cell in
* one linear pass over a few hundred markers.
*/
val home = remember(ownLocator6) { homePosition(ownLocator6) }
val clusterResult by remember(markers, groupingEnabled, canvasSize) {
derivedStateOf {
StationMapClusterer.cluster(
markers,
mapState.viewportFor(canvasSize.width, canvasSize.height),
groupingEnabled,
)
}
}
/*
* Fetching belongs here and not in the draw pass. A tile the fetcher already holds
* completes its future inline, so a fetch started while drawing would write the
* cache and ask for a redraw in the middle of the frame that needed it — and the
* frame would go out without it.
*/
LaunchedEffect(tileFetcher) {
/*
* Read inside a snapshotFlow and not as effect keys. Keys are read during
* composition, so keying on the zoom and the centre would recompose this whole
* composable — and rebuild its gesture nodes — on every frame of a drag, when
* all the frame needs is a redraw.
*/
snapshotFlow {
TileRequest(canvasSize, mapState.zoom, mapState.centerLon, mapState.centerLat)
}.collectLatest { request ->
if (request.size.width <= 0f || request.size.height <= 0f) {
return@collectLatest
}
val viewport = mapState.viewportFor(request.size.width, request.size.height)
for (key in visibleTileKeys(viewport)) {
if (imageCache.contains(key.cacheKey) || !fetchingTiles.add(key.cacheKey)) {
continue
}
tileFetcher.fetchTileAsync(key.zoom, key.x, key.y).whenComplete { bytes, _ ->
try {
if (bytes != null) {
imageCache.put(key.cacheKey, Image.makeFromEncoded(bytes).toComposeImageBitmap())
tileRevision++
}
} catch (undecodable: Exception) {
/*
* Bytes that will not decode are a truncated or corrupt cache
* entry, and serving them again on the next pan would blank that
* square for good. Thrown away so the tile is fetched afresh.
*/
tileFetcher.discardTile(key.zoom, key.x, key.y)
} finally {
fetchingTiles.remove(key.cacheKey)
}
}
}
}
}
Canvas(
modifier = modifier
.clipToBounds()
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoomChange, _ ->
var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
if (pan != Offset.Zero) {
viewport = viewport.pannedBy(pan.x.toDouble(), pan.y.toDouble())
}
if (zoomChange != 1f && zoomChange > 0f) {
viewport = viewport.zoomedTo(
viewport.zoom() + log2(zoomChange.toDouble()),
centroid.x.toDouble(),
centroid.y.toDouble(),
)
}
mapState.applyFrom(viewport)
}
}
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
when (event.type) {
PointerEventType.Scroll -> {
val change = event.changes.first()
val scrollDelta = change.scrollDelta.y
if (scrollDelta != 0f) {
val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
mapState.applyFrom(
viewport.zoomedTo(
viewport.zoom() - scrollDelta.sign * ZOOM_STEP_PER_WHEEL_NOTCH,
change.position.x.toDouble(),
change.position.y.toDouble(),
)
)
}
}
PointerEventType.Move -> hoverPosition = event.changes.first().position
PointerEventType.Exit -> hoverPosition = null
}
}
}
}
.pointerInput(markers, groupingEnabled, onMarkerClicked) {
detectTapGestures { offset ->
val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
val hitCluster = result.clusters().firstOrNull { cluster ->
within(
offset,
viewport.screenX(cluster.centerLon).toFloat(),
viewport.screenY(cluster.centerLat).toFloat(),
clusterRadiusPx(cluster.size()),
)
}
if (hitCluster != null) {
/*
* A cluster click does not select a station. It moves towards the
* cluster and zooms in until the stations inside become visible,
* which is what the Leaflet map did.
*/
mapState.centerLon = hitCluster.centerLon
mapState.centerLat = hitCluster.centerLat
mapState.zoom = minOf(
StationMapClusterer.DISABLE_AT_ZOOM,
mapState.zoom + 1.0,
).toFloat()
return@detectTapGestures
}
val hit = result.individual().firstOrNull { marker ->
within(
offset,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
if (hit != null) {
onMarkerClicked?.invoke(hit)
}
}
}
) {
@Suppress("UNUSED_EXPRESSION")
tileRevision
if (size.width <= 0f || size.height <= 0f) {
return@Canvas
}
val viewport = mapState.viewportFor(size.width, size.height)
drawRect(color = if (darkMode) MAP_BACKGROUND_DARK else MAP_BACKGROUND_LIGHT)
drawTiles(viewport, imageCache, darkMode)
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
}
}
/* ------------------------------------------------------------------ tiles */
/** The view a tile request was made for; the unit snapshotFlow deduplicates on. */
private data class TileRequest(
val size: Size,
val zoom: Float,
val centerLon: Double,
val centerLat: Double,
)
/** One tile of the current view. */
internal data class TileKey(val zoom: Int, val x: Int, val y: Int) {
val cacheKey: String get() = "$zoom/$x/$y"
}
/**
* The tiles the viewport covers, east-west wrap applied.
*
* The world wraps at the antimeridian but the tile index does not, so a view straddling
* it asks for indices outside 0..last and they are brought back round.
*/
internal fun visibleTileKeys(viewport: MapViewport): List<TileKey> {
val tileZoom = viewport.tileZoom()
val lastTileIndex = (1 shl tileZoom) - 1
val firstX = floor(viewport.worldXAt(0.0) / TILE_SIZE).toInt()
val lastX = floor(viewport.worldXAt(viewport.widthPx()) / TILE_SIZE).toInt()
val firstY = floor(viewport.worldYAt(0.0) / TILE_SIZE).toInt()
val lastY = floor(viewport.worldYAt(viewport.heightPx()) / TILE_SIZE).toInt()
val keys = mutableListOf<TileKey>()
for (tileY in firstY..lastY) {
if (tileY < 0 || tileY > lastTileIndex) {
continue
}
for (tileX in firstX..lastX) {
keys.add(TileKey(tileZoom, wrapTileX(tileX, lastTileIndex), tileY))
}
}
return keys
}
internal fun wrapTileX(tileX: Int, lastTileIndex: Int): Int {
val span = lastTileIndex + 1
val wrapped = tileX % span
return if (wrapped < 0) wrapped + span else wrapped
}
private fun DrawScope.drawTiles(
viewport: MapViewport,
imageCache: TileBitmapCache,
darkMode: Boolean,
) {
val tileZoom = viewport.tileZoom()
val lastTileIndex = (1 shl tileZoom) - 1
/*
* Rounded up. A tile scaled to a fractional width leaves a hairline of background
* between itself and its neighbour otherwise, and a grid of hairlines reads as a
* broken map.
*/
val tileSizePx = ceil(viewport.tileSizePx()).toInt().coerceAtLeast(1)
val firstX = floor(viewport.worldXAt(0.0) / TILE_SIZE).toInt()
val lastX = floor(viewport.worldXAt(viewport.widthPx()) / TILE_SIZE).toInt()
val firstY = floor(viewport.worldYAt(0.0) / TILE_SIZE).toInt()
val lastY = floor(viewport.worldYAt(viewport.heightPx()) / TILE_SIZE).toInt()
for (tileY in firstY..lastY) {
if (tileY < 0 || tileY > lastTileIndex) {
continue
}
for (tileX in firstX..lastX) {
val tile = imageCache[TileKey(tileZoom, wrapTileX(tileX, lastTileIndex), tileY).cacheKey]
?: continue
drawImage(
image = tile,
dstOffset = IntOffset(
floor(viewport.screenXForWorldX(tileX * TILE_SIZE)).toInt(),
floor(viewport.screenYForWorldY(tileY * TILE_SIZE)).toInt(),
),
dstSize = IntSize(tileSizePx, tileSizePx),
alpha = if (darkMode) DARK_TILE_ALPHA else 1.0f,
)
}
}
}
/* ------------------------------------------------------------- grid */
private fun DrawScope.drawMaidenheadGrid(
viewport: MapViewport,
textMeasurer: TextMeasurer,
darkMode: Boolean,
) {
/*
* The planner decides the precision, whether labels are shown at all, and how many
* cells apart they sit. Without it every cell gets a line and a label, which at
* six-digit precision is a carpet of text rather than a grid.
*/
val plan = MaidenheadGridRenderPlanner.createPlan(
viewport.tileZoom(),
viewport.southLat(),
viewport.westLon(),
viewport.northLat(),
viewport.eastLon(),
viewport.widthPx(),
viewport.heightPx(),
)
val cells = MaidenheadGridUtils.buildVisibleCells(
viewport.southLat(),
viewport.westLon(),
viewport.northLat(),
viewport.eastLon(),
plan.precision(),
)
val lineColor = if (darkMode) GRID_LINE_DARK else GRID_LINE_LIGHT
val lineAlpha = if (darkMode) GRID_LINE_ALPHA_DARK else GRID_LINE_ALPHA_LIGHT
/*
* Unique lines, not one stroked rectangle per cell. Neighbouring cells share an
* edge, so a rectangle each paints every interior line twice: at 0.48 a shared edge
* reaches about 0.73 while the outermost line stays at 0.48, and the grid comes out
* visibly uneven. Leaflet drew each line once.
*/
val verticals = sortedSetOf<Float>()
val horizontals = sortedSetOf<Float>()
for (cell in cells) {
verticals.add(viewport.screenX(cell.westLon()).toFloat())
verticals.add(viewport.screenX(cell.eastLon()).toFloat())
horizontals.add(viewport.screenY(cell.northLat()).toFloat())
horizontals.add(viewport.screenY(cell.southLat()).toFloat())
}
for (x in verticals) {
drawLine(
color = lineColor,
alpha = lineAlpha,
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = GRID_LINE_WIDTH_PX,
)
}
for (y in horizontals) {
drawLine(
color = lineColor,
alpha = lineAlpha,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = GRID_LINE_WIDTH_PX,
)
}
if (!plan.showLabels()) {
return
}
val labelStyle = TextStyle(
color = GRID_LABEL_COLOR,
fontWeight = FontWeight.SemiBold,
fontSize = TextUnit(plan.labelFontSizePx().toFloat(), TextUnitType.Sp),
)
val labelBackground = if (darkMode) GRID_LABEL_BACKGROUND_DARK else GRID_LABEL_BACKGROUND_LIGHT
for (cell in cells) {
if (!plan.shouldShowLabel(cell) || cell.locatorLabel().isNullOrBlank()) {
continue
}
/* Centred in the cell, the way the stylesheet's translate(-50%, -50%) was. */
val centerX = viewport.screenX((cell.westLon() + cell.eastLon()) / 2.0).toFloat()
val centerY = viewport.screenY((cell.southLat() + cell.northLat()) / 2.0).toFloat()
val layout = textMeasurer.measure(cell.locatorLabel(), style = labelStyle)
val x = centerX - layout.size.width / 2f
val y = centerY - layout.size.height / 2f
drawRect(
color = labelBackground,
topLeft = Offset(x - GRID_LABEL_PADDING_PX, y),
size = Size(layout.size.width + GRID_LABEL_PADDING_PX * 2f, layout.size.height.toFloat()),
)
drawText(textLayoutResult = layout, topLeft = Offset(x, y))
}
}
/* ------------------------------------------------- beam and path */
private fun DrawScope.drawBeamAndPath(
viewport: MapViewport,
home: Pair<Double, Double>?,
antennaAzimuthDeg: Double,
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
markers: List<MapCallsignRawSnapshot>,
selectedMarkerCallsign: String?,
darkMode: Boolean,
) {
if (home == null) {
return
}
val (homeLat, homeLon) = home
if (antennaBeamWidthDeg > 0.0 && maxQrbKm > 0.0) {
val polygon = Path()
buildBeamPolygon(homeLat, homeLon, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm)
.forEachIndexed { index, point ->
val x = viewport.screenX(point.second).toFloat()
val y = viewport.screenY(point.first).toFloat()
if (index == 0) polygon.moveTo(x, y) else polygon.lineTo(x, y)
}
polygon.close()
drawPath(path = polygon, color = BEAM_COLOR, alpha = BEAM_FILL_ALPHA, style = Fill)
drawPath(path = polygon, color = BEAM_COLOR, style = Stroke(width = BEAM_STROKE_WIDTH_PX))
}
val selected = markers.firstOrNull {
it.callSignRaw() == selectedMarkerCallsign && it.hasUsablePosition()
}
if (selected != null) {
drawLine(
color = if (darkMode) CONNECTION_COLOR_DARK else CONNECTION_COLOR_LIGHT,
alpha = CONNECTION_ALPHA,
start = Offset(viewport.screenX(homeLon).toFloat(), viewport.screenY(homeLat).toFloat()),
end = Offset(
viewport.screenX(selected.longitudeDeg()).toFloat(),
viewport.screenY(selected.latitudeDeg()).toFloat(),
),
strokeWidth = CONNECTION_STROKE_WIDTH_PX,
pathEffect = PathEffect.dashPathEffect(CONNECTION_DASH_PX),
)
}
/*
* The Leaflet map drew no home marker; the beam's apex was the only hint of where
* home is, and with the beam switched off there was none. A small dot is kept here
* deliberately.
*/
val homeX = viewport.screenX(homeLon).toFloat()
val homeY = viewport.screenY(homeLat).toFloat()
drawCircle(color = BEAM_COLOR, radius = HOME_RADIUS_PX, center = Offset(homeX, homeY))
drawCircle(color = Color.White, radius = HOME_INNER_RADIUS_PX, center = Offset(homeX, homeY))
}
/* ---------------------------------------------------------- stations */
private fun DrawScope.drawStations(
viewport: MapViewport,
clusterResult: StationMapClusterer.Result,
selectedMarkerCallsign: String?,
textMeasurer: TextMeasurer,
darkMode: Boolean,
) {
for (cluster in clusterResult.clusters()) {
drawClusterBubble(
x = viewport.screenX(cluster.centerLon).toFloat(),
y = viewport.screenY(cluster.centerLat).toFloat(),
count = cluster.size(),
containsWorked = cluster.getMarkers().any { it.worked() },
textMeasurer = textMeasurer,
)
}
for (marker in clusterResult.individual()) {
val x = viewport.screenX(marker.longitudeDeg()).toFloat()
val y = viewport.screenY(marker.latitudeDeg()).toFloat()
val selected = marker.callSignRaw() == selectedMarkerCallsign
drawStationDot(x, y, selected, marker.worked(), marker.warningToMyDirection())
drawStationLabel(x, y, marker.markerLabel(), marker.warningToMyDirection(), textMeasurer, darkMode)
}
}
/** 12 px across, 16 px when selected — the sizes `.station-dot` declared. */
private fun DrawScope.drawStationDot(
x: Float,
y: Float,
selected: Boolean,
worked: Boolean,
warning: Boolean,
) {
val radius = if (selected) SELECTED_DOT_RADIUS_PX else DOT_RADIUS_PX
val borderWidth = if (selected) SELECTED_DOT_BORDER_PX else DOT_BORDER_PX
val borderColor = when {
selected -> DOT_BORDER_SELECTED
warning -> DOT_BORDER_WARNING
worked -> DOT_BORDER_WORKED
else -> DOT_BORDER_DEFAULT
}
drawCircle(color = DOT_FILL, radius = radius, center = Offset(x, y))
drawCircle(
color = borderColor,
radius = radius - borderWidth / 2f,
center = Offset(x, y),
style = Stroke(width = borderWidth),
)
}
/** Up and to the right of the dot, as `.station-label` placed it. */
private fun DrawScope.drawStationLabel(
x: Float,
y: Float,
label: String,
warning: Boolean,
textMeasurer: TextMeasurer,
darkMode: Boolean,
) {
if (label.isBlank()) {
return
}
val layout = textMeasurer.measure(
label,
style = TextStyle(
color = if (warning) DOT_BORDER_WARNING else if (darkMode) LABEL_TEXT_DARK else LABEL_TEXT_LIGHT,
fontWeight = FontWeight.Bold,
fontSize = TextUnit(LABEL_FONT_SIZE_PX, TextUnitType.Sp),
),
)
val boxLeft = x + LABEL_OFFSET_X_PX
val boxTop = y + LABEL_OFFSET_Y_PX
val boxWidth = layout.size.width + LABEL_PADDING_X_PX * 2f
val boxHeight = layout.size.height + LABEL_PADDING_Y_PX * 2f
drawRect(
color = if (darkMode) LABEL_BACKGROUND_DARK else LABEL_BACKGROUND_LIGHT,
topLeft = Offset(boxLeft, boxTop),
size = Size(boxWidth, boxHeight),
)
drawRect(
color = if (warning) DOT_BORDER_WARNING else if (darkMode) LABEL_BORDER_DARK else LABEL_BORDER_LIGHT,
topLeft = Offset(boxLeft, boxTop),
size = Size(boxWidth, boxHeight),
style = Stroke(width = LABEL_BORDER_WIDTH_PX),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(boxLeft + LABEL_PADDING_X_PX, boxTop + LABEL_PADDING_Y_PX),
)
}
/** 32, 36 or 42 px across, by the thresholds `buildClusterMarkerHtml` used. */
private fun DrawScope.drawClusterBubble(
x: Float,
y: Float,
count: Int,
containsWorked: Boolean,
textMeasurer: TextMeasurer,
) {
val radius = clusterRadiusPx(count)
val borderColor = if (containsWorked) DOT_BORDER_WORKED else DOT_BORDER_DEFAULT
drawCircle(color = CLUSTER_FILL, radius = radius, center = Offset(x, y))
drawCircle(
color = borderColor,
radius = radius - DOT_BORDER_PX / 2f,
center = Offset(x, y),
style = Stroke(width = DOT_BORDER_PX),
)
val layout = textMeasurer.measure(
count.toString(),
style = TextStyle(
color = CLUSTER_TEXT,
fontWeight = FontWeight.ExtraBold,
fontSize = TextUnit(clusterFontSizePx(count), TextUnitType.Sp),
),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(x - layout.size.width / 2f, y - layout.size.height / 2f),
)
}
/* ------------------------------------------------------------ hover */
private fun DrawScope.drawHover(
viewport: MapViewport,
hoverPosition: Offset?,
clusterResult: StationMapClusterer.Result,
textMeasurer: TextMeasurer,
) {
val position = hoverPosition ?: return
val clusterUnderPointer = clusterResult.clusters().firstOrNull { cluster ->
within(
position,
viewport.screenX(cluster.centerLon).toFloat(),
viewport.screenY(cluster.centerLat).toFloat(),
clusterRadiusPx(cluster.size()),
)
}
val markerUnderPointer = clusterResult.individual().firstOrNull { marker ->
within(
position,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
val text = when {
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
markerUnderPointer != null -> markerUnderPointer.locator6().let { locator ->
if (locator.isBlank()) markerUnderPointer.displayCallSign()
else "${markerUnderPointer.displayCallSign()} ($locator)"
}
else -> return
}
val layout: TextLayoutResult = textMeasurer.measure(
text,
style = TextStyle(color = Color.Black, fontSize = TextUnit(LABEL_FONT_SIZE_PX, TextUnitType.Sp)),
)
val boxWidth = layout.size.width + TOOLTIP_PADDING_PX * 2f
val boxHeight = layout.size.height + TOOLTIP_PADDING_PX * 2f
/*
* Flipped away from the edge it would cross, then held inside the canvas. Flipping
* alone is not enough: a cluster tooltip is as tall as it has lines, and one taller
* than the canvas would otherwise be placed at a negative offset and cover the map
* it exists to explain.
*/
val left = (
if (position.x + TOOLTIP_OFFSET_PX + boxWidth > size.width) {
position.x - TOOLTIP_OFFSET_PX - boxWidth
} else {
position.x + TOOLTIP_OFFSET_PX
}
).coerceIn(0f, (size.width - boxWidth).coerceAtLeast(0f))
val top = (
if (position.y + TOOLTIP_OFFSET_PX + boxHeight > size.height) {
position.y - TOOLTIP_OFFSET_PX - boxHeight
} else {
position.y + TOOLTIP_OFFSET_PX
}
).coerceIn(0f, (size.height - boxHeight).coerceAtLeast(0f))
drawRect(color = TOOLTIP_BACKGROUND, topLeft = Offset(left, top), size = Size(boxWidth, boxHeight))
drawRect(
color = TOOLTIP_BORDER,
topLeft = Offset(left, top),
size = Size(boxWidth, boxHeight),
style = Stroke(width = 1f),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(left + TOOLTIP_PADDING_PX, top + TOOLTIP_PADDING_PX),
)
}
/* ----------------------------------------------------------- helpers */
private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
private val MapMarkerCluster.centerLat: Double get() = getCenterLat()
private fun within(point: Offset, x: Float, y: Float, radius: Float): Boolean {
val dx = point.x - x
val dy = point.y - y
return dx * dx + dy * dy <= radius * radius
}
private fun clusterRadiusPx(count: Int): Float = when {
count >= CLUSTER_LARGE_FROM -> CLUSTER_RADIUS_LARGE_PX
count >= CLUSTER_MEDIUM_FROM -> CLUSTER_RADIUS_MEDIUM_PX
else -> CLUSTER_RADIUS_SMALL_PX
}
private fun clusterFontSizePx(count: Int): Float = when {
count >= CLUSTER_LARGE_FROM -> 15f
count >= CLUSTER_MEDIUM_FROM -> 14f
else -> 13f
}
/** Null rather than a guess when the operator has no usable locator configured. */
private fun homePosition(ownLocator6: String): Pair<Double, Double>? {
if (ownLocator6.length < 4) {
return null
}
return try {
val location = kst4contest.locatorUtils.Location(ownLocator6)
Pair(location.latitude.toDegrees(), location.longitude.toDegrees())
} catch (malformedLocator: Exception) {
null
}
}
fun calculateDestinationPoint(
startLatDeg: Double,
startLonDeg: Double,
bearingDeg: Double,
distanceKm: Double,
): Pair<Double, Double> {
val earthRadiusKm = 6371.009
val angularDistance = distanceKm / earthRadiusKm
val bearingRad = Math.toRadians(bearingDeg)
val startLatRad = Math.toRadians(startLatDeg)
val startLonRad = Math.toRadians(startLonDeg)
val destLatRad = kotlin.math.asin(
kotlin.math.sin(startLatRad) * kotlin.math.cos(angularDistance) +
kotlin.math.cos(startLatRad) * kotlin.math.sin(angularDistance) * kotlin.math.cos(bearingRad)
)
val destLonRad = startLonRad + kotlin.math.atan2(
kotlin.math.sin(bearingRad) * kotlin.math.sin(angularDistance) * kotlin.math.cos(startLatRad),
kotlin.math.cos(angularDistance) - kotlin.math.sin(startLatRad) * kotlin.math.sin(destLatRad)
)
return Pair(Math.toDegrees(destLatRad), Math.toDegrees(destLonRad))
}
fun buildBeamPolygon(
startLatDeg: Double,
startLonDeg: Double,
centerAzimuthDeg: Double,
beamWidthDeg: Double,
radiusKm: Double,
): List<Pair<Double, Double>> {
val polygon = mutableListOf(Pair(startLatDeg, startLonDeg))
val segmentCount = maxOf(12, ceil(beamWidthDeg / 4.0).toInt())
val angleStep = beamWidthDeg / segmentCount
val startAzimuth = centerAzimuthDeg - beamWidthDeg / 2.0
for (segment in 0..segmentCount) {
var azimuth = startAzimuth + segment * angleStep
if (azimuth < 0.0) azimuth += 360.0
if (azimuth >= 360.0) azimuth -= 360.0
polygon.add(calculateDestinationPoint(startLatDeg, startLonDeg, azimuth, radiusKm))
}
polygon.add(Pair(startLatDeg, startLonDeg))
return polygon
}
/* --------------------------------------------------------- constants
*
* Every size is in canvas pixels and every colour is the one the Leaflet stylesheet
* declared, so the two maps can be held side by side.
*/
private const val TILE_SIZE = 256.0
private const val DARK_TILE_ALPHA = 0.82f
private const val ZOOM_STEP_PER_WHEEL_NOTCH = 0.5
private val MAP_BACKGROUND_LIGHT = Color(0xFFEDE9DF)
private val MAP_BACKGROUND_DARK = Color(0xFF23282D)
private val GRID_LINE_DARK = Color(0xFFE1E7EC)
private val GRID_LINE_LIGHT = Color(0xFF46586C)
private const val GRID_LINE_ALPHA_DARK = 0.48f
private const val GRID_LINE_ALPHA_LIGHT = 0.56f
private const val GRID_LINE_WIDTH_PX = 1.4f
private val GRID_LABEL_COLOR = Color(0xFF63067A)
private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f)
private val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f)
private const val GRID_LABEL_PADDING_PX = 3f
private val DOT_FILL = Color(0xFF1D1D1D)
private val DOT_BORDER_DEFAULT = Color(0xFF4DA6FF)
private val DOT_BORDER_WORKED = Color(0xFFFFD24D)
private val DOT_BORDER_WARNING = Color(0xFF00FF66)
private val DOT_BORDER_SELECTED = Color(0xFFFF9900)
private const val DOT_RADIUS_PX = 6f
private const val DOT_BORDER_PX = 2f
private const val SELECTED_DOT_RADIUS_PX = 8f
private const val SELECTED_DOT_BORDER_PX = 3f
private const val MARKER_HIT_RADIUS_PX = 10f
private val LABEL_BACKGROUND_LIGHT = Color(0xFFF8F8F8).copy(alpha = 0.95f)
private val LABEL_BACKGROUND_DARK = Color(0xFF24282D).copy(alpha = 0.96f)
private val LABEL_TEXT_LIGHT = Color(0xFF1C1C1C)
private val LABEL_TEXT_DARK = Color(0xFFF1F3F5)
private val LABEL_BORDER_LIGHT = Color.Black.copy(alpha = 0.20f)
private val LABEL_BORDER_DARK = Color.White.copy(alpha = 0.18f)
private const val LABEL_FONT_SIZE_PX = 12f
private const val LABEL_BORDER_WIDTH_PX = 1f
private const val LABEL_PADDING_X_PX = 5f
private const val LABEL_PADDING_Y_PX = 2f
private const val LABEL_OFFSET_X_PX = 10f
private const val LABEL_OFFSET_Y_PX = -22f
private val CLUSTER_FILL = Color(0xFF202428)
private val CLUSTER_TEXT = Color(0xFFF1F3F5)
private const val CLUSTER_MEDIUM_FROM = 8
private const val CLUSTER_LARGE_FROM = 20
private const val CLUSTER_RADIUS_SMALL_PX = 16f
private const val CLUSTER_RADIUS_MEDIUM_PX = 18f
private const val CLUSTER_RADIUS_LARGE_PX = 21f
private val BEAM_COLOR = Color(0xFFFF4D4D)
private const val BEAM_FILL_ALPHA = 0.12f
private const val BEAM_STROKE_WIDTH_PX = 2f
private const val HOME_RADIUS_PX = 6f
private const val HOME_INNER_RADIUS_PX = 2f
private val CONNECTION_COLOR_DARK = Color(0xFF2FD7FF)
private val CONNECTION_COLOR_LIGHT = Color(0xFF00A4CF)
private const val CONNECTION_ALPHA = 0.85f
private const val CONNECTION_STROKE_WIDTH_PX = 2f
private val CONNECTION_DASH_PX = floatArrayOf(6f, 6f)
private val TOOLTIP_BACKGROUND = Color.White.copy(alpha = 0.92f)
private val TOOLTIP_BORDER = Color.Black
private const val TOOLTIP_PADDING_PX = 4f
private const val TOOLTIP_OFFSET_PX = 12f
@@ -0,0 +1,120 @@
package kst4contest.view.compose.map
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import kst4contest.view.map.MapViewport
/** The zoom range the tile source serves, as the viewport defines it. */
internal val MIN_MAP_ZOOM = MapViewport.MIN_ZOOM.toFloat()
internal val MAX_MAP_ZOOM = MapViewport.MAX_ZOOM.toFloat()
/**
* The two things Leaflet drew on top of the map itself.
*
* The zoom buttons, because a map without them can only be zoomed by a wheel or a
* trackpad and neither is a given on a contest laptop. And the OpenStreetMap
* attribution, which is not decoration: the tile licence requires it to be visible
* wherever the tiles are.
*/
@Composable
internal fun BoxScope.MapOverlayControls(
darkMode: Boolean,
tilesUnavailable: Boolean,
onZoomIn: () -> Unit,
onZoomOut: () -> Unit,
) {
val strings = LocalStrings.current
Column(
modifier = Modifier.align(Alignment.TopStart).padding(10.dp),
) {
val strings = LocalStrings.current
ZoomButton("+", darkMode, onZoomIn)
ZoomButton("−", darkMode, onZoomOut)
}
if (tilesUnavailable) {
/*
* Said out loud. An empty map and a map with no stations on it look the same,
* and an operator in a contest should not have to guess which one they have.
*/
Text(
text = strings.mapTilesUnavailable,
color = TILE_WARNING_TEXT,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 10.dp)
.background(TILE_WARNING_BACKGROUND, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
Text(
text = "© OpenStreetMap contributors",
fontSize = TextUnit(10f, TextUnitType.Sp),
color = if (darkMode) ATTRIBUTION_TEXT_DARK else ATTRIBUTION_TEXT_LIGHT,
modifier = Modifier
.align(Alignment.BottomEnd)
.background(if (darkMode) ATTRIBUTION_BACKGROUND_DARK else ATTRIBUTION_BACKGROUND_LIGHT)
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
@Composable
private fun ZoomButton(label: String, darkMode: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(26.dp)
.background(
if (darkMode) CONTROL_BACKGROUND_DARK else CONTROL_BACKGROUND_LIGHT,
RoundedCornerShape(3.dp),
)
.border(
1.dp,
if (darkMode) CONTROL_BORDER_DARK else CONTROL_BORDER_LIGHT,
RoundedCornerShape(3.dp),
)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
val strings = LocalStrings.current
Text(
text = label,
color = if (darkMode) CONTROL_TEXT_DARK else CONTROL_TEXT_LIGHT,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
}
/* The `--control-*` and `--attribution-*` values of the Leaflet stylesheet. */
private val CONTROL_BACKGROUND_LIGHT = Color(0xFFFFFFFF).copy(alpha = 0.96f)
private val CONTROL_BACKGROUND_DARK = Color(0xFF373E43).copy(alpha = 0.96f)
private val CONTROL_TEXT_LIGHT = Color(0xFF242424)
private val CONTROL_TEXT_DARK = Color(0xFFE2E6EA)
private val CONTROL_BORDER_LIGHT = Color(0xFFB7B7B7)
private val CONTROL_BORDER_DARK = Color(0xFF556068)
private val ATTRIBUTION_BACKGROUND_LIGHT = Color(0xFFFFFFFF).copy(alpha = 0.88f)
private val ATTRIBUTION_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.86f)
private val ATTRIBUTION_TEXT_LIGHT = Color(0xFF2D2D2D)
private val ATTRIBUTION_TEXT_DARK = Color(0xFFD2D8DD)
private val TILE_WARNING_TEXT = Color(0xFFFFE0B2)
private val TILE_WARNING_BACKGROUND = Color(0xFF5A2B20).copy(alpha = 0.92f)
@@ -0,0 +1,151 @@
package kst4contest.view.compose.map
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import kst4contest.view.map.PathAnalysisResult
/**
* The path and terrain analysis column of the station map window.
*
* It lives in its own file so that it can be composed in a test. The window around it
* needs a running application runtime and a chat controller; this column needs nothing
* but a result, which is what made the layout defect below invisible to the suite for
* as long as it existed.
*/
@Composable
internal fun PathAnalysisDetails(
result: PathAnalysisResult?,
darkMode: Boolean,
modifier: Modifier = Modifier,
) {
val strings = LocalStrings.current
Column(modifier) {
Text(
strings.pathTitle,
fontSize = TextUnit(12f, TextUnitType.Sp),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 2.dp),
)
HorizontalDivider(Modifier.padding(vertical = 2.dp))
if (result == null) {
Text(
strings.pathSelectStation,
fontSize = TextUnit(12f, TextUnitType.Sp),
)
return@Column
}
DetailRow(strings.pathFromLocator, result.fromLocator6().ifBlank { "-" })
DetailRow(strings.pathToLocator, result.toLocator6().ifBlank { "-" })
DetailRow(strings.pathDistanceQtf, "${result.distanceText()} / ${result.bearingText()}")
DetailRow(strings.pathEndpoints, result.endpointSummaryText())
DetailRow(strings.pathSource, "${result.analysisMode().ifBlank { "-" }} / ${result.profilePoints().size} samples")
DetailRow(strings.pathFrequency, result.analysisFrequencyText())
DetailRow(strings.pathRefraction, result.effectiveEarthRadiusText())
DetailRow(strings.pathRadioHorizon, result.radioHorizonText())
DetailRow(strings.pathTerrainHorizon, result.terrainHorizonText())
DetailRow(strings.pathFresnel, "${result.fresnelText()} / ${result.worstFresnelClearanceText()}")
DetailRow(strings.pathObstruction, result.obstructionText())
val severity = result.propagationSeverityLevel()
val textColor = if (darkMode) Color(0xFFF0F0F0) else Color(0xFF202020)
val backgroundColor = when (severity) {
1 -> if (darkMode) Color(0xFF1F4D2B) else Color(0xFFD8F3DC)
2 -> if (darkMode) Color(0xFF4A4420) else Color(0xFFFFF3BF)
3 -> if (darkMode) Color(0xFF4D3520) else Color(0xFFFFE0B2)
4 -> if (darkMode) Color(0xFF5A2B20) else Color(0xFFFFC9A9)
5 -> if (darkMode) Color(0xFF5A2020) else Color(0xFFFFCDD2)
else -> if (darkMode) Color(0xFF33383E) else Color(0xFFEEEEEE)
}
val borderColor = when (severity) {
1 -> Color(0xFF3AA655)
2 -> Color(0xFFD4A017)
3 -> Color(0xFFE69138)
4 -> Color(0xFFD96C2C)
5 -> Color(0xFFD63B3B)
else -> if (darkMode) Color(0xFF666F78) else Color(0xFFCCCCCC)
}
Row(Modifier.padding(vertical = 2.dp).fillMaxWidth()) {
Text(
strings.pathAssessment,
modifier = Modifier.weight(LABEL_WEIGHT),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = TextUnit(11f, TextUnitType.Sp),
lineHeight = TextUnit(13f, TextUnitType.Sp),
)
Box(modifier = Modifier.weight(VALUE_WEIGHT)) {
Box(
modifier = Modifier
.background(backgroundColor, RoundedCornerShape(4.dp))
.border(1.dp, borderColor, RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 3.dp),
) {
Text(result.propagationAssessmentText(), color = textColor, fontWeight = FontWeight.Bold)
}
}
}
DetailRow(strings.pathLinkBudget, result.linkBudgetText())
DetailRow(strings.pathRxPower, result.linkBudgetRxPowerText())
DetailRow(strings.pathCwHint, result.cwHintText())
DetailRow(strings.pathMechanisms, result.propagationMechanismsText(), valueColor = textColor, valueBold = true)
DetailRow(strings.pathLos, "${result.losText()} / worst ${result.worstClearanceText()}")
DetailRow(strings.pathStatus, result.statusText())
}
}
/**
* One label and its value.
*
* Both columns are weighted, and the assessment row above follows the same two weights
* rather than a fixed label width. A fixed width does not shrink: at a narrow pane it
* keeps its dp and the value beside it gets whatever is left, which was 35% of the pane
* at 200 px and nothing at all at 60 px.
*/
@Composable
private fun DetailRow(
label: String,
value: String,
valueColor: Color = Color.Unspecified,
valueBold: Boolean = false,
) {
val strings = LocalStrings.current
Row(Modifier.fillMaxWidth()) {
Text(
text = label,
modifier = Modifier.weight(LABEL_WEIGHT),
fontSize = TextUnit(11f, TextUnitType.Sp),
lineHeight = TextUnit(13f, TextUnitType.Sp),
)
Text(
text = value,
modifier = Modifier.weight(VALUE_WEIGHT),
color = valueColor,
fontWeight = if (valueBold) FontWeight.Bold else null,
fontSize = TextUnit(11f, TextUnitType.Sp),
lineHeight = TextUnit(13f, TextUnitType.Sp),
)
}
}
private const val LABEL_WEIGHT = 0.38f
private const val VALUE_WEIGHT = 0.62f
@@ -0,0 +1,585 @@
package kst4contest.view.compose.map
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
import androidx.compose.ui.ExperimentalComposeUiApi
import kst4contest.view.map.PathGeometryUtils
import kst4contest.view.map.PathHorizonSummary
import kst4contest.view.map.PathObstructionSummary
import kst4contest.view.map.PathProfilePoint
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun PathProfileChart(
modifier: Modifier = Modifier,
profilePoints: List<PathProfilePoint> = emptyList(),
totalDistanceKm: Double = Double.NaN,
darkMode: Boolean = false,
homeAntennaHeightMeters: Double = Double.NaN,
targetAntennaHeightMeters: Double = Double.NaN,
analysisFrequencyMHz: Double = Double.NaN,
effectiveEarthRadiusFactor: Double = PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
horizonSummary: PathHorizonSummary = PathHorizonSummary.empty(),
obstructionSummary: PathObstructionSummary = PathObstructionSummary.empty(),
onProfilePointHovered: ((PathProfilePoint?) -> Unit)? = null
) {
val strings = LocalStrings.current
val textMeasurer = rememberTextMeasurer()
val textStyle = TextStyle(fontSize = 12.sp)
Canvas(modifier = modifier
.onPointerEvent(PointerEventType.Move) { event ->
if (onProfilePointHovered != null && profilePoints.isNotEmpty()) {
val position = event.changes.first().position
val x = position.x.toDouble()
val left = 58.0
val right = 18.0
// For a proper hover, we need the total width of the Canvas, which we can get from the layout or event
// event.changes bounds? We can assume the size is roughly available if we store it.
// Alternatively, we use awaitPointerEventScope inside pointerInput which gives access to size.
}
}
.onPointerEvent(PointerEventType.Exit) {
onProfilePointHovered?.invoke(null)
}
) {
val width = size.width.toDouble()
val height = size.height.toDouble()
if (width <= 1.0 || height <= 1.0) {
return@Canvas
}
// --- Colors ---
val background = if (darkMode) Color(34, 39, 43) else Color(248, 248, 248)
val border = if (darkMode) Color(96, 106, 116) else Color(180, 180, 180)
val gridColor = if (darkMode) Color(95, 105, 115, (255 * 0.70).toInt()) else Color(185, 185, 185, (255 * 0.75).toInt())
val textColor = if (darkMode) Color(230, 235, 239) else Color(40, 40, 40)
val terrainFill = if (darkMode) Color(70, 165, 95, (255 * 0.35).toInt()) else Color(90, 180, 110, (255 * 0.35).toInt())
val terrainLine = if (darkMode) Color(95, 210, 120) else Color(45, 150, 70)
val losLine = if (darkMode) Color(255, 182, 80) else Color(220, 130, 20)
val fresnelFill = if (darkMode) Color(170, 135, 255, (255 * 0.16).toInt()) else Color(150, 105, 240, (255 * 0.16).toInt())
val fresnelLine = if (darkMode) Color(205, 155, 255) else Color(130, 75, 210)
val endpointMarker = if (darkMode) Color(245, 245, 245) else Color(45, 45, 45)
val horizonMarker = if (darkMode) Color(95, 190, 255) else Color(30, 120, 200)
val criticalMarker = if (darkMode) Color(255, 100, 100) else Color(210, 45, 45)
val terrainHorizonMarker = if (darkMode) Color(255, 220, 105) else Color(185, 125, 0)
val obstructionMarker = if (darkMode) Color(255, 135, 75) else Color(225, 85, 30)
drawRect(color = background)
drawRect(
color = border,
topLeft = Offset(0.5f, 0.5f),
size = Size((width - 1.0).toFloat(), (height - 1.0).toFloat()),
style = Stroke(1.0f)
)
val left = 58.0
val headerTop = 12.0
val headerHeight = 34.0
val endpointTextHeight = 18.0
val top = headerTop + headerHeight + endpointTextHeight
val right = 18.0
val bottom = 56.0
val plotX = left
val plotY = top
val plotWidth = max(10.0, width - left - right)
val plotHeight = max(10.0, height - top - bottom)
// Draw Grid
for (i in 0..4) {
val y = plotY + plotHeight * i / 4.0
drawLine(gridColor, Offset(plotX.toFloat(), y.toFloat()), Offset((plotX + plotWidth).toFloat(), y.toFloat()), strokeWidth = 1f)
}
for (i in 0..4) {
val x = plotX + plotWidth * i / 4.0
drawLine(gridColor, Offset(x.toFloat(), plotY.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f)
}
fun estimateTextWidth(text: String?): Double {
if (text.isNullOrBlank()) return 0.0
// Simple approximation like in JavaFX code
return text.length * 6.2
}
fun drawAxisLabels(minElevation: Double, maxElevation: Double) {
val style = textStyle.copy(color = textColor)
for (i in 0..4) {
val fraction = i / 4.0
val y = plotY + plotHeight - fraction * plotHeight
val value = minElevation + fraction * (maxElevation - minElevation)
safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", value), topLeft = Offset(8f, (y - 6).toFloat()), style = style)
}
val xFractions = doubleArrayOf(0.0, 0.25, 0.50, 0.75, 1.0)
for (xFraction in xFractions) {
val x = plotX + xFraction * plotWidth
val distance = if (totalDistanceKm.isFinite()) totalDistanceKm * xFraction else 0.0
safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", distance), topLeft = Offset((x - 8).toFloat(), (plotY + plotHeight + 16.0).toFloat()), style = style)
}
safeDrawText(textMeasurer, strings.chartHeightAxis, topLeft = Offset(plotX.toFloat(), (plotY - 14.0).toFloat()), style = style)
safeDrawText(textMeasurer, strings.chartDistanceAxis, topLeft = Offset((plotX + plotWidth / 2.0 - 30.0).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style)
if (analysisFrequencyMHz.isFinite() && analysisFrequencyMHz > 0.0) {
val freqText = strings.chartFrequency(String.format(Locale.US, "%.3f", analysisFrequencyMHz))
safeDrawText(textMeasurer, freqText, topLeft = Offset((plotX + plotWidth - estimateTextWidth(freqText)).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style)
}
}
if (profilePoints.isEmpty()) {
safeDrawText(textMeasurer, strings.chartNoSamples, topLeft = Offset(plotX.toFloat(), (plotY + plotHeight / 2.0).toFloat()), style = textStyle.copy(color = textColor))
drawAxisLabels(0.0, 1.0)
return@Canvas
}
fun terrainDisplayElevationMeters(point: PathProfilePoint?): Double {
if (point == null) return Double.NaN
if (point.curvatureAdjustedElevationMeters().isFinite()) {
return point.curvatureAdjustedElevationMeters()
}
return PathGeometryUtils.calculateCurvatureAdjustedElevationMeters(point, totalDistanceKm)
}
fun determineMinimumElevation(): Double {
var minElev = Double.POSITIVE_INFINITY
for (point in profilePoints) {
minElev = min(minElev, terrainDisplayElevationMeters(point))
if (point.lineOfSightHeightMeters().isFinite()) minElev = min(minElev, point.lineOfSightHeightMeters())
if (point.fresnelUpperHeightMeters().isFinite()) minElev = min(minElev, point.fresnelUpperHeightMeters())
if (point.fresnelLowerHeightMeters().isFinite()) minElev = min(minElev, point.fresnelLowerHeightMeters())
}
return if (minElev.isInfinite()) 0.0 else minElev
}
fun determineMaximumElevation(): Double {
var maxElev = Double.NEGATIVE_INFINITY
for (point in profilePoints) {
maxElev = max(maxElev, terrainDisplayElevationMeters(point))
if (point.lineOfSightHeightMeters().isFinite()) maxElev = max(maxElev, point.lineOfSightHeightMeters())
if (point.fresnelUpperHeightMeters().isFinite()) maxElev = max(maxElev, point.fresnelUpperHeightMeters())
if (point.fresnelLowerHeightMeters().isFinite()) maxElev = max(maxElev, point.fresnelLowerHeightMeters())
}
return if (maxElev.isInfinite()) 1.0 else maxElev
}
var minElevation = determineMinimumElevation()
var maxElevation = determineMaximumElevation()
var elevationRange = max(1.0, maxElevation - minElevation)
val paddingMeters = max(20.0, elevationRange * 0.08)
minElevation -= paddingMeters
maxElevation += paddingMeters
elevationRange = max(1.0, maxElevation - minElevation)
fun normalizeDistance(distanceKm: Double): Double {
if (!totalDistanceKm.isFinite() || totalDistanceKm <= 0.0) return 0.0
return max(0.0, min(1.0, distanceKm / totalDistanceKm))
}
fun normalizeElevation(elevationMeters: Double, minElev: Double, elevRange: Double): Double {
return max(0.0, min(1.0, (elevationMeters - minElev) / elevRange))
}
fun hasEnrichedLosGeometry(): Boolean {
return profilePoints.any { it.lineOfSightHeightMeters().isFinite() }
}
fun hasEnrichedFresnelGeometry(): Boolean {
return profilePoints.any { it.fresnelUpperHeightMeters().isFinite() || it.fresnelLowerHeightMeters().isFinite() }
}
// Draw terrain fill
if (profilePoints.size >= 2) {
val path = Path()
path.moveTo((plotX + normalizeDistance(profilePoints.first().distanceKm()) * plotWidth).toFloat(), (plotY + plotHeight).toFloat())
for (point in profilePoints) {
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
path.lineTo(x.toFloat(), y.toFloat())
}
path.lineTo((plotX + normalizeDistance(profilePoints.last().distanceKm()) * plotWidth).toFloat(), (plotY + plotHeight).toFloat())
path.close()
drawPath(path, color = terrainFill, style = Fill)
}
// Draw Fresnel fill
if (hasEnrichedFresnelGeometry() && profilePoints.size >= 2) {
val allFresnelFinite = profilePoints.all { it.fresnelUpperHeightMeters().isFinite() && it.fresnelLowerHeightMeters().isFinite() }
if (allFresnelFinite) {
val path = Path()
path.moveTo((plotX + normalizeDistance(profilePoints.first().distanceKm()) * plotWidth).toFloat(),
(plotY + plotHeight - normalizeElevation(profilePoints.first().fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight).toFloat())
for (point in profilePoints) {
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(point.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
path.lineTo(x.toFloat(), y.toFloat())
}
for (i in profilePoints.indices.reversed()) {
val point = profilePoints[i]
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(point.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
path.lineTo(x.toFloat(), y.toFloat())
}
path.close()
drawPath(path, color = fresnelFill, style = Fill)
}
}
// Draw terrain line
for (i in 1 until profilePoints.size) {
val prev = profilePoints[i - 1]
val curr = profilePoints[i]
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
val y1 = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(prev), minElevation, elevationRange) * plotHeight
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
val y2 = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(curr), minElevation, elevationRange) * plotHeight
drawLine(terrainLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 2f)
}
// Draw Fresnel hull
if (hasEnrichedFresnelGeometry()) {
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f))
for (i in 1 until profilePoints.size) {
val prev = profilePoints[i - 1]
val curr = profilePoints[i]
if (prev.fresnelUpperHeightMeters().isFinite() && curr.fresnelUpperHeightMeters().isFinite()) {
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
val y1 = plotY + plotHeight - normalizeElevation(prev.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
val y2 = plotY + plotHeight - normalizeElevation(curr.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
drawLine(fresnelLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1f, pathEffect = dashEffect)
}
if (prev.fresnelLowerHeightMeters().isFinite() && curr.fresnelLowerHeightMeters().isFinite()) {
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
val y1 = plotY + plotHeight - normalizeElevation(prev.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
val y2 = plotY + plotHeight - normalizeElevation(curr.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
drawLine(fresnelLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1f, pathEffect = dashEffect)
}
}
}
// Draw LOS line
if (hasEnrichedLosGeometry()) {
for (i in 1 until profilePoints.size) {
val prev = profilePoints[i - 1]
val curr = profilePoints[i]
if (prev.lineOfSightHeightMeters().isFinite() && curr.lineOfSightHeightMeters().isFinite()) {
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
val y1 = plotY + plotHeight - normalizeElevation(prev.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
val y2 = plotY + plotHeight - normalizeElevation(curr.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight
drawLine(losLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1.5f)
}
}
} else if (profilePoints.size >= 2 && homeAntennaHeightMeters.isFinite() && targetAntennaHeightMeters.isFinite()) {
val startAntennaMeters = terrainDisplayElevationMeters(profilePoints.first()) + homeAntennaHeightMeters
val endAntennaMeters = terrainDisplayElevationMeters(profilePoints.last()) + targetAntennaHeightMeters
val x1 = plotX
val y1 = plotY + plotHeight - normalizeElevation(startAntennaMeters, minElevation, elevationRange) * plotHeight
val x2 = plotX + plotWidth
val y2 = plotY + plotHeight - normalizeElevation(endAntennaMeters, minElevation, elevationRange) * plotHeight
drawLine(losLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1.5f)
}
fun buildEndpointLabel(name: String, groundMetersAsl: Double, antennaHeightMetersAgl: Double): String {
val hasGround = groundMetersAsl.isFinite()
val hasAntenna = antennaHeightMetersAgl.isFinite()
if (hasGround && hasAntenna) return String.format(Locale.US, "%s: %.0f m ASL + %.0f m AGL", name, groundMetersAsl, antennaHeightMetersAgl)
if (hasGround) return String.format(Locale.US, "%s: %.0f m ASL", name, groundMetersAsl)
if (hasAntenna) return String.format(Locale.US, "%s: +%.0f m AGL", name, antennaHeightMetersAgl)
return "$name: -"
}
// Endpoint markers
if (profilePoints.size >= 2) {
val first = profilePoints.first()
val last = profilePoints.last()
val firstGround = terrainDisplayElevationMeters(first)
val lastGround = terrainDisplayElevationMeters(last)
val firstAntenna = if (first.lineOfSightHeightMeters().isFinite()) first.lineOfSightHeightMeters() else firstGround + homeAntennaHeightMeters
val lastAntenna = if (last.lineOfSightHeightMeters().isFinite()) last.lineOfSightHeightMeters() else lastGround + targetAntennaHeightMeters
val x1 = plotX
val y1Ground = plotY + plotHeight - normalizeElevation(firstGround, minElevation, elevationRange) * plotHeight
val y1Antenna = plotY + plotHeight - normalizeElevation(firstAntenna, minElevation, elevationRange) * plotHeight
val x2 = plotX + plotWidth
val y2Ground = plotY + plotHeight - normalizeElevation(lastGround, minElevation, elevationRange) * plotHeight
val y2Antenna = plotY + plotHeight - normalizeElevation(lastAntenna, minElevation, elevationRange) * plotHeight
drawLine(endpointMarker, Offset(x1.toFloat(), y1Ground.toFloat()), Offset(x1.toFloat(), y1Antenna.toFloat()), strokeWidth = 1.2f)
drawCircle(endpointMarker, radius = 3f, center = Offset(x1.toFloat(), y1Antenna.toFloat()))
drawLine(endpointMarker, Offset(x2.toFloat(), y2Ground.toFloat()), Offset(x2.toFloat(), y2Antenna.toFloat()), strokeWidth = 1.2f)
drawCircle(endpointMarker, radius = 3f, center = Offset(x2.toFloat(), y2Antenna.toFloat()))
val homeLabel = buildEndpointLabel(strings.chartHome, first.elevationMeters(), homeAntennaHeightMeters)
val dxLabel = buildEndpointLabel("DX", last.elevationMeters(), targetAntennaHeightMeters)
val headerLabelY = plotY - 20.0 // Adjusted for Compose text drawing top-left
safeDrawText(textMeasurer, homeLabel, topLeft = Offset((plotX + 4.0).toFloat(), headerLabelY.toFloat()), style = textStyle.copy(color = textColor))
val dxLabelWidth = estimateTextWidth(dxLabel)
safeDrawText(textMeasurer, dxLabel, topLeft = Offset(max(plotX + plotWidth - dxLabelWidth - 4.0, plotX + plotWidth * 0.45).toFloat(), headerLabelY.toFloat()), style = textStyle.copy(color = textColor))
}
// Find Point By Sample Index
fun findPointBySampleIndex(sampleIndex: Int): PathProfilePoint? {
if (sampleIndex < 0) return null
return profilePoints.find { it.sampleIndex() == sampleIndex }
}
// Radio Horizon Markers
if (totalDistanceKm.isFinite() && totalDistanceKm > 0.0) {
val homeHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm(homeAntennaHeightMeters, effectiveEarthRadiusFactor)
val targetHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm(targetAntennaHeightMeters, effectiveEarthRadiusFactor)
fun drawVerticalHorizonMarker(distanceKm: Double, label: String) {
val x = plotX + normalizeDistance(distanceKm) * plotWidth
drawLine(horizonMarker, Offset(x.toFloat(), plotY.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f)))
val fullLabel = String.format(Locale.US, "%s %.1f km", label, distanceKm)
val labelWidth = estimateTextWidth(fullLabel)
var labelX = x + 5.0
if (labelX + labelWidth > plotX + plotWidth) {
labelX = max(plotX, x - labelWidth - 5.0)
}
safeDrawText(textMeasurer, fullLabel, topLeft = Offset(labelX.toFloat(), (plotY + plotHeight - 18.0).toFloat()), style = textStyle.copy(color = textColor))
}
if (homeHorizonKm.isFinite() && homeHorizonKm > 0.0 && homeHorizonKm < totalDistanceKm) {
drawVerticalHorizonMarker(homeHorizonKm, strings.chartHomeRadioHorizon)
}
val targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm
if (targetMarkerDistanceKm.isFinite() && targetMarkerDistanceKm > 0.0 && targetMarkerDistanceKm < totalDistanceKm) {
drawVerticalHorizonMarker(targetMarkerDistanceKm, strings.chartDxRadioHorizon)
}
}
// Terrain Horizon Markers
if (horizonSummary != null && profilePoints.isNotEmpty()) {
fun drawTerrainHorizonMarker(sampleIndex: Int, label: String) {
val point = findPointBySampleIndex(sampleIndex) ?: return
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
val path = Path()
path.moveTo(x.toFloat(), (y - 8.0).toFloat())
path.lineTo((x - 5.0).toFloat(), (y + 2.0).toFloat())
path.lineTo((x + 5.0).toFloat(), (y + 2.0).toFloat())
path.close()
drawPath(path, color = terrainHorizonMarker, style = Fill)
drawLine(terrainHorizonMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 5f)))
val fullLabel = String.format(Locale.US, "%s %.1f km", label, point.distanceKm())
val labelWidth = estimateTextWidth(fullLabel)
var labelX = x + 7.0
if (labelX + labelWidth > plotX + plotWidth) {
labelX = max(plotX, x - labelWidth - 7.0)
}
var labelY = min(plotY + plotHeight - 22.0, y + 18.0)
labelY = max(plotY + 20.0, labelY)
safeDrawText(textMeasurer, fullLabel, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
}
if (horizonSummary.hasHomeTerrainHorizon()) {
drawTerrainHorizonMarker(horizonSummary.homeTerrainHorizonSampleIndex(), strings.chartHomeTerrainHorizon)
}
if (horizonSummary.hasTargetTerrainHorizon()) {
drawTerrainHorizonMarker(horizonSummary.targetTerrainHorizonSampleIndex(), strings.chartDxTerrainHorizon)
}
}
// Obstruction Marker
if (obstructionSummary != null && obstructionSummary.hasDominantLosObstruction()) {
val point = findPointBySampleIndex(obstructionSummary.dominantObstructionSampleIndex())
if (point != null) {
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
val path = Path()
path.moveTo(x.toFloat(), (y - 7.0).toFloat())
path.lineTo((x - 5.0).toFloat(), y.toFloat())
path.lineTo(x.toFloat(), (y + 7.0).toFloat())
path.lineTo((x + 5.0).toFloat(), y.toFloat())
path.close()
drawPath(path, color = obstructionMarker, style = Fill)
drawLine(obstructionMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 4f)))
val label = strings.chartDiffractionCandidate(
String.format(Locale.US, "%.1f", obstructionSummary.dominantObstructionPathDistanceKm()),
String.format(Locale.US, "%.1f", obstructionSummary.estimatedKnifeEdgeLossDb()),
)
val labelWidth = estimateTextWidth(label)
var labelX = x + 8.0
if (labelX + labelWidth > plotX + plotWidth) {
labelX = max(plotX, x - labelWidth - 8.0)
}
var labelY = min(plotY + plotHeight - 34.0, y + 30.0)
labelY = max(plotY + 34.0, labelY)
safeDrawText(textMeasurer, label, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
}
}
// Critical Marker
fun findCriticalPoint(): PathProfilePoint? {
var worstFresnelPoint: PathProfilePoint? = null
var worstFresnelIntrusion = 0.0
var worstLosPoint: PathProfilePoint? = null
var minimumLosClearance = Double.POSITIVE_INFINITY
for (point in profilePoints) {
if (point.fresnelIntrusionMeters().isFinite() && point.fresnelIntrusionMeters() > worstFresnelIntrusion) {
worstFresnelIntrusion = point.fresnelIntrusionMeters()
worstFresnelPoint = point
}
if (point.lineOfSightClearanceMeters().isFinite() && point.lineOfSightClearanceMeters() < minimumLosClearance) {
minimumLosClearance = point.lineOfSightClearanceMeters()
worstLosPoint = point
}
}
if (worstFresnelPoint != null) return worstFresnelPoint
if (worstLosPoint != null && worstLosPoint.isLineOfSightBlocked) return worstLosPoint
return null
}
val criticalPoint = findCriticalPoint()
if (criticalPoint != null) {
val x = plotX + normalizeDistance(criticalPoint.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(criticalPoint), minElevation, elevationRange) * plotHeight
drawCircle(criticalMarker, radius = 4f, center = Offset(x.toFloat(), y.toFloat()))
drawLine(criticalMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f)
val label = if (criticalPoint.hasFresnelIntrusion()) {
strings.chartCriticalFresnel(
String.format(Locale.US, "%.1f", criticalPoint.fresnelIntrusionMeters()),
String.format(Locale.US, "%.1f", criticalPoint.distanceKm()),
)
} else {
strings.chartCriticalLos(
String.format(Locale.US, "%.1f", criticalPoint.lineOfSightClearanceMeters()),
String.format(Locale.US, "%.1f", criticalPoint.distanceKm()),
)
}
val labelWidth = estimateTextWidth(label)
var labelX = x + 8.0
if (labelX + labelWidth > plotX + plotWidth) {
labelX = max(plotX, x - labelWidth - 8.0)
}
var labelY = max(plotY + 22.0, y - 10.0)
if (labelY < plotY + 18.0) {
labelY = plotY + 18.0
}
safeDrawText(textMeasurer, label, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
}
// Draw axis labels
drawAxisLabels(minElevation, maxElevation)
// Draw legend
var legX = plotX
val legY = headerTop + 12.0
val textY = legY - 10.0 // adjust for compose text drawing top-left vs baseline
safeDrawText(textMeasurer, strings.chartLegend, topLeft = Offset(legX.toFloat(), textY.toFloat()), style = textStyle.copy(color = textColor))
legX += 48.0
fun drawHorizontalLegendLineItem(lineColor: Color, dashed: Boolean, lx: Double, ly: Double, text: String): Double {
val pe = if (dashed) PathEffect.dashPathEffect(floatArrayOf(6f, 4f)) else null
drawLine(lineColor, Offset(lx.toFloat(), (ly - 4.0).toFloat()), Offset((lx + 14.0).toFloat(), (ly - 4.0).toFloat()), strokeWidth = 1.6f, pathEffect = pe)
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 18.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
return lx + 18.0 + estimateTextWidth(text) + 18.0
}
fun drawHorizontalLegendTriangleItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
val path = Path()
path.moveTo((lx + 4.0).toFloat(), (ly - 10.0).toFloat())
path.lineTo(lx.toFloat(), (ly - 2.0).toFloat())
path.lineTo((lx + 8.0).toFloat(), (ly - 2.0).toFloat())
path.close()
drawPath(path, color = markerColor, style = Fill)
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
return lx + 14.0 + estimateTextWidth(text) + 18.0
}
fun drawHorizontalLegendDiamondItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
val cx = lx + 4.0
val cy = ly - 6.0
val path = Path()
path.moveTo(cx.toFloat(), (cy - 4.0).toFloat())
path.lineTo((cx - 4.0).toFloat(), cy.toFloat())
path.lineTo(cx.toFloat(), (cy + 4.0).toFloat())
path.lineTo((cx + 4.0).toFloat(), cy.toFloat())
path.close()
drawPath(path, color = markerColor, style = Fill)
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
return lx + 14.0 + estimateTextWidth(text) + 18.0
}
fun drawHorizontalLegendDotItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
drawCircle(markerColor, radius = 4f, center = Offset((lx + 4.0).toFloat(), (ly - 4.0).toFloat()))
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
return lx + 14.0 + estimateTextWidth(text) + 18.0
}
legX = drawHorizontalLegendLineItem(terrainLine, false, legX, legY, strings.chartTerrain)
legX = drawHorizontalLegendLineItem(losLine, false, legX, legY, strings.chartLos)
legX = drawHorizontalLegendLineItem(fresnelLine, true, legX, legY, strings.chartFresnel)
legX = drawHorizontalLegendLineItem(horizonMarker, true, legX, legY, strings.chartRadioHorizonShort)
legX = drawHorizontalLegendTriangleItem(terrainHorizonMarker, legX, legY, strings.chartTerrainHorizonShort)
legX = drawHorizontalLegendDiamondItem(obstructionMarker, legX, legY, strings.chartDiffraction)
drawHorizontalLegendDotItem(criticalMarker, legX, legY, strings.chartCritical)
}
}
fun androidx.compose.ui.graphics.drawscope.DrawScope.safeDrawText(
textMeasurer: androidx.compose.ui.text.TextMeasurer,
text: String,
topLeft: androidx.compose.ui.geometry.Offset,
style: androidx.compose.ui.text.TextStyle
) {
if (topLeft.x.isNaN() || topLeft.y.isNaN() || topLeft.x.isInfinite() || topLeft.y.isInfinite()) return
this.drawText(
textMeasurer = textMeasurer,
text = text,
topLeft = topLeft,
style = style,
size = androidx.compose.ui.geometry.Size(10000f, 10000f)
)
}
Loaded 100 of 600 files, more files were not shown because too many files have changed in this diff. Show more