Fix blur and make window size be remembered

This commit is contained in:
2026-07-26 22:30:00 +03:00
Unverified
parent 16a14da642
commit 5952e1a9c5
9 changed files with 99 additions and 54 deletions
@@ -0,0 +1,35 @@
package ru.fromchat.desktop
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import java.util.prefs.Preferences
/**
* Desktop main-window size prefs.
*
* Stored in the same JVM prefs node as PlatformSettings (`ru.fromchat.settings`),
* keys `desktop_window_width` / `desktop_window_height` (float dp).
*/
internal object DesktopWindowPrefs {
private const val WIDTH_KEY = "desktop_window_width"
private const val HEIGHT_KEY = "desktop_window_height"
private val prefs: Preferences =
Preferences.userRoot().node("ru.fromchat.settings")
fun loadSize(): DpSize {
val width = prefs.getFloat(WIDTH_KEY, 0f)
val height = prefs.getFloat(HEIGHT_KEY, 0f)
return if (width <= 0f || height <= 0f) {
DpSize(800.dp, 600.dp)
} else {
DpSize(width.dp, height.dp)
}
}
fun saveSize(size: DpSize) {
prefs.putFloat(WIDTH_KEY, size.width.value)
prefs.putFloat(HEIGHT_KEY, size.height.value)
runCatching { prefs.flush() }
}
}
@@ -52,6 +52,7 @@ import kotlin.concurrent.thread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.skia.Image
@@ -232,7 +233,7 @@ fun main(args: Array<String>) {
UtilsLibrary.init()
DesktopApplicationBootstrap.launchOnApplicationStart()
val windowState = rememberWindowState()
val windowState = rememberWindowState(size = remember { DesktopWindowPrefs.loadSize() })
val aboutWindowState = rememberWindowState(
position = WindowPosition.Aligned(Alignment.Center),
size = DpSize(440.dp, 640.dp),
@@ -249,6 +250,11 @@ fun main(args: Array<String>) {
val trayShow = stringResource(Res.string.desktop_tray_show)
val quit = stringResource(Res.string.desktop_quit)
LaunchedEffect(windowState.size) {
delay(3_000)
DesktopWindowPrefs.saveSize(windowState.size)
}
DisposableEffect(trayState) {
DesktopNotifier.sink = { title, body ->
trayState.sendNotification(
@@ -67,6 +67,7 @@ import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -425,8 +426,9 @@ fun ChatTopBar(
/**
* Two-pane chat chrome: separate back pill + title pill. Matches [ChatInput] composer chrome
* ([rememberChatSurfaceContainerHazeStyle] + [androidx.compose.material3.ColorScheme.surfaceContainer]).
* Full-bleed progressive haze strip behind the pills (high at top → fade toward bottom); pills keep
* their own surfaceContainer + [hazeEffect]. Horizontal inset matches [ChatInput].
* Full-bleed progressive haze strip behind the pills uses [HazeMaterials.thin] (high at top → fade
* toward bottom); pills keep surfaceContainer + [rememberChatSurfaceContainerHazeStyle].
* Horizontal inset matches [ChatInput].
*/
@Composable
private fun ChatTopBarPill(
@@ -441,6 +443,7 @@ private fun ChatTopBarPill(
hazeBlurEnabled: Boolean = true,
) {
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
val progressiveStripHazeStyle = HazeMaterials.thin()
val chromeColor = MaterialTheme.colorScheme.surfaceContainer
val pillShape = RoundedCornerShape(28.dp)
@@ -452,7 +455,7 @@ private fun ChatTopBarPill(
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = hazeStyle
style = progressiveStripHazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
@@ -442,11 +442,15 @@ private fun ChatContextMenuBlurLayer(
blurProgress: Float,
modifier: Modifier = Modifier,
) {
val blurRadius = 12.dp * blurProgress
val progress = blurProgress.coerceIn(0f, 1f)
val blurRadius = 12.dp * progress
if (blurRadius <= 0.dp) return
// Opaque enough background so rounded chrome corners don't stay razor-sharp while
// interiors look frosted (Transparent + no tint left original AA edges unblurred).
// In-tree under the overlay (zIndex). Never use a Popup here: platform popups stack above
// the sharp row/menu, blur them, and intercept dismiss taps.
val surface = MaterialTheme.colorScheme.surfaceContainerLowest
Box(
modifier = modifier
.fillMaxSize()
@@ -454,10 +458,12 @@ private fun ChatContextMenuBlurLayer(
blurEffect {
style = HazeBlurStyle(
blurRadius = blurRadius,
colorEffects = emptyList(),
backgroundColor = Color.Transparent,
backgroundColor = surface,
colorEffects = listOf(
HazeColorEffect.tint(surface.copy(alpha = 0.4f * progress)),
),
noiseFactor = 0f,
fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
fallbackColorEffect = HazeColorEffect.tint(surface.copy(alpha = 0.4f * progress)),
)
}
},
@@ -268,7 +268,6 @@ internal fun ChatConversationsList(
publicChatSelected: Boolean,
selectedOtherUserIds: Set<Int>,
contextMenuState: ChatContextMenuState,
overlayCloneReady: Boolean,
rowRevealProgress: Float,
listContentPadding: PaddingValues = PaddingValues(0.dp),
showSearchBar: Boolean = false,
@@ -380,11 +379,11 @@ internal fun ChatConversationsList(
selectionTransitionProgress = selectionTransitionProgress,
isSelected = publicChatSelected,
isHiddenForOverlay = contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW &&
contextMenuState.isOverlayReplicaActive &&
overlayCloneReady,
contextMenuState.isOverlayReplicaActive,
isPressingForContextMenu = contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW &&
contextMenuState.phase == ChatContextMenuPhase.Pressing,
contextMenuPressScaleActive = contextMenuState.phase == ChatContextMenuPhase.Animating &&
contextMenuPressScaleActive = contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW &&
contextMenuState.phase == ChatContextMenuPhase.Animating &&
!contextMenuState.animatingOut,
rowRevealProgress = if (contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW) {
rowRevealProgress
@@ -461,11 +460,11 @@ internal fun ChatConversationsList(
selectionTransitionProgress = selectionTransitionProgress,
isSelected = conversation.otherUserId in selectedOtherUserIds,
isHiddenForOverlay = contextMenuState.listIndex == lazyIndex &&
contextMenuState.isOverlayReplicaActive &&
overlayCloneReady,
contextMenuState.isOverlayReplicaActive,
isPressingForContextMenu = contextMenuState.listIndex == lazyIndex &&
contextMenuState.phase == ChatContextMenuPhase.Pressing,
contextMenuPressScaleActive = contextMenuState.phase == ChatContextMenuPhase.Animating &&
contextMenuPressScaleActive = contextMenuState.listIndex == lazyIndex &&
contextMenuState.phase == ChatContextMenuPhase.Animating &&
!contextMenuState.animatingOut,
rowRevealProgress = if (contextMenuState.listIndex == lazyIndex) {
rowRevealProgress
@@ -103,8 +103,6 @@ class ChatContextMenuOverlayController {
var blurProgress by mutableFloatStateOf(0f)
/** Shared row scale progress (0 = pressed, 1 = full) for overlay ↔ list handoff. */
var rowRevealProgress by mutableFloatStateOf(0f)
/** True once the overlay row clone has been composed and positioned. */
var overlayCloneReady by mutableStateOf(false)
var onStateChange: (ChatContextMenuState) -> Unit = {}
var onDismiss: () -> Unit = {}
var onMessage: () -> Unit = {}
@@ -114,12 +112,10 @@ class ChatContextMenuOverlayController {
var onMarkPublicRead: () -> Unit = {}
var onDelete: (Int) -> Unit = {}
var onSelect: () -> Unit = {}
var onOverlayCloneReady: () -> Unit = {}
fun clear() {
uiState = null
blurProgress = 0f
rowRevealProgress = 0f
overlayCloneReady = false
}
}
@@ -829,7 +829,6 @@ fun ChatsTab(
),
)
val rowRevealProgress = chatContextMenuOverlay.rowRevealProgress
val overlayCloneReady = chatContextMenuOverlay.overlayCloneReady
val callsEnabled = ServerConfig.callsEnabled
fun markSelectedChatsRead() {
@@ -968,7 +967,6 @@ fun ChatsTab(
publicChatSelected = publicChatSelected,
selectedOtherUserIds = selectedOtherUserIds,
contextMenuState = contextMenuState,
overlayCloneReady = overlayCloneReady,
rowRevealProgress = rowRevealProgress,
listContentPadding = PaddingValues(top = fixedTopBarHeight),
showSearchBar = true,
@@ -1033,7 +1031,6 @@ fun ChatsTab(
return@ChatConversationsList
}
haptic(HapticFeedbackEvent.ContextMenuOpened)
chatContextMenuOverlay.overlayCloneReady = false
contextMenuState = ChatContextMenuState(
phase = ChatContextMenuPhase.Animating,
target = target,
@@ -1049,7 +1046,6 @@ fun ChatsTab(
if (suspensionState.isSuspended) return@ChatConversationsList
haptic(HapticFeedbackEvent.ContextMenuOpened)
avatarPressMark = null
chatContextMenuOverlay.overlayCloneReady = false
contextMenuState = ChatContextMenuState(
phase = ChatContextMenuPhase.Animating,
target = target,
@@ -1185,9 +1181,6 @@ fun ChatsTab(
animatingOut = true,
)
}
chatContextMenuOverlay.onOverlayCloneReady = {
chatContextMenuOverlay.overlayCloneReady = true
}
val shouldPublishOverlay = isVisible &&
contextMenuState.isOverlayReplicaActive &&
@@ -1215,19 +1208,10 @@ fun ChatsTab(
null
}
if (chatContextMenuOverlay.uiState != overlayUiState) {
val previousOverlay = chatContextMenuOverlay.uiState
chatContextMenuOverlay.uiState = overlayUiState
if (overlayUiState == null) {
chatContextMenuOverlay.blurProgress = 0f
chatContextMenuOverlay.rowRevealProgress = 0f
chatContextMenuOverlay.overlayCloneReady = false
} else {
val isNewOverlaySession = previousOverlay == null ||
previousOverlay.contextMenuState.listIndex != overlayUiState.contextMenuState.listIndex ||
!previousOverlay.contextMenuState.isOverlayReplicaActive
if (isNewOverlaySession) {
chatContextMenuOverlay.overlayCloneReady = false
}
}
}
@@ -1277,7 +1261,6 @@ internal fun ChatContextMenuOverlayHost(
uiState.contextMenuState.otherUserId?.let(controller.onDelete)
},
onSelect = controller.onSelect,
onOverlayCloneReady = controller.onOverlayCloneReady,
modifier = modifier,
)
}
@@ -1297,7 +1280,6 @@ private fun ChatContextMenuOverlay(
onMarkRead: () -> Unit,
onDelete: () -> Unit,
onSelect: () -> Unit,
onOverlayCloneReady: () -> Unit,
modifier: Modifier = Modifier,
) {
val contextMenuState = uiState.contextMenuState
@@ -1503,17 +1485,6 @@ private fun ChatContextMenuOverlay(
}
}
LaunchedEffect(
layoutReady,
contextMenuState.listIndex,
contextMenuState.target,
contextMenuState.otherUserId,
) {
if (layoutReady && contextMenuState.isOverlayReplicaActive) {
onOverlayCloneReady()
}
}
fun requestDismiss() {
when (contextMenuState.phase) {
ChatContextMenuPhase.Open -> {
@@ -1,23 +1,26 @@
package ru.fromchat.ui.main.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -31,12 +34,17 @@ import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.verticalScroll
import com.pr0gramm3r101.utils.widthSizeClass
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about
import ru.fromchat.api.ApiClient
import ru.fromchat.change_server
import ru.fromchat.change_server_d
import ru.fromchat.logs_title
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.settings_category_account
@@ -47,11 +55,12 @@ import ru.fromchat.settings_category_devices
import ru.fromchat.settings_category_devices_d
import ru.fromchat.settings_category_notifications
import ru.fromchat.settings_category_notifications_d
import ru.fromchat.logs_title
import ru.fromchat.settings_hub_about_sub
import ru.fromchat.settings_hub_logs_sub
import ru.fromchat.settings_hub_profile_sub
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.main.mainPagerBottomInset
@@ -64,6 +73,9 @@ val SettingsStepHorizontalPadding = 24.dp
fun SettingsTab() {
val navController = LocalNavController.current
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
val topChromeHazeState = rememberHazeState()
// Same material as MainScreen bottom nav (not HazeMaterials.thin).
val topChromeHazeStyle = rememberChatSurfaceContainerHazeStyle()
fun openDetail(route: String) {
navController.navigateReplacingMainDetail(
@@ -75,6 +87,7 @@ fun SettingsTab() {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
TopAppBar(
@@ -89,16 +102,27 @@ fun SettingsTab() {
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
// Match MainScreen bottom chrome: surfaceContainer + hazeEffect/blurEffect.
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = topChromeHazeState) {
blurEffect {
style = topChromeHazeStyle
}
},
)
}
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.hazeSource(topChromeHazeState)
.verticalScroll()
.mainPagerBottomInset()
.padding(innerPadding)
) {
// Scrollable top inset so rows can pass under the frosted top chrome.
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
if (isTwoPane) {
Category(Modifier.padding(top = 16.dp)) {
ListItem(
@@ -680,10 +680,15 @@ fun ListItem(
bottom = 8.dp
),
contentAlignment = Alignment.Center
) {
// Material list leadingIconColor default: onSurfaceVariant
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant,
) {
leadingContent()
}
}
}
ListItem(
headlineContent = headlineSlot ?: { Text(headline) },