package ui import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.rememberWindowState import data.WindowBounds import data.WindowPrefsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.withContext import java.awt.GraphicsEnvironment import java.awt.Rectangle import java.awt.Toolkit // How long a window has to sit still after a move/resize before its bounds are written // to window_prefs.json - dragging fires a position change per mouse event. private const val BOUNDS_SAVE_DEBOUNCE_MS = 500L // Cascade layout for windows with no usable saved position: the first one opens // CASCADE_MARGIN_DP in from the top-left of the primary screen's usable area (taskbar // excluded), each next one CASCADE_STEP_DP further down and right, wrapping back to the // start before a window would run off the screen. private const val CASCADE_MARGIN_DP = 40 private const val CASCADE_STEP_DP = 32 // A saved position only counts as "on a connected monitor" if at least this much of the // window's top strip (where the drag handle / title bar is) lands on one screen, so a // restored window can always be grabbed and moved. private const val TITLE_STRIP_HEIGHT_DP = 32 private const val MIN_VISIBLE_TITLE_WIDTH_DP = 80 // Saved sizes smaller than this are treated as invalid and replaced by the default. private const val MIN_WINDOW_WIDTH_DP = 200 private const val MIN_WINDOW_HEIGHT_DP = 120 private object WindowCascade { private var next = 0 @Synchronized fun nextPosition(size: DpSize): WindowPosition { val area = primaryUsableArea() val roomX = area.width - CASCADE_MARGIN_DP - size.width.value val roomY = area.height - CASCADE_MARGIN_DP - size.height.value val stepsThatFit = (minOf(roomX, roomY) / CASCADE_STEP_DP).toInt().coerceAtLeast(0) val step = next++ % (stepsThatFit + 1) val offset = CASCADE_MARGIN_DP + step * CASCADE_STEP_DP return WindowPosition.Absolute((area.x + offset).dp, (area.y + offset).dp) } } private fun primaryUsableArea(): Rectangle = try { val config = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration val bounds = config.bounds val insets = Toolkit.getDefaultToolkit().getScreenInsets(config) Rectangle( bounds.x + insets.left, bounds.y + insets.top, bounds.width - insets.left - insets.right, bounds.height - insets.top - insets.bottom ) } catch (e: Exception) { Rectangle(0, 0, 1280, 720) } private fun screenBounds(): List = try { GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { it.defaultConfiguration.bounds } } catch (e: Exception) { emptyList() } private fun isOnConnectedScreen(x: Float, y: Float, width: Float): Boolean { val strip = Rectangle(x.toInt(), y.toInt(), width.toInt().coerceAtLeast(1), TITLE_STRIP_HEIGHT_DP) val minVisibleWidth = minOf(MIN_VISIBLE_TITLE_WIDTH_DP, strip.width) return screenBounds().any { screen -> val visible = screen.intersection(strip) !visible.isEmpty && visible.width >= minVisibleWidth && visible.height >= TITLE_STRIP_HEIGHT_DP / 2 } } private fun resolveInitialPlacement(saved: WindowBounds?, defaultSize: DpSize): Pair { val savedWidth = saved?.width val savedHeight = saved?.height val size = if (savedWidth != null && savedHeight != null && savedWidth >= MIN_WINDOW_WIDTH_DP && savedHeight >= MIN_WINDOW_HEIGHT_DP ) { // Never restore a window bigger than the largest connected screen (e.g. it was // saved on a monitor that has since been unplugged). val screens = screenBounds() val maxWidth = screens.maxOfOrNull { it.width }?.toFloat() ?: savedWidth val maxHeight = screens.maxOfOrNull { it.height }?.toFloat() ?: savedHeight DpSize(minOf(savedWidth, maxWidth).dp, minOf(savedHeight, maxHeight).dp) } else { defaultSize } val savedX = saved?.x val savedY = saved?.y val position = if (savedX != null && savedY != null && isOnConnectedScreen(savedX, savedY, size.width.value)) { WindowPosition.Absolute(savedX.dp, savedY.dp) } else { WindowCascade.nextPosition(size) } return position to size } private fun saveBoundsIfFloating(state: WindowState, prefsRepo: WindowPrefsRepository, storageId: String) { // Minimized windows report a parking position far off-screen on Windows, and a // maximized/fullscreen window's bounds are the screen's - neither is a position the // user chose, so only plain floating bounds are remembered. if (state.isMinimized || state.placement != WindowPlacement.Floating) return val position = state.position val size = state.size if (position !is WindowPosition.Absolute || !size.isSpecified) return prefsRepo.setWindowBounds( storageId, WindowBounds(position.x.value, position.y.value, size.width.value, size.height.value) ) } /** * A WindowState that starts at the window's saved position and size from window_prefs.json * (if that position is still on a connected monitor), otherwise at the next cascade slot, * and writes its bounds back whenever the window is moved or resized and when it closes. */ @Composable fun rememberPersistentWindowState( prefsRepo: WindowPrefsRepository, storageId: String, defaultSize: DpSize ): WindowState { val (initialPosition, initialSize) = remember(storageId) { resolveInitialPlacement(prefsRepo.boundsFor(storageId), defaultSize) } val state = rememberWindowState(position = initialPosition, size = initialSize) LaunchedEffect(state, storageId) { snapshotFlow { listOf(state.position, state.size, state.placement, state.isMinimized) } .collectLatest { delay(BOUNDS_SAVE_DEBOUNCE_MS) withContext(Dispatchers.IO) { saveBoundsIfFloating(state, prefsRepo, storageId) } } } // Catches a move/resize made less than BOUNDS_SAVE_DEBOUNCE_MS before the window closed. DisposableEffect(state, storageId) { onDispose { saveBoundsIfFloating(state, prefsRepo, storageId) } } return state }