diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml
index 1bac8606..a1b9ea74 100644
--- a/.github/workflows/nightly-artifacts.yml
+++ b/.github/workflows/nightly-artifacts.yml
@@ -52,39 +52,23 @@ jobs:
shell: pwsh
run: choco install wixtoolset --no-progress -y
- - name: Build JAR and copy runtime dependencies
- shell: pwsh
- run: |
- .\gradlew.bat -S :app-desktop:collectRuntime
- $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 app-desktop/build/dist-libs/app.jar
-
- - name: Build app-image with jpackage
+ - name: Build app-image
shell: pwsh
run: |
+ .\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null
- $addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
- if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
- jpackage `
- --type app-image `
- --name praktiKST `
- --icon packaging/icons/kst4contest.ico `
- --input app-desktop/build/dist-libs `
- --main-jar app.jar `
- --main-class kst4contest.view.Main `
- --add-modules $addModules `
- --dest dist
+ # The next step reads dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
+ Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
- name: Create Windows ZIP
shell: pwsh
run: |
- if (-not (Test-Path dist/praktiKST)) {
- throw "No Windows app-image produced by jpackage"
+ if (-not (Test-Path dist/KST4Contest)) {
+ throw "No Windows app-image produced by createDistributable"
}
- Compress-Archive -Path dist/praktiKST -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
+ Compress-Archive -Path dist/KST4Contest -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4
@@ -116,23 +100,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest dist
+ # The following steps read dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ rm -rf dist/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Create AppDir metadata
run: |
@@ -203,27 +178,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- name: Build Debian package
run: |
+ ./gradlew -S :app-desktop:packageDeb
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type deb \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --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)"
+ DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
- echo "No DEB produced by jpackage" && exit 1
+ echo "No DEB produced by packageDeb" && exit 1
fi
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
@@ -262,27 +223,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- name: Build Fedora package
run: |
+ ./gradlew -S :app-desktop:packageRpm
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type rpm \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
- --dest dist
- RPM="$(ls dist/*.rpm | head -n 1)"
+ RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
- echo "No RPM produced by jpackage" && exit 1
+ echo "No RPM produced by packageRpm" && exit 1
fi
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
@@ -323,23 +270,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest dist
+ # The following steps read dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ rm -rf dist/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Build Arch Linux package artifact
run: |
@@ -445,20 +383,13 @@ jobs:
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
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
- ./gradlew -S :app-desktop:collectRuntime
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p target/flatpak-src
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest target/flatpak-src
+ # The manifest's source is flatpak-src/KST4Contest.
+ rm -rf target/flatpak-src/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
- name: Create Flatpak manifest
run: |
diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml
index f999238b..e70de310 100644
--- a/.github/workflows/tagged-release.yml
+++ b/.github/workflows/tagged-release.yml
@@ -34,39 +34,23 @@ jobs:
shell: pwsh
run: choco install wixtoolset --no-progress -y
- - name: Build JAR and copy runtime dependencies
- shell: pwsh
- run: |
- .\gradlew.bat -S :app-desktop:collectRuntime
- $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 app-desktop/build/dist-libs/app.jar
-
- - name: Build app-image with jpackage
+ - name: Build app-image
shell: pwsh
run: |
+ .\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null
- $addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
- if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
- jpackage `
- --type app-image `
- --name praktiKST `
- --icon packaging/icons/kst4contest.ico `
- --input app-desktop/build/dist-libs `
- --main-jar app.jar `
- --main-class kst4contest.view.Main `
- --add-modules $addModules `
- --dest dist
+ # The next step reads dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
+ Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
- name: Create Windows ZIP
shell: pwsh
run: |
- if (-not (Test-Path dist/praktiKST)) {
- throw "No Windows app-image produced by jpackage"
+ if (-not (Test-Path dist/KST4Contest)) {
+ throw "No Windows app-image produced by createDistributable"
}
- Compress-Archive -Path dist/praktiKST -DestinationPath dist/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
uses: actions/upload-artifact@v4.3.4
@@ -91,23 +75,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest dist
+ # The following steps read dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ rm -rf dist/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Create AppDir metadata
run: |
@@ -171,27 +146,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- name: Build Debian package
run: |
+ ./gradlew -S :app-desktop:packageDeb
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type deb \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --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)"
+ DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
- echo "No DEB produced by jpackage" && exit 1
+ echo "No DEB produced by packageDeb" && exit 1
fi
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
@@ -223,27 +184,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- name: Build Fedora package
run: |
+ ./gradlew -S :app-desktop:packageRpm
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type rpm \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
- --dest dist
- RPM="$(ls dist/*.rpm | head -n 1)"
+ RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
- echo "No RPM produced by jpackage" && exit 1
+ echo "No RPM produced by packageRpm" && exit 1
fi
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
@@ -275,23 +222,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest dist
+ # The following steps read dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ rm -rf dist/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Build Arch Linux package artifact
run: |
@@ -388,20 +326,13 @@ jobs:
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
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
- ./gradlew -S :app-desktop:collectRuntime
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p target/flatpak-src
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest target/flatpak-src
+ # The manifest's source is flatpak-src/KST4Contest.
+ rm -rf target/flatpak-src/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
- name: Create Flatpak manifest
run: |
diff --git a/.gitignore b/.gitignore
index fec6ff69..6e89ba3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,9 @@ website/_site/
build/
core/build/
app-desktop/build/
+
+# Agent scratch area - screenshots, notes, throwaway scripts
+scratch/
+
+# Kotlin incremental build data
+.kotlin/
diff --git a/CLAUDE.md b/CLAUDE.md
index 46ffc7e0..30db188b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,5 +29,5 @@ sind über beide Module hinweg dieselben.
Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter
`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch
-Compose Multiplatform in sechs Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
+Compose Multiplatform in neun Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
ist abgeschlossen.
diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts
index 4e898281..3bd3597a 100644
--- a/app-desktop/build.gradle.kts
+++ b/app-desktop/build.gradle.kts
@@ -1,7 +1,9 @@
plugins {
java
- application
alias(libs.plugins.javafx)
+ alias(libs.plugins.kotlinJvm)
+ alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.composeMultiplatform)
}
javafx {
@@ -9,13 +11,20 @@ javafx {
modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media")
}
+kotlin {
+ jvmToolchain(21)
+}
+
dependencies {
+ implementation("org.openjfx:javafx-swing:${libs.versions.javafx.get()}")
// Mirrors the compile-scope entries in pom.xml: 11 test classes live under
// src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
implementation(libs.jetbrains.annotations)
implementation(project(":core"))
+ implementation(compose.desktop.currentOs)
+ implementation(compose.material3)
implementation(libs.sqlite.jdbc)
implementation(libs.jlayer)
testImplementation(libs.junit.jupiter)
@@ -25,46 +34,77 @@ dependencies {
testRuntimeOnly(libs.junit.platform.launcher)
}
-application {
- mainClass.set("kst4contest.view.Kst4ContestApplication")
-}
+compose.desktop {
+ application {
+ // Main and not Kst4ContestApplication: an Application subclass cannot be
+ // started from the classpath, which is where JavaFX lives since Etappe 1.
+ mainClass = "kst4contest.view.Main"
-// Collects the application jar and its runtime dependencies into the layout the
-// packaging scripts expect: a flat directory with the main jar named app.jar.
-val collectRuntime by tasks.registering(Sync::class) {
- dependsOn(tasks.jar)
- from(tasks.jar) { rename { "app.jar" } }
- from(configurations.runtimeClasspath)
- into(layout.buildDirectory.dir("dist-libs"))
-}
+ 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
-val packageImage by tasks.registering(Exec::class) {
- dependsOn(collectRuntime)
- // jpackage must come from the project toolchain, not from PATH: jdk.jsobject
- // was removed after JDK 21, and jlink then cannot resolve the module list.
- val launcher = javaToolchains.launcherFor {
- languageVersion.set(JavaLanguageVersion.of(21))
- }.get()
- val jpackageTool = launcher.metadata.installationPath.file("bin/jpackage").asFile.absolutePath
- val input = layout.buildDirectory.dir("dist-libs").get().asFile
- val output = layout.buildDirectory.dir("jpackage").get().asFile
- val os = org.gradle.internal.os.OperatingSystem.current()
- val icon = when {
- os.isWindows -> "packaging/icons/kst4contest.ico"
- os.isMacOsX -> "packaging/icons/kst4contest.icns"
- else -> "packaging/icons/kst4contest.png"
+ /*
+ * 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, minus the javafx.* modules.
+ * jdk.jsobject carries netscape.javascript for the map bridge, and
+ * jdk.unsupported carries sun.misc.Unsafe, without which the JavaFX
+ * Marlin renderer fails to start. Neither is inferred: JavaFX comes
+ * from the classpath, so nothing declares them.
+ */
+ modules(
+ "java.desktop", "java.net.http", "java.sql",
+ "jdk.crypto.ec", "jdk.jsobject", "jdk.net",
+ "jdk.xml.dom", "jdk.unsupported",
+ )
+
+ linux { iconFile.set(rootProject.file("packaging/icons/kst4contest.png")) }
+ windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) }
+ macOS {
+ iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
+ // Must stay de.x08.KST4Contest: build-signed-dmg.sh signs against it.
+ bundleID = "de.x08.KST4Contest"
+ }
+ }
}
- doFirst { output.deleteRecursively() }
- commandLine(
- jpackageTool,
- "--icon", rootProject.file(icon).absolutePath,
- "--type", "app-image",
- "--name", "praktiKST",
- "--input", input.absolutePath,
- "--dest", output.absolutePath,
- "--main-jar", "app.jar",
- "--add-modules", providers.gradleProperty("jpackageAddModules").get(),
- "--main-class", "kst4contest.view.Main",
- "--java-options", "-Dfile.encoding=UTF-8"
- )
}
diff --git a/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java
index b0c571b2..0843817f 100644
--- a/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java
+++ b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java
@@ -22,11 +22,29 @@ public class CommandLineOptions {
*/
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
+ /**
+ * Opens the Compose main window beside the JavaFX one.
+ *
+ *
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.
+ */
+ 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;
}
@@ -42,6 +60,7 @@ public class CommandLineOptions {
public static CommandLineOptions parse(final List rawArguments) {
String requestedProfileName = null;
+ boolean composeMainWindow = false;
if (rawArguments != null) {
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
@@ -51,7 +70,9 @@ public class CommandLineOptions {
continue;
}
- if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
+ 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()) {
@@ -70,7 +91,8 @@ public class CommandLineOptions {
}
return new CommandLineOptions(
- requestedProfileName == null ? null : requestedProfileName.trim());
+ requestedProfileName == null ? null : requestedProfileName.trim(),
+ composeMainWindow);
}
/**
@@ -96,6 +118,15 @@ public class CommandLineOptions {
*
* @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;
}
diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java
new file mode 100644
index 00000000..683b9b3b
--- /dev/null
+++ b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java
@@ -0,0 +1,99 @@
+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.
+ *
+ * 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.
+ */
+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() {
+ if (application.tbl_chatMember == null
+ || application.tbl_chatMember.getSelectionModel() == null) {
+ return null;
+ }
+ return application.tbl_chatMember.getSelectionModel().getSelectedItem();
+ }
+
+ @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;
+ }
+}
diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java
new file mode 100644
index 00000000..1d9ddf73
--- /dev/null
+++ b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java
@@ -0,0 +1,136 @@
+package kst4contest.view;
+
+import javafx.scene.control.MenuItem;
+import kst4contest.view.compose.MainMenuActions;
+
+import java.util.function.Supplier;
+
+/**
+ * The Compose menu bar's actions, delegated to the JavaFX menu items they mirror.
+ *
+ * Firing the very same {@link MenuItem} is deliberate. The two bars run side by side while
+ * the operator compares the windows, and a second implementation of seventeen handlers would
+ * be a second thing to compare — differences between the bars would be indistinguishable from
+ * differences between the windows.
+ *
+ * When the JavaFX window goes, those handlers move into methods of their own and this class
+ * calls those instead. Until then it is a bridge, not a design.
+ */
+public final class ComposeMenuActions implements MainMenuActions {
+
+ private final Kst4ContestApplication application;
+
+ public ComposeMenuActions(final Kst4ContestApplication application) {
+ this.application = application;
+ }
+
+ @Override
+ public void connect() {
+ fire(() -> application.menuItemFileConnect);
+ }
+
+ @Override
+ public void disconnect() {
+ fire(() -> application.menuItemFileDisconnect);
+ }
+
+ @Override
+ public void switchOperatorProfile() {
+ application.runOnUi(application::showOperatorProfileSwitchDialogFromCompose);
+ }
+
+ @Override
+ public void exitApplication() {
+ fire(() -> application.menuItemFileExit);
+ }
+
+ @Override
+ public void setQrgAsNameInChat() {
+ fire(() -> application.menuItemOptionsSetFrequencyAsName);
+ }
+
+ @Override
+ public void toggleAwayState() {
+ fire(() -> application.menuItemOptionsAwayBack);
+ }
+
+ @Override
+ public void toggleSettingsWindow() {
+ fire(() -> application.menuItemOptionsShow);
+ }
+
+ @Override
+ public void toggleMonitorWindow() {
+ fire(() -> application.menuItemWindowCluster);
+ }
+
+ @Override
+ public void toggleStationMap() {
+ fire(() -> application.menuItemWindowStationMap);
+ }
+
+ @Override
+ public void useDarkDesign() {
+ fire(() -> application.menuItemWindowDarkDesign);
+ }
+
+ @Override
+ public void useDefaultDesign() {
+ fire(() -> application.menuItemWindowDefaultDesign);
+ }
+
+ @Override
+ public void openDonationPage() {
+ fire(() -> application.menuItemInfoDonate);
+ }
+
+ @Override
+ public void openHomepage() {
+ fire(() -> application.menuItemInfoHomepage);
+ }
+
+ @Override
+ public void openNewsgroup() {
+ fire(() -> application.menuItemInfoNewsgroup);
+ }
+
+ @Override
+ public void openChangelog() {
+ /*
+ * The JavaFX item and its handler are both commented out, so there is nothing to
+ * fire. The Compose menu no longer offers this either; the method stays because the
+ * interface is shared and an empty body is honest about there being no such feature.
+ */
+ }
+
+ @Override
+ public void openOv3tDonationPage() {
+ fire(() -> application.menuItemInfoDonateOv3t);
+ }
+
+ @Override
+ public void contactAuthor() {
+ fire(() -> application.menuItemInfoContact);
+ }
+
+ @Override
+ public void showAbout() {
+ fire(() -> application.menuItemInfoAbout);
+ }
+
+ /**
+ * Fires a menu item on the JavaFX thread.
+ *
+ * The item is looked up when the action runs rather than when this class is built,
+ * because initMenuBar creates them after the runtime exists. A missing item is ignored:
+ * a menu entry that cannot act is better than a crash mid contest.
+ */
+ private void fire(final Supplier