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 {
|
||||
bundleID = "ru.fromchat.desktop"
|
||||
iconFile.set(desktopWindowIconIcns)
|
||||
// Tray icons as NSImage templates (menu bar light/dark tint). Also set in Main.kt.
|
||||
jvmArgs("-Dapple.awt.enableTemplateImages=true")
|
||||
infoPlist {
|
||||
extraKeysRawXml = """
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -105,6 +107,7 @@ tasks.matching { it.name == "run" }.configureEach {
|
||||
}
|
||||
jvmArgs(
|
||||
dockIconArgs + listOf(
|
||||
"-Dapple.awt.enableTemplateImages=true",
|
||||
"--module-path",
|
||||
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
|
||||
"--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`):
|
||||
* - `desktop_window_width` / `desktop_window_height` (float dp)
|
||||
* - `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 {
|
||||
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
|
||||
|
||||
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.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.toComposeImageBitmap
|
||||
import androidx.compose.ui.graphics.toPainter
|
||||
@@ -34,6 +40,7 @@ import java.awt.Image
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.Taskbar
|
||||
import java.awt.desktop.AppReopenedListener
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.BufferedReader
|
||||
@@ -50,6 +57,8 @@ import javax.imageio.ImageIO
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JRootPane
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.UIManager
|
||||
import javax.swing.plaf.ColorUIResource
|
||||
import javax.swing.text.DefaultEditorKit
|
||||
import kotlin.concurrent.thread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -66,9 +75,13 @@ import ru.fromchat.Res
|
||||
import ru.fromchat.action_copy
|
||||
import ru.fromchat.action_select
|
||||
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.app_name
|
||||
import ru.fromchat.app_name_beta
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.desktop_about_app
|
||||
import ru.fromchat.desktop_cut
|
||||
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_tray_show
|
||||
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.LocalExtraStatusBarTop
|
||||
import ru.fromchat.ui.Theme
|
||||
|
||||
private object DesktopApplicationBootstrap {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -231,12 +248,19 @@ fun main(args: Array<String>) {
|
||||
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
|
||||
)
|
||||
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
|
||||
DesktopProtocolRegistration.registerBestEffort()
|
||||
// Close-to-tray keeps the process + tray; no SMAppService / Login Items registration.
|
||||
args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
|
||||
.forEach { DesktopDeepLinkBus.handleUri(it) }
|
||||
|
||||
// Avoid the AWT default white "control" flash before Compose paints (CMP #1794).
|
||||
applyDesktopWindowChromeBackground()
|
||||
|
||||
val dockIconImage = loadAppIconBufferedImage(tray = false)
|
||||
applyDockIcon(dockIconImage)
|
||||
// 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() },
|
||||
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) }
|
||||
val trayState = rememberTrayState()
|
||||
// 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 trayShow = stringResource(Res.string.desktop_tray_show)
|
||||
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) {
|
||||
delay(3_000)
|
||||
@@ -283,19 +332,54 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
fun openAbout() {
|
||||
fun showMainWindow() {
|
||||
windowVisible = true
|
||||
AppForeground.setForeground(true)
|
||||
}
|
||||
|
||||
fun openAbout() {
|
||||
showMainWindow()
|
||||
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) {
|
||||
if (!mac || !Desktop.isDesktopSupported()) return@LaunchedEffect
|
||||
if (!Desktop.isDesktopSupported()) return@LaunchedEffect
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (desktop.isSupported(Desktop.Action.APP_ABOUT)) {
|
||||
if (mac && desktop.isSupported(Desktop.Action.APP_ABOUT)) {
|
||||
desktop.setAboutHandler {
|
||||
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) {
|
||||
@@ -303,9 +387,11 @@ fun main(args: Array<String>) {
|
||||
icon = trayIcon,
|
||||
state = trayState,
|
||||
tooltip = appName,
|
||||
onAction = { windowVisible = true },
|
||||
onAction = { showMainWindow() },
|
||||
menu = {
|
||||
Item(trayShow) { windowVisible = true }
|
||||
Item(wsStatusLabel, enabled = false) {}
|
||||
Separator()
|
||||
Item(trayShow) { showMainWindow() }
|
||||
Item(aboutApp) { openAbout() }
|
||||
Separator()
|
||||
Item(quit) { exitApplication() }
|
||||
@@ -314,17 +400,10 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
|
||||
Window(
|
||||
onCloseRequest = {
|
||||
if (traySupported) {
|
||||
windowVisible = false
|
||||
AppForeground.setForeground(true)
|
||||
} else {
|
||||
exitApplication()
|
||||
}
|
||||
},
|
||||
onCloseRequest = { requestCloseToBackgroundOrQuit() },
|
||||
title = appName,
|
||||
state = windowState,
|
||||
visible = windowVisible || !traySupported,
|
||||
visible = contentReady && (windowVisible || !traySupported),
|
||||
icon = windowIcon,
|
||||
onPreviewKeyEvent = { event ->
|
||||
// macOS: MenuBar KeyShortcuts handle these. Win/Linux: no menu bar.
|
||||
@@ -333,17 +412,17 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
when {
|
||||
!event.isShiftPressed && event.key == Key.N -> {
|
||||
windowVisible = true
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
|
||||
true
|
||||
}
|
||||
!event.isShiftPressed && event.key == Key.F -> {
|
||||
windowVisible = true
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
|
||||
true
|
||||
}
|
||||
event.isShiftPressed && event.key == Key.S -> {
|
||||
windowVisible = true
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
|
||||
true
|
||||
}
|
||||
@@ -352,7 +431,7 @@ fun main(args: Array<String>) {
|
||||
true
|
||||
}
|
||||
event.isShiftPressed && event.key == Key.N -> {
|
||||
windowVisible = true
|
||||
showMainWindow()
|
||||
true
|
||||
}
|
||||
event.key == Key.M -> {
|
||||
@@ -367,8 +446,11 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
},
|
||||
) {
|
||||
LaunchedEffect(window, appName) {
|
||||
LaunchedEffect(window, appName, windowChrome) {
|
||||
window.title = appName
|
||||
val awt = windowChrome.toAwtColor()
|
||||
window.background = awt
|
||||
window.contentPane.background = awt
|
||||
enableEdgeToEdgeTitleBar(window.rootPane)
|
||||
dockIconImage?.let { image ->
|
||||
window.iconImages = listOf(image)
|
||||
@@ -383,18 +465,18 @@ fun main(args: Array<String>) {
|
||||
if (mac) {
|
||||
FromChatMenuBar(
|
||||
onNewChat = {
|
||||
windowVisible = true
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
|
||||
},
|
||||
onSearchConversations = {
|
||||
windowVisible = true
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
|
||||
},
|
||||
onSelectChats = {
|
||||
windowVisible = true
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.EnterChatListSelection)
|
||||
},
|
||||
onShow = { windowVisible = true },
|
||||
onShow = { showMainWindow() },
|
||||
onMinimize = { windowState.isMinimized = true },
|
||||
onZoom = {
|
||||
windowState.placement =
|
||||
@@ -411,7 +493,13 @@ fun main(args: Array<String>) {
|
||||
CompositionLocalProvider(
|
||||
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 =
|
||||
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) {
|
||||
if (!isMacOs()) return
|
||||
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.
|
||||
* 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
|
||||
* (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 {
|
||||
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="message_placeholder">Напишите сообщение…</string>
|
||||
<string name="status_connecting">Соединение</string>
|
||||
<string name="status_connected">Подключено</string>
|
||||
<string name="status_disconnected">Отключено</string>
|
||||
<string name="status_updating">Обновление</string>
|
||||
<string name="chat_group_label">Группа</string>
|
||||
<string name="chat_members_count">%1$d человек</string>
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
|
||||
<!-- Connection / chat chrome -->
|
||||
<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="chat_group_label">Group</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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
@@ -221,7 +220,9 @@ fun App(
|
||||
startAtProfileUserId: Int? = null,
|
||||
startAtProfileUsername: 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 ->
|
||||
ImageLoader.Builder(context)
|
||||
@@ -262,6 +263,7 @@ fun App(
|
||||
hasToken -> "chat"
|
||||
else -> "welcome"
|
||||
}
|
||||
onContentReady()
|
||||
|
||||
runCatching {
|
||||
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
|
||||
@@ -516,13 +518,6 @@ fun App(
|
||||
|
||||
ScreenSurface {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
val listPaneWidth =
|
||||
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
|
||||
448.dp
|
||||
} else {
|
||||
360.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavHost(modifier: Modifier = Modifier) {
|
||||
NavHost(
|
||||
@@ -677,7 +672,6 @@ fun App(
|
||||
LocalDesktopMainTab provides pendingMainTab,
|
||||
) {
|
||||
ConversationListDetailShell(
|
||||
listPaneWidth = listPaneWidth,
|
||||
detailInPanel = false,
|
||||
detailEdgeToEdge = detailEdgeToEdge,
|
||||
listPane = {
|
||||
|
||||
@@ -101,6 +101,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.ui.main.ConversationDetailContentPadding
|
||||
import ru.fromchat.ui.main.LocalConversationListDetailActive
|
||||
import ru.fromchat.ui.main.detailPaneShowBackButton
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.presence_recently
|
||||
import ru.fromchat.api.ApiClient
|
||||
@@ -1414,6 +1415,7 @@ fun ChatScreen(
|
||||
hazeState = hazeState,
|
||||
hazeBlurEnabled = hazeBlurEnabled,
|
||||
pillChrome = listDetailActive,
|
||||
showBackButton = detailPaneShowBackButton(),
|
||||
onBack = {
|
||||
runNav { navController.navigateUp() }
|
||||
},
|
||||
|
||||
@@ -333,6 +333,7 @@ fun ChatTopBar(
|
||||
modifier: Modifier = Modifier,
|
||||
hazeBlurEnabled: Boolean = true,
|
||||
pillChrome: Boolean = false,
|
||||
showBackButton: Boolean = true,
|
||||
) {
|
||||
if (pillChrome) {
|
||||
ChatTopBarPill(
|
||||
@@ -345,6 +346,7 @@ fun ChatTopBar(
|
||||
titleChrome = titleChrome,
|
||||
modifier = modifier,
|
||||
hazeBlurEnabled = hazeBlurEnabled,
|
||||
showBackButton = showBackButton,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -366,12 +368,14 @@ fun ChatTopBar(
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
navigationIcon = {
|
||||
if (showBackButton) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = backContentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { titleChrome() },
|
||||
actions = {
|
||||
@@ -441,6 +445,7 @@ private fun ChatTopBarPill(
|
||||
titleChrome: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
hazeBlurEnabled: Boolean = true,
|
||||
showBackButton: Boolean = true,
|
||||
) {
|
||||
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||
val progressiveStripHazeStyle = HazeMaterials.thin()
|
||||
@@ -471,6 +476,7 @@ private fun ChatTopBarPill(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (showBackButton) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
@@ -491,6 +497,7 @@ private fun ChatTopBarPill(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
|
||||
@@ -2,9 +2,14 @@ package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -37,6 +42,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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.supportsMouseMessageInteraction
|
||||
import ru.fromchat.ui.components.Text
|
||||
import com.pr0gramm3r101.utils.scaleOnPress
|
||||
|
||||
data class ContextMenuState(
|
||||
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
|
||||
fun MessageContextMenu(
|
||||
state: ContextMenuState,
|
||||
@@ -127,34 +157,38 @@ fun MessageContextMenu(
|
||||
mutableStateOf(state.isOpen && state.message != null)
|
||||
}
|
||||
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) {
|
||||
if (!state.isOpen) {
|
||||
animate(
|
||||
initialValue = 1f,
|
||||
initialValue = animationProgress.floatValue,
|
||||
targetValue = 0f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
animationSpec = contextMenuEnterSpec,
|
||||
) { value, _ ->
|
||||
animationProgress.floatValue = value
|
||||
}
|
||||
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) {
|
||||
shouldShowPopup = true
|
||||
if (!enterLaidOut) {
|
||||
animationProgress.floatValue = 0f
|
||||
return@LaunchedEffect
|
||||
}
|
||||
animationProgress.floatValue = 0f
|
||||
animate(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
animationSpec = contextMenuEnterSpec,
|
||||
) { value, _ ->
|
||||
animationProgress.floatValue = value
|
||||
}
|
||||
@@ -165,7 +199,6 @@ fun MessageContextMenu(
|
||||
val density = LocalDensity.current
|
||||
val paddingPx = with(density) { 16.dp.toPx().toInt() }
|
||||
val allowOutsideWindow = supportsMouseMessageInteraction()
|
||||
var popupSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
// Clamp with popupContentSize — do not SubcomposeLayout-measure under
|
||||
// SharedTransitionLayout (writes state during LookaheadMeasuring).
|
||||
@@ -182,60 +215,18 @@ fun MessageContextMenu(
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize,
|
||||
): IntOffset {
|
||||
if (allowOutsideWindow) return state.position
|
||||
var x = state.position.x
|
||||
var y = state.position.y
|
||||
val rightEdge = screenWidthPx - paddingPx
|
||||
val bottomEdge = screenHeightPx - paddingPx
|
||||
if (x + popupContentSize.width > rightEdge) {
|
||||
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(
|
||||
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)
|
||||
): IntOffset = clampContextMenuOffset(
|
||||
position = state.position,
|
||||
popupSize = popupContentSize,
|
||||
screenWidthPx = screenWidthPx,
|
||||
screenHeightPx = screenHeightPx,
|
||||
paddingPx = paddingPx,
|
||||
allowOutsideWindow = allowOutsideWindow,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 scale = 0.8f + 0.2f * animationProgress.floatValue
|
||||
val alpha = animationProgress.floatValue
|
||||
|
||||
// [state.position] is window coordinates (see MessageItem localToWindow).
|
||||
@@ -282,12 +273,29 @@ fun MessageContextMenu(
|
||||
onRetrySend(it)
|
||||
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,
|
||||
scale = scale,
|
||||
alpha = alpha,
|
||||
transformOriginX = transformOriginX,
|
||||
transformOriginY = transformOriginY,
|
||||
transformOriginX = frozenOrigin.pivotFractionX,
|
||||
transformOriginY = frozenOrigin.pivotFractionY,
|
||||
isReadOnly = isReadOnly,
|
||||
)
|
||||
}
|
||||
@@ -417,41 +425,39 @@ internal fun ChatStyleContextMenuFrame(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val menuShape = RoundedCornerShape(16.dp)
|
||||
val density = LocalDensity.current
|
||||
val shadowElevationPx = if (withShadow) {
|
||||
with(density) { 12.dp.toPx() }
|
||||
with(LocalDensity.current) { 12.dp.toPx() }
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val baseModifier = modifier.width(IntrinsicSize.Max)
|
||||
val containerModifier =
|
||||
// Draw shadow on an outer layer; clip/background stay on the inner box so hover
|
||||
// invalidation does not rebuild the elevated layer (avoids first-hover blink).
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.graphicsLayer {
|
||||
if (animated) {
|
||||
baseModifier.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
alpha = alpha,
|
||||
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
|
||||
shadowElevation = shadowElevationPx,
|
||||
shape = menuShape,
|
||||
clip = true,
|
||||
)
|
||||
} else {
|
||||
baseModifier.graphicsLayer(
|
||||
shadowElevation = shadowElevationPx,
|
||||
shape = menuShape,
|
||||
clip = true,
|
||||
)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
this.alpha = alpha
|
||||
transformOrigin = TransformOrigin(transformOriginX, transformOriginY)
|
||||
}
|
||||
|
||||
Box(modifier = containerModifier) {
|
||||
shadowElevation = shadowElevationPx
|
||||
shape = menuShape
|
||||
clip = false
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer, menuShape),
|
||||
.clip(menuShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
Box(modifier = Modifier.clip(menuShape)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChatStyleContextMenuItem(
|
||||
@@ -467,27 +473,40 @@ internal fun ChatStyleContextMenuItem(
|
||||
isError -> MaterialTheme.colorScheme.error
|
||||
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(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = pressScale
|
||||
scaleY = pressScale
|
||||
}
|
||||
.clip(chatStyleMenuItemShape)
|
||||
.then(
|
||||
if (enabled) {
|
||||
Modifier.scaleOnPress(
|
||||
scale = 0.96f,
|
||||
onClick = onClick,
|
||||
indication = LocalIndication.current,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
),
|
||||
)
|
||||
.background(
|
||||
if (enabled && hovered) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
} 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),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
@@ -498,7 +517,7 @@ internal fun ChatStyleContextMenuItem(
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text,
|
||||
tint = iconColor,
|
||||
tint = textColor,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
|
||||
@@ -1110,6 +1110,15 @@ fun MessageItem(
|
||||
!isCorrupted &&
|
||||
!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) {
|
||||
// Default selection colors use primary; on author
|
||||
// bubbles that matches the background, so
|
||||
@@ -1141,19 +1150,20 @@ fun MessageItem(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 14.dp,
|
||||
),
|
||||
modifier = selectableTextModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 = message.content,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor,
|
||||
modifier = Modifier.padding(horizontal = 14.dp)
|
||||
modifier = selectableTextModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+133
-7
@@ -1,8 +1,16 @@
|
||||
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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
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.ui.Alignment
|
||||
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.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.extraStatusBars
|
||||
import com.pr0gramm3r101.utils.settings.Settings as PlatformSettings
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* macOS title bar / system bars, then consumes those insets so child top bars do not double-pad
|
||||
@@ -49,20 +79,41 @@ val LocalConversationListDetailActive = staticCompositionLocalOf { false }
|
||||
*
|
||||
* Layout structure is stable regardless of [detailEdgeToEdge] / [detailInPanel] so detail
|
||||
* [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
|
||||
fun ConversationListDetailShell(
|
||||
listPaneWidth: Dp,
|
||||
listPane: @Composable () -> Unit,
|
||||
detailPane: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
detailInPanel: 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 shellBackground = MaterialTheme.colorScheme.background
|
||||
|
||||
CompositionLocalProvider(LocalConversationListDetailActive provides true) {
|
||||
Row(modifier.fillMaxSize()) {
|
||||
BoxWithConstraints(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(shellBackground),
|
||||
) {
|
||||
// List start padding (8.dp) + gap (8.dp) reserved outside the panel width.
|
||||
val maxListWidth =
|
||||
(maxWidth - 8.dp - 8.dp - 320.dp).coerceAtLeast(280.dp)
|
||||
val listWidth = storedListWidth.coerceIn(280.dp, maxListWidth)
|
||||
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
AppPanel(
|
||||
Modifier
|
||||
.windowInsetsPadding(paneInsets)
|
||||
@@ -72,17 +123,19 @@ fun ConversationListDetailShell(
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
)
|
||||
.width(listPaneWidth)
|
||||
.width(listWidth)
|
||||
.fillMaxHeight(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
) {
|
||||
listPane()
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
// 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
|
||||
@@ -97,6 +150,20 @@ fun ConversationListDetailShell(
|
||||
)
|
||||
},
|
||||
),
|
||||
) {
|
||||
val detailWidth by animateDpAsState(
|
||||
targetValue = maxWidth,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
label = "conversationDetailPaneWidth",
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.width(detailWidth.coerceAtMost(maxWidth))
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.CenterStart),
|
||||
) {
|
||||
if (detailInPanel) {
|
||||
AppPanel(
|
||||
@@ -109,6 +176,65 @@ fun ConversationListDetailShell(
|
||||
detailPane()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
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(
|
||||
navController = navController,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
showBackOnProfile = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +125,6 @@ fun DesktopSettingsDetailNavHost(
|
||||
profileDetailDestinations(
|
||||
navController = navController,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
showBackOnProfile = false,
|
||||
onOpenChat = onOpenChatFromProfile,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.zIndex
|
||||
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.
|
||||
@@ -17,6 +21,26 @@ object DesktopDetailNav {
|
||||
const val CHATS_ROOT = "desktop_chats_detail_root"
|
||||
const val SETTINGS_ROOT = "desktop_settings_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.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
fun NavGraphBuilder.profileDetailDestinations(
|
||||
navController: NavController,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
showBackOnProfile: Boolean = true,
|
||||
onOpenChat: (Int) -> Unit = { navController.navigateToDmChat(it) },
|
||||
) {
|
||||
composable(
|
||||
@@ -144,7 +142,7 @@ fun NavGraphBuilder.profileDetailDestinations(
|
||||
ProfileScreen(
|
||||
userId = userId,
|
||||
username = profileUsername,
|
||||
showBackButton = showBackOnProfile,
|
||||
showBackButton = detailPaneShowBackButton(),
|
||||
onBack = { navController.navigateUp() },
|
||||
onChat = onOpenChat,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
|
||||
@@ -60,6 +60,7 @@ fun AboutScreen() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
title = {
|
||||
|
||||
@@ -67,6 +67,7 @@ fun AppearanceScreen(onBack: () -> Unit) {
|
||||
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
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.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -58,6 +59,7 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
||||
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
|
||||
+15
-16
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -17,7 +18,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.NestedScrollSource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
@@ -32,24 +32,14 @@ import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
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. */
|
||||
private const val AppBarWheelSettleDelayMs = 64L
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** @see detailPaneShowBackButton */
|
||||
@Composable
|
||||
fun settingsDetailShowBackButton(): Boolean {
|
||||
if (currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT) {
|
||||
return true
|
||||
}
|
||||
val previousRoute = LocalNavController.current.previousBackStackEntry?.destination?.route
|
||||
return previousRoute != null && previousRoute != "chat"
|
||||
}
|
||||
fun settingsDetailShowBackButton(): Boolean = detailPaneShowBackButton()
|
||||
|
||||
@Composable
|
||||
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()) {
|
||||
MediumTopAppBar(
|
||||
title = title,
|
||||
navigationIcon = navigationIcon,
|
||||
scrollBehavior = scrollBehavior,
|
||||
// Transparent while expanded; default scrolledContainerColor when collapsed.
|
||||
// Unscrolled: pane. Collapsed: default scrolledContainerColor (elevation).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
containerColor = paneColor,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Desktop / expanded: pinned TopAppBar (not Large/Medium).
|
||||
TopAppBar(
|
||||
title = title,
|
||||
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.Scaffold
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -88,6 +89,11 @@ fun SettingsTab() {
|
||||
title = {
|
||||
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 ->
|
||||
|
||||
@@ -75,6 +75,8 @@ fun AccountScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
// Match AppPanel / SettingsDetailTopBar — default `background` is a darker stripe under the bar.
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
title = { Text(stringResource(Res.string.settings_account_title)) },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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.ContextMenuItem
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.layout.onSizeChanged
|
||||
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.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
@@ -80,12 +93,44 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
|
||||
return
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
offset = IntOffset(
|
||||
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(
|
||||
popupPositionProvider = positionProvider,
|
||||
onDismissRequest = {
|
||||
state.status = FoundationContextMenuState.Status.Closed
|
||||
},
|
||||
@@ -96,7 +141,16 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
|
||||
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(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
|
||||
@@ -48,7 +48,6 @@ markdownRendererM3 = "0.43.0"
|
||||
bouncycastle = "1.85"
|
||||
webkit = "1.16.0"
|
||||
openjfx = "21.0.6"
|
||||
|
||||
[libraries]
|
||||
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" }
|
||||
|
||||
Reference in New Issue
Block a user