WebSocket status in tray, more UI fixes

This commit is contained in:
2026-07-27 01:54:11 +03:00
Unverified
parent 5166f3507d
commit 846c1db3d1
28 changed files with 638 additions and 242 deletions
+3
View File
@@ -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)
*
* Listdetail 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,12 +368,14 @@ fun ChatTopBar(
titleContentColor = MaterialTheme.colorScheme.onSurface, titleContentColor = MaterialTheme.colorScheme.onSurface,
), ),
navigationIcon = { navigationIcon = {
if (showBackButton) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = backContentDescription contentDescription = backContentDescription
) )
} }
}
}, },
title = { titleChrome() }, title = { titleChrome() },
actions = { actions = {
@@ -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,6 +476,7 @@ private fun ChatTopBarPill(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (showBackButton) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
@@ -491,6 +497,7 @@ private fun ChatTopBarPill(
) )
} }
} }
}
Row( Row(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -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(
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) { val scale = 0.8f + 0.2f * animationProgress.floatValue
((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,40 +425,38 @@ 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).
Box(
modifier = modifier
.width(IntrinsicSize.Max)
.graphicsLayer {
if (animated) { if (animated) {
baseModifier.graphicsLayer( scaleX = scale
scaleX = scale, scaleY = scale
scaleY = scale, this.alpha = alpha
alpha = alpha, transformOrigin = TransformOrigin(transformOriginX, transformOriginY)
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
shadowElevation = shadowElevationPx,
shape = menuShape,
clip = true,
)
} else {
baseModifier.graphicsLayer(
shadowElevation = shadowElevationPx,
shape = menuShape,
clip = true,
)
} }
shadowElevation = shadowElevationPx
Box(modifier = containerModifier) { shape = menuShape
clip = false
},
) {
Box( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
.background(MaterialTheme.colorScheme.surfaceContainer, menuShape), .clip(menuShape)
.background(MaterialTheme.colorScheme.surfaceContainer),
) )
Box(modifier = Modifier.clip(menuShape)) {
content() content()
} }
}
} }
@Composable @Composable
@@ -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,
) )
} }
} }
@@ -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 listdetail * Horizontal inset for chat detail content (messages, top-bar pills, input) in listdetail
@@ -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,20 +79,41 @@ 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(
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( AppPanel(
Modifier Modifier
.windowInsetsPadding(paneInsets) .windowInsetsPadding(paneInsets)
@@ -72,17 +123,19 @@ fun ConversationListDetailShell(
top = 8.dp, top = 8.dp,
bottom = 8.dp, bottom = 8.dp,
) )
.width(listPaneWidth) .width(listWidth)
.fillMaxHeight(), .fillMaxHeight(),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
) { ) {
listPane() listPane()
} }
Spacer(Modifier.width(8.dp)) // Detail fills the remainder and draws under the leading gap so chrome blur
Box( // can extend into it when the gap strip is transparent.
BoxWithConstraints(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.background(shellBackground)
.then( .then(
if (detailEdgeToEdge) { if (detailEdgeToEdge) {
Modifier 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) { if (detailInPanel) {
AppPanel( AppPanel(
@@ -109,6 +176,65 @@ fun ConversationListDetailShell(
detailPane() 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 listdetail 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 listdetail: 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(
@@ -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 listdetail 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
} }
Popup( val anchor = IntOffset(
alignment = Alignment.TopStart,
offset = IntOffset(
status.rect.left.toInt(), status.rect.left.toInt(),
status.rect.bottom.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 = { 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)
-1
View File
@@ -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" }