diff --git a/src/main/java/kst4contest/utils/PlatformUtils.java b/src/main/java/kst4contest/utils/PlatformUtils.java new file mode 100644 index 00000000..1efb1141 --- /dev/null +++ b/src/main/java/kst4contest/utils/PlatformUtils.java @@ -0,0 +1,29 @@ +package kst4contest.utils; + +import java.util.Locale; + +/** + * Small helpers for operating-system specific UI behaviour. + */ +public final class PlatformUtils { + + private PlatformUtils() { + } + + /** + * @return true if the running JVM reports macOS as operating system + */ + public static boolean isMacOs() { + return isMacOs(System.getProperty("os.name")); + } + + /** + * Checks an {@code os.name} value for macOS. A missing value is treated as "not macOS". + */ + public static boolean isMacOs(String osName) { + if (osName == null) { + return false; + } + return osName.toLowerCase(Locale.ROOT).startsWith("mac"); + } +} diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index 1e0ee59b..4dd6edf9 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -70,6 +70,7 @@ import javafx.stage.Screen; import kst4contest.logic.BandOpportunityResolver; import kst4contest.utils.ApplicationFileUtils; +import kst4contest.utils.PlatformUtils; import kst4contest.view.map.StationMapBridge; import kst4contest.controller.ActiveOperatorProfile; import kst4contest.controller.OperatorProfileStore; @@ -133,6 +134,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private On4KstConnectionState lastDisplayedConnectionState; private String lastDisplayedConnectionDetail = ""; + /** Main window menu bar; its menus are shared with the secondary windows on macOS. */ + private MenuBar mainScreenMenuBar; + /** macOS only: connection state shown as menu title in the system menu bar. */ + private final Menu menuConnectionStateMacOs = new Menu("LINK"); + private final MenuItem menuItemConnectionStateDetailMacOs = new MenuItem(); /** Scenes whose stylesheet follows the light/dark design switch. */ private final List themedScenes = new ArrayList<>(); @@ -201,6 +207,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatcontroller.getChatPreferences(), this::requestLayoutSave ); + installSharedSystemMenuBar(stationMapView.getScene()); stationMapBridge = new StationMapBridge( chatcontroller, tbl_chatMember, @@ -4595,6 +4602,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL Scene candidatesScene = new Scene(root, 360, 500); registerThemedScene(candidatesScene); stage.setOnHidden(event -> themedScenes.remove(candidatesScene)); + installSharedSystemMenuBar(candidatesScene); stage.setScene(candidatesScene); stage.show(); } @@ -5756,9 +5764,46 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL MenuBar menubar = new MenuBar(); menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu); // macromenu deleted + // On macOS the menu belongs into the system menu bar at the top of the screen. + // The node stays in the status bar pane but is not rendered there. + // The connection state indicator moves into the menu bar as a read-only menu. + if (PlatformUtils.isMacOs()) { + menuItemConnectionStateDetailMacOs.setDisable(true); + menuConnectionStateMacOs.getItems().setAll(menuItemConnectionStateDetailMacOs); + menubar.getMenus().add(menuConnectionStateMacOs); + menubar.setUseSystemMenuBar(true); + } + return menubar; } + /** + * On macOS, attaches an invisible system menu bar to a secondary window, so the + * main menus stay visible in the screen menu bar while that window has the focus. + * The menus are shared with the main window instead of copied, so enabled state and + * labels stay consistent everywhere. Does nothing on other platforms. + */ + private void installSharedSystemMenuBar(Scene scene) { + if (!PlatformUtils.isMacOs() || mainScreenMenuBar == null || scene == null) { + return; + } + + Pane rootPane; + if (scene.getRoot() instanceof Pane pane) { + rootPane = pane; + } else { + // Controls like SplitPane do not accept extra children, so wrap them. + rootPane = new BorderPane(scene.getRoot()); + scene.setRoot(rootPane); + } + + MenuBar sharedMenuBar = new MenuBar(); + sharedMenuBar.setUseSystemMenuBar(true); + sharedMenuBar.setManaged(false); + Bindings.bindContent(sharedMenuBar.getMenus(), mainScreenMenuBar.getMenus()); + rootPane.getChildren().add(sharedMenuBar); + } + /** * Registers a scene whose stylesheet follows the light/dark design switch and * applies the currently active design to it. @@ -5796,6 +5841,22 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL : ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); } + /** + * Title of the macOS connection state menu, e.g. "🟢 LINK: Connected". + */ + static String macOsConnectionStateMenuTitle(On4KstConnectionState state) { + On4KstConnectionState effectiveState = state == null + ? On4KstConnectionState.DISCONNECTED : state; + return switch (effectiveState) { + case ONLINE -> "🟢 LINK: Connected"; + case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING, + SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT -> "🟡 LINK: Connecting…"; + case STOPPING -> "🟡 LINK: Disconnecting…"; + case RECONNECT_WAIT -> "🔴 LINK: Reconnecting…"; + case DISCONNECTED -> "🔴 LINK: Disconnected"; + }; + } + /***************************************************** * Sked warning Initializing and functional section @@ -5864,6 +5925,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tipConnectionStateIndicator.setText( "ON4KST link: " + effectiveState.name() + "\n" + stateDetail); btnConnectionStateIndicator.setAccessibleHelp(stateDetail); + menuConnectionStateMacOs.setText(macOsConnectionStateMenuTitle(effectiveState)); + menuItemConnectionStateDetailMacOs.setText(stateDetail); String commonStyle = "-fx-font-size: 10px;" @@ -7174,14 +7237,17 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL - MenuBar mainScreenMenuBar = initMenuBar(); + mainScreenMenuBar = initMenuBar(); flwpne_StatusBar = new FlowPane(); flwpne_StatusBar.getChildren().add(mainScreenMenuBar); bPaneChatWindow.setTop(flwpne_StatusBar); initConnectionStateIndicatorButton(); - flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator); + // On macOS the connection state is shown in the system menu bar instead. + if (!PlatformUtils.isMacOs()) { + flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator); + } initSkedWarnIndicatorButton(); @@ -9161,6 +9227,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); + installSharedSystemMenuBar(clusterAndQSOMonScene); clusterAndQSOMonStage.setScene(clusterAndQSOMonScene); clusterAndQSOMonStage.show(); @@ -9272,6 +9339,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL stage_updateStage.setScene(new Scene(vbxUpdateWindow, chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1])); registerThemedScene(stage_updateStage.getScene()); + installSharedSystemMenuBar(stage_updateStage.getScene()); stage_updateStage.getScene().widthProperty().addListener((observable, oldValue, newValue) -> { chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = newValue.doubleValue(); requestLayoutSave(); @@ -12318,6 +12386,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL requestLayoutSave(); }); + installSharedSystemMenuBar(settingsScene); settingsStage.setScene(settingsScene); // settingsStage.getScene().getWindow().addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, this::closeWindowEvent); diff --git a/src/main/java/kst4contest/view/map/StationMapView.java b/src/main/java/kst4contest/view/map/StationMapView.java index 6c4bb98c..e916ab26 100644 --- a/src/main/java/kst4contest/view/map/StationMapView.java +++ b/src/main/java/kst4contest/view/map/StationMapView.java @@ -267,6 +267,13 @@ public final class StationMapView { return stage.isShowing(); } + /** + * @return the map window scene, e.g. to attach the shared macOS system menu bar + */ + public Scene getScene() { + return scene; + } + public void applyThemeFromPreferences() { boolean darkMode = chatPreferences.isGUI_darkModeActive(); applySceneTheme(darkMode); diff --git a/src/test/java/kst4contest/test/PlatformUtilsTest.java b/src/test/java/kst4contest/test/PlatformUtilsTest.java new file mode 100644 index 00000000..abd01ff0 --- /dev/null +++ b/src/test/java/kst4contest/test/PlatformUtilsTest.java @@ -0,0 +1,24 @@ +package kst4contest.test; + +import kst4contest.utils.PlatformUtils; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlatformUtilsTest { + + @Test + void macOsNamesAreDetected() { + assertTrue(PlatformUtils.isMacOs("Mac OS X")); + assertTrue(PlatformUtils.isMacOs("macOS")); + } + + @Test + void otherOrMissingNamesAreNotMacOs() { + assertFalse(PlatformUtils.isMacOs("Windows 11")); + assertFalse(PlatformUtils.isMacOs("Linux")); + assertFalse(PlatformUtils.isMacOs("")); + assertFalse(PlatformUtils.isMacOs(null)); + } +} diff --git a/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java b/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java new file mode 100644 index 00000000..04d531d7 --- /dev/null +++ b/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java @@ -0,0 +1,34 @@ +package kst4contest.view; + +import kst4contest.controller.On4KstConnectionState; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MacOsConnectionStateMenuTitleTest { + + @Test + void everyStateHasTheAgreedTitle() { + assertEquals("🟢 LINK: Connected", + Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.ONLINE)); + for (On4KstConnectionState state : new On4KstConnectionState[]{ + On4KstConnectionState.CONNECTING, + On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT, + On4KstConnectionState.AUTHENTICATING, + On4KstConnectionState.SYNCING_MAIN_CHAT, + On4KstConnectionState.SYNCING_SECOND_CHAT}) { + assertEquals("🟡 LINK: Connecting…", Kst4ContestApplication.macOsConnectionStateMenuTitle(state)); + } + assertEquals("🟡 LINK: Disconnecting…", + Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.STOPPING)); + assertEquals("🔴 LINK: Reconnecting…", + Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.RECONNECT_WAIT)); + assertEquals("🔴 LINK: Disconnected", + Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.DISCONNECTED)); + } + + @Test + void missingStateIsShownAsDisconnected() { + assertEquals("🔴 LINK: Disconnected", Kst4ContestApplication.macOsConnectionStateMenuTitle(null)); + } +}