feat(desktop): unify macOS window chrome

This commit is contained in:
2026-07-13 08:34:44 +02:00
parent b90148f6b8
commit 4059bce3e4
6 changed files with 246 additions and 67 deletions

View File

@@ -1,6 +1,26 @@
package com.vnidrop.app package com.vnidrop.app
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.awtEventOrNull
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowScope
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.feature.send.DesktopShareBridge import com.vnidrop.app.feature.send.DesktopShareBridge
@@ -8,11 +28,13 @@ import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
import com.vnidrop.app.feature.receive.VniDropInvitationExtension import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.decodeInvitationBytes import com.vnidrop.app.feature.receive.decodeInvitationBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import java.awt.Desktop import java.awt.Desktop
import java.io.File import java.io.File
fun main(args: Array<String>) { fun main(args: Array<String>) {
val externalInvitations = ExternalInvitationController() val externalInvitations = ExternalInvitationController()
val macOs = DesktopAppearanceBridge.isMacOs()
configureMacOsNativeAppearance() configureMacOsNativeAppearance()
configureInvitationOpenHandler(externalInvitations) configureInvitationOpenHandler(externalInvitations)
args.asSequence() args.asSequence()
@@ -20,7 +42,7 @@ fun main(args: Array<String>) {
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) } .filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
.forEach { externalInvitations.openFile(it) } .forEach { externalInvitations.openFile(it) }
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) { if (macOs) {
DesktopShareBridge.shareFile = MacOsShareSheet::share DesktopShareBridge.shareFile = MacOsShareSheet::share
} }
application { application {
@@ -28,7 +50,16 @@ fun main(args: Array<String>) {
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "vnidrop", title = "vnidrop",
) { ) {
App(rememberJvmAppDependencies(externalInvitations)) App(
dependencies = rememberJvmAppDependencies(externalInvitations),
windowChromeTopInset = if (macOs) MacOsTitleBarHeight else 0.dp,
windowContentTopStartRadius = if (macOs) MacOsContentCornerRadius else 0.dp,
windowChrome = if (macOs) {
{ MacOsTitleBar() }
} else {
null
},
)
} }
} }
} }
@@ -52,8 +83,47 @@ private fun ExternalInvitationController.openFile(file: File) {
} }
private fun configureMacOsNativeAppearance() { private fun configureMacOsNativeAppearance() {
if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return if (!DesktopAppearanceBridge.isMacOs()) return
// AWT reads this before creating the first native window. Runtime theme // AWT reads this before creating the first native window. Runtime theme
// changes are handled in the JVM platform appearance hook. // changes are handled in the JVM platform appearance hook.
System.setProperty("apple.awt.application.appearance", "system") System.setProperty("apple.awt.application.appearance", "system")
} }
private val MacOsTitleBarHeight = 28.dp
private val MacOsTrafficLightsWidth = 76.dp
private val MacOsContentCornerRadius = 20.dp
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun WindowScope.MacOsTitleBar() {
val colors = LocalVniDropColors.current
Box(
modifier = Modifier
.fillMaxWidth()
.height(MacOsTitleBarHeight)
.background(colors.backgroundSurface200),
) {
WindowDraggableArea(
modifier = Modifier
.fillMaxSize()
.padding(start = MacOsTrafficLightsWidth)
.onPointerEvent(PointerEventType.Press) { event ->
if (event.awtEventOrNull?.clickCount == 2) {
DesktopAppearanceBridge.toggleMaximized(window)
}
},
) {
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
BasicText(
text = "vnidrop",
modifier = Modifier.align(Alignment.Center),
style = TextStyle(
color = colors.foregroundDefault,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
),
)
}
}
}
}

View File

