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

What the removal left open (4 commits).

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

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

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

The six tests that open a real window skip headless by their own guard. The
summary step counts from the result XML and fails on failures there, because
this project's rule is never to infer a green suite from an exit code.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:36:01 +02:00
1 parent fe4215bce9
commit 83e23f55a3
13 files changed
+970 -782

No files matched your search

+92 -1
View File
@@ -27,5 +27,96 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
# classes + testClasses rather than compileJava + compileTestJava. The old pair did
# cover Kotlin -- checked with a deliberately broken Kotlin test file, and also in
# --dry-run: the Kotlin plugin makes compileTestJava depend on compileTestKotlin,
# because Java has to see the Kotlin output. But that is the plugin's wiring and not
# this job's statement of intent, and it covered no resources. These two aggregates
# say "compile everything in both modules" and do not depend on knowing that.
- name: Compile
run: ./gradlew -S compileJava compileTestJava
run: ./gradlew -S classes testClasses
test:
name: Test (Java 21, headless)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
with:
distribution: temurin
java-version: "21"
- name: Ensure gradlew is executable
run: chmod +x gradlew
# Skiko's native library links against libGL, so it has to be loadable even though
# nothing here renders through it: the drawing tests use Skia's software backend and
# runComposeUiTest never opens a window. Without this the whole Compose half of the
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
# file" -- which reads like a missing display and is not one. Found by running this
# job under act, where the container has no libGL; GitHub's own runner image happens
# to ship it, so the step is what makes the job reproducible off GitHub.
- name: Install the OpenGL client library skiko links against
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
# runComposeUiTest reaches the semantics tree headless (measured before this job was
# written), and the ImageComposeScene drawing tests render in software. The handful
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
# skip here; the summary below prints the skip count, so that is visible rather than
# assumed.
#
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
# the hardest failure in this codebase to read, and importing that class of flake
# into CI would cost more than the few window tests are worth here.
- name: Test
run: ./gradlew -S test
- name: Summarise the test results
if: always()
run: |
set -euo pipefail
shopt -s nullglob
files=(*/build/test-results/test/TEST-*.xml)
if [ ${#files[@]} -eq 0 ]; then
echo "No test result XML was produced -- the test task did not run." >&2
exit 1
fi
# Counted from the XML and not from Gradle's console output: this project's own
# rule is never to infer "all tests passed" from an exit code.
summary=$(grep -h "<testsuite " "${files[@]}" \
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
read -r total skipped failures errors <<< "$summary"
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
{
echo "### Tests"
echo ""
echo "| total | skipped | failures | errors |"
echo "|---|---|---|---|"
echo "| $total | $skipped | $failures | $errors |"
echo ""
echo "Skipped are the tests that need a display, plus the deliberately"
echo "disabled reference-data generator. This job runs headless on purpose."
} >> "$GITHUB_STEP_SUMMARY"
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
echo "The result XML reports failures or errors." >&2
exit 1
fi
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
*/build/test-results/test/
*/build/reports/tests/test/
if-no-files-found: ignore
retention-days: 7
+15
View File
@@ -20,6 +20,21 @@ dependencies {
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)
@@ -1,10 +1,30 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import kst4contest.controller.On4KstConnectionState
import kst4contest.utils.PlatformUtils
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar
import kst4contest.controller.On4KstConnectionState
import kst4contest.utils.PlatformUtils
/**
* What the menu bar does when something is picked.
@@ -37,16 +57,15 @@ interface MainMenuActions {
}
/**
* The menu bar, declared once and called by every window.
* The menu bar in the system's own bar — macOS only.
*
* Compose has no shared bar across windows: `MenuBar` lives in the scope of a `Window`
* and macOS shows it in the system menu bar while that window is active. The JavaFX
* side arranged the same thing the other way round — `installSharedSystemMenuBar`
* mirrored the main window's menus into an invisible bar in every other window — and
* this is the Compose shape of it: one function each window can call.
* 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.
*
* The labels of the two window toggles are asked for at the moment the bar is built,
* because a Compose window can be closed from its own frame without telling the menu.
* Both renderers read [mainMenuModel]. Declaring the menus twice is how one of them quietly
* loses an item the other gained.
*/
@Composable
fun FrameWindowScope.Kst4ContestMenuBar(
@@ -56,73 +75,29 @@ fun FrameWindowScope.Kst4ContestMenuBar(
monitorWindowOpen: Boolean,
/**
* The connection state, for the read-only macOS menu. On macOS the status bar hides its
* badge because JavaFX moved the indicator into the system menu bar, so without this
* menu a macOS operator would have no connection indicator at all.
* 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,
) {
MenuBar {
Menu("File") {
Item(state.connectLabel, enabled = state.canConnect, onClick = actions::connect)
Item("Disconnect", enabled = state.canDisconnect, onClick = actions::disconnect)
Item("Switch operator profile...", onClick = actions::switchOperatorProfile)
Item("Exit + disconnect", onClick = actions::exitApplication)
}
Menu("Options") {
Item(
"Set QRG as name in Chat (main category)",
enabled = state.canUseChatActions,
onClick = 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.
*/
Item(
state.awayMenuLabel,
enabled = state.canUseChatActions,
onClick = actions::toggleAwayState,
)
Item(
if (settingsWindowOpen) "hide options" else "Show options",
onClick = actions::toggleSettingsWindow,
)
}
Menu("Windows") {
Item(
if (monitorWindowOpen) "Hide cluster / stranger QSOs"
else "Show cluster / stranger QSOs",
onClick = actions::toggleMonitorWindow,
)
Item(
if (settingsWindowOpen) "hide options" else "show options",
onClick = actions::toggleSettingsWindow,
)
Separator()
Item("Show / hide station map", onClick = actions::toggleStationMap)
Separator()
Item("Use dark mode design", onClick = actions::useDarkDesign)
Item("Use default mode design", onClick = actions::useDefaultDesign)
}
Menu("Info") {
Item("Donate for kst4Contest development via PayPal", onClick = actions::openDonationPage)
Item("Donate for OV3T´s plane feed service", onClick = actions::openOv3tDonationPage)
Item("Visit DARC X08-Homepage", onClick = actions::openHomepage)
Item("Join kst4Contest newsgroup", onClick = actions::openNewsgroup)
Item("Contact the author using default mail app", onClick = actions::contactAuthor)
Separator()
Item("About...", onClick = actions::showAbout)
mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen).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 is.
* single item is disabled, exactly as menuItemConnectionStateDetailMacOs was.
*/
if (PlatformUtils.isMacOs()) {
Menu(ConnectionIndicator.macOsMenuTitle(connectionState)) {
@@ -135,3 +110,103 @@ fun FrameWindowScope.Kst4ContestMenuBar(
}
}
}
/**
* 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 menus = mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen)
Row(
modifier = modifier
.fillMaxWidth()
.background(palette.base)
.heightIn(min = Density.TAB_MIN_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
menus.forEach { menu ->
MenuTitle(menu = menu, rowState = rowState)
}
}
}
@Composable
private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
val palette = LocalJavaFxPalette.current
val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState()
val open = rowState.openMenu == menu.title
Box {
Text(
text = menu.title,
style = MaterialTheme.typography.bodyMedium,
color = if (open) palette.textAccent else palette.labelTextFill,
modifier = Modifier
.background(
when {
open -> palette.accent
/*
* Hover is a hint, not a selection: the accent at full strength on
* mere pointer travel makes the bar flicker as the operator crosses
* it on the way to the chat field.
*/
hovered -> palette.accent.copy(alpha = HOVER_ALPHA)
else -> Color.Transparent
}
)
.hoverable(interactions)
.clickable { rowState.toggle(menu.title) }
.padding(
horizontal = Density.TAB_HORIZONTAL_PADDING,
vertical = MENU_TITLE_VERTICAL_PADDING,
),
)
DropdownMenu(expanded = open, onDismissRequest = rowState::close) {
menu.entries.forEach { entry ->
when (entry) {
is MenuEntry.Item -> DropdownMenuItem(
text = {
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
},
enabled = entry.enabled,
onClick = { rowState.pick(entry.onClick) },
)
MenuEntry.Separator ->
HorizontalDivider(
thickness = Density.HAIRLINE,
color = palette.separatorLine,
)
}
}
}
}
}
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */
private const val HOVER_ALPHA = 0.35f
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
@@ -0,0 +1,130 @@
package kst4contest.view.compose
/**
* 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,
): List<MenuSpec> = listOf(
MenuSpec(
"File",
listOf(
MenuEntry.Item(state.connectLabel, state.canConnect, actions::connect),
MenuEntry.Item("Disconnect", state.canDisconnect, actions::disconnect),
MenuEntry.Item(
"Switch operator profile...",
state.canSwitchProfile,
actions::switchOperatorProfile,
),
MenuEntry.Item("Exit + disconnect", state.canExit, actions::exitApplication),
),
),
MenuSpec(
"Options",
listOf(
MenuEntry.Item(
"Set QRG as name in Chat (main category)",
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,
state.canUseChatActions,
actions::toggleAwayState,
),
MenuEntry.Item(
if (settingsWindowOpen) "hide options" else "Show options",
enabled = true,
actions::toggleSettingsWindow,
),
),
),
MenuSpec(
"Windows",
listOf(
MenuEntry.Item(
if (monitorWindowOpen) "Hide cluster / stranger QSOs"
else "Show cluster / stranger QSOs",
enabled = true,
actions::toggleMonitorWindow,
),
MenuEntry.Item(
if (settingsWindowOpen) "hide options" else "show options",
enabled = true,
actions::toggleSettingsWindow,
),
MenuEntry.Separator,
MenuEntry.Item("Show / hide station map", enabled = true, actions::toggleStationMap),
MenuEntry.Separator,
MenuEntry.Item("Use dark mode design", enabled = true, actions::useDarkDesign),
MenuEntry.Item("Use default mode design", enabled = true, actions::useDefaultDesign),
),
),
MenuSpec(
"Info",
listOf(
MenuEntry.Item(
"Donate for kst4Contest development via PayPal",
enabled = true,
actions::openDonationPage,
),
MenuEntry.Item(
"Donate for OV3T´s plane feed service",
enabled = true,
actions::openOv3tDonationPage,
),
MenuEntry.Item("Visit DARC X08-Homepage", enabled = true, actions::openHomepage),
MenuEntry.Item("Join kst4Contest newsgroup", enabled = true, actions::openNewsgroup),
MenuEntry.Item(
"Contact the author using default mail app",
enabled = true,
actions::contactAuthor,
),
MenuEntry.Separator,
MenuEntry.Item("About...", 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()
}
}
@@ -6,6 +6,7 @@ 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
@@ -49,14 +50,24 @@ fun FrameWindowScope.MainWindow(
onShowMorePriorities: () -> Unit,
onCandidateClicked: (TimelineCandidate) -> Unit,
) {
Kst4ContestMenuBar(
state = state.menu,
actions = actions,
settingsWindowOpen = state.surroundings.settingsWindowOpen,
monitorWindowOpen = state.surroundings.monitorWindowOpen,
connectionState = state.surroundings.connectionState,
connectionDetail = state.surroundings.connectionDetail,
)
/*
* 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
@@ -68,17 +79,38 @@ fun FrameWindowScope.MainWindow(
Modifier
.fillMaxSize()
.onPreviewKeyEvent { event ->
WindowShortcuts.handle(
event = event,
send = state.chatInput::send,
clear = state.chatInput::clear,
insertSnippet = { index ->
state.chatInput.insertSnippet(state.snippets.entries, index)
},
)
/*
* 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,
@@ -154,7 +154,7 @@ object SettingsWindow {
}
@Composable
private fun SettingsContent(
internal fun SettingsContent(
tabs: List<SettingsTab>,
notices: SettingsNotices,
buttons: @Composable (close: () -> Unit) -> Unit,
@@ -241,7 +241,7 @@ private fun SettingsContent(
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SettingsTabStrip(
internal fun SettingsTabStrip(
tabs: List<SettingsTab>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
@@ -1,661 +0,0 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -1,6 +0,0 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
//# sourceMappingURL=leaflet.js.map
@@ -24,6 +24,31 @@ import java.util.concurrent.TimeUnit
*/
class ComposeWindowHostCloseTest {
/**
* Waits until the window with this title is actually showing.
*
* `ComposeWindowHost.isOpen` turns true the moment the window thread enters
* `application { }`, which is well before the window is realised — so a fixed sleep
* after it is a guess, and this test failed on that guess once the machine was busy.
* Frames are read on the event thread, which owns them.
*/
private fun awaitShowingFrame(title: String): java.awt.Frame {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20)
while (System.nanoTime() < deadline) {
var found: java.awt.Frame? = null
EventQueue.invokeAndWait {
found = java.awt.Window.getWindows()
.filterIsInstance<java.awt.Frame>()
.firstOrNull { it.isShowing && it.title == title }
}
found?.let { return it }
Thread.sleep(50)
}
throw AssertionError("the window titled '$title' never became visible")
}
private fun openHost(name: String): ComposeWindowHost {
val host = ComposeWindowHost(name)
host.show(
@@ -34,14 +59,8 @@ class ComposeWindowHostCloseTest {
heightDp = 120f,
) { Text("close test") }
// Wait for the window to really exist, so the close has something to close.
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
while (!host.isOpen && System.nanoTime() < deadline) {
Thread.sleep(25)
}
assertTrue(host.isOpen, "the window never opened, so the close proves nothing")
/* Let the first frame land; closing mid-composition is a different test. */
Thread.sleep(600)
// Wait for the window to really be on screen, so the close has something to close.
awaitShowingFrame("close test")
return host
}
@@ -90,30 +109,19 @@ class ComposeWindowHostCloseTest {
onCloseRequest = { asked.countDown() },
) { Text("close request test") }
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
while (!host.isOpen && System.nanoTime() < deadline) {
Thread.sleep(25)
}
assertTrue(host.isOpen, "the window never opened")
Thread.sleep(600)
/*
* Matched by title rather than by type: the suite runs in one JVM and other tests'
* Compose windows can still be showing, so a type filter sent the event to the
* wrong window and this latch waited for ever.
*/
val mine = awaitShowingFrame("close request test")
try {
/*
* What the window manager sends: a close request on the window itself. Matched
* by title rather than by type — the suite runs in one JVM and other tests'
* Compose windows can still be showing, so a type filter sent the event to the
* wrong window and this latch waited for ever.
*/
// What the window manager sends: a close request on the window itself.
EventQueue.invokeAndWait {
val mine = java.awt.Window.getWindows()
.filterIsInstance<java.awt.Frame>()
.filter { it.isShowing && it.title == "close request test" }
assertTrue(mine.isNotEmpty(), "this test's own window was not found")
mine.forEach {
it.dispatchEvent(
java.awt.event.WindowEvent(it, java.awt.event.WindowEvent.WINDOW_CLOSING)
)
}
mine.dispatchEvent(
java.awt.event.WindowEvent(mine, java.awt.event.WindowEvent.WINDOW_CLOSING)
)
}
assertTrue(
@@ -0,0 +1,207 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.tabs.MessageHandlingTab
import kst4contest.view.compose.tabs.MessageHandlingTabState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The four ways the drawing layer went wrong while the build was green.
*
* Every one of these shipped in the branch at the same time as a passing suite of 382
* tests, because not one of those tests drew anything. The state-level tests and the two
* settings coverage nets answer "was the setter called"; these answer "did it reach the
* screen", which is a different question and the one the operator asks.
*
* | Class | How it showed up |
* |---|---|
* | A control reads a non-observable value and never redraws | every keystroke in the settings was written through and not displayed |
* | A layout clips instead of wrapping | the tab strip cut "Workedstn database" mid-word |
* | Content is taller than the window and unreachable | a tab's form ended mid-way with nothing to say more was below |
* | Colours do not come from the stylesheet | the window surface took `-fx-base` instead of `derive(-fx-base, 26.4%)`; the accent was Material violet |
*
* All four run without a display: `runComposeUiTest` reaches the semantics tree headless,
* and the colour check reads the palette the theme hands out. That was measured before
* these were written, because the spec left it open and a net that only works on a
* developer's desk is worth saying so about.
*/
class DrawingLayerRegressionTest {
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
}
// ---------------------------------------------------------------- class 1
@OptIn(ExperimentalTestApi::class)
@Test
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
val prefs = ChatPreferences()
prefs.setMessageHandling_autoAnswerTextMainCat("")
val state = MessageHandlingTabState(prefs)
// The real tab, not a field in isolation: the fault was in the wiring between them.
setContent(themed { MessageHandlingTab(state) })
// MessageHandlingTab has exactly one text field, so this finder is unambiguous.
onNode(hasSetTextAction()).performTextInput("qrv 144.300")
assertEquals(
"qrv 144.300",
prefs.getMessageHandling_autoAnswerTextMainCat(),
"the value did not reach the preferences",
)
onNodeWithText("qrv 144.300").assertExists()
}
// ---------------------------------------------------------------- class 2
private val tabTitles = listOf(
"Station", "Log synch", "TRX synch", "Airscout", "Notification",
"Message handling", "Beacon", "Shortcuts", "GUI", "Profiles",
"Workedstn database",
)
/**
* The strip inside a box of a given width.
*
* The width is forced with a modifier rather than by sizing the test window, which is
* what makes this a measurement instead of a does-it-crash check: the same content is
* laid out twice and the two results are compared.
*/
private fun stripAt(widthDp: Int): @Composable () -> Unit = themed {
Box(Modifier.width(widthDp.dp)) {
SettingsTabStrip(
tabs = tabTitles.map { title -> SettingsTab(title) { Text(title) } },
selectedIndex = 0,
onSelect = {},
)
}
}
@OptIn(ExperimentalTestApi::class)
@Test
fun theTabStripWrapsRatherThanSqueezingItsTitles() = runComposeUiTest {
setContent(stripAt(WIDE_ENOUGH_FOR_EVERY_TAB))
val wide = onNodeWithText(LONGEST_TAB).fetchSemanticsNode()
val wideWidth = wide.size.width
val wideTop = wide.positionInRoot.y
assertTrue(wideWidth > 0, "the longest tab title was not laid out at all")
setContent(stripAt(TOO_NARROW_FOR_ONE_ROW))
val narrow = onNodeWithText(LONGEST_TAB).fetchSemanticsNode()
assertEquals(
wideWidth,
narrow.size.width,
"the tab got narrower when the row did, which means it is being squeezed "
+ "instead of wrapped — that is what cut '$LONGEST_TAB' mid-word",
)
assertTrue(
narrow.positionInRoot.y > wideTop,
"eleven tabs fitted on one line at $TOO_NARROW_FOR_ONE_ROW dp, so nothing "
+ "was measured; pick a narrower width",
)
}
// ---------------------------------------------------------------- class 3
@OptIn(ExperimentalTestApi::class)
@Test
fun aFormTallerThanTheWindowCanStillBeReached() = runComposeUiTest {
val fieldCount = 40
val lastLabel = "field $fieldCount"
setContent(
themed {
// The window's own scroll region, with content that cannot possibly fit.
SettingsContent(
tabs = listOf(
SettingsTab("Tall") {
Column {
(1..fieldCount).forEach { Text("field $it", Modifier.height(40.dp)) }
}
},
),
notices = SettingsNotices(),
buttons = {},
close = {},
)
}
)
/*
* performScrollTo fails if nothing in the chain scrolls. That is exactly the fault:
* each tab used to scroll for itself, so content simply ended mid-form with no
* scrollbar and no way down.
*/
onNodeWithText(lastLabel).performScrollTo().assertExists()
}
// ---------------------------------------------------------------- class 4
@Test
fun theWindowSurfaceIsDerivedFromTheBaseAndNotTheBaseItself() {
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
/*
* The fault was taking -fx-base for the window surface. The sheets derive it, and
* the two are visibly different greys — which is why the first Compose windows
* looked flat against the JavaFX ones beside them.
*/
assertTrue(
evening.windowBackground != evening.base,
"the window surface is the raw base colour again",
)
}
@Test
fun theAccentComesFromTheStylesheetAndNotFromMaterial() {
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
/*
* Material's default primary is a violet (#6650a4 family). The sheets name their
* own accent, and the two sheets name different ones — so a palette that agreed
* with Material, or a palette that was the same in both sheets, would mean the
* stylesheet is not being read.
*/
val materialViolet = androidx.compose.ui.graphics.Color(0xFF6650A4)
assertTrue(evening.accent != materialViolet, "the accent is Material's, not the sheet's")
assertTrue(day.accent != materialViolet, "the accent is Material's, not the sheet's")
assertTrue(
evening.accent != day.accent || evening.base != day.base,
"both sheets produced the same palette, so neither was really read",
)
}
private companion object {
const val LONGEST_TAB = "Workedstn database"
/** Comfortably more than the eleven tabs need, so none of them wraps. */
const val WIDE_ENOUGH_FOR_EVERY_TAB = 2000
/** Narrow enough that the row must break; the wrap is the point of the test. */
const val TOO_NARROW_FOR_ONE_ROW = 300
}
}
@@ -0,0 +1,166 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The menus, their labels and which of them can be used.
*
* None of this was covered while the bar was declared straight into Compose's `MenuBar`:
* the structure only existed inside a composable, so it took a running window to look at.
* It is now a plain function of the state, and these are the parts that go wrong silently —
* a label that stops following the away state, an item that stays usable while the station
* is not in the chat, or a menu that loses an entry when a second renderer is added.
*/
class MainMenuModelTest {
private class RecordingActions : MainMenuActions {
val calls = mutableListOf<String>()
override fun connect() { calls += "connect" }
override fun disconnect() { calls += "disconnect" }
override fun switchOperatorProfile() { calls += "switchOperatorProfile" }
override fun exitApplication() { calls += "exitApplication" }
override fun setQrgAsNameInChat() { calls += "setQrgAsNameInChat" }
override fun toggleAwayState() { calls += "toggleAwayState" }
override fun toggleSettingsWindow() { calls += "toggleSettingsWindow" }
override fun toggleMonitorWindow() { calls += "toggleMonitorWindow" }
override fun toggleStationMap() { calls += "toggleStationMap" }
override fun useDarkDesign() { calls += "useDarkDesign" }
override fun useDefaultDesign() { calls += "useDefaultDesign" }
override fun openDonationPage() { calls += "openDonationPage" }
override fun openHomepage() { calls += "openHomepage" }
override fun openNewsgroup() { calls += "openNewsgroup" }
override fun openChangelog() { calls += "openChangelog" }
override fun openOv3tDonationPage() { calls += "openOv3tDonationPage" }
override fun contactAuthor() { calls += "contactAuthor" }
override fun showAbout() { calls += "showAbout" }
}
private fun model(
connectionState: On4KstConnectionState = On4KstConnectionState.DISCONNECTED,
away: Boolean = false,
settingsOpen: Boolean = false,
monitorOpen: Boolean = false,
actions: MainMenuActions = RecordingActions(),
): List<MenuSpec> {
val state = MainMenuState()
state.connectionState = connectionState
state.awayFromChat = away
return mainMenuModel(state, actions, settingsOpen, monitorOpen)
}
private fun items(menu: MenuSpec) = menu.entries.filterIsInstance<MenuEntry.Item>()
private fun labels(menu: MenuSpec) = items(menu).map { it.label }
private fun item(model: List<MenuSpec>, label: String): MenuEntry.Item =
model.flatMap { items(it) }.single { it.label == label }
@Test
fun theFourMenusAppearInTheOrderTheyAlwaysHad() {
assertEquals(listOf("File", "Options", "Windows", "Info"), model().map { it.title })
}
@Test
fun noMenuIsEmptyAndTheSeparatorsSurviveTheTranslation() {
val model = model()
model.forEach { menu ->
assertTrue(items(menu).isNotEmpty(), "menu '${menu.title}' has no items")
}
// Windows has two separators, Info one. A renderer that drops them makes the
// design switch read as just another window toggle.
assertEquals(2, model.single { it.title == "Windows" }.entries.count { it is MenuEntry.Separator })
assertEquals(1, model.single { it.title == "Info" }.entries.count { it is MenuEntry.Separator })
}
@Test
fun theConnectItemCarriesTheStatesOwnLabel() {
val state = MainMenuState()
state.connectLabel = "Connect to 144/432 MHz"
val model = mainMenuModel(state, RecordingActions(), false, false)
assertEquals(
"Connect to 144/432 MHz",
labels(model.single { it.title == "File" }).first(),
"the label names the chat the station lands in; it must not be hardcoded here",
)
}
@Test
fun connectAndDisconnectAreNeverBothUsable() {
for (connectionState in On4KstConnectionState.values()) {
val model = model(connectionState = connectionState)
val connect = model.flatMap { items(it) }.first()
val disconnect = item(model, "Disconnect")
assertFalse(
connect.enabled && disconnect.enabled,
"both were usable while $connectionState",
)
assertTrue(
connect.enabled || disconnect.enabled,
"neither was usable while $connectionState, which would strand the operator",
)
}
}
@Test
fun theChatActionsNeedASessionThatIsReallyOnline() {
val offline = model(connectionState = On4KstConnectionState.CONNECTING)
// Logging in is not being in the chat: a command sent then goes nowhere.
assertFalse(item(offline, "Set QRG as name in Chat (main category)").enabled)
assertFalse(item(offline, "Show me as AWAY FROM chat!").enabled)
}
@Test
fun theAwayLabelNamesTheNextActionAndFollowsTheState() {
assertEquals(
"Show me as AWAY FROM chat!",
item(model(away = false), "Show me as AWAY FROM chat!").label,
)
assertEquals(
"Show me as ACTIVE in chat!",
item(model(away = true), "Show me as ACTIVE in chat!").label,
)
}
@Test
fun theWindowTogglesSayWhatPressingThemWillDo() {
val closed = model(settingsOpen = false, monitorOpen = false)
assertTrue(labels(closed.single { it.title == "Windows" }).any { it == "show options" })
assertTrue(
labels(closed.single { it.title == "Windows" })
.any { it == "Show cluster / stranger QSOs" },
)
val open = model(settingsOpen = true, monitorOpen = true)
assertTrue(labels(open.single { it.title == "Windows" }).any { it == "hide options" })
assertTrue(
labels(open.single { it.title == "Windows" })
.any { it == "Hide cluster / stranger QSOs" },
)
}
@Test
fun everyItemIsWiredToSomething() {
val actions = RecordingActions()
val model = model(connectionState = On4KstConnectionState.ONLINE, actions = actions)
model.flatMap { items(it) }.forEach { it.onClick() }
// One click each, and nothing silently wired to an empty lambda: the JavaFX bar had
// an item whose handler was commented out, and it looked exactly like the others.
assertEquals(
model.flatMap { items(it) }.size,
actions.calls.size,
"every item must reach an action; one of them did nothing",
)
}
}
@@ -0,0 +1,83 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* Which menu of the in-window row is open.
*
* Small, but it carries one thing that is not cosmetic: [MainMenuRowState.anyOpen] is what
* keeps the window-wide keys out of the way while a menu is down. `MainWindow` hangs
* `WindowShortcuts.handle` on the window's `onPreviewKeyEvent`, and a preview runs *before*
* its children — so without this, pressing Escape to dismiss a menu would first clear the
* operator's half-typed message.
*/
class MainMenuRowStateTest {
@Test
fun nothingIsOpenToStartWith() {
val state = MainMenuRowState()
assertNull(state.openMenu)
assertFalse(state.anyOpen)
}
@Test
fun aTitleOpensItsOwnMenu() {
val state = MainMenuRowState()
state.toggle("File")
assertEquals("File", state.openMenu)
assertTrue(state.anyOpen)
}
@Test
fun theSameTitleAgainClosesIt() {
val state = MainMenuRowState()
state.toggle("File")
state.toggle("File")
assertNull(state.openMenu, "pressing the open menu's own title must close it")
assertFalse(state.anyOpen)
}
@Test
fun anotherTitleSwitchesRatherThanStacking() {
val state = MainMenuRowState()
state.toggle("File")
state.toggle("Info")
// Two menus down at once is a thing no menu bar does.
assertEquals("Info", state.openMenu)
}
@Test
fun closeAlwaysEndsUpClosedAndIsIdempotent() {
val state = MainMenuRowState()
state.toggle("Windows")
state.close()
state.close()
assertNull(state.openMenu)
assertFalse(state.anyOpen)
}
@Test
fun anItemBeingPickedClosesTheMenu() {
val state = MainMenuRowState()
state.toggle("Options")
var ran = false
state.pick { ran = true }
assertTrue(ran, "the action must run")
assertNull(state.openMenu, "a picked item must close the menu it came from")
}
}