Compare commits

...
Author SHA1 Message Date
Rsclub2_2andClaude Opus 5.5 2aeebac38b ci: fail hanging tests by name instead of holding the job
Nightly #302 and the PR check for #147 hung in :app-desktop:test for
over an hour, and the log named only the task. Thirteen reruns of the
same commit passed, so the hang is rare and still unidentified.

Every test now gets a two-minute JUnit timeout in a separate thread, so
even an uninterruptible hang fails with the test's name and the stack
where it stood. The test jobs are capped at 20 minutes for a hang
outside any test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:15:02 +02:00
Praktimarc a4e10300e7 Fix Compose UI regressions in KST4Contest 1.50 (#147)
* test: make stylesheet tests platform-independent

* fix: address Compose UI regressions

* fix: complete Compose UI workflow corrections

* feat: improve manual QTF heading control
2026-10-24 22:10:36 +02:00
Rsclub2_2andClaude 4642d658a3 Make the monitor window's divider draggable and remembered (#144) (#145)
The cluster and QSO monitor split its two tables at half the height, fixed:
the Compose port had left the JavaFX SplitPane out until a splitter existed.
SplitterPane exists now, so the monitor uses it and reads and writes the same
profile value the SplitPane did (GUIpnl_directedMSGWin_dividerpositionDefault,
default 0.8), so a profile gets its old position back. Each heading now sits
in the pane of its table, and the "DX cluster" heading is translated
(monitor.dxCluster).

Fixes #144.


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

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


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

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

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

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

* Lowercase the owner in the generated Flatpak Pages URL

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

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

* Build the Flatpak against org.freedesktop.Platform 25.08

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

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

---------

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


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

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

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


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

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

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

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

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

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

* Plan: SO2R implementation (#46)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Document SO2R (#46)

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

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

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

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

* Remove the executed SO2R plan (#46)

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

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

---------

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

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

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

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

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

Review fixes for the window placement:

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* Document the minimum QRB filter (#21)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

* Ask about unapplied settings when the window is closed

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

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

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

* Document that settings take effect with Apply

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

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

* Repair stored settings once before the settings window is built

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* Drop changelog entries for defects that never shipped

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

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

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

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

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

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

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

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

* Judge a committed field against the live stored value

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

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

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

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

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

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

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

---------

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

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

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

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

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

* Make DXClusterThreadPooledServer.serverSocket volatile

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

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

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

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

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

* Check the test user.home in a finalizer task

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

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

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

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

* Keep generateStrings configuration-cache safe

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

MonitorContent becomes internal so its body can be drawn headless.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 10:12:46 +02:00
270 changed files with 24144 additions and 3117 deletions

No files matched your search

+15 -12
View File
@@ -32,13 +32,16 @@ jobs:
test: test:
name: Test (Java 21, headless) name: Test (Java 21, headless)
runs-on: ubuntu-latest runs-on: ubuntu-latest
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
timeout-minutes: 20
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -138,7 +141,7 @@ jobs:
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha" Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -188,7 +191,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -262,7 +265,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -308,7 +311,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -356,7 +359,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -468,7 +471,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -481,7 +484,7 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 flatpak --user install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
- name: Build app-image - name: Build app-image
run: | run: |
@@ -498,7 +501,7 @@ jobs:
cat > target/de.x08.KST4Contest.yml << 'EOF' cat > target/de.x08.KST4Contest.yml << 'EOF'
app-id: de.x08.KST4Contest app-id: de.x08.KST4Contest
runtime: org.freedesktop.Platform runtime: org.freedesktop.Platform
runtime-version: "24.08" runtime-version: "25.08"
sdk: org.freedesktop.Sdk sdk: org.freedesktop.Sdk
command: KST4Contest command: KST4Contest
finish-args: finish-args:
@@ -549,7 +552,7 @@ jobs:
- name: Create flatpakref (nightly) - name: Create flatpakref (nightly)
run: | run: |
REPO_NAME="${GITHUB_REPOSITORY#*/}" REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/" PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0) GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > "dist/de.x08.KST4Contest.nightly.flatpakref" << EOF cat > "dist/de.x08.KST4Contest.nightly.flatpakref" << EOF
[Flatpak Ref] [Flatpak Ref]
@@ -616,7 +619,7 @@ jobs:
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" . flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors # Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
REPO_NAME="${GITHUB_REPOSITORY#*/}" REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/" PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0) GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > kst4contest.flatpakrepo << EOF cat > kst4contest.flatpakrepo << EOF
[Flatpak Repo] [Flatpak Repo]
@@ -659,7 +662,7 @@ jobs:
echo "ARCH=$ARCH" >> "$GITHUB_ENV" echo "ARCH=$ARCH" >> "$GITHUB_ENV"
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
+5 -2
View File
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -155,13 +155,16 @@ jobs:
test: test:
name: Test (Java 21, headless) name: Test (Java 21, headless)
runs-on: ubuntu-latest runs-on: ubuntu-latest
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
timeout-minutes: 20
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
+34 -13
View File
@@ -24,6 +24,9 @@ jobs:
test: test:
name: Test (Java 21, headless) name: Test (Java 21, headless)
runs-on: ubuntu-latest runs-on: ubuntu-latest
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
timeout-minutes: 20
steps: steps:
- name: Checkout - name: Checkout
@@ -57,7 +60,7 @@ jobs:
echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION" echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION"
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -148,7 +151,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -217,7 +220,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -284,7 +287,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -355,7 +358,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -426,7 +429,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -529,7 +532,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -542,7 +545,7 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 flatpak --user install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
- name: Build app-image - name: Build app-image
run: | run: |
@@ -559,7 +562,7 @@ jobs:
cat > target/de.x08.KST4Contest.yml << 'EOF' cat > target/de.x08.KST4Contest.yml << 'EOF'
app-id: de.x08.KST4Contest app-id: de.x08.KST4Contest
runtime: org.freedesktop.Platform runtime: org.freedesktop.Platform
runtime-version: "24.08" runtime-version: "25.08"
sdk: org.freedesktop.Sdk sdk: org.freedesktop.Sdk
command: KST4Contest command: KST4Contest
finish-args: finish-args:
@@ -620,7 +623,7 @@ jobs:
- name: Create flatpakref - name: Create flatpakref
run: | run: |
REPO_NAME="${GITHUB_REPOSITORY#*/}" REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/" PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0) GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > "dist/${FLATPAKREF_NAME}" << EOF cat > "dist/${FLATPAKREF_NAME}" << EOF
[Flatpak Ref] [Flatpak Ref]
@@ -659,7 +662,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Java 21 - name: Set up Java 21
uses: actions/setup-java@v6.0.1 uses: actions/setup-java@v6.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -827,7 +830,7 @@ jobs:
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" . flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors # Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
REPO_NAME="${GITHUB_REPOSITORY#*/}" REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/" PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0) GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > kst4contest.flatpakrepo << EOF cat > kst4contest.flatpakrepo << EOF
[Flatpak Repo] [Flatpak Repo]
@@ -867,7 +870,9 @@ jobs:
fetch-depth: 0 fetch-depth: 0
# GitHub's generated release notes list merged pull requests only. Commits # GitHub's generated release notes list merged pull requests only. Commits
# pushed directly to main are added here as their own section: # pushed directly to main are added here as their own section, and the
# message of an annotated tag opens the body:
# <tag message> (annotated tags only; signature dropped)
# ## What's Changed # ## What's Changed
# ### Commits on main (first-parent commits, no merges, no bots) # ### Commits on main (first-parent commits, no merges, no bots)
# ### Merged PRs (GitHub's generated PR list) # ### Merged PRs (GitHub's generated PR list)
@@ -892,6 +897,19 @@ jobs:
RANGE="$TAG" RANGE="$TAG"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}" [ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}"
# actions/checkout can leave the triggering tag as a lightweight ref, so
# fetch the tag object itself before reading its message.
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
# A lightweight tag has no message of its own (its "message" would be the
# commit's); only an annotated tag contributes. contents:subject and
# contents:body leave out an SSH or GPG signature.
: > tag-message.md
if [ "$(git cat-file -t "refs/tags/${TAG}")" = "tag" ]; then
git for-each-ref "refs/tags/${TAG}" \
--format='%(contents:subject)%0a%0a%(contents:body)' > tag-message.md
fi
echo "Tag message:"; cat tag-message.md
# --first-parent keeps the commits made directly on main; commits inside # --first-parent keeps the commits made directly on main; commits inside
# pull requests are covered by the PR list. Squash-merged PRs end in # pull requests are covered by the PR list. Squash-merged PRs end in
# "(#123)" and are listed by GitHub as well. # "(#123)" and are listed by GitHub as well.
@@ -913,6 +931,7 @@ jobs:
import re import re
commits = open("direct-commits.md", encoding="utf-8").read().strip() commits = open("direct-commits.md", encoding="utf-8").read().strip()
tag_message = open("tag-message.md", encoding="utf-8").read().strip()
generated = open("generated-notes.md", encoding="utf-8").read() generated = open("generated-notes.md", encoding="utf-8").read()
# GitHub's body: "## What's Changed" with the PR list, optionally # GitHub's body: "## What's Changed" with the PR list, optionally
@@ -932,6 +951,8 @@ jobs:
rest.append(line) rest.append(line)
parts = [] parts = []
if tag_message:
parts.append(tag_message)
if commits or prs: if commits or prs:
parts.append("## What's Changed") parts.append("## What's Changed")
if commits: if commits:
@@ -30,7 +30,7 @@ jobs:
import os, urllib.request import os, urllib.request
REPO = 'praktimarc/kst4contest' REPO = 'HamRadioOnline/kst4contest'
token = os.environ['GH_TOKEN'] token = os.environ['GH_TOKEN']
def gh_get(path): def gh_get(path):
+7 -7
View File
@@ -9,8 +9,8 @@ The application is developed by Marc Fröhlich (DO5AMF) and since 2026 Philipp W
- [Project website](https://kst4contest.hamradioonline.de/) - [Project website](https://kst4contest.hamradioonline.de/)
- [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/) - [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/)
- [Online manual](https://kst4contest.hamradioonline.de/manual/) - [Online manual](https://kst4contest.hamradioonline.de/manual/)
- [GitHub wiki](https://github.com/praktimarc/kst4contest/wiki) - [GitHub wiki](https://github.com/HamRadioOnline/kst4contest/wiki)
- [Issues and bug reports](https://github.com/praktimarc/kst4contest/issues) - [Issues and bug reports](https://github.com/HamRadioOnline/kst4contest/issues)
- [Development roadmap](https://kst4contest.hamradioonline.de/roadmap/) - [Development roadmap](https://kst4contest.hamradioonline.de/roadmap/)
## What KST4Contest does ## What KST4Contest does
@@ -47,8 +47,8 @@ Use the central download page to select the appropriate build:
The documentation is available in German and English: The documentation is available in German and English:
- [German manual](https://github.com/praktimarc/kst4contest/wiki/de-Home) - [German manual](https://github.com/HamRadioOnline/kst4contest/wiki/de-Home)
- [English manual](https://github.com/praktimarc/kst4contest/wiki/en-Home) - [English manual](https://github.com/HamRadioOnline/kst4contest/wiki/en-Home)
- [Online manual](https://kst4contest.hamradioonline.de/manual/) - [Online manual](https://kst4contest.hamradioonline.de/manual/)
The Markdown sources used for the wiki and the generated PDF manuals are stored in [`github_docs`](github_docs/). The Markdown sources used for the wiki and the generated PDF manuals are stored in [`github_docs`](github_docs/).
@@ -83,13 +83,13 @@ Windows:
### Documentation ### Documentation
[![Publish wiki](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml) [![Publish wiki](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml)
[![Docs PDF](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml) [![Docs PDF](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml)
### Builds ### Builds
[![Nightly Runtime Artifacts](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml) [![Nightly Runtime Artifacts](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml)
## License ## License
+5 -2
View File
@@ -66,11 +66,14 @@ val generateStrings by tasks.registering(JavaExec::class) {
mainClass.set("kst4contest.i18n.GeneratorMain") mainClass.set("kst4contest.i18n.GeneratorMain")
val input = layout.projectDirectory.dir("src/main/i18n") val input = layout.projectDirectory.dir("src/main/i18n")
// A local copy: the provider below must not capture the script-level val, which
// drags the build script object into the configuration cache and fails to serialize.
val output = generatedI18nDirectory
inputs.dir(input) inputs.dir(input)
outputs.dir(generatedI18nDirectory) outputs.dir(output)
argumentProviders.add(CommandLineArgumentProvider { argumentProviders.add(CommandLineArgumentProvider {
listOf(input.asFile.absolutePath, generatedI18nDirectory.get().asFile.absolutePath) listOf(input.asFile.absolutePath, output.get().asFile.absolutePath)
}) })
} }
+111 -7
View File
@@ -1,5 +1,5 @@
# German. Held complete by a test, because this is a shipped language. # 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.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen airscout.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.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.clientIdentifier=KST4Contest-Clientkennung [KST]:
@@ -26,6 +26,7 @@ gui.language=Sprache
gui.language.system=Systemsprache gui.language.system=Systemsprache
gui.startupDesign=Design beim Start gui.startupDesign=Design beim Start
gui.startupFilters=Beim Start aktive Nachrichtenfilter gui.startupFilters=Beim Start aktive Nachrichtenfilter
gui.windows=Fenster
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe) logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
logSynch.fileLabel=Zu überwachende Logdatei: logSynch.fileLabel=Zu überwachende Logdatei:
@@ -88,6 +89,13 @@ notification.soundsSection=Töne
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
notification.spotterCallsign=Spotter-Rufzeichen notification.spotterCallsign=Spotter-Rufzeichen
settings.applyAndClose=Übernehmen/Schließen settings.applyAndClose=Übernehmen/Schließen
settings.applyFailed=Einige Einstellungen konnten nicht übernommen werden oder haben die laufende Sitzung nicht erreicht.\n\n(Technische Details: {0})
settings.pendingApply=Übernehmen
settings.pendingBack=Zurück zu den Einstellungen
settings.pendingDiscard=Verwerfen
settings.pendingQuestion=Einige Einstellungen wurden geändert, aber noch nicht übernommen. Zuerst übernehmen?
settings.pendingTitle=Nicht übernommene Änderungen
settings.invalidEntry={0}: „{1}" ist keine gültige Eingabe.
settings.save=Einstellungen speichern settings.save=Einstellungen speichern
shortcuts.addButton=Kurztaste hinzufügen shortcuts.addButton=Kurztaste hinzufügen
shortcuts.addSnippet=Neuen Textbaustein hinzufügen shortcuts.addSnippet=Neuen Textbaustein hinzufügen
@@ -99,6 +107,7 @@ station.callsign=Rufzeichen
station.chatSection=Chat-Kategorie station.chatSection=Chat-Kategorie
station.defaultsSection=Vorgaben station.defaultsSection=Vorgaben
station.demDirectory=DEM-Stammverzeichnis station.demDirectory=DEM-Stammverzeichnis
station.invalidOwnHeightAsl=„{0}" ist keine gültige Höhe. Meter von -500 bis 9000 eingeben oder das Feld leer lassen, dann gilt das Höhenmodell.
station.locator=Locator station.locator=Locator
station.mainCategory=Hauptkategorie station.mainCategory=Hauptkategorie
station.maxQrb=Maximaler QRB station.maxQrb=Maximaler QRB
@@ -106,30 +115,44 @@ station.nameMain=Name, Hauptkategorie
station.nameSecond=Name, zweite Kategorie station.nameSecond=Name, zweite Kategorie
station.ownAntennaGain=Eigener Antennengewinn, dBi station.ownAntennaGain=Eigener Antennengewinn, dBi
station.ownAntennaHeight=Eigene Antennenhöhe, Meter station.ownAntennaHeight=Eigene Antennenhöhe, Meter
station.ownHeightAsl=Eigene Standorthöhe über NN, Meter
station.ownSection=Eigene Station station.ownSection=Eigene Station
station.ownTxPower=Eigene Sendeleistung, Watt station.ownTxPower=Eigene Sendeleistung, Watt
station.password=Passwort station.password=Passwort
station.pathSection=Vorgaben für die Streckenanalyse station.pathSection=Vorgaben für die Streckenanalyse
station.qtf=QTF station.qtf=QTF
station.rotatorEnabled=Rotorabgleich aktiv station.rotatorEnabled=Rotorabgleich aktiv
station.manualAntennaHeadingSection=Manuelle Antennenrichtung
station.manualAntennaHeadingRotatorHint=Nicht verfügbar, solange PSTRotator 1 aktiviert ist
station.useQtfFilterDirectionAsAntennaHeading=QTF-Filterrichtung als eigene Antennenrichtung übernehmen
station.rotatorHost=Rotor-Host station.rotatorHost=Rotor-Host
station.rotatorPort=Rotor-Port station.rotatorPort=Rotor-Port
station.rotatorSection=PSTRotator station.rotatorSection=PSTRotator
station.rotator2Enabled=Rotor-2-Abgleich aktiv
station.rotator2Host=Rotor-2-Host
station.rotator2Port=Rotor-2-Port (antwortet auf Port + 1)
station.rotator2PortClash=Port {0} ist derselbe oder ein benachbarter Port wie beim anderen PSTRotator
station.rotator2Section=PSTRotator 2 (SO2R)
station.secondCategory=Zweite Kategorie station.secondCategory=Zweite Kategorie
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
station.serverHost=Servername station.serverHost=Servername
station.serverPort=Serverport station.serverPort=Serverport
station.serverSection=ON4KST-Server station.serverSection=ON4KST-Server
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfelds
station.targetTxPower=Sendeleistung der Gegenstation, Watt 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.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.hintSection=Was eine aktivierte Quelle bewirkt
trxSynch.so2rEnabled=SO2R: zwei Geräte getrennt führen (Gerät 2 schreibt SECONDQRG)
trxSynch.so2rHint=N1MM, DXLog und UCXLog werden über RadioNr zugeordnet; dafür ist nichts einzustellen. Win-Test braucht je Gerät einen Stationsnamen.
trxSynch.so2rSection=SO2R
trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz) 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.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
trxSynch.winTestSection=Win-Test-TRX-Abgleich trxSynch.winTestSection=Win-Test-TRX-Abgleich
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen) trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
trxSynch.winTestStationRadio2=Win-Test-Stationsname von Gerät 2 (z. B. STN2)
worked.empty=Die Datenbank enthält keine Rufzeichen. worked.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.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.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
@@ -228,6 +251,7 @@ column.lastQrgTx=Letzte QRG TX
filter.find=Suchen... filter.find=Suchen...
filter.reset=Filter zurücksetzen filter.reset=Filter zurücksetzen
filter.showOnlyQrb=Nur QRB [km] <= filter.showOnlyQrb=Nur QRB [km] <=
filter.showOnlyQrbAbove=Nur QRB [km] >
filter.showOnlyQtf=Nur QTF: filter.showOnlyQtf=Nur QTF:
filter.hideWorked=Gearbeitete ausblenden: filter.hideWorked=Gearbeitete ausblenden:
filter.inactiveStations=Inaktive Stationen filter.inactiveStations=Inaktive Stationen
@@ -235,28 +259,44 @@ filter.onlyNewGrids=Nur neue Felder
filter.gridColour=Feldfarbe filter.gridColour=Feldfarbe
filter.newBands=Neue Bänder filter.newBands=Neue Bänder
filter.tropo=Tropo >=0dB filter.tropo=Tropo >=0dB
filter.hide=Ausblenden
filter.show=Anzeigen
filter.reachability=Erreichbarkeit
filter.calcSelected=Auswahl berechnen
table.empty=Keine Einträge
selected.none=Keine Station gewählt. selected.none=Keine Station gewählt.
selected.priority=Priorität: selected.priority=Priorität:
selected.newJoined=Neu hinzugekommen:
selected.more=mehr selected.more=mehr
selected.showMessages=Nachrichten zeigen selected.showMessages=Nachrichten zeigen
selected.createSked=Sked anlegen selected.createSked=Sked anlegen
selected.sked=Sked selected.sked=Sked alert
selected.skedInMinutes=Sked in (Min.) selected.skedIn=Sked in
selected.skedMinutesAt=Min., auf
selected.skedModeLink=, Mode
selected.minutes=Min.
selected.skedFail=Sked fehlgeschlagen selected.skedFail=Sked fehlgeschlagen
selected.remindPmIn=Erinnerungs-PM in selected.remindPmIn=Erinnerungs-PM in
selected.reminderOffsets=Erinnerungsabstände
selected.resetFail=Zurücksetzen fehlgeschlagen selected.resetFail=Zurücksetzen fehlgeschlagen
selected.tagNotQrv=Als NOT QRV markieren selected.tagNotQrv={0} an folgenden Bändern als NOT QRV markieren:
selected.band=Band selected.band=Band
selected.mode=Mode selected.mode=Mode
selected.lookUpAndShow=Nachschlagen und zeigen selected.lookUpAndShow=Nachschlagen und zeigen
selected.lookupQrz=Auf qrz.com nachschlagen selected.lookupQrz=Auf qrz.com nachschlagen
selected.lookupQrzcq=Auf qrzcq.com nachschlagen selected.lookupQrzcq=Auf qrzcq.com nachschlagen
selected.showPathInAs=Strecke in AS zeigen selected.showPathInAs=Strecke in AS zeigen
selected.turnAntenna=Antenne 1 Richtung {0} drehen
selected.turnAntenna2=Antenne 2 Richtung {0} drehen
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.) selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
candidates.title=Stationen mit höchster Priorität candidates.title=Stationen mit höchster Priorität
candidates.none=Noch keine Kandidaten. candidates.none=Noch keine Kandidaten.
candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus. candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus.
timeline.inBeam1=Im Strahl 1
timeline.inBeam2=Im Strahl 2
timeline.priority1=Priorität 1
timeline.priority2=Priorität 2
timeline.skeds=Skeds
timeline.minuteSuffix=min
update.title=Aktualisierungsinformationen update.title=Aktualisierungsinformationen
update.available=Aktualisierung verfügbar! update.available=Aktualisierung verfügbar!
update.installedVersion=Installierte Version: update.installedVersion=Installierte Version:
@@ -271,7 +311,9 @@ update.versions=Versionen
map.title=Stationskarte map.title=Stationskarte
map.tab=Karte map.tab=Karte
map.resetView=Ansicht zurücksetzen map.resetView=Ansicht zurücksetzen
map.groupNearby=Nahe Stationen gruppieren map.groupNearby=Gruppierung
map.groupNearbyTooltip=Nahe beieinanderliegende Stationen auf der Karte gruppieren.
map.alwaysOnTop=Immer im Vordergrund
map.triggerClusterSpot=Cluster-Spot auslösen map.triggerClusterSpot=Cluster-Spot auslösen
map.showPathAnalysis=Streckenanalyse zeigen map.showPathAnalysis=Streckenanalyse zeigen
map.hidePathAnalysis=Streckenanalyse ausblenden map.hidePathAnalysis=Streckenanalyse ausblenden
@@ -322,6 +364,8 @@ title.connecting=Verbindung zu ON4KST wird aufgebaut…
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft… title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen… title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
title.disconnected=NICHT VERBUNDEN! title.disconnected=NICHT VERBUNDEN!
title.online=Verbunden mit: {0} als {1} ({2}) in {3} ({4} Benutzer online, {5} angezeigt), {6} Nachrichten insgesamt.
title.bothCategories={0} und {1}
status.noConnection=Keine ON4KST-Verbindung status.noConnection=Keine ON4KST-Verbindung
status.linkTooltip=ON4KST-Verbindung: {0}\n{1} status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
status.linkConnected=🟢 LINK: Verbunden status.linkConnected=🟢 LINK: Verbunden
@@ -361,10 +405,12 @@ tooltip.unknownGrid=unbekanntes Feld
tooltip.grid=Feld {0} tooltip.grid=Feld {0}
messages.public=Öffentliche Nachrichten messages.public=Öffentliche Nachrichten
messages.dxCluster=DXCluster-Nachrichten messages.dxCluster=DXCluster-Nachrichten
messages.qsoOfTheOther=QSO der Gegenstation messages.qsoOfTheOther=Fremd-QSOs
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
chat.qtf2Tooltip=Richtung von Antenne 2 (Rotor 2, SO2R); nur Anzeige
chat.secondQrgTooltipSo2r=QRG von Gerät 2 (aus RadioNr 2 bzw. Win-Test-Station 2)
list.moveDown=Auswahl nach unten list.moveDown=Auswahl nach unten
list.moveUp=Auswahl nach oben list.moveUp=Auswahl nach oben
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen. list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
@@ -378,3 +424,61 @@ contrast.textOnWindowSurface=Text auf der Fensterfläche
contrast.textInsideField=Text im Feld contrast.textInsideField=Text im Feld
contrast.accentOnWindowSurface=Akzent auf der Fensterfläche contrast.accentOnWindowSurface=Akzent auf der Fensterfläche
contrast.textOnMenuStrip=Text auf der Menüzeile contrast.textOnMenuStrip=Text auf der Menüzeile
gui.startInEveningDesign=Im Abenddesign starten
gui.filterPublic=Öffentliche Nachrichten
gui.filterPmToMe=Privatnachrichten an mich
gui.filterPmToOthers=Privatnachrichten an andere
gui.filterNothing=Kein Filter
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren
gui.rememberWindowPositions=Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden (nur auf demselben Bildschirm)
form.choose=Wählen...
form.notSelected=nicht gewählt
dialog.apply=Übernehmen
chat.clear=leeren
selected.showOnMap=🗺 Auf der Karte zeigen
selected.notQrvAll=alle
context.messageSnippets=Textbausteine
context.createSked=Sked erstellen…
context.tagNotQrv=Als NOT QRV markieren
context.markWorked=Als gearbeitet markieren
context.calculateReachability=Erreichbarkeit berechnen
context.chooseStation=Station auswählen
context.back=Zurück
context.category=Kategorie {0}
context.skedTitle=Sked für {0} erstellen
context.cancel=Abbrechen
column.category=Kategorie
column.sender=Absender
column.receiver=Empfänger
column.lastQrg=Letzte QRG
column.airplane=AP [Minuten / Pot%]
column.score=Wertung
monitor.title=Cluster & Fremd-QSOs
monitor.dxCluster=DX-Cluster
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
profiles.rootNotDeletable=Das Standardprofil verwendet die Dateien der Installation selbst und kann nicht gelöscht werden.
profiles.activeNotDeletable=Das gerade benutzte Profil kann nicht gelöscht werden. Zuerst zu einem anderen Profil wechseln.
profiles.deleteFailed=Das Profil konnte nicht gelöscht werden.
profiles.deletionFolder=Der folgende Ordner wird endgültig entfernt:\n{0}
profiles.deletionKeepsShared=Die gemeinsamen gearbeiteten Stationen der Station werden nicht angefasst.
profiles.deletionRemovesOwn=Die gearbeiteten Stationen dieses Profils werden ebenfalls gelöscht.
profiles.rootAlwaysShared=Das Standardprofil verwendet immer die gemeinsamen gearbeiteten Stationen der Station, weil diese Datenbank die der Installation selbst ist.
profiles.changeAfterSwitch=Die Änderung wirkt erst nach einem erneuten Wechsel zu diesem Profil.
profiles.alreadyActive=Dieses Profil ist bereits aktiv.
profiles.selectFirst=Zuerst ein Profil in der Tabelle wählen.
beacon.invalidMessage=Der Bakentext ist ungültig: {0}
beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minute eingeben.
notification.invalidPort=„{0}" ist kein gültiger TCP-Port. Einen Wert von 1 bis 65535 eingeben.
notification.invalidSpotter=„{0}" ist kein gültiges Spotter-Rufzeichen.
notification.invalidCallsign=Bitte ein gültiges Rufzeichen eingeben.
notification.alreadyMonitored=Dieses Grundrufzeichen steht bereits in der Überwachungsliste.
settings.windowTitle=Client-Einstellungen ändern
settings.noticeInfo=Info
settings.noticeProblem=Einstellungen
profiles.unknown=unbekannt
profiles.copyOf=Kopie von {0}
profiles.sharedDatabase=gemeinsame Stationsdatenbank
profiles.ownDatabase=eigene Datenbank
+111 -7
View File
@@ -8,7 +8,7 @@
# #
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file -- # 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. # 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.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
airscout.automaticFrequency=Select AirScout frequency automatically per station airscout.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.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.clientIdentifier=KST4Contest client identifier [KST]:
@@ -35,6 +35,7 @@ gui.language=Language
gui.language.system=System language gui.language.system=System language
gui.startupDesign=Startup design gui.startupDesign=Startup design
gui.startupFilters=Message filters active at startup gui.startupFilters=Message filters active at startup
gui.windows=Windows
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information) logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
logSynch.fileLabel=Log file to be monitored: logSynch.fileLabel=Log file to be monitored:
@@ -97,6 +98,13 @@ notification.soundsSection=Sounds
notification.speakCallsign=Speak the callsign on a received private message notification.speakCallsign=Speak the callsign on a received private message
notification.spotterCallsign=Spotter callsign notification.spotterCallsign=Spotter callsign
settings.applyAndClose=Apply/Close prefs settings.applyAndClose=Apply/Close prefs
settings.applyFailed=Some settings could not be applied, or their change did not reach the running session.\n\n(Technical details: {0})
settings.pendingApply=Apply
settings.pendingBack=Back to the settings
settings.pendingDiscard=Discard
settings.pendingQuestion=Some settings have been changed but not applied yet. Apply them first?
settings.pendingTitle=Changes not applied
settings.invalidEntry={0}: "{1}" is not a valid entry.
settings.save=Save settings settings.save=Save settings
shortcuts.addButton=Add shortcut shortcuts.addButton=Add shortcut
shortcuts.addSnippet=Add new snippet shortcuts.addSnippet=Add new snippet
@@ -108,6 +116,7 @@ station.callsign=Callsign
station.chatSection=Chat category station.chatSection=Chat category
station.defaultsSection=Defaults station.defaultsSection=Defaults
station.demDirectory=DEM root directory station.demDirectory=DEM root directory
station.invalidOwnHeightAsl="{0}" is not a valid height. Enter metres from -500 to 9000, or leave the field empty to use the elevation model.
station.locator=Locator station.locator=Locator
station.mainCategory=Main category station.mainCategory=Main category
station.maxQrb=Maximum QRB station.maxQrb=Maximum QRB
@@ -115,30 +124,44 @@ station.nameMain=Name, main category
station.nameSecond=Name, second category station.nameSecond=Name, second category
station.ownAntennaGain=Own antenna gain, dBi station.ownAntennaGain=Own antenna gain, dBi
station.ownAntennaHeight=Own antenna height, metres station.ownAntennaHeight=Own antenna height, metres
station.ownHeightAsl=Own ground height ASL, metres
station.ownSection=Own station station.ownSection=Own station
station.ownTxPower=Own TX power, watts station.ownTxPower=Own TX power, watts
station.password=Password station.password=Password
station.pathSection=Path analysis defaults station.pathSection=Path analysis defaults
station.qtf=QTF station.qtf=QTF
station.rotatorEnabled=Rotator sync enabled station.rotatorEnabled=Rotator sync enabled
station.manualAntennaHeadingSection=Manual antenna heading
station.manualAntennaHeadingRotatorHint=Unavailable while PSTRotator 1 is enabled
station.useQtfFilterDirectionAsAntennaHeading=Use QTF filter direction as antenna heading
station.rotatorHost=Rotator host station.rotatorHost=Rotator host
station.rotatorPort=Rotator port station.rotatorPort=Rotator port
station.rotatorSection=PSTRotator station.rotatorSection=PSTRotator
station.rotator2Enabled=Rotator 2 sync enabled
station.rotator2Host=Rotator 2 host
station.rotator2Port=Rotator 2 port (answers on port + 1)
station.rotator2PortClash=Port {0} is the same as or next to the port of the other PSTRotator
station.rotator2Section=PSTRotator 2 (SO2R)
station.secondCategory=Second category station.secondCategory=Second category
station.secondChatEnabled=2nd chat: log in to a second category station.secondChatEnabled=2nd chat: log in to a second category
station.serverHost=Server host name station.serverHost=Server host name
station.serverPort=Server port station.serverPort=Server port
station.serverSection=ON4KST server station.serverSection=ON4KST server
station.targetAntennaGain=Target antenna gain, dBi station.targetAntennaGain=Target antenna gain, dBi
station.targetHighestPoint=Target station on the highest point of its locator square
station.targetTxPower=Target TX power, watts 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.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.hintSection=What an enabled source does
trxSynch.so2rEnabled=SO2R: keep two radios apart (radio 2 writes SECONDQRG)
trxSynch.so2rHint=N1MM, DXLog and UCXLog are assigned by RadioNr; nothing to set. Win-Test needs one station name per radio.
trxSynch.so2rSection=SO2R
trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
trxSynch.ucxSection=Receive UCXLog TRX info trxSynch.ucxSection=Receive UCXLog TRX info
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency) 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.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
trxSynch.winTestSection=Win-Test TRX sync trxSynch.winTestSection=Win-Test TRX sync
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all) trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
trxSynch.winTestStationRadio2=Win-Test station name of radio 2 (e.g. STN2)
worked.empty=The worked database holds no callsigns. worked.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.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.lastResetAffected=Last reset affected {0} rows.
@@ -237,6 +260,7 @@ column.lastQrgTx=Last QRG TX
filter.find=Find... filter.find=Find...
filter.reset=Reset filters filter.reset=Reset filters
filter.showOnlyQrb=Show only QRB [km] <= filter.showOnlyQrb=Show only QRB [km] <=
filter.showOnlyQrbAbove=Show only QRB [km] >
filter.showOnlyQtf=Show only QTF: filter.showOnlyQtf=Show only QTF:
filter.hideWorked=Hide worked: filter.hideWorked=Hide worked:
filter.inactiveStations=Inactive stations filter.inactiveStations=Inactive stations
@@ -244,28 +268,44 @@ filter.onlyNewGrids=Only new grids
filter.gridColour=Grid color filter.gridColour=Grid color
filter.newBands=New bands filter.newBands=New bands
filter.tropo=Tropo >=0dB filter.tropo=Tropo >=0dB
filter.hide=Hide
filter.show=Show
filter.reachability=Reachability
filter.calcSelected=Calc selected
table.empty=No entries
selected.none=No station selected. selected.none=No station selected.
selected.priority=Priority: selected.priority=Priority:
selected.newJoined=New joined:
selected.more=more selected.more=more
selected.showMessages=Show messages selected.showMessages=Show messages
selected.createSked=Create sked selected.createSked=Create sked
selected.sked=Sked selected.sked=Sked alert
selected.skedInMinutes=Sked in (min) selected.skedIn=Sked in
selected.skedMinutesAt=min, at
selected.skedModeLink=, mode
selected.minutes=min
selected.skedFail=Sked fail selected.skedFail=Sked fail
selected.remindPmIn=Remind-PM in selected.remindPmIn=Remind pm in
selected.reminderOffsets=Reminder offsets
selected.resetFail=Reset fail selected.resetFail=Reset fail
selected.tagNotQrv=Tag not QRV selected.tagNotQrv=Tag {0} not QRV at bands:
selected.band=Band selected.band=Band
selected.mode=Mode selected.mode=Mode
selected.lookUpAndShow=Look up and show selected.lookUpAndShow=Look up and show
selected.lookupQrz=Lookup on qrz.com selected.lookupQrz=Lookup on qrz.com
selected.lookupQrzcq=Lookup on qrzcq.com selected.lookupQrzcq=Lookup on qrzcq.com
selected.showPathInAs=Show path in AS selected.showPathInAs=Show path in AS
selected.turnAntenna=Turn ant1 to {0}
selected.turnAntenna2=Turn ant2 to {0}
selected.lastActivity=Last activity: {0} ({1} min ago) selected.lastActivity=Last activity: {0} ({1} min ago)
candidates.title=Top priority candidates candidates.title=Top priority candidates
candidates.none=No candidates yet. candidates.none=No candidates yet.
candidates.doubleClick=Double-click a candidate to select it. candidates.doubleClick=Double-click a candidate to select it.
timeline.inBeam1=In beam 1
timeline.inBeam2=In beam 2
timeline.priority1=Priority 1
timeline.priority2=Priority 2
timeline.skeds=Skeds
timeline.minuteSuffix=min
update.title=Update information update.title=Update information
update.available=Update available! update.available=Update available!
update.installedVersion=Installed version: update.installedVersion=Installed version:
@@ -280,7 +320,9 @@ update.versions=Versions
map.title=Station Map map.title=Station Map
map.tab=Map map.tab=Map
map.resetView=Reset view map.resetView=Reset view
map.groupNearby=Group nearby stations map.groupNearby=Grouping
map.groupNearbyTooltip=Group stations that are close together on the map.
map.alwaysOnTop=Always on top
map.triggerClusterSpot=Trigger cluster spot map.triggerClusterSpot=Trigger cluster spot
map.showPathAnalysis=Show path analysis map.showPathAnalysis=Show path analysis
map.hidePathAnalysis=Hide path analysis map.hidePathAnalysis=Hide path analysis
@@ -331,6 +373,8 @@ title.connecting=Connecting to ON4KST…
title.authenticating=Connected – authenticating with ON4KST… title.authenticating=Connected – authenticating with ON4KST…
title.synchronizing=Connected – synchronizing ON4KST chat data… title.synchronizing=Connected – synchronizing ON4KST chat data…
title.disconnected=DISCONNECTED! title.disconnected=DISCONNECTED!
title.online=Connected to: {0} as {1} ({2}) in {3} ({4} users online, {5} shown), {6} messages total.
title.bothCategories={0} and {1}
status.noConnection=No ON4KST connection status.noConnection=No ON4KST connection
status.linkTooltip=ON4KST link: {0}\n{1} status.linkTooltip=ON4KST link: {0}\n{1}
status.linkConnected=🟢 LINK: Connected status.linkConnected=🟢 LINK: Connected
@@ -374,6 +418,8 @@ messages.qsoOfTheOther=QSO of the other
chat.ownQrgTooltip=Your own frequency in the main category chat.ownQrgTooltip=Your own frequency in the main category
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced
chat.qtf2Tooltip=Heading of antenna 2 (rotator 2, SO2R); display only
chat.secondQrgTooltipSo2r=QRG of radio 2 (from RadioNr 2 or Win-Test station 2)
list.moveDown=Move selected down list.moveDown=Move selected down
list.moveUp=Move selected up list.moveUp=Move selected up
list.clearToRemove=Clear the text of an entry to remove it. list.clearToRemove=Clear the text of an entry to remove it.
@@ -387,3 +433,61 @@ contrast.textOnWindowSurface=Text on the window surface
contrast.textInsideField=Text inside a field contrast.textInsideField=Text inside a field
contrast.accentOnWindowSurface=Accent on the window surface contrast.accentOnWindowSurface=Accent on the window surface
contrast.textOnMenuStrip=Text on the menu strip contrast.textOnMenuStrip=Text on the menu strip
gui.startInEveningDesign=Start in the evening design
gui.filterPublic=Public messages
gui.filterPmToMe=Private messages to me
gui.filterPmToOthers=Private messages to others
gui.filterNothing=No filter
gui.hintFreshCall=Mark a callsign seen for the first time
gui.hintLargeGridWorked=Mark a worked large grid field
gui.rememberWindowPositions=Reopen windows across restarts where they were last moved to (same screen only)
form.choose=Choose...
form.notSelected=not selected
dialog.apply=Apply
chat.clear=clear
selected.showOnMap=🗺 Show on map
selected.notQrvAll=all
context.messageSnippets=Message snippets
context.createSked=Create sked…
context.tagNotQrv=Tag NOT QRV
context.markWorked=Mark as worked
context.calculateReachability=Calculate reachability
context.chooseStation=Choose station
context.back=Back
context.category=Category {0}
context.skedTitle=Create sked for {0}
context.cancel=Cancel
column.category=Category
column.sender=Sender
column.receiver=Receiver
column.lastQrg=Last QRG
column.airplane=AP [minutes / pot%]
column.score=Score
monitor.title=Cluster & QSO of the other
monitor.dxCluster=DX cluster
profiles.createFailed=The profile could not be created. The profile registry could not be written.
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
profiles.duplicateFailed=The profile could not be duplicated.
profiles.rootNotDeletable=The default profile uses the files of the installation itself and cannot be deleted.
profiles.activeNotDeletable=The profile currently in use cannot be deleted. Switch to another profile first.
profiles.deleteFailed=The profile could not be deleted.
profiles.deletionFolder=The following folder is removed permanently:\n{0}
profiles.deletionKeepsShared=The common station worked stations are not touched.
profiles.deletionRemovesOwn=The worked stations of this profile are deleted as well.
profiles.rootAlwaysShared=The default profile always uses the common station worked stations, because that database is the one of the installation itself.
profiles.changeAfterSwitch=The change takes effect after switching to this profile again.
profiles.alreadyActive=This profile is already active.
profiles.selectFirst=Select a profile in the table first.
beacon.invalidMessage=The beacon message is invalid: {0}
beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minute.
notification.invalidPort="{0}" is not a valid TCP port. Enter a value from 1 to 65535.
notification.invalidSpotter="{0}" is not a valid spotter callsign.
notification.invalidCallsign=Please enter a valid callsign.
notification.alreadyMonitored=This base callsign is already in the monitoring list.
settings.windowTitle=Change Client Settings
settings.noticeInfo=Info
settings.noticeProblem=Settings
profiles.unknown=unknown
profiles.copyOf=Copy of {0}
profiles.sharedDatabase=common station database
profiles.ownDatabase=own database
@@ -1,4 +1,5 @@
package kst4contest.view; package kst4contest.view;
import kst4contest.view.compose.SettingsDraft;
import kst4contest.view.compose.SettingsHost; import kst4contest.view.compose.SettingsHost;
import kst4contest.view.compose.SettingsNotices; import kst4contest.view.compose.SettingsNotices;
import kst4contest.view.compose.SettingsTabsKt; import kst4contest.view.compose.SettingsTabsKt;
@@ -9,6 +10,7 @@ import kst4contest.view.compose.DataTableState;
import kst4contest.view.compose.MonitorColumns; import kst4contest.view.compose.MonitorColumns;
import kst4contest.view.compose.MonitorWindow; import kst4contest.view.compose.MonitorWindow;
import kst4contest.view.compose.RowKeys; import kst4contest.view.compose.RowKeys;
import kst4contest.view.compose.ReachabilityState;
import kst4contest.view.compose.BlinkingNotice; import kst4contest.view.compose.BlinkingNotice;
import kst4contest.view.compose.ThreadStatusButtons; import kst4contest.view.compose.ThreadStatusButtons;
import kst4contest.view.compose.StationColumns; import kst4contest.view.compose.StationColumns;
@@ -17,9 +19,16 @@ import kst4contest.view.compose.DirectedMessageColumns;
import kst4contest.view.compose.PublicMessageColumns; import kst4contest.view.compose.PublicMessageColumns;
import kst4contest.view.compose.SelectedMessageFilter; import kst4contest.view.compose.SelectedMessageFilter;
import kst4contest.view.compose.SelectedStationMessageColumns; import kst4contest.view.compose.SelectedStationMessageColumns;
import kst4contest.view.compose.SelectedStationMessagePredicate;
import kst4contest.view.compose.SelectedStationState; import kst4contest.view.compose.SelectedStationState;
import kst4contest.view.compose.DirectedMessageStationTarget;
import kst4contest.view.compose.StationIdentity;
import kst4contest.view.compose.StationSelectionSource;
import kst4contest.view.compose.StationSelectionState;
import kst4contest.view.compose.TopPriorityCandidatesWindow; import kst4contest.view.compose.TopPriorityCandidatesWindow;
import kst4contest.view.compose.TopPriorityState; import kst4contest.view.compose.TopPriorityState;
import kst4contest.view.compose.NewJoinedState;
import kst4contest.view.compose.SkedRequest;
import kst4contest.view.compose.TimelineState; import kst4contest.view.compose.TimelineState;
import kst4contest.view.compose.ChatInputState; import kst4contest.view.compose.ChatInputState;
import kst4contest.view.compose.MainMenuState; import kst4contest.view.compose.MainMenuState;
@@ -76,6 +85,9 @@ import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile; import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection; import kst4contest.model.OperatorProfileSelection;
import kst4contest.view.map.OfflineDemImportService; import kst4contest.view.map.OfflineDemImportService;
import kst4contest.view.map.MapCallsignRawSnapshot;
import kst4contest.view.map.MapCallsignRawSnapshotBuilder;
import kst4contest.view.map.PathAnalysisResult;
import kst4contest.controller.WorkedGrossFieldCache; import kst4contest.controller.WorkedGrossFieldCache;
@@ -120,8 +132,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private Predicate<ChatMember> lastAppliedChatMemberFilterPredicate = null; private Predicate<ChatMember> lastAppliedChatMemberFilterPredicate = null;
private Band selectedReachabilityBandOverride = null;
private kst4contest.view.feed.TimelineFeed timelineFeed; private kst4contest.view.feed.TimelineFeed timelineFeed;
private kst4contest.view.feed.SelectedStationMessagesFeed selectedStationMessagesFeed; private kst4contest.view.feed.SelectedStationMessagesFeed selectedStationMessagesFeed;
private kst4contest.view.feed.ConnectionStateFeed connectionStateFeed; private kst4contest.view.feed.ConnectionStateFeed connectionStateFeed;
@@ -263,6 +273,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
} }
} }
/** Applies one radio mode to one exact station identity. */
private void applySelectedStationMessageFilter(
SelectedMessageFilter filter,
ChatMember selectedMember) {
if (filter == null) {
return;
}
applySelectedCallSignInfoFilter(SelectedStationMessagePredicate.forSelection(
filter,
selectedMember,
chatcontroller.getChatPreferences().getStn_loginCallSign()));
}
/** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */ /** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */
@@ -1985,12 +2009,21 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/ */
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) { private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) { if (targetProfile == null) {
return; return;
} }
/*
* Settings edits not applied yet are asked about first: applied, they land in the
* profile being left, which is the one they were made in; "back" cancels the switch.
* Without pending edits this continues at once, as before.
*/
SettingsWindow.settleThen(() -> {
if (confirmOperatorProfileSwitch(targetProfile)) {
ApplicationRuntimeLauncher.switchProfile(targetProfile); ApplicationRuntimeLauncher.switchProfile(targetProfile);
} }
});
}
/** /**
* Asks whether the running session may be given up for a profile switch. * Asks whether the running session may be given up for a profile switch.
@@ -2110,6 +2143,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
} }
/**
* Hands the window hosts the active profile's stored window positions. Set on every
* startup, so a profile switch replaces the previous profile's positions. The opt-in is
* read on each call, so applying the setting takes effect without a restart.
*/
private void installWindowPositionMemory(ChatPreferences prefs) {
kst4contest.view.compose.WindowPlacementOnScreen.setMemory(
new kst4contest.view.compose.PreferencesWindowPositionMemory(prefs, this::requestLayoutSave));
}
private void requestLayoutSave() { private void requestLayoutSave() {
if (layoutAutosave != null) { if (layoutAutosave != null) {
layoutAutosave.requestSave(); layoutAutosave.requestSave();
@@ -2153,6 +2196,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
.isMessageHandling_debugModeToFileEnabled()); .isMessageHandling_debugModeToFileEnabled());
layoutAutosave = new LayoutAutosave( layoutAutosave = new LayoutAutosave(
chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher); chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher);
installWindowPositionMemory(chatcontroller.getChatPreferences());
// Every profile starts with its own configured design; the menu quick switch only lasts for the session. // Every profile starts with its own configured design; the menu quick switch only lasts for the session.
chatcontroller.getChatPreferences().setGUI_darkModeActive( chatcontroller.getChatPreferences().setGUI_darkModeActive(
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault()); chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
@@ -2421,6 +2465,15 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private void closeWindowEvent() { private void closeWindowEvent() {
System.out.println("Window close request ..."); System.out.println("Window close request ...");
/*
* Settings edits not applied yet are asked about before the quit question; "back"
* cancels the exit. Without pending edits this continues at once, as before.
*/
SettingsWindow.settleThen(this::confirmAndExit);
}
/** Asks whether to quit, and quits on yes. */
private void confirmAndExit() {
/* /*
* ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here, * ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here,
* and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on * and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on
@@ -2490,7 +2543,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
return; return;
} }
try { try {
Path logFile = Path.of(ApplicationFileUtils.getFilePath( Path logFile = Path.of(ApplicationFileUtils.getDebugFilePath(
ApplicationConstants.APPLICATION_NAME, ApplicationConstants.APPLICATION_NAME,
"kst4contest-errors.log")); "kst4contest-errors.log"));
// Start conservatively until the persisted preference has been loaded. // Start conservatively until the persisted preference has been loaded.
@@ -2609,10 +2662,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false; if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false;
if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false; if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) { if (!state.acceptsQrb(member.getQrb())) return false;
Double qrb = member.getQrb();
if (qrb == null || qrb > state.getMaxQrbKm()) return false;
}
if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) { if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
Double qtf = member.getQTFdirection(); Double qtf = member.getQTFdirection();
@@ -2978,6 +3028,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/ */
private kst4contest.view.i18n.LanguageStore languageStore; private kst4contest.view.i18n.LanguageStore languageStore;
private java.util.function.Consumer<java.util.List<ChatMember>> composeMemberListener; private java.util.function.Consumer<java.util.List<ChatMember>> composeMemberListener;
private java.util.function.Consumer<java.util.List<RecentChatJoin>> composeRecentJoinListener;
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener; private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
private java.util.function.Consumer<java.util.List<ClusterMessage>> composeClusterListener; private java.util.function.Consumer<java.util.List<ClusterMessage>> composeClusterListener;
@@ -3023,21 +3074,45 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
* arrange separately while both windows are on screen; sharing the ids would let a * arrange separately while both windows are on screen; sharing the ids would let a
* drag in one silently rearrange the other. * drag in one silently rearrange the other.
*/ */
EnumSet<Band> enabledStationBands =
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
ReachabilityState reachability = new ReachabilityState(
enabledStationBands,
member -> chatcontroller.getReachabilityService().resolveAutoBand(member),
() -> composeMainWindowState == null
? null : composeMainWindowState.getSelectedStation().getSelected(),
(member, band) -> {
chatcontroller.getReachabilityService()
.calculateSelectedStationOnDemand(member, band);
return kotlin.Unit.INSTANCE;
},
() -> {
forceChatMemberFilterRefresh();
if (composeMainWindowState != null) {
composeMainWindowState.getStations().forceRedraw();
}
return kotlin.Unit.INSTANCE;
});
StationSelectionState stationSelection = new StationSelectionState();
DataTableState<ChatMember> stations = new DataTableState<>( DataTableState<ChatMember> stations = new DataTableState<>(
StationColumns.INSTANCE.all( StationColumns.INSTANCE.all(
member -> { member -> {
Band band = resolveReachabilityBandForUi(member); Band band = reachability.resolveBand(member);
chatcontroller.getReachabilityService() chatcontroller.getReachabilityService()
.ensureTropoMarginCalculated(member, band); .ensureTropoMarginCalculated(member, band);
return member.formatTropoSsbMarginForBand(band); return member.formatTropoSsbMarginForBand(band);
}, },
member -> parseTableDouble(formatPriorityScore(member)), member -> parseTableDouble(formatPriorityScore(member)),
(member, band, worked) -> formatBandCellStatus(member, band, worked), (member, band, worked) -> formatBandCellStatus(member, band, worked),
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())), enabledStationBands),
RowKeys.INSTANCE.byValue(member -> member.getCallSign() + "|" RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
+ (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())),
"compose-stations", "compose-stations",
widths); widths,
"callsign",
true,
stationSelection,
true);
DataTableState<ChatMessage> directed = new DataTableState<>( DataTableState<ChatMessage> directed = new DataTableState<>(
DirectedMessageColumns.INSTANCE.all( DirectedMessageColumns.INSTANCE.all(
@@ -3073,6 +3148,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
MainMenuState menu = new MainMenuState(); MainMenuState menu = new MainMenuState();
MainWindowSurroundings surroundings = new MainWindowSurroundings(menu); MainWindowSurroundings surroundings = new MainWindowSurroundings(menu);
surroundings.setSo2rEnabled(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled());
surroundings.setSecondAntennaOffered(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled()
&& chatcontroller.getChatPreferences().isStn_pstRotator2Enabled());
SelectedStationState selectedStation = new SelectedStationState( SelectedStationState selectedStation = new SelectedStationState(
member -> { member -> {
@@ -3086,44 +3164,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (filter == null) { if (filter == null) {
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
} }
Predicate<ChatMessage> predicate; applySelectedStationMessageFilter(filter, selectedCallSignInfoStageChatMember);
switch (filter) {
case PM_TO_ME:
predicate = chatMessage -> {
try {
if (chatMessage.getReceiver().getCallSign().equals("ALL") && !(chatMessage.getMessageText().toLowerCase().contains(chatcontroller.getChatPreferences().getStn_loginCallSign().toLowerCase()))) {
return false;
}
return ((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()))) &&
((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())));
} catch (Exception e) { return false; }
};
break;
case PM_TO_OTHER:
predicate = chatMessage -> {
try {
return (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && !chatMessage.getReceiver().getCallSign().equals("ALL") && !chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) ||
(chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && !chatMessage.getReceiver().getCallSign().equals("ALL") && !chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()));
} catch (Exception e) { return false; }
};
break;
case PUBLIC:
predicate = chatMessage -> {
try {
return chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && chatMessage.getReceiver().getCallSign().equals("ALL");
} catch (Exception e) { return false; }
};
break;
case NOTHING:
default:
predicate = chatMessage -> {
try {
return chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) || chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign());
} catch (Exception e) { return false; }
};
break;
}
applySelectedCallSignInfoFilter(predicate);
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
}, },
member -> { member -> {
@@ -3136,24 +3177,34 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (member == null) return null; if (member == null) return null;
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
return resolveDefaultSkedBand(member, enabledBands); return resolveDefaultSkedBand(member, enabledBands);
}); },
azimuth -> {
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.FIRST, azimuth);
return kotlin.Unit.INSTANCE;
},
azimuth -> {
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth);
return kotlin.Unit.INSTANCE;
},
surroundings::getSecondAntennaOffered,
stationSelection);
selectedStation.setSkedRequestSubmitter(this::createSkedFromCompose);
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) { selectedStation.setMessageFilter(
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME); SelectedMessageFilter.fromPreferences(chatcontroller.getChatPreferences()));
} else if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToOther()) {
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_OTHER);
} else {
selectedStation.setMessageFilter(SelectedMessageFilter.NOTHING);
}
TopPriorityState topPriority = new TopPriorityState( TopPriorityState topPriority = new TopPriorityState(
this::composeTopStations, this::composeTopStations,
member -> selectStationInCompose(selectedStation, stations, member)); member -> selectStationInCompose(selectedStation, member, StationSelectionSource.PRIORITY));
NewJoinedState newJoined = new NewJoinedState(
System::currentTimeMillis,
member -> selectStationInCompose(
selectedStation, member, StationSelectionSource.NEW_JOINED));
ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this)); ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this));
composeMainWindowState = new MainWindowState( composeMainWindowState = new MainWindowState(
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()), enabledStationBands,
chatcontroller.getChatPreferences(), chatcontroller.getChatPreferences(),
stations, stations,
new StationFilterState(), new StationFilterState(),
@@ -3200,7 +3251,19 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
.setGUImainWindowRightSplitPane_dividerposition(positions); .setGUImainWindowRightSplitPane_dividerposition(positions);
requestLayoutSave(); requestLayoutSave();
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
})); }),
new SplitterState(
MainWindowState.SELECTED_STATION_PANE_COUNT,
chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition(),
SplitterState.DEFAULT_MIN_PANE_PX,
positions -> {
chatcontroller.getChatPreferences()
.setGUIselectedCallSignSplitPane_dividerposition(positions);
requestLayoutSave();
return kotlin.Unit.INSTANCE;
}),
newJoined,
reachability);
/* /*
* The feed, not the window construction, owns the timeline's data from here. * The feed, not the window construction, owns the timeline's data from here.
@@ -3211,6 +3274,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
composeMainWindowState.getTimeline(), uiDispatcher); composeMainWindowState.getTimeline(), uiDispatcher);
selectedStationMessagesFeed = new kst4contest.view.feed.SelectedStationMessagesFeed( selectedStationMessagesFeed = new kst4contest.view.feed.SelectedStationMessagesFeed(
composeMainWindowState.getSelectedStationMessages(), uiDispatcher); composeMainWindowState.getSelectedStationMessages(), uiDispatcher);
/*
* Build the live message mirror before the first Compose selection. The
* selection path already records the selected member before its legacy focus
* callback runs, so waiting for that callback leaves the mirror null and the
* table cannot receive either existing or newly arriving messages.
*/
rebuildSelectedCallSignInfoMessageBinding();
selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list());
connectionStateFeed = new kst4contest.view.feed.ConnectionStateFeed( connectionStateFeed = new kst4contest.view.feed.ConnectionStateFeed(
composeMainWindowState.getSurroundings(), uiDispatcher); composeMainWindowState.getSurroundings(), uiDispatcher);
@@ -3220,11 +3291,54 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
* Subscribed first and seeded afterwards, so a message arriving in between is not * Subscribed first and seeded afterwards, so a message arriving in between is not
* missed — the order RosterListBinding uses for the same reason. * missed — the order RosterListBinding uses for the same reason.
*/ */
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows)); composeMemberListener = rows -> uiDispatcher.runOnUi(() -> {
stations.replaceRows(rows);
newJoined.updateActiveMembers(rows);
});
chatcontroller.getLst_chatMemberList().addListener(composeMemberListener); chatcontroller.getLst_chatMemberList().addListener(composeMemberListener);
composeRecentJoinListener = rows -> uiDispatcher.runOnUi(() -> newJoined.updateEvents(rows));
chatcontroller.getRecentChatJoins().addListener(composeRecentJoinListener);
composeMainWindowState.setStationSelector(member -> { composeMainWindowState.setStationSelector((member, source) -> {
selectStationInCompose(selectedStation, stations, member); selectStationInCompose(selectedStation, member, source);
return kotlin.Unit.INSTANCE;
});
composeMainWindowState.setStationFiltersChanged(() -> {
updateComposeFilters();
return kotlin.Unit.INSTANCE;
});
composeMainWindowState.setLayoutSaveRequested(() -> {
requestLayoutSave();
return kotlin.Unit.INSTANCE;
});
composeMainWindowState.setMarkStationWorked(
(member, band) -> chatcontroller.markStationWorkedOnBand(member, band));
composeMainWindowState.setWorkedGrossFieldsProvider(
() -> chatcontroller.getWorkedGrossFieldCache().snapshotWorkedGrossFieldsAny());
composeMainWindowState.getMapReachability().setRequester((member, band, callback) -> {
java.util.List<ChatMember> variants = chatcontroller.getLst_chatMemberList().snapshot()
.stream()
.filter(candidate -> candidate != null
&& java.util.Objects.equals(candidate.getCallSignRaw(), member.getCallSignRaw()))
.toList();
java.util.EnumSet<Band> bands = java.util.EnumSet.noneOf(Band.class);
bands.addAll(enabledStationBands);
MapCallsignRawSnapshot snapshot = new MapCallsignRawSnapshotBuilder()
.buildSnapshots(variants, member, bands)
.stream()
.findFirst()
.orElse(null);
if (snapshot == null) {
callback.invoke(PathAnalysisResult.waitingForValidTarget(
chatcontroller.getChatPreferences().getStn_loginLocatorMainCat(),
member.getQra()));
return kotlin.Unit.INSTANCE;
}
chatcontroller.getReachabilityService().requestPathAnalysisForMap(
member,
snapshot,
band,
result -> callback.invoke(result));
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
}); });
@@ -3243,7 +3357,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
} }
uiDispatcher.runOnUi(() -> topPriority.refresh()); uiDispatcher.runOnUi(() -> topPriority.refresh());
stations.replaceRows(chatcontroller.getLst_chatMemberList().snapshot()); java.util.List<ChatMember> initialMembers = chatcontroller.getLst_chatMemberList().snapshot();
stations.replaceRows(initialMembers);
newJoined.updateActiveMembers(initialMembers);
newJoined.updateEvents(chatcontroller.getRecentChatJoins().snapshot());
directed.replaceRows(chatcontroller.toMeMessages()); directed.replaceRows(chatcontroller.toMeMessages());
publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot()); publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot());
cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot()); cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
@@ -3298,7 +3415,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
closeWindowEvent(); closeWindowEvent();
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
}, },
() -> composeSkedBands(selectedStation.getSelected()),
address -> runOnUi(() -> ExternalDocuments.open(address)), address -> runOnUi(() -> ExternalDocuments.open(address)),
() -> { () -> {
runOnUi(() -> showSelectedCallsignOnMap()); runOnUi(() -> showSelectedCallsignOnMap());
@@ -3310,8 +3426,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
} }
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
}, },
member -> selectStationInCompose(selectedStation, stations, member), member -> selectStationInCompose(selectedStation, member, StationSelectionSource.STATION_TABLE),
message -> onDirectedMessageClickedInCompose(selectedStation, stations, composeMainWindowState.getChatInput(), message), message -> onDirectedMessageClickedInCompose(selectedStation, composeMainWindowState.getChatInput(), message),
(message, source) -> onMessageSenderClickedInCompose(selectedStation, message, source),
this::isOwnChatMessage, this::isOwnChatMessage,
this::chatMessageAgeSeconds, this::chatMessageAgeSeconds,
() -> { () -> {
@@ -3329,7 +3446,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
ChatMember resolved = resolveChatMemberForCallRawAndCategory( ChatMember resolved = resolveChatMemberForCallRawAndCategory(
candidate.getCallSignRaw(), candidate.getPreferredChatCategory()); candidate.getCallSignRaw(), candidate.getPreferredChatCategory());
if (resolved != null) { if (resolved != null) {
selectStationInCompose(selectedStation, stations, resolved); selectStationInCompose(selectedStation, resolved, StationSelectionSource.TIMELINE);
composeMainWindowState.getChatInput().prepareCq(resolved.getCallSign(), false); composeMainWindowState.getChatInput().prepareCq(resolved.getCallSign(), false);
// Sync with JavaFX state just in case // Sync with JavaFX state just in case
@@ -3353,6 +3470,29 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
final int minutes, final int minutes,
final Band band, final Band band,
final String mode) { final String mode) {
boolean remindPm = false;
String reminderOffsets = SelectedStationState.Companion.getREMINDER_OFFSETS().get(0);
if (composeMainWindowState != null) {
SelectedStationState state = composeMainWindowState.getSelectedStation();
remindPm = state.getRemindPm();
reminderOffsets = state.getReminderOffsets();
}
return createSkedFromCompose(new SkedRequest(
selectedMember,
minutes,
band,
mode,
remindPm,
reminderOffsets));
}
private boolean createSkedFromCompose(final SkedRequest request) {
if (request == null) {
return false;
}
final ChatMember selectedMember = request.getTarget();
final int minutes = request.getMinutes();
final Band band = request.getBand();
if (selectedMember == null || band == null) { if (selectedMember == null || band == null) {
return false; return false;
@@ -3374,10 +3514,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.getScoreService().requestRecompute("sked-created"); chatcontroller.getScoreService().requestRecompute("sked-created");
// Arm PM reminders, exactly as the JavaFX Create sked handler does // Arm PM reminders, exactly as the JavaFX Create sked handler does
if (composeMainWindowState != null) { if (request.getRemindPm()) {
SelectedStationState ssState = composeMainWindowState.getSelectedStation(); List<Integer> offsets = parseMinuteOffsets(request.getReminderOffsets());
if (ssState.getRemindPm()) {
List<Integer> offsets = parseMinuteOffsets(ssState.getReminderOffsets());
chatcontroller.getSkedReminderService().armReminders( chatcontroller.getSkedReminderService().armReminders(
sked.getTargetChatCallsign(), sked.getTargetChatCallsign(),
sked.getTargetChatCategory(), sked.getTargetChatCategory(),
@@ -3385,7 +3523,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
offsets offsets
); );
} }
}
return true; return true;
} }
@@ -3424,28 +3561,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
return activeProfile.getProfile().getDisplayName(); return activeProfile.getProfile().getDisplayName();
} }
/** The bands a sked can be arranged on for this station: enabled here, available there. */
private java.util.List<Band> composeSkedBands(ChatMember selectedMember) {
if (selectedMember == null) {
return java.util.List.of();
}
java.util.List<ChatMember> variants =
chatcontroller.findActiveChatMembersByRawCall(selectedMember.getCallSignRaw());
if (variants.isEmpty()) {
variants = java.util.List.of(selectedMember);
}
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
EnumSet<Band> enabledBands =
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
EnumSet<Band> available = EnumSet.copyOf(resolution.getAvailableBands());
available.retainAll(enabledBands);
return new ArrayList<>(available);
}
/** The score service's ranking, as the Compose priority bar wants it. */ /** The score service's ranking, as the Compose priority bar wants it. */
private java.util.List<kotlin.Pair<ChatMember, Double>> composeTopStations() { private java.util.List<kotlin.Pair<ChatMember, Double>> composeTopStations() {
java.util.List<kotlin.Pair<ChatMember, Double>> out = new ArrayList<>(); java.util.List<kotlin.Pair<ChatMember, Double>> out = new ArrayList<>();
@@ -3477,21 +3592,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/ */
private kotlin.Unit selectStationInCompose( private kotlin.Unit selectStationInCompose(
SelectedStationState panel, SelectedStationState panel,
DataTableState<ChatMember> table, ChatMember member,
ChatMember member) { StationSelectionSource source) {
panel.select(member); panel.select(member, source);
table.select(member);
selectedCallSignInfoStageChatMember = member; selectedCallSignInfoStageChatMember = member;
if (selectedCallSignInfoMessageBinding != null) { applySelectedStationMessageFilter(panel.getMessageFilter(), member);
selectedCallSignInfoMessageBinding.refresh();
uiDispatcher.runOnUi(() -> {
if (selectedStationMessagesFeed != null) {
selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list());
}
});
}
if (member != null && chatcontroller.getScoreService() != null) { if (member != null && chatcontroller.getScoreService() != null) {
chatcontroller.getScoreService().setSelectedChatMember(member); chatcontroller.getScoreService().setSelectedChatMember(member);
@@ -3507,26 +3614,41 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private kotlin.Unit onDirectedMessageClickedInCompose( private kotlin.Unit onDirectedMessageClickedInCompose(
SelectedStationState panel, SelectedStationState panel,
DataTableState<ChatMember> stationsTable,
ChatInputState chatInput, ChatInputState chatInput,
ChatMessage message) { ChatMessage message) {
String ownCall = chatcontroller.getChatPreferences().getStn_loginCallSign();
StationIdentity target = DirectedMessageStationTarget.forMessage(message, ownCall);
if (target == null) {
return kotlin.Unit.INSTANCE;
}
ChatMember activeTarget = chatcontroller.findActiveChatMember(target.getCallSign(), message.getChatCategory());
if (activeTarget != null) {
selectStationInCompose(panel, activeTarget, StationSelectionSource.DIRECTED_MESSAGE);
} else {
chatInput.prepareCq(target.getCallSign(), false);
if (message.getSender() != null && message.getSender().getCallSign().equalsIgnoreCase(ownCall)) {
rememberAutoPreparedCqTarget(target.getCallSign(), message.getChatCategory());
}
}
return kotlin.Unit.INSTANCE;
}
private kotlin.Unit onMessageSenderClickedInCompose(
SelectedStationState panel,
ChatMessage message,
StationSelectionSource source) {
if (message == null || message.getSender() == null) { if (message == null || message.getSender() == null) {
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
} }
if (message.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) { ChatMember sender = chatcontroller.findActiveChatMember(
String receiverCallsign = message.getMessageText().substring(2, message.getMessageText().indexOf(")")); message.getSender().getCallSign(), message.getChatCategory());
chatInput.prepareCq(receiverCallsign, false); if (sender != null) {
rememberAutoPreparedCqTarget(receiverCallsign, message.getChatCategory()); selectStationInCompose(panel, sender, source);
} else {
selectStationInCompose(panel, stationsTable, message.getSender());
chatInput.prepareCq(message.getSender().getCallSign(), false);
prepareCqTextForSelectedChatMember(message.getSender(), false);
focusChatMemberAndPrepareCq(message.getSender(), false);
} }
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
} }
@@ -3582,6 +3704,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener); chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener);
composeMemberListener = null; composeMemberListener = null;
} }
if (composeRecentJoinListener != null) {
chatcontroller.getRecentChatJoins().removeListener(composeRecentJoinListener);
composeRecentJoinListener = null;
}
if (composeChatListener != null) { if (composeChatListener != null) {
chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener); chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener);
composeChatListener = null; composeChatListener = null;
@@ -3668,9 +3794,25 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot()); clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
qsoTable.replaceRows(chatcontroller.toOtherMessages()); qsoTable.replaceRows(chatcontroller.toOtherMessages());
/*
* The same preference the JavaFX SplitPane wrote, so a profile gets back the
* position its operator dragged before the Compose port (#144).
*/
SplitterState monitorSplitter = new SplitterState(
2,
chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault(),
SplitterState.DEFAULT_MIN_PANE_PX,
positions -> {
chatcontroller.getChatPreferences()
.setGUIpnl_directedMSGWin_dividerpositionDefault(positions);
requestLayoutSave();
return kotlin.Unit.INSTANCE;
});
MonitorWindow.show( MonitorWindow.show(
clusterTable, clusterTable,
qsoTable, qsoTable,
monitorSplitter,
chatcontroller.getChatPreferences().isGUI_darkModeActive(), chatcontroller.getChatPreferences().isGUI_darkModeActive(),
SETTINGS_WINDOW_FONT_SIZE_SP, SETTINGS_WINDOW_FONT_SIZE_SP,
paletteStore, paletteStore,
@@ -3727,6 +3869,19 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
} }
} }
/**
* The session state the settings window shows, read from the main window's Compose state
* so the Station tab's login fields follow it while the window is open. Before that state
* exists the controller's value is the best there is.
*/
private On4KstConnectionState settingsConnectionState() {
if (composeMainWindowState != null) {
return composeMainWindowState.getSurroundings().getConnectionState();
}
On4KstConnectionState current = chatcontroller.getOn4KstConnectionState();
return current == null ? On4KstConnectionState.DISCONNECTED : current;
}
/** /**
* Opens the settings window, or does nothing when it is already open. * Opens the settings window, or does nothing when it is already open.
* *
@@ -3735,9 +3890,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/ */
private void openSettingsWindow() { private void openSettingsWindow() {
/*
* One draft per opening: the tabs stage into it, the buttons apply it, and the window
* asks about it when closed with edits pending.
*/
SettingsDraft draft = new SettingsDraft();
SettingsWindow.show( SettingsWindow.show(
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore), SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
this::settingsConnectionState, draft),
settingsNotices, settingsNotices,
draft,
chatcontroller.getChatPreferences().isGUI_darkModeActive(), chatcontroller.getChatPreferences().isGUI_darkModeActive(),
SETTINGS_WINDOW_FONT_SIZE_SP, SETTINGS_WINDOW_FONT_SIZE_SP,
paletteStore, paletteStore,
@@ -3750,7 +3913,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
requestLayoutSave(); requestLayoutSave();
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
}, },
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices)); SettingsTabsKt.settingsButtons(
chatcontroller,
this,
settingsNotices,
draft,
this::settingsConnectionState));
} }
/* /*
@@ -3822,10 +3990,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.getMessageTXBus().add(sendMe); chatcontroller.getMessageTXBus().add(sendMe);
} }
/** Options/Windows -> open or close the settings window. */ /**
* Options/Windows -> open or close the settings window. Closing goes the way of the
* window's own X, so edits not applied yet are asked about rather than dropped.
*/
void menuActionToggleSettingsWindow() { void menuActionToggleSettingsWindow() {
if (SettingsWindow.isOpen()) { if (SettingsWindow.isOpen()) {
SettingsWindow.close(); SettingsWindow.requestClose();
} else { } else {
openSettingsWindow(); openSettingsWindow();
} }
@@ -3939,6 +4110,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
} }
} }
/** Pushes the stored SO2R switches to the main window so it shows or hides the radio 2 controls. */
@Override
public void refreshSo2rState() {
runOnUi(() -> {
if (composeMainWindowState == null) {
return;
}
kst4contest.model.ChatPreferences preferences = chatcontroller.getChatPreferences();
MainWindowSurroundings target = composeMainWindowState.getSurroundings();
target.setSo2rEnabled(preferences.isTrxSynch_so2rEnabled());
target.setSecondAntennaOffered(preferences.isTrxSynch_so2rEnabled() && preferences.isStn_pstRotator2Enabled());
});
}
/** /**
* No-op since the JavaFX own-QRG text field was removed with the never-shown window. * No-op since the JavaFX own-QRG text field was removed with the never-shown window.
* *
@@ -4130,31 +4315,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
* @return manually selected band or automatic band * @return manually selected band or automatic band
*/ */
private Band resolveReachabilityBandForUi(ChatMember member) { private Band resolveReachabilityBandForUi(ChatMember member) {
if (selectedReachabilityBandOverride != null) { if (composeMainWindowState != null) {
return selectedReachabilityBandOverride; return composeMainWindowState.getReachability().resolveBand(member);
} }
return chatcontroller.getReachabilityService().resolveAutoBand(member); return chatcontroller.getReachabilityService().resolveAutoBand(member);
} }
/**
* Parses the reachability ComboBox selection.
*
* @param selectedLabel selected UI text
* @return selected band, or null for Auto
*/
private Band parseReachabilityBandSelection(String selectedLabel) {
if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) {
return null;
}
for (Band band : Band.values()) {
if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) {
return band;
}
}
return null;
}
/** /**
* Formats the projected priority score for the station table. * Formats the projected priority score for the station table.
* *
@@ -3,7 +3,6 @@ package kst4contest.view.compose
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -17,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -37,9 +35,8 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type 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.graphics.SolidColor
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
@@ -59,9 +56,13 @@ fun ShortcutButtonRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
FlowRow( FlowRow(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp), modifier = modifier
horizontalArrangement = Arrangement.spacedBy(0.dp), .fillMaxWidth()
verticalArrangement = Arrangement.spacedBy(0.dp), .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
.padding(horizontal = 2.dp, vertical = 1.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
shortcuts.forEach { shortcut -> shortcuts.forEach { shortcut ->
when (shortcut) { when (shortcut) {
@@ -109,37 +110,8 @@ private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit
return return
} }
val palette = LocalJavaFxPalette.current Form.button(text = label, accented = true, onClick = onClick)
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 * The send row: the message field, TX, clear, and the three small fields the operator
@@ -158,6 +130,8 @@ fun ChatInputRow(
ownQrgMain: ObservedValue<String>, ownQrgMain: ObservedValue<String>,
ownQrgSecond: ObservedValue<String>, ownQrgSecond: ObservedValue<String>,
antennaQtf: ObservedValue<Double>, antennaQtf: ObservedValue<Double>,
antennaQtf2: ObservedValue<Double>,
so2rEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@@ -181,7 +155,7 @@ fun ChatInputRow(
) )
Form.button(text = "TX", onClick = { state.send() }) Form.button(text = "TX", onClick = { state.send() })
Form.button(text = "clear", onClick = { state.clear() }) Form.button(text = kst4contest.view.i18n.LocalStrings.current.chatClear, onClick = { state.clear() })
VerticalRule() VerticalRule()
@@ -195,13 +169,28 @@ fun ChatInputRow(
InlineCommittedField( InlineCommittedField(
stored = ownQrgSecond.value.orEmpty(), stored = ownQrgSecond.value.orEmpty(),
onCommit = { prefs.getMYQRGSecondCat().set(it) }, onCommit = { prefs.getMYQRGSecondCat().set(it) },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip, tooltip = if (so2rEnabled) {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
} else {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip
},
) )
InlineCommittedField( InlineCommittedField(
stored = antennaQtf.value?.toString().orEmpty(), stored = antennaQtf.value?.toString().orEmpty(),
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } }, onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip, tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
width = QTF_FIELD_WIDTH,
) )
if (so2rEnabled) {
// Display only: rotator 2 writes it, the operator does not.
InlineCommittedField(
stored = antennaQtf2.value?.takeIf { it.isFinite() }?.toString().orEmpty(),
onCommit = { },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
editable = false,
width = QTF_FIELD_WIDTH,
)
}
} }
} }
@@ -218,6 +207,8 @@ private fun InlineCommittedField(
stored: String, stored: String,
onCommit: (String) -> Unit, onCommit: (String) -> Unit,
tooltip: String, tooltip: String,
editable: Boolean = true,
width: Dp = QRG_FIELD_WIDTH,
) { ) {
var draft by remember(stored) { mutableStateOf(stored) } var draft by remember(stored) { mutableStateOf(stored) }
@@ -225,8 +216,9 @@ private fun InlineCommittedField(
CompactTextField( CompactTextField(
value = draft, value = draft,
onValueChange = { draft = it }, onValueChange = { draft = it },
enabled = editable,
modifier = Modifier modifier = Modifier
.width(QRG_FIELD_WIDTH) .width(width)
.onFocusChanged { focus -> .onFocusChanged { focus ->
if (!focus.isFocused && draft != stored) onCommit(draft) if (!focus.isFocused && draft != stored) onCommit(draft)
}, },
@@ -250,7 +242,8 @@ private fun TooltipChip(text: String) {
} }
} }
private val QRG_FIELD_WIDTH = 70.dp private val QRG_FIELD_WIDTH = 96.dp
private val QTF_FIELD_WIDTH = 56.dp
/** /**
* The message field. * The message field.
@@ -259,6 +252,7 @@ private val QRG_FIELD_WIDTH = 70.dp
* both were on the scene in JavaFX, which means they worked wherever the focus was; here * 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. * they are on the field, and the window handles them for the rest of the surface.
*/ */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun MessageField( private fun MessageField(
state: ChatInputState, state: ChatInputState,
@@ -267,12 +261,21 @@ private fun MessageField(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
TooltipArea(
tooltip = {
if (state.value.text.isNotBlank()) {
TooltipChip(state.value.text)
}
},
modifier = modifier,
) {
Box( Box(
modifier = modifier modifier = Modifier
.heightIn(min = Density.FIELD_MIN_HEIGHT) .fillMaxWidth()
.heightIn(min = Density.CONTROL_HEIGHT)
.border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE) .border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
.background(scheme.surface, Density.BUTTON_SHAPE) .background(scheme.surface, Density.BUTTON_SHAPE)
.padding(horizontal = 6.dp, vertical = 4.dp), .padding(horizontal = 6.dp),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
BasicTextField( BasicTextField(
@@ -290,11 +293,12 @@ private fun MessageField(
} }
}, },
singleLine = true, singleLine = true,
textStyle = LocalTextStyle.current.copy(color = scheme.onSurface), textStyle = compactControlTextStyle().copy(color = scheme.onSurface),
cursorBrush = SolidColor(scheme.primary), cursorBrush = SolidColor(scheme.primary),
) )
} }
} }
}
/** The vertical separator that sets the frequency fields apart from the send controls. */ /** The vertical separator that sets the frequency fields apart from the send controls. */
@Composable @Composable
@@ -302,7 +306,7 @@ private fun VerticalRule() {
Box( Box(
Modifier Modifier
.width(Density.HAIRLINE) .width(Density.HAIRLINE)
.height(Density.FIELD_MIN_HEIGHT) .height(Density.CONTROL_HEIGHT)
.background(MaterialTheme.colorScheme.outline), .background(MaterialTheme.colorScheme.outline),
) )
} }
@@ -254,7 +254,7 @@ object ComposeAlert {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS) val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
dialog.title = title dialog.title = title
dialog.size = DIALOG_SIZE dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null) WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* Disposed and not hidden, for the reason spelled out in open(). */ /* Disposed and not hidden, for the reason spelled out in open(). */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
dialog.isAlwaysOnTop = true dialog.isAlwaysOnTop = true
@@ -274,7 +274,7 @@ object ComposeAlert {
AlertContent( AlertContent(
header = header, header = header,
body = body, body = body,
buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX), buttons = alertButtons(CurrentStrings.get().alertOk, null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
onPressed = { dialog.dispose() }, onPressed = { dialog.dispose() },
link = linkText to linkTarget, link = linkText to linkTarget,
) )
@@ -322,7 +322,7 @@ object ComposeAlert {
) )
dialog.title = title dialog.title = title
dialog.size = DIALOG_SIZE dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null) WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* /*
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the * 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, * window and its Skia layer alive behind the operator's back — one per dismissal,
@@ -11,16 +11,25 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.loadImageBitmap import androidx.compose.ui.res.loadImageBitmap
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowPlacement as ComposeWindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.awt.ComposeWindow
import java.awt.EventQueue import java.awt.EventQueue
import java.awt.Frame import java.awt.Frame
import java.awt.Point
import java.awt.Toolkit import java.awt.Toolkit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
/** /**
* A Compose window that lives beside the JavaFX application. * A Compose window that lives beside the JavaFX application.
@@ -45,10 +54,20 @@ import java.util.concurrent.atomic.AtomicBoolean
* - **It must follow a design switch.** The operator can change the design from the * - **It must follow a design switch.** The operator can change the design from the
* main window's menu while this window is open. * main window's menu while this window is open.
* *
* Every window but the main one also opens on the screen the operator is working on
* (issue #59): centred on the main window's screen, or where the operator moved it to
* earlier in the session — see [WindowPlacement] for the rule.
*
* @param threadName names the thread in a stack dump, which is where one looks when a * @param threadName names the thread in a stack dump, which is where one looks when a
* window will not close * window will not close. It is also the key of the window's stored position, so
* renaming it forgets where the operator put the window.
* @param isMainWindow true for the main window only. It keeps the platform's placement
* and publishes its frame, which is what every other window is placed against.
*/ */
class ComposeWindowHost(private val threadName: String) { class ComposeWindowHost(
private val threadName: String,
private val isMainWindow: Boolean = false,
) {
private val open = AtomicBoolean(false) private val open = AtomicBoolean(false)
private val darkModeState = mutableStateOf(false) private val darkModeState = mutableStateOf(false)
@@ -61,6 +80,16 @@ class ComposeWindowHost(private val threadName: String) {
@Volatile @Volatile
private var frame: ComposeWindow? = null private var frame: ComposeWindow? = null
/**
* Where the operator moved this window to, for this session. Not persisted here: a
* stored position outlives the monitor layout it was made on, which is the very problem
* of issue #59. Keeping positions across restarts is the operator's opt-in, handled by
* [WindowPlacementOnScreen.memory] and used only on the very screen it was made on. Lives as long as the host, and the hosts are
* singletons, so it survives closing and reopening the window and a profile switch.
*/
@Volatile
private var movedTo: Point? = null
/** Whether the window is currently open, so a caller can tell without opening one. */ /** Whether the window is currently open, so a caller can tell without opening one. */
val isOpen: Boolean val isOpen: Boolean
get() = open.get() get() = open.get()
@@ -127,7 +156,7 @@ class ComposeWindowHost(private val threadName: String) {
* [deiconifiedState] for what to write — so the only thing left here that no headless * [deiconifiedState] for what to write — so the only thing left here that no headless
* test can observe is the single call to `toFront`. * test can observe is the single call to `toFront`.
*/ */
private fun raise() { fun raise() {
val frame = this.frame ?: return val frame = this.frame ?: return
EventQueue.invokeLater { EventQueue.invokeLater {
@@ -216,9 +245,34 @@ class ComposeWindowHost(private val threadName: String) {
return@application return@application
} }
/*
* Decided once per opening, on the event thread this composition runs
* on — which is where the screens and the main window may be read.
* Null keeps the platform's placement: always for the main window, and
* for any window that opens before the main window exists.
*/
val placed = remember {
val width = widthDp.roundToInt()
val height = heightDp.roundToInt()
when {
/*
* The main window only ever uses a stored position; without
* one the platform places it, as before.
*/
isMainWindow -> WindowPlacementOnScreen.restore(threadName, width, height)
/* This session's move is newer than anything stored. */
movedTo != null -> WindowPlacementOnScreen.place(movedTo, width, height)
else -> WindowPlacementOnScreen.restore(threadName, width, height)
?: WindowPlacementOnScreen.place(null, width, height)
}
}
val windowState = rememberWindowState( val windowState = rememberWindowState(
width = widthDp.dp, position = placed
height = heightDp.dp, ?.let { WindowPosition(it.x.dp, it.y.dp) }
?: WindowPosition.PlatformDefault,
width = placed?.width?.dp ?: widthDp.dp,
height = placed?.height?.dp ?: heightDp.dp,
) )
/* /*
@@ -227,16 +281,108 @@ class ComposeWindowHost(private val threadName: String) {
* comfort: these tables are wider than the default window, so an * comfort: these tables are wider than the default window, so an
* operator who widens it once must not have to do it again. * operator who widens it once must not have to do it again.
*/ */
/*
* The last size and position this host cut the window down to. Neither
* is the operator's choice: the size must not be written back as the
* stored size, and the position must not count as a move.
*/
val fit = remember { FitRecord() }
if (onResized != null) { if (onResized != null) {
LaunchedEffect(windowState) { LaunchedEffect(windowState) {
snapshotFlow { windowState.size }.collect { size -> /*
if (size.width.isSpecified && size.height.isSpecified) { * A secondary window skips the opening size: it may be the
* screen-clamped one, and writing that back would shrink the
* stored size for good because the window once opened on a
* smaller screen. The main window keeps writing it, as before,
* unless it was opened at a stored position, where the size may
* be clamped the same way. A size this host fitted the window to
* is skipped for the same reason.
*/
snapshotFlow { windowState.size }
.drop(if (isMainWindow && placed == null) 0 else 1)
.collect { size ->
if (size.width.isSpecified && size.height.isSpecified
&& !fit.takeSize(size.width.value.roundToInt(), size.height.value.roundToInt())
) {
onResized(size.width.value, size.height.value) onResized(size.width.value, size.height.value)
} }
} }
} }
} }
LaunchedEffect(windowState) {
/*
* One tracker per opening; the rule is in MoveTracker. An operator
* move is remembered for the session (secondary windows) and handed
* to the stored positions, which keep it only when the operator
* opted in. Only moves are stored, never the window manager's
* nudges, so a restored window cannot drift a little on every start.
*/
var tracker = MoveTracker(placed?.location)
snapshotFlow { windowState.position }.collect { position ->
if (position is WindowPosition.Absolute
&& windowState.placement == ComposeWindowPlacement.Floating
&& !windowState.isMinimized
) {
val now = Point(
position.x.value.roundToInt(),
position.y.value.roundToInt(),
)
val size = windowState.size
/*
* The host's own fit is a new anchor, not a move; the window
* manager may still nudge it within the tolerance.
*/
if (fit.takePosition(now)) {
tracker = MoveTracker(now)
return@collect
}
tracker.observe(now)?.let { moved ->
if (!isMainWindow) {
movedTo = moved
}
WindowPlacementOnScreen.store(
threadName,
moved,
if (size.width.isSpecified) size.width.value.roundToInt() else widthDp.roundToInt(),
if (size.height.isSpecified) size.height.value.roundToInt() else heightDp.roundToInt(),
)
}
}
}
}
LaunchedEffect(windowState) {
/*
* A window moved onto a smaller screen -- a laptop panel, a 640x480
* field monitor -- is cut down to it, so no edge or button ends up out
* of reach. Only once it has rested: resizing in the middle of a drag
* would fight the window manager for the window.
*/
snapshotFlow { windowState.position to windowState.size }.collectLatest { (position, size) ->
delay(FIT_SETTLE_MILLIS)
if (position is WindowPosition.Absolute
&& size.width.isSpecified && size.height.isSpecified
&& windowState.placement == ComposeWindowPlacement.Floating
&& !windowState.isMinimized
) {
WindowPlacementOnScreen.fitted(
java.awt.Rectangle(
position.x.value.roundToInt(),
position.y.value.roundToInt(),
size.width.value.roundToInt(),
size.height.value.roundToInt(),
),
)?.let { fitted ->
fit.record(fitted)
windowState.size = DpSize(fitted.width.dp, fitted.height.dp)
windowState.position = WindowPosition(fitted.x.dp, fitted.y.dp)
}
}
}
}
Window( Window(
onCloseRequest = { onCloseRequest?.invoke() ?: exitApplication() }, onCloseRequest = { onCloseRequest?.invoke() ?: exitApplication() },
state = windowState, state = windowState,
@@ -251,6 +397,9 @@ class ComposeWindowHost(private val threadName: String) {
*/ */
DisposableEffect(window) { DisposableEffect(window) {
frame = window frame = window
if (isMainWindow) {
WindowPlacementOnScreen.mainWindow = window
}
/* /*
* The only place the handle is dropped. Clearing it again in * The only place the handle is dropped. Clearing it again in
* the thread's finally block looks like a belt to this brace, * the thread's finally block looks like a belt to this brace,
@@ -259,7 +408,12 @@ class ComposeWindowHost(private val threadName: String) {
* the late write would wipe a live handle and bring back the * the late write would wipe a live handle and bring back the
* dead button this whole method exists to remove. * dead button this whole method exists to remove.
*/ */
onDispose { frame = null } onDispose {
frame = null
if (WindowPlacementOnScreen.mainWindow === window) {
WindowPlacementOnScreen.mainWindow = null
}
}
} }
CompositionLocalProvider( CompositionLocalProvider(
@@ -297,6 +451,126 @@ class ComposeWindowHost(private val threadName: String) {
} }
} }
/**
* How far a window must have gone from where it was opened to count as moved by the
* operator.
*
* Not zero, because window managers nudge a new window on their own — by the decoration,
* or to keep it clear of a panel — and that nudge would otherwise be remembered as the
* operator's choice. The window would then stop following the main window to another
* screen, and on X11 reopening it would drift by the decoration each time.
*/
internal const val MOVE_TOLERANCE_PX = 48
/** How long a window must rest after a move before it is cut down to a smaller screen. */
private const val FIT_SETTLE_MILLIS = 500L
/**
* What the host last cut a window down to, so the size and position it set itself are not
* taken for the operator's. Each is consumed by the first report that follows: a matching
* report is the fit echoing back, any other report is the operator's and clears it, so a
* stale record cannot swallow a later resize or move. Only touched on the window's event
* thread.
*/
internal class FitRecord {
private var pendingPosition: Point? = null
private var pendingWidth = 0
private var pendingHeight = 0
private var sizePending = false
fun record(bounds: java.awt.Rectangle) {
pendingPosition = bounds.location
pendingWidth = bounds.width
pendingHeight = bounds.height
sizePending = true
}
/**
* Whether [now] is the fitted position reported back. Compared with the move
* tolerance, because dp rounding under display scaling and a window manager's nudge
* both shift it slightly.
*/
fun takePosition(now: Point): Boolean {
val pending = pendingPosition ?: return false
pendingPosition = null
return !movedAway(pending, now)
}
/** Whether this size is the fitted one reported back, allowing for dp rounding. */
fun takeSize(width: Int, height: Int): Boolean {
if (!sizePending) {
return false
}
sizePending = false
return abs(width - pendingWidth) <= SIZE_ROUNDING_PX && abs(height - pendingHeight) <= SIZE_ROUNDING_PX
}
private companion object {
const val SIZE_ROUNDING_PX = 2
}
}
/** Whether [now] is far enough from [opened] to be the operator's move rather than a nudge. */
internal fun movedAway(opened: Point, now: Point): Boolean =
abs(now.x - opened.x) > MOVE_TOLERANCE_PX || abs(now.y - opened.y) > MOVE_TOLERANCE_PX
/**
* Tells the operator's moves of one opened window from the window manager's, fed with every
* floating position the window reports.
*
* - **Placed by the host** ([placedAt] not null): the anchor is that placement. Reports
* within [MOVE_TOLERANCE_PX] of it are the window manager's nudge and are ignored. This
* also holds when the window was reopened at its remembered spot, so a decoration nudge
* cannot make it drift a little further on every opening.
* - **Once a report has left the tolerance**, the operator is moving the window, and from
* then on every report is taken — including a move back to near where it opened, which
* would otherwise leave the stale far-away spot remembered.
* - **Not placed by the host** (opened before the main window existed): the platform picks
* the spot, and it may report a provisional position before its real placement. So the
* anchor is not trusted until it has settled: every report replaces it until one lands
* within the tolerance of the previous one. A window manager's placement jump is
* therefore a new anchor, not a move. The price: a single far jump by the operator
* before any second report — a keyboard "move to other monitor" right after opening —
* is taken as the anchor too, and the window then simply opens on the main window's
* screen next time, which is the default anyway. A time-based settle was the
* alternative; it would need a clock in a rule that is otherwise pure, and a drag
* produces a stream of close reports that settles the anchor on its own.
*
* @param placedAt where the host placed the window, or null when the platform did
*/
internal class MoveTracker(placedAt: Point?) {
private var anchor: Point? = placedAt
private var settled = placedAt != null
private var moving = false
/**
* @param now the position the window reports
* @return the position to remember as the operator's choice, or null to remember nothing
*/
fun observe(now: Point): Point? {
if (moving) {
return now
}
val current = anchor
if (!settled || current == null) {
if (current != null && !movedAway(current, now)) {
settled = true
} else {
anchor = now
}
return null
}
if (movedAway(current, now)) {
moving = true
return now
}
return null
}
}
/** /**
* The application icon, so these windows are not the only ones in the taskbar showing * The 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 * the default Java cup. Null when the resource is missing rather than failing: a window
@@ -7,15 +7,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
import kst4contest.view.i18n.Strings import kst4contest.view.i18n.Strings
import kst4contest.controller.ChatController import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatPreferences 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. * The button bar at the bottom of the settings window.
* *
@@ -26,16 +20,9 @@ enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
*/ */
class ConnectionBarState( class ConnectionBarState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val controller: ChatController, private val connectionState: () -> On4KstConnectionState,
) { ) {
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 — * 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 * and both chats when the second one is enabled, so the operator sees before
@@ -62,13 +49,13 @@ class ConnectionBarState(
return label return label
} }
/** Only a disconnected session can connect; the JavaFX button locked itself. */ /** A new attempt is safe only after the manager has completed its disconnect. */
val canConnect: Boolean val canConnect: Boolean
get() = connectionState != ConnectionState.LOGGED_IN get() = connectionState() == On4KstConnectionState.DISCONNECTED
/** Disconnecting needs a session that is actually logged in. */ /** A running attempt, online session or reconnect wait can be stopped by the operator. */
val canDisconnect: Boolean val canDisconnect: Boolean
get() = connectionState == ConnectionState.LOGGED_IN get() = connectionState().isConnectionAttemptActive
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@@ -15,6 +15,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -240,7 +242,7 @@ class EditableListState(
* reject it and put the stored value back. * reject it and put the stored value back.
* *
* @param onChanged called after every committed change — add, edit, removal, move. * @param onChanged called after every committed change — add, edit, removal, move.
* The lists write through, so this is where the roster is filled. * The settings tabs stage the list here; applying the draft fills the roster.
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX * @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
* tables opened an alert here. * tables opened an alert here.
* @param onAdd replaces the default "insert the template at the top" behaviour. * @param onAdd replaces the default "insert the template at the top" behaviour.
@@ -300,6 +302,23 @@ fun DataTable(
} }
} }
/*
* Inside the settings window a row still being edited is committed before the
* draft is applied: a click on "Apply/Close prefs" leaves the focus in the row,
* see SettingsDraft.registerTypedEntry. Only an edited row; committing an
* untouched one would report a change that was never made. A refusal is reported
* by commit() through onRefused, so the registration answers null.
*/
val settingsDraft = LocalSettingsDraft.current
val pendingEdit = rememberUpdatedState { if (draft != entry.text) commit() }
DisposableEffect(settingsDraft, entry.key) {
val registration = settingsDraft?.registerTypedEntry {
pendingEdit.value()
null
}
onDispose { registration?.close() }
}
val isSelected = state.selectedIndex == index val isSelected = state.selectedIndex == index
/* /*
@@ -162,6 +162,28 @@ interface ColumnWidthStore {
fun requestSave() fun requestSave()
} }
/**
* Optional shared row selection for tables whose selected entity is also displayed by
* other components. Ordinary event tables keep their selection inside [DataTableState].
*/
interface TableSelectionModel<T> {
val selected: T?
/** Changes whenever [selected] changes, including a repeated explicit selection. */
val selectionRevision: Long
/** Changes only when the table should scroll to the current selection. */
val scrollRequestRevision: Long
fun select(row: T?)
/** Replaces the selected instance after a roster refresh without requesting a scroll. */
fun reconcile(row: T?)
fun requestScroll()
}
/** /**
* The state of a multi-column table: its rows, its sorting and its selection. * The state of a multi-column table: its rows, its sorting and its selection.
* *
@@ -170,12 +192,16 @@ interface ColumnWidthStore {
* data that shows up as a stale display rather than as an unresponsive one — harder * data that shows up as a stale display rather than as an unresponsive one — harder
* to notice and worse in effect. * to notice and worse in effect.
*/ */
class DataTableState<T>( class DataTableState<T> @JvmOverloads constructor(
val columns: List<DataColumn<T>>, val columns: List<DataColumn<T>>,
private val rowKey: RowKeySource<T>, private val rowKey: RowKeySource<T>,
/** Part of the stored layout: renaming it loses the operator's column sizing. */ /** Part of the stored layout: renaming it loses the operator's column sizing. */
private val tableId: String = "", private val tableId: String = "",
private val widths: ColumnWidthStore? = null, private val widths: ColumnWidthStore? = null,
initialSortColumnId: String? = null,
initialSortAscending: Boolean = true,
private val selectionModel: TableSelectionModel<T>? = null,
private val keepSelectionVisible: Boolean = false,
) { ) {
/** /**
@@ -251,19 +277,29 @@ class DataTableState<T>(
var rows: List<T> by mutableStateOf(emptyList()) var rows: List<T> by mutableStateOf(emptyList())
private set private set
var sortedColumnId: String? by mutableStateOf(null) var sortedColumnId: String? by mutableStateOf(
initialSortColumnId?.takeIf { requested -> columns.any { it.id == requested } },
)
private set private set
var sortAscending: Boolean by mutableStateOf(true) var sortAscending: Boolean by mutableStateOf(initialSortAscending)
private set private set
private var selectedKey: Any? by mutableStateOf(null) private var selectedKey: Any? by mutableStateOf(null)
private var sortedCache: List<T>? = null private var internalSelectionRevision: Long by mutableStateOf(0L)
private var internalScrollRequestRevision: Long by mutableStateOf(0L)
private var filteredCache: List<T>? = null
private var visibleCache: List<T>? = null
private var cachedSelectionRevision: Long = Long.MIN_VALUE
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy()) private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
/** Optional filter predicate. If set, [visibleRows] will only include matching rows. */ /** Optional filter predicate. If set, [filteredRows] only includes matching rows. */
var rowFilter: ((T) -> Boolean)? var rowFilter: ((T) -> Boolean)?
get() = _rowFilter get() = _rowFilter
set(value) { set(value) {
@@ -275,7 +311,14 @@ class DataTableState<T>(
var contentRevision by mutableStateOf(0) var contentRevision by mutableStateOf(0)
private set private set
/**
* Redraws the rows and filters them again. The filter reads mutable row fields (a
* station's QRB, worked and QRV flags, its last activity), so a cached [visibleRows]
* from before the change could keep a row the filter now excludes, or hide one it
* now lets through -- in the list and in the station map, which draws the same rows.
*/
fun forceRedraw() { fun forceRedraw() {
invalidateOrder()
contentRevision++ contentRevision++
} }
@@ -287,7 +330,7 @@ class DataTableState<T>(
* per inbound message on the thread that draws — with a column that has no * per inbound message on the thread that draws — with a column that has no
* comparator calling its value function once per comparison on top. * comparator calling its value function once per comparison on top.
*/ */
val visibleRows: List<T> val filteredRows: List<T>
get() { get() {
/* /*
* Read before the cache is consulted, and that order is the whole point. * Read before the cache is consulted, and that order is the whole point.
@@ -299,45 +342,85 @@ class DataTableState<T>(
*/ */
val currentRows = rows val currentRows = rows
val currentFilter = rowFilter val currentFilter = rowFilter
val currentColumn = sortedColumnId contentRevision // read for the subscription: forceRedraw drops the cache
val currentColumnId = sortedColumnId
val ascending = sortAscending val ascending = sortAscending
filteredCache?.let { return it }
sortedCache?.let { return it } val filtered = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
val sorted = sort(filtered, currentColumnId, ascending)
val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows filteredCache = sorted
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 return sorted
} }
/**
* Rows drawn by this table. A station selected outside the table can optionally be
* included even when the active filter rejects it; [filteredRows] remains the exact
* shared filter result used by the map.
*/
val visibleRows: List<T>
get() {
val currentRows = rows
val filtered = filteredRows
val revision = selectionRevision
if (!keepSelectionVisible) {
return filtered
}
if (visibleCache != null && cachedSelectionRevision == revision) {
return visibleCache.orEmpty()
}
val selected = selectedRow
val visible = if (selected == null || filtered.any { keyOf(it) == keyOf(selected) }) {
filtered
} else if (currentRows.any { keyOf(it) == keyOf(selected) }) {
val filteredKeys = filtered.mapTo(HashSet<Any>()) { row -> keyOf(row) }
val selectedKey = keyOf(selected)
sort(currentRows.filter { row -> keyOf(row) == selectedKey || keyOf(row) in filteredKeys })
} else {
filtered
}
cachedSelectionRevision = revision
visibleCache = visible
return visible
}
private fun sort(
source: List<T>,
columnId: String? = sortedColumnId,
ascending: Boolean = sortAscending,
): List<T> {
val column = columns.firstOrNull { it.id == columnId } ?: return source
/*
* The displayed text is read once per row rather than once per comparison, and
* Kotlin's sort is stable, so equal values keep their source order.
*/
val order = column.comparator
?: compareBy(source.associateWith { column.value(it) }::getValue)
return if (ascending) source.sortedWith(order) else source.sortedWith(order.reversed())
}
private fun invalidateOrder() { private fun invalidateOrder() {
sortedCache = null filteredCache = null
visibleCache = null
} }
/** The selected row, or null when nothing is selected or it has gone. */ /** The selected row, or null when nothing is selected or it has gone. */
val selectedRow: T? val selectedRow: T?
get() = selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } } get() = selectionModel?.selected
?: selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
val selectionRevision: Long
get() = selectionModel?.selectionRevision ?: internalSelectionRevision
val scrollRequestRevision: Long
get() = selectionModel?.scrollRequestRevision ?: internalScrollRequestRevision
/** /**
* Replaces the rows. * Replaces the rows.
@@ -348,18 +431,43 @@ class DataTableState<T>(
* neighbour, which would silently point the operator at someone else. * neighbour, which would silently point the operator at someone else.
*/ */
fun replaceRows(newRows: List<T>) { fun replaceRows(newRows: List<T>) {
val selectedIdentity = selectionModel?.selected?.let(rowKey::keyOf) ?: selectedKey
rows = newRows rows = newRows
rowKey.retainOnly(newRows) rowKey.retainOnly(newRows)
invalidateOrder() invalidateOrder()
val key = selectedKey val replacement = selectedIdentity?.let { key -> newRows.firstOrNull { rowKey.keyOf(it) == key } }
if (key != null && newRows.none { rowKey.keyOf(it) == key }) {
selectedKey = null if (selectionModel != null) {
if (selectedIdentity != null) {
selectionModel.reconcile(replacement)
}
} else if (selectedIdentity != null) {
selectedKey = replacement?.let(rowKey::keyOf)
internalSelectionRevision++
} }
} }
fun select(row: T?) { fun select(row: T?) {
if (selectionModel != null) {
selectionModel.select(row)
} else {
selectedKey = row?.let(rowKey::keyOf) selectedKey = row?.let(rowKey::keyOf)
internalSelectionRevision++
}
visibleCache = null
}
/** Requests navigation to the current selection without changing it. */
fun requestScrollToSelection() {
if (selectedRow == null) return
if (selectionModel != null) {
selectionModel.requestScroll()
} else {
internalScrollRequestRevision++
}
} }
/** /**
@@ -369,7 +477,10 @@ class DataTableState<T>(
*/ */
fun keyOf(row: T): Any = rowKey.keyOf(row) fun keyOf(row: T): Any = rowKey.keyOf(row)
fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey fun isSelected(row: T): Boolean {
val selected = selectedRow ?: return false
return rowKey.keyOf(row) == rowKey.keyOf(selected)
}
/** /**
* Sorts by a column, cycling ascending, descending, unsorted. * Sorts by a column, cycling ascending, descending, unsorted.
@@ -1,10 +1,9 @@
package kst4contest.view.compose package kst4contest.view.compose
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -30,6 +29,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import java.awt.Cursor import java.awt.Cursor
@@ -44,12 +46,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset
import kst4contest.view.i18n.LocalStrings
import kotlin.math.roundToInt
/** /**
* A multi-column table. * A multi-column table.
@@ -71,15 +80,19 @@ import androidx.compose.ui.unit.dp
* the supported way to reach the hover text the JavaFX cells had, and losing that * 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. * loses the only way to read a value that does not fit its column.
*/ */
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun <T> DataTableView( fun <T> DataTableView(
state: DataTableState<T>, state: DataTableState<T>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
rowStyle: ((T) -> CellAccent?)? = null, rowStyle: ((T) -> CellAccent?)? = null,
emphasizeCell: ((T, DataColumn<T>) -> Boolean)? = null,
onRowClick: ((T) -> Unit)? = null, onRowClick: ((T) -> Unit)? = null,
onRowContextMenu: ((T, IntOffset) -> Unit)? = null,
selectOnRowClick: Boolean = true,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val automaticWidths = rememberAutomaticColumnWidths(state)
val horizontal = rememberScrollState() val horizontal = rememberScrollState()
val vertical = rememberLazyListState() val vertical = rememberLazyListState()
@@ -113,16 +126,36 @@ fun <T> DataTableView(
} }
} }
var handledScrollRequest by remember(state) { mutableStateOf(0L) }
val scrollRequest = state.scrollRequestRevision
val displayedRows = state.visibleRows
/*
* Resolve the index only after reading the current filtered and sorted rows. If the
* selected row has not arrived yet, leave the request pending and retry when that
* list changes. A handled request is never repeated by an ordinary data refresh.
*/
LaunchedEffect(scrollRequest, displayedRows) {
if (scrollRequest <= handledScrollRequest) return@LaunchedEffect
val index = displayedRows.indexOfFirst(state::isSelected)
if (index >= 0) {
vertical.scrollToItem(index)
handledScrollRequest = scrollRequest
}
}
/* /*
* Both directions scroll. The columns of these two tables need about 890dp and * 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 * 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 * are simply unreachable, and so are the handles that would narrow them. The JavaFX
* tables had a horizontal scrollbar. * tables had a horizontal scrollbar.
*/ */
BoxWithConstraints(modifier = modifier.fillMaxSize()) { Box(
val slack = unusedWidth(state, maxWidth) modifier = modifier
val lastColumnId = state.orderedColumns.lastOrNull()?.id .fillMaxSize()
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outline),
) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
/* /*
* A second header row above the first, spanning the columns that share a * A second header row above the first, spanning the columns that share a
@@ -150,9 +183,8 @@ fun <T> DataTableView(
val width = (index until index + span).sumOf { position -> val width = (index until index + span).sumOf { position ->
val column = state.orderedColumns[position] val column = state.orderedColumns[position]
val stored = state.widthOf(column.id) effectiveColumnWidthDp(state, column, automaticWidths).toDouble() +
val own = stored ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble() Density.COLUMN_HANDLE_WIDTH.value
own + Density.COLUMN_HANDLE_WIDTH.value
} }
Box( Box(
@@ -202,7 +234,7 @@ fun <T> DataTableView(
Box( Box(
modifier = Modifier modifier = Modifier
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp) .columnWidth(state, column, automaticWidths)
.fillMaxHeight() .fillMaxHeight()
.clickable { state.toggleSort(column.id) } .clickable { state.toggleSort(column.id) }
.pointerInput(column.id, index) { .pointerInput(column.id, index) {
@@ -211,7 +243,7 @@ fun <T> DataTableView(
onDragCancel = { dragged = 0f }, onDragCancel = { dragged = 0f },
) { _, dragAmount -> ) { _, dragAmount ->
dragged += dragAmount dragged += dragAmount
val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f val step = with(density) { DEFAULT_COLUMN_WIDTH_DP.dp.toPx() } / 2f
if (dragged > step) { if (dragged > step) {
state.moveColumn(column.id, index + 1) state.moveColumn(column.id, index + 1)
@@ -258,8 +290,11 @@ fun <T> DataTableView(
* and writes that into preferences.xml. * and writes that into preferences.xml.
*/ */
val deltaDp = with(density) { dragAmount.toDp().value } val deltaDp = with(density) { dragAmount.toDp().value }
val current = state.widthOf(column.id) val current = effectiveColumnWidthDp(
?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble() state,
column,
automaticWidths,
).toDouble()
state.resizeColumn(column.id, current + deltaDp) state.resizeColumn(column.id, current + deltaDp)
} }
@@ -278,7 +313,12 @@ fun <T> DataTableView(
HorizontalDivider(thickness = Density.HAIRLINE) HorizontalDivider(thickness = Density.HAIRLINE)
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f)),
) {
LazyColumn( LazyColumn(
state = vertical, state = vertical,
modifier = Modifier modifier = Modifier
@@ -291,6 +331,9 @@ fun <T> DataTableView(
val accent = rowStyle?.invoke(row) val accent = rowStyle?.invoke(row)
val isEven = index % 2 == 0 val isEven = index % 2 == 0
val _forceRedraw = state.contentRevision val _forceRedraw = state.contentRevision
var rowCoordinates by remember(state.keyOf(row)) {
mutableStateOf<LayoutCoordinates?>(null)
}
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Row( Row(
@@ -306,9 +349,25 @@ fun <T> DataTableView(
} }
) )
.clickable { .clickable {
if (selectOnRowClick) {
state.select(row) state.select(row)
}
onRowClick?.invoke(row) onRowClick?.invoke(row)
} }
.onGloballyPositioned { rowCoordinates = it }
.onPointerEvent(PointerEventType.Press) { event ->
if (event.buttons.isSecondaryPressed) {
val local = event.changes.firstOrNull()?.position
val window = local?.let { rowCoordinates?.localToWindow(it) }
if (window != null) {
onRowContextMenu?.invoke(
row,
IntOffset(window.x.roundToInt(), window.y.roundToInt()),
)
event.changes.forEach { it.consume() }
}
}
}
.heightIn(min = Density.ROW_MIN_HEIGHT), .heightIn(min = Density.ROW_MIN_HEIGHT),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -318,7 +377,7 @@ fun <T> DataTableView(
Box( Box(
modifier = Modifier modifier = Modifier
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp) .columnWidth(state, column, automaticWidths)
.fillMaxHeight() .fillMaxHeight()
.background(cell?.background ?: Color.Transparent) .background(cell?.background ?: Color.Transparent)
.padding(horizontal = 4.dp, vertical = 2.dp), .padding(horizontal = 4.dp, vertical = 2.dp),
@@ -345,6 +404,11 @@ fun <T> DataTableView(
Text( Text(
value, value,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = if (emphasizeCell?.invoke(row, column) == true) {
FontWeight.Bold
} else {
null
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = cell?.foreground color = cell?.foreground
@@ -378,6 +442,15 @@ fun <T> DataTableView(
} }
} }
if (state.visibleRows.isEmpty()) {
Text(
text = LocalStrings.current.tableEmpty,
modifier = Modifier.align(Alignment.Center).padding(8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
VerticalScrollbar( VerticalScrollbar(
adapter = rememberScrollbarAdapter(vertical), adapter = rememberScrollbarAdapter(vertical),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
@@ -402,39 +475,71 @@ fun <T> DataTableView(
} }
/** /** A stored width wins over the measured automatic width, even when it is narrower. */
* 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 @Composable
private fun <T> Modifier.columnWidth( private fun <T> Modifier.columnWidth(
state: DataTableState<T>, state: DataTableState<T>,
column: DataColumn<T>, column: DataColumn<T>,
extra: Dp = 0.dp, automaticWidths: Map<String, Float>,
): Modifier { ): Modifier {
val stored = state.widthOf(column.id) return this.width(effectiveColumnWidthDp(state, column, automaticWidths).dp)
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 * Measures automatic columns once from their headers and once more when the first
* already wider than the window, in which case the table scrolls. * source rows arrive. Later chat events do not resize a live table.
*/ */
@Composable @Composable
private fun <T> unusedWidth(state: DataTableState<T>, available: Dp): Dp { private fun <T> rememberAutomaticColumnWidths(state: DataTableState<T>): Map<String, Float> {
val used = state.orderedColumns.sumOf { column -> val density = LocalDensity.current
val stored = state.widthOf(column.id) val measurer = rememberTextMeasurer()
val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble() val headerStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold)
own + Density.COLUMN_HANDLE_WIDTH.value val cellStyle = MaterialTheme.typography.bodyMedium
val hasSourceRows = state.rows.isNotEmpty()
val titles = state.orderedColumns.associate { column -> column.id to column.title() }
return remember(
state,
density.density,
density.fontScale,
headerStyle,
cellStyle,
titles,
hasSourceRows,
) {
val sample = if (hasSourceRows) state.rows.take(AUTO_WIDTH_SAMPLE_SIZE) else emptyList()
state.orderedColumns.associate { column ->
val title = titles.getValue(column.id) + SORT_INDICATOR_SAMPLE
val headerWidthPx = measurer.measure(title, headerStyle).size.width
val contentWidthPx = sample.maxOfOrNull { row ->
val value = runCatching { column.value(row) }.getOrDefault("")
measurer.measure(value, cellStyle).size.width
} ?: 0
val measuredDp = with(density) {
maxOf(headerWidthPx, contentWidthPx).toDp().value + CELL_HORIZONTAL_PADDING_DP
}
val maximumDp = (AUTO_WIDTH_MAX_BASE_DP * column.weight.coerceAtLeast(0.5f))
.coerceIn(AUTO_WIDTH_MIN_MAX_DP, AUTO_WIDTH_ABSOLUTE_MAX_DP)
column.id to measuredDp.coerceIn(AUTO_WIDTH_MIN_DP, maximumDp)
}
}
} }
return (available.value - used).coerceAtLeast(0.0).dp internal fun <T> effectiveColumnWidthDp(
} state: DataTableState<T>,
column: DataColumn<T>,
automaticWidths: Map<String, Float>,
): Float = state.widthOf(column.id)?.toFloat()
?: automaticWidths[column.id]
?: DEFAULT_COLUMN_WIDTH_DP * column.weight
private val DEFAULT_COLUMN_WIDTH = 85.dp private const val AUTO_WIDTH_SAMPLE_SIZE = 160
private const val AUTO_WIDTH_MIN_DP = 32f
private const val AUTO_WIDTH_MIN_MAX_DP = 90f
private const val AUTO_WIDTH_MAX_BASE_DP = 180f
private const val AUTO_WIDTH_ABSOLUTE_MAX_DP = 360f
private const val CELL_HORIZONTAL_PADDING_DP = 10f
private const val DEFAULT_COLUMN_WIDTH_DP = 85f
private const val SORT_INDICATOR_SAMPLE = " ▲"
@@ -13,27 +13,36 @@ import androidx.compose.ui.unit.dp
* over thirty fields, so those defaults turn a settings window into a scrolling * over thirty fields, so those defaults turn a settings window into a scrolling
* exercise and make a desktop application look like a phone. * exercise and make a desktop application look like a phone.
* *
* Every height here is a **minimum**, never a fixed size. The configured base font * Interactive controls share one minimum visible height. Their common font and padding
* size scales all text through Typography.scaledTo, and a fixed height would clip a * keep the standard size exact while still allowing a larger configured font to grow.
* larger font instead of growing with it.
*/ */
internal object Density { internal object Density {
/** Text fields, pickers and anything else on the field column. */ /** Minimum for buttons, text fields, choices and checkbox rows in the desktop UI. */
val FIELD_MIN_HEIGHT = 30.dp val CONTROL_HEIGHT = 26.dp
/** Inside a text field. The vertical value is what buys back the 56dp. */ /** Text stays vertically centred by the control instead of by extra padding. */
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp) val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.dp)
val BUTTON_MIN_HEIGHT = 24.dp val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.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. */ /** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
val BUTTON_SHAPE = RoundedCornerShape(4.dp) val BUTTON_SHAPE = RoundedCornerShape(4.dp)
/** One row of a list or table. */ /** One row of a list or table. */
val ROW_MIN_HEIGHT = 26.dp val ROW_MIN_HEIGHT = CONTROL_HEIGHT
/** Equal width for all eight compass-direction filter buttons. */
val DIRECTION_BUTTON_WIDTH = 28.dp
/** Two-letter compass labels need only a small inset inside their fixed width. */
val DIRECTION_BUTTON_HORIZONTAL_PADDING = 2.dp
/** Enough room for the NOT-QRV and sked groups to remain usable side by side. */
val SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH = 450.dp
/** Keeps the station actions usable when a stored splitter position is too low. */
val SELECTED_ACTION_AREA_MIN_HEIGHT = 160.dp
val TAB_MIN_HEIGHT = 30.dp val TAB_MIN_HEIGHT = 30.dp
@@ -55,6 +64,9 @@ internal object Density {
/** Between buttons in a row. */ /** Between buttons in a row. */
val BUTTON_GAP = 6.dp val BUTTON_GAP = 6.dp
/** Compact inset for a visually grouped settings section. */
val GROUP_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
/** Around a selected row, and the hairline between rows. */ /** Around a selected row, and the hairline between rows. */
val SELECTION_BORDER = 1.5.dp val SELECTION_BORDER = 1.5.dp
@@ -54,7 +54,7 @@ object DirectedMessageColumns {
), ),
DataColumn( DataColumn(
id = "airscout", id = "airscout",
title = "AP [minutes / pot%]", title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) }, value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) }, accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo }, comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
@@ -62,7 +62,7 @@ object DirectedMessageColumns {
), ),
DataColumn( DataColumn(
id = "category", id = "category",
title = "Category", title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message -> value = { message ->
message.chatCategory message.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) } ?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.FlowRowScope import androidx.compose.foundation.layout.FlowRowScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -17,7 +18,11 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.border
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -33,25 +38,39 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
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.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.semantics.Role
/** One font size and natural line box for every compact interactive control. */
@Composable
internal fun compactControlTextStyle(): TextStyle =
MaterialTheme.typography.bodyMedium.copy(lineHeight = TextUnit.Unspecified)
/** /**
* Form controls shared by the settings tabs. * Form controls shared by the settings tabs.
@@ -111,10 +130,25 @@ object Form {
* every prefix of a valid entry, so the draft lives here until the operator leaves * every prefix of a valid entry, so the draft lives here until the operator leaves
* the field or presses Enter. * the field or presses Enter.
* *
* @param stored the value currently stored; the draft falls back to it when an * Only an edited draft is committed: drawing the field, or entering and leaving it
* entry is refused, which is what those fields did with setText * without typing, commits nothing, and neither does Enter on an unchanged value.
* A field removed while it holds the focus -- its tab switched away, its window
* closed -- commits a typed entry once: Compose reports the cleared focus to the
* field as a focus loss.
*
* @param stored the value stored when the caller composed; the draft starts from it
* @param commit returns null when the value was accepted, otherwise the reason * @param commit returns null when the value was accepted, otherwise the reason
* @param onRefused shown to the operator; the JavaFX fields opened an alert * @param onRefused shown to the operator; the JavaFX fields opened an alert
* @param isUnchanged whether the draft names the stored value. Textual by default,
* looser where two spellings mean one value, as with colours. It must agree
* with what [commit] stores: a draft that commit would store as `c` has to count
* as unchanged against `c`, or leaving the field after Enter commits it again.
* @param current the value in force right now, read when the field commits: inside the
* settings window the value staged in the draft, otherwise the stored one. The
* typed text is judged against it, and falls back to it on a refusal (what the
* JavaFX fields did with setText). Required, because [stored] alone goes stale
* when it comes from a plain getter: nothing need recompose the caller after a
* commit, so after Enter it can still name the old value.
*/ */
@Composable @Composable
fun committed( fun committed(
@@ -122,24 +156,67 @@ object Form {
stored: String, stored: String,
commit: (String) -> String?, commit: (String) -> String?,
onRefused: (String) -> Unit, onRefused: (String) -> Unit,
isUnchanged: (draft: String, stored: String) -> Boolean = { typed, now -> typed == now },
current: () -> String,
) { ) {
var draft by remember(stored) { mutableStateOf(stored) } var draft by remember(stored) { mutableStateOf(stored) }
/*
* Whether the field has held the focus since it was last committed. onFocusChanged
* also fires when the modifier is first attached, with "not focused"; reading that
* as a focus loss committed every such field the moment its tab was opened. Only a
* change from focused to unfocused is the operator leaving the field. A plain holder,
* not state: nothing is drawn from it.
*/
val hadFocus = remember { booleanArrayOf(false) }
fun commitDraft() { fun commitDraft() {
/*
* An untouched draft is no edit. Committing it anyway would turn a shipped colour
* into an override of itself, restart the beacon timer, or report a stored value
* the rule refuses although the operator only passed through the field.
*/
if (isUnchanged(draft, current())) return
val refusal = commit(draft) val refusal = commit(draft)
if (refusal != null) { if (refusal != null) {
onRefused(refusal) onRefused(refusal)
draft = stored draft = current()
} }
} }
/*
* Inside the settings window the draft can commit this field without a focus change:
* a click on "Apply/Close prefs" may leave the focus in the field, and the entry would
* otherwise reach the draft only when the closing window removed the field. Through
* an updated state, because commitDraft closes over this composition's parameters.
* A refusal is reported by commitDraft itself through onRefused, as on focus loss, so
* the registration answers null and the caller sees the refusal as the notice.
*/
val settingsDraft = LocalSettingsDraft.current
val latestCommit = rememberUpdatedState(::commitDraft)
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry {
latestCommit.value()
null
}
onDispose { entry?.close() }
}
labelled(label) { labelled(label) {
CompactTextField( CompactTextField(
value = draft, value = draft,
onValueChange = { draft = it }, onValueChange = { draft = it },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { focus -> if (!focus.isFocused) commitDraft() } .onFocusChanged { focus ->
if (focus.isFocused) {
hadFocus[0] = true
} else if (hadFocus[0]) {
hadFocus[0] = false
commitDraft()
}
}
.onKeyEvent { event -> .onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) { if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
commitDraft() commitDraft()
@@ -172,6 +249,7 @@ object Form {
val draft = rememberDraft(selected) val draft = rememberDraft(selected)
labelled(label) { labelled(label) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
Box { Box {
OutlinedButton( OutlinedButton(
onClick = { expanded = true }, onClick = { expanded = true },
@@ -180,18 +258,20 @@ object Form {
contentPadding = Density.FIELD_CONTENT_PADDING, contentPadding = Density.FIELD_CONTENT_PADDING,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = Density.FIELD_MIN_HEIGHT), .heightIn(min = Density.CONTROL_HEIGHT),
) { ) {
Text( Text(
draft.value?.let(describe) ?: "not selected", draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
style = MaterialTheme.typography.bodyMedium, style = compactControlTextStyle(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
items.forEach { item -> items.forEach { item ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) }, text = { Text(describe(item), style = compactControlTextStyle()) },
contentPadding = Density.FIELD_CONTENT_PADDING,
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
onClick = { onClick = {
expanded = false expanded = false
draft.value = item draft.value = item
@@ -203,6 +283,7 @@ object Form {
} }
} }
} }
}
@Composable @Composable
fun <T> inlineChoice( fun <T> inlineChoice(
@@ -211,30 +292,39 @@ object Form {
selected: T?, selected: T?,
describe: (T) -> String, describe: (T) -> String,
enabled: Boolean = true, enabled: Boolean = true,
trailingText: String = "",
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val draft = rememberDraft(selected) val draft = rememberDraft(selected)
Row(verticalAlignment = Alignment.CenterVertically) { Row(
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp)) verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
) {
if (label.isNotBlank()) {
Text(label, style = compactControlTextStyle(), modifier = Modifier.padding(end = 4.dp))
}
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
Box { Box {
OutlinedButton( OutlinedButton(
onClick = { expanded = true }, onClick = { expanded = true },
enabled = enabled, enabled = enabled,
shape = Density.BUTTON_SHAPE, shape = Density.BUTTON_SHAPE,
contentPadding = Density.FIELD_CONTENT_PADDING, contentPadding = Density.FIELD_CONTENT_PADDING,
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT), modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
) { ) {
Text( Text(
draft.value?.let(describe) ?: "not selected", draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
style = MaterialTheme.typography.bodyMedium, style = compactControlTextStyle(),
) )
} }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
items.forEach { item -> items.forEach { item ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) }, text = { Text(describe(item), style = compactControlTextStyle()) },
contentPadding = Density.FIELD_CONTENT_PADDING,
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
onClick = { onClick = {
expanded = false expanded = false
draft.value = item draft.value = item
@@ -245,53 +335,121 @@ object Form {
} }
} }
} }
if (trailingText.isNotBlank()) {
Text(
trailingText,
style = compactControlTextStyle(),
modifier = Modifier.padding(start = 4.dp),
)
}
}
} }
@Composable @Composable
fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) { fun check(
label: String,
value: Boolean,
enabled: Boolean = true,
onChange: (Boolean) -> Unit,
) {
val draft = rememberDraft(value) val draft = rememberDraft(value)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT), modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
) { ) {
CompactCheckbox(draft.value) { ticked -> CompactCheckbox(draft.value, enabled = enabled) { ticked ->
draft.value = ticked draft.value = ticked
onChange(ticked) onChange(ticked)
} }
Text(label, modifier = Modifier.padding(start = 6.dp)) Text(
} label,
} color = if (enabled) {
MaterialTheme.colorScheme.onSurface
@Composable } else {
fun int(label: String, value: Int, onChange: (Int) -> Unit) { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
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, style = compactControlTextStyle(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.padding(start = 4.dp),
) )
} }
} }
/** A compact two-state button for dense desktop option groups. */
@Composable @Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) { fun toggle(
var draft by remember(value) { mutableStateOf(value.toString()) } text: String,
checked: Boolean,
enabled: Boolean = true,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit,
) {
CompactToggleButton(
text = text,
checked = checked,
enabled = enabled,
modifier = modifier,
onCheckedChange = onCheckedChange,
)
}
@Composable
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
number(label, value, { it.trim().toIntOrNull() }, onChange)
@Composable
fun decimal(
label: String,
value: Double,
isValid: (Double) -> Boolean = { true },
onChange: (Double) -> Unit,
) = number(
label,
value,
{ text -> text.trim().replace(',', '.').toDoubleOrNull()?.takeIf(isValid) },
onChange,
)
/**
* The number field behind [int] and [decimal].
*
* The text follows the stored value only when that value is not what the operator's own
* text already says. A settings draft is Compose state, so the value an entry stages comes
* straight back as [value]; resetting the text to `value.toString()` then would turn a
* typed "1" into "1.0" before the next digit, and "135,5" could never be typed.
*/
@Composable
private fun <N> number(label: String, value: N, parse: (String) -> N?, onChange: (N) -> Unit) {
// The last text typed. A plain holder: it only seeds the text below, nothing draws it.
val typed = remember { arrayOf(value.toString()) }
var draft by remember(value) {
mutableStateOf(if (parse(typed[0]) == value) typed[0] else value.toString())
}
/*
* Text that does not parse stages nothing, so the draft still holds the last valid
* value while the field shows something else in red. Applying then would put a value
* into effect the operator no longer sees; the field refuses instead, naming itself.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (parse(draft) == null) strings.settingsInvalidEntry(label, draft) else null
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
labelled(label) { labelled(label) {
CompactTextField( CompactTextField(
value = draft, value = draft,
onValueChange = { typed -> onValueChange = { entered ->
draft = typed typed[0] = entered
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange) draft = entered
parse(entered)?.let(onChange)
}, },
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null, isError = parse(draft) == null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
@@ -322,6 +480,30 @@ object Form {
} }
} }
/** A compact settings group that needs stronger separation than a labelled divider. */
@Composable
fun panelSection(title: String, content: @Composable () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
shape = Density.BUTTON_SHAPE,
)
.border(
width = Density.HAIRLINE,
color = MaterialTheme.colorScheme.outlineVariant,
shape = Density.BUTTON_SHAPE,
)
.padding(Density.GROUP_CONTENT_PADDING),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Text(title, style = MaterialTheme.typography.titleSmall)
content()
}
}
/** /**
* A section whose controls flow across the width instead of stacking. * A section whose controls flow across the width instead of stacking.
* *
@@ -351,26 +533,49 @@ object Form {
} }
/** /**
* The button of the settings windows: square-ish corners and desktop padding. * The shared desktop button used in settings, filter and shortcut areas.
*
* 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 * 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: * 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. * a gradient while the pointer is over it and a red border while it is held down.
*/ */
@Composable @Composable
fun button(text: String, enabled: Boolean = true, onClick: () -> Unit) { fun button(
text: String,
enabled: Boolean = true,
accented: Boolean = false,
selected: Boolean = false,
modifier: Modifier = Modifier,
horizontalPadding: Dp = 6.dp,
onClick: () -> Unit,
) {
val palette = LocalJavaFxPalette.current val palette = LocalJavaFxPalette.current
val interactions = remember { MutableInteractionSource() } val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState() val hovered by interactions.collectIsHoveredAsState()
val pressed by interactions.collectIsPressedAsState() val pressed by interactions.collectIsPressedAsState()
val hoverGradient = palette.buttonHoverGradient val hoverGradient = palette.buttonHoverGradient
val accentGradient = if (palette.windowBackground.luminance() < 0.5f) {
listOf(Color(0xFF008000), Color(0xFF90EE90))
} else {
listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
}
val backgroundGradient = when {
!enabled -> emptyList()
hovered && hoverGradient.size >= 2 -> hoverGradient
accented -> accentGradient
else -> emptyList()
}
val containerColor = when {
backgroundGradient.isNotEmpty() -> Color.Transparent
selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = when {
hovered && enabled && hoverGradient.isNotEmpty() -> Color.Black
accented -> Color(0xFF395306)
selected -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
CompositionLocalProvider( CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides 0.dp LocalMinimumInteractiveComponentSize provides 0.dp
@@ -379,35 +584,28 @@ object Form {
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
shape = Density.BUTTON_SHAPE, shape = Density.BUTTON_SHAPE,
contentPadding = Density.BUTTON_CONTENT_PADDING, contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 0.dp),
interactionSource = interactions, interactionSource = interactions,
border = if (pressed) { border = when {
BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder) pressed -> BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
} else { enabled -> BorderStroke(Density.HAIRLINE, MaterialTheme.colorScheme.outline)
null else -> BorderStroke(
}, Density.HAIRLINE,
colors = if (hovered && enabled && hoverGradient.isNotEmpty()) { MaterialTheme.colorScheme.outline.copy(alpha = 0.38f),
/*
* 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 colors = ButtonDefaults.buttonColors(
.heightIn(min = Density.BUTTON_MIN_HEIGHT) containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f),
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
),
modifier = modifier
.heightIn(min = Density.CONTROL_HEIGHT)
.then( .then(
if (hovered && enabled && hoverGradient.size >= 2) { if (backgroundGradient.size >= 2) {
Modifier.background( Modifier.background(
brush = Brush.verticalGradient(hoverGradient), brush = Brush.verticalGradient(backgroundGradient),
shape = Density.BUTTON_SHAPE, shape = Density.BUTTON_SHAPE,
) )
} else { } else {
@@ -420,7 +618,11 @@ object Form {
* bar instead of simply being too narrow, and that bar then pushes the rest * bar instead of simply being too narrow, and that bar then pushes the rest
* of the window out of view. * of the window out of view.
*/ */
Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 1) Text(
text,
style = compactControlTextStyle(),
maxLines = 1,
)
} }
} }
} }
@@ -459,6 +661,138 @@ object Form {
} }
} }
/**
* A content-sized toggle with the same typography, height and palette as the shared
* desktop buttons. Checkbox semantics keep keyboard and accessibility behaviour while
* avoiding the extra symbol and label width of a checkbox row.
*/
@Composable
internal fun CompactToggleButton(
text: String,
checked: Boolean,
enabled: Boolean = true,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit,
) {
val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState()
val focused by interactions.collectIsFocusedAsState()
val pressed by interactions.collectIsPressedAsState()
val containerColor = when {
!enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f)
pressed -> MaterialTheme.colorScheme.primaryContainer
hovered && checked -> MaterialTheme.colorScheme.primaryContainer
checked -> MaterialTheme.colorScheme.primary
hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f)
else -> MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = when {
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
(pressed || hovered) && checked -> MaterialTheme.colorScheme.onPrimaryContainer
checked -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val outlineColor = when {
focused || checked -> MaterialTheme.colorScheme.primary
enabled -> MaterialTheme.colorScheme.outline
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.38f)
}
Box(
modifier = modifier
.heightIn(min = Density.CONTROL_HEIGHT)
.background(containerColor, Density.BUTTON_SHAPE)
.border(
if (focused) Density.SELECTION_BORDER else Density.HAIRLINE,
outlineColor,
Density.BUTTON_SHAPE,
)
.toggleable(
value = checked,
enabled = enabled,
role = Role.Checkbox,
interactionSource = interactions,
indication = null,
onValueChange = onCheckedChange,
)
.padding(horizontal = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = compactControlTextStyle(),
color = contentColor,
maxLines = 1,
)
}
}
/**
* A compact desktop tab with the same interaction palette as the shared controls.
* The selected surface visually continues into the content below it, while the border
* keeps inactive entries recognizable as tabs in both palettes.
*/
@Composable
internal fun CompactTab(
text: String,
selected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState()
val focused by interactions.collectIsFocusedAsState()
val pressed by interactions.collectIsPressedAsState()
val primary = MaterialTheme.colorScheme.primary
val background = when {
pressed -> MaterialTheme.colorScheme.primaryContainer
selected -> MaterialTheme.colorScheme.surface
hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f)
else -> MaterialTheme.colorScheme.surfaceVariant
}
val outline = when {
selected || focused -> primary
else -> MaterialTheme.colorScheme.outlineVariant
}
Box(
modifier = modifier
.heightIn(min = Density.TAB_MIN_HEIGHT)
.background(background)
.border(
if (focused) Density.SELECTION_BORDER else Density.HAIRLINE,
outline,
)
.selectable(
selected = selected,
interactionSource = interactions,
indication = null,
role = Role.Tab,
onClick = onClick,
)
.drawBehind {
if (selected) {
val thickness = 2.dp.toPx()
drawRect(
color = primary,
topLeft = Offset(0f, size.height - thickness),
size = Size(size.width, thickness),
)
}
}
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 3.dp),
contentAlignment = Alignment.Center,
) {
Text(
text,
style = compactControlTextStyle(),
color = if (selected) primary else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
/** /**
* A text field at desktop density. * A text field at desktop density.
* *
@@ -484,6 +818,7 @@ internal fun CompactTextField(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
isError: Boolean = false, isError: Boolean = false,
placeholder: String = "",
visualTransformation: VisualTransformation = VisualTransformation.None, visualTransformation: VisualTransformation = VisualTransformation.None,
borderColor: Color? = null, borderColor: Color? = null,
borderThickness: Dp? = null, borderThickness: Dp? = null,
@@ -517,11 +852,11 @@ internal fun CompactTextField(
onValueChange = onValueChange, onValueChange = onValueChange,
enabled = enabled, enabled = enabled,
singleLine = true, singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor), textStyle = compactControlTextStyle().copy(color = textColor),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT), modifier = modifier.heightIn(min = Density.CONTROL_HEIGHT),
) { innerTextField -> ) { innerTextField ->
OutlinedTextFieldDefaults.DecorationBox( OutlinedTextFieldDefaults.DecorationBox(
value = value, value = value,
@@ -533,6 +868,19 @@ internal fun CompactTextField(
isError = isError, isError = isError,
colors = colors, colors = colors,
contentPadding = Density.FIELD_CONTENT_PADDING, contentPadding = Density.FIELD_CONTENT_PADDING,
placeholder = if (placeholder.isNotEmpty()) {
{
Text(
placeholder,
style = compactControlTextStyle().copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
maxLines = 1,
)
}
} else {
null
},
container = { container = {
OutlinedTextFieldDefaults.Container( OutlinedTextFieldDefaults.Container(
enabled = enabled, enabled = enabled,
@@ -549,16 +897,21 @@ internal fun CompactTextField(
} }
/** /**
* A checkbox without the 48dp touch target Material reserves around it. On a desktop * A checkbox on the shared compact control surface instead of Material's 48dp touch target.
* form that target is dead space between every two rows.
*/ */
@Composable @Composable
internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { internal fun CompactCheckbox(
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { checked: Boolean,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
Checkbox( Checkbox(
checked = checked, checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
modifier = Modifier.size(Density.CHECK_SIZE), modifier = modifier.size(Density.CONTROL_HEIGHT),
) )
} }
} }
@@ -6,14 +6,20 @@ import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -21,8 +27,16 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar import androidx.compose.ui.window.MenuBar
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kst4contest.controller.On4KstConnectionState import kst4contest.controller.On4KstConnectionState
import kst4contest.utils.PlatformUtils import kst4contest.utils.PlatformUtils
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
@@ -163,7 +177,14 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
val hovered by interactions.collectIsHoveredAsState() val hovered by interactions.collectIsHoveredAsState()
val open = rowState.openMenu == menu.title val open = rowState.openMenu == menu.title
Box { /*
* Full bar height, so the popup anchored to this box starts at the bar's bottom edge
* rather than at the centred title text a few pixels above it.
*/
Box(
modifier = Modifier.heightIn(min = Density.TAB_MIN_HEIGHT),
contentAlignment = Alignment.Center,
) {
Text( Text(
text = menu.title, text = menu.title,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -195,7 +216,30 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
), ),
) )
DropdownMenu(expanded = open, onDismissRequest = rowState::close) { /*
* A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open
* flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside
* click and Escape come with a focusable popup), same MenuDefaults look; only the
* expand animation is gone, which a menu bar does not have anyway.
*/
if (open) {
Popup(
popupPositionProvider = MenuRowPopupPositionProvider,
onDismissRequest = rowState::close,
properties = PopupProperties(focusable = true),
) {
Surface(
shape = MenuDefaults.shape,
color = MenuDefaults.containerColor,
tonalElevation = MenuDefaults.TonalElevation,
shadowElevation = MenuDefaults.ShadowElevation,
) {
Column(
modifier = Modifier
.padding(vertical = MENU_POPUP_VERTICAL_PADDING)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),
) {
menu.entries.forEach { entry -> menu.entries.forEach { entry ->
when (entry) { when (entry) {
is MenuEntry.Item -> DropdownMenuItem( is MenuEntry.Item -> DropdownMenuItem(
@@ -216,8 +260,46 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
} }
} }
} }
}
}
}
/**
* Places a menu-bar menu: flush under its anchor, leading edges aligned.
*
* Material3's DropdownMenu cannot be told to do this. Its position provider refuses any
* position closer than 48 dp (`MenuVerticalMargin`) to the window's top edge, and a menu
* bar sits exactly there, so every menu opened pushed down to that margin — a visible gap
* below the bar. The margin is applied after the `offset` parameter and the provider is
* not replaceable, hence this one and the hand-rolled popup in [MenuTitle].
*/
internal object MenuRowPopupPositionProvider : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val preferredX = when (layoutDirection) {
LayoutDirection.Ltr -> anchorBounds.left
LayoutDirection.Rtl -> anchorBounds.right - popupContentSize.width
}
val x = preferredX.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width))
val below = anchorBounds.bottom
val above = anchorBounds.top - popupContentSize.height
val y = when {
below + popupContentSize.height <= windowSize.height -> below
above >= 0 -> above
else -> maxOf(0, windowSize.height - popupContentSize.height)
}
return IntOffset(x, y)
}
}
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */ /** 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 const val HOVER_ALPHA = 0.35f
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f) private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
/** Material3's DropdownMenuVerticalPadding, which is internal; keep the menus looking alike. */
private val MENU_POPUP_VERTICAL_PADDING = 8.dp
@@ -9,8 +9,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.FrameWindowScope
import kst4contest.model.Band
import kst4contest.model.ChatMember import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage import kst4contest.model.ChatMessage
import kst4contest.utils.PlatformUtils import kst4contest.utils.PlatformUtils
@@ -31,7 +31,6 @@ import java.util.function.Consumer
fun FrameWindowScope.MainWindow( fun FrameWindowScope.MainWindow(
state: MainWindowState, state: MainWindowState,
actions: MainMenuActions, actions: MainMenuActions,
skedBands: List<Band>,
openInBrowser: Consumer<String>, openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit, onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit, onShowPathInAirScout: () -> Unit,
@@ -42,6 +41,7 @@ fun FrameWindowScope.MainWindow(
* message aims at whoever it was addressed to rather than at oneself. * message aims at whoever it was addressed to rather than at oneself.
*/ */
onDirectedMessageClicked: (ChatMessage) -> Unit, onDirectedMessageClicked: (ChatMessage) -> Unit,
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
/** Whether a message came from this station; decides its row colour. */ /** Whether a message came from this station; decides its row colour. */
isOwnMessage: (ChatMessage) -> Boolean, isOwnMessage: (ChatMessage) -> Boolean,
/** How old a message is, for the fading highlight. */ /** How old a message is, for the fading highlight. */
@@ -133,6 +133,7 @@ fun FrameWindowScope.MainWindow(
state = state, state = state,
onCandidateClicked = onCandidateClicked, onCandidateClicked = onCandidateClicked,
onDirectedMessageClicked = onDirectedMessageClicked, onDirectedMessageClicked = onDirectedMessageClicked,
onMessageStationClicked = onMessageStationClicked,
isOwnMessage = isOwnMessage, isOwnMessage = isOwnMessage,
messageAgeSeconds = messageAgeSeconds, messageAgeSeconds = messageAgeSeconds,
) )
@@ -140,7 +141,6 @@ fun FrameWindowScope.MainWindow(
{ {
StationColumn( StationColumn(
state = state, state = state,
skedBands = skedBands,
openInBrowser = openInBrowser, openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap, onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout, onShowPathInAirScout = onShowPathInAirScout,
@@ -167,6 +167,7 @@ private fun MessageColumn(
state: MainWindowState, state: MainWindowState,
onCandidateClicked: (TimelineCandidate) -> Unit, onCandidateClicked: (TimelineCandidate) -> Unit,
onDirectedMessageClicked: (ChatMessage) -> Unit, onDirectedMessageClicked: (ChatMessage) -> Unit,
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
isOwnMessage: (ChatMessage) -> Boolean, isOwnMessage: (ChatMessage) -> Boolean,
messageAgeSeconds: (ChatMessage) -> Long, messageAgeSeconds: (ChatMessage) -> Long,
) { ) {
@@ -174,6 +175,13 @@ private fun MessageColumn(
state = state.messageSplitter, state = state.messageSplitter,
orientation = SplitterOrientation.VERTICAL, orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
minimumPaneSizes = listOf(
SplitterState.DEFAULT_MIN_PANE_PX.dp,
Density.CONTROL_HEIGHT,
TimelineGeometry.HEIGHT.dp,
Density.CONTROL_HEIGHT,
SplitterState.DEFAULT_MIN_PANE_PX.dp,
),
panes = listOf( panes = listOf(
{ {
SnippetContextMenu(state.snippets.entries, state.chatInput) { SnippetContextMenu(state.snippets.entries, state.chatInput) {
@@ -205,6 +213,8 @@ private fun MessageColumn(
ownQrgMain = state.ownQrgMain, ownQrgMain = state.ownQrgMain,
ownQrgSecond = state.ownQrgSecond, ownQrgSecond = state.ownQrgSecond,
antennaQtf = state.antennaQtf, antennaQtf = state.antennaQtf,
antennaQtf2 = state.antennaQtf2,
so2rEnabled = state.surroundings.so2rEnabled,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
}, },
@@ -214,6 +224,7 @@ private fun MessageColumn(
publicMessages = state.publicMessages, publicMessages = state.publicMessages,
clusterMessages = state.clusterMessages, clusterMessages = state.clusterMessages,
qsoOfTheOther = state.qsoOfTheOther, qsoOfTheOther = state.qsoOfTheOther,
onStationMessageClicked = onMessageStationClicked,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -228,7 +239,6 @@ private fun MessageColumn(
@Composable @Composable
private fun StationColumn( private fun StationColumn(
state: MainWindowState, state: MainWindowState,
skedBands: List<Band>,
openInBrowser: Consumer<String>, openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit, onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit, onShowPathInAirScout: () -> Unit,
@@ -236,6 +246,10 @@ private fun StationColumn(
onFiltersChanged: () -> Unit, onFiltersChanged: () -> Unit,
onShowMorePriorities: () -> Unit, onShowMorePriorities: () -> Unit,
) { ) {
val stationContextMenu = remember(state) {
StationContextMenuState(state::selectStation)
}
SplitterPane( SplitterPane(
state = state.rightSplitter, state = state.rightSplitter,
orientation = SplitterOrientation.VERTICAL, orientation = SplitterOrientation.VERTICAL,
@@ -246,24 +260,37 @@ private fun StationColumn(
StationFilterBar( StationFilterBar(
filters = state.stationFilter, filters = state.stationFilter,
activeBands = state.activeBands, activeBands = state.activeBands,
reachability = state.reachability,
onChanged = { onChanged = {
onFiltersChanged() onFiltersChanged()
}, },
onQtfDirectionChanged = state::applyExplicitQtfFilterDirection,
onReset = state::resetStationFilters,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
HorizontalDivider(thickness = Density.HAIRLINE) HorizontalDivider(thickness = Density.HAIRLINE)
SnippetContextMenu(state.snippets.entries, state.chatInput) {
DataTableView( DataTableView(
state = state.stations, state = state.stations,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
emphasizeCell = { member, column ->
column.id == "callsign" && state.newJoined.isRecent(member)
},
onRowClick = onStationSelected, onRowClick = onStationSelected,
onRowContextMenu = { member, position ->
stationContextMenu.open(
listOf(member),
StationSelectionSource.STATION_CONTEXT_MENU,
position,
)
},
selectOnRowClick = false,
) )
}
} }
}, },
{ {
TopPriorityBar( TopPriorityBar(
state = state.topPriority, state = state.topPriority,
newJoined = state.newJoined,
onShowMore = onShowMorePriorities, onShowMore = onShowMorePriorities,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -272,8 +299,8 @@ private fun StationColumn(
SelectedStationPanel( SelectedStationPanel(
state = state.selectedStation, state = state.selectedStation,
messages = state.selectedStationMessages, messages = state.selectedStationMessages,
skedBands = skedBands,
activeBands = state.activeBands, activeBands = state.activeBands,
contentSplitter = state.selectedStationSplitter,
openInBrowser = openInBrowser, openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap, onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout, onShowPathInAirScout = onShowPathInAirScout,
@@ -282,4 +309,5 @@ private fun StationColumn(
}, },
), ),
) )
StationContextOverlays(stationContextMenu, state, onShowOnMap)
} }
@@ -1,6 +1,7 @@
package kst4contest.view.compose package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
/** How big the main window opens. */ /** How big the main window opens. */
data class MainWindowSize(val heightDp: Double, val widthDp: Double) data class MainWindowSize(val heightDp: Double, val widthDp: Double)
@@ -56,30 +57,75 @@ object MainWindowFrame {
fun title(chatState: String, profileName: String?): String = fun title(chatState: String, profileName: String?): String =
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName" if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
/**
* What the title's first half says.
*
* The live connection line while the chat is usable -- exactly what the JavaFX title
* carried -- and a wording about the attempt otherwise. The connection manager's own
* detail ("session is authenticated and synchronized") is a diagnostic for the status
* bar, not a title: it says nothing about category, call or traffic.
*
* @param state the connection state, null before the first push
* @param onlineLine the line built by [onlineChatState], asked for only while online
* @return the chat half of the title
*/
fun chatState(state: On4KstConnectionState?, onlineLine: () -> String): String =
if (state == On4KstConnectionState.ONLINE) onlineLine() else offlineChatState(state)
/**
* The connection line while the chat is usable, as the JavaFX title had it:
* "Connected to: 2: 144/432 MHz as DO1JKS (Johannes 70/2) in JN59MU (106 users online,
* 106 shown), 14 messages total."
*
* Counts are passed as text so MessageFormat does not group them by locale. Without a
* main category there is nothing to name, so the line falls back to the offline wording
* for ONLINE instead of reading "Connected to: as ...".
*
* @param mainCategory the main chat category, null when none is configured
* @param secondCategory the second category, null when it is not logged in
* @param callSign the login callsign
* @param name the login name of the main category
* @param locator the login locator
* @param usersOnline every station in the chat
* @param shown the stations the filters let through
* @param messagesTotal every chat message held, the global list behind the public tab
*/
fun onlineChatState(
mainCategory: ChatCategory?,
secondCategory: ChatCategory?,
callSign: String?,
name: String?,
locator: String?,
usersOnline: Int,
shown: Int,
messagesTotal: Int,
): String {
if (mainCategory == null) {
return offlineChatState(On4KstConnectionState.ONLINE)
}
val strings = kst4contest.view.i18n.CurrentStrings.get()
val categories = if (secondCategory == null) {
mainCategory.toString()
} else {
strings.titleBothCategories(mainCategory.toString(), secondCategory.toString())
}
return strings.titleOnline(
categories,
callSign.orEmpty(),
name.orEmpty(),
locator.orEmpty(),
usersOnline.toString(),
shown.toString(),
messagesTotal.toString(),
)
}
/** /**
* What the title says while the chat is not usable. * What the title says while the chat is not usable.
* *
* The wording separates a scheduled reconnect from a dead session, which is the * The wording separates a scheduled reconnect from a dead session, which is the
* difference between waiting and doing something about it. * 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 = fun offlineChatState(state: On4KstConnectionState?): String =
when (state ?: On4KstConnectionState.DISCONNECTED) { when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
@@ -1,6 +1,5 @@
package kst4contest.view.compose package kst4contest.view.compose
import kst4contest.model.Band
import kst4contest.model.ChatMember import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage import kst4contest.model.ChatMessage
import java.util.function.Consumer import java.util.function.Consumer
@@ -16,7 +15,7 @@ import androidx.compose.runtime.setValue
*/ */
object MainWindowHost { object MainWindowHost {
private val host = ComposeWindowHost("main-window") private val host = ComposeWindowHost("main-window", isMainWindow = true)
@JvmStatic @JvmStatic
val isOpen: Boolean val isOpen: Boolean
@@ -34,12 +33,12 @@ object MainWindowHost {
*/ */
private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null) private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null)
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(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 currentOpenInBrowser by androidx.compose.runtime.mutableStateOf<Consumer<String>?>(null)
private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null) private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnShowPathInAirScout 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 currentOnStationSelected by androidx.compose.runtime.mutableStateOf<((ChatMember) -> Unit)?>(null)
private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null) private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
private var currentOnMessageStationClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage, StationSelectionSource) -> Unit)?>(null)
private var currentIsOwnMessage by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Boolean)?>(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 currentMessageAgeSeconds by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Long)?>(null)
private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null) private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
@@ -79,12 +78,12 @@ object MainWindowHost {
* leave the client running with the chat connected and nothing to see it in. * leave the client running with the chat connected and nothing to see it in.
*/ */
onCloseRequest: () -> Unit, onCloseRequest: () -> Unit,
skedBands: () -> List<Band>,
openInBrowser: Consumer<String>, openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit, onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit, onShowPathInAirScout: () -> Unit,
onStationSelected: (ChatMember) -> Unit, onStationSelected: (ChatMember) -> Unit,
onDirectedMessageClicked: (ChatMessage) -> Unit, onDirectedMessageClicked: (ChatMessage) -> Unit,
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
isOwnMessage: (ChatMessage) -> Boolean, isOwnMessage: (ChatMessage) -> Boolean,
messageAgeSeconds: (ChatMessage) -> Long, messageAgeSeconds: (ChatMessage) -> Long,
onFiltersChanged: () -> Unit, onFiltersChanged: () -> Unit,
@@ -93,12 +92,12 @@ object MainWindowHost {
) { ) {
currentState = state currentState = state
currentActions = actions currentActions = actions
currentSkedBands = skedBands
currentOpenInBrowser = openInBrowser currentOpenInBrowser = openInBrowser
currentOnShowOnMap = onShowOnMap currentOnShowOnMap = onShowOnMap
currentOnShowPathInAirScout = onShowPathInAirScout currentOnShowPathInAirScout = onShowPathInAirScout
currentOnStationSelected = onStationSelected currentOnStationSelected = onStationSelected
currentOnDirectedMessageClicked = onDirectedMessageClicked currentOnDirectedMessageClicked = onDirectedMessageClicked
currentOnMessageStationClicked = onMessageStationClicked
currentIsOwnMessage = isOwnMessage currentIsOwnMessage = isOwnMessage
currentMessageAgeSeconds = messageAgeSeconds currentMessageAgeSeconds = messageAgeSeconds
currentOnFiltersChanged = onFiltersChanged currentOnFiltersChanged = onFiltersChanged
@@ -111,14 +110,29 @@ object MainWindowHost {
* Read inside the composition, so the title follows the connection the way * 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 * 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 * (73 users online, 73 shown), 29 messages total." A captured string would
* say "disconnected" for the life of the session. * say "disconnected" for the life of the session. The counts are Compose
* state, so the title moves with every station and message. visibleRows
* costs nothing extra: it is cached per change, and the station table
* reads the same list anyway.
*/ */
val surroundings = currentState?.surroundings val state = currentState
MainWindowFrame.title( MainWindowFrame.title(
chatState = MainWindowFrame.chatState( chatState = if (state == null) {
surroundings?.connectionState, MainWindowFrame.offlineChatState(null)
surroundings?.connectionDetail, } else MainWindowFrame.chatState(state.surroundings.connectionState) {
), val prefs = state.prefs
MainWindowFrame.onlineChatState(
mainCategory = prefs.getLoginChatCategoryMain(),
secondCategory = prefs.getLoginChatCategorySecond()
.takeIf { prefs.isLoginToSecondChatEnabled() },
callSign = prefs.getStn_loginCallSign(),
name = prefs.getStn_loginNameMainCat(),
locator = prefs.getStn_loginLocatorMainCat(),
usersOnline = state.stations.rows.size,
shown = state.stations.visibleRows.size,
messagesTotal = state.publicMessages.rows.size,
)
},
profileName = profileName, profileName = profileName,
) )
}, },
@@ -136,12 +150,12 @@ object MainWindowHost {
MainWindow( MainWindow(
state = s, state = s,
actions = a, actions = a,
skedBands = currentSkedBands?.invoke() ?: emptyList(),
openInBrowser = currentOpenInBrowser ?: Consumer { _ -> }, openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
onShowOnMap = currentOnShowOnMap ?: {}, onShowOnMap = currentOnShowOnMap ?: {},
onShowPathInAirScout = currentOnShowPathInAirScout ?: {}, onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
onStationSelected = currentOnStationSelected ?: {}, onStationSelected = currentOnStationSelected ?: {},
onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {}, onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
onMessageStationClicked = currentOnMessageStationClicked ?: { _, _ -> },
isOwnMessage = currentIsOwnMessage ?: { false }, isOwnMessage = currentIsOwnMessage ?: { false },
messageAgeSeconds = currentMessageAgeSeconds ?: { 0L }, messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
onFiltersChanged = currentOnFiltersChanged ?: {}, onFiltersChanged = currentOnFiltersChanged ?: {},
@@ -55,19 +55,68 @@ class MainWindowState(
val bandUpgradeNotice: BlinkingNotice, val bandUpgradeNotice: BlinkingNotice,
val threadButtons: ThreadStatusButtons, val threadButtons: ThreadStatusButtons,
/** The three splitters, whose positions the operator has arranged and stored. */ /** The splitters whose positions the operator has arranged and stored. */
val outerSplitter: SplitterState, val outerSplitter: SplitterState,
val messageSplitter: SplitterState, val messageSplitter: SplitterState,
val rightSplitter: SplitterState, val rightSplitter: SplitterState,
val selectedStationSplitter: SplitterState = SplitterState(
SELECTED_STATION_PANE_COUNT,
doubleArrayOf(0.55),
) {},
val newJoined: NewJoinedState = NewJoinedState(onSelect = {}),
val reachability: ReachabilityState = ReachabilityState(activeBands),
) { ) {
/** Explicit map analysis requested from either station context menu. */
val mapReachability = MapReachabilityState(prefs.stn_loginLocatorMainCat.orEmpty())
/** The shortcut buttons above the send line. */ /** The shortcut buttons above the send line. */
/** /**
* The one path that selects a station for the whole application (panel, table, score * 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 * 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. * map, go through it instead of touching [selectedStation] alone. Null clears.
*/ */
var stationSelector: ((ChatMember?) -> Unit)? = null var stationSelector: ((ChatMember?, StationSelectionSource) -> Unit)? = null
/** Application callback that rebuilds the controller and Compose station filters. */
var stationFiltersChanged: (() -> Unit)? = null
/** Debounced persistence hook shared by the main and map windows. */
var layoutSaveRequested: (() -> Unit)? = null
/** Contactinfo-equivalent manual Worked action supplied by the controller host. */
var markStationWorked: ((ChatMember, kst4contest.model.Band) -> Boolean)? = null
/** Persistent any-band Worked gross fields used by the optional map overlay. */
var workedGrossFieldsProvider: (() -> Set<String>) = { emptySet() }
val stationSelection: StationSelectionState
get() = selectedStation.stationSelection
/** Routes every explicit station choice through the application-level action. */
fun selectStation(member: ChatMember?, source: StationSelectionSource) {
mapReachability.selectionChanged(StationIdentity.of(member))
val selector = stationSelector
if (selector != null) {
selector(member, source)
} else {
selectedStation.select(member, source)
}
}
fun resetStationFilters() {
stationFilter.reset()
stationFiltersChanged?.invoke()
}
/** Applies only a deliberate QTF filter entry, never a restored or reset filter value. */
fun applyExplicitQtfFilterDirection(qtf: Double) {
applyExplicitQtfHeading(prefs, qtf)
}
fun requestLayoutSave() {
layoutSaveRequested?.invoke()
}
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList()) val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -75,13 +124,14 @@ class MainWindowState(
val snippets = RosterMirror(prefs.getLst_txtSnipList()) val snippets = RosterMirror(prefs.getLst_txtSnipList())
/** /**
* The three small fields beside the send controls. Mirrored rather than read straight * The four small fields beside the send controls. Mirrored rather than read straight
* from the preferences, because all three move without the operator touching them: the * from the preferences, because all of them move without the operator touching them: the
* rotator writes the heading, and the frequency follower writes the own QRG. * rotator writes the heading, and the frequency follower writes the own QRG.
*/ */
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()) val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()) val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
val antennaQtf = ObservedValue(prefs.getActualQTF()) val antennaQtf = ObservedValue(prefs.getActualQTF())
val antennaQtf2 = ObservedValue(prefs.getActualQTF2())
/** /**
* Stops following the preferences. Called when this state is dropped — a profile switch * Stops following the preferences. Called when this state is dropped — a profile switch
@@ -92,6 +142,7 @@ class MainWindowState(
ownQrgMain.release() ownQrgMain.release()
ownQrgSecond.release() ownQrgSecond.release()
antennaQtf.release() antennaQtf.release()
antennaQtf2.release()
} }
companion object { companion object {
@@ -101,6 +152,9 @@ class MainWindowState(
/** Station list, top priorities, selected station. */ /** Station list, top priorities, selected station. */
const val RIGHT_PANE_COUNT = 3 const val RIGHT_PANE_COUNT = 3
/** Selected-station message table and its scrollable action area. */
const val SELECTED_STATION_PANE_COUNT = 2
} }
} }
@@ -161,4 +215,13 @@ class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState())
var settingsWindowOpen: Boolean by mutableStateOf(false) var settingsWindowOpen: Boolean by mutableStateOf(false)
var monitorWindowOpen: Boolean by mutableStateOf(false) var monitorWindowOpen: Boolean by mutableStateOf(false)
/**
* Whether SO2R is switched on. Compose state, because the preference is not: the
* application pushes the stored value in at start-up and after every settings Apply.
*/
var so2rEnabled: Boolean by mutableStateOf(false)
/** SO2R with rotator 2 enabled, i.e. antenna 2 can be turned; pushed in like [so2rEnabled]. */
var secondAntennaOffered: Boolean by mutableStateOf(false)
} }
@@ -0,0 +1,19 @@
package kst4contest.view.compose
import kst4contest.model.ChatPreferences
private const val MIN_QTF = 0.0
private const val MAX_QTF = 360.0
/**
* Applies a deliberate filter direction to the runtime antenna heading without sending a rotor command.
*
* @return true when the runtime heading was updated
*/
internal fun applyExplicitQtfHeading(prefs: ChatPreferences, qtf: Double): Boolean {
if (!qtf.isFinite() || qtf !in MIN_QTF..MAX_QTF) return false
if (!prefs.isQtfFilterDirectionAsAntennaHeadingEnabled) return false
prefs.actualQTF.set(qtf)
return true
}
@@ -0,0 +1,83 @@
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
import kst4contest.view.map.PathAnalysisResult
/**
* Explicit map reachability request shared by station-list and map context menus.
* A monotonically increasing request ID prevents older callbacks from replacing a
* result for a newer station or band.
*/
class MapReachabilityState(
private val ownLocator: String,
requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = { _, _, _ -> },
) {
var requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = requester
var result: PathAnalysisResult? by mutableStateOf(null)
private set
var running: Boolean by mutableStateOf(false)
private set
var requestRevision: Long by mutableStateOf(0L)
private set
var targetIdentity: StationIdentity? by mutableStateOf(null)
private set
var requestedBand: Band? by mutableStateOf(null)
private set
fun calculate(member: ChatMember?, band: Band?): Boolean {
if (member == null || band == null) return false
val identity = StationIdentity.of(member) ?: return false
val requestId = ++requestRevision
targetIdentity = identity
requestedBand = band
running = true
result = PathAnalysisResult.loading(ownLocator, member.qra, member.callSignRaw)
try {
requester(member, band) { completed ->
if (requestRevision == requestId
&& targetIdentity == identity
&& requestedBand == band
) {
result = completed
running = false
}
}
} catch (exception: RuntimeException) {
if (requestRevision == requestId) {
result = PathAnalysisResult.noProfile(
"Failed",
ownLocator,
member.qra,
member.callSignRaw,
Double.NaN,
member.getQTFdirection() ?: Double.NaN,
Double.NaN,
Double.NaN,
band.defaultAnalysisFrequencyMHz,
exception.message ?: "Reachability calculation failed.",
)
running = false
}
}
return true
}
fun selectionChanged(identity: StationIdentity?) {
if (targetIdentity == null || targetIdentity == identity) return
requestRevision++
targetIdentity = null
requestedBand = null
result = null
running = false
}
}
@@ -2,13 +2,11 @@ package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -18,13 +16,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.foundation.border
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.ChatMessage
import kst4contest.model.ClusterMessage import kst4contest.model.ClusterMessage
@@ -40,6 +33,7 @@ fun MessageTabs(
publicMessages: DataTableState<ChatMessage>, publicMessages: DataTableState<ChatMessage>,
clusterMessages: DataTableState<ClusterMessage>, clusterMessages: DataTableState<ClusterMessage>,
qsoOfTheOther: DataTableState<ChatMessage>, qsoOfTheOther: DataTableState<ChatMessage>,
onStationMessageClicked: (ChatMessage, StationSelectionSource) -> Unit = { _, _ -> },
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var selected by remember { mutableStateOf(0) } var selected by remember { mutableStateOf(0) }
@@ -51,35 +45,20 @@ fun MessageTabs(
strings.messagesQsoOfTheOther, strings.messagesQsoOfTheOther,
) )
Column(modifier = modifier.fillMaxSize()) { Column(
modifier = modifier
.fillMaxSize()
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
) {
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) { Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
titles.forEachIndexed { index, title -> titles.forEachIndexed { index, title ->
val isSelected = index == selected val isSelected = index == selected
val underline = MaterialTheme.colorScheme.primary
Box( CompactTab(
modifier = Modifier text = title,
.heightIn(min = Density.TAB_MIN_HEIGHT) selected = isSelected,
.background( onClick = { selected = index },
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)
}
} }
} }
@@ -87,9 +66,21 @@ fun MessageTabs(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when (selected) { when (selected) {
0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize()) 0 -> DataTableView(
publicMessages,
modifier = Modifier.fillMaxSize(),
onRowClick = { message ->
onStationMessageClicked(message, StationSelectionSource.PUBLIC_MESSAGE)
},
)
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize()) 1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize()) else -> DataTableView(
qsoOfTheOther,
modifier = Modifier.fillMaxSize(),
onRowClick = { message ->
onStationMessageClicked(message, StationSelectionSource.QSO_OF_THE_OTHER)
},
)
} }
} }
} }
@@ -51,7 +51,7 @@ object MonitorColumns {
*/ */
DataColumn( DataColumn(
id = "category", id = "category",
title = "Category", title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message -> value = { message ->
message.sender?.chatCategory message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) } ?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -17,10 +16,11 @@ import kst4contest.model.ClusterMessage
/** /**
* The cluster and QSO monitor. * The cluster and QSO monitor.
* *
* Two live tables, one above the other. This is the first window in which the table * Two live tables, one above the other, with a divider the operator can drag. This
* carries data that changes while the operator watches, which is why the row identity * is the first window in which the table carries data that changes while the operator
* rules in DataTableState matter here and not in the settings window: in Etappe 2 the * watches, which is why the row identity rules in DataTableState matter here and not in
* visible defects all came from that coupling, not from the table itself. * the settings window: in Etappe 2 the visible defects all came from that coupling, not
* from the table itself.
*/ */
object MonitorWindow { object MonitorWindow {
@@ -44,6 +44,8 @@ object MonitorWindow {
fun show( fun show(
clusterTable: DataTableState<ClusterMessage>, clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>, qsoTable: DataTableState<ChatMessage>,
/** The divider between the two tables, remembered in the profile (#144). */
splitter: SplitterState,
darkMode: Boolean, darkMode: Boolean,
baseFontSizeSp: Float, baseFontSizeSp: Float,
/** /**
@@ -61,7 +63,7 @@ object MonitorWindow {
onResized: (Float, Float) -> Unit, onResized: (Float, Float) -> Unit,
) { ) {
host.show( host.show(
title = { "Cluster & QSO of the other" }, title = { kst4contest.view.i18n.CurrentStrings.get().monitorTitle },
darkMode = darkMode, darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp, baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore, paletteStore = paletteStore,
@@ -70,38 +72,41 @@ object MonitorWindow {
heightDp = heightDp, heightDp = heightDp,
onResized = onResized, onResized = onResized,
) { _ -> ) { _ ->
MonitorContent(clusterTable, qsoTable) MonitorContent(clusterTable, qsoTable, splitter)
} }
} }
} }
/** Internal rather than private so the window's body can be drawn headless in a test. */
@Composable
internal fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
splitter: SplitterState,
) {
val strings = kst4contest.view.i18n.LocalStrings.current
Surface(modifier = Modifier.fillMaxSize()) {
/*
* Each heading sits in the pane of its table, so dragging the divider moves the
* heading with the table it names rather than leaving it stranded above the other.
*/
SplitterPane(
state = splitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize().padding(4.dp),
panes = listOf(
{ MonitorPane(strings.monitorDxCluster, clusterTable) },
{ MonitorPane(strings.messagesQsoOfTheOther, qsoTable) },
),
)
}
}
@Composable @Composable
private fun MonitorContent( private fun <T> MonitorPane(heading: String, table: DataTableState<T>) {
clusterTable: DataTableState<ClusterMessage>, Column(modifier = Modifier.fillMaxSize()) {
qsoTable: DataTableState<ChatMessage>, Text(heading, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp))
) { DataTableView(table, modifier = Modifier.weight(1f).fillMaxWidth())
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,100 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
/** One selectable station in the transient New joined group. */
class NewJoinedEntry internal constructor(
val identity: StationIdentity,
val label: String,
val joinedAtEpochMillis: Long,
private val member: ChatMember,
private val onSelect: (ChatMember) -> Unit,
) {
fun select() = onSelect(member)
}
/**
* Compose-facing view of live ON4KST join events.
*
* Initial and resynchronized user lists never reach this state. It receives the core's
* live-event roster and intersects it with the currently active station roster, so a
* departed station disappears immediately. The clock is injected to make expiry exact
* and testable without waiting ten minutes.
*/
class NewJoinedState(
private val clock: () -> Long = System::currentTimeMillis,
private val onSelect: (ChatMember) -> Unit,
) {
private var events: List<RecentChatJoin> = emptyList()
private var activeMembers: List<ChatMember> = emptyList()
private var liveEntries by mutableStateOf<List<NewJoinedEntry>>(emptyList())
val entries: List<NewJoinedEntry>
get() = liveEntries
val visibleEntries: List<NewJoinedEntry>
get() = liveEntries.take(SHOWN)
val overflowEntries: List<NewJoinedEntry>
get() = liveEntries.drop(SHOWN)
fun updateEvents(newEvents: List<RecentChatJoin>) {
events = newEvents.toList()
refresh()
}
fun updateActiveMembers(newMembers: List<ChatMember>) {
activeMembers = newMembers.toList()
refresh()
}
/** Removes expired or departed entries and resolves each event to the latest row instance. */
fun refresh() {
val now = clock()
val activeByIdentity = activeMembers.mapNotNull { member ->
StationIdentity.of(member)?.let { identity -> identity to member }
}.toMap()
val firstJoinByIdentity = linkedMapOf<StationIdentity, RecentChatJoin>()
events.forEach { event ->
val identity = StationIdentity.of(event.callSign, event.categoryNumber)
?: return@forEach
val existing = firstJoinByIdentity[identity]
if (existing == null || event.joinedAtEpochMillis < existing.joinedAtEpochMillis) {
firstJoinByIdentity[identity] = event
}
}
liveEntries = firstJoinByIdentity
.mapNotNull { (identity, event) ->
val active = activeByIdentity[identity] ?: return@mapNotNull null
val ageMillis = now - event.joinedAtEpochMillis
if (ageMillis < 0L || ageMillis >= VALIDITY_MILLIS) {
return@mapNotNull null
}
NewJoinedEntry(
identity = identity,
label = identity.callSign,
joinedAtEpochMillis = event.joinedAtEpochMillis,
member = active,
onSelect = onSelect,
)
}
.sortedByDescending(NewJoinedEntry::joinedAtEpochMillis)
}
fun isRecent(member: ChatMember): Boolean {
val identity = StationIdentity.of(member) ?: return false
return liveEntries.any { it.identity == identity }
}
companion object {
const val VALIDITY_MILLIS = 10L * 60L * 1000L
const val SHOWN = 5
}
}
@@ -123,7 +123,7 @@ object OperatorProfilePickerWindow {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL) val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
dialog.size = DIALOG_SIZE dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null) WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */ /* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
/* /*
@@ -0,0 +1,39 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import kst4contest.model.ChatPreferences
/**
* The stored window positions of one profile, gated by the operator's opt-in.
*
* The opt-in is read on every call, so applying the setting takes effect without a
* restart: off means nothing is restored and nothing is written.
*
* @param requestSave asks the layout autosave to write the preferences file
*/
class PreferencesWindowPositionMemory(
private val prefs: ChatPreferences,
private val requestSave: Runnable,
) : WindowPositionMemory {
override fun load(windowId: String): StoredWindowPosition? {
if (!prefs.isGuiOptions_rememberWindowPositions()) {
return null
}
val p = prefs.getWindowPosition(windowId) ?: return null
return StoredWindowPosition(Point(p[0], p[1]), Rectangle(p[2], p[3], p[4], p[5]))
}
override fun save(windowId: String, stored: StoredWindowPosition) {
if (!prefs.isGuiOptions_rememberWindowPositions()) {
return
}
prefs.setWindowPosition(
windowId,
stored.position.x, stored.position.y,
stored.screen.x, stored.screen.y, stored.screen.width, stored.screen.height,
)
requestSave.run()
}
}
@@ -26,7 +26,7 @@ object PublicMessageColumns {
DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }), DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
DataColumn( DataColumn(
id = "last-qrg", id = "last-qrg",
title = "Last QRG", title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrg },
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) }, value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
weight = 0.8f, weight = 0.8f,
), ),
@@ -38,7 +38,7 @@ object PublicMessageColumns {
), ),
DataColumn( DataColumn(
id = "category", id = "category",
title = "Category", title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message -> value = { message ->
message.sender?.chatCategory message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) } ?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -0,0 +1,58 @@
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
/** One selectable entry in the reachability-band control. A null band means Auto. */
data class ReachabilityBandOption(val band: Band?, val label: String)
/**
* Operator-controlled reachability selection and the one explicit calculation action.
*
* Band selection only invalidates cached table/filter presentation. Online terrain work
* stays behind [calculateSelected], so recomposition and sorting cannot consume API calls.
*/
class ReachabilityState(
activeBands: Set<Band>,
private val resolveAutoBand: (ChatMember) -> Band? = { null },
private val selectedStation: () -> ChatMember? = { null },
private val calculate: (ChatMember, Band) -> Unit = { _, _ -> },
private val refresh: () -> Unit = { },
) {
private val supportedBands = activeBands.toSet()
val options: List<ReachabilityBandOption> =
listOf(ReachabilityBandOption(null, "Auto")) +
activeBands.sortedBy(Band::ordinal).map { band ->
ReachabilityBandOption(band, band.displayLabel)
}
var bandOverride: Band? by mutableStateOf(null)
private set
val selectedOption: ReachabilityBandOption
get() = options.first { it.band == bandOverride }
val canCalculate: Boolean
get() = selectedStation()?.let(::resolveBand) != null
fun selectBand(band: Band?) {
val accepted = band?.takeIf(supportedBands::contains)
if (bandOverride == accepted) return
bandOverride = accepted
refresh()
}
fun resolveBand(member: ChatMember): Band? = bandOverride ?: resolveAutoBand(member)
/** Returns whether a complete station/band request was handed to the service. */
fun calculateSelected(): Boolean {
val member = selectedStation() ?: return false
val band = resolveBand(member) ?: return false
calculate(member, band)
return true
}
}
@@ -16,13 +16,13 @@ object SelectedStationMessageColumns {
), ),
DataColumn( DataColumn(
id = "call-tx", id = "call-tx",
title = "Sender", title = { kst4contest.view.i18n.CurrentStrings.get().columnSender },
value = { MonitorColumns.callSignOf(it.sender) }, value = { MonitorColumns.callSignOf(it.sender) },
weight = 0.8f, weight = 0.8f,
), ),
DataColumn( DataColumn(
id = "call-rx", id = "call-rx",
title = "Receiver", title = { kst4contest.view.i18n.CurrentStrings.get().columnReceiver },
value = { MonitorColumns.callSignOf(it.receiver) }, value = { MonitorColumns.callSignOf(it.receiver) },
weight = 0.8f, weight = 0.8f,
), ),
@@ -0,0 +1,60 @@
package kst4contest.view.compose
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import java.util.Locale
import java.util.function.Predicate
/** Builds the selected-station message filter without changing the source history. */
object SelectedStationMessagePredicate {
@JvmStatic
fun forSelection(
filter: SelectedMessageFilter,
selected: ChatMember?,
ownCallSign: String?,
): Predicate<ChatMessage> {
val selectedIdentity = StationIdentity.of(selected)
val ownCall = ownCallSign.normalizedCall()
return Predicate { message ->
if (selectedIdentity?.categoryNumber == null) {
return@Predicate false
}
val senderCall = message?.sender?.callSign.normalizedCall()
val receiverCall = message?.receiver?.callSign.normalizedCall()
val messageCategory = message?.chatCategory?.categoryNumber
if (senderCall.isBlank() || receiverCall.isBlank() ||
messageCategory != selectedIdentity.categoryNumber
) {
return@Predicate false
}
val selectedIsSender = senderCall == selectedIdentity.callSign
val selectedIsReceiver = receiverCall == selectedIdentity.callSign
val ownIsSender = senderCall == ownCall
val ownIsReceiver = receiverCall == ownCall
val isPublic = message.isMessageDirectedToCommunity || receiverCall == PUBLIC_RECEIVER
when (filter) {
SelectedMessageFilter.PM_TO_ME ->
ownCall.isNotBlank() && (
(!isPublic && ((selectedIsSender && ownIsReceiver) ||
(ownIsSender && selectedIsReceiver))) ||
(isPublic && selectedIsSender && message.isMessageDirectedToMe)
)
SelectedMessageFilter.PM_TO_OTHER ->
ownCall.isNotBlank() && selectedIsSender && !isPublic && !ownIsReceiver
SelectedMessageFilter.PUBLIC -> selectedIsSender && isPublic
SelectedMessageFilter.NOTHING -> selectedIsSender || selectedIsReceiver
}
}
}
private fun String?.normalizedCall(): String = this?.trim()?.uppercase(Locale.ROOT).orEmpty()
private const val PUBLIC_RECEIVER = "ALL"
}
@@ -2,15 +2,36 @@ package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -22,8 +43,26 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import kst4contest.model.Band import kst4contest.model.Band
import java.util.function.Consumer import java.util.function.Consumer
import kotlinx.coroutines.delay
internal const val SELECTED_STATION_PANEL_TAG = "selected-station-panel"
internal const val SELECTED_MESSAGES_TABLE_TAG = "selected-messages-table"
internal const val SELECTED_NOT_QRV_GROUP_TAG = "selected-not-qrv-group"
internal const val SELECTED_SKED_GROUP_TAG = "selected-sked-group"
internal const val NEW_JOINED_MORE_TAG = "new-joined-more"
internal const val NEW_JOINED_GROUP_TAG = "new-joined-group"
internal const val PRIORITY_GROUP_TAG = "priority-group"
internal fun skedBandOptions(activeBands: Set<Band>): List<Band> {
val options = ArrayList<Band>(activeBands.size)
Band.entries.forEach { band ->
if (activeBands.contains(band)) options.add(band)
}
return options
}
/** /**
* The panel below the station list, for whichever station is selected. * The panel below the station list, for whichever station is selected.
@@ -40,10 +79,9 @@ import java.util.function.Consumer
fun SelectedStationPanel( fun SelectedStationPanel(
state: SelectedStationState, state: SelectedStationState,
messages: DataTableState<kst4contest.model.ChatMessage>, messages: DataTableState<kst4contest.model.ChatMessage>,
/** The bands this station is set up for; BandOpportunityResolver decides them. */ /** My own station's enabled bands; used for NOT-QRV and sked controls. */
skedBands: List<Band>,
/** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */
activeBands: Set<Band>, activeBands: Set<Band>,
contentSplitter: SplitterState? = null,
openInBrowser: Consumer<String>, openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit, onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit, onShowPathInAirScout: () -> Unit,
@@ -51,58 +89,131 @@ fun SelectedStationPanel(
) { ) {
val strings = LocalStrings.current val strings = LocalStrings.current
val member = state.selected val member = state.selected
val splitter = contentSplitter ?: remember {
SplitterState(2, doubleArrayOf(DEFAULT_SELECTED_MESSAGE_FRACTION)) {}
}
Column( Box(
modifier = modifier.fillMaxWidth().padding(4.dp), modifier = modifier
verticalArrangement = Arrangement.spacedBy(3.dp), .fillMaxWidth()
.testTag(SELECTED_STATION_PANEL_TAG)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.22f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
) { ) {
Column(modifier = Modifier.fillMaxSize()) {
if (member == null) { if (member == null) {
Text( Text(
strings.selectedNone, strings.selectedNone,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(4.dp),
) )
return@Column return@Column
} }
Text( SelectedStationHeaderAndFilter(state, member)
member.callSign.orEmpty() + " " + member.qra.orEmpty(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
FlowRow( SplitterPane(
horizontalArrangement = Arrangement.spacedBy(16.dp), state = splitter,
verticalArrangement = Arrangement.spacedBy(4.dp), orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.fillMaxWidth().weight(1f),
minimumPaneSizes = listOf(
Density.ROW_MIN_HEIGHT * 2,
Density.SELECTED_ACTION_AREA_MIN_HEIGHT,
),
panes = listOf(
{
DataTableView(
state = messages,
modifier = Modifier.fillMaxSize().testTag(SELECTED_MESSAGES_TABLE_TAG),
)
},
{
SelectedStationFunctions(
state = state,
activeBands = activeBands,
openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout,
)
},
),
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SelectedStationHeaderAndFilter(
state: SelectedStationState,
member: kst4contest.model.ChatMember,
) {
val strings = LocalStrings.current
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) { ) {
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) } val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
if (category != null) { val detectedBands = state.detectedBands()
Text(category, style = MaterialTheme.typography.bodySmall) val metadata = buildList {
} category?.let(::add)
if (member.getQTFdirection() != null) { member.getQTFdirection()?.let { add("QTF: $it°") }
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall) member.qrb?.let { add("QRB: $it km") }
}
if (member.qrb != null) {
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
}
val lastActSec = member.activityTimeLastInEpoch val lastActSec = member.activityTimeLastInEpoch
if (lastActSec > 0L) { if (lastActSec > 0L) {
val actStr = MessageFormats.clockTime(lastActSec.toString()) val actStr = MessageFormats.clockTime(lastActSec.toString())
val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60 val minAgo = kst4contest.controller.Utils4KST
Text(strings.selectedLastActivity(actStr, minAgo), style = MaterialTheme.typography.bodySmall) .time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
add(strings.selectedLastActivity(actStr, minAgo))
}
add(detectedBands.ifBlank { strings.tooltipBandsNone })
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 1.dp),
) {
Text(
member.callSign.orEmpty(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
metadata.forEach { value ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"|",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(end = Density.BUTTON_GAP),
)
Text(value, style = MaterialTheme.typography.bodySmall)
} }
val detectedBands = state.detectedBands()
if (detectedBands.isNotBlank()) {
Text(detectedBands, style = MaterialTheme.typography.bodySmall)
} }
} }
Form.section(strings.selectedShowMessages) { FlowRow(
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { modifier = Modifier.fillMaxWidth(),
SelectedMessageFilter.entries.forEach { filter -> horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
itemVerticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(Density.CONTROL_HEIGHT),
) {
Text(
"${strings.selectedShowMessages}:",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(end = Density.BUTTON_GAP),
)
RadioChoice(
label = SelectedMessageFilter.PM_TO_ME.label,
selected = state.messageFilter == SelectedMessageFilter.PM_TO_ME,
onSelect = { state.messageFilter = SelectedMessageFilter.PM_TO_ME },
)
}
SelectedMessageFilter.entries.drop(1).forEach { filter ->
RadioChoice( RadioChoice(
label = filter.label, label = filter.label,
selected = state.messageFilter == filter, selected = state.messageFilter == filter,
@@ -111,79 +222,63 @@ fun SelectedStationPanel(
} }
} }
} }
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) }) { @OptIn(ExperimentalLayoutApi::class)
state.tagNotQrvAll(it) @Composable
} private fun SelectedStationFunctions(
} state: SelectedStationState,
} activeBands: Set<Band>,
openInBrowser: Consumer<String>,
Form.section("Sked") { onShowOnMap: () -> Unit,
FlowRow( onShowPathInAirScout: () -> Unit,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
) { ) {
Form.inlineChoice( val strings = LocalStrings.current
label = strings.selectedSkedInMinutes, val scroll = rememberScrollState()
items = SelectedStationState.SKED_MINUTES, Column(
selected = state.skedMinutes, modifier = Modifier.fillMaxSize().padding(start = 4.dp, top = 1.dp, bottom = 2.dp),
describe = { it.toString() }, verticalArrangement = Arrangement.spacedBy(1.dp),
onSelect = { state.skedMinutes = it }, ) {
) Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
Form.inlineChoice( Column(
label = strings.selectedBand, modifier = Modifier
items = skedBands, .fillMaxWidth()
selected = state.skedBand, .padding(end = Density.SCROLLBAR_GUTTER)
describe = { it.prefix + " MHz" }, .verticalScroll(scroll),
onSelect = { state.skedBand = it }, ) {
) SelectedStationActionGroups(
Form.inlineChoice( state = state,
label = strings.selectedMode, activeBands = activeBands,
items = SelectedStationState.SKED_MODES,
selected = state.skedMode,
describe = { it },
onSelect = { state.skedMode = it },
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { VerticalScrollbar(
Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it } adapter = rememberScrollbarAdapter(scroll),
Form.inlineChoice( modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
label = strings.selectedReminderOffsets, style = LocalScrollbarStyle.current.copy(
items = SelectedStationState.REMINDER_OFFSETS, unhoverColor = MaterialTheme.colorScheme.outline,
selected = state.reminderOffsets, hoverColor = MaterialTheme.colorScheme.onSurface,
describe = { it }, ),
enabled = state.remindPm,
onSelect = { state.reminderOffsets = it },
) )
} }
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { CompactFunctionGroup(
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() } title = strings.selectedLookUpAndShow,
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() } modifier = Modifier.fillMaxWidth().padding(end = Density.SCROLLBAR_GUTTER),
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() } ) {
}
}
Form.section(strings.selectedLookUpAndShow) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout) Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
Form.button("🗺 Show on map", onClick = onShowOnMap) Form.button(strings.selectedShowOnMap, onClick = onShowOnMap)
Form.button(
strings.selectedTurnAntenna(state.selected?.callSignRaw.orEmpty()),
enabled = state.canTurnAntenna,
) { state.turnAntenna() }
if (state.showsSecondAntenna) {
Form.button(
strings.selectedTurnAntenna2(state.selected?.callSignRaw.orEmpty()),
enabled = state.canTurnSecondAntenna,
) { state.turnSecondAntenna() }
}
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) { Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
openInBrowser.accept(state.qrzComUrl()) openInBrowser.accept(state.qrzComUrl())
} }
@@ -192,8 +287,207 @@ fun SelectedStationPanel(
} }
} }
} }
}
}
private const val DEFAULT_SELECTED_MESSAGE_FRACTION = 0.55
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SelectedStationActionGroups(
state: SelectedStationState,
activeBands: Set<Band>,
) {
val strings = LocalStrings.current
val member = state.selected ?: return
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
val notQrvContent: @Composable () -> Unit = {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
activeNotQrvBands.forEach { band ->
Form.toggle(band.label, state.isTaggedNotQrv(band)) {
state.tagNotQrv(band, it)
}
}
Form.toggle(
strings.selectedNotQrvAll,
activeNotQrvBands.all { state.isTaggedNotQrv(it) },
) {
state.tagNotQrvAll(it)
}
}
}
val skedContent: @Composable () -> Unit = {
SkedAlertControls(state, activeBands)
}
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
if (maxWidth >= Density.SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.Top,
) {
CompactFunctionGroup(
title = strings.selectedTagNotQrv(member.callSign.orEmpty()),
modifier = Modifier.weight(0.35f).testTag(SELECTED_NOT_QRV_GROUP_TAG),
content = notQrvContent,
)
CompactFunctionGroup(
title = strings.selectedSked,
modifier = Modifier.weight(0.65f).testTag(SELECTED_SKED_GROUP_TAG),
content = skedContent,
)
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
CompactFunctionGroup(
title = strings.selectedTagNotQrv(member.callSign.orEmpty()),
modifier = Modifier.fillMaxWidth().testTag(SELECTED_NOT_QRV_GROUP_TAG),
content = notQrvContent,
)
CompactFunctionGroup(
title = strings.selectedSked,
modifier = Modifier.fillMaxWidth().testTag(SELECTED_SKED_GROUP_TAG),
content = skedContent,
)
}
}
}
}
@Composable
private fun CompactFunctionGroup(
title: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
.padding(3.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
content()
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band>) {
val strings = LocalStrings.current
SkedEditorControls(
activeBands = activeBands,
minutes = state.skedMinutes,
onMinutesChanged = { state.skedMinutes = it },
band = state.skedBand,
onBandChanged = { state.skedBand = it },
mode = state.skedMode,
onModeChanged = { state.skedMode = it },
remindPm = state.remindPm,
onRemindPmChanged = { state.remindPm = it },
reminderOffsets = state.reminderOffsets,
onReminderOffsetsChanged = { state.reminderOffsets = it },
canCreate = state.canCreateSked,
onCreate = { state.createSked() },
additionalActions = {
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
},
)
}
/** Shared compact sked editor used by the station panel and the context popup. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SkedEditorControls(
activeBands: Set<Band>,
minutes: Int,
onMinutesChanged: (Int) -> Unit,
band: Band?,
onBandChanged: (Band) -> Unit,
mode: String?,
onModeChanged: (String) -> Unit,
remindPm: Boolean,
onRemindPmChanged: (Boolean) -> Unit,
reminderOffsets: String,
onReminderOffsetsChanged: (String) -> Unit,
canCreate: Boolean,
onCreate: () -> Unit,
additionalActions: @Composable () -> Unit = {},
) {
val strings = LocalStrings.current
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Form.inlineChoice(
label = strings.selectedSkedIn,
items = SelectedStationState.SKED_MINUTES,
selected = minutes,
describe = { it.toString() },
trailingText = strings.selectedSkedMinutesAt,
onSelect = onMinutesChanged,
)
Form.inlineChoice(
label = "",
items = skedBandOptions(activeBands),
selected = band,
describe = { it.prefix + " MHz" },
trailingText = strings.selectedSkedModeLink,
onSelect = onBandChanged,
)
Form.inlineChoice(
label = "",
items = SelectedStationState.SKED_MODES,
selected = mode,
describe = { it },
trailingText = ".",
onSelect = onModeChanged,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(Density.CONTROL_HEIGHT),
) {
CompactCheckbox(remindPm, onCheckedChange = onRemindPmChanged)
Text(
strings.selectedRemindPmIn,
style = compactControlTextStyle(),
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
)
Form.inlineChoice(
label = "",
items = SelectedStationState.REMINDER_OFFSETS,
selected = reminderOffsets,
describe = { it },
enabled = remindPm,
trailingText = strings.selectedMinutes,
onSelect = onReminderOffsetsChanged,
)
}
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Form.button(strings.selectedCreateSked, enabled = canCreate, onClick = onCreate)
additionalActions()
} }
} }
@@ -204,16 +498,52 @@ fun SelectedStationPanel(
@Composable @Composable
fun TopPriorityBar( fun TopPriorityBar(
state: TopPriorityState, state: TopPriorityState,
newJoined: NewJoinedState? = null,
onShowMore: () -> Unit, onShowMore: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val strings = LocalStrings.current val strings = LocalStrings.current
Row( LaunchedEffect(newJoined) {
modifier = modifier.padding(2.dp), while (newJoined != null) {
delay(NEW_JOINED_REFRESH_INTERVAL_MILLIS)
newJoined.refresh()
}
}
BoxWithConstraints(modifier = modifier) {
val allJoinedEntries = newJoined?.entries.orEmpty()
val directCount = when {
maxWidth >= 520.dp -> NewJoinedState.SHOWN
maxWidth >= 450.dp -> 4
maxWidth >= 380.dp -> 3
maxWidth >= 310.dp -> 2
else -> 1
}
val joinedEntries = allJoinedEntries.take(directCount)
val overflow = allJoinedEntries.drop(directCount)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) { ) {
if (joinedEntries.isNotEmpty()) {
CandidateGroup(Modifier.testTag(NEW_JOINED_GROUP_TAG)) {
Text(strings.selectedNewJoined, style = MaterialTheme.typography.bodySmall)
joinedEntries.forEach { entry ->
Form.button(entry.label, horizontalPadding = 4.dp, onClick = entry::select)
}
if (overflow.isNotEmpty()) {
NewJoinedOverflow(overflow)
}
}
}
CandidateGroup(Modifier.testTag(PRIORITY_GROUP_TAG)) {
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall) Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
state.entries.forEach { entry -> state.entries.forEach { entry ->
@@ -223,6 +553,55 @@ fun TopPriorityBar(
Form.button(strings.selectedMore, onClick = onShowMore) Form.button(strings.selectedMore, onClick = onShowMore)
} }
} }
}
}
@Composable
private fun CandidateGroup(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
FlowRow(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
content()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NewJoinedOverflow(entries: List<NewJoinedEntry>) {
val strings = LocalStrings.current
var expanded by remember { mutableStateOf(false) }
Box {
Form.button(
strings.selectedMore,
modifier = Modifier.testTag(NEW_JOINED_MORE_TAG),
horizontalPadding = 4.dp,
) { expanded = true }
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
entries.forEach { entry ->
DropdownMenuItem(
text = { Text(entry.label, style = compactControlTextStyle()) },
onClick = {
expanded = false
entry.select()
},
contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
)
}
}
}
}
}
private const val NEW_JOINED_REFRESH_INTERVAL_MILLIS = 1_000L
/** /**
@@ -239,6 +618,7 @@ private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit)
Row( Row(
modifier = Modifier modifier = Modifier
.selectable(selected = selected, onClick = onSelect) .selectable(selected = selected, onClick = onSelect)
.height(Density.CONTROL_HEIGHT)
.padding(end = Density.BUTTON_GAP), .padding(end = Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import kst4contest.model.Band import kst4contest.model.Band
import kst4contest.model.ChatMember import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
/** /**
* The bands a station can be tagged as not being on, under the labels the panel shows. * The bands a station can be tagged as not being on, under the labels the panel shows.
@@ -39,7 +40,17 @@ enum class SelectedMessageFilter(val label: String) {
PM_TO_ME("pm to me"), PM_TO_ME("pm to me"),
PM_TO_OTHER("pm to other"), PM_TO_OTHER("pm to other"),
PUBLIC("public msgs"), PUBLIC("public msgs"),
NOTHING("nothing"), NOTHING("nothing");
companion object {
@JvmStatic
fun fromPreferences(prefs: ChatPreferences): SelectedMessageFilter = when {
prefs.isGuiOptions_defaultFilterPmToMe -> PM_TO_ME
prefs.isGuiOptions_defaultFilterPmToOther -> PM_TO_OTHER
prefs.isGuiOptions_defaultFilterPublicMsgs -> PUBLIC
else -> NOTHING
}
}
} }
/** /**
@@ -54,8 +65,14 @@ enum class SelectedMessageFilter(val label: String) {
* `propagateNotQrvStateToActiveMembers` does * `propagateNotQrvStateToActiveMembers` does
* @param createSked takes the station, the minutes, the band and the mode; returns * @param createSked takes the station, the minutes, the band and the mode; returns
* whether it was accepted * whether it was accepted
* @param turnAntennaTo turns the antenna to an azimuth in degrees, which is what
* `ChatController.rotateTo` does; null where no rotator can be driven
* @param turnSecondAntennaTo the same for antenna 2 (rotator 2); null where it cannot be driven
* @param secondAntennaOffered whether SO2R with rotator 2 is configured right now; called on
* every draw, so the supplied lambda has to read Compose state for the button to
* follow a settings Apply
*/ */
class SelectedStationState( class SelectedStationState @JvmOverloads constructor(
private val propagateNotQrv: (ChatMember) -> Unit, private val propagateNotQrv: (ChatMember) -> Unit,
private val createSked: (ChatMember, Int, Band?, String?) -> Boolean, private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
private val markSkedFail: (ChatMember) -> Unit = { }, private val markSkedFail: (ChatMember) -> Unit = { },
@@ -63,10 +80,17 @@ class SelectedStationState(
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { }, private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
private val detectBands: (ChatMember) -> String = { "" }, private val detectBands: (ChatMember) -> String = { "" },
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null }, private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
private val turnAntennaTo: ((Double) -> Unit)? = null,
private val turnSecondAntennaTo: ((Double) -> Unit)? = null,
private val secondAntennaOffered: () -> Boolean = { false },
val stationSelection: StationSelectionState = StationSelectionState(),
) { ) {
var selected: ChatMember? by mutableStateOf(null) /** Shared sked action used by both the panel and isolated context-menu drafts. */
private set var skedRequestSubmitter: ((SkedRequest) -> Boolean)? = null
val selected: ChatMember?
get() = stationSelection.selected
/** Bumped on a tag so the panel redraws; the flags live on the station itself. */ /** Bumped on a tag so the panel redraws; the flags live on the station itself. */
private var tagRevision: Int by mutableStateOf(0) private var tagRevision: Int by mutableStateOf(0)
@@ -103,25 +127,60 @@ class SelectedStationState(
val canLookUp: Boolean val canLookUp: Boolean
get() = !selected?.callSign.isNullOrBlank() get() = !selected?.callSign.isNullOrBlank()
fun select(member: ChatMember?) { /** Needs a rotator to drive and a finite heading to the selected station. */
if (selected?.callSignRaw != member?.callSignRaw) { val canTurnAntenna: Boolean
get() = turnAntennaTo != null && selected?.getQTFdirection()?.isFinite() == true
/** Turns the antenna to the selected station's heading; does nothing when it cannot. */
fun turnAntenna() {
val heading = selected?.getQTFdirection() ?: return
if (heading.isFinite()) turnAntennaTo?.invoke(heading)
}
/** SO2R with rotator 2 is configured, so the second turn button is offered at all. */
val showsSecondAntenna: Boolean
get() = turnSecondAntennaTo != null && secondAntennaOffered()
/** Needs the button to be offered and a finite heading to the selected station. */
val canTurnSecondAntenna: Boolean
get() = showsSecondAntenna && selected?.getQTFdirection()?.isFinite() == true
/** Turns antenna 2 to the selected station's heading; does nothing when it cannot. */
fun turnSecondAntenna() {
val heading = selected?.getQTFdirection() ?: return
if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading)
}
@JvmOverloads
fun select(
member: ChatMember?,
source: StationSelectionSource = StationSelectionSource.PROGRAMMATIC,
) {
if (StationIdentity.of(selected) != StationIdentity.of(member)) {
skedBand = member?.let { resolveDefaultSkedBand(it) } skedBand = member?.let { resolveDefaultSkedBand(it) }
} }
selected = member stationSelection.select(member, source)
} }
fun isTaggedNotQrv(band: NotQrvBand): Boolean { fun isTaggedNotQrv(band: NotQrvBand): Boolean {
@Suppress("UNUSED_EXPRESSION") tagRevision @Suppress("UNUSED_EXPRESSION") tagRevision
val member = selected ?: return false return selected?.let { isTaggedNotQrv(it, band) } ?: false
}
fun isTaggedNotQrv(member: ChatMember, band: NotQrvBand): Boolean {
@Suppress("UNUSED_EXPRESSION") tagRevision
return !band.read(member) return !band.read(member)
} }
/** Tagged means not QRV, so the station's QRV flag goes the other way. */ /** Tagged means not QRV, so the station's QRV flag goes the other way. */
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) { fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
val member = selected ?: return val member = selected ?: return
tagNotQrv(member, band, tagged)
}
/** Applies a context action to its fixed target instead of the current selection. */
fun tagNotQrv(member: ChatMember, band: NotQrvBand, tagged: Boolean) {
band.write(member, !tagged) band.write(member, !tagged)
tagRevision++ tagRevision++
propagateNotQrv(member) propagateNotQrv(member)
@@ -129,7 +188,10 @@ class SelectedStationState(
fun tagNotQrvAll(tagged: Boolean) { fun tagNotQrvAll(tagged: Boolean) {
val member = selected ?: return val member = selected ?: return
tagNotQrvAll(member, tagged)
}
fun tagNotQrvAll(member: ChatMember, tagged: Boolean) {
NotQrvBand.entries.forEach { it.write(member, !tagged) } NotQrvBand.entries.forEach { it.write(member, !tagged) }
tagRevision++ tagRevision++
propagateNotQrv(member) propagateNotQrv(member)
@@ -139,9 +201,33 @@ class SelectedStationState(
fun createSked(): Boolean { fun createSked(): Boolean {
val member = selected ?: return false val member = selected ?: return false
return createSked(member, skedMinutes, skedBand, skedMode) val request = SkedRequest(
target = member,
minutes = skedMinutes,
band = skedBand,
mode = skedMode,
remindPm = remindPm,
reminderOffsets = reminderOffsets,
)
return submitSked(request)
} }
/** Submits a fixed-target request through the shared application action. */
fun submitSked(request: SkedRequest): Boolean =
skedRequestSubmitter?.invoke(request)
?: createSked(request.target, request.minutes, request.band, request.mode)
/** Copies the panel values into an isolated draft for a fixed target. */
fun createSkedDraft(target: ChatMember): SkedEditorState = SkedEditorState(
target = target,
minutes = skedMinutes,
band = skedBand ?: resolveDefaultSkedBand(target),
mode = skedMode,
remindPm = remindPm,
reminderOffsets = reminderOffsets,
submitter = ::submitSked,
)
/** /**
* Marks the path to this station as failed. * Marks the path to this station as failed.
* *
@@ -0,0 +1,247 @@
package kst4contest.view.compose
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateMapOf
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* The edits made in the settings window that have not been applied yet.
*
* One draft exists per opened settings window and is handed to every tab state. A tab
* state stages a change here instead of writing ChatPreferences, and reads its values
* back through [valueOf], so the window shows what the operator entered while the running
* session still uses what is stored. "Apply/Close prefs" and "Save settings" call [apply];
* closing the window and choosing to discard calls [discard].
*
* Why a list of pending writes rather than a copy of ChatPreferences: a setting is more
* than its value. Several settings carry an effect on the running session -- the beacon
* timer restarts, a UDP listener rebinds, the DX cluster server starts -- and that effect
* has to stay attached to the setting it belongs to, so that applying runs exactly what an
* immediate edit ran, once per changed setting.
*
* The order [apply] keeps is part of the contract, see there. The design and the list of
* settings that deliberately bypass the draft are in
* `docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`.
*
* Not thread-safe, and it need not be: the settings window is drawn on the AWT event
* thread, and every caller -- a control, a button, the close request -- runs there.
* Open for the tests, which use a draft that applies every change at once to pin the
* rules of a tab state without the draft in between.
*/
open class SettingsDraft {
private class Change(
val value: Any?,
val write: () -> Unit,
val effectKey: String?,
val effect: (() -> Unit)?,
)
/*
* Snapshot state, so a composition reading a value through valueOf is redrawn when that
* value is staged. The tab states are plain facades; this is what makes them observable.
*/
private val changes = mutableStateMapOf<String, Change>()
/*
* The order in which settings were first changed. The state map keeps no order, and
* apply() must not run effects in hash order.
*/
private val order = mutableListOf<String>()
private val typedEntries = mutableListOf<() -> String?>()
/** Whether anything waits to be applied. Compose state: a composition reading it follows. */
val hasPendingChanges: Boolean
get() = changes.isNotEmpty()
/** The staged value of [key], or the stored one read from [live]. */
@Suppress("UNCHECKED_CAST")
fun <T> valueOf(key: String, live: () -> T): T {
val change = changes[key] ?: return live()
return change.value as T
}
/**
* Records a change of the setting [key]. A later stage of the same key replaces the value
* and keeps the position. Staging the stored value removes the change: a box ticked and
* unticked again leaves nothing to apply and nothing to ask about on closing.
*
* Only valid values may be staged. The fields refuse an invalid entry before it gets
* here, as they always did.
*
* @param live reads the stored value
* @param write stores the value; called by [apply]
* @param effectKey settings with the same key share one effect, which [apply] runs once.
* Defaults to [key].
* @param effect what the running session needs after the write, run by [apply] after all
* writes
*/
open fun <T> stage(
key: String,
value: T,
live: () -> T,
write: (T) -> Unit,
effectKey: String? = null,
effect: (() -> Unit)? = null,
) {
if (value == live()) {
changes.remove(key)
order.remove(key)
return
}
if (!changes.containsKey(key)) {
order += key
}
changes[key] = Change(value, { write(value) }, effectKey ?: key, effect)
}
/**
* Writes every change, then runs every effect, then forgets what was written.
*
* Writes come first, in the order the settings were first changed, so that each effect
* sees the finished state: enabling the Win-Test listener and moving its port in one go
* would otherwise restart the listener on the old port. Effects follow in the same order,
* each effect key once: two restarts of one socket in quick succession can fail on the
* port the first one has not released yet.
*
* Every write and every effect runs on its own: one that throws is recorded and the rest
* still run, so a socket that cannot be rebound does not leave the beacon timer or the
* next setting unapplied. A change whose write failed stays in the draft, and its effect
* does not run -- it would act on a value that was never stored. A failed effect's write
* did happen, so that change is gone from the draft like every other.
*
* Typed entries still held by a field are not committed here; the caller does that first
* and decides on a refusal before anything is applied, see [commitTypedEntries].
*
* @return what could not be done; empty when everything went through
*/
fun apply(): ApplyOutcome {
val staged = order.mapNotNull { key -> changes[key]?.let { key to it } }
val failedWrites = mutableListOf<String>()
val failedKeys = HashSet<String>()
staged.forEach { (key, change) ->
try {
change.write()
} catch (failure: Exception) {
failedWrites += describe(key, failure)
failedKeys += key
}
}
val failedEffects = mutableListOf<String>()
val ran = HashSet<String?>()
staged.forEach { (key, change) ->
val effect = change.effect ?: return@forEach
if (key in failedKeys || !ran.add(change.effectKey)) return@forEach
try {
effect()
} catch (failure: Exception) {
failedEffects += describe(change.effectKey ?: key, failure)
}
}
staged.forEach { (key, _) ->
if (key !in failedKeys) {
changes.remove(key)
order.remove(key)
}
}
return ApplyOutcome(failedWrites, failedEffects)
}
/** Forgets every change without writing anything. */
fun discard() {
clear()
}
/**
* Registers a field that holds typed text the draft has not seen yet: a field that commits
* on leaving it (Form.committed), a list row being edited, or a field whose text does not
* parse and therefore staged nothing.
*
* Clicking a button does not reliably take the focus from a text field, so without this an
* entry typed into such a field and followed straight by "Apply/Close prefs" would be
* committed only when the closing window removed the field -- into a draft nobody applies
* any more. And a field showing invalid text in red would let Apply go ahead with the last
* valid value, which the operator no longer sees.
*
* @param commit commits what the field holds and returns null, or returns the reason it
* cannot be accepted -- worded for the operator, naming the field
* @return closing it unregisters the field
*/
fun registerTypedEntry(commit: () -> String?): AutoCloseable {
typedEntries += commit
return AutoCloseable { typedEntries.remove(commit) }
}
/**
* Commits what the registered fields hold, so it is staged, found unchanged or refused.
*
* @return the refusals, empty when every field was accepted
*/
fun commitTypedEntries(): List<String> =
typedEntries.toList().mapNotNull { it() }
private fun describe(key: String, failure: Exception): String =
key.substringAfterLast('.') + ": " + (failure.message ?: failure.javaClass.simpleName)
private fun clear() {
changes.clear()
order.clear()
}
}
/**
* What [SettingsDraft.apply] could not do, each entry worded as "setting: reason".
*
* @param failedWrites settings that could not be stored; they stay in the draft
* @param failedEffects effects that failed after their settings were stored
*/
class ApplyOutcome(val failedWrites: List<String>, val failedEffects: List<String>) {
val failures: List<String>
get() = failedWrites + failedEffects
val isClean: Boolean
get() = failures.isEmpty()
}
/**
* A tab-state property whose reads go through [this] draft and whose writes are staged in it.
*
* The key is the owning class and the property name, so two tabs cannot collide and a
* property needs no key spelled out beside it.
*
* @param read reads the stored value
* @param write stores a value; runs on apply
* @param effectKey see [SettingsDraft.stage]
* @param effect see [SettingsDraft.stage]
*/
fun <T> SettingsDraft.setting(
read: () -> T,
effectKey: String? = null,
effect: (() -> Unit)? = null,
write: (T) -> Unit,
): ReadWriteProperty<Any, T> = object : ReadWriteProperty<Any, T> {
private fun keyOf(owner: Any, property: KProperty<*>) =
(owner::class.simpleName ?: "settings") + "." + property.name
override fun getValue(thisRef: Any, property: KProperty<*>): T =
valueOf(keyOf(thisRef, property), read)
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
stage(keyOf(thisRef, property), value, read, write, effectKey, effect)
}
}
/**
* The draft of the settings window being drawn, or null outside it. Form.committed registers
* its typed entry here; the tab screen tests draw a tab alone and need none.
*/
val LocalSettingsDraft = compositionLocalOf<SettingsDraft?> { null }
@@ -1,13 +1,11 @@
package kst4contest.view.compose package kst4contest.view.compose
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kst4contest.controller.ChatController import kst4contest.controller.ChatController
import kst4contest.controller.ActiveOperatorProfile import kst4contest.controller.ActiveOperatorProfile
import kst4contest.controller.OperatorProfileManagementService import kst4contest.controller.OperatorProfileManagementService
import kst4contest.controller.On4KstConnectionState
import kst4contest.controller.OperatorProfilePaths import kst4contest.controller.OperatorProfilePaths
import kst4contest.model.ChatMember import kst4contest.model.ChatMember
import kst4contest.model.OperatorProfile import kst4contest.model.OperatorProfile
@@ -36,6 +34,7 @@ import kst4contest.view.compose.tabs.TrxSynchTab
import kst4contest.view.compose.tabs.TrxSynchTabState import kst4contest.view.compose.tabs.TrxSynchTabState
import kst4contest.view.compose.tabs.WorkedDatabaseTab import kst4contest.view.compose.tabs.WorkedDatabaseTab
import kst4contest.view.compose.tabs.WorkedDatabaseTabState import kst4contest.view.compose.tabs.WorkedDatabaseTabState
import kst4contest.view.compose.tabs.repairStoredSettings
/** /**
* The work the settings window needs from the main window. * The work the settings window needs from the main window.
@@ -56,6 +55,13 @@ interface SettingsHost {
*/ */
fun refreshShortcutButtonsFromSettings() fun refreshShortcutButtonsFromSettings()
/**
* Pushes the stored SO2R switch and the rotator 2 switch into the main window's Compose
* state, so the QTF2 field and the "Turn ant2" button follow an Apply. The
* implementation has to hop onto the UI thread; the settings window runs on its own.
*/
fun refreshSo2rState()
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */ /** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
fun refreshTextSnippetContextMenusFromSettings() fun refreshTextSnippetContextMenusFromSettings()
@@ -113,6 +119,12 @@ interface SettingsHost {
* place, and the foreign-write coverage test can see it. A tab that only wrote a * 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 * preference and skipped its session call would look correct in the file and do
* nothing until the next restart. * nothing until the next restart.
*
* @param connectionState the session state as Compose state reads it; the Station tab
* locks the login fields from it. The controller's own getter is not observable,
* so a tab reading it would stay on whatever the link was when the window opened.
* @param draft the edits of this opening of the window; every tab state stages into it,
* and the button bar applies it
*/ */
fun buildSettingsTabs( fun buildSettingsTabs(
controller: ChatController, controller: ChatController,
@@ -120,32 +132,42 @@ fun buildSettingsTabs(
notices: SettingsNotices, notices: SettingsNotices,
paletteStore: PaletteStore, paletteStore: PaletteStore,
languageStore: kst4contest.view.i18n.LanguageStore, languageStore: kst4contest.view.i18n.LanguageStore,
connectionState: () -> On4KstConnectionState,
draft: SettingsDraft,
): List<SettingsTab> { ): List<SettingsTab> {
val prefs = controller.chatPreferences val prefs = controller.chatPreferences
// Once, before any tab reads: see repairStoredSettings for why not inside the tabs.
repairStoredSettings(prefs, host::detectWintestBroadcastAddress)
val stationState = StationTabState( val stationState = StationTabState(
prefs, prefs,
draft,
applyMainCategory = { category -> controller.setChatCategoryMain(category) }, applyMainCategory = { category -> controller.setChatCategoryMain(category) },
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) }, applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
connectionState = connectionState,
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
) )
val notificationState = NotificationTabState( val notificationState = NotificationTabState(
prefs, prefs,
draft,
controller.lstNotify_QSOSniffer_sniffedCallSignList, controller.lstNotify_QSOSniffer_sniffedCallSignList,
applyDxClusterServerEnabled = { enabled -> applyDxClusterServer = {
/* /*
* Starting a server that nothing can reach yet is pointless, so the JavaFX * 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 * checkbox only touched the server while the session was logged in; the
* flag alone decides what happens at the next login. * flag alone decides what happens at the next login. Restarted rather than
* started when enabled: the same call then binds a changed port, and a server
* that was not running is simply started.
*/ */
if (controller.isConnectedAndLoggedIn) { if (controller.isConnectedAndLoggedIn) {
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer() if (prefs.isNotify_dxClusterServerEnabled()) {
}
},
applyDxClusterServerPort = {
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled() controller.restartDxClusterServerIfEnabled()
} else {
controller.stopDxClusterServer()
}
} }
}, },
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) }, broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
@@ -153,12 +175,14 @@ fun buildSettingsTabs(
val shortcutsState = ShortcutsTabState( val shortcutsState = ShortcutsTabState(
prefs, prefs,
draft,
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings, refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings, refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
) )
val beaconState = BeaconTabState( val beaconState = BeaconTabState(
prefs, prefs,
draft,
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) }, validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
restartBeaconTimer = { controller.restartBeaconTimer() }, restartBeaconTimer = { controller.restartBeaconTimer() },
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback), mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
@@ -167,9 +191,9 @@ fun buildSettingsTabs(
val logSynchState = LogSynchTabState( val logSynchState = LogSynchTabState(
prefs, prefs,
draft,
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() }, restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
stopWintestListener = { controller.stopWintestUdpListener() }, stopWintestListener = { controller.stopWintestUdpListener() },
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
) )
val workedDatabaseState = WorkedDatabaseTabState( val workedDatabaseState = WorkedDatabaseTabState(
@@ -204,14 +228,14 @@ fun buildSettingsTabs(
* so a tidier order or a shorter title would be a regression, not an improvement. * so a tidier order or a shorter title would be a regression, not an improvement.
*/ */
return listOf( return listOf(
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) }, SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) }, 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().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() }, applyOwnQrgFollower = host::applyOwnQrgFollower)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) }, SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) }, 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().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) }, SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) }, SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, draft, host::applyDebugModeToFile)) },
/* /*
* Selecting this tab re-read the database in the JavaFX window; the list is a * 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. * snapshot and would otherwise show what was true when the window opened.
@@ -220,7 +244,7 @@ fun buildSettingsTabs(
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase }, { kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() }, onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
) { WorkedDatabaseTab(workedDatabaseState) }, ) { WorkedDatabaseTab(workedDatabaseState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) }, SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, draft, languageStore)) },
/* /*
* Appended last on purpose so no established tab position shifts, the reason * Appended last on purpose so no established tab position shifts, the reason
* the JavaFX tab gave for the same placement. * the JavaFX tab gave for the same placement.
@@ -257,18 +281,18 @@ fun buildSettingsTabs(
*/ */
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? { private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) { if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
return "Connect KST4Contest and enable the local DX Cluster server first." return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
} }
val server = controller.dxClusterServer val server = controller.dxClusterServer
?: return "Connect KST4Contest and enable the local DX Cluster server first." ?: return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
if (!server.hasConnectedClients()) { if (!server.hasConnectedClients()) {
return "No DX Cluster client is connected to KST4Contest." return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNoClient
} }
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) { if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
return "The test spot could not be delivered." return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNotDelivered
} }
return null return null
@@ -284,49 +308,109 @@ private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: St
* Built here because it needs the controller for its state and the host for its * 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 * 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. * from the window itself: only the window can end its own Compose application.
*
* Three buttons apply the [draft] before they act. "Apply/Close prefs" applies and closes,
* "Save settings" applies and writes the file, and Connect applies before it connects: the
* session would otherwise use the stored server or second-chat switch rather than the one
* the tab shows. A field that refuses what it holds stops the button before anything is
* applied, so the operator sees why instead of a closed window or a saved file; the X still
* offers Discard. A write or effect that fails is reported in a problem notice.
*/ */
fun settingsButtons( fun settingsButtons(
controller: ChatController, controller: ChatController,
host: SettingsHost, host: SettingsHost,
notices: SettingsNotices, notices: SettingsNotices,
draft: SettingsDraft,
connectionState: () -> On4KstConnectionState,
): @Composable (close: () -> Unit) -> Unit { ): @Composable (close: () -> Unit) -> Unit {
val barState = ConnectionBarState(controller.chatPreferences, controller) val barState = ConnectionBarState(controller.chatPreferences, connectionState)
return { close -> 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( ConnectionBar(
state = barState, state = barState,
onConnect = { onConnect = {
if (applyChecked(draft, notices)) {
val refusal = host.connectFromSettings() val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal) 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, onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings, onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
onDisconnectOnly = { onDisconnectOnly = {
host.disconnectOnlyFromSettings() host.disconnectOnlyFromSettings()
revision++
}, },
) )
} }
} }
/**
* Commits what the fields still hold and tells whether all of it was accepted. On a refusal
* the reason is the notice on screen and nothing has been applied yet. The operator corrects
* the field, or discards everything through the window's X, whose question offers Discard
* even while a field refuses (see SettingsQuestion).
*
* A problem notice already standing counts as a refusal too. The click on a button can
* itself take the focus from a field, which then commits -- and is refused, reporting it --
* before the button's action runs. A problem notice from earlier cannot be standing here:
* its dialog covers the window, so nothing in it could have been clicked.
*/
internal fun typedEntriesAccepted(draft: SettingsDraft, notices: SettingsNotices): Boolean {
val refusals = draft.commitTypedEntries()
if (refusals.isNotEmpty()) {
notices.problem(refusals.joinToString("\n"))
return false
}
return notices.current !is SettingsNotice.Problem
}
/**
* Checks the typed entries, then applies [draft], and tells whether the caller may go on:
* false on a refusal (nothing applied) or when a write or an effect failed (the rest
* applied, the failures named in a problem notice).
*/
internal fun applyChecked(draft: SettingsDraft, notices: SettingsNotices): Boolean {
if (!typedEntriesAccepted(draft, notices)) return false
val outcome = draft.apply()
if (!outcome.isClean) {
notices.problem(applyFailedText(outcome))
return false
}
return true
}
/**
* The work of "Save settings": check the typed entries, apply the draft, write the file, say
* where it went. The window stays open.
*
* A failed effect does not stop the file: its setting was stored, and the file should hold
* it. A failed write does, because the file would then miss what the window shows.
*
* @param save writes the preferences and returns the file, or null when it could not
*/
internal fun applyAndSave(draft: SettingsDraft, notices: SettingsNotices, save: () -> String?) {
if (!typedEntriesAccepted(draft, notices)) return
val outcome = draft.apply()
if (outcome.failedWrites.isNotEmpty()) {
notices.problem(applyFailedText(outcome))
return
}
val strings = kst4contest.view.i18n.CurrentStrings.get()
val target = save()
when {
target == null -> notices.problem(
listOfNotNull(
outcome.takeIf { !it.isClean }?.let(::applyFailedText),
strings.settingsWriteFailed,
).joinToString("\n\n")
)
!outcome.isClean -> notices.problem(applyFailedText(outcome) + "\n\n" + strings.settingsWrittenTo(target))
else -> notices.info(strings.settingsWrittenTo(target))
}
}
private fun applyFailedText(outcome: ApplyOutcome): String =
kst4contest.view.i18n.CurrentStrings.get().settingsApplyFailed(outcome.failures.joinToString("; "))
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -29,12 +30,16 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.awt.EventQueue
import kst4contest.view.i18n.LocalStrings
/** /**
* One tab of the settings window: a title and the content to show. * One tab of the settings window: a title and the content to show.
@@ -104,6 +109,90 @@ class SettingsNotices {
} }
} }
/**
* The question about edits not applied yet, asked when the window closes, the profile is
* switched or the client exits.
*
* The decision is taken in [settle], on the calling event thread, not in the window's next
* frame: a minimised window may draw no frames, and an exit from the main window would then
* wait for the settings window to be restored. Only the dialog itself needs a frame, and the
* window is raised before it is needed. Compose state, so the open window shows the question.
*/
class SettingsQuestion {
/** What a shown question goes on with. */
class Asked(
/** Run after Apply or Discard; null means close the window, which is what the X wants. */
val then: (() -> Unit)?,
/**
* Why Apply cannot be offered: a field holds text it refuses. Discard and Back still
* work, so the operator is never trapped behind a field he only wants to get rid of.
*/
val refusal: String?,
)
/** The question being shown, or null. */
var asking: Asked? by mutableStateOf(null)
private set
/** Counts the closes asked for with nothing to settle; the window closes on each. */
var closeRequests by mutableStateOf(0)
private set
/**
* Settles the pending edits of [draft]: runs [nothingPending] at once when nothing waits
* and no field refuses what it holds, and shows the question otherwise.
*
* While a question is shown a further request is ignored. Replacing its continuation would
* turn an exit the operator is being asked about into a mere close of the window -- or the
* other way round.
*
* @return true when a question is shown now
*/
fun settle(
draft: SettingsDraft,
notices: SettingsNotices,
then: (() -> Unit)?,
nothingPending: () -> Unit,
): Boolean {
if (asking != null) return true
val refusals = draft.commitTypedEntries()
// A refusal a field reported on its own focus loss is standing as the notice.
val standing = notices.current as? SettingsNotice.Problem
val refusal = refusals.takeIf { it.isNotEmpty() }?.joinToString("\n") ?: standing?.message
if (refusal == null && !draft.hasPendingChanges) {
nothingPending()
return false
}
// The reason moves into the question, so two dialogs do not stand on top of each other.
if (standing != null) notices.dismiss()
asking = Asked(then, refusal)
return true
}
/** The X of the window: close when nothing waits, ask otherwise. */
fun settleClose(draft: SettingsDraft, notices: SettingsNotices) {
settle(draft, notices, then = null, nothingPending = { closeRequests++ })
}
/** The question was answered; called before acting on the answer. */
fun answered() {
asking = null
}
}
/**
* Wraps [action] so it runs at most once, however often it is invoked: a continuation of a
* profile switch or an exit must not run twice because a dialog was clicked twice.
*/
internal fun runOnce(action: Runnable): Runnable {
val ran = java.util.concurrent.atomic.AtomicBoolean(false)
return Runnable { if (ran.compareAndSet(false, true)) action.run() }
}
/** /**
* The settings window. * The settings window.
* *
@@ -112,9 +201,11 @@ class SettingsNotices {
* most once — a second instance would let two windows write the same preferences * most once — a second instance would let two windows write the same preferences
* against each other. * against each other.
* *
* Saving does not collect values. Every control writes into ChatPreferences as it * The controls stage their edits in a [SettingsDraft]; the button bar applies it. Closing
* changes; the button bar only persists, which is what the JavaFX "Save settings" * the window, switching the operator profile or leaving the client while edits are waiting
* button did. * first asks whether to apply or discard them, or to go back, which cancels the close, the
* switch or the exit ([requestClose], [settleThen]). Only [close] -- the teardown that
* follows -- and a JVM shutdown hook drop them without asking.
*/ */
object SettingsWindow { object SettingsWindow {
@@ -135,11 +226,67 @@ object SettingsWindow {
@JvmStatic @JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode) fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */ /** What the open window was built with, if one is open. */
private class OpenWindow(
val draft: SettingsDraft,
val notices: SettingsNotices,
val question: SettingsQuestion,
)
@Volatile
private var openWith: OpenWindow? = null
/**
* Closes the window and waits for it to be gone; see ComposeWindowHost.close. Edits not
* applied yet are dropped, so this is the teardown after [settleThen] has asked, not the
* operator's close; that one goes through [requestClose].
*/
@JvmStatic @JvmStatic
fun close() = host.close() fun close() = host.close()
/** /**
* Closes the window the way its X does: at once when nothing waits to be applied,
* otherwise after asking.
*/
@JvmStatic
fun requestClose() {
val open = openWith
if (open == null || !host.isOpen) return
if (open.question.settle(open.draft, open.notices, then = null, nothingPending = { host.close() })) {
host.raise()
}
}
/**
* Runs [action] once the window's pending edits are settled: at once when the window is
* closed or nothing waits, otherwise after the operator chose Apply or Discard in the
* window. Back cancels -- [action] never runs. For the profile switch and the exit, so
* that neither drops edits the operator has not decided about. While a question is
* already shown the request is ignored, see [SettingsQuestion.settle].
*
* Must be called on the event thread, and never waits there. The decision is taken here
* and now; with something to ask the window is raised -- de-iconified if minimised, so it
* draws again -- and the question set as Compose state. The action then runs from the
* dialog's callback, posted to the event queue, so whatever it opens -- the switch
* confirmation, the window teardown -- does not run inside the click handler. It runs at
* most once.
*/
@JvmStatic
fun settleThen(action: Runnable) {
val once = runOnce(action)
val open = openWith
if (open == null || !host.isOpen) {
once.run()
return
}
if (open.question.settle(open.draft, open.notices, then = { EventQueue.invokeLater(once) }, nothingPending = once::run)) {
host.raise()
}
}
/**
* @param draft the edits of this opening of the window; the tabs and the buttons were
* built with the same one
* @param buttons the bottom button row; it receives the action that closes the * @param buttons the bottom button row; it receives the action that closes the
* window, because only the window itself can end its Compose application * window, because only the window itself can end its Compose application
*/ */
@@ -147,6 +294,7 @@ object SettingsWindow {
fun show( fun show(
tabs: List<SettingsTab>, tabs: List<SettingsTab>,
notices: SettingsNotices, notices: SettingsNotices,
draft: SettingsDraft,
darkMode: Boolean, darkMode: Boolean,
baseFontSizeSp: Float, baseFontSizeSp: Float,
/** /**
@@ -164,8 +312,17 @@ object SettingsWindow {
onResized: (Float, Float) -> Unit, onResized: (Float, Float) -> Unit,
buttons: @Composable (close: () -> Unit) -> Unit, buttons: @Composable (close: () -> Unit) -> Unit,
) { ) {
if (host.isOpen) {
// Raises the open window; the new draft belongs to no window and is dropped.
host.raise()
return
}
val question = SettingsQuestion()
openWith = OpenWindow(draft, notices, question)
host.show( host.show(
title = { "Change Client Settings" }, title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
darkMode = darkMode, darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp, baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore, paletteStore = paletteStore,
@@ -173,21 +330,35 @@ object SettingsWindow {
widthDp = widthDp, widthDp = widthDp,
heightDp = heightDp, heightDp = heightDp,
onResized = onResized, onResized = onResized,
// The X is clicked in a window that is showing, so its frames run.
onCloseRequest = { question.settleClose(draft, notices) },
) { close -> ) { close ->
SettingsContent(tabs, notices, buttons, close) SettingsContent(tabs, notices, buttons, close, draft, question)
} }
} }
} }
/**
* @param draft the edits waiting to be applied. Provided to the tabs, so a field still holding
* a typed entry can be committed into it before an apply.
* @param question the question about the pending edits, and the closes asked for without one
*/
@Composable @Composable
internal fun SettingsContent( internal fun SettingsContent(
tabs: List<SettingsTab>, tabs: List<SettingsTab>,
notices: SettingsNotices, notices: SettingsNotices,
buttons: @Composable (close: () -> Unit) -> Unit, buttons: @Composable (close: () -> Unit) -> Unit,
close: () -> Unit, close: () -> Unit,
draft: SettingsDraft = SettingsDraft(),
question: SettingsQuestion = SettingsQuestion(),
) { ) {
var selected by remember { mutableStateOf(0) } var selected by remember { mutableStateOf(0) }
val closes = question.closeRequests
LaunchedEffect(closes) {
if (closes > 0) close()
}
CompositionLocalProvider(LocalSettingsDraft provides draft) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier.padding(10.dp), modifier = Modifier.padding(10.dp),
@@ -234,6 +405,25 @@ internal fun SettingsContent(
buttons(close) buttons(close)
} }
} }
}
question.asking?.let { asked ->
val proceed = asked.then ?: close
PendingChangesDialog(
refusal = asked.refusal,
onApply = {
question.answered()
// A refusal or a failure stops here, with the reason shown; nothing goes on.
if (applyChecked(draft, notices)) proceed()
},
onDiscard = {
question.answered()
draft.discard()
proceed()
},
onBack = { question.answered() },
)
}
notices.current?.let { notice -> notices.current?.let { notice ->
AlertDialog( AlertDialog(
@@ -241,17 +431,57 @@ internal fun SettingsContent(
title = { title = {
Text( Text(
when (notice) { when (notice) {
is SettingsNotice.Info -> "Info" is SettingsNotice.Info -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeInfo
is SettingsNotice.Problem -> "Settings" is SettingsNotice.Problem -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeProblem
} }
) )
}, },
text = { Text(notice.message) }, text = { Text(notice.message) },
confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } }, confirmButton = { TextButton(onClick = notices::dismiss) { Text(kst4contest.view.i18n.LocalStrings.current.dialogOk) } },
) )
} }
} }
/**
* Asks what becomes of the edits not applied yet when the operator closes the window,
* switches the profile or exits: apply them, discard them, or go back to the window.
* Dismissing it goes back -- nothing is lost by a stray click beside it.
*
* @param refusal set when a field holds text it refuses: shown in the dialog, and Apply is
* disabled. Discard and Back stay available, so a field the operator only wants to get
* rid of never blocks the close, the switch or the exit.
*/
@Composable
internal fun PendingChangesDialog(
refusal: String?,
onApply: () -> Unit,
onDiscard: () -> Unit,
onBack: () -> Unit,
) {
val strings = LocalStrings.current
AlertDialog(
onDismissRequest = onBack,
title = { Text(strings.settingsPendingTitle) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(strings.settingsPendingQuestion)
// Why Apply is not offered; Discard and Back still are.
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(onClick = onApply, enabled = refusal == null) { Text(strings.settingsPendingApply) }
},
dismissButton = {
Row {
TextButton(onClick = onDiscard) { Text(strings.settingsPendingDiscard) }
TextButton(onClick = onBack) { Text(strings.settingsPendingBack) }
}
},
)
}
/** /**
* The row of tab buttons. * The row of tab buttons.
* *
@@ -0,0 +1,50 @@
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
/** Complete immutable request handed to the shared sked creation action. */
data class SkedRequest(
val target: ChatMember,
val minutes: Int,
val band: Band?,
val mode: String?,
val remindPm: Boolean,
val reminderOffsets: String,
)
/**
* Isolated editor draft used by the station-context popup.
*
* The target is captured when the popup opens. Later station selections therefore do
* not redirect a partially edited sked.
*/
class SkedEditorState(
val target: ChatMember,
minutes: Int,
band: Band?,
mode: String?,
remindPm: Boolean,
reminderOffsets: String,
private val submitter: (SkedRequest) -> Boolean,
) {
var minutes by mutableStateOf(minutes)
var band by mutableStateOf(band)
var mode by mutableStateOf(mode)
var remindPm by mutableStateOf(remindPm)
var reminderOffsets by mutableStateOf(reminderOffsets)
fun request(): SkedRequest = SkedRequest(
target = target,
minutes = minutes,
band = band,
mode = mode,
remindPm = remindPm,
reminderOffsets = reminderOffsets,
)
fun submit(): Boolean = submitter(request())
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.awt.Cursor import java.awt.Cursor
@@ -41,6 +42,7 @@ fun SplitterPane(
orientation: SplitterOrientation, orientation: SplitterOrientation,
panes: List<@Composable () -> Unit>, panes: List<@Composable () -> Unit>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
minimumPaneSizes: List<Dp> = emptyList(),
) { ) {
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
val density = LocalDensity.current val density = LocalDensity.current
@@ -55,7 +57,10 @@ fun SplitterPane(
* cursor rather than drifting from it by a handle width per divider. * cursor rather than drifting from it by a handle width per divider.
*/ */
val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount) val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount)
val sizes = state.paneSizes(totalPx) val minimumPaneSizesPx = FloatArray(minimumPaneSizes.size) { index ->
with(density) { minimumPaneSizes[index].toPx() }
}
val sizes = state.paneSizes(totalPx, minimumPaneSizesPx)
val body: @Composable () -> Unit = { val body: @Composable () -> Unit = {
panes.forEachIndexed { index, pane -> panes.forEachIndexed { index, pane ->
@@ -77,6 +82,7 @@ fun SplitterPane(
index = index, index = index,
orientation = orientation, orientation = orientation,
totalPx = totalPx, totalPx = totalPx,
minimumPaneSizesPx = minimumPaneSizesPx,
) )
} }
} }
@@ -100,6 +106,7 @@ private fun SplitterHandle(
index: Int, index: Int,
orientation: SplitterOrientation, orientation: SplitterOrientation,
totalPx: Float, totalPx: Float,
minimumPaneSizesPx: FloatArray,
) { ) {
val horizontal = orientation == SplitterOrientation.HORIZONTAL val horizontal = orientation == SplitterOrientation.HORIZONTAL
val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR
@@ -111,15 +118,20 @@ private fun SplitterHandle(
else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(), else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(),
) )
.pointerHoverIcon(PointerIcon(Cursor(cursor))) .pointerHoverIcon(PointerIcon(Cursor(cursor)))
.pointerInput(index, totalPx) { .pointerInput(index, totalPx, minimumPaneSizesPx.contentHashCode()) {
detectDragGestures( detectDragGestures(
onDragEnd = { state.dragFinished() }, onDragEnd = { state.dragFinished() },
onDragCancel = { state.dragFinished() }, onDragCancel = { state.dragFinished() },
) { _, dragAmount -> ) { _, dragAmount ->
if (totalPx <= 0f) return@detectDragGestures if (totalPx <= 0f) return@detectDragGestures
val delta = if (horizontal) dragAmount.x else dragAmount.y val delta = if (horizontal) dragAmount.x else dragAmount.y
val current = state.positions.getOrElse(index) { 0f } val current = state.dividerPosition(index, totalPx, minimumPaneSizesPx)
state.drag(index, current + delta / totalPx, totalPx) state.drag(
index = index,
to = current + delta / totalPx,
totalPx = totalPx,
minimumPaneSizesPx = minimumPaneSizesPx,
)
} }
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -39,9 +39,27 @@ class SplitterState(
* @param to the wanted position as a fraction of the whole * @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 * @param totalPx the splitter's length, needed to turn the minimum into a fraction
*/ */
fun drag(index: Int, to: Float, totalPx: Float) { fun drag(
index: Int,
to: Float,
totalPx: Float,
minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS,
) {
if (index !in 0 until dividerCount) return if (index !in 0 until dividerCount) return
if (minimumPaneSizesPx.isNotEmpty()) {
val minimums = scaledMinimums(totalPx, minimumPaneSizesPx)
val effective = effectiveDividerPositions(totalPx, minimumPaneSizesPx)
val lowerBound = (effective.getOrElse(index - 1) { 0f } + minimums[index]) / totalPx
val upperBound = (effective.getOrElse(index + 1) { totalPx } - minimums[index + 1]) / totalPx
livePositions[index] = if (lowerBound > upperBound) {
(lowerBound + upperBound) / 2f
} else {
to.coerceIn(lowerBound, upperBound)
}
return
}
val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f
val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction
val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction
@@ -64,18 +82,71 @@ class SplitterState(
} }
/** The pane lengths for a given splitter length; they always add up to the whole. */ /** The pane lengths for a given splitter length; they always add up to the whole. */
fun paneSizes(totalPx: Float): List<Float> { fun paneSizes(totalPx: Float, minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS): List<Float> {
if (paneCount <= 1) return listOf(totalPx) if (paneCount <= 1) return listOf(totalPx)
val edges = buildList { val edges = buildList {
add(0f) add(0f)
livePositions.forEach { add(it * totalPx) } effectiveDividerPositions(totalPx, minimumPaneSizesPx).forEach { add(it) }
add(totalPx) add(totalPx)
} }
return (0 until paneCount).map { edges[it + 1] - edges[it] } return (0 until paneCount).map { edges[it + 1] - edges[it] }
} }
/** The position the pointer sees after pane-specific minimums have been applied. */
internal fun dividerPosition(
index: Int,
totalPx: Float,
minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS,
): Float = effectiveDividerPositions(totalPx, minimumPaneSizesPx)
.getOrElse(index) { 0f } / totalPx.coerceAtLeast(1f)
private fun effectiveDividerPositions(
totalPx: Float,
minimumPaneSizesPx: FloatArray,
): FloatArray {
if (minimumPaneSizesPx.isEmpty()) {
return FloatArray(dividerCount) { index -> livePositions[index] * totalPx }
}
val minimums = scaledMinimums(totalPx, minimumPaneSizesPx)
val effective = FloatArray(dividerCount)
var previous = 0f
repeat(dividerCount) { index ->
val lowerBound = previous + minimums[index]
var trailingMinimum = 0f
for (remainingIndex in index + 1 until paneCount) {
trailingMinimum += minimums[remainingIndex]
}
val upperBound = totalPx - trailingMinimum
val wanted = livePositions[index] * totalPx
// Scaling impossible minimums can leave the two bounds a few float ULPs
// apart. Treat that rounding-only empty range like drag() does instead of
// letting coerceIn throw during layout.
val position = if (lowerBound > upperBound) {
(lowerBound + upperBound) / 2f
} else {
wanted.coerceIn(lowerBound, upperBound)
}
effective[index] = position
previous = position
}
return effective
}
private fun scaledMinimums(totalPx: Float, minimumPaneSizesPx: FloatArray): FloatArray {
val minimums = FloatArray(paneCount) { index ->
minimumPaneSizesPx.getOrNull(index)?.coerceAtLeast(minPanePx) ?: minPanePx
}
val sum = minimums.sum()
if (sum <= totalPx || sum <= 0f) return minimums
val scale = totalPx.coerceAtLeast(0f) / sum
return FloatArray(paneCount) { index -> minimums[index] * scale }
}
companion object { companion object {
private val EMPTY_MINIMUMS = FloatArray(0)
/** Enough to still see a table header and grab the divider again. */ /** Enough to still see a table header and grab the divider again. */
const val DEFAULT_MIN_PANE_PX = 24f const val DEFAULT_MIN_PANE_PX = 24f
@@ -26,13 +26,33 @@ object StationColumns {
/** The heading the ten worked columns sit under. */ /** The heading the ten worked columns sit under. */
const val WORKED_GROUP = "worked" const val WORKED_GROUP = "worked"
/**
* The identity a station row is drawn and selected under: the callsign and the chat
* category. The category is part of it because the same callsign can be in two chats at
* once, and AGENTS.md makes the category part of the chat identity; keyed by callsign
* alone, the two rows collided (fixed in 21251377).
*
* Kept here rather than inline at the one call site so the station list can be tested
* with the key it really uses. The text is exactly what the inline Java expression
* produced, including "null" for a missing callsign.
*/
fun rowKey(member: ChatMember): String =
"${member.callSign}|${member.chatCategory?.categoryNumber ?: ""}"
fun all( fun all(
tropoOf: (ChatMember) -> String, tropoOf: (ChatMember) -> String,
priorityScoreOf: (ChatMember) -> Double, priorityScoreOf: (ChatMember) -> Double,
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String, formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java), activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
): List<DataColumn<ChatMember>> = buildList { ): List<DataColumn<ChatMember>> = buildList {
add(DataColumn(id = "callsign", title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { it.callSign.orEmpty() })) add(DataColumn(
id = "callsign",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign },
value = { it.callSign.orEmpty() },
comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { member: ChatMember ->
member.callSign?.trim().orEmpty()
},
))
add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.name.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 = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
add(DataColumn( add(DataColumn(
@@ -52,7 +72,7 @@ object StationColumns {
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f)) add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
add(DataColumn( add(DataColumn(
id = "airscout", id = "airscout",
title = "AP [minutes / pot%]", title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
value = { AirplaneCell.format(it.airPlaneReflectInfo) }, value = { AirplaneCell.format(it.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) }, accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.airPlaneReflectInfo }, comparator = compareBy(AirplaneCell.order) { it.airPlaneReflectInfo },
@@ -61,7 +81,7 @@ object StationColumns {
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f)) add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
add(DataColumn( add(DataColumn(
id = "score", id = "score",
title = "Score", title = { kst4contest.view.i18n.CurrentStrings.get().columnScore },
value = { priorityScoreOf(it).toInt().toString() }, value = { priorityScoreOf(it).toInt().toString() },
comparator = compareBy { priorityScoreOf(it) }, comparator = compareBy { priorityScoreOf(it) },
weight = 0.6f, weight = 0.6f,
@@ -100,7 +120,7 @@ object StationColumns {
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f)) add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
add(DataColumn( add(DataColumn(
id = "category", id = "category",
title = "Category", title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { member -> value = { member ->
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty() member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
}, },
@@ -0,0 +1,314 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.platform.testTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.view.i18n.LocalStrings
internal const val STATION_CONTEXT_MENU_TAG = "station-context-menu"
internal const val STATION_CONTEXT_TARGET_CHOOSER_TAG = "station-context-target-chooser"
internal const val STATION_SKED_POPUP_TAG = "station-sked-popup"
private enum class StationContextPage {
ROOT,
SNIPPETS,
NOT_QRV,
WORKED,
REACHABILITY,
}
/** Shared station action popup used by the user list and the map. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StationContextOverlays(
menuState: StationContextMenuState,
mainWindowState: MainWindowState,
onShowMap: (() -> Unit)? = null,
) {
val strings = LocalStrings.current
var page by remember { mutableStateOf(StationContextPage.ROOT) }
var skedDraft by remember { mutableStateOf<SkedEditorState?>(null) }
var skedPosition by remember { mutableStateOf(IntOffset.Zero) }
LaunchedEffect(menuState.candidates, menuState.target) {
page = StationContextPage.ROOT
}
if (menuState.isOpen) {
Popup(
popupPositionProvider = WindowPointPopupPositionProvider(menuState.position),
onDismissRequest = menuState::close,
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier.testTag(STATION_CONTEXT_MENU_TAG),
shape = MenuDefaults.shape,
color = MenuDefaults.containerColor,
tonalElevation = MenuDefaults.TonalElevation,
shadowElevation = MenuDefaults.ShadowElevation,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Column(
modifier = Modifier
.widthIn(min = 190.dp, max = 360.dp)
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
) {
val target = menuState.target
if (target == null) {
ContextHeading(
strings.contextChooseStation,
Modifier.testTag(STATION_CONTEXT_TARGET_CHOOSER_TAG),
)
menuState.candidates.forEach { candidate ->
ContextItem(stationContextTargetLabel(candidate)) {
menuState.chooseTarget(candidate)
}
}
} else {
when (page) {
StationContextPage.ROOT -> {
ContextSubmenu(strings.contextMessageSnippets) {
page = StationContextPage.SNIPPETS
}
ContextItem(strings.contextCreateSked) {
skedPosition = menuState.position
skedDraft = mainWindowState.selectedStation.createSkedDraft(target)
menuState.close()
}
ContextSeparator()
ContextSubmenu(strings.contextTagNotQrv) {
page = StationContextPage.NOT_QRV
}
ContextSubmenu(strings.contextMarkWorked) {
page = StationContextPage.WORKED
}
ContextSeparator()
ContextSubmenu(strings.contextCalculateReachability) {
page = StationContextPage.REACHABILITY
}
}
StationContextPage.SNIPPETS -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.snippets.entries.forEach { snippet ->
ContextItem(snippet) {
mainWindowState.chatInput.appendSnippet(snippet)
menuState.close()
}
}
}
StationContextPage.NOT_QRV -> {
ContextBack { page = StationContextPage.ROOT }
NotQrvBand.entries
.filter { it.band in mainWindowState.activeBands }
.forEach { band ->
val tagged = mainWindowState.selectedStation
.isTaggedNotQrv(target, band)
ContextCheckItem(band.label, tagged) {
mainWindowState.selectedStation
.tagNotQrv(target, band, !tagged)
}
}
}
StationContextPage.WORKED -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
val worked = target.isWorkedOn(band)
ContextCheckItem(band.displayLabel, worked, enabled = !worked) {
if (mainWindowState.markStationWorked?.invoke(target, band) == true) {
menuState.close()
}
}
}
}
StationContextPage.REACHABILITY -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
ContextItem(band.displayLabel) {
if (mainWindowState.mapReachability.calculate(target, band)) {
onShowMap?.invoke()
menuState.close()
}
}
}
}
}
}
}
}
}
}
}
skedDraft?.let { draft ->
Popup(
popupPositionProvider = WindowPointPopupPositionProvider(skedPosition),
onDismissRequest = { skedDraft = null },
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier
.widthIn(min = 420.dp, max = 560.dp)
.testTag(STATION_SKED_POPUP_TAG),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp,
shadowElevation = 8.dp,
) {
Column(
modifier = Modifier.padding(8.dp),
) {
Text(
strings.contextSkedTitle(draft.target.callSign.orEmpty()),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(bottom = 4.dp),
)
SkedEditorControls(
activeBands = mainWindowState.activeBands,
minutes = draft.minutes,
onMinutesChanged = { draft.minutes = it },
band = draft.band,
onBandChanged = { draft.band = it },
mode = draft.mode,
onModeChanged = { draft.mode = it },
remindPm = draft.remindPm,
onRemindPmChanged = { draft.remindPm = it },
reminderOffsets = draft.reminderOffsets,
onReminderOffsetsChanged = { draft.reminderOffsets = it },
canCreate = draft.band != null,
onCreate = {
if (draft.submit()) skedDraft = null
},
additionalActions = {
Form.button(strings.contextCancel) { skedDraft = null }
},
)
}
}
}
}
}
@Composable
private fun ContextHeading(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
)
}
@Composable
private fun ContextBack(onClick: () -> Unit) {
val strings = LocalStrings.current
ContextItem("‹ ${strings.contextBack}", onClick = onClick)
ContextSeparator()
}
@Composable
private fun ContextSubmenu(text: String, onClick: () -> Unit) {
ContextItem("$text ›", onClick = onClick)
}
@Composable
private fun ContextCheckItem(
text: String,
checked: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
ContextItem((if (checked) "✓ " else " ") + text, enabled, onClick)
}
@Composable
private fun ContextItem(text: String, enabled: Boolean = true, onClick: () -> Unit) {
DropdownMenuItem(
text = { Text(text, style = compactControlTextStyle(), maxLines = 1) },
enabled = enabled,
onClick = onClick,
contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
)
}
@Composable
private fun ContextSeparator() {
HorizontalDivider(
thickness = Density.HAIRLINE,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
internal fun stationContextTargetLabel(member: ChatMember): String {
val category = member.chatCategory
val categoryName = category?.getChatCategoryName(category.categoryNumber)
?.takeIf { it.isNotBlank() }
?: category?.categoryNumber?.toString().orEmpty()
return if (categoryName.isBlank()) member.callSign.orEmpty()
else "${member.callSign.orEmpty()} | $categoryName"
}
internal fun ChatMember.isWorkedOn(band: Band): Boolean = when (band) {
Band.B_50 -> isWorked50
Band.B_70 -> isWorked70
Band.B_144 -> isWorked144
Band.B_432 -> isWorked432
Band.B_1296 -> isWorked1240
Band.B_2320 -> isWorked2300
Band.B_3400 -> isWorked3400
Band.B_5760 -> isWorked5600
Band.B_10G -> isWorked10G
Band.B_24G -> isWorked24G
}
/** Window-relative popup position clamped to the visible window on every DPI scale. */
internal data class WindowPointPopupPositionProvider(
val point: IntOffset,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset = IntOffset(
x = point.x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)),
y = point.y.coerceIn(0, maxOf(0, windowSize.height - popupContentSize.height)),
)
}
@@ -0,0 +1,59 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.IntOffset
import kst4contest.model.ChatMember
/** Current target and window-relative anchor of a station context menu. */
class StationContextMenuState(
private val selectStation: (ChatMember, StationSelectionSource) -> Unit,
) {
var position: IntOffset by mutableStateOf(IntOffset.Zero)
private set
var candidates: List<ChatMember> by mutableStateOf(emptyList())
private set
var target: ChatMember? by mutableStateOf(null)
private set
var source: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
private set
val isOpen: Boolean
get() = candidates.isNotEmpty()
fun open(
members: List<ChatMember>,
source: StationSelectionSource,
position: IntOffset,
) {
val unique = members
.filter { StationIdentity.of(it) != null }
.distinctBy(StationIdentity::of)
if (unique.isEmpty()) {
close()
return
}
this.position = position
this.source = source
candidates = unique
target = unique.singleOrNull()
target?.let { selectStation(it, source) }
}
fun chooseTarget(member: ChatMember) {
val identity = StationIdentity.of(member) ?: return
val resolved = candidates.firstOrNull { StationIdentity.of(it) == identity } ?: return
target = resolved
selectStation(resolved, source)
}
fun close() {
candidates = emptyList()
target = null
}
}
@@ -3,131 +3,68 @@ package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
internal const val FILTER_DISTANCE_GROUP_TAG = "filter-distance-group"
internal const val FILTER_DIRECTION_GROUP_TAG = "filter-direction-group"
internal const val FILTER_STATION_GROUP_TAG = "filter-station-group"
internal const val FILTER_WORKED_GROUP_TAG = "filter-worked-group"
internal const val FILTER_PROPAGATION_GROUP_TAG = "filter-propagation-group"
/** /**
* The station filter bar above the station table. * The station filter bar above the station table.
* *
* Laid out to match the JavaFX original: three tight rows of controls separated * Compact surfaces keep related filters together while the outer flow rows still wrap
* by thin vertical rules between logical groups. * against the station panel's actual width.
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun StationFilterBar( fun StationFilterBar(
filters: StationFilterState, filters: StationFilterState,
activeBands: Set<kst4contest.model.Band>, activeBands: Set<kst4contest.model.Band>,
reachability: ReachabilityState,
onChanged: () -> Unit, onChanged: () -> Unit,
onQtfDirectionChanged: (Double) -> Unit = {},
onReset: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val strings = LocalStrings.current val strings = LocalStrings.current
Column( Column(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp), modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.18f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
.padding(horizontal = 2.dp, vertical = 1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(1.dp),
) { ) {
// ROW 1: Reset | QRB | QTF + bearings /*
Row( * ROW 1: Reset | QRB max + min | QTF | bearings
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), *
* It wraps (#21): with both QRB filters the line no longer fits a narrow station
* pane, and a plain row cut the bearing buttons off at the right edge. Each group
* stays together on its line; only the QRB pair and the bearings may break up
* inside their group when even that group is wider than the pane.
*/
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(1.dp),
) { itemVerticalAlignment = 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( CompactTextField(
value = filters.searchText, value = filters.searchText,
@@ -139,9 +76,141 @@ fun StationFilterBar(
modifier = Modifier.width(90.dp), modifier = Modifier.width(90.dp),
) )
VerticalSeparator() // Reset filters
FilterButton(strings.filterReset, on = false, isAccent = true) {
if (onReset != null) {
onReset()
} else {
filters.reset()
onChanged()
}
}
Text(strings.filterHideWorked, style = MaterialTheme.typography.bodySmall) // Distance and bearing-width controls form one functional surface.
FilterGroupSurface(FILTER_DISTANCE_GROUP_TAG) {
FilterGroup {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
FilterGroup {
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrb, style = compactControlTextStyle())
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) {
filters.maxQrbKm.toInt().toString()
} else {
filters.maxQrbKm.toString()
},
onValueChange = {
val parsed = parseQrbKm(it)
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),
)
}
FilterGroup {
CompactCheckbox(
checked = filters.minQrbEnabled,
onCheckedChange = {
filters.minQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle())
CompactTextField(
value = if (filters.minQrbKm.rem(1) == 0.0) {
filters.minQrbKm.toInt().toString()
} else {
filters.minQrbKm.toString()
},
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.minQrbKm = parsed
if (filters.minQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.minQrbKm = 0.0
if (filters.minQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp),
)
}
}
}
}
FilterGroupSurface(FILTER_DIRECTION_GROUP_TAG) {
FilterGroup {
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQtf, style = compactControlTextStyle())
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) {
filters.qtfDegrees.toInt().toString()
} else {
filters.qtfDegrees.toString()
},
onValueChange = {
val parsed = parseQtfDegrees(it)
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
onQtfDirectionChanged(parsed)
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 = compactControlTextStyle())
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
Bearing.entries.forEach { bearing ->
FilterButton(
bearing.name,
on = filters.bearing == bearing,
modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH),
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
) {
filters.toggleBearing(bearing)
if (filters.bearing == bearing) {
onQtfDirectionChanged(bearing.degrees)
}
onChanged()
}
}
}
}
FilterGroupSurface(FILTER_STATION_GROUP_TAG) {
Text(strings.filterHide, style = compactControlTextStyle())
FilterButton("wkdany", on = filters.hideWorkedAny) { FilterButton("wkdany", on = filters.hideWorkedAny) {
filters.hideWorkedAny = !filters.hideWorkedAny filters.hideWorkedAny = !filters.hideWorkedAny
onChanged() onChanged()
@@ -154,25 +223,18 @@ fun StationFilterBar(
} }
} }
} }
VerticalSeparator()
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) { FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
filters.hideInactive = !filters.hideInactive filters.hideInactive = !filters.hideInactive
onChanged() onChanged()
} }
}
FilterGroupSurface(FILTER_WORKED_GROUP_TAG) {
Text(strings.filterShow, style = compactControlTextStyle())
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) { FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
filters.onlyNewGrids = !filters.onlyNewGrids filters.onlyNewGrids = !filters.onlyNewGrids
onChanged() 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) { FilterButton(strings.filterGridColour, on = filters.gridColouring) {
filters.gridColouring = !filters.gridColouring filters.gridColouring = !filters.gridColouring
} }
@@ -189,94 +251,77 @@ fun StationFilterBar(
onChanged() onChanged()
} }
} }
FilterGroupSurface(FILTER_PROPAGATION_GROUP_TAG) {
FilterGroup {
Text(strings.filterReachability, style = compactControlTextStyle())
Form.inlineChoice(
label = "",
items = reachability.options,
selected = reachability.selectedOption,
describe = ReachabilityBandOption::label,
onSelect = { reachability.selectBand(it.band) },
)
Form.button(
text = strings.filterCalcSelected,
enabled = reachability.canCalculate,
onClick = { reachability.calculateSelected() },
)
}
}
}
} }
} }
/** /**
* A compact toggle button that does NOT use Material 3 Button (which enforces 40dp). * A compact toggle using the same interaction states as every other desktop button.
* Instead it is a plain clickable Box with border and background, matching the tiny
* JavaFX filter buttons.
*/ */
@Composable @Composable
private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false, onClick: () -> Unit) { private fun FilterButton(
val palette = LocalJavaFxPalette.current label: String,
val isDark = palette.windowBackground.luminance() < 0.5f on: Boolean,
isAccent: Boolean = false,
val accentBrush = if (isDark) { enabled: Boolean = true,
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, modifier: Modifier = Modifier,
horizontalPadding: androidx.compose.ui.unit.Dp = 6.dp,
onClick: () -> Unit,
) { ) {
BasicTextField( Form.button(
value = value, text = label,
onValueChange = onValueChange, enabled = enabled,
singleLine = true, accented = isAccent,
textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface), selected = on,
decorationBox = { innerTextField -> modifier = modifier,
Box( horizontalPadding = horizontalPadding,
modifier = modifier onClick = onClick,
.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()
/** Controls that belong together and move to the next line of a wrapping row as one. */
@Composable
private fun FilterGroup(content: @Composable () -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
content()
}
}
/** A compact surface that keeps related filters visually and spatially together. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FilterGroupSurface(tag: String, content: @Composable () -> Unit) {
FlowRow(
modifier = Modifier
.testTag(tag)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
content()
} }
},
)
} }
@@ -18,6 +18,7 @@ enum class StationFilter {
NEW_BANDS, NEW_BANDS,
AIRSCOUT_NEXT_5_MIN, AIRSCOUT_NEXT_5_MIN,
MAX_QRB, MAX_QRB,
MIN_QRB,
QTF, QTF,
SEARCH_TEXT, SEARCH_TEXT,
HIDE_INACTIVE, HIDE_INACTIVE,
@@ -33,6 +34,21 @@ enum class StationFilter {
HIDE_WORKED_3, HIDE_WORKED_3,
} }
/**
* What a QRB field of the filter bar makes of the text typed into it: a distance in km, or
* `null` when the text is none.
*
* `toDouble` alone also reads "NaN" and "Infinity", and a pasted "Infinity" as the minimum
* hid every station while "NaN" let every one through. A negative distance means nothing
* either. All of these are refused, so the field keeps its last valid value (#21).
*/
internal fun parseQrbKm(text: String): Double? =
text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 }
/** A deliberate antenna/filter heading in the convention used by the QTF display. */
internal fun parseQtfDegrees(text: String): Double? =
text.toDoubleOrNull()?.takeIf { it.isFinite() && it in 0.0..360.0 }
/** The bands the "hide worked" row offers, under the labels the bar shows. */ /** 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) { 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_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
@@ -99,6 +115,16 @@ class StationFilterState {
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */ /** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
var maxQrbKm: Double by mutableStateOf(0.0) var maxQrbKm: Double by mutableStateOf(0.0)
var minQrbEnabled: Boolean
get() = isOn(StationFilter.MIN_QRB)
set(value) = switch(StationFilter.MIN_QRB, value)
/**
* Only meaningful while [minQrbEnabled]. Exclusive: a station exactly this far away is
* hidden, so that "QRB > 300" and "QRB <= 300" split the list without overlap (#21).
*/
var minQrbKm: Double by mutableStateOf(0.0)
var qtfEnabled: Boolean var qtfEnabled: Boolean
get() = isOn(StationFilter.QTF) get() = isOn(StationFilter.QTF)
set(value) = switch(StationFilter.QTF, value) set(value) = switch(StationFilter.QTF, value)
@@ -130,6 +156,23 @@ class StationFilterState {
fun isOn(filter: StationFilter): Boolean = switched[filter] == true fun isOn(filter: StationFilter): Boolean = switched[filter] == true
/**
* Whether a station [qrb] km away passes the distance filters that are switched on.
*
* Both on make a range: `min < qrb <= max`. A station without a known distance is
* hidden as soon as either is on — `null` is unknown, not zero, and an unknown distance
* can be in neither range.
*/
fun acceptsQrb(qrb: Double?): Boolean {
val max = isOn(StationFilter.MAX_QRB)
val min = isOn(StationFilter.MIN_QRB)
if (!max && !min) return true
if (qrb == null) return false
if (max && qrb > maxQrbKm) return false
if (min && qrb <= minQrbKm) return false
return true
}
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide) fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter) fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
@@ -0,0 +1,135 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import java.util.Locale
/** Full ON4KST station identity used across tables, messages and the map. */
data class StationIdentity(
val callSign: String,
val categoryNumber: Int?,
) {
companion object {
@JvmStatic
fun of(member: ChatMember?): StationIdentity? =
of(member?.callSign, member?.chatCategory?.categoryNumber)
@JvmStatic
fun of(callSignValue: String?, categoryNumber: Int?): StationIdentity? {
val callSign = callSignValue?.trim()?.uppercase(Locale.ROOT).orEmpty()
if (callSign.isBlank()) return null
return StationIdentity(callSign, categoryNumber)
}
}
}
/** Resolves the station navigated to by a directed-message row click. */
object DirectedMessageStationTarget {
@JvmStatic
fun forMessage(message: ChatMessage?, ownCallSign: String?): StationIdentity? {
val senderCallSign = message?.sender?.callSign ?: return null
val ownCall = ownCallSign?.trim()?.uppercase(Locale.ROOT).orEmpty()
val senderCall = senderCallSign.trim().uppercase(Locale.ROOT)
val targetCall = if (senderCall == ownCall) {
message.receiver?.callSign ?: receiverFromPayload(message.messageText)
} else {
senderCallSign
}
return StationIdentity.of(targetCall, message.chatCategory?.categoryNumber)
}
private fun receiverFromPayload(messageText: String?): String? {
if (messageText == null || !messageText.startsWith('(')) return null
val closing = messageText.indexOf(')')
return if (closing > 1) messageText.substring(1, closing).trim() else null
}
}
/** Where an explicit station selection came from. */
enum class StationSelectionSource {
STATION_TABLE,
STATION_CONTEXT_MENU,
DIRECTED_MESSAGE,
PUBLIC_MESSAGE,
QSO_OF_THE_OTHER,
MAP,
MAP_CONTEXT_MENU,
PRIORITY,
NEW_JOINED,
TIMELINE,
RESET_VIEW,
PROGRAMMATIC,
}
/**
* Canonical station selection shared by the station table and detail views.
*
* Data refreshes reconcile the selected instance without moving the table. Explicit
* user navigation increments [scrollRequestRevision], even when the identity is the
* same as before.
*/
class StationSelectionState : TableSelectionModel<ChatMember> {
private var current: ChatMember? by mutableStateOf(null)
override val selected: ChatMember?
get() = current
override var selectionRevision: Long by mutableStateOf(0L)
private set
override var scrollRequestRevision: Long by mutableStateOf(0L)
private set
var lastSource: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
private set
val identity: StationIdentity?
get() = StationIdentity.of(current)
override fun select(row: ChatMember?) {
select(row, StationSelectionSource.STATION_TABLE)
}
fun select(row: ChatMember?, source: StationSelectionSource) {
current = row
lastSource = source
selectionRevision++
if (row != null && source.requestsStationTableScroll()) {
scrollRequestRevision++
}
}
override fun reconcile(row: ChatMember?) {
current = row
selectionRevision++
}
override fun requestScroll() {
if (current != null) {
scrollRequestRevision++
}
}
}
private fun StationSelectionSource.requestsStationTableScroll(): Boolean = when (this) {
StationSelectionSource.STATION_TABLE,
StationSelectionSource.STATION_CONTEXT_MENU,
StationSelectionSource.RESET_VIEW,
StationSelectionSource.PROGRAMMATIC -> false
StationSelectionSource.DIRECTED_MESSAGE,
StationSelectionSource.PUBLIC_MESSAGE,
StationSelectionSource.QSO_OF_THE_OTHER,
StationSelectionSource.MAP,
StationSelectionSource.MAP_CONTEXT_MENU,
StationSelectionSource.PRIORITY,
StationSelectionSource.NEW_JOINED,
StationSelectionSource.TIMELINE -> true
}
@@ -49,6 +49,23 @@ data class TimelineCandidate(
val tooltipText: String?, val tooltipText: String?,
) )
/** The four candidate groups produced for every minute bucket, in display order. */
enum class TimelineLane(val index: Int) {
IN_BEAM_1(0),
IN_BEAM_2(1),
PRIORITY_1(2),
PRIORITY_2(3);
companion object {
fun forIndex(index: Int): TimelineLane = entries[index.coerceIn(0, entries.lastIndex)]
}
}
data class TimelineTick(
val minutes: Int,
val x: Float,
)
/** /**
* Where things sit on the strip. * Where things sit on the strip.
* *
@@ -67,27 +84,27 @@ object TimelineGeometry {
* is why the candidate labels and the sked labels landed on top of each other. The * 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. * lanes below are laid out for the real height.
*/ */
const val HEIGHT: Float = 80f const val HEIGHT: Float = 104f
/** The time axis, with the candidate lanes above it and the skeds below. */ /** The time axis, with the candidate lanes above it and the skeds below. */
const val AXIS_Y: Float = 44f const val AXIS_Y: Float = 70f
const val TICK_LABEL_Y: Float = 72f
/** Candidates keep further from the edges than skeds do, because their labels are wider. */ /** Candidates keep further from the edges than skeds do, because their labels are wider. */
const val CANDIDATE_MARGIN: Float = 30f const val CANDIDATE_MARGIN: Float = 30f
const val SKED_MARGIN: Float = 10f const val SKED_MARGIN: Float = 10f
const val LANE_BASE_Y: Float = 2f const val LANE_BASE_Y: Float = 2f
const val LANE_HEIGHT: Float = 20f const val LANE_HEIGHT: Float = 16f
/** /**
* How many candidate lanes fit above the axis. The producer keeps at most two * How many candidate lanes the producer defines: two in-beam choices followed by
* candidates per minute, so this is a guard rather than a limit in practice: a third * two global priority choices.
* lane shares the second one instead of being drawn across the axis.
*/ */
const val LANE_COUNT: Int = 2 const val LANE_COUNT: Int = 4
/** Skeds get their own lane below the axis, which is what frees the labels. */ /** Skeds get their own lane below the axis, which is what frees the labels. */
const val SKED_Y: Float = 52f const val SKED_Y: Float = 86f
const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0 const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0
@@ -124,6 +141,23 @@ object TimelineGeometry {
fun laneY(laneIndex: Int): Float = fun laneY(laneIndex: Int): Float =
LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1) LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1)
/**
* Relative minute ticks for the current preview window. Five-minute ticks are used
* whenever their labels fit; compact layouts increase the interval instead of
* drawing overlapping text.
*/
fun minuteTicks(axisWidth: Float, minimumLabelSpacing: Float): List<TimelineTick> {
if (axisWidth <= 0f) return emptyList()
val previewMinutes = (PREVIEW_MILLIS / 60_000L).toInt()
val interval = listOf(5, 10, 15, previewMinutes).first { minutes ->
axisWidth * minutes / previewMinutes >= minimumLabelSpacing
}
return (0..previewMinutes step interval).map { minutes ->
TimelineTick(minutes, axisWidth * minutes / previewMinutes)
}
}
} }
/** /**
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kst4contest.model.ContestSked import kst4contest.model.ContestSked
import kst4contest.view.i18n.LocalStrings
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** The strip is read like a radar screen, so it stays dark in both designs. */ /** The strip is read like a radar screen, so it stays dark in both designs. */
@@ -47,6 +49,8 @@ private val MARKER_SIZE = 12.dp
private val LABEL_GAP = 4.dp private val LABEL_GAP = 4.dp
private val CHIP_SHAPE = RoundedCornerShape(6.dp) private val CHIP_SHAPE = RoundedCornerShape(6.dp)
private val CHIP_BACKGROUND = Color(0xA6000000) private val CHIP_BACKGROUND = Color(0xA6000000)
private val LANE_LABEL_WIDTH = 68.dp
private val AXIS_LABEL_PADDING = 6.dp
/** /**
* The strip above the send field: what is coming in the next half hour. * The strip above the send field: what is coming in the next half hour.
@@ -82,6 +86,7 @@ fun TimelineView(
val density = LocalDensity.current val density = LocalDensity.current
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
val strings = LocalStrings.current
BoxWithConstraints( BoxWithConstraints(
modifier modifier
@@ -89,17 +94,60 @@ fun TimelineView(
.background(STRIP_BACKGROUND), .background(STRIP_BACKGROUND),
) { ) {
val widthPx = with(density) { maxWidth.toPx() } val widthPx = with(density) { maxWidth.toPx() }
val labelGutterPx = with(density) { LANE_LABEL_WIDTH.toPx() }.coerceAtMost(widthPx)
val axisWidthPx = (widthPx - labelGutterPx).coerceAtLeast(0f)
val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() } val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() } val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() } val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
val tickLabels = TimelineGeometry.minuteTicks(
axisWidth = axisWidthPx,
minimumLabelSpacing = measurer.measure("+30 ${strings.timelineMinuteSuffix}", AXIS_TEXT_STYLE)
.size.width.toFloat() + with(density) { AXIS_LABEL_PADDING.toPx() },
).map { tick ->
tick to if (tick.minutes == 0) {
"0 ${strings.timelineMinuteSuffix}"
} else {
"+${tick.minutes} ${strings.timelineMinuteSuffix}"
}
}
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
drawLine(AXIS_COLOR, Offset(0f, axisY), Offset(size.width, axisY)) drawLine(AXIS_COLOR, Offset(labelGutterPx, axisY), Offset(size.width, axisY))
tickLabels.forEach { (tick, _) ->
val x = labelGutterPx + tick.x
drawLine(AXIS_COLOR, Offset(x, axisY - 2.dp.toPx()), Offset(x, axisY + 3.dp.toPx()))
}
}
val laneLabels = listOf(
strings.timelineInBeam1,
strings.timelineInBeam2,
strings.timelinePriority1,
strings.timelinePriority2,
)
laneLabels.forEachIndexed { index, label ->
TimelineAxisLabel(label, TimelineGeometry.laneY(index).dp)
}
TimelineAxisLabel(strings.timelineSkeds, TimelineGeometry.SKED_Y.dp)
tickLabels.forEach { (tick, label) ->
val labelWidthPx = measurer.measure(label, AXIS_TEXT_STYLE).size.width.toFloat()
val x = (labelGutterPx + tick.x - labelWidthPx / 2f)
.coerceIn(labelGutterPx, (widthPx - labelWidthPx).coerceAtLeast(labelGutterPx))
Text(
text = label,
modifier = Modifier.offset {
IntOffset(x.roundToInt(), with(density) { TimelineGeometry.TICK_LABEL_Y.dp.toPx() }.roundToInt())
},
style = AXIS_TEXT_STYLE,
maxLines = 1,
)
} }
// The candidate lanes, above the axis. // The candidate lanes, above the axis.
state.candidates.forEach { candidate -> state.candidates.forEach { candidate ->
val x = TimelineGeometry.xFor(candidate.timeUntilMs, widthPx, candidateMargin) val x = TimelineGeometry.xFor(candidate.timeUntilMs, axisWidthPx, candidateMargin)
?.plus(labelGutterPx)
?: return@forEach ?: return@forEach
TimelineMarker( TimelineMarker(
@@ -120,7 +168,8 @@ fun TimelineView(
// above it, which is why their labels sat on top of the candidates'. // above it, which is why their labels sat on top of the candidates'.
val now = nowMillis.value val now = nowMillis.value
state.skeds.forEach { sked -> state.skeds.forEach { sked ->
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, widthPx, skedMargin) val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, axisWidthPx, skedMargin)
?.plus(labelGutterPx)
?: return@forEach ?: return@forEach
TimelineMarker( TimelineMarker(
@@ -157,6 +206,23 @@ private val CHIP_TEXT_STYLE = TextStyle(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
private val AXIS_TEXT_STYLE = TextStyle(
color = Color(0xFFB8B8B8),
fontSize = 8.sp,
)
@Composable
private fun TimelineAxisLabel(text: String, top: Dp) {
Text(
text = text,
modifier = Modifier
.offset(x = 4.dp, y = top)
.width(LANE_LABEL_WIDTH - 8.dp),
style = AXIS_TEXT_STYLE,
maxLines = 1,
)
}
/** /**
* One marker: the symbol on the time axis and its callsign beside it. * One marker: the symbol on the time axis and its callsign beside it.
* *
@@ -0,0 +1,353 @@
package kst4contest.view.compose
import java.awt.GraphicsEnvironment
import java.awt.Point
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.Window
/**
* One connected screen: its whole area and the part a window may use.
*
* @param bounds the screen in the shared desktop coordinate space. A screen left of or
* above the primary one has negative coordinates.
* @param usable [bounds] minus task bars, docks and panels
*/
data class ScreenArea(val bounds: Rectangle, val usable: Rectangle)
/**
* A window position kept across restarts, together with the screen it was on.
*
* @param position the window's top-left corner in desktop coordinates
* @param screen the bounds of the screen the window was on when it was stored
*/
data class StoredWindowPosition(val position: Point, val screen: Rectangle)
/**
* Where window positions are kept across restarts, when the operator opted in.
*
* The application sets it per profile; [load] answers null and [save] does nothing while
* the opt-in is off, so the hosts need not know about the setting.
*/
interface WindowPositionMemory {
fun load(windowId: String): StoredWindowPosition?
fun save(windowId: String, stored: StoredWindowPosition)
}
/**
* Where a secondary window opens, decided without touching AWT (issue #59).
*
* The rule: a window the operator moved during this session reopens where it was moved to,
* as long as its title bar can still be reached on a connected screen — shifted just far
* enough to lie wholly within that screen's usable area. Otherwise it opens centred on the
* screen the main window is on. Either way its size is cut down to the usable area of the
* screen it lands on, so no button sits past the edge.
*
* The cut-down size is only the opening size. The host does not write it back, so the
* stored size survives a stay on a smaller screen; a resize the operator makes there is
* an ordinary resize and is saved.
*
* Before this, every secondary window got the platform's default position, which on a
* multi-monitor desk is the primary screen — the station map opened on a screen the
* operator was not looking at, or on one that was switched off.
*
* Pure on purpose: the rule is the part that can be wrong, and it needs screens that no
* test machine has. [WindowPlacementOnScreen] feeds it the real screens.
*/
object WindowPlacement {
/**
* How much of the window's top edge counts as the title bar. A remembered position
* is only kept when this strip can be grabbed, because a window whose title bar is
* off-screen cannot be moved back by the operator.
*/
const val TITLE_BAR_HEIGHT = 32
/**
* How wide a piece of the title bar must be visible to count as reachable. Narrower
* windows need their whole width.
*/
const val MIN_VISIBLE_TITLE_WIDTH = 100
/**
* Where the window opens and how big it is.
*
* @param screens the connected screens; empty when there is no display
* @param mainWindow the main window's frame bounds, null while it does not exist
* @param remembered where the operator moved this window to during the session, or null
* @param width the size the window asks for
* @param height the size the window asks for
* @return the frame bounds to open with, or null to leave placement to the platform —
* which is the behaviour before the main window exists
*/
fun place(
screens: List<ScreenArea>,
mainWindow: Rectangle?,
remembered: Point?,
width: Int,
height: Int,
): Rectangle? {
if (screens.isEmpty()) {
return null
}
if (remembered != null) {
val screen = screenShowingTitleBar(screens, remembered, width)
if (screen != null) {
return keptWithin(usableArea(screen), remembered, width, height)
}
}
if (mainWindow == null) {
return null
}
return centredOn(usableArea(screenOf(screens, mainWindow)), width, height)
}
/**
* Where a window opens from a position stored in an earlier run.
*
* Only on the very screen it was stored on: same place in the desktop, same size. A
* changed layout -- a monitor unplugged, rearranged or switched to another resolution --
* makes the stored position meaningless, and the caller falls back to the session rule.
* On that screen the window is kept within the usable area, like a session position.
*
* @return the frame bounds to open with, or null when the stored screen is not connected
*/
fun restore(screens: List<ScreenArea>, stored: StoredWindowPosition, width: Int, height: Int): Rectangle? {
val screen = screens.firstOrNull { it.bounds == stored.screen } ?: return null
return keptWithin(usableArea(screen), stored.position, width, height)
}
/**
* The window cut down to the usable area of the screen it is on, or null when it fits.
*
* For a window that is already open and was moved onto a smaller screen -- a laptop
* panel, a 640x480 field monitor. Opening sizes are cut by [place] and [restore]; this
* covers the move. Shifted the least distance into the usable area, like [keptWithin].
*/
fun fitted(screens: List<ScreenArea>, window: Rectangle): Rectangle? {
if (screens.isEmpty()) {
return null
}
val usable = usableArea(screenOf(screens, window))
if (window.width <= usable.width && window.height <= usable.height) {
return null
}
return keptWithin(usable, window.location, window.width, window.height)
}
/**
* A minimum size no larger than the smallest usable area: AWT enforces the minimum even when it does
* not fit the screen, which leaves the window's edges unreachable.
*/
fun minimumWithin(screens: List<ScreenArea>, width: Int, height: Int): java.awt.Dimension {
if (screens.isEmpty()) {
return java.awt.Dimension(width, height)
}
val areas = screens.map(::usableArea)
return java.awt.Dimension(
clamp(width, areas.minOf { it.width }),
clamp(height, areas.minOf { it.height }),
)
}
/**
* The window centred on [usable], cut down to fit it.
*/
fun centredOn(usable: Rectangle, width: Int, height: Int): Rectangle {
val w = clamp(width, usable.width)
val h = clamp(height, usable.height)
return Rectangle(
usable.x + (usable.width - w) / 2,
usable.y + (usable.height - h) / 2,
w,
h,
)
}
/**
* The window at [position], cut down to fit [usable] and shifted the least distance
* that puts it wholly inside. The cut comes first, so the shift always has room.
*/
fun keptWithin(usable: Rectangle, position: Point, width: Int, height: Int): Rectangle {
val w = clamp(width, usable.width)
val h = clamp(height, usable.height)
return Rectangle(
position.x.coerceIn(usable.x, usable.x + usable.width - w),
position.y.coerceIn(usable.y, usable.y + usable.height - h),
w,
h,
)
}
/**
* The screen a window is on: the one holding its centre, else the one it overlaps
* most, else the first. The centre decides first because a window straddling two
* screens is "on" the one the operator sees most of it on, and the centre says so
* without an area comparison in the common case.
*/
fun screenOf(screens: List<ScreenArea>, window: Rectangle): ScreenArea {
val centre = Point(window.x + window.width / 2, window.y + window.height / 2)
screens.firstOrNull { it.bounds.contains(centre) }?.let { return it }
return screens.maxByOrNull { overlapArea(it.bounds, window) }
?.takeIf { overlapArea(it.bounds, window) > 0 }
?: screens.first()
}
/**
* The screen on which the title bar of a window at [position] can be grabbed, or null
* when it cannot be grabbed on any — the screen was unplugged, or the window was
* pushed past an edge.
*/
private fun screenShowingTitleBar(
screens: List<ScreenArea>,
position: Point,
width: Int,
): ScreenArea? {
val titleBar = Rectangle(position.x, position.y, width.coerceAtLeast(1), TITLE_BAR_HEIGHT)
val neededWidth = minOf(MIN_VISIBLE_TITLE_WIDTH, titleBar.width)
val neededHeight = TITLE_BAR_HEIGHT / 2
return screens
.filter {
val visible = usableArea(it).intersection(titleBar)
!visible.isEmpty && visible.width >= neededWidth && visible.height >= neededHeight
}
.maxByOrNull { overlapArea(usableArea(it), titleBar) }
}
/**
* The usable area, or the whole screen when the insets leave nothing — a broken inset
* report must not shrink every window to a single pixel.
*/
private fun usableArea(screen: ScreenArea): Rectangle =
if (screen.usable.isEmpty) screen.bounds else screen.usable
private fun clamp(wanted: Int, available: Int): Int =
wanted.coerceAtMost(available).coerceAtLeast(1)
private fun overlapArea(a: Rectangle, b: Rectangle): Long {
val overlap = a.intersection(b)
return if (overlap.isEmpty) 0L else overlap.width.toLong() * overlap.height
}
}
/**
* The AWT side of [WindowPlacement]: the real screens and the real main window.
*
* Kept thin so that what no headless test can see is only reading the screens and
* handing them over. Call it on the AWT event thread, like everything that touches a
* window.
*/
object WindowPlacementOnScreen {
/**
* The main window's frame, published by its host while it is open. Read only for its
* bounds; nothing here moves it.
*/
@Volatile
var mainWindow: Window? = null
/**
* The positions kept across restarts, set by the application for the active profile.
* Null in tests and before startup, which means nothing is restored or stored.
*/
@JvmStatic
@Volatile
var memory: WindowPositionMemory? = null
/**
* Where a window opens from its stored position, or null when none applies.
*/
fun restore(windowId: String, width: Int, height: Int): Rectangle? {
val stored = memory?.load(windowId) ?: return null
return WindowPlacement.restore(connectedScreens(), stored, width, height)
}
/**
* The minimum size a window may enforce, capped to the smallest connected screen. The
* smallest rather than the current one, because the window may be moved there later
* and must then still be cut down to fit.
*/
fun minimumSizeOnScreens(width: Int, height: Int): java.awt.Dimension =
WindowPlacement.minimumWithin(connectedScreens(), width, height)
/**
* The window cut down to the screen it is now on, or null when it fits.
*/
fun fitted(window: Rectangle): Rectangle? = WindowPlacement.fitted(connectedScreens(), window)
/**
* Stores the position the operator moved a window to, with the screen it is now on.
*/
fun store(windowId: String, position: Point, width: Int, height: Int) {
val target = memory ?: return
val screens = connectedScreens()
if (screens.isEmpty()) {
return
}
val screen = WindowPlacement.screenOf(screens, Rectangle(position.x, position.y, width, height))
target.save(windowId, StoredWindowPosition(position, screen.bounds))
}
/** The connected screens, the default one first. Empty without a display. */
fun connectedScreens(): List<ScreenArea> = runCatching {
if (GraphicsEnvironment.isHeadless()) {
return emptyList()
}
val environment = GraphicsEnvironment.getLocalGraphicsEnvironment()
val toolkit = Toolkit.getDefaultToolkit()
val default = environment.defaultScreenDevice
environment.screenDevices
.sortedByDescending { it == default }
.map { device ->
val configuration = device.defaultConfiguration
val bounds = configuration.bounds
val insets = toolkit.getScreenInsets(configuration)
ScreenArea(
bounds = bounds,
usable = Rectangle(
bounds.x + insets.left,
bounds.y + insets.top,
bounds.width - insets.left - insets.right,
bounds.height - insets.top - insets.bottom,
),
)
}
}.getOrDefault(emptyList())
/**
* The main window's bounds, or null while there is no main window on screen.
*/
fun mainWindowBounds(): Rectangle? =
mainWindow?.takeIf { it.isDisplayable && it.isShowing }?.bounds
/**
* Where a secondary window opens, from the live screens and main window.
*
* @return null to leave placement to the platform
*/
fun place(remembered: Point?, width: Int, height: Int): Rectangle? =
WindowPlacement.place(connectedScreens(), mainWindowBounds(), remembered, width, height)
/**
* Centres a dialog on the main window's screen, cut down to fit.
*
* Before the main window exists — the profile picker at startup — the dialog is
* centred the way it always was, on the primary screen.
*/
@JvmStatic
fun centreOnMainWindowScreen(dialog: Window) {
val placed = place(null, dialog.width, dialog.height)
if (placed == null) {
dialog.setLocationRelativeTo(null)
} else {
dialog.bounds = placed
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -24,7 +25,11 @@ import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -50,6 +55,7 @@ import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.log2 import kotlin.math.log2
import kotlin.math.sign import kotlin.math.sign
import kotlin.math.roundToInt
/** /**
* Where the map is looking. Compose state, so a pan or a zoom reaches the screen. * Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
@@ -94,6 +100,7 @@ class MapState(
* any zoom with a fraction, the antenna beam did not start where the map said home was. * 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. * Neither failure is possible here, because nothing is scaled.
*/ */
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun ComposeStationMap( fun ComposeStationMap(
markers: List<MapCallsignRawSnapshot>, markers: List<MapCallsignRawSnapshot>,
@@ -106,6 +113,8 @@ fun ComposeStationMap(
antennaBeamWidthDeg: Double, antennaBeamWidthDeg: Double,
maxQrbKm: Double, maxQrbKm: Double,
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?, onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
onMarkerContextRequested: ((List<MapCallsignRawSnapshot>, IntOffset) -> Unit)? = null,
workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true, darkMode: Boolean = true,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -122,6 +131,7 @@ fun ComposeStationMap(
* wrongly grouped markers whenever the window is resized. * wrongly grouped markers whenever the window is resized.
*/ */
var canvasSize by remember { mutableStateOf(Size.Zero) } var canvasSize by remember { mutableStateOf(Size.Zero) }
var canvasCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
/* Bumped when a tile arrives, which is what asks for the frame that shows it. */ /* Bumped when a tile arrives, which is what asks for the frame that shows it. */
var tileRevision by remember { mutableStateOf(0) } var tileRevision by remember { mutableStateOf(0) }
@@ -196,6 +206,39 @@ fun ComposeStationMap(
modifier = modifier modifier = modifier
.clipToBounds() .clipToBounds()
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) } .onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
.onGloballyPositioned { canvasCoordinates = it }
.onPointerEvent(PointerEventType.Press) { event ->
if (event.buttons.isSecondaryPressed) {
val local = event.changes.firstOrNull()?.position ?: return@onPointerEvent
val viewport = mapState.viewportFor(canvasSize.width, canvasSize.height)
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
val cluster = result.clusters().firstOrNull { candidate ->
within(
local,
viewport.screenX(candidate.centerLon).toFloat(),
viewport.screenY(candidate.centerLat).toFloat(),
clusterRadiusPx(candidate.size()),
)
}
val individual = result.individual().firstOrNull { marker ->
within(
local,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
val targets = cluster?.getMarkers() ?: individual?.let(::listOf)
val window = canvasCoordinates?.localToWindow(local)
if (!targets.isNullOrEmpty() && window != null) {
onMarkerContextRequested?.invoke(
targets,
IntOffset(window.x.roundToInt(), window.y.roundToInt()),
)
event.changes.forEach { it.consume() }
}
}
}
.pointerInput(Unit) { .pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoomChange, _ -> detectTransformGestures { centroid, pan, zoomChange, _ ->
var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat()) var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
@@ -293,6 +336,7 @@ fun ComposeStationMap(
drawTiles(viewport, imageCache, darkMode) drawTiles(viewport, imageCache, darkMode)
drawMaidenheadGrid(viewport, textMeasurer, darkMode) drawMaidenheadGrid(viewport, textMeasurer, darkMode)
drawWorkedGrossFields(viewport, workedGrossFields)
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode) drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode) drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
drawHover(viewport, hoverPosition, clusterResult, textMeasurer) drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
@@ -709,10 +753,7 @@ private fun DrawScope.drawHover(
val text = when { val text = when {
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer) clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
markerUnderPointer != null -> markerUnderPointer.locator6().let { locator -> markerUnderPointer != null -> markerUnderPointer.tooltipText()
if (locator.isBlank()) markerUnderPointer.displayCallSign()
else "${markerUnderPointer.displayCallSign()} ($locator)"
}
else -> return else -> return
} }
@@ -758,6 +799,46 @@ private fun DrawScope.drawHover(
) )
} }
private fun DrawScope.drawWorkedGrossFields(
viewport: MapViewport,
workedGrossFields: Set<String>,
) {
visibleWorkedGrossFieldCells(viewport, workedGrossFields).forEach { cell ->
val left = viewport.screenX(cell.westLon()).toFloat()
val right = viewport.screenX(cell.eastLon()).toFloat()
val top = viewport.screenY(cell.northLat()).toFloat()
val bottom = viewport.screenY(cell.southLat()).toFloat()
val topLeft = Offset(minOf(left, right), minOf(top, bottom))
val cellSize = Size(kotlin.math.abs(right - left), kotlin.math.abs(bottom - top))
drawRect(
color = WORKED_GRID_FILL,
topLeft = topLeft,
size = cellSize,
)
drawRect(
color = WORKED_GRID_BORDER,
topLeft = topLeft,
size = cellSize,
style = Stroke(width = WORKED_GRID_BORDER_WIDTH_PX),
)
}
}
/** Worked four-character cells visible in the current viewport, independent of markers. */
internal fun visibleWorkedGrossFieldCells(
viewport: MapViewport,
workedGrossFields: Set<String>,
): List<MaidenheadGridUtils.GridCell> {
if (workedGrossFields.isEmpty()) return emptyList()
return MaidenheadGridUtils.buildVisibleCells(
viewport.southLat(),
viewport.westLon(),
viewport.northLat(),
viewport.eastLon(),
MaidenheadGridUtils.GridPrecision.SQUARE_4,
).filter { it.locatorLabel() in workedGrossFields }
}
/* ----------------------------------------------------------- helpers */ /* ----------------------------------------------------------- helpers */
private val MapMarkerCluster.centerLon: Double get() = getCenterLon() private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
@@ -864,6 +945,9 @@ private const val GRID_LINE_WIDTH_PX = 1.4f
private val GRID_LABEL_COLOR = Color(0xFF63067A) private val GRID_LABEL_COLOR = Color(0xFF63067A)
private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f) 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 val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f)
private val WORKED_GRID_FILL = Color.Black.copy(alpha = 0.30f)
private val WORKED_GRID_BORDER = Color.Black
private const val WORKED_GRID_BORDER_WIDTH_PX = 3f
private const val GRID_LABEL_PADDING_PX = 3f private const val GRID_LABEL_PADDING_PX = 3f
private val DOT_FILL = Color(0xFF1D1D1D) private val DOT_FILL = Color(0xFF1D1D1D)
@@ -1,5 +1,6 @@
package kst4contest.view.compose.map package kst4contest.view.compose.map
import kst4contest.view.compose.WindowPlacementOnScreen
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -10,6 +11,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -18,13 +20,20 @@ import kst4contest.view.ApplicationRuntimeLauncher
import kst4contest.view.compose.PaletteStore import kst4contest.view.compose.PaletteStore
import kst4contest.view.compose.ComposeWindowHost import kst4contest.view.compose.ComposeWindowHost
import kst4contest.view.compose.MainWindowState import kst4contest.view.compose.MainWindowState
import kst4contest.view.compose.StationIdentity
import kst4contest.view.compose.StationContextMenuState
import kst4contest.view.compose.StationContextOverlays
import kst4contest.view.compose.StationSelectionSource
import kst4contest.view.map.MapCallsignRawSnapshotBuilder import kst4contest.view.map.MapCallsignRawSnapshotBuilder
import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.StationMapStatusText import kst4contest.view.map.StationMapStatusText
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
import java.util.EnumSet import java.util.EnumSet
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @OptIn(
androidx.compose.foundation.ExperimentalFoundationApi::class,
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
)
object StationMapWindow { object StationMapWindow {
private val host = ComposeWindowHost("Map") private val host = ComposeWindowHost("Map")
@@ -58,11 +67,17 @@ object StationMapWindow {
widthDp = widthDp, widthDp = widthDp,
heightDp = heightDp, heightDp = heightDp,
onResized = onResized, onResized = onResized,
alwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop,
) { _ -> ) { _ ->
val strings = LocalStrings.current
val window = this.window val window = this.window
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) } /*
* Never larger than the smallest screen: AWT enforces a minimum size even past
* the edge, and some operators work on 640x480.
*/
androidx.compose.runtime.LaunchedEffect(window, mainWindowState.prefs.isGUIstationMapAlwaysOnTop) {
window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600)
window?.isAlwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop
}
val tileFetcher = remember { TileFetcher() } val tileFetcher = remember { TileFetcher() }
/* /*
@@ -74,6 +89,42 @@ object StationMapWindow {
onDispose { tileFetcher.close() } onDispose { tileFetcher.close() }
} }
Content(
mainWindowState = mainWindowState,
tileFetcher = tileFetcher,
onAlwaysOnTopChanged = { enabled -> window?.isAlwaysOnTop = enabled },
)
} // host.show content
}
/**
* The window's body, without the window.
*
* Split out of [show] so it can be drawn headless in a test, with a tile fetcher that
* stays off the network and the user's tile cache, a roster that is not the running
* application's, and a [MapState] the test can read. The defaults are exactly what
* [show] used inline before the split.
*
* @param chatMembers the roster a marker click looks its station up in; a marker stands
* for a raw callsign, and this is where its category variants come from
* @param mapState where the map is looking
* @param pathAnalysis asked each time the selection changes; null means there is no
* running application, and then the map neither pans nor analyses, exactly as
* when no chat controller existed before the split
*/
@Composable
internal fun Content(
mainWindowState: MainWindowState,
tileFetcher: TileFetcher,
chatMembers: () -> List<kst4contest.model.ChatMember> = {
ApplicationRuntimeLauncher.getCurrent()?.chatController?.lst_chatMemberList?.snapshot().orEmpty()
},
mapState: MapState = remember { MapState() },
pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis,
onAlwaysOnTopChanged: (Boolean) -> Unit = { },
) {
val strings = LocalStrings.current
/* /*
* The fetcher is plain Java state, so nothing about it recomposes on its * The fetcher is plain Java state, so nothing about it recomposes on its
* own. Polled rather than wired through a callback: this drives one line of * own. Polled rather than wired through a callback: this drives one line of
@@ -91,6 +142,10 @@ object StationMapWindow {
var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) } var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) } var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
var alwaysOnTop by remember { mutableStateOf(prefs.isGUIstationMapAlwaysOnTop) }
val stationContextMenu = remember(mainWindowState) {
StationContextMenuState(mainWindowState::selectStation)
}
val selectedMember = mainWindowState.selectedStation.selected val selectedMember = mainWindowState.selectedStation.selected
@@ -99,7 +154,19 @@ object StationMapWindow {
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) { /*
* The markers are the stations the list shows, not all of them (#21): the
* JavaFX map took the sorted, filtered list (StationMapBridge.refreshNow), and
* the status line below already says "filtered view active". A selected
* station that a filter hides gets no marker either, as it did there.
*/
val markers by remember(
mainWindowState.stations.rows,
mainWindowState.stations.rowFilter,
mainWindowState.stations.contentRevision,
selectedMember,
mainWindowState.activeBands,
) {
derivedStateOf { derivedStateOf {
val builder = MapCallsignRawSnapshotBuilder() val builder = MapCallsignRawSnapshotBuilder()
val activeBands = mainWindowState.activeBands val activeBands = mainWindowState.activeBands
@@ -110,7 +177,7 @@ object StationMapWindow {
} }
builder.buildSnapshots( builder.buildSnapshots(
mainWindowState.stations.rows, mainWindowState.stations.filteredRows,
selectedMember, selectedMember,
bandSet bandSet
) )
@@ -118,19 +185,36 @@ object StationMapWindow {
} }
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) } var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
val mapState = remember { MapState() } var automaticAnalysisRevision by remember { mutableStateOf(0L) }
LaunchedEffect(selectedMember) { // Read through updated state so a caller passing a new lambda is never served a stale one.
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
/*
* Whether the selected station has a marker. A filter can hide it (#21), and a
* station without a locator never has one; then there is nothing to analyse.
*/
val selectedHasMarker = selectedMember != null &&
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
LaunchedEffect(selectedMember, selectedHasMarker) {
val automaticRequest = ++automaticAnalysisRevision
val explicitRevisionAtStart = mainWindowState.mapReachability.requestRevision
if (selectedMember == null) { if (selectedMember == null) {
pathAnalysisResult = null pathAnalysisResult = null
return@LaunchedEffect return@LaunchedEffect
} }
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
if (chatController != null) {
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw } val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
if (selectedSnapshot == null) {
// No marker: no request, and no "Loading" for a station the map does not show.
pathAnalysisResult = null
return@LaunchedEffect
}
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) { val analysis = currentPathAnalysis()
if (analysis != null) {
if (selectedSnapshot.hasUsablePosition()) {
/* /*
* Pan to the station and leave the zoom alone. The Leaflet map * Pan to the station and leave the zoom alone. The Leaflet map
* did exactly this (focusCallsignRaw) and touched the zoom only * did exactly this (focusCallsignRaw) and touched the zoom only
@@ -151,21 +235,35 @@ object StationMapWindow {
pathAnalysisResult = PathAnalysisResult.loading( pathAnalysisResult = PathAnalysisResult.loading(
prefs.stn_loginLocatorMainCat, prefs.stn_loginLocatorMainCat,
selectedSnapshot?.locator6() ?: "", selectedSnapshot.locator6(),
selectedSnapshot?.callSignRaw() ?: "" selectedSnapshot.callSignRaw()
) )
val overrideBand: kst4contest.model.Band? = null analysis.request(selectedMember, selectedSnapshot) { result ->
if (automaticAnalysisRevision == automaticRequest &&
chatController.reachabilityService.requestPathAnalysisForMap( mainWindowState.mapReachability.requestRevision == explicitRevisionAtStart
selectedMember, ) {
selectedSnapshot,
overrideBand
) { result ->
pathAnalysisResult = result pathAnalysisResult = result
} }
} }
} }
}
val explicitReachabilityResult = mainWindowState.mapReachability.result
LaunchedEffect(mainWindowState.mapReachability.requestRevision, explicitReachabilityResult) {
if (explicitReachabilityResult != null) {
automaticAnalysisRevision++
pathAnalysisResult = explicitReachabilityResult
pathAnalysisVisible = true
}
}
val workedGrossFields = if (mainWindowState.stationFilter.gridColouring) {
mainWindowState.stations.contentRevision
mainWindowState.workedGrossFieldsProvider()
} else {
emptySet()
}
androidx.compose.material3.Surface( androidx.compose.material3.Surface(
color = androidx.compose.material3.MaterialTheme.colorScheme.background, color = androidx.compose.material3.MaterialTheme.colorScheme.background,
@@ -180,10 +278,11 @@ object StationMapWindow {
* the theme's primary colour, which in the evening sheet is a bright * the theme's primary colour, which in the evening sheet is a bright
* green, and the map window was the only one wearing it. * green, and the map window was the only one wearing it.
*/ */
Row( FlowRow(
modifier = Modifier.fillMaxWidth().padding(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(4.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) { ) {
val statusText = StationMapStatusText.build( val statusText = StationMapStatusText.build(
markers.size, markers.size,
@@ -217,8 +316,7 @@ object StationMapWindow {
} }
kst4contest.view.compose.Form.button(strings.mapResetView) { kst4contest.view.compose.Form.button(strings.mapResetView) {
mainWindowState.stationSelector?.invoke(null) mainWindowState.selectStation(null, StationSelectionSource.RESET_VIEW)
?: mainWindowState.selectedStation.select(null)
val home = runCatching { val home = runCatching {
kst4contest.locatorUtils.Location(ownLocator6) kst4contest.locatorUtils.Location(ownLocator6)
}.getOrNull() }.getOrNull()
@@ -227,13 +325,39 @@ object StationMapWindow {
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */ /* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
} }
kst4contest.view.compose.Form.button(strings.filterReset) {
mainWindowState.resetStationFilters()
}
androidx.compose.foundation.TooltipArea(
tooltip = { kst4contest.view.compose.TooltipCard(strings.mapGroupNearbyTooltip) },
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
kst4contest.view.compose.CompactCheckbox(groupingEnabled) { kst4contest.view.compose.CompactCheckbox(
checked = groupingEnabled,
modifier = Modifier.testTag(GROUPING_TAG),
) {
groupingEnabled = it groupingEnabled = it
prefs.isGUIstationMapClusteringEnabled = it prefs.isGUIstationMapClusteringEnabled = it
mainWindowState.requestLayoutSave()
} }
Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp)) Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp))
} }
}
Row(verticalAlignment = Alignment.CenterVertically) {
kst4contest.view.compose.CompactCheckbox(
checked = alwaysOnTop,
onCheckedChange = { enabled ->
alwaysOnTop = enabled
prefs.isGUIstationMapAlwaysOnTop = enabled
onAlwaysOnTopChanged(enabled)
mainWindowState.requestLayoutSave()
},
modifier = Modifier.testTag(ALWAYS_ON_TOP_TAG),
)
Text(strings.mapAlwaysOnTop, modifier = Modifier.padding(start = 4.dp))
}
if (!pathAnalysisVisible) { if (!pathAnalysisVisible) {
Text( Text(
@@ -248,6 +372,7 @@ object StationMapWindow {
) { ) {
pathAnalysisVisible = !pathAnalysisVisible pathAnalysisVisible = !pathAnalysisVisible
prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
mainWindowState.requestLayoutSave()
} }
} }
@@ -276,29 +401,37 @@ object StationMapWindow {
antennaBeamWidthDeg = antennaBeamWidthDeg, antennaBeamWidthDeg = antennaBeamWidthDeg,
maxQrbKm = maxQrbKm, maxQrbKm = maxQrbKm,
darkMode = isDarkTheme, darkMode = isDarkTheme,
workedGrossFields = workedGrossFields,
onMarkerClicked = { snapshot -> onMarkerClicked = { snapshot ->
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController val variants = chatMembers()
val variants = chatController?.lst_chatMemberList?.snapshot() .filter { it.callSignRaw == snapshot.callSignRaw() }
?.filter { it.callSignRaw == snapshot.callSignRaw() }
.orEmpty()
/* /*
* A marker stands for a raw callsign, not a category. Keep the * A marker stands for a raw callsign, not a category. Keep the
* category already selected when it is one of the variants. * category already selected when it is one of the variants.
*/ */
val member = variants.firstOrNull { it === mainWindowState.selectedStation.selected } val selectedIdentity = StationIdentity.of(mainWindowState.selectedStation.selected)
?: variants.firstOrNull() val member = variants.firstOrNull {
StationIdentity.of(it) == selectedIdentity
} ?: variants.firstOrNull {
it.chatCategory?.categoryNumber ==
prefs.loginChatCategoryMain?.categoryNumber
} ?: variants.singleOrNull()
if (member != null) { if (member != null) {
val selector = mainWindowState.stationSelector mainWindowState.selectStation(member, StationSelectionSource.MAP)
if (selector != null) {
selector(member)
} else {
mainWindowState.selectedStation.select(member)
}
// Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq // Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true) mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
} }
}, },
modifier = Modifier.fillMaxSize() onMarkerContextRequested = { snapshots, position ->
val callsigns = snapshots.mapTo(linkedSetOf()) { it.callSignRaw() }
val candidates = chatMembers().filter { it.callSignRaw in callsigns }
stationContextMenu.open(
candidates,
StationSelectionSource.MAP_CONTEXT_MENU,
position,
)
},
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
) )
MapOverlayControls( MapOverlayControls(
@@ -361,8 +494,13 @@ object StationMapWindow {
} // main layout column } // main layout column
} // window column } // window column
} // Surface } // Surface
} // host.show content StationContextOverlays(stationContextMenu, mainWindowState)
} } // Content
/** Marks the map canvas so a test can click a marker on it. */
internal const val MAP_CANVAS_TAG = "station-map-canvas"
internal const val GROUPING_TAG = "station-map-grouping"
internal const val ALWAYS_ON_TOP_TAG = "station-map-always-on-top"
@JvmStatic @JvmStatic
fun applyDarkMode(darkMode: Boolean) { fun applyDarkMode(darkMode: Boolean) {
@@ -397,3 +535,34 @@ object StationMapWindow {
} }
} }
} }
/**
* Asks for the path analysis of the station selected on the map.
*
* The seam between the map window and the running application: the window pans to the
* station and starts the analysis only when there is one to ask, which a test supplies
* without building a chat controller.
*/
internal fun interface MapPathAnalysis {
fun request(
member: kst4contest.model.ChatMember,
snapshot: kst4contest.view.map.MapCallsignRawSnapshot?,
onResult: (PathAnalysisResult) -> Unit,
)
}
/**
* The running application's reachability service, or null before there is one.
*
* Looked up on every call, as the window did inline: a profile switch replaces the runtime.
* No band override -- the map always analyses on the automatically resolved band.
*/
private fun runtimePathAnalysis(): MapPathAnalysis? {
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController ?: return null
return MapPathAnalysis { member, snapshot, onResult ->
chatController.reachabilityService.requestPathAnalysisForMap(member, snapshot, null) { result ->
onResult(result)
}
}
}
@@ -16,8 +16,8 @@ import kst4contest.view.compose.Form
/** /**
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen. * The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
* *
* Labels, defaults and the closing note are the JavaFX ones, including that the server * Labels and defaults are the JavaFX ones. The closing note says that the server
* identifier, the client identifier and the frequency mode take effect at once while a * identifier, the client identifier and the frequency mode take effect when applied while a
* changed UDP port needs a reconnect. * changed UDP port needs a reconnect.
* *
* The identifier, port and band fields keep what the operator typed and mark it as an * The identifier, port and band fields keep what the operator typed and mark it as an
@@ -1,14 +1,16 @@
package kst4contest.view.compose.tabs package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/** /**
* The Airscout tab as a typed facade over ChatPreferences. * The Airscout tab as a typed facade over ChatPreferences.
* *
* Every property writes through immediately and there is deliberately no apply(), the * Edits are staged in the window's [SettingsDraft] and take effect when the operator
* way the JavaFX controls behaved. * applies them.
* *
* Three properties are guarded before the write, and the guard is not decoration. The * Three properties are guarded before they are staged, and the guard is not decoration. The
* ChatPreferences setters do not keep the previous value on bad input, they substitute * ChatPreferences setters do not keep the previous value on bad input, they substitute
* the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535 * the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535
* becomes 9872, a band value that is not a positive number becomes "1440000". The * becomes 9872, a band value that is not a positive number becomes "1440000". The
@@ -19,27 +21,48 @@ import kst4contest.model.ChatPreferences
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band * The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
* value are protocol values and neither the defaults nor the bounds are touched. * value are protocol values and neither the defaults nor the bounds are touched.
*/ */
class AirscoutTabState(private val prefs: ChatPreferences) { class AirscoutTabState(private val prefs: ChatPreferences, private val draft: SettingsDraft) {
var airScout_asUDPListenerEnabled: Boolean var airScout_asUDPListenerEnabled: Boolean by draft.setting(
get() = prefs.isAirScout_asUDPListenerEnabled() { prefs.isAirScout_asUDPListenerEnabled() },
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) } ) { prefs.setAirScout_asUDPListenerEnabled(it) }
/*
* The drafted values behind the guarded properties below. Only a valid, normalised value
* is staged, so neither the draft nor apply can hand the setters what they would replace
* with the factory default.
*/
private var serverName: String by draft.setting(
{ prefs.getAirScout_asServerNameString() },
) { prefs.setAirScout_asServerNameString(it) }
private var clientName: String by draft.setting(
{ prefs.getAirScout_asClientNameString() },
) { prefs.setAirScout_asClientNameString(it) }
private var communicationPort: Int by draft.setting(
{ prefs.getAirScout_asCommunicationPort() },
) { prefs.setAirScout_asCommunicationPort(it) }
private var bandString: String by draft.setting(
{ prefs.getAirScout_asBandString() },
) { prefs.setAirScout_asBandString(it) }
/** Logical identifier of the target AirScout server; invalid input is ignored. */ /** Logical identifier of the target AirScout server; invalid input is ignored. */
var airScout_asServerNameString: String var airScout_asServerNameString: String
get() = prefs.getAirScout_asServerNameString() get() = serverName
set(value) { set(value) {
if (isValidIdentifier(value)) { if (isValidIdentifier(value)) {
prefs.setAirScout_asServerNameString(value.trim()) serverName = value.trim()
} }
} }
/** Identifier of this KST4Contest instance; invalid input is ignored. */ /** Identifier of this KST4Contest instance; invalid input is ignored. */
var airScout_asClientNameString: String var airScout_asClientNameString: String
get() = prefs.getAirScout_asClientNameString() get() = clientName
set(value) { set(value) {
if (isValidIdentifier(value)) { if (isValidIdentifier(value)) {
prefs.setAirScout_asClientNameString(value.trim()) clientName = value.trim()
} }
} }
@@ -49,10 +72,10 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* that changes here. * that changes here.
*/ */
var airScout_asCommunicationPort: Int var airScout_asCommunicationPort: Int
get() = prefs.getAirScout_asCommunicationPort() get() = communicationPort
set(value) { set(value) {
if (isValidPort(value)) { if (isValidPort(value)) {
prefs.setAirScout_asCommunicationPort(value) communicationPort = value
} }
} }
@@ -60,9 +83,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* Pick the AirScout frequency per station instead of forcing one value. Uses the * Pick the AirScout frequency per station instead of forcing one value. Uses the
* station's current QRG first, then station-name and chat-category evidence. * station's current QRG first, then station-name and chat-category evidence.
*/ */
var airScout_autoBandSelectionEnabled: Boolean var airScout_autoBandSelectionEnabled: Boolean by draft.setting(
get() = prefs.isAirScout_autoBandSelectionEnabled() { prefs.isAirScout_autoBandSelectionEnabled() },
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) } ) { prefs.setAirScout_autoBandSelectionEnabled(it) }
/** /**
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz. * The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
@@ -70,9 +93,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* value produced in the JavaFX field. * value produced in the JavaFX field.
*/ */
var airScout_asBandString: String var airScout_asBandString: String
get() = prefs.getAirScout_asBandString() get() = bandString
set(value) { set(value) {
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) } parseBandValue(value)?.let { bandString = it }
} }
/** /**
@@ -31,6 +31,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconTextMainCat, stored = state.beaconTextMainCat,
commit = state::commitBeaconTextMainCat, commit = state::commitBeaconTextMainCat,
onRefused = onRefused, onRefused = onRefused,
current = { state.beaconTextMainCat },
) )
} }
@@ -43,6 +44,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconTextSecondCat, stored = state.beaconTextSecondCat,
commit = state::commitBeaconTextSecondCat, commit = state::commitBeaconTextSecondCat,
onRefused = onRefused, onRefused = onRefused,
current = { state.beaconTextSecondCat },
) )
} }
@@ -52,6 +54,12 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconIntervalMinutes.toString(), stored = state.beaconIntervalMinutes.toString(),
commit = state::commitBeaconInterval, commit = state::commitBeaconInterval,
onRefused = onRefused, onRefused = onRefused,
/*
* Compared as the number commitBeaconInterval stores, so " 9" or "09" after
* Enter does not count as a second edit and restart the timer again.
*/
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
current = { state.beaconIntervalMinutes.toString() },
) )
} }
} }
@@ -2,6 +2,9 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/** /**
* State of the beacon tab: the two CQ templates and the one interval they share. * State of the beacon tab: the two CQ templates and the one interval they share.
@@ -9,34 +12,57 @@ import kst4contest.model.ChatPreferences
* The two commit methods are the ports of `applyBeaconTextSetting` and * The two commit methods are the ports of `applyBeaconTextSetting` and
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they * `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
* return the refusal text instead, so the tab decides how to show it and a test can * return the refusal text instead, so the tab decides how to show it and a test can
* read it. * read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
* when the operator applies it; a refused one never gets there.
* *
* @param validateBeaconTemplate throws IllegalArgumentException with the reason, * @param validateBeaconTemplate throws IllegalArgumentException with the reason,
* the contract of `ChatController.validateBeaconTemplate`. * the contract of `ChatController.validateBeaconTemplate`.
* @param restartBeaconTimer called only after the interval actually changed; the * @param restartBeaconTimer called when a changed interval is applied; the JavaFX code
* JavaFX code restarted the shared timer there and nowhere else. * restarted the shared timer there and nowhere else.
*/ */
class BeaconTabState( class BeaconTabState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val validateBeaconTemplate: (String) -> Unit, private val validateBeaconTemplate: (String) -> Unit,
private val restartBeaconTimer: () -> Unit, private val restartBeaconTimer: () -> Unit,
val mainCategoryName: String, val mainCategoryName: String,
val secondCategoryName: String, val secondCategoryName: String,
) { ) {
var bcn_beaconsEnabledMainCat: Boolean var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
get() = prefs.isBcn_beaconsEnabledMainCat() { prefs.isBcn_beaconsEnabledMainCat() },
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) } ) { prefs.setBcn_beaconsEnabledMainCat(it) }
var bcn_beaconsEnabledSecondCat: Boolean var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
get() = prefs.isBcn_beaconsEnabledSecondCat() { prefs.isBcn_beaconsEnabledSecondCat() },
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) } ) { prefs.setBcn_beaconsEnabledSecondCat(it) }
private var textMainCat: String by draft.setting(
{ prefs.getBcn_beaconTextMainCat() },
) { prefs.setBcn_beaconTextMainCat(it) }
private var textSecondCat: String by draft.setting(
{ prefs.getBcn_beaconTextSecondCat() },
) { prefs.setBcn_beaconTextSecondCat(it) }
/*
* Both stored intervals written together, as applySharedBeaconInterval did. The
* second-category value is legacy XML which nothing reads any more, but it is written
* to keep the two in step.
*/
private var interval: Int by draft.setting(
{ prefs.getBcn_beaconIntervalInMinutesMainCat() },
effect = { restartBeaconTimer() },
) { minutes ->
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
}
val beaconTextMainCat: String val beaconTextMainCat: String
get() = prefs.getBcn_beaconTextMainCat() get() = textMainCat
val beaconTextSecondCat: String val beaconTextSecondCat: String
get() = prefs.getBcn_beaconTextSecondCat() get() = textSecondCat
/** /**
* The interval both categories share, never below the minimum. * The interval both categories share, never below the minimum.
@@ -49,16 +75,16 @@ class BeaconTabState(
val beaconIntervalMinutes: Int val beaconIntervalMinutes: Int
get() = maxOf( get() = maxOf(
ChatController.MIN_BEACON_INTERVAL_MINUTES, ChatController.MIN_BEACON_INTERVAL_MINUTES,
prefs.getBcn_beaconIntervalInMinutesMainCat(), interval,
) )
/** @return null when stored, otherwise the reason it was refused. */ /** @return null when staged, otherwise the reason it was refused. */
fun commitBeaconTextMainCat(template: String): String? = fun commitBeaconTextMainCat(template: String): String? =
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) } commitBeaconText(template) { textMainCat = it }
/** @return null when stored, otherwise the reason it was refused. */ /** @return null when staged, otherwise the reason it was refused. */
fun commitBeaconTextSecondCat(template: String): String? = fun commitBeaconTextSecondCat(template: String): String? =
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) } commitBeaconText(template) { textSecondCat = it }
private fun commitBeaconText(template: String, store: (String) -> Unit): String? { private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
try { try {
@@ -69,7 +95,7 @@ class BeaconTabState(
* so a refused template that replaced a working one would silently stop * so a refused template that replaced a working one would silently stop
* the beacon instead of reporting anything. * the beacon instead of reporting anything.
*/ */
return "The beacon message is invalid: " + refusal.message return CurrentStrings.get().beaconInvalidMessage(refusal.message.orEmpty())
} }
store(template) store(template)
@@ -77,28 +103,18 @@ class BeaconTabState(
} }
/** /**
* Applies the shared interval to both categories and restarts the timer. * Stages the shared interval for both categories; applying it restarts the timer.
* *
* @return null when applied, otherwise the reason it was refused. * @return null when staged, otherwise the reason it was refused.
*/ */
fun commitBeaconInterval(entered: String): String? { fun commitBeaconInterval(entered: String): String? {
val minutes = entered.trim().toIntOrNull() val minutes = entered.trim().toIntOrNull()
if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) { if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
return "Enter a whole beacon interval of at least " + return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
ChatController.MIN_BEACON_INTERVAL_MINUTES + " minute."
} }
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes) interval = minutes
/*
* The second-category value is legacy XML which nothing reads any more, but
* it is written to keep the two in step, exactly as applySharedBeaconInterval
* did.
*/
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
restartBeaconTimer()
return null return null
} }
} }
@@ -119,6 +119,13 @@ fun ColoursTab(
stored = row.hex, stored = row.hex,
commit = { typed -> state.set(row.role, typed) }, commit = { typed -> state.set(row.role, typed) },
onRefused = reportRefusal, onRefused = reportRefusal,
/*
* A colour is compared the way PaletteOverrides normalises it -- trimmed,
* case ignored -- so retyping the shown colour in lower case is no edit
* and does not turn it into an override of itself.
*/
isUnchanged = { typed, now -> typed.trim().equals(now.trim(), ignoreCase = true) },
current = { state.roles().single { it.role == row.role }.hex },
) )
} }
} }
@@ -12,13 +12,14 @@ import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.AwtWindow import androidx.compose.ui.window.AwtWindow
import java.awt.FileDialog import java.awt.FileDialog
@@ -36,8 +37,7 @@ import java.io.File
* back on bad input. Writing through on every keystroke without that guard would be * back on bad input. Writing through on every keystroke without that guard would be
* worse than the old behaviour, because the ChatPreferences setters for the AirScout * worse than the old behaviour, because the ChatPreferences setters for the AirScout
* identifiers, the AirScout port and the AirScout band value substitute the factory * identifiers, the AirScout port and the AirScout band value substitute the factory
* default on bad input instead of keeping the previous value. It also reports focus * default on bad input instead of keeping the previous value.
* loss, which is where a port change may rebind a socket.
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab. * - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
*/ */
@Composable @Composable
@@ -47,7 +47,6 @@ internal fun GuardedTextField(
enabled: Boolean = true, enabled: Boolean = true,
accepts: (String) -> Boolean = { true }, accepts: (String) -> Boolean = { true },
onAccepted: (String) -> Unit, onAccepted: (String) -> Unit,
onCommit: () -> Unit = {},
) { ) {
/* /*
* Keyed on the stored value: the draft is only reset when the stored value really * Keyed on the stored value: the draft is only reset when the stored value really
@@ -55,7 +54,21 @@ internal fun GuardedTextField(
* value such as a trailing space that is trimmed away, leaves the typed text alone. * value such as a trailing space that is trimmed away, leaves the typed text alone.
*/ */
var draft by remember(value) { mutableStateOf(value) } var draft by remember(value) { mutableStateOf(value) }
var hadFocus by remember { mutableStateOf(false) }
/*
* A refused entry stages nothing, so the draft keeps the last accepted value while the
* field shows the refused text in red. Inside the settings window the field refuses the
* apply instead of letting that hidden value go into effect.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = kst4contest.view.compose.LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (accepts(draft)) null else strings.settingsInvalidEntry(label, draft)
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
LabelledRow(label) { LabelledRow(label) {
CompactTextField( CompactTextField(
@@ -68,12 +81,7 @@ internal fun GuardedTextField(
}, },
enabled = enabled, enabled = enabled,
isError = !accepts(draft), isError = !accepts(draft),
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState -> modifier = Modifier.fillMaxWidth(),
if (hadFocus && !focusState.isFocused) {
onCommit()
}
hadFocus = focusState.isFocused
},
) )
} }
} }
@@ -103,7 +111,7 @@ internal fun FilePathRow(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Form.button("Choose...") { choosing = true } Form.button(kst4contest.view.i18n.LocalStrings.current.formChoose) { choosing = true }
} }
} }
@@ -25,31 +25,31 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Form.section(strings.guiStartupDesign) { Form.section(strings.guiStartupDesign) {
Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) { Form.check(strings.guiStartInEveningDesign, state.gUI_darkModeActiveByDefault) {
state.gUI_darkModeActiveByDefault = it state.gUI_darkModeActiveByDefault = it
} }
} }
Form.flowingSection(strings.guiStartupFilters) { Form.flowingSection(strings.guiStartupFilters) {
Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) { Form.check(strings.guiFilterPublic, state.guiOptions_defaultFilterPublicMsgs) {
state.guiOptions_defaultFilterPublicMsgs = it state.guiOptions_defaultFilterPublicMsgs = it
} }
Form.check("Private messages to me", state.guiOptions_defaultFilterPmToMe) { Form.check(strings.guiFilterPmToMe, state.guiOptions_defaultFilterPmToMe) {
state.guiOptions_defaultFilterPmToMe = it state.guiOptions_defaultFilterPmToMe = it
} }
Form.check("Private messages to others", state.guiOptions_defaultFilterPmToOther) { Form.check(strings.guiFilterPmToOthers, state.guiOptions_defaultFilterPmToOther) {
state.guiOptions_defaultFilterPmToOther = it state.guiOptions_defaultFilterPmToOther = it
} }
Form.check("No filter", state.guiOptions_defaultFilterNothing) { Form.check(strings.guiFilterNothing, state.guiOptions_defaultFilterNothing) {
state.guiOptions_defaultFilterNothing = it state.guiOptions_defaultFilterNothing = it
} }
} }
Form.flowingSection(strings.guiBandColumnHints) { Form.flowingSection(strings.guiBandColumnHints) {
Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) { Form.check(strings.guiHintFreshCall, state.guiOptions_showFreshCallHintInBandColumns) {
state.guiOptions_showFreshCallHintInBandColumns = it state.guiOptions_showFreshCallHintInBandColumns = it
} }
Form.check("Mark a worked large grid field", state.guiOptions_showGrossFieldWorkedHintInBandColumns) { Form.check(strings.guiHintLargeGridWorked, state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
state.guiOptions_showGrossFieldWorkedHintInBandColumns = it state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
} }
} }
@@ -76,5 +76,12 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
) )
} }
/* Appended after the language for the same reason: no section position moves. */
Form.section(strings.guiWindows) {
Form.check(strings.guiRememberWindowPositions, state.guiOptions_rememberWindowPositions) {
state.guiOptions_rememberWindowPositions = it
}
}
} }
} }
@@ -1,42 +1,34 @@
package kst4contest.view.compose.tabs package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import kst4contest.view.i18n.LanguageStore import kst4contest.view.i18n.LanguageStore
/** /**
* The GUI tab as a typed facade over ChatPreferences. * The GUI tab as a typed facade over ChatPreferences.
* *
* Writes through immediately and has no apply(), like every settings tab: the * Edits are staged in the window's [SettingsDraft] and take effect when the operator
* JavaFX controls wrote as they changed and "Save settings" only persisted. * applies them. The language is the exception: it switches at once, because the switch is
* itself what the operator wants to see.
*/ */
class GuiOptionsTabState( class GuiOptionsTabState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val languageStore: LanguageStore, private val languageStore: LanguageStore,
) { ) {
var gUI_darkModeActiveByDefault: Boolean var gUI_darkModeActiveByDefault: Boolean by draft.setting({ prefs.isGUI_darkModeActiveByDefault() }) { prefs.setGUI_darkModeActiveByDefault(it) }
get() = prefs.isGUI_darkModeActiveByDefault()
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
var guiOptions_defaultFilterNothing: Boolean var guiOptions_defaultFilterNothing: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterNothing() }) { prefs.setGuiOptions_defaultFilterNothing(it) }
get() = prefs.isGuiOptions_defaultFilterNothing()
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
var guiOptions_defaultFilterPmToMe: Boolean var guiOptions_defaultFilterPmToMe: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToMe() }) { prefs.setGuiOptions_defaultFilterPmToMe(it) }
get() = prefs.isGuiOptions_defaultFilterPmToMe()
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
var guiOptions_defaultFilterPmToOther: Boolean var guiOptions_defaultFilterPmToOther: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToOther() }) { prefs.setGuiOptions_defaultFilterPmToOther(it) }
get() = prefs.isGuiOptions_defaultFilterPmToOther()
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
var guiOptions_defaultFilterPublicMsgs: Boolean var guiOptions_defaultFilterPublicMsgs: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPublicMsgs() }) { prefs.setGuiOptions_defaultFilterPublicMsgs(it) }
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
var guiOptions_showFreshCallHintInBandColumns: Boolean var guiOptions_showFreshCallHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showFreshCallHintInBandColumns() }) { prefs.setGuiOptions_showFreshCallHintInBandColumns(it) }
get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
/** /**
* The interface language, as the picker offers it: a language code, or the empty string * The interface language, as the picker offers it: a language code, or the empty string
@@ -50,8 +42,8 @@ class GuiOptionsTabState(
get() = languageStore.stored get() = languageStore.stored
set(value) { languageStore.use(value) } set(value) { languageStore.use(value) }
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean var guiOptions_rememberWindowPositions: Boolean by draft.setting({ prefs.isGuiOptions_rememberWindowPositions() }) { prefs.setGuiOptions_rememberWindowPositions(it) }
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) } var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) }
} }
@@ -17,8 +17,8 @@ import kst4contest.view.compose.Form
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by * The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network. * UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
* *
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way * The controls stage their edits in the window's draft (see LogSynchTabState); the file
* the JavaFX controls did. "Save settings" persists, it does not collect. * chooser writes the chosen path at once.
* *
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is * Labels, units and default hints are the JavaFX ones. The Win-Test port field is
* disabled while its listener is off, as it was there. * disabled while its listener is off, as it was there.
@@ -75,9 +75,8 @@ fun LogSynchTab(state: LogSynchTabState) {
value = state.logsynch_wintestNetworkPort.toString(), value = state.logsynch_wintestNetworkPort.toString(),
enabled = state.logsynch_wintestNetworkListenerEnabled, enabled = state.logsynch_wintestNetworkListenerEnabled,
accepts = { it.trim().toIntOrNull() != null }, accepts = { it.trim().toIntOrNull() != null },
// The socket is rebound when the port is applied, not per digit.
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ }, onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
// Rebinding the socket belongs to the finished entry, not to every digit.
onCommit = { state.commitWintestNetworkPort() },
) )
GuardedTextField( GuardedTextField(
@@ -1,23 +1,24 @@
package kst4contest.view.compose.tabs package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/** /**
* The Log synch tab as a typed facade over ChatPreferences. * The Log synch tab as a typed facade over ChatPreferences.
* *
* Every property writes through immediately, and there is deliberately no apply(). * Edits are staged in the window's [SettingsDraft] and take effect when the operator
* That mirrors the JavaFX window it replaces: its controls each wrote into * applies them. The log file path is the exception: it changes through a file chooser,
* ChatPreferences the moment they changed, and its two buttons only persisted * an action, and is written when chosen.
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
* *
* Two things this tab controlled are not settings at all but effects on the running * One thing this tab controls is not a setting at all but an effect on the running session,
* session, and no coverage test can see them because they are not ChatPreferences * and no coverage test can see it because it is not a ChatPreferences setter: the Win-Test
* setters. They are taken as callbacks rather than from a ChatController so the rules * UDP listener is started, stopped or rebound when its switch or its port is applied -- once
* stay testable without opening a database: * per apply, see [applyWintestListener]. It is taken as callbacks rather than from a
* ChatController so the rules stay testable without opening a database.
* *
* - the Win-Test UDP listener is started and stopped with its setting; * The broadcast address the JavaFX window auto-detected while building the pane is detected
* - the Win-Test broadcast address is auto-detected while it is still the catch-all * by repairStoredSettings now, before the window is built, not by this state.
* default, which the JavaFX window did while building the settings pane.
* *
* Ports, transports and framing are untouched: the accepted values, the defaults and * Ports, transports and framing are untouched: the accepted values, the defaults and
* the moment a socket is rebound are the ones the JavaFX window had. * the moment a socket is rebound are the ones the JavaFX window had.
@@ -25,23 +26,17 @@ import kst4contest.model.ChatPreferences
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to * @param restartWintestListener rebinds the Win-Test UDP listener; maps to
* ChatController.restartWintestUdpListenerIfEnabled() * ChatController.restartWintestUdpListenerIfEnabled()
* @param stopWintestListener maps to ChatController.stopWintestUdpListener() * @param stopWintestListener maps to ChatController.stopWintestUdpListener()
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null
* when none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/ */
class LogSynchTabState( class LogSynchTabState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val restartWintestListener: () -> Unit, private val restartWintestListener: () -> Unit,
private val stopWintestListener: () -> Unit, private val stopWintestListener: () -> Unit,
private val detectWintestBroadcastAddress: () -> String?,
) { ) {
init { var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean by draft.setting(
autoDetectBroadcastAddressWhileStillDefault() { prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled() },
} ) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(it) }
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean
get() = prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(value) }
/** /**
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and * The log file polled for worked callsigns. The JavaFX tab showed it read-only and
@@ -53,75 +48,73 @@ class LogSynchTabState(
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly() get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) } set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean var logsynch_ucxUDPWkdCallListenerEnabled: Boolean by draft.setting(
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled() { prefs.isLogsynch_ucxUDPWkdCallListenerEnabled() },
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) } ) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(it) }
/** /**
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field * Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
* accepted any number here and rebound nothing — that listener comes up at start — * accepted any number here and rebound nothing — that listener comes up at start —
* so neither a range nor a restart is added. * so neither a range nor a restart is added.
*/ */
var logsynch_ucxUDPWkdCallListenerPort: Int var logsynch_ucxUDPWkdCallListenerPort: Int by draft.setting(
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort() { prefs.getLogsynch_ucxUDPWkdCallListenerPort() },
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) } ) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(it) }
var logsynch_wintestNetworkListenerEnabled: Boolean var logsynch_wintestNetworkListenerEnabled: Boolean by draft.setting(
get() = prefs.isLogsynch_wintestNetworkListenerEnabled() { prefs.isLogsynch_wintestNetworkListenerEnabled() },
set(value) { effectKey = WINTEST_LISTENER,
prefs.setLogsynch_wintestNetworkListenerEnabled(value) effect = ::applyWintestListener,
if (value) restartWintestListener() else stopWintestListener() ) { prefs.setLogsynch_wintestNetworkListenerEnabled(it) }
}
/** /**
* UDP port for the Win-Test listener, default 9871. The setting is written at once * UDP port for the Win-Test listener, default 9871. The socket is rebound when the port
* like every other, but the socket is only rebound by [commitWintestNetworkPort] — * is applied, not per keystroke: rebinding per digit would bind and unbind a UDP port
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind * for every number on the way to the intended one.
* and unbind a UDP port for every digit on the way to the intended one.
*/ */
var logsynch_wintestNetworkPort: Int var logsynch_wintestNetworkPort: Int by draft.setting(
get() = prefs.getLogsynch_wintestNetworkPort() { prefs.getLogsynch_wintestNetworkPort() },
set(value) { prefs.setLogsynch_wintestNetworkPort(value) } effectKey = WINTEST_LISTENER,
effect = ::applyWintestListener,
) { prefs.setLogsynch_wintestNetworkPort(it) }
/** Applies the entered port to the running listener, if there is one. */ /*
fun commitWintestNetworkPort() { * The two trimmed values are staged trimmed, so the draft holds what will be stored and a
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) { * trailing space does not count as a change.
restartWintestListener() */
} private var stationNameOfKst: String by draft.setting(
} { prefs.getLogsynch_wintestNetworkStationNameOfKST() },
) { prefs.setLogsynch_wintestNetworkStationNameOfKST(it) }
private var broadcastAddress: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkBroadcastAddress() },
) { prefs.setLogsynch_wintestNetworkBroadcastAddress(it) }
/** The name KST4Contest uses as source of Win-Test SKED packets. */ /** The name KST4Contest uses as source of Win-Test SKED packets. */
var logsynch_wintestNetworkStationNameOfKST: String var logsynch_wintestNetworkStationNameOfKST: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST() get() = stationNameOfKst
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) } set(value) { stationNameOfKst = value.trim() }
/** UDP broadcast address used for sending to Win-Test. */ /** UDP broadcast address used for sending to Win-Test. */
var logsynch_wintestNetworkBroadcastAddress: String var logsynch_wintestNetworkBroadcastAddress: String
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress() get() = broadcastAddress
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) } set(value) { broadcastAddress = value.trim() }
/** /**
* Replaces the catch-all default 255.255.255.255 with the detected subnet * Brings the Win-Test listener in line with the applied switch and port: rebound while
* broadcast address. An address the operator or a previous detection chose is * enabled, stopped otherwise. The switch and the port share it, so applying both runs it
* left alone, and a failed detection keeps the default: guessing an address here * once and binds the new port; a port changed while the listener is off meets a listener
* would send SKED packets somewhere nobody is listening. * that is not running, and stopping that does nothing.
*/ */
private fun autoDetectBroadcastAddressWhileStillDefault() { private fun applyWintestListener() {
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) { if (prefs.isLogsynch_wintestNetworkListenerEnabled()) restartWintestListener() else stopWintestListener()
return
}
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (detected != null && detected.isNotBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
} }
companion object { companion object {
/** The stored value that still means "not configured for this network". */ /** The stored value that still means "not configured for this network". */
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255" const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
/** The effect key the Win-Test switch and port share. */
private const val WINTEST_LISTENER = "wintestListener"
} }
} }
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/** /**
* State of the message-handling tab. * State of the message-handling tab.
@@ -10,42 +12,44 @@ import kst4contest.model.ChatPreferences
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps * (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
* that: a tab that wrote only the main category would leave the second chat on its * that: a tab that wrote only the main category would leave the second chat on its
* previous value, which no part of the window ever displayed. * previous value, which no part of the window ever displayed.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*/ */
class MessageHandlingTabState( class MessageHandlingTabState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applyDebugModeToFile: (Boolean) -> Unit = {}, private val applyDebugModeToFile: (Boolean) -> Unit = {},
) { ) {
/** Drives both category flags, as the single JavaFX checkbox did. */ /** Drives both category flags, as the single JavaFX checkbox did. */
var autoAnswerEnabled: Boolean var autoAnswerEnabled: Boolean by draft.setting(
get() = prefs.isMessageHandling_autoAnswerEnabled() { prefs.isMessageHandling_autoAnswerEnabled() },
set(value) { ) { enabled ->
prefs.setMessageHandling_autoAnswerEnabled(value) prefs.setMessageHandling_autoAnswerEnabled(enabled)
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value) prefs.setMessageHandling_autoAnswerEnabledSecondCat(enabled)
} }
/** Drives both category texts, as the single JavaFX text field did. */ /** Drives both category texts, as the single JavaFX text field did. */
var autoAnswerText: String var autoAnswerText: String by draft.setting(
get() = prefs.getMessageHandling_autoAnswerTextMainCat() { prefs.getMessageHandling_autoAnswerTextMainCat() },
set(value) { ) { text ->
prefs.setMessageHandling_autoAnswerTextMainCat(value) prefs.setMessageHandling_autoAnswerTextMainCat(text)
prefs.setMessageHandling_autoAnswerTextSecondCat(value) prefs.setMessageHandling_autoAnswerTextSecondCat(text)
} }
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */ /** Independent of the automatic answer; its own checkbox in the JavaFX window. */
var autoAnswerToQRGRequestEnabled: Boolean var autoAnswerToQRGRequestEnabled: Boolean by draft.setting(
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() { prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() },
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) } ) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(it) }
/** /**
* Records the whole session to file. Beyond the preference it opens or closes the * Records the whole session to file. Beyond the preference it opens or closes the
* error-log handler and the history recorder, which only the application can sequence; * error-log handler and the history recorder, which only the application can sequence;
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept. * [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
*/ */
var debugModeToFileEnabled: Boolean var debugModeToFileEnabled: Boolean by draft.setting(
get() = prefs.isMessageHandling_debugModeToFileEnabled() { prefs.isMessageHandling_debugModeToFileEnabled() },
set(value) { effect = { applyDebugModeToFile(prefs.isMessageHandling_debugModeToFileEnabled()) },
prefs.setMessageHandling_debugModeToFileEnabled(value) ) { prefs.setMessageHandling_debugModeToFileEnabled(it) }
applyDebugModeToFile(value)
}
} }
@@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import kst4contest.model.Band import kst4contest.model.Band
import kst4contest.view.compose.DataTable import kst4contest.view.compose.DataTable
import kst4contest.view.compose.Form import kst4contest.view.compose.Form
@@ -76,12 +77,18 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
stored = state.notify_dxclusterServerPort.toString(), stored = state.notify_dxclusterServerPort.toString(),
commit = state::commitDxClusterServerPort, commit = state::commitDxClusterServerPort,
onRefused = onRefused, onRefused = onRefused,
// Compared as the number commitDxClusterServerPort stores.
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
current = { state.notify_dxclusterServerPort.toString() },
) )
Form.committed( Form.committed(
label = strings.notificationSpotterCallsign, label = strings.notificationSpotterCallsign,
stored = state.notify_DXCSrv_SpottersCallSign, stored = state.notify_DXCSrv_SpottersCallSign,
commit = state::commitSpotterCallSign, commit = state::commitSpotterCallSign,
onRefused = onRefused, onRefused = onRefused,
// commitSpotterCallSign stores the callsign trimmed and in upper case.
isUnchanged = { typed, now -> typed.trim().uppercase(java.util.Locale.ROOT) == now },
current = { state.notify_DXCSrv_SpottersCallSign },
) )
/* /*
* Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the * Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the
@@ -134,6 +141,7 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
onValueChange = { entered = it }, onValueChange = { entered = it },
singleLine = true, singleLine = true,
label = { Text(strings.notificationCallsign) }, label = { Text(strings.notificationCallsign) },
modifier = Modifier.testTag(MONITORED_CALLSIGN_FIELD_TAG),
) )
} }
}, },
@@ -149,3 +157,6 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
) )
} }
} }
/** Marks the callsign field of the add-to-monitoring dialog, so a test can type into it. */
internal const val MONITORED_CALLSIGN_FIELD_TAG = "monitored-callsign-field"
@@ -9,137 +9,144 @@ import kst4contest.view.GuiUtils
import kst4contest.view.compose.CommitOutcome import kst4contest.view.compose.CommitOutcome
import kst4contest.view.compose.EditableListState import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.EntryRule import kst4contest.view.compose.EntryRule
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import java.util.Locale import java.util.Locale
/** /**
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster * State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
* server and the QSO monitoring list. * server and the QSO monitoring list.
* *
* Three of these reach past the preferences into the running session, which the * Edits are staged in the window's [SettingsDraft] and take effect when the operator
* settings coverage test cannot see, so they are constructor callbacks rather than * applies them; the monitoring list too. The test spot is an action and is sent at once.
* plain properties: starting and stopping the server, restarting it after a port *
* change, and sending the test spot. * Two of these reach past the preferences into the running session, which the settings
* coverage test cannot see, so they are constructor callbacks rather than plain
* properties: bringing the server in line with its switch and port, and sending the test
* spot.
* *
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`; * @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
* it lives on the controller, not in the preferences, which is why it is * it lives on the controller, not in the preferences, which is why it is
* passed separately. * passed separately.
* @param applyDxClusterServerEnabled starts or stops the server, the work the * @param applyDxClusterServer brings the running server in line with the applied switch
* JavaFX checkbox did beyond writing the flag. * and port: restarted while enabled, stopped otherwise. The switch and the port
* @param applyDxClusterServerPort restarts the server; called only when the port * share it, so one apply restarts the server once, on the new port.
* actually changed, as the JavaFX field did.
* @param broadcastTestSpot delivers the spot and returns null on success or the * @param broadcastTestSpot delivers the spot and returns null on success or the
* reason it could not be delivered. * reason it could not be delivered.
*/ */
class NotificationTabState( class NotificationTabState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val monitoredRoster: SimpleRoster<String>, private val monitoredRoster: SimpleRoster<String>,
private val applyDxClusterServerEnabled: (Boolean) -> Unit, private val applyDxClusterServer: () -> Unit,
private val applyDxClusterServerPort: (Int) -> Unit,
private val broadcastTestSpot: (ChatMember) -> String?, private val broadcastTestSpot: (ChatMember) -> String?,
) { ) {
var notify_playSimpleSounds: Boolean var notify_playSimpleSounds: Boolean by draft.setting(
get() = prefs.isNotify_playSimpleSounds() { prefs.isNotify_playSimpleSounds() },
set(value) { prefs.setNotify_playSimpleSounds(value) } ) { prefs.setNotify_playSimpleSounds(it) }
var notify_playCWCallsignsOnRxedPMs: Boolean var notify_playCWCallsignsOnRxedPMs: Boolean by draft.setting(
get() = prefs.isNotify_playCWCallsignsOnRxedPMs() { prefs.isNotify_playCWCallsignsOnRxedPMs() },
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) } ) { prefs.setNotify_playCWCallsignsOnRxedPMs(it) }
var notify_playVoiceCallsignsOnRxedPMs: Boolean var notify_playVoiceCallsignsOnRxedPMs: Boolean by draft.setting(
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs() { prefs.isNotify_playVoiceCallsignsOnRxedPMs() },
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) } ) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(it) }
var notify_bandUpgradeHintOnLogEnabled: Boolean var notify_bandUpgradeHintOnLogEnabled: Boolean by draft.setting(
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled() { prefs.isNotify_bandUpgradeHintOnLogEnabled() },
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) } ) { prefs.setNotify_bandUpgradeHintOnLogEnabled(it) }
var notify_bandUpgradePriorityBoostEnabled: Boolean var notify_bandUpgradePriorityBoostEnabled: Boolean by draft.setting(
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled() { prefs.isNotify_bandUpgradePriorityBoostEnabled() },
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) } ) { prefs.setNotify_bandUpgradePriorityBoostEnabled(it) }
private var dxClusterServerEnabled: Boolean by draft.setting(
{ prefs.isNotify_dxClusterServerEnabled() },
effectKey = DX_CLUSTER_SERVER,
effect = { applyDxClusterServer() },
) { prefs.setNotify_dxClusterServerEnabled(it) }
private var dxClusterServerPort: Int by draft.setting(
{ prefs.getNotify_dxclusterServerPort() },
effectKey = DX_CLUSTER_SERVER,
effect = { applyDxClusterServer() },
) { prefs.setNotify_dxclusterServerPort(it) }
private var spotterCallSign: String by draft.setting(
{ prefs.getNotify_DXCSrv_SpottersCallSign().get() },
) { prefs.setNotify_DXCSrv_SpottersCallSign(it) }
val notify_dxClusterServerEnabled: Boolean val notify_dxClusterServerEnabled: Boolean
get() = prefs.isNotify_dxClusterServerEnabled() get() = dxClusterServerEnabled
val notify_dxclusterServerPort: Int val notify_dxclusterServerPort: Int
get() = prefs.getNotify_dxclusterServerPort() get() = dxClusterServerPort
val notify_DXCSrv_SpottersCallSign: String val notify_DXCSrv_SpottersCallSign: String
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get() get() = spotterCallSign
/** /*
* The band a spot falls back to when its frequency carries no band information. * Stored as a prefix string, so an unreadable value is possible. It is shown as 144 here
* * and written back as 144 by repairStoredSettings before the window is built -- never
* Stored as a prefix string, so an unreadable value is possible; reading it * from this read, which runs during composition.
* repairs the stored value to 144, which is what the JavaFX window did when it
* built the combo box (Kst4ContestApplication:11349).
*/ */
private var fallbackBand: Band by draft.setting(
{ Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) ?: Band.B_144 },
) { prefs.setNotify_optionalFrequencyPrefix(it.prefix) }
/** The band a spot falls back to when its frequency carries no band information. */
var notify_optionalFrequencyPrefix: Band var notify_optionalFrequencyPrefix: Band
get() { get() = fallbackBand
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) set(value) { fallbackBand = value }
if (stored == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
return Band.B_144
}
return stored
}
set(value) { prefs.setNotify_optionalFrequencyPrefix(value.prefix) }
val monitoredCallSigns = EditableListState( val monitoredCallSigns = EditableListState(
monitoredRoster.snapshot(), monitoredRoster.snapshot(),
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) }, rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
) )
/** Writes the flag and lets the running session start or stop the server. */ /** Stages the server switch; applying it starts or stops the server. */
fun enableDxClusterServer(enabled: Boolean) { fun enableDxClusterServer(enabled: Boolean) {
prefs.setNotify_dxClusterServerEnabled(enabled) dxClusterServerEnabled = enabled
applyDxClusterServerEnabled(enabled)
} }
/** /**
* @return null when applied, otherwise the reason the port was refused. * @return null when staged, otherwise the reason the port was refused.
*/ */
fun commitDxClusterServerPort(entered: String): String? { fun commitDxClusterServerPort(entered: String): String? {
val previousPort = prefs.getNotify_dxclusterServerPort()
val port = entered.trim().toIntOrNull() val port = entered.trim().toIntOrNull()
if (port == null || port < 1 || port > 65535) { if (port == null || port < 1 || port > 65535) {
return "\"" + entered.trim() + "\" is not a valid TCP port. " + return CurrentStrings.get().notificationInvalidPort(entered.trim())
"Enter a value from 1 to 65535."
} }
prefs.setNotify_dxclusterServerPort(port)
/* /*
* Restarting on an unchanged port would drop a connected logger for nothing; * The stored port staged again is no change, so it restarts nothing: restarting on
* the JavaFX field guarded the restart the same way. * an unchanged port would drop a connected logger for nothing, and the JavaFX field
* guarded the restart the same way.
*/ */
if (port != previousPort) { dxClusterServerPort = port
applyDxClusterServerPort(port)
}
return null return null
} }
/** /**
* @return null when stored, otherwise the reason the callsign was refused. * @return null when staged, otherwise the reason the callsign was refused.
*/ */
fun commitSpotterCallSign(entered: String): String? { fun commitSpotterCallSign(entered: String): String? {
val spotterCallSign = entered.trim().uppercase(Locale.ROOT) val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
if (!GuiUtils.isCallSignSyntax(spotterCallSign)) { if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
return "\"" + spotterCallSign + "\" is not a valid spotter callsign." return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
} }
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign) this.spotterCallSign = spotterCallSign
return null return null
} }
/** /**
* Appends a callsign to the monitoring list and fills the roster at once, as the * Appends a callsign to the monitoring list and stages the list.
* JavaFX dialog did.
* *
* @return null when added, otherwise the reason it was refused. * @return null when added, otherwise the reason it was refused.
*/ */
@@ -151,7 +158,7 @@ class NotificationTabState(
} }
if (verdict !is CommitOutcome.Stored) { if (verdict !is CommitOutcome.Stored) {
return "Please enter a valid callsign." return CurrentStrings.get().notificationInvalidCallsign
} }
/* /*
@@ -163,9 +170,14 @@ class NotificationTabState(
return null return null
} }
/** Fills the monitoring roster from the list. */ /** Stages the list; applying it fills the monitoring roster. */
fun commitMonitoredCallSigns() { fun commitMonitoredCallSigns() {
monitoredCallSigns.commitTo(monitoredRoster) draft.stage(
MONITORED_CALL_SIGNS,
monitoredCallSigns.texts.toList(),
live = { monitoredRoster.snapshot() },
write = { monitoredCallSigns.commitTo(monitoredRoster) },
)
} }
/** /**
@@ -202,13 +214,20 @@ class NotificationTabState(
) )
if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) { if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
return CommitOutcome.Refused("Please enter a valid callsign.") return CommitOutcome.Refused(CurrentStrings.get().notificationInvalidCallsign)
} }
if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) { if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
return CommitOutcome.Refused("This base callsign is already in the monitoring list.") return CommitOutcome.Refused(CurrentStrings.get().notificationAlreadyMonitored)
} }
return CommitOutcome.Stored(baseCall) return CommitOutcome.Stored(baseCall)
} }
private companion object {
/** The effect key the server switch and port share. */
const val DX_CLUSTER_SERVER = "dxClusterServer"
const val MONITORED_CALL_SIGNS = "NotificationTabState.monitoredCallSigns"
}
} }
@@ -341,7 +341,7 @@ private fun ProfileDialogs(
) )
} }
}, },
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } }, confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text(strings.dialogApply) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } },
) )
} }
@@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue
import kst4contest.controller.OperatorProfileManagementService import kst4contest.controller.OperatorProfileManagementService
import kst4contest.model.OperatorProfile import kst4contest.model.OperatorProfile
import kst4contest.model.OperatorProfileSelection import kst4contest.model.OperatorProfileSelection
import kst4contest.view.i18n.CurrentStrings
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -57,7 +58,7 @@ class ProfilesTabState(
} }
val activeProfileName: String val activeProfileName: String
get() = activeSelection()?.profile?.displayName ?: "unknown" get() = activeSelection()?.profile?.displayName ?: CurrentStrings.get().profilesUnknown
val preferencesPath: String val preferencesPath: String
get() = activeSelection()?.preferencesAbsolutePath ?: "" get() = activeSelection()?.preferencesAbsolutePath ?: ""
@@ -90,9 +91,7 @@ class ProfilesTabState(
} }
val created = service.createProfile(name.trim(), sharedWorkedDatabase) val created = service.createProfile(name.trim(), sharedWorkedDatabase)
?: return ProfileNotice.Error( ?: return ProfileNotice.Error(CurrentStrings.get().profilesCreateFailed)
"The profile could not be created. The profile registry could not be written."
)
refresh() refresh()
selected = created selected = created
@@ -101,10 +100,7 @@ class ProfilesTabState(
* Said explicitly because a new profile that cannot log in looks broken * Said explicitly because a new profile that cannot log in looks broken
* otherwise: callsign and password are deliberately not copied or invented. * otherwise: callsign and password are deliberately not copied or invented.
*/ */
return ProfileNotice.Info( return ProfileNotice.Info(CurrentStrings.get().profilesCreatedWithoutCredentials(created.displayName))
"The profile \"" + created.displayName + "\" was created without callsign and " +
"password. Enter them on the Station tab after switching to it."
)
} }
fun duplicateSelected(name: String): ProfileNotice? { fun duplicateSelected(name: String): ProfileNotice? {
@@ -115,7 +111,7 @@ class ProfilesTabState(
} }
val duplicate = service.duplicateProfile(profile, name) val duplicate = service.duplicateProfile(profile, name)
?: return ProfileNotice.Error("The profile could not be duplicated.") ?: return ProfileNotice.Error(CurrentStrings.get().profilesDuplicateFailed)
refresh() refresh()
selected = duplicate selected = duplicate
@@ -124,7 +120,7 @@ class ProfilesTabState(
/** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */ /** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
fun duplicateNameSuggestion(): String = fun duplicateNameSuggestion(): String =
selected?.let { "Copy of " + it.displayName } ?: "" selected?.let { CurrentStrings.get().profilesCopyOf(it.displayName) } ?: ""
fun renameSelected(name: String): ProfileNotice? { fun renameSelected(name: String): ProfileNotice? {
val profile = selected ?: return notASelection() val profile = selected ?: return notASelection()
@@ -142,20 +138,16 @@ class ProfilesTabState(
val profile = selected ?: return notASelection() val profile = selected ?: return notASelection()
if (profile.isRootProfile) { if (profile.isRootProfile) {
return ProfileNotice.Error( return ProfileNotice.Error(CurrentStrings.get().profilesRootNotDeletable)
"The default profile uses the files of the installation itself and cannot be deleted."
)
} }
if (isActive(profile)) { if (isActive(profile)) {
return ProfileNotice.Error( return ProfileNotice.Error(CurrentStrings.get().profilesActiveNotDeletable)
"The profile currently in use cannot be deleted. Switch to another profile first."
)
} }
if (!service.deleteProfile(profile)) { if (!service.deleteProfile(profile)) {
refresh() refresh()
return ProfileNotice.Error("The profile could not be deleted.") return ProfileNotice.Error(CurrentStrings.get().profilesDeleteFailed)
} }
refresh() refresh()
@@ -164,22 +156,18 @@ class ProfilesTabState(
/** The folder the deletion confirmation names, and what happens to worked stations. */ /** The folder the deletion confirmation names, and what happens to worked stations. */
fun describeDeletion(profile: OperatorProfile): String = fun describeDeletion(profile: OperatorProfile): String =
"The following folder is removed permanently:\n" + CurrentStrings.get().profilesDeletionFolder(service.getProfileDirectory(profile).toString()) + "\n\n" +
service.getProfileDirectory(profile) + "\n\n" +
if (profile.isSharedWorkedDatabase) { if (profile.isSharedWorkedDatabase) {
"The common station worked stations are not touched." CurrentStrings.get().profilesDeletionKeepsShared
} else { } else {
"The worked stations of this profile are deleted as well." CurrentStrings.get().profilesDeletionRemovesOwn
} }
fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? { fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
val profile = selected ?: return notASelection() val profile = selected ?: return notASelection()
if (profile.isRootProfile) { if (profile.isRootProfile) {
return ProfileNotice.Error( return ProfileNotice.Error(CurrentStrings.get().profilesRootAlwaysShared)
"The default profile always uses the common station worked stations, because " +
"that database is the one of the installation itself."
)
} }
if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) { if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
@@ -190,7 +178,7 @@ class ProfilesTabState(
refresh() refresh()
if (isActive(profile)) { if (isActive(profile)) {
return ProfileNotice.Info("The change takes effect after switching to this profile again.") return ProfileNotice.Info(CurrentStrings.get().profilesChangeAfterSwitch)
} }
return null return null
@@ -200,7 +188,7 @@ class ProfilesTabState(
val profile = selected ?: return notASelection() val profile = selected ?: return notASelection()
if (isActive(profile)) { if (isActive(profile)) {
return ProfileNotice.Info("This profile is already active.") return ProfileNotice.Info(CurrentStrings.get().profilesAlreadyActive)
} }
requestActivation(profile) requestActivation(profile)
@@ -217,7 +205,7 @@ class ProfilesTabState(
} }
private fun notASelection(): ProfileNotice = private fun notASelection(): ProfileNotice =
ProfileNotice.Info("Select a profile in the table first.") ProfileNotice.Info(CurrentStrings.get().profilesSelectFirst)
companion object { companion object {
@@ -230,9 +218,9 @@ class ProfilesTabState(
*/ */
fun describeWorkedDataMode(profile: OperatorProfile): String = fun describeWorkedDataMode(profile: OperatorProfile): String =
if (profile.isRootProfile || profile.isSharedWorkedDatabase) { if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
"common station database" CurrentStrings.get().profilesSharedDatabase
} else { } else {
"own database" CurrentStrings.get().profilesOwnDatabase
} }
/** Blank for a profile never used, rather than an epoch date. */ /** Blank for a profile never used, rather than an epoch date. */
@@ -2,15 +2,15 @@ package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import kst4contest.view.compose.EditableListState import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.SettingsDraft
/** /**
* State of the shortcuts-and-snippets tab: the two single-column lists from * State of the shortcuts-and-snippets tab: the two single-column lists from
* `grdPnlShorts`. * `grdPnlShorts`.
* *
* Both lists write through to their roster on every committed change. The JavaFX * Every committed change of a list is staged in the window's [SettingsDraft]. Applying
* tables did the same — their Add buttons called `mutate` on the roster directly — * it fills the roster and rebuilds what is built from it -- the shortcut buttons above the
* and the shortcut buttons above the message field are rebuilt from the roster, so * message field, the snippet menus -- once per apply, however many edits went before.
* a list that only wrote on Save would leave those buttons stale.
* *
* @param refreshShortcutButtons rebuilds the button row above the message field, * @param refreshShortcutButtons rebuilds the button row above the message field,
* as `refreshShortcutButtons` did after every edit commit and move. * as `refreshShortcutButtons` did after every edit commit and move.
@@ -19,6 +19,7 @@ import kst4contest.view.compose.EditableListState
*/ */
class ShortcutsTabState( class ShortcutsTabState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val refreshShortcutButtons: () -> Unit, private val refreshShortcutButtons: () -> Unit,
private val refreshTextSnippetContextMenus: () -> Unit, private val refreshTextSnippetContextMenus: () -> Unit,
) { ) {
@@ -27,16 +28,26 @@ class ShortcutsTabState(
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot()) val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
/** Fills the shortcut roster and rebuilds the buttons built from it. */ /** Stages the shortcut list; applying it fills the roster and rebuilds the buttons. */
fun commitShortcuts() { fun commitShortcuts() {
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList()) draft.stage(
refreshShortcutButtons() SHORTCUTS,
shortcuts.texts.toList(),
live = { prefs.getLst_txtShortCutBtnList().snapshot() },
write = { shortcuts.commitTo(prefs.getLst_txtShortCutBtnList()) },
effect = refreshShortcutButtons,
)
} }
/** Fills the snippet roster and rebuilds the context menus built from it. */ /** Stages the snippet list; applying it fills the roster and rebuilds the menus. */
fun commitSnippets() { fun commitSnippets() {
snippets.commitTo(prefs.getLst_txtSnipList()) draft.stage(
refreshTextSnippetContextMenus() SNIPPETS,
snippets.texts.toList(),
live = { prefs.getLst_txtSnipList().snapshot() },
write = { snippets.commitTo(prefs.getLst_txtSnipList()) },
effect = refreshTextSnippetContextMenus,
)
} }
companion object { companion object {
@@ -47,5 +58,8 @@ class ShortcutsTabState(
*/ */
const val NEW_ENTRY_TEMPLATE = const val NEW_ENTRY_TEMPLATE =
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key" "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
private const val SHORTCUTS = "ShortcutsTabState.shortcuts"
private const val SNIPPETS = "ShortcutsTabState.snippets"
} }
} }
@@ -1,21 +1,27 @@
package kst4contest.view.compose.tabs package kst4contest.view.compose.tabs
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Form import kst4contest.view.compose.Form
import kst4contest.view.compose.TooltipCard
/** /**
* The Station tab: own station, server, active bands and path-analysis defaults. * The Station tab: own station, server, active bands and path-analysis defaults.
* *
* No confirmation callback: StationTabState writes through to ChatPreferences, the * The controls stage their edits in the window's draft (see StationTabState); only the
* way the JavaFX controls did. "Save settings" persists, it does not collect. * login credentials write through at once.
*
* @param onRefused shows why a committed entry was refused (the own ground height)
*/ */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun StationTab(state: StationTabState) { fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
val strings = LocalStrings.current val strings = LocalStrings.current
Column( Column(
@@ -84,22 +90,71 @@ fun StationTab(state: StationTabState) {
Form.section(strings.stationDefaultsSection) { Form.section(strings.stationDefaultsSection) {
Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it } Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it }
Form.decimal(strings.stationQtf, state.stn_qtfDefault) { state.stn_qtfDefault = it } Form.decimal(
label = strings.stationQtf,
value = state.stn_qtfDefault,
isValid = { it.isFinite() && it in 0.0..360.0 },
) { state.stn_qtfDefault = it }
Form.decimal(strings.stationBeamWidth, state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it } Form.decimal(strings.stationBeamWidth, state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it }
} }
Form.panelSection(strings.stationManualAntennaHeadingSection) {
val headingOption: @Composable () -> Unit = {
Form.check(
strings.stationUseQtfFilterDirectionAsAntennaHeading,
state.stn_useQtfFilterDirectionAsAntennaHeading,
enabled = !state.stn_pstRotatorEnabled,
) { state.stn_useQtfFilterDirectionAsAntennaHeading = it }
}
if (state.stn_pstRotatorEnabled) {
TooltipArea(
tooltip = { TooltipCard(strings.stationManualAntennaHeadingRotatorHint) },
content = headingOption,
)
} else {
headingOption()
}
}
Form.section(strings.stationRotatorSection) { Form.section(strings.stationRotatorSection) {
Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it } Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it } Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it } Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
} }
/*
* Only with SO2R, as stored: Form.check and Form.int have no disabled state, and a
* rotator 2 switch without SO2R would do nothing. Switching SO2R on shows the block
* after Apply.
*/
if (state.so2rEnabled) {
Form.section(strings.stationRotator2Section) {
Form.check(strings.stationRotator2Enabled, state.stn_pstRotator2Enabled) { state.stn_pstRotator2Enabled = it }
Form.text(strings.stationRotator2Host, state.stn_pstRotator2Host) { state.stn_pstRotator2Host = it }
Form.int(strings.stationRotator2Port, state.stn_pstRotator2Port) { state.stn_pstRotator2Port = it }
}
}
Form.section(strings.stationPathSection) { Form.section(strings.stationPathSection) {
Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it } Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it } Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it } Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
/*
* Committed on Enter or on leaving the field, not per keystroke: the stored
* value reads back as "250.0", and a per-keystroke write would feed that back
* into the field while the operator is still typing.
*/
Form.committed(
label = strings.stationOwnHeightAsl,
stored = state.stn_pathAnalysisOwnHeightAslText,
commit = state::commitOwnHeightAsl,
onRefused = onRefused,
isUnchanged = StationTabState::isOwnHeightAslUnchanged,
current = { state.stn_pathAnalysisOwnHeightAslText },
)
Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it } Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it } Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
Form.check(strings.stationTargetHighestPoint, state.stn_pathAnalysisDxHighestPointInSquare) { state.stn_pathAnalysisDxHighestPointInSquare = it }
Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it } Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
} }
} }
@@ -1,24 +1,32 @@
package kst4contest.view.compose.tabs package kst4contest.view.compose.tabs
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.view.compose.Form
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import kst4contest.view.i18n.CurrentStrings
/** /**
* The Station tab as a typed facade over ChatPreferences. * The Station tab as a typed facade over ChatPreferences.
* *
* Every property writes through immediately, and there is deliberately no apply(). * Edits are staged in the window's [SettingsDraft] and take effect when the operator
* That mirrors the JavaFX window it replaces: its controls each wrote into * applies them (Apply/Close prefs, Save settings). The login credentials are the exception
* ChatPreferences the moment they changed, and its two buttons only persisted * and still write through at once: callsign, names, locator, password and both chat
* ("Save settings" calls writePreferencesToXmlFile) or closed the window * categories. They are locked while a session exists ([loginFieldsEnabled]), and without
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would * one they take effect at the next login anyway, so holding them back would only make
* change behaviour an operator may rely on: today a changed setting takes effect * Connect use something other than what the tab shows.
* in the running session at once, whether or not it is ever written to disk.
* *
* The property list was derived mechanically from the setters the JavaFX tab * The property list was derived mechanically from the setters the JavaFX tab
* called, intersected with the setters ChatPreferences declares. * called, intersected with the setters ChatPreferences declares.
*/ */
class StationTabState( class StationTabState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val draft: SettingsDraft,
/** /**
* Applies the chat category to the running session. Passed in rather than taken * Applies the chat category to the running session. Passed in rather than taken
* from a ChatController so the rules stay testable without building a controller, * from a ChatController so the rules stay testable without building a controller,
@@ -26,6 +34,16 @@ class StationTabState(
*/ */
private val applyMainCategory: (ChatCategory?) -> Unit, private val applyMainCategory: (ChatCategory?) -> Unit,
private val applySecondCategory: (ChatCategory?) -> Unit, private val applySecondCategory: (ChatCategory?) -> Unit,
/**
* The state of the ON4KST session. A lambda over Compose state, so the login fields
* follow it while the tab is open; the default suits a state built without a session.
*/
private val connectionState: () -> On4KstConnectionState = { On4KstConnectionState.DISCONNECTED },
/**
* Restarts or stops rotator 2 to match the stored settings; shared by this tab and the
* TRX synch tab through [SECOND_ROTATOR], so it runs once per apply.
*/
private val applySecondRotator: () -> Unit = {},
) { ) {
var loginChatCategoryMain: ChatCategory? var loginChatCategoryMain: ChatCategory?
@@ -37,45 +55,25 @@ class StationTabState(
get() = prefs.getLoginChatCategorySecond() get() = prefs.getLoginChatCategorySecond()
set(value) { prefs.setLoginChatCategorySecond(value) } set(value) { prefs.setLoginChatCategorySecond(value) }
var stn_antennaBeamWidthDeg: Double var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
get() = prefs.getStn_antennaBeamWidthDeg()
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
var stn_bandActive10G: Boolean var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
get() = prefs.isStn_bandActive10G()
set(value) { prefs.setStn_bandActive10G(value) }
var stn_bandActive1240: Boolean var stn_bandActive1240: Boolean by draft.setting({ prefs.isStn_bandActive1240() }) { prefs.setStn_bandActive1240(it) }
get() = prefs.isStn_bandActive1240()
set(value) { prefs.setStn_bandActive1240(value) }
var stn_bandActive144: Boolean var stn_bandActive144: Boolean by draft.setting({ prefs.isStn_bandActive144() }) { prefs.setStn_bandActive144(it) }
get() = prefs.isStn_bandActive144()
set(value) { prefs.setStn_bandActive144(value) }
var stn_bandActive2300: Boolean var stn_bandActive2300: Boolean by draft.setting({ prefs.isStn_bandActive2300() }) { prefs.setStn_bandActive2300(it) }
get() = prefs.isStn_bandActive2300()
set(value) { prefs.setStn_bandActive2300(value) }
var stn_bandActive3400: Boolean var stn_bandActive3400: Boolean by draft.setting({ prefs.isStn_bandActive3400() }) { prefs.setStn_bandActive3400(it) }
get() = prefs.isStn_bandActive3400()
set(value) { prefs.setStn_bandActive3400(value) }
var stn_bandActive432: Boolean var stn_bandActive432: Boolean by draft.setting({ prefs.isStn_bandActive432() }) { prefs.setStn_bandActive432(it) }
get() = prefs.isStn_bandActive432()
set(value) { prefs.setStn_bandActive432(value) }
var stn_bandActive50: Boolean var stn_bandActive50: Boolean by draft.setting({ prefs.isStn_bandActive50() }) { prefs.setStn_bandActive50(it) }
get() = prefs.isStn_bandActive50()
set(value) { prefs.setStn_bandActive50(value) }
var stn_bandActive5600: Boolean var stn_bandActive5600: Boolean by draft.setting({ prefs.isStn_bandActive5600() }) { prefs.setStn_bandActive5600(it) }
get() = prefs.isStn_bandActive5600()
set(value) { prefs.setStn_bandActive5600(value) }
var stn_bandActive70: Boolean var stn_bandActive70: Boolean by draft.setting({ prefs.isStn_bandActive70() }) { prefs.setStn_bandActive70(it) }
get() = prefs.isStn_bandActive70()
set(value) { prefs.setStn_bandActive70(value) }
var stn_loginCallSign: String var stn_loginCallSign: String
get() = prefs.getStn_loginCallSign() get() = prefs.getStn_loginCallSign()
@@ -97,57 +95,129 @@ class StationTabState(
get() = prefs.getStn_loginPassword() get() = prefs.getStn_loginPassword()
set(value) { prefs.setStn_loginPassword(value) } set(value) { prefs.setStn_loginPassword(value) }
var stn_maxQRBDefault: Double var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
get() = prefs.getStn_maxQRBDefault()
set(value) { prefs.setStn_maxQRBDefault(value) }
var stn_on4kstServersDns: String var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
get() = prefs.getStn_on4kstServersDns()
set(value) { prefs.setStn_on4kstServersDns(value) }
var stn_on4kstServersPort: Int var stn_on4kstServersPort: Int by draft.setting({ prefs.getStn_on4kstServersPort() }) { prefs.setStn_on4kstServersPort(it) }
get() = prefs.getStn_on4kstServersPort()
set(value) { prefs.setStn_on4kstServersPort(value) }
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(it) }
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double var stn_pathAnalysisDefaultTargetTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(it) }
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
var stn_pathAnalysisDemRootDirectory: String var stn_pathAnalysisDemRootDirectory: String by draft.setting({ prefs.getStn_pathAnalysisDemRootDirectory() }) { prefs.setStn_pathAnalysisDemRootDirectory(it) }
get() = prefs.getStn_pathAnalysisDemRootDirectory()
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
var stn_pathAnalysisOwnAntennaGainDbi: Double var stn_pathAnalysisOwnAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaGainDbi() }) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(it) }
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
var stn_pathAnalysisOwnAntennaHeightMeters: Double var stn_pathAnalysisOwnAntennaHeightMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(it) }
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
var stn_pathAnalysisOwnTxPowerWatts: Double var stn_pathAnalysisOwnTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisOwnTxPowerWatts() }) { prefs.setStn_pathAnalysisOwnTxPowerWatts(it) }
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
var stn_pstRotatorEnabled: Boolean /**
get() = prefs.isStn_pstRotatorEnabled() * Own ground elevation above sea level in metres, NaN while the elevation model decides.
set(value) { prefs.setStn_pstRotatorEnabled(value) } * Staged like every path-analysis setting; the reachability service reads the stored
* value, so a changed height reaches the analysis only once it is applied.
*/
var stn_pathAnalysisOwnHeightAslMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnHeightAslMeters() }) { prefs.setStn_pathAnalysisOwnHeightAslMeters(it) }
var stn_pstRotatorHost: String /** Whether the path ends at the highest point of the remote locator square. */
get() = prefs.getStn_pstRotatorHost() var stn_pathAnalysisDxHighestPointInSquare: Boolean by draft.setting({ prefs.isStn_pathAnalysisDxHighestPointInSquare() }) { prefs.setStn_pathAnalysisDxHighestPointInSquare(it) }
set(value) { prefs.setStn_pstRotatorHost(value) }
var stn_pstRotatorPort: Int /**
get() = prefs.getStn_pstRotatorPort() * The entry last committed to the own-height field, as the operator typed it.
set(value) { prefs.setStn_pstRotatorPort(value) } *
* [Form.committed] keys its draft text on the stored text, and the staged number reads
* back in its own spelling ("25.0" for "25"). The settings draft is Compose state, so
* the commit itself recomposes the field with the new key, and without this the field
* would swap "25" for "25.0" under the cursor right after Enter, or drop keys typed
* since. Handing back the committed spelling while it still names the same number
* keeps key and text in agreement.
*/
private var committedOwnHeightAslText by mutableStateOf<String?>(null)
var stn_qtfDefault: Double /**
get() = prefs.getStn_qtfDefault() * The own ground elevation as its field shows it: empty while the elevation model
set(value) { prefs.setStn_qtfDefault(value) } * decides. The spelling of the last commit while it still names the drafted number,
* otherwise that number itself (discarded, applied elsewhere, or never committed here).
* Written only through [commitOwnHeightAsl].
*/
val stn_pathAnalysisOwnHeightAslText: String
get() {
val drafted = stn_pathAnalysisOwnHeightAslMeters.let { if (it.isFinite()) it.toString() else "" }
val committed = committedOwnHeightAslText
return if (committed != null && isOwnHeightAslUnchanged(committed, drafted)) committed else drafted
}
/**
* Commits an own ground elevation entry into the draft. Empty hands the elevation back
* to the model; a number in range is staged; anything else is refused and nothing is
* staged.
*
* @return null when staged, otherwise the reason shown to the operator
*/
fun commitOwnHeightAsl(entered: String): String? {
val meters = parseOwnHeightAsl(entered)
?: return CurrentStrings.get().stationInvalidOwnHeightAsl(entered.trim())
stn_pathAnalysisOwnHeightAslMeters = meters
committedOwnHeightAslText = entered.trim()
return null
}
var stn_pstRotatorEnabled: Boolean by draft.setting(
{ prefs.isStn_pstRotatorEnabled() },
) { prefs.setStn_pstRotatorEnabled(it) }
var stn_useQtfFilterDirectionAsAntennaHeading: Boolean by draft.setting(
{ prefs.isStn_useQtfFilterDirectionAsAntennaHeading() },
) { prefs.setStn_useQtfFilterDirectionAsAntennaHeading(it) }
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
/**
* Refused on apply when it clashes with rotor 2's port as it will be stored, but only
* while rotor 2 is in use (SO2R on and rotor 2 enabled), so a station without SO2R is
* never held back by rotor 2's default port. See [portsClash].
*/
var stn_pstRotatorPort: Int by draft.setting({ prefs.getStn_pstRotatorPort() }) { port ->
if (prefs.isTrxSynch_so2rEnabled() && stn_pstRotator2Enabled) {
require(!portsClash(port, stn_pstRotator2Port)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
}
prefs.setStn_pstRotatorPort(port)
}
var stn_pstRotator2Enabled: Boolean by draft.setting(
{ prefs.isStn_pstRotator2Enabled() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setStn_pstRotator2Enabled(it) }
var stn_pstRotator2Host: String by draft.setting(
{ prefs.getStn_pstRotator2Host() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setStn_pstRotator2Host(it) }
/**
* Refused on apply when it is the same as or adjacent to rotator 1's port as it will be
* stored (see [portsClash]). Compared with the drafted value of rotator 1, so changing
* both in one apply is judged by the result, not by the order of the edits.
*/
var stn_pstRotator2Port: Int by draft.setting(
{ prefs.getStn_pstRotator2Port() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { port ->
require(!portsClash(port, stn_pstRotatorPort)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
prefs.setStn_pstRotator2Port(port)
}
/** Whether the rotator 2 block is active; follows the stored SO2R switch. */
val so2rEnabled: Boolean
get() = prefs.isTrxSynch_so2rEnabled()
var stn_qtfDefault: Double by draft.setting({ prefs.getStn_qtfDefault() }) { prefs.setStn_qtfDefault(it) }
/** /**
* The chat category is held twice: in ChatPreferences and in ChatController. * The chat category is held twice: in ChatPreferences and in ChatController.
@@ -164,17 +234,21 @@ class StationTabState(
* pickers it governs. It decides what the Connect button does, so it belongs with * pickers it governs. It decides what the Connect button does, so it belongs with
* the login settings and not with the display options. * the login settings and not with the display options.
*/ */
var loginToSecondChatEnabled: Boolean var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
get() = prefs.isLoginToSecondChatEnabled()
set(value) { prefs.setLoginToSecondChatEnabled(value) }
/** /**
* Whether the login fields may be edited. The JavaFX window disabled callsign, * Whether the login fields may be edited. The JavaFX window disabled callsign,
* password, name, locator and both category pickers once the session was logged * password, name, locator and both category pickers once the session was logged
* in: changing them there would not reach the running connection, so an edit * in: changing them there would not reach the running connection, so an edit
* would look applied and be ignored until the next login. * would look applied and be ignored until the next login.
*
* Locked for every state but DISCONNECTED: a reconnect wait or a session being
* stopped still belongs to the credentials it was started with. Derived on each read
* rather than held, so a composition reading it follows the connection state the
* moment that changes, provided [connectionState] reads Compose state.
*/ */
var loginFieldsEnabled: Boolean = true val loginFieldsEnabled: Boolean
get() = connectionState() == On4KstConnectionState.DISCONNECTED
/** /**
* The categories the operator can log into, built the way the JavaFX choice box * The categories the operator can log into, built the way the JavaFX choice box
@@ -210,4 +284,40 @@ class StationTabState(
loginChatCategorySecond = category loginChatCategorySecond = category
applySecondCategory(category) applySecondCategory(category)
} }
companion object {
/** The effect key of everything that restarts rotator 2; shared with the TRX synch tab. */
const val SECOND_ROTATOR: String = "secondRotator"
/**
* Whether two PSTRotator ports collide: the same port, or one apart because
* PSTRotator answers on port + 1, which would be the other client's local port.
*/
fun portsClash(a: Int, b: Int): Boolean = kotlin.math.abs(a - b) <= 1
/**
* Reads an own ground elevation entry: empty means "elevation model" (NaN), a
* number in the accepted range is taken, anything else is refused with null.
* A comma is read as the decimal separator, as in the other number fields.
*/
fun parseOwnHeightAsl(text: String): Double? {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
return Double.NaN
}
val meters = trimmed.replace(',', '.').toDoubleOrNull() ?: return null
return if (ChatPreferences.isValidOwnHeightAslMeters(meters)) meters else null
}
/**
* Whether a draft names the stored height. Compared as numbers, so "250", "250.0"
* and "250,0" all count as the stored 250.0, and an empty draft as the unset value;
* a draft that does not parse is never unchanged, so leaving the field refuses it.
*/
fun isOwnHeightAslUnchanged(draft: String, stored: String): Boolean {
val typed = parseOwnHeightAsl(draft) ?: return false
val now = parseOwnHeightAsl(stored) ?: return false
return if (typed.isNaN() || now.isNaN()) typed.isNaN() && now.isNaN() else typed == now
}
}
} }
@@ -0,0 +1,41 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
/**
* Corrects stored settings the settings window cannot show as they are, once, before the
* window is built.
*
* These writes go to ChatPreferences at once and not through the draft: they are no edit of
* the operator's, there is nothing to apply or discard, and they are what the JavaFX window
* did while building its panes. They used to happen inside the tab states -- one during a
* read in composition, one in a constructor -- where a write to the preferences is easy to
* miss and runs whenever Compose decides to read. Here they run at one known moment.
*
* - The fallback band of the DX cluster is stored as a prefix string; one no band has is
* repaired to 144 (Kst4ContestApplication:11349 in the JavaFX window).
* - The Win-Test broadcast address is replaced by the detected subnet broadcast address
* while it is still the catch-all default 255.255.255.255. An address the operator or a
* previous detection chose is left alone, and a failed detection keeps the default:
* guessing an address would send SKED packets somewhere nobody is listening.
*
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null when
* none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
fun repairStoredSettings(prefs: ChatPreferences, detectWintestBroadcastAddress: () -> String?) {
if (Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
}
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() == LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) {
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (!detected.isNullOrBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
}
}
@@ -20,8 +20,7 @@ import kst4contest.view.compose.Form
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS * QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
* data, so a checked box must not read as "my QRG is being kept current". * data, so a checked box must not read as "my QRG is being kept current".
* *
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way * The controls stage their edits in the window's draft (see TrxSynchTabState).
* the JavaFX controls did.
*/ */
@Composable @Composable
fun TrxSynchTab(state: TrxSynchTabState) { fun TrxSynchTab(state: TrxSynchTabState) {
@@ -63,6 +62,24 @@ fun TrxSynchTab(state: TrxSynchTabState) {
) )
} }
Form.section(strings.trxSynchSo2rSection) {
Form.check(
strings.trxSynchSo2rEnabled,
state.trxSynch_so2rEnabled,
) { state.trxSynch_so2rEnabled = it; revision++ }
GuardedTextField(
label = strings.trxSynchWinTestStationRadio2,
value = state.logsynch_wintestNetworkStationNameOfWintestClient2,
enabled = state.trxSynch_so2rEnabled,
onAccepted = {
state.logsynch_wintestNetworkStationNameOfWintestClient2 = it
revision++
},
)
Text(strings.trxSynchSo2rHint)
}
Form.section(strings.trxSynchHintSection) { Form.section(strings.trxSynchHintSection) {
Text(strings.trxSynchHint) Text(strings.trxSynchHint)
} }
@@ -1,12 +1,14 @@
package kst4contest.view.compose.tabs package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/** /**
* The TRX synch tab as a typed facade over ChatPreferences. * The TRX synch tab as a typed facade over ChatPreferences.
* *
* Every property writes through immediately and there is deliberately no apply(), the * Edits are staged in the window's [SettingsDraft] and take effect when the operator
* way the JavaFX controls behaved. * applies them.
* *
* The tab carries the two sources that may update the own QRG. Both live under the * The tab carries the two sources that may update the own QRG. Both live under the
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this * logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
@@ -18,34 +20,42 @@ import kst4contest.model.ChatPreferences
* Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator * Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator
* cannot read a checked box as "my QRG is being kept current". * cannot read a checked box as "my QRG is being kept current".
* *
* @param applySecondRotator restarts or stops rotator 2 to match the stored SO2R and rotator 2
* switches, once per apply. Declared before [applyOwnQrgFollower] so callers passing the
* follower as a trailing lambda keep binding to it.
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled * @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower() * and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage * plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
* test can see it, and passed in so the rules stay testable without a controller. * test can see it, and passed in so the rules stay testable without a controller.
*/ */
class TrxSynchTabState( class TrxSynchTabState(
private val prefs: ChatPreferences, private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applySecondRotator: () -> Unit = {},
private val applyOwnQrgFollower: (Boolean) -> Unit, private val applyOwnQrgFollower: (Boolean) -> Unit,
) { ) {
var trxSynch_ucxLogUDPListenerEnabled: Boolean var trxSynch_ucxLogUDPListenerEnabled: Boolean by draft.setting(
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled() { prefs.isTrxSynch_ucxLogUDPListenerEnabled() },
set(value) { effectKey = OWN_QRG_FOLLOWER,
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value) effect = ::applyFollower,
applyOwnQrgFollower(anyQrgSourceEnabled) ) { prefs.setTrxSynch_ucxLogUDPListenerEnabled(it) }
}
var logsynch_wintestQrgSyncEnabled: Boolean var logsynch_wintestQrgSyncEnabled: Boolean by draft.setting(
get() = prefs.isLogsynch_wintestQrgSyncEnabled() { prefs.isLogsynch_wintestQrgSyncEnabled() },
set(value) { effectKey = OWN_QRG_FOLLOWER,
prefs.setLogsynch_wintestQrgSyncEnabled(value) effect = ::applyFollower,
applyOwnQrgFollower(anyQrgSourceEnabled) ) { prefs.setLogsynch_wintestQrgSyncEnabled(it) }
}
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */ /** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
var logsynch_wintestUsePassQrg: Boolean var logsynch_wintestUsePassQrg: Boolean by draft.setting(
get() = prefs.isLogsynch_wintestUsePassQrg() { prefs.isLogsynch_wintestUsePassQrg() },
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) } ) { prefs.setLogsynch_wintestUsePassQrg(it) }
// Staged trimmed, so the draft holds what will be stored.
private var stationFilter: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(it) }
/** /**
* Win-Test station name filter. An empty filter accepts every station, which is * Win-Test station name filter. An empty filter accepts every station, which is
@@ -53,8 +63,29 @@ class TrxSynchTabState(
* way the JavaFX field committed it. * way the JavaFX field committed it.
*/ */
var logsynch_wintestNetworkStationNameOfWintestClient1: String var logsynch_wintestNetworkStationNameOfWintestClient1: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() get() = stationFilter
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) } set(value) { stationFilter = value.trim() }
/** SO2R: radio 2 gets SECONDQRG and, with its own switch, rotator 2. */
var trxSynch_so2rEnabled: Boolean by draft.setting(
{ prefs.isTrxSynch_so2rEnabled() },
effectKey = StationTabState.SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setTrxSynch_so2rEnabled(it) }
private var stationRadio2: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient2(it) }
/** Win-Test station of radio 2; empty sends nothing to radio 2. Staged trimmed. */
var logsynch_wintestNetworkStationNameOfWintestClient2: String
get() = stationRadio2
set(value) { stationRadio2 = value.trim() }
/* Read from the stored values: it runs on apply, after both switches are written. */
private fun applyFollower() {
applyOwnQrgFollower(prefs.isTrxSynch_ucxLogUDPListenerEnabled() || prefs.isLogsynch_wintestQrgSyncEnabled())
}
/** /**
* True when at least one source may update the own QRG. One source switched off is * True when at least one source may update the own QRG. One source switched off is
@@ -74,5 +105,8 @@ class TrxSynchTabState(
* would read whichever one the tab happened to use. * would read whichever one the tab happened to use.
*/ */
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint" const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
/** The effect key both QRG sources share. */
private const val OWN_QRG_FOLLOWER = "ownQrgFollower"
} }
} }
@@ -1,12 +1,19 @@
package kst4contest.view; package kst4contest.view;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.observe.DirectUiDispatcher; import kst4contest.observe.DirectUiDispatcher;
import kst4contest.observe.SimpleRoster; import kst4contest.observe.SimpleRoster;
import kst4contest.view.compose.SelectedMessageFilter;
import kst4contest.view.compose.SelectedStationMessagePredicate;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -136,6 +143,39 @@ class RosterListBindingTest {
"a newly set filter must be visible without waiting for the next roster change"); "a newly set filter must be visible without waiting for the next roster change");
} }
@Test
void selectedStationMessagesFollowNewMessagesAndSelectionChanges() {
ChatMember first = member("DL1FIRST", 2);
ChatMember second = member("DL2SECOND", 2);
SimpleRoster<ChatMessage> messages = new SimpleRoster<>();
AtomicReference<Predicate<ChatMessage>> filter = new AtomicReference<>(
SelectedStationMessagePredicate.forSelection(
SelectedMessageFilter.PUBLIC, first, "DL0OWN"));
RosterListBinding<ChatMessage> binding = RosterListBinding.derived(
new DirectUiDispatcher(),
() -> messages.snapshot().stream().filter(filter.get()).toList(),
messages);
ChatMessage unrelated = publicMessage(second);
messages.add(unrelated);
assertTrue(binding.list().isEmpty());
ChatMessage matching = publicMessage(first);
messages.add(matching);
assertEquals(List.of(matching), List.copyOf(binding.list()));
filter.set(SelectedStationMessagePredicate.forSelection(
SelectedMessageFilter.PUBLIC, second, "DL0OWN"));
binding.refresh();
assertEquals(List.of(unrelated), List.copyOf(binding.list()),
"the former station's messages must disappear immediately");
ChatMessage next = publicMessage(second);
messages.add(next);
assertEquals(List.of(unrelated, next), List.copyOf(binding.list()));
}
@Test @Test
void aDisposedDerivedBindingDetachesFromEverySource() { void aDisposedDerivedBindingDetachesFromEverySource() {
SimpleRoster<String> members = new SimpleRoster<>(); SimpleRoster<String> members = new SimpleRoster<>();
@@ -150,4 +190,20 @@ class RosterListBindingTest {
assertTrue(binding.list().isEmpty()); assertTrue(binding.list().isEmpty());
} }
private static ChatMember member(String callSign, int category) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
return member;
}
private static ChatMessage publicMessage(ChatMember sender) {
ChatMessage message = new ChatMessage();
message.setSender(sender);
message.setReceiver(member("ALL", sender.getChatCategory().getCategoryNumber()));
message.setChatCategory(sender.getChatCategory());
message.setMessageText("test");
return message;
}
} }
@@ -0,0 +1,180 @@
package kst4contest.view;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.ApplicationConstants;
import kst4contest.controller.ChatController;
import kst4contest.controller.MessageBusManagementThread;
import kst4contest.controller.ScoreService;
import kst4contest.controller.StationMetricsService;
import kst4contest.controller.ThreadStatusCallback;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.observe.DirectUiDispatcher;
import kst4contest.observe.SimpleRoster;
import kst4contest.observe.SimpleValue;
import kst4contest.view.compose.DataTableState;
import kst4contest.view.compose.RowKeys;
import kst4contest.view.compose.SelectedMessageFilter;
import kst4contest.view.compose.SelectedStationMessagePredicate;
import kst4contest.view.feed.SelectedStationMessagesFeed;
/** Exercises protocol parsing, selected-station filtering and the displayed table together. */
class SelectedStationMessageFlowTest {
private static final String OWN_CALL = "DL0OWN";
private static final String SELECTED_CALL = "DM5M";
private final LinkedBlockingQueue<ChatMessage> receiveQueue = new LinkedBlockingQueue<>();
private final SimpleRoster<ChatMessage> messages = new SimpleRoster<>();
private final AtomicReference<Predicate<ChatMessage>> filter = new AtomicReference<>();
private ChatController controller;
private ChatPreferences preferences;
private ChatMember selected;
private RosterListBinding<ChatMessage> binding;
private DataTableState<ChatMessage> displayed;
@BeforeEach
void setUp() {
preferences = mock(ChatPreferences.class);
when(preferences.getStn_loginCallSign()).thenReturn(OWN_CALL);
when(preferences.getStn_loginLocatorMainCat()).thenReturn("JO50LL");
when(preferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.300"));
when(preferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("1296.200"));
controller = mock(ChatController.class);
when(controller.getChatPreferences()).thenReturn(preferences);
when(controller.getChatCategoryMain()).thenReturn(new ChatCategory(ChatCategory.VUHF));
when(controller.getChatCategorySecondChat()).thenReturn(new ChatCategory(ChatCategory.MICROWAVE));
when(controller.getStationMetricsService()).thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> activeMember(invocation.getArgument(0)));
doAnswer(invocation -> {
messages.add(invocation.getArgument(0));
return null;
}).when(controller).publishChatMessage(any(ChatMessage.class));
selected = member(SELECTED_CALL, ChatCategory.VUHF);
filter.set(SelectedStationMessagePredicate.forSelection(
SelectedMessageFilter.PM_TO_ME, selected, OWN_CALL));
displayed = new DataTableState<>(List.of(), RowKeys.INSTANCE.byReference(), "selected-flow-test");
SelectedStationMessagesFeed feed =
new SelectedStationMessagesFeed(displayed, new DirectUiDispatcher());
binding = RosterListBinding.derived(
new DirectUiDispatcher(),
() -> messages.snapshot().stream().filter(filter.get()).toList(),
messages);
binding.onChanged(feed::push);
feed.push(binding.list());
}
@Test
@Timeout(10)
void realisticFramesReachTheDisplayedRowsUnderAllFourModes() throws InterruptedException {
runThrough(
chatFrame(2, SELECTED_CALL, "private", OWN_CALL),
chatFrame(2, SELECTED_CALL, "(" + OWN_CALL + ") public reply", "0"),
chatFrame(2, SELECTED_CALL, "general public message", "0"),
chatFrame(2, SELECTED_CALL, "private to another station", "DL2OTHER"),
chatFrame(2, "DL2OTHER", "other station", "0"),
chatFrame(3, SELECTED_CALL, "same call, other category", "0"));
assertEquals(2, displayed.getRows().size(),
"pm to me must contain the genuine PM and the explicitly addressed public line");
selectMode(SelectedMessageFilter.PUBLIC);
assertEquals(2, displayed.getRows().size(),
"the two public lines overlap with the addressed-public PM view");
selectMode(SelectedMessageFilter.PM_TO_OTHER);
assertEquals(1, displayed.getRows().size(),
"pm to other keeps private traffic sent by the selected station to another station");
selectMode(SelectedMessageFilter.NOTHING);
assertEquals(4, displayed.getRows().size(),
"nothing removes only the type/receiver restriction");
selectStation(member("DL2OTHER", ChatCategory.VUHF));
assertEquals(2, displayed.getRows().size(),
"changing the selected station must replace the displayed station scope immediately");
selectStation(member(SELECTED_CALL, ChatCategory.VUHF));
runThrough(chatFrame(2, SELECTED_CALL, "new while the panel is open", OWN_CALL));
assertEquals(5, displayed.getRows().size(),
"a new matching line must reach the already open table");
}
private void selectMode(SelectedMessageFilter mode) {
filter.set(SelectedStationMessagePredicate.forSelection(mode, selected, OWN_CALL));
binding.refresh();
}
private void selectStation(ChatMember station) {
selected = station;
selectMode(SelectedMessageFilter.NOTHING);
}
private void runThrough(ChatMessage... frames) throws InterruptedException {
MessageBusManagementThread thread = new MessageBusManagementThread(
controller,
mock(ThreadStatusCallback.class),
11L,
receiveQueue,
ignored -> true);
for (ChatMessage frame : frames) {
receiveQueue.add(frame);
}
receiveQueue.add(poisonPill());
thread.start();
thread.join();
}
private static ChatMessage chatFrame(int category, String sender, String text, String receiver) {
return wireMessage("CH|" + category + "|1700000000|" + sender
+ "|Operator|0|" + text + "|" + receiver + "|");
}
private static ChatMessage poisonPill() {
ChatMessage message = wireMessage(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
return message;
}
private static ChatMessage wireMessage(String text) {
ChatMessage message = new ChatMessage();
message.setMessageText(text);
return message;
}
private static ChatMember activeMember(ChatMember lookup) {
return member(lookup.getCallSign(), lookup.getChatCategory().getCategoryNumber());
}
private static ChatMember member(String callSign, int category) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
member.setQra("JO31AA");
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return member;
}
}
@@ -0,0 +1,122 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.math.abs
/** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
@OptIn(ExperimentalTestApi::class)
class ChatInputRowSo2rScreenTest {
@TempDir
lateinit var tempDir: Path
private fun prefs() = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
getActualQTF().set(145.0)
getActualQTF2().set(270.0)
}
private fun chatInputStateFor(prefs: ChatPreferences) = ChatInputState(object : ChatInputActions {
override fun panelSelection(): ChatMember? = null
override fun tableSelection(): ChatMember? = null
override fun scoreSelection(): ChatMember? = null
override fun resolveVariables(template: String, member: ChatMember?): String? = template
override fun isAddressedToOwnCallsign(text: String): Boolean = false
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
override fun mainCategory(): ChatCategory? = null
override fun recordOutboundCq(text: String) {}
override fun requestRecompute(reason: String) {}
override fun queue(message: ChatMessage) {}
override fun ownQrgMain(): String = prefs.getMYQRGFirstCat().get().orEmpty()
override fun ownQrgSecond(): String = prefs.getMYQRGSecondCat().get().orEmpty()
})
@Test
fun `QTF2 is shown only with SO2R and cannot be edited`() = runComposeUiTest {
val prefs = prefs()
var so2r by mutableStateOf(false)
setContent {
ChatInputRow(
state = chatInputStateFor(prefs),
prefs = prefs,
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
antennaQtf = ObservedValue(prefs.getActualQTF()),
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
so2rEnabled = so2r,
)
}
onAllNodesWithText("270.0").assertCountEquals(0)
so2r = true
waitForIdle()
onNodeWithText("270.0").assertExists()
onNodeWithText("270.0").assertIsNotEnabled()
}
@Test
fun `send buttons message and station fields share height and preserve realistic values`() = runComposeUiTest {
val prefs = prefs().apply {
getMYQRGFirstCat().set("1296.123.000")
getMYQRGSecondCat().set("144.310.000")
getActualQTF().set(360.0)
}
val state = chatInputStateFor(prefs).apply {
value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg")
}
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
ChatInputRow(
state = state,
prefs = prefs,
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
antennaQtf = ObservedValue(prefs.getActualQTF()),
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
so2rEnabled = false,
)
}
}
val values = listOf("1296.123.000", "144.310.000", "360.0")
val heights = listOf("TX", "clear")
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } +
values.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
assertTrue(
heights.max() - heights.min() <= 1f,
"send-row control heights differ: $heights",
)
val minimumQrgWidth = with(density) { 96.dp.toPx() }
values.take(2).forEach { value ->
val width = onNodeWithText(value).fetchSemanticsNode().boundsInRoot.width
assertTrue(width >= minimumQrgWidth - 1f, "$value has only $width px")
}
val qtfWidth = onNodeWithText("360.0").fetchSemanticsNode().boundsInRoot.width
assertTrue(
abs(qtfWidth - with(density) { 56.dp.toPx() }) <= 1f,
"360.0 field is $qtfWidth px wide",
)
assertTrue(
abs(onNodeWithText("TX").fetchSemanticsNode().boundsInRoot.height -
with(density) { 26.dp.toPx() }) <= 1f,
)
}
}
@@ -0,0 +1,149 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import kotlin.math.abs
/** Shared fields and choices use the same compact desktop height. */
@OptIn(ExperimentalTestApi::class)
class CompactControlsScreenTest {
@Test
fun fieldsChoicesAndButtonsShareTheDesktopControlHeight() = runComposeUiTest {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Column {
CompactTextField(value = "144.310.000", onValueChange = {})
Form.inlineChoice(
label = "Reachability",
items = listOf("Auto"),
selected = "Auto",
describe = { it },
onSelect = {},
)
Form.button(text = "TX", onClick = {})
Form.button(text = "Filter", onClick = {})
}
}
}
val heights = listOf("144.310.000", "Auto", "TX", "Filter")
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
val targetHeight = with(density) { 26.dp.toPx() }
assertTrue(
heights.all { abs(it - targetHeight) <= 1f },
"controls are $heights px tall instead of $targetHeight px",
)
assertTrue(
heights.max() - heights.min() <= 1f,
"control heights differ by more than one pixel: $heights",
)
}
@Test
fun accentAndToggleButtonsShareEnabledAndDisabledClickBehaviour() = runComposeUiTest {
var accentClicks = 0
var disabledClicks = 0
setContent {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
Column {
Form.button(
text = "Accent",
accented = true,
onClick = { accentClicks++ },
)
Form.button(
text = "Selected",
selected = true,
onClick = {},
)
Form.button(
text = "Disabled accent",
enabled = false,
accented = true,
onClick = { disabledClicks++ },
)
}
}
}
onNodeWithText("Accent").performClick()
onNodeWithText("Disabled accent").performClick()
assertEquals(1, accentClicks)
assertEquals(0, disabledClicks)
assertTrue(onNodeWithText("Selected").fetchSemanticsNode().boundsInRoot.height < 40f)
}
@Test
fun controlsStayAlignedAt125PercentDensityInEveningDesign() = runComposeUiTest {
setContent {
CompositionLocalProvider(
LocalDensity provides androidx.compose.ui.unit.Density(1.25f),
) {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
Column {
CompactTextField(value = "10368.000", onValueChange = {})
Form.inlineChoice(
label = "Reachability",
items = listOf("Auto"),
selected = "Auto",
describe = { it },
onSelect = {},
)
Form.button(text = "TX", onClick = {})
}
}
}
}
val heights = listOf("10368.000", "Auto", "TX")
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
val expected = 26f * 1.25f
assertTrue(
heights.all { abs(it - expected) <= 1f },
"125% controls are $heights px tall instead of about $expected px",
)
assertTrue(
heights.max() - heights.min() <= 1f,
"125% control heights differ by more than one pixel: $heights",
)
}
@Test
fun controlsGrowTogetherInsteadOfClippingALargerConfiguredFont() = runComposeUiTest {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 24f) {
Column {
CompactTextField(value = "1296.123.000", onValueChange = {})
Form.inlineChoice(
label = "Reachability",
items = listOf("Auto"),
selected = "Auto",
describe = { it },
onSelect = {},
)
Form.button(text = "TX", onClick = {})
}
}
}
val heights = listOf("1296.123.000", "Auto", "TX")
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
assertTrue(heights.min() > with(density) { 26.dp.toPx() })
assertTrue(
heights.max() - heights.min() <= 1f,
"large-font controls do not grow together: $heights",
)
}
}
@@ -0,0 +1,114 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.util.EnumSet
/** Renders the compact control strips for visual inspection in both supported palettes. */
class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Path) {
@Test
fun rendersDayAndEveningControlsAt100And125Percent() {
val reportDirectory = Path.of("build", "reports", "compact-controls")
Files.createDirectories(reportDirectory)
listOf(false, true).forEach { darkMode ->
listOf(1f, 1.25f).forEach { scale ->
val bytes = render(darkMode, scale)
val palette = if (darkMode) "evening" else "day"
val scaling = if (scale == 1f) "100" else "125"
Files.write(reportDirectory.resolve("$palette-$scaling.png"), bytes)
assertTrue(bytes.size > 10_000, "$palette at $scaling% did not render")
}
}
}
private fun render(darkMode: Boolean, scale: Float): ByteArray {
val preferences = ChatPreferences(tempDir.resolve("preferences-$darkMode-$scale.xml")).apply {
getMYQRGFirstCat().set("1296.123.000")
getMYQRGSecondCat().set("144.310.000")
getActualQTF().set(360.0)
}
val chatInputState = ChatInputState(RenderActions(preferences)).apply {
value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg")
}
val filters = StationFilterState()
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(Modifier.padding(4.dp)) {
TimelineView(
state = TimelineState(),
onCandidateClicked = {},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
StationFilterBar(
filters = filters,
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
reachability = ReachabilityState(
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
),
onChanged = {},
)
Spacer(Modifier.height(12.dp))
ChatInputRow(
state = chatInputState,
prefs = preferences,
ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()),
ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()),
antennaQtf = ObservedValue(preferences.getActualQTF()),
antennaQtf2 = ObservedValue(preferences.getActualQTF2()),
so2rEnabled = false,
)
}
}
}
}
return try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
}
}
private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions {
override fun panelSelection(): ChatMember? = null
override fun tableSelection(): ChatMember? = null
override fun scoreSelection(): ChatMember? = null
override fun resolveVariables(template: String, member: ChatMember?): String = template
override fun isAddressedToOwnCallsign(text: String): Boolean = false
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
override fun mainCategory(): ChatCategory? = null
override fun recordOutboundCq(text: String) = Unit
override fun requestRecompute(reason: String) = Unit
override fun queue(message: ChatMessage) = Unit
override fun ownQrgMain(): String = preferences.getMYQRGFirstCat().get().orEmpty()
override fun ownQrgSecond(): String = preferences.getMYQRGSecondCat().get().orEmpty()
}
}
@@ -0,0 +1,103 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.tabs.showTab
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@OptIn(ExperimentalTestApi::class)
class ConnectionBarScreenTest(@param:TempDir private val tempDir: Path) {
@Test
fun `button availability follows every manager lifecycle state`() {
var connectionState = On4KstConnectionState.DISCONNECTED
val state = ConnectionBarState(preferences()) { connectionState }
On4KstConnectionState.entries.forEach { lifecycle ->
connectionState = lifecycle
assertEqualsLifecycleRules(lifecycle, state)
}
}
@Test
fun `async disconnect redraws the open settings connection bar`() = runComposeUiTest {
var connectionState by mutableStateOf(On4KstConnectionState.ONLINE)
val state = ConnectionBarState(preferences()) { connectionState }
showTab {
ConnectionBar(
state = state,
onConnect = { },
onSave = { },
onApplyAndClose = { },
onDisconnectAndCloseChat = { },
onDisconnectOnly = { },
)
}
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
onNodeWithText("Disconnect").assertIsEnabled()
runOnIdle { connectionState = On4KstConnectionState.STOPPING }
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
onNodeWithText("Disconnect").assertIsNotEnabled()
runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED }
onNodeWithText("Connect", substring = true).assertIsEnabled()
onNodeWithText("Disconnect").assertIsNotEnabled()
}
@Test
fun `a failed attempt enables one new connection attempt`() = runComposeUiTest {
var connectionState by mutableStateOf(On4KstConnectionState.CONNECTING)
var attempts = 0
val state = ConnectionBarState(preferences()) { connectionState }
showTab {
ConnectionBar(
state = state,
onConnect = {
attempts++
connectionState = On4KstConnectionState.CONNECTING
},
onSave = { },
onApplyAndClose = { },
onDisconnectAndCloseChat = { },
onDisconnectOnly = { },
)
}
runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED }
onNodeWithText("Connect", substring = true).performClick()
assertTrue(attempts == 1)
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
}
private fun assertEqualsLifecycleRules(
lifecycle: On4KstConnectionState,
state: ConnectionBarState,
) {
assertTrue(state.canConnect == (lifecycle == On4KstConnectionState.DISCONNECTED), lifecycle.name)
assertTrue(state.canDisconnect == lifecycle.isConnectionAttemptActive, lifecycle.name)
if (lifecycle == On4KstConnectionState.STOPPING) {
assertFalse(state.canConnect)
assertFalse(state.canDisconnect)
}
}
private fun preferences() = ChatPreferences(tempDir.resolve("preferences.xml"))
}
@@ -34,6 +34,97 @@ class DataTableStateTest {
rowKey = RowKeys.byValue { s: Station -> s.callSign }, rowKey = RowKeys.byValue { s: Station -> s.callSign },
) )
@Test
fun `an initial sort is applied once and keeps later rows in order`() {
val table = DataTableState(
columns = listOf(
DataColumn(
id = "callsign",
title = "Callsign",
value = { station: Station -> station.callSign },
comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { station: Station ->
station.callSign.trim()
},
),
),
rowKey = RowKeys.byValue { station: Station -> station.callSign },
initialSortColumnId = "callsign",
)
table.replaceRows(listOf(Station("do5amf", 1), Station("DL1ABC", 2)))
assertEquals(listOf("DL1ABC", "do5amf"), table.visibleRows.map { it.callSign })
table.replaceRows(
listOf(Station("ZZ9ZZZ", 3), Station("do5amf", 1), Station("AA1AAA", 4), Station("DL1ABC", 2)),
)
assertEquals(
listOf("AA1AAA", "DL1ABC", "do5amf", "ZZ9ZZZ"),
table.visibleRows.map { it.callSign },
)
}
@Test
fun `a user sort replaces the initial sort and survives row and filter updates`() {
val table = DataTableState(
columns = listOf(
DataColumn(id = "callsign", title = "Callsign", value = { station: Station -> station.callSign }),
DataColumn(
id = "qrb",
title = "QRB",
value = { station: Station -> station.qrb.toString() },
comparator = compareBy { station: Station -> station.qrb },
),
),
rowKey = RowKeys.byValue { station: Station -> station.callSign },
initialSortColumnId = "callsign",
)
table.replaceRows(listOf(Station("B", 300), Station("A", 100)))
table.toggleSort("qrb")
table.rowFilter = { it.qrb >= 100 }
table.replaceRows(listOf(Station("C", 200), Station("B", 300), Station("A", 100)))
assertEquals("qrb", table.sortedColumnId)
assertEquals(listOf("A", "C", "B"), table.visibleRows.map { it.callSign })
}
@Test
fun `a selected filtered row can be pinned only in the table view`() {
val table = DataTableState(
columns = listOf(DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign })),
rowKey = RowKeys.byValue { station: Station -> station.callSign },
keepSelectionVisible = true,
)
val hidden = Station("DL1HIDE", 100)
val shown = Station("DL2SHOW", 500)
table.replaceRows(listOf(hidden, shown))
table.rowFilter = { it.qrb >= 300 }
table.select(hidden)
assertEquals(listOf("DL2SHOW"), table.filteredRows.map { it.callSign }, "the shared map filter changed")
assertEquals(
listOf("DL1HIDE", "DL2SHOW"),
table.visibleRows.map { it.callSign },
"the selected station was not made reachable in the table",
)
}
@Test
fun `the same selected row can request scrolling more than once`() {
val table = stationTable()
val selected = Station("DL1ABC", 100)
table.replaceRows(listOf(selected))
table.select(selected)
table.requestScrollToSelection()
val first = table.scrollRequestRevision
table.requestScrollToSelection()
assertTrue(table.scrollRequestRevision > first)
}
@Test @Test
fun `the selection follows the row, not its position`() { fun `the selection follows the row, not its position`() {
val table = stationTable() val table = stationTable()
@@ -322,6 +413,26 @@ class DataTableStateTest {
assertSame(kept, table.selectedRow) assertSame(kept, table.selectedRow)
} }
/**
* The filter reads fields of the row objects, which change in place: a station's QRB,
* its worked flags, its last activity. forceRedraw is how the application says so, and
* the cached visible rows must be filtered again after it (#21).
*/
@Test
fun `a redraw filters rows again whose fields changed in place`() {
val table = stationTable()
val moving = Station("DN9APW", 100)
table.replaceRows(listOf(moving, Station("DO5AMF", 400)))
table.rowFilter = { it.qrb > 300 }
assertEquals(listOf("DO5AMF"), table.visibleRows.map { it.callSign })
moving.qrb = 500
table.forceRedraw()
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign }.sorted(),
"the cached rows from before the change were handed back")
}
} }
/** /**
@@ -368,6 +479,24 @@ class DataTableColumnWidthTest {
assertEquals(140.0, table(store).widthOf("callsign")) assertEquals(140.0, table(store).widthOf("callsign"))
} }
@Test
fun `a stored narrow width wins over the automatic width`() {
val store = RecordingStore()
store.stored["dx-cluster-monitor/callsign"] = 36.0
val table = table(store)
val column = table.columns.first { it.id == "callsign" }
assertEquals(36f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f)))
}
@Test
fun `the automatic width is used only without a stored width`() {
val table = table(RecordingStore())
val column = table.columns.first { it.id == "callsign" }
assertEquals(140f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f)))
}
@Test @Test
fun `a column never sized yet has no stored width`() { fun `a column never sized yet has no stored width`() {
assertNull(table(RecordingStore()).widthOf("qra"), assertNull(table(RecordingStore()).widthOf("qra"),
@@ -20,6 +20,8 @@ import kst4contest.view.compose.tabs.MessageHandlingTabState
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** /**
* The four ways the drawing layer went wrong while the build was green. * The four ways the drawing layer went wrong while the build was green.
@@ -41,7 +43,7 @@ import org.junit.jupiter.api.Test
* these were written, because the spec left it open and a net that only works on a * these were written, because the spec left it open and a net that only works on a
* developer's desk is worth saying so about. * developer's desk is worth saying so about.
*/ */
class DrawingLayerRegressionTest { class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = { private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() } Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
@@ -52,9 +54,9 @@ class DrawingLayerRegressionTest {
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
@Test @Test
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest { fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerTextMainCat("") prefs.setMessageHandling_autoAnswerTextMainCat("")
val state = MessageHandlingTabState(prefs) val state = MessageHandlingTabState(prefs, ImmediateDraft())
// The real tab, not a field in isolation: the fault was in the wiring between them. // The real tab, not a field in isolation: the fault was in the wiring between them.
setContent(themed { MessageHandlingTab(state) }) setContent(themed { MessageHandlingTab(state) })
@@ -0,0 +1,54 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The size and position the host fitted a window to are not the operator's: the first
* matching report is swallowed, and nothing after it.
*/
class FitRecordTest {
private val fitted = Rectangle(1920, 0, 640, 480)
@Test
fun `nothing counts as fitted before a fit`() {
val fit = FitRecord()
assertFalse(fit.takeSize(640, 480))
assertFalse(fit.takePosition(Point(1920, 0)))
}
@Test
fun `the fit echoing back is recognised despite rounding and a nudge`() {
val fit = FitRecord()
fit.record(fitted)
assertTrue(fit.takeSize(641, 479))
assertTrue(fit.takePosition(Point(1920 + 10, 0 + 28)))
}
@Test
fun `a fit is consumed once, so a later identical resize or move is the operator's`() {
val fit = FitRecord()
fit.record(fitted)
fit.takeSize(640, 480)
fit.takePosition(Point(1920, 0))
assertFalse(fit.takeSize(640, 480))
assertFalse(fit.takePosition(Point(1920, 0)))
}
@Test
fun `an operator report in between clears the record`() {
val fit = FitRecord()
fit.record(fitted)
assertFalse(fit.takeSize(900, 700))
assertFalse(fit.takePosition(Point(100, 100)))
assertFalse(fit.takeSize(640, 480))
}
}
@@ -0,0 +1,57 @@
package kst4contest.view.compose
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.tabs.GuiOptionsTabState
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.SYSTEM_LANGUAGE
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.Locale
class GuiStartupDesignPersistenceTest(@param:TempDir private val tempDir: Path) {
@Test
fun `root profile keeps evening startup design across save layout autosave and reload`() {
assertStartupDesignSurvives(tempDir.resolve("preferences.xml"))
}
@Test
fun `named profile keeps evening startup design across save layout autosave and reload`() {
assertStartupDesignSurvives(tempDir.resolve("profiles/CONTEST/preferences.xml"))
}
private fun assertStartupDesignSurvives(preferencesFile: Path) {
val preferences = ChatPreferences(preferencesFile)
val draft = SettingsDraft()
val notices = SettingsNotices()
val state = GuiOptionsTabState(preferences, draft, languageStore())
state.gUI_darkModeActiveByDefault = true
applyAndSave(draft, notices) {
preferences.writePreferencesToXmlFile()
.takeIf { it }
?.let { preferences.storeAndRestorePreferencesFileName }
}
assertTrue(preferences.isGUI_darkModeActiveByDefault)
// The menu switch is session-only and must not alter the next startup.
preferences.isGUI_darkModeActive = false
preferences.getGUIsettingsStageSceneSizeHW()[0] = 777.0
assertTrue(preferences.writeLayoutPreferencesToXmlFile())
val restored = ChatPreferences(preferencesFile)
assertTrue(restored.readPreferencesFromXmlFile())
assertTrue(restored.isGUI_darkModeActiveByDefault)
assertFalse(restored.isGUI_darkModeActive)
}
private fun languageStore() = LanguageStore(
storedLanguage = { SYSTEM_LANGUAGE },
storeLanguage = { },
systemDefault = Locale.UK,
)
}
@@ -0,0 +1,26 @@
package kst4contest.view.compose
/**
* A draft that applies every change the moment it is staged.
*
* For the tests that pin a tab state's own rules -- validation, normalisation, which effect
* an edit triggers -- without the draft in between. The draft itself is pinned by
* SettingsDraftTest, and that every drafted setting really waits for Apply by
* DraftedSettingsTableTest. A failed write or effect throws here, so a test cannot pass
* over one unnoticed.
*/
internal class ImmediateDraft : SettingsDraft() {
override fun <T> stage(
key: String,
value: T,
live: () -> T,
write: (T) -> Unit,
effectKey: String?,
effect: (() -> Unit)?,
) {
super.stage(key, value, live, write, effectKey, effect)
val outcome = apply()
check(outcome.isClean) { "apply failed: ${outcome.failures}" }
}
}
@@ -0,0 +1,346 @@
package kst4contest.view.compose
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.PaletteRole
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LocalStrings
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* The in-window menu row, drawn and clicked (#107).
*
* [MainMenuModelTest] covers the bar as data. This covers what the operator actually meets:
* a title that opens, an item that runs its own action, a label that follows the state while
* the row is on screen. Two regressions are the reason it exists:
*
* - `fe4215bc` (Remove JavaFX, in `main` as `2a5c89b9`): the sixteen menu actions still hung
* on JavaFX `MenuItem`s that had been deleted, so picking them in the Compose menu did
* nothing. Found by a GUI acceptance run, never by the build. They were moved onto their
* own `menuAction…` methods behind [MainMenuActions].
* - `83e23f55` (Draw the menu bar in Compose, in `main` as `2a5c89b9`): Compose's `MenuBar`
* is a Swing `JMenuBar` that no theme reaches, so Linux showed the default Metal menu above
* a themed window. The row is now Compose and reads the stylesheet palette.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class MainMenuRowScreenTest {
private class RecordingActions : MainMenuActions {
val calls = mutableListOf<String>()
override fun connect() { calls += "connect" }
override fun disconnect() { calls += "disconnect" }
override fun switchOperatorProfile() { calls += "switchOperatorProfile" }
override fun exitApplication() { calls += "exitApplication" }
override fun setQrgAsNameInChat() { calls += "setQrgAsNameInChat" }
override fun toggleAwayState() { calls += "toggleAwayState" }
override fun toggleSettingsWindow() { calls += "toggleSettingsWindow" }
override fun toggleMonitorWindow() { calls += "toggleMonitorWindow" }
override fun toggleStationMap() { calls += "toggleStationMap" }
override fun useDarkDesign() { calls += "useDarkDesign" }
override fun useDefaultDesign() { calls += "useDefaultDesign" }
override fun openDonationPage() { calls += "openDonationPage" }
override fun openHomepage() { calls += "openHomepage" }
override fun openNewsgroup() { calls += "openNewsgroup" }
override fun openChangelog() { calls += "openChangelog" }
override fun openOv3tDonationPage() { calls += "openOv3tDonationPage" }
override fun contactAuthor() { calls += "contactAuthor" }
override fun showAbout() { calls += "showAbout" }
}
private val rowTag = "menu-row"
private fun ComposeUiTest.showRow(
state: MainMenuState,
actions: MainMenuActions,
rowState: MainMenuRowState = MainMenuRowState(),
settingsOpen: () -> Boolean = { false },
monitorOpen: () -> Boolean = { false },
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = state,
actions = actions,
settingsWindowOpen = settingsOpen(),
monitorWindowOpen = monitorOpen(),
rowState = rowState,
modifier = Modifier.testTag(rowTag),
)
}
}
}
private fun onlineState() = MainMenuState().apply {
connectionState = On4KstConnectionState.ONLINE
connectLabel = "Connect to 144/432 MHz"
}
/**
* Every item, picked through the drawn row, reaches its own action and nobody else's.
* This is the `fe4215bc` fault in the form the operator saw it: the item was there and
* picking it did nothing.
*/
@Test
fun everyItemPickedInTheRowRunsItsOwnAction() = runComposeUiTest {
val actions = RecordingActions()
val state = onlineState()
val rowState = MainMenuRowState()
showRow(state, actions, rowState)
// Online, so Disconnect is usable and Connect is not; Connect has its own test below.
val expected = listOf(
Triple("File", "Disconnect", "disconnect"),
Triple("File", "Switch operator profile...", "switchOperatorProfile"),
Triple("File", "Exit + disconnect", "exitApplication"),
Triple("Options", "Set QRG as name in Chat (main category)", "setQrgAsNameInChat"),
Triple("Options", "Show me as AWAY FROM chat!", "toggleAwayState"),
Triple("Options", "Show options", "toggleSettingsWindow"),
Triple("Windows", "Show cluster / stranger QSOs", "toggleMonitorWindow"),
Triple("Windows", "Show / hide station map", "toggleStationMap"),
Triple("Windows", "Use dark mode design", "useDarkDesign"),
Triple("Windows", "Use default mode design", "useDefaultDesign"),
Triple("Info", "Donate for kst4Contest development via PayPal", "openDonationPage"),
Triple("Info", "Donate for OV3T´s plane feed service", "openOv3tDonationPage"),
Triple("Info", "Visit DARC X08-Homepage", "openHomepage"),
Triple("Info", "Join kst4Contest newsgroup", "openNewsgroup"),
Triple("Info", "Contact the author using default mail app", "contactAuthor"),
Triple("Info", "About...", "showAbout"),
)
expected.forEach { (menu, item, action) ->
onNodeWithText(menu).performClick()
awaitNode(inPopup(item)).performClick()
awaitGone(inPopup(item))
assertEquals(action, actions.calls.lastOrNull(), "'$menu > $item' ran the wrong action")
assertEquals(null, rowState.openMenu, "'$menu' stayed down after '$item' was picked")
}
assertEquals(
expected.size,
actions.calls.size,
"an item ran more than one action, or one ran none",
)
}
/** Offline, Connect works; once an attempt runs it is greyed out and Disconnect works. */
@Test
fun theConnectItemsFollowTheConnectionStateWhileTheMenuIsDown() = runComposeUiTest {
val actions = RecordingActions()
val state = MainMenuState().apply { connectLabel = "Connect to 144/432 MHz" }
showRow(state, actions)
onNodeWithText("File").performClick()
awaitNode(inPopup("Connect to 144/432 MHz")).assertIsEnabled()
onNode(inPopup("Disconnect")).assertIsNotEnabled()
onNode(inPopup("Connect to 144/432 MHz")).performClick()
awaitGone(inPopup("Connect to 144/432 MHz"))
assertEquals(listOf("connect"), actions.calls, "Connect did not run while offline")
onNodeWithText("File").performClick()
awaitNode(inPopup("Connect to 144/432 MHz"))
// The state changes from elsewhere -- the connection callback -- with the menu down.
runOnIdle { state.connectionState = On4KstConnectionState.CONNECTING }
waitForIdle()
onNodeWithText("Connect to 144/432 MHz").assertIsNotEnabled()
onNodeWithText("Disconnect").assertIsEnabled()
onNodeWithText("Connect to 144/432 MHz").performClick()
waitForIdle()
assertEquals(listOf("connect"), actions.calls, "a disabled item ran its action")
}
/**
* The away item's label is the only place the AFK state is shown at all. JavaFX flipped
* it inside the click handler, which left it wrong whenever the state changed from
* elsewhere; here it must follow the state, drawn.
*/
@Test
fun theAwayLabelFollowsTheStateAndNotTheClick() = runComposeUiTest {
val actions = RecordingActions()
val state = onlineState()
showRow(state, actions)
onNodeWithText("Options").performClick()
waitForIdle()
onNodeWithText("Show me as AWAY FROM chat!").performClick()
waitForIdle()
// The click alone changes nothing: the action did not touch the state.
onNodeWithText("Options").performClick()
waitForIdle()
onNodeWithText("Show me as AWAY FROM chat!").assertExists()
runOnIdle { state.awayFromChat = true }
waitForIdle()
onNodeWithText("Show me as ACTIVE in chat!").assertExists()
onNodeWithText("Show me as AWAY FROM chat!").assertDoesNotExist()
}
/** Both toggles name the next action, and follow windows that close on their own. */
@Test
fun theWindowTogglesFollowWhetherTheWindowIsOpen() = runComposeUiTest {
var monitorOpen by mutableStateOf(false)
showRow(onlineState(), RecordingActions(), monitorOpen = { monitorOpen })
onNodeWithText("Windows").performClick()
waitForIdle()
onNodeWithText("Show cluster / stranger QSOs").assertExists()
runOnIdle { monitorOpen = true }
waitForIdle()
onNodeWithText("Hide cluster / stranger QSOs").assertExists()
onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist()
}
/**
* The menu starts at the bar's bottom edge, like any menu bar's menu. Material3's
* DropdownMenu cannot do this: its position provider refuses any y closer than 48 dp
* (MenuVerticalMargin) to the window's top edge, and the bar sits exactly there, so
* every menu opened with a visible gap hanging below the bar.
*
* The popup-semantics node is a scene-filling layout that positions the menu inside
* itself, so its own bounds say nothing; the first item is measured instead, which
* sits [MENU_POPUP_VERTICAL_PADDING] below the menu's top edge.
*/
@Test
fun theMenuOpensFlushUnderTheBar() = runComposeUiTest {
showRow(onlineState(), RecordingActions())
onNodeWithText("File").performClick()
val firstItem = awaitNode(inPopup("Connect to 144/432 MHz"))
val rowBottom = onNodeWithTag(rowTag).fetchSemanticsNode().boundsInWindow.bottom
val itemTop = firstItem.fetchSemanticsNode().boundsInWindow.top
val paddingPx = 8f
assertEquals(rowBottom + paddingPx, itemTop, 0.5f, "the menu does not start at the bar's bottom edge")
}
/** A second click on an open title closes it again, the way a menu bar behaves. */
@Test
fun clickingAnOpenTitleClosesIt() = runComposeUiTest {
val rowState = MainMenuRowState()
showRow(onlineState(), RecordingActions(), rowState)
onNodeWithText("Info").performClick()
waitForIdle()
assertEquals("Info", rowState.openMenu)
onNodeWithText("About...").assertExists()
onNodeWithText("Info").performClick()
waitForIdle()
assertEquals(null, rowState.openMenu)
onNodeWithText("About...").assertDoesNotExist()
}
/**
* The `83e23f55` fault: the bar must be drawn in the palette, and a change of the palette
* must reach it while it is on screen. Measured on the rendered row, to the right of the
* last title, where nothing but the row's own background is drawn.
*/
@Test
fun theRowIsDrawnInThePaletteAndFollowsAChange() = runComposeUiTest {
var stored = ""
val store = PaletteStore(
shippedOf = { JavaFxStylesheet.read("/KST4ContestDefaultDay.css") },
fileRolesOf = { emptyMap() },
storedOverridesOf = { stored },
storeOverrides = { _, encoded -> stored = encoded },
)
setContent {
CompositionLocalProvider(LocalPaletteStore provides store) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = onlineState(),
actions = RecordingActions(),
settingsWindowOpen = false,
monitorWindowOpen = false,
rowState = MainMenuRowState(),
modifier = Modifier.testTag(rowTag),
)
}
}
}
waitForIdle()
assertEquals(store.shipped(false).base, rightEdgeColour(), "the row is not in the shipped base")
runOnIdle { store.set(darkMode = false, role = PaletteRole.SURFACE, colour = "#FF0000") }
waitForIdle()
assertEquals(Color(0xFFFF0000), rightEdgeColour(), "a palette change did not reach the row")
}
private fun ComposeUiTest.rightEdgeColour(): Color {
val pixels = onNodeWithTag(rowTag).captureToImage().toPixelMap()
// Well inside the row's right end, clear of any edge antialiasing.
return pixels[pixels.width - 12, pixels.height / 2]
}
/** A language picked after the first draw reaches the titles and the items. */
@Test
fun aLanguageChosenWhileTheRowIsShownReachesIt() = runComposeUiTest {
var storedLanguage = "en"
val languages = LanguageStore(
storedLanguage = { storedLanguage },
storeLanguage = { storedLanguage = it },
systemDefault = Locale.UK,
)
setContent {
CompositionLocalProvider(LocalStrings provides languages.strings) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = onlineState(),
actions = RecordingActions(),
settingsWindowOpen = false,
monitorWindowOpen = false,
rowState = MainMenuRowState(),
)
}
}
}
onNodeWithText("File").assertExists()
try {
runOnIdle { languages.use("de") }
waitForIdle()
onNodeWithText("Datei").performClick()
waitForIdle()
onNodeWithText("Trennen").assertExists()
onNodeWithText("File").assertDoesNotExist()
} finally {
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
runOnIdle { languages.use("en") }
}
}
}
@@ -1,6 +1,7 @@
package kst4contest.view.compose package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -123,36 +124,79 @@ class MainWindowFrameTest {
* The title carries the whole connection line while the chat is usable. * The title carries the whole connection line while the chat is usable.
* *
* The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in * The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in
* JO50JP (73 users online, 73 shown), 29 messages total." -- and the Compose window * JO50JP (73 users online, 73 shown), 29 messages total." The first Compose title took
* opened with a fixed "KST4Contest (Compose)" instead, which told a tiling window * the connection manager's diagnostic instead and read "ON4KST session is authenticated
* manager nothing and told the operator less. * and synchronized" for the whole contest.
*/ */
@Test @Test
fun `the title carries the live connection line once the chat is usable`() { fun `the title carries the live connection line once the chat is usable`() {
val detail = "Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " + val line = MainWindowFrame.onlineChatState(
"(73 users online, 73 shown), 29 messages total." mainCategory = ChatCategory(ChatCategory.VUHF),
secondCategory = null,
callSign = "DN5PW",
name = "Testing",
locator = "JO50JP",
usersOnline = 73,
shown = 70,
messagesTotal = 29,
)
assertEquals(detail, MainWindowFrame.chatState(On4KstConnectionState.ONLINE, detail)) assertEquals(
"Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " +
"(73 users online, 70 shown), 29 messages total.",
line,
)
assertEquals(line, MainWindowFrame.chatState(On4KstConnectionState.ONLINE) { line })
}
@Test
fun `both categories are named when the second chat is logged in`() {
val line = MainWindowFrame.onlineChatState(
mainCategory = ChatCategory(ChatCategory.VUHF),
secondCategory = ChatCategory(ChatCategory.MICROWAVE),
callSign = "DN5PW",
name = "",
locator = "JO50JP",
usersOnline = 1,
shown = 1,
messagesTotal = 1,
)
assertEquals(
"Connected to: 2: 144/432 MHz and 3: Microwave as DN5PW () in JO50JP " +
"(1 users online, 1 shown), 1 messages total.",
line,
)
}
@Test
fun `without a main category the title does not name an empty one`() {
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE),
MainWindowFrame.onlineChatState(null, null, "DN5PW", "x", "JO50JP", 1, 1, 1),
)
}
@Test
fun `large counts are not grouped by locale`() {
val line = MainWindowFrame.onlineChatState(ChatCategory(ChatCategory.VUHF), null, "DN5PW", "x", "JO50JP", 1234, 1200, 23456)
assertTrue(line.contains("(1234 users online, 1200 shown), 23456 messages total."), line)
} }
@Test @Test
fun `an unusable chat says what it is doing instead`() { fun `an unusable chat says what it is doing instead`() {
assertEquals( assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING), MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING),
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING, "ignored while connecting"), MainWindowFrame.chatState(On4KstConnectionState.CONNECTING) { error("not online") },
) )
} }
@Test @Test
fun `a blank detail falls through rather than leaving the title empty`() { fun `no state at all reads as disconnected without building the online line`() {
// The feed substitutes the state name for a blank detail, but it may not have run yet.
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE),
MainWindowFrame.chatState(On4KstConnectionState.ONLINE, " "),
)
assertEquals( assertEquals(
MainWindowFrame.offlineChatState(null), MainWindowFrame.offlineChatState(null),
MainWindowFrame.chatState(null, null), MainWindowFrame.chatState(null) { error("not online") },
) )
} }
} }
@@ -6,6 +6,8 @@ import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** /**
* The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies * The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
@@ -13,11 +15,11 @@ import org.junit.jupiter.api.Test
* above the send line follow the settings window — the reason ShortcutsTabState has been * above the send line follow the settings window — the reason ShortcutsTabState has been
* calling refreshShortcutButtons into thin air since Etappe 3b. * calling refreshShortcutButtons into thin air since Etappe 3b.
*/ */
class MainWindowStateRosterTest { class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
@Test @Test
fun `the shortcuts start out as what the profile holds`() { fun `the shortcuts start out as what the profile holds`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked")) prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList()) val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -31,7 +33,7 @@ class MainWindowStateRosterTest {
*/ */
@Test @Test
fun `a roster change reaches the mirror when it is refreshed`() { fun `a roster change reaches the mirror when it is refreshed`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG")) prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList()) val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -44,7 +46,7 @@ class MainWindowStateRosterTest {
@Test @Test
fun `an emptied roster empties the mirror`() { fun `an emptied roster empties the mirror`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG")) prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList()) val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -60,7 +62,7 @@ class MainWindowStateRosterTest {
*/ */
@Test @Test
fun `drawing the buttons subscribes to the mirror`() { fun `drawing the buttons subscribes to the mirror`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG")) prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList()) val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -79,9 +81,9 @@ class MainWindowStateRosterTest {
* A mismatch here is silent and total: SplitterState falls back to an even split, so * A mismatch here is silent and total: SplitterState falls back to an even split, so
* every operator would lose the layout they arranged without any error saying so. * every operator would lose the layout they arranged without any error saying so.
*/ */
class MainWindowSplitterDefaultsTest { class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
private val prefs = ChatPreferences() private val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
@Test @Test
fun `the message column has as many dividers as the profile stores positions`() { fun `the message column has as many dividers as the profile stores positions`() {
@@ -118,6 +120,15 @@ class MainWindowSplitterDefaultsTest {
val splitter = SplitterState(paneCount = 2, stored = stored) {} val splitter = SplitterState(paneCount = 2, stored = stored) {}
assertEquals(stored.map { it.toFloat() }, splitter.positions) assertEquals(stored.map { it.toFloat() }, splitter.positions)
} }
@Test
fun `the selected station content splitter takes the one stored profile position`() {
val stored = prefs.getGUIselectedCallSignSplitPane_dividerposition()
assertEquals(1, stored.size)
val splitter = SplitterState(paneCount = MainWindowState.SELECTED_STATION_PANE_COUNT, stored = stored) {}
assertEquals(stored.map { it.toFloat() }, splitter.positions)
}
} }
/** /**
@@ -0,0 +1,55 @@
package kst4contest.view.compose
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class ManualQtfHeadingSynchronizerTest(@param:TempDir private val directory: Path) {
@Test
fun `valid explicit QTF updates actual heading when synchronization is enabled`() {
val prefs = preferences().apply {
actualQTF.set(20.0)
}
assertTrue(applyExplicitQtfHeading(prefs, 315.5))
assertEquals(315.5, prefs.actualQTF.get(), 0.0001)
assertEquals(135.0, prefs.stn_qtfDefault, 0.0001)
}
@Test
fun `primary rotator prevents manual QTF synchronization`() {
val prefs = preferences().apply {
setStn_pstRotatorEnabled(true)
actualQTF.set(82.0)
}
assertFalse(applyExplicitQtfHeading(prefs, 180.0))
assertEquals(82.0, prefs.actualQTF.get(), 0.0001)
}
@Test
fun `disabled option and invalid angles leave actual heading unchanged`() {
val prefs = preferences().apply {
setStn_useQtfFilterDirectionAsAntennaHeading(false)
actualQTF.set(82.0)
}
listOf(90.0, Double.NaN, Double.POSITIVE_INFINITY, -0.1, 360.1).forEach { qtf ->
assertFalse(applyExplicitQtfHeading(prefs, qtf), "unexpectedly accepted $qtf")
assertEquals(82.0, prefs.actualQTF.get(), 0.0001)
}
}
@Test
fun `zero and 360 follow the existing antenna heading convention`() {
val prefs = preferences()
assertTrue(applyExplicitQtfHeading(prefs, 0.0))
assertEquals(0.0, prefs.actualQTF.get(), 0.0001)
assertTrue(applyExplicitQtfHeading(prefs, 360.0))
assertEquals(360.0, prefs.actualQTF.get(), 0.0001)
}
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
}
@@ -0,0 +1,66 @@
package kst4contest.view.compose
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class MapReachabilityStateTest {
@Test
fun `latest station and band request rejects stale results`() {
val callbacks = mutableListOf<(PathAnalysisResult) -> Unit>()
val state = MapReachabilityState("JO51AA") { _, _, callback -> callbacks += callback }
val first = member("DL1ABC", 2, "JN49AA")
val second = member("DL2XYZ", 3, "JO60AA")
assertTrue(state.calculate(first, Band.B_144))
assertEquals("Loading", state.result?.analysisMode())
assertTrue(state.calculate(second, Band.B_432))
callbacks[1](result("DL2XYZ", 432.0))
callbacks[0](result("DL1ABC", 144.0))
assertEquals("DL2XYZ", state.result?.toCallsignRaw())
assertEquals(432.0, state.result?.analysisFrequencyMHz())
assertFalse(state.running)
}
@Test
fun `selecting another station invalidates an outstanding explicit result`() {
lateinit var callback: (PathAnalysisResult) -> Unit
val state = MapReachabilityState("JO51AA") { _, _, result -> callback = result }
val first = member("DL1ABC", 2, "JN49AA")
state.calculate(first, Band.B_144)
state.selectionChanged(StationIdentity("DL2XYZ", 2))
callback(result("DL1ABC", 144.0))
assertNull(state.result)
assertFalse(state.running)
}
@Test
fun `missing station or band does not start a request`() {
val state = MapReachabilityState("JO51AA") { _, _, _ -> error("must not run") }
assertFalse(state.calculate(null, Band.B_144))
assertFalse(state.calculate(member("DL1ABC", 2, "JN49AA"), null))
}
private fun member(callSign: String, category: Int, locator: String) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(category)
qra = locator
}
private fun result(callSign: String, frequency: Double) = PathAnalysisResult.noProfile(
"Calculated", "JO51AA", "JN49AA", callSign,
100.0, 180.0, 10.0, 10.0, frequency, "done",
)
}
@@ -0,0 +1,65 @@
package kst4contest.view.compose
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* The menu row's own popup placement, as pure arithmetic.
*
* Material3's DropdownMenuPositionProvider refuses any y closer than 48 dp to the window's
* top edge, which pushed every menu of the bar down to that margin. This provider must not:
* a menu-bar menu starts flush under its anchor, whatever the distance to the window edge.
*/
class MenuRowPopupPositionProviderTest {
private val window = IntSize(800, 600)
private val menu = IntSize(200, 150)
private fun positionOf(
anchor: IntRect,
window: IntSize = this.window,
direction: LayoutDirection = LayoutDirection.Ltr,
menu: IntSize = this.menu,
) = MenuRowPopupPositionProvider.calculatePosition(
anchorBounds = anchor,
windowSize = window,
layoutDirection = direction,
popupContentSize = menu,
)
/** The regression itself: an anchor at the very top gets its menu directly below it. */
@Test
fun opensFlushUnderAnAnchorAtTheTopOfTheWindow() {
val position = positionOf(IntRect(0, 0, 40, 30))
assertEquals(IntOffset(0, 30), position)
}
@Test
fun staysInsideTheWindowWhenTheAnchorIsNearTheRightEdge() {
val position = positionOf(IntRect(760, 0, 800, 30))
assertEquals(IntOffset(600, 30), position)
}
@Test
fun opensAboveTheAnchorWhenThereIsNoRoomBelow() {
val position = positionOf(IntRect(0, 560, 40, 590))
assertEquals(IntOffset(0, 410), position)
}
/** No room on either side: pinned to the window's bottom edge rather than clipped. */
@Test
fun staysInsideAWindowTooSmallForTheMenu() {
val position = positionOf(IntRect(0, 40, 40, 70), window = IntSize(800, 100), menu = IntSize(200, 90))
assertEquals(IntOffset(0, 10), position)
}
@Test
fun alignsTheRightEdgesInRightToLeftLayouts() {
val position = positionOf(IntRect(600, 0, 640, 30), direction = LayoutDirection.Rtl)
assertEquals(IntOffset(440, 30), position)
}
}
@@ -0,0 +1,118 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
@OptIn(ExperimentalTestApi::class)
class MessageTabsScreenTest {
@Test
fun `tab semantics distinguish the active tab and follow selection`() = runComposeUiTest {
setContent {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
MessageTabs(
publicMessages = messageTable(),
clusterMessages = clusterTable(),
qsoOfTheOther = messageTable(),
)
}
}
onNodeWithText("Public messages").assertIsSelected()
onNodeWithText("DXCluster messages").assertIsNotSelected().performClick()
waitForIdle()
onNodeWithText("Public messages").assertIsNotSelected()
onNodeWithText("DXCluster messages").assertIsSelected()
}
@Test
fun `a public message row reports its sender selection source`() = runComposeUiTest {
val public = messageTable()
val message = messageFrom("DL1PUBLIC")
public.replaceRows(listOf(message))
val clicks = mutableListOf<Pair<ChatMessage, StationSelectionSource>>()
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
MessageTabs(
publicMessages = public,
clusterMessages = clusterTable(),
qsoOfTheOther = messageTable(),
onStationMessageClicked = { clicked, source -> clicks += clicked to source },
)
}
}
waitForIdle()
onNodeWithText("DL1PUBLIC").performClick()
waitForIdle()
assertSame(message, clicks.single().first)
assertEquals(StationSelectionSource.PUBLIC_MESSAGE, clicks.single().second)
}
@Test
fun `a qso of the other row reports its sender selection source`() = runComposeUiTest {
val qso = messageTable()
val message = messageFrom("DL2QSO")
qso.replaceRows(listOf(message))
val clicks = mutableListOf<Pair<ChatMessage, StationSelectionSource>>()
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
MessageTabs(
publicMessages = messageTable(),
clusterMessages = clusterTable(),
qsoOfTheOther = qso,
onStationMessageClicked = { clicked, source -> clicks += clicked to source },
)
}
}
waitForIdle()
onNodeWithText("QSO of the other").performClick()
waitForIdle()
onNodeWithText("DL2QSO").performClick()
waitForIdle()
assertSame(message, clicks.single().first)
assertEquals(StationSelectionSource.QSO_OF_THE_OTHER, clicks.single().second)
}
private fun messageTable() = DataTableState(
columns = listOf(
DataColumn(
id = "sender",
title = "Sender",
value = { message: ChatMessage -> message.sender?.callSign.orEmpty() },
),
),
rowKey = RowKeys.byReference<ChatMessage>(),
)
private fun clusterTable() = DataTableState(
columns = emptyList<DataColumn<ClusterMessage>>(),
rowKey = RowKeys.byReference<ClusterMessage>(),
)
private fun messageFrom(callSign: String): ChatMessage = ChatMessage().apply {
sender = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(2)
}
receiver = ChatMember().apply { this.callSign = "ALL" }
chatCategory = ChatCategory(2)
messageText = "test"
}
}
@@ -0,0 +1,184 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The cluster and QSO monitor's body, drawn with live tables (#107).
*
* This was the first window whose tables fill while the operator watches, and the defects
* that the Compose port shipped here were all about the coupling between a live list and
* what gets drawn, not about the table itself (`cc8c9020`, Draw the whole interface in
* Compose; in `main` as `2a5c89b9`):
*
* - The monitor sat empty beside a filling JavaFX tab and only redrew when an unrelated
* click forced it: `DataTableState.visibleRows` returned its cache before reading the row
* state, so a recomposition that hit the cache left the table subscribed to nothing.
* - Rows without identity were keyed in a way that could hand out the same key twice, which
* a `LazyColumn` refuses; two messages that read the same are still two messages.
*
* The tables here are built exactly as `Kst4ContestApplication` builds them: the monitor
* columns, keyed by reference.
*
* Headless: [MonitorContent] is drawn on its own, no window is opened.
*/
@OptIn(ExperimentalTestApi::class)
class MonitorWindowScreenTest {
private fun clusterTable() =
DataTableState(MonitorColumns.dxCluster(), RowKeys.byReference<ClusterMessage>(), "dx-cluster-monitor")
private fun qsoTable() =
DataTableState(MonitorColumns.qsoOfTheOther(), RowKeys.byReference<ChatMessage>(), "qso-other-monitor")
private fun member(call: String) = ChatMember().apply { callSign = call }
private fun spot(from: String, to: String, text: String) = ClusterMessage().apply {
sender = member(from)
receiver = member(to)
messageInhibited = text
}
private fun qso(from: String, to: String, text: String) = ChatMessage().apply {
sender = member(from)
receiver = member(to)
messageText = text
}
private fun ComposeUiTest.show(
cluster: DataTableState<ClusterMessage>,
qsos: DataTableState<ChatMessage>,
splitter: SplitterState = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) {},
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Box(Modifier.size(600.dp, 400.dp)) {
MonitorContent(cluster, qsos, splitter)
}
}
}
waitForIdle()
}
@Test
fun bothTablesAreDrawnUnderTheirHeadings() = runComposeUiTest {
show(clusterTable(), qsoTable())
onNodeWithText("DX cluster").assertExists()
onNodeWithText("QSO of the other").assertExists()
onNodeWithText("Call tx").assertExists()
onNodeWithText("Call TX").assertExists()
}
/**
* The two tables used to get half the height each, fixed, and the stored position the
* JavaFX SplitPane had written was read by nobody (#144). The QSO heading is the top of
* the lower pane, so where it sits says where the divider is.
*/
@Test
fun theStoredDividerPositionSplitsTheWindow() = runComposeUiTest {
show(clusterTable(), qsoTable(), SplitterState(paneCount = 2, stored = doubleArrayOf(0.8)) {})
val window = onRoot().getBoundsInRoot()
val qsoHeadingTop = onNodeWithText("QSO of the other").getBoundsInRoot().top
val share = (qsoHeadingTop - window.top) / (window.bottom - window.top)
assertTrue(share in 0.75f..0.85f, "QSO pane starts at $share of the height, expected about 0.8")
}
@Test
fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest {
val cluster = clusterTable()
val qsos = qsoTable()
show(cluster, qsos)
runOnIdle {
cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "144.300 tnx qso")))
qsos.replaceRows(listOf(qso("DL2CCC", "OK2DDD", "pse sked 432")))
}
waitForIdle()
onNodeWithText("144.300 tnx qso").assertExists()
onNodeWithText("pse sked 432").assertExists()
}
/**
* The empty-monitor fault in its exact shape: the table recomposes once for a reason that
* is not its rows -- here a column moved by the operator -- and the rows that arrive after
* that must still come through.
*/
@Test
fun rowsStillArriveAfterARedrawThatWasNotAboutTheRows() = runComposeUiTest {
val cluster = clusterTable()
show(cluster, qsoTable())
runOnIdle { cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "first spot"))) }
waitForIdle()
onNodeWithText("first spot").assertExists()
// A redraw with the order cache warm.
runOnIdle { cluster.moveColumn("message", 0) }
waitForIdle()
runOnIdle {
cluster.replaceRows(
listOf(spot("DL3EEE", "OK3FFF", "second spot")) + cluster.rows,
)
}
waitForIdle()
onNodeWithText("second spot").assertExists()
onNodeWithText("first spot").assertExists()
}
/** Two spots that read alike are two rows; neither is dropped and the list does not throw. */
@Test
fun twoIdenticalLookingMessagesAreTwoRows() = runComposeUiTest {
val cluster = clusterTable()
show(cluster, qsoTable())
runOnIdle {
cluster.replaceRows(
listOf(
spot("DL1AAA", "OK1BBB", "repeated spot"),
spot("DL1AAA", "OK1BBB", "repeated spot"),
),
)
}
waitForIdle()
assertEquals(2, onAllNodesWithText("repeated spot").fetchSemanticsNodes().size)
}
/** A half-filled message off the wire draws as empty cells, not as a crash. */
@Test
fun aMessageWithoutSenderOrReceiverStillDraws() = runComposeUiTest {
val cluster = clusterTable()
val qsos = qsoTable()
show(cluster, qsos)
runOnIdle {
cluster.replaceRows(listOf(ClusterMessage().apply { messageInhibited = "orphan spot" }))
qsos.replaceRows(listOf(ChatMessage().apply { messageText = "orphan qso" }))
}
waitForIdle()
onNodeWithText("orphan spot").assertExists()
onNodeWithText("orphan qso").assertExists()
}
}
@@ -0,0 +1,65 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.awt.Point
/**
* Which reported positions of an opened window count as the operator's move (issue #59).
*/
class MoveTrackerTest {
private val opened = Point(560, 220)
private fun offset(dx: Int, dy: Int = 0) = Point(opened.x + dx, opened.y + dy)
@Test
fun `a nudge around the placed position is not remembered`() {
val tracker = MoveTracker(opened)
assertNull(tracker.observe(offset(0, 30)))
assertNull(tracker.observe(offset(-MOVE_TOLERANCE_PX)))
}
@Test
fun `a move away from the placed position is remembered`() {
val tracker = MoveTracker(opened)
assertEquals(offset(100), tracker.observe(offset(100)))
}
/** The review case: open at O, drag to O+100, drag back to O+10. */
@Test
fun `a move back to near the opening spot replaces the far one`() {
val tracker = MoveTracker(opened)
assertEquals(offset(100), tracker.observe(offset(100)))
assertEquals(offset(10), tracker.observe(offset(10)))
assertEquals(opened, tracker.observe(opened))
}
@Test
fun `an unplaced window takes the platform's placement jump as its anchor`() {
val tracker = MoveTracker(null)
/* Provisional position, then the window manager's real placement on another screen. */
assertNull(tracker.observe(Point(0, 0)))
assertNull(tracker.observe(Point(2400, 300)))
/* A decoration nudge settles it. */
assertNull(tracker.observe(Point(2400, 330)))
/* Now the operator drags. */
assertEquals(Point(2600, 330), tracker.observe(Point(2600, 330)))
}
@Test
fun `an unplaced window settled by the start of a drag remembers the rest of it`() {
val tracker = MoveTracker(null)
assertNull(tracker.observe(Point(500, 300)))
assertNull(tracker.observe(Point(510, 300)))
assertNull(tracker.observe(Point(540, 300)))
assertEquals(Point(700, 300), tracker.observe(Point(700, 300)))
assertEquals(Point(520, 300), tracker.observe(Point(520, 300)))
}
}
@@ -0,0 +1,126 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@OptIn(ExperimentalTestApi::class)
class NewJoinedBarScreenTest {
@Test
fun fiveNewestStationsAreDirectAndAdditionalStationsUseTheMoreDropdown() = runComposeUiTest {
val selection = StationSelectionState()
val members = (1..7).map { member("DL${it}ABC") }
val newJoined = NewJoinedState(clock = { 10_000L }) { member ->
selection.select(member, StationSelectionSource.NEW_JOINED)
}.apply {
updateActiveMembers(members)
updateEvents(
members.mapIndexed { index, member ->
RecentChatJoin(member.callSign, 2, 9_000L + index)
},
)
}
val priority = TopPriorityState(topStations = { emptyList() }, onSelect = {})
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
TopPriorityBar(
state = priority,
newJoined = newJoined,
onShowMore = {},
)
}
}
onNodeWithText("New joined:").assertIsDisplayed()
listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC").forEach {
onNodeWithText(it).assertIsDisplayed()
}
onNodeWithText("DL2ABC").assertDoesNotExist()
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
onNodeWithText("DL2ABC").assertIsDisplayed().performClick()
assertSame(members[1], selection.selected)
assertEquals(StationSelectionSource.NEW_JOINED, selection.lastSource)
assertTrue(selection.scrollRequestRevision > 0L)
}
@Test
fun emptyNewJoinedStateUsesNoSpaceBesidePriority() = runComposeUiTest {
val newJoined = NewJoinedState(onSelect = {})
val priorityMember = member("OK1FPR")
val priority = TopPriorityState(
topStations = { listOf(priorityMember to 767.0) },
onSelect = {},
).apply { refresh() }
setContent {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
TopPriorityBar(
state = priority,
newJoined = newJoined,
onShowMore = {},
)
}
}
onNodeWithText("New joined:").assertDoesNotExist()
onNodeWithText("Priority:").assertIsDisplayed()
onNodeWithText("1 OK1FPR 767").assertIsDisplayed()
}
@Test
fun narrowBarMovesAdditionalDirectEntriesIntoTheSameDropdownWithoutOverflow() = runComposeUiTest {
val members = (1..7).map { member("DL${it}ABC") }
val newJoined = NewJoinedState(clock = { 10_000L }, onSelect = {}).apply {
updateActiveMembers(members)
updateEvents(members.mapIndexed { index, member ->
RecentChatJoin(member.callSign, 2, 9_000L + index)
})
}
setContent {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
Box(Modifier.width(300.dp)) {
TopPriorityBar(
state = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
newJoined = newJoined,
onShowMore = {},
)
}
}
}
onNodeWithText("DL7ABC").assertIsDisplayed()
onNodeWithText("DL6ABC").assertDoesNotExist()
val limit = with(density) { 300.dp.toPx() }
listOf(NEW_JOINED_GROUP_TAG, PRIORITY_GROUP_TAG).forEach { tag ->
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
assertTrue(bounds.right <= limit + 0.5f, "$tag crosses the narrow bar")
}
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
onNodeWithText("DL6ABC").assertIsDisplayed()
}
private fun member(callSign: String) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(2)
}
}
@@ -0,0 +1,115 @@
package kst4contest.view.compose
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicLong
class NewJoinedStateTest {
private val now = AtomicLong(1_000_000L)
private var selected: ChatMember? = null
private val state = NewJoinedState(clock = now::get, onSelect = { selected = it })
@Test
fun `an initial active roster without live events produces no entries`() {
state.updateActiveMembers(listOf(member("HB9SJV", 2)))
assertTrue(state.entries.isEmpty())
}
@Test
fun `a live join is shown once and duplicate events do not renew it`() {
val station = member("HB9SJV", 2)
state.updateActiveMembers(listOf(station))
state.updateEvents(
listOf(
event("HB9SJV", 2, now.get() - 2_000L),
event("HB9SJV", 2, now.get() - 1_000L),
),
)
assertEquals(listOf("HB9SJV"), state.entries.map { it.label })
assertEquals(now.get() - 2_000L, state.entries.single().joinedAtEpochMillis)
}
@Test
fun `the same full callsign in two categories stays distinguishable`() {
val main = member("DL1KDA", 2)
val microwave = member("DL1KDA", 3)
state.updateActiveMembers(listOf(main, microwave))
state.updateEvents(
listOf(
event("DL1KDA", 3, now.get()),
event("DL1KDA", 2, now.get() - 1L),
),
)
assertEquals(listOf(3, 2), state.entries.map { it.identity.categoryNumber })
state.entries.first().select()
assertSame(microwave, selected)
}
@Test
fun `an entry expires exactly ten minutes after the join`() {
val station = member("OE5SOS", 2)
state.updateActiveMembers(listOf(station))
state.updateEvents(listOf(event("OE5SOS", 2, now.get())))
now.addAndGet(NewJoinedState.VALIDITY_MILLIS - 1L)
state.refresh()
assertTrue(state.isRecent(station))
now.incrementAndGet()
state.refresh()
assertFalse(state.isRecent(station))
assertTrue(state.entries.isEmpty())
}
@Test
fun `only five newest entries are direct and the rest remain in the overflow`() {
val members = (1..7).map { member("DL${it}ABC", 2) }
state.updateActiveMembers(members)
state.updateEvents(
members.mapIndexed { index, member ->
event(member.callSign, 2, now.get() - (members.size - index))
},
)
assertEquals(listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC"),
state.visibleEntries.map { it.label })
assertEquals(listOf("DL2ABC", "DL1ABC"), state.overflowEntries.map { it.label })
}
@Test
fun `a station leaving disappears and a real rejoin gets the new event`() {
val first = member("PA2M", 2)
state.updateActiveMembers(listOf(first))
state.updateEvents(listOf(event("PA2M", 2, now.get())))
state.updateActiveMembers(emptyList())
assertTrue(state.entries.isEmpty())
val rejoined = member("PA2M", 2)
now.addAndGet(5_000L)
state.updateActiveMembers(listOf(rejoined))
state.updateEvents(listOf(event("PA2M", 2, now.get())))
state.entries.single().select()
assertSame(rejoined, selected)
assertEquals(now.get(), state.entries.single().joinedAtEpochMillis)
}
private fun member(callSign: String, categoryNumber: Int) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(categoryNumber)
}
private fun event(callSign: String, categoryNumber: Int, joinedAt: Long) =
RecentChatJoin(callSign, categoryNumber, joinedAt)
}
@@ -5,6 +5,8 @@ import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** /**
* ChatPreferences hands out MutableValue, which notifies its own listeners and nothing * ChatPreferences hands out MutableValue, which notifies its own listeners and nothing
@@ -15,11 +17,11 @@ import org.junit.jupiter.api.Test
* The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is * The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
* the same idea, ending in Compose state instead of a TextField. * the same idea, ending in Compose state instead of a TextField.
*/ */
class ObservedValueTest { class ObservedValueTest(@TempDir private val tempDir: Path) {
@Test @Test
fun `the mirror starts at the stored value`() { fun `the mirror starts at the stored value`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370") prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat()) val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -30,7 +32,7 @@ class ObservedValueTest {
/** The rotator moving must reach the screen without anything asking it to. */ /** The rotator moving must reach the screen without anything asking it to. */
@Test @Test
fun `a change to the underlying value reaches the mirror by itself`() { fun `a change to the underlying value reaches the mirror by itself`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getActualQTF().set(90.0) prefs.getActualQTF().set(90.0)
val observed = ObservedValue(prefs.getActualQTF()) val observed = ObservedValue(prefs.getActualQTF())
@@ -41,7 +43,7 @@ class ObservedValueTest {
@Test @Test
fun `a null value is carried through rather than turned into a default`() { fun `a null value is carried through rather than turned into a default`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val observed = ObservedValue(prefs.getMYQRGSecondCat()) val observed = ObservedValue(prefs.getMYQRGSecondCat())
prefs.getMYQRGSecondCat().set(null) prefs.getMYQRGSecondCat().set(null)
@@ -55,7 +57,7 @@ class ObservedValueTest {
*/ */
@Test @Test
fun `drawing from the mirror subscribes to it`() { fun `drawing from the mirror subscribes to it`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370") prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat()) val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -73,7 +75,7 @@ class ObservedValueTest {
*/ */
@Test @Test
fun `releasing the mirror stops it following`() { fun `releasing the mirror stops it following`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370") prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat()) val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -85,7 +87,7 @@ class ObservedValueTest {
@Test @Test
fun `releasing twice is harmless`() { fun `releasing twice is harmless`() {
val prefs = ChatPreferences() val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val observed = ObservedValue(prefs.getMYQRGFirstCat()) val observed = ObservedValue(prefs.getMYQRGFirstCat())
observed.release() observed.release()
observed.release() observed.release()
@@ -0,0 +1,45 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isPopup
/*
* Waiting for what a click opens on top of the window (#107).
*
* In Compose Multiplatform 1.8.2 a Material3 AlertDialog goes through the skiko Dialog, and a
* DropdownMenu through the skiko Popup; both compose their content into a ComposeSceneLayer of
* the same scene (Dialog_skikoKt calls rememberComposeSceneLayer, checked in the bytecode of
* ui-desktop 1.8.2). The layer's content is set from the composition that opened it, so it can
* take one more frame than the click itself. These helpers wait for the node instead of
* assuming that frame has already happened, and they look only inside the overlay, so a text
* that also stands in the window below can never be picked by accident.
*/
private const val OVERLAY_TIMEOUT_MILLIS = 5_000L
/** Waits until a node matching [matcher] is in the tree, then returns it. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.awaitNode(matcher: SemanticsMatcher): SemanticsNodeInteraction {
waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() }
return onNode(matcher)
}
/** Waits until no node matches [matcher] any more. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.awaitGone(matcher: SemanticsMatcher) {
waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isEmpty() }
}
/** A text inside an open dialog. */
internal fun inDialog(text: String): SemanticsMatcher =
hasText(text, substring = false) and hasAnyAncestor(isDialog())
/** A text inside an open popup, which is what a dropdown menu is. */
internal fun inPopup(text: String): SemanticsMatcher =
hasText(text, substring = false) and hasAnyAncestor(isPopup())
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff. Show more