mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
WebSocket status in tray, more UI fixes
This commit is contained in:
@@ -55,6 +55,8 @@ compose.desktop {
|
|||||||
macOS {
|
macOS {
|
||||||
bundleID = "ru.fromchat.desktop"
|
bundleID = "ru.fromchat.desktop"
|
||||||
iconFile.set(desktopWindowIconIcns)
|
iconFile.set(desktopWindowIconIcns)
|
||||||
|
// Tray icons as NSImage templates (menu bar light/dark tint). Also set in Main.kt.
|
||||||
|
jvmArgs("-Dapple.awt.enableTemplateImages=true")
|
||||||
infoPlist {
|
infoPlist {
|
||||||
extraKeysRawXml = """
|
extraKeysRawXml = """
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -105,6 +107,7 @@ tasks.matching { it.name == "run" }.configureEach {
|
|||||||
}
|
}
|
||||||
jvmArgs(
|
jvmArgs(
|
||||||
dockIconArgs + listOf(
|
dockIconArgs + listOf(
|
||||||
|
"-Dapple.awt.enableTemplateImages=true",
|
||||||
"--module-path",
|
"--module-path",
|
||||||
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
|
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
|
||||||
"--add-modules",
|
"--add-modules",
|
||||||
|
|||||||
Binary file not shown.
@@ -11,6 +11,9 @@ import java.util.prefs.Preferences
|
|||||||
* Stored in the same JVM prefs node as PlatformSettings (`ru.fromchat.settings`):
|
* Stored in the same JVM prefs node as PlatformSettings (`ru.fromchat.settings`):
|
||||||
* - `desktop_window_width` / `desktop_window_height` (float dp)
|
* - `desktop_window_width` / `desktop_window_height` (float dp)
|
||||||
* - `desktop_window_x` / `desktop_window_y` (float dp, absolute; absent → platform default)
|
* - `desktop_window_x` / `desktop_window_y` (float dp, absolute; absent → platform default)
|
||||||
|
*
|
||||||
|
* List–detail left pane width uses the sibling key `desktop_list_pane_width`
|
||||||
|
* (see [ru.fromchat.ui.main.ConversationListDetailShell]).
|
||||||
*/
|
*/
|
||||||
internal object DesktopWindowPrefs {
|
internal object DesktopWindowPrefs {
|
||||||
private const val WIDTH_KEY = "desktop_window_width"
|
private const val WIDTH_KEY = "desktop_window_width"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.fromchat.desktop
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS close-to-background policy notes (implemented in [MainKt] / tray close handler).
|
||||||
|
*
|
||||||
|
* ## Window close → background (not quit)
|
||||||
|
* The red traffic-light / window close hides the main window and keeps the JVM process
|
||||||
|
* plus menu-bar tray alive (close-to-tray). Quit is only via tray Quit, File → Quit, or ⌘Q.
|
||||||
|
* Dock icon stays (NSApplicationActivationPolicyRegular) so macOS can show the Dock
|
||||||
|
* "Running in Background" indicator when no windows are visible.
|
||||||
|
*
|
||||||
|
* ## No Login Item / SMAppService
|
||||||
|
* We intentionally do **not** register `SMAppService.mainAppService` (Open at Login /
|
||||||
|
* Login Items & Extensions). Close-to-tray does not require that registration; the process
|
||||||
|
* simply stays alive with a menu-bar tray while the window is hidden.
|
||||||
|
*/
|
||||||
|
internal object MacBackgroundLifecycle {
|
||||||
|
const val LOG_TAG = "MacBackground"
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
package ru.fromchat.desktop
|
package ru.fromchat.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
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.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
import androidx.compose.ui.graphics.toPainter
|
import androidx.compose.ui.graphics.toPainter
|
||||||
@@ -34,6 +40,7 @@ import java.awt.Image
|
|||||||
import java.awt.KeyboardFocusManager
|
import java.awt.KeyboardFocusManager
|
||||||
import java.awt.RenderingHints
|
import java.awt.RenderingHints
|
||||||
import java.awt.Taskbar
|
import java.awt.Taskbar
|
||||||
|
import java.awt.desktop.AppReopenedListener
|
||||||
import java.awt.event.ActionEvent
|
import java.awt.event.ActionEvent
|
||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
@@ -50,6 +57,8 @@ import javax.imageio.ImageIO
|
|||||||
import javax.swing.JComponent
|
import javax.swing.JComponent
|
||||||
import javax.swing.JRootPane
|
import javax.swing.JRootPane
|
||||||
import javax.swing.SwingUtilities
|
import javax.swing.SwingUtilities
|
||||||
|
import javax.swing.UIManager
|
||||||
|
import javax.swing.plaf.ColorUIResource
|
||||||
import javax.swing.text.DefaultEditorKit
|
import javax.swing.text.DefaultEditorKit
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -66,9 +75,13 @@ import ru.fromchat.Res
|
|||||||
import ru.fromchat.action_copy
|
import ru.fromchat.action_copy
|
||||||
import ru.fromchat.action_select
|
import ru.fromchat.action_select
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
|
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||||
|
import ru.fromchat.api.local.db.store.ConnectionStatus
|
||||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||||
import ru.fromchat.app_name
|
import ru.fromchat.app_name
|
||||||
import ru.fromchat.app_name_beta
|
import ru.fromchat.app_name_beta
|
||||||
|
import ru.fromchat.config.Settings
|
||||||
import ru.fromchat.desktop_about_app
|
import ru.fromchat.desktop_about_app
|
||||||
import ru.fromchat.desktop_cut
|
import ru.fromchat.desktop_cut
|
||||||
import ru.fromchat.desktop_menu_edit
|
import ru.fromchat.desktop_menu_edit
|
||||||
@@ -83,8 +96,12 @@ import ru.fromchat.desktop_select_all
|
|||||||
import ru.fromchat.desktop_show_app
|
import ru.fromchat.desktop_show_app
|
||||||
import ru.fromchat.desktop_tray_show
|
import ru.fromchat.desktop_tray_show
|
||||||
import ru.fromchat.desktop_zoom
|
import ru.fromchat.desktop_zoom
|
||||||
|
import ru.fromchat.status_connected
|
||||||
|
import ru.fromchat.status_connecting
|
||||||
|
import ru.fromchat.status_disconnected
|
||||||
import ru.fromchat.ui.App
|
import ru.fromchat.ui.App
|
||||||
import ru.fromchat.ui.LocalExtraStatusBarTop
|
import ru.fromchat.ui.LocalExtraStatusBarTop
|
||||||
|
import ru.fromchat.ui.Theme
|
||||||
|
|
||||||
private object DesktopApplicationBootstrap {
|
private object DesktopApplicationBootstrap {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@@ -231,12 +248,19 @@ fun main(args: Array<String>) {
|
|||||||
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
|
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
|
||||||
)
|
)
|
||||||
System.setProperty("apple.laf.useScreenMenuBar", "true")
|
System.setProperty("apple.laf.useScreenMenuBar", "true")
|
||||||
|
// Treat TrayIcon images as NSImage templates so the menu bar tints them for light/dark.
|
||||||
|
// Must be set before CTrayIcon loads (Compose Tray / SystemTray).
|
||||||
|
System.setProperty("apple.awt.enableTemplateImages", "true")
|
||||||
}
|
}
|
||||||
if (!DesktopSingleInstance.acquireOrForward(args)) return
|
if (!DesktopSingleInstance.acquireOrForward(args)) return
|
||||||
DesktopProtocolRegistration.registerBestEffort()
|
DesktopProtocolRegistration.registerBestEffort()
|
||||||
|
// Close-to-tray keeps the process + tray; no SMAppService / Login Items registration.
|
||||||
args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
|
args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
|
||||||
.forEach { DesktopDeepLinkBus.handleUri(it) }
|
.forEach { DesktopDeepLinkBus.handleUri(it) }
|
||||||
|
|
||||||
|
// Avoid the AWT default white "control" flash before Compose paints (CMP #1794).
|
||||||
|
applyDesktopWindowChromeBackground()
|
||||||
|
|
||||||
val dockIconImage = loadAppIconBufferedImage(tray = false)
|
val dockIconImage = loadAppIconBufferedImage(tray = false)
|
||||||
applyDockIcon(dockIconImage)
|
applyDockIcon(dockIconImage)
|
||||||
// Re-apply after AWT is fully up — Taskbar can ignore early sets on macOS :run.
|
// Re-apply after AWT is fully up — Taskbar can ignore early sets on macOS :run.
|
||||||
@@ -250,6 +274,8 @@ fun main(args: Array<String>) {
|
|||||||
size = remember { DesktopWindowPrefs.loadSize() },
|
size = remember { DesktopWindowPrefs.loadSize() },
|
||||||
position = remember { DesktopWindowPrefs.loadPosition() },
|
position = remember { DesktopWindowPrefs.loadPosition() },
|
||||||
)
|
)
|
||||||
|
// Hide until App bootstrap sets startDestination (avoids ~2s empty/white window).
|
||||||
|
var contentReady by remember { mutableStateOf(false) }
|
||||||
var windowVisible by remember { mutableStateOf(true) }
|
var windowVisible by remember { mutableStateOf(true) }
|
||||||
val trayState = rememberTrayState()
|
val trayState = rememberTrayState()
|
||||||
// BufferedImage.toPainter() keeps the AWT pixels (avoids blank BitmapPainter round-trip).
|
// BufferedImage.toPainter() keeps the AWT pixels (avoids blank BitmapPainter round-trip).
|
||||||
@@ -265,6 +291,29 @@ fun main(args: Array<String>) {
|
|||||||
val aboutApp = stringResource(Res.string.desktop_about_app)
|
val aboutApp = stringResource(Res.string.desktop_about_app)
|
||||||
val trayShow = stringResource(Res.string.desktop_tray_show)
|
val trayShow = stringResource(Res.string.desktop_tray_show)
|
||||||
val quit = stringResource(Res.string.desktop_quit)
|
val quit = stringResource(Res.string.desktop_quit)
|
||||||
|
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||||
|
var wsLinked by remember { mutableStateOf(WebSocketManager.isConnected) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
wsLinked = WebSocketManager.isConnected
|
||||||
|
delay(400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mirror ConnectionStateStore + WebSocketManager.isConnected (+ logged-out → disconnected).
|
||||||
|
val wsStatusLabel = when {
|
||||||
|
connectionStatus == ConnectionStatus.CONNECTED ||
|
||||||
|
connectionStatus == ConnectionStatus.UPDATING ||
|
||||||
|
wsLinked -> stringResource(Res.string.status_connected)
|
||||||
|
ApiClient.token.isNullOrEmpty() -> stringResource(Res.string.status_disconnected)
|
||||||
|
else -> stringResource(Res.string.status_connecting)
|
||||||
|
}
|
||||||
|
val windowChrome = remember { desktopThemeBackgroundCompose() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
// Safety: never leave the window stuck hidden if bootstrap hangs.
|
||||||
|
delay(8_000)
|
||||||
|
contentReady = true
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(windowState.size, windowState.position) {
|
LaunchedEffect(windowState.size, windowState.position) {
|
||||||
delay(3_000)
|
delay(3_000)
|
||||||
@@ -283,19 +332,54 @@ fun main(args: Array<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openAbout() {
|
fun showMainWindow() {
|
||||||
windowVisible = true
|
windowVisible = true
|
||||||
|
AppForeground.setForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openAbout() {
|
||||||
|
showMainWindow()
|
||||||
DesktopMenuCommands.emit(DesktopMenuCommand.OpenAbout)
|
DesktopMenuCommands.emit(DesktopMenuCommand.OpenAbout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hide window, keep process + tray (close-to-background). Else quit. */
|
||||||
|
fun requestCloseToBackgroundOrQuit() {
|
||||||
|
if (traySupported) {
|
||||||
|
windowVisible = false
|
||||||
|
// Desktop keeps sockets alive while the process runs (see keepWebSocketAliveInBackground).
|
||||||
|
AppForeground.setForeground(true)
|
||||||
|
if (mac) {
|
||||||
|
Logger.i(MacBackgroundLifecycle.LOG_TAG, "Window closed → background (tray kept, process alive)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exitApplication()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (!mac || !Desktop.isDesktopSupported()) return@LaunchedEffect
|
if (!Desktop.isDesktopSupported()) return@LaunchedEffect
|
||||||
val desktop = Desktop.getDesktop()
|
val desktop = Desktop.getDesktop()
|
||||||
if (desktop.isSupported(Desktop.Action.APP_ABOUT)) {
|
if (mac && desktop.isSupported(Desktop.Action.APP_ABOUT)) {
|
||||||
desktop.setAboutHandler {
|
desktop.setAboutHandler {
|
||||||
SwingUtilities.invokeLater { openAbout() }
|
SwingUtilities.invokeLater { openAbout() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ⌘Q / system Quit must fully exit (not close-to-tray).
|
||||||
|
if (desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER)) {
|
||||||
|
desktop.setQuitHandler { _, response ->
|
||||||
|
DesktopSingleInstance.release()
|
||||||
|
response.performQuit()
|
||||||
|
exitApplication()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dock click while window is hidden → show again (macOS AppReopened).
|
||||||
|
if (mac) {
|
||||||
|
desktop.addAppEventListener(
|
||||||
|
AppReopenedListener {
|
||||||
|
SwingUtilities.invokeLater { showMainWindow() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (traySupported) {
|
if (traySupported) {
|
||||||
@@ -303,9 +387,11 @@ fun main(args: Array<String>) {
|
|||||||
icon = trayIcon,
|
icon = trayIcon,
|
||||||
state = trayState,
|
state = trayState,
|
||||||
tooltip = appName,
|
tooltip = appName,
|
||||||
onAction = { windowVisible = true },
|
onAction = { showMainWindow() },
|
||||||
menu = {
|
menu = {
|
||||||
Item(trayShow) { windowVisible = true }
|
Item(wsStatusLabel, enabled = false) {}
|
||||||
|
Separator()
|
||||||
|
Item(trayShow) { showMainWindow() }
|
||||||
Item(aboutApp) { openAbout() }
|
Item(aboutApp) { openAbout() }
|
||||||
Separator()
|
Separator()
|
||||||
Item(quit) { exitApplication() }
|
Item(quit) { exitApplication() }
|
||||||
@@ -314,17 +400,10 @@ fun main(args: Array<String>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = {
|
onCloseRequest = { requestCloseToBackgroundOrQuit() },
|
||||||
if (traySupported) {
|
|
||||||
windowVisible = false
|
|
||||||
AppForeground.setForeground(true)
|
|
||||||
} else {
|
|
||||||
exitApplication()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = appName,
|
title = appName,
|
||||||
state = windowState,
|
state = windowState,
|
||||||
visible = windowVisible || !traySupported,
|
visible = contentReady && (windowVisible || !traySupported),
|
||||||
icon = windowIcon,
|
icon = windowIcon,
|
||||||
onPreviewKeyEvent = { event ->
|
onPreviewKeyEvent = { event ->
|
||||||
// macOS: MenuBar KeyShortcuts handle these. Win/Linux: no menu bar.
|
// macOS: MenuBar KeyShortcuts handle these. Win/Linux: no menu bar.
|
||||||
@@ -333,17 +412,17 @@ fun main(args: Array<String>) {
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
!event.isShiftPressed && event.key == Key.N -> {
|
!event.isShiftPressed && event.key == Key.N -> {
|
||||||
windowVisible = true
|
showMainWindow()
|
||||||
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
|
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
!event.isShiftPressed && event.key == Key.F -> {
|
!event.isShiftPressed && event.key == Key.F -> {
|
||||||
windowVisible = true
|
showMainWindow()
|
||||||
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
|
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
event.isShiftPressed && event.key == Key.S -> {
|
event.isShiftPressed && event.key == Key.S -> {
|
||||||
windowVisible = true
|
showMainWindow()
|
||||||
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
|
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -352,7 +431,7 @@ fun main(args: Array<String>) {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
event.isShiftPressed && event.key == Key.N -> {
|
event.isShiftPressed && event.key == Key.N -> {
|
||||||
windowVisible = true
|
showMainWindow()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
event.key == Key.M -> {
|
event.key == Key.M -> {
|
||||||
@@ -367,8 +446,11 @@ fun main(args: Array<String>) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(window, appName) {
|
LaunchedEffect(window, appName, windowChrome) {
|
||||||
window.title = appName
|
window.title = appName
|
||||||
|
val awt = windowChrome.toAwtColor()
|
||||||
|
window.background = awt
|
||||||
|
window.contentPane.background = awt
|
||||||
enableEdgeToEdgeTitleBar(window.rootPane)
|
enableEdgeToEdgeTitleBar(window.rootPane)
|
||||||
dockIconImage?.let { image ->
|
dockIconImage?.let { image ->
|
||||||
window.iconImages = listOf(image)
|
window.iconImages = listOf(image)
|
||||||
@@ -383,18 +465,18 @@ fun main(args: Array<String>) {
|
|||||||
if (mac) {
|
if (mac) {
|
||||||
FromChatMenuBar(
|
FromChatMenuBar(
|
||||||
onNewChat = {
|
onNewChat = {
|
||||||
windowVisible = true
|
showMainWindow()
|
||||||
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
|
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
|
||||||
},
|
},
|
||||||
onSearchConversations = {
|
onSearchConversations = {
|
||||||
windowVisible = true
|
showMainWindow()
|
||||||
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
|
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
|
||||||
},
|
},
|
||||||
onSelectChats = {
|
onSelectChats = {
|
||||||
windowVisible = true
|
showMainWindow()
|
||||||
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
|
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
|
||||||
},
|
},
|
||||||
onShow = { windowVisible = true },
|
onShow = { showMainWindow() },
|
||||||
onMinimize = { windowState.isMinimized = true },
|
onMinimize = { windowState.isMinimized = true },
|
||||||
onZoom = {
|
onZoom = {
|
||||||
windowState.placement =
|
windowState.placement =
|
||||||
@@ -411,7 +493,13 @@ fun main(args: Array<String>) {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalExtraStatusBarTop provides if (mac) 28.dp else 0.dp,
|
LocalExtraStatusBarTop provides if (mac) 28.dp else 0.dp,
|
||||||
) {
|
) {
|
||||||
App()
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(windowChrome),
|
||||||
|
) {
|
||||||
|
App(onContentReady = { contentReady = true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,6 +610,39 @@ private fun isWindowsOs(): Boolean =
|
|||||||
private fun isLinuxOs(): Boolean =
|
private fun isLinuxOs(): Boolean =
|
||||||
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
|
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JFrame uses UIManager "control" as its default background before Compose draws.
|
||||||
|
* Set it to the app theme background so dark theme never flashes white (CMP #1794).
|
||||||
|
*
|
||||||
|
* Colors match Material3 [darkColorScheme]/[lightColorScheme] defaults used by [ru.fromchat.ui.getColorScheme].
|
||||||
|
*/
|
||||||
|
private fun applyDesktopWindowChromeBackground() {
|
||||||
|
UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun desktopThemeBackgroundCompose(): Color {
|
||||||
|
val dark = when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
|
||||||
|
Theme.Dark -> true
|
||||||
|
Theme.Light -> false
|
||||||
|
Theme.AsSystem -> isSystemAppearanceDark()
|
||||||
|
}
|
||||||
|
// M3 default scheme backgrounds (desktop module has no material3 dependency).
|
||||||
|
return if (dark) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Color.toAwtColor(): java.awt.Color =
|
||||||
|
java.awt.Color(red, green, blue, alpha)
|
||||||
|
|
||||||
|
private fun isSystemAppearanceDark(): Boolean {
|
||||||
|
if (!isMacOs()) return false
|
||||||
|
return runCatching {
|
||||||
|
val process = ProcessBuilder("defaults", "read", "-g", "AppleInterfaceStyle")
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
process.inputStream.bufferedReader().readText().trim().equals("Dark", ignoreCase = true)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
|
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
|
||||||
if (!isMacOs()) return
|
if (!isMacOs()) return
|
||||||
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
|
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
|
||||||
@@ -553,10 +674,14 @@ private fun applyDockIcon(image: BufferedImage?) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the desktop tray or window/dock icon from packaged resources.
|
* Loads the desktop tray or window/dock icon from packaged resources.
|
||||||
* Tray prefers the flat mark (`app_icon`); dock/window prefer the gradient (`app_window_icon`).
|
* Tray prefers the flat white+alpha mark (`app_icon`, template-ready); dock/window prefer
|
||||||
|
* the branded AppIcon (`app_window_icon`).
|
||||||
*
|
*
|
||||||
* Uses [BufferedImage.toPainter] so Compose Tray/Window hand the same AWT pixels to the OS
|
* Uses [BufferedImage.toPainter] so Compose Tray/Window hand the same AWT pixels to the OS
|
||||||
* (BitmapPainter → ImageBitmap round-trips can rasterize blank and fall back to Compose's default).
|
* (BitmapPainter → ImageBitmap round-trips can rasterize blank and fall back to Compose's default).
|
||||||
|
*
|
||||||
|
* On macOS, `apple.awt.enableTemplateImages` makes the tray mark an NSImage template so the
|
||||||
|
* menu bar recolors it for the current appearance (set early in [main]).
|
||||||
*/
|
*/
|
||||||
private fun loadAppIconPainter(tray: Boolean): Painter {
|
private fun loadAppIconPainter(tray: Boolean): Painter {
|
||||||
val image = loadAppIconBufferedImage(tray)
|
val image = loadAppIconBufferedImage(tray)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 KiB |
@@ -111,6 +111,8 @@
|
|||||||
<string name="search_not_found_message">Ничего не найдено. Попробуйте переформулировать запрос.</string>
|
<string name="search_not_found_message">Ничего не найдено. Попробуйте переформулировать запрос.</string>
|
||||||
<string name="message_placeholder">Напишите сообщение…</string>
|
<string name="message_placeholder">Напишите сообщение…</string>
|
||||||
<string name="status_connecting">Соединение</string>
|
<string name="status_connecting">Соединение</string>
|
||||||
|
<string name="status_connected">Подключено</string>
|
||||||
|
<string name="status_disconnected">Отключено</string>
|
||||||
<string name="status_updating">Обновление</string>
|
<string name="status_updating">Обновление</string>
|
||||||
<string name="chat_group_label">Группа</string>
|
<string name="chat_group_label">Группа</string>
|
||||||
<string name="chat_members_count">%1$d человек</string>
|
<string name="chat_members_count">%1$d человек</string>
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
|
|
||||||
<!-- Connection / chat chrome -->
|
<!-- Connection / chat chrome -->
|
||||||
<string name="status_connecting">Connecting</string>
|
<string name="status_connecting">Connecting</string>
|
||||||
|
<string name="status_connected">Connected</string>
|
||||||
|
<string name="status_disconnected">Disconnected</string>
|
||||||
<string name="status_updating">Updating</string>
|
<string name="status_updating">Updating</string>
|
||||||
<string name="chat_group_label">Group</string>
|
<string name="chat_group_label">Group</string>
|
||||||
<string name="chat_members_count">%1$d people</string>
|
<string name="chat_members_count">%1$d people</string>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
@@ -221,7 +220,9 @@ fun App(
|
|||||||
startAtProfileUserId: Int? = null,
|
startAtProfileUserId: Int? = null,
|
||||||
startAtProfileUsername: String? = null,
|
startAtProfileUsername: String? = null,
|
||||||
profileLookupErrorMessage: String? = null,
|
profileLookupErrorMessage: String? = null,
|
||||||
onProfileLookupErrorMessageConsumed: () -> Unit = {}
|
onProfileLookupErrorMessageConsumed: () -> Unit = {},
|
||||||
|
/** Desktop: fired once [startDestination] is known so the window can show after bootstrap. */
|
||||||
|
onContentReady: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
setSingletonImageLoaderFactory { context ->
|
setSingletonImageLoaderFactory { context ->
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
@@ -262,6 +263,7 @@ fun App(
|
|||||||
hasToken -> "chat"
|
hasToken -> "chat"
|
||||||
else -> "welcome"
|
else -> "welcome"
|
||||||
}
|
}
|
||||||
|
onContentReady()
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
|
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
|
||||||
@@ -516,13 +518,6 @@ fun App(
|
|||||||
|
|
||||||
ScreenSurface {
|
ScreenSurface {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
val listPaneWidth =
|
|
||||||
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
|
|
||||||
448.dp
|
|
||||||
} else {
|
|
||||||
360.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(modifier: Modifier = Modifier) {
|
fun AppNavHost(modifier: Modifier = Modifier) {
|
||||||
NavHost(
|
NavHost(
|
||||||
@@ -677,7 +672,6 @@ fun App(
|
|||||||
LocalDesktopMainTab provides pendingMainTab,
|
LocalDesktopMainTab provides pendingMainTab,
|
||||||
) {
|
) {
|
||||||
ConversationListDetailShell(
|
ConversationListDetailShell(
|
||||||
listPaneWidth = listPaneWidth,
|
|
||||||
detailInPanel = false,
|
detailInPanel = false,
|
||||||
detailEdgeToEdge = detailEdgeToEdge,
|
detailEdgeToEdge = detailEdgeToEdge,
|
||||||
listPane = {
|
listPane = {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.ui.main.ConversationDetailContentPadding
|
import ru.fromchat.ui.main.ConversationDetailContentPadding
|
||||||
import ru.fromchat.ui.main.LocalConversationListDetailActive
|
import ru.fromchat.ui.main.LocalConversationListDetailActive
|
||||||
|
import ru.fromchat.ui.main.detailPaneShowBackButton
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.presence_recently
|
import ru.fromchat.presence_recently
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
@@ -1414,6 +1415,7 @@ fun ChatScreen(
|
|||||||
hazeState = hazeState,
|
hazeState = hazeState,
|
||||||
hazeBlurEnabled = hazeBlurEnabled,
|
hazeBlurEnabled = hazeBlurEnabled,
|
||||||
pillChrome = listDetailActive,
|
pillChrome = listDetailActive,
|
||||||
|
showBackButton = detailPaneShowBackButton(),
|
||||||
onBack = {
|
onBack = {
|
||||||
runNav { navController.navigateUp() }
|
runNav { navController.navigateUp() }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ fun ChatTopBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
hazeBlurEnabled: Boolean = true,
|
hazeBlurEnabled: Boolean = true,
|
||||||
pillChrome: Boolean = false,
|
pillChrome: Boolean = false,
|
||||||
|
showBackButton: Boolean = true,
|
||||||
) {
|
) {
|
||||||
if (pillChrome) {
|
if (pillChrome) {
|
||||||
ChatTopBarPill(
|
ChatTopBarPill(
|
||||||
@@ -345,6 +346,7 @@ fun ChatTopBar(
|
|||||||
titleChrome = titleChrome,
|
titleChrome = titleChrome,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hazeBlurEnabled = hazeBlurEnabled,
|
hazeBlurEnabled = hazeBlurEnabled,
|
||||||
|
showBackButton = showBackButton,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -366,11 +368,13 @@ fun ChatTopBar(
|
|||||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
if (showBackButton) {
|
||||||
Icon(
|
IconButton(onClick = onBack) {
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
Icon(
|
||||||
contentDescription = backContentDescription
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
)
|
contentDescription = backContentDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = { titleChrome() },
|
title = { titleChrome() },
|
||||||
@@ -441,6 +445,7 @@ private fun ChatTopBarPill(
|
|||||||
titleChrome: @Composable () -> Unit,
|
titleChrome: @Composable () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
hazeBlurEnabled: Boolean = true,
|
hazeBlurEnabled: Boolean = true,
|
||||||
|
showBackButton: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
|
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
val progressiveStripHazeStyle = HazeMaterials.thin()
|
val progressiveStripHazeStyle = HazeMaterials.thin()
|
||||||
@@ -471,24 +476,26 @@ private fun ChatTopBarPill(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
if (showBackButton) {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.size(48.dp)
|
modifier = Modifier
|
||||||
.clip(CircleShape)
|
.size(48.dp)
|
||||||
.background(chromeColor)
|
.clip(CircleShape)
|
||||||
.hazeEffect(state = hazeState) {
|
.background(chromeColor)
|
||||||
blurEffect {
|
.hazeEffect(state = hazeState) {
|
||||||
blurEnabled = hazeBlurEnabled
|
blurEffect {
|
||||||
style = hazeStyle
|
blurEnabled = hazeBlurEnabled
|
||||||
}
|
style = hazeStyle
|
||||||
},
|
}
|
||||||
contentAlignment = Alignment.Center,
|
},
|
||||||
) {
|
contentAlignment = Alignment.Center,
|
||||||
IconButton(onClick = onBack) {
|
) {
|
||||||
Icon(
|
IconButton(onClick = onBack) {
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
Icon(
|
||||||
contentDescription = backContentDescription,
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
)
|
contentDescription = backContentDescription,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ package ru.fromchat.ui.chat
|
|||||||
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animate
|
import androidx.compose.animation.core.animate
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.LocalIndication
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -37,6 +42,7 @@ import androidx.compose.runtime.setValue
|
|||||||
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
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
@@ -65,7 +71,6 @@ import ru.fromchat.api.local.messages.isQueuedOutbound
|
|||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
import ru.fromchat.supportsMouseMessageInteraction
|
import ru.fromchat.supportsMouseMessageInteraction
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
|
||||||
|
|
||||||
data class ContextMenuState(
|
data class ContextMenuState(
|
||||||
val isOpen: Boolean = false,
|
val isOpen: Boolean = false,
|
||||||
@@ -96,6 +101,31 @@ internal fun messageContextMenuFingerprint(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val contextMenuEnterSpec = spring<Float>(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun clampContextMenuOffset(
|
||||||
|
position: IntOffset,
|
||||||
|
popupSize: IntSize,
|
||||||
|
screenWidthPx: Int,
|
||||||
|
screenHeightPx: Int,
|
||||||
|
paddingPx: Int,
|
||||||
|
allowOutsideWindow: Boolean,
|
||||||
|
): IntOffset {
|
||||||
|
if (allowOutsideWindow || popupSize == IntSize.Zero) return position
|
||||||
|
var x = position.x
|
||||||
|
var y = position.y
|
||||||
|
val rightEdge = screenWidthPx - paddingPx
|
||||||
|
val bottomEdge = screenHeightPx - paddingPx
|
||||||
|
if (x + popupSize.width > rightEdge) x = rightEdge - popupSize.width
|
||||||
|
if (y + popupSize.height > bottomEdge) y = bottomEdge - popupSize.height
|
||||||
|
if (x < paddingPx) x = paddingPx
|
||||||
|
if (y < paddingPx) y = paddingPx
|
||||||
|
return IntOffset(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageContextMenu(
|
fun MessageContextMenu(
|
||||||
state: ContextMenuState,
|
state: ContextMenuState,
|
||||||
@@ -127,34 +157,38 @@ fun MessageContextMenu(
|
|||||||
mutableStateOf(state.isOpen && state.message != null)
|
mutableStateOf(state.isOpen && state.message != null)
|
||||||
}
|
}
|
||||||
val animationProgress = remember { mutableFloatStateOf(0f) }
|
val animationProgress = remember { mutableFloatStateOf(0f) }
|
||||||
|
var enterLaidOut by remember(state.message) { mutableStateOf(false) }
|
||||||
|
var frozenOrigin by remember(state.message) {
|
||||||
|
mutableStateOf(TransformOrigin(0f, 0f))
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(state.isOpen) {
|
LaunchedEffect(state.isOpen) {
|
||||||
if (!state.isOpen) {
|
if (!state.isOpen) {
|
||||||
animate(
|
animate(
|
||||||
initialValue = 1f,
|
initialValue = animationProgress.floatValue,
|
||||||
targetValue = 0f,
|
targetValue = 0f,
|
||||||
animationSpec = spring(
|
animationSpec = contextMenuEnterSpec,
|
||||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
|
||||||
stiffness = Spring.StiffnessMediumLow
|
|
||||||
)
|
|
||||||
) { value, _ ->
|
) { value, _ ->
|
||||||
animationProgress.floatValue = value
|
animationProgress.floatValue = value
|
||||||
}
|
}
|
||||||
shouldShowPopup = false
|
shouldShowPopup = false
|
||||||
|
enterLaidOut = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(state.isOpen, state.message) {
|
// Enter only after first layout so transform origin / position stay fixed mid-animation.
|
||||||
|
LaunchedEffect(state.isOpen, state.message, enterLaidOut) {
|
||||||
if (state.isOpen && state.message != null) {
|
if (state.isOpen && state.message != null) {
|
||||||
shouldShowPopup = true
|
shouldShowPopup = true
|
||||||
|
if (!enterLaidOut) {
|
||||||
|
animationProgress.floatValue = 0f
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
animationProgress.floatValue = 0f
|
animationProgress.floatValue = 0f
|
||||||
animate(
|
animate(
|
||||||
initialValue = 0f,
|
initialValue = 0f,
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = spring(
|
animationSpec = contextMenuEnterSpec,
|
||||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
|
||||||
stiffness = Spring.StiffnessMediumLow
|
|
||||||
)
|
|
||||||
) { value, _ ->
|
) { value, _ ->
|
||||||
animationProgress.floatValue = value
|
animationProgress.floatValue = value
|
||||||
}
|
}
|
||||||
@@ -165,7 +199,6 @@ fun MessageContextMenu(
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val paddingPx = with(density) { 16.dp.toPx().toInt() }
|
val paddingPx = with(density) { 16.dp.toPx().toInt() }
|
||||||
val allowOutsideWindow = supportsMouseMessageInteraction()
|
val allowOutsideWindow = supportsMouseMessageInteraction()
|
||||||
var popupSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
|
|
||||||
|
|
||||||
// Clamp with popupContentSize — do not SubcomposeLayout-measure under
|
// Clamp with popupContentSize — do not SubcomposeLayout-measure under
|
||||||
// SharedTransitionLayout (writes state during LookaheadMeasuring).
|
// SharedTransitionLayout (writes state during LookaheadMeasuring).
|
||||||
@@ -182,60 +215,18 @@ fun MessageContextMenu(
|
|||||||
windowSize: IntSize,
|
windowSize: IntSize,
|
||||||
layoutDirection: LayoutDirection,
|
layoutDirection: LayoutDirection,
|
||||||
popupContentSize: IntSize,
|
popupContentSize: IntSize,
|
||||||
): IntOffset {
|
): IntOffset = clampContextMenuOffset(
|
||||||
if (allowOutsideWindow) return state.position
|
position = state.position,
|
||||||
var x = state.position.x
|
popupSize = popupContentSize,
|
||||||
var y = state.position.y
|
screenWidthPx = screenWidthPx,
|
||||||
val rightEdge = screenWidthPx - paddingPx
|
screenHeightPx = screenHeightPx,
|
||||||
val bottomEdge = screenHeightPx - paddingPx
|
paddingPx = paddingPx,
|
||||||
if (x + popupContentSize.width > rightEdge) {
|
allowOutsideWindow = allowOutsideWindow,
|
||||||
x = rightEdge - popupContentSize.width
|
)
|
||||||
}
|
|
||||||
if (y + popupContentSize.height > bottomEdge) {
|
|
||||||
y = bottomEdge - popupContentSize.height
|
|
||||||
}
|
|
||||||
if (x < paddingPx) x = paddingPx
|
|
||||||
if (y < paddingPx) y = paddingPx
|
|
||||||
return IntOffset(x, y)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val adjustedOffset = remember(
|
val scale = 0.8f + 0.2f * animationProgress.floatValue
|
||||||
popupSize,
|
|
||||||
state.position,
|
|
||||||
screenWidthPx,
|
|
||||||
screenHeightPx,
|
|
||||||
paddingPx,
|
|
||||||
allowOutsideWindow,
|
|
||||||
) {
|
|
||||||
if (allowOutsideWindow || popupSize == IntSize.Zero) {
|
|
||||||
state.position
|
|
||||||
} else {
|
|
||||||
var x = state.position.x
|
|
||||||
var y = state.position.y
|
|
||||||
val rightEdge = screenWidthPx - paddingPx
|
|
||||||
val bottomEdge = screenHeightPx - paddingPx
|
|
||||||
if (x + popupSize.width > rightEdge) x = rightEdge - popupSize.width
|
|
||||||
if (y + popupSize.height > bottomEdge) y = bottomEdge - popupSize.height
|
|
||||||
if (x < paddingPx) x = paddingPx
|
|
||||||
if (y < paddingPx) y = paddingPx
|
|
||||||
IntOffset(x, y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val transformOriginX = if (popupSize.width > 0) {
|
|
||||||
((state.position.x - adjustedOffset.x).toFloat() / popupSize.width).coerceIn(0f, 1f)
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
val transformOriginY = if (popupSize.height > 0) {
|
|
||||||
((state.position.y - adjustedOffset.y).toFloat() / popupSize.height).coerceIn(0f, 1f)
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
|
|
||||||
val scale = 0.5f + 0.5f * animationProgress.floatValue
|
|
||||||
val alpha = animationProgress.floatValue
|
val alpha = animationProgress.floatValue
|
||||||
|
|
||||||
// [state.position] is window coordinates (see MessageItem localToWindow).
|
// [state.position] is window coordinates (see MessageItem localToWindow).
|
||||||
@@ -282,12 +273,29 @@ fun MessageContextMenu(
|
|||||||
onRetrySend(it)
|
onRetrySend(it)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
modifier = modifier.onSizeChanged { popupSize = it },
|
modifier = modifier.onSizeChanged { size ->
|
||||||
|
if (size.width <= 0 || size.height <= 0 || enterLaidOut) return@onSizeChanged
|
||||||
|
val adjusted = clampContextMenuOffset(
|
||||||
|
position = state.position,
|
||||||
|
popupSize = size,
|
||||||
|
screenWidthPx = screenWidthPx,
|
||||||
|
screenHeightPx = screenHeightPx,
|
||||||
|
paddingPx = paddingPx,
|
||||||
|
allowOutsideWindow = allowOutsideWindow,
|
||||||
|
)
|
||||||
|
frozenOrigin = TransformOrigin(
|
||||||
|
pivotFractionX = ((state.position.x - adjusted.x).toFloat() / size.width)
|
||||||
|
.coerceIn(0f, 1f),
|
||||||
|
pivotFractionY = ((state.position.y - adjusted.y).toFloat() / size.height)
|
||||||
|
.coerceIn(0f, 1f),
|
||||||
|
)
|
||||||
|
enterLaidOut = true
|
||||||
|
},
|
||||||
animated = true,
|
animated = true,
|
||||||
scale = scale,
|
scale = scale,
|
||||||
alpha = alpha,
|
alpha = alpha,
|
||||||
transformOriginX = transformOriginX,
|
transformOriginX = frozenOrigin.pivotFractionX,
|
||||||
transformOriginY = transformOriginY,
|
transformOriginY = frozenOrigin.pivotFractionY,
|
||||||
isReadOnly = isReadOnly,
|
isReadOnly = isReadOnly,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -417,39 +425,37 @@ internal fun ChatStyleContextMenuFrame(
|
|||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val menuShape = RoundedCornerShape(16.dp)
|
val menuShape = RoundedCornerShape(16.dp)
|
||||||
val density = LocalDensity.current
|
|
||||||
val shadowElevationPx = if (withShadow) {
|
val shadowElevationPx = if (withShadow) {
|
||||||
with(density) { 12.dp.toPx() }
|
with(LocalDensity.current) { 12.dp.toPx() }
|
||||||
} else {
|
} else {
|
||||||
0f
|
0f
|
||||||
}
|
}
|
||||||
val baseModifier = modifier.width(IntrinsicSize.Max)
|
// Draw shadow on an outer layer; clip/background stay on the inner box so hover
|
||||||
val containerModifier =
|
// invalidation does not rebuild the elevated layer (avoids first-hover blink).
|
||||||
if (animated) {
|
Box(
|
||||||
baseModifier.graphicsLayer(
|
modifier = modifier
|
||||||
scaleX = scale,
|
.width(IntrinsicSize.Max)
|
||||||
scaleY = scale,
|
.graphicsLayer {
|
||||||
alpha = alpha,
|
if (animated) {
|
||||||
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
|
scaleX = scale
|
||||||
shadowElevation = shadowElevationPx,
|
scaleY = scale
|
||||||
shape = menuShape,
|
this.alpha = alpha
|
||||||
clip = true,
|
transformOrigin = TransformOrigin(transformOriginX, transformOriginY)
|
||||||
)
|
}
|
||||||
} else {
|
shadowElevation = shadowElevationPx
|
||||||
baseModifier.graphicsLayer(
|
shape = menuShape
|
||||||
shadowElevation = shadowElevationPx,
|
clip = false
|
||||||
shape = menuShape,
|
},
|
||||||
clip = true,
|
) {
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = containerModifier) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.matchParentSize()
|
.matchParentSize()
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer, menuShape),
|
.clip(menuShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
)
|
)
|
||||||
content()
|
Box(modifier = Modifier.clip(menuShape)) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,27 +473,40 @@ internal fun ChatStyleContextMenuItem(
|
|||||||
isError -> MaterialTheme.colorScheme.error
|
isError -> MaterialTheme.colorScheme.error
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
}
|
}
|
||||||
val iconColor = textColor
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val hovered by interactionSource.collectIsHoveredAsState()
|
||||||
|
val pressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val pressScale by animateFloatAsState(
|
||||||
|
targetValue = if (pressed && enabled) 0.96f else 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
),
|
||||||
|
label = "contextMenuItemPress",
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = pressScale
|
||||||
|
scaleY = pressScale
|
||||||
|
}
|
||||||
.clip(chatStyleMenuItemShape)
|
.clip(chatStyleMenuItemShape)
|
||||||
.then(
|
.background(
|
||||||
if (enabled) {
|
if (enabled && hovered) {
|
||||||
Modifier.scaleOnPress(
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||||
scale = 0.96f,
|
|
||||||
onClick = onClick,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessMedium,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Color.Transparent
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
@@ -498,7 +517,7 @@ internal fun ChatStyleContextMenuItem(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = text,
|
contentDescription = text,
|
||||||
tint = iconColor,
|
tint = textColor,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -1110,6 +1110,15 @@ fun MessageItem(
|
|||||||
!isCorrupted &&
|
!isCorrupted &&
|
||||||
!isFilenameOnlyMessageCaption(message)
|
!isFilenameOnlyMessageCaption(message)
|
||||||
) {
|
) {
|
||||||
|
// I-beam over message text: SelectionContainer registers
|
||||||
|
// PointerIcon.Text on Android/desktop; outer hover icon
|
||||||
|
// covers padding and ContextMenuArea gaps on desktop.
|
||||||
|
val selectableTextModifier = Modifier
|
||||||
|
.pointerHoverIcon(
|
||||||
|
PointerIcon.Text,
|
||||||
|
overrideDescendants = true,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 14.dp)
|
||||||
if (mouseMessageUi) {
|
if (mouseMessageUi) {
|
||||||
// Default selection colors use primary; on author
|
// Default selection colors use primary; on author
|
||||||
// bubbles that matches the background, so
|
// bubbles that matches the background, so
|
||||||
@@ -1141,19 +1150,20 @@ fun MessageItem(
|
|||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = contentColor,
|
color = contentColor,
|
||||||
modifier = Modifier.padding(
|
modifier = selectableTextModifier,
|
||||||
horizontal = 14.dp,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Android (and touch): still show text cursor when a
|
||||||
|
// mouse/trackpad hovers the caption; selection stays
|
||||||
|
// off so long-press keeps opening the message menu.
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = contentColor,
|
color = contentColor,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp)
|
modifier = selectableTextModifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-45
@@ -1,8 +1,16 @@
|
|||||||
package ru.fromchat.ui.main
|
package ru.fromchat.ui.main
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -13,14 +21,32 @@ import androidx.compose.foundation.layout.union
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.PointerIcon
|
||||||
|
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import ru.fromchat.ui.components.AppPanel
|
import ru.fromchat.ui.components.AppPanel
|
||||||
import ru.fromchat.ui.extraStatusBars
|
import ru.fromchat.ui.extraStatusBars
|
||||||
|
import com.pr0gramm3r101.utils.settings.Settings as PlatformSettings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal inset for chat detail content (messages, top-bar pills, input) in list–detail
|
* Horizontal inset for chat detail content (messages, top-bar pills, input) in list–detail
|
||||||
@@ -40,7 +66,11 @@ val LocalConversationListDetailActive = staticCompositionLocalOf { false }
|
|||||||
* ([detailInPanel]); chat detail is edge-to-edge ([detailEdgeToEdge]) so floating chrome
|
* ([detailInPanel]); chat detail is edge-to-edge ([detailEdgeToEdge]) so floating chrome
|
||||||
* can blur past its inner padding toward the window.
|
* can blur past its inner padding toward the window.
|
||||||
*
|
*
|
||||||
* A small gutter separates panes so list-panel round corners stay visible.
|
* A small gutter separates panes so list-panel round corners stay visible. Drag that gap
|
||||||
|
* to resize the list; the remembered width is stored in desktop prefs
|
||||||
|
* (`desktop_list_pane_width`) and does not jump when adaptive window size classes change.
|
||||||
|
* While the gap is hovered or dragged it stays transparent so right-pane blur can extend
|
||||||
|
* into it.
|
||||||
*
|
*
|
||||||
* Pads with [WindowInsets.safeDrawing] ∪ [WindowInsets.extraStatusBars] so panes clear the
|
* Pads with [WindowInsets.safeDrawing] ∪ [WindowInsets.extraStatusBars] so panes clear the
|
||||||
* macOS title bar / system bars, then consumes those insets so child top bars do not double-pad
|
* macOS title bar / system bars, then consumes those insets so child top bars do not double-pad
|
||||||
@@ -49,66 +79,162 @@ val LocalConversationListDetailActive = staticCompositionLocalOf { false }
|
|||||||
*
|
*
|
||||||
* Layout structure is stable regardless of [detailEdgeToEdge] / [detailInPanel] so detail
|
* Layout structure is stable regardless of [detailEdgeToEdge] / [detailInPanel] so detail
|
||||||
* [androidx.compose.animation.AnimatedContent] is not remounted when empty ↔ chat.
|
* [androidx.compose.animation.AnimatedContent] is not remounted when empty ↔ chat.
|
||||||
|
*
|
||||||
|
* The detail slot fills immediately with [androidx.compose.material3.ColorScheme.background]
|
||||||
|
* when the window grows (avoids a white/unpainted flash in the new strip). Detail content
|
||||||
|
* width springs toward the slot size so continuous resize eases instead of jumping.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ConversationListDetailShell(
|
fun ConversationListDetailShell(
|
||||||
listPaneWidth: Dp,
|
|
||||||
listPane: @Composable () -> Unit,
|
listPane: @Composable () -> Unit,
|
||||||
detailPane: @Composable () -> Unit,
|
detailPane: @Composable () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
detailInPanel: Boolean = false,
|
detailInPanel: Boolean = false,
|
||||||
detailEdgeToEdge: Boolean = false,
|
detailEdgeToEdge: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
var storedListWidth by remember { mutableStateOf(ConversationListPanePrefs.load()) }
|
||||||
|
var gapDragging by remember { mutableStateOf(false) }
|
||||||
|
val gapInteractionSource = remember { MutableInteractionSource() }
|
||||||
|
val gapHovered by gapInteractionSource.collectIsHoveredAsState()
|
||||||
|
val gapActive = gapHovered || gapDragging
|
||||||
|
|
||||||
val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)
|
val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)
|
||||||
|
val shellBackground = MaterialTheme.colorScheme.background
|
||||||
|
|
||||||
CompositionLocalProvider(LocalConversationListDetailActive provides true) {
|
CompositionLocalProvider(LocalConversationListDetailActive provides true) {
|
||||||
Row(modifier.fillMaxSize()) {
|
BoxWithConstraints(
|
||||||
AppPanel(
|
modifier
|
||||||
Modifier
|
.fillMaxSize()
|
||||||
.windowInsetsPadding(paneInsets)
|
.background(shellBackground),
|
||||||
.consumeWindowInsets(paneInsets)
|
) {
|
||||||
.padding(
|
// List start padding (8.dp) + gap (8.dp) reserved outside the panel width.
|
||||||
start = 8.dp,
|
val maxListWidth =
|
||||||
top = 8.dp,
|
(maxWidth - 8.dp - 8.dp - 320.dp).coerceAtLeast(280.dp)
|
||||||
bottom = 8.dp,
|
val listWidth = storedListWidth.coerceIn(280.dp, maxListWidth)
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
AppPanel(
|
||||||
|
Modifier
|
||||||
|
.windowInsetsPadding(paneInsets)
|
||||||
|
.consumeWindowInsets(paneInsets)
|
||||||
|
.padding(
|
||||||
|
start = 8.dp,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
)
|
||||||
|
.width(listWidth)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
) {
|
||||||
|
listPane()
|
||||||
|
}
|
||||||
|
// Detail fills the remainder and draws under the leading gap so chrome blur
|
||||||
|
// can extend into it when the gap strip is transparent.
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(shellBackground)
|
||||||
|
.then(
|
||||||
|
if (detailEdgeToEdge) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
.windowInsetsPadding(paneInsets)
|
||||||
|
.consumeWindowInsets(paneInsets)
|
||||||
|
.padding(
|
||||||
|
top = 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val detailWidth by animateDpAsState(
|
||||||
|
targetValue = maxWidth,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
),
|
||||||
|
label = "conversationDetailPaneWidth",
|
||||||
)
|
)
|
||||||
.width(listPaneWidth)
|
Box(
|
||||||
.fillMaxHeight(),
|
Modifier
|
||||||
shape = RoundedCornerShape(24.dp),
|
.width(detailWidth.coerceAtMost(maxWidth))
|
||||||
) {
|
.fillMaxHeight()
|
||||||
listPane()
|
.align(Alignment.CenterStart),
|
||||||
}
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.then(
|
|
||||||
if (detailEdgeToEdge) {
|
|
||||||
Modifier
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
.windowInsetsPadding(paneInsets)
|
|
||||||
.consumeWindowInsets(paneInsets)
|
|
||||||
.padding(
|
|
||||||
top = 8.dp,
|
|
||||||
end = 8.dp,
|
|
||||||
bottom = 8.dp,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
if (detailInPanel) {
|
|
||||||
AppPanel(
|
|
||||||
Modifier.fillMaxSize(),
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
) {
|
) {
|
||||||
detailPane()
|
if (detailInPanel) {
|
||||||
|
AppPanel(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
) {
|
||||||
|
detailPane()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detailPane()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
Box(
|
||||||
detailPane()
|
Modifier
|
||||||
|
.width(8.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.zIndex(1f)
|
||||||
|
.background(if (gapActive) Color.Transparent else shellBackground)
|
||||||
|
.pointerHoverIcon(PointerIcon.Hand)
|
||||||
|
.hoverable(gapInteractionSource)
|
||||||
|
.pointerInput(maxListWidth, density) {
|
||||||
|
var dragWidth = storedListWidth
|
||||||
|
var dragged = false
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
gapDragging = true
|
||||||
|
dragged = false
|
||||||
|
dragWidth =
|
||||||
|
storedListWidth.coerceIn(280.dp, maxListWidth)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
gapDragging = false
|
||||||
|
if (dragged) {
|
||||||
|
ConversationListPanePrefs.save(dragWidth)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragCancel = { gapDragging = false },
|
||||||
|
onHorizontalDrag = { _, dragAmount ->
|
||||||
|
val delta = with(density) { dragAmount.toDp() }
|
||||||
|
dragged = true
|
||||||
|
dragWidth =
|
||||||
|
(dragWidth + delta).coerceIn(280.dp, maxListWidth)
|
||||||
|
storedListWidth = dragWidth
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists list–detail left pane width in the same JVM prefs node as window size
|
||||||
|
* (`ru.fromchat.settings` / [PlatformSettings]).
|
||||||
|
*/
|
||||||
|
private object ConversationListPanePrefs {
|
||||||
|
private const val WIDTH_KEY = "desktop_list_pane_width"
|
||||||
|
|
||||||
|
private val settings = PlatformSettings()
|
||||||
|
|
||||||
|
fun load(): Dp {
|
||||||
|
val stored = runBlocking { settings.getFloat(WIDTH_KEY, 0f) }
|
||||||
|
return if (stored <= 0f) 360.dp else stored.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(width: Dp) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
settings.putFloat(WIDTH_KEY, width.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ fun DesktopChatsDetailNavHost(
|
|||||||
profileDetailDestinations(
|
profileDetailDestinations(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
showBackOnProfile = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +125,6 @@ fun DesktopSettingsDetailNavHost(
|
|||||||
profileDetailDestinations(
|
profileDetailDestinations(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
showBackOnProfile = false,
|
|
||||||
onOpenChat = onOpenChatFromProfile,
|
onOpenChat = onOpenChatFromProfile,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import com.pr0gramm3r101.utils.WindowWidthSizeClass
|
||||||
|
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
|
||||||
|
import com.pr0gramm3r101.utils.widthSizeClass
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty roots for desktop per-tab detail [androidx.navigation.NavHost]s.
|
* Empty roots for desktop per-tab detail [androidx.navigation.NavHost]s.
|
||||||
@@ -17,6 +21,26 @@ object DesktopDetailNav {
|
|||||||
const val CHATS_ROOT = "desktop_chats_detail_root"
|
const val CHATS_ROOT = "desktop_chats_detail_root"
|
||||||
const val SETTINGS_ROOT = "desktop_settings_detail_root"
|
const val SETTINGS_ROOT = "desktop_settings_detail_root"
|
||||||
const val CONTACTS_ROOT = "desktop_contacts_detail_root"
|
const val CONTACTS_ROOT = "desktop_contacts_detail_root"
|
||||||
|
|
||||||
|
fun isEmptyRoot(route: String?): Boolean =
|
||||||
|
route == CHATS_ROOT || route == SETTINGS_ROOT || route == CONTACTS_ROOT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a detail-pane / full-screen top bar should show a back control.
|
||||||
|
*
|
||||||
|
* Compact (full-screen stack): always — leaving the screen requires back.
|
||||||
|
* Large list–detail: only when the previous entry is a real destination, not an empty
|
||||||
|
* desktop detail root or the main `chat` shell (popping would only reveal a placeholder).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun detailPaneShowBackButton(): Boolean {
|
||||||
|
if (currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val previousRoute = LocalNavController.current.previousBackStackEntry?.destination?.route
|
||||||
|
?: return false
|
||||||
|
return previousRoute != "chat" && !DesktopDetailNav.isEmptyRoot(previousRoute)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -99,13 +99,11 @@ fun NavGraphBuilder.conversationDetailDestinations(
|
|||||||
/**
|
/**
|
||||||
* Standalone profile + edit-profile destinations.
|
* Standalone profile + edit-profile destinations.
|
||||||
*
|
*
|
||||||
* @param showBackOnProfile When false (desktop settings detail own-profile), hides the back button.
|
|
||||||
* @param onOpenChat Opens a DM; on desktop settings host this should target the Chats detail nav.
|
* @param onOpenChat Opens a DM; on desktop settings host this should target the Chats detail nav.
|
||||||
*/
|
*/
|
||||||
fun NavGraphBuilder.profileDetailDestinations(
|
fun NavGraphBuilder.profileDetailDestinations(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
sharedTransitionScope: SharedTransitionScope,
|
sharedTransitionScope: SharedTransitionScope,
|
||||||
showBackOnProfile: Boolean = true,
|
|
||||||
onOpenChat: (Int) -> Unit = { navController.navigateToDmChat(it) },
|
onOpenChat: (Int) -> Unit = { navController.navigateToDmChat(it) },
|
||||||
) {
|
) {
|
||||||
composable(
|
composable(
|
||||||
@@ -144,7 +142,7 @@ fun NavGraphBuilder.profileDetailDestinations(
|
|||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
username = profileUsername,
|
username = profileUsername,
|
||||||
showBackButton = showBackOnProfile,
|
showBackButton = detailPaneShowBackButton(),
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
onChat = onOpenChat,
|
onChat = onOpenChat,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ fun AboutScreen() {
|
|||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||||
topBar = {
|
topBar = {
|
||||||
SettingsDetailTopBar(
|
SettingsDetailTopBar(
|
||||||
title = {
|
title = {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ fun AppearanceScreen(onBack: () -> Unit) {
|
|||||||
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
|
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||||
topBar = {
|
topBar = {
|
||||||
SettingsDetailTopBar(
|
SettingsDetailTopBar(
|
||||||
title = { Text(stringResource(Res.string.settings_category_appearance)) },
|
title = { Text(stringResource(Res.string.settings_category_appearance)) },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Notifications
|
|||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -58,6 +59,7 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
|||||||
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||||
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
SettingsDetailTopBar(
|
SettingsDetailTopBar(
|
||||||
|
|||||||
+15
-16
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MediumTopAppBar
|
import androidx.compose.material3.MediumTopAppBar
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -17,7 +18,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.unit.Velocity
|
import androidx.compose.ui.unit.Velocity
|
||||||
@@ -32,24 +32,14 @@ import kotlinx.coroutines.launch
|
|||||||
import org.jetbrains.compose.resources.stringResource
|
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.LocalNavController
|
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. */
|
||||||
private const val AppBarWheelSettleDelayMs = 64L
|
private const val AppBarWheelSettleDelayMs = 64L
|
||||||
|
|
||||||
/**
|
/** @see detailPaneShowBackButton */
|
||||||
* Back is shown on compact (full-screen stack), and on large screens only when the
|
|
||||||
* previous destination is not the list–detail shell (`chat`) — i.e. when nested
|
|
||||||
* deeper than a settings detail opened as the right pane.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun settingsDetailShowBackButton(): Boolean {
|
fun settingsDetailShowBackButton(): Boolean = detailPaneShowBackButton()
|
||||||
if (currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
val previousRoute = LocalNavController.current.previousBackStackEntry?.destination?.route
|
|
||||||
return previousRoute != null && previousRoute != "chat"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun settingsDetailUseCollapsingTopBar(): Boolean =
|
fun settingsDetailUseCollapsingTopBar(): Boolean =
|
||||||
@@ -91,20 +81,29 @@ fun SettingsDetailTopBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Must match settings-detail Scaffold.containerColor and AppPanel
|
||||||
|
// (`surfaceContainerLowest`). Scaffold defaults to `background`, which is a
|
||||||
|
// different token (e.g. light Neutral98 vs Neutral100) — that was the stripe.
|
||||||
|
val paneColor = MaterialTheme.colorScheme.surfaceContainerLowest
|
||||||
if (settingsDetailUseCollapsingTopBar()) {
|
if (settingsDetailUseCollapsingTopBar()) {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = title,
|
title = title,
|
||||||
navigationIcon = navigationIcon,
|
navigationIcon = navigationIcon,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
// Transparent while expanded; default scrolledContainerColor when collapsed.
|
// Unscrolled: pane. Collapsed: default scrolledContainerColor (elevation).
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = paneColor,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// Desktop / expanded: pinned TopAppBar (not Large/Medium).
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = title,
|
title = title,
|
||||||
navigationIcon = navigationIcon,
|
navigationIcon = navigationIcon,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = paneColor,
|
||||||
|
scrolledContainerColor = paneColor,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -88,6 +89,11 @@ fun SettingsTab() {
|
|||||||
title = {
|
title = {
|
||||||
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
},
|
},
|
||||||
|
// Match AppPanel / Chats unscrolled chrome — default `surface` reads as an
|
||||||
|
// elevated (scrolled) stripe over `surfaceContainerLowest`.
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ fun AccountScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
// Match AppPanel / SettingsDetailTopBar — default `background` is a darker stripe under the bar.
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||||
topBar = {
|
topBar = {
|
||||||
SettingsDetailTopBar(
|
SettingsDetailTopBar(
|
||||||
title = { Text(stringResource(Res.string.settings_account_title)) },
|
title = { Text(stringResource(Res.string.settings_account_title)) },
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animate
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.ContextMenuArea
|
import androidx.compose.foundation.ContextMenuArea
|
||||||
import androidx.compose.foundation.ContextMenuItem
|
import androidx.compose.foundation.ContextMenuItem
|
||||||
import androidx.compose.foundation.ContextMenuRepresentation
|
import androidx.compose.foundation.ContextMenuRepresentation
|
||||||
@@ -16,12 +19,22 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.rounded.ContentCopy
|
import androidx.compose.material.icons.rounded.ContentCopy
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
@@ -80,12 +93,44 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val anchor = IntOffset(
|
||||||
|
status.rect.left.toInt(),
|
||||||
|
status.rect.bottom.toInt(),
|
||||||
|
)
|
||||||
|
val animationProgress = remember { mutableFloatStateOf(0f) }
|
||||||
|
var enterLaidOut by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(enterLaidOut) {
|
||||||
|
if (!enterLaidOut) {
|
||||||
|
animationProgress.floatValue = 0f
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
animationProgress.floatValue = 0f
|
||||||
|
animate(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
),
|
||||||
|
) { value, _ ->
|
||||||
|
animationProgress.floatValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val positionProvider = remember(anchor) {
|
||||||
|
object : PopupPositionProvider {
|
||||||
|
override fun calculatePosition(
|
||||||
|
anchorBounds: IntRect,
|
||||||
|
windowSize: IntSize,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
popupContentSize: IntSize,
|
||||||
|
): IntOffset = anchor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Popup(
|
Popup(
|
||||||
alignment = Alignment.TopStart,
|
popupPositionProvider = positionProvider,
|
||||||
offset = IntOffset(
|
|
||||||
status.rect.left.toInt(),
|
|
||||||
status.rect.bottom.toInt(),
|
|
||||||
),
|
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
state.status = FoundationContextMenuState.Status.Closed
|
state.status = FoundationContextMenuState.Status.Closed
|
||||||
},
|
},
|
||||||
@@ -96,7 +141,16 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
|
|||||||
clippingEnabled = false,
|
clippingEnabled = false,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
ChatStyleContextMenuFrame {
|
ChatStyleContextMenuFrame(
|
||||||
|
modifier = Modifier.onSizeChanged { size ->
|
||||||
|
if (size.width > 0 && size.height > 0) enterLaidOut = true
|
||||||
|
},
|
||||||
|
animated = true,
|
||||||
|
scale = 0.8f + 0.2f * animationProgress.floatValue,
|
||||||
|
alpha = animationProgress.floatValue,
|
||||||
|
transformOriginX = 0f,
|
||||||
|
transformOriginY = 0f,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ markdownRendererM3 = "0.43.0"
|
|||||||
bouncycastle = "1.85"
|
bouncycastle = "1.85"
|
||||||
webkit = "1.16.0"
|
webkit = "1.16.0"
|
||||||
openjfx = "21.0.6"
|
openjfx = "21.0.6"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
|
||||||
|
|||||||
Reference in New Issue
Block a user