mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(window): implement window state persistence and restoration
This commit is contained in:
parent
9db35e092b
commit
9fba2aa33d
@ -77,6 +77,12 @@ class Database(val paths: Paths) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val windowStateDataStore: DataStore<Preferences> by lazy {
|
||||||
|
PreferenceDataStoreFactory.createWithPath(
|
||||||
|
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_window.preferences_pb".toPath() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
/** Preference files written by previous launches; their presence means an upgrade. */
|
/** Preference files written by previous launches; their presence means an upgrade. */
|
||||||
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
||||||
@ -95,4 +101,5 @@ object DatabaseKeys {
|
|||||||
val AUDIO_PLAYER_QUEUE_STATE_KEY = stringPreferencesKey("audio_player_queue_state")
|
val AUDIO_PLAYER_QUEUE_STATE_KEY = stringPreferencesKey("audio_player_queue_state")
|
||||||
val LOCAL_MEDIA_CACHE_STATE_KEY = stringPreferencesKey("local_media_cache_state")
|
val LOCAL_MEDIA_CACHE_STATE_KEY = stringPreferencesKey("local_media_cache_state")
|
||||||
val LOCAL_MEDIA_LAST_SCAN_AT_KEY = longPreferencesKey("local_media_last_scan_at")
|
val LOCAL_MEDIA_LAST_SCAN_AT_KEY = longPreferencesKey("local_media_last_scan_at")
|
||||||
|
val WINDOW_STATE_KEY = stringPreferencesKey("window_state")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,9 +30,12 @@ import org.koin.core.module.dsl.createdAtStart
|
|||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.core.module.dsl.withOptions
|
import org.koin.core.module.dsl.withOptions
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
import org.spotube.spotube.core.db.Database
|
||||||
|
import org.spotube.spotube.core.window.WindowStateRepository
|
||||||
|
|
||||||
actual val platformModules = module {
|
actual val platformModules = module {
|
||||||
singleOf(::Paths)
|
singleOf(::Paths)
|
||||||
|
single { WindowStateRepository(get<Database>()) }
|
||||||
single<AudioPlayerInterface> { AudioPlayer(Unit) }
|
single<AudioPlayerInterface> { AudioPlayer(Unit) }
|
||||||
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
||||||
single<ShareService> { JvmShareService() }
|
single<ShareService> { JvmShareService() }
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.window
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.spotube.spotube.core.db.Database
|
||||||
|
import org.spotube.spotube.core.db.DatabaseKeys
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What survives a restart: the last *normal* (floating) size, and whether the window was
|
||||||
|
* maximized. The size is kept while maximized so un-maximizing after a restart restores it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PersistedWindowState(
|
||||||
|
val widthDp: Float,
|
||||||
|
val heightDp: Float,
|
||||||
|
val maximized: Boolean
|
||||||
|
) {
|
||||||
|
val size: DpSize get() = DpSize(widthDp.dp, heightDp.dp)
|
||||||
|
val placement: WindowPlacement
|
||||||
|
get() = if (maximized) WindowPlacement.Maximized else WindowPlacement.Floating
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(size: DpSize, placement: WindowPlacement) = PersistedWindowState(
|
||||||
|
widthDp = size.width.value,
|
||||||
|
heightDp = size.height.value,
|
||||||
|
maximized = placement == WindowPlacement.Maximized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WindowStateRepository internal constructor(private val dataStore: DataStore<Preferences>) {
|
||||||
|
constructor(database: Database) : this(database.windowStateDataStore)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/** Returns null when nothing was saved yet or the stored value is unreadable. */
|
||||||
|
suspend fun load(): PersistedWindowState? = runCatching {
|
||||||
|
val raw = dataStore.data.first()[DatabaseKeys.WINDOW_STATE_KEY] ?: return null
|
||||||
|
json.decodeFromString<PersistedWindowState>(raw)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
suspend fun save(state: PersistedWindowState) {
|
||||||
|
runCatching {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[DatabaseKeys.WINDOW_STATE_KEY] = json.encodeToString(PersistedWindowState.serializer(), state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a restored [size] usable: never smaller than [min], and never bigger than the
|
||||||
|
* available screen ([maxWidth] x [maxHeight]) so a size saved on a larger monitor can't open
|
||||||
|
* off-screen. A null max means the screen size is unknown, so only [min] is enforced.
|
||||||
|
*/
|
||||||
|
fun DpSize.coerceForScreen(
|
||||||
|
min: DpSize,
|
||||||
|
maxWidth: Dp?,
|
||||||
|
maxHeight: Dp?
|
||||||
|
): DpSize {
|
||||||
|
val width = width.coerceAtLeast(min.width).let { if (maxWidth != null) it.coerceAtMost(maxOf(maxWidth, min.width)) else it }
|
||||||
|
val height = height.coerceAtLeast(min.height).let { if (maxHeight != null) it.coerceAtMost(maxOf(maxHeight, min.height)) else it }
|
||||||
|
return DpSize(width, height)
|
||||||
|
}
|
||||||
@ -18,18 +18,37 @@
|
|||||||
package org.spotube.spotube
|
package org.spotube.spotube
|
||||||
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.isSpecified
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
import com.sun.jna.Library
|
import dev.nucleusframework.application.DecoratedWindow
|
||||||
import com.sun.jna.Native
|
import dev.nucleusframework.application.NucleusBackend
|
||||||
|
import dev.nucleusframework.application.nucleusApplication
|
||||||
|
import dev.nucleusframework.window.tao.TaoScreenGeometry
|
||||||
|
import io.github.vinceglb.filekit.FileKit
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.get
|
||||||
import org.spotube.spotube.core.di.initKoin
|
import org.spotube.spotube.core.di.initKoin
|
||||||
import org.spotube.spotube.core.deeplink.ExternalUriHandler
|
import org.spotube.spotube.core.i18n.AppLocaleProvider
|
||||||
import org.spotube.spotube.core.newpipe.NewPipeDownloader
|
import org.spotube.spotube.core.newpipe.NewPipeDownloader
|
||||||
import org.spotube.spotube.core.paths.Paths
|
import org.spotube.spotube.core.paths.Paths
|
||||||
import org.spotube.spotube.core.systemtray.SystemTray
|
import org.spotube.spotube.core.systemtray.SystemTray
|
||||||
@ -37,19 +56,11 @@ import org.spotube.spotube.core.systemtray.SystemTrayService
|
|||||||
import org.spotube.spotube.core.ui.component.LocalApplicationScope
|
import org.spotube.spotube.core.ui.component.LocalApplicationScope
|
||||||
import org.spotube.spotube.core.ui.component.LocalWindowScope
|
import org.spotube.spotube.core.ui.component.LocalWindowScope
|
||||||
import org.spotube.spotube.core.ui.component.LocalWindowState
|
import org.spotube.spotube.core.ui.component.LocalWindowState
|
||||||
import org.spotube.spotube.core.i18n.AppLocaleProvider
|
import org.spotube.spotube.core.window.PersistedWindowState
|
||||||
|
import org.spotube.spotube.core.window.WindowStateRepository
|
||||||
|
import org.spotube.spotube.core.window.coerceForScreen
|
||||||
import org.spotube.spotube.modules.settings.SettingsProvider
|
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||||
import org.spotube.spotube.modules.settings.SupportedLanguages
|
import org.spotube.spotube.modules.settings.SupportedLanguages
|
||||||
import dev.nucleusframework.application.DecoratedWindow
|
|
||||||
import dev.nucleusframework.application.NucleusBackend
|
|
||||||
import dev.nucleusframework.application.nucleusApplication
|
|
||||||
import io.github.vinceglb.filekit.FileKit
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
import org.koin.core.component.get
|
|
||||||
|
|
||||||
object KoinPathsProvider : KoinComponent {
|
object KoinPathsProvider : KoinComponent {
|
||||||
val paths: Paths get() = get()
|
val paths: Paths get() = get()
|
||||||
@ -58,9 +69,65 @@ object KoinPathsProvider : KoinComponent {
|
|||||||
private object KoinServicesProvider : KoinComponent {
|
private object KoinServicesProvider : KoinComponent {
|
||||||
val systemTrayService: SystemTrayService get() = get()
|
val systemTrayService: SystemTrayService get() = get()
|
||||||
val settingsProvider: SettingsProvider get() = get()
|
val settingsProvider: SettingsProvider get() = get()
|
||||||
|
val windowStateRepository: WindowStateRepository get() = get()
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
private const val WINDOW_SCREEN_FRACTION = 0.85f
|
||||||
|
private const val WINDOW_ASPECT_RATIO = 16f / 10f
|
||||||
|
private val MAX_WINDOW_WIDTH = 1440.dp
|
||||||
|
private val MAX_WINDOW_HEIGHT = 960.dp
|
||||||
|
private val MIN_WINDOW_SIZE = DpSize(800.dp, 500.dp)
|
||||||
|
private const val WINDOW_STATE_SAVE_DEBOUNCE_MS = 500L
|
||||||
|
|
||||||
|
/** Largest [WINDOW_ASPECT_RATIO] size that fits inside [maxWidth] x [maxHeight]. */
|
||||||
|
private fun fitAspectRatio(maxWidth: Dp, maxHeight: Dp): DpSize {
|
||||||
|
val width = minOf(maxWidth, maxHeight * WINDOW_ASPECT_RATIO)
|
||||||
|
return DpSize(width, width / WINDOW_ASPECT_RATIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 16:10 window that fits in 85% of the primary monitor's work area (screen minus
|
||||||
|
* taskbar/dock), capped at 1440x960.
|
||||||
|
*
|
||||||
|
* Uses Nucleus' Tao screen geometry rather than AWT, which doesn't exist on the Tao runtime.
|
||||||
|
* The work area is in physical pixels, so it's divided by the monitor scale factor to get dp.
|
||||||
|
* Falls back to the cap box when the platform bridge can't report a monitor.
|
||||||
|
*/
|
||||||
|
private fun initialWindowSize(): DpSize {
|
||||||
|
val workArea = availableScreenSize()
|
||||||
|
?: return fitAspectRatio(MAX_WINDOW_WIDTH, MAX_WINDOW_HEIGHT)
|
||||||
|
|
||||||
|
return fitAspectRatio(
|
||||||
|
maxWidth = (workArea.width * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_WIDTH),
|
||||||
|
maxHeight = (workArea.height * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_HEIGHT))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The primary monitor's work area in dp, or null when the platform bridge can't report it. */
|
||||||
|
private fun availableScreenSize(): DpSize? {
|
||||||
|
val workArea = runCatching { TaoScreenGeometry.primaryMonitorWorkAreaPx() }.getOrNull()
|
||||||
|
if (workArea == null || workArea.size < 4 || workArea[2] <= 0 || workArea[3] <= 0) return null
|
||||||
|
|
||||||
|
val scale = runCatching { TaoScreenGeometry.primaryMonitorScaleFactor() }
|
||||||
|
.getOrDefault(1f)
|
||||||
|
.takeIf { it > 0f } ?: 1f
|
||||||
|
|
||||||
|
return DpSize((workArea[2] / scale).dp, (workArea[3] / scale).dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved size if there is one (clamped to this screen), otherwise the default for a first launch. */
|
||||||
|
private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowState {
|
||||||
|
if (saved == null || saved.widthDp <= 0f || saved.heightDp <= 0f) {
|
||||||
|
return PersistedWindowState.of(initialWindowSize(), WindowPlacement.Floating)
|
||||||
|
}
|
||||||
|
val screen = availableScreenSize()
|
||||||
|
val size = saved.size.coerceForScreen(
|
||||||
|
min = MIN_WINDOW_SIZE,
|
||||||
|
maxWidth = screen?.width,
|
||||||
|
maxHeight = screen?.height)
|
||||||
|
return PersistedWindowState.of(size, saved.placement)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class)
|
||||||
fun main() {
|
fun main() {
|
||||||
FileKit.init(appId = "org.spotube.spotube")
|
FileKit.init(appId = "org.spotube.spotube")
|
||||||
initKoin()
|
initKoin()
|
||||||
@ -68,10 +135,28 @@ fun main() {
|
|||||||
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
nucleusApplication(backend = NucleusBackend.Tao) {
|
nucleusApplication(backend = NucleusBackend.Tao) {
|
||||||
val windowState = rememberWindowState(
|
val windowStateRepository = remember { KoinServicesProvider.windowStateRepository }
|
||||||
width = 1080.dp,
|
// Needed before the window is created, so the one-time read at startup is blocking
|
||||||
height = 720.dp
|
val restored = remember { restoreWindowState(runBlocking { windowStateRepository.load() }) }
|
||||||
)
|
val windowState = rememberWindowState(placement = restored.placement, size = restored.size)
|
||||||
|
|
||||||
|
// The size to restore into when un-maximizing; only floating windows update it
|
||||||
|
var normalSize by remember { mutableStateOf(restored.size) }
|
||||||
|
val currentWindowState = { PersistedWindowState.of(normalSize, windowState.placement) }
|
||||||
|
// Exit paths can't wait on a coroutine that is about to be cancelled
|
||||||
|
val persistWindowStateBlocking = { runBlocking { windowStateRepository.save(currentWindowState()) } }
|
||||||
|
|
||||||
|
LaunchedEffect(windowState) {
|
||||||
|
snapshotFlow { windowState.placement to windowState.size }
|
||||||
|
.onEach { (placement, size) ->
|
||||||
|
if (placement == WindowPlacement.Floating && size.isSpecified &&
|
||||||
|
size.width > 0.dp && size.height > 0.dp) {
|
||||||
|
normalSize = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.debounce(WINDOW_STATE_SAVE_DEBOUNCE_MS)
|
||||||
|
.collect { windowStateRepository.save(currentWindowState()) }
|
||||||
|
}
|
||||||
|
|
||||||
val settingsProvider = KoinServicesProvider.settingsProvider
|
val settingsProvider = KoinServicesProvider.settingsProvider
|
||||||
val settings by settingsProvider.settingsState.collectAsState(initial = null)
|
val settings by settingsProvider.settingsState.collectAsState(initial = null)
|
||||||
@ -83,6 +168,7 @@ fun main() {
|
|||||||
|
|
||||||
val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }
|
val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }
|
||||||
val onExit = {
|
val onExit = {
|
||||||
|
persistWindowStateBlocking()
|
||||||
trayService.close()
|
trayService.close()
|
||||||
appScope.cancel()
|
appScope.cancel()
|
||||||
exitApplication()
|
exitApplication()
|
||||||
@ -96,7 +182,8 @@ fun main() {
|
|||||||
SystemTray(
|
SystemTray(
|
||||||
isWindowVisible = isWindowVisible,
|
isWindowVisible = isWindowVisible,
|
||||||
onToggleWindowVisibility = onToggleWindowVisibility,
|
onToggleWindowVisibility = onToggleWindowVisibility,
|
||||||
onExit = onExit)
|
onExit = onExit
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,13 +193,15 @@ fun main() {
|
|||||||
if (minimizeToTray) {
|
if (minimizeToTray) {
|
||||||
isWindowVisible = false
|
isWindowVisible = false
|
||||||
} else {
|
} else {
|
||||||
|
persistWindowStateBlocking()
|
||||||
trayService.close()
|
trayService.close()
|
||||||
appScope.cancel()
|
appScope.cancel()
|
||||||
exitApplication()
|
exitApplication()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = "Spotube",
|
title = "Spotube",
|
||||||
visible = isWindowVisible) {
|
visible = isWindowVisible
|
||||||
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalApplicationScope provides this@nucleusApplication,
|
LocalApplicationScope provides this@nucleusApplication,
|
||||||
LocalWindowScope provides this@DecoratedWindow,
|
LocalWindowScope provides this@DecoratedWindow,
|
||||||
|
|||||||
@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.window
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okio.Path.Companion.toPath
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class WindowStateRepositoryTest {
|
||||||
|
private fun repository(): WindowStateRepository {
|
||||||
|
val dir = Files.createTempDirectory("window-state-test").toFile()
|
||||||
|
val file = File(dir, "window.preferences_pb")
|
||||||
|
return WindowStateRepository(
|
||||||
|
PreferenceDataStoreFactory.createWithPath(produceFile = { file.absolutePath.toPath() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missingValueLoadsNull() = runBlocking {
|
||||||
|
assertNull(repository().load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripsNormalState() = runBlocking {
|
||||||
|
val store = repository()
|
||||||
|
store.save(PersistedWindowState.of(DpSize(1200.dp, 750.dp), WindowPlacement.Floating))
|
||||||
|
|
||||||
|
val loaded = store.load()!!
|
||||||
|
assertEquals(DpSize(1200.dp, 750.dp), loaded.size)
|
||||||
|
assertEquals(WindowPlacement.Floating, loaded.placement)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripsMaximizedStateKeepingNormalSize() = runBlocking {
|
||||||
|
val store = repository()
|
||||||
|
store.save(PersistedWindowState.of(DpSize(1100.dp, 700.dp), WindowPlacement.Maximized))
|
||||||
|
|
||||||
|
val loaded = store.load()!!
|
||||||
|
assertEquals(WindowPlacement.Maximized, loaded.placement)
|
||||||
|
assertEquals(DpSize(1100.dp, 700.dp), loaded.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oversizedSavedSizeIsClampedToScreen() {
|
||||||
|
val clamped = DpSize(3000.dp, 2000.dp)
|
||||||
|
.coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp)
|
||||||
|
assertEquals(DpSize(1800.dp, 1000.dp), clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tinySavedSizeIsRaisedToMinimum() {
|
||||||
|
val clamped = DpSize(100.dp, 100.dp)
|
||||||
|
.coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp)
|
||||||
|
assertEquals(DpSize(800.dp, 500.dp), clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownScreenOnlyEnforcesMinimum() {
|
||||||
|
val clamped = DpSize(3000.dp, 2000.dp)
|
||||||
|
.coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = null, maxHeight = null)
|
||||||
|
assertEquals(DpSize(3000.dp, 2000.dp), clamped)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user