Browse Source

central DesktopState policy for surface coordination

Milan Jurkulak 3 months ago
parent
commit
4ba406bc90

+ 56 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/state/DesktopPolicy.kt

@@ -0,0 +1,56 @@
+package org.mjdev.desktop.state
+
+/**
+ * The single, pure decision function for the three desktop surfaces (bottom bar, apps menu,
+ * control center). No Compose, no windows, no side effects — just "given the world, what should
+ * be visible". This is the readable matrix the whole desktop behaviour is derived from; the
+ * stateful wiring lives in [DesktopState].
+ *
+ * The rules, verbatim from the UX spec:
+ *  1. If nothing overlaps the bottom bar, the bar is visible. It hides only when a real window
+ *     overlaps it, or when the control center is open.
+ *  2. When the apps menu is open, the bar is always visible.
+ *  3. When the control center is open, both the bar and the menu are hidden.
+ *  4. When the control center closes and the bar is not overlapped, the bar becomes visible.
+ *  5. Clicking outside a surface hides what should hide (menu + control center); the bar hides
+ *     only if it is overlapped (handled by re-evaluating after the intent flags clear).
+ */
+object DesktopPolicy {
+    fun decide(input: DesktopInputs): DesktopDecision {
+        val controlCenter = input.controlCenterOpen
+        // rule 3: the control center takes over — the menu steps aside while it is open
+        val menu = input.menuOpen && !controlCenter
+        val bar = when {
+            controlCenter -> false // rule 3: control center hides the bar
+            menu -> true // rule 2: an open menu keeps the bar up
+            !input.barOverlapped -> true // rules 1 & 4: nothing covers it -> visible
+            else -> input.pointerInBarRevealZone // rule 1: covered -> reveal only on hover
+        }
+        return DesktopDecision(
+            bar = bar,
+            menu = menu,
+            controlCenter = controlCenter,
+        )
+    }
+}
+
+/**
+ * Everything the policy needs to make a decision. [menuOpen] / [controlCenterOpen] are user
+ * *intent* (the user asked for them), the rest are facts about the world.
+ */
+data class DesktopInputs(
+    val menuOpen: Boolean = false,
+    val controlCenterOpen: Boolean = false,
+    // a real application window overlaps the bar's strip — only knowable under the compositor;
+    // false everywhere else (plain runDesktop), which is exactly why the bar shows by default there
+    val barOverlapped: Boolean = false,
+    // pointer is over the bar or its bottom reveal hotspot (used only when the bar is overlapped)
+    val pointerInBarRevealZone: Boolean = false,
+)
+
+/** What each surface should actually be after applying [DesktopPolicy]. */
+data class DesktopDecision(
+    val bar: Boolean,
+    val menu: Boolean,
+    val controlCenter: Boolean,
+)

+ 8 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/state/SurfaceKind.kt

@@ -0,0 +1,8 @@
+package org.mjdev.desktop.state
+
+/** The desktop surfaces coordinated by [DesktopState] / [DesktopPolicy]. */
+enum class SurfaceKind {
+    Bar,
+    Menu,
+    ControlCenter,
+}

+ 7 - 16
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/appsmenu/AppsMenuWindow.kt

