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. // Treat TrayIcon images as NSImage templates so the menu bar tints them for light/dark.
// Must be set before CTrayIcon loads (Compose Tray / SystemTray). // Must be set before CTrayIcon loads (Compose Tray / SystemTray).
System.setProperty("apple.awt.enableTemplateImages", "true") 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 if (!DesktopSingleInstance.acquireOrForward(args)) return
DesktopProtocolRegistration.register() DesktopProtocolRegistration.register()
+1
View File
@@ -169,6 +169,7 @@ kotlin {
} }
jvmMain.dependencies { jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.jetbrains.kotlinx.io.bytestring) implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.cio) implementation(libs.ktor.client.cio)
@@ -9,6 +9,7 @@ import androidx.compose.ui.window.PopupProperties
internal actual fun MessageContextMenuPopup( internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider, positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Popup( Popup(
@@ -18,7 +19,8 @@ internal actual fun MessageContextMenuPopup(
focusable = true, focusable = true,
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true, dismissOnClickOutside = true,
clippingEnabled = true, // Let the spring draw past the laid-out bounds while [reserveOvershoot] is true.
clippingEnabled = !reserveOvershoot,
), ),
content = content, content = content,
) )
@@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.mimeTypes
import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draganddrop.toAndroidDragEvent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.net.toUri 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 { actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
val mimeTypes = event.mimeTypes() val mimeTypes = event.mimeTypes()
if (mimeTypes.any { it == ClipDescription.MIMETYPE_TEXT_URILIST }) return true 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.ActionButton
import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
private data class PendingDocument( private data class PendingDocument(
val parsed: ParsedDocument, val parsed: ParsedDocument,
@@ -119,6 +120,7 @@ fun DocumentScreen(
topBar = { topBar = {
val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle() val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
MediumTopAppBar( MediumTopAppBar(
windowInsets = WindowInsets.extraStatusBars,
title = { title = {
Text( Text(
text = when (type) { 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.captcha.SmartCaptchaScreen
import ru.fromchat.ui.auth.yandex.YandexOAuthNav import ru.fromchat.ui.auth.yandex.YandexOAuthNav
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen 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.calls.CallOverlay
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
@@ -471,13 +474,15 @@ fun App(
} }
} }
val fullscreenImageController = remember { ChatFullscreenImageController() }
CompositionLocalProvider( CompositionLocalProvider(
LocalNavController provides navController, LocalNavController provides navController,
LocalDesktopChatsNavController provides LocalDesktopChatsNavController provides
if (isDesktopListDetail) chatsDetailNavController else null, if (isDesktopListDetail) chatsDetailNavController else null,
LocalDesktopSettingsNavController provides LocalDesktopSettingsNavController provides
if (isDesktopListDetail) settingsDetailNavController else null, if (isDesktopListDetail) settingsDetailNavController else null,
LocalSystemBarsVisibility provides rememberSystemBarsController() LocalSystemBarsVisibility provides rememberSystemBarsController(),
LocalChatFullscreenImageController provides fullscreenImageController,
) { ) {
if (startDestination != null) { if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState() val currentEntry by navController.currentBackStackEntryAsState()
@@ -673,9 +678,6 @@ fun App(
AppNavHost(Modifier.fillMaxSize()) AppNavHost(Modifier.fillMaxSize())
if (showConversationListDetail) { if (showConversationListDetail) {
val detailEdgeToEdge =
pendingMainTab == MAIN_PAGE_CHATS ||
pendingMainTab == MAIN_PAGE_CONTACTS
val listPaneHazeState = rememberHazeState() val listPaneHazeState = rememberHazeState()
CompositionLocalProvider( CompositionLocalProvider(
LocalPaneHazeState provides listPaneHazeState, LocalPaneHazeState provides listPaneHazeState,
@@ -683,7 +685,7 @@ fun App(
) { ) {
ConversationListDetailShell( ConversationListDetailShell(
detailInPanel = false, detailInPanel = false,
detailEdgeToEdge = detailEdgeToEdge, detailEdgeToEdge = true,
listPane = { listPane = {
MainScreen( MainScreen(
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
@@ -732,6 +734,7 @@ fun App(
} }
} }
ChatFullscreenImageHost(Modifier.fillMaxSize())
CallOverlay(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.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned 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.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@@ -239,15 +239,8 @@ fun AttachmentPreview(
.then( .then(
if (onImageBounds != null && showImageTile) { if (onImageBounds != null && showImageTile) {
Modifier.onGloballyPositioned { coords -> Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val size = coords.size
onImageBounds( onImageBounds(
Rect( coords.boundsInWindow()
pos.x,
pos.y,
pos.x + size.width.toFloat(),
pos.y + size.height.toFloat()
)
) )
} }
} else { } else {
@@ -117,12 +117,12 @@ import ru.fromchat.message_placeholder
import ru.fromchat.message_replying_to import ru.fromchat.message_replying_to
import ru.fromchat.suspend_chat_banner_message import ru.fromchat.suspend_chat_banner_message
import ru.fromchat.ui.chat.utils.SelectedAttachment 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.TypingHandler
import ru.fromchat.ui.chat.utils.AttachmentDropBridge import ru.fromchat.ui.chat.utils.AttachmentDropBridge
import ru.fromchat.ui.chat.utils.GlobalAttachmentDropRouter import ru.fromchat.ui.chat.utils.GlobalAttachmentDropRouter
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget
import ru.fromchat.ui.chat.utils.getFilenameFromUri 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.rememberFilePicker
import ru.fromchat.ui.chat.utils.rememberImagePicker import ru.fromchat.ui.chat.utils.rememberImagePicker
import ru.fromchat.ui.chat.utils.urisToSelectedAttachments import ru.fromchat.ui.chat.utils.urisToSelectedAttachments
@@ -327,8 +327,7 @@ fun ChatInput(
val dropScrimColor = lerp(MaterialTheme.colorScheme.primary, Color.Black, 0.62f) val dropScrimColor = lerp(MaterialTheme.colorScheme.primary, Color.Black, 0.62f)
val attachmentDropEnabled = supportsAttachments && !isReadOnly val attachmentDropEnabled = supportsAttachments && !isReadOnly
val dropHighlightActive = val dropHighlightActive =
attachmentDropEnabled && attachmentDropEnabled && attachmentDropBridge.isDropHighlightActive()
(attachmentDropBridge.dropHighlightActive || AttachmentDragSession.isActive)
val dropAnimMs = 220 val dropAnimMs = 220
val dropScale by animateFloatAsState( val dropScale by animateFloatAsState(
targetValue = if (dropHighlightActive) 0.96f else 1f, 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.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -1532,8 +1533,60 @@ fun ChatScreen(
} }
} }
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,
thumbnailBounds = imageThumbBounds[imageAttachmentKey(msg, idx)],
)
if (host.request != published) host.request = published
}
if (fullscreenHost == null) {
expandedImage?.let { (msg, idx) -> expandedImage?.let { (msg, idx) ->
val key = imageAttachmentKey(msg, idx)
ImageFullscreenPreview( ImageFullscreenPreview(
message = msg, message = msg,
fileIndex = idx, fileIndex = idx,
@@ -1557,10 +1610,12 @@ fun ChatScreen(
panel.handleDeleteMessage(m.id) panel.handleDeleteMessage(m.id)
} }
}, },
onSave = { msg, fileIndex -> onSave = { savedMsg, fileIndex ->
if (!isMessageImageFullyLoaded(msg, fileIndex)) return@ImageFullscreenPreview if (!isMessageImageFullyLoaded(savedMsg, fileIndex)) {
val file = msg.files?.getOrNull(fileIndex) ?: return@ImageFullscreenPreview return@ImageFullscreenPreview
resolveImageSourceUri(msg, fileIndex)?.let { source -> }
val file = savedMsg.files?.getOrNull(fileIndex) ?: return@ImageFullscreenPreview
resolveImageSourceUri(savedMsg, fileIndex)?.let { source ->
saveMessageImage( saveMessageImage(
SavableMessageImage( SavableMessageImage(
fileIndex = fileIndex, fileIndex = fileIndex,
@@ -1575,11 +1630,11 @@ fun ChatScreen(
animatedVisibilityScope = null, animatedVisibilityScope = null,
sharedImageKey = null, sharedImageKey = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
thumbnailBounds = imageThumbBounds[key] thumbnailBounds = imageThumbBounds[imageAttachmentKey(msg, idx)],
) )
} }
} }
}
} }
@Composable @Composable
@@ -45,6 +45,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -101,6 +102,46 @@ private data class InitialTransform(
val bgAlpha: Float 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 @Composable
fun ImageFullscreenPreview( fun ImageFullscreenPreview(
message: Message, message: Message,
@@ -17,8 +17,10 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -36,6 +38,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -75,6 +78,7 @@ import ru.fromchat.ui.components.Text
internal expect fun MessageContextMenuPopup( internal expect fun MessageContextMenuPopup(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider, positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean = false,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) )
@@ -164,12 +168,15 @@ fun MessageContextMenu(
} }
val animationProgress = remember { mutableFloatStateOf(0f) } val animationProgress = remember { mutableFloatStateOf(0f) }
var enterLaidOut by remember(state.message) { mutableStateOf(false) } var enterLaidOut by remember(state.message) { mutableStateOf(false) }
var reserveOvershoot by remember(state.message) { mutableStateOf(true) }
var frozenOrigin by remember(state.message) { var frozenOrigin by remember(state.message) {
mutableStateOf(TransformOrigin(0f, 0f)) mutableStateOf(TransformOrigin(0f, 0f))
} }
var lockedMenuWidthPx by remember(state.message) { mutableIntStateOf(0) }
LaunchedEffect(state.isOpen) { LaunchedEffect(state.isOpen) {
if (!state.isOpen) { if (!state.isOpen) {
reserveOvershoot = true
animate( animate(
initialValue = animationProgress.floatValue, initialValue = animationProgress.floatValue,
targetValue = 0f, targetValue = 0f,
@@ -188,9 +195,11 @@ fun MessageContextMenu(
shouldShowPopup = true shouldShowPopup = true
if (!enterLaidOut) { if (!enterLaidOut) {
animationProgress.floatValue = 0f animationProgress.floatValue = 0f
reserveOvershoot = true
return@LaunchedEffect return@LaunchedEffect
} }
animationProgress.floatValue = 0f animationProgress.floatValue = 0f
reserveOvershoot = true
animate( animate(
initialValue = 0f, initialValue = 0f,
targetValue = 1f, targetValue = 1f,
@@ -198,6 +207,7 @@ fun MessageContextMenu(
) { value, _ -> ) { value, _ ->
animationProgress.floatValue = value animationProgress.floatValue = value
} }
reserveOvershoot = false
} }
} }
@@ -239,10 +249,11 @@ fun MessageContextMenu(
// [state.position] is window coordinates (see MessageItem localToWindow). // [state.position] is window coordinates (see MessageItem localToWindow).
// Alignment+offset would add the popup parents window origin again (double offset // Alignment+offset would add the popup parents window origin again (double offset
// in listdetail panes). Place with an absolute provider instead. // 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( MessageContextMenuPopup(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
positionProvider = positionProvider, positionProvider = positionProvider,
reserveOvershoot = reserveOvershoot,
) { ) {
ContextMenuContent( ContextMenuContent(
message = state.message, message = state.message,
@@ -276,7 +287,18 @@ fun MessageContextMenu(
onRetrySend(it) onRetrySend(it)
onDismiss() onDismiss()
}, },
modifier = modifier.onSizeChanged { size -> modifier = modifier
.then(
if (lockedMenuWidthPx > 0) {
Modifier.requiredWidth(with(density) { lockedMenuWidthPx.toDp() })
} else {
Modifier
},
)
.onSizeChanged { size ->
if (size.width > 0 && size.width > lockedMenuWidthPx) {
lockedMenuWidthPx = size.width
}
if (size.width <= 0 || size.height <= 0 || enterLaidOut) return@onSizeChanged if (size.width <= 0 || size.height <= 0 || enterLaidOut) return@onSizeChanged
val adjusted = clampContextMenuOffset( val adjusted = clampContextMenuOffset(
position = state.position, position = state.position,
@@ -437,6 +459,7 @@ internal fun ChatStyleContextMenuFrame(
// invalidation does not rebuild the elevated layer (avoids first-hover blink). // invalidation does not rebuild the elevated layer (avoids first-hover blink).
Box( Box(
modifier = modifier modifier = modifier
.wrapContentWidth(unbounded = true)
.width(IntrinsicSize.Max) .width(IntrinsicSize.Max)
.graphicsLayer { .graphicsLayer {
if (animated) { if (animated) {
@@ -14,10 +14,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed 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.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip 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.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.time.Clock 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. */ /** True while a file drag that this process accepted is still in progress. */
object AttachmentDragSession { object AttachmentDragSession {
var isActive by mutableStateOf(false) 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. * 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). * Needed when a chat opens after drag start (Compose targets miss ACTION_DRAG_STARTED).
@@ -90,33 +158,19 @@ object PendingChatAttachmentDrops {
class AttachmentDropBridge { class AttachmentDropBridge {
internal var consumer: ((List<String>) -> Unit)? = null 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>) { fun deliver(uris: List<String>) {
if (uris.isNotEmpty()) consumer?.invoke(uris) 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 @Composable
fun rememberAttachmentDropBridge(): AttachmentDropBridge = remember { AttachmentDropBridge() } fun rememberAttachmentDropBridge(): AttachmentDropBridge = remember { AttachmentDropBridge() }
/** Whether [bridge] currently owns the exclusive drop highlight. */
@Composable
fun AttachmentDropBridge.isDropHighlightActive(): Boolean =
AttachmentDropHighlight.activeBridge === this
fun urisToSelectedAttachments( fun urisToSelectedAttachments(
uris: List<String>, uris: List<String>,
existingAttachmentCount: Int, existingAttachmentCount: Int,
@@ -145,6 +199,23 @@ expect fun handleAttachmentDrop(
onUris: (List<String>) -> Unit, onUris: (List<String>) -> Unit,
): Boolean ): 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) @OptIn(ExperimentalFoundationApi::class)
fun Modifier.chatAttachmentDropTarget( fun Modifier.chatAttachmentDropTarget(
enabled: Boolean, enabled: Boolean,
@@ -152,33 +223,45 @@ fun Modifier.chatAttachmentDropTarget(
): Modifier = composed { ): Modifier = composed {
if (!enabled) return@composed Modifier if (!enabled) return@composed Modifier
val permissionsHost = rememberAttachmentDropPermissionsHost() val permissionsHost = rememberAttachmentDropPermissionsHost()
DisposableEffect(bridge) {
onDispose { AttachmentDropHighlight.unregister(bridge) }
}
val target = remember(bridge, permissionsHost) { val target = remember(bridge, permissionsHost) {
object : DragAndDropTarget { object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) { override fun onStarted(event: DragAndDropEvent) {
AttachmentDragSession.isActive = true AttachmentDragSession.begin()
syncDropHighlight(event)
} }
override fun onEntered(event: DragAndDropEvent) { override fun onEntered(event: DragAndDropEvent) {
bridge.enterDropHighlight() syncDropHighlight(event)
}
override fun onMoved(event: DragAndDropEvent) {
syncDropHighlight(event)
} }
override fun onExited(event: DragAndDropEvent) { override fun onExited(event: DragAndDropEvent) {
bridge.exitDropHighlight() syncDropHighlight(event)
} }
override fun onEnded(event: DragAndDropEvent) { override fun onEnded(event: DragAndDropEvent) {
bridge.endDropHighlight() AttachmentDragSession.end()
AttachmentDragSession.isActive = false
} }
override fun onDrop(event: DragAndDropEvent): Boolean { override fun onDrop(event: DragAndDropEvent): Boolean {
bridge.endDropHighlight() syncDropHighlight(event)
AttachmentDragSession.isActive = false val accepted = handleAttachmentDrop(permissionsHost, event) { uris ->
return handleAttachmentDrop(permissionsHost, event) { bridge.deliver(it) } dispatchDropUris(bridge, uris)
}
AttachmentDragSession.end()
return accepted
} }
} }
} }
dragAndDropTarget( onGloballyPositioned { coords ->
AttachmentDropHighlight.register(bridge, coords.boundsInWindow())
}.dragAndDropTarget(
shouldStartDragAndDrop = { acceptsAttachmentDrop(it) }, shouldStartDragAndDrop = { acceptsAttachmentDrop(it) },
target = target, target = target,
) )
@@ -194,18 +277,33 @@ fun Modifier.appRootAttachmentDropTarget(): Modifier = composed {
val target = remember(permissionsHost) { val target = remember(permissionsHost) {
object : DragAndDropTarget { object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) { 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) { override fun onEnded(event: DragAndDropEvent) {
AttachmentDragSession.isActive = false AttachmentDragSession.end()
} }
override fun onDrop(event: DragAndDropEvent): Boolean { override fun onDrop(event: DragAndDropEvent): Boolean {
AttachmentDragSession.isActive = false syncDropHighlight(event)
return handleAttachmentDrop(permissionsHost, event) { val accepted = handleAttachmentDrop(permissionsHost, event) { uris ->
GlobalAttachmentDropRouter.deliver(it) 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.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.AppPanel
private val detailNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing) private val detailNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
@@ -82,9 +80,8 @@ fun DesktopChatsDetailNavHost(
* Independent Settings-tab detail [NavHost] for desktop listdetail * Independent Settings-tab detail [NavHost] for desktop listdetail
* (settings screens, own profile, and edit profile on the same stack). * (settings screens, own profile, and edit profile on the same stack).
* *
* [AppPanel] is drawn only behind non-empty destinations so the empty root stays * Settings detail is edge-to-edge on the pane background; empty root stays
* bare (no surface fill), matching the prior AnimatedContent empty vs panel split. * unpainted so the listdetail shell shows through.
* Panel is a sibling under the [NavHost] so the host is not remounted empty content.
*/ */
@Composable @Composable
fun DesktopSettingsDetailNavHost( fun DesktopSettingsDetailNavHost(
@@ -101,13 +98,11 @@ fun DesktopSettingsDetailNavHost(
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
if (showPanel) { if (showPanel) {
// Match Profile / ConversationListDetailShell (`background`), not the Box(
// default AppPanel `surfaceContainerLowest` (visible mismatch in two-pane). Modifier
AppPanel( .fillMaxSize()
Modifier.fillMaxSize(), .background(MaterialTheme.colorScheme.background),
color = MaterialTheme.colorScheme.background, )
shape = RoundedCornerShape(24.dp),
) {}
} }
CompositionLocalProvider(LocalNavController provides navController) { CompositionLocalProvider(LocalNavController provides navController) {
NavHost( NavHost(
@@ -1,7 +1,10 @@
package ru.fromchat.ui.main package ru.fromchat.ui.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -68,12 +71,15 @@ import ru.fromchat.profile
import ru.fromchat.settings import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle 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.FromChatSnackbarHost
import ru.fromchat.ui.components.LocalPaneHazeState import ru.fromchat.ui.components.LocalPaneHazeState
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost 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.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
@@ -175,6 +181,17 @@ fun MainScreen(
val contextMenuHazeState = paneHazeState ?: rememberHazeState() val contextMenuHazeState = paneHazeState ?: rememberHazeState()
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
var chatListSelectionRequestId by remember { mutableStateOf(0L) } 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 val widthClass = currentWindowAdaptiveInfo().widthSizeClass
@@ -193,11 +210,12 @@ fun MainScreen(
if (pagerState.currentPage != PAGE_CHATS) { if (pagerState.currentPage != PAGE_CHATS) {
pagerState.scrollToPage(PAGE_CHATS) pagerState.scrollToPage(PAGE_CHATS)
} }
navController.navigate("search/conversations") { openConversationSearch()
launchSingleTop = true
}
} }
DesktopMenuCommand.EnterChatListSelection -> { DesktopMenuCommand.EnterChatListSelection -> {
if (paneSearchOpen) {
paneSearchOpen = false
}
if (navController.currentBackStackEntry?.destination?.route == if (navController.currentBackStackEntry?.destination?.route ==
"search/conversations" "search/conversations"
) { ) {
@@ -282,9 +300,7 @@ fun MainScreen(
when (page) { when (page) {
PAGE_CHATS -> ChatsTab( PAGE_CHATS -> ChatsTab(
isVisible = isChatsPage, isVisible = isChatsPage,
onOpenSearch = { onOpenSearch = { openConversationSearch() },
navController.navigate("search/conversations")
},
chatContextMenuOverlay = chatContextMenuOverlay, chatContextMenuOverlay = chatContextMenuOverlay,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
@@ -429,6 +445,35 @@ fun MainScreen(
.fillMaxSize() .fillMaxSize()
.zIndex(3f), .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 kotlinx.coroutines.withTimeout
import ru.fromchat.ui.chat.utils.AttachmentDropHighlightBox import ru.fromchat.ui.chat.utils.AttachmentDropHighlightBox
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget 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.chat.utils.rememberAttachmentDropBridge
import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.SearchBarSharedElement
@@ -868,7 +869,7 @@ internal fun PublicChatRow(
} }
AttachmentDropHighlightBox( AttachmentDropHighlightBox(
active = dropBridge.dropHighlightActive, active = dropBridge.isDropHighlightActive(),
shape = clipShape, shape = clipShape,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1035,7 +1036,7 @@ internal fun DmConversationRow(
} }
AttachmentDropHighlightBox( AttachmentDropHighlightBox(
active = dropBridge.dropHighlightActive, active = dropBridge.isDropHighlightActive(),
shape = clipShape, shape = clipShape,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -432,6 +432,7 @@ fun DevicesScreen(onBack: () -> Unit) {
topBar = { topBar = {
val topBarHazeStyle = HazeMaterials.thin() val topBarHazeStyle = HazeMaterials.thin()
TopAppBar( TopAppBar(
windowInsets = settingsDetailWindowInsets(),
title = {}, title = {},
navigationIcon = { navigationIcon = {
if (settingsDetailShowBackButton()) { if (settingsDetailShowBackButton()) {
@@ -448,6 +448,7 @@ fun LogFilesScreen(
topBar = { topBar = {
Box { Box {
TopAppBar( TopAppBar(
windowInsets = settingsDetailWindowInsets(),
modifier = Modifier modifier = Modifier
.graphicsLayer { alpha = 1f - selectionProgress } .graphicsLayer { alpha = 1f - selectionProgress }
.background(MaterialTheme.colorScheme.surfaceContainer), .background(MaterialTheme.colorScheme.surfaceContainer),
@@ -473,6 +474,7 @@ fun LogFilesScreen(
) )
if (selectionMode || selectionProgress > 0f) { if (selectionMode || selectionProgress > 0f) {
TopAppBar( TopAppBar(
windowInsets = settingsDetailWindowInsets(),
modifier = Modifier.graphicsLayer { alpha = selectionProgress }, modifier = Modifier.graphicsLayer { alpha = selectionProgress },
navigationIcon = { navigationIcon = {
IconButton( IconButton(
@@ -770,6 +770,7 @@ fun LogsScreen() {
topBar = { topBar = {
Box { Box {
TopAppBar( TopAppBar(
windowInsets = settingsDetailWindowInsets(),
modifier = Modifier modifier = Modifier
.graphicsLayer { .graphicsLayer {
alpha = (1f - selectionProgress) * (1f - searchProgress) alpha = (1f - selectionProgress) * (1f - searchProgress)
@@ -884,6 +885,7 @@ fun LogsScreen() {
) )
if (searchMode) { if (searchMode) {
TopAppBar( TopAppBar(
windowInsets = settingsDetailWindowInsets(),
modifier = Modifier.graphicsLayer { alpha = searchProgress }, modifier = Modifier.graphicsLayer { alpha = searchProgress },
colors = logsTransparentTopAppBarColors(), colors = logsTransparentTopAppBarColors(),
navigationIcon = { navigationIcon = {
@@ -944,6 +946,7 @@ fun LogsScreen() {
} }
if (selectionMode) { if (selectionMode) {
TopAppBar( TopAppBar(
windowInsets = settingsDetailWindowInsets(),
modifier = Modifier.graphicsLayer { alpha = selectionProgress }, modifier = Modifier.graphicsLayer { alpha = selectionProgress },
navigationIcon = { navigationIcon = {
IconButton( IconButton(
@@ -34,7 +34,6 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.ui.main.detailPaneShowBackButton import ru.fromchat.ui.main.detailPaneShowBackButton
/** Idle gap after mouse-wheel / trackpad deltas before snapping the collapsing bar. */ /** Idle gap after mouse-wheel / trackpad deltas before snapping the collapsing bar. */
@@ -44,6 +43,9 @@ private const val AppBarWheelSettleDelayMs = 64L
@Composable @Composable
fun settingsDetailShowBackButton(): Boolean = detailPaneShowBackButton() fun settingsDetailShowBackButton(): Boolean = detailPaneShowBackButton()
@Composable
fun settingsDetailWindowInsets(): WindowInsets = WindowInsets.extraStatusBars
@Composable @Composable
fun settingsDetailUseCollapsingTopBar(): Boolean = fun settingsDetailUseCollapsingTopBar(): Boolean =
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT
@@ -88,13 +90,7 @@ fun SettingsDetailTopBar(
// `surfaceContainerLowest` — that token reads as a separate pane fill in // `surfaceContainerLowest` — that token reads as a separate pane fill in
// two-pane against the listdetail shell. // two-pane against the listdetail shell.
val paneColor = MaterialTheme.colorScheme.background val paneColor = MaterialTheme.colorScheme.background
// Listdetail shell already pads/consumes extraStatusBars; full-screen settings val topBarWindowInsets = settingsDetailWindowInsets()
// (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
}
if (settingsDetailUseCollapsingTopBar()) { if (settingsDetailUseCollapsingTopBar()) {
MediumTopAppBar( MediumTopAppBar(
title = title, title = title,
@@ -121,6 +121,8 @@ import ru.fromchat.ui.components.rememberLazyListFocusScrollState
import ru.fromchat.ui.components.trackLazyListFocus import ru.fromchat.ui.components.trackLazyListFocus
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.ui.main.settings.settingsDetailShowBackButton import ru.fromchat.ui.main.settings.settingsDetailShowBackButton
import ru.fromchat.ui.main.settings.settingsDetailWindowInsets
import ru.fromchat.ui.extraStatusBars
private object ServerConfigLazyListIndices { private object ServerConfigLazyListIndices {
const val SERVER_IP_FIELD = 2 const val SERVER_IP_FIELD = 2
@@ -339,6 +341,7 @@ fun ServerConfigScreen() {
topBar = { topBar = {
val topBarHazeStyle = HazeMaterials.thin() val topBarHazeStyle = HazeMaterials.thin()
TopAppBar( TopAppBar(
windowInsets = settingsDetailWindowInsets(),
title = {}, title = {},
navigationIcon = { navigationIcon = {
if (settingsDetailShowBackButton()) { if (settingsDetailShowBackButton()) {
@@ -366,7 +369,7 @@ fun ServerConfigScreen() {
) )
} }
) { innerPadding -> ) { 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 bottomInsetPadding = innerPadding.calculateBottomPadding()
val serverConfigListState = rememberLazyListState() val serverConfigListState = rememberLazyListState()
var listViewportBounds by remember { mutableStateOf<Rect?>(null) } var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
@@ -9,6 +9,7 @@ import androidx.compose.ui.window.PopupProperties
internal actual fun MessageContextMenuPopup( internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider, positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Popup( Popup(
@@ -18,7 +19,7 @@ internal actual fun MessageContextMenuPopup(
focusable = true, focusable = true,
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true, dismissOnClickOutside = true,
clippingEnabled = true, clippingEnabled = !reserveOvershoot,
), ),
content = content, content = content,
) )
@@ -2,6 +2,7 @@ package ru.fromchat.ui.chat.utils
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.geometry.Offset
actual class AttachmentDropPermissionsHost actual class AttachmentDropPermissionsHost
@@ -9,6 +10,8 @@ actual class AttachmentDropPermissionsHost
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost = actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost =
AttachmentDropPermissionsHost() AttachmentDropPermissionsHost()
actual fun dragPointerInWindow(event: DragAndDropEvent): Offset? = null
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean = false actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean = false
actual fun handleAttachmentDrop( actual fun handleAttachmentDrop(
@@ -3,11 +3,31 @@ package ru.fromchat.ui
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import java.awt.Window
import javax.swing.RootPaneContainer
@Composable @Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) = actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
if (darkTheme) darkColorScheme() else lightColorScheme() if (darkTheme) darkColorScheme() else lightColorScheme()
@Composable @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 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.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.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 * Desktop message menus use a real OS [Window] so they can hang outside the main app frame.
* steal focus from the main frame. [PopupProperties.clippingEnabled] is false so the menu can * The window is not focusable so opening it does not defocus the chat. The opening right-click
* extend past the window edge when placement allows it. * is ignored until it is released; later presses outside the menu dismiss it.
*/ */
@Composable @Composable
internal actual fun MessageContextMenuPopup( internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider, positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Popup( val density = LocalDensity.current
onDismissRequest = onDismissRequest, val layoutDirection = LocalLayoutDirection.current
popupPositionProvider = positionProvider, val containerSize = LocalWindowInfo.current.containerSize
properties = PopupProperties( val parentWindow = LocalAwtWindow.current
focusable = true, val onDismiss by rememberUpdatedState(onDismissRequest)
dismissOnBackPress = true, var menuSize by remember { mutableStateOf(IntSize.Zero) }
dismissOnClickOutside = true,
clippingEnabled = false, val positionInWindow = remember(positionProvider, menuSize, containerSize, layoutDirection) {
), positionProvider.calculatePosition(
content = content, 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.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragData
import androidx.compose.ui.draganddrop.awtTransferable 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.awt.datatransfer.DataFlavor
import java.io.File import java.io.File
import java.net.URI import java.net.URI
@@ -15,15 +19,33 @@ actual class AttachmentDropPermissionsHost
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost = actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost =
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 { actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
return runCatching {
when (val data = event.dragData()) {
is DragData.FilesList -> true
is DragData.Image -> true
else -> {
val transferable = event.awtTransferable val transferable = event.awtTransferable
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return true transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor) ||
return transferable.transferDataFlavors.any { flavor -> transferable.transferDataFlavors.any { flavor ->
val mime = flavor.mimeType.orEmpty() val mime = flavor.mimeType.orEmpty()
mime.startsWith("image/") || mime.startsWith("image/") ||
mime == "text/uri-list" ||
mime == "application/octet-stream" || mime == "application/octet-stream" ||
flavor == DataFlavor.stringFlavor flavor == DataFlavor.stringFlavor
} }
}
}
}.getOrDefault(false)
} }
actual fun handleAttachmentDrop( actual fun handleAttachmentDrop(
@@ -31,33 +53,83 @@ actual fun handleAttachmentDrop(
event: DragAndDropEvent, event: DragAndDropEvent,
onUris: (List<String>) -> Unit, onUris: (List<String>) -> Unit,
): Boolean { ): Boolean {
val uris = extractAttachmentDropUris(event) val uris = runCatching { extractAttachmentDropUris(event) }.getOrDefault(emptyList())
if (uris.isEmpty()) return false if (uris.isEmpty()) return false
onUris(uris) onUris(uris)
return true return true
} }
private fun extractAttachmentDropUris(event: DragAndDropEvent): List<String> { 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 val transferable = event.awtTransferable
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as? List<*> val raw = transferable.getTransferData(DataFlavor.javaFileListFlavor)
return files.orEmpty().mapNotNull { entry -> val files = when (raw) {
(entry as? File)?.absolutePath?.takeIf { it.isNotBlank() } is List<*> -> raw
}.distinct() 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)) { if (transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) {
val text = (transferable.getTransferData(DataFlavor.stringFlavor) as? String)?.trim().orEmpty() val text = (transferable.getTransferData(DataFlavor.stringFlavor) as? String)?.trim().orEmpty()
if (text.startsWith("file:")) { if (text.isNotEmpty()) {
val path = runCatching { URI(text).path }.getOrNull()?.takeIf { it.isNotBlank() } val fromLines = text.lineSequence()
?: text.removePrefix("file://") .map { it.trim() }
return listOf(path) .filter { it.isNotEmpty() }
} .mapNotNull { uriStringToLocalPath(it) }
if (text.startsWith("/") || looksLikeWindowsPath(text)) { .distinct()
return listOf(text) .toList()
if (fromLines.isNotEmpty()) return fromLines
uriStringToLocalPath(text)?.let { return listOf(it) }
} }
} }
return emptyList() 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 = private fun looksLikeWindowsPath(text: String): Boolean =
text.length > 2 && text[0].isLetter() && text[1] == ':' 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.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.awt.LocalAwtWindow
import java.awt.FileDialog import java.awt.FileDialog
import java.awt.Frame import java.awt.Frame
import java.awt.Window
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import javax.imageio.ImageIO import javax.imageio.ImageIO
import javax.swing.JFileChooser
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.ui.applyMacOsAppAppearance
import ru.fromchat.ui.isAppInDarkTheme
actual fun getFilenameFromUri(uri: String): String { actual fun getFilenameFromUri(uri: String): String {
val path = when { val path = when {
@@ -19,41 +23,66 @@ actual fun getFilenameFromUri(uri: String): String {
return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file" return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit { 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 { showNativeFilePicker(
owner = owner,
dark = dark,
imagesOnly = true,
onResult = onResult,
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
val owner = LocalAwtWindow.current
val dark = isAppInDarkTheme()
return remember(onResult, owner, dark) {
{
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 isMultipleMode = true
if (imagesOnly) {
setFilenameFilter { _, name -> setFilenameFilter { _, name ->
val lower = name.lowercase() val lower = name.lowercase()
lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") ||
lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp") lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp")
} }
} }
}
dialog.isVisible = true dialog.isVisible = true
val dir = dialog.directory val dir = dialog.directory
val files = dialog.files val files = dialog.files
if (dir != null && files != null && files.isNotEmpty()) { if (dir != null && files != null && files.isNotEmpty()) {
onResult(files.map { File(dir, it.name).absolutePath }) onResult(files.map { File(dir, it.name).absolutePath })
} }
}
}
}
@Composable
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
return remember(onResult) {
{
val chooser = JFileChooser().apply {
isMultiSelectionEnabled = true
fileSelectionMode = JFileChooser.FILES_ONLY
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
onResult(chooser.selectedFiles.map { it.absolutePath })
}
}
}
} }
actual suspend fun getImageAspectRatio(uri: String): Float? { actual suspend fun getImageAspectRatio(uri: String): Float? {