Context menu

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-08-17 13:30:12 +03:00
Unverified
parent 7a553ff02d
commit c7590a3d06
26 changed files with 785 additions and 202 deletions
@@ -262,6 +262,12 @@ fun main(args: Array<String>) {
// 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()
+1
View File
@@ -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)
@@ -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,
)
@@ -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
@@ -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) {
@@ -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())
}
}
@@ -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 {
@@ -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,
@@ -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
@@ -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<ChatFullscreenImageRequest?>(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<ChatFullscreenImageController?> { 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,
@@ -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 parents window origin again (double offset
// in listdetail 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) {
@@ -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<AttachmentDropBridge?>(null)
private set
private val boundsByBridge = mutableMapOf<AttachmentDropBridge, Rect>()
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<String>): 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<String>) -> Unit)? = null
private var dropHighlightDepth by mutableIntStateOf(0)
var dropHighlightActive by mutableStateOf(false)
private set
fun deliver(uris: List<String>) {
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<String>,
existingAttachmentCount: Int,
@@ -145,6 +199,23 @@ expect fun handleAttachmentDrop(
onUris: (List<String>) -> Unit,
): Boolean
private fun syncDropHighlight(event: DragAndDropEvent) {
val point = dragPointerInWindow(event) ?: return
AttachmentDropHighlight.syncFromPointer(point)
}
private fun dispatchDropUris(
bridge: AttachmentDropBridge?,
uris: List<String>,
) {
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
}
}
}
@@ -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<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
@@ -82,9 +80,8 @@ fun DesktopChatsDetailNavHost(
* Independent Settings-tab detail [NavHost] for desktop listdetail
* (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 listdetail 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(
@@ -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)
},
)
}
}
}
}
@@ -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()
@@ -432,6 +432,7 @@ fun DevicesScreen(onBack: () -> Unit) {
topBar = {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
windowInsets = settingsDetailWindowInsets(),
title = {},
navigationIcon = {
if (settingsDetailShowBackButton()) {
@@ -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(
@@ -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(
@@ -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 listdetail shell.
val paneColor = MaterialTheme.colorScheme.background
// Listdetail 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,
@@ -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<Rect?>(null) }
@@ -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,
)
@@ -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(
@@ -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)
}
}
}
@@ -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())
}
}
@@ -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<String>) -> 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<String> {
// 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] == ':'
@@ -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<String>) -> 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<String>) -> 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<String>) -> 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? {