@@ -37,17 +37,20 @@ fun AppsMenuWindow(
     exitAnimation: ExitTransition = AppsMenuExitAnimation,
     searchTextState: MutableState<String> = rememberState(""),
     onFocusChange: ChromeWindowState.(Boolean) -> Unit = {},
+    // Close the menu through the desktop's single authority (DesktopState) so its intent stays in
+    // sync; callers wire this to desktopState.closeMenu(). Defaults to a direct hide for previews.
+    onCloseMenu: () -> Unit = { runAsync { menuState.hide() } },
     onAppClick: DesktopContextScope.(IApp) -> Unit = { app ->
         runAsync {
             app.start()
             searchTextState.clear()
-            menuState.hide()
+            onCloseMenu()
         }
     },
     onAppContextMenuClick: DesktopContextScope.(IApp) -> Unit = {},
     onCategoryContextMenuClick: DesktopContextScope.(Category) -> Unit = {},
     onUserAvatarClick: () -> Unit = {},
-    onActionClick: () -> Unit = { runAsync { menuState.hide() } },
+    onActionClick: () -> Unit = { onCloseMenu() },
     onTooltip: (item: Any?) -> Unit = {},
 ) = withDesktopContext {
     val size by rememberComputed(
@@ -90,20 +93,8 @@ fun AppsMenuWindow(
                 (menuState.enabled || appsMenuState.enabled)
         },
         onGlobalKey = {
-            onMenuKey {
-                runAsync {
-                    menuState.show()
-                    appsMenuState.show()
-                }
-                false
-            }
-            onEscape {
-                runAsync {
-                    menuState.hide()
-                    appsMenuState.hide()
-                }
-                true
-            }
+            // Escape and the menu/Super key are owned by DesktopState now (see DesktopStateDriver);
+            // here we only handle in-menu search typing while the menu is open.
             onBack {
                 appsMenuState.searchTextState.clear()
                 true

+ 0 - 41
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/controlcenter/ControlCenterWindow.kt

@@ -57,17 +57,6 @@ fun ControlCenterWindow(
             0.dp,
         )
     }
-    val mouseRange by rememberCalculated(
-        containerSize,
-        size,
-    ) {
-        MouseRange(
-            x = containerSize.width - controlCenterDividerWidth,
-            y = 0.dp,
-            width = size.width,
-            height = containerSize.height,
-        )
-    }
     ChromeWindow(
         name = "ControlCenter",
         visible = true,
@@ -79,36 +68,6 @@ fun ControlCenterWindow(
             controlCenterState.size = size
             controlCenterState.position = position
         },
-        isGlobalKeyHandlerEnabled = {
-            controlCenterState.isVisible && controlCenterState.enabled
-        },
-        onGlobalKey = {
-            onEscape {
-                runAsync {
-                    controlCenterState.hide()
-                }
-                true
-            }
-            onEscape {
-                runAsync {
-                    controlCenterState.hide()
-                }
-                true
-            }
-        },
-        isGlobalMouseHandlerEnabled = { isUserLoggedIn.orElse { false } },
-        onGlobalMouse = {
-            onPointerEnter(mouseRange) {
-                runAsync {
-//                    println("Pointer enter control center.")
-                    controlCenterState.showOrFocus()
-                }
-            }
-            // Intentionally NO pointer-leave autohide: the control center must stay open when the
-            // pointer moves off it (it vanished on every mouse-leave before, which felt broken).
-            // It closes on a click outside instead — the desktop's onLeftMouseClick hides it, and
-            // focus loss does too. Works in JVM and nested alike.
-        },
     ) {
         ControlCenter(
             controlCenterState = controlCenterState,

+ 1 - 95
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/dockbar/DockBarWindow.kt

@@ -102,39 +102,6 @@ fun DockBarWindow(
             containerSize.height - size.height,
         )
     }
-    val mouseRange by rememberCalculated(
-        containerSize,
-        size,
-        position,
-    ) {
-        // While the control center is open the reveal hotspot stops at its left edge —
-        // otherwise touching the bottom of the control center itself would close it and
-        // pop the dock instead.
-        val revealWidth =
-            if (controlCenterState.isVisible) {
-                (containerSize.width - controlCenterState.size.width).coerceAtLeast(0.dp)
-            } else {
-                containerSize.width
-            }
-        MouseRange(
-            x = 0.dp,
-            y = containerSize.height - controlCenterDividerWidth,
-            width = revealWidth,
-            height = size.height,
-        )
-    }
-    // bounds of the *expanded* dock — used to autohide on pointer-leave (decoupled from focus,
-    // per the docking UX spec). The mouseRange above is only the thin bottom reveal hotspot, so
-    // it cannot tell when the pointer has left the visible dock; this range can.
-    val leaveRange by rememberCalculated(containerSize) {
-        val expandedHeight = panelHeight(true)
-        MouseRange(
-            x = 0.dp,
-            y = containerSize.height - expandedHeight,
-            width = containerSize.width,
-            height = expandedHeight,
-        )
-    }
     ChromeWindow(
         name = "DockBar",
         visible = true,
@@ -145,56 +112,6 @@ fun DockBarWindow(
         onCreated = {
             runAsync { panelState.applyBounds(position, size) }
         },
-        isGlobalKeyHandlerEnabled = {
-            panelState.isVisible && panelState.enabled
-        },
-        onGlobalKey = {
-            onEscape {
-                runAsync {
-                    menuState.hide()
-                    panelState.hide()
-                }
-                true
-            }
-            onMenuKey {
-                runAsync {
-                    panelState.showOrFocus()
-                    menuState.showOrFocus()
-                }
-                true
-            }
-        },
-        isGlobalMouseHandlerEnabled = {
-            isUserLoggedIn && panelState.enabled
-        },
-        onGlobalMouse = {
-            onPointerEnter(mouseRange) {
-                runAsync {
-                    // show() only on a real reveal (not showOrFocus): re-focusing an already-open
-                    // dock churns focus-follows-mouse and flip-flops the size, drifting geometry.
-                    if (menuState.isNotVisible && panelState.isNotVisible) {
-                        // Revealing the dock while the control center is open would put the dock
-                        // BELOW the control center window (JVM z-order) and fight it for focus.
-                        // The two never show together: a deliberate reveal closes the control
-                        // center first (same as clicking outside it).
-                        if (controlCenterState.isVisible) {
-                            controlCenterState.hide()
-                        }
-                        panelState.show()
-                    }
-                }
-            }
-            // autohide when the pointer leaves the expanded dock (and no menu is open, which
-            // must keep the dock alive). Driven by pointer position, not focus, so it no longer
-            // gets stuck open over the non-focusable desktop.
-            onPointerLeave(leaveRange) {
-                runAsync {
-                    if (panelState.isVisible && menuState.isNotVisible) {
-                        panelState.hide()
-                    }
-                }
-            }
-        },
     ) {
         DesktopPanel(
             iconSize = iconSize,
@@ -210,18 +127,7 @@ fun DockBarWindow(
             onAppContextMenuClick = onAppContextMenuClick,
             onLanguageClick = onLanguageClick,
             onTooltip = onTooltip,
-            onFocusChange = { focused ->
-                // Only re-show on a real transition into focus while hidden; re-showing an already
-                // visible dock on every focus flicker (focus-follows-mouse) was a churn source.
-                if (focused && panelState.isNotVisible) {
-                    runAsync {
-                        panelState.show()
-                        if (menuState.isVisible) {
-                            menuState.focus()
-                        }
-                    }
-                }
-            },
+            onFocusChange = {},
         )
     }
     // Single atomic driver for the dock's geometry: position + size applied absolutely together

+ 65 - 45
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt

@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.tooling.preview.Preview
@@ -25,6 +26,10 @@ import org.mjdev.desktop.components.sliding.base.VisibilityState.Companion.remem
 import org.mjdev.desktop.components.tooltip.TooltipState
 import org.mjdev.desktop.components.tooltip.TooltipWindow
 import org.mjdev.desktop.components.tooltip.rememberTooltipState
+import org.mjdev.desktop.state.DesktopState
+import org.mjdev.desktop.state.DesktopStateDriver
+import org.mjdev.desktop.data.PanelLocation
+import org.mjdev.desktop.state.SurfaceKind
 import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
 import org.mjdev.desktop.extensions.Compose.isDesign
 import org.mjdev.desktop.extensions.Compose.preview
@@ -42,12 +47,13 @@ fun MainWindow() = withDesktopContext {
         rememberChromeWindowState(
             visible = isDesign,
         )
+    // The bar is visible by default now; DesktopState hides it only when a window overlaps it or
+    // the control center opens (see DesktopPolicy). No per-window autohide timer anymore.
     val panelState =
         rememberChromeWindowState(
-            hideDelay = panelHideDelay,
-            visible = isDesign || !panelAutoHideEnabled,
-            enabled = panelAutoHideEnabled,
+            visible = true,
         )
+    val desktopState = remember { DesktopState(scope) }
     val menuState =
         rememberChromeWindowState(
             visible = isDesign,
@@ -72,21 +78,51 @@ fun MainWindow() = withDesktopContext {
 //        println("Tooltip: $item")
         tooltipState.show(item)
     }
-    // When the control center opens it covers the desktop, so the dock bar and any open menu
-    // step aside (this also frees focus so the control center can actually take it).
-    // The dock is dropped IMMEDIATELY (isVisible, not hide()) — hide() honours the multi-second
-    // panelHideDelay, which left the dock lingering *below* the control center for seconds.
-    LaunchedEffect(controlCenterState.isVisible) {
-        if (controlCenterState.isVisible) {
-            panelState.isVisible = false
-            if (menuState.isVisible) {
-                menuState.hide()
-            }
-            if (appsMenuState.isVisible) {
-                appsMenuState.hide()
-            }
-        }
+    // Register the three surfaces with the central DesktopState. It owns all show/hide decisions
+    // (bar/menu/control-center coordination) via DesktopPolicy; the windows below just render.
+    DisposableEffect(desktopState, containerSize) {
+        val cw = containerSize.width.value.toDouble()
+        val ch = containerSize.height.value.toDouble()
+        val edge = controlCenterDividerWidth.value.toDouble()
+        fun rectOf(state: org.mjdev.desktop.windows.ChromeWindowState) =
+            DesktopState.ScreenRect(
+                left = state.position.x.value.toDouble(),
+                top = state.position.y.value.toDouble(),
+                right = (state.position.x + state.size.width).value.toDouble(),
+                bottom = (state.position.y + state.size.height).value.toDouble(),
+            )
+        desktopState.register(
+            kind = SurfaceKind.Bar,
+            window = panelState,
+            bounds = { rectOf(panelState) },
+            // reveal strip along whichever edge the dock lives on (only used when overlapped).
+            // Derived from PanelLocation so drag-to-edge later needs no change here.
+            revealHotspot = {
+                when (panelLocation) {
+                    PanelLocation.Bottom -> DesktopState.ScreenRect(0.0, ch - edge, cw, ch)
+                    PanelLocation.Top -> DesktopState.ScreenRect(0.0, 0.0, cw, edge)
+                    PanelLocation.Left -> DesktopState.ScreenRect(0.0, 0.0, edge, ch)
+                    PanelLocation.Right -> DesktopState.ScreenRect(cw - edge, 0.0, cw, ch)
+                }
+            },
+        )
+        desktopState.register(
+            kind = SurfaceKind.Menu,
+            window = menuState,
+            bounds = { rectOf(menuState) },
+            onApply = { visible -> appsMenuState.isVisible = visible },
+        )
+        desktopState.register(
+            kind = SurfaceKind.ControlCenter,
+            window = controlCenterState,
+            bounds = { rectOf(controlCenterState) },
+            // right reveal strip — hovering it opens the control center
+            revealHotspot = { DesktopState.ScreenRect(cw - edge, 0.0, cw, ch) },
+        )
+        onDispose { }
     }
+    // One place feeds global pointer + clicks + Escape into DesktopState.
+    DesktopStateDriver(desktopState)
     DesktopWindow(
         panelState = panelState,
         controlCenterState = controlCenterState,
@@ -113,11 +149,8 @@ fun MainWindow() = withDesktopContext {
 //                )
             },
             onLeftMouseClick = {
-                runAsync {
-                    panelState.hide()
-                    menuState.hide()
-                    controlCenterState.hide()
-                }
+                // desktop click = click outside every surface -> dismiss menu + control center
+                desktopState.dismissTransients()
             },
             onRightMouseClick = {
 //                contextMenuState.show()
@@ -145,9 +178,9 @@ fun MainWindow() = withDesktopContext {
         panelState = panelState,
         menuState = menuState,
         controlCenterState = controlCenterState,
-        // Autohide is driven purely by pointer-leave (see DockBarWindow.onGlobalMouse), NOT by
-        // focus. Hiding on focus-loss flooded hide() under focus-follows-mouse (every pointer
-        // flicker over a non-focused window fired a hide) and flip-flopped the dock 16<->80.
+        // The menu button toggles the apps menu through DesktopState (which also closes the
+        // control center). All show/hide policy lives there now, not in the window.
+        onMenuIconClicked = { desktopState.toggleMenu() },
         onFocusChange = {},
     )
     AppsMenuWindow(
@@ -155,27 +188,17 @@ fun MainWindow() = withDesktopContext {
         panelState = panelState,
         appsMenuState = appsMenuState,
         onTooltip = onTooltip,
-        onFocusChange = { focused ->
-            Log.d("menu focus : $focused")
-//            if (!focused) {
-//                menuState.hide()
-//            }
-        },
+        // Route menu-close (app launched, action clicked) through DesktopState so its intent stays
+        // in sync — a direct menuState.hide() would desync and the menu could pop back.
+        onCloseMenu = { desktopState.closeMenu() },
+        onFocusChange = {},
     )
     ControlCenterWindow(
         onTooltip = onTooltip,
         controlCenterState = controlCenterState,
-        // Close on focus-loss = the "click outside" dismissal. Guard on isVisible so it does not
-        // fire a hide() on every focus flicker while already hidden (focus-follows-mouse would
-        // otherwise flood runAsync). Pointer-leave no longer hides it, so it stays open until a
-        // real click moves focus away (or a desktop click via onLeftMouseClick).
-        onFocusChange = { focused ->
-            if (!focused && controlCenterState.isVisible) {
-                runAsync {
-                    controlCenterState.hide()
-                }
-            }
-        },
+        // Dismissal (click-outside / Escape) is owned by DesktopState now, so the window no longer
+        // hides itself on focus loss (that fought focus-follows-mouse).
+        onFocusChange = {},
     )
     // Standalone auto-hide tooltip window — every onTooltip above lands here.
     TooltipWindow(
@@ -216,9 +239,6 @@ fun MainWindow() = withDesktopContext {
             Log.i("App ended.")
         }
     }
-    LaunchedEffect(menuState.isVisible) {
-        appsMenuState.isVisible = menuState.isVisible
-    }
 }
 
 // todo

+ 31 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/mouseevents/GlobalClickListener.kt

@@ -0,0 +1,31 @@
+package org.mjdev.desktop.helpers.mouseevents
+
+import java.awt.AWTEvent
+import java.awt.Toolkit
+import java.awt.event.AWTEventListener
+import java.awt.event.MouseEvent
+
+/**
+ * Process-global mouse-press listener. Like [org.mjdev.desktop.helpers.keyevents.GlobalKeyListener]
+ * but for clicks: it catches a press on ANY AWT window of this app and reports it in absolute
+ * screen coordinates, so [org.mjdev.desktop.state.DesktopState] can decide whether the click
+ * landed outside the open surfaces (and dismiss them).
+ */
+class GlobalClickListener(
+    private val onPress: (screenX: Int, screenY: Int, button: Int) -> Unit,
+) : AWTEventListener {
+    init {
+        Toolkit.getDefaultToolkit().addAWTEventListener(this, AWTEvent.MOUSE_EVENT_MASK)
+    }
+
+    fun dispose() {
+        Toolkit.getDefaultToolkit().removeAWTEventListener(this)
+    }
+
+    override fun eventDispatched(event: AWTEvent?) {
+        val mouse = event as? MouseEvent ?: return
+        if (mouse.id != MouseEvent.MOUSE_PRESSED) return
+        val onScreen = mouse.locationOnScreen
+        onPress(onScreen.x, onScreen.y, mouse.button)
+    }
+}

+ 171 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/state/DesktopState.kt

@@ -0,0 +1,171 @@
+package org.mjdev.desktop.state
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.mjdev.desktop.windows.ChromeWindowState
+
+/**
+ * The brain of the desktop. Surfaces (bottom bar, apps menu, control center) *register*
+ * themselves with a window state and lambdas describing their on-screen rectangle and their
+ * hover-reveal zone; nothing about the individual surfaces is hardcoded here. The state then
+ * turns raw input — pointer moves, clicks, Escape, the menu button, and window-overlap news
+ * from the compositor — into intent, runs it through the pure [DesktopPolicy], and applies the
+ * resulting visibility to each registered window.
+ *
+ * Because overlap is only known under the compositor, plain `runDesktop` reports "nothing
+ * overlaps", so the bar is simply always visible there — no fragile global-pointer reveal needed.
+ *
+ * Add a new surface or rule by registering another kind and extending [DesktopPolicy]; the wiring
+ * here stays the same.
+ */
+class DesktopState(
+    private val scope: CoroutineScope,
+) {
+    /** An axis-aligned screen rectangle in pixels (pointer + window coords are 1:1 px↔dp). */
+    data class ScreenRect(
+        val left: Double,
+        val top: Double,
+        val right: Double,
+        val bottom: Double,
+    ) {
+        fun contains(
+            x: Double,
+            y: Double,
+        ) = x in left..right && y in top..bottom
+    }
+
+    private class Surface(
+        val window: ChromeWindowState,
+        val bounds: () -> ScreenRect,
+        val revealHotspot: () -> ScreenRect?,
+        val onApply: (visible: Boolean) -> Unit,
+    )
+
+    private val surfaces = linkedMapOf<SurfaceKind, Surface>()
+
+    // user intent (what the user asked to be open)
+    private var menuOpen = false
+    private var controlCenterOpen = false
+
+    // facts about the world
+    private var barOverlapped = false
+    private var pointerInBarZone = false
+
+    fun register(
+        kind: SurfaceKind,
+        window: ChromeWindowState,
+        bounds: () -> ScreenRect,
+        revealHotspot: () -> ScreenRect? = { null },
+        onApply: (visible: Boolean) -> Unit = {},
+    ) {
+        surfaces[kind] = Surface(window, bounds, revealHotspot, onApply)
+        reconcile()
+    }
+
+    // ---- input events ------------------------------------------------------
+
+    /** The global pointer moved. Reveals the control center on its edge, tracks the bar zone. */
+    fun onPointerMove(
+        x: Int,
+        y: Int,
+    ) {
+        val px = x.toDouble()
+        val py = y.toDouble()
+        // control center opens when the pointer reaches its reveal edge (stays open until dismissed)
+        surfaces[SurfaceKind.ControlCenter]?.revealHotspot?.invoke()?.let { hotspot ->
+            if (hotspot.contains(px, py)) controlCenterOpen = true
+        }
+        // the bar's zone = the bar itself plus its bottom reveal strip; only matters when overlapped
+        pointerInBarZone =
+            surfaces[SurfaceKind.Bar]?.let { bar ->
+                bar.bounds().contains(px, py) || bar.revealHotspot()?.contains(px, py) == true
+            } ?: false
+        reconcile()
+    }
+
+    /** A global click. Anything outside every open surface dismisses the menu + control center. */
+    fun onClick(
+        x: Int,
+        y: Int,
+    ) {
+        val px = x.toDouble()
+        val py = y.toDouble()
+        val insideAnOpenSurface =
+            surfaces.values.any { s -> s.window.isVisible && s.bounds().contains(px, py) }
+        if (!insideAnOpenSurface) {
+            menuOpen = false
+            controlCenterOpen = false
+            reconcile()
+        }
+    }
+
+    fun onEscape() = dismissTransients()
+
+    /** Dismiss the menu + control center (e.g. a click on empty desktop, or Escape). */
+    fun dismissTransients() {
+        menuOpen = false
+        controlCenterOpen = false
+        reconcile()
+    }
+
+    fun toggleMenu() {
+        menuOpen = !menuOpen
+        if (menuOpen) controlCenterOpen = false
+        reconcile()
+    }
+
+    fun openMenu() {
+        menuOpen = true
+        controlCenterOpen = false
+        reconcile()
+    }
+
+    fun closeMenu() {
+        menuOpen = false
+        reconcile()
+    }
+
+    fun openControlCenter() {
+        controlCenterOpen = true
+        reconcile()
+    }
+
+    /** The compositor tells us whether a real window now overlaps the bar strip. */
+    fun setBarOverlapped(overlapped: Boolean) {
+        if (barOverlapped != overlapped) {
+            barOverlapped = overlapped
+            reconcile()
+        }
+    }
+
+    // ---- decision + apply --------------------------------------------------
+
+    private fun reconcile() {
+        val decision =
+            DesktopPolicy.decide(
+                DesktopInputs(
+                    menuOpen = menuOpen,
+                    controlCenterOpen = controlCenterOpen,
+                    barOverlapped = barOverlapped,
+                    pointerInBarRevealZone = pointerInBarZone,
+                ),
+            )
+        scope.launch {
+            // control center first (it gates the others), then menu, then bar
+            apply(SurfaceKind.ControlCenter, decision.controlCenter)
+            apply(SurfaceKind.Menu, decision.menu)
+            apply(SurfaceKind.Bar, decision.bar)
+        }
+    }
+
+    private suspend fun apply(
+        kind: SurfaceKind,
+        visible: Boolean,
+    ) {
+        val surface = surfaces[kind] ?: return
+        if (surface.window.isVisible != visible) {
+            if (visible) surface.window.show() else surface.window.hide(force = true)
+        }
+        surface.onApply(visible)
+    }
+}

+ 50 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/state/DesktopStateDriver.kt

@@ -0,0 +1,50 @@
+package org.mjdev.desktop.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import org.mjdev.desktop.helpers.keyevents.GlobalKeyListener
+import org.mjdev.desktop.helpers.mouseevents.CompositorMouseSource
+import org.mjdev.desktop.helpers.mouseevents.GlobalClickListener
+import org.mjdev.desktop.helpers.mouseevents.GlobalMouseListener
+import java.awt.event.KeyEvent
+
+/**
+ * Feeds raw global input into [DesktopState]: pointer moves (compositor feed when running, AWT
+ * polling otherwise), clicks (for dismiss-on-click-outside), and Escape. One place wires the
+ * whole desktop's input, replacing the per-window global handlers that used to fight each other.
+ */
+@Suppress("FunctionName")
+@Composable
+fun DesktopStateDriver(desktopState: DesktopState) {
+    val scope = rememberCoroutineScope()
+    DisposableEffect(desktopState) {
+        // pointer: prefer the compositor's reliable feed, fall back to AWT global polling
+        val ipc = CompositorMouseSource.connect(scope) { p -> desktopState.onPointerMove(p.x, p.y) }
+        val awtPointer =
+            if (ipc == null) {
+                GlobalMouseListener(scope = scope) { p -> desktopState.onPointerMove(p.x, p.y) }
+            } else {
+                null
+            }
+        val clicks = GlobalClickListener { x, y, _ -> desktopState.onClick(x, y) }
+        val keys =
+            GlobalKeyListener { event ->
+                if (event.id != KeyEvent.KEY_RELEASED) return@GlobalKeyListener
+                when (event.keyCode) {
+                    // Escape closes whatever is open (menu or control center); the bar stays if
+                    // nothing overlaps it — DesktopPolicy works that out.
+                    KeyEvent.VK_ESCAPE -> desktopState.onEscape()
+                    // Super / Menu key toggles the apps menu: opening it also closes the control
+                    // center and keeps the bar visible.
+                    KeyEvent.VK_WINDOWS, KeyEvent.VK_CONTEXT_MENU -> desktopState.toggleMenu()
+                }
+            }
+        onDispose {
+            ipc?.dispose()
+            awtPointer?.dispose()
+            clicks.dispose()
+            keys.dispose()
+        }
+    }
+}