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" }