mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-20 14:23:08 +02:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c43934c7ba | ||
|
|
d38b385166 | ||
|
|
a28abe1591 | ||
|
|
a0039cd52e | ||
|
|
8dec00870b | ||
|
|
7ee02fb029 | ||
|
|
e0c1c6b6d4 | ||
|
|
797b01c748 | ||
|
|
5b7c774f5f | ||
|
|
feb4b4e2c9 | ||
|
|
bb0e9eb327 | ||
|
|
92c2576a81 | ||
|
|
ee4f1fe02e | ||
|
|
f17ae0905f | ||
|
|
c64e9a9014 | ||
|
|
88d10259d4 | ||
|
|
c9b838e946 | ||
|
|
a9d8c013ef | ||
|
|
81b16ad4c5 | ||
|
|
2898c530d9 | ||
|
|
e1e6fe7297 | ||
|
|
4b10d96091 | ||
|
|
2db71ee864 | ||
|
|
4679c708bc | ||
|
|
6b0c80f71e | ||
|
|
fb38c858bf | ||
|
|
0ee29dc585 | ||
|
|
71b2d3931c | ||
|
|
dd9f87a767
|
||
|
|
7b44eb0c2e | ||
|
|
91863fee1f | ||
|
|
f2b0fdd5b4 | ||
|
|
89d37e62e1 | ||
|
|
2eb1ba22cf | ||
|
|
5491cbcf8b | ||
|
|
4a90ae7d30
|
||
|
|
78de8b0a9c
|
||
|
|
ab28216280
|
||
|
|
456957808d
|
||
|
|
e67e2db2aa
|
||
|
|
93c1423dcd
|
||
|
|
eddcbc82e8
|
||
|
|
ab42c6ef35
|
||
|
|
0039ec98e3
|
||
|
|
294a978c3a
|
||
|
|
19b9bf0fca
|
||
|
|
5b9fe0d3c1
|
||
|
|
2a5c89b943 | ||
|
|
3e8457eede | ||
|
|
df70cb1943 | ||
|
|
323c249bf7 | ||
|
|
f9760bcbaa | ||
|
|
a8f12876ec | ||
|
|
89422d18ad | ||
|
|
a3f3bf8872 | ||
|
|
393c9c49ee | ||
|
|
f0508f51a2 | ||
|
|
923d76beb0 | ||
|
|
821535a24f | ||
|
|
914a06cb7f | ||
|
|
b23884bde1 | ||
|
|
4ad1cf71dd
|
||
|
|
ad212e3e71 | ||
|
|
08d65a0e23 | ||
|
|
edf71b4105
|
||
|
|
885bf83c2f
|
||
|
|
634b88238d
|
||
|
|
830e4020a2 | ||
|
|
7ee50267ec | ||
|
|
113e843111 | ||
|
|
79161d2afa | ||
|
|
3ed1cad5ab | ||
|
|
75fe45b50b | ||
|
|
53555cbe69 | ||
|
|
595fb84362 | ||
|
|
9eb0550106 | ||
|
|
51aa04bfb5 | ||
|
|
6b0d699a98 | ||
|
|
6b29ffe3ba | ||
|
|
08b109106e | ||
|
|
21b2d9970a | ||
|
|
94fc13e3c3 | ||
|
|
d475c6b2c4 | ||
|
|
211be6081d | ||
|
|
b550d79b4b | ||
|
|
352cdcceb2 | ||
|
|
905ce5766e | ||
|
|
2803f7d8e3 | ||
|
|
4a8e08a331 | ||
|
|
a24db90ad2 | ||
|
|
d91b119112 | ||
|
|
345c4adfc3 | ||
|
|
0e8cd06e43 | ||
|
|
7ff7248e7d | ||
|
|
3193e4ac73 | ||
|
|
3ec6cab46a | ||
|
|
b280f1b0db | ||
|
|
7fe33613e9 | ||
|
|
9378bf2afd | ||
|
|
047891e109 | ||
|
|
4f88101ab7 | ||
|
|
5955ba4ecf | ||
|
|
a9eba266ee | ||
|
|
4601199587 | ||
|
|
8c6ce6b402 | ||
|
|
75ab24da25 | ||
|
|
8aadbb9ee0
|
||
|
|
3b344482ba
|
||
|
|
86ad2f7a43
|
||
|
|
29ce6b0a4d
|
||
|
|
4d8b8aa12e
|
No files matched your search
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: kst4contest-change
|
||||||
|
description: Analyze or implement KST4Contest Java/JavaFX changes, bug fixes, refactorings, protocol handling, contest workflow behaviour, callsign/band logic, AirScout/logging/rotor/DXCluster integrations, threading and state management. Use current code as source of truth and follow the mandatory concept-and-question gate before edits.
|
||||||
|
---
|
||||||
|
|
||||||
|
# KST4Contest change workflow
|
||||||
|
|
||||||
|
Read the relevant reference files before proposing a concept.
|
||||||
|
|
||||||
|
## Phase 1: read-only analysis
|
||||||
|
|
||||||
|
- Inspect the current code and tests.
|
||||||
|
- Identify the current data flow and thread ownership.
|
||||||
|
- Identify user-visible and protocol-visible behaviour.
|
||||||
|
- Check relevant `docs/PROJECT_CONTEXT.md` sections when they exist.
|
||||||
|
- Check whether the task overlaps a known invariant in the references.
|
||||||
|
- Do not modify files.
|
||||||
|
|
||||||
|
## Phase 2: report understanding in German
|
||||||
|
|
||||||
|
Explain:
|
||||||
|
|
||||||
|
- what Marc wants changed;
|
||||||
|
- what must remain unchanged;
|
||||||
|
- which components appear affected;
|
||||||
|
- what evidence in the current code supports that understanding;
|
||||||
|
- any conflict between current code and historical project context.
|
||||||
|
|
||||||
|
Never resolve a conflict by guessing.
|
||||||
|
|
||||||
|
## Phase 3: questions and final concept in German
|
||||||
|
|
||||||
|
Before finalizing the concept:
|
||||||
|
|
||||||
|
- identify all material implementation choices;
|
||||||
|
- ask Marc questions that are not already answered by code, project instructions or prior confirmed decisions;
|
||||||
|
- wait for answers when needed.
|
||||||
|
|
||||||
|
Then present:
|
||||||
|
|
||||||
|
- intended data/control flow;
|
||||||
|
- exact behavioural changes;
|
||||||
|
- compatibility impact;
|
||||||
|
- thread/UI impact;
|
||||||
|
- persistence impact;
|
||||||
|
- test strategy;
|
||||||
|
- likely documentation impact;
|
||||||
|
- likely durable project-context impact;
|
||||||
|
- related-project impact when relevant.
|
||||||
|
|
||||||
|
Ask for explicit concept approval and wait before editing.
|
||||||
|
|
||||||
|
## Phase 4: implementation
|
||||||
|
|
||||||
|
After approval:
|
||||||
|
|
||||||
|
- implement the smallest coherent change;
|
||||||
|
- preserve unrelated behaviour;
|
||||||
|
- keep comments/Javadoc in English;
|
||||||
|
- add/update focused tests;
|
||||||
|
- keep external protocol parsing defensive;
|
||||||
|
- avoid hidden defaulting for unknown values.
|
||||||
|
|
||||||
|
## Phase 5: verification and documentation impact
|
||||||
|
|
||||||
|
Run focused checks, then appropriate broader checks.
|
||||||
|
|
||||||
|
Because KST4Contest build configuration may ignore failures/findings, inspect summaries and reports rather than only command exit status.
|
||||||
|
|
||||||
|
Then use `$software-project-context`:
|
||||||
|
|
||||||
|
- do a low-cost documentation-impact classification;
|
||||||
|
- inspect only likely affected manual/README/website sections;
|
||||||
|
- update targeted documentation when clearly required by the approved implementation;
|
||||||
|
- update `docs/PROJECT_CONTEXT.md` for significant durable technical decisions/state changes;
|
||||||
|
- do not perform a full manual audit unless there is a specific trigger.
|
||||||
|
|
||||||
|
## Phase 6: report
|
||||||
|
|
||||||
|
Report in German:
|
||||||
|
|
||||||
|
- files changed;
|
||||||
|
- implementation summary;
|
||||||
|
- tests/checks;
|
||||||
|
- warnings/findings;
|
||||||
|
- documentation-impact result;
|
||||||
|
- documentation/context updates or why none were required;
|
||||||
|
- related-project impact when relevant;
|
||||||
|
- unresolved issues;
|
||||||
|
- no Git publication action unless explicitly requested.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "KST4Contest Change"
|
||||||
|
short_description: "Plan and implement KST4Contest changes safely"
|
||||||
|
default_prompt: "Analyze the requested KST4Contest change, explain your understanding and concept in German, ask implementation questions, and wait for approval before editing."
|
||||||
|
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Architecture context
|
||||||
|
|
||||||
|
## Current package shape
|
||||||
|
|
||||||
|
The repository currently contains major packages under `src/main/java/kst4contest/` including:
|
||||||
|
|
||||||
|
- `controller`
|
||||||
|
- `locatorUtils`
|
||||||
|
- `logic`
|
||||||
|
- `model`
|
||||||
|
- `service`
|
||||||
|
- `test`
|
||||||
|
- `utils`
|
||||||
|
- `view`
|
||||||
|
|
||||||
|
Do not treat package names alone as proof of clean MVC boundaries. Inspect actual dependencies.
|
||||||
|
|
||||||
|
## Preferred message/member data flow
|
||||||
|
|
||||||
|
The established target architecture for active chat members is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ON4KST / network
|
||||||
|
|
|
||||||
|
v
|
||||||
|
MessageBusManagementThread
|
||||||
|
|
|
||||||
|
v
|
||||||
|
ChatController
|
||||||
|
|
|
||||||
|
v
|
||||||
|
thread-safe active-member domain state
|
||||||
|
(ConcurrentMap; identity includes callsign + category)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
JavaFX ObservableList UI mirror
|
||||||
|
|
|
||||||
|
v
|
||||||
|
FilteredList / SortedList / TableView / selection
|
||||||
|
```
|
||||||
|
|
||||||
|
Key rule:
|
||||||
|
|
||||||
|
`ObservableList` is a JavaFX UI projection, not the canonical store for worker-thread logic.
|
||||||
|
|
||||||
|
`MessageBusManagementThread` must not directly read or modify the UI list.
|
||||||
|
|
||||||
|
## JavaFX boundary
|
||||||
|
|
||||||
|
UI-visible mutations belong on the JavaFX Application Thread.
|
||||||
|
|
||||||
|
Prefer controller-owned helpers such as an existing `runOnFxThread` abstraction when available; otherwise use `Platform.runLater` consistently.
|
||||||
|
|
||||||
|
Do not move business/data access into the FX thread merely to silence a threading problem.
|
||||||
|
|
||||||
|
## Parser/service separation
|
||||||
|
|
||||||
|
For protocol receivers, the preferred direction is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Receiver (I/O only)
|
||||||
|
-> Parser (wire data -> DTO)
|
||||||
|
-> Service (domain/persistence logic)
|
||||||
|
-> Controller (UI coordination)
|
||||||
|
-> Observable UI model
|
||||||
|
-> View
|
||||||
|
```
|
||||||
|
|
||||||
|
A previous concrete example for UCXLog was:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UcxUdpReceiver
|
||||||
|
-> UcxPacketParser
|
||||||
|
-> DTO
|
||||||
|
-> UcxLogService
|
||||||
|
-> ChatController
|
||||||
|
-> UI projection
|
||||||
|
```
|
||||||
|
|
||||||
|
This is architectural guidance, not permission for a broad refactor. Apply only when it is in scope and approved.
|
||||||
|
|
||||||
|
## DTO preference
|
||||||
|
|
||||||
|
Prefer explicit DTO classes over records when introducing protocol/transport data structures in this project, unless the approved concept intentionally changes that convention.
|
||||||
|
|
||||||
|
## Null safety
|
||||||
|
|
||||||
|
Chat members can be incomplete, especially:
|
||||||
|
|
||||||
|
- fallback members;
|
||||||
|
- historic message senders;
|
||||||
|
- server-derived partial members.
|
||||||
|
|
||||||
|
Values such as QRB and QTF can be absent.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- absence stays absence;
|
||||||
|
- do not map `null` to numeric zero;
|
||||||
|
- UI must render unavailable state safely;
|
||||||
|
- sorting/filtering/calculation code must tolerate missing values;
|
||||||
|
- unexpected missing values must not terminate worker or UI threads.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Automated messaging, beacons and skeds
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
Automated replies/beacons must be conservative because they interact with the live ON4KST service.
|
||||||
|
|
||||||
|
## Established safety behaviour
|
||||||
|
|
||||||
|
Historical confirmed rules include:
|
||||||
|
|
||||||
|
- beacon/autoanswer scheduling shares controlled timing rather than spawning uncontrolled independent timers;
|
||||||
|
- minimum interval has been tightened to avoid spam;
|
||||||
|
- automated text length is bounded;
|
||||||
|
- invalid or incomplete replies are rejected before transmission;
|
||||||
|
- a cooldown is not consumed unless a complete valid reply enters the TX queue;
|
||||||
|
- QRG/frequency requests take precedence where the current implementation defines that;
|
||||||
|
- automated-message markers are ignored to prevent response loops.
|
||||||
|
|
||||||
|
Recent logic used a two-minute cooldown keyed by complete callsign plus chat category and ignored the project's own automated-message marker.
|
||||||
|
|
||||||
|
Treat exact marker strings and timing constants as current-code facts to verify, not values to recreate from memory.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Station monitoring is intentionally base-call-wide:
|
||||||
|
|
||||||
|
Entering a variant such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DN9APW-2
|
||||||
|
DN9APW-70
|
||||||
|
```
|
||||||
|
|
||||||
|
monitors:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DN9APW
|
||||||
|
```
|
||||||
|
|
||||||
|
This reduces manual configuration for sked monitoring.
|
||||||
|
|
||||||
|
Keep this separate from chat-member identity, which may require full suffix + category.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Domain, callsigns and bands
|
||||||
|
|
||||||
|
## Chat-member identity
|
||||||
|
|
||||||
|
Full callsign variants can be distinct chat identities.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DN9APW
|
||||||
|
DN9APW-2
|
||||||
|
DN9APW-70
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not globally strip suffixes when identifying chat members.
|
||||||
|
|
||||||
|
Category is also part of identity. A practical key is conceptually equivalent to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FULL_CALLSIGN|CATEGORY
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not allow messages from one category to attach to a same-looking member in another category.
|
||||||
|
|
||||||
|
## Base-call operations
|
||||||
|
|
||||||
|
Some features intentionally operate on the base callsign.
|
||||||
|
|
||||||
|
Confirmed examples:
|
||||||
|
|
||||||
|
### Worked state
|
||||||
|
|
||||||
|
Worked status is shared across suffix variants of the same base call.
|
||||||
|
|
||||||
|
If the base station has been worked on the relevant basis, variants such as `CALL-2`, `CALL-70`, `CALL-144`, `CALL-432` should not become independent worked identities merely because of the suffix.
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Monitoring a station entered as `DN9APW-2` or `DN9APW-70` should monitor the base call `DN9APW`.
|
||||||
|
|
||||||
|
This is intentional: a user monitoring another station's skeds should not have to create one monitor entry per SSID.
|
||||||
|
|
||||||
|
Do not extend base-call matching to unrelated features without approval.
|
||||||
|
|
||||||
|
## Suffix semantics
|
||||||
|
|
||||||
|
Do not assume a suffix always means a band or category.
|
||||||
|
|
||||||
|
Historical examples have shown the same base calls with different suffix conventions in different chat categories.
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
|
||||||
|
- preserve exact full-call identity where needed;
|
||||||
|
- normalize only for explicitly approved base-call features;
|
||||||
|
- never infer missing band/category semantics from the suffix alone.
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
KST4Contest's central VHF/UHF usage focuses on ON4KST categories 2 and 3.
|
||||||
|
|
||||||
|
However, other categories can occur.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- unsupported/uninteresting categories must be ignored or handled safely;
|
||||||
|
- they must not produce index, switch or null errors;
|
||||||
|
- do not let their values contaminate category-2/category-3 band logic.
|
||||||
|
|
||||||
|
## Band availability
|
||||||
|
|
||||||
|
Band activity can be derived from several signals including name/text parsing and explicit/manual information.
|
||||||
|
|
||||||
|
Confirmed invariant:
|
||||||
|
|
||||||
|
`NOT-QRV` overrides positive availability indications.
|
||||||
|
|
||||||
|
Known-active-band and `B+` handling should use one consistent interpretation across the program.
|
||||||
|
|
||||||
|
Do not implement separate slightly different parsers in multiple UI/features if a shared existing mechanism is available.
|
||||||
|
|
||||||
|
## Current QRG
|
||||||
|
|
||||||
|
Features that depend on propagation/band/frequency should use the current relevant QRG or the approved band calculation.
|
||||||
|
|
||||||
|
Never silently reintroduce a universal hardcoded 144 MHz fallback.
|
||||||
|
|
||||||
|
When a frequency is ambiguous and no approved fallback exists, ask rather than guessing.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Known edge cases and regression patterns
|
||||||
|
|
||||||
|
This file is a regression-awareness list. Reproduce/inspect current code before deciding a historical bug still exists.
|
||||||
|
|
||||||
|
## Incomplete ChatMember
|
||||||
|
|
||||||
|
Known failure pattern:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cannot invoke "java.lang.Double.intValue()" because
|
||||||
|
ChatMember.getQrb() is null
|
||||||
|
```
|
||||||
|
|
||||||
|
Lesson:
|
||||||
|
|
||||||
|
- QRB can be absent;
|
||||||
|
- UI/calculation code must not blindly unbox/convert;
|
||||||
|
- unavailable is not zero.
|
||||||
|
|
||||||
|
Apply the same reasoning to QTF and other server/fallback-derived fields.
|
||||||
|
|
||||||
|
## Callsign suffix collisions
|
||||||
|
|
||||||
|
A historical issue caused messages/identity problems between:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DN9APW
|
||||||
|
DN9APW-2
|
||||||
|
```
|
||||||
|
|
||||||
|
The corrective model is not "strip all suffixes".
|
||||||
|
|
||||||
|
Instead:
|
||||||
|
|
||||||
|
- keep full-call chat identities distinct;
|
||||||
|
- include category;
|
||||||
|
- use base call only for explicitly base-call-wide features such as worked state/monitoring.
|
||||||
|
|
||||||
|
## Unsupported chat categories
|
||||||
|
|
||||||
|
The ON4KST ecosystem can expose categories outside the two central ones.
|
||||||
|
|
||||||
|
A parser/switch/filter must not throw because the category is irrelevant to KST4Contest.
|
||||||
|
|
||||||
|
Safe ignore/fallback beats fake band assignment.
|
||||||
|
|
||||||
|
## AirScout higher-band queries
|
||||||
|
|
||||||
|
A historical observation showed aircraft visible in AirScout while API results for a 432 MHz case were empty.
|
||||||
|
|
||||||
|
Potential causes included frequency-string formatting.
|
||||||
|
|
||||||
|
Lesson:
|
||||||
|
|
||||||
|
- verify the exact upstream contract;
|
||||||
|
- compare request produced by KST4Contest with a known-working request;
|
||||||
|
- do not "fix" by guessing a string format or falling back to an unrelated band.
|
||||||
|
|
||||||
|
## Second airplane-scatter result / missing aircraft
|
||||||
|
|
||||||
|
A known UI failure involved missing/partial airplane-scatter data and a `TextInputControl` range error (`start must be <= end`).
|
||||||
|
|
||||||
|
Lesson:
|
||||||
|
|
||||||
|
- empty/partial AP results must be validated before text-range highlighting/selection;
|
||||||
|
- second-result paths need the same null/range checks as primary results.
|
||||||
|
|
||||||
|
## Historic/unknown user message
|
||||||
|
|
||||||
|
A user/message record can refer to a callsign not present in the current member list.
|
||||||
|
|
||||||
|
Do not require current login membership to render or classify historic messages.
|
||||||
|
|
||||||
|
## UM3-style handling
|
||||||
|
|
||||||
|
Historical message handling included cases that should be ignored safely if the user is not in the chat/member state.
|
||||||
|
|
||||||
|
Lesson:
|
||||||
|
|
||||||
|
- external message types must tolerate missing member references.
|
||||||
|
|
||||||
|
## CR/LF and disconnect suspicion
|
||||||
|
|
||||||
|
Do not treat line endings as harmless text formatting in socket code.
|
||||||
|
|
||||||
|
When diagnosing a disconnect:
|
||||||
|
|
||||||
|
- inspect transmitted bytes;
|
||||||
|
- inspect server response/EOF;
|
||||||
|
- compare Windows versions only after proving the application sends different bytes;
|
||||||
|
- avoid duplicated LF/CRLF terminators.
|
||||||
|
|
||||||
|
## Filter reset
|
||||||
|
|
||||||
|
A reset button that clears control values but leaves predicates active is not a valid reset.
|
||||||
|
|
||||||
|
Verify final predicate composition, not only UI state.
|
||||||
|
|
||||||
|
## Map render flicker
|
||||||
|
|
||||||
|
Leaflet/WebView render fragmentation under Java 21 was mitigated by:
|
||||||
|
|
||||||
|
```text
|
||||||
|
window.L_DISABLE_3D = true
|
||||||
|
```
|
||||||
|
|
||||||
|
before Leaflet load.
|
||||||
|
|
||||||
|
Do not remove as "obsolete CSS cleanup" without a visual regression check.
|
||||||
|
|
||||||
|
## Network start/reconnect loop
|
||||||
|
|
||||||
|
Initial connection failure must not spin indefinitely or block controlled recovery.
|
||||||
|
|
||||||
|
Connection state must be based on actual I/O lifecycle rather than only `Socket.isConnected()`-style historical state.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Project behaviour catalog
|
||||||
|
|
||||||
|
This catalog summarizes behaviour established during prior KST4Contest work. It is context for analysis, not permission to overwrite newer code. Always inspect the current implementation before modifying a listed area.
|
||||||
|
|
||||||
|
## Core purpose
|
||||||
|
|
||||||
|
KST4Contest is an ON4KST-oriented desktop client optimized for VHF/UHF/microwave contest workflows.
|
||||||
|
|
||||||
|
Core areas developed over time include:
|
||||||
|
|
||||||
|
- simultaneous ON4KST chat handling;
|
||||||
|
- priority candidates;
|
||||||
|
- sked workflow and timeline;
|
||||||
|
- worked-state synchronization;
|
||||||
|
- logging integrations;
|
||||||
|
- DXCluster;
|
||||||
|
- AirScout / airplane-scatter assistance;
|
||||||
|
- rotor/control integrations;
|
||||||
|
- map/path visualization;
|
||||||
|
- automated replies/beacons;
|
||||||
|
- user filtering and reachability;
|
||||||
|
- documentation and website/update-feed integration.
|
||||||
|
|
||||||
|
## Two chat categories
|
||||||
|
|
||||||
|
The application is designed around two simultaneous relevant chat categories in normal operation.
|
||||||
|
|
||||||
|
Important consequences:
|
||||||
|
|
||||||
|
- same-looking calls in different categories are not automatically the same chat identity;
|
||||||
|
- category is part of message/member identity;
|
||||||
|
- category-specific QRG/band settings must not leak into the other category;
|
||||||
|
- unsupported categories must not crash shared logic.
|
||||||
|
|
||||||
|
## Priority candidates
|
||||||
|
|
||||||
|
Priority scoring has included factors such as:
|
||||||
|
|
||||||
|
- QTF match;
|
||||||
|
- recent activity;
|
||||||
|
- message count;
|
||||||
|
- positive signal indications;
|
||||||
|
- sked rate.
|
||||||
|
|
||||||
|
Do not change weighting/meaning as collateral work. Treat it as user-facing contest logic.
|
||||||
|
|
||||||
|
## Timeline / skeds
|
||||||
|
|
||||||
|
The sked timeline has used 30-minute lanes and visualized airplane-scatter probability windows.
|
||||||
|
|
||||||
|
Known historical AP strength levels:
|
||||||
|
|
||||||
|
- 100%;
|
||||||
|
- 75%;
|
||||||
|
- 50%.
|
||||||
|
|
||||||
|
Sked reminder presets have covered short contest-relevant lead times.
|
||||||
|
|
||||||
|
Do not hardcode historical display constants into new code without verifying the current view/model.
|
||||||
|
|
||||||
|
## Worked state
|
||||||
|
|
||||||
|
Worked state is loaded from persistence and updated live from supported logging inputs.
|
||||||
|
|
||||||
|
The simplified UI meaning has been "worked any" where the locator/worked indicator is concerned.
|
||||||
|
|
||||||
|
Worked state is base-call-wide across suffix variants where established.
|
||||||
|
|
||||||
|
When changing persistence or logging synchronization, verify:
|
||||||
|
|
||||||
|
- startup DB load;
|
||||||
|
- live update;
|
||||||
|
- suffix/base-call mapping;
|
||||||
|
- band mapping;
|
||||||
|
- 50/70 MHz support where applicable;
|
||||||
|
- UI projection.
|
||||||
|
|
||||||
|
## Known active bands / B+
|
||||||
|
|
||||||
|
Known-active-band information is derived consistently across the application from available hints.
|
||||||
|
|
||||||
|
Historical work unified:
|
||||||
|
|
||||||
|
- band mentions in user names;
|
||||||
|
- band mentions in text;
|
||||||
|
- manual/global band information;
|
||||||
|
- `B+`-style availability.
|
||||||
|
|
||||||
|
Explicit `NOT-QRV` overrides positive hints.
|
||||||
|
|
||||||
|
Avoid introducing a second parser with different semantics.
|
||||||
|
|
||||||
|
## Selection and send workflow
|
||||||
|
|
||||||
|
Established fast-workflow behaviour includes:
|
||||||
|
|
||||||
|
- selecting a new station prefills `/cq callsign`;
|
||||||
|
- send text is geared toward minimal contest interaction;
|
||||||
|
- if no target chat category is selected, Main is the established fallback.
|
||||||
|
|
||||||
|
These are intentional workflow decisions, not incidental UI details.
|
||||||
|
|
||||||
|
## DXCluster
|
||||||
|
|
||||||
|
DXCluster support has included:
|
||||||
|
|
||||||
|
- integrated display;
|
||||||
|
- copyable lines;
|
||||||
|
- `/cq` workflow support;
|
||||||
|
- beacon monitoring;
|
||||||
|
- QTF/bearing-related presentation.
|
||||||
|
|
||||||
|
Preserve locator semantics and avoid sender/receiver field confusion.
|
||||||
|
|
||||||
|
## Map / path view
|
||||||
|
|
||||||
|
Map work has included:
|
||||||
|
|
||||||
|
- Leaflet 1.9.4 in JavaFX WebView;
|
||||||
|
- terrain/path information;
|
||||||
|
- airplane-scatter integration;
|
||||||
|
- target-station selection;
|
||||||
|
- path-analysis visibility;
|
||||||
|
- station-count/status information;
|
||||||
|
- target reset that does not reset zoom.
|
||||||
|
|
||||||
|
A persistent right-side station-information panel has been reduced/removed in favour of more compact presentation in later UI work.
|
||||||
|
|
||||||
|
Before changing layout, inspect the current version because this area has been actively iterated.
|
||||||
|
|
||||||
|
## Reachability and filters
|
||||||
|
|
||||||
|
Filter work has separated reachability concerns from generic filters.
|
||||||
|
|
||||||
|
A Reset Filter control must reset the actual filter predicates, not just visual controls.
|
||||||
|
|
||||||
|
UI sorting/filtering must remain stable when backing data changes.
|
||||||
|
|
||||||
|
## Autoanswer and beacons
|
||||||
|
|
||||||
|
Automated messaging exists to reduce repetitive contest chat work without creating spam or feedback loops.
|
||||||
|
|
||||||
|
Important principles:
|
||||||
|
|
||||||
|
- conservative timing;
|
||||||
|
- bounded text;
|
||||||
|
- no loop on own automated markers;
|
||||||
|
- only consume cooldown after a valid queued reply;
|
||||||
|
- category/callsign-safe identity;
|
||||||
|
- QRG requests handled with the intended precedence.
|
||||||
|
|
||||||
|
## Connection handling
|
||||||
|
|
||||||
|
Network reliability is contest-critical.
|
||||||
|
|
||||||
|
Work has explicitly targeted:
|
||||||
|
|
||||||
|
- accurate connected/disconnected state;
|
||||||
|
- server disconnect detection;
|
||||||
|
- reconnect behaviour on unstable links;
|
||||||
|
- no infinite loop on initial connection failure;
|
||||||
|
- visible connection-state indication in the UI.
|
||||||
|
|
||||||
|
Do not regress connection state into "socket object exists therefore connected".
|
||||||
|
|
||||||
|
## Historic messages
|
||||||
|
|
||||||
|
Historic/non-current chat senders may not have a complete live `ChatMember`.
|
||||||
|
|
||||||
|
Highlighting, display and parsing must tolerate users not currently logged in.
|
||||||
|
|
||||||
|
## Website and documentation
|
||||||
|
|
||||||
|
The application repository also contains:
|
||||||
|
|
||||||
|
- bilingual manual content;
|
||||||
|
- documentation images;
|
||||||
|
- automated documentation PDF build;
|
||||||
|
- Eleventy website;
|
||||||
|
- download/update metadata generation;
|
||||||
|
- release-oriented website automation.
|
||||||
|
|
||||||
|
A user-visible feature change may therefore affect more than Java source.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Protocols and external integrations
|
||||||
|
|
||||||
|
This file records stable rules plus historical context. For exact current wire formats, ports and frequency strings, inspect the current code and authoritative upstream documentation.
|
||||||
|
|
||||||
|
## ON4KST
|
||||||
|
|
||||||
|
KST4Contest depends on long-lived server communication where malformed commands or framing can lead to disconnects.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- preserve exact protocol framing;
|
||||||
|
- treat CR/LF changes as protocol changes, not formatting cleanup;
|
||||||
|
- do not append extra line terminators without verification;
|
||||||
|
- detect actual socket/server disconnects reliably;
|
||||||
|
- initial connection failure must not create an uncontrolled infinite loop;
|
||||||
|
- reconnect logic must tolerate unstable Internet access;
|
||||||
|
- the UI should make disconnected state clearly visible where implemented.
|
||||||
|
|
||||||
|
If Windows-specific behaviour is suspected, do not assume Win10/Win11 line-ending semantics explain it without reproducing or tracing the bytes.
|
||||||
|
|
||||||
|
## UCXLog / DXLog UDP XML
|
||||||
|
|
||||||
|
`contactreplace` must be handled equivalently to `contactinfo` for whole-log broadcasts where applicable.
|
||||||
|
|
||||||
|
Historical raw-packet XML start detection included:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<?xml
|
||||||
|
<contactinfo
|
||||||
|
<contactreplace
|
||||||
|
<RadioInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
DOM handling also included a fallback to `contactreplace`.
|
||||||
|
|
||||||
|
Before changing this path, inspect the current parser because the code may have been refactored since this behaviour was introduced.
|
||||||
|
|
||||||
|
Preferred layering:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UDP receiver -> parser -> DTO -> service/domain/DB -> controller -> UI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Win-Test
|
||||||
|
|
||||||
|
Historical integration uses UDP port 8721 for Win-Test information.
|
||||||
|
|
||||||
|
Do not hardcode this fact into unrelated logic. Verify current configuration before changing listener setup or band mapping.
|
||||||
|
|
||||||
|
Changes involving 50/70 MHz, worked state or frequency mapping must be consistent with other logging inputs.
|
||||||
|
|
||||||
|
## AirScout
|
||||||
|
|
||||||
|
KST4Contest integrates with AirScout path/airplane-scatter information.
|
||||||
|
|
||||||
|
Stable principles:
|
||||||
|
|
||||||
|
- propagation/path queries must reflect the current relevant frequency/band;
|
||||||
|
- do not fall back to 144 MHz merely because older code did;
|
||||||
|
- unsupported chat categories must fail safely;
|
||||||
|
- frequency-string formatting is an external API contract and must be checked, not guessed.
|
||||||
|
|
||||||
|
Historical work included a temporary 430 MHz approximation for ambiguous higher-band handling. Treat that as historical context, not a permanent invariant. Inspect the current implementation before using or changing it.
|
||||||
|
|
||||||
|
## PSTRotator
|
||||||
|
|
||||||
|
The integration has evolved.
|
||||||
|
|
||||||
|
Historical project notes mention more than one control approach, and recent work included UDP control/feedback behaviour around a configurable control port and feedback on the next port, including SPID movement retry logic.
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
|
||||||
|
- inspect the current implementation before assuming TCP vs UDP;
|
||||||
|
- inspect current settings/defaults;
|
||||||
|
- do not copy an old port/transport assumption into new code;
|
||||||
|
- preserve asynchronous JavaFX-safe handling;
|
||||||
|
- preserve any verified retry sequence only if it still exists in current code/tests.
|
||||||
|
|
||||||
|
If current code and historical notes conflict, ask Marc after showing the conflict.
|
||||||
|
|
||||||
|
## DXCluster
|
||||||
|
|
||||||
|
DXCluster is integrated into the contest workflow.
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
- copyable/usable cluster lines;
|
||||||
|
- correct sender/receiver locator semantics;
|
||||||
|
- safe handling of missing locator data.
|
||||||
|
|
||||||
|
A historical bug copied sender and receiver locators as equal; do not reintroduce that behaviour.
|
||||||
|
|
||||||
|
## Protocol-wide error handling
|
||||||
|
|
||||||
|
External data is not trusted to be complete.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- validate before dereferencing;
|
||||||
|
- unknown categories/bands/tags should degrade safely;
|
||||||
|
- malformed packets must not kill long-running receiver/management threads;
|
||||||
|
- logging should make the rejected input diagnosable without flooding normal operation.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Deferred and roadmap context
|
||||||
|
|
||||||
|
This file is background only. Do not implement these items merely because they are mentioned here.
|
||||||
|
|
||||||
|
## Propagation model
|
||||||
|
|
||||||
|
After the manual audit, Marc intends to revisit and improve KST4Contest propagation modelling.
|
||||||
|
|
||||||
|
Exploration areas include:
|
||||||
|
|
||||||
|
- higher-density Copernicus GLO-30 terrain sampling;
|
||||||
|
- Fresnel-zone analysis;
|
||||||
|
- diffraction modelling;
|
||||||
|
- simplified ray tracing / multi-segment paths;
|
||||||
|
- VHF/UHF/microwave contest applicability, including around 1296 MHz and above;
|
||||||
|
- practical contest-oriented prediction rather than academic complexity for its own sake.
|
||||||
|
|
||||||
|
This requires a fresh concept before implementation.
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
|
||||||
|
Roadmap context must never silently enlarge the scope of a current task.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Settings and data context
|
||||||
|
|
||||||
|
Inspect `Config`/settings classes and current UI before using these names; this list records important settings/concepts encountered during prior work.
|
||||||
|
|
||||||
|
## Band / station settings
|
||||||
|
|
||||||
|
Important concepts have included:
|
||||||
|
|
||||||
|
- `MYQRGFirstCat`;
|
||||||
|
- `MYQRGSecondCat`;
|
||||||
|
- manual station band information;
|
||||||
|
- current/actual QTF;
|
||||||
|
- known-active bands;
|
||||||
|
- selected/current QRG.
|
||||||
|
|
||||||
|
Band-dependent features must use the correct category/station context.
|
||||||
|
|
||||||
|
## Antenna / path settings
|
||||||
|
|
||||||
|
Important concepts have included:
|
||||||
|
|
||||||
|
- `actualQTF`;
|
||||||
|
- `antennaBeamWidthDeg`;
|
||||||
|
- maximum QRB;
|
||||||
|
- AirScout/path-analysis settings.
|
||||||
|
|
||||||
|
Missing QRB/QTF must remain unknown, not numeric zero.
|
||||||
|
|
||||||
|
## UI settings
|
||||||
|
|
||||||
|
Persisted UI behaviour has included:
|
||||||
|
|
||||||
|
- dark mode;
|
||||||
|
- map/path-analysis visibility;
|
||||||
|
- filters/reachability controls;
|
||||||
|
- column visibility;
|
||||||
|
- divider/layout state where implemented.
|
||||||
|
|
||||||
|
Do not reset persisted user choices as an incidental effect of a feature change.
|
||||||
|
|
||||||
|
## Logging / worked persistence
|
||||||
|
|
||||||
|
Worked information is persisted and updated through multiple input paths.
|
||||||
|
|
||||||
|
Before changing one path, compare semantics across:
|
||||||
|
|
||||||
|
- DB load on startup;
|
||||||
|
- simple/manual log integration where present;
|
||||||
|
- UCXLog/DXLog;
|
||||||
|
- Win-Test;
|
||||||
|
- other current logging inputs.
|
||||||
|
|
||||||
|
The goal is one worked-state interpretation regardless of source.
|
||||||
|
|
||||||
|
## Chat/message automation
|
||||||
|
|
||||||
|
Configuration has included:
|
||||||
|
|
||||||
|
- beacon/autoanswer enablement;
|
||||||
|
- beacon defaults;
|
||||||
|
- message limits/timers;
|
||||||
|
- category-specific communication.
|
||||||
|
|
||||||
|
Do not duplicate timers or create per-feature scheduling that bypasses the shared safety model.
|
||||||
|
|
||||||
|
## Connection state
|
||||||
|
|
||||||
|
Connection-state UI must reflect actual ON4KST connection lifecycle.
|
||||||
|
|
||||||
|
Any new state enum/property should have a clear owner and thread boundary.
|
||||||
|
|
||||||
|
## Persistence rule
|
||||||
|
|
||||||
|
Do not change persisted keys/schema/semantics simply to make new code easier.
|
||||||
|
|
||||||
|
If a schema/key migration is required:
|
||||||
|
|
||||||
|
1. explain current and new format;
|
||||||
|
2. describe backward compatibility;
|
||||||
|
3. ask for approval before implementing.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Build, tests, static analysis and release safety
|
||||||
|
|
||||||
|
## Maven
|
||||||
|
|
||||||
|
Use the repository Maven wrapper.
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.\mvnw.cmd test
|
||||||
|
.\mvnw.cmd package
|
||||||
|
```
|
||||||
|
|
||||||
|
Run narrower tests first when possible.
|
||||||
|
|
||||||
|
## Important Surefire behaviour
|
||||||
|
|
||||||
|
The project has used:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<testFailureIgnore>true</testFailureIgnore>
|
||||||
|
```
|
||||||
|
|
||||||
|
Therefore an exit code of zero is not sufficient evidence that all tests passed.
|
||||||
|
|
||||||
|
Always inspect:
|
||||||
|
|
||||||
|
- test counts;
|
||||||
|
- failures;
|
||||||
|
- errors;
|
||||||
|
- skipped tests;
|
||||||
|
- Surefire report output when necessary.
|
||||||
|
|
||||||
|
State exact results in the completion report.
|
||||||
|
|
||||||
|
## PMD and SpotBugs
|
||||||
|
|
||||||
|
PMD and SpotBugs are integrated, but their findings have historically not always failed the build.
|
||||||
|
|
||||||
|
Do not say "static analysis clean" unless the relevant reports/output were actually checked.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
The build contains packaging/module-list consistency logic.
|
||||||
|
|
||||||
|
Changes involving modules, JavaFX modules, jpackage or `module-info.java` must check:
|
||||||
|
|
||||||
|
- `pom.xml`;
|
||||||
|
- `packaging/` helpers;
|
||||||
|
- module requirements;
|
||||||
|
- packaging verification output.
|
||||||
|
|
||||||
|
Do not manually update only one copy of a generated/synchronized module list.
|
||||||
|
|
||||||
|
## Website
|
||||||
|
|
||||||
|
The website is Eleventy-based and has Node tests.
|
||||||
|
|
||||||
|
Inspect `website/package.json`, `website/test/` and current scripts before choosing exact commands.
|
||||||
|
|
||||||
|
Historical website validation included Node tests for generated version/update information.
|
||||||
|
|
||||||
|
## Documentation build
|
||||||
|
|
||||||
|
GitHub Actions generates documentation/PDF and site artefacts.
|
||||||
|
|
||||||
|
A local code build does not prove documentation/site CI will pass.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Do not change project version, semantic version, update feed, tag or release metadata unless explicitly requested.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
Each of these needs separate authorization:
|
||||||
|
|
||||||
|
- stage;
|
||||||
|
- commit;
|
||||||
|
- push;
|
||||||
|
- PR;
|
||||||
|
- merge;
|
||||||
|
- tag;
|
||||||
|
- release.
|
||||||
|
|
||||||
|
When asked to commit, use a concise English commit message.
|
||||||
|
|
||||||
|
Do not stage unrelated files.
|
||||||
|
|
||||||
|
## Release communication
|
||||||
|
|
||||||
|
When a release is explicitly in scope, check:
|
||||||
|
|
||||||
|
- current changelog;
|
||||||
|
- GitHub release/tag;
|
||||||
|
- website download/update feed;
|
||||||
|
- documentation;
|
||||||
|
- HamRadioOnline download/manual destinations;
|
||||||
|
- any SourceForge publication workflow currently used.
|
||||||
|
|
||||||
|
Do not assume an older deployment pipeline is still active.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Threading and state management
|
||||||
|
|
||||||
|
## Canonical state vs UI state
|
||||||
|
|
||||||
|
Use a thread-safe canonical state for data consumed by worker/network threads.
|
||||||
|
|
||||||
|
The active-member UI list is only a projection.
|
||||||
|
|
||||||
|
Preferred conceptual model:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConcurrentMap<MemberKey, ChatMember> activeMembers
|
||||||
|
|
|
||||||
|
| FX-thread projection/update
|
||||||
|
v
|
||||||
|
ObservableList<ChatMember> activeMembersUi
|
||||||
|
```
|
||||||
|
|
||||||
|
`MemberKey` semantics must preserve full callsign plus category unless the specific operation is intentionally base-call-wide.
|
||||||
|
|
||||||
|
## MessageBusManagementThread
|
||||||
|
|
||||||
|
Do not:
|
||||||
|
|
||||||
|
- iterate JavaFX `ObservableList` from the worker thread;
|
||||||
|
- add/remove JavaFX-list entries directly from the worker thread;
|
||||||
|
- use the FX thread as a substitute for proper domain state ownership.
|
||||||
|
|
||||||
|
Do:
|
||||||
|
|
||||||
|
- pass domain events/data to the controller/service boundary;
|
||||||
|
- modify canonical thread-safe state outside UI code as appropriate;
|
||||||
|
- project changes to JavaFX state on the FX thread.
|
||||||
|
|
||||||
|
## Controller boundary
|
||||||
|
|
||||||
|
`ChatController` is the preferred coordination boundary for UI-visible state.
|
||||||
|
|
||||||
|
Keep view-specific operations out of protocol receiver code.
|
||||||
|
|
||||||
|
## External receiver design
|
||||||
|
|
||||||
|
For receiver refactors, separate:
|
||||||
|
|
||||||
|
- socket/UDP/TCP I/O;
|
||||||
|
- parsing;
|
||||||
|
- DTO;
|
||||||
|
- domain/persistence;
|
||||||
|
- UI coordination.
|
||||||
|
|
||||||
|
## Error containment
|
||||||
|
|
||||||
|
Long-running threads must survive:
|
||||||
|
|
||||||
|
- malformed server records;
|
||||||
|
- incomplete members;
|
||||||
|
- unknown bands/categories;
|
||||||
|
- missing locators;
|
||||||
|
- null QRB/QTF;
|
||||||
|
- temporary socket failure.
|
||||||
|
|
||||||
|
Catch errors at meaningful boundaries and include enough context in English diagnostic logs/comments to trace the input and stage of failure.
|
||||||
|
|
||||||
|
Do not swallow errors silently.
|
||||||
|
|
||||||
|
## JavaFX selection/sorting
|
||||||
|
|
||||||
|
When updating backing data:
|
||||||
|
|
||||||
|
- preserve current selection where the feature expects it;
|
||||||
|
- avoid invalidating `FilteredList`/`SortedList` assumptions;
|
||||||
|
- do not create recursive UI updates;
|
||||||
|
- avoid accessing control state from worker threads.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# UI behaviour and workflow invariants
|
||||||
|
|
||||||
|
These are known user-experience decisions. Verify the current implementation before changing them.
|
||||||
|
|
||||||
|
## Selection and send text
|
||||||
|
|
||||||
|
A new station selection should prefill the established command form:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/cq callsign
|
||||||
|
```
|
||||||
|
|
||||||
|
This behaviour is deliberate even when prior input text existed, according to the established workflow.
|
||||||
|
|
||||||
|
If no category is selected for sending, the established fallback is the Main category.
|
||||||
|
|
||||||
|
Do not change either behaviour as a side effect of unrelated refactoring.
|
||||||
|
|
||||||
|
## Map view
|
||||||
|
|
||||||
|
Known decisions:
|
||||||
|
|
||||||
|
- reset clears the target/station selection;
|
||||||
|
- reset does not change the current zoom level;
|
||||||
|
- selected-station information was moved toward the compact status line rather than requiring a persistent right-side detail panel;
|
||||||
|
- path-analysis visibility is user-controllable and should not become undiscoverable;
|
||||||
|
- map controls must remain usable in dark/light modes.
|
||||||
|
|
||||||
|
## Leaflet / JavaFX WebView
|
||||||
|
|
||||||
|
With Leaflet 1.9.4 under Java 21, fragmented rendering/flicker was fixed by disabling Leaflet CSS 3D transforms before Leaflet loads:
|
||||||
|
|
||||||
|
```text
|
||||||
|
window.L_DISABLE_3D = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not remove/reorder this workaround without reproducing the rendering problem and proving the replacement.
|
||||||
|
|
||||||
|
## Filters
|
||||||
|
|
||||||
|
Known UI direction:
|
||||||
|
|
||||||
|
- Reset Filter must actually clear relevant filter predicates;
|
||||||
|
- reachability controls are conceptually separate from generic filter controls;
|
||||||
|
- reset control should remain visually discoverable;
|
||||||
|
- truncated text should remain accessible through tooltips where implemented;
|
||||||
|
- clickable links should remain functional in both themes.
|
||||||
|
|
||||||
|
## Priority / timeline
|
||||||
|
|
||||||
|
The contest workflow includes:
|
||||||
|
- priority candidate presentation;
|
||||||
|
- sked timeline;
|
||||||
|
- activity/AP windows;
|
||||||
|
- sked reminders.
|
||||||
|
|
||||||
|
Avoid UI changes that damage quick contest operation merely to make layout code simpler.
|
||||||
|
|
||||||
|
## Null display
|
||||||
|
|
||||||
|
Missing data is not `0`.
|
||||||
|
|
||||||
|
For QRB/QTF/locator/derived values, follow the current UI convention for unavailable/empty state.
|
||||||
|
|
||||||
|
Do not show a plausible-looking number when the model value is actually unknown.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: kst4contest-documentation
|
||||||
|
description: Perform targeted KST4Contest documentation-impact checks and update affected German/English manuals, README, website feature text, changelog, release notes and durable project context. Avoid full audits by default; keep documentation aligned with implemented behaviour and use the praktimarc-writing-style skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# KST4Contest documentation workflow
|
||||||
|
|
||||||
|
Use `$software-project-context` and `$praktimarc-writing-style`.
|
||||||
|
|
||||||
|
## Default behaviour
|
||||||
|
|
||||||
|
Do not read the complete manual or website after every code change.
|
||||||
|
|
||||||
|
Start with a documentation-impact classification and search for the affected feature, setting, UI label, protocol/integration or operational concept.
|
||||||
|
|
||||||
|
Escalate to a broader audit only when:
|
||||||
|
|
||||||
|
- Marc explicitly requests it;
|
||||||
|
- a major release is being prepared;
|
||||||
|
- the change is broad across UI/workflows;
|
||||||
|
- multiple targeted checks reveal wider drift.
|
||||||
|
|
||||||
|
## Before editing documentation
|
||||||
|
|
||||||
|
1. Inspect the actual implementation or approved specification.
|
||||||
|
2. Explain in German what documentation is probably affected.
|
||||||
|
3. Ask only unresolved behaviour/scope questions.
|
||||||
|
4. If documentation updates are already part of an approved implementation concept, no second approval is required for obvious synchronisation.
|
||||||
|
5. If documentation reveals a new material product decision, stop and ask Marc.
|
||||||
|
|
||||||
|
## Manuals
|
||||||
|
|
||||||
|
When user-facing behaviour is affected:
|
||||||
|
|
||||||
|
- search English and German manual content under `github_docs/` for the relevant feature/labels first;
|
||||||
|
- inspect surrounding sections only;
|
||||||
|
- keep both language versions semantically equivalent;
|
||||||
|
- do not translate mechanically; English must be idiomatic;
|
||||||
|
- preserve exact UI labels, values, callsigns, ports and protocol terminology;
|
||||||
|
- document current behaviour only;
|
||||||
|
- if code and manual disagree and it is unclear which behaviour is intended, report the conflict;
|
||||||
|
- identify outdated/missing screenshots explicitly.
|
||||||
|
|
||||||
|
## README / website
|
||||||
|
|
||||||
|
Check only when the changed feature, installation, configuration, capability or compatibility is represented there or should reasonably be represented there.
|
||||||
|
|
||||||
|
- Keep feature descriptions concise.
|
||||||
|
- Explain real contest/operating benefit, not marketing slogans.
|
||||||
|
- Avoid duplicating large manual sections on the website.
|
||||||
|
- Keep the main manual/download destinations consistent with the current site strategy.
|
||||||
|
|
||||||
|
## Durable project context
|
||||||
|
|
||||||
|
Update `docs/PROJECT_CONTEXT.md` for significant:
|
||||||
|
|
||||||
|
- architectural decisions;
|
||||||
|
- threading/state ownership;
|
||||||
|
- callsign/category semantics;
|
||||||
|
- protocol/integration contracts;
|
||||||
|
- persistence/configuration changes;
|
||||||
|
- durable workarounds;
|
||||||
|
- deployment/website relationships;
|
||||||
|
- cross-project dependencies;
|
||||||
|
- planned propagation/API architecture when it becomes concrete.
|
||||||
|
|
||||||
|
Keep current-state sections current rather than using the file as a raw changelog.
|
||||||
|
|
||||||
|
## Changelog / release notes
|
||||||
|
|
||||||
|
- Compact factual bullets.
|
||||||
|
- Include user-visible changes and important reliability/compatibility fixes.
|
||||||
|
- Do not invent version scope; derive it from actual commits/changelog/release context.
|
||||||
|
- Keep release posts short and operationally relevant.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "KST4Contest Documentation"
|
||||||
|
short_description: "Keep KST4Contest manuals, website and release text aligned"
|
||||||
|
default_prompt: "Review the implemented KST4Contest behaviour, explain the documentation impact in German, propose a concept, ask questions, and wait for approval before editing."
|
||||||
|
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# User-facing feature context
|
||||||
|
|
||||||
|
This is a documentation coverage reminder, not a canonical feature list. Verify each item in current code before documenting it.
|
||||||
|
|
||||||
|
Areas repeatedly documented or changed include:
|
||||||
|
|
||||||
|
- simultaneous ON4KST chat categories;
|
||||||
|
- priority candidates;
|
||||||
|
- sked timeline/reminders;
|
||||||
|
- known active bands / B+ / NOT-QRV;
|
||||||
|
- worked indicators;
|
||||||
|
- DXCluster;
|
||||||
|
- AirScout integration;
|
||||||
|
- map/path analysis;
|
||||||
|
- filtering and reachability;
|
||||||
|
- PSTRotator/rotor integration;
|
||||||
|
- UCXLog/DXLog and Win-Test log synchronization;
|
||||||
|
- beacon/autoanswer behaviour;
|
||||||
|
- connection status/reconnect behaviour;
|
||||||
|
- dark/light UI behaviour;
|
||||||
|
- QTF/bearing workflow;
|
||||||
|
- download/update behaviour.
|
||||||
|
|
||||||
|
When one of these changes, search both language manuals and website copy for affected references.
|
||||||
|
|
||||||
|
Do not specialize documentation beyond actual behaviour. A useful example from prior work is callsign monitoring: entering an SSID-style variant can intentionally monitor the base call rather than requiring every suffix to be configured individually.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Manual and website context
|
||||||
|
|
||||||
|
## Manual location
|
||||||
|
|
||||||
|
KST4Contest documentation is maintained in `github_docs/` with English and German Markdown pages plus screenshots.
|
||||||
|
|
||||||
|
The documentation build is automated through repository workflows.
|
||||||
|
|
||||||
|
## Audit workflow established with Marc
|
||||||
|
|
||||||
|
The normal review method is:
|
||||||
|
|
||||||
|
1. compare documentation with actual code/behaviour;
|
||||||
|
2. propose exact changes;
|
||||||
|
3. note missing/outdated screenshots and their intended repo location;
|
||||||
|
4. if code must change to match the manual, stop and confirm that code change first;
|
||||||
|
5. keep German and English content aligned;
|
||||||
|
6. prefer one thorough update over many cosmetic iterations.
|
||||||
|
|
||||||
|
With Codex editing locally, the old copy/paste insertion-guide step is replaced by direct edits, but the approval logic remains.
|
||||||
|
|
||||||
|
## Examples and easter eggs
|
||||||
|
|
||||||
|
Deliberate examples/test strings must not be "cleaned up" merely because they are informal.
|
||||||
|
|
||||||
|
A known example uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DO5AMF
|
||||||
|
Testing DXC-Spot: Congrats, you donated $100!
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve such deliberate easter eggs unless Marc explicitly asks to remove or replace them.
|
||||||
|
|
||||||
|
## Website
|
||||||
|
|
||||||
|
The website under `website/` uses Eleventy/Nunjucks.
|
||||||
|
|
||||||
|
Style direction:
|
||||||
|
|
||||||
|
- modern and concise;
|
||||||
|
- technically focused;
|
||||||
|
- no promotional tone;
|
||||||
|
- English primary where appropriate;
|
||||||
|
- documentation remains the detailed source; website text should not duplicate entire manual sections.
|
||||||
|
|
||||||
|
Current website architecture/scripts must be inspected before changes.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Release communications
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
Write concise English change descriptions.
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- behaviour users notice;
|
||||||
|
- contest workflow impact;
|
||||||
|
- protocol/integration compatibility;
|
||||||
|
- bug/reliability fixes;
|
||||||
|
- documentation improvements.
|
||||||
|
|
||||||
|
Avoid internal refactor trivia unless it materially changes reliability or maintainability relevant to the release.
|
||||||
|
|
||||||
|
## Social release post
|
||||||
|
|
||||||
|
Use `$praktimarc-writing-style`.
|
||||||
|
|
||||||
|
Typical structure:
|
||||||
|
|
||||||
|
- version;
|
||||||
|
- short statement of what the release contains;
|
||||||
|
- compact highlights;
|
||||||
|
- operational context where relevant, e.g. preparation for a VUSHF contest or planned use at DM5M;
|
||||||
|
- one clear download/manual destination.
|
||||||
|
|
||||||
|
Do not oversell.
|
||||||
|
|
||||||
|
## Download/manual direction
|
||||||
|
|
||||||
|
Marc has preferred routing users to the HamRadioOnline/KST4Contest download/manual pages rather than scattering multiple download links.
|
||||||
|
|
||||||
|
Before publishing new text, inspect the current website URLs and release setup instead of copying an old link.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: kst4contest-review
|
||||||
|
description: Review current KST4Contest local changes or a proposed diff before commit. Check regressions, null safety, JavaFX threading, callsign/category semantics, band handling, protocol compatibility, tests, targeted documentation impact, durable project context and unintended scope. Report findings in German and do not modify files unless explicitly asked after the review.
|
||||||
|
---
|
||||||
|
|
||||||
|
# KST4Contest review
|
||||||
|
|
||||||
|
Review first; do not edit during the review.
|
||||||
|
|
||||||
|
Read the relevant KST4Contest change references.
|
||||||
|
|
||||||
|
## Review priorities
|
||||||
|
|
||||||
|
1. Behaviour matches the approved concept.
|
||||||
|
2. No unrelated changes.
|
||||||
|
3. Full callsign/category identity remains correct.
|
||||||
|
4. Base-call normalization is used only where intended.
|
||||||
|
5. Null/unknown values are not converted to fake defaults.
|
||||||
|
6. Worker threads do not manipulate JavaFX UI collections.
|
||||||
|
7. FX-thread boundaries are correct.
|
||||||
|
8. Protocol framing, CR/LF, XML and frequency formatting are unchanged unless explicitly intended.
|
||||||
|
9. External malformed input cannot kill long-running threads.
|
||||||
|
10. Tests cover the changed behaviour.
|
||||||
|
11. Maven test output was interpreted correctly despite ignored-failure settings.
|
||||||
|
12. A documentation-impact assessment was performed.
|
||||||
|
13. Any likely affected manual/README/website sections match the implementation.
|
||||||
|
14. `docs/PROJECT_CONTEXT.md` is updated when the change introduces a durable architectural/protocol/state/operational/integration decision.
|
||||||
|
15. Comments/Javadoc are English.
|
||||||
|
16. No unintended dependency/version/release changes.
|
||||||
|
|
||||||
|
Do not demand a full manual audit for an internal-only change when the impact assessment reasonably concludes there is no documentation effect.
|
||||||
|
|
||||||
|
## Report format
|
||||||
|
|
||||||
|
Report in German, ordered by severity.
|
||||||
|
|
||||||
|
For each finding include:
|
||||||
|
|
||||||
|
- affected file/location;
|
||||||
|
- concrete problem;
|
||||||
|
- consequence;
|
||||||
|
- recommended correction.
|
||||||
|
|
||||||
|
Then include:
|
||||||
|
|
||||||
|
- verification gaps;
|
||||||
|
- documentation-impact result;
|
||||||
|
- durable-context gaps;
|
||||||
|
- related-project gaps when relevant;
|
||||||
|
- overall assessment.
|
||||||
|
|
||||||
|
Do not fix findings until Marc explicitly asks for implementation and the normal concept gate has been satisfied for the fixes.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "KST4Contest Review"
|
||||||
|
short_description: "Review KST4Contest diffs before commit"
|
||||||
|
default_prompt: "Review the current KST4Contest changes only. Report findings in German and do not edit files."
|
||||||
|
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Review checklist
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Is every changed file necessary?
|
||||||
|
- Did unrelated formatting or refactoring slip in?
|
||||||
|
- Were user-authored local changes preserved?
|
||||||
|
|
||||||
|
## Architecture/threading
|
||||||
|
|
||||||
|
- Is canonical state owned outside JavaFX controls/lists?
|
||||||
|
- Does worker code avoid `ObservableList` access?
|
||||||
|
- Are UI mutations routed to the FX thread?
|
||||||
|
- Are parser/I/O/domain/UI responsibilities clearer or at least not more coupled?
|
||||||
|
|
||||||
|
## Domain
|
||||||
|
|
||||||
|
- Full callsign + category identity preserved?
|
||||||
|
- Base-call matching restricted to worked/monitoring or another explicitly approved feature?
|
||||||
|
- Unknown category/band values safe?
|
||||||
|
- NOT-QRV precedence preserved?
|
||||||
|
- Missing QRB/QTF remains unavailable rather than zero?
|
||||||
|
|
||||||
|
## Protocols
|
||||||
|
|
||||||
|
- ON4KST framing unchanged unless approved?
|
||||||
|
- CR/LF exact?
|
||||||
|
- UCX `contactreplace` compatibility preserved?
|
||||||
|
- Frequency strings verified rather than guessed?
|
||||||
|
- PSTRotator/AirScout transport/API assumptions checked against current code?
|
||||||
|
- Malformed input contained?
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
- Selection/focus/zoom/sorting preserved?
|
||||||
|
- `/cq callsign` prefill behaviour preserved where relevant?
|
||||||
|
- Main send-category fallback preserved where relevant?
|
||||||
|
- map WebView workaround preserved?
|
||||||
|
- null values displayed safely?
|
||||||
|
|
||||||
|
## Tests/build
|
||||||
|
|
||||||
|
- Focused regression test added or updated?
|
||||||
|
- Test summary checked?
|
||||||
|
- Ignored failures explicitly reported?
|
||||||
|
- PMD/SpotBugs output considered?
|
||||||
|
- packaging/module-list checks considered if modules changed?
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- DE and EN manual both checked?
|
||||||
|
- website/README/changelog checked if user-visible?
|
||||||
|
- screenshot impact reported?
|
||||||
|
- writing style applied?
|
||||||
|
- no undocumented implementation or documented-but-unimplemented behaviour?
|
||||||
|
|
||||||
|
## Git/release
|
||||||
|
|
||||||
|
- No version bump unless requested?
|
||||||
|
- No generated release/update-feed changes by accident?
|
||||||
|
- No staging/commit/push without explicit authorization?
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
model_reasoning_effort = "high"
|
||||||
|
approval_policy = "on-request"
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
web_search = "live"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Gradle dependencies, including the version catalog in gradle/libs.versions.toml
|
||||||
|
- package-ecosystem: "gradle"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
target-branch: "main"
|
||||||
|
ignore:
|
||||||
|
# Kotlin and Compose Multiplatform are coupled; bump them manually together
|
||||||
|
- dependency-name: "org.jetbrains.kotlin*"
|
||||||
|
- dependency-name: "org.jetbrains.compose*"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|
||||||
|
# Actions used in .github/workflows
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
target-branch: "main"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
@@ -41,8 +41,8 @@ jobs:
|
|||||||
- name: Compute pkgver
|
- name: Compute pkgver
|
||||||
id: ver
|
id: ver
|
||||||
run: |
|
run: |
|
||||||
BASE=$(grep -m1 '<version>' pom.xml \
|
BASE=$(grep -m1 '^version=' gradle.properties \
|
||||||
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' \
|
| cut -d= -f2 \
|
||||||
| sed 's/[-.]nightly//')
|
| sed 's/[-.]nightly//')
|
||||||
|
|
||||||
GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
|
GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ on:
|
|||||||
options: ["false", "true"]
|
options: ["false", "true"]
|
||||||
default: "false"
|
default: "false"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
AUR_SSH_DIR: /tmp/aur-ssh
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-aur:
|
publish-aur:
|
||||||
@@ -35,9 +39,14 @@ jobs:
|
|||||||
useradd -m builder
|
useradd -m builder
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ssh-key: ${{ secrets.WEBSITE_DEPLOY_KEY }}
|
||||||
|
|
||||||
|
- name: Mark workspace as safe Git directory
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
|
||||||
- name: Resolve release version
|
- name: Resolve release version
|
||||||
id: ver
|
id: ver
|
||||||
@@ -98,9 +107,9 @@ jobs:
|
|||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest-bin/PKGBUILD
|
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest-bin/PKGBUILD
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD
|
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD
|
||||||
|
|
||||||
# kst4contest-git: Basis aus pom.xml, Suffix aus git
|
# kst4contest-git: Basis aus gradle.properties, Suffix aus git
|
||||||
BASE_VER=$(grep -m1 '<version>' pom.xml \
|
BASE_VER=$(grep -m1 '^version=' gradle.properties \
|
||||||
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//')
|
| cut -d= -f2 | sed 's/[-.]nightly//')
|
||||||
GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
|
GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
|
||||||
sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD
|
sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD
|
||||||
|
|
||||||
@@ -118,52 +127,74 @@ jobs:
|
|||||||
cat "packaging/aur/${pkg}/.SRCINFO"
|
cat "packaging/aur/${pkg}/.SRCINFO"
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Commit updated PKGBUILDs to repo
|
|
||||||
if: inputs.dry_run != 'true'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
|
|
||||||
git add packaging/aur/
|
|
||||||
git diff --cached --quiet && echo "No PKGBUILD changes to commit." && exit 0
|
|
||||||
git commit -m "chore: update AUR packages to ${{ steps.ver.outputs.tag }} [skip ci]"
|
|
||||||
git push
|
|
||||||
|
|
||||||
- name: Set up AUR SSH
|
- name: Set up AUR SSH
|
||||||
if: inputs.dry_run != 'true'
|
if: inputs.dry_run != 'true'
|
||||||
env:
|
env:
|
||||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p "${AUR_SSH_DIR}"
|
||||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur_ed25519
|
|
||||||
chmod 600 ~/.ssh/aur_ed25519
|
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" \
|
||||||
ssh-keyscan -t ed25519 aur.archlinux.org >> ~/.ssh/known_hosts
|
> "${AUR_SSH_DIR}/aur_ed25519"
|
||||||
cat >> ~/.ssh/config << 'EOF'
|
|
||||||
Host aur.archlinux.org
|
sed -i 's/\r$//' "${AUR_SSH_DIR}/aur_ed25519"
|
||||||
IdentityFile ~/.ssh/aur_ed25519
|
chmod 600 "${AUR_SSH_DIR}/aur_ed25519"
|
||||||
User aur
|
|
||||||
EOF
|
ssh-keygen -y \
|
||||||
|
-f "${AUR_SSH_DIR}/aur_ed25519" \
|
||||||
|
> /dev/null
|
||||||
|
|
||||||
|
ssh-keyscan \
|
||||||
|
-T 10 \
|
||||||
|
-t ed25519 \
|
||||||
|
aur.archlinux.org \
|
||||||
|
> "${AUR_SSH_DIR}/known_hosts"
|
||||||
|
|
||||||
|
if [ ! -s "${AUR_SSH_DIR}/known_hosts" ]; then
|
||||||
|
echo "::error::No SSH host key was received from aur.archlinux.org."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Received AUR host-key fingerprint:"
|
||||||
|
ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts"
|
||||||
|
|
||||||
|
if ! ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts" \
|
||||||
|
| grep -Fq "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4"; then
|
||||||
|
echo "::error::The AUR SSH host-key fingerprint does not match the official fingerprint."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' \
|
||||||
|
"Host aur.archlinux.org" \
|
||||||
|
" HostName aur.archlinux.org" \
|
||||||
|
" User aur" \
|
||||||
|
" IdentityFile ${AUR_SSH_DIR}/aur_ed25519" \
|
||||||
|
" IdentitiesOnly yes" \
|
||||||
|
" StrictHostKeyChecking yes" \
|
||||||
|
" UserKnownHostsFile ${AUR_SSH_DIR}/known_hosts" \
|
||||||
|
> "${AUR_SSH_DIR}/config"
|
||||||
|
|
||||||
|
chmod 600 "${AUR_SSH_DIR}/config"
|
||||||
|
chmod 600 "${AUR_SSH_DIR}/known_hosts"
|
||||||
|
|
||||||
- name: Push to AUR
|
- name: Push to AUR
|
||||||
if: inputs.dry_run != 'true'
|
if: inputs.dry_run != 'true'
|
||||||
env:
|
env:
|
||||||
TAG: ${{ steps.ver.outputs.tag }}
|
TAG: ${{ steps.ver.outputs.tag }}
|
||||||
|
GIT_SSH_COMMAND: ssh -F /tmp/aur-ssh/config
|
||||||
run: |
|
run: |
|
||||||
git config --global user.email "philipp@wagnersnetz.de"
|
git config --global user.email "philipp@wagnersnetz.de"
|
||||||
git config --global user.name "Philipp Wagner"
|
git config --global user.name "Philipp Wagner"
|
||||||
|
|
||||||
|
mkdir -p /tmp/aur
|
||||||
|
|
||||||
push_to_aur() {
|
push_to_aur() {
|
||||||
local pkg="$1"
|
local pkg="$1"
|
||||||
local msg="$2"
|
local msg="$2"
|
||||||
local aur_dir="/tmp/aur/${pkg}"
|
local aur_dir="/tmp/aur/${pkg}"
|
||||||
|
|
||||||
git clone "ssh://aur@aur.archlinux.org/${pkg}.git" "${aur_dir}" 2>/dev/null || {
|
git -c init.defaultBranch=master clone \
|
||||||
mkdir -p "${aur_dir}"
|
"ssh://aur@aur.archlinux.org/${pkg}.git" "${aur_dir}"
|
||||||
git -C "${aur_dir}" init
|
|
||||||
git -C "${aur_dir}" remote add origin "ssh://aur@aur.archlinux.org/${pkg}.git"
|
|
||||||
}
|
|
||||||
|
|
||||||
cp "packaging/aur/${pkg}/PKGBUILD" "${aur_dir}/"
|
cp "packaging/aur/${pkg}/PKGBUILD" "${aur_dir}/"
|
||||||
cp "packaging/aur/${pkg}/.SRCINFO" "${aur_dir}/"
|
cp "packaging/aur/${pkg}/.SRCINFO" "${aur_dir}/"
|
||||||
@@ -179,3 +210,19 @@ jobs:
|
|||||||
push_to_aur kst4contest "Update to ${TAG}"
|
push_to_aur kst4contest "Update to ${TAG}"
|
||||||
push_to_aur kst4contest-git \
|
push_to_aur kst4contest-git \
|
||||||
"Update pkgver to $(grep '^pkgver=' packaging/aur/kst4contest-git/PKGBUILD | cut -d= -f2)"
|
"Update pkgver to $(grep '^pkgver=' packaging/aur/kst4contest-git/PKGBUILD | cut -d= -f2)"
|
||||||
|
|
||||||
|
- name: Commit updated PKGBUILDs to repo
|
||||||
|
if: inputs.dry_run != 'true'
|
||||||
|
run: |
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git add packaging/aur/
|
||||||
|
git diff --cached --quiet && echo "No PKGBUILD changes to commit." && exit 0
|
||||||
|
git commit -m "chore: update AUR packages to ${{ steps.ver.outputs.tag }} [skip ci]"
|
||||||
|
|
||||||
|
if ! git push; then
|
||||||
|
echo "::warning::PKGBUILD bookkeeping commit could not be pushed to main."
|
||||||
|
echo "The AUR packages were published; only the in-repo copy stays behind."
|
||||||
|
echo "Check that WEBSITE_DEPLOY_KEY still has write access and may bypass"
|
||||||
|
echo "the branch ruleset, or commit packaging/aur/ by hand."
|
||||||
|
fi
|
||||||
@@ -25,14 +25,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Resolve version string
|
- name: Resolve version string
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||||
VERSION="${{ github.ref_name }}"
|
VERSION="${{ github.ref_name }}"
|
||||||
else
|
else
|
||||||
VERSION="$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')-${GITHUB_SHA::7}"
|
VERSION="$(grep -m1 '^version=' gradle.properties | cut -d= -f2)-${GITHUB_SHA::7}"
|
||||||
fi
|
fi
|
||||||
echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV"
|
echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ jobs:
|
|||||||
github_docs/en-Home.md \
|
github_docs/en-Home.md \
|
||||||
github_docs/en-Installation.md \
|
github_docs/en-Installation.md \
|
||||||
github_docs/en-Configuration.md \
|
github_docs/en-Configuration.md \
|
||||||
|
github_docs/en-Contest-Workflow.md \
|
||||||
github_docs/en-Features.md \
|
github_docs/en-Features.md \
|
||||||
github_docs/en-User-Interface.md \
|
github_docs/en-User-Interface.md \
|
||||||
github_docs/en-Macros-and-Variables.md \
|
github_docs/en-Macros-and-Variables.md \
|
||||||
@@ -91,6 +92,7 @@ jobs:
|
|||||||
github_docs/de-Home.md \
|
github_docs/de-Home.md \
|
||||||
github_docs/de-Installation.md \
|
github_docs/de-Installation.md \
|
||||||
github_docs/de-Konfiguration.md \
|
github_docs/de-Konfiguration.md \
|
||||||
|
github_docs/de-Contest-Workflow.md \
|
||||||
github_docs/de-Funktionen.md \
|
github_docs/de-Funktionen.md \
|
||||||
github_docs/de-Benutzeroberflaeche.md \
|
github_docs/de-Benutzeroberflaeche.md \
|
||||||
github_docs/de-Makros-und-Variablen.md \
|
github_docs/de-Makros-und-Variablen.md \
|
||||||
@@ -100,7 +102,7 @@ jobs:
|
|||||||
github_docs/de-Changelog.md
|
github_docs/de-Changelog.md
|
||||||
|
|
||||||
- name: Upload PDF artifacts
|
- name: Upload PDF artifacts
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: docs-pdf
|
name: docs-pdf
|
||||||
path: dist/KST4Contest-*-manual-*.pdf
|
path: dist/KST4Contest-*-manual-*.pdf
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ jobs:
|
|||||||
publish-wiki:
|
publish-wiki:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: Andrew-Chen-Wang/github-wiki-action@v4
|
- uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||||
with:
|
with:
|
||||||
path: github_docs
|
path: github_docs
|
||||||
disable-empty-commits: true
|
disable-empty-commits: true
|
||||||
@@ -5,12 +5,15 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- "src/**"
|
|
||||||
- "packaging/icons/**"
|
- "packaging/icons/**"
|
||||||
- "packaging/macos/**"
|
- "packaging/macos/**"
|
||||||
- "pom.xml"
|
- "core/**"
|
||||||
- "mvnw"
|
- "app-desktop/**"
|
||||||
- "mvnw.cmd"
|
- "*.gradle.kts"
|
||||||
|
- "gradle.properties"
|
||||||
|
- "gradle/**"
|
||||||
|
- "gradlew"
|
||||||
|
- "gradlew.bat"
|
||||||
- ".github/workflows/nightly-artifacts.yml"
|
- ".github/workflows/nightly-artifacts.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -22,26 +25,120 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows-zip:
|
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
|
||||||
name: Build Windows ZIP
|
# would be one more indirection to read, and the comments belong next to the steps they
|
||||||
runs-on: windows-latest
|
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
|
||||||
|
# no artifact (#62). Edit all three copies together.
|
||||||
|
test:
|
||||||
|
name: Test (Java 21, headless)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Java 21
|
||||||
|
uses: actions/setup-java@v6.1.0
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Ensure gradlew is executable
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
# Skiko's native library links against libGL, so it has to be loadable even though
|
||||||
|
# nothing here renders through it: the drawing tests use Skia's software backend and
|
||||||
|
# runComposeUiTest never opens a window. Without this the whole Compose half of the
|
||||||
|
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
|
||||||
|
# file" -- which reads like a missing display and is not one. Found by running this
|
||||||
|
# job under act, where the container has no libGL; GitHub's own runner image happens
|
||||||
|
# to ship it, so the step is what makes the job reproducible off GitHub.
|
||||||
|
- name: Install the OpenGL client library skiko links against
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends libgl1
|
||||||
|
|
||||||
|
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
|
||||||
|
# runComposeUiTest reaches the semantics tree headless (measured before this job was
|
||||||
|
# written), and the ImageComposeScene drawing tests render in software. The handful
|
||||||
|
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
|
||||||
|
# skip here; the summary below prints the skip count, so that is visible rather than
|
||||||
|
# assumed.
|
||||||
|
#
|
||||||
|
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
|
||||||
|
# the hardest failure in this codebase to read, and importing that class of flake
|
||||||
|
# into CI would cost more than the few window tests are worth here.
|
||||||
|
|
||||||
|
# All three modules. The translation generator is a Gradle module and not buildSrc
|
||||||
|
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
|
||||||
|
# build` -- measured, not assumed. The summary below globs */build/test-results, so
|
||||||
|
# the module was counted the moment it existed.
|
||||||
|
- name: Test
|
||||||
|
run: ./gradlew -S test
|
||||||
|
|
||||||
|
- name: Summarise the test results
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(*/build/test-results/test/TEST-*.xml)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No test result XML was produced -- the test task did not run." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Counted from the XML and not from Gradle's console output: this project's own
|
||||||
|
# rule is never to infer "all tests passed" from an exit code.
|
||||||
|
summary=$(grep -h "<testsuite " "${files[@]}" \
|
||||||
|
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
|
||||||
|
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
|
||||||
|
read -r total skipped failures errors <<< "$summary"
|
||||||
|
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
|
||||||
|
{
|
||||||
|
echo "### Tests"
|
||||||
|
echo ""
|
||||||
|
echo "| total | skipped | failures | errors |"
|
||||||
|
echo "|---|---|---|---|"
|
||||||
|
echo "| $total | $skipped | $failures | $errors |"
|
||||||
|
echo ""
|
||||||
|
echo "Skipped are the tests that need a display, plus the deliberately"
|
||||||
|
echo "disabled reference-data generator. This job runs headless on purpose."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
|
||||||
|
echo "The result XML reports failures or errors." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Keep the test reports on a failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: |
|
||||||
|
*/build/test-results/test/
|
||||||
|
*/build/reports/tests/test/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-windows-zip:
|
||||||
|
name: Build Windows ZIP
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
- name: Resolve nightly version info
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$xml = [xml](Get-Content pom.xml)
|
$version = (Select-String -Path gradle.properties -Pattern '^version=').Line.Split('=')[1]
|
||||||
$version = $xml.project.version
|
|
||||||
$shortSha = "${{ github.sha }}".Substring(0, 7)
|
$shortSha = "${{ github.sha }}".Substring(0, 7)
|
||||||
Add-Content -Path $env:GITHUB_ENV -Value "VERSION=$version"
|
Add-Content -Path $env:GITHUB_ENV -Value "VERSION=$version"
|
||||||
Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha"
|
Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha"
|
||||||
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@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -50,43 +147,26 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: choco install wixtoolset --no-progress -y
|
run: choco install wixtoolset --no-progress -y
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
- name: Build app-image
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
.\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
|
||||||
if (-not $jar) {
|
|
||||||
throw "No project JAR produced"
|
|
||||||
}
|
|
||||||
Copy-Item $jar.FullName target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build app-image with jpackage
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
|
.\gradlew.bat -S :app-desktop:createDistributable
|
||||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||||
$addModules = & java packaging/AddModules.java
|
# The next step reads dist/KST4Contest, so the Compose output goes
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
|
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||||
jpackage `
|
if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
|
||||||
--type app-image `
|
Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
|
||||||
--name praktiKST `
|
|
||||||
--icon packaging/icons/kst4contest.ico `
|
|
||||||
--input target/dist-libs `
|
|
||||||
--main-jar app.jar `
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication `
|
|
||||||
--module-path target/dist-libs `
|
|
||||||
--add-modules $addModules `
|
|
||||||
--dest dist
|
|
||||||
|
|
||||||
- name: Create Windows ZIP
|
- name: Create Windows ZIP
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
if (-not (Test-Path dist/praktiKST)) {
|
if (-not (Test-Path dist/KST4Contest)) {
|
||||||
throw "No Windows app-image produced by jpackage"
|
throw "No Windows app-image produced by createDistributable"
|
||||||
}
|
}
|
||||||
Compress-Archive -Path dist/praktiKST -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
|
Compress-Archive -Path dist/KST4Contest -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
|
||||||
|
|
||||||
- name: Upload Windows artifact
|
- name: Upload Windows artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: windows-zip
|
name: windows-zip
|
||||||
path: dist/praktiKST-*-windows-x64.zip
|
path: dist/praktiKST-*-windows-x64.zip
|
||||||
@@ -95,48 +175,39 @@ jobs:
|
|||||||
build-linux-appimage:
|
build-linux-appimage:
|
||||||
name: Build Linux AppImage
|
name: Build Linux AppImage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
- name: Resolve nightly version info
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
- name: Build app-image
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build app-image with jpackage
|
|
||||||
run: |
|
run: |
|
||||||
|
./gradlew -S :app-desktop:createDistributable
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
# The following steps read dist/KST4Contest, so the Compose output goes
|
||||||
jpackage \
|
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||||
--type app-image \
|
rm -rf dist/KST4Contest
|
||||||
--name KST4Contest \
|
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--dest dist
|
|
||||||
|
|
||||||
- name: Create AppDir metadata
|
- name: Create AppDir metadata
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
rm -rf target/KST4Contest.AppDir
|
rm -rf target/KST4Contest.AppDir
|
||||||
cp -a dist/KST4Contest target/KST4Contest.AppDir
|
cp -a dist/KST4Contest target/KST4Contest.AppDir
|
||||||
|
|
||||||
@@ -163,12 +234,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x target/appimagetool.AppImage
|
chmod +x target/appimagetool.AppImage
|
||||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
|
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
|
||||||
|
|
||||||
- name: Upload Linux artifact
|
- name: Upload Linux artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-appimage
|
name: linux-appimage
|
||||||
path: dist/KST4Contest-*-linux-x86_64.AppImage
|
path: dist/KST4Contest-*-linux-x86_64.AppImage
|
||||||
@@ -177,19 +249,20 @@ jobs:
|
|||||||
build-linux-deb:
|
build-linux-deb:
|
||||||
name: Build Debian package
|
name: Build Debian package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
- name: Resolve nightly version info
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -199,37 +272,21 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends fakeroot
|
sudo apt-get install -y --no-install-recommends fakeroot
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build Debian package
|
- name: Build Debian package
|
||||||
run: |
|
run: |
|
||||||
|
./gradlew -S :app-desktop:packageDeb
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
|
||||||
jpackage \
|
|
||||||
--type deb \
|
|
||||||
--name KST4Contest \
|
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
|
|
||||||
--dest dist
|
|
||||||
DEB="$(ls dist/*.deb | head -n 1)"
|
|
||||||
if [ -z "$DEB" ]; then
|
if [ -z "$DEB" ]; then
|
||||||
echo "No DEB produced by jpackage" && exit 1
|
echo "No DEB produced by packageDeb" && exit 1
|
||||||
fi
|
fi
|
||||||
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
|
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
|
||||||
|
|
||||||
- name: Upload Debian artifact
|
- name: Upload Debian artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-debian
|
name: linux-debian
|
||||||
path: dist/KST4Contest-*-debian-amd64.deb
|
path: dist/KST4Contest-*-debian-amd64.deb
|
||||||
@@ -238,19 +295,20 @@ jobs:
|
|||||||
build-linux-rpm:
|
build-linux-rpm:
|
||||||
name: Build Fedora package
|
name: Build Fedora package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
- name: Resolve nightly version info
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -260,37 +318,21 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends rpm
|
sudo apt-get install -y --no-install-recommends rpm
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build Fedora package
|
- name: Build Fedora package
|
||||||
run: |
|
run: |
|
||||||
|
./gradlew -S :app-desktop:packageRpm
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
|
||||||
jpackage \
|
|
||||||
--type rpm \
|
|
||||||
--name KST4Contest \
|
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
|
|
||||||
--dest dist
|
|
||||||
RPM="$(ls dist/*.rpm | head -n 1)"
|
|
||||||
if [ -z "$RPM" ]; then
|
if [ -z "$RPM" ]; then
|
||||||
echo "No RPM produced by jpackage" && exit 1
|
echo "No RPM produced by packageRpm" && exit 1
|
||||||
fi
|
fi
|
||||||
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
|
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
|
||||||
|
|
||||||
- name: Upload Fedora artifact
|
- name: Upload Fedora artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-fedora
|
name: linux-fedora
|
||||||
path: dist/KST4Contest-*-fedora-x86_64.rpm
|
path: dist/KST4Contest-*-fedora-x86_64.rpm
|
||||||
@@ -299,21 +341,22 @@ jobs:
|
|||||||
build-linux-arch:
|
build-linux-arch:
|
||||||
name: Build Arch Linux package
|
name: Build Arch Linux package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
- name: Resolve nightly version info
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
||||||
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@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -323,31 +366,21 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends zstd
|
sudo apt-get install -y --no-install-recommends zstd
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
- name: Build app-image
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build app-image with jpackage
|
|
||||||
run: |
|
run: |
|
||||||
|
./gradlew -S :app-desktop:createDistributable
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
# The following steps read dist/KST4Contest, so the Compose output goes
|
||||||
jpackage \
|
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||||
--type app-image \
|
rm -rf dist/KST4Contest
|
||||||
--name KST4Contest \
|
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--dest dist
|
|
||||||
|
|
||||||
- name: Build Arch Linux package artifact
|
- name: Build Arch Linux package artifact
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g')
|
PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g')
|
||||||
PKGROOT="target/archpkg"
|
PKGROOT="target/archpkg"
|
||||||
@@ -411,7 +444,7 @@ jobs:
|
|||||||
tar --zstd --transform 's|^\./||' -cf "dist/${ASSET_BASENAME}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
|
tar --zstd --transform 's|^\./||' -cf "dist/${ASSET_BASENAME}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
|
||||||
|
|
||||||
- name: Upload Arch Linux artifact
|
- name: Upload Arch Linux artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-arch
|
name: linux-arch
|
||||||
path: dist/KST4Contest-*-archlinux-*.pkg.tar.zst
|
path: dist/KST4Contest-*-archlinux-*.pkg.tar.zst
|
||||||
@@ -420,59 +453,52 @@ jobs:
|
|||||||
build-flatpak:
|
build-flatpak:
|
||||||
name: Build Flatpak
|
name: Build Flatpak
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
- name: Resolve nightly version info
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
||||||
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@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Install Flatpak tooling
|
- name: Install Flatpak tooling
|
||||||
run: |
|
run: |
|
||||||
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 with jpackage
|
- name: Build app-image
|
||||||
run: |
|
run: |
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
./gradlew -S :app-desktop:createDistributable
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
mkdir -p target/flatpak-src
|
mkdir -p target/flatpak-src
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
# The manifest's source is flatpak-src/KST4Contest.
|
||||||
jpackage \
|
rm -rf target/flatpak-src/KST4Contest
|
||||||
--type app-image \
|
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
|
||||||
--name KST4Contest \
|
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--dest target/flatpak-src
|
|
||||||
|
|
||||||
- name: Create Flatpak manifest
|
- name: Create Flatpak manifest
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||||
app-id: de.x08.KST4Contest
|
app-id: de.x08.KST4Contest
|
||||||
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:
|
||||||
@@ -515,6 +541,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Flatpak repo (nightly)
|
- name: Build Flatpak repo (nightly)
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
|
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
|
||||||
flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build nightly
|
flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build nightly
|
||||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
|
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
|
||||||
@@ -522,7 +549,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]
|
||||||
@@ -536,13 +563,13 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Upload flatpakref
|
- name: Upload flatpakref
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: flatpakref
|
name: flatpakref
|
||||||
path: dist/de.x08.KST4Contest.nightly.flatpakref
|
path: dist/de.x08.KST4Contest.nightly.flatpakref
|
||||||
|
|
||||||
- name: Upload Flatpak OSTree repo
|
- name: Upload Flatpak OSTree repo
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: flatpak-ostree-repo
|
name: flatpak-ostree-repo
|
||||||
path: target/flatpak-repo/
|
path: target/flatpak-repo/
|
||||||
@@ -551,6 +578,9 @@ jobs:
|
|||||||
name: Publish Flatpak OSTree Repo (nightly)
|
name: Publish Flatpak OSTree Repo (nightly)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-flatpak
|
needs: build-flatpak
|
||||||
|
concurrency:
|
||||||
|
group: flatpak-repo-publish
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install Flatpak tooling
|
- name: Install Flatpak tooling
|
||||||
@@ -564,13 +594,13 @@ jobs:
|
|||||||
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
|
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Download OSTree repo artifact
|
- name: Download OSTree repo artifact
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: flatpak-ostree-repo
|
name: flatpak-ostree-repo
|
||||||
path: flatpak-ostree-repo/
|
path: flatpak-ostree-repo/
|
||||||
|
|
||||||
- name: Checkout existing flatpak-repo branch
|
- name: Checkout existing flatpak-repo branch
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: flatpak-repo
|
ref: flatpak-repo
|
||||||
path: existing-flatpak-repo
|
path: existing-flatpak-repo
|
||||||
@@ -586,7 +616,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]
|
||||||
@@ -608,6 +638,7 @@ jobs:
|
|||||||
build-macos-dmg:
|
build-macos-dmg:
|
||||||
name: Build macOS DMG (${{ matrix.os }})
|
name: Build macOS DMG (${{ matrix.os }})
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
needs: test
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -615,11 +646,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
- name: Resolve nightly version info
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
@@ -628,13 +659,13 @@ 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@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Import signing certificate
|
- name: Import signing certificate
|
||||||
env:
|
env:
|
||||||
@@ -672,7 +703,7 @@ jobs:
|
|||||||
mv "$DMG" "dist/${ASSET_BASENAME}-macos-${ARCH}.dmg"
|
mv "$DMG" "dist/${ASSET_BASENAME}-macos-${ARCH}.dmg"
|
||||||
|
|
||||||
- name: Upload macOS artifact
|
- name: Upload macOS artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: macos-dmg-${{ matrix.os }}
|
name: macos-dmg-${{ matrix.os }}
|
||||||
path: dist/KST4Contest-*-macos-*.dmg
|
path: dist/KST4Contest-*-macos-*.dmg
|
||||||
|
|||||||
@@ -16,19 +16,280 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Verify packaging module list matches module-info.java
|
|
||||||
run: java packaging/AddModules.java --verify-pom
|
|
||||||
|
|
||||||
|
# classes + testClasses rather than compileJava + compileTestJava. The old pair did
|
||||||
|
# cover Kotlin -- checked with a deliberately broken Kotlin test file, and also in
|
||||||
|
# --dry-run: the Kotlin plugin makes compileTestJava depend on compileTestKotlin,
|
||||||
|
# because Java has to see the Kotlin output. But that is the plugin's wiring and not
|
||||||
|
# this job's statement of intent, and it covered no resources. These two aggregates
|
||||||
|
# say "compile everything in all three modules" and do not depend on knowing that.
|
||||||
|
#
|
||||||
|
# They also run the translation generator, which app-desktop's compileKotlin depends
|
||||||
|
# on. That is why a pull request contributing a language needs no workflow of its own:
|
||||||
|
# an orphan key, a dropped placeholder or a duplicate fails here, with the generator's
|
||||||
|
# own message naming the file.
|
||||||
- name: Compile
|
- name: Compile
|
||||||
run: ./mvnw -B -DskipTests compile
|
run: ./gradlew -S classes testClasses
|
||||||
|
|
||||||
|
# Report only: both tools run with ignoreFailures, so a finding cannot fail this step
|
||||||
|
# -- a crash of the tool itself still does, which is wanted. The findings used to exist
|
||||||
|
# only in the console log; since #110 they are files, and the table and the artifact
|
||||||
|
# below are what makes them readable from a pull request.
|
||||||
|
- name: Static analysis (report only)
|
||||||
|
run: ./gradlew -S pmdMain spotbugsMain
|
||||||
|
|
||||||
|
# Counted from the report XML, never from Gradle's output or exit code. A missing
|
||||||
|
# report prints "no report" and does not fail the step: that case is already visible
|
||||||
|
# in the step above.
|
||||||
|
- name: Summarise the static analysis
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
count() {
|
||||||
|
local file="$1" element="$2"
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
# grep exits 1 on zero matches, which pipefail would turn into a failed step.
|
||||||
|
{ grep -o "<$element " "$file" || true; } | wc -l | tr -d ' '
|
||||||
|
else
|
||||||
|
echo "no report"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Static analysis"
|
||||||
|
echo ""
|
||||||
|
echo "| module | PMD violations | SpotBugs bugs |"
|
||||||
|
echo "|---|---|---|"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
for module in core app-desktop i18n-generator; do
|
||||||
|
pmd=$(count "$module/build/reports/pmd/main.xml" violation)
|
||||||
|
spotbugs=$(count "$module/build/reports/spotbugs/main.xml" BugInstance)
|
||||||
|
echo "$module: pmd=$pmd spotbugs=$spotbugs"
|
||||||
|
echo "| \`$module\` | $pmd | $spotbugs |" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
done
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "Report only: nothing fails on a finding. The XML and HTML reports are the"
|
||||||
|
echo "static-analysis artifact of this run. i18n-generator is Kotlin only, so PMD"
|
||||||
|
echo "has no Java source there and writes no report."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Keep the static analysis reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: static-analysis
|
||||||
|
path: |
|
||||||
|
*/build/reports/pmd/
|
||||||
|
*/build/reports/spotbugs/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# What a translation PR is actually about, put where its author will look. The counts
|
||||||
|
# come from the files rather than from Gradle's output, so an up-to-date task cannot
|
||||||
|
# make the table disappear.
|
||||||
|
- name: Summarise the interface texts
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
base="app-desktop/src/main/i18n/strings_en.properties"
|
||||||
|
if [ ! -f "$base" ]; then
|
||||||
|
echo "no base translation file at $base" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
count_keys() { grep -cE '^[^#!][^=]*=' "$1" || true; }
|
||||||
|
|
||||||
|
total=$(count_keys "$base")
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Interface texts"
|
||||||
|
echo ""
|
||||||
|
echo "| language | translated | of | missing |"
|
||||||
|
echo "|---|---|---|---|"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
for file in app-desktop/src/main/i18n/strings_*.properties; do
|
||||||
|
lang=$(basename "$file" .properties); lang=${lang#strings_}
|
||||||
|
have=$(count_keys "$file")
|
||||||
|
missing=$(( total - have ))
|
||||||
|
[ "$missing" -lt 0 ] && missing=0
|
||||||
|
echo "| \`$lang\` | $have | $total | $missing |" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
done
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "A language may be incomplete: a key it leaves out falls back to English,"
|
||||||
|
echo "which is what lets a translation be contributed a piece at a time. German is"
|
||||||
|
echo "held complete by a test, because it ships."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
worksheets=(app-desktop/build/generated/i18n/todo/*.todo.properties)
|
||||||
|
if [ ${#worksheets[@]} -gt 0 ]; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "The build wrote a worksheet of what is still missing for:"
|
||||||
|
echo ""
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
for w in "${worksheets[@]}"; do
|
||||||
|
echo "- \`$(basename "$w")\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test (Java 21, headless)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Java 21
|
||||||
|
uses: actions/setup-java@v6.1.0
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Ensure gradlew is executable
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
# Skiko's native library links against libGL, so it has to be loadable even though
|
||||||
|
# nothing here renders through it: the drawing tests use Skia's software backend and
|
||||||
|
# runComposeUiTest never opens a window. Without this the whole Compose half of the
|
||||||
|
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
|
||||||
|
# file" -- which reads like a missing display and is not one. Found by running this
|
||||||
|
# job under act, where the container has no libGL; GitHub's own runner image happens
|
||||||
|
# to ship it, so the step is what makes the job reproducible off GitHub.
|
||||||
|
- name: Install the OpenGL client library skiko links against
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends libgl1
|
||||||
|
|
||||||
|
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
|
||||||
|
# runComposeUiTest reaches the semantics tree headless (measured before this job was
|
||||||
|
# written), and the ImageComposeScene drawing tests render in software. The handful
|
||||||
|
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
|
||||||
|
# skip here; the summary below prints the skip count, so that is visible rather than
|
||||||
|
# assumed.
|
||||||
|
#
|
||||||
|
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
|
||||||
|
# the hardest failure in this codebase to read, and importing that class of flake
|
||||||
|
# into CI would cost more than the few window tests are worth here.
|
||||||
|
|
||||||
|
# All three modules. The translation generator is a Gradle module and not buildSrc
|
||||||
|
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
|
||||||
|
# build` -- measured, not assumed. The summary below globs */build/test-results, so
|
||||||
|
# the module was counted the moment it existed.
|
||||||
|
- name: Test
|
||||||
|
run: ./gradlew -S test
|
||||||
|
|
||||||
|
# Coverage is a report, not a gate: no threshold, nothing in it can fail on a
|
||||||
|
# percentage. Deliberately not `if: always()` -- without a completed test run the
|
||||||
|
# report would be partial, and after a failed test the result XML below is what matters.
|
||||||
|
# jacocoTestReport writes the per-module XML the summary reads; the aggregate merges
|
||||||
|
# core and app-desktop (i18n-generator is build tooling and stays out).
|
||||||
|
- name: Coverage report
|
||||||
|
run: ./gradlew -S jacocoTestReport jacocoAggregatedReport
|
||||||
|
|
||||||
|
- name: Summarise the test results
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(*/build/test-results/test/TEST-*.xml)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No test result XML was produced -- the test task did not run." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Counted from the XML and not from Gradle's console output: this project's own
|
||||||
|
# rule is never to infer "all tests passed" from an exit code.
|
||||||
|
summary=$(grep -h "<testsuite " "${files[@]}" \
|
||||||
|
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
|
||||||
|
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
|
||||||
|
read -r total skipped failures errors <<< "$summary"
|
||||||
|
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
|
||||||
|
{
|
||||||
|
echo "### Tests"
|
||||||
|
echo ""
|
||||||
|
echo "| total | skipped | failures | errors |"
|
||||||
|
echo "|---|---|---|---|"
|
||||||
|
echo "| $total | $skipped | $failures | $errors |"
|
||||||
|
echo ""
|
||||||
|
echo "Skipped are the tests that need a display, plus the deliberately"
|
||||||
|
echo "disabled reference-data generator. This job runs headless on purpose."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
|
||||||
|
echo "The result XML reports failures or errors." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Summarise the coverage
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# The last LINE/BRANCH counter in a JaCoCo XML is the report-level total; the
|
||||||
|
# per-package and per-class counters come before it.
|
||||||
|
percent() {
|
||||||
|
local file="$1" type="$2"
|
||||||
|
{ grep -o "<counter type=\"$type\"[^>]*/>" "$file" || true; } | tail -1 \
|
||||||
|
| sed 's/.*missed="\([0-9]*\)".*covered="\([0-9]*\)".*/\1 \2/' \
|
||||||
|
| awk 'NF == 2 { t=$1+$2; if (t==0) print "n/a"; else printf "%.1f%% (%d/%d)", 100*$2/t, $2, t }'
|
||||||
|
}
|
||||||
|
row() {
|
||||||
|
local label="$1" file="$2"
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
echo "| $label | $(percent "$file" LINE) | $(percent "$file" BRANCH) |"
|
||||||
|
else
|
||||||
|
echo "| $label | no report | no report |"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "### Coverage"
|
||||||
|
echo ""
|
||||||
|
echo "| module | lines | branches |"
|
||||||
|
echo "|---|---|---|"
|
||||||
|
row "core" core/build/reports/jacoco/test/jacocoTestReport.xml
|
||||||
|
row "app-desktop" app-desktop/build/reports/jacoco/test/jacocoTestReport.xml
|
||||||
|
row "core + app-desktop" build/reports/jacoco/aggregated/jacoco.xml
|
||||||
|
echo ""
|
||||||
|
echo "Report only: nothing fails on a percentage. The aggregated HTML report is the"
|
||||||
|
echo "coverage-report artifact of this run. i18n-generator is build tooling and is"
|
||||||
|
echo "not counted."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Keep the coverage report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: build/reports/jacoco/aggregated/html/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Keep the test reports on a failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: |
|
||||||
|
*/build/test-results/test/
|
||||||
|
*/build/reports/tests/test/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
@@ -16,16 +16,139 @@ env:
|
|||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows-zip:
|
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
|
||||||
name: Build Windows ZIP
|
# would be one more indirection to read, and the comments belong next to the steps they
|
||||||
runs-on: windows-latest
|
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
|
||||||
|
# no artifact (#62). Edit all three copies together -- except for the tag check
|
||||||
|
# right after the checkout, which only makes sense here.
|
||||||
|
test:
|
||||||
|
name: Test (Java 21, headless)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# composePackageVersion is the package version of every Compose target format,
|
||||||
|
# and the Linux package release below is derived from the tag alone. A tag that
|
||||||
|
# names a different X.Y.Z than gradle.properties would therefore ship packages
|
||||||
|
# whose version contradicts their file name and upgrade order (#113). Fail
|
||||||
|
# before the tests rather than after an hour of packaging. Skipped for
|
||||||
|
# workflow_dispatch, where the ref is a branch and carries no version.
|
||||||
|
- name: Verify the tag matches composePackageVersion
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$REF_NAME" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||||
|
TAG_VERSION="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$REF_NAME" =~ ^beta-v([0-9]+\.[0-9]+\.[0-9]+)-.+$ ]]; then
|
||||||
|
TAG_VERSION="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so it carries no package version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PROPERTY_VERSION="$(sed -n 's/^composePackageVersion=//p' gradle.properties)"
|
||||||
|
if [[ "$TAG_VERSION" != "$PROPERTY_VERSION" ]]; then
|
||||||
|
echo "::error::Tag $REF_NAME names version $TAG_VERSION, but gradle.properties has composePackageVersion=$PROPERTY_VERSION. Bump composePackageVersion (and version) before tagging, or move the tag."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Ensure gradlew is executable
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
# Skiko's native library links against libGL, so it has to be loadable even though
|
||||||
|
# nothing here renders through it: the drawing tests use Skia's software backend and
|
||||||
|
# runComposeUiTest never opens a window. Without this the whole Compose half of the
|
||||||
|
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
|
||||||
|
# file" -- which reads like a missing display and is not one. Found by running this
|
||||||
|
# job under act, where the container has no libGL; GitHub's own runner image happens
|
||||||
|
# to ship it, so the step is what makes the job reproducible off GitHub.
|
||||||
|
- name: Install the OpenGL client library skiko links against
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends libgl1
|
||||||
|
|
||||||
|
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
|
||||||
|
# runComposeUiTest reaches the semantics tree headless (measured before this job was
|
||||||
|
# written), and the ImageComposeScene drawing tests render in software. The handful
|
||||||
|
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
|
||||||
|
# skip here; the summary below prints the skip count, so that is visible rather than
|
||||||
|
# assumed.
|
||||||
|
#
|
||||||
|
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
|
||||||
|
# the hardest failure in this codebase to read, and importing that class of flake
|
||||||
|
# into CI would cost more than the few window tests are worth here.
|
||||||
|
|
||||||
|
# All three modules. The translation generator is a Gradle module and not buildSrc
|
||||||
|
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
|
||||||
|
# build` -- measured, not assumed. The summary below globs */build/test-results, so
|
||||||
|
# the module was counted the moment it existed.
|
||||||
|
- name: Test
|
||||||
|
run: ./gradlew -S test
|
||||||
|
|
||||||
|
- name: Summarise the test results
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(*/build/test-results/test/TEST-*.xml)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No test result XML was produced -- the test task did not run." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Counted from the XML and not from Gradle's console output: this project's own
|
||||||
|
# rule is never to infer "all tests passed" from an exit code.
|
||||||
|
summary=$(grep -h "<testsuite " "${files[@]}" \
|
||||||
|
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
|
||||||
|
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
|
||||||
|
read -r total skipped failures errors <<< "$summary"
|
||||||
|
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
|
||||||
|
{
|
||||||
|
echo "### Tests"
|
||||||
|
echo ""
|
||||||
|
echo "| total | skipped | failures | errors |"
|
||||||
|
echo "|---|---|---|---|"
|
||||||
|
echo "| $total | $skipped | $failures | $errors |"
|
||||||
|
echo ""
|
||||||
|
echo "Skipped are the tests that need a display, plus the deliberately"
|
||||||
|
echo "disabled reference-data generator. This job runs headless on purpose."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
|
||||||
|
echo "The result XML reports failures or errors." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Keep the test reports on a failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: |
|
||||||
|
*/build/test-results/test/
|
||||||
|
*/build/reports/tests/test/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-windows-zip:
|
||||||
|
name: Build Windows ZIP and MSI
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Java 21
|
||||||
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -34,86 +157,86 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: choco install wixtoolset --no-progress -y
|
run: choco install wixtoolset --no-progress -y
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
- name: Build app-image
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
.\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
|
||||||
if (-not $jar) {
|
|
||||||
throw "No project JAR produced"
|
|
||||||
}
|
|
||||||
Copy-Item $jar.FullName target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build app-image with jpackage
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
|
.\gradlew.bat -S :app-desktop:createDistributable
|
||||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||||
$addModules = & java packaging/AddModules.java
|
# The next step reads dist/KST4Contest, so the Compose output goes
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
|
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||||
jpackage `
|
if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
|
||||||
--type app-image `
|
Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
|
||||||
--name praktiKST `
|
|
||||||
--icon packaging/icons/kst4contest.ico `
|
|
||||||
--input target/dist-libs `
|
|
||||||
--main-jar app.jar `
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication `
|
|
||||||
--module-path target/dist-libs `
|
|
||||||
--add-modules $addModules `
|
|
||||||
--dest dist
|
|
||||||
|
|
||||||
- name: Create Windows ZIP
|
- name: Create Windows ZIP
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
if (-not (Test-Path dist/praktiKST)) {
|
if (-not (Test-Path dist/KST4Contest)) {
|
||||||
throw "No Windows app-image produced by jpackage"
|
throw "No Windows app-image produced by createDistributable"
|
||||||
}
|
}
|
||||||
Compress-Archive -Path dist/praktiKST -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
|
Compress-Archive -Path dist/KST4Contest -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
|
||||||
|
|
||||||
- name: Upload Windows artifact
|
- name: Upload Windows artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: windows-zip
|
name: windows-zip
|
||||||
path: dist/praktiKST-${{ github.ref_name }}-windows-x64.zip
|
path: dist/praktiKST-${{ github.ref_name }}-windows-x64.zip
|
||||||
|
|
||||||
|
# Stable tags only (issue #61): the MSI carries composePackageVersion
|
||||||
|
# from gradle.properties, so a nightly or beta MSI would have the same
|
||||||
|
# version as the following stable one. Windows Installer refuses to
|
||||||
|
# install an equal version over the installed one (jpackage's upgrade
|
||||||
|
# table only accepts strictly higher versions), so a beta tester could
|
||||||
|
# not move to the stable without uninstalling first. Betas and nightlies
|
||||||
|
# therefore stay ZIP-only. jpackage needs WiX 3.x, installed above. The
|
||||||
|
# ZIP is unchanged and stays alongside.
|
||||||
|
- name: Build Windows MSI installer
|
||||||
|
if: ${{ !startsWith(github.ref_name, 'beta-') }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
.\gradlew.bat -S :app-desktop:packageMsi
|
||||||
|
$msi = Get-ChildItem -Path app-desktop/build/compose/binaries/main/msi -Filter *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if (-not $msi) {
|
||||||
|
throw "No Windows MSI produced by packageMsi"
|
||||||
|
}
|
||||||
|
Copy-Item $msi.FullName dist/KST4Contest-${{ github.ref_name }}-windows-x64.msi
|
||||||
|
|
||||||
|
- name: Upload Windows MSI artifact
|
||||||
|
if: ${{ !startsWith(github.ref_name, 'beta-') }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: windows-msi
|
||||||
|
path: dist/KST4Contest-${{ github.ref_name }}-windows-x64.msi
|
||||||
|
|
||||||
build-linux-appimage:
|
build-linux-appimage:
|
||||||
name: Build Linux AppImage
|
name: Build Linux AppImage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
- name: Build app-image
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build app-image with jpackage
|
|
||||||
run: |
|
run: |
|
||||||
|
./gradlew -S :app-desktop:createDistributable
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
# The following steps read dist/KST4Contest, so the Compose output goes
|
||||||
jpackage \
|
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||||
--type app-image \
|
rm -rf dist/KST4Contest
|
||||||
--name KST4Contest \
|
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--dest dist
|
|
||||||
|
|
||||||
- name: Create AppDir metadata
|
- name: Create AppDir metadata
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
rm -rf target/KST4Contest.AppDir
|
rm -rf target/KST4Contest.AppDir
|
||||||
cp -a dist/KST4Contest target/KST4Contest.AppDir
|
cp -a dist/KST4Contest target/KST4Contest.AppDir
|
||||||
|
|
||||||
@@ -140,12 +263,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x target/appimagetool.AppImage
|
chmod +x target/appimagetool.AppImage
|
||||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||||
|
|
||||||
- name: Upload Linux artifact
|
- name: Upload Linux artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-appimage
|
name: linux-appimage
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
path: dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||||
@@ -153,13 +277,14 @@ jobs:
|
|||||||
build-linux-deb:
|
build-linux-deb:
|
||||||
name: Build Debian package
|
name: Build Debian package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -169,37 +294,53 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends fakeroot
|
sudo apt-get install -y --no-install-recommends fakeroot
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
# rpm and dpkg refuse to replace a package by one of equal version, and the
|
||||||
|
# version is composePackageVersion for beta and stable alike. The package
|
||||||
|
# RELEASE number tells them apart, following the Fedora pre-release convention:
|
||||||
|
# beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001, beta-v1.50.0-rc02 -> 1.50.0-0.rc02,
|
||||||
|
# v1.50.0 -> 1.50.0-1. Everything starting with "0." sorts below "1", so the
|
||||||
|
# stable installs as an upgrade over any beta (#113). The beta part is the tag
|
||||||
|
# remainder after beta-vX.Y.Z- with "-" turned into "."; rpm and dpkg accept
|
||||||
|
# only [A-Za-z0-9.] there, anything else fails this step. nightly-artifacts.yml
|
||||||
|
# does not set the property and keeps release 1, as do local builds.
|
||||||
|
- name: Derive the Linux package release from the tag
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
REF_TYPE: ${{ github.ref_type }}
|
||||||
run: |
|
run: |
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
set -euo pipefail
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
if [[ "$REF_TYPE" != "tag" ]]; then
|
||||||
|
RELEASE="1"
|
||||||
|
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
RELEASE="1"
|
||||||
|
elif [[ "$REF_NAME" =~ ^beta-v[0-9]+\.[0-9]+\.[0-9]+-(.+)$ ]]; then
|
||||||
|
RELEASE="0.${BASH_REMATCH[1]//-/.}"
|
||||||
|
else
|
||||||
|
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so no package release can be derived"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$RELEASE" =~ ^[A-Za-z0-9.]+$ ]]; then
|
||||||
|
echo "::error::Derived package release '$RELEASE' contains characters rpm and dpkg do not accept"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Linux package release for $REF_NAME: $RELEASE"
|
||||||
|
echo "LINUX_APP_RELEASE=$RELEASE" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build Debian package
|
- name: Build Debian package
|
||||||
run: |
|
run: |
|
||||||
|
./gradlew -S :app-desktop:packageDeb -PlinuxAppRelease="$LINUX_APP_RELEASE"
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
|
||||||
jpackage \
|
|
||||||
--type deb \
|
|
||||||
--name KST4Contest \
|
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
|
|
||||||
--dest dist
|
|
||||||
DEB="$(ls dist/*.deb | head -n 1)"
|
|
||||||
if [ -z "$DEB" ]; then
|
if [ -z "$DEB" ]; then
|
||||||
echo "No DEB produced by jpackage" && exit 1
|
echo "No DEB produced by packageDeb" && exit 1
|
||||||
fi
|
fi
|
||||||
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
|
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
|
||||||
|
|
||||||
- name: Upload Debian artifact
|
- name: Upload Debian artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-debian
|
name: linux-debian
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb
|
path: dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb
|
||||||
@@ -207,13 +348,14 @@ jobs:
|
|||||||
build-linux-rpm:
|
build-linux-rpm:
|
||||||
name: Build Fedora package
|
name: Build Fedora package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -223,37 +365,53 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends rpm
|
sudo apt-get install -y --no-install-recommends rpm
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
# rpm and dpkg refuse to replace a package by one of equal version, and the
|
||||||
|
# version is composePackageVersion for beta and stable alike. The package
|
||||||
|
# RELEASE number tells them apart, following the Fedora pre-release convention:
|
||||||
|
# beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001, beta-v1.50.0-rc02 -> 1.50.0-0.rc02,
|
||||||
|
# v1.50.0 -> 1.50.0-1. Everything starting with "0." sorts below "1", so the
|
||||||
|
# stable installs as an upgrade over any beta (#113). The beta part is the tag
|
||||||
|
# remainder after beta-vX.Y.Z- with "-" turned into "."; rpm and dpkg accept
|
||||||
|
# only [A-Za-z0-9.] there, anything else fails this step. nightly-artifacts.yml
|
||||||
|
# does not set the property and keeps release 1, as do local builds.
|
||||||
|
- name: Derive the Linux package release from the tag
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
REF_TYPE: ${{ github.ref_type }}
|
||||||
run: |
|
run: |
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
set -euo pipefail
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
if [[ "$REF_TYPE" != "tag" ]]; then
|
||||||
|
RELEASE="1"
|
||||||
|
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
RELEASE="1"
|
||||||
|
elif [[ "$REF_NAME" =~ ^beta-v[0-9]+\.[0-9]+\.[0-9]+-(.+)$ ]]; then
|
||||||
|
RELEASE="0.${BASH_REMATCH[1]//-/.}"
|
||||||
|
else
|
||||||
|
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so no package release can be derived"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$RELEASE" =~ ^[A-Za-z0-9.]+$ ]]; then
|
||||||
|
echo "::error::Derived package release '$RELEASE' contains characters rpm and dpkg do not accept"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Linux package release for $REF_NAME: $RELEASE"
|
||||||
|
echo "LINUX_APP_RELEASE=$RELEASE" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build Fedora package
|
- name: Build Fedora package
|
||||||
run: |
|
run: |
|
||||||
|
./gradlew -S :app-desktop:packageRpm -PlinuxAppRelease="$LINUX_APP_RELEASE"
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
|
||||||
jpackage \
|
|
||||||
--type rpm \
|
|
||||||
--name KST4Contest \
|
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
|
|
||||||
--dest dist
|
|
||||||
RPM="$(ls dist/*.rpm | head -n 1)"
|
|
||||||
if [ -z "$RPM" ]; then
|
if [ -z "$RPM" ]; then
|
||||||
echo "No RPM produced by jpackage" && exit 1
|
echo "No RPM produced by packageRpm" && exit 1
|
||||||
fi
|
fi
|
||||||
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
|
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
|
||||||
|
|
||||||
- name: Upload Fedora artifact
|
- name: Upload Fedora artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-fedora
|
name: linux-fedora
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm
|
path: dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm
|
||||||
@@ -261,13 +419,14 @@ jobs:
|
|||||||
build-linux-arch:
|
build-linux-arch:
|
||||||
name: Build Arch Linux package
|
name: Build Arch Linux package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -277,31 +436,21 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends zstd
|
sudo apt-get install -y --no-install-recommends zstd
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
- name: Build app-image
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build app-image with jpackage
|
|
||||||
run: |
|
run: |
|
||||||
|
./gradlew -S :app-desktop:createDistributable
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
# The following steps read dist/KST4Contest, so the Compose output goes
|
||||||
jpackage \
|
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||||
--type app-image \
|
rm -rf dist/KST4Contest
|
||||||
--name KST4Contest \
|
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--dest dist
|
|
||||||
|
|
||||||
- name: Build Arch Linux package artifact
|
- name: Build Arch Linux package artifact
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g')
|
PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g')
|
||||||
PKGROOT="target/archpkg"
|
PKGROOT="target/archpkg"
|
||||||
@@ -365,7 +514,7 @@ jobs:
|
|||||||
tar --zstd --transform 's|^\./||' -cf "dist/KST4Contest-${{ github.ref_name }}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
|
tar --zstd --transform 's|^\./||' -cf "dist/KST4Contest-${{ github.ref_name }}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
|
||||||
|
|
||||||
- name: Upload Arch Linux artifact
|
- name: Upload Arch Linux artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-arch
|
name: linux-arch
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-archlinux-*.pkg.tar.zst
|
path: dist/KST4Contest-${{ github.ref_name }}-archlinux-*.pkg.tar.zst
|
||||||
@@ -373,51 +522,44 @@ jobs:
|
|||||||
build-flatpak:
|
build-flatpak:
|
||||||
name: Build Flatpak
|
name: Build Flatpak
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Install Flatpak tooling
|
- name: Install Flatpak tooling
|
||||||
run: |
|
run: |
|
||||||
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 with jpackage
|
- name: Build app-image
|
||||||
run: |
|
run: |
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
./gradlew -S :app-desktop:createDistributable
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
mkdir -p target/flatpak-src
|
mkdir -p target/flatpak-src
|
||||||
ADD_MODULES="$(java packaging/AddModules.java)"
|
# The manifest's source is flatpak-src/KST4Contest.
|
||||||
jpackage \
|
rm -rf target/flatpak-src/KST4Contest
|
||||||
--type app-image \
|
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
|
||||||
--name KST4Contest \
|
|
||||||
--icon packaging/icons/kst4contest.png \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules "$ADD_MODULES" \
|
|
||||||
--dest target/flatpak-src
|
|
||||||
|
|
||||||
- name: Create Flatpak manifest
|
- name: Create Flatpak manifest
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||||
app-id: de.x08.KST4Contest
|
app-id: de.x08.KST4Contest
|
||||||
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:
|
||||||
@@ -470,6 +612,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Flatpak repo
|
- name: Build Flatpak repo
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p target
|
||||||
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
|
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
|
||||||
flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build "$OSTREE_BRANCH"
|
flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build "$OSTREE_BRANCH"
|
||||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
|
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
|
||||||
@@ -477,7 +620,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]
|
||||||
@@ -491,13 +634,13 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Upload flatpakref
|
- name: Upload flatpakref
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: flatpakref
|
name: flatpakref
|
||||||
path: dist/*.flatpakref
|
path: dist/*.flatpakref
|
||||||
|
|
||||||
- name: Upload Flatpak OSTree repo
|
- name: Upload Flatpak OSTree repo
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: flatpak-ostree-repo
|
name: flatpak-ostree-repo
|
||||||
path: target/flatpak-repo/
|
path: target/flatpak-repo/
|
||||||
@@ -505,6 +648,7 @@ jobs:
|
|||||||
build-macos-dmg:
|
build-macos-dmg:
|
||||||
name: Build macOS DMG (${{ matrix.os }})
|
name: Build macOS DMG (${{ matrix.os }})
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
needs: test
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -512,16 +656,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
- name: Ensure gradlew is executable
|
||||||
run: chmod +x mvnw
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Import signing certificate
|
- name: Import signing certificate
|
||||||
env:
|
env:
|
||||||
@@ -560,7 +704,7 @@ jobs:
|
|||||||
mv "$DMG" "dist/KST4Contest-${{ github.ref_name }}-macos-${ARCH}.dmg"
|
mv "$DMG" "dist/KST4Contest-${{ github.ref_name }}-macos-${ARCH}.dmg"
|
||||||
|
|
||||||
- name: Upload macOS artifact
|
- name: Upload macOS artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: macos-dmg-${{ matrix.os }}
|
name: macos-dmg-${{ matrix.os }}
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-macos-*.dmg
|
path: dist/KST4Contest-${{ github.ref_name }}-macos-*.dmg
|
||||||
@@ -571,7 +715,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install pandoc and LaTeX toolchain
|
- name: Install pandoc and LaTeX toolchain
|
||||||
run: |
|
run: |
|
||||||
@@ -637,7 +781,7 @@ jobs:
|
|||||||
github_docs/de-Changelog.md
|
github_docs/de-Changelog.md
|
||||||
|
|
||||||
- name: Upload PDF artifacts
|
- name: Upload PDF artifacts
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: docs-pdf
|
name: docs-pdf
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-manual-*.pdf
|
path: dist/KST4Contest-${{ github.ref_name }}-manual-*.pdf
|
||||||
@@ -646,6 +790,9 @@ jobs:
|
|||||||
name: Publish Flatpak OSTree Repo (${{ github.ref_name }})
|
name: Publish Flatpak OSTree Repo (${{ github.ref_name }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-flatpak
|
needs: build-flatpak
|
||||||
|
concurrency:
|
||||||
|
group: flatpak-repo-publish
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install Flatpak tooling
|
- name: Install Flatpak tooling
|
||||||
@@ -659,13 +806,13 @@ jobs:
|
|||||||
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
|
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Download OSTree repo artifact
|
- name: Download OSTree repo artifact
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: flatpak-ostree-repo
|
name: flatpak-ostree-repo
|
||||||
path: flatpak-ostree-repo/
|
path: flatpak-ostree-repo/
|
||||||
|
|
||||||
- name: Checkout existing flatpak-repo branch
|
- name: Checkout existing flatpak-repo branch
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: flatpak-repo
|
ref: flatpak-repo
|
||||||
path: existing-flatpak-repo
|
path: existing-flatpak-repo
|
||||||
@@ -680,7 +827,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]
|
||||||
@@ -714,53 +861,162 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout release source
|
- name: Checkout release source
|
||||||
uses: actions/checkout@v4.1.7
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
# Full history and tags, needed to list the commits since the previous release.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# GitHub's generated release notes list merged pull requests only. Commits
|
||||||
|
# pushed directly to main are added here as their own section, and the
|
||||||
|
# message of an annotated tag opens the body:
|
||||||
|
# <tag message> (annotated tags only; signature dropped)
|
||||||
|
# ## What's Changed
|
||||||
|
# ### Commits on main (first-parent commits, no merges, no bots)
|
||||||
|
# ### Merged PRs (GitHub's generated PR list)
|
||||||
|
# The body also feeds the in-app update feed (website/src/_data/versionInfo.js,
|
||||||
|
# cleanReleaseBody), which drops headings and keeps the "* ..." lines.
|
||||||
|
- name: Build release notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Stable releases compare against the previous stable release, betas
|
||||||
|
# against the previous tag of any kind.
|
||||||
|
if [[ "$TAG" == beta-* ]]; then
|
||||||
|
PREVIOUS_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||||
|
else
|
||||||
|
PREVIOUS_TAG="$(git describe --tags --abbrev=0 --exclude 'beta-*' "${TAG}^" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
echo "Previous tag: ${PREVIOUS_TAG:-<none>}"
|
||||||
|
|
||||||
|
RANGE="$TAG"
|
||||||
|
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# pull requests are covered by the PR list. Squash-merged PRs end in
|
||||||
|
# "(#123)" and are listed by GitHub as well.
|
||||||
|
git log --first-parent --no-merges --format='%an%x09%s' "$RANGE" \
|
||||||
|
| awk -F '\t' '$1 != "github-actions[bot]" { print $2 }' \
|
||||||
|
| grep -Ev '\(#[0-9]+\)$' \
|
||||||
|
| sed 's/^/* /' > direct-commits.md || true
|
||||||
|
|
||||||
|
# Let GitHub generate the PR part for the same range.
|
||||||
|
if [ -n "$PREVIOUS_TAG" ]; then
|
||||||
|
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
|
||||||
|
-f tag_name="$TAG" -f previous_tag_name="$PREVIOUS_TAG" --jq .body > generated-notes.md
|
||||||
|
else
|
||||||
|
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
|
||||||
|
-f tag_name="$TAG" --jq .body > generated-notes.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
import re
|
||||||
|
|
||||||
|
commits = open("direct-commits.md", encoding="utf-8").read().strip()
|
||||||
|
tag_message = open("tag-message.md", encoding="utf-8").read().strip()
|
||||||
|
generated = open("generated-notes.md", encoding="utf-8").read()
|
||||||
|
|
||||||
|
# GitHub's body: "## What's Changed" with the PR list, optionally
|
||||||
|
# "## New Contributors", and a closing "**Full Changelog**" line.
|
||||||
|
prs, rest = [], []
|
||||||
|
section = None
|
||||||
|
for line in generated.splitlines():
|
||||||
|
if re.match(r"^##\s+What's Changed", line):
|
||||||
|
section = "prs"
|
||||||
|
continue
|
||||||
|
if line.startswith("## ") or line.startswith("**Full Changelog**"):
|
||||||
|
section = "rest"
|
||||||
|
if section == "prs":
|
||||||
|
if line.strip():
|
||||||
|
prs.append(line)
|
||||||
|
elif line.strip() or rest:
|
||||||
|
rest.append(line)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if tag_message:
|
||||||
|
parts.append(tag_message)
|
||||||
|
if commits or prs:
|
||||||
|
parts.append("## What's Changed")
|
||||||
|
if commits:
|
||||||
|
parts.append("### Commits on main\n" + commits)
|
||||||
|
if prs:
|
||||||
|
parts.append("### Merged PRs\n" + "\n".join(prs))
|
||||||
|
if rest:
|
||||||
|
parts.append("\n".join(rest).strip())
|
||||||
|
|
||||||
|
with open("release-notes.md", "w", encoding="utf-8") as out:
|
||||||
|
out.write("\n\n".join(parts) + "\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
cat release-notes.md
|
||||||
|
|
||||||
- name: Download Windows artifact
|
- name: Download Windows artifact
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: windows-zip
|
name: windows-zip
|
||||||
path: release-assets/windows
|
path: release-assets/windows
|
||||||
|
|
||||||
|
- name: Download Windows MSI artifact
|
||||||
|
if: ${{ !startsWith(github.ref_name, 'beta-') }}
|
||||||
|
uses: actions/download-artifact@v8.0.2
|
||||||
|
with:
|
||||||
|
name: windows-msi
|
||||||
|
path: release-assets/windows
|
||||||
|
|
||||||
- name: Download Linux artifact
|
- name: Download Linux artifact
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: linux-appimage
|
name: linux-appimage
|
||||||
path: release-assets/linux
|
path: release-assets/linux
|
||||||
|
|
||||||
- name: Download macOS artifacts
|
- name: Download macOS artifacts
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
pattern: macos-dmg-*
|
pattern: macos-dmg-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
path: release-assets/macos
|
path: release-assets/macos
|
||||||
|
|
||||||
- name: Download Debian artifact
|
- name: Download Debian artifact
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: linux-debian
|
name: linux-debian
|
||||||
path: release-assets/debian
|
path: release-assets/debian
|
||||||
|
|
||||||
- name: Download Fedora artifact
|
- name: Download Fedora artifact
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: linux-fedora
|
name: linux-fedora
|
||||||
path: release-assets/fedora
|
path: release-assets/fedora
|
||||||
|
|
||||||
- name: Download Arch Linux artifact
|
- name: Download Arch Linux artifact
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: linux-arch
|
name: linux-arch
|
||||||
path: release-assets/archlinux
|
path: release-assets/archlinux
|
||||||
|
|
||||||
- name: Download flatpakref
|
- name: Download flatpakref
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: flatpakref
|
name: flatpakref
|
||||||
path: release-assets/flatpakref
|
path: release-assets/flatpakref
|
||||||
|
|
||||||
- name: Download PDF manuals
|
- name: Download PDF manuals
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v8.0.2
|
||||||
with:
|
with:
|
||||||
name: docs-pdf
|
name: docs-pdf
|
||||||
path: release-assets/docs
|
path: release-assets/docs
|
||||||
@@ -775,9 +1031,14 @@ jobs:
|
|||||||
allowUpdates: false
|
allowUpdates: false
|
||||||
replacesArtifacts: false
|
replacesArtifacts: false
|
||||||
makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }}
|
makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }}
|
||||||
generateReleaseNotes: true
|
bodyFile: release-notes.md
|
||||||
|
generateReleaseNotes: false
|
||||||
|
# The MSI entry is absent for beta tags (the Windows job skips it);
|
||||||
|
# artifactErrorsFailBuild is left at its default, so a missing path
|
||||||
|
# only warns and the release still gets every other asset.
|
||||||
artifacts: >-
|
artifacts: >-
|
||||||
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
|
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
|
||||||
|
release-assets/windows/KST4Contest-${{ github.ref_name }}-windows-x64.msi,
|
||||||
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
|
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
|
||||||
release-assets/debian/KST4Contest-${{ github.ref_name }}-debian-amd64.deb,
|
release-assets/debian/KST4Contest-${{ github.ref_name }}-debian-amd64.deb,
|
||||||
release-assets/fedora/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm,
|
release-assets/fedora/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm,
|
||||||
@@ -791,7 +1052,7 @@ jobs:
|
|||||||
# release. Otherwise the Releases API cannot return the release notes
|
# release. Otherwise the Releases API cannot return the release notes
|
||||||
# belonging to the tag which triggered this workflow.
|
# belonging to the tag which triggered this workflow.
|
||||||
- name: Set up Node.js for website build
|
- name: Set up Node.js for website build
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -822,7 +1083,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Upload verified website artifact
|
- name: Upload verified website artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: kst4contest-website-${{ github.ref_name }}
|
name: kst4contest-website-${{ github.ref_name }}
|
||||||
path: website/_site/
|
path: website/_site/
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: TEST publish-version-xml (local only)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-version-xml:
|
||||||
|
name: Publish Version Info XML via WebDAV
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Generate kst4ContestVersionInfo.xml from GitHub releases + history
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
python3 << 'PYEOF'
|
||||||
|
import subprocess, json
|
||||||
|
from xml.etree.ElementTree import Element, SubElement, ElementTree, parse, indent
|
||||||
|
|
||||||
|
def safe_version(tag):
|
||||||
|
cleaned = tag.lstrip('v')
|
||||||
|
try:
|
||||||
|
float(cleaned)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
import os, urllib.request
|
||||||
|
|
||||||
|
REPO = 'HamRadioOnline/kst4contest'
|
||||||
|
token = os.environ['GH_TOKEN']
|
||||||
|
|
||||||
|
def gh_get(path):
|
||||||
|
url = f'https://api.github.com/repos/{REPO}{path}'
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
'Authorization': f'Bearer {token}',
|
||||||
|
'Accept': 'application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
|
||||||
|
def get_issues_for_release(since, until):
|
||||||
|
"""Return closed issues in (since, until] categorised by label."""
|
||||||
|
LABEL_MAP = {'enhancement': 'added', 'bug': 'fixed'}
|
||||||
|
buckets = {'added': [], 'fixed': []}
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
items = gh_get(f'/issues?state=closed&per_page=100&page={page}')
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for issue in items:
|
||||||
|
if 'pull_request' in issue:
|
||||||
|
continue
|
||||||
|
closed = issue.get('closed_at') or ''
|
||||||
|
if not (since < closed <= until):
|
||||||
|
continue
|
||||||
|
labels = [l['name'] for l in issue['labels']]
|
||||||
|
bucket = next((LABEL_MAP[l] for l in labels if l in LABEL_MAP), None)
|
||||||
|
if bucket:
|
||||||
|
buckets[bucket].append(f'- {issue["title"]}')
|
||||||
|
if len(items) < 100:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return {k: '\n'.join(v) for k, v in buckets.items()}
|
||||||
|
|
||||||
|
def clean_body(body):
|
||||||
|
"""Strip GitHub markdown noise, keep plain text."""
|
||||||
|
import re
|
||||||
|
lines = []
|
||||||
|
for line in (body or '').splitlines():
|
||||||
|
if re.match(r'^#{1,3}\s', line):
|
||||||
|
continue
|
||||||
|
if line.strip().startswith('**Full Changelog**'):
|
||||||
|
continue
|
||||||
|
line = re.sub(r'\s+by @\S+ in https://\S+', '', line)
|
||||||
|
line = re.sub(r'^\* ', '- ', line)
|
||||||
|
if line.strip():
|
||||||
|
lines.append(line)
|
||||||
|
return re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)).strip()
|
||||||
|
|
||||||
|
# --- Fetch all releases ---
|
||||||
|
raw = gh_get('/releases?per_page=100')
|
||||||
|
gh_releases = [
|
||||||
|
{
|
||||||
|
'tagName': r['tag_name'],
|
||||||
|
'publishedAt': r['published_at'] or '',
|
||||||
|
'name': r['name'] or r['tag_name'],
|
||||||
|
'body': r['body'] or '',
|
||||||
|
'isPrerelease': r['prerelease'],
|
||||||
|
}
|
||||||
|
for r in raw
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Simulate a stable 1.41 release for testing ---
|
||||||
|
gh_releases.insert(0, {
|
||||||
|
'tagName': '1.41',
|
||||||
|
'publishedAt': '2026-06-30T10:00:00Z',
|
||||||
|
'name': 'Release 1.41',
|
||||||
|
'body': 'Release 1.41',
|
||||||
|
'isPrerelease': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
gh_releases.sort(key=lambda r: r['publishedAt'], reverse=True)
|
||||||
|
|
||||||
|
stable_releases = [r for r in gh_releases if not r['isPrerelease']]
|
||||||
|
stable = stable_releases[0] if stable_releases else None
|
||||||
|
|
||||||
|
gh_versions = {safe_version(r['tagName']) for r in stable_releases}
|
||||||
|
|
||||||
|
history_tree = parse('.github/version-history.xml')
|
||||||
|
history_entries = history_tree.getroot().findall('changeLog')
|
||||||
|
|
||||||
|
root = Element('praktiKST')
|
||||||
|
|
||||||
|
lv = SubElement(root, 'latestVersion')
|
||||||
|
latest_tag = stable['tagName'] if stable else 'unknown'
|
||||||
|
SubElement(lv, 'versionNumber').text = safe_version(latest_tag)
|
||||||
|
SubElement(lv, 'adminMessage').text = ''
|
||||||
|
latest_issues = get_issues_for_release('1970-01-01T00:00:00Z', stable['publishedAt']) if stable else {}
|
||||||
|
SubElement(lv, 'majorChanges').text = latest_issues.get('added', '')[:300]
|
||||||
|
win_filename = f'praktiKST-{latest_tag}-windows-x64.zip'
|
||||||
|
SubElement(lv, 'latestVersionPathOnWebserver').text = (
|
||||||
|
f'https://github.com/{REPO}/releases/download/{latest_tag}/{win_filename}'
|
||||||
|
)
|
||||||
|
|
||||||
|
nuf = SubElement(root, 'needUpdateSinceLastVersion')
|
||||||
|
SubElement(nuf, 'filename').text = win_filename
|
||||||
|
|
||||||
|
# Build changelog for each stable release using closed issues in its window
|
||||||
|
for i, rel in enumerate(stable_releases):
|
||||||
|
until = rel['publishedAt']
|
||||||
|
since = stable_releases[i + 1]['publishedAt'] if i + 1 < len(stable_releases) else '1970-01-01T00:00:00Z'
|
||||||
|
s = get_issues_for_release(since, until)
|
||||||
|
cl = SubElement(root, 'changeLog')
|
||||||
|
SubElement(cl, 'changedVersionNumber').text = safe_version(rel['tagName'])
|
||||||
|
SubElement(cl, 'date').text = until[:10]
|
||||||
|
SubElement(cl, 'description').text = rel['name']
|
||||||
|
SubElement(cl, 'added').text = s['added']
|
||||||
|
SubElement(cl, 'changed').text = clean_body(rel['body'])
|
||||||
|
SubElement(cl, 'fixed').text = s['fixed']
|
||||||
|
SubElement(cl, 'removed').text = ''
|
||||||
|
|
||||||
|
for entry in history_entries:
|
||||||
|
ver_node = entry.find('changedVersionNumber')
|
||||||
|
if ver_node is not None and ver_node.text and ver_node.text.strip() in gh_versions:
|
||||||
|
continue
|
||||||
|
root.append(entry)
|
||||||
|
|
||||||
|
indent(root, space=' ')
|
||||||
|
tree = ElementTree(root)
|
||||||
|
with open('kst4ContestVersionInfo.xml', 'w', encoding='utf-8') as f:
|
||||||
|
f.write('<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n')
|
||||||
|
tree.write(f, encoding='unicode', xml_declaration=False)
|
||||||
|
|
||||||
|
print('Generated kst4ContestVersionInfo.xml')
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
- name: Show generated XML (first 40 lines)
|
||||||
|
run: head -40 kst4ContestVersionInfo.xml
|
||||||
|
|
||||||
|
- name: Upload to WebDAV
|
||||||
|
run: |
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--user "${{ secrets.WEBDAV_USER }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||||
|
--upload-file kst4ContestVersionInfo.xml \
|
||||||
|
"${{ secrets.WEBDAV_URL }}/kst4ContestVersionInfo.xml"
|
||||||
+21
@@ -20,6 +20,12 @@ debug.out
|
|||||||
SimpleLogFile.txt
|
SimpleLogFile.txt
|
||||||
udpReaderBackup.txt
|
udpReaderBackup.txt
|
||||||
|
|
||||||
|
# Runtime/debug logs
|
||||||
|
Messagehistory.raw
|
||||||
|
Messagehistory-*.raw
|
||||||
|
kst4contest-errors.log
|
||||||
|
kst4contest-errors.log.lck
|
||||||
|
|
||||||
#tempfiles
|
#tempfiles
|
||||||
.idea/
|
.idea/
|
||||||
out/
|
out/
|
||||||
@@ -47,3 +53,18 @@ website/_site/
|
|||||||
|
|
||||||
# Apple notarization private keys - never commit these
|
# Apple notarization private keys - never commit these
|
||||||
*.p8
|
*.p8
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
core/build/
|
||||||
|
app-desktop/build/
|
||||||
|
|
||||||
|
# Agent scratch area - screenshots, notes, throwaway scripts
|
||||||
|
scratch/
|
||||||
|
|
||||||
|
# Kotlin incremental build data
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# JVM crash reports
|
||||||
|
hs_err_pid*.log
|
||||||
Vendored
BIN
Binary file not shown.
-18
@@ -1,18 +0,0 @@
|
|||||||
# Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
# or more contributor license agreements. See the NOTICE file
|
|
||||||
# distributed with this work for additional information
|
|
||||||
# regarding copyright ownership. The ASF licenses this file
|
|
||||||
# to you under the Apache License, Version 2.0 (the
|
|
||||||
# "License"); you may not use this file except in compliance
|
|
||||||
# with the License. You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing,
|
|
||||||
# software distributed under the License is distributed on an
|
|
||||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
# KIND, either express or implied. See the License for the
|
|
||||||
# specific language governing permissions and limitations
|
|
||||||
# under the License.
|
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.2/apache-maven-3.9.2-bin.zip
|
|
||||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
|
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# KST4Contest agent instructions
|
||||||
|
|
||||||
|
These project instructions extend Marc's global Codex working agreements.
|
||||||
|
|
||||||
|
## Project identity
|
||||||
|
|
||||||
|
KST4Contest is a Java/Kotlin desktop client for ON4KST chat, with a Compose Multiplatform user interface with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence.
|
||||||
|
|
||||||
|
Primary repository areas:
|
||||||
|
|
||||||
|
- `core/src/main/java/kst4contest/` and `core/src/test/`
|
||||||
|
- `app-desktop/src/main/java/kst4contest/` and `app-desktop/src/test/`
|
||||||
|
- `github_docs/`
|
||||||
|
- `website/`
|
||||||
|
- `docs/`
|
||||||
|
- `packaging/`
|
||||||
|
- `.github/`
|
||||||
|
- `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/`
|
||||||
|
|
||||||
|
Inspect the current tree before assuming an exact class/path still exists.
|
||||||
|
|
||||||
|
## Mandatory interaction rule
|
||||||
|
|
||||||
|
For every planned code or documentation implementation:
|
||||||
|
|
||||||
|
1. inspect first;
|
||||||
|
2. explain the task understanding in German;
|
||||||
|
3. identify and ask all relevant implementation questions;
|
||||||
|
4. wait for answers when needed;
|
||||||
|
5. present the final concept in German;
|
||||||
|
6. state what must remain unchanged;
|
||||||
|
7. request explicit concept approval;
|
||||||
|
8. wait;
|
||||||
|
9. implement only after approval.
|
||||||
|
|
||||||
|
If an answer is uncertain, do not interpolate it. Check current code/tests/docs/project context first and ask Marc when the uncertainty can affect behaviour.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
- Communicate with Marc in German.
|
||||||
|
- Write source-code comments and Javadoc exclusively in English.
|
||||||
|
- Keep log/protocol/API literals in their canonical form.
|
||||||
|
- Commit messages are concise English when a commit is explicitly requested.
|
||||||
|
- User-facing DE/EN documentation follows `$praktimarc-writing-style`.
|
||||||
|
|
||||||
|
## Java and user-interface architecture
|
||||||
|
|
||||||
|
- Preserve or improve separation between network/parsing/service/controller/UI responsibilities.
|
||||||
|
- Do not solve architecture problems by letting worker/model code directly manipulate user-interface collections.
|
||||||
|
- Active chat-member domain state is conceptually thread-safe state and lives in an `ObservableRoster` in `core`; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store.
|
||||||
|
- `MessageBusManagementThread` must not directly read or mutate the row lists the user interface draws from; it works on snapshots.
|
||||||
|
- Route UI-visible mutations through the controller and the `UiDispatcher` (`AwtUiDispatcher` hands them to the AWT event dispatch thread, where Compose draws). Do not reach for a toolkit thread call such as `Platform.runLater` or `EventQueue.invokeLater` in `core`; it has no toolkit dependency.
|
||||||
|
- Prefer explicit DTOs over records when introducing transport/parser DTOs in this codebase unless the approved concept says otherwise.
|
||||||
|
- Handle incomplete external/historical data defensively.
|
||||||
|
- `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero.
|
||||||
|
- Unexpected input must not terminate message-processing or UI threads.
|
||||||
|
|
||||||
|
## Callsign and category identity
|
||||||
|
|
||||||
|
- Preserve full callsign variants as distinct chat-member identities where the server exposes them separately.
|
||||||
|
- Category is part of chat identity. Do not merge messages across categories.
|
||||||
|
- Base-call normalization may be used only for explicitly base-call-wide features such as worked status or monitoring rules.
|
||||||
|
- Worked status is shared across suffix variants of the same base call.
|
||||||
|
- Monitoring a callsign variant such as `DN9APW-2` or `DN9APW-70` is intended to monitor the base call `DN9APW`, so users do not need to enter every SSID.
|
||||||
|
- Do not generalize suffix semantics beyond behaviour explicitly established by the current code/specification.
|
||||||
|
|
||||||
|
## Bands and availability
|
||||||
|
|
||||||
|
- ON4KST categories 2 and 3 are central to the normal VHF/UHF workflow, but other category values can occur and must fail safely.
|
||||||
|
- Do not let unsupported categories produce exceptions.
|
||||||
|
- Known-active-band logic and `B+` interpretation must remain consistent across the application.
|
||||||
|
- Band information parsed from names/text must respect explicit `NOT-QRV` information; NOT-QRV overrides positive availability hints.
|
||||||
|
- Do not silently fall back to a fixed band/frequency when a required decision is ambiguous unless an approved fallback exists.
|
||||||
|
- Manual band settings and actual current QRG must remain consistent with features that depend on frequency.
|
||||||
|
|
||||||
|
## External protocols and integrations
|
||||||
|
|
||||||
|
Before changing ON4KST, AirScout, UCXLog/DXLog, Win-Test, PSTRotator or DXCluster handling:
|
||||||
|
|
||||||
|
- inspect the current implementation;
|
||||||
|
- preserve exact framing and compatibility;
|
||||||
|
- inspect current tests;
|
||||||
|
- check authoritative upstream documentation when the protocol detail is uncertain;
|
||||||
|
- ask Marc if more than one behaviour is plausible.
|
||||||
|
|
||||||
|
Specific invariants and historical context are in `$kst4contest-change` references.
|
||||||
|
|
||||||
|
Never change CR/LF, XML framing, callsign normalization, frequency formatting or port/transport assumptions casually.
|
||||||
|
|
||||||
|
## UI behaviour
|
||||||
|
|
||||||
|
- Preserve contest workflow speed and discoverability.
|
||||||
|
- Do not change zoom, selection, focus, sorting, tab choice or prefilled text as an incidental side effect.
|
||||||
|
- Map reset behaviour should clear the selected target without changing the zoom unless a new task explicitly changes this.
|
||||||
|
- New station selection should preserve the established `/cq callsign` prefill behaviour.
|
||||||
|
- If no send category is selected, preserve the established Main-category fallback unless explicitly changed.
|
||||||
|
- Null/unknown data must render as unavailable/empty according to current UI conventions, not as fake zero values.
|
||||||
|
|
||||||
|
## Map
|
||||||
|
|
||||||
|
- The station map is a Compose canvas. It fetches its tiles directly; the former JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the `TileProxyServer` that worked around the WebView's TLS behaviour in AppImage and Flatpak packaging are all gone.
|
||||||
|
- Map reset clears the selected target without changing the zoom. Offline DEM, terrain packages and the profile cache must keep working unchanged.
|
||||||
|
|
||||||
|
## Autoanswer / beacon safety
|
||||||
|
|
||||||
|
- Prevent automated-message loops.
|
||||||
|
- Respect the established minimum interval/cooldown logic.
|
||||||
|
- Do not consume a cooldown for a reply that is rejected before a complete valid TX item is queued.
|
||||||
|
- Preserve priority of frequency/QRG requests where established.
|
||||||
|
- Cooldown identity must not accidentally collapse unrelated callsign/category identities.
|
||||||
|
- Treat the current implementation/tests as the source of truth for exact message markers and timer details.
|
||||||
|
|
||||||
|
## Build and verification
|
||||||
|
|
||||||
|
Use the Gradle wrapper.
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.\gradlew.bat ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the current `gradle.properties` and `gradle/libs.versions.toml` before relying on version numbers.
|
||||||
|
|
||||||
|
At this context snapshot the project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x, with JUnit 5/Mockito, PMD and SpotBugs integrated. JavaFX is gone; there is no `org.openjfx` dependency left.
|
||||||
|
|
||||||
|
Important: a test failure fails the build, but PMD and SpotBugs run with `ignoreFailures`, so static-analysis findings do not. Test classes live under `src/test` only (since #62; before that a few sat under `src/main` and were never run), and JUnit/Mockito are test-only dependencies. Therefore:
|
||||||
|
|
||||||
|
- inspect the Gradle test summary;
|
||||||
|
- inspect the test-results XML under `*/build/test-results/` when needed;
|
||||||
|
- do not infer "all tests passed" from exit code 0 -- read the XML;
|
||||||
|
- report PMD/SpotBugs findings that are visible in the relevant build. Both tools write XML and HTML: `<module>/build/reports/pmd/main.{xml,html}` and `<module>/build/reports/spotbugs/main.{xml,html}` (since #110; before that SpotBugs printed to the console only). Count from the XML (`<violation`, `<BugInstance`), not from the console. The PR workflow runs `pmdMain spotbugsMain`, prints the counts per module into the job summary and publishes the reports as the `static-analysis` artifact.
|
||||||
|
|
||||||
|
Run focused tests first, then normally the relevant broader test/build command for the scope.
|
||||||
|
|
||||||
|
## Documentation and durable project context
|
||||||
|
|
||||||
|
Use `$software-project-context`, `$kst4contest-documentation`, and `$praktimarc-writing-style` as relevant.
|
||||||
|
|
||||||
|
Do not perform a full manual or website audit after every implementation.
|
||||||
|
|
||||||
|
After a completed change:
|
||||||
|
|
||||||
|
1. perform a short documentation-impact classification;
|
||||||
|
2. if user-visible behaviour is plausibly affected, search only the relevant German and English manual sections under `github_docs/`;
|
||||||
|
3. keep both language versions semantically aligned when an update is required;
|
||||||
|
4. check README and website feature text only when the changed feature/configuration is represented there or is likely to need representation;
|
||||||
|
5. identify screenshots that are likely stale instead of fabricating replacements;
|
||||||
|
6. update `docs/PROJECT_CONTEXT.md` for significant architectural, protocol, state/persistence, operational, integration, deployment, workaround, or long-lived behavioural decisions;
|
||||||
|
7. keep `Related Projects / Integration Points` current when KST4Contest, its website, hamradioonline infrastructure or planned propagation services affect one another.
|
||||||
|
|
||||||
|
A full documentation audit is reserved for explicit audit requests, major release preparation, broad UI/workflow changes, or evidence that documentation is broadly stale.
|
||||||
|
|
||||||
|
## Website
|
||||||
|
|
||||||
|
The repository contains an Eleventy-based website under `website/` with its own tests/build logic.
|
||||||
|
|
||||||
|
Do not assume website deployment/update-feed details; inspect current scripts/workflows before changing them.
|
||||||
|
|
||||||
|
## Change scope and Git
|
||||||
|
|
||||||
|
- No unrelated refactoring.
|
||||||
|
- No production dependency without prior approval.
|
||||||
|
- No automatic version bump.
|
||||||
|
- No commit/push/merge/tag/release/deploy without separate explicit authorization.
|
||||||
|
- Preserve deliberate test data and easter eggs unless explicitly changed.
|
||||||
|
- Never overwrite unrelated working-tree changes.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
After implementation, report in German:
|
||||||
|
|
||||||
|
- understanding fulfilled;
|
||||||
|
- changed files;
|
||||||
|
- important design decisions;
|
||||||
|
- tests/builds and exact results;
|
||||||
|
- documentation-impact classification;
|
||||||
|
- manual/website/README/context updates made or why none were necessary;
|
||||||
|
- related-project impact when relevant;
|
||||||
|
- remaining uncertainty;
|
||||||
|
- suggested next action, without performing it automatically.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Diese Datei gilt für Claude Code in diesem Repository.
|
||||||
|
|
||||||
|
## Projektregeln
|
||||||
|
|
||||||
|
Die fachlichen und stilistischen Regeln stehen in **[AGENTS.md](AGENTS.md)** und
|
||||||
|
**[docs/PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md)**. Beide sind verbindlich und
|
||||||
|
werden hier nicht wiederholt. Besonders wichtig: Kommunikation auf Deutsch,
|
||||||
|
Quelltextkommentare und Javadoc auf Englisch, und das Konzept-vor-Umsetzung-Vorgehen
|
||||||
|
aus dem Abschnitt „Mandatory interaction rule" in `AGENTS.md`.
|
||||||
|
|
||||||
|
## Subagenten
|
||||||
|
|
||||||
|
Subagenten (Agent-Tool) dürfen ohne Rückfrage genutzt werden, wenn eine Aufgabe
|
||||||
|
davon profitiert — insbesondere für Abschlussprüfungen ganzer Zweige und für
|
||||||
|
umfangreiche Umbauten, bei denen ein frischer Blick pro Teilaufgabe etwas wert ist.
|
||||||
|
|
||||||
|
Nicht sinnvoll bei kleinen, aufeinander aufbauenden Schritten: dort liest jeder
|
||||||
|
neue Subagent die Codebasis von null und kostet mehr, als er beiträgt.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Gradle, nicht Maven. `./gradlew clean build`, Java 21. Das Projekt besteht aus
|
||||||
|
drei Modulen: `core` (Fachlichkeit), `app-desktop` (Oberfläche) und
|
||||||
|
`i18n-generator` (reines Bauwerkzeug, das aus den Übersetzungsdateien Kotlin
|
||||||
|
erzeugt und **nicht ausgeliefert** wird). Paketnamen sind über `core` und
|
||||||
|
`app-desktop` hinweg dieselben.
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
Kommunikation auf Deutsch, in **neuer Rechtschreibung** — `muss` und `dass`,
|
||||||
|
nicht `muß` und `daß`. Quelltextkommentare und Javadoc bleiben ausschließlich Englisch.
|
||||||
|
|
||||||
|
Oberflächentexte stehen seit Etappe 9 in `app-desktop/src/main/i18n/strings_<code>.properties`
|
||||||
|
und nicht mehr im Quelltext. Wer dort etwas ändert: der Erzeuger prüft beim Bau, und die
|
||||||
|
Regel, was übersetzt wird, steht in `docs/PROJECT_CONTEXT.md` unter „Important Decisions".
|
||||||
|
|
||||||
|
## Laufende Arbeit
|
||||||
|
|
||||||
|
Spezifikationen liegen unter `docs/superpowers/specs/` und bleiben: sie sagen, **warum** die
|
||||||
|
Architektur so ist, und das steht nirgends sonst. Umsetzungspläne werden nach der Ausführung
|
||||||
|
entfernt — sie sind Arbeitsanweisungen für erledigte Arbeit, und ein liegengebliebener Plan
|
||||||
|
liest sich für den Nächsten wie eine offene Absicht. Was aus ihnen zu behalten war, steht in
|
||||||
|
den Commit-Nachrichten und in `docs/PROJECT_CONTEXT.md`. Die Ablösung von JavaFX durch Compose Multiplatform ist
|
||||||
|
**abgeschlossen**: unter `src/main` steht kein `javafx.` mehr, und `org.openjfx` ist
|
||||||
|
aus `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml` entfernt. Die
|
||||||
|
Begründungen und die harten Lehren stehen in
|
||||||
|
`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`; die wichtigste: ein
|
||||||
|
blockierendes Warten auf dem AWT-Ereignisfaden verklemmt die Oberfläche, und der grüne
|
||||||
|
Build sagt über Fadenzugehörigkeit nichts.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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/).
|
||||||
@@ -57,26 +57,24 @@ Changes to operating behaviour should be documented together with their purpose
|
|||||||
|
|
||||||
## Building from source
|
## Building from source
|
||||||
|
|
||||||
Building KST4Contest requires JDK 21. The Maven Wrapper included in the repository should be used, so a separate Maven installation is not required.
|
Building KST4Contest requires JDK 21. The Gradle Wrapper included in the repository should be used, so a separate Gradle installation is not required.
|
||||||
|
|
||||||
Linux and macOS:
|
Linux and macOS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./mvnw clean test
|
./gradlew clean build
|
||||||
./mvnw -B -DskipTests compile
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
mvnw.cmd clean test
|
.\gradlew.bat clean build
|
||||||
mvnw.cmd -B -DskipTests compile
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Repository structure
|
## Repository structure
|
||||||
|
|
||||||
- `src/main/java/` – application source code
|
- `core/` – domain, controller and network code, free of user-interface technology
|
||||||
- `src/test/` – automated tests
|
- `app-desktop/` – the Compose Multiplatform user interface and the packaging tasks
|
||||||
- `github_docs/` – German and English manual sources
|
- `github_docs/` – German and English manual sources
|
||||||
- `website/` – project website sources
|
- `website/` – project website sources
|
||||||
- `packaging/` – platform-specific packaging files
|
- `packaging/` – platform-specific packaging files
|
||||||
@@ -85,14 +83,23 @@ mvnw.cmd -B -DskipTests compile
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml)
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml)
|
||||||
|
|
||||||
### Builds
|
### Builds
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
KST4Contest is distributed under the [GNU General Public License v3.0](LICENSE).
|
KST4Contest is distributed under the [GNU General Public License v3.0](LICENSE).
|
||||||
|
|
||||||
|
## Supporters/Sponsor
|
||||||
|
|
||||||
|
This Project is possible due to donations of the community.
|
||||||
|
|
||||||
|
You can sponsor KST4Contest here: [Support on Ko-Fi](https://ko-fi.com/praktimarc). If you add an Display Name there you will be mentioned here.
|
||||||
|
|
||||||
|
The following people are sponsoring KST4Contest:
|
||||||
|
- Marco (DJ5KW)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
plugins {
|
||||||
|
java
|
||||||
|
alias(libs.plugins.kotlinJvm)
|
||||||
|
alias(libs.plugins.composeCompiler)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// JUnit and Mockito are test-only. Until #62 a few test classes sat under src/main/java
|
||||||
|
// and dragged both -- plus byte-buddy and objenesis -- into lib/app of every app image.
|
||||||
|
implementation(libs.jetbrains.annotations)
|
||||||
|
implementation(project(":core"))
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(libs.sqlite.jdbc)
|
||||||
|
implementation(libs.jlayer)
|
||||||
|
/*
|
||||||
|
* The Compose UI test harness, used through runComposeUiTest { } from ordinary Jupiter
|
||||||
|
* tests. Deliberately NOT ui-test-junit4: that one brings the vintage engine, and two
|
||||||
|
* engines on one classpath is how half a suite silently stops being run. Checked after
|
||||||
|
* adding this: the only engine resolved is junit-jupiter-engine. JUnit 4 classes are on
|
||||||
|
* the test classpath, but they were before this too — something else pulls them
|
||||||
|
* transitively — and with no vintage engine nothing executes them.
|
||||||
|
*
|
||||||
|
* The accessor is marked experimental by the Compose Gradle plugin, so the opt-in sits
|
||||||
|
* here — one place, for one dependency. What it buys is the only mechanism that can
|
||||||
|
* answer "did the value reach the screen": the state-level tests and the two settings
|
||||||
|
* coverage nets check that a setter is called, which is a different question.
|
||||||
|
*/
|
||||||
|
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
|
||||||
|
testImplementation(compose.uiTest)
|
||||||
|
testImplementation(libs.junit.jupiter)
|
||||||
|
testImplementation(libs.mockito.core)
|
||||||
|
testImplementation(libs.mockito.junit.jupiter)
|
||||||
|
// Gradle 9 no longer adds the launcher implicitly.
|
||||||
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The translation files are turned into Kotlin before anything compiles, by the
|
||||||
|
* :i18n-generator module. Two consequences worth naming:
|
||||||
|
*
|
||||||
|
* - A broken translation fails `./gradlew classes`, which is what the PR workflow already
|
||||||
|
* runs -- so a contributor's mistake is reported in their pull request without a new
|
||||||
|
* workflow existing for it.
|
||||||
|
* - The generator is reached through its own configuration, not through `implementation`, so
|
||||||
|
* it stays out of the application's runtime classpath and out of the jar.
|
||||||
|
*/
|
||||||
|
val i18nGenerator: Configuration by configurations.creating
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
i18nGenerator(project(":i18n-generator"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val generatedI18nDirectory = layout.buildDirectory.dir("generated/i18n")
|
||||||
|
|
||||||
|
val generateStrings by tasks.registering(JavaExec::class) {
|
||||||
|
description = "Generates Strings.kt from app-desktop/src/main/i18n"
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
classpath = i18nGenerator
|
||||||
|
mainClass.set("kst4contest.i18n.GeneratorMain")
|
||||||
|
|
||||||
|
val input = layout.projectDirectory.dir("src/main/i18n")
|
||||||
|
// A local copy: the provider below must not capture the script-level val, which
|
||||||
|
// drags the build script object into the configuration cache and fails to serialize.
|
||||||
|
val output = generatedI18nDirectory
|
||||||
|
inputs.dir(input)
|
||||||
|
outputs.dir(output)
|
||||||
|
|
||||||
|
argumentProviders.add(CommandLineArgumentProvider {
|
||||||
|
listOf(input.asFile.absolutePath, output.get().asFile.absolutePath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin.sourceSets["main"].kotlin.srcDir(generatedI18nDirectory)
|
||||||
|
|
||||||
|
tasks.named("compileKotlin") { dependsOn(generateStrings) }
|
||||||
|
|
||||||
|
compose.desktop {
|
||||||
|
application {
|
||||||
|
// Main forwards to Kst4ContestApplication.main. It was a workaround for
|
||||||
|
// launching a JavaFX Application subclass from the classpath; it stays because
|
||||||
|
// the packaged launchers and the AUR/Flatpak wrappers name this class.
|
||||||
|
mainClass = "kst4contest.view.Main"
|
||||||
|
|
||||||
|
nativeDistributions {
|
||||||
|
/*
|
||||||
|
* Only the formats the building machine can actually produce. Compose rejects a
|
||||||
|
* foreign one while CONFIGURING the project, not while packaging it, so a list
|
||||||
|
* naming all five made every Gradle task fail on macOS and Windows — including
|
||||||
|
* `run` and `test`, which package nothing. The build was Linux-only without
|
||||||
|
* anyone having decided that.
|
||||||
|
*/
|
||||||
|
val host = org.gradle.internal.os.OperatingSystem.current()
|
||||||
|
targetFormats(
|
||||||
|
*when {
|
||||||
|
host.isMacOsX -> arrayOf(
|
||||||
|
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
|
||||||
|
)
|
||||||
|
host.isWindows -> arrayOf(
|
||||||
|
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
|
||||||
|
)
|
||||||
|
else -> arrayOf(
|
||||||
|
org.jetbrains.compose.desktop.application.dsl.TargetFormat.AppImage,
|
||||||
|
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
|
||||||
|
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/*
|
||||||
|
* jlink must come from JDK 21, not from whatever the Gradle JVM is.
|
||||||
|
* jdk.jsobject was removed after 21, and this machine has 26 on PATH;
|
||||||
|
* Etappe 1 hit the same wall with jpackage.
|
||||||
|
*/
|
||||||
|
javaHome = javaToolchains.launcherFor {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(21))
|
||||||
|
}.get().metadata.installationPath.asFile.absolutePath
|
||||||
|
|
||||||
|
/*
|
||||||
|
* KST4Contest and not praktiKST: this one name drives the app-image
|
||||||
|
* directory, the launcher, the .app bundle, the bundled icon file
|
||||||
|
* and -- lowercased -- the deb/rpm package name. Every packaging
|
||||||
|
* consumer already uses KST4Contest there (AppRun, the Arch and
|
||||||
|
* Flatpak wrappers, the AUR PKGBUILDs, build-signed-dmg.sh), and the
|
||||||
|
* shipped deb/rpm must keep the package identity "kst4contest" so an
|
||||||
|
* installed copy still upgrades.
|
||||||
|
*/
|
||||||
|
packageName = "KST4Contest"
|
||||||
|
packageVersion = providers.gradleProperty("composePackageVersion").get()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kept from the former module-info.java. jdk.jsobject went with the map
|
||||||
|
* bridge: it carried netscape.javascript, and nothing imports that any
|
||||||
|
* more. jdk.unsupported stays — its justification used to be the JavaFX
|
||||||
|
* Marlin renderer, but sun.misc.Unsafe is reached by other dependencies
|
||||||
|
* too, and dropping it is a separate decision from removing JavaFX.
|
||||||
|
*/
|
||||||
|
modules(
|
||||||
|
"java.desktop", "java.net.http", "java.sql",
|
||||||
|
"jdk.crypto.ec", "jdk.net",
|
||||||
|
"jdk.xml.dom", "jdk.unsupported",
|
||||||
|
)
|
||||||
|
|
||||||
|
linux {
|
||||||
|
iconFile.set(rootProject.file("packaging/icons/kst4contest.png"))
|
||||||
|
/*
|
||||||
|
* Package release number behind the version (1.50.0-<release>).
|
||||||
|
* The version itself is composePackageVersion for every target
|
||||||
|
* format, so a beta DEB/RPM would otherwise carry exactly the
|
||||||
|
* version of the following stable one -- and dnf, rpm -U and
|
||||||
|
* dpkg refuse to replace a package by one of equal version, so
|
||||||
|
* a beta user could not move to the stable (#113). The tagged
|
||||||
|
* release workflow therefore passes -PlinuxAppRelease following
|
||||||
|
* the Fedora pre-release convention:
|
||||||
|
*
|
||||||
|
* beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001
|
||||||
|
* beta-v1.50.0-rc02 -> 1.50.0-0.rc02
|
||||||
|
* v1.50.0 -> 1.50.0-1
|
||||||
|
*
|
||||||
|
* Everything starting with "0." sorts below "1" for both rpm
|
||||||
|
* and dpkg. Nightlies and local builds do not set the property
|
||||||
|
* and keep release 1.
|
||||||
|
*/
|
||||||
|
appRelease = providers.gradleProperty("linuxAppRelease").orElse("1").get()
|
||||||
|
}
|
||||||
|
windows {
|
||||||
|
iconFile.set(rootProject.file("packaging/icons/kst4contest.ico"))
|
||||||
|
/*
|
||||||
|
* The MSI installer is built for tagged releases only; nightlies ship
|
||||||
|
* the portable ZIP alone, because an MSI always carries the plain
|
||||||
|
* composePackageVersion and two nightly MSIs could not be told apart.
|
||||||
|
* It installs per user, so it needs neither administrator rights nor
|
||||||
|
* a UAC prompt, and it adds a Start menu group and a desktop shortcut.
|
||||||
|
*
|
||||||
|
* upgradeUuid must NEVER change: Windows Installer uses it to
|
||||||
|
* recognise an installed KST4Contest and replace it in place when a
|
||||||
|
* newer MSI runs. A different value would install the new release
|
||||||
|
* side by side with the old one instead of upgrading it.
|
||||||
|
*/
|
||||||
|
upgradeUuid = "3375071a-25ba-468e-9ae5-8296bc535462"
|
||||||
|
menu = true
|
||||||
|
menuGroup = "KST4Contest"
|
||||||
|
shortcut = true
|
||||||
|
perUserInstall = true
|
||||||
|
dirChooser = true
|
||||||
|
}
|
||||||
|
macOS {
|
||||||
|
iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
|
||||||
|
// Must stay de.x08.KST4Contest: build-signed-dmg.sh signs against it.
|
||||||
|
bundleID = "de.x08.KST4Contest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
# German. Held complete by a test, because this is a shipped language.
|
||||||
|
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
|
||||||
|
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
|
||||||
|
airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen.
|
||||||
|
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
|
||||||
|
airscout.enabled=AirScout-UDP-Anbindung aktivieren (AirScout 0.9.9.5 oder neuer)
|
||||||
|
airscout.forcedBand=Fester AirScout-Bandwert [1440000 = 144 MHz]:
|
||||||
|
airscout.frequencySection=Abgefragte Frequenz
|
||||||
|
airscout.serverIdentifier=AirScout-Serverkennung [AS]:
|
||||||
|
airscout.udpPort=AirScout-UDP-Port [9872] — nach Änderung neu verbinden:
|
||||||
|
airscout.udpSection=AirScout-UDP-Kommunikation
|
||||||
|
alert.ok=OK
|
||||||
|
beacon.enabled=Die Bake in dieser Kategorie senden
|
||||||
|
beacon.intervalMinutes=Abstand in ganzen Minuten
|
||||||
|
beacon.intervalSection=Gemeinsamer Abstand
|
||||||
|
beacon.message=Bakentext
|
||||||
|
beacon.sectionFor=CQ-Bake für {0}
|
||||||
|
connection.connect=Verbinden
|
||||||
|
connection.connectTo=Mit {0} verbinden
|
||||||
|
connection.disconnect=Trennen
|
||||||
|
connection.disconnectAndCloseChat=Trennen & Chat schließen
|
||||||
|
dialog.cancel=Abbrechen
|
||||||
|
dialog.ok=OK
|
||||||
|
gui.bandColumnHints=Hinweise in den Bandspalten
|
||||||
|
gui.language=Sprache
|
||||||
|
gui.language.system=Systemsprache
|
||||||
|
gui.startupDesign=Design beim Start
|
||||||
|
gui.startupFilters=Beim Start aktive Nachrichtenfilter
|
||||||
|
gui.windows=Fenster
|
||||||
|
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
|
||||||
|
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
|
||||||
|
logSynch.fileLabel=Zu überwachende Logdatei:
|
||||||
|
logSynch.fileSection=Dateiabfrage für gearbeitete Rufzeichen
|
||||||
|
logSynch.networkEnabled=QSO-Nachrichten von N1MM+, QARTEST, UCXLog und DXLog.net verarbeiten
|
||||||
|
logSynch.networkSection=Netzbasierter QSO-Abgleich (N1MM/QARTEST/UCXLog/DXLog)
|
||||||
|
logSynch.sharedPort=Gemeinsamer UDP-Port für QSO- und TRX-Nachrichten [Vorgabe 12060]:
|
||||||
|
logSynch.winTestBroadcast=UDP-Broadcast-Adresse für Win-Test (Vorgabe = Broadcast der Internetschnittstelle)
|
||||||
|
logSynch.winTestEnabled=Win-Test-UDP-Lognachrichten aus dem Netz empfangen
|
||||||
|
logSynch.winTestHint=Die Broadcast-Adresse wird einmal ermittelt, solange sie noch {0} ist. Eine Portänderung wirkt auf den laufenden Empfänger, sobald das Feld den Fokus verliert.
|
||||||
|
logSynch.winTestPort=UDP-Port für den Win-Test-Empfänger (Vorgabe ist 9871)
|
||||||
|
logSynch.winTestSection=Win-Test-Netzwerkempfänger
|
||||||
|
logSynch.winTestStationName=KST-Stationsname im Win-Test-Netz (Quelle der SKED-Pakete)
|
||||||
|
menu.about=Über...
|
||||||
|
menu.contactAuthor=Den Autor per Standard-Mailprogramm anschreiben
|
||||||
|
menu.disconnect=Trennen
|
||||||
|
menu.donateOv3t=OV3Ts Flugzeug-Datendienst unterstützen
|
||||||
|
menu.donatePayPal=Entwicklung von kst4Contest per PayPal unterstützen
|
||||||
|
menu.exitAndDisconnect=Beenden + trennen
|
||||||
|
menu.file=Datei
|
||||||
|
menu.hideMonitor=Cluster- / Fremd-QSOs ausblenden
|
||||||
|
menu.hideOptions=Optionen ausblenden
|
||||||
|
menu.homepage=DARC-X08-Homepage besuchen
|
||||||
|
menu.info=Info
|
||||||
|
menu.newsgroup=kst4Contest-Newsgroup beitreten
|
||||||
|
menu.options=Optionen
|
||||||
|
menu.setQrgAsNameInChat=QRG als Namen im Chat setzen (Hauptkategorie)
|
||||||
|
menu.showMeAsActive=Mich als AKTIV im Chat zeigen!
|
||||||
|
menu.showMeAsAway=Mich als ABWESEND vom Chat zeigen!
|
||||||
|
menu.showMonitor=Cluster- / Fremd-QSOs anzeigen
|
||||||
|
menu.showOptions=Optionen anzeigen
|
||||||
|
menu.switchOperatorProfile=Operatorprofil wechseln...
|
||||||
|
menu.toggleStationMap=Stationskarte ein-/ausblenden
|
||||||
|
menu.useDarkDesign=Abenddesign verwenden
|
||||||
|
menu.useDefaultDesign=Tagdesign verwenden
|
||||||
|
menu.windows=Fenster
|
||||||
|
messageHandling.autoAnswerEnabled=Privatnachrichten automatisch beantworten
|
||||||
|
messageHandling.autoAnswerSection=Automatische Antwort auf Privatnachrichten
|
||||||
|
messageHandling.autoAnswerText=Antworttext
|
||||||
|
messageHandling.qrgAnswerEnabled=Eine QRG-Anfrage mit der aktuellen Frequenz beantworten
|
||||||
|
messageHandling.qrgAnswerSection=Automatische Antwort auf eine QRG-Anfrage
|
||||||
|
messageHandling.debugToFileEnabled=Die vollständige Sitzung in eine Datei aufzeichnen
|
||||||
|
messageHandling.debugToFileHint=Zeichnet den kompletten ON4KST-Sende- und Empfangsverkehr in Messagehistory.raw auf und schreibt zusätzliche Diagnosedetails in kst4contest-errors.log. Anmeldepasswörter werden geschwärzt. Warnungen und Fehler werden immer protokolliert.
|
||||||
|
messageHandling.diagnosticsSection=Diagnose
|
||||||
|
notification.addCallsignToList=Ein Rufzeichen zur Überwachungsliste hinzufügen
|
||||||
|
notification.addMonitored=Überwachtes Rufzeichen hinzufügen
|
||||||
|
notification.bandUpgradeHint=Auf einen möglichen Bandwechsel hinweisen, wenn ein QSO geloggt wird
|
||||||
|
notification.bandUpgradePriority=Die Priorität einer Station mit möglichem Bandwechsel anheben
|
||||||
|
notification.bandUpgradeSection=Hinweise auf Bandwechsel
|
||||||
|
notification.callsign=Rufzeichen
|
||||||
|
notification.clusterEnabled=Den DX-Cluster-Server betreiben
|
||||||
|
notification.clusterPort=TCP-Port
|
||||||
|
notification.clusterSection=Lokaler DX-Cluster-Server
|
||||||
|
notification.cwCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht in CW geben
|
||||||
|
notification.fallbackBand=Ersatzband für ein Spot ohne Bandangabe
|
||||||
|
notification.monitoringSection=QSO-Überwachung
|
||||||
|
notification.sendTestSpot=Ein Testspot senden
|
||||||
|
notification.simpleSounds=Einfache Töne abspielen
|
||||||
|
notification.soundsSection=Töne
|
||||||
|
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
|
||||||
|
notification.spotterCallsign=Spotter-Rufzeichen
|
||||||
|
settings.applyAndClose=Übernehmen/Schließen
|
||||||
|
settings.applyFailed=Einige Einstellungen konnten nicht übernommen werden oder haben die laufende Sitzung nicht erreicht.\n\n(Technische Details: {0})
|
||||||
|
settings.pendingApply=Übernehmen
|
||||||
|
settings.pendingBack=Zurück zu den Einstellungen
|
||||||
|
settings.pendingDiscard=Verwerfen
|
||||||
|
settings.pendingQuestion=Einige Einstellungen wurden geändert, aber noch nicht übernommen. Zuerst übernehmen?
|
||||||
|
settings.pendingTitle=Nicht übernommene Änderungen
|
||||||
|
settings.invalidEntry={0}: „{1}" ist keine gültige Eingabe.
|
||||||
|
settings.save=Einstellungen speichern
|
||||||
|
shortcuts.addButton=Kurztaste hinzufügen
|
||||||
|
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
|
||||||
|
shortcuts.buttonsSection=Kurztastenknöpfe über dem Nachrichtenfeld
|
||||||
|
shortcuts.snippetsSection=Textbausteine (die ersten 10 über Strg+1 bis Strg+0)
|
||||||
|
station.bandsSection=Aktive Bänder
|
||||||
|
station.beamWidth=Öffnungswinkel der Antenne, Grad
|
||||||
|
station.callsign=Rufzeichen
|
||||||
|
station.chatSection=Chat-Kategorie
|
||||||
|
station.defaultsSection=Vorgaben
|
||||||
|
station.demDirectory=DEM-Stammverzeichnis
|
||||||
|
station.invalidOwnHeightAsl=„{0}" ist keine gültige Höhe. Meter von -500 bis 9000 eingeben oder das Feld leer lassen, dann gilt das Höhenmodell.
|
||||||
|
station.locator=Locator
|
||||||
|
station.mainCategory=Hauptkategorie
|
||||||
|
station.maxQrb=Maximaler QRB
|
||||||
|
station.nameMain=Name, Hauptkategorie
|
||||||
|
station.nameSecond=Name, zweite Kategorie
|
||||||
|
station.ownAntennaGain=Eigener Antennengewinn, dBi
|
||||||
|
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
|
||||||
|
station.ownHeightAsl=Eigene Standorthöhe über NN, Meter
|
||||||
|
station.ownSection=Eigene Station
|
||||||
|
station.ownTxPower=Eigene Sendeleistung, Watt
|
||||||
|
station.password=Passwort
|
||||||
|
station.pathSection=Vorgaben für die Streckenanalyse
|
||||||
|
station.qtf=QTF
|
||||||
|
station.rotatorEnabled=Rotorabgleich aktiv
|
||||||
|
station.rotatorHost=Rotor-Host
|
||||||
|
station.rotatorPort=Rotor-Port
|
||||||
|
station.rotatorSection=PSTRotator
|
||||||
|
station.rotator2Enabled=Rotor-2-Abgleich aktiv
|
||||||
|
station.rotator2Host=Rotor-2-Host
|
||||||
|
station.rotator2Port=Rotor-2-Port (antwortet auf Port + 1)
|
||||||
|
station.rotator2PortClash=Port {0} ist derselbe oder ein benachbarter Port wie beim anderen PSTRotator
|
||||||
|
station.rotator2Section=PSTRotator 2 (SO2R)
|
||||||
|
station.secondCategory=Zweite Kategorie
|
||||||
|
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
|
||||||
|
station.serverHost=Servername
|
||||||
|
station.serverPort=Serverport
|
||||||
|
station.serverSection=ON4KST-Server
|
||||||
|
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
|
||||||
|
station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfelds
|
||||||
|
station.targetTxPower=Sendeleistung der Gegenstation, Watt
|
||||||
|
trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde.
|
||||||
|
trxSynch.hintSection=Was eine aktivierte Quelle bewirkt
|
||||||
|
trxSynch.so2rEnabled=SO2R: zwei Geräte getrennt führen (Gerät 2 schreibt SECONDQRG)
|
||||||
|
trxSynch.so2rHint=N1MM, DXLog und UCXLog werden über RadioNr zugeordnet; dafür ist nichts einzustellen. Win-Test braucht je Gerät einen Stationsnamen.
|
||||||
|
trxSynch.so2rSection=SO2R
|
||||||
|
trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
|
||||||
|
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
|
||||||
|
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz)
|
||||||
|
trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
|
||||||
|
trxSynch.winTestSection=Win-Test-TRX-Abgleich
|
||||||
|
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
|
||||||
|
trxSynch.winTestStationRadio2=Win-Test-Stationsname von Gerät 2 (z. B. STN2)
|
||||||
|
worked.empty=Die Datenbank enthält keine Rufzeichen.
|
||||||
|
worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig.
|
||||||
|
worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
|
||||||
|
worked.refresh=Datenbank neu einlesen
|
||||||
|
worked.reset=Worked-, NOT-QRV- und Feldangaben zurücksetzen...
|
||||||
|
worked.resetConfirm=Daten zurücksetzen
|
||||||
|
worked.resetDialogBody=Alle Worked-, NOT-QRV- und Feldangaben zurücksetzen?\n\nDas löscht jede contestbezogene Markierung in der internen Datenbank. Vor jedem Contest ist das normalerweise nicht nötig, weil diese Angaben nach drei Tagen verfallen.
|
||||||
|
worked.resetDialogTitle=Contestdaten zurücksetzen
|
||||||
|
worked.resetFailed=Die Contestdaten konnten nicht zurückgesetzt werden. Die interne Datenbank ist möglicherweise nicht erreichbar oder inkonsistent. Es wurde kein erfolgreiches Zurücksetzen bestätigt.
|
||||||
|
worked.section=Datenbank gearbeiteter Stationen
|
||||||
|
tab.station=Station
|
||||||
|
tab.logSynch=Log-Abgleich
|
||||||
|
tab.trxSynch=TRX-Abgleich
|
||||||
|
tab.airscout=Airscout
|
||||||
|
tab.notification=Hinweise
|
||||||
|
tab.shortcuts=Kurztasten
|
||||||
|
tab.beacon=Bake
|
||||||
|
tab.messageHandling=Nachrichten
|
||||||
|
tab.workedDatabase=Worked-Datenbank
|
||||||
|
tab.gui=GUI
|
||||||
|
tab.profiles=Profile
|
||||||
|
tab.colours=Farben
|
||||||
|
settings.categoryMainFallback=Haupt-Chat-Kategorie
|
||||||
|
settings.categorySecondFallback=Zweite Chat-Kategorie
|
||||||
|
settings.spotNeedsConnection=Zuerst KST4Contest verbinden und den lokalen DX-Cluster-Server einschalten.
|
||||||
|
settings.spotNoClient=Es ist kein DX-Cluster-Client mit KST4Contest verbunden.
|
||||||
|
settings.spotNotDelivered=Das Testspot konnte nicht zugestellt werden.
|
||||||
|
settings.writeFailed=Die Konfigurationsdatei konnte nicht geschrieben werden. Die Einstellungen bleiben für diese Sitzung aktiv.
|
||||||
|
settings.writtenTo=Die Einstellungen sind als Vorgabe in der XML-Konfigurationsdatei gespeichert:\n{0}
|
||||||
|
profiles.active=Aktives Profil:
|
||||||
|
profiles.settingsFile=Einstellungsdatei:
|
||||||
|
profiles.workedStations=Gearbeitete Stationen:
|
||||||
|
profiles.new=Neues Profil...
|
||||||
|
profiles.duplicate=Duplizieren...
|
||||||
|
profiles.rename=Umbenennen...
|
||||||
|
profiles.delete=Löschen...
|
||||||
|
profiles.changeWorked=Gearbeitete Stationen ändern...
|
||||||
|
profiles.switchTo=Zum gewählten Profil wechseln...
|
||||||
|
profiles.explanation=Jedes Profil hat eigene Einstellungen und ein eigenes Fensterlayout. Ein Profil kann entweder die gemeinsamen gearbeiteten Stationen der Station mitbenutzen — was eine Mehrmann-Station will — oder eigene führen. Beim Duplizieren wird alles übernommen außer Rufzeichen und Passwort, und gearbeitete Stationen nie.
|
||||||
|
profiles.dialogTitle=Operatorprofile
|
||||||
|
profiles.none=Kein Operatorprofil eingerichtet.
|
||||||
|
profiles.columnProfile=Profil
|
||||||
|
profiles.columnWorked=Gearbeitete Stationen
|
||||||
|
profiles.columnLastUsed=Zuletzt benutzt
|
||||||
|
profiles.inUse= (in Benutzung)
|
||||||
|
profiles.newTitle=Neues Operatorprofil
|
||||||
|
profiles.newBody=Name und gearbeitete Stationen des neuen Profils
|
||||||
|
profiles.nameLabel=Profilname, zum Beispiel DN9APW
|
||||||
|
profiles.create=Profil anlegen
|
||||||
|
profiles.duplicateTitle=Operatorprofil duplizieren
|
||||||
|
profiles.duplicateBody=Name des neuen Profils
|
||||||
|
profiles.duplicateHint=Alles wird übernommen außer Rufzeichen und Passwort. Gearbeitete Stationen werden nie kopiert.
|
||||||
|
profiles.renameTitle=Operatorprofil umbenennen
|
||||||
|
profiles.renameBody=Neuer Name des Profils
|
||||||
|
profiles.renameHint=Dateien und Ordner des Profils werden nicht angefasst.
|
||||||
|
profiles.deleteTitle=Operatorprofil löschen
|
||||||
|
profiles.deleteBody=Das Profil "{0}" löschen?
|
||||||
|
profiles.deleteConfirm=Profil löschen
|
||||||
|
profiles.workedTitle=Gearbeitete Stationen
|
||||||
|
profiles.workedBody=Gearbeitete Stationen von "{0}"
|
||||||
|
profiles.workedHint=Das Umschalten verschiebt keine Daten. Bereits unter der anderen Einstellung gesammelte gearbeitete Stationen bleiben, wo sie sind.
|
||||||
|
colours.section=Farben dieses Entwurfs
|
||||||
|
colours.roleWithSource={0} ({1})
|
||||||
|
colours.readabilitySection=Lesbarkeit
|
||||||
|
colours.warning={0} liest sich jetzt mit {1} zu 1, wo dieser Entwurf {2} zu 1 ausliefert.
|
||||||
|
colours.warningKept=Die Farben werden trotzdem behalten — eine harte Kombination kann gewollt sein.
|
||||||
|
colours.keepingSection=Diese Farben behalten
|
||||||
|
colours.keepingHint=Eine Farbe gilt sofort und überall. Den Neustart überlebt sie erst, wenn „Einstellungen speichern" gedrückt wurde.
|
||||||
|
colours.waysBackSection=Wege zurück
|
||||||
|
colours.backToPrevious=Zurück zum Stand von vorher ({0})
|
||||||
|
colours.discardChanges=Meine Änderungen verwerfen ({0})
|
||||||
|
colours.resetToShipped=Auf Auslieferung zurücksetzen ({0})
|
||||||
|
colours.fileSection=Die Stilvorlage von Hand bearbeiten
|
||||||
|
colours.fileReads=Dieser Entwurf liest {0}. Eine oben gesetzte Farbe überstimmt für diese Rolle, was die Datei sagt.
|
||||||
|
colours.fileAbsent=Dieser Entwurf hat keine eigene Stilvorlage. Eine kann als Ausgangspunkt nach {0} geschrieben werden.
|
||||||
|
colours.exportButton=Die Stilvorlage dieses Entwurfs in mein Profil schreiben
|
||||||
|
colours.exportWritten=Nach {0} geschrieben. Bearbeiten und neu starten, um das Ergebnis zu sehen.
|
||||||
|
colours.exportAlreadyThere={0} ist schon vorhanden und wurde nicht angefasst.
|
||||||
|
colours.exportFailed=Die Stilvorlage konnte nicht geschrieben werden: {0}
|
||||||
|
colours.sourceShipped=Auslieferung
|
||||||
|
colours.sourceFile=Datei
|
||||||
|
colours.sourceChanged=geändert
|
||||||
|
colours.roleSurface=Fläche (Menüzeile, ausgewählte Zeilen)
|
||||||
|
colours.roleWindowSurface=Fensterfläche (die Fenster selbst)
|
||||||
|
colours.roleFieldInterior=Feldinneres (Felder, Listen, Knöpfe)
|
||||||
|
colours.roleText=Text
|
||||||
|
colours.roleAccent=Akzent
|
||||||
|
colours.roleSeparator=Trennlinie
|
||||||
|
colours.notAColour='{0}' ist keine Farbe. Sechs Hexadezimalziffern mit führendem Rautezeichen, zum Beispiel #3C7A4B. {1} bleibt unverändert.
|
||||||
|
column.callsign=Rufzeichen
|
||||||
|
column.name=Name
|
||||||
|
column.message=Nachricht
|
||||||
|
column.time=Zeit
|
||||||
|
column.lastQrgRx=Letzte QRG RX
|
||||||
|
column.lastQrgTx=Letzte QRG TX
|
||||||
|
filter.find=Suchen...
|
||||||
|
filter.reset=Filter zurücksetzen
|
||||||
|
filter.showOnlyQrb=Nur QRB [km] <=
|
||||||
|
filter.showOnlyQrbAbove=Nur QRB [km] >
|
||||||
|
filter.showOnlyQtf=Nur QTF:
|
||||||
|
filter.hideWorked=Gearbeitete ausblenden:
|
||||||
|
filter.inactiveStations=Inaktive Stationen
|
||||||
|
filter.onlyNewGrids=Nur neue Felder
|
||||||
|
filter.gridColour=Feldfarbe
|
||||||
|
filter.newBands=Neue Bänder
|
||||||
|
filter.tropo=Tropo >=0dB
|
||||||
|
selected.none=Keine Station gewählt.
|
||||||
|
selected.priority=Priorität:
|
||||||
|
selected.more=mehr
|
||||||
|
selected.showMessages=Nachrichten zeigen
|
||||||
|
selected.createSked=Sked anlegen
|
||||||
|
selected.sked=Sked
|
||||||
|
selected.skedInMinutes=Sked in (Min.)
|
||||||
|
selected.skedFail=Sked fehlgeschlagen
|
||||||
|
selected.remindPmIn=Erinnerungs-PM in
|
||||||
|
selected.reminderOffsets=Erinnerungsabstände
|
||||||
|
selected.resetFail=Zurücksetzen fehlgeschlagen
|
||||||
|
selected.tagNotQrv=Als NOT QRV markieren
|
||||||
|
selected.band=Band
|
||||||
|
selected.mode=Mode
|
||||||
|
selected.lookUpAndShow=Nachschlagen und zeigen
|
||||||
|
selected.lookupQrz=Auf qrz.com nachschlagen
|
||||||
|
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
|
||||||
|
selected.showPathInAs=Strecke in AS zeigen
|
||||||
|
selected.turnAntenna=Antenne 1 Richtung {0} drehen
|
||||||
|
selected.turnAntenna2=Antenne 2 Richtung {0} drehen
|
||||||
|
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
|
||||||
|
candidates.title=Stationen mit höchster Priorität
|
||||||
|
candidates.none=Noch keine Kandidaten.
|
||||||
|
candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus.
|
||||||
|
update.title=Aktualisierungsinformationen
|
||||||
|
update.available=Aktualisierung verfügbar!
|
||||||
|
update.installedVersion=Installierte Version:
|
||||||
|
update.latestVersion=Neueste stabile Version:
|
||||||
|
update.mainChanges=Wesentliche Änderungen:
|
||||||
|
update.additionalInformation=Weitere Informationen:
|
||||||
|
update.download=Herunterladen:
|
||||||
|
update.openReleasePage=Release-Seite öffnen
|
||||||
|
update.changeLog=Änderungsprotokoll
|
||||||
|
update.knownBugs=Bekannte Fehler
|
||||||
|
update.versions=Versionen
|
||||||
|
map.title=Stationskarte
|
||||||
|
map.tab=Karte
|
||||||
|
map.resetView=Ansicht zurücksetzen
|
||||||
|
map.groupNearby=Nahe Stationen gruppieren
|
||||||
|
map.triggerClusterSpot=Cluster-Spot auslösen
|
||||||
|
map.showPathAnalysis=Streckenanalyse zeigen
|
||||||
|
map.hidePathAnalysis=Streckenanalyse ausblenden
|
||||||
|
map.pathAnalysisHidden=Die Streckenanalyse ist ausgeblendet.
|
||||||
|
map.tilesUnavailable=Kartenkacheln nicht verfügbar — die Netzverbindung prüfen.
|
||||||
|
path.title=Strecken- und Geländeanalyse
|
||||||
|
path.selectStation=Eine Station wählen, um Strecken- und Geländeanalyse vorzubereiten.
|
||||||
|
path.fromLocator=Vom Locator:
|
||||||
|
path.toLocator=Zum Locator:
|
||||||
|
path.endpoints=Endpunkte:
|
||||||
|
path.distanceQtf=Entfernung/QTF:
|
||||||
|
path.frequency=Frequenz:
|
||||||
|
path.status=Status:
|
||||||
|
path.source=Quelle:
|
||||||
|
path.los=LOS:
|
||||||
|
path.fresnel=Fresnel:
|
||||||
|
path.obstruction=Hindernis:
|
||||||
|
path.radioHorizon=Funkhorizont:
|
||||||
|
path.terrainHorizon=Geländehorizont:
|
||||||
|
path.refraction=Refraktion:
|
||||||
|
path.mechanisms=Mechanismen:
|
||||||
|
path.assessment=Bewertung:
|
||||||
|
path.linkBudget=Streckenbilanz:
|
||||||
|
path.rxPower=Empfangsleistung:
|
||||||
|
path.cwHint=CW-Hinweis:
|
||||||
|
chart.noSamples=Keine Profilpunkte verfügbar.
|
||||||
|
chart.distanceAxis=Entfernung [km]
|
||||||
|
chart.heightAxis=Höhe [m]
|
||||||
|
chart.legend=Legende:
|
||||||
|
chart.terrain=Gelände
|
||||||
|
chart.los=LOS
|
||||||
|
chart.fresnel=Fresnel
|
||||||
|
chart.critical=Kritisch
|
||||||
|
chart.diffraction=Beugung
|
||||||
|
chart.home=Eigene
|
||||||
|
chart.radioHorizonShort=Funkhor.
|
||||||
|
chart.terrainHorizonShort=Geländehor.
|
||||||
|
chart.homeRadioHorizon=Eigener Funkhorizont
|
||||||
|
chart.homeTerrainHorizon=Eigener Geländehorizont
|
||||||
|
chart.dxRadioHorizon=Funkhorizont der Gegenstation
|
||||||
|
chart.dxTerrainHorizon=Geländehorizont der Gegenstation
|
||||||
|
chart.frequency=f = {0} MHz
|
||||||
|
chart.criticalFresnel=Kritischer Punkt: Fresnel-Eingriff {0} m bei {1} km
|
||||||
|
chart.criticalLos=Kritischer Punkt: LOS-Freiraum {0} m bei {1} km
|
||||||
|
chart.diffractionCandidate=Beugungskandidat {0} km, KE ≈ {1} dB
|
||||||
|
title.connectionLost=VERBINDUNG VERLOREN – neuer Versuch eingeplant
|
||||||
|
title.connecting=Verbindung zu ON4KST wird aufgebaut…
|
||||||
|
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
|
||||||
|
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
|
||||||
|
title.disconnected=NICHT VERBUNDEN!
|
||||||
|
title.online=Verbunden mit: {0} als {1} ({2}) in {3} ({4} Benutzer online, {5} angezeigt), {6} Nachrichten insgesamt.
|
||||||
|
title.bothCategories={0} und {1}
|
||||||
|
status.noConnection=Keine ON4KST-Verbindung
|
||||||
|
status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
|
||||||
|
status.linkConnected=🟢 LINK: Verbunden
|
||||||
|
status.linkConnecting=🟡 LINK: Verbindet…
|
||||||
|
status.linkDisconnecting=🟡 LINK: Trennt…
|
||||||
|
status.linkReconnecting=🔴 LINK: Verbindet neu…
|
||||||
|
status.linkDisconnected=🔴 LINK: Nicht verbunden
|
||||||
|
picker.title=Operatorprofil wählen
|
||||||
|
picker.moreThanOne=Es ist mehr als ein Operatorprofil eingerichtet.
|
||||||
|
picker.start=Starten
|
||||||
|
picker.quit=Beenden
|
||||||
|
dialog.yes=Ja
|
||||||
|
dialog.switchProfile=Profil wechseln
|
||||||
|
alert.profileTitle=Operatorprofil
|
||||||
|
alert.profileNotFound=Das angeforderte Operatorprofil wurde nicht gefunden.
|
||||||
|
alert.profilesTitle=Operatorprofile
|
||||||
|
alert.onlyOneProfile=Es ist nur ein Operatorprofil eingerichtet.
|
||||||
|
alert.onlyOneProfileBody=Weitere Profile werden im Einstellungsfenster im Reiter „Profile" angelegt. Jedes Profil behält eigene Einstellungen und ein eigenes Layout und kann die Worked-Datenbank der Station entweder mitbenutzen oder eine eigene führen.
|
||||||
|
alert.switchProfileTitle=Operatorprofil wechseln
|
||||||
|
alert.switchProfileQuestion=Zu „{0}" wechseln?
|
||||||
|
alert.switchProfileBody=Die ON4KST-Verbindung wird geschlossen und alle Fenster werden mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut.\n\nNicht gespeicherte Einstellungen des aktuellen Profils gehen verloren. Fenstergrößen sowie Teiler- und Spaltenbreiten werden automatisch gesichert.
|
||||||
|
alert.quitTitle=Anwendung beenden
|
||||||
|
alert.quitQuestion=Soll die Verbindung zum Chat getrennt werden?
|
||||||
|
alert.simpleLogTitle=Simplelogfile angelegt
|
||||||
|
alert.simpleLogHeader=Das gewählte Simplelogfile gab es nicht und wurde angelegt
|
||||||
|
alert.simpleLogBody=Datei: {0}\n\nPrüfe zuerst, ob du die Simplelogfile-Anbindung überhaupt brauchst. Bietet dein Logprogramm eine unterstützte Netzschnittstelle, nutze diese für Band- und Locatorangaben.\n\nWenn du Simplelogfile benutzt, stelle dein Logprogramm so ein, dass es sein laufendes Log in diese Datei schreibt. Logge dann ein Test-QSO und prüfe, ob das Rufzeichen in KST4Contest binnen einer Minute als gearbeitet markiert wird.\n\nPrüfe vor jedem Contest, dass das Logprogramm das aktuelle Contestlog in genau diese Datei schreibt. KST4Contest setzt aus Simplelogfile stammende Worked-Markierungen zum Contestbeginn nicht von selbst zurück.
|
||||||
|
alert.simpleLogLink=Das Simplelogfile-Handbuch öffnen
|
||||||
|
tooltip.bandsNone=Bänder: -
|
||||||
|
tooltip.bandsPrefix=Bänder:
|
||||||
|
tooltip.bandStatusLegend=Bandstatus:\nX = auf diesem Band gearbeitet\nB+ = Band verfügbar, auf diesem Band noch nicht gearbeitet (Rufzeichen auf einem anderen Band bereits gearbeitet)\na = Band verfügbar, Rufzeichen auf keinem Band gearbeitet (in den GUI-Einstellungen abschaltbar; fällt dann auf B+ zurück)\no = Großfeld auf diesem Band bereits gearbeitet, von einer beliebigen Station (in den GUI-Einstellungen abschaltbar)\n(leer) = keine Angaben zu diesem Band\no kann mit den übrigen zusammentreffen, zum Beispiel „ao" oder „B+o"
|
||||||
|
tooltip.bandStatusThisStation=\n\nDiese Station ({0}):\nStatus: {1}
|
||||||
|
tooltip.workedStatusLegend=Worked-Status:\nleer = Rufzeichen nicht gearbeitet, Feld nicht gearbeitet\nx = Rufzeichen gearbeitet\no = Feld gearbeitet\nxo = Rufzeichen und Feld gearbeitet
|
||||||
|
tooltip.workedStatusThisStation=\n\nDiese Station:\nRufzeichen gearbeitet: {0}\nFeld gearbeitet: {1} ({2})
|
||||||
|
tooltip.yes=ja
|
||||||
|
tooltip.no=nein
|
||||||
|
tooltip.unknownGrid=unbekanntes Feld
|
||||||
|
tooltip.grid=Feld {0}
|
||||||
|
messages.public=Öffentliche Nachrichten
|
||||||
|
messages.dxCluster=DXCluster-Nachrichten
|
||||||
|
messages.qsoOfTheOther=Fremd-QSOs
|
||||||
|
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
|
||||||
|
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
|
||||||
|
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
|
||||||
|
chat.qtf2Tooltip=Richtung von Antenne 2 (Rotor 2, SO2R); nur Anzeige
|
||||||
|
chat.secondQrgTooltipSo2r=QRG von Gerät 2 (aus RadioNr 2 bzw. Win-Test-Station 2)
|
||||||
|
list.moveDown=Auswahl nach unten
|
||||||
|
list.moveUp=Auswahl nach oben
|
||||||
|
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
|
||||||
|
column.lastKnownQrg=Letzte bekannte QRG
|
||||||
|
alert.cannotConnectTitle=Verbinden nicht möglich
|
||||||
|
alert.credentialsMissing=Anmeldedaten fehlen
|
||||||
|
alert.credentialsMissingBody=Bitte zuerst Rufzeichen und Passwort in den Einstellungen eintragen.
|
||||||
|
alert.connectionFailedTitle=Verbindung fehlgeschlagen
|
||||||
|
alert.connectionFailedBody=Verbinden nicht möglich: {0}
|
||||||
|
contrast.textOnWindowSurface=Text auf der Fensterfläche
|
||||||
|
contrast.textInsideField=Text im Feld
|
||||||
|
contrast.accentOnWindowSurface=Akzent auf der Fensterfläche
|
||||||
|
contrast.textOnMenuStrip=Text auf der Menüzeile
|
||||||
|
gui.startInEveningDesign=Im Abenddesign starten
|
||||||
|
gui.filterPublic=Öffentliche Nachrichten
|
||||||
|
gui.filterPmToMe=Privatnachrichten an mich
|
||||||
|
gui.filterPmToOthers=Privatnachrichten an andere
|
||||||
|
gui.filterNothing=Kein Filter
|
||||||
|
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
|
||||||
|
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren
|
||||||
|
gui.rememberWindowPositions=Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden (nur auf demselben Bildschirm)
|
||||||
|
form.choose=Wählen...
|
||||||
|
form.notSelected=nicht gewählt
|
||||||
|
dialog.apply=Übernehmen
|
||||||
|
chat.clear=leeren
|
||||||
|
selected.showOnMap=🗺 Auf der Karte zeigen
|
||||||
|
selected.notQrvAll=alle
|
||||||
|
column.category=Kategorie
|
||||||
|
column.sender=Absender
|
||||||
|
column.receiver=Empfänger
|
||||||
|
column.lastQrg=Letzte QRG
|
||||||
|
column.airplane=AP [Minuten / Pot%]
|
||||||
|
column.score=Wertung
|
||||||
|
monitor.title=Cluster & Fremd-QSOs
|
||||||
|
monitor.dxCluster=DX-Cluster
|
||||||
|
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
|
||||||
|
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
|
||||||
|
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
|
||||||
|
profiles.rootNotDeletable=Das Standardprofil verwendet die Dateien der Installation selbst und kann nicht gelöscht werden.
|
||||||
|
profiles.activeNotDeletable=Das gerade benutzte Profil kann nicht gelöscht werden. Zuerst zu einem anderen Profil wechseln.
|
||||||
|
profiles.deleteFailed=Das Profil konnte nicht gelöscht werden.
|
||||||
|
profiles.deletionFolder=Der folgende Ordner wird endgültig entfernt:\n{0}
|
||||||
|
profiles.deletionKeepsShared=Die gemeinsamen gearbeiteten Stationen der Station werden nicht angefasst.
|
||||||
|
profiles.deletionRemovesOwn=Die gearbeiteten Stationen dieses Profils werden ebenfalls gelöscht.
|
||||||
|
profiles.rootAlwaysShared=Das Standardprofil verwendet immer die gemeinsamen gearbeiteten Stationen der Station, weil diese Datenbank die der Installation selbst ist.
|
||||||
|
profiles.changeAfterSwitch=Die Änderung wirkt erst nach einem erneuten Wechsel zu diesem Profil.
|
||||||
|
profiles.alreadyActive=Dieses Profil ist bereits aktiv.
|
||||||
|
profiles.selectFirst=Zuerst ein Profil in der Tabelle wählen.
|
||||||
|
beacon.invalidMessage=Der Bakentext ist ungültig: {0}
|
||||||
|
beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minute eingeben.
|
||||||
|
notification.invalidPort=„{0}" ist kein gültiger TCP-Port. Einen Wert von 1 bis 65535 eingeben.
|
||||||
|
notification.invalidSpotter=„{0}" ist kein gültiges Spotter-Rufzeichen.
|
||||||
|
notification.invalidCallsign=Bitte ein gültiges Rufzeichen eingeben.
|
||||||
|
notification.alreadyMonitored=Dieses Grundrufzeichen steht bereits in der Überwachungsliste.
|
||||||
|
settings.windowTitle=Client-Einstellungen ändern
|
||||||
|
settings.noticeInfo=Info
|
||||||
|
settings.noticeProblem=Einstellungen
|
||||||
|
profiles.unknown=unbekannt
|
||||||
|
profiles.copyOf=Kopie von {0}
|
||||||
|
profiles.sharedDatabase=gemeinsame Stationsdatenbank
|
||||||
|
profiles.ownDatabase=eigene Datenbank
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
# The base language. Every key and every argument count comes from this file.
|
||||||
|
#
|
||||||
|
# To contribute a language: copy this file to strings_<code>.properties and translate the
|
||||||
|
# values to the right of the equals sign. An untranslated key falls back to English, so a
|
||||||
|
# partial file is welcome. `./gradlew :app-desktop:classes` builds and reports any mistake,
|
||||||
|
# and writes a worksheet of what is still missing to
|
||||||
|
# app-desktop/build/generated/i18n/todo/.
|
||||||
|
#
|
||||||
|
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file --
|
||||||
|
# none of it is here, and three tests keep it that way.
|
||||||
|
airscout.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
|
||||||
|
airscout.automaticFrequency=Select AirScout frequency automatically per station
|
||||||
|
airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station.
|
||||||
|
airscout.clientIdentifier=KST4Contest client identifier [KST]:
|
||||||
|
airscout.enabled=Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)
|
||||||
|
airscout.forcedBand=Forced AirScout band value [1440000 = 144 MHz]:
|
||||||
|
airscout.frequencySection=Queried frequency
|
||||||
|
airscout.serverIdentifier=AirScout server identifier [AS]:
|
||||||
|
airscout.udpPort=AirScout UDP port [9872] — reconnect after changing:
|
||||||
|
airscout.udpSection=AirScout UDP communication
|
||||||
|
alert.ok=OK
|
||||||
|
beacon.enabled=Send the beacon in this category
|
||||||
|
beacon.intervalMinutes=Interval in whole minutes
|
||||||
|
beacon.intervalSection=Shared interval
|
||||||
|
beacon.message=Beacon message
|
||||||
|
beacon.sectionFor=CQ beacon for {0}
|
||||||
|
connection.connect=Connect
|
||||||
|
connection.connectTo=Connect to {0}
|
||||||
|
connection.disconnect=Disconnect
|
||||||
|
connection.disconnectAndCloseChat=Disconnect & close Chat
|
||||||
|
dialog.cancel=Cancel
|
||||||
|
dialog.ok=OK
|
||||||
|
gui.bandColumnHints=Hints in the band columns
|
||||||
|
gui.language=Language
|
||||||
|
gui.language.system=System language
|
||||||
|
gui.startupDesign=Startup design
|
||||||
|
gui.startupFilters=Message filters active at startup
|
||||||
|
gui.windows=Windows
|
||||||
|
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
|
||||||
|
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
|
||||||
|
logSynch.fileLabel=Log file to be monitored:
|
||||||
|
logSynch.fileSection=File polling for worked callsigns
|
||||||
|
logSynch.networkEnabled=Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net
|
||||||
|
logSynch.networkSection=Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)
|
||||||
|
logSynch.sharedPort=Shared UDP port for QSO and TRX messages [default 12060]:
|
||||||
|
logSynch.winTestBroadcast=UDP broadcast address for Win-Test (default = internet interface broadcast)
|
||||||
|
logSynch.winTestEnabled=Receive Win-Test network based UDP log messages
|
||||||
|
logSynch.winTestHint=The broadcast address is detected once while it is still {0}. A port change takes effect on the running listener when the field loses focus.
|
||||||
|
logSynch.winTestPort=UDP-Port for Win-Test listener (default is 9871)
|
||||||
|
logSynch.winTestSection=Win-Test Network-Listener
|
||||||
|
logSynch.winTestStationName=KST station name in Win-Test network (src of SKED packets)
|
||||||
|
menu.about=About...
|
||||||
|
menu.contactAuthor=Contact the author using default mail app
|
||||||
|
menu.disconnect=Disconnect
|
||||||
|
menu.donateOv3t=Donate for OV3T´s plane feed service
|
||||||
|
menu.donatePayPal=Donate for kst4Contest development via PayPal
|
||||||
|
menu.exitAndDisconnect=Exit + disconnect
|
||||||
|
menu.file=File
|
||||||
|
menu.hideMonitor=Hide cluster / stranger QSOs
|
||||||
|
menu.hideOptions=hide options
|
||||||
|
menu.homepage=Visit DARC X08-Homepage
|
||||||
|
menu.info=Info
|
||||||
|
menu.newsgroup=Join kst4Contest newsgroup
|
||||||
|
menu.options=Options
|
||||||
|
menu.setQrgAsNameInChat=Set QRG as name in Chat (main category)
|
||||||
|
menu.showMeAsActive=Show me as ACTIVE in chat!
|
||||||
|
menu.showMeAsAway=Show me as AWAY FROM chat!
|
||||||
|
menu.showMonitor=Show cluster / stranger QSOs
|
||||||
|
menu.showOptions=Show options
|
||||||
|
menu.switchOperatorProfile=Switch operator profile...
|
||||||
|
menu.toggleStationMap=Show / hide station map
|
||||||
|
menu.useDarkDesign=Use dark mode design
|
||||||
|
menu.useDefaultDesign=Use default mode design
|
||||||
|
menu.windows=Windows
|
||||||
|
messageHandling.autoAnswerEnabled=Answer private messages automatically
|
||||||
|
messageHandling.autoAnswerSection=Automatic answer to private messages
|
||||||
|
messageHandling.autoAnswerText=Answer text
|
||||||
|
messageHandling.qrgAnswerEnabled=Answer a QRG request with the current frequency
|
||||||
|
messageHandling.qrgAnswerSection=Automatic answer to a QRG request
|
||||||
|
messageHandling.debugToFileEnabled=Record the whole session to file
|
||||||
|
messageHandling.debugToFileHint=Records complete ON4KST RX/TX session traffic in Messagehistory.raw and adds diagnostic details to kst4contest-errors.log. Login passwords are redacted. Warnings and errors are always logged.
|
||||||
|
messageHandling.diagnosticsSection=Diagnostics
|
||||||
|
notification.addCallsignToList=Add a callsign to the monitoring list
|
||||||
|
notification.addMonitored=Add monitored callsign
|
||||||
|
notification.bandUpgradeHint=Hint at a band upgrade when a QSO is logged
|
||||||
|
notification.bandUpgradePriority=Raise the priority of a station worth upgrading
|
||||||
|
notification.bandUpgradeSection=Band upgrade hints
|
||||||
|
notification.callsign=Callsign
|
||||||
|
notification.clusterEnabled=Run the DX Cluster server
|
||||||
|
notification.clusterPort=TCP port
|
||||||
|
notification.clusterSection=Local DX Cluster server
|
||||||
|
notification.cwCallsign=Play the callsign in CW on a received private message
|
||||||
|
notification.fallbackBand=Fallback band for a spot without band information
|
||||||
|
notification.monitoringSection=QSO monitoring
|
||||||
|
notification.sendTestSpot=Send a test spot
|
||||||
|
notification.simpleSounds=Play simple sounds
|
||||||
|
notification.soundsSection=Sounds
|
||||||
|
notification.speakCallsign=Speak the callsign on a received private message
|
||||||
|
notification.spotterCallsign=Spotter callsign
|
||||||
|
settings.applyAndClose=Apply/Close prefs
|
||||||
|
settings.applyFailed=Some settings could not be applied, or their change did not reach the running session.\n\n(Technical details: {0})
|
||||||
|
settings.pendingApply=Apply
|
||||||
|
settings.pendingBack=Back to the settings
|
||||||
|
settings.pendingDiscard=Discard
|
||||||
|
settings.pendingQuestion=Some settings have been changed but not applied yet. Apply them first?
|
||||||
|
settings.pendingTitle=Changes not applied
|
||||||
|
settings.invalidEntry={0}: "{1}" is not a valid entry.
|
||||||
|
settings.save=Save settings
|
||||||
|
shortcuts.addButton=Add shortcut
|
||||||
|
shortcuts.addSnippet=Add new snippet
|
||||||
|
shortcuts.buttonsSection=Shortcut buttons above the message field
|
||||||
|
shortcuts.snippetsSection=Text snippets (the first 10 use Ctrl+1 through Ctrl+0)
|
||||||
|
station.bandsSection=Active bands
|
||||||
|
station.beamWidth=Antenna beam width, degrees
|
||||||
|
station.callsign=Callsign
|
||||||
|
station.chatSection=Chat category
|
||||||
|
station.defaultsSection=Defaults
|
||||||
|
station.demDirectory=DEM root directory
|
||||||
|
station.invalidOwnHeightAsl="{0}" is not a valid height. Enter metres from -500 to 9000, or leave the field empty to use the elevation model.
|
||||||
|
station.locator=Locator
|
||||||
|
station.mainCategory=Main category
|
||||||
|
station.maxQrb=Maximum QRB
|
||||||
|
station.nameMain=Name, main category
|
||||||
|
station.nameSecond=Name, second category
|
||||||
|
station.ownAntennaGain=Own antenna gain, dBi
|
||||||
|
station.ownAntennaHeight=Own antenna height, metres
|
||||||
|
station.ownHeightAsl=Own ground height ASL, metres
|
||||||
|
station.ownSection=Own station
|
||||||
|
station.ownTxPower=Own TX power, watts
|
||||||
|
station.password=Password
|
||||||
|
station.pathSection=Path analysis defaults
|
||||||
|
station.qtf=QTF
|
||||||
|
station.rotatorEnabled=Rotator sync enabled
|
||||||
|
station.rotatorHost=Rotator host
|
||||||
|
station.rotatorPort=Rotator port
|
||||||
|
station.rotatorSection=PSTRotator
|
||||||
|
station.rotator2Enabled=Rotator 2 sync enabled
|
||||||
|
station.rotator2Host=Rotator 2 host
|
||||||
|
station.rotator2Port=Rotator 2 port (answers on port + 1)
|
||||||
|
station.rotator2PortClash=Port {0} is the same as or next to the port of the other PSTRotator
|
||||||
|
station.rotator2Section=PSTRotator 2 (SO2R)
|
||||||
|
station.secondCategory=Second category
|
||||||
|
station.secondChatEnabled=2nd chat: log in to a second category
|
||||||
|
station.serverHost=Server host name
|
||||||
|
station.serverPort=Server port
|
||||||
|
station.serverSection=ON4KST server
|
||||||
|
station.targetAntennaGain=Target antenna gain, dBi
|
||||||
|
station.targetHighestPoint=Target station on the highest point of its locator square
|
||||||
|
station.targetTxPower=Target TX power, watts
|
||||||
|
trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand.
|
||||||
|
trxSynch.hintSection=What an enabled source does
|
||||||
|
trxSynch.so2rEnabled=SO2R: keep two radios apart (radio 2 writes SECONDQRG)
|
||||||
|
trxSynch.so2rHint=N1MM, DXLog and UCXLog are assigned by RadioNr; nothing to set. Win-Test needs one station name per radio.
|
||||||
|
trxSynch.so2rSection=SO2R
|
||||||
|
trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
|
||||||
|
trxSynch.ucxSection=Receive UCXLog TRX info
|
||||||
|
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)
|
||||||
|
trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
|
||||||
|
trxSynch.winTestSection=Win-Test TRX sync
|
||||||
|
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
|
||||||
|
trxSynch.winTestStationRadio2=Win-Test station name of radio 2 (e.g. STN2)
|
||||||
|
worked.empty=The worked database holds no callsigns.
|
||||||
|
worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required.
|
||||||
|
worked.lastResetAffected=Last reset affected {0} rows.
|
||||||
|
worked.refresh=Refresh worked database
|
||||||
|
worked.reset=Reset worked, NOT-QRV and grid data...
|
||||||
|
worked.resetConfirm=Reset data
|
||||||
|
worked.resetDialogBody=Reset all worked, NOT-QRV and grid data?\n\nThis clears every contest-related flag in the internal database. A reset before every contest is normally not required because these data expire after three days.
|
||||||
|
worked.resetDialogTitle=Reset contest data
|
||||||
|
worked.resetFailed=The contest data could not be reset. The internal database may be unavailable or inconsistent. No successful reset was confirmed.
|
||||||
|
worked.section=Worked station database
|
||||||
|
tab.station=Station
|
||||||
|
tab.logSynch=Log synch
|
||||||
|
tab.trxSynch=TRX synch
|
||||||
|
tab.airscout=Airscout
|
||||||
|
tab.notification=Notification
|
||||||
|
tab.shortcuts=Shortcuts
|
||||||
|
tab.beacon=Beacon
|
||||||
|
tab.messageHandling=Messagehandling
|
||||||
|
tab.workedDatabase=Workedstn database
|
||||||
|
tab.gui=GUI
|
||||||
|
tab.profiles=Profiles
|
||||||
|
tab.colours=Colours
|
||||||
|
settings.categoryMainFallback=Main chat category
|
||||||
|
settings.categorySecondFallback=Second chat category
|
||||||
|
settings.spotNeedsConnection=Connect KST4Contest and enable the local DX Cluster server first.
|
||||||
|
settings.spotNoClient=No DX Cluster client is connected to KST4Contest.
|
||||||
|
settings.spotNotDelivered=The test spot could not be delivered.
|
||||||
|
settings.writeFailed=The configuration file could not be written. The settings stay active for this session.
|
||||||
|
settings.writtenTo=Settings are stored as default to the xml config file:\n{0}
|
||||||
|
profiles.active=Active profile:
|
||||||
|
profiles.settingsFile=Settings file:
|
||||||
|
profiles.workedStations=Worked stations:
|
||||||
|
profiles.new=New profile...
|
||||||
|
profiles.duplicate=Duplicate...
|
||||||
|
profiles.rename=Rename...
|
||||||
|
profiles.delete=Delete...
|
||||||
|
profiles.changeWorked=Change worked stations...
|
||||||
|
profiles.switchTo=Switch to selected profile...
|
||||||
|
profiles.explanation=Each profile has its own settings and window layout. A profile can either share the common station worked stations, which is what a multi operator station wants, or keep its own. Duplicating a profile copies everything except callsign and password, and never copies worked stations.
|
||||||
|
profiles.dialogTitle=Operator profiles
|
||||||
|
profiles.none=No operator profile configured.
|
||||||
|
profiles.columnProfile=Profile
|
||||||
|
profiles.columnWorked=Worked stations
|
||||||
|
profiles.columnLastUsed=Last used
|
||||||
|
profiles.inUse= (in use)
|
||||||
|
profiles.newTitle=New operator profile
|
||||||
|
profiles.newBody=Name and worked stations of the new profile
|
||||||
|
profiles.nameLabel=Profile name, for example DN9APW
|
||||||
|
profiles.create=Create profile
|
||||||
|
profiles.duplicateTitle=Duplicate operator profile
|
||||||
|
profiles.duplicateBody=Name of the new profile
|
||||||
|
profiles.duplicateHint=Everything is copied except callsign and password. Worked stations are never copied.
|
||||||
|
profiles.renameTitle=Rename operator profile
|
||||||
|
profiles.renameBody=New name of the profile
|
||||||
|
profiles.renameHint=Files and folders of the profile are not touched.
|
||||||
|
profiles.deleteTitle=Delete operator profile
|
||||||
|
profiles.deleteBody=Delete the profile "{0}"?
|
||||||
|
profiles.deleteConfirm=Delete profile
|
||||||
|
profiles.workedTitle=Worked stations
|
||||||
|
profiles.workedBody=Worked stations of "{0}"
|
||||||
|
profiles.workedHint=Switching does not move any data. Worked stations already collected under the other setting stay where they are.
|
||||||
|
colours.section=Colours of this design
|
||||||
|
colours.roleWithSource={0} ({1})
|
||||||
|
colours.readabilitySection=Readability
|
||||||
|
colours.warning={0} now reads at {1} to 1, where this design ships {2} to 1.
|
||||||
|
colours.warningKept=The colours are kept either way — a hard combination can be deliberate.
|
||||||
|
colours.keepingSection=Keeping these colours
|
||||||
|
colours.keepingHint=A colour applies at once, everywhere. It survives a restart only once Save settings has been pressed.
|
||||||
|
colours.waysBackSection=Ways back
|
||||||
|
colours.backToPrevious=Back to how it was ({0})
|
||||||
|
colours.discardChanges=Discard my changes ({0})
|
||||||
|
colours.resetToShipped=Reset to the shipped colours ({0})
|
||||||
|
colours.fileSection=Editing the stylesheet by hand
|
||||||
|
colours.fileReads=This design reads {0}. A colour set above overrides what the file says for that role.
|
||||||
|
colours.fileAbsent=This design has no stylesheet of its own. One can be written to {0} as a starting point.
|
||||||
|
colours.exportButton=Write this design's stylesheet into my profile
|
||||||
|
colours.exportWritten=Written to {0}. Edit it and restart to see the result.
|
||||||
|
colours.exportAlreadyThere={0} is already there and was left untouched.
|
||||||
|
colours.exportFailed=Could not write the stylesheet: {0}
|
||||||
|
colours.sourceShipped=shipped
|
||||||
|
colours.sourceFile=from file
|
||||||
|
colours.sourceChanged=changed
|
||||||
|
colours.roleSurface=Surface (menu strip, selected rows)
|
||||||
|
colours.roleWindowSurface=Window surface (the windows themselves)
|
||||||
|
colours.roleFieldInterior=Field interior (fields, lists, buttons)
|
||||||
|
colours.roleText=Text
|
||||||
|
colours.roleAccent=Accent
|
||||||
|
colours.roleSeparator=Separator line
|
||||||
|
colours.notAColour='{0}' is not a colour. Six hexadecimal digits with a leading hash, for example #3C7A4B. {1} is unchanged.
|
||||||
|
column.callsign=Callsign
|
||||||
|
column.name=Name
|
||||||
|
column.message=Message
|
||||||
|
column.time=Time
|
||||||
|
column.lastQrgRx=Last QRG RX
|
||||||
|
column.lastQrgTx=Last QRG TX
|
||||||
|
filter.find=Find...
|
||||||
|
filter.reset=Reset filters
|
||||||
|
filter.showOnlyQrb=Show only QRB [km] <=
|
||||||
|
filter.showOnlyQrbAbove=Show only QRB [km] >
|
||||||
|
filter.showOnlyQtf=Show only QTF:
|
||||||
|
filter.hideWorked=Hide worked:
|
||||||
|
filter.inactiveStations=Inactive stations
|
||||||
|
filter.onlyNewGrids=Only new grids
|
||||||
|
filter.gridColour=Grid color
|
||||||
|
filter.newBands=New bands
|
||||||
|
filter.tropo=Tropo >=0dB
|
||||||
|
selected.none=No station selected.
|
||||||
|
selected.priority=Priority:
|
||||||
|
selected.more=more
|
||||||
|
selected.showMessages=Show messages
|
||||||
|
selected.createSked=Create sked
|
||||||
|
selected.sked=Sked
|
||||||
|
selected.skedInMinutes=Sked in (min)
|
||||||
|
selected.skedFail=Sked fail
|
||||||
|
selected.remindPmIn=Remind-PM in
|
||||||
|
selected.reminderOffsets=Reminder offsets
|
||||||
|
selected.resetFail=Reset fail
|
||||||
|
selected.tagNotQrv=Tag not QRV
|
||||||
|
selected.band=Band
|
||||||
|
selected.mode=Mode
|
||||||
|
selected.lookUpAndShow=Look up and show
|
||||||
|
selected.lookupQrz=Lookup on qrz.com
|
||||||
|
selected.lookupQrzcq=Lookup on qrzcq.com
|
||||||
|
selected.showPathInAs=Show path in AS
|
||||||
|
selected.turnAntenna=Turn ant1 to {0}
|
||||||
|
selected.turnAntenna2=Turn ant2 to {0}
|
||||||
|
selected.lastActivity=Last activity: {0} ({1} min ago)
|
||||||
|
candidates.title=Top priority candidates
|
||||||
|
candidates.none=No candidates yet.
|
||||||
|
candidates.doubleClick=Double-click a candidate to select it.
|
||||||
|
update.title=Update information
|
||||||
|
update.available=Update available!
|
||||||
|
update.installedVersion=Installed version:
|
||||||
|
update.latestVersion=Latest stable version:
|
||||||
|
update.mainChanges=Main changes:
|
||||||
|
update.additionalInformation=Additional information:
|
||||||
|
update.download=Download:
|
||||||
|
update.openReleasePage=Open release page
|
||||||
|
update.changeLog=ChangeLog
|
||||||
|
update.knownBugs=Known bugs
|
||||||
|
update.versions=Versions
|
||||||
|
map.title=Station Map
|
||||||
|
map.tab=Map
|
||||||
|
map.resetView=Reset view
|
||||||
|
map.groupNearby=Group nearby stations
|
||||||
|
map.triggerClusterSpot=Trigger cluster spot
|
||||||
|
map.showPathAnalysis=Show path analysis
|
||||||
|
map.hidePathAnalysis=Hide path analysis
|
||||||
|
map.pathAnalysisHidden=Path analysis is hidden.
|
||||||
|
map.tilesUnavailable=Map tiles unavailable — check the network connection.
|
||||||
|
path.title=Path / terrain analysis
|
||||||
|
path.selectStation=Select a station to prepare path and terrain analysis.
|
||||||
|
path.fromLocator=From locator:
|
||||||
|
path.toLocator=To locator:
|
||||||
|
path.endpoints=Endpoints:
|
||||||
|
path.distanceQtf=Distance/QTF:
|
||||||
|
path.frequency=Frequency:
|
||||||
|
path.status=Status:
|
||||||
|
path.source=Source:
|
||||||
|
path.los=LOS:
|
||||||
|
path.fresnel=Fresnel:
|
||||||
|
path.obstruction=Obstruction:
|
||||||
|
path.radioHorizon=Radio horizon:
|
||||||
|
path.terrainHorizon=Terrain horizon:
|
||||||
|
path.refraction=Refraction:
|
||||||
|
path.mechanisms=Mechanisms:
|
||||||
|
path.assessment=Assessment:
|
||||||
|
path.linkBudget=Link budget:
|
||||||
|
path.rxPower=RX power:
|
||||||
|
path.cwHint=CW hint:
|
||||||
|
chart.noSamples=No profile samples available.
|
||||||
|
chart.distanceAxis=Distance [km]
|
||||||
|
chart.heightAxis=Height [m]
|
||||||
|
chart.legend=Legend:
|
||||||
|
chart.terrain=Terrain
|
||||||
|
chart.los=LOS
|
||||||
|
chart.fresnel=Fresnel
|
||||||
|
chart.critical=Critical
|
||||||
|
chart.diffraction=Diffraction
|
||||||
|
chart.home=Home
|
||||||
|
chart.radioHorizonShort=Radio hor.
|
||||||
|
chart.terrainHorizonShort=Terr. hor.
|
||||||
|
chart.homeRadioHorizon=Home radio horizon
|
||||||
|
chart.homeTerrainHorizon=Home terrain horizon
|
||||||
|
chart.dxRadioHorizon=DX radio horizon
|
||||||
|
chart.dxTerrainHorizon=DX terrain horizon
|
||||||
|
chart.frequency=f = {0} MHz
|
||||||
|
chart.criticalFresnel=Critical point: Fresnel intrusion {0} m @ {1} km
|
||||||
|
chart.criticalLos=Critical point: LOS clearance {0} m @ {1} km
|
||||||
|
chart.diffractionCandidate=Diffraction candidate {0} km, KE ≈ {1} dB
|
||||||
|
title.connectionLost=CONNECTION LOST – reconnect scheduled
|
||||||
|
title.connecting=Connecting to ON4KST…
|
||||||
|
title.authenticating=Connected – authenticating with ON4KST…
|
||||||
|
title.synchronizing=Connected – synchronizing ON4KST chat data…
|
||||||
|
title.disconnected=DISCONNECTED!
|
||||||
|
title.online=Connected to: {0} as {1} ({2}) in {3} ({4} users online, {5} shown), {6} messages total.
|
||||||
|
title.bothCategories={0} and {1}
|
||||||
|
status.noConnection=No ON4KST connection
|
||||||
|
status.linkTooltip=ON4KST link: {0}\n{1}
|
||||||
|
status.linkConnected=🟢 LINK: Connected
|
||||||
|
status.linkConnecting=🟡 LINK: Connecting…
|
||||||
|
status.linkDisconnecting=🟡 LINK: Disconnecting…
|
||||||
|
status.linkReconnecting=🔴 LINK: Reconnecting…
|
||||||
|
status.linkDisconnected=🔴 LINK: Disconnected
|
||||||
|
picker.title=Select operator profile
|
||||||
|
picker.moreThanOne=More than one operator profile is configured.
|
||||||
|
picker.start=Start
|
||||||
|
picker.quit=Quit
|
||||||
|
dialog.yes=Yes
|
||||||
|
dialog.switchProfile=Switch profile
|
||||||
|
alert.profileTitle=Operator profile
|
||||||
|
alert.profileNotFound=The requested operator profile was not found.
|
||||||
|
alert.profilesTitle=Operator profiles
|
||||||
|
alert.onlyOneProfile=Only one operator profile is configured.
|
||||||
|
alert.onlyOneProfileBody=Additional profiles are created in the settings window on the "Profiles" tab. Each profile keeps its own settings and layout, and can either share the station worked database or use its own.
|
||||||
|
alert.switchProfileTitle=Switch operator profile
|
||||||
|
alert.switchProfileQuestion=Switch to "{0}"?
|
||||||
|
alert.switchProfileBody=The ON4KST connection is closed and all windows are rebuilt with the settings and layout of the selected profile.\n\nUnsaved settings of the current profile are lost. Window sizes, divider and column widths are saved automatically.
|
||||||
|
alert.quitTitle=Quit application
|
||||||
|
alert.quitQuestion=Do you want to disconnect from the Chat?
|
||||||
|
alert.simpleLogTitle=Simplelogfile created
|
||||||
|
alert.simpleLogHeader=The selected Simplelogfile did not exist and has been created
|
||||||
|
alert.simpleLogBody=File: {0}\n\nFirst check whether you need the Simplelogfile integration. If your logging application provides a supported network interface, use that interface for band and locator information.\n\nIf you use Simplelogfile, configure your logging application to write its live log to this file. Then log a test QSO and verify that the callsign is marked as worked in KST4Contest within one minute.\n\nBefore each contest, verify that the logging application writes the current contest log to this exact file. KST4Contest does not reset Simplelogfile-derived Worked marks automatically when a new contest starts.
|
||||||
|
alert.simpleLogLink=Open the Simplelogfile manual
|
||||||
|
tooltip.bandsNone=Bands: -
|
||||||
|
tooltip.bandsPrefix=Bands:
|
||||||
|
tooltip.bandStatusLegend=Band status:\nX = worked on this band\nB+ = band available, not worked on this band yet (call already worked on another band)\na = band available, call not worked on any band yet (can be turned off in GUI settings; falls back to B+)\no = grid square already worked on this band, by any station (can be turned off in GUI settings)\n(empty) = no information for this band\no can combine with the others, e.g. "ao" or "B+o"
|
||||||
|
tooltip.bandStatusThisStation=\n\nThis station ({0}):\nStatus: {1}
|
||||||
|
tooltip.workedStatusLegend=Worked status:\nempty = call not worked, grid not worked\nx = call worked\no = grid worked\nxo = call and grid worked
|
||||||
|
tooltip.workedStatusThisStation=\n\nThis station:\nCall worked: {0}\nGrid worked: {1} ({2})
|
||||||
|
tooltip.yes=yes
|
||||||
|
tooltip.no=no
|
||||||
|
tooltip.unknownGrid=unknown grid
|
||||||
|
tooltip.grid=grid {0}
|
||||||
|
messages.public=Public messages
|
||||||
|
messages.dxCluster=DXCluster messages
|
||||||
|
messages.qsoOfTheOther=QSO of the other
|
||||||
|
chat.ownQrgTooltip=Your own frequency in the main category
|
||||||
|
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
|
||||||
|
chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced
|
||||||
|
chat.qtf2Tooltip=Heading of antenna 2 (rotator 2, SO2R); display only
|
||||||
|
chat.secondQrgTooltipSo2r=QRG of radio 2 (from RadioNr 2 or Win-Test station 2)
|
||||||
|
list.moveDown=Move selected down
|
||||||
|
list.moveUp=Move selected up
|
||||||
|
list.clearToRemove=Clear the text of an entry to remove it.
|
||||||
|
column.lastKnownQrg=Last known QRG
|
||||||
|
alert.cannotConnectTitle=Cannot connect
|
||||||
|
alert.credentialsMissing=Login credentials missing
|
||||||
|
alert.credentialsMissingBody=Please configure your callsign and password in Settings first.
|
||||||
|
alert.connectionFailedTitle=Connection failed
|
||||||
|
alert.connectionFailedBody=Could not connect: {0}
|
||||||
|
contrast.textOnWindowSurface=Text on the window surface
|
||||||
|
contrast.textInsideField=Text inside a field
|
||||||
|
contrast.accentOnWindowSurface=Accent on the window surface
|
||||||
|
contrast.textOnMenuStrip=Text on the menu strip
|
||||||
|
gui.startInEveningDesign=Start in the evening design
|
||||||
|
gui.filterPublic=Public messages
|
||||||
|
gui.filterPmToMe=Private messages to me
|
||||||
|
gui.filterPmToOthers=Private messages to others
|
||||||
|
gui.filterNothing=No filter
|
||||||
|
gui.hintFreshCall=Mark a callsign seen for the first time
|
||||||
|
gui.hintLargeGridWorked=Mark a worked large grid field
|
||||||
|
gui.rememberWindowPositions=Reopen windows across restarts where they were last moved to (same screen only)
|
||||||
|
form.choose=Choose...
|
||||||
|
form.notSelected=not selected
|
||||||
|
dialog.apply=Apply
|
||||||
|
chat.clear=clear
|
||||||
|
selected.showOnMap=🗺 Show on map
|
||||||
|
selected.notQrvAll=all
|
||||||
|
column.category=Category
|
||||||
|
column.sender=Sender
|
||||||
|
column.receiver=Receiver
|
||||||
|
column.lastQrg=Last QRG
|
||||||
|
column.airplane=AP [minutes / pot%]
|
||||||
|
column.score=Score
|
||||||
|
monitor.title=Cluster & QSO of the other
|
||||||
|
monitor.dxCluster=DX cluster
|
||||||
|
profiles.createFailed=The profile could not be created. The profile registry could not be written.
|
||||||
|
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
|
||||||
|
profiles.duplicateFailed=The profile could not be duplicated.
|
||||||
|
profiles.rootNotDeletable=The default profile uses the files of the installation itself and cannot be deleted.
|
||||||
|
profiles.activeNotDeletable=The profile currently in use cannot be deleted. Switch to another profile first.
|
||||||
|
profiles.deleteFailed=The profile could not be deleted.
|
||||||
|
profiles.deletionFolder=The following folder is removed permanently:\n{0}
|
||||||
|
profiles.deletionKeepsShared=The common station worked stations are not touched.
|
||||||
|
profiles.deletionRemovesOwn=The worked stations of this profile are deleted as well.
|
||||||
|
profiles.rootAlwaysShared=The default profile always uses the common station worked stations, because that database is the one of the installation itself.
|
||||||
|
profiles.changeAfterSwitch=The change takes effect after switching to this profile again.
|
||||||
|
profiles.alreadyActive=This profile is already active.
|
||||||
|
profiles.selectFirst=Select a profile in the table first.
|
||||||
|
beacon.invalidMessage=The beacon message is invalid: {0}
|
||||||
|
beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minute.
|
||||||
|
notification.invalidPort="{0}" is not a valid TCP port. Enter a value from 1 to 65535.
|
||||||
|
notification.invalidSpotter="{0}" is not a valid spotter callsign.
|
||||||
|
notification.invalidCallsign=Please enter a valid callsign.
|
||||||
|
notification.alreadyMonitored=This base callsign is already in the monitoring list.
|
||||||
|
settings.windowTitle=Change Client Settings
|
||||||
|
settings.noticeInfo=Info
|
||||||
|
settings.noticeProblem=Settings
|
||||||
|
profiles.unknown=unknown
|
||||||
|
profiles.copyOf=Copy of {0}
|
||||||
|
profiles.sharedDatabase=common station database
|
||||||
|
profiles.ownDatabase=own database
|
||||||
File renamed without changes.
@@ -0,0 +1,101 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.controller.ActiveOperatorProfile;
|
||||||
|
import kst4contest.controller.OperatorProfilePaths;
|
||||||
|
import kst4contest.controller.OperatorProfileStore;
|
||||||
|
import kst4contest.model.OperatorProfile;
|
||||||
|
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the lifecycle of the running application.
|
||||||
|
*
|
||||||
|
* <p>Switching the operator profile tears the current runtime down completely and builds
|
||||||
|
* a fresh one in the same process. Rebinding the existing windows is not an option: the
|
||||||
|
* user interface is built from the chat controller outwards, with several hundred
|
||||||
|
* references to the active preferences, and many controls are instance fields created
|
||||||
|
* once. A new {@link Kst4ContestApplication} instance gets fresh controls, which is safe
|
||||||
|
* here because the class keeps no mutable static state.</p>
|
||||||
|
*/
|
||||||
|
public final class ApplicationRuntimeLauncher {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(ApplicationRuntimeLauncher.class.getName());
|
||||||
|
|
||||||
|
private static Kst4ContestApplication currentRuntime;
|
||||||
|
|
||||||
|
private ApplicationRuntimeLauncher() {
|
||||||
|
// Utility class.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the runtime that is currently live.
|
||||||
|
*
|
||||||
|
* @param runtime the running application instance
|
||||||
|
*/
|
||||||
|
public static void setCurrent(final Kst4ContestApplication runtime) {
|
||||||
|
currentRuntime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the runtime that is currently live.
|
||||||
|
*
|
||||||
|
* @return the running application instance, or null before the first startup
|
||||||
|
*/
|
||||||
|
public static Kst4ContestApplication getCurrent() {
|
||||||
|
return currentRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuts the application down.
|
||||||
|
*
|
||||||
|
* <p>Releases the live runtime's resources explicitly: after a profile switch that is
|
||||||
|
* no longer the instance the process started with.</p>
|
||||||
|
*/
|
||||||
|
public static void exitApplication() {
|
||||||
|
|
||||||
|
if (currentRuntime != null) {
|
||||||
|
currentRuntime.shutdownRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the running runtime with one bound to another operator profile.
|
||||||
|
*
|
||||||
|
* @param targetProfile profile to activate
|
||||||
|
* @return true if the new runtime was built
|
||||||
|
*/
|
||||||
|
public static boolean switchProfile(final OperatorProfile targetProfile) {
|
||||||
|
|
||||||
|
if (targetProfile == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
new OperatorProfileStore().recordLastUsed(targetProfile.getProfileId());
|
||||||
|
|
||||||
|
if (currentRuntime != null) {
|
||||||
|
currentRuntime.shutdownRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveOperatorProfile.set(OperatorProfilePaths.resolve(targetProfile));
|
||||||
|
|
||||||
|
Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
|
||||||
|
|
||||||
|
try {
|
||||||
|
nextRuntime.startRuntime();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// The previous runtime is already gone, so there is nothing left to return to.
|
||||||
|
LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
|
||||||
|
Kst4ContestApplication.alertWindowEvent(
|
||||||
|
"The operator profile could not be started: " + e.getMessage()
|
||||||
|
+ "\n\nKST4Contest has to be closed.");
|
||||||
|
System.exit(1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrent(nextRuntime);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.observe.UiDispatcher;
|
||||||
|
|
||||||
|
import java.awt.EventQueue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link UiDispatcher} backed by the AWT event dispatch thread.
|
||||||
|
*
|
||||||
|
* <p>That is the thread Compose Desktop composes on, so this is where user-interface
|
||||||
|
* state belongs. The contract is the one {@code JavaFxUiDispatcher} had, down to the
|
||||||
|
* inline execution: a task handed over from the user-interface thread runs before
|
||||||
|
* {@link #runOnUi(Runnable)} returns rather than queueing behind work that is already
|
||||||
|
* pending. Several callers in {@code ChatController} ask {@link #isUiThread()} first and
|
||||||
|
* rely on exactly that.</p>
|
||||||
|
*/
|
||||||
|
public class AwtUiDispatcher implements UiDispatcher {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runOnUi(Runnable task) {
|
||||||
|
if (EventQueue.isDispatchThread()) {
|
||||||
|
runGuarded(task);
|
||||||
|
} else {
|
||||||
|
EventQueue.invokeLater(() -> runGuarded(task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isUiThread() {
|
||||||
|
return EventQueue.isDispatchThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runGuarded(Runnable task) {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Must not tear down the AWT event dispatch thread.
|
||||||
|
System.err.println("[observe] UI task failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses a burst of requests into one run, a fixed delay after the last of them.
|
||||||
|
*
|
||||||
|
* <p>Replaces the JavaFX pause transition the station list used. A
|
||||||
|
* {@code javax.swing.Timer} fires on the AWT event thread, which is where the user
|
||||||
|
* interface lives, and {@code restart()} means exactly what that class's
|
||||||
|
* {@code playFromStart()} meant. The old one needed a running JavaFX toolkit, which the
|
||||||
|
* application no longer starts.</p>
|
||||||
|
*/
|
||||||
|
public final class CoalescingTrigger {
|
||||||
|
|
||||||
|
private final DelayTimer timer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param delayMs how long to wait after the last request
|
||||||
|
* @param action runs on the user-interface thread once the delay elapses
|
||||||
|
*/
|
||||||
|
public CoalescingTrigger(final int delayMs, final Runnable action) {
|
||||||
|
this(delayMs, action, DelayTimer.SWING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seam: the same trigger on a timer whose firing the caller controls.
|
||||||
|
*
|
||||||
|
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
|
||||||
|
*/
|
||||||
|
CoalescingTrigger(final int delayMs, final Runnable action, final DelayTimer.Factory timers) {
|
||||||
|
Objects.requireNonNull(action, "action");
|
||||||
|
this.timer = timers.create(delayMs, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requests a run, restarting the delay. */
|
||||||
|
public void trigger() {
|
||||||
|
timer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a pending run. Idempotent, and the trigger stays usable afterwards. */
|
||||||
|
public void cancel() {
|
||||||
|
timer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command line options of the application.
|
||||||
|
*
|
||||||
|
* <p>The parsed value is additionally kept in a process wide holder. JavaFX only knows
|
||||||
|
* the parameters of the {@code Application} instance it launched itself, so an instance
|
||||||
|
* created during a profile switch would see no parameters at all. Parsing once at
|
||||||
|
* startup and remembering the result avoids that entirely.</p>
|
||||||
|
*/
|
||||||
|
public class CommandLineOptions {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command line switch selecting the operator profile to start with.
|
||||||
|
*/
|
||||||
|
public static final String PROFILE_ARGUMENT = "--profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System property used as an alternative to the command line switch.
|
||||||
|
*/
|
||||||
|
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the Compose main window beside the JavaFX one.
|
||||||
|
*
|
||||||
|
* <p>Off unless asked for. The two are meant to run side by side while the operator
|
||||||
|
* compares them against a live session; until that comparison is done, an ordinary start
|
||||||
|
* must behave exactly as it did.</p>
|
||||||
|
*/
|
||||||
|
public static final String COMPOSE_MAIN_WINDOW_ARGUMENT = "--compose-main-window";
|
||||||
|
|
||||||
|
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
|
||||||
|
|
||||||
|
private final String requestedProfileName;
|
||||||
|
|
||||||
|
private final boolean composeMainWindowRequested;
|
||||||
|
|
||||||
|
public CommandLineOptions(final String requestedProfileName) {
|
||||||
|
this(requestedProfileName, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandLineOptions(
|
||||||
|
final String requestedProfileName,
|
||||||
|
final boolean composeMainWindowRequested) {
|
||||||
|
this.composeMainWindowRequested = composeMainWindowRequested;
|
||||||
|
this.requestedProfileName = requestedProfileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the raw application arguments.
|
||||||
|
*
|
||||||
|
* <p>Unknown arguments are ignored on purpose. A typo in a command line must never
|
||||||
|
* keep an operator out of the application shortly before a contest.</p>
|
||||||
|
*
|
||||||
|
* @param rawArguments raw arguments, may be null
|
||||||
|
* @return the parsed options
|
||||||
|
*/
|
||||||
|
public static CommandLineOptions parse(final List<String> rawArguments) {
|
||||||
|
|
||||||
|
String requestedProfileName = null;
|
||||||
|
boolean composeMainWindow = false;
|
||||||
|
|
||||||
|
if (rawArguments != null) {
|
||||||
|
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
|
||||||
|
String currentArgument = rawArguments.get(argumentIndex);
|
||||||
|
|
||||||
|
if (currentArgument == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (COMPOSE_MAIN_WINDOW_ARGUMENT.equals(currentArgument)) {
|
||||||
|
composeMainWindow = true;
|
||||||
|
} else if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
|
||||||
|
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
|
||||||
|
} else if (PROFILE_ARGUMENT.equals(currentArgument)
|
||||||
|
&& argumentIndex + 1 < rawArguments.size()) {
|
||||||
|
requestedProfileName = rawArguments.get(argumentIndex + 1);
|
||||||
|
argumentIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedProfileName == null || requestedProfileName.isBlank()) {
|
||||||
|
requestedProfileName = System.getProperty(PROFILE_SYSTEM_PROPERTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedProfileName != null && requestedProfileName.isBlank()) {
|
||||||
|
requestedProfileName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CommandLineOptions(
|
||||||
|
requestedProfileName == null ? null : requestedProfileName.trim(),
|
||||||
|
composeMainWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the parsed options for the lifetime of the process.
|
||||||
|
*
|
||||||
|
* @param options options to remember
|
||||||
|
*/
|
||||||
|
public static void remember(final CommandLineOptions options) {
|
||||||
|
rememberedOptions = options == null ? new CommandLineOptions(null) : options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the options parsed at application startup.
|
||||||
|
*
|
||||||
|
* @return the remembered options, never null
|
||||||
|
*/
|
||||||
|
public static CommandLineOptions remembered() {
|
||||||
|
return rememberedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the operator profile requested on the command line.
|
||||||
|
*
|
||||||
|
* @return the requested profile name, or null when none was given
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Whether the Compose main window should be opened beside the JavaFX one.
|
||||||
|
*
|
||||||
|
* @return {@code true} only when --compose-main-window was passed
|
||||||
|
*/
|
||||||
|
public boolean isComposeMainWindowRequested() {
|
||||||
|
return composeMainWindowRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequestedProfileName() {
|
||||||
|
return requestedProfileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.model.ChatCategory;
|
||||||
|
import kst4contest.model.ChatMember;
|
||||||
|
import kst4contest.model.ChatMessage;
|
||||||
|
import kst4contest.view.compose.ChatInputActions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Compose send line needs from the runtime.
|
||||||
|
*
|
||||||
|
* <p>Every method forwards to the same code the JavaFX send button runs, so that while the two
|
||||||
|
* windows sit side by side a message sent from either takes exactly one path. The order the
|
||||||
|
* steps are called in lives in {@code ChatInputState}, where it is tested against the
|
||||||
|
* original; this class only answers questions.</p>
|
||||||
|
*/
|
||||||
|
public final class ComposeChatInputActions implements ChatInputActions {
|
||||||
|
|
||||||
|
private final Kst4ContestApplication application;
|
||||||
|
|
||||||
|
public ComposeChatInputActions(final Kst4ContestApplication application) {
|
||||||
|
this.application = application;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatMember panelSelection() {
|
||||||
|
return application.selectedCallSignInfoStageChatMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatMember tableSelection() {
|
||||||
|
/*
|
||||||
|
* The JavaFX station table is gone. Kept rather than dropped from the interface
|
||||||
|
* because ChatInputState's three-step lookup is the documented selection order, and
|
||||||
|
* a Compose station selection may fill this in again.
|
||||||
|
*/
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatMember scoreSelection() {
|
||||||
|
if (application.chatcontroller == null
|
||||||
|
|| application.chatcontroller.getScoreService() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return application.chatcontroller.getScoreService().getSelectedChatMember();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String resolveVariables(final String template, final ChatMember member) {
|
||||||
|
if (application.messageVariableResolver == null) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
return application.messageVariableResolver.resolveForSelectedStation(template, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAddressedToOwnCallsign(final String text) {
|
||||||
|
return application.isMessageAddressedToOwnCallsignFromCompose(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatCategory resolveCategory(final String text, final ChatMember member) {
|
||||||
|
return application.resolveOutgoingChatCategoryFromCompose(text, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatCategory mainCategory() {
|
||||||
|
return application.chatcontroller.getChatCategoryMain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void recordOutboundCq(final String text) {
|
||||||
|
application.chatcontroller.getStationMetricsService()
|
||||||
|
.tryRecordOutboundCq(text, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void requestRecompute(final String reason) {
|
||||||
|
if (application.chatcontroller.getScoreService() != null) {
|
||||||
|
application.chatcontroller.getScoreService().requestRecompute(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void queue(final ChatMessage message) {
|
||||||
|
application.chatcontroller.getMessageTXBus().add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String ownQrgMain() {
|
||||||
|
String value = application.chatcontroller.getChatPreferences().getMYQRGFirstCat().get();
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String ownQrgSecond() {
|
||||||
|
String value = application.chatcontroller.getChatPreferences().getMYQRGSecondCat().get();
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.view.compose.MainMenuActions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Compose menu bar's actions, delegated to methods on {@link Kst4ContestApplication}.
|
||||||
|
*
|
||||||
|
* <p>These used to fire the {@code MenuItem}s of the old JavaFX menu bar,
|
||||||
|
* which ran side by side with the Compose bar so the two could be compared. The JavaFX bar is
|
||||||
|
* gone (part 3), so the handlers now live in {@code menuAction…} methods of the application and
|
||||||
|
* this class calls those directly. It stays a thin bridge: no behaviour of its own, one call per
|
||||||
|
* menu entry.</p>
|
||||||
|
*/
|
||||||
|
public final class ComposeMenuActions implements MainMenuActions {
|
||||||
|
|
||||||
|
private final Kst4ContestApplication application;
|
||||||
|
|
||||||
|
public ComposeMenuActions(final Kst4ContestApplication application) {
|
||||||
|
this.application = application;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connect() {
|
||||||
|
application.runOnUi(application::menuActionConnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void disconnect() {
|
||||||
|
application.runOnUi(application::menuActionDisconnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void switchOperatorProfile() {
|
||||||
|
application.runOnUi(application::showOperatorProfileSwitchDialogFromCompose);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void exitApplication() {
|
||||||
|
application.runOnUi(application::menuActionExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setQrgAsNameInChat() {
|
||||||
|
application.runOnUi(application::menuActionSetQrgAsNameInChat);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void toggleAwayState() {
|
||||||
|
application.runOnUi(application::menuActionToggleAwayState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void toggleSettingsWindow() {
|
||||||
|
application.runOnUi(application::menuActionToggleSettingsWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void toggleMonitorWindow() {
|
||||||
|
application.runOnUi(application::menuActionToggleMonitorWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void toggleStationMap() {
|
||||||
|
application.runOnUi(application::menuActionToggleStationMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void useDarkDesign() {
|
||||||
|
application.runOnUi(application::menuActionUseDarkDesign);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void useDefaultDesign() {
|
||||||
|
application.runOnUi(application::menuActionUseDefaultDesign);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void openDonationPage() {
|
||||||
|
application.runOnUi(application::menuActionOpenDonationPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void openHomepage() {
|
||||||
|
application.runOnUi(application::menuActionOpenHomepage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void openNewsgroup() {
|
||||||
|
application.runOnUi(application::menuActionOpenNewsgroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void openChangelog() {
|
||||||
|
/*
|
||||||
|
* The JavaFX item and its handler were both commented out, so there is nothing to do.
|
||||||
|
* The method stays because the interface is shared and an empty body is honest about
|
||||||
|
* there being no such feature.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void openOv3tDonationPage() {
|
||||||
|
application.runOnUi(application::menuActionOpenOv3tDonationPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void contactAuthor() {
|
||||||
|
application.runOnUi(application::menuActionContactAuthor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showAbout() {
|
||||||
|
application.runOnUi(application::menuActionShowAbout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import javax.swing.Timer;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-shot delay that can be restarted and stopped.
|
||||||
|
*
|
||||||
|
* <p>The thin seam between the two coalescing classes in this package and the
|
||||||
|
* {@code javax.swing.Timer} they run on. In production the Swing timer is the only
|
||||||
|
* implementation; tests substitute one whose firing they control, so "nothing ran" can
|
||||||
|
* be asserted without waiting for a delay that might not have elapsed yet.</p>
|
||||||
|
*/
|
||||||
|
interface DelayTimer {
|
||||||
|
|
||||||
|
/** Starts the delay, or starts it over if it is already running. */
|
||||||
|
void restart();
|
||||||
|
|
||||||
|
/** Drops a pending run. Idempotent. */
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
/** Creates one-shot timers for a fixed delay and action. */
|
||||||
|
interface Factory {
|
||||||
|
/**
|
||||||
|
* @param delayMs how long the timer waits after a {@link #restart()}
|
||||||
|
* @param onFire what runs once the delay elapses
|
||||||
|
*/
|
||||||
|
DelayTimer create(int delayMs, Runnable onFire);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The production factory: a {@code javax.swing.Timer}, which fires on the AWT event
|
||||||
|
* thread where the user interface lives.
|
||||||
|
*/
|
||||||
|
Factory SWING = (delayMs, onFire) -> new Swing(delayMs, onFire);
|
||||||
|
|
||||||
|
/** {@code javax.swing.Timer} behind the interface. */
|
||||||
|
final class Swing implements DelayTimer {
|
||||||
|
|
||||||
|
private final Timer timer;
|
||||||
|
|
||||||
|
Swing(final int delayMs, final Runnable onFire) {
|
||||||
|
Objects.requireNonNull(onFire, "onFire");
|
||||||
|
this.timer = new Timer(delayMs, event -> onFire.run());
|
||||||
|
this.timer.setRepeats(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void restart() {
|
||||||
|
timer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
timer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import java.awt.Desktop;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens an address in whatever the desktop uses for it.
|
||||||
|
*
|
||||||
|
* <p>Replaces {@code Application.getHostServices().showDocument(...)}, which went away
|
||||||
|
* with {@code extends Application}. JavaFX treated web and mail addresses alike; AWT does
|
||||||
|
* not, so the scheme is decided here: {@code Desktop.mail} for mail, {@code Desktop.browse}
|
||||||
|
* for everything else. A {@code browse("mailto:...")} silently opens nothing on Linux.</p>
|
||||||
|
*/
|
||||||
|
public final class ExternalDocuments {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(ExternalDocuments.class.getName());
|
||||||
|
|
||||||
|
private static final String MAIL_SCHEME = "mailto:";
|
||||||
|
|
||||||
|
private ExternalDocuments() {
|
||||||
|
// Utility class.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the address names a mail recipient rather than a document.
|
||||||
|
*
|
||||||
|
* @param address the address to classify, may be null
|
||||||
|
* @return true when the address carries the mailto scheme
|
||||||
|
*/
|
||||||
|
public static boolean isMailAddress(final String address) {
|
||||||
|
|
||||||
|
if (address == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return address.trim().toLowerCase(Locale.ROOT).startsWith(MAIL_SCHEME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the address, or logs why it could not be opened.
|
||||||
|
*
|
||||||
|
* <p>A failure here is never worth terminating anything: the operator asked for a web
|
||||||
|
* page, not for a state change.</p>
|
||||||
|
*
|
||||||
|
* @param address an http/https address or a mailto address, may be null
|
||||||
|
*/
|
||||||
|
public static void open(final String address) {
|
||||||
|
|
||||||
|
if (address == null || address.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
URI target = URI.create(address.trim());
|
||||||
|
|
||||||
|
if (!Desktop.isDesktopSupported()) {
|
||||||
|
LOGGER.log(Level.WARNING, "No desktop integration available for {0}", address);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Desktop desktop = Desktop.getDesktop();
|
||||||
|
|
||||||
|
if (isMailAddress(address)) {
|
||||||
|
desktop.mail(target);
|
||||||
|
} else {
|
||||||
|
desktop.browse(target);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.log(Level.WARNING, "Could not open " + address, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-55
@@ -4,62 +4,15 @@ import kst4contest.controller.ChatController;
|
|||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import javafx.scene.image.Image;
|
import java.awt.EventQueue;
|
||||||
import javafx.stage.Stage;
|
|
||||||
|
|
||||||
import java.net.URL;
|
|
||||||
|
|
||||||
public class GuiUtils {
|
public class GuiUtils {
|
||||||
|
|
||||||
private static final String APPLICATION_ICON_RESOURCE = "/icons/kst4contest.png";
|
/*
|
||||||
|
* The application icon used to live here as a JavaFX image applied to a Stage. There is
|
||||||
private static Image applicationIcon;
|
* no Stage left: every window is a Compose window and loads the same resource itself in
|
||||||
|
* ComposeWindowHost.applicationIcon().
|
||||||
/**
|
|
||||||
* Applies the common KST4Contest application icon to a JavaFX stage.
|
|
||||||
*
|
|
||||||
* <p>The icon is loaded only once and reused for all application windows.
|
|
||||||
* A missing icon resource must never prevent a window from opening.</p>
|
|
||||||
*
|
|
||||||
* @param stage stage that should receive the application icon
|
|
||||||
*/
|
*/
|
||||||
public static void applyApplicationIcon(Stage stage) {
|
|
||||||
|
|
||||||
if (stage == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Image icon = getApplicationIcon();
|
|
||||||
|
|
||||||
if (icon != null && !stage.getIcons().contains(icon)) {
|
|
||||||
stage.getIcons().add(icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and caches the common KST4Contest application icon.
|
|
||||||
*
|
|
||||||
* @return application icon or null if the resource is unavailable
|
|
||||||
*/
|
|
||||||
private static Image getApplicationIcon() {
|
|
||||||
|
|
||||||
if (applicationIcon != null) {
|
|
||||||
return applicationIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
URL iconUrl = GuiUtils.class.getResource(APPLICATION_ICON_RESOURCE);
|
|
||||||
|
|
||||||
if (iconUrl == null) {
|
|
||||||
System.err.println(
|
|
||||||
"Application icon resource not found: "
|
|
||||||
+ APPLICATION_ICON_RESOURCE
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
applicationIcon = new Image(iconUrl.toExternalForm());
|
|
||||||
return applicationIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$";
|
private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$";
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +30,7 @@ public class GuiUtils {
|
|||||||
* @param maybeCallSignValue
|
* @param maybeCallSignValue
|
||||||
* @return true if yes
|
* @return true if yes
|
||||||
*/
|
*/
|
||||||
static boolean isCallSignSyntax(String maybeCallSignValue) {
|
public static boolean isCallSignSyntax(String maybeCallSignValue) {
|
||||||
|
|
||||||
Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE);
|
Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE);
|
||||||
Matcher matcher = pattern.matcher(maybeCallSignValue);
|
Matcher matcher = pattern.matcher(maybeCallSignValue);
|
||||||
@@ -96,10 +49,10 @@ public class GuiUtils {
|
|||||||
|
|
||||||
public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) {
|
public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) {
|
||||||
|
|
||||||
if (javafx.application.Platform.isFxApplicationThread()) {
|
if (EventQueue.isDispatchThread()) {
|
||||||
triggerUpdate(chatController);
|
triggerUpdate(chatController);
|
||||||
} else {
|
} else {
|
||||||
javafx.application.Platform.runLater(() -> triggerUpdate(chatController));
|
EventQueue.invokeLater(() -> triggerUpdate(chatController));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,83 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.observe.UiDispatcher;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesces layout changes into selective preferences writes.
|
||||||
|
*
|
||||||
|
* <p>Dragging a column divider produces a stream of change events; writing the
|
||||||
|
* preferences file on each would make the drag stutter. The write is therefore delayed
|
||||||
|
* and restarted on every request, so a burst ends in exactly one write.</p>
|
||||||
|
*
|
||||||
|
* <p>The class knows nothing about where the layout goes: it takes the write action, not
|
||||||
|
* the preferences. That is what lets the debounce be tested without touching the
|
||||||
|
* operator's profile on disk.</p>
|
||||||
|
*/
|
||||||
|
public final class LayoutAutosave {
|
||||||
|
|
||||||
|
private static final int SAVE_DELAY_MS = 750;
|
||||||
|
|
||||||
|
private final Runnable writeLayout;
|
||||||
|
private final UiDispatcher uiDispatcher;
|
||||||
|
private final DelayTimer saveDelay;
|
||||||
|
private boolean pending;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param writeLayout persists the layout; called on the user-interface thread
|
||||||
|
* @param uiDispatcher confines the pending state to one thread
|
||||||
|
*/
|
||||||
|
public LayoutAutosave(final Runnable writeLayout, final UiDispatcher uiDispatcher) {
|
||||||
|
/*
|
||||||
|
* A javax.swing.Timer and not the former JavaFX PauseTransition: it fires on the
|
||||||
|
* AWT event thread, which is where the user interface lives now, and restart()
|
||||||
|
* has the same meaning playFromStart() had. A PauseTransition would need the
|
||||||
|
* JavaFX toolkit to be running, which it no longer is.
|
||||||
|
*/
|
||||||
|
this(writeLayout, uiDispatcher, DelayTimer.SWING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seam: the same debounce on a timer whose firing the caller controls.
|
||||||
|
*
|
||||||
|
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
|
||||||
|
*/
|
||||||
|
LayoutAutosave(
|
||||||
|
final Runnable writeLayout,
|
||||||
|
final UiDispatcher uiDispatcher,
|
||||||
|
final DelayTimer.Factory timers
|
||||||
|
) {
|
||||||
|
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
|
||||||
|
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
|
||||||
|
this.saveDelay = timers.create(SAVE_DELAY_MS, this::flushPending);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks for a save, restarting the delay. Safe to call from any thread. */
|
||||||
|
public void requestSave() {
|
||||||
|
if (!uiDispatcher.isUiThread()) {
|
||||||
|
uiDispatcher.runOnUi(this::requestSave);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = true;
|
||||||
|
saveDelay.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a pending layout now. Does nothing when nothing is pending. */
|
||||||
|
public void flushPending() {
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDelay.stop();
|
||||||
|
pending = false;
|
||||||
|
writeLayout.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a pending layout without writing it. */
|
||||||
|
public void cancelPending() {
|
||||||
|
saveDelay.stop();
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,107 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.controller.OperatorProfilePaths;
|
||||||
|
import kst4contest.controller.OperatorProfileStore;
|
||||||
|
import kst4contest.model.OperatorProfile;
|
||||||
|
import kst4contest.model.OperatorProfileSelection;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides which operator profile the application starts with.
|
||||||
|
*
|
||||||
|
* <p>The class contains no user interface code so the decision can be tested headless.
|
||||||
|
* Asking the operator is delegated to an {@link OperatorProfileChoiceRequester}, and a
|
||||||
|
* problem worth telling the operator about is reported through
|
||||||
|
* {@link #getStartupWarning()} instead of being shown here.</p>
|
||||||
|
*
|
||||||
|
* <p>The most important property of this logic is what it does <em>not</em> do: an
|
||||||
|
* installation with no or exactly one profile is resolved without asking anything and
|
||||||
|
* without touching a single file, so a single operator start stays exactly as fast and
|
||||||
|
* as quiet as it was before profiles existed.</p>
|
||||||
|
*/
|
||||||
|
public class OperatorProfileBootstrap {
|
||||||
|
|
||||||
|
private String startupWarning;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the operator profile to start with.
|
||||||
|
*
|
||||||
|
* @param store registry to read the profiles from
|
||||||
|
* @param commandLineOptions parsed command line options
|
||||||
|
* @param choiceRequester requester used when the operator has to choose
|
||||||
|
* @return the resolved selection, or null when the operator chose to quit
|
||||||
|
*/
|
||||||
|
public OperatorProfileSelection resolveAtStartup(final OperatorProfileStore store,
|
||||||
|
final CommandLineOptions commandLineOptions,
|
||||||
|
final OperatorProfileChoiceRequester choiceRequester) {
|
||||||
|
|
||||||
|
startupWarning = null;
|
||||||
|
|
||||||
|
List<OperatorProfile> availableProfiles = store.loadProfiles();
|
||||||
|
String requestedProfileName = commandLineOptions == null
|
||||||
|
? null
|
||||||
|
: commandLineOptions.getRequestedProfileName();
|
||||||
|
|
||||||
|
if (requestedProfileName != null) {
|
||||||
|
OperatorProfile requestedProfile = findProfile(availableProfiles, requestedProfileName);
|
||||||
|
|
||||||
|
if (requestedProfile != null) {
|
||||||
|
return OperatorProfilePaths.resolve(requestedProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
startupWarning = "The operator profile \"" + requestedProfileName
|
||||||
|
+ "\" is unknown. KST4Contest continues with the normal profile selection.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableProfiles.isEmpty()) {
|
||||||
|
// No registry at all: the historic flat installation is the only profile.
|
||||||
|
return OperatorProfilePaths.resolve(store.buildImplicitRootProfile());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableProfiles.size() == 1) {
|
||||||
|
return OperatorProfilePaths.resolve(availableProfiles.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
String preselectedProfileId = store.loadLastUsedProfileId().orElse(null);
|
||||||
|
Optional<OperatorProfile> chosenProfile =
|
||||||
|
choiceRequester.requestProfileChoice(availableProfiles, preselectedProfileId);
|
||||||
|
|
||||||
|
return chosenProfile.map(OperatorProfilePaths::resolve).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a message that should be shown to the operator after startup.
|
||||||
|
*
|
||||||
|
* @return the warning text, or null when startup was unremarkable
|
||||||
|
*/
|
||||||
|
public String getStartupWarning() {
|
||||||
|
return startupWarning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a profile by identifier or display name, ignoring case.
|
||||||
|
*
|
||||||
|
* @param availableProfiles profiles to search
|
||||||
|
* @param requestedName identifier or display name entered by the operator
|
||||||
|
* @return the matching profile, or null
|
||||||
|
*/
|
||||||
|
private static OperatorProfile findProfile(final List<OperatorProfile> availableProfiles,
|
||||||
|
final String requestedName) {
|
||||||
|
|
||||||
|
for (OperatorProfile currentProfile : availableProfiles) {
|
||||||
|
if (requestedName.equalsIgnoreCase(currentProfile.getProfileId())) {
|
||||||
|
return currentProfile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (OperatorProfile currentProfile : availableProfiles) {
|
||||||
|
if (requestedName.equalsIgnoreCase(currentProfile.getDisplayName())) {
|
||||||
|
return currentProfile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.model.OperatorProfile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the operator which profile to start with.
|
||||||
|
*
|
||||||
|
* <p>The startup logic depends on this interface rather than on a dialog, so the
|
||||||
|
* decision which profile to use can be tested without a JavaFX runtime.</p>
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface OperatorProfileChoiceRequester {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a profile choice.
|
||||||
|
*
|
||||||
|
* @param selectableProfiles profiles to choose from, never empty
|
||||||
|
* @param preselectedProfileId identifier to preselect, may be null
|
||||||
|
* @return the chosen profile, or empty when the operator wants to quit
|
||||||
|
*/
|
||||||
|
Optional<OperatorProfile> requestProfileChoice(List<OperatorProfile> selectableProfiles,
|
||||||
|
String preselectedProfileId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the CSS style class used for a private-message table row.
|
||||||
|
*/
|
||||||
|
public final class PrivateMessageRowStyleResolver {
|
||||||
|
|
||||||
|
/** Style used for messages sent by the local station. */
|
||||||
|
public static final String OWN_STYLE_CLASS =
|
||||||
|
"messageHighlightOwn-column";
|
||||||
|
|
||||||
|
/** Upper inclusive age bounds for the private-message color levels. */
|
||||||
|
private static final List<Long> AGE_LIMITS = List.of(
|
||||||
|
30L,
|
||||||
|
60L,
|
||||||
|
90L,
|
||||||
|
120L,
|
||||||
|
180L,
|
||||||
|
300L
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Style classes corresponding to the configured age bounds. */
|
||||||
|
private static final List<String> AGE_STYLES = List.of(
|
||||||
|
"messageHighlight30-column",
|
||||||
|
"messageHighlight60-column",
|
||||||
|
"messageHighlight90-column",
|
||||||
|
"messageHighlight120-column",
|
||||||
|
"messageHighlight180-column",
|
||||||
|
"messageHighlight300-column"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** All private-message row classes managed by the row factory. */
|
||||||
|
private static final List<String> MANAGED_STYLES = List.of(
|
||||||
|
OWN_STYLE_CLASS,
|
||||||
|
AGE_STYLES.get(0),
|
||||||
|
AGE_STYLES.get(1),
|
||||||
|
AGE_STYLES.get(2),
|
||||||
|
AGE_STYLES.get(3),
|
||||||
|
AGE_STYLES.get(4),
|
||||||
|
AGE_STYLES.get(5)
|
||||||
|
);
|
||||||
|
|
||||||
|
private PrivateMessageRowStyleResolver() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the complete set of private-message row classes managed by the
|
||||||
|
* row factory.
|
||||||
|
*
|
||||||
|
* @return immutable list of managed style classes
|
||||||
|
*/
|
||||||
|
public static List<String> knownStyleClasses() {
|
||||||
|
return MANAGED_STYLES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the private-message row class for the supplied message age.
|
||||||
|
*
|
||||||
|
* @param ownMessage whether the message was sent by the local station
|
||||||
|
* @param ageSeconds message age in seconds
|
||||||
|
* @return managed CSS class, or {@code null} after the five-minute window
|
||||||
|
*/
|
||||||
|
public static String resolveStyleClass(
|
||||||
|
final boolean ownMessage,
|
||||||
|
final long ageSeconds
|
||||||
|
) {
|
||||||
|
String styleClass = null;
|
||||||
|
if (ownMessage) {
|
||||||
|
styleClass = OWN_STYLE_CLASS;
|
||||||
|
} else {
|
||||||
|
for (int index = 0; index < AGE_LIMITS.size(); index++) {
|
||||||
|
if (ageSeconds <= AGE_LIMITS.get(index)) {
|
||||||
|
styleClass = AGE_STYLES.get(index);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return styleClass;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.observe.ObservableRoster;
|
||||||
|
import kst4contest.observe.UiDispatcher;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors an {@link ObservableRoster} into a plain list the user interface can read.
|
||||||
|
*
|
||||||
|
* <p>The mirror instance never changes: callers hold on to the list they were handed, so
|
||||||
|
* {@link #list()} returns the same unmodifiable view every time. {@link #dispose()}
|
||||||
|
* unregisters the listener, which is what keeps a discarded runtime from being held alive
|
||||||
|
* across a profile switch.</p>
|
||||||
|
*
|
||||||
|
* <p>Besides the plain one-to-one mirror there is a derived form: a supplier computes the
|
||||||
|
* visible content (filtered, ordered) and one or more rosters act as the change sources.
|
||||||
|
* That is what replaced the former {@code FilteredList}/{@code SortedList} chains, which
|
||||||
|
* recomputed themselves whenever their source list changed.</p>
|
||||||
|
*
|
||||||
|
* <p>Named a binding and not a mirror on purpose: {@code kst4contest.view.compose.RosterMirror}
|
||||||
|
* already exists and mirrors a {@code SimpleRoster} into Compose state. Two classes of the same
|
||||||
|
* name doing the same job in sibling packages is a reader's trap.</p>
|
||||||
|
*
|
||||||
|
* <p>This was an {@code ObservableList} while a {@code TableView} was bound to it. The
|
||||||
|
* Compose panes take a snapshot instead — {@code SelectedStationMessagesFeed.push} copies
|
||||||
|
* what it is given — so the only thing the observable collection still provided was the
|
||||||
|
* change notification, which is now {@link #onChanged(Consumer)}.</p>
|
||||||
|
*/
|
||||||
|
public final class RosterListBinding<T> {
|
||||||
|
|
||||||
|
private final List<T> mirror = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handed out by {@link #list()}. A field and not a fresh wrapper per call, so the
|
||||||
|
* instance a caller kept stays the instance it keeps seeing.
|
||||||
|
*/
|
||||||
|
private final List<T> readOnlyView = Collections.unmodifiableList(mirror);
|
||||||
|
|
||||||
|
private final List<Consumer<List<T>>> changeListeners = new ArrayList<>();
|
||||||
|
private final List<Runnable> unsubscribes = new ArrayList<>();
|
||||||
|
private final Supplier<List<T>> derivation;
|
||||||
|
private final UiDispatcher dispatcher;
|
||||||
|
private volatile boolean disposed;
|
||||||
|
|
||||||
|
private RosterListBinding(Supplier<List<T>> derivation, UiDispatcher dispatcher) {
|
||||||
|
this.derivation = derivation;
|
||||||
|
this.dispatcher = dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the roster one to one. */
|
||||||
|
public static <T> RosterListBinding<T> mirror(ObservableRoster<T> source, UiDispatcher dispatcher) {
|
||||||
|
RosterListBinding<T> binding = new RosterListBinding<>(source::snapshot, dispatcher);
|
||||||
|
binding.subscribe(source);
|
||||||
|
binding.applyNow();
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors whatever {@code derivation} computes and recomputes it whenever one of the
|
||||||
|
* given rosters changes.
|
||||||
|
*
|
||||||
|
* @param dispatcher hands the mirror update to the user-interface thread
|
||||||
|
* @param derivation computes the visible content; called on the UI thread
|
||||||
|
* @param sources rosters whose changes trigger a recomputation
|
||||||
|
*/
|
||||||
|
public static <T> RosterListBinding<T> derived(UiDispatcher dispatcher,
|
||||||
|
Supplier<List<T>> derivation,
|
||||||
|
ObservableRoster<?>... sources) {
|
||||||
|
RosterListBinding<T> binding = new RosterListBinding<>(derivation, dispatcher);
|
||||||
|
for (ObservableRoster<?> source : sources) {
|
||||||
|
binding.subscribe(source);
|
||||||
|
}
|
||||||
|
binding.applyNow();
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a listener for real content changes.
|
||||||
|
*
|
||||||
|
* <p>Replaces the {@code ListChangeListener} a {@code TableView} used to attach. It is
|
||||||
|
* called on the user-interface thread, with an immutable snapshot, and only when the
|
||||||
|
* content actually changed — see {@link #applyNow()}.</p>
|
||||||
|
*/
|
||||||
|
public void onChanged(Consumer<List<T>> listener) {
|
||||||
|
changeListeners.add(Objects.requireNonNull(listener, "listener"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes the mirror. Needed when something other than a source roster changed the
|
||||||
|
* result, for example a newly set filter predicate.
|
||||||
|
*/
|
||||||
|
public void refresh() {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatcher.runOnUi(this::applyNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list to read. Always the same instance, and not modifiable by the caller. */
|
||||||
|
public List<T> list() {
|
||||||
|
return readOnlyView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregisters from every source and empties the mirror. Idempotent. */
|
||||||
|
public void dispose() {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disposed = true;
|
||||||
|
for (Runnable unsubscribe : unsubscribes) {
|
||||||
|
unsubscribe.run();
|
||||||
|
}
|
||||||
|
unsubscribes.clear();
|
||||||
|
changeListeners.clear();
|
||||||
|
mirror.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private <S> void subscribe(ObservableRoster<S> source) {
|
||||||
|
Consumer<List<S>> listener = snapshot -> refresh();
|
||||||
|
source.addListener(listener);
|
||||||
|
unsubscribes.add(() -> source.removeListener(listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyNow() {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<T> computed = derivation.get();
|
||||||
|
/*
|
||||||
|
* An unchanged result must not notify. The selected-station message pane replaces
|
||||||
|
* its rows on every notification, and the periodic station-list refresh would
|
||||||
|
* otherwise fight the operator for the selected row on every tick.
|
||||||
|
*
|
||||||
|
* This comparison also breaks a cycle: a consumer that reacts by asking for a
|
||||||
|
* refresh would land here again, and notifying unconditionally would ask it to
|
||||||
|
* react again. Once the content is equal the chain stops.
|
||||||
|
*
|
||||||
|
* It rests on element identity. ChatMember declares equals(ChatMember) — an
|
||||||
|
* overload, not an override of equals(Object) — so List.equals falls back to
|
||||||
|
* reference comparison. Turning that overload into a real override would make two
|
||||||
|
* different rows with the same callsign compare equal here, and a genuine change
|
||||||
|
* could be dropped. See ChatMember.
|
||||||
|
*/
|
||||||
|
if (mirror.equals(computed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mirror.clear();
|
||||||
|
mirror.addAll(computed);
|
||||||
|
|
||||||
|
List<T> snapshot = List.copyOf(mirror);
|
||||||
|
for (Consumer<List<T>> listener : changeListeners) {
|
||||||
|
listener.accept(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import java.awt.GraphicsEnvironment;
|
||||||
|
import java.awt.Rectangle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen area a window may actually use.
|
||||||
|
*
|
||||||
|
* <p>Replaces the former JavaFX screen lookup, which asked the primary screen for its
|
||||||
|
* visual bounds. {@code GraphicsEnvironment.getMaximumWindowBounds()} is the AWT
|
||||||
|
* equivalent: it excludes task bars and docks, which is exactly why the old code used the
|
||||||
|
* visual bounds rather than the raw bounds.</p>
|
||||||
|
*/
|
||||||
|
public final class ScreenBounds {
|
||||||
|
|
||||||
|
/** Used when there is no display at all, so a window still gets a usable size. */
|
||||||
|
public static final double FALLBACK_WIDTH = 1234.0;
|
||||||
|
|
||||||
|
/** Used when there is no display at all, so a window still gets a usable size. */
|
||||||
|
public static final double FALLBACK_HEIGHT = 768.0;
|
||||||
|
|
||||||
|
private ScreenBounds() {
|
||||||
|
// Utility class.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The usable width of the primary screen.
|
||||||
|
*
|
||||||
|
* @return the width in pixels, or {@link #FALLBACK_WIDTH} without a display
|
||||||
|
*/
|
||||||
|
public static double availableWidth() {
|
||||||
|
return widthOf(maximumWindowBounds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The usable height of the primary screen.
|
||||||
|
*
|
||||||
|
* @return the height in pixels, or {@link #FALLBACK_HEIGHT} without a display
|
||||||
|
*/
|
||||||
|
public static double availableHeight() {
|
||||||
|
return heightOf(maximumWindowBounds());
|
||||||
|
}
|
||||||
|
|
||||||
|
static double widthOf(final Rectangle bounds) {
|
||||||
|
return bounds == null || bounds.width <= 0 ? FALLBACK_WIDTH : bounds.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double heightOf(final Rectangle bounds) {
|
||||||
|
return bounds == null || bounds.height <= 0 ? FALLBACK_HEIGHT : bounds.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rectangle maximumWindowBounds() {
|
||||||
|
|
||||||
|
if (GraphicsEnvironment.isHeadless()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column-width rule the station and monitor tables are sized by.
|
||||||
|
*
|
||||||
|
* <p>What is left of the class that applied persisted leaf-column widths to a
|
||||||
|
* {@code TableView} and performed one content-based initial sizing pass. The tables are
|
||||||
|
* Compose now: {@code DataTableState} applies the stored widths and
|
||||||
|
* {@code ColumnWidthStore} persists them, so the installation, the resize detection and
|
||||||
|
* the measuring all went with the JavaFX table.</p>
|
||||||
|
*
|
||||||
|
* <p>The sizing rule itself did not. It survives because it is the one piece that decides
|
||||||
|
* what the operator sees on a first run, and because several Compose column definitions
|
||||||
|
* still name this class as the place their column ids were registered — a reader who
|
||||||
|
* follows that reference should find the rule, not a gap.</p>
|
||||||
|
*/
|
||||||
|
public final class TableLayoutManager {
|
||||||
|
|
||||||
|
/** Room for the cell's left and right padding, on top of the measured text. */
|
||||||
|
private static final double CELL_HORIZONTAL_PADDING = 16.0;
|
||||||
|
|
||||||
|
private TableLayoutManager() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double clamp(double value, double minimum, double maximum) {
|
||||||
|
return Math.max(minimum, Math.min(value, maximum));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a compact content width. Package-private for focused sizing tests.
|
||||||
|
*
|
||||||
|
* @param measuredWidth width the content needs without padding
|
||||||
|
* @param minimum narrowest acceptable column width
|
||||||
|
* @param maximum widest width an initial sizing pass may choose
|
||||||
|
* @return the width to use, padding included and clamped into range
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("PMD.CommentDefaultAccessModifier")
|
||||||
|
static double calculateInitialContentWidth(
|
||||||
|
final double measuredWidth,
|
||||||
|
final double minimum,
|
||||||
|
final double maximum
|
||||||
|
) {
|
||||||
|
return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure tooltip decisions kept separate from JavaFX controls for unit testing.
|
||||||
|
*/
|
||||||
|
final class TruncatedTextTooltipSupport {
|
||||||
|
|
||||||
|
private TruncatedTextTooltipSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isTextClipped(double requiredWidth, double availableWidth) {
|
||||||
|
return requiredWidth > availableWidth + 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildTooltipText(String fullText, boolean clipped, String functionalText) {
|
||||||
|
boolean hasFunctionalText = functionalText != null && !functionalText.isBlank();
|
||||||
|
if (!clipped && !hasFunctionalText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!clipped) {
|
||||||
|
return functionalText;
|
||||||
|
}
|
||||||
|
if (!hasFunctionalText) {
|
||||||
|
return fullText;
|
||||||
|
}
|
||||||
|
return fullText + "\n\n" + functionalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package kst4contest.view
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build identity shown in window titles and in the about text.
|
||||||
|
*
|
||||||
|
* Reads the version the Gradle build stamped into the jar manifest and falls
|
||||||
|
* back to a marker that is obviously not a release, so an unstamped build can
|
||||||
|
* never be mistaken for one.
|
||||||
|
*/
|
||||||
|
object BuildInfo {
|
||||||
|
|
||||||
|
const val UNKNOWN_VERSION: String = "dev-unstamped"
|
||||||
|
|
||||||
|
val version: String
|
||||||
|
get() = BuildInfo::class.java.`package`?.implementationVersion ?: UNKNOWN_VERSION
|
||||||
|
|
||||||
|
fun windowTitle(base: String): String = "$base $version"
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kst4contest.model.AirPlane
|
||||||
|
import kst4contest.model.AirPlaneReflectionInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The aircraft-reflection cell, shared by the station list and the directed messages.
|
||||||
|
*
|
||||||
|
* Both JavaFX tables built this text by hand and identically, which is why it lives in
|
||||||
|
* one place now: minutes until the aeroplane is in the path, and how promising that path
|
||||||
|
* is, for at most the first two.
|
||||||
|
*/
|
||||||
|
object AirplaneCell {
|
||||||
|
|
||||||
|
/** How many fit in the column before it stops being readable. */
|
||||||
|
private const val SHOWN_AIRPLANES = 2
|
||||||
|
|
||||||
|
fun format(info: AirPlaneReflectionInfo?): String {
|
||||||
|
// Nothing computed for this station yet. Both JavaFX cells ended up blank here, by
|
||||||
|
// different routes: the directed-message one catches the NullPointerException, the
|
||||||
|
// station-list one has no guard at all and lets it escape the value factory.
|
||||||
|
val planes = info?.risingAirplanes ?: return if (info == null) "" else NOTHING_COMING
|
||||||
|
|
||||||
|
if (planes.isEmpty()) return NOTHING_COMING
|
||||||
|
|
||||||
|
return planes.take(SHOWN_AIRPLANES).joinToString(" / ") { plane ->
|
||||||
|
"${plane.arrivingDurationMinutes} (${plane.potential}%)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not an empty cell: the operator has to tell "no aeroplane is coming" apart from
|
||||||
|
* "nothing has been computed for this station yet".
|
||||||
|
*/
|
||||||
|
private const val NOTHING_COMING = "nil"
|
||||||
|
|
||||||
|
// ---- the order ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Unknown or no aeroplane sorts after every real one. */
|
||||||
|
private const val NO_PLANE = Int.MAX_VALUE
|
||||||
|
|
||||||
|
/** The aeroplane the cell shows first; the parser already put the best one there. */
|
||||||
|
private fun first(info: AirPlaneReflectionInfo?): AirPlane? = info?.risingAirplanes?.firstOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ascending: the minutes the operator reads first, then the better potential first.
|
||||||
|
*
|
||||||
|
* Issue #87: without a comparator the table fell back to the rendered text, which put
|
||||||
|
* "13 (75%)" above "3 (50%)". Only the first aeroplane counts, as it is the one the
|
||||||
|
* operator reads and the one the colour marks; the second is in the cell for context.
|
||||||
|
* Empty and "nil" cells carry [NO_PLANE]: like qrb and qtf, an absent value gets a
|
||||||
|
* sentinel instead of a null check, here the maximum so the cell sorts last ascending.
|
||||||
|
*/
|
||||||
|
val order: Comparator<AirPlaneReflectionInfo?> =
|
||||||
|
compareBy<AirPlaneReflectionInfo?> { first(it)?.arrivingDurationMinutes ?: NO_PLANE }
|
||||||
|
.thenByDescending { first(it)?.potential ?: 0 }
|
||||||
|
|
||||||
|
/** `.table-cell-100PercentAP`, identical in both stylesheets: a certain path. */
|
||||||
|
val PURPLE_100 = Color(0xFFF98AFF)
|
||||||
|
|
||||||
|
/** `.table-cell-75PercentAP`: a good one. */
|
||||||
|
val RED_75 = Color(0xFFFA6666)
|
||||||
|
|
||||||
|
/** `.table-cell-50PercentAP`: a fair one. */
|
||||||
|
val ORANGE_50 = Color(0xFFFA9F66)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour of the cell, which is what an aircraft-scatter chance is spotted by — the
|
||||||
|
* numbers are read afterwards.
|
||||||
|
*
|
||||||
|
* Pinned as found: the JavaFX cell factories matched the RENDERED STRING, so only
|
||||||
|
* exactly 100, 75 and 50 are coloured and 85% gets nothing. Colouring by threshold
|
||||||
|
* instead would light up cells the operator has never seen lit, which changes what the
|
||||||
|
* column means at a glance.
|
||||||
|
*/
|
||||||
|
fun accentFor(renderedValue: String): CellAccent? = when {
|
||||||
|
renderedValue.contains("100%") -> CellAccent(foreground = PURPLE_100)
|
||||||
|
renderedValue.contains("75%") -> CellAccent(foreground = RED_75)
|
||||||
|
renderedValue.contains("50%") -> CellAccent(foreground = ORANGE_50)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.TooltipArea
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line the operator types into, and the shortcut buttons above it.
|
||||||
|
*
|
||||||
|
* This is the busiest strip of the window: everything that leaves this station passes
|
||||||
|
* through it. Two details matter more here than anywhere else — the field keeps the
|
||||||
|
* focus, and the caret stays at the end. A button that swallows the focus mid sentence
|
||||||
|
* costs a QSO.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ShortcutButtonRow(
|
||||||
|
shortcuts: List<String>,
|
||||||
|
state: ChatInputState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
) {
|
||||||
|
shortcuts.forEach { shortcut ->
|
||||||
|
when (shortcut) {
|
||||||
|
// The two frequency buttons take their text straight from the field beside
|
||||||
|
// them rather than through the variable resolver, which is why they are
|
||||||
|
// not ordinary shortcuts.
|
||||||
|
MYQRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
|
||||||
|
state.appendOwnQrg(mainCategory = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
SECOND_QRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
|
||||||
|
state.appendOwnQrg(mainCategory = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ShortcutButton(
|
||||||
|
label = shortcut,
|
||||||
|
accented = shortcut == SET_NAME_MYQRG_SHORTCUT,
|
||||||
|
) {
|
||||||
|
state.appendShortcut(shortcut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two names the shortcut roster gives special meaning to. */
|
||||||
|
const val MYQRG_SHORTCUT = "MYQRG"
|
||||||
|
const val SECOND_QRG_SHORTCUT = "SECONDQRG"
|
||||||
|
|
||||||
|
/** Accented like the two frequency buttons, but an ordinary appending shortcut otherwise. */
|
||||||
|
const val SET_NAME_MYQRG_SHORTCUT = "/SETNAME MYQRG"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shortcut button.
|
||||||
|
*
|
||||||
|
* Three shortcuts wear `.buttonMyQrg1` in both stylesheets — the two frequency buttons and
|
||||||
|
* "/SETNAME MYQRG" — the same green-to-lightgreen gradient the palette already reads as the
|
||||||
|
* accent, so they are told apart by colour rather than by position. Everything else is an
|
||||||
|
* ordinary button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit) {
|
||||||
|
if (!accented) {
|
||||||
|
Form.button(text = label, onClick = onClick)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val palette = LocalJavaFxPalette.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
|
||||||
|
.background(
|
||||||
|
brush = if (palette.windowBackground.luminance() < 0.5f) {
|
||||||
|
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||||
|
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||||
|
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
shape = Density.BUTTON_SHAPE
|
||||||
|
)
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(Density.BUTTON_CONTENT_PADDING),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = MYQRG_LABEL_COLOR,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `-fx-text-fill: #395306` from `.buttonMyQrg1`; a dark olive that reads on the green. */
|
||||||
|
private val MYQRG_LABEL_COLOR = Color(0xFF395306)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The send row: the message field, TX, clear, and the three small fields the operator
|
||||||
|
* keeps their own frequencies and antenna heading in.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChatInputRow(
|
||||||
|
state: ChatInputState,
|
||||||
|
prefs: ChatPreferences,
|
||||||
|
/**
|
||||||
|
* The three fields' live values. Mirrors and not preference reads: the rotator and the
|
||||||
|
* frequency follower move these while the operator is looking at them, and a plain read
|
||||||
|
* would draw the value once and then never again.
|
||||||
|
*/
|
||||||
|
ownQrgMain: ObservedValue<String>,
|
||||||
|
ownQrgSecond: ObservedValue<String>,
|
||||||
|
antennaQtf: ObservedValue<Double>,
|
||||||
|
antennaQtf2: ObservedValue<Double>,
|
||||||
|
so2rEnabled: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
// Every insertion bumps focusRequests; this is requestFocus() + selectEnd().
|
||||||
|
LaunchedEffect(state.focusRequests) {
|
||||||
|
if (state.focusRequests > 0) {
|
||||||
|
runCatching { focusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
MessageField(
|
||||||
|
state = state,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Form.button(text = "TX", onClick = { state.send() })
|
||||||
|
Form.button(text = kst4contest.view.i18n.LocalStrings.current.chatClear, onClick = { state.clear() })
|
||||||
|
|
||||||
|
VerticalRule()
|
||||||
|
|
||||||
|
// Commit on focus loss, as in JavaFX: typing a frequency should not write the
|
||||||
|
// profile on every keystroke.
|
||||||
|
InlineCommittedField(
|
||||||
|
stored = ownQrgMain.value.orEmpty(),
|
||||||
|
onCommit = { prefs.getMYQRGFirstCat().set(it) },
|
||||||
|
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatOwnQrgTooltip,
|
||||||
|
)
|
||||||
|
InlineCommittedField(
|
||||||
|
stored = ownQrgSecond.value.orEmpty(),
|
||||||
|
onCommit = { prefs.getMYQRGSecondCat().set(it) },
|
||||||
|
tooltip = if (so2rEnabled) {
|
||||||
|
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
|
||||||
|
} else {
|
||||||
|
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip
|
||||||
|
},
|
||||||
|
)
|
||||||
|
InlineCommittedField(
|
||||||
|
stored = antennaQtf.value?.toString().orEmpty(),
|
||||||
|
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
|
||||||
|
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
|
||||||
|
)
|
||||||
|
if (so2rEnabled) {
|
||||||
|
// Display only: rotator 2 writes it, the operator does not.
|
||||||
|
InlineCommittedField(
|
||||||
|
stored = antennaQtf2.value?.takeIf { it.isFinite() }?.toString().orEmpty(),
|
||||||
|
onCommit = { },
|
||||||
|
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
|
||||||
|
editable = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the three narrow fields beside the send controls.
|
||||||
|
*
|
||||||
|
* Written back when the focus leaves, not on every keystroke — the JavaFX fields hung
|
||||||
|
* their write on focusedProperty for the same reason: a half-typed frequency is not a
|
||||||
|
* frequency, and the profile is a file.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun InlineCommittedField(
|
||||||
|
stored: String,
|
||||||
|
onCommit: (String) -> Unit,
|
||||||
|
tooltip: String,
|
||||||
|
editable: Boolean = true,
|
||||||
|
) {
|
||||||
|
var draft by remember(stored) { mutableStateOf(stored) }
|
||||||
|
|
||||||
|
TooltipArea(tooltip = { TooltipChip(tooltip) }) {
|
||||||
|
CompactTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
enabled = editable,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(QRG_FIELD_WIDTH)
|
||||||
|
.onFocusChanged { focus ->
|
||||||
|
if (!focus.isFocused && draft != stored) onCommit(draft)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TooltipChip(text: String) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
shape = Density.BUTTON_SHAPE,
|
||||||
|
tonalElevation = 4.dp,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val QRG_FIELD_WIDTH = 70.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message field.
|
||||||
|
*
|
||||||
|
* Enter sends and is consumed, so the newline never reaches the text. Escape clears —
|
||||||
|
* both were on the scene in JavaFX, which means they worked wherever the focus was; here
|
||||||
|
* they are on the field, and the window handles them for the rest of the surface.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MessageField(
|
||||||
|
state: ChatInputState,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(min = Density.FIELD_MIN_HEIGHT)
|
||||||
|
.border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
|
||||||
|
.background(scheme.surface, Density.BUTTON_SHAPE)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = state.value,
|
||||||
|
onValueChange = { state.value = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.Enter, Key.NumPadEnter -> { state.send(); true }
|
||||||
|
Key.Escape -> { state.clear(); true }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = LocalTextStyle.current.copy(color = scheme.onSurface),
|
||||||
|
cursorBrush = SolidColor(scheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The vertical separator that sets the frequency fields apart from the send controls. */
|
||||||
|
@Composable
|
||||||
|
private fun VerticalRule() {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(Density.HAIRLINE)
|
||||||
|
.height(Density.FIELD_MIN_HEIGHT)
|
||||||
|
.background(MaterialTheme.colorScheme.outline),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the send line needs from the rest of the application.
|
||||||
|
*
|
||||||
|
* An interface rather than a handful of lambdas because there are twelve of them and
|
||||||
|
* they belong together; the implementation is the runtime, the test implements it with
|
||||||
|
* recording doubles.
|
||||||
|
*/
|
||||||
|
interface ChatInputActions {
|
||||||
|
|
||||||
|
/** The station shown in the info panel, set by clicking one. */
|
||||||
|
fun panelSelection(): ChatMember?
|
||||||
|
|
||||||
|
fun tableSelection(): ChatMember?
|
||||||
|
|
||||||
|
fun scoreSelection(): ChatMember?
|
||||||
|
|
||||||
|
/** Replaces %variables% in a template; may legitimately return blank. */
|
||||||
|
fun resolveVariables(template: String, member: ChatMember?): String?
|
||||||
|
|
||||||
|
/** Whether an outgoing private message targets the local station. */
|
||||||
|
fun isAddressedToOwnCallsign(text: String): Boolean
|
||||||
|
|
||||||
|
/** Which chat the message belongs in; the /cq target in the text outranks the selection. */
|
||||||
|
fun resolveCategory(text: String, member: ChatMember?): ChatCategory?
|
||||||
|
|
||||||
|
fun mainCategory(): ChatCategory?
|
||||||
|
|
||||||
|
fun recordOutboundCq(text: String)
|
||||||
|
|
||||||
|
fun requestRecompute(reason: String)
|
||||||
|
|
||||||
|
/** Hands the message to the TX queue; the existing mechanism sends it. */
|
||||||
|
fun queue(message: ChatMessage)
|
||||||
|
|
||||||
|
fun ownQrgMain(): String
|
||||||
|
|
||||||
|
fun ownQrgSecond(): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line the operator types into, and the buttons around it.
|
||||||
|
*
|
||||||
|
* Three things go into this field, and they behave differently on purpose. A shortcut
|
||||||
|
* button APPENDS, so several can be strung together into one message. A snippet
|
||||||
|
* REPLACES, because it builds a whole directed message and a half-typed remainder would
|
||||||
|
* only corrupt it. Typing is typing.
|
||||||
|
*/
|
||||||
|
class ChatInputState(private val actions: ChatInputActions) {
|
||||||
|
|
||||||
|
/** Text and caret together, because every insertion also moves the caret. */
|
||||||
|
var value: TextFieldValue by mutableStateOf(TextFieldValue(""))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped whenever the field should take the focus back. The Compose counterpart of
|
||||||
|
* requestFocus() + selectEnd(): the operator is mid sentence, and a field that lost
|
||||||
|
* the focus to a button turns the next keystroke into nothing.
|
||||||
|
*/
|
||||||
|
var focusRequests: Int by mutableStateOf(0)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves, checks and queues the message, then empties the field.
|
||||||
|
*
|
||||||
|
* The order is the original's, including one inconsistency that is kept because
|
||||||
|
* changing it would change what gets transmitted: the score service's selection is
|
||||||
|
* consulted only after the variables have been resolved, so a station only it knows
|
||||||
|
* about reaches the category decision but not the resolver.
|
||||||
|
*/
|
||||||
|
fun send() {
|
||||||
|
var member = actions.panelSelection() ?: actions.tableSelection()
|
||||||
|
|
||||||
|
val resolved = actions.resolveVariables(value.text, member)
|
||||||
|
|
||||||
|
// A variable may legitimately resolve to an empty string; then there is nothing
|
||||||
|
// to send, and the field is emptied anyway so the operator sees it was consumed.
|
||||||
|
if (resolved.isNullOrBlank()) {
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.isAddressedToOwnCallsign(resolved)) {
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member == null) member = actions.scoreSelection()
|
||||||
|
|
||||||
|
val category = actions.resolveCategory(resolved, member) ?: actions.mainCategory()
|
||||||
|
|
||||||
|
val message = ChatMessage()
|
||||||
|
message.chatCategory = category
|
||||||
|
message.messageText = resolved
|
||||||
|
message.isMessageDirectedToServer = false
|
||||||
|
|
||||||
|
// A /cq starts the reply clock the no-reply tracking runs on.
|
||||||
|
actions.recordOutboundCq(resolved)
|
||||||
|
actions.requestRecompute("outbound-tx")
|
||||||
|
|
||||||
|
actions.queue(message)
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
value = TextFieldValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a shortcut's resolved text. A template that resolves to nothing leaves the
|
||||||
|
* field — and the focus — alone.
|
||||||
|
*/
|
||||||
|
fun appendShortcut(template: String) {
|
||||||
|
val resolved = actions.resolveVariables("$template ", effectiveStation())
|
||||||
|
if (resolved.isNullOrBlank()) return
|
||||||
|
appendAndFocus(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a snippet chosen from a right-click menu, resolved, with no trailing space
|
||||||
|
* and no /cq prefix.
|
||||||
|
*
|
||||||
|
* The same roster reaches the field three ways and all three differ: Ctrl+1..Ctrl+0
|
||||||
|
* replaces the field with a whole directed message, a right-click appends just the
|
||||||
|
* snippet, and a shortcut button appends its own roster's entry followed by a space.
|
||||||
|
* Collapsing any two of them would change what goes out over the air.
|
||||||
|
*/
|
||||||
|
fun appendSnippet(snippet: String) {
|
||||||
|
val resolved = actions.resolveVariables(snippet, effectiveStation())
|
||||||
|
if (resolved.isNullOrBlank()) return
|
||||||
|
appendAndFocus(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the operator's own frequency for one of the two chats. These two buttons
|
||||||
|
* take the value straight from its field rather than through the resolver, which is
|
||||||
|
* why they are not ordinary shortcuts.
|
||||||
|
*/
|
||||||
|
fun appendOwnQrg(mainCategory: Boolean) {
|
||||||
|
val qrg = if (mainCategory) actions.ownQrgMain() else actions.ownQrgSecond()
|
||||||
|
appendAndFocus("$qrg ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the field with "/cq CALL <snippet>", resolved.
|
||||||
|
*
|
||||||
|
* Without a selected station there is nobody to direct it at, so nothing happens —
|
||||||
|
* silently, as before, because this runs from Ctrl+1..Ctrl+0 while the operator is
|
||||||
|
* typing and a dialog would be worse than nothing.
|
||||||
|
*/
|
||||||
|
fun insertSnippet(snippets: List<String>, index: Int) {
|
||||||
|
val station = effectiveStation() ?: return
|
||||||
|
val callSign = station.callSign ?: return
|
||||||
|
if (index < 0 || index >= snippets.size) return
|
||||||
|
|
||||||
|
val resolved = actions.resolveVariables("/cq $callSign ${snippets[index]}", station)
|
||||||
|
if (resolved.isNullOrBlank()) return
|
||||||
|
|
||||||
|
setAndFocus(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastAutoPreparedSendText: String = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the field with "/cq CALL ", if the field is empty or contains a previous auto-generated CQ.
|
||||||
|
*/
|
||||||
|
fun prepareCq(callSign: String, forceOverwrite: Boolean = false) {
|
||||||
|
if (callSign.isBlank()) return
|
||||||
|
val preparedText = "/cq ${callSign.trim()} "
|
||||||
|
|
||||||
|
val canOverwrite = value.text.isEmpty() || value.text == lastAutoPreparedSendText
|
||||||
|
if (!forceOverwrite && !canOverwrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAutoPreparedSendText = preparedText
|
||||||
|
setAndFocus(preparedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three-step lookup the shortcuts and snippets use. */
|
||||||
|
private fun effectiveStation(): ChatMember? =
|
||||||
|
actions.panelSelection() ?: actions.tableSelection() ?: actions.scoreSelection()
|
||||||
|
|
||||||
|
private fun appendAndFocus(text: String) = setAndFocus(value.text + text)
|
||||||
|
|
||||||
|
private fun setAndFocus(text: String) {
|
||||||
|
value = TextFieldValue(text = text, selection = TextRange(text.length))
|
||||||
|
focusRequests++
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.view.i18n.CurrentStrings
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.awt.ComposeDialog
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.awt.Dialog
|
||||||
|
import java.awt.Dimension
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import javax.swing.WindowConstants
|
||||||
|
import kst4contest.view.ExternalDocuments
|
||||||
|
|
||||||
|
/** One button of an alert, and whether pressing it means "go ahead". */
|
||||||
|
internal data class AlertButton(val text: String, val confirming: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which JavaFX button type the confirming button stood for.
|
||||||
|
*
|
||||||
|
* Not a detail: JavaFX ordered `YES` and `OK_DONE` differently, and the two dialogs this
|
||||||
|
* class replaces used one each. Flattening them put the button that gives up a running
|
||||||
|
* contest session where the cancel button used to be.
|
||||||
|
*/
|
||||||
|
enum class ConfirmKind(internal val code: Char) { YES('Y'), OK_DONE('O') }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The JavaFX ButtonBar orders, read off JavaFX 21.0.5 on a real machine rather than from
|
||||||
|
* memory. Each letter is a ButtonData type code; only three matter here: Y = YES,
|
||||||
|
* C = CANCEL_CLOSE, O = OK_DONE. Keeping the whole strings rather than a hand-made table
|
||||||
|
* means the rule below is the same rule JavaFX applied, and can be checked against it.
|
||||||
|
*/
|
||||||
|
internal const val BUTTON_ORDER_LINUX = "L_HE+UNYACBXIO_R"
|
||||||
|
internal const val BUTTON_ORDER_MAC_OS = "L_HE+U+FBIX_NCYOA_R"
|
||||||
|
internal const val BUTTON_ORDER_WINDOWS = "L_E+U+FBXI_YNOCAH_R"
|
||||||
|
|
||||||
|
private const val CANCEL_CODE = 'C'
|
||||||
|
|
||||||
|
/** The order string for an operating system name, as JavaFX chose one. */
|
||||||
|
internal fun buttonOrderFor(osName: String): String = when {
|
||||||
|
osName.startsWith("mac", ignoreCase = true) -> BUTTON_ORDER_MAC_OS
|
||||||
|
osName.startsWith("darwin", ignoreCase = true) -> BUTTON_ORDER_MAC_OS
|
||||||
|
osName.startsWith("windows", ignoreCase = true) -> BUTTON_ORDER_WINDOWS
|
||||||
|
else -> BUTTON_ORDER_LINUX
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button row of an alert, in the order JavaFX would have shown it.
|
||||||
|
*
|
||||||
|
* The operator reaches for a position, not a word. Both confirmations this class shows
|
||||||
|
* give something up — the running ON4KST session, or the whole client — so a row in the
|
||||||
|
* wrong order is a wrong action, not a cosmetic complaint.
|
||||||
|
*/
|
||||||
|
internal fun alertButtons(
|
||||||
|
confirmText: String,
|
||||||
|
cancelText: String?,
|
||||||
|
confirmKind: ConfirmKind,
|
||||||
|
buttonOrder: String,
|
||||||
|
): List<AlertButton> {
|
||||||
|
|
||||||
|
val confirm = AlertButton(confirmText, confirming = true)
|
||||||
|
|
||||||
|
if (cancelText == null) {
|
||||||
|
return listOf(confirm)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cancel = AlertButton(cancelText, confirming = false)
|
||||||
|
|
||||||
|
return if (buttonOrder.indexOf(confirmKind.code) < buttonOrder.indexOf(CANCEL_CODE)) {
|
||||||
|
listOf(confirm, cancel)
|
||||||
|
} else {
|
||||||
|
listOf(cancel, confirm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button Enter presses: always the confirming one, as the JavaFX Alert's default
|
||||||
|
* button was.
|
||||||
|
*/
|
||||||
|
internal fun buttonForEnter(buttons: List<AlertButton>): AlertButton? =
|
||||||
|
buttons.firstOrNull { it.confirming }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button ESC presses.
|
||||||
|
*
|
||||||
|
* The cancelling one when there is one. On an acknowledgement there is not, and dismissing
|
||||||
|
* an acknowledgement is acknowledging it — which is what ESC did to a JavaFX Alert holding
|
||||||
|
* a lone OK button.
|
||||||
|
*/
|
||||||
|
internal fun buttonForEscape(buttons: List<AlertButton>): AlertButton? =
|
||||||
|
buttons.firstOrNull { !it.confirming } ?: buttons.firstOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and shows a modal dialog on the AWT event thread, blocking the caller.
|
||||||
|
*
|
||||||
|
* Both halves matter and neither is optional.
|
||||||
|
*
|
||||||
|
* A `ComposeDialog` is a heavyweight Swing component wrapping a Skia layer, so it has to
|
||||||
|
* be created and realised on the event thread. Doing it on another thread is not merely
|
||||||
|
* against the Swing rule: skiko then tries to create its OpenGL context off that thread
|
||||||
|
* and fails outright — `Failed to create Skia OpenGL context! Can't wrap nullptr`,
|
||||||
|
* followed by `RenderException: Cannot init graphic context`. The dialog appears as an
|
||||||
|
* empty frame that never paints, and because nothing can be clicked, a modal wait on it
|
||||||
|
* never ends. That was reproducible three runs out of three from `main`.
|
||||||
|
*
|
||||||
|
* `invokeAndWait` is what keeps the blocking contract while moving the work: it returns
|
||||||
|
* only once [open] returns, and [open] returns only once the modal dialog closes. So the
|
||||||
|
* caller still blocks exactly as `Stage.showAndWait()` made it block, and it also gives
|
||||||
|
* the happens-before edge that makes the answer written on the event thread visible here.
|
||||||
|
*/
|
||||||
|
internal fun openOnEventThread(open: () -> Unit) {
|
||||||
|
if (EventQueue.isDispatchThread()) {
|
||||||
|
open()
|
||||||
|
} else {
|
||||||
|
EventQueue.invokeAndWait(open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What closing the window without pressing a button means.
|
||||||
|
*
|
||||||
|
* The JavaFX Alert returned an empty Optional and every caller read that as "do not
|
||||||
|
* proceed"; `closeWindowEvent` would otherwise quit the client when the operator
|
||||||
|
* dismisses the question rather than answering it.
|
||||||
|
*/
|
||||||
|
internal fun dismissalConfirms(): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The replacement for the JavaFX `Alert`.
|
||||||
|
*
|
||||||
|
* A modal [ComposeDialog] rather than a Compose `application { }` window on its own
|
||||||
|
* thread: a modal AWT dialog runs a nested event pump, so it blocks its caller *and*
|
||||||
|
* keeps drawing. Measured before this was written — from the event thread and from a
|
||||||
|
* worker thread alike, the call blocks for the dialog's lifetime and the content
|
||||||
|
* composes. The thread-plus-latch pattern [OperatorProfilePickerWindow] uses cannot do
|
||||||
|
* that: once the dispatcher hands menu actions to the event thread, blocking it with a
|
||||||
|
* latch would deadlock against the window trying to draw on that very thread.
|
||||||
|
*/
|
||||||
|
object ComposeAlert {
|
||||||
|
|
||||||
|
/** The font size the JavaFX dialogs had; the settings windows use the same value. */
|
||||||
|
private const val DIALOG_FONT_SIZE_SP = 12f
|
||||||
|
|
||||||
|
private val DIALOG_SIZE = Dimension(520, 260)
|
||||||
|
|
||||||
|
/** Shows the alert and returns at once. For notices nothing waits on. */
|
||||||
|
@JvmStatic
|
||||||
|
@JvmOverloads
|
||||||
|
fun show(title: String, header: String?, body: String, darkMode: Boolean = false) {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
open(
|
||||||
|
title = title,
|
||||||
|
header = header,
|
||||||
|
body = body,
|
||||||
|
confirmText = CurrentStrings.get().alertOk,
|
||||||
|
cancelText = null,
|
||||||
|
confirmKind = ConfirmKind.OK_DONE,
|
||||||
|
darkMode = darkMode,
|
||||||
|
modal = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the alert and blocks until the operator acknowledges it. */
|
||||||
|
@JvmStatic
|
||||||
|
@JvmOverloads
|
||||||
|
fun acknowledge(title: String, header: String?, body: String, darkMode: Boolean = false) {
|
||||||
|
open(
|
||||||
|
title = title,
|
||||||
|
header = header,
|
||||||
|
body = body,
|
||||||
|
confirmText = CurrentStrings.get().alertOk,
|
||||||
|
cancelText = null,
|
||||||
|
confirmKind = ConfirmKind.OK_DONE,
|
||||||
|
darkMode = darkMode,
|
||||||
|
modal = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks a yes/no question and blocks until it is answered.
|
||||||
|
*
|
||||||
|
* @return true only when the operator pressed the confirming button; dismissing the
|
||||||
|
* window counts as a no, the way an empty Optional did.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
@JvmOverloads
|
||||||
|
fun confirm(
|
||||||
|
title: String,
|
||||||
|
header: String?,
|
||||||
|
body: String,
|
||||||
|
confirmText: String,
|
||||||
|
cancelText: String,
|
||||||
|
darkMode: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Which JavaFX button type the confirming button stood for. It decides the button
|
||||||
|
* order per platform, so it is the caller's to state rather than a default to
|
||||||
|
* forget: the quit dialog used YES, the profile switch used OK_DONE, and on Linux
|
||||||
|
* those two order the row differently.
|
||||||
|
*/
|
||||||
|
confirmKind: ConfirmKind = ConfirmKind.OK_DONE,
|
||||||
|
): Boolean = open(
|
||||||
|
title = title,
|
||||||
|
header = header,
|
||||||
|
body = body,
|
||||||
|
confirmText = confirmText,
|
||||||
|
cancelText = cancelText,
|
||||||
|
confirmKind = confirmKind,
|
||||||
|
darkMode = darkMode,
|
||||||
|
modal = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a notice with one clickable line below the text, and returns at once.
|
||||||
|
*
|
||||||
|
* Only `onSimpleLogFileCreated` needs this; the JavaFX version put a Hyperlink into
|
||||||
|
* the dialog pane. Kept as its own entry point rather than a nullable parameter on
|
||||||
|
* [show], so the ordinary notice stays a three-argument call.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* The key-handling overload of ComposeDialog.setContent is marked experimental. Opted
|
||||||
|
* into the way the rest of this package already opts into Compose's experimental
|
||||||
|
* surface; if it changes, the compiler says so and the fallback is an AWT key listener.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@JvmStatic
|
||||||
|
@JvmOverloads
|
||||||
|
fun showWithLink(
|
||||||
|
title: String,
|
||||||
|
header: String?,
|
||||||
|
body: String,
|
||||||
|
linkText: String,
|
||||||
|
linkTarget: String,
|
||||||
|
darkMode: Boolean = false,
|
||||||
|
) {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
|
||||||
|
dialog.title = title
|
||||||
|
dialog.size = DIALOG_SIZE
|
||||||
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
|
/* Disposed and not hidden, for the reason spelled out in open(). */
|
||||||
|
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||||
|
dialog.isAlwaysOnTop = true
|
||||||
|
dialog.setContent(
|
||||||
|
onKeyEvent = { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown
|
||||||
|
&& (event.key == Key.Enter || event.key == Key.NumPadEnter
|
||||||
|
|| event.key == Key.Escape)) {
|
||||||
|
dialog.dispose()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
|
||||||
|
AlertContent(
|
||||||
|
header = header,
|
||||||
|
body = body,
|
||||||
|
buttons = alertButtons(CurrentStrings.get().alertOk, null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
|
||||||
|
onPressed = { dialog.dispose() },
|
||||||
|
link = linkText to linkTarget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
private fun open(
|
||||||
|
title: String,
|
||||||
|
header: String?,
|
||||||
|
body: String,
|
||||||
|
confirmText: String,
|
||||||
|
cancelText: String?,
|
||||||
|
confirmKind: ConfirmKind,
|
||||||
|
darkMode: Boolean,
|
||||||
|
modal: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
/*
|
||||||
|
* Atomic rather than a captured var. Every confirm() caller happens to press and
|
||||||
|
* read on the same thread today, so a plain var would work by an unwritten AWT
|
||||||
|
* detail; this makes it correct on purpose, and the cost is nothing.
|
||||||
|
*/
|
||||||
|
val answer = AtomicBoolean(dismissalConfirms())
|
||||||
|
|
||||||
|
if (!EventQueue.isDispatchThread() && modal) {
|
||||||
|
/*
|
||||||
|
* A modal alert asked for from a non-event thread — startup warnings and the
|
||||||
|
* failure notice in main do this. The dialog must still be built on the event
|
||||||
|
* thread, so the whole open is handed over and this call blocks until it
|
||||||
|
* returns, which is the behaviour the caller expects.
|
||||||
|
*/
|
||||||
|
openOnEventThread {
|
||||||
|
answer.set(open(title, header, body, confirmText, cancelText, confirmKind, darkMode, true))
|
||||||
|
}
|
||||||
|
return answer.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = ComposeDialog(
|
||||||
|
owner = null,
|
||||||
|
modalityType =
|
||||||
|
if (modal) Dialog.ModalityType.APPLICATION_MODAL else Dialog.ModalityType.MODELESS,
|
||||||
|
)
|
||||||
|
dialog.title = title
|
||||||
|
dialog.size = DIALOG_SIZE
|
||||||
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
|
/*
|
||||||
|
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
|
||||||
|
* window and its Skia layer alive behind the operator's back — one per dismissal,
|
||||||
|
* and a reconnect storm shows this dialog repeatedly.
|
||||||
|
*/
|
||||||
|
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||||
|
/*
|
||||||
|
* Above everything else on purpose. These dialogs are asked for from Compose
|
||||||
|
* windows, which are not their owner; without this the question opens behind the
|
||||||
|
* window that asked it and the operator sees nothing happen at all. The JavaFX
|
||||||
|
* profile-switch confirmation needed the same and said so.
|
||||||
|
*/
|
||||||
|
dialog.isAlwaysOnTop = true
|
||||||
|
|
||||||
|
val buttons = alertButtons(
|
||||||
|
confirmText,
|
||||||
|
cancelText,
|
||||||
|
confirmKind,
|
||||||
|
buttonOrderFor(System.getProperty("os.name") ?: ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
val press: (AlertButton) -> Unit = { pressed ->
|
||||||
|
answer.set(pressed.confirming)
|
||||||
|
dialog.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.setContent(
|
||||||
|
/*
|
||||||
|
* Enter and ESC, which the JavaFX Alert bound for free. Without them the quit
|
||||||
|
* question ignored both keys, and a dialog one cannot dismiss from the keyboard
|
||||||
|
* is an obstacle in a contest.
|
||||||
|
*/
|
||||||
|
onKeyEvent = { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
when (event.key) {
|
||||||
|
Key.Enter, Key.NumPadEnter -> {
|
||||||
|
buttonForEnter(buttons)?.let(press)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.Escape -> {
|
||||||
|
buttonForEscape(buttons)?.let(press)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
|
||||||
|
AlertContent(
|
||||||
|
header = header,
|
||||||
|
body = body,
|
||||||
|
buttons = buttons,
|
||||||
|
onPressed = press,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.isVisible = true
|
||||||
|
return answer.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AlertContent(
|
||||||
|
header: String?,
|
||||||
|
body: String,
|
||||||
|
buttons: List<AlertButton>,
|
||||||
|
onPressed: (AlertButton) -> Unit,
|
||||||
|
link: Pair<String, String>? = null,
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(15.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (header != null) {
|
||||||
|
Text(header, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
/* Scrolls rather than clips: onSimpleLogFileCreated shows five paragraphs. */
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
|
||||||
|
if (link != null) {
|
||||||
|
Text(
|
||||||
|
link.first,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.clickable { ExternalDocuments.open(link.second) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
|
buttons.forEach { button ->
|
||||||
|
Form.button(button.text) { onPressed(button) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.unit.isSpecified
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.res.loadImageBitmap
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
|
import androidx.compose.ui.window.WindowPlacement as ComposeWindowPlacement
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import androidx.compose.ui.awt.ComposeWindow
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.awt.Point
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Compose window that lives beside the JavaFX application.
|
||||||
|
*
|
||||||
|
* Every such window needs the same four things, and getting any of them wrong has
|
||||||
|
* already cost a defect:
|
||||||
|
*
|
||||||
|
* - **It must not end the process.** `application` defaults to calling `exitProcess`
|
||||||
|
* when its last window closes, which would quit the client when the operator closes
|
||||||
|
* a settings window.
|
||||||
|
* - **It must open at most once, and pressing the opener again must raise it.** Two
|
||||||
|
* windows writing the same preferences would work against each other, so a second
|
||||||
|
* press cannot build a second window — but it must not do nothing either. The JavaFX
|
||||||
|
* originals built a fresh Stage per press and were raised as a side effect of that;
|
||||||
|
* refusing silently leaves the operator pressing a button that appears dead while the
|
||||||
|
* window sits behind the main one.
|
||||||
|
* - **It must be closable from outside.** A profile switch tears every window down and
|
||||||
|
* rebuilds it. `closeOwnedStages` walks JavaFX Stages and cannot reach a Compose
|
||||||
|
* window; when the settings window survived a switch, its "already open" flag stayed
|
||||||
|
* set and the rebuilt profile silently got no window at all — and that window carries
|
||||||
|
* the Connect button.
|
||||||
|
* - **It must follow a design switch.** The operator can change the design from the
|
||||||
|
* main window's menu while this window is open.
|
||||||
|
*
|
||||||
|
* Every window but the main one also opens on the screen the operator is working on
|
||||||
|
* (issue #59): centred on the main window's screen, or where the operator moved it to
|
||||||
|
* earlier in the session — see [WindowPlacement] for the rule.
|
||||||
|
*
|
||||||
|
* @param threadName names the thread in a stack dump, which is where one looks when a
|
||||||
|
* window will not close. It is also the key of the window's stored position, so
|
||||||
|
* renaming it forgets where the operator put the window.
|
||||||
|
* @param isMainWindow true for the main window only. It keeps the platform's placement
|
||||||
|
* and publishes its frame, which is what every other window is placed against.
|
||||||
|
*/
|
||||||
|
class ComposeWindowHost(
|
||||||
|
private val threadName: String,
|
||||||
|
private val isMainWindow: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val open = AtomicBoolean(false)
|
||||||
|
private val darkModeState = mutableStateOf(false)
|
||||||
|
private val visible = mutableStateOf(true)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var windowThread: Thread? = null
|
||||||
|
|
||||||
|
/** The AWT frame behind the Compose window, held only so a second press can raise it. */
|
||||||
|
@Volatile
|
||||||
|
private var frame: ComposeWindow? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the operator moved this window to, for this session. Not persisted here: a
|
||||||
|
* stored position outlives the monitor layout it was made on, which is the very problem
|
||||||
|
* of issue #59. Keeping positions across restarts is the operator's opt-in, handled by
|
||||||
|
* [WindowPlacementOnScreen.memory] and used only on the very screen it was made on. Lives as long as the host, and the hosts are
|
||||||
|
* singletons, so it survives closing and reopening the window and a profile switch.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var movedTo: Point? = null
|
||||||
|
|
||||||
|
/** Whether the window is currently open, so a caller can tell without opening one. */
|
||||||
|
val isOpen: Boolean
|
||||||
|
get() = open.get()
|
||||||
|
|
||||||
|
/** Follows a design switch made in the main window. */
|
||||||
|
fun applyDarkMode(darkMode: Boolean) {
|
||||||
|
darkModeState.value = darkMode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the window and waits for it to be gone.
|
||||||
|
*
|
||||||
|
* The wait is not politeness: a profile switch opens the replacement window
|
||||||
|
* immediately afterwards, and it must not be refused by the old one still closing.
|
||||||
|
*
|
||||||
|
* How it waits depends on the calling thread, and the distinction is not cosmetic.
|
||||||
|
* This window's `application { }` advances only as continuations that skiko's
|
||||||
|
* SwingDispatcher posts to the AWT event thread — unconditionally, with no
|
||||||
|
* `isDispatchNeeded` shortcut. So on that thread a plain `join` can never be
|
||||||
|
* satisfied: nothing drains the queue the teardown is sitting in. Every Windows-menu
|
||||||
|
* toggle and the whole of `shutdownRuntime` call this from there, and before the fix
|
||||||
|
* the interface froze for the timeout and then returned with [isOpen] still true —
|
||||||
|
* which made a profile switch raise the dead runtime's window and leave the rebuilt
|
||||||
|
* profile with no settings window, the one carrying the Connect button.
|
||||||
|
*
|
||||||
|
* A secondary loop is the way out: it blocks this caller while continuing to dispatch
|
||||||
|
* events, which is the same mechanism a modal dialog uses.
|
||||||
|
*/
|
||||||
|
fun close() {
|
||||||
|
if (!open.get()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
val thread = windowThread ?: return
|
||||||
|
|
||||||
|
if (!EventQueue.isDispatchThread()) {
|
||||||
|
thread.join(CLOSE_TIMEOUT_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val loop = Toolkit.getDefaultToolkit().systemEventQueue.createSecondaryLoop()
|
||||||
|
Thread({
|
||||||
|
thread.join(CLOSE_TIMEOUT_MS)
|
||||||
|
/*
|
||||||
|
* Posted rather than called directly, and that is what closes the documented
|
||||||
|
* race in SecondaryLoop: an exit() that lands before enter() has begun returns
|
||||||
|
* false and leaves enter() blocked forever. Posting it means the running loop
|
||||||
|
* itself dispatches the exit, so it cannot arrive too early. The join's
|
||||||
|
* timeout also bounds the whole wait, so a window that refuses to close costs
|
||||||
|
* the timeout rather than the session.
|
||||||
|
*/
|
||||||
|
EventQueue.invokeLater { loop.exit() }
|
||||||
|
}, "$threadName-close").start()
|
||||||
|
|
||||||
|
loop.enter()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the open window to the front, de-iconified if the operator had minimised it.
|
||||||
|
*
|
||||||
|
* Hops to the AWT event queue because the caller is the JavaFX thread. Both decisions
|
||||||
|
* live beside this method as plain functions — [raiseSteps] for what to do and
|
||||||
|
* [deiconifiedState] for what to write — so the only thing left here that no headless
|
||||||
|
* test can observe is the single call to `toFront`.
|
||||||
|
*/
|
||||||
|
fun raise() {
|
||||||
|
val frame = this.frame ?: return
|
||||||
|
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
raiseSteps(frame.isDisplayable, frame.extendedState and Frame.ICONIFIED != 0)
|
||||||
|
.forEach { step ->
|
||||||
|
when (step) {
|
||||||
|
RaiseStep.DEICONIFY ->
|
||||||
|
frame.extendedState = deiconifiedState(frame.extendedState)
|
||||||
|
RaiseStep.TO_FRONT -> frame.toFront()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the window, or raises it when it is already open.
|
||||||
|
*
|
||||||
|
* @param content receives the action that closes the window; only the window itself
|
||||||
|
* can end its own Compose application
|
||||||
|
* @param onResized called with the new size whenever the operator resizes the
|
||||||
|
* window. The JavaFX scenes had width and height listeners writing the
|
||||||
|
* stored size, and the spec's acceptance criterion for this stage names
|
||||||
|
* window sizes explicitly.
|
||||||
|
*/
|
||||||
|
fun show(
|
||||||
|
/**
|
||||||
|
* Read during composition, so a window whose title says what the chat is doing keeps
|
||||||
|
* saying it. A captured string would freeze at whatever was true when the window
|
||||||
|
* opened -- which for the main window is "disconnected", for the whole session.
|
||||||
|
*/
|
||||||
|
title: () -> String,
|
||||||
|
darkMode: Boolean,
|
||||||
|
baseFontSizeSp: Float,
|
||||||
|
widthDp: Float,
|
||||||
|
heightDp: Float,
|
||||||
|
onResized: ((Float, Float) -> Unit)? = null,
|
||||||
|
/**
|
||||||
|
* Raised above the other windows. The update window needs it: it opens during
|
||||||
|
* startup, and behind the main window it goes unnoticed, which is the one thing
|
||||||
|
* it exists not to do.
|
||||||
|
*/
|
||||||
|
alwaysOnTop: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The palette of the active profile. Null draws the shipped palette, which is right
|
||||||
|
* for a window that opens before a profile exists.
|
||||||
|
*/
|
||||||
|
paletteStore: PaletteStore? = null,
|
||||||
|
/**
|
||||||
|
* The interface language of the active profile. Null draws the base language, which
|
||||||
|
* is right for a window that opens before a profile exists.
|
||||||
|
*/
|
||||||
|
languageStore: kst4contest.view.i18n.LanguageStore? = null,
|
||||||
|
/**
|
||||||
|
* What a close request from outside should do — the title bar's X, and a tiling
|
||||||
|
* window manager's close, which is the same WM_DELETE_WINDOW either way.
|
||||||
|
*
|
||||||
|
* Null means close this window and leave the process alone, which is right for the
|
||||||
|
* settings, monitor and update windows. The MAIN window must pass one: closing it
|
||||||
|
* has to run the application's quit flow. Without it Compose's default merely ends
|
||||||
|
* this window's `application { }`, and the client lives on with the chat connected
|
||||||
|
* and no window to see it in — a tiling window manager shows that plainly, because
|
||||||
|
* the window disappears while the launching Gradle task keeps running.
|
||||||
|
*/
|
||||||
|
onCloseRequest: (() -> Unit)? = null,
|
||||||
|
/**
|
||||||
|
* The window's content. A FrameWindowScope receiver, because that is the only scope
|
||||||
|
* a MenuBar can be declared in — without it no window but the main one could ever
|
||||||
|
* carry the shared menu bar.
|
||||||
|
*/
|
||||||
|
content: @Composable FrameWindowScope.(close: () -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
darkModeState.value = darkMode
|
||||||
|
|
||||||
|
if (!open.compareAndSet(false, true)) {
|
||||||
|
raise()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.value = true
|
||||||
|
|
||||||
|
val thread = Thread({
|
||||||
|
try {
|
||||||
|
application(exitProcessOnExit = false) {
|
||||||
|
if (!visible.value) {
|
||||||
|
/* Nothing rendered means no window, and the application ends. */
|
||||||
|
return@application
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Decided once per opening, on the event thread this composition runs
|
||||||
|
* on — which is where the screens and the main window may be read.
|
||||||
|
* Null keeps the platform's placement: always for the main window, and
|
||||||
|
* for any window that opens before the main window exists.
|
||||||
|
*/
|
||||||
|
val placed = remember {
|
||||||
|
val width = widthDp.roundToInt()
|
||||||
|
val height = heightDp.roundToInt()
|
||||||
|
when {
|
||||||
|
/*
|
||||||
|
* The main window only ever uses a stored position; without
|
||||||
|
* one the platform places it, as before.
|
||||||
|
*/
|
||||||
|
isMainWindow -> WindowPlacementOnScreen.restore(threadName, width, height)
|
||||||
|
/* This session's move is newer than anything stored. */
|
||||||
|
movedTo != null -> WindowPlacementOnScreen.place(movedTo, width, height)
|
||||||
|
else -> WindowPlacementOnScreen.restore(threadName, width, height)
|
||||||
|
?: WindowPlacementOnScreen.place(null, width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val windowState = rememberWindowState(
|
||||||
|
position = placed
|
||||||
|
?.let { WindowPosition(it.x.dp, it.y.dp) }
|
||||||
|
?: WindowPosition.PlatformDefault,
|
||||||
|
width = placed?.width?.dp ?: widthDp.dp,
|
||||||
|
height = placed?.height?.dp ?: heightDp.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Written back on every change. The JavaFX scenes did this through
|
||||||
|
* width and height listeners, and the size matters here beyond
|
||||||
|
* comfort: these tables are wider than the default window, so an
|
||||||
|
* operator who widens it once must not have to do it again.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* The last size and position this host cut the window down to. Neither
|
||||||
|
* is the operator's choice: the size must not be written back as the
|
||||||
|
* stored size, and the position must not count as a move.
|
||||||
|
*/
|
||||||
|
val fit = remember { FitRecord() }
|
||||||
|
|
||||||
|
if (onResized != null) {
|
||||||
|
LaunchedEffect(windowState) {
|
||||||
|
/*
|
||||||
|
* A secondary window skips the opening size: it may be the
|
||||||
|
* screen-clamped one, and writing that back would shrink the
|
||||||
|
* stored size for good because the window once opened on a
|
||||||
|
* smaller screen. The main window keeps writing it, as before,
|
||||||
|
* unless it was opened at a stored position, where the size may
|
||||||
|
* be clamped the same way. A size this host fitted the window to
|
||||||
|
* is skipped for the same reason.
|
||||||
|
*/
|
||||||
|
snapshotFlow { windowState.size }
|
||||||
|
.drop(if (isMainWindow && placed == null) 0 else 1)
|
||||||
|
.collect { size ->
|
||||||
|
if (size.width.isSpecified && size.height.isSpecified
|
||||||
|
&& !fit.takeSize(size.width.value.roundToInt(), size.height.value.roundToInt())
|
||||||
|
) {
|
||||||
|
onResized(size.width.value, size.height.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(windowState) {
|
||||||
|
/*
|
||||||
|
* One tracker per opening; the rule is in MoveTracker. An operator
|
||||||
|
* move is remembered for the session (secondary windows) and handed
|
||||||
|
* to the stored positions, which keep it only when the operator
|
||||||
|
* opted in. Only moves are stored, never the window manager's
|
||||||
|
* nudges, so a restored window cannot drift a little on every start.
|
||||||
|
*/
|
||||||
|
var tracker = MoveTracker(placed?.location)
|
||||||
|
snapshotFlow { windowState.position }.collect { position ->
|
||||||
|
if (position is WindowPosition.Absolute
|
||||||
|
&& windowState.placement == ComposeWindowPlacement.Floating
|
||||||
|
&& !windowState.isMinimized
|
||||||
|
) {
|
||||||
|
val now = Point(
|
||||||
|
position.x.value.roundToInt(),
|
||||||
|
position.y.value.roundToInt(),
|
||||||
|
)
|
||||||
|
val size = windowState.size
|
||||||
|
/*
|
||||||
|
* The host's own fit is a new anchor, not a move; the window
|
||||||
|
* manager may still nudge it within the tolerance.
|
||||||
|
*/
|
||||||
|
if (fit.takePosition(now)) {
|
||||||
|
tracker = MoveTracker(now)
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
tracker.observe(now)?.let { moved ->
|
||||||
|
if (!isMainWindow) {
|
||||||
|
movedTo = moved
|
||||||
|
}
|
||||||
|
WindowPlacementOnScreen.store(
|
||||||
|
threadName,
|
||||||
|
moved,
|
||||||
|
if (size.width.isSpecified) size.width.value.roundToInt() else widthDp.roundToInt(),
|
||||||
|
if (size.height.isSpecified) size.height.value.roundToInt() else heightDp.roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(windowState) {
|
||||||
|
/*
|
||||||
|
* A window moved onto a smaller screen -- a laptop panel, a 640x480
|
||||||
|
* field monitor -- is cut down to it, so no edge or button ends up out
|
||||||
|
* of reach. Only once it has rested: resizing in the middle of a drag
|
||||||
|
* would fight the window manager for the window.
|
||||||
|
*/
|
||||||
|
snapshotFlow { windowState.position to windowState.size }.collectLatest { (position, size) ->
|
||||||
|
delay(FIT_SETTLE_MILLIS)
|
||||||
|
if (position is WindowPosition.Absolute
|
||||||
|
&& size.width.isSpecified && size.height.isSpecified
|
||||||
|
&& windowState.placement == ComposeWindowPlacement.Floating
|
||||||
|
&& !windowState.isMinimized
|
||||||
|
) {
|
||||||
|
WindowPlacementOnScreen.fitted(
|
||||||
|
java.awt.Rectangle(
|
||||||
|
position.x.value.roundToInt(),
|
||||||
|
position.y.value.roundToInt(),
|
||||||
|
size.width.value.roundToInt(),
|
||||||
|
size.height.value.roundToInt(),
|
||||||
|
),
|
||||||
|
)?.let { fitted ->
|
||||||
|
fit.record(fitted)
|
||||||
|
windowState.size = DpSize(fitted.width.dp, fitted.height.dp)
|
||||||
|
windowState.position = WindowPosition(fitted.x.dp, fitted.y.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Window(
|
||||||
|
onCloseRequest = { onCloseRequest?.invoke() ?: exitApplication() },
|
||||||
|
state = windowState,
|
||||||
|
title = title(),
|
||||||
|
alwaysOnTop = alwaysOnTop,
|
||||||
|
icon = applicationIcon(),
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
* The frame is the only handle that can raise this window later,
|
||||||
|
* and it must go when the window does: a stale frame would make a
|
||||||
|
* second press raise a window that is no longer there.
|
||||||
|
*/
|
||||||
|
DisposableEffect(window) {
|
||||||
|
frame = window
|
||||||
|
if (isMainWindow) {
|
||||||
|
WindowPlacementOnScreen.mainWindow = window
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* The only place the handle is dropped. Clearing it again in
|
||||||
|
* the thread's finally block looks like a belt to this brace,
|
||||||
|
* but that block runs after open.set(false) has already let
|
||||||
|
* another thread open a new window and publish its frame — so
|
||||||
|
* the late write would wipe a live handle and bring back the
|
||||||
|
* dead button this whole method exists to remove.
|
||||||
|
*/
|
||||||
|
onDispose {
|
||||||
|
frame = null
|
||||||
|
if (WindowPlacementOnScreen.mainWindow === window) {
|
||||||
|
WindowPlacementOnScreen.mainWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalPaletteStore provides paletteStore,
|
||||||
|
/*
|
||||||
|
* Read inside the composition, not outside: that state read is
|
||||||
|
* what makes a language change recompose this window. Etappe 8
|
||||||
|
* shipped the outside version for the palette.
|
||||||
|
*/
|
||||||
|
kst4contest.view.i18n.LocalStrings provides
|
||||||
|
(languageStore?.strings
|
||||||
|
?: kst4contest.view.i18n.LocalStrings.current),
|
||||||
|
) {
|
||||||
|
Kst4ContestTheme(
|
||||||
|
darkMode = darkModeState.value,
|
||||||
|
baseFontSizeSp = baseFontSizeSp,
|
||||||
|
) {
|
||||||
|
content(::exitApplication)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
open.set(false)
|
||||||
|
windowThread = null
|
||||||
|
}
|
||||||
|
}, threadName)
|
||||||
|
|
||||||
|
windowThread = thread
|
||||||
|
thread.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CLOSE_TIMEOUT_MS = 2_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a window must have gone from where it was opened to count as moved by the
|
||||||
|
* operator.
|
||||||
|
*
|
||||||
|
* Not zero, because window managers nudge a new window on their own — by the decoration,
|
||||||
|
* or to keep it clear of a panel — and that nudge would otherwise be remembered as the
|
||||||
|
* operator's choice. The window would then stop following the main window to another
|
||||||
|
* screen, and on X11 reopening it would drift by the decoration each time.
|
||||||
|
*/
|
||||||
|
internal const val MOVE_TOLERANCE_PX = 48
|
||||||
|
|
||||||
|
/** How long a window must rest after a move before it is cut down to a smaller screen. */
|
||||||
|
private const val FIT_SETTLE_MILLIS = 500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the host last cut a window down to, so the size and position it set itself are not
|
||||||
|
* taken for the operator's. Each is consumed by the first report that follows: a matching
|
||||||
|
* report is the fit echoing back, any other report is the operator's and clears it, so a
|
||||||
|
* stale record cannot swallow a later resize or move. Only touched on the window's event
|
||||||
|
* thread.
|
||||||
|
*/
|
||||||
|
internal class FitRecord {
|
||||||
|
private var pendingPosition: Point? = null
|
||||||
|
private var pendingWidth = 0
|
||||||
|
private var pendingHeight = 0
|
||||||
|
private var sizePending = false
|
||||||
|
|
||||||
|
fun record(bounds: java.awt.Rectangle) {
|
||||||
|
pendingPosition = bounds.location
|
||||||
|
pendingWidth = bounds.width
|
||||||
|
pendingHeight = bounds.height
|
||||||
|
sizePending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [now] is the fitted position reported back. Compared with the move
|
||||||
|
* tolerance, because dp rounding under display scaling and a window manager's nudge
|
||||||
|
* both shift it slightly.
|
||||||
|
*/
|
||||||
|
fun takePosition(now: Point): Boolean {
|
||||||
|
val pending = pendingPosition ?: return false
|
||||||
|
pendingPosition = null
|
||||||
|
return !movedAway(pending, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this size is the fitted one reported back, allowing for dp rounding. */
|
||||||
|
fun takeSize(width: Int, height: Int): Boolean {
|
||||||
|
if (!sizePending) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sizePending = false
|
||||||
|
return abs(width - pendingWidth) <= SIZE_ROUNDING_PX && abs(height - pendingHeight) <= SIZE_ROUNDING_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SIZE_ROUNDING_PX = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether [now] is far enough from [opened] to be the operator's move rather than a nudge. */
|
||||||
|
internal fun movedAway(opened: Point, now: Point): Boolean =
|
||||||
|
abs(now.x - opened.x) > MOVE_TOLERANCE_PX || abs(now.y - opened.y) > MOVE_TOLERANCE_PX
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the operator's moves of one opened window from the window manager's, fed with every
|
||||||
|
* floating position the window reports.
|
||||||
|
*
|
||||||
|
* - **Placed by the host** ([placedAt] not null): the anchor is that placement. Reports
|
||||||
|
* within [MOVE_TOLERANCE_PX] of it are the window manager's nudge and are ignored. This
|
||||||
|
* also holds when the window was reopened at its remembered spot, so a decoration nudge
|
||||||
|
* cannot make it drift a little further on every opening.
|
||||||
|
* - **Once a report has left the tolerance**, the operator is moving the window, and from
|
||||||
|
* then on every report is taken — including a move back to near where it opened, which
|
||||||
|
* would otherwise leave the stale far-away spot remembered.
|
||||||
|
* - **Not placed by the host** (opened before the main window existed): the platform picks
|
||||||
|
* the spot, and it may report a provisional position before its real placement. So the
|
||||||
|
* anchor is not trusted until it has settled: every report replaces it until one lands
|
||||||
|
* within the tolerance of the previous one. A window manager's placement jump is
|
||||||
|
* therefore a new anchor, not a move. The price: a single far jump by the operator
|
||||||
|
* before any second report — a keyboard "move to other monitor" right after opening —
|
||||||
|
* is taken as the anchor too, and the window then simply opens on the main window's
|
||||||
|
* screen next time, which is the default anyway. A time-based settle was the
|
||||||
|
* alternative; it would need a clock in a rule that is otherwise pure, and a drag
|
||||||
|
* produces a stream of close reports that settles the anchor on its own.
|
||||||
|
*
|
||||||
|
* @param placedAt where the host placed the window, or null when the platform did
|
||||||
|
*/
|
||||||
|
internal class MoveTracker(placedAt: Point?) {
|
||||||
|
|
||||||
|
private var anchor: Point? = placedAt
|
||||||
|
private var settled = placedAt != null
|
||||||
|
private var moving = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param now the position the window reports
|
||||||
|
* @return the position to remember as the operator's choice, or null to remember nothing
|
||||||
|
*/
|
||||||
|
fun observe(now: Point): Point? {
|
||||||
|
if (moving) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = anchor
|
||||||
|
if (!settled || current == null) {
|
||||||
|
if (current != null && !movedAway(current, now)) {
|
||||||
|
settled = true
|
||||||
|
} else {
|
||||||
|
anchor = now
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movedAway(current, now)) {
|
||||||
|
moving = true
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application icon, so these windows are not the only ones in the taskbar showing
|
||||||
|
* the default Java cup. Null when the resource is missing rather than failing: a window
|
||||||
|
* without an icon still works.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun applicationIcon(): Painter? = remember {
|
||||||
|
runCatching {
|
||||||
|
ComposeWindowHost::class.java.getResourceAsStream(APPLICATION_ICON)?.use {
|
||||||
|
BitmapPainter(loadImageBitmap(it))
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val APPLICATION_ICON = "/icons/kst4contest.png"
|
||||||
|
|
||||||
|
/** One thing raising a window has to do. */
|
||||||
|
internal enum class RaiseStep { DEICONIFY, TO_FRONT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame state that restores a minimised window without disturbing anything else.
|
||||||
|
*
|
||||||
|
* `extendedState` is a bit set, so this clears one bit rather than assigning a value.
|
||||||
|
* Writing `Frame.NORMAL` would be an assignment of zero: a window the operator maximised
|
||||||
|
* and then minimised holds `MAXIMIZED_BOTH or ICONIFIED` — 7 — and raising it would hand
|
||||||
|
* it back restored-down, dropping a maximisation nobody asked to drop.
|
||||||
|
*/
|
||||||
|
internal fun deiconifiedState(current: Int): Int = current and Frame.ICONIFIED.inv()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What raising an already-open window has to do, decided without touching AWT.
|
||||||
|
*
|
||||||
|
* Split out so the rule can be tested at all: the three calls it describes need a real
|
||||||
|
* display and a real window, while the rule itself is the part that can be wrong.
|
||||||
|
*
|
||||||
|
* A minimised window has to be restored before it is raised — `toFront` on an iconified
|
||||||
|
* frame leaves it iconified on every desktop this client runs on, which would be the
|
||||||
|
* same dead button in a different disguise. A frame that is no longer displayable gets
|
||||||
|
* nothing: the window is on its way out and raising it would fight the close.
|
||||||
|
*
|
||||||
|
* There is no focus step. `toFront` is the documented way to ask for a top-level window
|
||||||
|
* to come forward, while `Component.requestFocus` on a window is a focus-cycle-root
|
||||||
|
* request that most window managers ignore — and under X11 focus-stealing prevention has
|
||||||
|
* the last word either way. It was in the first version of this list without evidence
|
||||||
|
* that it ever changed an outcome, which is not a reason to pin a third step.
|
||||||
|
*/
|
||||||
|
internal fun raiseSteps(displayable: Boolean, iconified: Boolean): List<RaiseStep> = when {
|
||||||
|
!displayable -> emptyList()
|
||||||
|
iconified -> listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT)
|
||||||
|
else -> listOf(RaiseStep.TO_FRONT)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import kst4contest.view.i18n.LocalStrings
|
||||||
|
import kst4contest.view.i18n.Strings
|
||||||
|
import kst4contest.controller.ChatController
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connection state the button bar shows. Derived from the controller rather than
|
||||||
|
* held, because the session also connects and drops outside this window.
|
||||||
|
*/
|
||||||
|
enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button bar at the bottom of the settings window.
|
||||||
|
*
|
||||||
|
* This bar is the reason the settings window exists at startup: in the JavaFX client
|
||||||
|
* it carried the Connect button, which is the one place where `ChatController.execute`
|
||||||
|
* is called and therefore the only way into the chat. Saving and closing sit next to
|
||||||
|
* it because that is where the operator has always found them.
|
||||||
|
*/
|
||||||
|
class ConnectionBarState(
|
||||||
|
private val prefs: ChatPreferences,
|
||||||
|
private val controller: ChatController,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val connectionState: ConnectionState
|
||||||
|
get() = when {
|
||||||
|
controller.isConnectedAndLoggedIn -> ConnectionState.LOGGED_IN
|
||||||
|
controller.isConnectedAndNOTLoggedIn -> ConnectionState.CONNECTED_NOT_LOGGED_IN
|
||||||
|
else -> ConnectionState.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label of the Connect button, which names the chat the station will land in —
|
||||||
|
* and both chats when the second one is enabled, so the operator sees before
|
||||||
|
* pressing it what they are logging into.
|
||||||
|
*
|
||||||
|
* Takes the texts as a parameter because this is a plain property holder and not a
|
||||||
|
* composable: it cannot read the composition local itself, and the caller can.
|
||||||
|
*/
|
||||||
|
fun connectButtonText(strings: Strings): String {
|
||||||
|
val main = prefs.getLoginChatCategoryMain()
|
||||||
|
?: return strings.connectionConnect
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Formatted rather than concatenated: in German the chat name comes first ("Mit
|
||||||
|
* 144 MHz verbinden"), which no amount of "Connect to " + name can produce.
|
||||||
|
*/
|
||||||
|
var label = strings.connectionConnectTo(main.getChatCategoryName(main.categoryNumber))
|
||||||
|
val second = prefs.getLoginChatCategorySecond()
|
||||||
|
|
||||||
|
if (prefs.isLoginToSecondChatEnabled() && second != null) {
|
||||||
|
label += " & " + second.getChatCategoryName(second.categoryNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only a disconnected session can connect; the JavaFX button locked itself. */
|
||||||
|
val canConnect: Boolean
|
||||||
|
get() = connectionState != ConnectionState.LOGGED_IN
|
||||||
|
|
||||||
|
/** Disconnecting needs a session that is actually logged in. */
|
||||||
|
val canDisconnect: Boolean
|
||||||
|
get() = connectionState == ConnectionState.LOGGED_IN
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ConnectionBar(
|
||||||
|
state: ConnectionBarState,
|
||||||
|
onConnect: () -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onApplyAndClose: () -> Unit,
|
||||||
|
onDisconnectAndCloseChat: () -> Unit,
|
||||||
|
onDisconnectOnly: () -> Unit,
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
* Order as in the JavaFX HBox: connect, save, apply, disconnect and close, and
|
||||||
|
* disconnect only. Operators reach for these by position.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* A wrapping row, not a plain one. Five buttons do not fit a narrow window, and a
|
||||||
|
* plain Row does not wrap: it squeezes the last children instead. A button squeezed
|
||||||
|
* to a few pixels wraps its own label to one letter per line and grows into a tall
|
||||||
|
* coloured bar that eats the whole window height — which is exactly what happened.
|
||||||
|
*/
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
) {
|
||||||
|
Form.button(
|
||||||
|
state.connectButtonText(strings),
|
||||||
|
enabled = state.canConnect,
|
||||||
|
onClick = onConnect,
|
||||||
|
)
|
||||||
|
Form.button(strings.settingsSave, onClick = onSave)
|
||||||
|
Form.button(strings.settingsApplyAndClose, onClick = onApplyAndClose)
|
||||||
|
Form.button(strings.connectionDisconnectAndCloseChat, onClick = onDisconnectAndCloseChat)
|
||||||
|
Form.button(
|
||||||
|
strings.connectionDisconnect,
|
||||||
|
enabled = state.canDisconnect,
|
||||||
|
onClick = onDisconnectOnly,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
/** WCAG AA for body text. Below this a pair is reported, never refused. */
|
||||||
|
const val CONTRAST_FLOOR = 4.5
|
||||||
|
|
||||||
|
/** The pairs that actually sit on top of each other, as the tab names them. */
|
||||||
|
enum class ContrastPair { TEXT_ON_WINDOW_SURFACE, TEXT_INSIDE_FIELD, ACCENT_ON_WINDOW_SURFACE, TEXT_ON_MENU_STRIP }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pair that reads badly.
|
||||||
|
*
|
||||||
|
* Carries the pair itself and not its name: this is a pure function, and a translated string
|
||||||
|
* here would make every test that filters on it depend on whichever language ran last in the
|
||||||
|
* shared JVM. The tab turns it into words.
|
||||||
|
*
|
||||||
|
* @param ratio what the pair measures now
|
||||||
|
* @param shippedRatio what the same pair measures in the shipped sheet, so the tab can say
|
||||||
|
* how far the change moved it
|
||||||
|
*/
|
||||||
|
data class ContrastWarning(val what: ContrastPair, val ratio: Double, val shippedRatio: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WCAG contrast ratio of two colours, between 1.0 and 21.0.
|
||||||
|
*
|
||||||
|
* Relative luminance and not brightness: the two are easy to confuse, and the JavaFX colour
|
||||||
|
* derivation in this same package is a standing reminder that guessing a colour formula
|
||||||
|
* produces values that look plausible and are wrong.
|
||||||
|
*/
|
||||||
|
fun contrastRatio(a: Color, b: Color): Double {
|
||||||
|
val lighter = maxOf(relativeLuminance(a), relativeLuminance(b))
|
||||||
|
val darker = minOf(relativeLuminance(a), relativeLuminance(b))
|
||||||
|
return (lighter + 0.05) / (darker + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relativeLuminance(colour: Color): Double {
|
||||||
|
fun channel(value: Float): Double {
|
||||||
|
val v = value.toDouble()
|
||||||
|
return if (v <= 0.03928) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.2126 * channel(colour.red) +
|
||||||
|
0.7152 * channel(colour.green) +
|
||||||
|
0.0722 * channel(colour.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pairs that actually sit on top of each other and read worse than they shipped.
|
||||||
|
*
|
||||||
|
* Two conditions, both required: under [CONTRAST_FLOOR], and worse than the same pair in the
|
||||||
|
* shipped sheet. The second is there because the shipped sheets do not meet the floor
|
||||||
|
* themselves — the evening accent measures 2.26 on its own surface — so an absolute rule
|
||||||
|
* would warn about a client nobody has touched, which is how an operator learns to ignore
|
||||||
|
* warnings. With this rule the shipped state never warns and an improvement never warns.
|
||||||
|
*
|
||||||
|
* Four pairs, and each one is read off `Theme.kt`'s Material mapping rather than off the role
|
||||||
|
* names. The names mislead: `background` and `surface` are both `windowBackground`, with
|
||||||
|
* `onBackground` and `onSurface` both `labelTextFill`, so almost every label in every window
|
||||||
|
* sits on the window surface and not on `base` — `base` reaches the screen only through the
|
||||||
|
* menu strip and the selection tint. And `primary` is `textAccent`, not `accent`, drawn as
|
||||||
|
* text and as tab underlines. Measuring `labelTextFill` against `base` alone let an operator
|
||||||
|
* black out every window and hear nothing.
|
||||||
|
*
|
||||||
|
* Four and not fifteen, deliberately: most pairs of six roles never meet on screen, and a
|
||||||
|
* warning about a pair that cannot occur costs the same attention as a real one.
|
||||||
|
*/
|
||||||
|
fun contrastWarnings(resolved: JavaFxPalette, shipped: JavaFxPalette): List<ContrastWarning> {
|
||||||
|
|
||||||
|
fun pairsOf(palette: JavaFxPalette) = listOf(
|
||||||
|
/* onBackground/onSurface on background/surface: nearly every label in every window. */
|
||||||
|
ContrastPair.TEXT_ON_WINDOW_SURFACE to (palette.labelTextFill to palette.windowBackground),
|
||||||
|
/* onSurfaceVariant on surfaceVariant: inside a field, a list, and on every button. */
|
||||||
|
ContrastPair.TEXT_INSIDE_FIELD to (palette.labelTextFill to palette.controlInnerBackground),
|
||||||
|
/* primary as text and as a tab underline, on the window surface. */
|
||||||
|
ContrastPair.ACCENT_ON_WINDOW_SURFACE to (palette.textAccent to palette.windowBackground),
|
||||||
|
/* The menu strip paints base behind its titles; narrow, but it is real. */
|
||||||
|
ContrastPair.TEXT_ON_MENU_STRIP to (palette.labelTextFill to palette.base),
|
||||||
|
)
|
||||||
|
|
||||||
|
val shippedRatios = pairsOf(shipped).associate { (what, colours) ->
|
||||||
|
what to contrastRatio(colours.first, colours.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairsOf(resolved).mapNotNull { (what, colours) ->
|
||||||
|
val ratio = contrastRatio(colours.first, colours.second)
|
||||||
|
val shippedRatio = shippedRatios.getValue(what)
|
||||||
|
|
||||||
|
if (ratio < CONTRAST_FLOOR && ratio < shippedRatio) {
|
||||||
|
ContrastWarning(what, ratio, shippedRatio)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.observe.SimpleRoster
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of an [EditableListState].
|
||||||
|
*
|
||||||
|
* The key is what a LazyColumn is keyed by, and it must survive an insertion
|
||||||
|
* above the row: keying by list index makes every row below an insertion change
|
||||||
|
* identity, which moves the selection. Keying by text would collapse duplicates,
|
||||||
|
* and the shortcut and snippet lists can hold the same text twice.
|
||||||
|
*/
|
||||||
|
data class ListEntry(val key: Long, val text: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What committing an edit did to the list.
|
||||||
|
*
|
||||||
|
* A commit is the counterpart of the JavaFX `setOnEditCommit` handler, which ran
|
||||||
|
* when the operator left the cell — not on every keystroke. That distinction is
|
||||||
|
* what makes a rule such as "fold a callsign to its base call" usable: applying
|
||||||
|
* it per keystroke would fight the typist.
|
||||||
|
*/
|
||||||
|
sealed interface CommitOutcome {
|
||||||
|
|
||||||
|
/** The entry now holds this text, which a rule may have rewritten. */
|
||||||
|
data class Stored(val text: String) : CommitOutcome
|
||||||
|
|
||||||
|
/** The entry is gone: clearing the text was how the operator removed one. */
|
||||||
|
data object Removed : CommitOutcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The edit was refused and the stored text stands. The message is the one the
|
||||||
|
* JavaFX table showed in an alert before calling `refresh()` on itself.
|
||||||
|
*/
|
||||||
|
data class Refused(val message: String) : CommitOutcome
|
||||||
|
|
||||||
|
/** The row no longer exists: a pending edit can outlive its row. */
|
||||||
|
data object Ignored : CommitOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides what a committed edit becomes. The default accepts the text as typed
|
||||||
|
* and treats a blank one as a removal; the monitored-callsign list replaces it
|
||||||
|
* with the normalize-and-reject-duplicates rule the JavaFX table had.
|
||||||
|
*/
|
||||||
|
fun interface EntryRule {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param candidate text as the operator typed it
|
||||||
|
* @param index row being committed
|
||||||
|
* @param others texts of the other rows in order, the edited row excluded, so a
|
||||||
|
* duplicate check does not compare a row against itself
|
||||||
|
*/
|
||||||
|
fun decide(candidate: String, index: Int, others: List<String>): CommitOutcome
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/** Accept as typed; a blank text removes the entry. */
|
||||||
|
val asTyped: EntryRule = EntryRule { candidate, _, _ ->
|
||||||
|
if (candidate.isBlank()) CommitOutcome.Removed else CommitOutcome.Stored(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editing state of a single-column string list, free of Compose so the rules are
|
||||||
|
* testable without a toolkit.
|
||||||
|
*
|
||||||
|
* Reproduces what the JavaFX tables in the settings window did: a new entry goes
|
||||||
|
* to the top and becomes the selection, and an entry edited to blank disappears —
|
||||||
|
* that was how the operator removed one
|
||||||
|
* (Kst4ContestApplication, ShortCol.setOnEditCommit).
|
||||||
|
*/
|
||||||
|
class EditableListState(
|
||||||
|
initial: List<String>,
|
||||||
|
private val rule: EntryRule = EntryRule.asTyped,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var nextKey: Long = 0
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compose state, so a row redraws when the selection moves off it or its text
|
||||||
|
* changes. Plain collections left the user interface guessing: select() is called
|
||||||
|
* on a focus change without any other notification, and the row that lost the
|
||||||
|
* selection kept its accent border.
|
||||||
|
*/
|
||||||
|
private val items: SnapshotStateList<ListEntry> =
|
||||||
|
mutableStateListOf<ListEntry>().apply {
|
||||||
|
initial.forEach { add(ListEntry(nextKey++, it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val entries: List<ListEntry>
|
||||||
|
get() = items.toList()
|
||||||
|
|
||||||
|
val texts: List<String>
|
||||||
|
get() = items.map { it.text }
|
||||||
|
|
||||||
|
var selectedIndex: Int? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun select(index: Int?) {
|
||||||
|
selectedIndex = index?.takeIf { it in items.indices }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addAtTop(entry: String) {
|
||||||
|
items.add(0, ListEntry(nextKey++, entry))
|
||||||
|
selectedIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends an entry. The monitoring list grew this way: its Add button asked for
|
||||||
|
* a callsign in a dialog and called `add` on the roster, which appends.
|
||||||
|
*/
|
||||||
|
fun addAtBottom(entry: String) {
|
||||||
|
items.add(ListEntry(nextKey++, entry))
|
||||||
|
selectedIndex = items.lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An index outside the list is ignored: a pending edit can outlive its row. */
|
||||||
|
fun updateAt(index: Int, text: String) {
|
||||||
|
if (index !in items.indices) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (text.isBlank()) {
|
||||||
|
removeAt(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items[index] = items[index].copy(text = text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits an edit through the list's [EntryRule]. This is what the user interface
|
||||||
|
* calls when the operator leaves a row or presses Enter, mirroring
|
||||||
|
* `setOnEditCommit`; [updateAt] stays the unguarded path.
|
||||||
|
*/
|
||||||
|
fun commitAt(index: Int, candidate: String): CommitOutcome {
|
||||||
|
if (index !in items.indices) {
|
||||||
|
return CommitOutcome.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
val others = items.filterIndexed { position, _ -> position != index }.map { it.text }
|
||||||
|
|
||||||
|
return when (val verdict = rule.decide(candidate, index, others)) {
|
||||||
|
is CommitOutcome.Stored -> {
|
||||||
|
items[index] = items[index].copy(text = verdict.text)
|
||||||
|
verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
CommitOutcome.Removed -> {
|
||||||
|
removeAt(index)
|
||||||
|
verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> verdict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAt(index: Int) {
|
||||||
|
if (index !in items.indices) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.removeAt(index)
|
||||||
|
selectedIndex = selectedIndex?.let { current ->
|
||||||
|
if (current >= items.size) items.indices.lastOrNull() else current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the selected entry by [offset] positions and keeps it selected, the way
|
||||||
|
* `moveSelectedTableEntry` did. Returns false when there is no selection or the
|
||||||
|
* target position is outside the list, so the caller can skip its follow-up work.
|
||||||
|
*/
|
||||||
|
fun moveSelected(offset: Int): Boolean {
|
||||||
|
val current = selectedIndex ?: return false
|
||||||
|
val target = current + offset
|
||||||
|
|
||||||
|
if (current !in items.indices || target !in items.indices) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
items.add(target, items.removeAt(current))
|
||||||
|
selectedIndex = target
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the given roster. Deliberately setAll and not a field swap: replacing
|
||||||
|
* the roster would orphan every listener registered on it, which is the rule
|
||||||
|
* the ChatPreferences setters have followed since Etappe 2.
|
||||||
|
*/
|
||||||
|
fun commitTo(roster: SimpleRoster<String>) {
|
||||||
|
roster.setAll(texts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An editable single-column list, the Compose counterpart of the three
|
||||||
|
* TableView<String> tables in the settings window.
|
||||||
|
*
|
||||||
|
* Column widths, sorting and row styling are deliberately absent: no consumer in
|
||||||
|
* the settings window needs them. They belong with the main-window tables, where
|
||||||
|
* TableLayoutManager persists widths against stable column ids.
|
||||||
|
*
|
||||||
|
* Editing is committed when the operator leaves a row or presses Enter, not on
|
||||||
|
* every keystroke. That is what the JavaFX `TextFieldTableCell` did: the typed
|
||||||
|
* text lived in the cell editor until it was committed, which is why a rule could
|
||||||
|
* reject it and put the stored value back.
|
||||||
|
*
|
||||||
|
* @param onChanged called after every committed change — add, edit, removal, move.
|
||||||
|
* The settings tabs stage the list here; applying the draft fills the roster.
|
||||||
|
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
|
||||||
|
* tables opened an alert here.
|
||||||
|
* @param onAdd replaces the default "insert the template at the top" behaviour.
|
||||||
|
* The monitoring list needs it: its Add button asked for a callsign first.
|
||||||
|
* @param listHeight the list is a bounded, scrolling area like the TableView it
|
||||||
|
* replaces. It also keeps a LazyColumn out of an unbounded parent scroll,
|
||||||
|
* which Compose rejects.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DataTable(
|
||||||
|
state: EditableListState,
|
||||||
|
addButtonText: String,
|
||||||
|
newEntryTemplate: String,
|
||||||
|
onChanged: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRefused: (String) -> Unit = {},
|
||||||
|
onAdd: (() -> Unit)? = null,
|
||||||
|
showMoveButtons: Boolean = false,
|
||||||
|
listHeight: Dp = 220.dp,
|
||||||
|
) {
|
||||||
|
var revision by remember { mutableStateOf(0) }
|
||||||
|
val focusOnNewRow = remember { FocusRequester() }
|
||||||
|
|
||||||
|
fun changed() {
|
||||||
|
revision++
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
@Suppress("UNUSED_EXPRESSION") revision // read so edits recompose the list
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(listHeight)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.entries, key = { _, entry -> entry.key }) { index, entry ->
|
||||||
|
/*
|
||||||
|
* Keyed on the revision too, so a refused edit is visibly put back:
|
||||||
|
* the stored text did not change, and without the revision the draft
|
||||||
|
* would keep the text the rule just rejected.
|
||||||
|
*/
|
||||||
|
var draft by remember(entry.key, entry.text, revision) { mutableStateOf(entry.text) }
|
||||||
|
var hadFocus by remember(entry.key) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun commit() {
|
||||||
|
when (val outcome = state.commitAt(index, draft)) {
|
||||||
|
is CommitOutcome.Refused -> {
|
||||||
|
changed()
|
||||||
|
onRefused(outcome.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
CommitOutcome.Ignored -> Unit
|
||||||
|
|
||||||
|
else -> changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inside the settings window a row still being edited is committed before the
|
||||||
|
* draft is applied: a click on "Apply/Close prefs" leaves the focus in the row,
|
||||||
|
* see SettingsDraft.registerTypedEntry. Only an edited row; committing an
|
||||||
|
* untouched one would report a change that was never made. A refusal is reported
|
||||||
|
* by commit() through onRefused, so the registration answers null.
|
||||||
|
*/
|
||||||
|
val settingsDraft = LocalSettingsDraft.current
|
||||||
|
val pendingEdit = rememberUpdatedState { if (draft != entry.text) commit() }
|
||||||
|
DisposableEffect(settingsDraft, entry.key) {
|
||||||
|
val registration = settingsDraft?.registerTypedEntry {
|
||||||
|
pendingEdit.value()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onDispose { registration?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val isSelected = state.selectedIndex == index
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The selected row is marked on the field's own outline rather than by a
|
||||||
|
* second frame around it: every row here already is a bordered text
|
||||||
|
* field, and a frame inside a frame reads as noise instead of as a
|
||||||
|
* selection. The tinted background carries it the rest of the way.
|
||||||
|
*/
|
||||||
|
CompactTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
borderColor = if (isSelected) MaterialTheme.colorScheme.primary else null,
|
||||||
|
borderThickness = if (isSelected) 2.dp else null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
.background(
|
||||||
|
if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
if (focusState.isFocused) {
|
||||||
|
state.select(index)
|
||||||
|
} else if (hadFocus) {
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
hadFocus = focusState.isFocused
|
||||||
|
}
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown &&
|
||||||
|
(event.key == Key.Enter || event.key == Key.NumPadEnter)
|
||||||
|
) {
|
||||||
|
commit()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { if (index == 0) it.focusRequester(focusOnNewRow) else it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Form.button(addButtonText) {
|
||||||
|
if (onAdd != null) {
|
||||||
|
onAdd()
|
||||||
|
revision++
|
||||||
|
return@button
|
||||||
|
}
|
||||||
|
|
||||||
|
state.addAtTop(newEntryTemplate)
|
||||||
|
changed()
|
||||||
|
/*
|
||||||
|
* Focus is requested explicitly. The JavaFX table opened the new row
|
||||||
|
* for editing; hoping Compose does the same would repeat the focus
|
||||||
|
* defects of Etappe 2, where two of three bugs were focus bugs.
|
||||||
|
*/
|
||||||
|
runCatching { focusOnNewRow.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMoveButtons) {
|
||||||
|
Form.button(kst4contest.view.i18n.CurrentStrings.get().listMoveDown) { if (state.moveSelected(1)) changed() }
|
||||||
|
Form.button(kst4contest.view.i18n.CurrentStrings.get().listMoveUp) { if (state.moveSelected(-1)) changed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
kst4contest.view.i18n.CurrentStrings.get().listClearToRemove,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import java.util.IdentityHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a cell is painted beyond its text. Both colours are optional; null keeps the
|
||||||
|
* table's own.
|
||||||
|
*/
|
||||||
|
data class CellAccent(val background: Color? = null, val foreground: Color? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One column of a data table.
|
||||||
|
*
|
||||||
|
* The shape is taken from what the station list of Etappe 5 actually does, not from
|
||||||
|
* what the two monitor tables of Etappe 4 need. Those use the simple cases only —
|
||||||
|
* cutting the interface to fit them would produce one the station list cannot use,
|
||||||
|
* which is how the single-column DataTable of Etappe 3b came about.
|
||||||
|
*
|
||||||
|
* @param id stable across releases; the stored column widths are keyed by it, so
|
||||||
|
* renaming one loses the operator's layout
|
||||||
|
* @param value what the cell shows
|
||||||
|
* @param tooltip built from the row **and** the displayed value, because the JavaFX
|
||||||
|
* cells do exactly that — the QRA column explains its grid status that way
|
||||||
|
* @param accent colouring per cell, again from row and value together
|
||||||
|
* @param comparator sorts this column; without one the displayed text decides
|
||||||
|
*/
|
||||||
|
class DataColumn<T>(
|
||||||
|
/**
|
||||||
|
* Never translated: this is what the stored column layout is keyed on, so a language
|
||||||
|
* change must leave it alone or every operator's remembered widths and order would be
|
||||||
|
* read under names that no longer match.
|
||||||
|
*/
|
||||||
|
val id: String,
|
||||||
|
/**
|
||||||
|
* Read when the header is drawn, not stored as text. The column lists are built once at
|
||||||
|
* startup, outside any composition; a stored title would stay in the language that was
|
||||||
|
* in force then while everything around it switched.
|
||||||
|
*/
|
||||||
|
val title: () -> String,
|
||||||
|
val value: (T) -> String,
|
||||||
|
val tooltip: ((T, String) -> String)? = null,
|
||||||
|
val accent: ((T, String) -> CellAccent?)? = null,
|
||||||
|
val comparator: Comparator<T>? = null,
|
||||||
|
val weight: Float = 1f,
|
||||||
|
/**
|
||||||
|
* Columns sharing a group are drawn under one heading, as the JavaFX table drew
|
||||||
|
* its worked columns. Without it ten columns headed "wkdany 50 70 144 432 23 13
|
||||||
|
* 9 6 3" say nothing about what they mean.
|
||||||
|
*/
|
||||||
|
val group: String? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** For a title that genuinely never changes -- a ham abbreviation, or a test. */
|
||||||
|
constructor(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
value: (T) -> String,
|
||||||
|
tooltip: ((T, String) -> String)? = null,
|
||||||
|
accent: ((T, String) -> CellAccent?)? = null,
|
||||||
|
comparator: Comparator<T>? = null,
|
||||||
|
weight: Float = 1f,
|
||||||
|
group: String? = null,
|
||||||
|
) : this(id, { title }, value, tooltip, accent, comparator, weight, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives every row a key that survives a refresh.
|
||||||
|
*
|
||||||
|
* There are two kinds of list here and they need different answers:
|
||||||
|
*
|
||||||
|
* - **Entity lists** — the station list, the worked-stations list. A row *is* a
|
||||||
|
* station, and the same station comes back updated. Its own key is the identity:
|
||||||
|
* [RowKeys.byValue] with the raw callsign.
|
||||||
|
* - **Event lists** — the DX cluster and the QSO monitor. A row is something that
|
||||||
|
* happened. Two identical messages a second apart are genuinely two messages, so a
|
||||||
|
* key built from their fields would merge them: [RowKeys.byReference].
|
||||||
|
*
|
||||||
|
* There is deliberately no default. In Etappe 2 the visible defects came from row
|
||||||
|
* identity — a key that was the position shifted on every insert at the top, and one
|
||||||
|
* that was the text collapsed duplicates. Making the caller decide keeps that choice
|
||||||
|
* where the knowledge is.
|
||||||
|
*/
|
||||||
|
interface RowKeySource<T> {
|
||||||
|
|
||||||
|
fun keyOf(row: T): Any
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a source that remembers keys forget the rows that have gone.
|
||||||
|
*
|
||||||
|
* Called whenever the rows are replaced. Both stores behind these tables are
|
||||||
|
* capped on purpose — ten thousand cluster messages, thirty thousand chat messages
|
||||||
|
* — so that memory cannot grow without bound. A key map holding a strong reference
|
||||||
|
* to every row it ever saw would undo that cap from the outside.
|
||||||
|
*/
|
||||||
|
fun retainOnly(rows: List<T>) {}
|
||||||
|
|
||||||
|
/** How many keys are being remembered. Zero for a source that remembers none. */
|
||||||
|
fun size(): Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
object RowKeys {
|
||||||
|
|
||||||
|
/** For rows that carry their own identity; nothing is remembered. */
|
||||||
|
fun <T> byValue(key: (T) -> String): RowKeySource<T> = object : RowKeySource<T> {
|
||||||
|
override fun keyOf(row: T): Any = key(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For append-only lists whose rows carry no identity.
|
||||||
|
*
|
||||||
|
* Keys are handed out on first sight and remembered by object reference, which
|
||||||
|
* holds because the core rosters hand back the same instances on every snapshot.
|
||||||
|
* A row that really is a new object gets a new key — which is the point.
|
||||||
|
*
|
||||||
|
* Synchronised throughout: the rows are replaced from the JavaFX thread while
|
||||||
|
* Compose draws on its own, and an IdentityHashMap torn between two threads hands
|
||||||
|
* out a duplicate key (the list then throws) or loses one (the row jumps). Both
|
||||||
|
* were seen in Etappe 2.
|
||||||
|
*/
|
||||||
|
fun <T> byReference(): RowKeySource<T> = object : RowKeySource<T> {
|
||||||
|
|
||||||
|
private val keys = IdentityHashMap<T, Long>()
|
||||||
|
private var next = 0L
|
||||||
|
|
||||||
|
override fun keyOf(row: T): Any = synchronized(keys) {
|
||||||
|
keys.getOrPut(row) { next++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun retainOnly(rows: List<T>) {
|
||||||
|
synchronized(keys) {
|
||||||
|
val surviving = IdentityHashMap<T, Boolean>()
|
||||||
|
rows.forEach { surviving[it] = true }
|
||||||
|
keys.keys.retainAll { surviving.containsKey(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun size(): Int = synchronized(keys) { keys.size }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where column widths are kept between sessions.
|
||||||
|
*
|
||||||
|
* An interface rather than a direct call to ChatPreferences, so the rules can be
|
||||||
|
* tested without one. The real implementation is two lines over
|
||||||
|
* `getTableColumnWidth`/`setTableColumnWidth` plus `LayoutAutosave.requestSave`:
|
||||||
|
* that storage is toolkit-free and stays as it is, while the JavaFX glue around it
|
||||||
|
* does not come across.
|
||||||
|
*/
|
||||||
|
interface ColumnWidthStore {
|
||||||
|
|
||||||
|
/** The stored width, or null when this column was never sized. */
|
||||||
|
fun width(tableId: String, columnId: String): Double?
|
||||||
|
|
||||||
|
fun setWidth(tableId: String, columnId: String, width: Double)
|
||||||
|
|
||||||
|
/** Asks for a debounced save; widths must not be written per pixel of a drag. */
|
||||||
|
fun requestSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of a multi-column table: its rows, its sorting and its selection.
|
||||||
|
*
|
||||||
|
* Free of Compose UI so the rules can be tested, but the three mutable properties are
|
||||||
|
* Compose state: a table bound to plain properties does not repaint, and with live
|
||||||
|
* data that shows up as a stale display rather than as an unresponsive one — harder
|
||||||
|
* to notice and worse in effect.
|
||||||
|
*/
|
||||||
|
class DataTableState<T>(
|
||||||
|
val columns: List<DataColumn<T>>,
|
||||||
|
private val rowKey: RowKeySource<T>,
|
||||||
|
/** Part of the stored layout: renaming it loses the operator's column sizing. */
|
||||||
|
private val tableId: String = "",
|
||||||
|
private val widths: ColumnWidthStore? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order the columns are shown in, by id.
|
||||||
|
*
|
||||||
|
* Within the session only. The JavaFX TableView let the operator drag a column
|
||||||
|
* elsewhere but never stored where they put it — TableLayoutManager persists widths
|
||||||
|
* and ChatPreferences has no column order at all — and 1.50 is a functionally equal
|
||||||
|
* port, not an improvement on it.
|
||||||
|
*/
|
||||||
|
private var order: List<String> by mutableStateOf(columns.map { it.id })
|
||||||
|
|
||||||
|
/** The columns in the order they are shown. */
|
||||||
|
val orderedColumns: List<DataColumn<T>>
|
||||||
|
get() = order.mapNotNull { id -> columns.firstOrNull { it.id == id } }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a column to another position.
|
||||||
|
*
|
||||||
|
* A target outside the table is clamped rather than refused: a drag that overshoots
|
||||||
|
* the last header means "put it at the end", which is what the operator did with
|
||||||
|
* the pointer.
|
||||||
|
*/
|
||||||
|
fun moveColumn(columnId: String, toIndex: Int) {
|
||||||
|
val from = order.indexOf(columnId)
|
||||||
|
|
||||||
|
if (from < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = toIndex.coerceIn(0, order.size - 1)
|
||||||
|
|
||||||
|
if (target == from) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order = order.toMutableList().apply { add(target, removeAt(from)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bumped on a resize so a table reading widths repaints. */
|
||||||
|
private var widthRevision: Int by mutableStateOf(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored width of a column, or null when it was never sized.
|
||||||
|
*
|
||||||
|
* Null rather than a made-up number: the caller falls back to the column's own
|
||||||
|
* weight, which is where the sensible default lives.
|
||||||
|
*/
|
||||||
|
fun widthOf(columnId: String): Double? {
|
||||||
|
@Suppress("UNUSED_EXPRESSION") widthRevision
|
||||||
|
|
||||||
|
return widths?.width(tableId, columnId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a new width for a column of this table.
|
||||||
|
*
|
||||||
|
* A width of zero or less is refused. A drag past the left edge would otherwise
|
||||||
|
* store a column the operator cannot grab again, and the sizing survives restarts.
|
||||||
|
*/
|
||||||
|
fun resizeColumn(columnId: String, width: Double) {
|
||||||
|
if (width <= 0.0 || columns.none { it.id == columnId }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widths?.let { store ->
|
||||||
|
store.setWidth(tableId, columnId, width)
|
||||||
|
store.requestSave()
|
||||||
|
widthRevision++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows: List<T> by mutableStateOf(emptyList())
|
||||||
|
private set
|
||||||
|
|
||||||
|
var sortedColumnId: String? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var sortAscending: Boolean by mutableStateOf(true)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var selectedKey: Any? by mutableStateOf(null)
|
||||||
|
|
||||||
|
private var sortedCache: List<T>? = null
|
||||||
|
|
||||||
|
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
|
||||||
|
|
||||||
|
/** Optional filter predicate. If set, [visibleRows] will only include matching rows. */
|
||||||
|
var rowFilter: ((T) -> Boolean)?
|
||||||
|
get() = _rowFilter
|
||||||
|
set(value) {
|
||||||
|
_rowFilter = value
|
||||||
|
invalidateOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incremented to force Compose to redraw visible rows when mutable backend objects change without list structural changes. */
|
||||||
|
var contentRevision by mutableStateOf(0)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the rows and filters them again. The filter reads mutable row fields (a
|
||||||
|
* station's QRB, worked and QRV flags, its last activity), so a cached [visibleRows]
|
||||||
|
* from before the change could keep a row the filter now excludes, or hide one it
|
||||||
|
* now lets through -- in the list and in the station map, which draws the same rows.
|
||||||
|
*/
|
||||||
|
fun forceRedraw() {
|
||||||
|
invalidateOrder()
|
||||||
|
contentRevision++
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows in the order they are shown.
|
||||||
|
*
|
||||||
|
* Computed once per change and then handed back. This is read on every
|
||||||
|
* recomposition, and sorting here would mean sorting up to thirty thousand rows
|
||||||
|
* per inbound message on the thread that draws — with a column that has no
|
||||||
|
* comparator calling its value function once per comparison on top.
|
||||||
|
*/
|
||||||
|
val visibleRows: List<T>
|
||||||
|
get() {
|
||||||
|
/*
|
||||||
|
* Read before the cache is consulted, and that order is the whole point.
|
||||||
|
* Compose repaints a composable when state it *read* changes; a cache that
|
||||||
|
* returned early stopped reading these, so the next recomposition left the
|
||||||
|
* table subscribed to nothing and later rows never reached the screen. It
|
||||||
|
* showed as a monitor window sitting empty beside a filling JavaFX tab,
|
||||||
|
* redrawing only when an unrelated click forced it.
|
||||||
|
*/
|
||||||
|
val currentRows = rows
|
||||||
|
val currentFilter = rowFilter
|
||||||
|
contentRevision // read for the subscription: forceRedraw drops the cache
|
||||||
|
val currentColumn = sortedColumnId
|
||||||
|
val ascending = sortAscending
|
||||||
|
|
||||||
|
sortedCache?.let { return it }
|
||||||
|
|
||||||
|
val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
|
||||||
|
|
||||||
|
val column = columns.firstOrNull { it.id == currentColumn }
|
||||||
|
val sorted = if (column == null) {
|
||||||
|
filteredRows
|
||||||
|
} else {
|
||||||
|
/*
|
||||||
|
* The displayed text is read once per row rather than once per
|
||||||
|
* comparison, and sortedBy is stable, so rows that compare equal keep
|
||||||
|
* the order they arrived in. Etappe 2 lost that: the JavaFX SortedList
|
||||||
|
* inserted by binary search and put ties wherever it landed, which made
|
||||||
|
* rows jump.
|
||||||
|
*/
|
||||||
|
val order = column.comparator
|
||||||
|
?: compareBy(filteredRows.associateWith { column.value(it) }::getValue)
|
||||||
|
|
||||||
|
if (ascending) {
|
||||||
|
filteredRows.sortedWith(order)
|
||||||
|
} else {
|
||||||
|
filteredRows.sortedWith(order.reversed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedCache = sorted
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateOrder() {
|
||||||
|
sortedCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected row, or null when nothing is selected or it has gone. */
|
||||||
|
val selectedRow: T?
|
||||||
|
get() = selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the rows.
|
||||||
|
*
|
||||||
|
* The selection is kept **by key**, not by position: a station that moved because
|
||||||
|
* something above it was inserted is still the station the operator picked. When
|
||||||
|
* the selected row is gone the selection is dropped rather than sliding onto its
|
||||||
|
* neighbour, which would silently point the operator at someone else.
|
||||||
|
*/
|
||||||
|
fun replaceRows(newRows: List<T>) {
|
||||||
|
rows = newRows
|
||||||
|
rowKey.retainOnly(newRows)
|
||||||
|
invalidateOrder()
|
||||||
|
|
||||||
|
val key = selectedKey
|
||||||
|
if (key != null && newRows.none { rowKey.keyOf(it) == key }) {
|
||||||
|
selectedKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(row: T?) {
|
||||||
|
selectedKey = row?.let(rowKey::keyOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key a row is drawn under. Its identity, never its position: a positional key
|
||||||
|
* reshuffles every row whenever something arrives at the top, which in a live list
|
||||||
|
* is constant.
|
||||||
|
*/
|
||||||
|
fun keyOf(row: T): Any = rowKey.keyOf(row)
|
||||||
|
|
||||||
|
fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts by a column, cycling ascending, descending, unsorted.
|
||||||
|
*
|
||||||
|
* The third click is not a flourish: JavaFX TableView gives that cycle for free, and
|
||||||
|
* without it an operator who sorted the message list once could never get back to
|
||||||
|
* arrival order — newest at the bottom — short of restarting.
|
||||||
|
*
|
||||||
|
* An unknown column is ignored rather than clearing the sorting: it can only come
|
||||||
|
* from a stored layout that names a column this release no longer has.
|
||||||
|
*/
|
||||||
|
fun toggleSort(columnId: String) {
|
||||||
|
if (columns.none { it.id == columnId }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
sortedColumnId != columnId -> {
|
||||||
|
sortedColumnId = columnId
|
||||||
|
sortAscending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
sortAscending -> sortAscending = false
|
||||||
|
|
||||||
|
// Third click: back to the order the rows arrived in.
|
||||||
|
else -> {
|
||||||
|
sortedColumnId = null
|
||||||
|
sortAscending = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.HorizontalScrollbar
|
||||||
|
import androidx.compose.foundation.LocalScrollbarStyle
|
||||||
|
import androidx.compose.foundation.TooltipArea
|
||||||
|
import androidx.compose.foundation.VerticalScrollbar
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.input.pointer.PointerIcon
|
||||||
|
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import java.awt.Cursor
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A multi-column table.
|
||||||
|
*
|
||||||
|
* The rules — row identity, selection across a refresh, stable sorting, stored widths
|
||||||
|
* — live in [DataTableState] and are tested there. What is here is only the drawing
|
||||||
|
* and the gestures, because nothing that draws is covered by a test in this release.
|
||||||
|
*
|
||||||
|
* Rows are keyed by the state's own row key, never by their position: the lists this
|
||||||
|
* table serves are live, and a positional key reshuffles every row whenever something
|
||||||
|
* arrives at the top.
|
||||||
|
*
|
||||||
|
* @param rowStyle colours a whole row, as the station list does from its priority
|
||||||
|
* score. Not used by the monitor tables, and deliberately still present: the
|
||||||
|
* interface is cut for the station list of Etappe 5.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* TooltipArea is experimental in Compose foundation. The opt-in is deliberate: it is
|
||||||
|
* the supported way to reach the hover text the JavaFX cells had, and losing that
|
||||||
|
* loses the only way to read a value that does not fit its column.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun <T> DataTableView(
|
||||||
|
state: DataTableState<T>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
rowStyle: ((T) -> CellAccent?)? = null,
|
||||||
|
onRowClick: ((T) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val horizontal = rememberScrollState()
|
||||||
|
val vertical = rememberLazyListState()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These lists are newest-first, and a LazyColumn anchors to the item it was
|
||||||
|
* showing — so every arrival at the top pushes the view down and the operator
|
||||||
|
* drifts away from the newest line without touching anything.
|
||||||
|
*
|
||||||
|
* The rule: stay at the top while the operator is at the top, and leave them alone
|
||||||
|
* once they have scrolled down to read something. Yanking a reader back to the top
|
||||||
|
* on every inbound message would be worse than drifting.
|
||||||
|
*/
|
||||||
|
var stickToTop by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
LaunchedEffect(vertical) {
|
||||||
|
snapshotFlow { vertical.isScrollInProgress }.collect { scrolling ->
|
||||||
|
/* Only a scroll the operator made ends; a prepend does not scroll. */
|
||||||
|
if (!scrolling) {
|
||||||
|
stickToTop = vertical.firstVisibleItemIndex == 0 &&
|
||||||
|
vertical.firstVisibleItemScrollOffset == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newestKey = state.visibleRows.firstOrNull()?.let { state.keyOf(it) }
|
||||||
|
|
||||||
|
LaunchedEffect(newestKey) {
|
||||||
|
if (stickToTop && newestKey != null) {
|
||||||
|
vertical.scrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Both directions scroll. The columns of these two tables need about 890dp and
|
||||||
|
* 1000dp, and the stored window width starts at 700: without this the last columns
|
||||||
|
* are simply unreachable, and so are the handles that would narrow them. The JavaFX
|
||||||
|
* tables had a horizontal scrollbar.
|
||||||
|
*/
|
||||||
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
|
val slack = unusedWidth(state, maxWidth)
|
||||||
|
val lastColumnId = state.orderedColumns.lastOrNull()?.id
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
/*
|
||||||
|
* A second header row above the first, spanning the columns that share a
|
||||||
|
* group. The station list needs it: ten columns headed "wkdany 50 70 144 432
|
||||||
|
* 23 13 9 6 3" say nothing without the word "worked" over them.
|
||||||
|
*/
|
||||||
|
val groups = state.orderedColumns.map { it.group }
|
||||||
|
|
||||||
|
if (groups.any { it != null }) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.horizontalScroll(horizontal)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||||
|
) {
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
while (index < state.orderedColumns.size) {
|
||||||
|
val group = groups[index]
|
||||||
|
var span = 1
|
||||||
|
|
||||||
|
while (index + span < groups.size && groups[index + span] == group && group != null) {
|
||||||
|
span++
|
||||||
|
}
|
||||||
|
|
||||||
|
val width = (index until index + span).sumOf { position ->
|
||||||
|
val column = state.orderedColumns[position]
|
||||||
|
val stored = state.widthOf(column.id)
|
||||||
|
val own = stored ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||||
|
own + Density.COLUMN_HANDLE_WIDTH.value
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.width(width.dp).padding(horizontal = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (group != null) {
|
||||||
|
Text(
|
||||||
|
group,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index += span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* IntrinsicSize.Min, so the fillMaxHeight of the cells and the grab handles
|
||||||
|
* resolves against the header's own content instead of against the whole
|
||||||
|
* table. Without it the header took the entire height and the handles became
|
||||||
|
* full-length bars down the window.
|
||||||
|
*/
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.horizontalScroll(horizontal)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||||
|
) {
|
||||||
|
state.orderedColumns.forEachIndexed { index, column ->
|
||||||
|
val sorted = state.sortedColumnId == column.id
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Click to sort, drag sideways to reorder — the two gestures the JavaFX
|
||||||
|
* header had. The drag is measured in whole column steps rather than
|
||||||
|
* pixels, so a column moves once per column crossed instead of
|
||||||
|
* flickering between two places.
|
||||||
|
*/
|
||||||
|
var dragged by remember(column.id) { mutableStateOf(0f) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clickable { state.toggleSort(column.id) }
|
||||||
|
.pointerInput(column.id, index) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragEnd = { dragged = 0f },
|
||||||
|
onDragCancel = { dragged = 0f },
|
||||||
|
) { _, dragAmount ->
|
||||||
|
dragged += dragAmount
|
||||||
|
val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f
|
||||||
|
|
||||||
|
if (dragged > step) {
|
||||||
|
state.moveColumn(column.id, index + 1)
|
||||||
|
dragged = 0f
|
||||||
|
} else if (dragged < -step) {
|
||||||
|
state.moveColumn(column.id, index - 1)
|
||||||
|
dragged = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerHoverIcon(PointerIcon(Cursor(Cursor.MOVE_CURSOR)))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 3.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
column.title() + if (sorted) (if (state.sortAscending) " ▲" else " ▼") else "",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The grab handle between two headers. Two pixels wide on purpose: the
|
||||||
|
* JavaFX table put the same gesture on the column border, and operators
|
||||||
|
* resize these tables constantly during a contest.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* A hairline to look at, a wider area to grab. The JavaFX column border
|
||||||
|
* was a thin line; a 4dp bar in the outline colour reads as a wall
|
||||||
|
* between the columns.
|
||||||
|
*/
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(Density.COLUMN_HANDLE_WIDTH)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
|
||||||
|
.pointerInput(column.id) {
|
||||||
|
detectHorizontalDragGestures { _, dragAmount ->
|
||||||
|
/*
|
||||||
|
* The drag is in pixels, the stored width is in dp, and
|
||||||
|
* the stored value survives a restart. Mixing the two
|
||||||
|
* doubles a column on its first touch on a HiDPI screen
|
||||||
|
* and writes that into preferences.xml.
|
||||||
|
*/
|
||||||
|
val deltaDp = with(density) { dragAmount.toDp().value }
|
||||||
|
val current = state.widthOf(column.id)
|
||||||
|
?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||||
|
|
||||||
|
state.resizeColumn(column.id, current + deltaDp)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(Density.HAIRLINE)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.outline),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
LazyColumn(
|
||||||
|
state = vertical,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.horizontalScroll(horizontal)
|
||||||
|
.padding(end = Density.SCROLLBAR_GUTTER),
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.visibleRows, key = { _, row -> state.keyOf(row) }) { index, row ->
|
||||||
|
val selected = state.isSelected(row)
|
||||||
|
val accent = rowStyle?.invoke(row)
|
||||||
|
val isEven = index % 2 == 0
|
||||||
|
val _forceRedraw = state.contentRevision
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.background(
|
||||||
|
when {
|
||||||
|
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
accent?.background != null -> accent.background
|
||||||
|
isEven -> Color.Transparent
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clickable {
|
||||||
|
state.select(row)
|
||||||
|
onRowClick?.invoke(row)
|
||||||
|
}
|
||||||
|
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
state.orderedColumns.forEach { column ->
|
||||||
|
val value = column.value(row)
|
||||||
|
val cell = column.accent?.invoke(row, value)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(cell?.background ?: Color.Transparent)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
TooltipArea(
|
||||||
|
tooltip = {
|
||||||
|
val hint = column.tooltip?.invoke(row, value) ?: value
|
||||||
|
|
||||||
|
if (hint.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
hint,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = cell?.foreground
|
||||||
|
?: accent?.foreground
|
||||||
|
?: MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(Density.COLUMN_HANDLE_WIDTH)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(Density.HAIRLINE)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = Density.HAIRLINE,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalScrollbar(
|
||||||
|
adapter = rememberScrollbarAdapter(vertical),
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||||
|
style = LocalScrollbarStyle.current.copy(
|
||||||
|
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||||
|
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalScrollbar(
|
||||||
|
adapter = rememberScrollbarAdapter(horizontal),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = LocalScrollbarStyle.current.copy(
|
||||||
|
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||||
|
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stored width when the operator has sized this column, otherwise the column's own
|
||||||
|
* weight. The stored value wins because it is the operator's decision.
|
||||||
|
*
|
||||||
|
* @param extra added to the last column so the table fills the window instead of
|
||||||
|
* leaving a band of empty background to the right. The JavaFX table did the
|
||||||
|
* same through `flexibleInitialWidth`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <T> Modifier.columnWidth(
|
||||||
|
state: DataTableState<T>,
|
||||||
|
column: DataColumn<T>,
|
||||||
|
extra: Dp = 0.dp,
|
||||||
|
): Modifier {
|
||||||
|
val stored = state.widthOf(column.id)
|
||||||
|
val own = if (stored != null) stored.dp else DEFAULT_COLUMN_WIDTH * column.weight
|
||||||
|
|
||||||
|
return this.width(own + extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much is left over once every column has its width. Zero when the columns are
|
||||||
|
* already wider than the window, in which case the table scrolls.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <T> unusedWidth(state: DataTableState<T>, available: Dp): Dp {
|
||||||
|
val used = state.orderedColumns.sumOf { column ->
|
||||||
|
val stored = state.widthOf(column.id)
|
||||||
|
val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||||
|
own + Density.COLUMN_HANDLE_WIDTH.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return (available.value - used).coerceAtLeast(0.0).dp
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DEFAULT_COLUMN_WIDTH = 85.dp
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The measurements of the settings windows, in one place so the tabs cannot drift
|
||||||
|
* apart.
|
||||||
|
*
|
||||||
|
* Material 3 ships phone densities: a text field is 56dp tall, a button 40dp and
|
||||||
|
* fully rounded, a checkbox reserves a 48dp touch target. The Station tab alone has
|
||||||
|
* over thirty fields, so those defaults turn a settings window into a scrolling
|
||||||
|
* exercise and make a desktop application look like a phone.
|
||||||
|
*
|
||||||
|
* Every height here is a **minimum**, never a fixed size. The configured base font
|
||||||
|
* size scales all text through Typography.scaledTo, and a fixed height would clip a
|
||||||
|
* larger font instead of growing with it.
|
||||||
|
*/
|
||||||
|
internal object Density {
|
||||||
|
|
||||||
|
/** Text fields, pickers and anything else on the field column. */
|
||||||
|
val FIELD_MIN_HEIGHT = 30.dp
|
||||||
|
|
||||||
|
/** Inside a text field. The vertical value is what buys back the 56dp. */
|
||||||
|
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
|
||||||
|
val BUTTON_MIN_HEIGHT = 24.dp
|
||||||
|
|
||||||
|
val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 1.dp)
|
||||||
|
|
||||||
|
/** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
|
||||||
|
val BUTTON_SHAPE = RoundedCornerShape(4.dp)
|
||||||
|
|
||||||
|
/** One row of a list or table. */
|
||||||
|
val ROW_MIN_HEIGHT = 26.dp
|
||||||
|
|
||||||
|
val TAB_MIN_HEIGHT = 30.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left and right of a tab title. Material reserves 90dp minimum width per tab,
|
||||||
|
* which eleven tabs cannot live with; this is what a tab costs beyond its text.
|
||||||
|
*/
|
||||||
|
val TAB_HORIZONTAL_PADDING = 10.dp
|
||||||
|
|
||||||
|
/** The box itself; the 48dp touch target around it is switched off separately. */
|
||||||
|
val CHECK_SIZE = 18.dp
|
||||||
|
|
||||||
|
/** The label column of the forms, wide enough for the longest label in use. */
|
||||||
|
val LABEL_COLUMN_WIDTH = 260.dp
|
||||||
|
|
||||||
|
/** Between a label and its field, and between two fields. */
|
||||||
|
val FIELD_GAP = 3.dp
|
||||||
|
|
||||||
|
/** Between buttons in a row. */
|
||||||
|
val BUTTON_GAP = 6.dp
|
||||||
|
|
||||||
|
/** Around a selected row, and the hairline between rows. */
|
||||||
|
val SELECTION_BORDER = 1.5.dp
|
||||||
|
|
||||||
|
/** Kept free at the right edge so the scrollbar never sits on a control. */
|
||||||
|
val SCROLLBAR_GUTTER = 12.dp
|
||||||
|
|
||||||
|
/** Grab area between two columns; the line drawn inside it is a hairline. */
|
||||||
|
val COLUMN_HANDLE_WIDTH = 5.dp
|
||||||
|
|
||||||
|
val HAIRLINE = 1.dp
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns of the directed-message table: what was said to this station.
|
||||||
|
*
|
||||||
|
* Its own pane above the send line rather than one of the tabs, because an operator must
|
||||||
|
* be able to see a private call without switching away from whatever they are reading.
|
||||||
|
*
|
||||||
|
* The ids are the keys the operator's stored column widths hang on, exactly as
|
||||||
|
* TableLayoutManager registered them under "private-messages".
|
||||||
|
*
|
||||||
|
* @param ownCallSign the local station, which gets no distance to itself
|
||||||
|
* @param formatMessage the controller's display formatting for a message text
|
||||||
|
*/
|
||||||
|
object DirectedMessageColumns {
|
||||||
|
|
||||||
|
fun all(
|
||||||
|
ownCallSign: () -> String? = { null },
|
||||||
|
formatMessage: (ChatMessage) -> String,
|
||||||
|
): List<DataColumn<ChatMessage>> = listOf(
|
||||||
|
DataColumn(
|
||||||
|
id = "time",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnTime },
|
||||||
|
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
|
||||||
|
weight = 0.7f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "callsign",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign },
|
||||||
|
value = { MonitorColumns.callSignOf(it.sender) },
|
||||||
|
),
|
||||||
|
DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
|
||||||
|
DataColumn(id = "qra", title = "QRA", value = { MonitorColumns.qraOf(it.sender) }),
|
||||||
|
DataColumn(
|
||||||
|
id = "qrb",
|
||||||
|
title = "QRB",
|
||||||
|
value = { message -> qrbOf(message, ownCallSign()) },
|
||||||
|
comparator = compareBy { it.sender?.qrb ?: 0.0 },
|
||||||
|
weight = 0.9f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "message",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage },
|
||||||
|
value = formatMessage,
|
||||||
|
weight = 3f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "last-qrg",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastKnownQrg },
|
||||||
|
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
|
||||||
|
weight = 0.9f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "airscout",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
|
||||||
|
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
|
||||||
|
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
||||||
|
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
|
||||||
|
weight = 1.4f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "category",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
|
||||||
|
value = { message ->
|
||||||
|
message.chatCategory
|
||||||
|
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||||
|
.orEmpty()
|
||||||
|
},
|
||||||
|
weight = 1.2f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance and bearing as one cell, both cut to whole numbers to save width.
|
||||||
|
*
|
||||||
|
* The degree sign sits outside the bracket — "342 km (88)°" — which is how JavaFX
|
||||||
|
* wrote it and therefore how operators read it.
|
||||||
|
*/
|
||||||
|
private fun qrbOf(message: ChatMessage, ownCallSign: String?): String {
|
||||||
|
val sender = message.sender ?: return ""
|
||||||
|
if (sender.callSign == null || sender.callSign == ownCallSign) return ""
|
||||||
|
|
||||||
|
val qrb = sender.qrb ?: return ""
|
||||||
|
val bearing = sender.getQTFdirection() ?: return ""
|
||||||
|
return "${qrb.toInt()} km (${bearing.toInt()})°"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kst4contest.view.PrivateMessageRowStyleResolver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row colouring of the directed-message table.
|
||||||
|
*
|
||||||
|
* A new private message is highlighted and the highlight fades over five minutes, so a call
|
||||||
|
* that arrived while the operator was looking at the station list is still findable — being
|
||||||
|
* noticed is what that pane exists for. Own messages are blue at any age: they are context,
|
||||||
|
* not news.
|
||||||
|
*
|
||||||
|
* The steps come from [PrivateMessageRowStyleResolver], which the JavaFX row factory uses, so
|
||||||
|
* the two cannot drift apart. The colours are the stylesheets' `.messageHighlight*-column`,
|
||||||
|
* identical in both designs.
|
||||||
|
*/
|
||||||
|
object DirectedMessageRowStyle {
|
||||||
|
|
||||||
|
/** `.messageHighlightOwn-column`. */
|
||||||
|
val OWN = Color(0xFF00FFFF)
|
||||||
|
|
||||||
|
/** `.messageHighlight30-column`: just arrived. */
|
||||||
|
val FRESH = Color(0xFF33CC33)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fading green of `.messageHighlight30` through `.messageHighlight300`, in the order
|
||||||
|
* the resolver's age limits name them.
|
||||||
|
*/
|
||||||
|
private val FADE = listOf(
|
||||||
|
FRESH,
|
||||||
|
Color(0xFF40BF40),
|
||||||
|
Color(0xFF4DB34D),
|
||||||
|
Color(0xFF59A659),
|
||||||
|
Color(0xFF669966),
|
||||||
|
Color(0xFF738C73),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The resolver's style class names, in the same order as [FADE]. */
|
||||||
|
private val FADE_CLASSES = listOf(
|
||||||
|
"messageHighlight30-column",
|
||||||
|
"messageHighlight60-column",
|
||||||
|
"messageHighlight90-column",
|
||||||
|
"messageHighlight120-column",
|
||||||
|
"messageHighlight180-column",
|
||||||
|
"messageHighlight300-column",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a row is coloured, or null once the message is no longer news.
|
||||||
|
*
|
||||||
|
* The style class is asked of the resolver rather than recomputed here, so a change to
|
||||||
|
* the fade steps reaches both views at once.
|
||||||
|
*/
|
||||||
|
fun accentFor(ownMessage: Boolean, ageSeconds: Long): CellAccent? {
|
||||||
|
val styleClass = PrivateMessageRowStyleResolver.resolveStyleClass(ownMessage, ageSeconds)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
if (styleClass == PrivateMessageRowStyleResolver.OWN_STYLE_CLASS) {
|
||||||
|
return CellAccent(background = OWN, foreground = Color.Black)
|
||||||
|
}
|
||||||
|
|
||||||
|
val index = FADE_CLASSES.indexOf(styleClass)
|
||||||
|
if (index < 0) return null
|
||||||
|
|
||||||
|
return CellAccent(background = FADE[index], foreground = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.FlowRowScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form controls shared by the settings tabs.
|
||||||
|
*
|
||||||
|
* Numeric fields keep the operator's text while it is being typed and only hand a
|
||||||
|
* parsed value upwards when it parses. Rejecting keystrokes instead would make a
|
||||||
|
* field impossible to clear, and replacing an unparsable entry with a default would
|
||||||
|
* silently change a setting — PROJECT_CONTEXT is explicit that an absent or malformed
|
||||||
|
* value must not become a fabricated zero.
|
||||||
|
*
|
||||||
|
* Every control here is built to desktop density; see [Density] for why and for the
|
||||||
|
* measurements.
|
||||||
|
*/
|
||||||
|
object Form {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun text(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
|
||||||
|
val draft = rememberDraft(value ?: "")
|
||||||
|
|
||||||
|
labelled(label) {
|
||||||
|
CompactTextField(
|
||||||
|
value = draft.value,
|
||||||
|
onValueChange = { typed ->
|
||||||
|
draft.value = typed
|
||||||
|
onChange(typed)
|
||||||
|
},
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun password(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
|
||||||
|
val draft = rememberDraft(value ?: "")
|
||||||
|
|
||||||
|
labelled(label) {
|
||||||
|
CompactTextField(
|
||||||
|
value = draft.value,
|
||||||
|
onValueChange = { typed ->
|
||||||
|
draft.value = typed
|
||||||
|
onChange(typed)
|
||||||
|
},
|
||||||
|
enabled = enabled,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text field that is validated when it is committed, not while it is typed.
|
||||||
|
*
|
||||||
|
* This is the Compose counterpart of the JavaFX fields which hung their rule on
|
||||||
|
* `focusedProperty` — the DX cluster port, the spotter callsign, the beacon
|
||||||
|
* templates and the beacon interval. Committing on every keystroke would refuse
|
||||||
|
* every prefix of a valid entry, so the draft lives here until the operator leaves
|
||||||
|
* the field or presses Enter.
|
||||||
|
*
|
||||||
|
* Only an edited draft is committed: drawing the field, or entering and leaving it
|
||||||
|
* without typing, commits nothing, and neither does Enter on an unchanged value.
|
||||||
|
* A field removed while it holds the focus -- its tab switched away, its window
|
||||||
|
* closed -- commits a typed entry once: Compose reports the cleared focus to the
|
||||||
|
* field as a focus loss.
|
||||||
|
*
|
||||||
|
* @param stored the value stored when the caller composed; the draft starts from it
|
||||||
|
* @param commit returns null when the value was accepted, otherwise the reason
|
||||||
|
* @param onRefused shown to the operator; the JavaFX fields opened an alert
|
||||||
|
* @param isUnchanged whether the draft names the stored value. Textual by default,
|
||||||
|
* looser where two spellings mean one value, as with colours. It must agree
|
||||||
|
* with what [commit] stores: a draft that commit would store as `c` has to count
|
||||||
|
* as unchanged against `c`, or leaving the field after Enter commits it again.
|
||||||
|
* @param current the value in force right now, read when the field commits: inside the
|
||||||
|
* settings window the value staged in the draft, otherwise the stored one. The
|
||||||
|
* typed text is judged against it, and falls back to it on a refusal (what the
|
||||||
|
* JavaFX fields did with setText). Required, because [stored] alone goes stale
|
||||||
|
* when it comes from a plain getter: nothing need recompose the caller after a
|
||||||
|
* commit, so after Enter it can still name the old value.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun committed(
|
||||||
|
label: String,
|
||||||
|
stored: String,
|
||||||
|
commit: (String) -> String?,
|
||||||
|
onRefused: (String) -> Unit,
|
||||||
|
isUnchanged: (draft: String, stored: String) -> Boolean = { typed, now -> typed == now },
|
||||||
|
current: () -> String,
|
||||||
|
) {
|
||||||
|
var draft by remember(stored) { mutableStateOf(stored) }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Whether the field has held the focus since it was last committed. onFocusChanged
|
||||||
|
* also fires when the modifier is first attached, with "not focused"; reading that
|
||||||
|
* as a focus loss committed every such field the moment its tab was opened. Only a
|
||||||
|
* change from focused to unfocused is the operator leaving the field. A plain holder,
|
||||||
|
* not state: nothing is drawn from it.
|
||||||
|
*/
|
||||||
|
val hadFocus = remember { booleanArrayOf(false) }
|
||||||
|
|
||||||
|
fun commitDraft() {
|
||||||
|
/*
|
||||||
|
* An untouched draft is no edit. Committing it anyway would turn a shipped colour
|
||||||
|
* into an override of itself, restart the beacon timer, or report a stored value
|
||||||
|
* the rule refuses although the operator only passed through the field.
|
||||||
|
*/
|
||||||
|
if (isUnchanged(draft, current())) return
|
||||||
|
|
||||||
|
val refusal = commit(draft)
|
||||||
|
if (refusal != null) {
|
||||||
|
onRefused(refusal)
|
||||||
|
draft = current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inside the settings window the draft can commit this field without a focus change:
|
||||||
|
* a click on "Apply/Close prefs" may leave the focus in the field, and the entry would
|
||||||
|
* otherwise reach the draft only when the closing window removed the field. Through
|
||||||
|
* an updated state, because commitDraft closes over this composition's parameters.
|
||||||
|
* A refusal is reported by commitDraft itself through onRefused, as on focus loss, so
|
||||||
|
* the registration answers null and the caller sees the refusal as the notice.
|
||||||
|
*/
|
||||||
|
val settingsDraft = LocalSettingsDraft.current
|
||||||
|
val latestCommit = rememberUpdatedState(::commitDraft)
|
||||||
|
DisposableEffect(settingsDraft) {
|
||||||
|
val entry = settingsDraft?.registerTypedEntry {
|
||||||
|
latestCommit.value()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onDispose { entry?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
labelled(label) {
|
||||||
|
CompactTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged { focus ->
|
||||||
|
if (focus.isFocused) {
|
||||||
|
hadFocus[0] = true
|
||||||
|
} else if (hadFocus[0]) {
|
||||||
|
hadFocus[0] = false
|
||||||
|
commitDraft()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
|
||||||
|
commitDraft()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks one item from a fixed list, the counterpart of the JavaFX ChoiceBox.
|
||||||
|
*
|
||||||
|
* A null selection is possible and shown as such: the second chat category has no
|
||||||
|
* value until the operator picks one, and inventing one would log the station into
|
||||||
|
* a chat it never chose.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> choice(
|
||||||
|
label: String,
|
||||||
|
items: List<T>,
|
||||||
|
selected: T?,
|
||||||
|
describe: (T) -> String,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val draft = rememberDraft(selected)
|
||||||
|
|
||||||
|
labelled(label) {
|
||||||
|
Box {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { expanded = true },
|
||||||
|
enabled = enabled,
|
||||||
|
shape = Density.BUTTON_SHAPE,
|
||||||
|
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
items.forEach { item ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
draft.value = item
|
||||||
|
onSelect(item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> inlineChoice(
|
||||||
|
label: String,
|
||||||
|
items: List<T>,
|
||||||
|
selected: T?,
|
||||||
|
describe: (T) -> String,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val draft = rememberDraft(selected)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp))
|
||||||
|
Box {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { expanded = true },
|
||||||
|
enabled = enabled,
|
||||||
|
shape = Density.BUTTON_SHAPE,
|
||||||
|
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||||
|
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
items.forEach { item ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
draft.value = item
|
||||||
|
onSelect(item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
val draft = rememberDraft(value)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||||
|
) {
|
||||||
|
CompactCheckbox(draft.value) { ticked ->
|
||||||
|
draft.value = ticked
|
||||||
|
onChange(ticked)
|
||||||
|
}
|
||||||
|
Text(label, modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
|
||||||
|
number(label, value, { it.trim().toIntOrNull() }, onChange)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
|
||||||
|
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number field behind [int] and [decimal].
|
||||||
|
*
|
||||||
|
* The text follows the stored value only when that value is not what the operator's own
|
||||||
|
* text already says. A settings draft is Compose state, so the value an entry stages comes
|
||||||
|
* straight back as [value]; resetting the text to `value.toString()` then would turn a
|
||||||
|
* typed "1" into "1.0" before the next digit, and "135,5" could never be typed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <N> number(label: String, value: N, parse: (String) -> N?, onChange: (N) -> Unit) {
|
||||||
|
// The last text typed. A plain holder: it only seeds the text below, nothing draws it.
|
||||||
|
val typed = remember { arrayOf(value.toString()) }
|
||||||
|
var draft by remember(value) {
|
||||||
|
mutableStateOf(if (parse(typed[0]) == value) typed[0] else value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Text that does not parse stages nothing, so the draft still holds the last valid
|
||||||
|
* value while the field shows something else in red. Applying then would put a value
|
||||||
|
* into effect the operator no longer sees; the field refuses instead, naming itself.
|
||||||
|
*/
|
||||||
|
val strings = kst4contest.view.i18n.LocalStrings.current
|
||||||
|
val settingsDraft = LocalSettingsDraft.current
|
||||||
|
val refusal = rememberUpdatedState {
|
||||||
|
if (parse(draft) == null) strings.settingsInvalidEntry(label, draft) else null
|
||||||
|
}
|
||||||
|
DisposableEffect(settingsDraft) {
|
||||||
|
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
|
||||||
|
onDispose { entry?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
labelled(label) {
|
||||||
|
CompactTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { entered ->
|
||||||
|
typed[0] = entered
|
||||||
|
draft = entered
|
||||||
|
parse(entered)?.let(onChange)
|
||||||
|
},
|
||||||
|
isError = parse(draft) == null,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A titled section.
|
||||||
|
*
|
||||||
|
* The JavaFX window drew `generateLabeledSeparator`: a rule, the label, another
|
||||||
|
* rule. Here the label leads and one rule runs to the right edge, which sits
|
||||||
|
* better above the left-aligned label column than a centred title does. Either
|
||||||
|
* way it is a visible division, which a bare line of text was not.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun section(title: String, content: @Composable () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
HorizontalDivider(modifier = Modifier.weight(1f).padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A section whose controls flow across the width instead of stacking.
|
||||||
|
*
|
||||||
|
* For short, like-for-like options — the nine band checkboxes, the startup filters.
|
||||||
|
* Stacked they cost nine rows of height for nine words; flowing they fill the width
|
||||||
|
* the window actually has and re-flow when it changes.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun flowingSection(title: String, content: @Composable FlowRowScope.() -> Unit) {
|
||||||
|
section(title) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A labelled row holding a control this object does not provide, so a tab can add
|
||||||
|
* one without losing the shared label column.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun labelledRow(label: String, field: @Composable () -> Unit) {
|
||||||
|
labelled(label, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button of the settings windows: square-ish corners and desktop padding.
|
||||||
|
*
|
||||||
|
* Material's own Button is a 40dp pill, which is the single strongest reason the
|
||||||
|
* window looked like a phone application.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The button of the settings windows.
|
||||||
|
*
|
||||||
|
* Material's own Button is a 40dp pill; this one is square-ish and at desktop
|
||||||
|
* height. It also carries the two states the JavaFX stylesheets give every button:
|
||||||
|
* a gradient while the pointer is over it and a red border while it is held down.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun button(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||||
|
val palette = LocalJavaFxPalette.current
|
||||||
|
val interactions = remember { MutableInteractionSource() }
|
||||||
|
val hovered by interactions.collectIsHoveredAsState()
|
||||||
|
val pressed by interactions.collectIsPressedAsState()
|
||||||
|
|
||||||
|
val hoverGradient = palette.buttonHoverGradient
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalMinimumInteractiveComponentSize provides 0.dp
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = Density.BUTTON_SHAPE,
|
||||||
|
contentPadding = Density.BUTTON_CONTENT_PADDING,
|
||||||
|
interactionSource = interactions,
|
||||||
|
border = if (pressed) {
|
||||||
|
BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
colors = if (hovered && enabled && hoverGradient.isNotEmpty()) {
|
||||||
|
/*
|
||||||
|
* Compose paints a solid container, so the gradient is applied as the
|
||||||
|
* background below and the container itself is cleared. The text turns
|
||||||
|
* dark because the gradient the sheets use is a bright lime.
|
||||||
|
*/
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
contentColor = Color.Black,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
|
||||||
|
.then(
|
||||||
|
if (hovered && enabled && hoverGradient.size >= 2) {
|
||||||
|
Modifier.background(
|
||||||
|
brush = Brush.verticalGradient(hoverGradient),
|
||||||
|
shape = Density.BUTTON_SHAPE,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
* One line, always. A label left to wrap turns a squeezed button into a tall
|
||||||
|
* bar instead of simply being too narrow, and that bar then pushes the rest
|
||||||
|
* of the window out of view.
|
||||||
|
*/
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds what the operator sees while they change it.
|
||||||
|
*
|
||||||
|
* The settings controls are bound to plain ChatPreferences values, and those are not
|
||||||
|
* Compose state: writing one triggers no recomposition, so a field bound straight to
|
||||||
|
* the stored value keeps painting the value from the last composition. Typing then
|
||||||
|
* writes through correctly and shows nothing, a checkbox never appears to tick, and a
|
||||||
|
* picker never appears to change — which is exactly how it behaved.
|
||||||
|
*
|
||||||
|
* Keyed on the stored value, so an external change still reaches the control, and so
|
||||||
|
* a setter that normalises its input (a trimmed space, an upper-cased callsign) puts
|
||||||
|
* the normalised value back. This is the pattern GuardedTextField already used for
|
||||||
|
* the external-interface fields.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <T> rememberDraft(stored: T): MutableState<T> =
|
||||||
|
remember(stored) { mutableStateOf(stored) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun labelled(label: String, field: @Composable () -> Unit) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.width(Density.LABEL_COLUMN_WIDTH),
|
||||||
|
)
|
||||||
|
field()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text field at desktop density.
|
||||||
|
*
|
||||||
|
* Built from BasicTextField and the outlined decoration box rather than from
|
||||||
|
* OutlinedTextField, because that composable enforces a 56dp minimum height which
|
||||||
|
* cannot be passed a smaller one. The decoration box takes the content padding, and
|
||||||
|
* that is what buys the height back.
|
||||||
|
*
|
||||||
|
* The decoration box and its container are marked experimental in Material 3. The
|
||||||
|
* opt-in is deliberate and there is no alternative: it is the only supported way to
|
||||||
|
* build a text field below the enforced minimum height. Should the signature change,
|
||||||
|
* the two calls below are the whole surface to fix, and the tests around the tab states
|
||||||
|
* stay unaffected because none of them render.
|
||||||
|
*
|
||||||
|
* @param borderColor overrides the outline, so a caller can mark a selected row
|
||||||
|
* @param borderThickness overrides the outline width for the same reason
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun CompactTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
isError: Boolean = false,
|
||||||
|
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||||
|
borderColor: Color? = null,
|
||||||
|
borderThickness: Dp? = null,
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
/*
|
||||||
|
* The container is the JavaFX -fx-control-inner-background, which both sheets set
|
||||||
|
* apart from the window background. Material leaves an outlined field transparent,
|
||||||
|
* which made every field vanish into the surface.
|
||||||
|
*/
|
||||||
|
val colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
unfocusedBorderColor = borderColor ?: MaterialTheme.colorScheme.outline,
|
||||||
|
focusedBorderColor = borderColor ?: MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The colour has to be carried in the text style: BasicTextField has no theme of
|
||||||
|
* its own, so without this the text would stay black in the evening design.
|
||||||
|
*/
|
||||||
|
val onField = MaterialTheme.colorScheme.surfaceVariant.let { field ->
|
||||||
|
val brightness = 0.3f * field.red + 0.59f * field.green + 0.11f * field.blue
|
||||||
|
if (brightness > 0.5f) Color.Black else Color.White
|
||||||
|
}
|
||||||
|
val textColor = if (enabled) onField else onField.copy(alpha = 0.38f)
|
||||||
|
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||||
|
) { innerTextField ->
|
||||||
|
OutlinedTextFieldDefaults.DecorationBox(
|
||||||
|
value = value,
|
||||||
|
innerTextField = innerTextField,
|
||||||
|
enabled = enabled,
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
isError = isError,
|
||||||
|
colors = colors,
|
||||||
|
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||||
|
container = {
|
||||||
|
OutlinedTextFieldDefaults.Container(
|
||||||
|
enabled = enabled,
|
||||||
|
isError = isError,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
colors = colors,
|
||||||
|
shape = Density.BUTTON_SHAPE,
|
||||||
|
focusedBorderThickness = borderThickness ?: 2.dp,
|
||||||
|
unfocusedBorderThickness = borderThickness ?: 1.dp,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A checkbox without the 48dp touch target Material reserves around it. On a desktop
|
||||||
|
* form that target is dead space between every two rows.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||||
|
Checkbox(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
modifier = Modifier.size(Density.CHECK_SIZE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kst4contest.ApplicationConstants
|
||||||
|
import kst4contest.model.PaletteRole
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colours the Compose windows take from the JavaFX stylesheets.
|
||||||
|
*
|
||||||
|
* @param fontSizePx the `-fx-font-size` of `.root`; both shipped sheets set 12px
|
||||||
|
*/
|
||||||
|
data class JavaFxPalette(
|
||||||
|
val base: Color,
|
||||||
|
/**
|
||||||
|
* What a window is actually painted with. Modena's `-fx-background` is
|
||||||
|
* `derive(-fx-base, 26.4%)`, not `-fx-base` itself: taking the base raw makes a
|
||||||
|
* Compose window visibly darker than the JavaFX one beside it.
|
||||||
|
*/
|
||||||
|
val windowBackground: Color,
|
||||||
|
val accent: Color,
|
||||||
|
/**
|
||||||
|
* The colour the operator actually reads as the accent.
|
||||||
|
*
|
||||||
|
* `-fx-accent` only drives JavaFX's own selection bar, which barely shows. What is
|
||||||
|
* seen instead is `.text-field .text`: both sheets fill the text inside every field
|
||||||
|
* — and the table column headers of the evening sheet — with a green gradient. A
|
||||||
|
* solid colour is needed here, so the gradient is collapsed to its midpoint.
|
||||||
|
*/
|
||||||
|
val textAccent: Color,
|
||||||
|
val controlInnerBackground: Color,
|
||||||
|
val labelTextFill: Color,
|
||||||
|
val separatorLine: Color,
|
||||||
|
val buttonHoverGradient: List<Color>,
|
||||||
|
val buttonPressedBorder: Color,
|
||||||
|
val fontSizePx: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the colours of a JavaFX stylesheet.
|
||||||
|
*
|
||||||
|
* This is deliberately not a CSS engine. It looks up the handful of declarations the
|
||||||
|
* Compose windows need and understands only what those declarations actually use:
|
||||||
|
* colour literals, named colours, references to another `-fx-` value in the same
|
||||||
|
* sheet, `derive(colour, percent)` and the stops of a `linear-gradient`.
|
||||||
|
*
|
||||||
|
* **The source is the classpath resource, the same one JavaFX loads.** The copies
|
||||||
|
* under the application directory are written at startup but never read back:
|
||||||
|
* `applyThemeStylesheet` passes the bare file name to `Scene.getStylesheets()`, and
|
||||||
|
* JavaFX resolves a relative stylesheet name against the classpath. Reading the copy
|
||||||
|
* here would make the two windows disagree exactly when an operator edits it.
|
||||||
|
*
|
||||||
|
* The daylight sheet declares almost nothing and relies on JavaFX's own Modena
|
||||||
|
* defaults, so every lookup falls back to the matching Modena value.
|
||||||
|
*/
|
||||||
|
object JavaFxStylesheet {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The classpath paths of the two shipped sheets, derived from the one place that owns
|
||||||
|
* their file names. They used to be spelled out in four places -- here, the theme, the
|
||||||
|
* store factory and the startup copy -- and two of those had to stay equal for the root
|
||||||
|
* profile's own stylesheet to be read at all: the copy is written under one name and read
|
||||||
|
* under the other. A rename on one side would silently restore the very defect this stage
|
||||||
|
* was commissioned to fix, with a green build.
|
||||||
|
*/
|
||||||
|
val DAYLIGHT_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT
|
||||||
|
val EVENING_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shipped palette of one design.
|
||||||
|
*
|
||||||
|
* @param darkMode true for the evening design, false for the daylight one
|
||||||
|
* @return the complete palette, Modena fallbacks included
|
||||||
|
*/
|
||||||
|
fun shipped(darkMode: Boolean): JavaFxPalette =
|
||||||
|
read(if (darkMode) EVENING_RESOURCE else DAYLIGHT_RESOURCE)
|
||||||
|
|
||||||
|
/** Modena's own `-fx-background: derive(-fx-base, 26.4%)`. */
|
||||||
|
private const val MODENA_BACKGROUND_DERIVE = 26.4
|
||||||
|
|
||||||
|
/* Modena's defaults, used wherever a sheet declares nothing. */
|
||||||
|
private val MODENA_BASE = Color(0xFFECECEC)
|
||||||
|
private val MODENA_ACCENT = Color(0xFF0096C9)
|
||||||
|
private val MODENA_CONTROL_INNER = Color(0xFFFFFFFF)
|
||||||
|
private val MODENA_TEXT = Color(0xFF000000)
|
||||||
|
|
||||||
|
private val NAMED_COLORS = mapOf(
|
||||||
|
"white" to Color.White,
|
||||||
|
"black" to Color.Black,
|
||||||
|
"lightgray" to Color(0xFFD3D3D3),
|
||||||
|
"lightgrey" to Color(0xFFD3D3D3),
|
||||||
|
"gray" to Color(0xFF808080),
|
||||||
|
"grey" to Color(0xFF808080),
|
||||||
|
"darkgray" to Color(0xFFA9A9A9),
|
||||||
|
"green" to Color(0xFF008000),
|
||||||
|
"lightgreen" to Color(0xFF90EE90),
|
||||||
|
"red" to Color(0xFFFF0000),
|
||||||
|
"blue" to Color(0xFF0000FF),
|
||||||
|
"yellow" to Color(0xFFFFFF00),
|
||||||
|
"orange" to Color(0xFFFFA500),
|
||||||
|
"transparent" to Color.Transparent,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resourcePath classpath path of the sheet, for example
|
||||||
|
* `/KST4ContestDefaultEvening.css`
|
||||||
|
*/
|
||||||
|
fun read(resourcePath: String): JavaFxPalette {
|
||||||
|
val source = JavaFxStylesheet::class.java.getResource(resourcePath)?.readText().orEmpty()
|
||||||
|
val blocks = parseBlocks(source)
|
||||||
|
val root = blocks[".root"].orEmpty()
|
||||||
|
|
||||||
|
val base = colorOf(root["-fx-base"], root) ?: MODENA_BASE
|
||||||
|
val accent = colorOf(root["-fx-accent"], root) ?: MODENA_ACCENT
|
||||||
|
|
||||||
|
return JavaFxPalette(
|
||||||
|
base = base,
|
||||||
|
windowBackground = colorOf(root["-fx-background"], root)
|
||||||
|
?: derive(base, MODENA_BACKGROUND_DERIVE),
|
||||||
|
textAccent = midpointOf(
|
||||||
|
gradientStops(blocks[".text-field .text"]?.get("-fx-fill"), root)
|
||||||
|
) ?: accent,
|
||||||
|
accent = accent,
|
||||||
|
controlInnerBackground = colorOf(root["-fx-control-inner-background"], root)
|
||||||
|
?: MODENA_CONTROL_INNER,
|
||||||
|
labelTextFill = colorOf(blocks[".label"]?.get("-fx-text-fill"), root) ?: MODENA_TEXT,
|
||||||
|
/* Modena draws the line one fifth darker than the base. */
|
||||||
|
separatorLine = colorOf(blocks[".separator *.line"]?.get("-fx-background-color"), root)
|
||||||
|
?: derive(base, -20.0),
|
||||||
|
buttonHoverGradient = gradientStops(
|
||||||
|
blocks[".button:hover"]?.get("-fx-background-color"), root
|
||||||
|
),
|
||||||
|
buttonPressedBorder = colorOf(blocks[".button:pressed"]?.get("-fx-border-color"), root)
|
||||||
|
?: accent,
|
||||||
|
fontSizePx = root["-fx-font-size"]?.removeSuffix("px")?.trim()?.toFloatOrNull() ?: 12f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles a stylesheet source actually names.
|
||||||
|
*
|
||||||
|
* Deliberately not [read]: that one fills a missing role from the Modena defaults, which
|
||||||
|
* is right for the shipped sheets and wrong for a layer meant to override one role at a
|
||||||
|
* time. A sheet saying only `-fx-base` must declare exactly one role.
|
||||||
|
*
|
||||||
|
* Each role is looked up at its own selector, because three of the six are stated
|
||||||
|
* outside `.root` and one of those properties -- `-fx-background-color` -- appears a
|
||||||
|
* dozen times elsewhere in the evening sheet. `.root` stays the scope in which a `-fx-`
|
||||||
|
* reference or a `derive(...)` is resolved, exactly as [read] resolves them.
|
||||||
|
*
|
||||||
|
* A value this reader does not understand -- a gradient, say -- leaves the role
|
||||||
|
* undeclared, so it falls through to the layer below rather than becoming a guess.
|
||||||
|
*/
|
||||||
|
fun declaredRoles(source: String): Map<PaletteRole, Color> {
|
||||||
|
val blocks = parseBlocks(source)
|
||||||
|
val root = blocks[".root"].orEmpty()
|
||||||
|
|
||||||
|
val stated = PaletteRole.values().mapNotNull { role ->
|
||||||
|
val value = blocks[role.selector()]?.get(role.cssProperty())
|
||||||
|
?: return@mapNotNull null
|
||||||
|
val colour = colorOf(value, root) ?: return@mapNotNull null
|
||||||
|
role to colour
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A sheet that states `-fx-base` and no `-fx-background` has stated the window surface
|
||||||
|
* too, because that is what JavaFX does with it: Modena's `-fx-background` is
|
||||||
|
* `derive(-fx-base, 26.4%)`, and [read] applies the same fallback. Without this the
|
||||||
|
* most natural hand edit is nearly inert -- `-fx-base: #303030` would leave every
|
||||||
|
* window the shipped light grey, since the base itself reaches the screen only through
|
||||||
|
* the menu strip and the selection tint.
|
||||||
|
*
|
||||||
|
* Only here, in the file layer. The settings tab keeps the two roles independent,
|
||||||
|
* which is why the spec separates them at all: six fields are visible there, so an
|
||||||
|
* operator who wants both changed can say so.
|
||||||
|
*
|
||||||
|
* A consequence worth stating rather than discovering: the tab then reports the window
|
||||||
|
* surface as coming *from file* for a colour the file never spells out. That is the
|
||||||
|
* right answer. The provenance exists to answer "why is this colour what it is", not
|
||||||
|
* "which line names it", and the file's `-fx-base` is why.
|
||||||
|
*/
|
||||||
|
val base = stated[PaletteRole.SURFACE]
|
||||||
|
|
||||||
|
if (base == null || stated.containsKey(PaletteRole.WINDOW_SURFACE)) {
|
||||||
|
return stated
|
||||||
|
}
|
||||||
|
|
||||||
|
return stated + (PaletteRole.WINDOW_SURFACE to derive(base, MODENA_BACKGROUND_DERIVE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles a stylesheet file names, or none when it cannot be read.
|
||||||
|
*
|
||||||
|
* Never throws. A client that will not start because of a broken colour file is worse
|
||||||
|
* than a client in the wrong colours, and this file is one the operator edited by hand.
|
||||||
|
*/
|
||||||
|
fun declaredRolesOfFile(file: java.io.File): Map<PaletteRole, Color> =
|
||||||
|
runCatching {
|
||||||
|
if (!file.isFile) emptyMap() else declaredRoles(file.readText())
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaFX `derive(colour, percent)`, reimplemented.
|
||||||
|
*
|
||||||
|
* No JavaFX call and no reflection: this is the algorithm itself. The brightness it
|
||||||
|
* derives from is not the HSB value but `0.3R + 0.59G + 0.11B`, and the positive
|
||||||
|
* branch follows a staircase rather than a formula, which is why an earlier
|
||||||
|
* approximation here was measurably wrong.
|
||||||
|
*
|
||||||
|
* Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this against
|
||||||
|
* values the real JavaFX colour-derivation routine produced, frozen into
|
||||||
|
* `javafx-derive-reference.txt` so the check survives JavaFX being removed. That
|
||||||
|
* file's header names the exact class the values came from.
|
||||||
|
* Every grey and both stylesheet bases match. One saturated blue drifts by up to
|
||||||
|
* three steps per channel. That is tolerable only because no saturated colour
|
||||||
|
* reaches here: three of the four call sites pass the window background or
|
||||||
|
* `-fx-base`, and the fourth is [parseColour]'s own `derive(...)` branch below,
|
||||||
|
* which passes whatever a sheet names — and every `derive(...)` in both shipped
|
||||||
|
* sheets derives from `-fx-base` alone. A hand-edited sheet can break that: an
|
||||||
|
* operator who sets `-fx-base` to a saturated colour sends every `derive(...)` of it
|
||||||
|
* through the inaccurate part. Revisited when the colours became operator-editable and
|
||||||
|
* deliberately left alone — a drift of three steps per channel is not perceptible in a
|
||||||
|
* colour somebody chose themselves, and what the settings tab sets does not pass
|
||||||
|
* through here at all: the resolver substitutes finished colours.
|
||||||
|
*/
|
||||||
|
fun derive(color: Color, percent: Double): Color {
|
||||||
|
val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue
|
||||||
|
var calcBrightness = percent / 100.0
|
||||||
|
|
||||||
|
if (calcBrightness > 0) {
|
||||||
|
if (baseBrightness > 0.85) {
|
||||||
|
calcBrightness *= 1.6
|
||||||
|
} else if (baseBrightness > 0.6) {
|
||||||
|
// no change
|
||||||
|
} else if (baseBrightness > 0.5) {
|
||||||
|
calcBrightness *= 0.9
|
||||||
|
} else if (baseBrightness > 0.4) {
|
||||||
|
calcBrightness *= 0.8
|
||||||
|
} else if (baseBrightness > 0.3) {
|
||||||
|
calcBrightness *= 0.7
|
||||||
|
} else {
|
||||||
|
calcBrightness *= 0.6
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (baseBrightness < 0.2) {
|
||||||
|
calcBrightness *= 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)
|
||||||
|
|
||||||
|
// RGBtoHSB
|
||||||
|
val r = color.red.toDouble()
|
||||||
|
val g = color.green.toDouble()
|
||||||
|
val b = color.blue.toDouble()
|
||||||
|
val cmax = maxOf(r, g, b)
|
||||||
|
val cmin = minOf(r, g, b)
|
||||||
|
val brightness = cmax
|
||||||
|
val saturation = if (cmax != 0.0) (cmax - cmin) / cmax else 0.0
|
||||||
|
var hue = 0.0
|
||||||
|
|
||||||
|
if (saturation != 0.0) {
|
||||||
|
val redc = (cmax - r) / (cmax - cmin)
|
||||||
|
val greenc = (cmax - g) / (cmax - cmin)
|
||||||
|
val bluec = (cmax - b) / (cmax - cmin)
|
||||||
|
hue = when {
|
||||||
|
r == cmax -> bluec - greenc
|
||||||
|
g == cmax -> 2.0 + redc - bluec
|
||||||
|
else -> 4.0 + greenc - redc
|
||||||
|
}
|
||||||
|
hue /= 6.0
|
||||||
|
if (hue < 0) {
|
||||||
|
hue += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hue *= 360.0
|
||||||
|
var derivedSat = saturation
|
||||||
|
var derivedBri = brightness
|
||||||
|
|
||||||
|
if (calcBrightness > 0) {
|
||||||
|
derivedSat *= (1.0 - calcBrightness)
|
||||||
|
derivedBri += (1.0 - derivedBri) * calcBrightness
|
||||||
|
} else {
|
||||||
|
derivedBri *= (calcBrightness + 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
derivedSat = derivedSat.coerceIn(0.0, 1.0)
|
||||||
|
derivedBri = derivedBri.coerceIn(0.0, 1.0)
|
||||||
|
|
||||||
|
return Color.hsv(hue.toFloat(), derivedSat.toFloat(), derivedBri.toFloat(), color.alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `selector { property: value; ... }`, comments removed. */
|
||||||
|
private fun parseBlocks(source: String): Map<String, Map<String, String>> {
|
||||||
|
val withoutComments = source.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "")
|
||||||
|
val blocks = mutableMapOf<String, MutableMap<String, String>>()
|
||||||
|
|
||||||
|
Regex("([^{}]+)\\{([^{}]*)\\}").findAll(withoutComments).forEach { match ->
|
||||||
|
val selector = match.groupValues[1].trim().replace(Regex("\\s+"), " ")
|
||||||
|
val declarations = blocks.getOrPut(selector) { mutableMapOf() }
|
||||||
|
|
||||||
|
splitOnTopLevel(match.groupValues[2], ';').forEach { declaration ->
|
||||||
|
val separator = declaration.indexOf(':')
|
||||||
|
if (separator > 0) {
|
||||||
|
declarations[declaration.take(separator).trim()] =
|
||||||
|
declaration.substring(separator + 1).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one value to a colour, or null when it is absent or is something this
|
||||||
|
* reader does not need to understand.
|
||||||
|
*
|
||||||
|
* @param variables the `.root` declarations, where `-fx-` references are looked up
|
||||||
|
*/
|
||||||
|
private fun colorOf(value: String?, variables: Map<String, String>, depth: Int = 0): Color? {
|
||||||
|
val text = value?.trim()?.removeSuffix(";")?.trim() ?: return null
|
||||||
|
|
||||||
|
/* A malformed sheet must not spin here: -fx-a: -fx-b; -fx-b: -fx-a; */
|
||||||
|
if (depth > 8 || text.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
NAMED_COLORS[text.lowercase()]?.let { return it }
|
||||||
|
|
||||||
|
if (text.startsWith("#")) {
|
||||||
|
return hexColor(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith("-fx-")) {
|
||||||
|
return colorOf(variables[text], variables, depth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith("derive(")) {
|
||||||
|
val arguments = splitOnTopLevel(text.removePrefix("derive(").dropLast(1), ',')
|
||||||
|
if (arguments.size == 2) {
|
||||||
|
val source = colorOf(arguments[0], variables, depth + 1) ?: return null
|
||||||
|
val percent = arguments[1].removeSuffix("%").trim().toDoubleOrNull() ?: return null
|
||||||
|
return derive(source, percent)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.contains("linear-gradient(")) {
|
||||||
|
return gradientStops(text, variables).firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour stops of the first `linear-gradient` in a value. A stop may carry a
|
||||||
|
* position (`green 0%`) or a direction (`from 0% 0% to 100% 200%`); only the
|
||||||
|
* colours are wanted here.
|
||||||
|
*/
|
||||||
|
private fun gradientStops(value: String?, variables: Map<String, String>): List<Color> {
|
||||||
|
val text = value?.trim() ?: return emptyList()
|
||||||
|
val start = text.indexOf("linear-gradient(")
|
||||||
|
|
||||||
|
if (start < 0) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
var depth = 0
|
||||||
|
var end = -1
|
||||||
|
|
||||||
|
for (index in start + "linear-gradient".length until text.length) {
|
||||||
|
when (text[index]) {
|
||||||
|
'(' -> depth++
|
||||||
|
')' -> {
|
||||||
|
depth--
|
||||||
|
if (depth == 0) {
|
||||||
|
end = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end < 0) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return splitOnTopLevel(text.substring(start + "linear-gradient(".length, end), ',')
|
||||||
|
.filterNot { it.startsWith("from ") || it.startsWith("to ") }
|
||||||
|
.mapNotNull { colorOf(it.split(Regex("\\s+")).first(), variables) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses a gradient to one colour by averaging its ends. Compose paints solid
|
||||||
|
* where JavaFX paints a gradient across the glyphs, and the average is what the eye
|
||||||
|
* takes from the gradient anyway.
|
||||||
|
*/
|
||||||
|
private fun midpointOf(stops: List<Color>): Color? {
|
||||||
|
if (stops.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val first = stops.first()
|
||||||
|
val last = stops.last()
|
||||||
|
|
||||||
|
return Color(
|
||||||
|
red = (first.red + last.red) / 2f,
|
||||||
|
green = (first.green + last.green) / 2f,
|
||||||
|
blue = (first.blue + last.blue) / 2f,
|
||||||
|
alpha = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits on a separator that is not inside brackets. */
|
||||||
|
private fun splitOnTopLevel(text: String, separator: Char): List<String> {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
var depth = 0
|
||||||
|
var start = 0
|
||||||
|
|
||||||
|
text.forEachIndexed { index, character ->
|
||||||
|
when (character) {
|
||||||
|
'(' -> depth++
|
||||||
|
')' -> depth--
|
||||||
|
separator -> if (depth == 0) {
|
||||||
|
parts.add(text.substring(start, index))
|
||||||
|
start = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.add(text.substring(start))
|
||||||
|
return parts.map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexColor(text: String): Color? {
|
||||||
|
val digits = text.removePrefix("#")
|
||||||
|
|
||||||
|
val expanded = when (digits.length) {
|
||||||
|
3 -> digits.toCharArray().joinToString("") { "$it$it" }
|
||||||
|
6 -> digits
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val value = expanded.toLongOrNull(16) ?: return null
|
||||||
|
return Color(0xFF000000L or value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MenuDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
|
import androidx.compose.ui.window.MenuBar
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
|
import androidx.compose.ui.window.PopupProperties
|
||||||
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.utils.PlatformUtils
|
||||||
|
import kst4contest.view.i18n.LocalStrings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the menu bar does when something is picked.
|
||||||
|
*
|
||||||
|
* An interface rather than a dozen lambdas: every window is meant to carry this same bar, and a
|
||||||
|
* parameter list that long is misread sooner or later.
|
||||||
|
*/
|
||||||
|
interface MainMenuActions {
|
||||||
|
fun connect()
|
||||||
|
fun disconnect()
|
||||||
|
fun switchOperatorProfile()
|
||||||
|
fun exitApplication()
|
||||||
|
fun setQrgAsNameInChat()
|
||||||
|
fun toggleAwayState()
|
||||||
|
fun toggleSettingsWindow()
|
||||||
|
fun toggleMonitorWindow()
|
||||||
|
fun toggleStationMap()
|
||||||
|
fun useDarkDesign()
|
||||||
|
fun useDefaultDesign()
|
||||||
|
fun openDonationPage()
|
||||||
|
fun openHomepage()
|
||||||
|
fun openNewsgroup()
|
||||||
|
/** The JavaFX item and its handler are both commented out; kept so the bar can say so. */
|
||||||
|
fun openChangelog()
|
||||||
|
|
||||||
|
/** OV3T runs the aeroplane feed the AirScout predictions come from. */
|
||||||
|
fun openOv3tDonationPage()
|
||||||
|
fun contactAuthor()
|
||||||
|
fun showAbout()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu bar in the system's own bar — macOS only.
|
||||||
|
*
|
||||||
|
* Compose's `MenuBar` is a `javax.swing.JMenuBar` set on the window, so Swing draws it and
|
||||||
|
* no Compose theme reaches it. On macOS that is what one wants: the bar belongs at the top
|
||||||
|
* of the screen, drawn by the system. Everywhere else it meant an unthemed Metal menu
|
||||||
|
* sitting above a themed window, which is why [Kst4ContestMenuRow] exists.
|
||||||
|
*
|
||||||
|
* Both renderers read [mainMenuModel]. Declaring the menus twice is how one of them quietly
|
||||||
|
* loses an item the other gained.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FrameWindowScope.Kst4ContestMenuBar(
|
||||||
|
state: MainMenuState,
|
||||||
|
actions: MainMenuActions,
|
||||||
|
settingsWindowOpen: Boolean,
|
||||||
|
monitorWindowOpen: Boolean,
|
||||||
|
/**
|
||||||
|
* The connection state, for the read-only macOS menu. On macOS the status bar hides its
|
||||||
|
* badge because the indicator lives in the system menu bar, so without this menu a
|
||||||
|
* macOS operator would have no connection indicator at all.
|
||||||
|
*/
|
||||||
|
connectionState: On4KstConnectionState? = null,
|
||||||
|
connectionDetail: String? = null,
|
||||||
|
) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
|
MenuBar {
|
||||||
|
mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen, strings)
|
||||||
|
.forEach { menu ->
|
||||||
|
Menu(menu.title) {
|
||||||
|
menu.entries.forEach { entry ->
|
||||||
|
when (entry) {
|
||||||
|
is MenuEntry.Item ->
|
||||||
|
Item(entry.label, enabled = entry.enabled, onClick = entry.onClick)
|
||||||
|
MenuEntry.Separator -> Separator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The connection indicator, for macOS only: the system menu bar is where it lives
|
||||||
|
* there, because the status bar's badge is hidden on that platform. Read-only — the
|
||||||
|
* single item is disabled, exactly as menuItemConnectionStateDetailMacOs was.
|
||||||
|
*/
|
||||||
|
if (PlatformUtils.isMacOs()) {
|
||||||
|
Menu(ConnectionIndicator.macOsMenuTitle(connectionState)) {
|
||||||
|
Item(
|
||||||
|
ConnectionIndicator.detailFor(connectionState, connectionDetail),
|
||||||
|
enabled = false,
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu bar drawn inside the window, by Compose.
|
||||||
|
*
|
||||||
|
* Used everywhere except macOS. It exists because Compose's `MenuBar` hands the job to
|
||||||
|
* Swing, which knows nothing of the stylesheet palette: under Linux that produced the
|
||||||
|
* default cross-platform Metal menu above a themed window. This row reads the same palette
|
||||||
|
* as every other control, so a design switch reaches it.
|
||||||
|
*
|
||||||
|
* A menu title is shaped like a tab — flat, highlighted while active — so it takes the tab
|
||||||
|
* measurements rather than the button ones, and it highlights with the accent colour, which
|
||||||
|
* is what a JavaFX menu did.
|
||||||
|
*
|
||||||
|
* @param rowState which menu is down. Owned by the caller because `MainWindow` has to ask
|
||||||
|
* [MainMenuRowState.anyOpen] before letting the window-wide keys run.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Kst4ContestMenuRow(
|
||||||
|
state: MainMenuState,
|
||||||
|
actions: MainMenuActions,
|
||||||
|
settingsWindowOpen: Boolean,
|
||||||
|
monitorWindowOpen: Boolean,
|
||||||
|
rowState: MainMenuRowState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val palette = LocalJavaFxPalette.current
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
val menus = mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen, strings)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(palette.base)
|
||||||
|
.heightIn(min = Density.TAB_MIN_HEIGHT),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
menus.forEach { menu ->
|
||||||
|
MenuTitle(menu = menu, rowState = rowState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
|
||||||
|
val palette = LocalJavaFxPalette.current
|
||||||
|
val interactions = remember { MutableInteractionSource() }
|
||||||
|
val hovered by interactions.collectIsHoveredAsState()
|
||||||
|
val open = rowState.openMenu == menu.title
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Full bar height, so the popup anchored to this box starts at the bar's bottom edge
|
||||||
|
* rather than at the centred title text a few pixels above it.
|
||||||
|
*/
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.heightIn(min = Density.TAB_MIN_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = menu.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
/*
|
||||||
|
* contrastingText and not textAccent: the accent is the background right behind
|
||||||
|
* this title, and once an operator can set the accent the two can be the same
|
||||||
|
* colour -- an invisible menu title. Black or white by brightness is readable by
|
||||||
|
* construction.
|
||||||
|
*/
|
||||||
|
color = if (open) contrastingText(palette.accent) else palette.labelTextFill,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
when {
|
||||||
|
open -> palette.accent
|
||||||
|
/*
|
||||||
|
* Hover is a hint, not a selection: the accent at full strength on
|
||||||
|
* mere pointer travel makes the bar flicker as the operator crosses
|
||||||
|
* it on the way to the chat field.
|
||||||
|
*/
|
||||||
|
hovered -> palette.accent.copy(alpha = HOVER_ALPHA)
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.hoverable(interactions)
|
||||||
|
.clickable { rowState.toggle(menu.title) }
|
||||||
|
.padding(
|
||||||
|
horizontal = Density.TAB_HORIZONTAL_PADDING,
|
||||||
|
vertical = MENU_TITLE_VERTICAL_PADDING,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open
|
||||||
|
* flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside
|
||||||
|
* click and Escape come with a focusable popup), same MenuDefaults look; only the
|
||||||
|
* expand animation is gone, which a menu bar does not have anyway.
|
||||||
|
*/
|
||||||
|
if (open) {
|
||||||
|
Popup(
|
||||||
|
popupPositionProvider = MenuRowPopupPositionProvider,
|
||||||
|
onDismissRequest = rowState::close,
|
||||||
|
properties = PopupProperties(focusable = true),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MenuDefaults.shape,
|
||||||
|
color = MenuDefaults.containerColor,
|
||||||
|
tonalElevation = MenuDefaults.TonalElevation,
|
||||||
|
shadowElevation = MenuDefaults.ShadowElevation,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = MENU_POPUP_VERTICAL_PADDING)
|
||||||
|
.width(IntrinsicSize.Max)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
menu.entries.forEach { entry ->
|
||||||
|
when (entry) {
|
||||||
|
is MenuEntry.Item -> DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
enabled = entry.enabled,
|
||||||
|
onClick = { rowState.pick(entry.onClick) },
|
||||||
|
)
|
||||||
|
|
||||||
|
MenuEntry.Separator ->
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = Density.HAIRLINE,
|
||||||
|
color = palette.separatorLine,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places a menu-bar menu: flush under its anchor, leading edges aligned.
|
||||||
|
*
|
||||||
|
* Material3's DropdownMenu cannot be told to do this. Its position provider refuses any
|
||||||
|
* position closer than 48 dp (`MenuVerticalMargin`) to the window's top edge, and a menu
|
||||||
|
* bar sits exactly there, so every menu opened pushed down to that margin — a visible gap
|
||||||
|
* below the bar. The margin is applied after the `offset` parameter and the provider is
|
||||||
|
* not replaceable, hence this one and the hand-rolled popup in [MenuTitle].
|
||||||
|
*/
|
||||||
|
internal object MenuRowPopupPositionProvider : PopupPositionProvider {
|
||||||
|
override fun calculatePosition(
|
||||||
|
anchorBounds: IntRect,
|
||||||
|
windowSize: IntSize,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
popupContentSize: IntSize,
|
||||||
|
): IntOffset {
|
||||||
|
val preferredX = when (layoutDirection) {
|
||||||
|
LayoutDirection.Ltr -> anchorBounds.left
|
||||||
|
LayoutDirection.Rtl -> anchorBounds.right - popupContentSize.width
|
||||||
|
}
|
||||||
|
val x = preferredX.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width))
|
||||||
|
val below = anchorBounds.bottom
|
||||||
|
val above = anchorBounds.top - popupContentSize.height
|
||||||
|
val y = when {
|
||||||
|
below + popupContentSize.height <= windowSize.height -> below
|
||||||
|
above >= 0 -> above
|
||||||
|
else -> maxOf(0, windowSize.height - popupContentSize.height)
|
||||||
|
}
|
||||||
|
return IntOffset(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */
|
||||||
|
private const val HOVER_ALPHA = 0.35f
|
||||||
|
|
||||||
|
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
|
||||||
|
|
||||||
|
/** Material3's DropdownMenuVerticalPadding, which is internal; keep the menus looking alike. */
|
||||||
|
private val MENU_POPUP_VERTICAL_PADDING = 8.dp
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.view.i18n.Strings
|
||||||
|
/**
|
||||||
|
* One line of a menu.
|
||||||
|
*
|
||||||
|
* Toolkit-free on purpose: the bar is drawn two different ways — Compose's `MenuBar`,
|
||||||
|
* which is a `javax.swing.JMenuBar` under the covers and only used on macOS, and the
|
||||||
|
* in-window Compose row everywhere else. Declaring the menus twice is how one renderer
|
||||||
|
* quietly loses an item that the other gained, so they both read this instead.
|
||||||
|
*/
|
||||||
|
sealed interface MenuEntry {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param enabled whether it can be pressed; a menu item that merely looks usable is
|
||||||
|
* worse than a disabled one, because the operator presses it mid-contest and
|
||||||
|
* either nothing happens or the wrong thing does
|
||||||
|
*/
|
||||||
|
data class Item(
|
||||||
|
val label: String,
|
||||||
|
val enabled: Boolean,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
) : MenuEntry
|
||||||
|
|
||||||
|
data object Separator : MenuEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One menu of the bar: its title and what drops down from it. */
|
||||||
|
data class MenuSpec(val title: String, val entries: List<MenuEntry>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole bar, as data.
|
||||||
|
*
|
||||||
|
* A plain function of the state rather than a composable, which is what finally makes the
|
||||||
|
* structure, the dynamic labels and the enabled states testable: while this lived inside
|
||||||
|
* Compose's `MenuBar` it took a running window to look at, and none of it was covered.
|
||||||
|
*
|
||||||
|
* @param settingsWindowOpen asked for at build time, not held in [MainMenuState]: a Compose
|
||||||
|
* window can be closed from its own frame without telling the menu
|
||||||
|
* @param monitorWindowOpen the same for the cluster / stranger-QSO window
|
||||||
|
*/
|
||||||
|
fun mainMenuModel(
|
||||||
|
state: MainMenuState,
|
||||||
|
actions: MainMenuActions,
|
||||||
|
settingsWindowOpen: Boolean,
|
||||||
|
monitorWindowOpen: Boolean,
|
||||||
|
/*
|
||||||
|
* Passed in rather than read from the composition, because this model is deliberately not
|
||||||
|
* a composable -- both renderers build from it and the two menu tests assert over it
|
||||||
|
* without a composition in sight.
|
||||||
|
*/
|
||||||
|
strings: Strings,
|
||||||
|
): List<MenuSpec> = listOf(
|
||||||
|
MenuSpec(
|
||||||
|
strings.menuFile,
|
||||||
|
listOf(
|
||||||
|
MenuEntry.Item(state.connectLabel, state.canConnect, actions::connect),
|
||||||
|
MenuEntry.Item(strings.menuDisconnect, state.canDisconnect, actions::disconnect),
|
||||||
|
MenuEntry.Item(
|
||||||
|
strings.menuSwitchOperatorProfile,
|
||||||
|
state.canSwitchProfile,
|
||||||
|
actions::switchOperatorProfile,
|
||||||
|
),
|
||||||
|
MenuEntry.Item(strings.menuExitAndDisconnect, state.canExit, actions::exitApplication),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
strings.menuOptions,
|
||||||
|
listOf(
|
||||||
|
MenuEntry.Item(
|
||||||
|
strings.menuSetQrgAsNameInChat,
|
||||||
|
state.canUseChatActions,
|
||||||
|
actions::setQrgAsNameInChat,
|
||||||
|
),
|
||||||
|
/*
|
||||||
|
* This label is the only place the AFK state is visible anywhere in the
|
||||||
|
* application, so it follows the state rather than being flipped inside the
|
||||||
|
* click handler as JavaFX did.
|
||||||
|
*/
|
||||||
|
MenuEntry.Item(
|
||||||
|
state.awayMenuLabel(strings),
|
||||||
|
state.canUseChatActions,
|
||||||
|
actions::toggleAwayState,
|
||||||
|
),
|
||||||
|
MenuEntry.Item(
|
||||||
|
if (settingsWindowOpen) strings.menuHideOptions else strings.menuShowOptions,
|
||||||
|
enabled = true,
|
||||||
|
actions::toggleSettingsWindow,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
strings.menuWindows,
|
||||||
|
listOf(
|
||||||
|
MenuEntry.Item(
|
||||||
|
if (monitorWindowOpen) strings.menuHideMonitor else strings.menuShowMonitor,
|
||||||
|
enabled = true,
|
||||||
|
actions::toggleMonitorWindow,
|
||||||
|
),
|
||||||
|
MenuEntry.Item(
|
||||||
|
if (settingsWindowOpen) strings.menuHideOptions else strings.menuShowOptions,
|
||||||
|
enabled = true,
|
||||||
|
actions::toggleSettingsWindow,
|
||||||
|
),
|
||||||
|
MenuEntry.Separator,
|
||||||
|
MenuEntry.Item(strings.menuToggleStationMap, enabled = true, actions::toggleStationMap),
|
||||||
|
MenuEntry.Separator,
|
||||||
|
MenuEntry.Item(strings.menuUseDarkDesign, enabled = true, actions::useDarkDesign),
|
||||||
|
MenuEntry.Item(strings.menuUseDefaultDesign, enabled = true, actions::useDefaultDesign),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
strings.menuInfo,
|
||||||
|
listOf(
|
||||||
|
MenuEntry.Item(
|
||||||
|
strings.menuDonatePayPal,
|
||||||
|
enabled = true,
|
||||||
|
actions::openDonationPage,
|
||||||
|
),
|
||||||
|
MenuEntry.Item(
|
||||||
|
strings.menuDonateOv3t,
|
||||||
|
enabled = true,
|
||||||
|
actions::openOv3tDonationPage,
|
||||||
|
),
|
||||||
|
MenuEntry.Item(strings.menuHomepage, enabled = true, actions::openHomepage),
|
||||||
|
MenuEntry.Item(strings.menuNewsgroup, enabled = true, actions::openNewsgroup),
|
||||||
|
MenuEntry.Item(
|
||||||
|
strings.menuContactAuthor,
|
||||||
|
enabled = true,
|
||||||
|
actions::contactAuthor,
|
||||||
|
),
|
||||||
|
MenuEntry.Separator,
|
||||||
|
MenuEntry.Item(strings.menuAbout, enabled = true, actions::showAbout),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which menu of the in-window menu row is currently down.
|
||||||
|
*
|
||||||
|
* Compose state, because the row redraws when it changes. One menu at a time: two dropped
|
||||||
|
* at once is a thing no menu bar does.
|
||||||
|
*
|
||||||
|
* [anyOpen] is the part that is not cosmetic. `MainWindow` hangs `WindowShortcuts.handle`
|
||||||
|
* on the window's `onPreviewKeyEvent`, and a preview runs *before* its children — so
|
||||||
|
* without asking this first, pressing Escape to dismiss a menu would also clear the
|
||||||
|
* operator's half-typed message.
|
||||||
|
*/
|
||||||
|
class MainMenuRowState {
|
||||||
|
|
||||||
|
/** The title of the open menu, or null when none is. */
|
||||||
|
var openMenu: String? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Whether a menu is down, and the window keys should therefore stay out of the way. */
|
||||||
|
val anyOpen: Boolean
|
||||||
|
get() = openMenu != null
|
||||||
|
|
||||||
|
/** Opens the named menu, or closes it when it is the one already open. */
|
||||||
|
fun toggle(title: String) {
|
||||||
|
openMenu = if (openMenu == title) null else title
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes whatever is open. Idempotent; a dismissal from outside lands here too. */
|
||||||
|
fun close() {
|
||||||
|
openMenu = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a picked item's action and closes the menu it came from.
|
||||||
|
*
|
||||||
|
* Closing first would be wrong in the one case that matters: the quit item puts up a
|
||||||
|
* modal dialog, and the row must not still be drawing a dropdown underneath it.
|
||||||
|
*/
|
||||||
|
fun pick(action: () -> Unit) {
|
||||||
|
openMenu = null
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.view.i18n.Strings
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which menu items can be used.
|
||||||
|
*
|
||||||
|
* Compose state, not plain properties, and deliberately so: a menu item that merely
|
||||||
|
* looks usable is a worse place for a stale value than a form field. The operator
|
||||||
|
* presses it in the middle of a contest and nothing happens, or the wrong thing does.
|
||||||
|
* The JavaFX window drove the same flags from onConnectionStateChanged.
|
||||||
|
*/
|
||||||
|
class MainMenuState {
|
||||||
|
|
||||||
|
/** Before the first callback there is no session, which reads as disconnected. */
|
||||||
|
var connectionState: On4KstConnectionState by mutableStateOf(On4KstConnectionState.DISCONNECTED)
|
||||||
|
|
||||||
|
private val attemptRunning: Boolean
|
||||||
|
get() = connectionState.isConnectionAttemptActive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Connect item's label, which names the chat the station will land in — and both when
|
||||||
|
* the second one is on, so the operator sees before pressing it what they are logging
|
||||||
|
* into. JavaFX built the same string in initMenuBar.
|
||||||
|
*/
|
||||||
|
var connectLabel: String by mutableStateOf("Connect")
|
||||||
|
|
||||||
|
val canConnect: Boolean
|
||||||
|
get() = !attemptRunning
|
||||||
|
|
||||||
|
/** Also while an attempt is still running: it can be given up. */
|
||||||
|
val canDisconnect: Boolean
|
||||||
|
get() = attemptRunning
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sending to the chat — the away state, the QRG as name — needs a session that is
|
||||||
|
* really online. Logging in is not being in the chat, and a command sent then goes
|
||||||
|
* nowhere.
|
||||||
|
*/
|
||||||
|
val canUseChatActions: Boolean
|
||||||
|
get() = connectionState.isOnline
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the operator is marked away in the chat.
|
||||||
|
*
|
||||||
|
* Compose state because the away item's own label is the ONLY place this is visible —
|
||||||
|
* there is no separate indicator anywhere. JavaFX flipped the label inside the click
|
||||||
|
* handler, which left it wrong whenever the state changed from elsewhere.
|
||||||
|
*/
|
||||||
|
var awayFromChat: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the away item says. It names the next action rather than the current state,
|
||||||
|
* which is what the JavaFX wording did.
|
||||||
|
*
|
||||||
|
* Takes the texts as a parameter: this is a plain state holder, not a composable, so the
|
||||||
|
* caller reads the composition local and hands them over.
|
||||||
|
*/
|
||||||
|
fun awayMenuLabel(strings: Strings): String =
|
||||||
|
if (awayFromChat) strings.menuShowMeAsActive else strings.menuShowMeAsAway
|
||||||
|
|
||||||
|
/** Never blocked: switching profiles tears the session down on its own. */
|
||||||
|
val canSwitchProfile: Boolean = true
|
||||||
|
|
||||||
|
/** Never blocked: an operator must always be able to leave. */
|
||||||
|
val canExit: Boolean = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.utils.PlatformUtils
|
||||||
|
import java.util.function.Consumer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main window, assembled.
|
||||||
|
*
|
||||||
|
* The JavaFX original built this in 2164 lines inside start(). What is left here is the
|
||||||
|
* arrangement — every part was built and tested on its own in 5a, 5b and tasks 10 to 13d.
|
||||||
|
* That is the point of the split: this file says how the window is put together and
|
||||||
|
* nothing else, so a change to the arrangement cannot break a table.
|
||||||
|
*
|
||||||
|
* The window is a pure function of [MainWindowState]. That is what makes the profile
|
||||||
|
* switch a state swap instead of a restart.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FrameWindowScope.MainWindow(
|
||||||
|
state: MainWindowState,
|
||||||
|
actions: MainMenuActions,
|
||||||
|
skedBands: List<Band>,
|
||||||
|
openInBrowser: Consumer<String>,
|
||||||
|
onShowOnMap: () -> Unit,
|
||||||
|
onShowPathInAirScout: () -> Unit,
|
||||||
|
onStationSelected: (ChatMember) -> Unit,
|
||||||
|
/**
|
||||||
|
* A clicked private message prepares a reply to it. JavaFX resolved the receiver out of
|
||||||
|
* the message text when the sender was the local station, so replying to one's own
|
||||||
|
* message aims at whoever it was addressed to rather than at oneself.
|
||||||
|
*/
|
||||||
|
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||||
|
/** Whether a message came from this station; decides its row colour. */
|
||||||
|
isOwnMessage: (ChatMessage) -> Boolean,
|
||||||
|
/** How old a message is, for the fading highlight. */
|
||||||
|
messageAgeSeconds: (ChatMessage) -> Long,
|
||||||
|
onFiltersChanged: () -> Unit,
|
||||||
|
onShowMorePriorities: () -> Unit,
|
||||||
|
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
* macOS gets the system menu bar at the top of the screen, which is where that platform
|
||||||
|
* expects it and which Swing draws natively. Everywhere else Compose's MenuBar meant an
|
||||||
|
* unthemed Metal menu above a themed window, so the bar is drawn in the window instead —
|
||||||
|
* see Kst4ContestMenuRow.
|
||||||
|
*/
|
||||||
|
if (PlatformUtils.isMacOs()) {
|
||||||
|
Kst4ContestMenuBar(
|
||||||
|
state = state.menu,
|
||||||
|
actions = actions,
|
||||||
|
settingsWindowOpen = state.surroundings.settingsWindowOpen,
|
||||||
|
monitorWindowOpen = state.surroundings.monitorWindowOpen,
|
||||||
|
connectionState = state.surroundings.connectionState,
|
||||||
|
connectionDetail = state.surroundings.connectionDetail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val menuRow = remember { MainMenuRowState() }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The window-wide keys, where JavaFX had them: on the scene. Enter sends and Escape
|
||||||
|
* clears wherever the focus is, and Ctrl+1..Ctrl+0 inserts a snippet — on the field alone
|
||||||
|
* they would be gone the moment the operator clicked a station or a message row, which is
|
||||||
|
* most of the time.
|
||||||
|
*/
|
||||||
|
Surface(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
/*
|
||||||
|
* Not while a menu is down. This is a PREVIEW handler, so it runs before the
|
||||||
|
* dropdown sees the key — without the guard, Escape to dismiss a menu would
|
||||||
|
* first clear the operator's half-typed message.
|
||||||
|
*/
|
||||||
|
if (menuRow.anyOpen) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
WindowShortcuts.handle(
|
||||||
|
event = event,
|
||||||
|
send = state.chatInput::send,
|
||||||
|
clear = state.chatInput::clear,
|
||||||
|
insertSnippet = { index ->
|
||||||
|
state.chatInput.insertSnippet(state.snippets.entries, index)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
if (!PlatformUtils.isMacOs()) {
|
||||||
|
Kst4ContestMenuRow(
|
||||||
|
state = state.menu,
|
||||||
|
actions = actions,
|
||||||
|
settingsWindowOpen = state.surroundings.settingsWindowOpen,
|
||||||
|
monitorWindowOpen = state.surroundings.monitorWindowOpen,
|
||||||
|
rowState = menuRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusBar(
|
||||||
|
connectionState = state.surroundings.connectionState,
|
||||||
|
connectionDetail = state.surroundings.connectionDetail,
|
||||||
|
skedNotice = state.skedNotice,
|
||||||
|
bandUpgradeNotice = state.bandUpgradeNotice,
|
||||||
|
threadButtons = state.threadButtons,
|
||||||
|
showConnectionBadge = !PlatformUtils.isMacOs(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
|
|
||||||
|
SplitterPane(
|
||||||
|
state = state.outerSplitter,
|
||||||
|
orientation = SplitterOrientation.HORIZONTAL,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
panes = listOf(
|
||||||
|
{
|
||||||
|
MessageColumn(
|
||||||
|
state = state,
|
||||||
|
onCandidateClicked = onCandidateClicked,
|
||||||
|
onDirectedMessageClicked = onDirectedMessageClicked,
|
||||||
|
isOwnMessage = isOwnMessage,
|
||||||
|
messageAgeSeconds = messageAgeSeconds,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StationColumn(
|
||||||
|
state = state,
|
||||||
|
skedBands = skedBands,
|
||||||
|
openInBrowser = openInBrowser,
|
||||||
|
onShowOnMap = onShowOnMap,
|
||||||
|
onShowPathInAirScout = onShowPathInAirScout,
|
||||||
|
onStationSelected = onStationSelected,
|
||||||
|
onFiltersChanged = onFiltersChanged,
|
||||||
|
onShowMorePriorities = onShowMorePriorities,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left column: what was said, and what this station says back.
|
||||||
|
*
|
||||||
|
* Five panes in the order the operator has looked at them for years — directed messages
|
||||||
|
* at the top, then the shortcut buttons, the timeline, the send line, and the public
|
||||||
|
* message tabs at the bottom.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MessageColumn(
|
||||||
|
state: MainWindowState,
|
||||||
|
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||||
|
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||||
|
isOwnMessage: (ChatMessage) -> Boolean,
|
||||||
|
messageAgeSeconds: (ChatMessage) -> Long,
|
||||||
|
) {
|
||||||
|
SplitterPane(
|
||||||
|
state = state.messageSplitter,
|
||||||
|
orientation = SplitterOrientation.VERTICAL,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
panes = listOf(
|
||||||
|
{
|
||||||
|
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||||
|
DataTableView(
|
||||||
|
state = state.directedMessages,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
rowStyle = { message ->
|
||||||
|
DirectedMessageRowStyle.accentFor(
|
||||||
|
ownMessage = isOwnMessage(message),
|
||||||
|
ageSeconds = messageAgeSeconds(message),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onRowClick = onDirectedMessageClicked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ShortcutButtonRow(state.shortcuts.entries, state.chatInput) },
|
||||||
|
{
|
||||||
|
TimelineView(
|
||||||
|
state = state.timeline,
|
||||||
|
onCandidateClicked = onCandidateClicked,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ChatInputRow(
|
||||||
|
state = state.chatInput,
|
||||||
|
prefs = state.prefs,
|
||||||
|
ownQrgMain = state.ownQrgMain,
|
||||||
|
ownQrgSecond = state.ownQrgSecond,
|
||||||
|
antennaQtf = state.antennaQtf,
|
||||||
|
antennaQtf2 = state.antennaQtf2,
|
||||||
|
so2rEnabled = state.surroundings.so2rEnabled,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||||
|
MessageTabs(
|
||||||
|
publicMessages = state.publicMessages,
|
||||||
|
clusterMessages = state.clusterMessages,
|
||||||
|
qsoOfTheOther = state.qsoOfTheOther,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right column: who is on, who to work next, and everything about the one selected.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StationColumn(
|
||||||
|
state: MainWindowState,
|
||||||
|
skedBands: List<Band>,
|
||||||
|
openInBrowser: Consumer<String>,
|
||||||
|
onShowOnMap: () -> Unit,
|
||||||
|
onShowPathInAirScout: () -> Unit,
|
||||||
|
onStationSelected: (ChatMember) -> Unit,
|
||||||
|
onFiltersChanged: () -> Unit,
|
||||||
|
onShowMorePriorities: () -> Unit,
|
||||||
|
) {
|
||||||
|
SplitterPane(
|
||||||
|
state = state.rightSplitter,
|
||||||
|
orientation = SplitterOrientation.VERTICAL,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
panes = listOf(
|
||||||
|
{
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
StationFilterBar(
|
||||||
|
filters = state.stationFilter,
|
||||||
|
activeBands = state.activeBands,
|
||||||
|
onChanged = {
|
||||||
|
onFiltersChanged()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
|
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||||
|
DataTableView(
|
||||||
|
state = state.stations,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onRowClick = onStationSelected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TopPriorityBar(
|
||||||
|
state = state.topPriority,
|
||||||
|
onShowMore = onShowMorePriorities,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = state.selectedStation,
|
||||||
|
messages = state.selectedStationMessages,
|
||||||
|
skedBands = skedBands,
|
||||||
|
activeBands = state.activeBands,
|
||||||
|
openInBrowser = openInBrowser,
|
||||||
|
onShowOnMap = onShowOnMap,
|
||||||
|
onShowPathInAirScout = onShowPathInAirScout,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
|
||||||
|
/** How big the main window opens. */
|
||||||
|
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame around the main window: the size it opens at and what its title says.
|
||||||
|
*
|
||||||
|
* Both are arithmetic and string building rather than drawing, which is why they live
|
||||||
|
* apart from the window itself and are covered by tests instead of by looking.
|
||||||
|
*/
|
||||||
|
object MainWindowFrame {
|
||||||
|
|
||||||
|
private const val FALLBACK_HEIGHT = 768.0
|
||||||
|
private const val FALLBACK_WIDTH = 1234.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room left for the native window decoration and the screen edge. The JavaFX scene
|
||||||
|
* size did not include the decoration, and Compose's window size does not include
|
||||||
|
* everything a tiling compositor puts around it either.
|
||||||
|
*/
|
||||||
|
private const val SCREEN_MARGIN = 40.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remembered size, never larger than the screen it is opening on.
|
||||||
|
*
|
||||||
|
* The clamp is the point: a window remembered from a larger monitor opens with its
|
||||||
|
* buttons past the edge and no way to reach them. Each dimension falls back on its
|
||||||
|
* own, because half a stored size is still half a usable one.
|
||||||
|
*/
|
||||||
|
fun startupSize(stored: DoubleArray?, screenHeight: Double, screenWidth: Double): MainWindowSize {
|
||||||
|
var height = FALLBACK_HEIGHT
|
||||||
|
var width = FALLBACK_WIDTH
|
||||||
|
|
||||||
|
if (stored != null && stored.size >= 2) {
|
||||||
|
if (stored[0].isFinite() && stored[0] > 0) height = stored[0]
|
||||||
|
if (stored[1].isFinite() && stored[1] > 0) width = stored[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
val availableHeight = (screenHeight - SCREEN_MARGIN).coerceAtLeast(1.0)
|
||||||
|
val availableWidth = (screenWidth - SCREEN_MARGIN).coerceAtLeast(1.0)
|
||||||
|
|
||||||
|
return MainWindowSize(
|
||||||
|
heightDp = minOf(height, availableHeight),
|
||||||
|
widthDp = minOf(width, availableWidth),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window title: what the chat is doing, and which profile, when it is not the
|
||||||
|
* only one. Two profiles open side by side have to be tellable apart from the
|
||||||
|
* taskbar.
|
||||||
|
*/
|
||||||
|
fun title(chatState: String, profileName: String?): String =
|
||||||
|
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the title's first half says.
|
||||||
|
*
|
||||||
|
* The live connection line while the chat is usable -- exactly what the JavaFX title
|
||||||
|
* carried -- and a wording about the attempt otherwise. The connection manager's own
|
||||||
|
* detail ("session is authenticated and synchronized") is a diagnostic for the status
|
||||||
|
* bar, not a title: it says nothing about category, call or traffic.
|
||||||
|
*
|
||||||
|
* @param state the connection state, null before the first push
|
||||||
|
* @param onlineLine the line built by [onlineChatState], asked for only while online
|
||||||
|
* @return the chat half of the title
|
||||||
|
*/
|
||||||
|
fun chatState(state: On4KstConnectionState?, onlineLine: () -> String): String =
|
||||||
|
if (state == On4KstConnectionState.ONLINE) onlineLine() else offlineChatState(state)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connection line while the chat is usable, as the JavaFX title had it:
|
||||||
|
* "Connected to: 2: 144/432 MHz as DO1JKS (Johannes 70/2) in JN59MU (106 users online,
|
||||||
|
* 106 shown), 14 messages total."
|
||||||
|
*
|
||||||
|
* Counts are passed as text so MessageFormat does not group them by locale. Without a
|
||||||
|
* main category there is nothing to name, so the line falls back to the offline wording
|
||||||
|
* for ONLINE instead of reading "Connected to: as ...".
|
||||||
|
*
|
||||||
|
* @param mainCategory the main chat category, null when none is configured
|
||||||
|
* @param secondCategory the second category, null when it is not logged in
|
||||||
|
* @param callSign the login callsign
|
||||||
|
* @param name the login name of the main category
|
||||||
|
* @param locator the login locator
|
||||||
|
* @param usersOnline every station in the chat
|
||||||
|
* @param shown the stations the filters let through
|
||||||
|
* @param messagesTotal every chat message held, the global list behind the public tab
|
||||||
|
*/
|
||||||
|
fun onlineChatState(
|
||||||
|
mainCategory: ChatCategory?,
|
||||||
|
secondCategory: ChatCategory?,
|
||||||
|
callSign: String?,
|
||||||
|
name: String?,
|
||||||
|
locator: String?,
|
||||||
|
usersOnline: Int,
|
||||||
|
shown: Int,
|
||||||
|
messagesTotal: Int,
|
||||||
|
): String {
|
||||||
|
if (mainCategory == null) {
|
||||||
|
return offlineChatState(On4KstConnectionState.ONLINE)
|
||||||
|
}
|
||||||
|
val strings = kst4contest.view.i18n.CurrentStrings.get()
|
||||||
|
val categories = if (secondCategory == null) {
|
||||||
|
mainCategory.toString()
|
||||||
|
} else {
|
||||||
|
strings.titleBothCategories(mainCategory.toString(), secondCategory.toString())
|
||||||
|
}
|
||||||
|
return strings.titleOnline(
|
||||||
|
categories,
|
||||||
|
callSign.orEmpty(),
|
||||||
|
name.orEmpty(),
|
||||||
|
locator.orEmpty(),
|
||||||
|
usersOnline.toString(),
|
||||||
|
shown.toString(),
|
||||||
|
messagesTotal.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the title says while the chat is not usable.
|
||||||
|
*
|
||||||
|
* The wording separates a scheduled reconnect from a dead session, which is the
|
||||||
|
* difference between waiting and doing something about it.
|
||||||
|
*/
|
||||||
|
fun offlineChatState(state: On4KstConnectionState?): String =
|
||||||
|
when (state ?: On4KstConnectionState.DISCONNECTED) {
|
||||||
|
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
|
||||||
|
On4KstConnectionState.CONNECTING -> kst4contest.view.i18n.CurrentStrings.get().titleConnecting
|
||||||
|
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
|
||||||
|
On4KstConnectionState.AUTHENTICATING -> kst4contest.view.i18n.CurrentStrings.get().titleAuthenticating
|
||||||
|
On4KstConnectionState.SYNCING_MAIN_CHAT,
|
||||||
|
On4KstConnectionState.SYNCING_SECOND_CHAT -> kst4contest.view.i18n.CurrentStrings.get().titleSynchronizing
|
||||||
|
else -> kst4contest.view.i18n.CurrentStrings.get().titleDisconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import java.util.function.Consumer
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the Compose main window.
|
||||||
|
*
|
||||||
|
* Deliberately a sibling of the JavaFX window rather than its replacement for now: the two
|
||||||
|
* are meant to run side by side so the operator can compare them against a live session,
|
||||||
|
* which is the only way the differences of the last three Etappen came to light.
|
||||||
|
*/
|
||||||
|
object MainWindowHost {
|
||||||
|
|
||||||
|
private val host = ComposeWindowHost("main-window", isMainWindow = true)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
val isOpen: Boolean
|
||||||
|
get() = host.isOpen
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun close() = host.close()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param state everything the window draws; replacing it is what a profile switch will be
|
||||||
|
* @param profileName the profile part of the title; the chat part is read from the state
|
||||||
|
*/
|
||||||
|
private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null)
|
||||||
|
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(null)
|
||||||
|
private var currentSkedBands by androidx.compose.runtime.mutableStateOf<(() -> List<Band>)?>(null)
|
||||||
|
private var currentOpenInBrowser by androidx.compose.runtime.mutableStateOf<Consumer<String>?>(null)
|
||||||
|
private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||||
|
private var currentOnShowPathInAirScout by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||||
|
private var currentOnStationSelected by androidx.compose.runtime.mutableStateOf<((ChatMember) -> Unit)?>(null)
|
||||||
|
private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
|
||||||
|
private var currentIsOwnMessage by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Boolean)?>(null)
|
||||||
|
private var currentMessageAgeSeconds by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Long)?>(null)
|
||||||
|
private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||||
|
private var currentOnShowMorePriorities by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||||
|
private var currentOnCandidateClicked by androidx.compose.runtime.mutableStateOf<((TimelineCandidate) -> Unit)?>(null)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun show(
|
||||||
|
state: MainWindowState,
|
||||||
|
actions: MainMenuActions,
|
||||||
|
/**
|
||||||
|
* The profile name for the title, or null for the only profile. The rest of the
|
||||||
|
* title is what the chat is doing, and that is read from the state during
|
||||||
|
* composition -- as the JavaFX window did, where the title carried the whole
|
||||||
|
* connection line.
|
||||||
|
*/
|
||||||
|
profileName: String?,
|
||||||
|
darkMode: Boolean,
|
||||||
|
baseFontSizeSp: Float,
|
||||||
|
/**
|
||||||
|
* The palette of the active profile, handed to the theme. Null draws the shipped
|
||||||
|
* palette.
|
||||||
|
*/
|
||||||
|
paletteStore: PaletteStore? = null,
|
||||||
|
/**
|
||||||
|
* The interface language of the active profile, handed to the window. Null draws the
|
||||||
|
* base language.
|
||||||
|
*/
|
||||||
|
languageStore: kst4contest.view.i18n.LanguageStore? = null,
|
||||||
|
widthDp: Float,
|
||||||
|
heightDp: Float,
|
||||||
|
onResized: (Float, Float) -> Unit,
|
||||||
|
/**
|
||||||
|
* Runs the application's quit flow. This is the main window: its X — and a tiling
|
||||||
|
* window manager's close, the same request — has to ask whether to disconnect and
|
||||||
|
* then end the process, the way the File menu's Exit does. Closing it must not
|
||||||
|
* leave the client running with the chat connected and nothing to see it in.
|
||||||
|
*/
|
||||||
|
onCloseRequest: () -> Unit,
|
||||||
|
skedBands: () -> List<Band>,
|
||||||
|
openInBrowser: Consumer<String>,
|
||||||
|
onShowOnMap: () -> Unit,
|
||||||
|
onShowPathInAirScout: () -> Unit,
|
||||||
|
onStationSelected: (ChatMember) -> Unit,
|
||||||
|
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||||
|
isOwnMessage: (ChatMessage) -> Boolean,
|
||||||
|
messageAgeSeconds: (ChatMessage) -> Long,
|
||||||
|
onFiltersChanged: () -> Unit,
|
||||||
|
onShowMorePriorities: () -> Unit,
|
||||||
|
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||||
|
) {
|
||||||
|
currentState = state
|
||||||
|
currentActions = actions
|
||||||
|
currentSkedBands = skedBands
|
||||||
|
currentOpenInBrowser = openInBrowser
|
||||||
|
currentOnShowOnMap = onShowOnMap
|
||||||
|
currentOnShowPathInAirScout = onShowPathInAirScout
|
||||||
|
currentOnStationSelected = onStationSelected
|
||||||
|
currentOnDirectedMessageClicked = onDirectedMessageClicked
|
||||||
|
currentIsOwnMessage = isOwnMessage
|
||||||
|
currentMessageAgeSeconds = messageAgeSeconds
|
||||||
|
currentOnFiltersChanged = onFiltersChanged
|
||||||
|
currentOnShowMorePriorities = onShowMorePriorities
|
||||||
|
currentOnCandidateClicked = onCandidateClicked
|
||||||
|
|
||||||
|
host.show(
|
||||||
|
title = {
|
||||||
|
/*
|
||||||
|
* Read inside the composition, so the title follows the connection the way
|
||||||
|
* the JavaFX one did: "Connected to: 2: 144/432 MHz as DN5PW in JO50JP
|
||||||
|
* (73 users online, 73 shown), 29 messages total." A captured string would
|
||||||
|
* say "disconnected" for the life of the session. The counts are Compose
|
||||||
|
* state, so the title moves with every station and message. visibleRows
|
||||||
|
* costs nothing extra: it is cached per change, and the station table
|
||||||
|
* reads the same list anyway.
|
||||||
|
*/
|
||||||
|
val state = currentState
|
||||||
|
MainWindowFrame.title(
|
||||||
|
chatState = if (state == null) {
|
||||||
|
MainWindowFrame.offlineChatState(null)
|
||||||
|
} else MainWindowFrame.chatState(state.surroundings.connectionState) {
|
||||||
|
val prefs = state.prefs
|
||||||
|
MainWindowFrame.onlineChatState(
|
||||||
|
mainCategory = prefs.getLoginChatCategoryMain(),
|
||||||
|
secondCategory = prefs.getLoginChatCategorySecond()
|
||||||
|
.takeIf { prefs.isLoginToSecondChatEnabled() },
|
||||||
|
callSign = prefs.getStn_loginCallSign(),
|
||||||
|
name = prefs.getStn_loginNameMainCat(),
|
||||||
|
locator = prefs.getStn_loginLocatorMainCat(),
|
||||||
|
usersOnline = state.stations.rows.size,
|
||||||
|
shown = state.stations.visibleRows.size,
|
||||||
|
messagesTotal = state.publicMessages.rows.size,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
profileName = profileName,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
darkMode = darkMode,
|
||||||
|
baseFontSizeSp = baseFontSizeSp,
|
||||||
|
paletteStore = paletteStore,
|
||||||
|
languageStore = languageStore,
|
||||||
|
widthDp = widthDp,
|
||||||
|
heightDp = heightDp,
|
||||||
|
onResized = onResized,
|
||||||
|
onCloseRequest = onCloseRequest,
|
||||||
|
) { _ ->
|
||||||
|
val s = currentState ?: return@show
|
||||||
|
val a = currentActions ?: return@show
|
||||||
|
MainWindow(
|
||||||
|
state = s,
|
||||||
|
actions = a,
|
||||||
|
skedBands = currentSkedBands?.invoke() ?: emptyList(),
|
||||||
|
openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
|
||||||
|
onShowOnMap = currentOnShowOnMap ?: {},
|
||||||
|
onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
|
||||||
|
onStationSelected = currentOnStationSelected ?: {},
|
||||||
|
onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
|
||||||
|
isOwnMessage = currentIsOwnMessage ?: { false },
|
||||||
|
messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
|
||||||
|
onFiltersChanged = currentOnFiltersChanged ?: {},
|
||||||
|
onShowMorePriorities = currentOnShowMorePriorities ?: {},
|
||||||
|
onCandidateClicked = currentOnCandidateClicked ?: {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.model.ClusterMessage
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.observe.SimpleRoster
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the main window draws, in one object.
|
||||||
|
*
|
||||||
|
* This is the shape the profile switch needs. Today a switch tears the runtime down and
|
||||||
|
* builds a new Kst4ContestApplication, because the controls are inline-created instance
|
||||||
|
* fields and there is no other way to be rid of them. Once the window is a pure function
|
||||||
|
* of this object, switching profiles is replacing it — the reason the JavaFX version had
|
||||||
|
* to restart itself is gone.
|
||||||
|
*
|
||||||
|
* Nothing here holds a window or a thread; those belong to the runtime that builds this. It
|
||||||
|
* does hold three preference listeners, which is why [release] exists and has to be called
|
||||||
|
* when the state is dropped.
|
||||||
|
*/
|
||||||
|
class MainWindowState(
|
||||||
|
val activeBands: Set<kst4contest.model.Band>,
|
||||||
|
val prefs: ChatPreferences,
|
||||||
|
|
||||||
|
/** The station list with its filters: the operator's main working surface. */
|
||||||
|
val stations: DataTableState<ChatMember>,
|
||||||
|
val stationFilter: StationFilterState,
|
||||||
|
|
||||||
|
/** Messages addressed to this station, in their own pane above the send line. */
|
||||||
|
val directedMessages: DataTableState<ChatMessage>,
|
||||||
|
|
||||||
|
/** The three tabs at the bottom. */
|
||||||
|
val publicMessages: DataTableState<ChatMessage>,
|
||||||
|
val clusterMessages: DataTableState<ClusterMessage>,
|
||||||
|
val qsoOfTheOther: DataTableState<ChatMessage>,
|
||||||
|
|
||||||
|
val selectedStationMessages: DataTableState<ChatMessage>,
|
||||||
|
val selectedStation: SelectedStationState,
|
||||||
|
val topPriority: TopPriorityState,
|
||||||
|
val timeline: TimelineState,
|
||||||
|
val chatInput: ChatInputState,
|
||||||
|
val menu: MainMenuState,
|
||||||
|
|
||||||
|
/** The link and the other windows, pushed in because none of it is Compose state at source. */
|
||||||
|
val surroundings: MainWindowSurroundings,
|
||||||
|
|
||||||
|
/** The status indicators along the top. */
|
||||||
|
val skedNotice: BlinkingNotice,
|
||||||
|
val bandUpgradeNotice: BlinkingNotice,
|
||||||
|
val threadButtons: ThreadStatusButtons,
|
||||||
|
|
||||||
|
/** The three splitters, whose positions the operator has arranged and stored. */
|
||||||
|
val outerSplitter: SplitterState,
|
||||||
|
val messageSplitter: SplitterState,
|
||||||
|
val rightSplitter: SplitterState,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** The shortcut buttons above the send line. */
|
||||||
|
/**
|
||||||
|
* The one path that selects a station for the whole application (panel, table, score
|
||||||
|
* service, send line). Set by the host after construction; other windows, such as the
|
||||||
|
* map, go through it instead of touching [selectedStation] alone. Null clears.
|
||||||
|
*/
|
||||||
|
var stationSelector: ((ChatMember?) -> Unit)? = null
|
||||||
|
|
||||||
|
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
||||||
|
|
||||||
|
/** The snippets behind Ctrl+1..Ctrl+0 and the right-click menus. */
|
||||||
|
val snippets = RosterMirror(prefs.getLst_txtSnipList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four small fields beside the send controls. Mirrored rather than read straight
|
||||||
|
* from the preferences, because all of them move without the operator touching them: the
|
||||||
|
* rotator writes the heading, and the frequency follower writes the own QRG.
|
||||||
|
*/
|
||||||
|
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
|
||||||
|
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
|
||||||
|
val antennaQtf = ObservedValue(prefs.getActualQTF())
|
||||||
|
val antennaQtf2 = ObservedValue(prefs.getActualQTF2())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops following the preferences. Called when this state is dropped — a profile switch
|
||||||
|
* builds a new window over the same preferences, and a leaked follower keeps the old
|
||||||
|
* one alive and writing.
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
ownQrgMain.release()
|
||||||
|
ownQrgSecond.release()
|
||||||
|
antennaQtf.release()
|
||||||
|
antennaQtf2.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/** The five panes of the message column, in the order they are stacked. */
|
||||||
|
const val MESSAGE_PANE_COUNT = 5
|
||||||
|
|
||||||
|
/** Station list, top priorities, selected station. */
|
||||||
|
const val RIGHT_PANE_COUNT = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A roster from the preferences, mirrored into Compose state.
|
||||||
|
*
|
||||||
|
* SimpleRoster notifies its own listeners, none of which Compose can see, so a view drawn
|
||||||
|
* straight from one would render once and then go stale — the failure mode that produced
|
||||||
|
* most of this migration's invisible defects. The mirror is what the settings window's
|
||||||
|
* refresh hooks have been calling into thin air since Etappe 3b.
|
||||||
|
*/
|
||||||
|
class RosterMirror(private val roster: SimpleRoster<String>?) {
|
||||||
|
|
||||||
|
private val live = mutableStateListOf<String>().apply {
|
||||||
|
addAll(roster?.snapshot().orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a view reads; a read here is a subscription. */
|
||||||
|
val entries: List<String> get() = live
|
||||||
|
|
||||||
|
/** Catches the mirror up with the roster. Called after every committed edit. */
|
||||||
|
fun refresh() {
|
||||||
|
val current = roster?.snapshot().orEmpty()
|
||||||
|
if (current == live.toList()) return
|
||||||
|
live.clear()
|
||||||
|
live.addAll(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the window shows about its surroundings: the link, and which other windows are open.
|
||||||
|
*
|
||||||
|
* None of this is Compose state at its source — the connection state lives in the controller,
|
||||||
|
* the window flags in AtomicBooleans inside the hosts — so it is pushed in here instead of
|
||||||
|
* being read through a lambda at composition time. Read that way it would be drawn once: the
|
||||||
|
* badge frozen on whatever the link was when the window opened, and the menu's "hide options"
|
||||||
|
* saying the wrong thing for the rest of the session.
|
||||||
|
*
|
||||||
|
* @param menu kept in step with the connection state, because the badge and the menu are two
|
||||||
|
* renderings of one fact and an operator comparing them must not have to choose
|
||||||
|
*/
|
||||||
|
class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState()) {
|
||||||
|
|
||||||
|
private val liveConnectionState =
|
||||||
|
mutableStateOf(On4KstConnectionState.DISCONNECTED)
|
||||||
|
|
||||||
|
var connectionState: On4KstConnectionState
|
||||||
|
get() = liveConnectionState.value
|
||||||
|
set(value) {
|
||||||
|
liveConnectionState.value = value
|
||||||
|
menu.connectionState = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectionDetail: String by mutableStateOf(kst4contest.view.i18n.CurrentStrings.get().statusNoConnection)
|
||||||
|
|
||||||
|
var settingsWindowOpen: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
var monitorWindowOpen: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether SO2R is switched on. Compose state, because the preference is not: the
|
||||||
|
* application pushes the stored value in at start-up and after every settings Apply.
|
||||||
|
*/
|
||||||
|
var so2rEnabled: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
/** SO2R with rotator 2 enabled, i.e. antenna 2 can be turned; pushed in like [so2rEnabled]. */
|
||||||
|
var secondAntennaOffered: Boolean by mutableStateOf(false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.controller.Utils4KST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the message tables show what arrives off the wire.
|
||||||
|
*
|
||||||
|
* Shared rather than repeated: the same value appears in the monitor window and in the
|
||||||
|
* tabs below the station list, and two tables of the same data disagreeing about how
|
||||||
|
* they show it is worse than either format on its own.
|
||||||
|
*/
|
||||||
|
internal object MessageFormats {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The epoch seconds off the wire as a clock time, the way the JavaFX cells showed
|
||||||
|
* them. An unreadable value is shown as it came: an operator seeing the raw text
|
||||||
|
* learns more than one seeing an empty cell.
|
||||||
|
*/
|
||||||
|
fun clockTime(epochFromServer: String?): String {
|
||||||
|
val raw = epochFromServer?.trim().orEmpty()
|
||||||
|
|
||||||
|
if (raw.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching { Utils4KST().time_convertEpochToReadable(raw) }.getOrDefault(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three decimal places, as `formatQrgForUi` pads them in the main window. The same
|
||||||
|
* column appears in both windows, and two tables of the same data must not
|
||||||
|
* disagree about how they show it.
|
||||||
|
*/
|
||||||
|
fun paddedQrg(raw: String): String {
|
||||||
|
val text = raw.trim().replace(',', '.')
|
||||||
|
|
||||||
|
if (text.isEmpty() || text.toDoubleOrNull() == null) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
val dot = text.indexOf('.')
|
||||||
|
|
||||||
|
if (dot < 0) {
|
||||||
|
return text + ".000"
|
||||||
|
}
|
||||||
|
|
||||||
|
val decimals = text.length - dot - 1
|
||||||
|
|
||||||
|
return if (decimals >= 3) text else text + "0".repeat(3 - decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.view.i18n.LocalStrings
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.model.ClusterMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three tabs below the station list.
|
||||||
|
*
|
||||||
|
* Two of them are the monitor window's tables under their own layout ids — the same
|
||||||
|
* columns, separate stored widths, which is what PROJECT_CONTEXT requires. Only the
|
||||||
|
* public message tab is its own.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MessageTabs(
|
||||||
|
publicMessages: DataTableState<ChatMessage>,
|
||||||
|
clusterMessages: DataTableState<ClusterMessage>,
|
||||||
|
qsoOfTheOther: DataTableState<ChatMessage>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var selected by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
val titles = listOf(
|
||||||
|
strings.messagesPublic,
|
||||||
|
strings.messagesDxCluster,
|
||||||
|
strings.messagesQsoOfTheOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||||
|
titles.forEachIndexed { index, title ->
|
||||||
|
val isSelected = index == selected
|
||||||
|
val underline = MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(min = Density.TAB_MIN_HEIGHT)
|
||||||
|
.background(
|
||||||
|
if (isSelected) MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else Color.Transparent
|
||||||
|
)
|
||||||
|
.clickable { selected = index }
|
||||||
|
.drawBehind {
|
||||||
|
if (isSelected) {
|
||||||
|
val thickness = 2.dp.toPx()
|
||||||
|
drawRect(
|
||||||
|
color = underline,
|
||||||
|
topLeft = Offset(0f, size.height - thickness),
|
||||||
|
size = Size(size.width, thickness),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 5.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
when (selected) {
|
||||||
|
0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize())
|
||||||
|
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
|
||||||
|
else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.controller.Utils4KST
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.model.ClusterMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns of the two monitor tables.
|
||||||
|
*
|
||||||
|
* The ids are not free to choose: they are part of the operator's stored layout, so
|
||||||
|
* renaming one loses their column widths without saying so. They are kept exactly as
|
||||||
|
* `TableLayoutManager.column(...)` registered them.
|
||||||
|
*
|
||||||
|
* Every value survives a half-filled message. These tables are fed straight from the
|
||||||
|
* wire, a sender or receiver can be missing, and a NullPointerException in a live
|
||||||
|
* table during a contest is the worst possible moment for one.
|
||||||
|
*/
|
||||||
|
object MonitorColumns {
|
||||||
|
|
||||||
|
fun dxCluster(): List<DataColumn<ClusterMessage>> = listOf(
|
||||||
|
DataColumn(id = "time", title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.timeGenerated) }, weight = 0.7f),
|
||||||
|
DataColumn(id = "call-tx", title = "Call tx", value = { callSignOf(it.sender) }),
|
||||||
|
DataColumn(id = "locator-tx", title = "LOC tx", value = { qraOf(it.sender) }, weight = 0.7f),
|
||||||
|
DataColumn(id = "call-rx", title = "Call rx", value = { callSignOf(it.receiver) }),
|
||||||
|
DataColumn(id = "locator-rx", title = "LOC rx", value = { qraOf(it.receiver) }, weight = 0.7f),
|
||||||
|
DataColumn(id = "qrg", title = "QRG", value = { MessageFormats.paddedQrg(frequencyOf(it.receiver)) }, weight = 0.8f),
|
||||||
|
DataColumn(id = "message", title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = { it.messageInhibited.orEmpty() }, weight = 2.5f),
|
||||||
|
/* "X" and not "true": the JavaFX cell printed a cross. */
|
||||||
|
DataColumn(id = "worked", title = "wkd", value = { if (it.isReceiverWkd) "X" else "" }, weight = 0.4f),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nine columns, not ten. The JavaFX method built a "Name" column and never added
|
||||||
|
* it to the table; porting it would put a column on screen that release 1.49
|
||||||
|
* never showed.
|
||||||
|
*/
|
||||||
|
fun qsoOfTheOther(): List<DataColumn<ChatMessage>> = listOf(
|
||||||
|
DataColumn(id = "time", title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f),
|
||||||
|
DataColumn(id = "call-tx", title = "Call TX", value = { callSignOf(it.sender) }),
|
||||||
|
DataColumn(id = "last-qrg-tx", title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgTx }, value = { frequencyOf(it.sender) }, weight = 0.8f),
|
||||||
|
DataColumn(id = "worked-tx", title = "wkd TX?", value = { workedMark(it.sender) }, weight = 0.4f),
|
||||||
|
DataColumn(id = "call-rx", title = "Call RX", value = { callSignOf(it.receiver) }),
|
||||||
|
DataColumn(id = "last-qrg-rx", title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgRx }, value = { frequencyOf(it.receiver) }, weight = 0.8f),
|
||||||
|
DataColumn(id = "worked-rx", title = "wkd RX?", value = { workedMark(it.receiver) }, weight = 0.4f),
|
||||||
|
DataColumn(id = "message", title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = { it.messageText.orEmpty() }, weight = 2.5f),
|
||||||
|
/*
|
||||||
|
* From the sender, not from the message. Both carry a field of that name and
|
||||||
|
* they are set on different parsing paths, so they are not always the same
|
||||||
|
* value; the JavaFX cell read the sender's.
|
||||||
|
*/
|
||||||
|
DataColumn(
|
||||||
|
id = "category",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
|
||||||
|
value = { message ->
|
||||||
|
message.sender?.chatCategory
|
||||||
|
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||||
|
.orEmpty()
|
||||||
|
},
|
||||||
|
weight = 1.2f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun callSignOf(member: ChatMember?): String = member?.callSign.orEmpty()
|
||||||
|
|
||||||
|
internal fun qraOf(member: ChatMember?): String = member?.qra.orEmpty()
|
||||||
|
|
||||||
|
internal fun frequencyOf(member: ChatMember?): String = member?.frequency?.get().orEmpty()
|
||||||
|
|
||||||
|
private fun workedMark(member: ChatMember?): String =
|
||||||
|
if (member != null && member.isWorked) "X" else ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.model.ClusterMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cluster and QSO monitor.
|
||||||
|
*
|
||||||
|
* Two live tables, one above the other, with a divider the operator can drag. This
|
||||||
|
* is the first window in which the table carries data that changes while the operator
|
||||||
|
* watches, which is why the row identity rules in DataTableState matter here and not in
|
||||||
|
* the settings window: in Etappe 2 the visible defects all came from that coupling, not
|
||||||
|
* from the table itself.
|
||||||
|
*/
|
||||||
|
object MonitorWindow {
|
||||||
|
|
||||||
|
private val host = ComposeWindowHost("monitor-window")
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
val isOpen: Boolean
|
||||||
|
get() = host.isOpen
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun close() = host.close()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param clusterTable fed from `getLst_clusterMemberList`
|
||||||
|
* @param qsoTable fed from `toOtherMessages` over the global chat message list
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun show(
|
||||||
|
clusterTable: DataTableState<ClusterMessage>,
|
||||||
|
qsoTable: DataTableState<ChatMessage>,
|
||||||
|
/** The divider between the two tables, remembered in the profile (#144). */
|
||||||
|
splitter: SplitterState,
|
||||||
|
darkMode: Boolean,
|
||||||
|
baseFontSizeSp: Float,
|
||||||
|
/**
|
||||||
|
* The palette of the active profile, handed to the theme. Null draws the shipped
|
||||||
|
* palette.
|
||||||
|
*/
|
||||||
|
paletteStore: PaletteStore? = null,
|
||||||
|
/**
|
||||||
|
* The interface language of the active profile, handed to the window. Null draws the
|
||||||
|
* base language.
|
||||||
|
*/
|
||||||
|
languageStore: kst4contest.view.i18n.LanguageStore? = null,
|
||||||
|
widthDp: Float,
|
||||||
|
heightDp: Float,
|
||||||
|
onResized: (Float, Float) -> Unit,
|
||||||
|
) {
|
||||||
|
host.show(
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().monitorTitle },
|
||||||
|
darkMode = darkMode,
|
||||||
|
baseFontSizeSp = baseFontSizeSp,
|
||||||
|
paletteStore = paletteStore,
|
||||||
|
languageStore = languageStore,
|
||||||
|
widthDp = widthDp,
|
||||||
|
heightDp = heightDp,
|
||||||
|
onResized = onResized,
|
||||||
|
) { _ ->
|
||||||
|
MonitorContent(clusterTable, qsoTable, splitter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal rather than private so the window's body can be drawn headless in a test. */
|
||||||
|
@Composable
|
||||||
|
internal fun MonitorContent(
|
||||||
|
clusterTable: DataTableState<ClusterMessage>,
|
||||||
|
qsoTable: DataTableState<ChatMessage>,
|
||||||
|
splitter: SplitterState,
|
||||||
|
) {
|
||||||
|
val strings = kst4contest.view.i18n.LocalStrings.current
|
||||||
|
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
/*
|
||||||
|
* Each heading sits in the pane of its table, so dragging the divider moves the
|
||||||
|
* heading with the table it names rather than leaving it stranded above the other.
|
||||||
|
*/
|
||||||
|
SplitterPane(
|
||||||
|
state = splitter,
|
||||||
|
orientation = SplitterOrientation.VERTICAL,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||||
|
panes = listOf(
|
||||||
|
{ MonitorPane(strings.monitorDxCluster, clusterTable) },
|
||||||
|
{ MonitorPane(strings.messagesQsoOfTheOther, qsoTable) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> MonitorPane(heading: String, table: DataTableState<T>) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Text(heading, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp))
|
||||||
|
DataTableView(table, modifier = Modifier.weight(1f).fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kst4contest.observe.ObservableValue
|
||||||
|
import java.util.function.Consumer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An [ObservableValue] from the domain layer, mirrored into Compose state.
|
||||||
|
*
|
||||||
|
* The domain's values notify their own listeners, none of which Compose can see, so a view
|
||||||
|
* drawn straight from one renders once and then silently goes stale. Three of them move on
|
||||||
|
* their own while the operator watches: the rotator writes the antenna heading, and the
|
||||||
|
* frequency follower writes the operator's own QRG.
|
||||||
|
*
|
||||||
|
* The JavaFX window did exactly this with attachQtfFollower() and attachOwnQrgFollower(),
|
||||||
|
* ending in a TextField; this one ends in Compose state.
|
||||||
|
*
|
||||||
|
* [release] must be called when the mirror is dropped. A profile switch builds a new window
|
||||||
|
* over the same preferences, and a leaked listener keeps the old one alive and writing.
|
||||||
|
*/
|
||||||
|
class ObservedValue<T>(private val source: ObservableValue<T>) {
|
||||||
|
|
||||||
|
var value: T? by mutableStateOf(source.get())
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val follower = Consumer<T> { updated -> value = updated }
|
||||||
|
|
||||||
|
init {
|
||||||
|
source.addListener(follower)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
source.removeListener(follower)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.awt.ComposeDialog
|
||||||
|
import kst4contest.model.OperatorProfile
|
||||||
|
import java.awt.Dialog
|
||||||
|
import java.awt.Dimension
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.util.Optional
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.swing.WindowConstants
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text the JavaFX dialog showed below a profile name. Kept as a function so
|
||||||
|
* the wording and the rule behind it stay covered by a test: the root profile and
|
||||||
|
* a sharing profile both use the installation's shared worked database.
|
||||||
|
*/
|
||||||
|
internal fun workedDatabaseDescription(profile: OperatorProfile): String =
|
||||||
|
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
|
||||||
|
"shared station worked database"
|
||||||
|
} else {
|
||||||
|
"own worked database"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection state of the operator profile picker, free of Compose so the rules
|
||||||
|
* are testable without a toolkit.
|
||||||
|
*/
|
||||||
|
class OperatorProfilePickerState(
|
||||||
|
val profiles: List<OperatorProfile>,
|
||||||
|
preselectedProfileId: String?,
|
||||||
|
) {
|
||||||
|
var selected: OperatorProfile? =
|
||||||
|
profiles.firstOrNull { it.profileId == preselectedProfileId } ?: profiles.firstOrNull()
|
||||||
|
private set
|
||||||
|
|
||||||
|
val canConfirm: Boolean
|
||||||
|
get() = selected != null
|
||||||
|
|
||||||
|
fun select(profile: OperatorProfile) {
|
||||||
|
if (profiles.any { it.profileId == profile.profileId }) {
|
||||||
|
selected = profiles.first { it.profileId == profile.profileId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the operator which profile to start with.
|
||||||
|
*
|
||||||
|
* Replaces the JavaFX dialog of the same name and keeps its contract: blocks
|
||||||
|
* until the operator decides, returns the chosen profile, or empty when they
|
||||||
|
* want to quit. Kst4ContestApplication uses this as a method reference, so the
|
||||||
|
* signature must not drift.
|
||||||
|
*/
|
||||||
|
object OperatorProfilePickerWindow {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The font size the JavaFX dialog hardcoded. It runs before any profile is
|
||||||
|
* loaded, so there is no configured size to read; using a different one here
|
||||||
|
* would make the dialog look unlike the windows that follow it.
|
||||||
|
*/
|
||||||
|
private const val DIALOG_FONT_SIZE_SP = 12f
|
||||||
|
|
||||||
|
private val DIALOG_SIZE = Dimension(380, 300)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the picker and blocks until the operator decides.
|
||||||
|
*
|
||||||
|
* A modal [ComposeDialog] and NOT the former dedicated thread plus `CountDownLatch`.
|
||||||
|
* That construction deadlocked once the UiDispatcher moved to the AWT event thread:
|
||||||
|
* `ComposeMenuActions.switchOperatorProfile` delivers the File menu's profile switch
|
||||||
|
* there, and skiko's SwingDispatcher posts *every* continuation of `application { }`
|
||||||
|
* — composition, the button callbacks, even the return that would release the latch —
|
||||||
|
* to that same thread, with no `isDispatchNeeded` shortcut. A latch does not pump
|
||||||
|
* events, so the window never appeared and the client froze with no timeout anywhere
|
||||||
|
* in the path. A modal AWT dialog runs a nested event pump instead: it blocks its
|
||||||
|
* caller *and* keeps drawing, which is what `Stage.showAndWait()` did.
|
||||||
|
*
|
||||||
|
* Works from any thread. The startup path calls this from `main` before any window
|
||||||
|
* exists; the File menu calls it from the event thread. Either way the dialog itself is
|
||||||
|
* built and shown ON the event thread — see [openOnEventThread].
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun showAndSelect(
|
||||||
|
selectableProfiles: List<OperatorProfile>,
|
||||||
|
preselectedProfileId: String?,
|
||||||
|
): Optional<OperatorProfile> {
|
||||||
|
val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId)
|
||||||
|
val chosen = AtomicReference<OperatorProfile?>(null)
|
||||||
|
|
||||||
|
openOnEventThread { openPicker(state, chosen) }
|
||||||
|
|
||||||
|
return Optional.ofNullable(chosen.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openPicker(
|
||||||
|
state: OperatorProfilePickerState,
|
||||||
|
chosen: AtomicReference<OperatorProfile?>,
|
||||||
|
) {
|
||||||
|
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
|
||||||
|
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
|
||||||
|
dialog.size = DIALOG_SIZE
|
||||||
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
|
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
|
||||||
|
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||||
|
/*
|
||||||
|
* Raised above everything else. At startup there is nothing to hide behind, but
|
||||||
|
* the File menu opens this over the main window, which is not its owner.
|
||||||
|
*/
|
||||||
|
dialog.isAlwaysOnTop = true
|
||||||
|
|
||||||
|
dialog.setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
|
||||||
|
PickerContent(
|
||||||
|
state = state,
|
||||||
|
onStart = { chosen.set(state.selected); dialog.dispose() },
|
||||||
|
/*
|
||||||
|
* Quit and the window X agree: no choice. Every caller reads an empty
|
||||||
|
* Optional as "the operator does not want to continue", which at
|
||||||
|
* startup ends the process.
|
||||||
|
*/
|
||||||
|
onQuit = { chosen.set(null); dialog.dispose() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PickerContent(
|
||||||
|
state: OperatorProfilePickerState,
|
||||||
|
onStart: () -> Unit,
|
||||||
|
onQuit: () -> Unit,
|
||||||
|
) {
|
||||||
|
var selectedId by mutableStateOf(state.selected?.profileId)
|
||||||
|
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(modifier = Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(kst4contest.view.i18n.CurrentStrings.get().pickerMoreThanOne)
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
items(state.profiles, key = { it.profileId }) { profile ->
|
||||||
|
val isSelected = profile.profileId == selectedId
|
||||||
|
|
||||||
|
Column {
|
||||||
|
/*
|
||||||
|
* Marked the same way as the profiles tab: a frame in the accent
|
||||||
|
* colour over a tinted row. Colouring only the name left the
|
||||||
|
* operator guessing which profile they were about to start.
|
||||||
|
*/
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (isSelected) {
|
||||||
|
Modifier.border(
|
||||||
|
Density.SELECTION_BORDER,
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.selectable(selected = isSelected, onClick = {
|
||||||
|
state.select(profile)
|
||||||
|
selectedId = profile.profileId
|
||||||
|
})
|
||||||
|
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(profile.displayName)
|
||||||
|
Text(
|
||||||
|
workedDatabaseDescription(profile),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSelected) {
|
||||||
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
|
Form.button(kst4contest.view.i18n.CurrentStrings.get().pickerStart, enabled = state.canConfirm, onClick = onStart)
|
||||||
|
Form.button(kst4contest.view.i18n.CurrentStrings.get().pickerQuit, onClick = onQuit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kst4contest.model.PaletteRole
|
||||||
|
|
||||||
|
/** Which of the three layers a colour in force came from. */
|
||||||
|
enum class PaletteSource { SHIPPED, FILE, CHANGED }
|
||||||
|
|
||||||
|
/** One role's colour, and where it came from. */
|
||||||
|
data class ResolvedRole(val colour: Color, val source: PaletteSource)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette in force, and the provenance of every role in it.
|
||||||
|
*
|
||||||
|
* @param roles always all six, so the settings tab can show a source for each
|
||||||
|
* @param palette what the windows draw
|
||||||
|
*/
|
||||||
|
data class ResolvedPalette(
|
||||||
|
val roles: Map<PaletteRole, ResolvedRole>,
|
||||||
|
val palette: JavaFxPalette,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the three layers, role by role: the later layer wins.
|
||||||
|
*
|
||||||
|
* The shipped sheet is the floor and always complete. A file and a change each carry only
|
||||||
|
* the roles they name — which is why [JavaFxStylesheet.declaredRoles] exists rather than
|
||||||
|
* `read`: a file saying only `-fx-base` must override exactly one role.
|
||||||
|
*
|
||||||
|
* Fields that are not roles — the hover gradient, the pressed border, the font size — stay
|
||||||
|
* with the shipped sheet. They are derived values rather than decisions an operator wants to
|
||||||
|
* make one by one.
|
||||||
|
*
|
||||||
|
* The exception is `textAccent`, which follows the accent role whenever that role's colour
|
||||||
|
* actually differs from the shipped one: Material's primary is mapped onto it rather than onto
|
||||||
|
* `accent`, because `-fx-accent` paints only JavaFX's selection bar while the green of
|
||||||
|
* `.text-field .text` is what an operator reads as the accent. Left on the sheet's own value,
|
||||||
|
* a chosen accent would change almost nothing visible; keyed on provenance instead of on the
|
||||||
|
* value, an accent nobody chose would repaint it.
|
||||||
|
*/
|
||||||
|
fun resolvePalette(
|
||||||
|
shipped: JavaFxPalette,
|
||||||
|
fromFile: Map<PaletteRole, Color>,
|
||||||
|
changed: Map<PaletteRole, Color>,
|
||||||
|
): ResolvedPalette {
|
||||||
|
|
||||||
|
val shippedColour = mapOf(
|
||||||
|
PaletteRole.SURFACE to shipped.base,
|
||||||
|
PaletteRole.WINDOW_SURFACE to shipped.windowBackground,
|
||||||
|
PaletteRole.FIELD_INTERIOR to shipped.controlInnerBackground,
|
||||||
|
PaletteRole.TEXT to shipped.labelTextFill,
|
||||||
|
PaletteRole.ACCENT to shipped.accent,
|
||||||
|
PaletteRole.SEPARATOR to shipped.separatorLine,
|
||||||
|
)
|
||||||
|
|
||||||
|
val roles = PaletteRole.values().associateWith { role ->
|
||||||
|
when {
|
||||||
|
changed.containsKey(role) -> ResolvedRole(changed.getValue(role), PaletteSource.CHANGED)
|
||||||
|
fromFile.containsKey(role) -> ResolvedRole(fromFile.getValue(role), PaletteSource.FILE)
|
||||||
|
else -> ResolvedRole(shippedColour.getValue(role), PaletteSource.SHIPPED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun colourOf(role: PaletteRole) = roles.getValue(role).colour
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keyed on the value and deliberately not on the provenance. The root profile's own
|
||||||
|
* stylesheet is the one copyResourceIfRequired writes at startup -- the shipped sheet
|
||||||
|
* itself -- so on every existing installation layer 2 declares the accent without anybody
|
||||||
|
* having chosen anything. Asking "does it come from a file" repainted the evening primary
|
||||||
|
* from green to blue on upgrade, and made resetToShipped disagree with discardChanges
|
||||||
|
* about what shipped looks like.
|
||||||
|
*/
|
||||||
|
val chosenAccent = roles.getValue(PaletteRole.ACCENT).colour.takeIf { it != shipped.accent }
|
||||||
|
|
||||||
|
return ResolvedPalette(
|
||||||
|
roles = roles,
|
||||||
|
palette = shipped.copy(
|
||||||
|
base = colourOf(PaletteRole.SURFACE),
|
||||||
|
windowBackground = colourOf(PaletteRole.WINDOW_SURFACE),
|
||||||
|
controlInnerBackground = colourOf(PaletteRole.FIELD_INTERIOR),
|
||||||
|
labelTextFill = colourOf(PaletteRole.TEXT),
|
||||||
|
accent = colourOf(PaletteRole.ACCENT),
|
||||||
|
textAccent = chosenAccent ?: shipped.textAccent,
|
||||||
|
separatorLine = colourOf(PaletteRole.SEPARATOR),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kst4contest.model.PaletteOverrides
|
||||||
|
import kst4contest.model.PaletteRole
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette of both designs for the active profile, held as Compose state.
|
||||||
|
*
|
||||||
|
* **This is what makes a colour change reach every open window.** `Kst4ContestTheme` used to
|
||||||
|
* take its palette from `remember(darkMode)`, so a change reached nothing until somebody
|
||||||
|
* toggled day/evening. Reading [resolved] inside the theme turns every palette change into a
|
||||||
|
* recomposition of every window that draws it.
|
||||||
|
*
|
||||||
|
* Built over four lambdas rather than over `ChatPreferences` so it can be tested without a
|
||||||
|
* profile on disk: that constructor copies resources into the operator's home directory.
|
||||||
|
*
|
||||||
|
* **Where "immediately" and "survives a restart" part company:** every change writes through
|
||||||
|
* to the preferences in memory at once, which is what recolours the windows, but the XML is
|
||||||
|
* written by the existing `savePreferencesFromSettings`. That is deliberately the same
|
||||||
|
* contract every other settings tab has — the JavaFX controls wrote as they changed and
|
||||||
|
* "Save settings" only persisted — but it means a change made and never saved is gone after a
|
||||||
|
* restart.
|
||||||
|
*
|
||||||
|
* @param shippedOf layer 1: the shipped sheet for a design, always complete
|
||||||
|
* @param fileRolesOf layer 2: the roles the profile's own stylesheet names, if any
|
||||||
|
* @param storedOverridesOf layer 3 as stored, encoded by [PaletteOverrides]
|
||||||
|
* @param storeOverrides writes layer 3 back
|
||||||
|
*/
|
||||||
|
class PaletteStore(
|
||||||
|
shippedOf: (darkMode: Boolean) -> JavaFxPalette,
|
||||||
|
fileRolesOf: (darkMode: Boolean) -> Map<PaletteRole, Color>,
|
||||||
|
storedOverridesOf: (darkMode: Boolean) -> String,
|
||||||
|
private val storeOverrides: (darkMode: Boolean, encoded: String) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Layers 1 and 2 are read once per design, on first use, and then held. They do not change
|
||||||
|
* while the application runs -- the shipped sheet is a classpath resource, and both manuals
|
||||||
|
* promise that edits to the profile's stylesheet take effect after a restart. Re-reading
|
||||||
|
* them inside resolved() meant two CSS parses and a disk read per recomposition of every
|
||||||
|
* window, on the UI thread, which in this codebase is never a detail.
|
||||||
|
*
|
||||||
|
* Only the overrides below stay live. The state read is the mechanism that repaints the
|
||||||
|
* windows; re-parsing immutable input was never part of it.
|
||||||
|
*/
|
||||||
|
private val shippedDaylight by lazy { shippedOf(false) }
|
||||||
|
private val shippedEvening by lazy { shippedOf(true) }
|
||||||
|
private val fileRolesDaylight by lazy { fileRolesOf(false) }
|
||||||
|
private val fileRolesEvening by lazy { fileRolesOf(true) }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The encoded overrides per design, as state. Everything else derives from these two, so
|
||||||
|
* there is exactly one place a change has to land for the windows to follow.
|
||||||
|
*/
|
||||||
|
private val daylightOverrides = mutableStateOf(storedOverridesOf(false))
|
||||||
|
private val eveningOverrides = mutableStateOf(storedOverridesOf(true))
|
||||||
|
|
||||||
|
/** What [restoreSnapshot] goes back to, or null while nothing has been remembered. */
|
||||||
|
private var snapshot: Pair<String, String>? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette in force for a design, with every role's provenance.
|
||||||
|
*
|
||||||
|
* Reads the override state, so a composable calling this recomposes when a colour
|
||||||
|
* changes. That state read is the mechanism and must not be cached; the two immutable
|
||||||
|
* layers under it are held, which is a different thing.
|
||||||
|
*/
|
||||||
|
fun resolved(darkMode: Boolean): ResolvedPalette = resolvePalette(
|
||||||
|
shipped = shipped(darkMode),
|
||||||
|
fromFile = if (darkMode) fileRolesEvening else fileRolesDaylight,
|
||||||
|
changed = changedRoles(darkMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shipped palette for a design, untouched by file or change.
|
||||||
|
*
|
||||||
|
* The lifeline reads this. A control drawn from [resolved] would disappear in exactly the
|
||||||
|
* palette it exists to undo.
|
||||||
|
*
|
||||||
|
* @param darkMode the design
|
||||||
|
* @return layer 1
|
||||||
|
*/
|
||||||
|
fun shipped(darkMode: Boolean): JavaFxPalette =
|
||||||
|
if (darkMode) shippedEvening else shippedDaylight
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets one role of one design.
|
||||||
|
*
|
||||||
|
* An unusable colour is ignored rather than stored: an operator mistyping a hex value
|
||||||
|
* must not end up with a black client. The tab reports the refusal.
|
||||||
|
*
|
||||||
|
* @param darkMode the design to change
|
||||||
|
* @param role the role to set
|
||||||
|
* @param colour a six-digit hex colour; anything else is ignored
|
||||||
|
*/
|
||||||
|
fun set(darkMode: Boolean, role: PaletteRole, colour: String) {
|
||||||
|
|
||||||
|
if (!PaletteOverrides.isValidColour(colour)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val overrides = PaletteOverrides.parse(encoded(darkMode)).toMutableMap()
|
||||||
|
overrides[role] = colour
|
||||||
|
write(darkMode, PaletteOverrides.format(overrides))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets every role of a design to its shipped colour, as an explicit change.
|
||||||
|
*
|
||||||
|
* Not the same as [discardChanges]: it has to beat a stylesheet file too, and only an
|
||||||
|
* explicit override on all six roles does that. This is the action the stage's acceptance
|
||||||
|
* criterion names — "exactly the shipped colours", file or no file.
|
||||||
|
*
|
||||||
|
* @param darkMode the design to reset
|
||||||
|
*/
|
||||||
|
fun resetToShipped(darkMode: Boolean) {
|
||||||
|
val shipped = shipped(darkMode)
|
||||||
|
|
||||||
|
val overrides = PaletteRole.values().associateWith { role ->
|
||||||
|
hexOf(shippedColourOf(shipped, role))
|
||||||
|
}
|
||||||
|
|
||||||
|
write(darkMode, PaletteOverrides.format(overrides))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empties layer 3, so the file or the shipped sheet applies again.
|
||||||
|
*
|
||||||
|
* @param darkMode the design to clear
|
||||||
|
*/
|
||||||
|
fun discardChanges(darkMode: Boolean) {
|
||||||
|
write(darkMode, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remembers the overrides of both designs, for [restoreSnapshot]. */
|
||||||
|
fun takeSnapshot() {
|
||||||
|
snapshot = daylightOverrides.value to eveningOverrides.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores the overrides remembered by [takeSnapshot], including a change that was
|
||||||
|
* already there when it was taken. Does nothing when nothing was remembered.
|
||||||
|
*
|
||||||
|
* Both designs, not only the one in force. [takeSnapshot] captures both, and an operator
|
||||||
|
* who changed a colour, switched day/evening from the main window's menu, changed another
|
||||||
|
* and then asked to go back to how it was means both -- restoring half of it would leave
|
||||||
|
* a state they never chose and cannot name.
|
||||||
|
*/
|
||||||
|
fun restoreSnapshot() {
|
||||||
|
val remembered = snapshot ?: return
|
||||||
|
write(darkMode = false, encoded = remembered.first)
|
||||||
|
write(darkMode = true, encoded = remembered.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changedRoles(darkMode: Boolean): Map<PaletteRole, Color> =
|
||||||
|
PaletteOverrides.parse(encoded(darkMode)).mapValues { (_, hex) -> colourOf(hex) }
|
||||||
|
|
||||||
|
private fun encoded(darkMode: Boolean): String =
|
||||||
|
if (darkMode) eveningOverrides.value else daylightOverrides.value
|
||||||
|
|
||||||
|
private fun write(darkMode: Boolean, encoded: String) {
|
||||||
|
if (darkMode) {
|
||||||
|
eveningOverrides.value = encoded
|
||||||
|
} else {
|
||||||
|
daylightOverrides.value = encoded
|
||||||
|
}
|
||||||
|
storeOverrides(darkMode, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shippedColourOf(shipped: JavaFxPalette, role: PaletteRole): Color = when (role) {
|
||||||
|
PaletteRole.SURFACE -> shipped.base
|
||||||
|
PaletteRole.WINDOW_SURFACE -> shipped.windowBackground
|
||||||
|
PaletteRole.FIELD_INTERIOR -> shipped.controlInnerBackground
|
||||||
|
PaletteRole.TEXT -> shipped.labelTextFill
|
||||||
|
PaletteRole.ACCENT -> shipped.accent
|
||||||
|
PaletteRole.SEPARATOR -> shipped.separatorLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A six-digit hex colour as a drawable colour.
|
||||||
|
*
|
||||||
|
* Only ever called with a value [PaletteOverrides.isValidColour] has already accepted, which
|
||||||
|
* is why it does not have to answer what an unparsable value means.
|
||||||
|
*/
|
||||||
|
internal fun colourOf(hex: String): Color =
|
||||||
|
Color(("ff" + hex.trim().removePrefix("#")).toLong(16))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A colour as the six-digit hex form the preferences store.
|
||||||
|
*
|
||||||
|
* Alpha is dropped on purpose: the roles are opaque surfaces and text, and a stored colour
|
||||||
|
* the operator cannot type back in would be a colour they cannot edit.
|
||||||
|
*/
|
||||||
|
internal fun hexOf(colour: Color): String = "#%02X%02X%02X".format(
|
||||||
|
(colour.red * 255).toInt(),
|
||||||
|
(colour.green * 255).toInt(),
|
||||||
|
(colour.blue * 255).toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store for the windows below, or null where there is none.
|
||||||
|
*
|
||||||
|
* Null is not caution but a real case: the operator profile picker draws before a profile —
|
||||||
|
* and therefore before a store — exists, and so do the tests of individual controls. Those
|
||||||
|
* get the shipped palette.
|
||||||
|
*/
|
||||||
|
val LocalPaletteStore = staticCompositionLocalOf<PaletteStore?> { null }
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.ApplicationConstants
|
||||||
|
import kst4contest.controller.OperatorProfilePaths
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.model.OperatorProfile
|
||||||
|
import kst4contest.utils.ApplicationFileUtils
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one profile's own stylesheets live — layer 2 of the palette, one file per design.
|
||||||
|
*
|
||||||
|
* A value rather than a path derived inside the factory, so the resolution can be tested
|
||||||
|
* without writing into the operator's home directory.
|
||||||
|
*/
|
||||||
|
data class OperatorProfilePaletteFiles(val dayFile: File, val eveningFile: File) {
|
||||||
|
|
||||||
|
/** The file for a design. */
|
||||||
|
fun fileFor(darkMode: Boolean): File = if (darkMode) eveningFile else dayFile
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The files of a profile, by the project's own path convention.
|
||||||
|
*
|
||||||
|
* @param profile the profile whose stylesheets are wanted
|
||||||
|
* @return both files, which need not exist
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun of(profile: OperatorProfile): OperatorProfilePaletteFiles =
|
||||||
|
OperatorProfilePaletteFiles(
|
||||||
|
dayFile = absoluteFile(OperatorProfilePaths.paletteRelativeFileName(profile, false)),
|
||||||
|
eveningFile = absoluteFile(OperatorProfilePaths.paletteRelativeFileName(profile, true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun absoluteFile(relativeName: String): File =
|
||||||
|
File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, relativeName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the [PaletteStore] of the active profile.
|
||||||
|
*
|
||||||
|
* The store itself takes four lambdas so it can be tested without a profile on disk; this is
|
||||||
|
* where those lambdas are tied to the real preferences and the real files, and it is the only
|
||||||
|
* place that knows which preference belongs to which design.
|
||||||
|
*/
|
||||||
|
object PaletteStoreFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the store for one profile.
|
||||||
|
*
|
||||||
|
* Changes write through to [prefs] immediately, which is what recolours the open windows.
|
||||||
|
* The XML is written by the existing "save settings" path, exactly like every other
|
||||||
|
* setting in this application.
|
||||||
|
*
|
||||||
|
* @param prefs the active profile's preferences
|
||||||
|
* @param files the active profile's own stylesheets, which need not exist
|
||||||
|
* @return a store for both designs
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun create(prefs: ChatPreferences, files: OperatorProfilePaletteFiles): PaletteStore =
|
||||||
|
PaletteStore(
|
||||||
|
shippedOf = { darkMode -> JavaFxStylesheet.shipped(darkMode) },
|
||||||
|
fileRolesOf = { darkMode ->
|
||||||
|
JavaFxStylesheet.declaredRolesOfFile(files.fileFor(darkMode))
|
||||||
|
},
|
||||||
|
storedOverridesOf = { darkMode ->
|
||||||
|
if (darkMode) {
|
||||||
|
prefs.guiOptions_paletteOverridesEvening
|
||||||
|
} else {
|
||||||
|
prefs.guiOptions_paletteOverridesDay
|
||||||
|
}
|
||||||
|
},
|
||||||
|
storeOverrides = { darkMode, encoded ->
|
||||||
|
if (darkMode) {
|
||||||
|
prefs.guiOptions_paletteOverridesEvening = encoded
|
||||||
|
} else {
|
||||||
|
prefs.guiOptions_paletteOverridesDay = encoded
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the store for the active profile, or for the root profile when no profile has
|
||||||
|
* been resolved yet.
|
||||||
|
*
|
||||||
|
* @param prefs the active profile's preferences
|
||||||
|
* @param profile the active profile
|
||||||
|
* @return a store for both designs
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun create(prefs: ChatPreferences, profile: OperatorProfile): PaletteStore =
|
||||||
|
create(prefs, OperatorProfilePaletteFiles.of(profile))
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import java.awt.Point
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored window positions of one profile, gated by the operator's opt-in.
|
||||||
|
*
|
||||||
|
* The opt-in is read on every call, so applying the setting takes effect without a
|
||||||
|
* restart: off means nothing is restored and nothing is written.
|
||||||
|
*
|
||||||
|
* @param requestSave asks the layout autosave to write the preferences file
|
||||||
|
*/
|
||||||
|
class PreferencesWindowPositionMemory(
|
||||||
|
private val prefs: ChatPreferences,
|
||||||
|
private val requestSave: Runnable,
|
||||||
|
) : WindowPositionMemory {
|
||||||
|
|
||||||
|
override fun load(windowId: String): StoredWindowPosition? {
|
||||||
|
if (!prefs.isGuiOptions_rememberWindowPositions()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val p = prefs.getWindowPosition(windowId) ?: return null
|
||||||
|
return StoredWindowPosition(Point(p[0], p[1]), Rectangle(p[2], p[3], p[4], p[5]))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(windowId: String, stored: StoredWindowPosition) {
|
||||||
|
if (!prefs.isGuiOptions_rememberWindowPositions()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.setWindowPosition(
|
||||||
|
windowId,
|
||||||
|
stored.position.x, stored.position.y,
|
||||||
|
stored.screen.x, stored.screen.y, stored.screen.width, stored.screen.height,
|
||||||
|
)
|
||||||
|
requestSave.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns of the public message tab.
|
||||||
|
*
|
||||||
|
* The other two tabs below the station list reuse the monitor tables under their own
|
||||||
|
* layout ids; this one is its own. Its ids are the operator's stored column widths,
|
||||||
|
* kept exactly as TableLayoutManager registered them.
|
||||||
|
*/
|
||||||
|
object PublicMessageColumns {
|
||||||
|
|
||||||
|
fun all(): List<DataColumn<ChatMessage>> = listOf(
|
||||||
|
DataColumn(
|
||||||
|
id = "time",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnTime },
|
||||||
|
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
|
||||||
|
weight = 0.7f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "callsign",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign },
|
||||||
|
value = { MonitorColumns.callSignOf(it.sender) },
|
||||||
|
),
|
||||||
|
DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
|
||||||
|
DataColumn(
|
||||||
|
id = "last-qrg",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrg },
|
||||||
|
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
|
||||||
|
weight = 0.8f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "message",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage },
|
||||||
|
value = { it.messageText.orEmpty() },
|
||||||
|
weight = 3f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "category",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
|
||||||
|
value = { message ->
|
||||||
|
message.sender?.chatCategory
|
||||||
|
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||||
|
.orEmpty()
|
||||||
|
},
|
||||||
|
weight = 1.2f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns of the selected station message tab.
|
||||||
|
*/
|
||||||
|
object SelectedStationMessageColumns {
|
||||||
|
|
||||||
|
fun all(): List<DataColumn<ChatMessage>> = listOf(
|
||||||
|
DataColumn(
|
||||||
|
id = "time",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnTime },
|
||||||
|
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
|
||||||
|
weight = 0.7f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "call-tx",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnSender },
|
||||||
|
value = { MonitorColumns.callSignOf(it.sender) },
|
||||||
|
weight = 0.8f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "call-rx",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnReceiver },
|
||||||
|
value = { MonitorColumns.callSignOf(it.receiver) },
|
||||||
|
weight = 0.8f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "qrg-tx",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgTx },
|
||||||
|
value = { it.sender?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
|
||||||
|
weight = 0.8f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "qrg-rx",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgRx },
|
||||||
|
value = { it.receiver?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
|
||||||
|
weight = 0.8f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "message",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage },
|
||||||
|
value = { it.messageText.orEmpty() },
|
||||||
|
weight = 3f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "wkd-rx",
|
||||||
|
title = "wkd RX?",
|
||||||
|
value = { if (it.receiver?.isWorked == true) "wkd" else "" },
|
||||||
|
weight = 0.6f,
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
id = "wkd-tx",
|
||||||
|
title = "wkd TX?",
|
||||||
|
value = { if (it.sender?.isWorked == true) "wkd" else "" },
|
||||||
|
weight = 0.6f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.view.i18n.LocalStrings
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import java.util.function.Consumer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel below the station list, for whichever station is selected.
|
||||||
|
*
|
||||||
|
* Its rules live in [SelectedStationState] and are tested there; what is here is the
|
||||||
|
* drawing. With nothing selected it says so rather than showing dead controls — the
|
||||||
|
* JavaFX pane disabled them, which looks the same as broken.
|
||||||
|
*
|
||||||
|
* @param openInBrowser opens a lookup address; reaching the browser is the host
|
||||||
|
* application's business
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SelectedStationPanel(
|
||||||
|
state: SelectedStationState,
|
||||||
|
messages: DataTableState<kst4contest.model.ChatMessage>,
|
||||||
|
/** The bands this station is set up for; BandOpportunityResolver decides them. */
|
||||||
|
skedBands: List<Band>,
|
||||||
|
/** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */
|
||||||
|
activeBands: Set<Band>,
|
||||||
|
openInBrowser: Consumer<String>,
|
||||||
|
onShowOnMap: () -> Unit,
|
||||||
|
onShowPathInAirScout: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
val member = state.selected
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
) {
|
||||||
|
if (member == null) {
|
||||||
|
Text(
|
||||||
|
strings.selectedNone,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
member.callSign.orEmpty() + " " + member.qra.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
) {
|
||||||
|
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||||
|
if (category != null) {
|
||||||
|
Text(category, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (member.getQTFdirection() != null) {
|
||||||
|
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (member.qrb != null) {
|
||||||
|
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastActSec = member.activityTimeLastInEpoch
|
||||||
|
if (lastActSec > 0L) {
|
||||||
|
val actStr = MessageFormats.clockTime(lastActSec.toString())
|
||||||
|
val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
|
||||||
|
Text(strings.selectedLastActivity(actStr, minAgo), style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
val detectedBands = state.detectedBands()
|
||||||
|
if (detectedBands.isNotBlank()) {
|
||||||
|
Text(detectedBands, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Form.section(strings.selectedShowMessages) {
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
|
SelectedMessageFilter.entries.forEach { filter ->
|
||||||
|
RadioChoice(
|
||||||
|
label = filter.label,
|
||||||
|
selected = state.messageFilter == filter,
|
||||||
|
onSelect = { state.messageFilter = filter },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTableView(
|
||||||
|
state = messages,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Form.section(strings.selectedTagNotQrv) {
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
|
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
|
||||||
|
|
||||||
|
activeNotQrvBands.forEach { band ->
|
||||||
|
Form.check(band.label, state.isTaggedNotQrv(band)) {
|
||||||
|
state.tagNotQrv(band, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
|
||||||
|
state.tagNotQrvAll(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Form.section("Sked") {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
|
||||||
|
) {
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = strings.selectedSkedInMinutes,
|
||||||
|
items = SelectedStationState.SKED_MINUTES,
|
||||||
|
selected = state.skedMinutes,
|
||||||
|
describe = { it.toString() },
|
||||||
|
onSelect = { state.skedMinutes = it },
|
||||||
|
)
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = strings.selectedBand,
|
||||||
|
items = skedBands,
|
||||||
|
selected = state.skedBand,
|
||||||
|
describe = { it.prefix + " MHz" },
|
||||||
|
onSelect = { state.skedBand = it },
|
||||||
|
)
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = strings.selectedMode,
|
||||||
|
items = SelectedStationState.SKED_MODES,
|
||||||
|
selected = state.skedMode,
|
||||||
|
describe = { it },
|
||||||
|
onSelect = { state.skedMode = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it }
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = strings.selectedReminderOffsets,
|
||||||
|
items = SelectedStationState.REMINDER_OFFSETS,
|
||||||
|
selected = state.reminderOffsets,
|
||||||
|
describe = { it },
|
||||||
|
enabled = state.remindPm,
|
||||||
|
onSelect = { state.reminderOffsets = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
|
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() }
|
||||||
|
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
|
||||||
|
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Form.section(strings.selectedLookUpAndShow) {
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
|
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
|
||||||
|
Form.button(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) {
|
||||||
|
openInBrowser.accept(state.qrzComUrl())
|
||||||
|
}
|
||||||
|
Form.button(strings.selectedLookupQrzcq, enabled = state.canLookUp) {
|
||||||
|
openInBrowser.accept(state.qrzCqUrl())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two priority buttons beside the station list: a shortcut to the station the
|
||||||
|
* score says to work next.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TopPriorityBar(
|
||||||
|
state: TopPriorityState,
|
||||||
|
onShowMore: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier.padding(2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
||||||
|
state.entries.forEach { entry ->
|
||||||
|
Form.button(entry.label, onClick = entry.select)
|
||||||
|
}
|
||||||
|
|
||||||
|
Form.button(strings.selectedMore, onClick = onShowMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of a set of mutually exclusive choices.
|
||||||
|
*
|
||||||
|
* Material's RadioButton carries the same 48dp minimum that made every field too tall on
|
||||||
|
* the desktop, so the touch target is released the way the other compact controls do it.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.selectable(selected = selected, onClick = onSelect)
|
||||||
|
.padding(end = Density.BUTTON_GAP),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selected,
|
||||||
|
onClick = onSelect,
|
||||||
|
modifier = Modifier.size(Density.CHECK_SIZE),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bands a station can be tagged as not being on, under the labels the panel shows.
|
||||||
|
*
|
||||||
|
* @param read whether the station is QRV on this band
|
||||||
|
* @param write sets that flag
|
||||||
|
*/
|
||||||
|
enum class NotQrvBand(
|
||||||
|
val label: String,
|
||||||
|
val band: kst4contest.model.Band,
|
||||||
|
val read: (ChatMember) -> Boolean,
|
||||||
|
val write: (ChatMember, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
B_50("50", kst4contest.model.Band.B_50, { it.isQrv50 }, { m, qrv -> m.isQrv50 = qrv }),
|
||||||
|
B_70("70", kst4contest.model.Band.B_70, { it.isQrv70 }, { m, qrv -> m.isQrv70 = qrv }),
|
||||||
|
B_144("144", kst4contest.model.Band.B_144, { it.isQrv144 }, { m, qrv -> m.isQrv144 = qrv }),
|
||||||
|
B_432("432", kst4contest.model.Band.B_432, { it.isQrv432 }, { m, qrv -> m.isQrv432 = qrv }),
|
||||||
|
B_23("23cm", kst4contest.model.Band.B_1296, { it.isQrv1240 }, { m, qrv -> m.isQrv1240 = qrv }),
|
||||||
|
B_13("13cm", kst4contest.model.Band.B_2320, { it.isQrv2300 }, { m, qrv -> m.isQrv2300 = qrv }),
|
||||||
|
B_9("9cm", kst4contest.model.Band.B_3400, { it.isQrv3400 }, { m, qrv -> m.isQrv3400 = qrv }),
|
||||||
|
B_6("6cm", kst4contest.model.Band.B_5760, { it.isQrv5600 }, { m, qrv -> m.isQrv5600 = qrv }),
|
||||||
|
B_3("3cm", kst4contest.model.Band.B_10G, { it.isQrv10G }, { m, qrv -> m.isQrv10G = qrv }),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which messages the panel lists for the selected station.
|
||||||
|
*
|
||||||
|
* One choice, not four switches: the JavaFX controls were a toggle group, so two can
|
||||||
|
* never be on at once.
|
||||||
|
*/
|
||||||
|
enum class SelectedMessageFilter(val label: String) {
|
||||||
|
PM_TO_ME("pm to me"),
|
||||||
|
PM_TO_OTHER("pm to other"),
|
||||||
|
PUBLIC("public msgs"),
|
||||||
|
NOTHING("nothing"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel below the station list, for whichever station is selected.
|
||||||
|
*
|
||||||
|
* Its "tag not qrv" marks are not display state: they are written into the station and
|
||||||
|
* propagated to the active members, so a wrong one hides that station from every list
|
||||||
|
* filtering on QRV. That is why they go through the controller rather than being kept
|
||||||
|
* here.
|
||||||
|
*
|
||||||
|
* @param propagateNotQrv hands the changed station to the controller, which is what
|
||||||
|
* `propagateNotQrvStateToActiveMembers` does
|
||||||
|
* @param createSked takes the station, the minutes, the band and the mode; returns
|
||||||
|
* whether it was accepted
|
||||||
|
* @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(
|
||||||
|
private val propagateNotQrv: (ChatMember) -> Unit,
|
||||||
|
private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
|
||||||
|
private val markSkedFail: (ChatMember) -> Unit = { },
|
||||||
|
private val resetSkedFail: (ChatMember) -> Unit = { },
|
||||||
|
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
|
||||||
|
private val detectBands: (ChatMember) -> String = { "" },
|
||||||
|
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
|
||||||
|
private val turnAntennaTo: ((Double) -> Unit)? = null,
|
||||||
|
private val turnSecondAntennaTo: ((Double) -> Unit)? = null,
|
||||||
|
private val secondAntennaOffered: () -> Boolean = { false },
|
||||||
|
) {
|
||||||
|
|
||||||
|
var selected: ChatMember? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Bumped on a tag so the panel redraws; the flags live on the station itself. */
|
||||||
|
private var tagRevision: Int by mutableStateOf(0)
|
||||||
|
|
||||||
|
var skedMinutes: Int by mutableStateOf(DEFAULT_SKED_MINUTES)
|
||||||
|
|
||||||
|
var skedBand: Band? by mutableStateOf(null)
|
||||||
|
|
||||||
|
var skedMode: String? by mutableStateOf(null)
|
||||||
|
|
||||||
|
private var _messageFilter by mutableStateOf<SelectedMessageFilter?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which messages the panel follows, or null until the operator picks one.
|
||||||
|
*
|
||||||
|
* JavaFX has the setSelected(true) on "pm to me" commented out, so its toggle group
|
||||||
|
* starts with nothing on. Starting on a filter would silently hide messages nobody
|
||||||
|
* asked to hide.
|
||||||
|
*/
|
||||||
|
var messageFilter: SelectedMessageFilter?
|
||||||
|
get() = _messageFilter
|
||||||
|
set(value) {
|
||||||
|
_messageFilter = value
|
||||||
|
onMessageFilterChange(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
var remindPm: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
var reminderOffsets: String by mutableStateOf(REMINDER_OFFSETS.first())
|
||||||
|
|
||||||
|
val canCreateSked: Boolean
|
||||||
|
get() = selected != null
|
||||||
|
|
||||||
|
val canLookUp: Boolean
|
||||||
|
get() = !selected?.callSign.isNullOrBlank()
|
||||||
|
|
||||||
|
/** Needs a rotator to drive and a finite heading to the selected station. */
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(member: ChatMember?) {
|
||||||
|
if (selected?.callSignRaw != member?.callSignRaw) {
|
||||||
|
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
||||||
|
}
|
||||||
|
selected = member
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
|
||||||
|
@Suppress("UNUSED_EXPRESSION") tagRevision
|
||||||
|
|
||||||
|
val member = selected ?: return false
|
||||||
|
|
||||||
|
return !band.read(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tagged means not QRV, so the station's QRV flag goes the other way. */
|
||||||
|
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
|
||||||
|
val member = selected ?: return
|
||||||
|
|
||||||
|
band.write(member, !tagged)
|
||||||
|
tagRevision++
|
||||||
|
propagateNotQrv(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tagNotQrvAll(tagged: Boolean) {
|
||||||
|
val member = selected ?: return
|
||||||
|
|
||||||
|
NotQrvBand.entries.forEach { it.write(member, !tagged) }
|
||||||
|
tagRevision++
|
||||||
|
propagateNotQrv(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether a sked was created; false when nothing is selected. */
|
||||||
|
fun createSked(): Boolean {
|
||||||
|
val member = selected ?: return false
|
||||||
|
|
||||||
|
return createSked(member, skedMinutes, skedBand, skedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the path to this station as failed.
|
||||||
|
*
|
||||||
|
* Permanent until reset, and it lowers the priority score sharply — so it is an
|
||||||
|
* answer to "I called and heard nothing", not to "not now".
|
||||||
|
*/
|
||||||
|
fun markSkedFail() {
|
||||||
|
selected?.let(markSkedFail)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetSkedFail() {
|
||||||
|
selected?.let(resetSkedFail)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detectedBands(): String {
|
||||||
|
val member = selected ?: return ""
|
||||||
|
val ignored = tagRevision
|
||||||
|
return detectBands(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun qrzComUrl(): String = QRZ_COM + selected?.callSign.orEmpty()
|
||||||
|
|
||||||
|
fun qrzCqUrl(): String = QRZ_CQ + selected?.callSign.orEmpty()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The minutes the JavaFX choice offered, and the one it started on. */
|
||||||
|
val SKED_MINUTES = listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20)
|
||||||
|
|
||||||
|
val SKED_MODES = listOf("SSB", "CW")
|
||||||
|
|
||||||
|
/** How long before a sked the reminders come, in minutes. */
|
||||||
|
val REMINDER_OFFSETS = listOf("2+1", "5+2+1", "10+5+2+1")
|
||||||
|
|
||||||
|
private const val DEFAULT_SKED_MINUTES = 5
|
||||||
|
private const val QRZ_COM = "https://www.qrz.com/db/"
|
||||||
|
private const val QRZ_CQ = "https://www.qrzcq.com/call/"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 802 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user