Open menu row menus flush under the bar (#143)

Material3's DropdownMenu clamps every popup to 48dp below the window's
top edge (MenuVerticalMargin in its position provider); the margin is
applied after the offset parameter and the provider is not replaceable,
so the in-window menu bar's menus all opened with a visible gap hanging
below the bar. Replace them with a plain focusable Popup in MenuDefaults
styling and an own position provider that puts the menu directly under
its anchor, flipping above only when there is no room below. The anchor
box takes the full bar height so the menu starts at the bar's bottom
edge, not at the centred title text.


Claude-Session: https://claude.ai/code/session_01BegdsGSNNxKR6VPNPNvfoc

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude authored and GitHub committed 2026-10-18 00:08:51 +02:00
1 parent a28abe1591
commit d38b385166
4 files changed
+195 -17

No files matched your search

@@ -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 {
@@ -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)
}
}