diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt index 21873d53..897ddc92 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt @@ -6,14 +6,20 @@ import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.material3.DropdownMenu +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -21,8 +27,16 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.MenuBar +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties import kst4contest.controller.On4KstConnectionState import kst4contest.utils.PlatformUtils import kst4contest.view.i18n.LocalStrings @@ -163,7 +177,14 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) { val hovered by interactions.collectIsHoveredAsState() val open = rowState.openMenu == menu.title - Box { + /* + * Full bar height, so the popup anchored to this box starts at the bar's bottom edge + * rather than at the centred title text a few pixels above it. + */ + Box( + modifier = Modifier.heightIn(min = Density.TAB_MIN_HEIGHT), + contentAlignment = Alignment.Center, + ) { Text( text = menu.title, style = MaterialTheme.typography.bodyMedium, @@ -195,29 +216,90 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) { ), ) - 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) }, - ) + /* + * A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open + * flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside + * click and Escape come with a focusable popup), same MenuDefaults look; only the + * expand animation is gone, which a menu bar does not have anyway. + */ + if (open) { + Popup( + popupPositionProvider = MenuRowPopupPositionProvider, + onDismissRequest = rowState::close, + properties = PopupProperties(focusable = true), + ) { + Surface( + shape = MenuDefaults.shape, + color = MenuDefaults.containerColor, + tonalElevation = MenuDefaults.TonalElevation, + shadowElevation = MenuDefaults.ShadowElevation, + ) { + Column( + modifier = Modifier + .padding(vertical = MENU_POPUP_VERTICAL_PADDING) + .width(IntrinsicSize.Max) + .verticalScroll(rememberScrollState()), + ) { + menu.entries.forEach { entry -> + when (entry) { + is MenuEntry.Item -> DropdownMenuItem( + text = { + Text(entry.label, style = MaterialTheme.typography.bodyMedium) + }, + enabled = entry.enabled, + onClick = { rowState.pick(entry.onClick) }, + ) - MenuEntry.Separator -> - HorizontalDivider( - thickness = Density.HAIRLINE, - color = palette.separatorLine, - ) + MenuEntry.Separator -> + HorizontalDivider( + thickness = Density.HAIRLINE, + color = palette.separatorLine, + ) + } + } + } } } } } } +/** + * Places a menu-bar menu: flush under its anchor, leading edges aligned. + * + * Material3's DropdownMenu cannot be told to do this. Its position provider refuses any + * position closer than 48 dp (`MenuVerticalMargin`) to the window's top edge, and a menu + * bar sits exactly there, so every menu opened pushed down to that margin — a visible gap + * below the bar. The margin is applied after the `offset` parameter and the provider is + * not replaceable, hence this one and the hand-rolled popup in [MenuTitle]. + */ +internal object MenuRowPopupPositionProvider : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val preferredX = when (layoutDirection) { + LayoutDirection.Ltr -> anchorBounds.left + LayoutDirection.Rtl -> anchorBounds.right - popupContentSize.width + } + val x = preferredX.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)) + val below = anchorBounds.bottom + val above = anchorBounds.top - popupContentSize.height + val y = when { + below + popupContentSize.height <= windowSize.height -> below + above >= 0 -> above + else -> maxOf(0, windowSize.height - popupContentSize.height) + } + return IntOffset(x, y) + } +} + /** Enough tint to show the pointer is over a title, not enough to read as a selection. */ private const val HOVER_ALPHA = 0.35f private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f) + +/** Material3's DropdownMenuVerticalPadding, which is internal; keep the menus looking alike. */ +private val MENU_POPUP_VERTICAL_PADDING = 8.dp diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowScreenTest.kt index 0863d883..7e6e08f4 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowScreenTest.kt @@ -219,6 +219,29 @@ class MainMenuRowScreenTest { onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist() } + /** + * The menu starts at the bar's bottom edge, like any menu bar's menu. Material3's + * DropdownMenu cannot do this: its position provider refuses any y closer than 48 dp + * (MenuVerticalMargin) to the window's top edge, and the bar sits exactly there, so + * every menu opened with a visible gap hanging below the bar. + * + * The popup-semantics node is a scene-filling layout that positions the menu inside + * itself, so its own bounds say nothing; the first item is measured instead, which + * sits [MENU_POPUP_VERTICAL_PADDING] below the menu's top edge. + */ + @Test + fun theMenuOpensFlushUnderTheBar() = runComposeUiTest { + showRow(onlineState(), RecordingActions()) + + onNodeWithText("File").performClick() + val firstItem = awaitNode(inPopup("Connect to 144/432 MHz")) + + val rowBottom = onNodeWithTag(rowTag).fetchSemanticsNode().boundsInWindow.bottom + val itemTop = firstItem.fetchSemanticsNode().boundsInWindow.top + val paddingPx = 8f + assertEquals(rowBottom + paddingPx, itemTop, 0.5f, "the menu does not start at the bar's bottom edge") + } + /** A second click on an open title closes it again, the way a menu bar behaves. */ @Test fun clickingAnOpenTitleClosesIt() = runComposeUiTest { diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MenuRowPopupPositionProviderTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MenuRowPopupPositionProviderTest.kt new file mode 100644 index 00000000..f1e3b304 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MenuRowPopupPositionProviderTest.kt @@ -0,0 +1,65 @@ +package kst4contest.view.compose + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * The menu row's own popup placement, as pure arithmetic. + * + * Material3's DropdownMenuPositionProvider refuses any y closer than 48 dp to the window's + * top edge, which pushed every menu of the bar down to that margin. This provider must not: + * a menu-bar menu starts flush under its anchor, whatever the distance to the window edge. + */ +class MenuRowPopupPositionProviderTest { + + private val window = IntSize(800, 600) + private val menu = IntSize(200, 150) + + private fun positionOf( + anchor: IntRect, + window: IntSize = this.window, + direction: LayoutDirection = LayoutDirection.Ltr, + menu: IntSize = this.menu, + ) = MenuRowPopupPositionProvider.calculatePosition( + anchorBounds = anchor, + windowSize = window, + layoutDirection = direction, + popupContentSize = menu, + ) + + /** The regression itself: an anchor at the very top gets its menu directly below it. */ + @Test + fun opensFlushUnderAnAnchorAtTheTopOfTheWindow() { + val position = positionOf(IntRect(0, 0, 40, 30)) + assertEquals(IntOffset(0, 30), position) + } + + @Test + fun staysInsideTheWindowWhenTheAnchorIsNearTheRightEdge() { + val position = positionOf(IntRect(760, 0, 800, 30)) + assertEquals(IntOffset(600, 30), position) + } + + @Test + fun opensAboveTheAnchorWhenThereIsNoRoomBelow() { + val position = positionOf(IntRect(0, 560, 40, 590)) + assertEquals(IntOffset(0, 410), position) + } + + /** No room on either side: pinned to the window's bottom edge rather than clipped. */ + @Test + fun staysInsideAWindowTooSmallForTheMenu() { + val position = positionOf(IntRect(0, 40, 40, 70), window = IntSize(800, 100), menu = IntSize(200, 90)) + assertEquals(IntOffset(0, 10), position) + } + + @Test + fun alignsTheRightEdgesInRightToLeftLayouts() { + val position = positionOf(IntRect(600, 0, 640, 30), direction = LayoutDirection.Rtl) + assertEquals(IntOffset(440, 30), position) + } +} diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index c31481b1..8ecd1481 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -317,6 +317,14 @@ section records only the durable architecture and operational boundaries. - **A column's `id` is never translated.** It is what the remembered column widths and order are keyed on; translating it would read an operator's stored layout under names that no longer match. +- **The menu row's menus are a hand-rolled popup, not Material3's `DropdownMenu`** (release + 1.50): M3's position provider refuses any position closer than 48 dp to the window's top + edge (`MenuVerticalMargin`), the margin is applied after the `offset` parameter and the + provider is not replaceable — so every menu of a bar at the top of the window opened with a + visible gap below it. `MenuRowPopupPositionProvider` plus a plain `Popup`/`Surface` in + `MenuDefaults` styling opens flush under the bar; outside-click and Escape dismissal come + with a focusable skiko popup for free, only the expand animation is gone. The combo boxes + keep the stock `DropdownMenu`: they never sit at the window edge. - **The palette has three sources, and the later one wins role by role** (release 1.50): the shipped stylesheet from the classpath, then the profile's own stylesheet file if present,