WebSocket status in tray, more UI fixes

This commit is contained in:
2026-07-27 01:54:11 +03:00
Unverified
parent 5166f3507d
commit 846c1db3d1
28 changed files with 638 additions and 242 deletions
+3
View File
@@ -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 = """
<key>CFBundleURLTypes</key>
@@ -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",
Binary file not shown.
@@ -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)
*
* Listdetail 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"
@@ -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"
}
@@ -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<String>) {
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<String>) {
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<String>) {
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<String>) {
}
}
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<String>) {
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<String>) {
}
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<String>) {
}
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<String>) {
true
}
event.isShiftPressed && event.key == Key.N -> {
windowVisible = true
showMainWindow()
true
}
event.key == Key.M -> {
@@ -367,8 +446,11 @@ fun main(args: Array<String>) {
}
},
) {
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<String>) {
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<String>) {
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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

@@ -111,6 +111,8 @@
<string name="search_not_found_message">Ничего не найдено. Попробуйте переформулировать запрос.</string>
<string name="message_placeholder">Напишите сообщение…</string>
<string name="status_connecting">Соединение</string>
<string name="status_connected">Подключено</string>
<string name="status_disconnected">Отключено</string>
<string name="status_updating">Обновление</string>
<string name="chat_group_label">Группа</string>
<string name="chat_members_count">%1$d человек</string>
@@ -123,6 +123,8 @@
<!-- Connection / chat chrome -->
<string name="status_connecting">Connecting</string>
<string name="status_connected">Connected</string>
<string name="status_disconnected">Disconnected</string>
<string name="status_updating">Updating</string>
<string name="chat_group_label">Group</string>
<string name="chat_members_count">%1$d people</string>
@@ -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 = {
@@ -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() }
},
@@ -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(
@@ -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<Float>(
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(
@@ -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,
)
}
}
@@ -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 listdetail
@@ -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 listdetail 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)
}
}
}
@@ -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,
)
}
@@ -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 listdetail: 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)
}
/**
@@ -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,
@@ -60,6 +60,7 @@ fun AboutScreen() {
val uriHandler = LocalUriHandler.current
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
topBar = {
SettingsDetailTopBar(
title = {
@@ -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)) },
@@ -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(
@@ -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 listdetail 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,
),
)
}
}
@@ -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 ->
@@ -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)) },
@@ -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)
-1
View File
@@ -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" }