@@ -1,5 +1,7 @@
package com.vnidrop.app package com.vnidrop.app
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -10,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -36,6 +39,7 @@ import com.vnidrop.app.ui.shell.ScreenScrollContainer
import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.windowClassFor import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.LocalVniDropColors
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@@ -43,7 +47,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@Composable @Composable
fun App(dependencies: AppDependencies) { fun App(
dependencies: AppDependencies,
windowChromeTopInset: Dp = 0.dp,
windowContentTopStartRadius: Dp = 0.dp,
windowChrome: (@Composable () -> Unit)? = null,
) {
val graphHolder = viewModel { AppGraphViewModel(dependencies) } val graphHolder = viewModel { AppGraphViewModel(dependencies) }
val graph = graphHolder.graph val graph = graphHolder.graph
@@ -122,7 +131,16 @@ fun App(dependencies: AppDependencies) {
val darkTheme = rememberResolvedDarkTheme(appState.themeMode) val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
PlatformSystemAppearance(darkTheme) PlatformSystemAppearance(darkTheme)
VniDropTheme(isDarkTheme = darkTheme) { VniDropTheme(isDarkTheme = darkTheme) {
BoxWithConstraints { Box(
modifier = Modifier
.fillMaxSize()
.background(LocalVniDropColors.current.backgroundSurface200),
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(top = windowChromeTopInset),
) {
val windowClass = windowClassFor(maxWidth.value) val windowClass = windowClassFor(maxWidth.value)
val showSendAction = appState.destination == AppDestination.Send && val showSendAction = appState.destination == AppDestination.Send &&
windowClass == WindowClass.Phone && windowClass == WindowClass.Phone &&
@@ -138,6 +156,7 @@ fun App(dependencies: AppDependencies) {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination, selectedDestination = appState.destination,
windowClass = windowClass, windowClass = windowClass,
mainContentTopStartRadius = windowContentTopStartRadius,
onDestinationSelected = appViewModel::selectDestination, onDestinationSelected = appViewModel::selectDestination,
overlay = { overlay = {
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
@@ -172,5 +191,7 @@ fun App(dependencies: AppDependencies) {
onRefuse = graph.approvalCoordinator::refuse, onRefuse = graph.approvalCoordinator::refuse,
) )
} }
windowChrome?.invoke()
}
} }
} }

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -36,6 +37,7 @@ import org.jetbrains.compose.resources.stringResource
fun AppSidebarNavigation( fun AppSidebarNavigation(
selected: AppDestination, selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp = 0.dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
@@ -62,7 +64,8 @@ fun AppSidebarNavigation(
} }
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.TopEnd)
.padding(top = dividerTopInset)
.width(1.dp) .width(1.dp)
.fillMaxHeight() .fillMaxHeight()
.background(colors.borderDefault), .background(colors.borderDefault),

View File

@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.navigation.AppBottomNavigation import com.vnidrop.app.ui.navigation.AppBottomNavigation
import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.navigation.AppDestination
@@ -26,6 +29,7 @@ fun AppShell(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selectedDestination: AppDestination, selectedDestination: AppDestination,
windowClass: WindowClass, windowClass: WindowClass,
mainContentTopStartRadius: Dp = 0.dp,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
floatingAction: (@Composable BoxScope.() -> Unit)? = null, floatingAction: (@Composable BoxScope.() -> Unit)? = null,
@@ -49,6 +53,7 @@ fun AppShell(
} else { } else {
WideShell( WideShell(
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
mainContentTopStartRadius = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
overlay = overlay, overlay = overlay,
floatingAction = floatingAction, floatingAction = floatingAction,
@@ -61,17 +66,38 @@ fun AppShell(
@Composable @Composable
private fun WideShell( private fun WideShell(
selectedDestination: AppDestination, selectedDestination: AppDestination,
mainContentTopStartRadius: Dp,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit, overlay: @Composable BoxScope.() -> Unit,
floatingAction: (@Composable BoxScope.() -> Unit)?, floatingAction: (@Composable BoxScope.() -> Unit)?,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Row(modifier = Modifier.fillMaxSize()) { val colors = LocalVniDropColors.current
val roundedContent = mainContentTopStartRadius > 0.dp
Row(
modifier = Modifier
.fillMaxSize()
.then(if (roundedContent) Modifier.background(colors.backgroundSurface200) else Modifier),
) {
AppSidebarNavigation( AppSidebarNavigation(
selected = selectedDestination, selected = selectedDestination,
dividerTopInset = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
) )
Box(modifier = Modifier.weight(1f).fillMaxSize()) { Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.then(
if (roundedContent) {
Modifier
.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
.background(colors.backgroundDashCanvas)
} else {
Modifier
},
),
) {
content() content()
floatingAction?.invoke(this) floatingAction?.invoke(this)
Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() } Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() }

View File

@@ -4,8 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import java.awt.Color import java.awt.Color
import java.awt.EventQueue import java.awt.EventQueue
import java.awt.Frame
import java.awt.Window import java.awt.Window
import javax.swing.JFrame import javax.swing.JFrame
import javax.swing.JRootPane
@Composable @Composable
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) { actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
@@ -16,10 +18,12 @@ actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
internal object DesktopSystemAppearance { internal object DesktopSystemAppearance {
private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance" private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance"
private const val FULL_WINDOW_CONTENT_PROPERTY = "apple.awt.fullWindowContent"
private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar" private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar"
private const val WINDOW_TITLE_VISIBLE_PROPERTY = "apple.awt.windowTitleVisible"
fun apply(isDarkTheme: Boolean) { fun apply(isDarkTheme: Boolean) {
if (!isMacOs()) return if (!DesktopAppearanceBridge.isMacOs()) return
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme)) System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
EventQueue.invokeLater { EventQueue.invokeLater {
DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme) DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme)
@@ -33,27 +37,50 @@ internal object DesktopSystemAppearance {
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
internal fun usesTransparentTitlebar(): Boolean = true internal fun usesTransparentTitlebar(): Boolean = true
internal fun usesFullWindowContent(): Boolean = true
internal fun showsNativeWindowTitle(): Boolean = false
internal fun titlebarBackground(isDarkTheme: Boolean): Color = internal fun titlebarBackground(isDarkTheme: Boolean): Color =
if (isDarkTheme) Color(0x12, 0x12, 0x12) else Color(0xF8, 0xF8, 0xF8) if (isDarkTheme) Color(0x21, 0x21, 0x21) else Color(0xF3, 0xF3, 0xF3)
private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) { private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) {
val background = titlebarBackground(isDarkTheme) val background = titlebarBackground(isDarkTheme)
window.background = background window.background = background
(window as? JFrame)?.rootPane?.let { rootPane -> (window as? JFrame)?.rootPane?.let { rootPane -> applyRootPaneChrome(rootPane, background) }
// The titlebar stays native, but AppKit receives the resolved app }
// appearance so title text and controls switch contrast at runtime.
internal fun applyRootPaneChrome(rootPane: JRootPane, background: Color) {
// Extending the Compose surface beneath the native titlebar lets the
// window chrome and sidebar share one uninterrupted background.
rootPane.putClientProperty(FULL_WINDOW_CONTENT_PROPERTY, usesFullWindowContent())
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar()) rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar())
rootPane.putClientProperty(WINDOW_TITLE_VISIBLE_PROPERTY, showsNativeWindowTitle())
rootPane.background = background rootPane.background = background
rootPane.contentPane.background = background rootPane.contentPane.background = background
} }
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
} }
object DesktopAppearanceBridge { object DesktopAppearanceBridge {
@Volatile @Volatile
var applyNativeAppearance: ((Boolean) -> Unit)? = null var applyNativeAppearance: ((Boolean) -> Unit)? = null
fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name"))
fun toggleMaximized(window: Window) {
if (!isMacOs()) return
val frame = window as? Frame ?: return
EventQueue.invokeLater {
frame.extendedState = toggledWindowState(frame.extendedState)
}
}
internal fun isMacOs(osName: String): Boolean =
osName.startsWith("Mac", ignoreCase = true)
internal fun toggledWindowState(currentState: Int): Int =
if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) {
Frame.NORMAL
} else {
Frame.MAXIMIZED_BOTH
}
} }

View File

@@ -1,9 +1,27 @@
package com.vnidrop.app.platform package com.vnidrop.app.platform
import java.awt.Color
import java.awt.Frame
import javax.swing.JRootPane
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopSystemAppearanceTest { class DesktopSystemAppearanceTest {
@Test
fun customWindowChromeIsMacOsOnly() {
assertTrue(DesktopAppearanceBridge.isMacOs("Mac OS X"))
assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11"))
assertFalse(DesktopAppearanceBridge.isMacOs("Linux"))
}
@Test
fun titlebarDoubleClickTogglesMaximizedWindowState() {
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))
assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH))
}
@Test @Test
fun macOsAppearanceNamesMatchResolvedTheme() { fun macOsAppearanceNamesMatchResolvedTheme() {
assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true)) assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true))
@@ -11,9 +29,9 @@ class DesktopSystemAppearanceTest {
} }
@Test @Test
fun titlebarBackgroundUsesLightAndDarkSurfaces() { fun titlebarBackgroundMatchesSidebarSurface() {
assertEquals(0x121212, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF) assertEquals(0x212121, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
assertEquals(0xF8F8F8, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF) assertEquals(0xF3F3F3, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
} }
@Test @Test
@@ -21,6 +39,20 @@ class DesktopSystemAppearanceTest {
assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar()) assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar())
} }
@Test
fun composeContentExtendsUnderMacOsTitlebar() {
val rootPane = JRootPane()
val background = Color(0x21, 0x21, 0x21)
DesktopSystemAppearance.applyRootPaneChrome(rootPane, background)
assertEquals(true, rootPane.getClientProperty("apple.awt.fullWindowContent"))
assertEquals(true, rootPane.getClientProperty("apple.awt.transparentTitleBar"))
assertEquals(false, rootPane.getClientProperty("apple.awt.windowTitleVisible"))
assertEquals(background, rootPane.background)
assertEquals(background, rootPane.contentPane.background)
}
@Test @Test
fun runtimeAppearanceCallIsFailSoft() { fun runtimeAppearanceCallIsFailSoft() {
DesktopSystemAppearance.apply(isDarkTheme = true) DesktopSystemAppearance.apply(isDarkTheme = true)