From 9111b75b42c968b66ef8eb13ae1924513c32703e Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Tue, 22 Sep 2026 22:33:04 +0200 Subject: [PATCH] Use the macOS system menu bar in all windows On macOS the main menus move into the system menu bar and are shared with the secondary windows, so they stay available whichever window has the focus. The connection state indicator becomes a read-only LINK menu there instead of the status bar button. Other platforms are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../java/kst4contest/utils/PlatformUtils.java | 29 ++++++++ .../view/Kst4ContestApplication.java | 73 ++++++++++++++++++- .../kst4contest/view/map/StationMapView.java | 7 ++ .../kst4contest/test/PlatformUtilsTest.java | 24 ++++++ .../MacOsConnectionStateMenuTitleTest.java | 34 +++++++++ 5 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/main/java/kst4contest/utils/PlatformUtils.java create mode 100644 src/test/java/kst4contest/test/PlatformUtilsTest.java create mode 100644 src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java 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 6804cf55..bf899c11 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -69,6 +69,7 @@ import javafx.stage.Screen; import kst4contest.logic.BandOpportunityResolver; import kst4contest.utils.ApplicationFileLogging; 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;" @@ -7178,14 +7241,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(); @@ -9165,6 +9231,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); + installSharedSystemMenuBar(clusterAndQSOMonScene); clusterAndQSOMonStage.setScene(clusterAndQSOMonScene); clusterAndQSOMonStage.show(); @@ -9276,6 +9343,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(); @@ -12347,6 +12415,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 7f43bbc5..26ea772e 100644 --- a/src/main/java/kst4contest/view/map/StationMapView.java +++ b/src/main/java/kst4contest/view/map/StationMapView.java @@ -277,6 +277,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)); + } +}