mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-1
@@ -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,
|
||||
)
|
||||
|
||||
+6
@@ -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 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) {
|
||||
|
||||
@@ -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 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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
+4
-8
@@ -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,
|
||||
|
||||
+4
-1
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user