diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts
index 21a362f..77bc046 100644
--- a/app/desktop/build.gradle.kts
+++ b/app/desktop/build.gradle.kts
@@ -55,6 +55,8 @@ compose.desktop {
macOS {
bundleID = "ru.fromchat.desktop"
iconFile.set(desktopWindowIconIcns)
+ // Tray icons as NSImage templates (menu bar light/dark tint). Also set in Main.kt.
+ jvmArgs("-Dapple.awt.enableTemplateImages=true")
infoPlist {
extraKeysRawXml = """
CFBundleURLTypes
@@ -105,6 +107,7 @@ tasks.matching { it.name == "run" }.configureEach {
}
jvmArgs(
dockIconArgs + listOf(
+ "-Dapple.awt.enableTemplateImages=true",
"--module-path",
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
"--add-modules",
diff --git a/app/desktop/icons/app_window_icon.icns b/app/desktop/icons/app_window_icon.icns
index 8e3913a..1cc6557 100644
Binary files a/app/desktop/icons/app_window_icon.icns and b/app/desktop/icons/app_window_icon.icns differ
diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowPrefs.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowPrefs.kt
index e970e6e..4d59449 100644
--- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowPrefs.kt
+++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowPrefs.kt
@@ -11,6 +11,9 @@ import java.util.prefs.Preferences
* Stored in the same JVM prefs node as PlatformSettings (`ru.fromchat.settings`):
* - `desktop_window_width` / `desktop_window_height` (float dp)
* - `desktop_window_x` / `desktop_window_y` (float dp, absolute; absent → platform default)
+ *
+ * List–detail left pane width uses the sibling key `desktop_list_pane_width`
+ * (see [ru.fromchat.ui.main.ConversationListDetailShell]).
*/
internal object DesktopWindowPrefs {
private const val WIDTH_KEY = "desktop_window_width"
diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/MacBackgroundLifecycle.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/MacBackgroundLifecycle.kt
new file mode 100644
index 0000000..86464bd
--- /dev/null
+++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/MacBackgroundLifecycle.kt
@@ -0,0 +1,19 @@
+package ru.fromchat.desktop
+
+/**
+ * macOS close-to-background policy notes (implemented in [MainKt] / tray close handler).
+ *
+ * ## Window close → background (not quit)
+ * The red traffic-light / window close hides the main window and keeps the JVM process
+ * plus menu-bar tray alive (close-to-tray). Quit is only via tray Quit, File → Quit, or ⌘Q.
+ * Dock icon stays (NSApplicationActivationPolicyRegular) so macOS can show the Dock
+ * "Running in Background" indicator when no windows are visible.
+ *
+ * ## No Login Item / SMAppService
+ * We intentionally do **not** register `SMAppService.mainAppService` (Open at Login /
+ * Login Items & Extensions). Close-to-tray does not require that registration; the process
+ * simply stays alive with a menu-bar tray while the window is hidden.
+ */
+internal object MacBackgroundLifecycle {
+ const val LOG_TAG = "MacBackground"
+}
diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt
index f929b79..8fad21d 100644
--- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt
+++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt
@@ -1,13 +1,19 @@
package ru.fromchat.desktop
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.graphics.toPainter
@@ -34,6 +40,7 @@ import java.awt.Image
import java.awt.KeyboardFocusManager
import java.awt.RenderingHints
import java.awt.Taskbar
+import java.awt.desktop.AppReopenedListener
import java.awt.event.ActionEvent
import java.awt.image.BufferedImage
import java.io.BufferedReader
@@ -50,6 +57,8 @@ import javax.imageio.ImageIO
import javax.swing.JComponent
import javax.swing.JRootPane
import javax.swing.SwingUtilities
+import javax.swing.UIManager
+import javax.swing.plaf.ColorUIResource
import javax.swing.text.DefaultEditorKit
import kotlin.concurrent.thread
import kotlinx.coroutines.CoroutineScope
@@ -66,9 +75,13 @@ import ru.fromchat.Res
import ru.fromchat.action_copy
import ru.fromchat.action_select
import ru.fromchat.api.ApiClient
+import ru.fromchat.api.local.WebSocketManager
+import ru.fromchat.api.local.db.store.ConnectionStateStore
+import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.app_name
import ru.fromchat.app_name_beta
+import ru.fromchat.config.Settings
import ru.fromchat.desktop_about_app
import ru.fromchat.desktop_cut
import ru.fromchat.desktop_menu_edit
@@ -83,8 +96,12 @@ import ru.fromchat.desktop_select_all
import ru.fromchat.desktop_show_app
import ru.fromchat.desktop_tray_show
import ru.fromchat.desktop_zoom
+import ru.fromchat.status_connected
+import ru.fromchat.status_connecting
+import ru.fromchat.status_disconnected
import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop
+import ru.fromchat.ui.Theme
private object DesktopApplicationBootstrap {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -231,12 +248,19 @@ fun main(args: Array) {
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
)
System.setProperty("apple.laf.useScreenMenuBar", "true")
+ // Treat TrayIcon images as NSImage templates so the menu bar tints them for light/dark.
+ // Must be set before CTrayIcon loads (Compose Tray / SystemTray).
+ System.setProperty("apple.awt.enableTemplateImages", "true")
}
if (!DesktopSingleInstance.acquireOrForward(args)) return
DesktopProtocolRegistration.registerBestEffort()
+ // Close-to-tray keeps the process + tray; no SMAppService / Login Items registration.
args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
.forEach { DesktopDeepLinkBus.handleUri(it) }
+ // Avoid the AWT default white "control" flash before Compose paints (CMP #1794).
+ applyDesktopWindowChromeBackground()
+
val dockIconImage = loadAppIconBufferedImage(tray = false)
applyDockIcon(dockIconImage)
// Re-apply after AWT is fully up — Taskbar can ignore early sets on macOS :run.
@@ -250,6 +274,8 @@ fun main(args: Array) {
size = remember { DesktopWindowPrefs.loadSize() },
position = remember { DesktopWindowPrefs.loadPosition() },
)
+ // Hide until App bootstrap sets startDestination (avoids ~2s empty/white window).
+ var contentReady by remember { mutableStateOf(false) }
var windowVisible by remember { mutableStateOf(true) }
val trayState = rememberTrayState()
// BufferedImage.toPainter() keeps the AWT pixels (avoids blank BitmapPainter round-trip).
@@ -265,6 +291,29 @@ fun main(args: Array) {
val aboutApp = stringResource(Res.string.desktop_about_app)
val trayShow = stringResource(Res.string.desktop_tray_show)
val quit = stringResource(Res.string.desktop_quit)
+ val connectionStatus by ConnectionStateStore.status.collectAsState()
+ var wsLinked by remember { mutableStateOf(WebSocketManager.isConnected) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ wsLinked = WebSocketManager.isConnected
+ delay(400)
+ }
+ }
+ // Mirror ConnectionStateStore + WebSocketManager.isConnected (+ logged-out → disconnected).
+ val wsStatusLabel = when {
+ connectionStatus == ConnectionStatus.CONNECTED ||
+ connectionStatus == ConnectionStatus.UPDATING ||
+ wsLinked -> stringResource(Res.string.status_connected)
+ ApiClient.token.isNullOrEmpty() -> stringResource(Res.string.status_disconnected)
+ else -> stringResource(Res.string.status_connecting)
+ }
+ val windowChrome = remember { desktopThemeBackgroundCompose() }
+
+ LaunchedEffect(Unit) {
+ // Safety: never leave the window stuck hidden if bootstrap hangs.
+ delay(8_000)
+ contentReady = true
+ }
LaunchedEffect(windowState.size, windowState.position) {
delay(3_000)
@@ -283,19 +332,54 @@ fun main(args: Array) {
}
}
- fun openAbout() {
+ fun showMainWindow() {
windowVisible = true
+ AppForeground.setForeground(true)
+ }
+
+ fun openAbout() {
+ showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.OpenAbout)
}
+ /** Hide window, keep process + tray (close-to-background). Else quit. */
+ fun requestCloseToBackgroundOrQuit() {
+ if (traySupported) {
+ windowVisible = false
+ // Desktop keeps sockets alive while the process runs (see keepWebSocketAliveInBackground).
+ AppForeground.setForeground(true)
+ if (mac) {
+ Logger.i(MacBackgroundLifecycle.LOG_TAG, "Window closed → background (tray kept, process alive)")
+ }
+ } else {
+ exitApplication()
+ }
+ }
+
LaunchedEffect(Unit) {
- if (!mac || !Desktop.isDesktopSupported()) return@LaunchedEffect
+ if (!Desktop.isDesktopSupported()) return@LaunchedEffect
val desktop = Desktop.getDesktop()
- if (desktop.isSupported(Desktop.Action.APP_ABOUT)) {
+ if (mac && desktop.isSupported(Desktop.Action.APP_ABOUT)) {
desktop.setAboutHandler {
SwingUtilities.invokeLater { openAbout() }
}
}
+ // ⌘Q / system Quit must fully exit (not close-to-tray).
+ if (desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER)) {
+ desktop.setQuitHandler { _, response ->
+ DesktopSingleInstance.release()
+ response.performQuit()
+ exitApplication()
+ }
+ }
+ // Dock click while window is hidden → show again (macOS AppReopened).
+ if (mac) {
+ desktop.addAppEventListener(
+ AppReopenedListener {
+ SwingUtilities.invokeLater { showMainWindow() }
+ },
+ )
+ }
}
if (traySupported) {
@@ -303,9 +387,11 @@ fun main(args: Array) {
icon = trayIcon,
state = trayState,
tooltip = appName,
- onAction = { windowVisible = true },
+ onAction = { showMainWindow() },
menu = {
- Item(trayShow) { windowVisible = true }
+ Item(wsStatusLabel, enabled = false) {}
+ Separator()
+ Item(trayShow) { showMainWindow() }
Item(aboutApp) { openAbout() }
Separator()
Item(quit) { exitApplication() }
@@ -314,17 +400,10 @@ fun main(args: Array) {
}
Window(
- onCloseRequest = {
- if (traySupported) {
- windowVisible = false
- AppForeground.setForeground(true)
- } else {
- exitApplication()
- }
- },
+ onCloseRequest = { requestCloseToBackgroundOrQuit() },
title = appName,
state = windowState,
- visible = windowVisible || !traySupported,
+ visible = contentReady && (windowVisible || !traySupported),
icon = windowIcon,
onPreviewKeyEvent = { event ->
// macOS: MenuBar KeyShortcuts handle these. Win/Linux: no menu bar.
@@ -333,17 +412,17 @@ fun main(args: Array) {
}
when {
!event.isShiftPressed && event.key == Key.N -> {
- windowVisible = true
+ showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
true
}
!event.isShiftPressed && event.key == Key.F -> {
- windowVisible = true
+ showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
true
}
event.isShiftPressed && event.key == Key.S -> {
- windowVisible = true
+ showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
true
}
@@ -352,7 +431,7 @@ fun main(args: Array) {
true
}
event.isShiftPressed && event.key == Key.N -> {
- windowVisible = true
+ showMainWindow()
true
}
event.key == Key.M -> {
@@ -367,8 +446,11 @@ fun main(args: Array) {
}
},
) {
- LaunchedEffect(window, appName) {
+ LaunchedEffect(window, appName, windowChrome) {
window.title = appName
+ val awt = windowChrome.toAwtColor()
+ window.background = awt
+ window.contentPane.background = awt
enableEdgeToEdgeTitleBar(window.rootPane)
dockIconImage?.let { image ->
window.iconImages = listOf(image)
@@ -383,18 +465,18 @@ fun main(args: Array) {
if (mac) {
FromChatMenuBar(
onNewChat = {
- windowVisible = true
+ showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
},
onSearchConversations = {
- windowVisible = true
+ showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
},
onSelectChats = {
- windowVisible = true
+ showMainWindow()
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
},
- onShow = { windowVisible = true },
+ onShow = { showMainWindow() },
onMinimize = { windowState.isMinimized = true },
onZoom = {
windowState.placement =
@@ -411,7 +493,13 @@ fun main(args: Array) {
CompositionLocalProvider(
LocalExtraStatusBarTop provides if (mac) 28.dp else 0.dp,
) {
- App()
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(windowChrome),
+ ) {
+ App(onContentReady = { contentReady = true })
+ }
}
}
}
@@ -522,6 +610,39 @@ private fun isWindowsOs(): Boolean =
private fun isLinuxOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
+/**
+ * JFrame uses UIManager "control" as its default background before Compose draws.
+ * Set it to the app theme background so dark theme never flashes white (CMP #1794).
+ *
+ * Colors match Material3 [darkColorScheme]/[lightColorScheme] defaults used by [ru.fromchat.ui.getColorScheme].
+ */
+private fun applyDesktopWindowChromeBackground() {
+ UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor()))
+}
+
+private fun desktopThemeBackgroundCompose(): Color {
+ val dark = when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
+ Theme.Dark -> true
+ Theme.Light -> false
+ Theme.AsSystem -> isSystemAppearanceDark()
+ }
+ // M3 default scheme backgrounds (desktop module has no material3 dependency).
+ return if (dark) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
+}
+
+private fun Color.toAwtColor(): java.awt.Color =
+ java.awt.Color(red, green, blue, alpha)
+
+private fun isSystemAppearanceDark(): Boolean {
+ if (!isMacOs()) return false
+ return runCatching {
+ val process = ProcessBuilder("defaults", "read", "-g", "AppleInterfaceStyle")
+ .redirectErrorStream(true)
+ .start()
+ process.inputStream.bufferedReader().readText().trim().equals("Dark", ignoreCase = true)
+ }.getOrDefault(false)
+}
+
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
if (!isMacOs()) return
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
@@ -553,10 +674,14 @@ private fun applyDockIcon(image: BufferedImage?) {
/**
* Loads the desktop tray or window/dock icon from packaged resources.
- * Tray prefers the flat mark (`app_icon`); dock/window prefer the gradient (`app_window_icon`).
+ * Tray prefers the flat white+alpha mark (`app_icon`, template-ready); dock/window prefer
+ * the branded AppIcon (`app_window_icon`).
*
* Uses [BufferedImage.toPainter] so Compose Tray/Window hand the same AWT pixels to the OS
* (BitmapPainter → ImageBitmap round-trips can rasterize blank and fall back to Compose's default).
+ *
+ * On macOS, `apple.awt.enableTemplateImages` makes the tray mark an NSImage template so the
+ * menu bar recolors it for the current appearance (set early in [main]).
*/
private fun loadAppIconPainter(tray: Boolean): Painter {
val image = loadAppIconBufferedImage(tray)
diff --git a/app/desktop/src/main/resources/app_icon.png b/app/desktop/src/main/resources/app_icon.png
index 9cb9b79..94cfd01 100644
Binary files a/app/desktop/src/main/resources/app_icon.png and b/app/desktop/src/main/resources/app_icon.png differ
diff --git a/app/desktop/src/main/resources/app_icon.webp b/app/desktop/src/main/resources/app_icon.webp
deleted file mode 100644
index 3f5657f..0000000
Binary files a/app/desktop/src/main/resources/app_icon.webp and /dev/null differ
diff --git a/app/desktop/src/main/resources/app_window_icon.png b/app/desktop/src/main/resources/app_window_icon.png
index ccbf862..80c667a 100644
Binary files a/app/desktop/src/main/resources/app_window_icon.png and b/app/desktop/src/main/resources/app_window_icon.png differ
diff --git a/app/desktop/src/main/resources/app_window_icon.webp b/app/desktop/src/main/resources/app_window_icon.webp
deleted file mode 100644
index 894ad89..0000000
Binary files a/app/desktop/src/main/resources/app_window_icon.webp and /dev/null differ
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 1e19d0a..a4e2067 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -111,6 +111,8 @@
Ничего не найдено. Попробуйте переформулировать запрос.
Напишите сообщение…
Соединение
+ Подключено
+ Отключено
Обновление
Группа
%1$d человек
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index 761a464..6245ca0 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -123,6 +123,8 @@
Connecting
+ Connected
+ Disconnected
Updating
Group
%1$d people
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index 55ccc97..09a12b8 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -24,7 +24,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavBackStackEntry
@@ -221,7 +220,9 @@ fun App(
startAtProfileUserId: Int? = null,
startAtProfileUsername: String? = null,
profileLookupErrorMessage: String? = null,
- onProfileLookupErrorMessageConsumed: () -> Unit = {}
+ onProfileLookupErrorMessageConsumed: () -> Unit = {},
+ /** Desktop: fired once [startDestination] is known so the window can show after bootstrap. */
+ onContentReady: () -> Unit = {},
) {
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context)
@@ -262,6 +263,7 @@ fun App(
hasToken -> "chat"
else -> "welcome"
}
+ onContentReady()
runCatching {
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
@@ -516,13 +518,6 @@ fun App(
ScreenSurface {
Box(Modifier.fillMaxSize()) {
- val listPaneWidth =
- if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
- 448.dp
- } else {
- 360.dp
- }
-
@Composable
fun AppNavHost(modifier: Modifier = Modifier) {
NavHost(
@@ -677,7 +672,6 @@ fun App(
LocalDesktopMainTab provides pendingMainTab,
) {
ConversationListDetailShell(
- listPaneWidth = listPaneWidth,
detailInPanel = false,
detailEdgeToEdge = detailEdgeToEdge,
listPane = {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
index 76dfbb5..6bc610a 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
@@ -101,6 +101,7 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
import ru.fromchat.ui.main.ConversationDetailContentPadding
import ru.fromchat.ui.main.LocalConversationListDetailActive
+import ru.fromchat.ui.main.detailPaneShowBackButton
import ru.fromchat.Res
import ru.fromchat.presence_recently
import ru.fromchat.api.ApiClient
@@ -1414,6 +1415,7 @@ fun ChatScreen(
hazeState = hazeState,
hazeBlurEnabled = hazeBlurEnabled,
pillChrome = listDetailActive,
+ showBackButton = detailPaneShowBackButton(),
onBack = {
runNav { navController.navigateUp() }
},
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
index 7fbd709..0c593c9 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
@@ -333,6 +333,7 @@ fun ChatTopBar(
modifier: Modifier = Modifier,
hazeBlurEnabled: Boolean = true,
pillChrome: Boolean = false,
+ showBackButton: Boolean = true,
) {
if (pillChrome) {
ChatTopBarPill(
@@ -345,6 +346,7 @@ fun ChatTopBar(
titleChrome = titleChrome,
modifier = modifier,
hazeBlurEnabled = hazeBlurEnabled,
+ showBackButton = showBackButton,
)
return
}
@@ -366,11 +368,13 @@ fun ChatTopBar(
titleContentColor = MaterialTheme.colorScheme.onSurface,
),
navigationIcon = {
- IconButton(onClick = onBack) {
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
- contentDescription = backContentDescription
- )
+ if (showBackButton) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = backContentDescription
+ )
+ }
}
},
title = { titleChrome() },
@@ -441,6 +445,7 @@ private fun ChatTopBarPill(
titleChrome: @Composable () -> Unit,
modifier: Modifier = Modifier,
hazeBlurEnabled: Boolean = true,
+ showBackButton: Boolean = true,
) {
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
val progressiveStripHazeStyle = HazeMaterials.thin()
@@ -471,24 +476,26 @@ private fun ChatTopBarPill(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- Box(
- modifier = Modifier
- .size(48.dp)
- .clip(CircleShape)
- .background(chromeColor)
- .hazeEffect(state = hazeState) {
- blurEffect {
- blurEnabled = hazeBlurEnabled
- style = hazeStyle
- }
- },
- contentAlignment = Alignment.Center,
- ) {
- IconButton(onClick = onBack) {
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
- contentDescription = backContentDescription,
- )
+ if (showBackButton) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(chromeColor)
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ blurEnabled = hazeBlurEnabled
+ style = hazeStyle
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = backContentDescription,
+ )
+ }
}
}
Row(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt
index c5f4907..202130e 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt
@@ -2,9 +2,14 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
-import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -37,6 +42,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
@@ -65,7 +71,6 @@ import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.components.Text
-import com.pr0gramm3r101.utils.scaleOnPress
data class ContextMenuState(
val isOpen: Boolean = false,
@@ -96,6 +101,31 @@ internal fun messageContextMenuFingerprint(
}
}
+private val contextMenuEnterSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMedium,
+)
+
+private fun clampContextMenuOffset(
+ position: IntOffset,
+ popupSize: IntSize,
+ screenWidthPx: Int,
+ screenHeightPx: Int,
+ paddingPx: Int,
+ allowOutsideWindow: Boolean,
+): IntOffset {
+ if (allowOutsideWindow || popupSize == IntSize.Zero) return position
+ var x = position.x
+ var y = position.y
+ val rightEdge = screenWidthPx - paddingPx
+ val bottomEdge = screenHeightPx - paddingPx
+ if (x + popupSize.width > rightEdge) x = rightEdge - popupSize.width
+ if (y + popupSize.height > bottomEdge) y = bottomEdge - popupSize.height
+ if (x < paddingPx) x = paddingPx
+ if (y < paddingPx) y = paddingPx
+ return IntOffset(x, y)
+}
+
@Composable
fun MessageContextMenu(
state: ContextMenuState,
@@ -127,34 +157,38 @@ fun MessageContextMenu(
mutableStateOf(state.isOpen && state.message != null)
}
val animationProgress = remember { mutableFloatStateOf(0f) }
+ var enterLaidOut by remember(state.message) { mutableStateOf(false) }
+ var frozenOrigin by remember(state.message) {
+ mutableStateOf(TransformOrigin(0f, 0f))
+ }
LaunchedEffect(state.isOpen) {
if (!state.isOpen) {
animate(
- initialValue = 1f,
+ initialValue = animationProgress.floatValue,
targetValue = 0f,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioLowBouncy,
- stiffness = Spring.StiffnessMediumLow
- )
+ animationSpec = contextMenuEnterSpec,
) { value, _ ->
animationProgress.floatValue = value
}
shouldShowPopup = false
+ enterLaidOut = false
}
}
- LaunchedEffect(state.isOpen, state.message) {
+ // Enter only after first layout so transform origin / position stay fixed mid-animation.
+ LaunchedEffect(state.isOpen, state.message, enterLaidOut) {
if (state.isOpen && state.message != null) {
shouldShowPopup = true
+ if (!enterLaidOut) {
+ animationProgress.floatValue = 0f
+ return@LaunchedEffect
+ }
animationProgress.floatValue = 0f
animate(
initialValue = 0f,
targetValue = 1f,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioLowBouncy,
- stiffness = Spring.StiffnessMediumLow
- )
+ animationSpec = contextMenuEnterSpec,
) { value, _ ->
animationProgress.floatValue = value
}
@@ -165,7 +199,6 @@ fun MessageContextMenu(
val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() }
val allowOutsideWindow = supportsMouseMessageInteraction()
- var popupSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
// Clamp with popupContentSize — do not SubcomposeLayout-measure under
// SharedTransitionLayout (writes state during LookaheadMeasuring).
@@ -182,60 +215,18 @@ fun MessageContextMenu(
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
- ): IntOffset {
- if (allowOutsideWindow) return state.position
- var x = state.position.x
- var y = state.position.y
- val rightEdge = screenWidthPx - paddingPx
- val bottomEdge = screenHeightPx - paddingPx
- if (x + popupContentSize.width > rightEdge) {
- x = rightEdge - popupContentSize.width
- }
- if (y + popupContentSize.height > bottomEdge) {
- y = bottomEdge - popupContentSize.height
- }
- if (x < paddingPx) x = paddingPx
- if (y < paddingPx) y = paddingPx
- return IntOffset(x, y)
- }
+ ): IntOffset = clampContextMenuOffset(
+ position = state.position,
+ popupSize = popupContentSize,
+ screenWidthPx = screenWidthPx,
+ screenHeightPx = screenHeightPx,
+ paddingPx = paddingPx,
+ allowOutsideWindow = allowOutsideWindow,
+ )
}
}
- val adjustedOffset = remember(
- popupSize,
- state.position,
- screenWidthPx,
- screenHeightPx,
- paddingPx,
- allowOutsideWindow,
- ) {
- if (allowOutsideWindow || popupSize == IntSize.Zero) {
- state.position
- } else {
- var x = state.position.x
- var y = state.position.y
- val rightEdge = screenWidthPx - paddingPx
- val bottomEdge = screenHeightPx - paddingPx
- if (x + popupSize.width > rightEdge) x = rightEdge - popupSize.width
- if (y + popupSize.height > bottomEdge) y = bottomEdge - popupSize.height
- if (x < paddingPx) x = paddingPx
- if (y < paddingPx) y = paddingPx
- IntOffset(x, y)
- }
- }
-
- val transformOriginX = if (popupSize.width > 0) {
- ((state.position.x - adjustedOffset.x).toFloat() / popupSize.width).coerceIn(0f, 1f)
- } else {
- 0f
- }
- val transformOriginY = if (popupSize.height > 0) {
- ((state.position.y - adjustedOffset.y).toFloat() / popupSize.height).coerceIn(0f, 1f)
- } else {
- 0f
- }
-
- val scale = 0.5f + 0.5f * animationProgress.floatValue
+ val scale = 0.8f + 0.2f * animationProgress.floatValue
val alpha = animationProgress.floatValue
// [state.position] is window coordinates (see MessageItem localToWindow).
@@ -282,12 +273,29 @@ fun MessageContextMenu(
onRetrySend(it)
onDismiss()
},
- modifier = modifier.onSizeChanged { popupSize = it },
+ modifier = modifier.onSizeChanged { size ->
+ if (size.width <= 0 || size.height <= 0 || enterLaidOut) return@onSizeChanged
+ val adjusted = clampContextMenuOffset(
+ position = state.position,
+ popupSize = size,
+ screenWidthPx = screenWidthPx,
+ screenHeightPx = screenHeightPx,
+ paddingPx = paddingPx,
+ allowOutsideWindow = allowOutsideWindow,
+ )
+ frozenOrigin = TransformOrigin(
+ pivotFractionX = ((state.position.x - adjusted.x).toFloat() / size.width)
+ .coerceIn(0f, 1f),
+ pivotFractionY = ((state.position.y - adjusted.y).toFloat() / size.height)
+ .coerceIn(0f, 1f),
+ )
+ enterLaidOut = true
+ },
animated = true,
scale = scale,
alpha = alpha,
- transformOriginX = transformOriginX,
- transformOriginY = transformOriginY,
+ transformOriginX = frozenOrigin.pivotFractionX,
+ transformOriginY = frozenOrigin.pivotFractionY,
isReadOnly = isReadOnly,
)
}
@@ -417,39 +425,37 @@ internal fun ChatStyleContextMenuFrame(
content: @Composable () -> Unit,
) {
val menuShape = RoundedCornerShape(16.dp)
- val density = LocalDensity.current
val shadowElevationPx = if (withShadow) {
- with(density) { 12.dp.toPx() }
+ with(LocalDensity.current) { 12.dp.toPx() }
} else {
0f
}
- val baseModifier = modifier.width(IntrinsicSize.Max)
- val containerModifier =
- if (animated) {
- baseModifier.graphicsLayer(
- scaleX = scale,
- scaleY = scale,
- alpha = alpha,
- transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
- shadowElevation = shadowElevationPx,
- shape = menuShape,
- clip = true,
- )
- } else {
- baseModifier.graphicsLayer(
- shadowElevation = shadowElevationPx,
- shape = menuShape,
- clip = true,
- )
- }
-
- Box(modifier = containerModifier) {
+ // Draw shadow on an outer layer; clip/background stay on the inner box so hover
+ // invalidation does not rebuild the elevated layer (avoids first-hover blink).
+ Box(
+ modifier = modifier
+ .width(IntrinsicSize.Max)
+ .graphicsLayer {
+ if (animated) {
+ scaleX = scale
+ scaleY = scale
+ this.alpha = alpha
+ transformOrigin = TransformOrigin(transformOriginX, transformOriginY)
+ }
+ shadowElevation = shadowElevationPx
+ shape = menuShape
+ clip = false
+ },
+ ) {
Box(
modifier = Modifier
.matchParentSize()
- .background(MaterialTheme.colorScheme.surfaceContainer, menuShape),
+ .clip(menuShape)
+ .background(MaterialTheme.colorScheme.surfaceContainer),
)
- content()
+ Box(modifier = Modifier.clip(menuShape)) {
+ content()
+ }
}
}
@@ -467,27 +473,40 @@ internal fun ChatStyleContextMenuItem(
isError -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
- val iconColor = textColor
+ val interactionSource = remember { MutableInteractionSource() }
+ val hovered by interactionSource.collectIsHoveredAsState()
+ val pressed by interactionSource.collectIsPressedAsState()
+ val pressScale by animateFloatAsState(
+ targetValue = if (pressed && enabled) 0.96f else 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMedium,
+ ),
+ label = "contextMenuItemPress",
+ )
Box(
modifier = modifier
.fillMaxWidth()
.padding(4.dp)
+ .graphicsLayer {
+ scaleX = pressScale
+ scaleY = pressScale
+ }
.clip(chatStyleMenuItemShape)
- .then(
- if (enabled) {
- Modifier.scaleOnPress(
- scale = 0.96f,
- onClick = onClick,
- indication = LocalIndication.current,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioMediumBouncy,
- stiffness = Spring.StiffnessMedium,
- ),
- )
+ .background(
+ if (enabled && hovered) {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
} else {
- Modifier
+ Color.Transparent
},
)
+ .hoverable(interactionSource = interactionSource, enabled = enabled)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ enabled = enabled,
+ onClick = onClick,
+ )
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart,
) {
@@ -498,7 +517,7 @@ internal fun ChatStyleContextMenuItem(
Icon(
imageVector = icon,
contentDescription = text,
- tint = iconColor,
+ tint = textColor,
modifier = Modifier.size(20.dp),
)
Text(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
index 6c49c62..80d8a91 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
@@ -1110,6 +1110,15 @@ fun MessageItem(
!isCorrupted &&
!isFilenameOnlyMessageCaption(message)
) {
+ // I-beam over message text: SelectionContainer registers
+ // PointerIcon.Text on Android/desktop; outer hover icon
+ // covers padding and ContextMenuArea gaps on desktop.
+ val selectableTextModifier = Modifier
+ .pointerHoverIcon(
+ PointerIcon.Text,
+ overrideDescendants = true,
+ )
+ .padding(horizontal = 14.dp)
if (mouseMessageUi) {
// Default selection colors use primary; on author
// bubbles that matches the background, so
@@ -1141,19 +1150,20 @@ fun MessageItem(
text = message.content,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
- modifier = Modifier.padding(
- horizontal = 14.dp,
- ),
+ modifier = selectableTextModifier,
)
}
}
}
} else {
+ // Android (and touch): still show text cursor when a
+ // mouse/trackpad hovers the caption; selection stays
+ // off so long-press keeps opening the message menu.
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
- modifier = Modifier.padding(horizontal = 14.dp)
+ modifier = selectableTextModifier,
)
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt
index 112ed35..aedc8ae 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt
@@ -1,8 +1,16 @@
package ru.fromchat.ui.main
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
@@ -13,14 +21,32 @@ import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import ru.fromchat.ui.components.AppPanel
import ru.fromchat.ui.extraStatusBars
+import com.pr0gramm3r101.utils.settings.Settings as PlatformSettings
/**
* Horizontal inset for chat detail content (messages, top-bar pills, input) in list–detail
@@ -40,7 +66,11 @@ val LocalConversationListDetailActive = staticCompositionLocalOf { false }
* ([detailInPanel]); chat detail is edge-to-edge ([detailEdgeToEdge]) so floating chrome
* can blur past its inner padding toward the window.
*
- * A small gutter separates panes so list-panel round corners stay visible.
+ * A small gutter separates panes so list-panel round corners stay visible. Drag that gap
+ * to resize the list; the remembered width is stored in desktop prefs
+ * (`desktop_list_pane_width`) and does not jump when adaptive window size classes change.
+ * While the gap is hovered or dragged it stays transparent so right-pane blur can extend
+ * into it.
*
* Pads with [WindowInsets.safeDrawing] ∪ [WindowInsets.extraStatusBars] so panes clear the
* macOS title bar / system bars, then consumes those insets so child top bars do not double-pad
@@ -49,66 +79,162 @@ val LocalConversationListDetailActive = staticCompositionLocalOf { false }
*
* Layout structure is stable regardless of [detailEdgeToEdge] / [detailInPanel] so detail
* [androidx.compose.animation.AnimatedContent] is not remounted when empty ↔ chat.
+ *
+ * The detail slot fills immediately with [androidx.compose.material3.ColorScheme.background]
+ * when the window grows (avoids a white/unpainted flash in the new strip). Detail content
+ * width springs toward the slot size so continuous resize eases instead of jumping.
*/
@Composable
fun ConversationListDetailShell(
- listPaneWidth: Dp,
listPane: @Composable () -> Unit,
detailPane: @Composable () -> Unit,
modifier: Modifier = Modifier,
detailInPanel: Boolean = false,
detailEdgeToEdge: Boolean = false,
) {
+ val density = LocalDensity.current
+ var storedListWidth by remember { mutableStateOf(ConversationListPanePrefs.load()) }
+ var gapDragging by remember { mutableStateOf(false) }
+ val gapInteractionSource = remember { MutableInteractionSource() }
+ val gapHovered by gapInteractionSource.collectIsHoveredAsState()
+ val gapActive = gapHovered || gapDragging
+
val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)
+ val shellBackground = MaterialTheme.colorScheme.background
CompositionLocalProvider(LocalConversationListDetailActive provides true) {
- Row(modifier.fillMaxSize()) {
- AppPanel(
- Modifier
- .windowInsetsPadding(paneInsets)
- .consumeWindowInsets(paneInsets)
- .padding(
- start = 8.dp,
- top = 8.dp,
- bottom = 8.dp,
+ BoxWithConstraints(
+ modifier
+ .fillMaxSize()
+ .background(shellBackground),
+ ) {
+ // List start padding (8.dp) + gap (8.dp) reserved outside the panel width.
+ val maxListWidth =
+ (maxWidth - 8.dp - 8.dp - 320.dp).coerceAtLeast(280.dp)
+ val listWidth = storedListWidth.coerceIn(280.dp, maxListWidth)
+
+ Row(Modifier.fillMaxSize()) {
+ AppPanel(
+ Modifier
+ .windowInsetsPadding(paneInsets)
+ .consumeWindowInsets(paneInsets)
+ .padding(
+ start = 8.dp,
+ top = 8.dp,
+ bottom = 8.dp,
+ )
+ .width(listWidth)
+ .fillMaxHeight(),
+ shape = RoundedCornerShape(24.dp),
+ ) {
+ listPane()
+ }
+ // Detail fills the remainder and draws under the leading gap so chrome blur
+ // can extend into it when the gap strip is transparent.
+ BoxWithConstraints(
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .background(shellBackground)
+ .then(
+ if (detailEdgeToEdge) {
+ Modifier
+ } else {
+ Modifier
+ .windowInsetsPadding(paneInsets)
+ .consumeWindowInsets(paneInsets)
+ .padding(
+ top = 8.dp,
+ end = 8.dp,
+ bottom = 8.dp,
+ )
+ },
+ ),
+ ) {
+ val detailWidth by animateDpAsState(
+ targetValue = maxWidth,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ label = "conversationDetailPaneWidth",
)
- .width(listPaneWidth)
- .fillMaxHeight(),
- shape = RoundedCornerShape(24.dp),
- ) {
- listPane()
- }
- Spacer(Modifier.width(8.dp))
- Box(
- Modifier
- .weight(1f)
- .fillMaxHeight()
- .then(
- if (detailEdgeToEdge) {
- Modifier
- } else {
- Modifier
- .windowInsetsPadding(paneInsets)
- .consumeWindowInsets(paneInsets)
- .padding(
- top = 8.dp,
- end = 8.dp,
- bottom = 8.dp,
- )
- },
- ),
- ) {
- if (detailInPanel) {
- AppPanel(
- Modifier.fillMaxSize(),
- shape = RoundedCornerShape(24.dp),
+ Box(
+ Modifier
+ .width(detailWidth.coerceAtMost(maxWidth))
+ .fillMaxHeight()
+ .align(Alignment.CenterStart),
) {
- detailPane()
+ if (detailInPanel) {
+ AppPanel(
+ Modifier.fillMaxSize(),
+ shape = RoundedCornerShape(24.dp),
+ ) {
+ detailPane()
+ }
+ } else {
+ detailPane()
+ }
}
- } else {
- detailPane()
+ Box(
+ Modifier
+ .width(8.dp)
+ .fillMaxHeight()
+ .align(Alignment.CenterStart)
+ .zIndex(1f)
+ .background(if (gapActive) Color.Transparent else shellBackground)
+ .pointerHoverIcon(PointerIcon.Hand)
+ .hoverable(gapInteractionSource)
+ .pointerInput(maxListWidth, density) {
+ var dragWidth = storedListWidth
+ var dragged = false
+ detectHorizontalDragGestures(
+ onDragStart = {
+ gapDragging = true
+ dragged = false
+ dragWidth =
+ storedListWidth.coerceIn(280.dp, maxListWidth)
+ },
+ onDragEnd = {
+ gapDragging = false
+ if (dragged) {
+ ConversationListPanePrefs.save(dragWidth)
+ }
+ },
+ onDragCancel = { gapDragging = false },
+ onHorizontalDrag = { _, dragAmount ->
+ val delta = with(density) { dragAmount.toDp() }
+ dragged = true
+ dragWidth =
+ (dragWidth + delta).coerceIn(280.dp, maxListWidth)
+ storedListWidth = dragWidth
+ },
+ )
+ },
+ )
}
}
}
}
}
+
+/**
+ * Persists list–detail left pane width in the same JVM prefs node as window size
+ * (`ru.fromchat.settings` / [PlatformSettings]).
+ */
+private object ConversationListPanePrefs {
+ private const val WIDTH_KEY = "desktop_list_pane_width"
+
+ private val settings = PlatformSettings()
+
+ fun load(): Dp {
+ val stored = runBlocking { settings.getFloat(WIDTH_KEY, 0f) }
+ return if (stored <= 0f) 360.dp else stored.dp
+ }
+
+ fun save(width: Dp) {
+ CoroutineScope(Dispatchers.IO).launch {
+ settings.putFloat(WIDTH_KEY, width.value)
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt
index 30d290e..8f1c37c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt
@@ -72,7 +72,6 @@ fun DesktopChatsDetailNavHost(
profileDetailDestinations(
navController = navController,
sharedTransitionScope = sharedTransitionScope,
- showBackOnProfile = true,
)
}
}
@@ -126,7 +125,6 @@ fun DesktopSettingsDetailNavHost(
profileDetailDestinations(
navController = navController,
sharedTransitionScope = sharedTransitionScope,
- showBackOnProfile = false,
onOpenChat = onOpenChatFromProfile,
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt
index 6f151e6..6495f1a 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopTabDetailHosts.kt
@@ -8,6 +8,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex
import androidx.navigation.NavController
+import com.pr0gramm3r101.utils.WindowWidthSizeClass
+import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
+import com.pr0gramm3r101.utils.widthSizeClass
+import ru.fromchat.ui.LocalNavController
/**
* Empty roots for desktop per-tab detail [androidx.navigation.NavHost]s.
@@ -17,6 +21,26 @@ object DesktopDetailNav {
const val CHATS_ROOT = "desktop_chats_detail_root"
const val SETTINGS_ROOT = "desktop_settings_detail_root"
const val CONTACTS_ROOT = "desktop_contacts_detail_root"
+
+ fun isEmptyRoot(route: String?): Boolean =
+ route == CHATS_ROOT || route == SETTINGS_ROOT || route == CONTACTS_ROOT
+}
+
+/**
+ * Whether a detail-pane / full-screen top bar should show a back control.
+ *
+ * Compact (full-screen stack): always — leaving the screen requires back.
+ * Large list–detail: only when the previous entry is a real destination, not an empty
+ * desktop detail root or the main `chat` shell (popping would only reveal a placeholder).
+ */
+@Composable
+fun detailPaneShowBackButton(): Boolean {
+ if (currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT) {
+ return true
+ }
+ val previousRoute = LocalNavController.current.previousBackStackEntry?.destination?.route
+ ?: return false
+ return previousRoute != "chat" && !DesktopDetailNav.isEmptyRoot(previousRoute)
}
/**
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt
index 8de9498..bc27c6c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt
@@ -99,13 +99,11 @@ fun NavGraphBuilder.conversationDetailDestinations(
/**
* Standalone profile + edit-profile destinations.
*
- * @param showBackOnProfile When false (desktop settings detail own-profile), hides the back button.
* @param onOpenChat Opens a DM; on desktop settings host this should target the Chats detail nav.
*/
fun NavGraphBuilder.profileDetailDestinations(
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
- showBackOnProfile: Boolean = true,
onOpenChat: (Int) -> Unit = { navController.navigateToDmChat(it) },
) {
composable(
@@ -144,7 +142,7 @@ fun NavGraphBuilder.profileDetailDestinations(
ProfileScreen(
userId = userId,
username = profileUsername,
- showBackButton = showBackOnProfile,
+ showBackButton = detailPaneShowBackButton(),
onBack = { navController.navigateUp() },
onChat = onOpenChat,
sharedTransitionScope = sharedTransitionScope,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt
index 3212c7f..8430d81 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt
@@ -60,6 +60,7 @@ fun AboutScreen() {
val uriHandler = LocalUriHandler.current
Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
topBar = {
SettingsDetailTopBar(
title = {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt
index 6579bb3..d09e84a 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt
@@ -67,6 +67,7 @@ fun AppearanceScreen(onBack: () -> Unit) {
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
topBar = {
SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_category_appearance)) },
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt
index 03198ac..83e49f7 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
@@ -58,6 +59,7 @@ fun NotificationsScreen(onBack: () -> Unit) {
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
Scaffold(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
SettingsDetailTopBar(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt
index 0af9758..c334ce0 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsDetailTopBar.kt
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -17,7 +18,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
@@ -32,24 +32,14 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.back
-import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.main.detailPaneShowBackButton
/** Idle gap after mouse-wheel / trackpad deltas before snapping the collapsing bar. */
private const val AppBarWheelSettleDelayMs = 64L
-/**
- * Back is shown on compact (full-screen stack), and on large screens only when the
- * previous destination is not the list–detail shell (`chat`) — i.e. when nested
- * deeper than a settings detail opened as the right pane.
- */
+/** @see detailPaneShowBackButton */
@Composable
-fun settingsDetailShowBackButton(): Boolean {
- if (currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT) {
- return true
- }
- val previousRoute = LocalNavController.current.previousBackStackEntry?.destination?.route
- return previousRoute != null && previousRoute != "chat"
-}
+fun settingsDetailShowBackButton(): Boolean = detailPaneShowBackButton()
@Composable
fun settingsDetailUseCollapsingTopBar(): Boolean =
@@ -91,20 +81,29 @@ fun SettingsDetailTopBar(
}
}
}
+ // Must match settings-detail Scaffold.containerColor and AppPanel
+ // (`surfaceContainerLowest`). Scaffold defaults to `background`, which is a
+ // different token (e.g. light Neutral98 vs Neutral100) — that was the stripe.
+ val paneColor = MaterialTheme.colorScheme.surfaceContainerLowest
if (settingsDetailUseCollapsingTopBar()) {
MediumTopAppBar(
title = title,
navigationIcon = navigationIcon,
scrollBehavior = scrollBehavior,
- // Transparent while expanded; default scrolledContainerColor when collapsed.
+ // Unscrolled: pane. Collapsed: default scrolledContainerColor (elevation).
colors = TopAppBarDefaults.topAppBarColors(
- containerColor = Color.Transparent,
+ containerColor = paneColor,
),
)
} else {
+ // Desktop / expanded: pinned TopAppBar (not Large/Medium).
TopAppBar(
title = title,
navigationIcon = navigationIcon,
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = paneColor,
+ scrolledContainerColor = paneColor,
+ ),
)
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
index 7681c4d..c3abc93 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
@@ -21,6 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -88,6 +89,11 @@ fun SettingsTab() {
title = {
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
+ // Match AppPanel / Chats unscrolled chrome — default `surface` reads as an
+ // elevated (scrolled) stripe over `surfaceContainerLowest`.
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
+ ),
)
}
) { innerPadding ->
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
index 880e737..31053a1 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
@@ -75,6 +75,8 @@ fun AccountScreen(
}
Scaffold(
+ // Match AppPanel / SettingsDetailTopBar — default `background` is a darker stripe under the bar.
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
topBar = {
SettingsDetailTopBar(
title = { Text(stringResource(Res.string.settings_account_title)) },
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ChatTextSelectionMenu.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ChatTextSelectionMenu.jvm.kt
index 158050a..74214d3 100644
--- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ChatTextSelectionMenu.jvm.kt
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ChatTextSelectionMenu.jvm.kt
@@ -1,5 +1,8 @@
package ru.fromchat.ui.chat
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.ContextMenuRepresentation
@@ -16,12 +19,22 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onSizeChanged
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 androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -80,12 +93,44 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
return
}
+ val anchor = IntOffset(
+ status.rect.left.toInt(),
+ status.rect.bottom.toInt(),
+ )
+ val animationProgress = remember { mutableFloatStateOf(0f) }
+ var enterLaidOut by remember { mutableStateOf(false) }
+
+ LaunchedEffect(enterLaidOut) {
+ if (!enterLaidOut) {
+ animationProgress.floatValue = 0f
+ return@LaunchedEffect
+ }
+ animationProgress.floatValue = 0f
+ animate(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMedium,
+ ),
+ ) { value, _ ->
+ animationProgress.floatValue = value
+ }
+ }
+
+ val positionProvider = remember(anchor) {
+ object : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize,
+ ): IntOffset = anchor
+ }
+ }
+
Popup(
- alignment = Alignment.TopStart,
- offset = IntOffset(
- status.rect.left.toInt(),
- status.rect.bottom.toInt(),
- ),
+ popupPositionProvider = positionProvider,
onDismissRequest = {
state.status = FoundationContextMenuState.Status.Closed
},
@@ -96,7 +141,16 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
clippingEnabled = false,
),
) {
- ChatStyleContextMenuFrame {
+ ChatStyleContextMenuFrame(
+ modifier = Modifier.onSizeChanged { size ->
+ if (size.width > 0 && size.height > 0) enterLaidOut = true
+ },
+ animated = true,
+ scale = 0.8f + 0.2f * animationProgress.floatValue,
+ alpha = animationProgress.floatValue,
+ transformOriginX = 0f,
+ transformOriginY = 0f,
+ ) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 8.dp)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dbabf40..4516d29 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -48,7 +48,6 @@ markdownRendererM3 = "0.43.0"
bouncycastle = "1.85"
webkit = "1.16.0"
openjfx = "21.0.6"
-
[libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }