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 1fcc592..6ad533b 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -262,6 +262,12 @@ fun main(args: Array) { // 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") + val appearanceName = when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) { + Theme.Dark -> "NSAppearanceNameDarkAqua" + Theme.Light -> "NSAppearanceNameAqua" + Theme.AsSystem -> "system" + } + System.setProperty("apple.awt.application.appearance", appearanceName) } if (!DesktopSingleInstance.acquireOrForward(args)) return DesktopProtocolRegistration.register() diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 5c6235c..3ad8bcd 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -169,6 +169,7 @@ kotlin { } jvmMain.dependencies { + implementation(compose.desktop.currentOs) implementation(libs.jetbrains.kotlinx.io.bytestring) implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.ktor.client.cio) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.android.kt index 0e67806..6e222a5 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.android.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.window.PopupProperties internal actual fun MessageContextMenuPopup( onDismissRequest: () -> Unit, positionProvider: PopupPositionProvider, + reserveOvershoot: Boolean, content: @Composable () -> Unit, ) { Popup( @@ -18,7 +19,8 @@ internal actual fun MessageContextMenuPopup( focusable = true, dismissOnBackPress = true, dismissOnClickOutside = true, - clippingEnabled = true, + // Let the spring draw past the laid-out bounds while [reserveOvershoot] is true. + clippingEnabled = !reserveOvershoot, ), content = content, ) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.android.kt index d033695..e482c64 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.android.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalContext import androidx.core.app.ActivityCompat import androidx.core.net.toUri @@ -34,6 +35,11 @@ actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHos } } +actual fun dragPointerInWindow(event: DragAndDropEvent): Offset? { + val androidEvent = event.toAndroidDragEvent() + return Offset(androidEvent.x, androidEvent.y) +} + actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean { val mimeTypes = event.mimeTypes() if (mimeTypes.any { it == ClipDescription.MIMETYPE_TEXT_URILIST }) return true diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt index 0e21a64..74e134d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt @@ -70,6 +70,7 @@ import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.Text +import ru.fromchat.ui.extraStatusBars private data class PendingDocument( val parsed: ParsedDocument, @@ -119,6 +120,7 @@ fun DocumentScreen( topBar = { val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle() MediumTopAppBar( + windowInsets = WindowInsets.extraStatusBars, title = { Text( text = when (type) { 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 13f152f..ba2fb6e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -84,6 +84,9 @@ import ru.fromchat.ui.auth.captcha.SmartCaptchaNav import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen import ru.fromchat.ui.auth.yandex.YandexOAuthNav import ru.fromchat.ui.auth.yandex.YandexOAuthScreen +import ru.fromchat.ui.chat.ChatFullscreenImageController +import ru.fromchat.ui.chat.ChatFullscreenImageHost +import ru.fromchat.ui.chat.LocalChatFullscreenImageController import ru.fromchat.ui.calls.CallOverlay import ru.fromchat.ui.chat.panels.dm.navigateToDmChat import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav @@ -471,13 +474,15 @@ fun App( } } + val fullscreenImageController = remember { ChatFullscreenImageController() } CompositionLocalProvider( LocalNavController provides navController, LocalDesktopChatsNavController provides if (isDesktopListDetail) chatsDetailNavController else null, LocalDesktopSettingsNavController provides if (isDesktopListDetail) settingsDetailNavController else null, - LocalSystemBarsVisibility provides rememberSystemBarsController() + LocalSystemBarsVisibility provides rememberSystemBarsController(), + LocalChatFullscreenImageController provides fullscreenImageController, ) { if (startDestination != null) { val currentEntry by navController.currentBackStackEntryAsState() @@ -673,9 +678,6 @@ fun App( AppNavHost(Modifier.fillMaxSize()) if (showConversationListDetail) { - val detailEdgeToEdge = - pendingMainTab == MAIN_PAGE_CHATS || - pendingMainTab == MAIN_PAGE_CONTACTS val listPaneHazeState = rememberHazeState() CompositionLocalProvider( LocalPaneHazeState provides listPaneHazeState, @@ -683,7 +685,7 @@ fun App( ) { ConversationListDetailShell( detailInPanel = false, - detailEdgeToEdge = detailEdgeToEdge, + detailEdgeToEdge = true, listPane = { MainScreen( sharedTransitionScope = this@SharedTransitionLayout, @@ -732,6 +734,7 @@ fun App( } } + ChatFullscreenImageHost(Modifier.fillMaxSize()) CallOverlay(Modifier.fillMaxSize()) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 003f9be..dd1fe69 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -59,7 +59,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntSize @@ -239,15 +239,8 @@ fun AttachmentPreview( .then( if (onImageBounds != null && showImageTile) { Modifier.onGloballyPositioned { coords -> - val pos = coords.positionInRoot() - val size = coords.size onImageBounds( - Rect( - pos.x, - pos.y, - pos.x + size.width.toFloat(), - pos.y + size.height.toFloat() - ) + coords.boundsInWindow() ) } } else { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index 21191fe..9e6b15c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -117,12 +117,12 @@ import ru.fromchat.message_placeholder import ru.fromchat.message_replying_to import ru.fromchat.suspend_chat_banner_message import ru.fromchat.ui.chat.utils.SelectedAttachment -import ru.fromchat.ui.chat.utils.AttachmentDragSession import ru.fromchat.ui.chat.utils.TypingHandler import ru.fromchat.ui.chat.utils.AttachmentDropBridge import ru.fromchat.ui.chat.utils.GlobalAttachmentDropRouter import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget import ru.fromchat.ui.chat.utils.getFilenameFromUri +import ru.fromchat.ui.chat.utils.isDropHighlightActive import ru.fromchat.ui.chat.utils.rememberFilePicker import ru.fromchat.ui.chat.utils.rememberImagePicker import ru.fromchat.ui.chat.utils.urisToSelectedAttachments @@ -327,8 +327,7 @@ fun ChatInput( val dropScrimColor = lerp(MaterialTheme.colorScheme.primary, Color.Black, 0.62f) val attachmentDropEnabled = supportsAttachments && !isReadOnly val dropHighlightActive = - attachmentDropEnabled && - (attachmentDropBridge.dropHighlightActive || AttachmentDragSession.isActive) + attachmentDropEnabled && attachmentDropBridge.isDropHighlightActive() val dropAnimMs = 220 val dropScale by animateFloatAsState( targetValue = if (dropHighlightActive) 0.96f else 1f, 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 d9b9962..d28e0cd 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 @@ -44,6 +44,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState @@ -1532,54 +1533,108 @@ fun ChatScreen( } } - expandedImage?.let { (msg, idx) -> - val key = imageAttachmentKey(msg, idx) - ImageFullscreenPreview( + val fullscreenHost = LocalChatFullscreenImageController.current + DisposableEffect(fullscreenHost) { + onDispose { fullscreenHost?.request = null } + } + val expandedImageSnapshot = expandedImage + SideEffect { + val host = fullscreenHost ?: return@SideEffect + val pair = expandedImageSnapshot + if (pair == null) { + if (host.request != null) host.request = null + return@SideEffect + } + val (msg, idx) = pair + host.onDismiss = { + isImageClosing = false + expandedImage = null + } + host.onClosingChange = { closing -> isImageClosing = closing } + host.onReply = { m -> + replyTo = m + if (editingMessage != null) { + editingMessage = null + inputText = "" + } + isImageClosing = false + expandedImage = null + } + host.onDelete = { m -> + scope.launch { panel.handleDeleteMessage(m.id) } + } + host.onSave = save@{ savedMsg, fileIndex -> + if (!isMessageImageFullyLoaded(savedMsg, fileIndex)) return@save + val file = savedMsg.files?.getOrNull(fileIndex) ?: return@save + resolveImageSourceUri(savedMsg, fileIndex)?.let { source -> + saveMessageImage( + SavableMessageImage( + fileIndex = fileIndex, + sourceUri = source, + filename = file.name, + mimeType = mimeTypeForImageFilename(file.name), + ), + ) + } + } + val published = ChatFullscreenImageRequest( message = msg, fileIndex = idx, currentUserId = currentUserId, - onDismiss = { - isImageClosing = false - expandedImage = null - }, - onClosingChange = { isImageClosing = it }, - onReply = { m -> - replyTo = m - if (editingMessage != null) { - editingMessage = null - inputText = "" - } - isImageClosing = false - expandedImage = null - }, - onDelete = { m -> - scope.launch { - panel.handleDeleteMessage(m.id) - } - }, - onSave = { msg, fileIndex -> - if (!isMessageImageFullyLoaded(msg, fileIndex)) return@ImageFullscreenPreview - val file = msg.files?.getOrNull(fileIndex) ?: return@ImageFullscreenPreview - resolveImageSourceUri(msg, fileIndex)?.let { source -> - saveMessageImage( - SavableMessageImage( - fileIndex = fileIndex, - sourceUri = source, - filename = file.name, - mimeType = mimeTypeForImageFilename(file.name), - ), - ) - } - }, - sharedTransitionScope = null, - animatedVisibilityScope = null, - sharedImageKey = null, - modifier = Modifier.fillMaxSize(), - thumbnailBounds = imageThumbBounds[key] + thumbnailBounds = imageThumbBounds[imageAttachmentKey(msg, idx)], ) + if (host.request != published) host.request = published + } + if (fullscreenHost == null) { + expandedImage?.let { (msg, idx) -> + ImageFullscreenPreview( + message = msg, + fileIndex = idx, + currentUserId = currentUserId, + onDismiss = { + isImageClosing = false + expandedImage = null + }, + onClosingChange = { isImageClosing = it }, + onReply = { m -> + replyTo = m + if (editingMessage != null) { + editingMessage = null + inputText = "" + } + isImageClosing = false + expandedImage = null + }, + onDelete = { m -> + scope.launch { + panel.handleDeleteMessage(m.id) + } + }, + onSave = { savedMsg, fileIndex -> + if (!isMessageImageFullyLoaded(savedMsg, fileIndex)) { + return@ImageFullscreenPreview + } + val file = savedMsg.files?.getOrNull(fileIndex) ?: return@ImageFullscreenPreview + resolveImageSourceUri(savedMsg, fileIndex)?.let { source -> + saveMessageImage( + SavableMessageImage( + fileIndex = fileIndex, + sourceUri = source, + filename = file.name, + mimeType = mimeTypeForImageFilename(file.name), + ), + ) + } + }, + sharedTransitionScope = null, + animatedVisibilityScope = null, + sharedImageKey = null, + modifier = Modifier.fillMaxSize(), + thumbnailBounds = imageThumbBounds[imageAttachmentKey(msg, idx)], + ) + } } } - } @Composable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt index 0fe6964..7799875 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -101,6 +102,46 @@ private data class InitialTransform( val bgAlpha: Float ) +class ChatFullscreenImageController { + var request by mutableStateOf(null) + var onDismiss: () -> Unit = {} + var onClosingChange: (Boolean) -> Unit = {} + var onReply: (Message) -> Unit = {} + var onDelete: (Message) -> Unit = {} + var onSave: (Message, Int) -> Unit = { _, _ -> } +} + +data class ChatFullscreenImageRequest( + val message: Message, + val fileIndex: Int, + val currentUserId: Int?, + val thumbnailBounds: Rect?, +) + +val LocalChatFullscreenImageController = + staticCompositionLocalOf { null } + +@Composable +fun ChatFullscreenImageHost(modifier: Modifier = Modifier) { + val controller = LocalChatFullscreenImageController.current ?: return + val request = controller.request ?: return + ImageFullscreenPreview( + message = request.message, + fileIndex = request.fileIndex, + currentUserId = request.currentUserId, + onDismiss = controller.onDismiss, + onClosingChange = controller.onClosingChange, + onReply = controller.onReply, + onDelete = controller.onDelete, + onSave = controller.onSave, + sharedTransitionScope = null, + animatedVisibilityScope = null, + sharedImageKey = null, + modifier = modifier.fillMaxSize(), + thumbnailBounds = request.thumbnailBounds, + ) +} + @Composable fun ImageFullscreenPreview( message: Message, 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 a64b9a6..1cb9587 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 @@ -17,8 +17,10 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -36,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -75,6 +78,7 @@ import ru.fromchat.ui.components.Text internal expect fun MessageContextMenuPopup( onDismissRequest: () -> Unit, positionProvider: PopupPositionProvider, + reserveOvershoot: Boolean = false, content: @Composable () -> Unit, ) @@ -164,12 +168,15 @@ fun MessageContextMenu( } val animationProgress = remember { mutableFloatStateOf(0f) } var enterLaidOut by remember(state.message) { mutableStateOf(false) } + var reserveOvershoot by remember(state.message) { mutableStateOf(true) } var frozenOrigin by remember(state.message) { mutableStateOf(TransformOrigin(0f, 0f)) } + var lockedMenuWidthPx by remember(state.message) { mutableIntStateOf(0) } LaunchedEffect(state.isOpen) { if (!state.isOpen) { + reserveOvershoot = true animate( initialValue = animationProgress.floatValue, targetValue = 0f, @@ -188,9 +195,11 @@ fun MessageContextMenu( shouldShowPopup = true if (!enterLaidOut) { animationProgress.floatValue = 0f + reserveOvershoot = true return@LaunchedEffect } animationProgress.floatValue = 0f + reserveOvershoot = true animate( initialValue = 0f, targetValue = 1f, @@ -198,6 +207,7 @@ fun MessageContextMenu( ) { value, _ -> animationProgress.floatValue = value } + reserveOvershoot = false } } @@ -239,10 +249,11 @@ fun MessageContextMenu( // [state.position] is window coordinates (see MessageItem localToWindow). // Alignment+offset would add the popup parent’s window origin again (double offset // in list–detail panes). Place with an absolute provider instead. - // Desktop uses a real OS window so the menu can sit outside the app frame. + // Desktop hosts this in a transparent OS window so the menu can leave the app frame. MessageContextMenuPopup( onDismissRequest = onDismiss, positionProvider = positionProvider, + reserveOvershoot = reserveOvershoot, ) { ContextMenuContent( message = state.message, @@ -276,24 +287,35 @@ fun MessageContextMenu( onRetrySend(it) onDismiss() }, - 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, + modifier = modifier + .then( + if (lockedMenuWidthPx > 0) { + Modifier.requiredWidth(with(density) { lockedMenuWidthPx.toDp() }) + } else { + Modifier + }, ) - 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 - }, + .onSizeChanged { size -> + if (size.width > 0 && size.width > lockedMenuWidthPx) { + lockedMenuWidthPx = size.width + } + 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, @@ -437,6 +459,7 @@ internal fun ChatStyleContextMenuFrame( // invalidation does not rebuild the elevated layer (avoids first-hover blink). Box( modifier = modifier + .wrapContentWidth(unbounded = true) .width(IntrinsicSize.Max) .graphicsLayer { if (animated) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.kt index 9eecad4..2a1055f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.kt @@ -14,10 +14,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -26,10 +26,14 @@ import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlin.time.Clock @@ -43,9 +47,73 @@ import com.pr0gramm3r101.utils.conditional /** True while a file drag that this process accepted is still in progress. */ object AttachmentDragSession { var isActive by mutableStateOf(false) - internal set + private set + + internal fun begin() { + isActive = true + } + + /** Clears drag-session UI. Safe to call from any drop / end path. */ + fun end() { + isActive = false + AttachmentDropHighlight.clear() + } } +/** + * Exactly one drop target may show the blur/scrim at a time. Hover is resolved by + * hit-testing registered window bounds against the current pointer so nested AWT + * targets cannot leave a stale row blurred. + */ +object AttachmentDropHighlight { + var activeBridge by mutableStateOf(null) + private set + + private val boundsByBridge = mutableMapOf() + + fun register(bridge: AttachmentDropBridge, bounds: Rect) { + boundsByBridge[bridge] = bounds + } + + fun unregister(bridge: AttachmentDropBridge) { + boundsByBridge.remove(bridge) + if (activeBridge === bridge) activeBridge = null + } + + fun syncFromPointer(windowPoint: Offset) { + activeBridge = hitTest(windowPoint) + } + + fun clear() { + activeBridge = null + } + + /** Delivers to the currently hovered target. Returns true if handled. */ + fun deliverToOwner(uris: List): Boolean { + if (uris.isEmpty()) return false + val bridge = activeBridge ?: return false + val consumer = bridge.consumer ?: return false + consumer(uris) + return true + } + + private fun hitTest(windowPoint: Offset): AttachmentDropBridge? { + var best: AttachmentDropBridge? = null + var bestArea = Float.POSITIVE_INFINITY + boundsByBridge.forEach { (bridge, bounds) -> + if (!bounds.contains(windowPoint)) return@forEach + val area = bounds.width * bounds.height + if (area > 0f && area < bestArea) { + bestArea = area + best = bridge + } + } + return best + } +} + +expect fun dragPointerInWindow(event: DragAndDropEvent): Offset? + /** * Delivers drops from the always-mounted app-root target to the visible chat composer. * Needed when a chat opens after drag start (Compose targets miss ACTION_DRAG_STARTED). @@ -90,33 +158,19 @@ object PendingChatAttachmentDrops { class AttachmentDropBridge { internal var consumer: ((List) -> Unit)? = null - private var dropHighlightDepth by mutableIntStateOf(0) - var dropHighlightActive by mutableStateOf(false) - private set - fun deliver(uris: List) { if (uris.isNotEmpty()) consumer?.invoke(uris) } - - internal fun enterDropHighlight() { - dropHighlightDepth++ - dropHighlightActive = dropHighlightDepth > 0 - } - - internal fun exitDropHighlight() { - if (dropHighlightDepth > 0) dropHighlightDepth-- - dropHighlightActive = dropHighlightDepth > 0 - } - - internal fun endDropHighlight() { - dropHighlightDepth = 0 - dropHighlightActive = false - } } @Composable fun rememberAttachmentDropBridge(): AttachmentDropBridge = remember { AttachmentDropBridge() } +/** Whether [bridge] currently owns the exclusive drop highlight. */ +@Composable +fun AttachmentDropBridge.isDropHighlightActive(): Boolean = + AttachmentDropHighlight.activeBridge === this + fun urisToSelectedAttachments( uris: List, existingAttachmentCount: Int, @@ -145,6 +199,23 @@ expect fun handleAttachmentDrop( onUris: (List) -> Unit, ): Boolean +private fun syncDropHighlight(event: DragAndDropEvent) { + val point = dragPointerInWindow(event) ?: return + AttachmentDropHighlight.syncFromPointer(point) +} + +private fun dispatchDropUris( + bridge: AttachmentDropBridge?, + uris: List, +) { + if (AttachmentDropHighlight.deliverToOwner(uris)) return + if (bridge != null) { + bridge.deliver(uris) + } else { + GlobalAttachmentDropRouter.deliver(uris) + } +} + @OptIn(ExperimentalFoundationApi::class) fun Modifier.chatAttachmentDropTarget( enabled: Boolean, @@ -152,33 +223,45 @@ fun Modifier.chatAttachmentDropTarget( ): Modifier = composed { if (!enabled) return@composed Modifier val permissionsHost = rememberAttachmentDropPermissionsHost() + DisposableEffect(bridge) { + onDispose { AttachmentDropHighlight.unregister(bridge) } + } val target = remember(bridge, permissionsHost) { object : DragAndDropTarget { override fun onStarted(event: DragAndDropEvent) { - AttachmentDragSession.isActive = true + AttachmentDragSession.begin() + syncDropHighlight(event) } override fun onEntered(event: DragAndDropEvent) { - bridge.enterDropHighlight() + syncDropHighlight(event) + } + + override fun onMoved(event: DragAndDropEvent) { + syncDropHighlight(event) } override fun onExited(event: DragAndDropEvent) { - bridge.exitDropHighlight() + syncDropHighlight(event) } override fun onEnded(event: DragAndDropEvent) { - bridge.endDropHighlight() - AttachmentDragSession.isActive = false + AttachmentDragSession.end() } override fun onDrop(event: DragAndDropEvent): Boolean { - bridge.endDropHighlight() - AttachmentDragSession.isActive = false - return handleAttachmentDrop(permissionsHost, event) { bridge.deliver(it) } + syncDropHighlight(event) + val accepted = handleAttachmentDrop(permissionsHost, event) { uris -> + dispatchDropUris(bridge, uris) + } + AttachmentDragSession.end() + return accepted } } } - dragAndDropTarget( + onGloballyPositioned { coords -> + AttachmentDropHighlight.register(bridge, coords.boundsInWindow()) + }.dragAndDropTarget( shouldStartDragAndDrop = { acceptsAttachmentDrop(it) }, target = target, ) @@ -194,18 +277,33 @@ fun Modifier.appRootAttachmentDropTarget(): Modifier = composed { val target = remember(permissionsHost) { object : DragAndDropTarget { override fun onStarted(event: DragAndDropEvent) { - AttachmentDragSession.isActive = true + AttachmentDragSession.begin() + syncDropHighlight(event) + } + + override fun onMoved(event: DragAndDropEvent) { + syncDropHighlight(event) + } + + override fun onEntered(event: DragAndDropEvent) { + syncDropHighlight(event) + } + + override fun onExited(event: DragAndDropEvent) { + syncDropHighlight(event) } override fun onEnded(event: DragAndDropEvent) { - AttachmentDragSession.isActive = false + AttachmentDragSession.end() } override fun onDrop(event: DragAndDropEvent): Boolean { - AttachmentDragSession.isActive = false - return handleAttachmentDrop(permissionsHost, event) { - GlobalAttachmentDropRouter.deliver(it) + syncDropHighlight(event) + val accepted = handleAttachmentDrop(permissionsHost, event) { uris -> + dispatchDropUris(bridge = null, uris = uris) } + AttachmentDragSession.end() + return accepted } } } 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 74903c9..438fe8e 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 @@ -9,20 +9,18 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.components.AppPanel private val detailNavTween = tween(durationMillis = 250, easing = FastOutSlowInEasing) @@ -82,9 +80,8 @@ fun DesktopChatsDetailNavHost( * Independent Settings-tab detail [NavHost] for desktop list–detail * (settings screens, own profile, and edit profile on the same stack). * - * [AppPanel] is drawn only behind non-empty destinations so the empty root stays - * bare (no surface fill), matching the prior AnimatedContent empty vs panel split. - * Panel is a sibling under the [NavHost] so the host is not remounted empty ↔ content. + * Settings detail is edge-to-edge on the pane background; empty root stays + * unpainted so the list–detail shell shows through. */ @Composable fun DesktopSettingsDetailNavHost( @@ -101,13 +98,11 @@ fun DesktopSettingsDetailNavHost( Box(modifier.fillMaxSize()) { if (showPanel) { - // Match Profile / ConversationListDetailShell (`background`), not the - // default AppPanel `surfaceContainerLowest` (visible mismatch in two-pane). - AppPanel( - Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(24.dp), - ) {} + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) } CompositionLocalProvider(LocalNavController provides navController) { NavHost( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index 32062e5..4c52ba3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -1,7 +1,10 @@ package ru.fromchat.ui.main +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -68,12 +71,15 @@ import ru.fromchat.profile import ru.fromchat.settings import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle +import ru.fromchat.ui.chat.panels.dm.navigateToDmChat +import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.LocalPaneHazeState import ru.fromchat.ui.components.Text import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost +import ru.fromchat.ui.main.chats.ChatsSearchScreen import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.profile.ProfileScreen @@ -175,6 +181,17 @@ fun MainScreen( val contextMenuHazeState = paneHazeState ?: rememberHazeState() val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } var chatListSelectionRequestId by remember { mutableStateOf(0L) } + var paneSearchOpen by remember { mutableStateOf(false) } + + fun openConversationSearch() { + if (embeddedInListDetail) { + paneSearchOpen = true + } else { + navController.navigate("search/conversations") { + launchSingleTop = true + } + } + } val widthClass = currentWindowAdaptiveInfo().widthSizeClass @@ -193,11 +210,12 @@ fun MainScreen( if (pagerState.currentPage != PAGE_CHATS) { pagerState.scrollToPage(PAGE_CHATS) } - navController.navigate("search/conversations") { - launchSingleTop = true - } + openConversationSearch() } DesktopMenuCommand.EnterChatListSelection -> { + if (paneSearchOpen) { + paneSearchOpen = false + } if (navController.currentBackStackEntry?.destination?.route == "search/conversations" ) { @@ -282,9 +300,7 @@ fun MainScreen( when (page) { PAGE_CHATS -> ChatsTab( isVisible = isChatsPage, - onOpenSearch = { - navController.navigate("search/conversations") - }, + onOpenSearch = { openConversationSearch() }, chatContextMenuOverlay = chatContextMenuOverlay, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, @@ -429,6 +445,35 @@ fun MainScreen( .fillMaxSize() .zIndex(3f), ) + + if (embeddedInListDetail) { + BackHandler(paneSearchOpen) { paneSearchOpen = false } + AnimatedVisibility( + visible = paneSearchOpen, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .fillMaxSize() + .zIndex(4f), + ) { + val chatsNav = LocalDesktopChatsNavController.current ?: navController + ChatsSearchScreen( + onBack = { paneSearchOpen = false }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = this, + onOpenProfile = { userId: Int -> + if (userId == 0) return@ChatsSearchScreen + paneSearchOpen = false + chatsNav.navigateReplacingMainDetail("profile/$userId") + }, + onOpenConversation = { userId: Int -> + if (userId == 0) return@ChatsSearchScreen + paneSearchOpen = false + chatsNav.navigateToDmChat(userId) + }, + ) + } + } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index 7badfcb..c385394 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -79,6 +79,7 @@ import androidx.compose.ui.util.fastAll import kotlinx.coroutines.withTimeout import ru.fromchat.ui.chat.utils.AttachmentDropHighlightBox import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget +import ru.fromchat.ui.chat.utils.isDropHighlightActive import ru.fromchat.ui.chat.utils.rememberAttachmentDropBridge import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBarSharedElement @@ -868,7 +869,7 @@ internal fun PublicChatRow( } AttachmentDropHighlightBox( - active = dropBridge.dropHighlightActive, + active = dropBridge.isDropHighlightActive(), shape = clipShape, modifier = Modifier .fillMaxWidth() @@ -1035,7 +1036,7 @@ internal fun DmConversationRow( } AttachmentDropHighlightBox( - active = dropBridge.dropHighlightActive, + active = dropBridge.isDropHighlightActive(), shape = clipShape, modifier = Modifier .fillMaxWidth() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt index f2f4816..74c207b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt @@ -432,6 +432,7 @@ fun DevicesScreen(onBack: () -> Unit) { topBar = { val topBarHazeStyle = HazeMaterials.thin() TopAppBar( + windowInsets = settingsDetailWindowInsets(), title = {}, navigationIcon = { if (settingsDetailShowBackButton()) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt index 0560d06..b922f15 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt @@ -448,6 +448,7 @@ fun LogFilesScreen( topBar = { Box { TopAppBar( + windowInsets = settingsDetailWindowInsets(), modifier = Modifier .graphicsLayer { alpha = 1f - selectionProgress } .background(MaterialTheme.colorScheme.surfaceContainer), @@ -473,6 +474,7 @@ fun LogFilesScreen( ) if (selectionMode || selectionProgress > 0f) { TopAppBar( + windowInsets = settingsDetailWindowInsets(), modifier = Modifier.graphicsLayer { alpha = selectionProgress }, navigationIcon = { IconButton( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt index bd7e46e..f874848 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt @@ -770,6 +770,7 @@ fun LogsScreen() { topBar = { Box { TopAppBar( + windowInsets = settingsDetailWindowInsets(), modifier = Modifier .graphicsLayer { alpha = (1f - selectionProgress) * (1f - searchProgress) @@ -884,6 +885,7 @@ fun LogsScreen() { ) if (searchMode) { TopAppBar( + windowInsets = settingsDetailWindowInsets(), modifier = Modifier.graphicsLayer { alpha = searchProgress }, colors = logsTransparentTopAppBarColors(), navigationIcon = { @@ -944,6 +946,7 @@ fun LogsScreen() { } if (selectionMode) { TopAppBar( + windowInsets = settingsDetailWindowInsets(), modifier = Modifier.graphicsLayer { alpha = selectionProgress }, navigationIcon = { IconButton( 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 a94efe0..ab3c5e7 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 @@ -34,7 +34,6 @@ import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.back import ru.fromchat.ui.extraStatusBars -import ru.fromchat.ui.main.LocalConversationListDetailActive import ru.fromchat.ui.main.detailPaneShowBackButton /** Idle gap after mouse-wheel / trackpad deltas before snapping the collapsing bar. */ @@ -44,6 +43,9 @@ private const val AppBarWheelSettleDelayMs = 64L @Composable fun settingsDetailShowBackButton(): Boolean = detailPaneShowBackButton() +@Composable +fun settingsDetailWindowInsets(): WindowInsets = WindowInsets.extraStatusBars + @Composable fun settingsDetailUseCollapsingTopBar(): Boolean = currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT @@ -88,13 +90,7 @@ fun SettingsDetailTopBar( // `surfaceContainerLowest` — that token reads as a separate pane fill in // two-pane against the list–detail shell. val paneColor = MaterialTheme.colorScheme.background - // List–detail shell already pads/consumes extraStatusBars; full-screen settings - // (e.g. logged-out About) need the desktop title-bar inset explicitly. - val topBarWindowInsets = if (LocalConversationListDetailActive.current) { - WindowInsets(0, 0, 0, 0) - } else { - WindowInsets.extraStatusBars - } + val topBarWindowInsets = settingsDetailWindowInsets() if (settingsDetailUseCollapsingTopBar()) { MediumTopAppBar( title = title, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt index df00106..7d84a83 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt @@ -121,6 +121,8 @@ import ru.fromchat.ui.components.rememberLazyListFocusScrollState import ru.fromchat.ui.components.trackLazyListFocus import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.settingsDetailShowBackButton +import ru.fromchat.ui.main.settings.settingsDetailWindowInsets +import ru.fromchat.ui.extraStatusBars private object ServerConfigLazyListIndices { const val SERVER_IP_FIELD = 2 @@ -339,6 +341,7 @@ fun ServerConfigScreen() { topBar = { val topBarHazeStyle = HazeMaterials.thin() TopAppBar( + windowInsets = settingsDetailWindowInsets(), title = {}, navigationIcon = { if (settingsDetailShowBackButton()) { @@ -366,7 +369,7 @@ fun ServerConfigScreen() { ) } ) { innerPadding -> - val floatingHeaderClearance = WindowInsets.statusBars.getTop(density).toDp(density) + 68.dp + val floatingHeaderClearance = WindowInsets.extraStatusBars.getTop(density).toDp(density) + 68.dp val bottomInsetPadding = innerPadding.calculateBottomPadding() val serverConfigListState = rememberLazyListState() var listViewportBounds by remember { mutableStateOf(null) } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.ios.kt index 0e67806..a2ccc17 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.ios.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.window.PopupProperties internal actual fun MessageContextMenuPopup( onDismissRequest: () -> Unit, positionProvider: PopupPositionProvider, + reserveOvershoot: Boolean, content: @Composable () -> Unit, ) { Popup( @@ -18,7 +19,7 @@ internal actual fun MessageContextMenuPopup( focusable = true, dismissOnBackPress = true, dismissOnClickOutside = true, - clippingEnabled = true, + clippingEnabled = !reserveOvershoot, ), content = content, ) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.ios.kt index 2bff088..3e6b7d5 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.ios.kt @@ -2,6 +2,7 @@ package ru.fromchat.ui.chat.utils import androidx.compose.runtime.Composable import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.geometry.Offset actual class AttachmentDropPermissionsHost @@ -9,6 +10,8 @@ actual class AttachmentDropPermissionsHost actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost = AttachmentDropPermissionsHost() +actual fun dragPointerInWindow(event: DragAndDropEvent): Offset? = null + actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean = false actual fun handleAttachmentDrop( diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/Theme.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/Theme.jvm.kt index 9da7155..0536dba 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/Theme.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/Theme.jvm.kt @@ -3,11 +3,31 @@ package ru.fromchat.ui import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color +import java.awt.Window +import javax.swing.RootPaneContainer @Composable actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) = if (darkTheme) darkColorScheme() else lightColorScheme() @Composable -actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) = Unit +actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) { + SideEffect { + applyMacOsAppAppearance(darkTheme) + } +} + +internal fun applyMacOsAppAppearance(dark: Boolean) { + val os = System.getProperty("os.name").orEmpty().lowercase() + if (!os.contains("mac")) return + val appearance = if (dark) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" + System.setProperty("apple.awt.application.appearance", appearance) + for (window in Window.getWindows()) { + if (window is RootPaneContainer) { + window.rootPane.putClientProperty("apple.awt.application.appearance", appearance) + window.rootPane.putClientProperty("apple.awt.windowAppearance", appearance) + } + } +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.jvm.kt index 78812d4..962355e 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.jvm.kt @@ -1,30 +1,213 @@ +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + package ru.fromchat.ui.chat +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable -import androidx.compose.ui.window.Popup +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.LocalAwtWindow +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import java.awt.AWTEvent +import java.awt.Point +import java.awt.Toolkit +import java.awt.event.AWTEventListener +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent +import javax.swing.RootPaneContainer +import javax.swing.SwingUtilities + +/** Extra OS-window size so the LowBouncy scale can overshoot the laid-out menu. */ +private const val MenuOvershootReserve = 0.15f /** - * Desktop uses an in-window [Popup] (not a separate OS window) so opening the menu does not - * steal focus from the main frame. [PopupProperties.clippingEnabled] is false so the menu can - * extend past the window edge when placement allows it. + * Desktop message menus use a real OS [Window] so they can hang outside the main app frame. + * The window is not focusable so opening it does not defocus the chat. The opening right-click + * is ignored until it is released; later presses outside the menu dismiss it. */ @Composable internal actual fun MessageContextMenuPopup( onDismissRequest: () -> Unit, positionProvider: PopupPositionProvider, + reserveOvershoot: Boolean, content: @Composable () -> Unit, ) { - Popup( - onDismissRequest = onDismissRequest, - popupPositionProvider = positionProvider, - properties = PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true, - clippingEnabled = false, - ), - content = content, + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val containerSize = LocalWindowInfo.current.containerSize + val parentWindow = LocalAwtWindow.current + val onDismiss by rememberUpdatedState(onDismissRequest) + var menuSize by remember { mutableStateOf(IntSize.Zero) } + + val positionInWindow = remember(positionProvider, menuSize, containerSize, layoutDirection) { + positionProvider.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = containerSize, + layoutDirection = layoutDirection, + popupContentSize = menuSize, + ) + } + val positionOnScreen = remember(positionInWindow, parentWindow, density) { + composeOffsetToScreen(parentWindow, positionInWindow, density) + } + + val shadowPad = 12.dp + val overshootPad = if (reserveOvershoot && menuSize != IntSize.Zero) { + with(density) { + DpSize( + (menuSize.width * MenuOvershootReserve).toDp(), + (menuSize.height * MenuOvershootReserve).toDp(), + ) + } + } else { + DpSize(0.dp, 0.dp) + } + val windowSize = if (menuSize == IntSize.Zero) { + // Compose Desktop cannot actualize Unspecified window sizes. + DpSize(360.dp, 560.dp) + } else { + with(density) { + DpSize( + menuSize.width.toDp() + shadowPad * 2 + overshootPad.width, + menuSize.height.toDp() + shadowPad * 2 + overshootPad.height, + ) + } + } + val windowPosition = WindowPosition( + x = positionOnScreen.x - shadowPad, + y = positionOnScreen.y - shadowPad, + ) + + val windowState = rememberWindowState( + position = windowPosition, + size = windowSize, + ) + SideEffect { + if (windowState.position != windowPosition) windowState.position = windowPosition + if (windowState.size != windowSize) windowState.size = windowSize + } + + Window( + onCloseRequest = onDismiss, + state = windowState, + title = "", + undecorated = true, + transparent = true, + resizable = false, + alwaysOnTop = true, + focusable = false, + ) { + val menuWindow = window + DisposableEffect(menuWindow) { + menuWindow.isAlwaysOnTop = true + onDispose { } + } + DisposableEffect(menuWindow, onDismiss) { + val toolkit = Toolkit.getDefaultToolkit() + var armed = false + val listener = AWTEventListener { awtEvent -> + when (awtEvent.id) { + MouseEvent.MOUSE_RELEASED -> armed = true + MouseEvent.MOUSE_PRESSED -> { + if (!armed) return@AWTEventListener + val mouse = awtEvent as MouseEvent + val sourceWindow = SwingUtilities.getWindowAncestor(mouse.component) + ?: mouse.component as? java.awt.Window + if (sourceWindow !== menuWindow) { + onDismiss() + } + } + KeyEvent.KEY_PRESSED -> { + if ((awtEvent as KeyEvent).keyCode == KeyEvent.VK_ESCAPE) { + onDismiss() + } + } + } + } + toolkit.addAWTEventListener( + listener, + AWTEvent.MOUSE_EVENT_MASK or AWTEvent.KEY_EVENT_MASK, + ) + onDispose { toolkit.removeAWTEventListener(listener) } + } + Box(Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(shadowPad), + ) { + Box( + // Unbounded width only — verticalScroll rejects infinite max height. + modifier = Modifier + .wrapContentWidth(unbounded = true) + .onSizeChanged { size -> + if (size.width <= 0 || size.height <= 0) return@onSizeChanged + // Grow-only: a lagging OS resize must not stick a squeezed width + // for the rest of the spring. + if ( + menuSize == IntSize.Zero || + size.width > menuSize.width || + size.height > menuSize.height + ) { + menuSize = IntSize( + maxOf(menuSize.width, size.width), + maxOf(menuSize.height, size.height), + ) + } + }, + ) { + content() + } + } + } + } +} + +/** + * Compose Desktop [WindowPosition] maps `Dp.value` 1:1 onto AWT screen pixels. Compose + * [IntOffset]s from `localToWindow` are density-scaled pixels, so they must be converted + * with [androidx.compose.ui.unit.Density] instead of added to the AWT origin first. + */ +private fun composeOffsetToScreen( + parentWindow: java.awt.Window?, + offsetInWindow: IntOffset, + density: Density, +): WindowPosition { + val origin = parentWindow?.composeLocalOriginOnScreen() ?: Point() + return WindowPosition( + x = origin.x.dp + with(density) { offsetInWindow.x.toDp() }, + y = origin.y.dp + with(density) { offsetInWindow.y.toDp() }, ) } + +private fun java.awt.Window.composeLocalOriginOnScreen(): Point { + val root = (this as? RootPaneContainer)?.contentPane ?: this + return runCatching { + Point(0, 0).also { SwingUtilities.convertPointToScreen(it, root) } + }.getOrElse { + runCatching { locationOnScreen }.getOrDefault(Point()) + } +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.jvm.kt index 1305e9d..7009704 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.jvm.kt @@ -4,7 +4,11 @@ package ru.fromchat.ui.chat.utils import androidx.compose.runtime.Composable import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragData import androidx.compose.ui.draganddrop.awtTransferable +import androidx.compose.ui.draganddrop.dragData +import androidx.compose.ui.geometry.Offset +import java.awt.MouseInfo import java.awt.datatransfer.DataFlavor import java.io.File import java.net.URI @@ -15,15 +19,33 @@ actual class AttachmentDropPermissionsHost actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost = AttachmentDropPermissionsHost() +actual fun dragPointerInWindow(event: DragAndDropEvent): Offset? { + val pointer = MouseInfo.getPointerInfo()?.location ?: return null + val window = java.awt.KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + ?: java.awt.Window.getWindows().firstOrNull { it.isShowing } + ?: return null + val origin = runCatching { window.locationOnScreen }.getOrNull() ?: return null + return Offset((pointer.x - origin.x).toFloat(), (pointer.y - origin.y).toFloat()) +} + actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean { - val transferable = event.awtTransferable - if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return true - return transferable.transferDataFlavors.any { flavor -> - val mime = flavor.mimeType.orEmpty() - mime.startsWith("image/") || - mime == "application/octet-stream" || - flavor == DataFlavor.stringFlavor - } + return runCatching { + when (val data = event.dragData()) { + is DragData.FilesList -> true + is DragData.Image -> true + else -> { + val transferable = event.awtTransferable + transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor) || + transferable.transferDataFlavors.any { flavor -> + val mime = flavor.mimeType.orEmpty() + mime.startsWith("image/") || + mime == "text/uri-list" || + mime == "application/octet-stream" || + flavor == DataFlavor.stringFlavor + } + } + } + }.getOrDefault(false) } actual fun handleAttachmentDrop( @@ -31,33 +53,83 @@ actual fun handleAttachmentDrop( event: DragAndDropEvent, onUris: (List) -> Unit, ): Boolean { - val uris = extractAttachmentDropUris(event) + val uris = runCatching { extractAttachmentDropUris(event) }.getOrDefault(emptyList()) if (uris.isEmpty()) return false onUris(uris) return true } private fun extractAttachmentDropUris(event: DragAndDropEvent): List { + // Prefer Compose's AWT helper — returns file:// URIs for javaFileListFlavor. + when (val data = event.dragData()) { + is DragData.FilesList -> { + val paths = data.readFiles().mapNotNull { uriStringToLocalPath(it) }.distinct() + if (paths.isNotEmpty()) return paths + } + else -> Unit + } + val transferable = event.awtTransferable if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as? List<*> - return files.orEmpty().mapNotNull { entry -> - (entry as? File)?.absolutePath?.takeIf { it.isNotBlank() } + val raw = transferable.getTransferData(DataFlavor.javaFileListFlavor) + val files = when (raw) { + is List<*> -> raw + is Array<*> -> raw.toList() + else -> emptyList() + } + val paths = files.mapNotNull { entry -> + when (entry) { + is File -> entry.absolutePath.takeIf { it.isNotBlank() } + is String -> uriStringToLocalPath(entry) + else -> null + } }.distinct() + if (paths.isNotEmpty()) return paths } + + val uriListFlavor = runCatching { + DataFlavor("text/uri-list;class=java.lang.String") + }.getOrNull() + if (uriListFlavor != null && transferable.isDataFlavorSupported(uriListFlavor)) { + val text = (transferable.getTransferData(uriListFlavor) as? String).orEmpty() + val paths = text.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .mapNotNull { uriStringToLocalPath(it) } + .distinct() + .toList() + if (paths.isNotEmpty()) return paths + } + if (transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) { val text = (transferable.getTransferData(DataFlavor.stringFlavor) as? String)?.trim().orEmpty() - if (text.startsWith("file:")) { - val path = runCatching { URI(text).path }.getOrNull()?.takeIf { it.isNotBlank() } - ?: text.removePrefix("file://") - return listOf(path) - } - if (text.startsWith("/") || looksLikeWindowsPath(text)) { - return listOf(text) + if (text.isNotEmpty()) { + val fromLines = text.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .mapNotNull { uriStringToLocalPath(it) } + .distinct() + .toList() + if (fromLines.isNotEmpty()) return fromLines + uriStringToLocalPath(text)?.let { return listOf(it) } } } return emptyList() } +/** Accepts `file://` URIs, plain absolute paths, and Windows paths; returns a local filesystem path. */ +private fun uriStringToLocalPath(text: String): String? { + val trimmed = text.trim().takeIf { it.isNotBlank() } ?: return null + if (trimmed.startsWith("file:")) { + val path = runCatching { URI(trimmed).path }.getOrNull()?.takeIf { it.isNotBlank() } + ?: trimmed.removePrefix("file://") + return path.takeIf { File(it).isFile || File(it).exists() } ?: path.takeIf { it.isNotBlank() } + } + if (trimmed.startsWith("/") || looksLikeWindowsPath(trimmed)) { + return trimmed + } + return null +} + private fun looksLikeWindowsPath(text: String): Boolean = text.length > 2 && text[0].isLetter() && text[1] == ':' diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.jvm.kt index 883d71c..98a4d80 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.jvm.kt @@ -2,14 +2,18 @@ package ru.fromchat.ui.chat.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.LocalAwtWindow import java.awt.FileDialog import java.awt.Frame +import java.awt.Window import java.io.File import java.net.URI import javax.imageio.ImageIO -import javax.swing.JFileChooser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import ru.fromchat.ui.applyMacOsAppAppearance +import ru.fromchat.ui.isAppInDarkTheme actual fun getFilenameFromUri(uri: String): String { val path = when { @@ -19,41 +23,66 @@ actual fun getFilenameFromUri(uri: String): String { return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file" } +@OptIn(ExperimentalComposeUiApi::class) @Composable actual fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit { - return remember(onResult) { + val owner = LocalAwtWindow.current + val dark = isAppInDarkTheme() + return remember(onResult, owner, dark) { { - val dialog = FileDialog(null as Frame?, "Select images", FileDialog.LOAD).apply { - isMultipleMode = true - setFilenameFilter { _, name -> - val lower = name.lowercase() - lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || - lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp") - } - } - dialog.isVisible = true - val dir = dialog.directory - val files = dialog.files - if (dir != null && files != null && files.isNotEmpty()) { - onResult(files.map { File(dir, it.name).absolutePath }) - } + showNativeFilePicker( + owner = owner, + dark = dark, + imagesOnly = true, + onResult = onResult, + ) } } } +@OptIn(ExperimentalComposeUiApi::class) @Composable actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit { - return remember(onResult) { + val owner = LocalAwtWindow.current + val dark = isAppInDarkTheme() + return remember(onResult, owner, dark) { { - val chooser = JFileChooser().apply { - isMultiSelectionEnabled = true - fileSelectionMode = JFileChooser.FILES_ONLY - } - if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { - onResult(chooser.selectedFiles.map { it.absolutePath }) + showNativeFilePicker( + owner = owner, + dark = dark, + imagesOnly = false, + onResult = onResult, + ) + } + } +} + +private fun showNativeFilePicker( + owner: Window?, + dark: Boolean, + imagesOnly: Boolean, + onResult: (List) -> Unit, +) { + applyMacOsAppAppearance(dark) + val dialog = when (owner) { + is Frame -> FileDialog(owner, if (imagesOnly) "Select images" else "Select files", FileDialog.LOAD) + else -> FileDialog(null as Frame?, if (imagesOnly) "Select images" else "Select files", FileDialog.LOAD) + }.apply { + isMultipleMode = true + if (imagesOnly) { + setFilenameFilter { _, name -> + val lower = name.lowercase() + lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || + lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp") } } } + dialog.isVisible = true + val dir = dialog.directory + val files = dialog.files + if (dir != null && files != null && files.isNotEmpty()) { + onResult(files.map { File(dir, it.name).absolutePath }) + } } actual suspend fun getImageAspectRatio(uri: String): Float? {