diff --git a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoComposableMenu.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoComposableMenu.kt new file mode 100644 index 0000000..25650f5 --- /dev/null +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoComposableMenu.kt @@ -0,0 +1,79 @@ +package com.kdroid.composetray.demo + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import com.kdroid.composetray.tray.api.Tray +import com.kdroid.composetray.utils.SingleInstanceManager +import composenativetray.demo.generated.resources.Res +import composenativetray.demo.generated.resources.icon +import composenativetray.demo.generated.resources.icon2 +import org.jetbrains.compose.resources.painterResource + +/** + * Showcases the @Composable menu DSL. No need to hoist `painterResource(...)` above + * `application { … }` — every menu/submenu lambda is composable. + */ +fun main() = application { + var isWindowVisible by remember { mutableStateOf(true) } + var enableHeavyMode by remember { mutableStateOf(false) } + + val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = { + isWindowVisible = true + }) + if (!isSingleInstance) { + exitApplication() + return@application + } + + Tray( + icon = painterResource(Res.drawable.icon), + tooltip = "Composable Menu Demo", + primaryAction = { isWindowVisible = true }, + menuContent = { + Item(label = "Open", icon = painterResource(Res.drawable.icon)) { + isWindowVisible = true + } + + SubMenu( + label = "Advanced", + icon = painterResource(Res.drawable.icon2), + ) { + Item(label = "Reload", icon = painterResource(Res.drawable.icon)) { + println("Reload") + } + Item(label = "Reset", icon = painterResource(Res.drawable.icon2)) { + println("Reset") + } + } + + Divider() + + CheckableItem( + label = "Heavy mode", + icon = painterResource(Res.drawable.icon2), + checked = enableHeavyMode, + onCheckedChange = { enableHeavyMode = it }, + ) + + Divider() + + Item(label = "Exit", icon = painterResource(Res.drawable.icon2)) { + dispose() + exitApplication() + } + }, + ) + + Window( + onCloseRequest = { isWindowVisible = false }, + title = "Composable Menu Demo", + visible = isWindowVisible, + icon = painterResource(Res.drawable.icon), + ) { + window.toFront() + } +} diff --git a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/PainterResourceWorkaroundDemo.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/PainterResourceWorkaroundDemo.kt deleted file mode 100644 index f243951..0000000 --- a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/PainterResourceWorkaroundDemo.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.kdroid.composetray.demo - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import com.kdroid.composetray.tray.api.Tray -import com.kdroid.composetray.utils.ComposeNativeTrayLoggingLevel -import com.kdroid.composetray.utils.SingleInstanceManager -import com.kdroid.composetray.utils.allowComposeNativeTrayLogging -import com.kdroid.composetray.utils.composeNativeTrayLoggingLevel -import composenativetray.demo.generated.resources.Res -import composenativetray.demo.generated.resources.icon -import composenativetray.demo.generated.resources.icon2 -import org.jetbrains.compose.resources.painterResource - -/** - * Demo application that showcases the workaround for using painterResource with menu items. - * This demo demonstrates how to properly use painterResource with menu items by declaring - * the icon variables in the composable context. - */ -fun main() = application { - allowComposeNativeTrayLogging = true - composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG - - val logTag = "PainterResourceWorkaroundDemo" - - var isWindowVisible by remember { mutableStateOf(true) } - var alwaysShowTray by remember { mutableStateOf(true) } - var hideOnClose by remember { mutableStateOf(true) } - - // Declare icon variables in the composable context - val mainIcon = painterResource(Res.drawable.icon) - val settingsIcon = painterResource(Res.drawable.icon2) - - val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = { - isWindowVisible = true - }) - - if (!isSingleInstance) { - exitApplication() - return@application - } - - val showTray = alwaysShowTray || !isWindowVisible - - if (showTray) { - Tray( - icon = mainIcon, // Using the cached painter - tooltip = "Painter Resource Workaround Demo", - primaryAction = { - isWindowVisible = true - println("$logTag: Primary action clicked") - } - ) { - // Using the cached painter in a menu item - SubMenu( - label = "Settings", - icon = settingsIcon // Works correctly because we're using a variable - ) { - Item(label = "Option 1") { - println("$logTag: Option 1 selected") - } - - Item(label = "Option 2") { - println("$logTag: Option 2 selected") - } - } - - Divider() - - CheckableItem( - label = "Always show tray", - checked = alwaysShowTray, - onCheckedChange = { checked -> - alwaysShowTray = checked - println("$logTag: Always show tray ${if (checked) "enabled" else "disabled"}") - } - ) - - CheckableItem( - label = "Hide on close", - checked = hideOnClose, - onCheckedChange = { checked -> - hideOnClose = checked - println("$logTag: Hide on close ${if (checked) "enabled" else "disabled"}") - } - ) - - Divider() - - Item(label = "Exit") { - println("$logTag: Exiting application") - dispose() - exitApplication() - } - } - } - - Window( - onCloseRequest = { - if (hideOnClose) { - isWindowVisible = false - } else { - exitApplication() - } - }, - title = "Painter Resource Workaround Demo", - visible = isWindowVisible, - icon = mainIcon // Using the cached painter for the window icon - ) { - window.toFront() - App( - textVisible = true, - alwaysShowTray = alwaysShowTray, - hideOnClose = hideOnClose - ) { alwaysShow, hideOnCloseState -> - alwaysShowTray = alwaysShow - hideOnClose = hideOnCloseState - } - } -} \ No newline at end of file diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/menu/api/ComposableTrayMenuScope.kt b/src/jvmMain/kotlin/com/kdroid/composetray/menu/api/ComposableTrayMenuScope.kt new file mode 100644 index 0000000..e0cf6ea --- /dev/null +++ b/src/jvmMain/kotlin/com/kdroid/composetray/menu/api/ComposableTrayMenuScope.kt @@ -0,0 +1,173 @@ +package com.kdroid.composetray.menu.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import com.kdroid.composetray.utils.IconRenderProperties +import org.jetbrains.compose.resources.DrawableResource + +/** + * Composable-aware DSL for declaring tray menus. + * + * Unlike [TrayMenuBuilder] — whose submenu lambdas are plain Kotlin lambdas executed by the + * native impls outside of any composition — every method here is `@Composable`. You can call + * `painterResource(...)`, read state, etc., directly inside menu and submenu bodies without + * hoisting: + * + * ``` + * Tray(icon = Res.drawable.icon, tooltip = "App") { + * SubMenu(label = "Advanced", icon = painterResource(Res.drawable.advanced)) { + * Item(label = "Reload", icon = painterResource(Res.drawable.reload)) { ... } + * } + * } + * ``` + * + * Internally, calls are recorded during composition and replayed into the [TrayMenuBuilder] + * consumed by the native menu impls. Recomposition triggered by state reads inside the lambda + * transparently rebuilds the menu. + */ +interface ComposableTrayMenuScope { + @Composable + fun Item( + label: String, + isEnabled: Boolean = true, + shortcut: KeyShortcut? = null, + onClick: () -> Unit = {}, + ) + + @Composable + fun Item( + label: String, + icon: Painter, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + shortcut: KeyShortcut? = null, + onClick: () -> Unit = {}, + ) + + @Composable + fun Item( + label: String, + icon: ImageVector, + iconTint: Color? = null, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + shortcut: KeyShortcut? = null, + onClick: () -> Unit = {}, + ) + + @Composable + fun CheckableItem( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean = true, + shortcut: KeyShortcut? = null, + ) + + @Composable + fun CheckableItem( + label: String, + icon: Painter, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + shortcut: KeyShortcut? = null, + ) + + @Composable + fun CheckableItem( + label: String, + icon: ImageVector, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconTint: Color? = null, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + shortcut: KeyShortcut? = null, + ) + + @Composable + fun SubMenu( + label: String, + isEnabled: Boolean = true, + submenuContent: @Composable ComposableTrayMenuScope.() -> Unit, + ) + + @Composable + fun SubMenu( + label: String, + icon: Painter, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + submenuContent: @Composable ComposableTrayMenuScope.() -> Unit, + ) + + @Composable + fun SubMenu( + label: String, + icon: ImageVector, + iconTint: Color? = null, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + submenuContent: @Composable ComposableTrayMenuScope.() -> Unit, + ) + + @Composable + fun Item( + label: String, + icon: DrawableResource, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + shortcut: KeyShortcut? = null, + onClick: () -> Unit = {}, + ) + + @Composable + fun CheckableItem( + label: String, + icon: DrawableResource, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + shortcut: KeyShortcut? = null, + ) + + @Composable + fun SubMenu( + label: String, + icon: DrawableResource, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forMenuItem(), + isEnabled: Boolean = true, + submenuContent: @Composable ComposableTrayMenuScope.() -> Unit, + ) + + @Composable + fun Divider() + + /** + * Backward-compatibility shim. Under the composable DSL, the tray lifecycle is owned by the + * surrounding `DisposableEffect` in [Tray], so this is a no-op. Existing menus that call + * `dispose()` before `exitApplication()` keep compiling without behavior change. + */ + fun dispose() { + // no-op: lifecycle managed by Tray composable + } + + @Deprecated( + message = "Use CheckableItem with separate checked and onCheckedChange parameters", + replaceWith = ReplaceWith("CheckableItem(label, checked, onToggle, isEnabled)"), + ) + @Composable + fun CheckableItem( + label: String, + checked: Boolean = false, + isEnabled: Boolean = true, + onToggle: (Boolean) -> Unit, + ) { + CheckableItem(label, checked, onToggle, isEnabled, null) + } +} diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/menu/impl/RecordingComposableScope.kt b/src/jvmMain/kotlin/com/kdroid/composetray/menu/impl/RecordingComposableScope.kt new file mode 100644 index 0000000..e5b92f1 --- /dev/null +++ b/src/jvmMain/kotlin/com/kdroid/composetray/menu/impl/RecordingComposableScope.kt @@ -0,0 +1,394 @@ +package com.kdroid.composetray.menu.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import com.kdroid.composetray.menu.api.ComposableTrayMenuScope +import com.kdroid.composetray.menu.api.KeyShortcut +import com.kdroid.composetray.menu.api.TrayMenuBuilder +import com.kdroid.composetray.utils.IconRenderProperties +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource + +/** + * Records calls made by a user-supplied `@Composable ComposableTrayMenuScope.() -> Unit` lambda + * during composition into a flat list of [MenuOp]s. Once composition finishes, [buildReplay] can + * produce a non-composable `TrayMenuBuilder.() -> Unit` that emits the recorded operations into + * any [TrayMenuBuilder] — including those constructed by the native (Windows/Linux/Mac/Awt) + * tray impls. + * + * This is the bridge that lets users freely call `painterResource(...)` and other composable + * functions inside the tray DSL without hoisting them above `application { … }`. + */ +internal class RecordingComposableScope : ComposableTrayMenuScope { + private val ops = mutableListOf() + + fun snapshot(): List = ops.toList() + + @Composable + override fun Item( + label: String, + isEnabled: Boolean, + shortcut: KeyShortcut?, + onClick: () -> Unit, + ) { + ops += MenuOp.PlainItem(label, isEnabled, shortcut, onClick) + } + + @Composable + override fun Item( + label: String, + icon: Painter, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + shortcut: KeyShortcut?, + onClick: () -> Unit, + ) { + ops += MenuOp.PainterItem(label, icon, iconRenderProperties, isEnabled, shortcut, onClick) + } + + @Composable + override fun Item( + label: String, + icon: ImageVector, + iconTint: Color?, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + shortcut: KeyShortcut?, + onClick: () -> Unit, + ) { + ops += MenuOp.VectorItem(label, icon, iconTint, iconRenderProperties, isEnabled, shortcut, onClick) + } + + @Composable + override fun CheckableItem( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean, + shortcut: KeyShortcut?, + ) { + ops += MenuOp.PlainCheckable(label, checked, onCheckedChange, isEnabled, shortcut) + } + + @Composable + override fun CheckableItem( + label: String, + icon: Painter, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + shortcut: KeyShortcut?, + ) { + ops += MenuOp.PainterCheckable(label, icon, iconRenderProperties, checked, onCheckedChange, isEnabled, shortcut) + } + + @Composable + override fun CheckableItem( + label: String, + icon: ImageVector, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconTint: Color?, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + shortcut: KeyShortcut?, + ) { + ops += + MenuOp.VectorCheckable( + label, icon, iconTint, iconRenderProperties, checked, onCheckedChange, isEnabled, shortcut, + ) + } + + @Composable + override fun SubMenu( + label: String, + isEnabled: Boolean, + submenuContent: @Composable ComposableTrayMenuScope.() -> Unit, + ) { + val child = RecordingComposableScope() + child.submenuContent() + ops += MenuOp.PlainSubMenu(label, isEnabled, child.snapshot()) + } + + @Composable + override fun SubMenu( + label: String, + icon: Painter, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + submenuContent: @Composable ComposableTrayMenuScope.() -> Unit, + ) { + val child = RecordingComposableScope() + child.submenuContent() + ops += MenuOp.PainterSubMenu(label, icon, iconRenderProperties, isEnabled, child.snapshot()) + } + + @Composable + override fun SubMenu( + label: String, + icon: ImageVector, + iconTint: Color?, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + submenuContent: @Composable ComposableTrayMenuScope.() -> Unit, + ) { + val child = RecordingComposableScope() + child.submenuContent() + ops += MenuOp.VectorSubMenu(label, icon, iconTint, iconRenderProperties, isEnabled, child.snapshot()) + } + + @Composable + override fun Item( + label: String, + icon: DrawableResource, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + shortcut: KeyShortcut?, + onClick: () -> Unit, + ) { + // Resolve the resource during composition so the recorded op holds a plain Painter. + Item(label, painterResource(icon), iconRenderProperties, isEnabled, shortcut, onClick) + } + + @Composable + override fun CheckableItem( + label: String, + icon: DrawableResource, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + shortcut: KeyShortcut?, + ) { + CheckableItem( + label, + painterResource(icon), + checked, + onCheckedChange, + iconRenderProperties, + isEnabled, + shortcut, + ) + } + + @Composable + override fun SubMenu( + label: String, + icon: DrawableResource, + iconRenderProperties: IconRenderProperties, + isEnabled: Boolean, + submenuContent: @Composable ComposableTrayMenuScope.() -> Unit, + ) { + SubMenu(label, painterResource(icon), iconRenderProperties, isEnabled, submenuContent) + } + + @Composable + override fun Divider() { + ops += MenuOp.Divider + } +} + +/** + * Recorded menu operation. Captures everything needed to later emit the same call against any + * [TrayMenuBuilder] instance. + */ +internal sealed interface MenuOp { + data class PlainItem( + val label: String, + val isEnabled: Boolean, + val shortcut: KeyShortcut?, + val onClick: () -> Unit, + ) : MenuOp + + data class PainterItem( + val label: String, + val icon: Painter, + val iconRenderProperties: IconRenderProperties, + val isEnabled: Boolean, + val shortcut: KeyShortcut?, + val onClick: () -> Unit, + ) : MenuOp + + data class VectorItem( + val label: String, + val icon: ImageVector, + val iconTint: Color?, + val iconRenderProperties: IconRenderProperties, + val isEnabled: Boolean, + val shortcut: KeyShortcut?, + val onClick: () -> Unit, + ) : MenuOp + + data class PlainCheckable( + val label: String, + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit, + val isEnabled: Boolean, + val shortcut: KeyShortcut?, + ) : MenuOp + + data class PainterCheckable( + val label: String, + val icon: Painter, + val iconRenderProperties: IconRenderProperties, + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit, + val isEnabled: Boolean, + val shortcut: KeyShortcut?, + ) : MenuOp + + data class VectorCheckable( + val label: String, + val icon: ImageVector, + val iconTint: Color?, + val iconRenderProperties: IconRenderProperties, + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit, + val isEnabled: Boolean, + val shortcut: KeyShortcut?, + ) : MenuOp + + data class PlainSubMenu( + val label: String, + val isEnabled: Boolean, + val children: List, + ) : MenuOp + + data class PainterSubMenu( + val label: String, + val icon: Painter, + val iconRenderProperties: IconRenderProperties, + val isEnabled: Boolean, + val children: List, + ) : MenuOp + + data class VectorSubMenu( + val label: String, + val icon: ImageVector, + val iconTint: Color?, + val iconRenderProperties: IconRenderProperties, + val isEnabled: Boolean, + val children: List, + ) : MenuOp + + data object Divider : MenuOp +} + +/** + * Replays a recorded list of [MenuOp]s into a [TrayMenuBuilder]. The resulting lambda is plain + * Kotlin (non-composable), suitable for the native menu impls. + */ +internal fun List.asTrayMenuBuilderBlock(): TrayMenuBuilder.() -> Unit = + { + replay(this@asTrayMenuBuilderBlock) + } + +private fun TrayMenuBuilder.replay(ops: List) { + for (op in ops) { + when (op) { + is MenuOp.PlainItem -> Item(op.label, op.isEnabled, op.shortcut, op.onClick) + is MenuOp.PainterItem -> + Item(op.label, op.icon, op.iconRenderProperties, op.isEnabled, op.shortcut, op.onClick) + is MenuOp.VectorItem -> + Item(op.label, op.icon, op.iconTint, op.iconRenderProperties, op.isEnabled, op.shortcut, op.onClick) + is MenuOp.PlainCheckable -> + CheckableItem(op.label, op.checked, op.onCheckedChange, op.isEnabled, op.shortcut) + is MenuOp.PainterCheckable -> + CheckableItem( + op.label, + op.icon, + op.iconRenderProperties, + op.checked, + op.onCheckedChange, + op.isEnabled, + op.shortcut, + ) + is MenuOp.VectorCheckable -> + CheckableItem( + op.label, + op.icon, + op.iconTint, + op.iconRenderProperties, + op.checked, + op.onCheckedChange, + op.isEnabled, + op.shortcut, + ) + is MenuOp.PlainSubMenu -> + SubMenu(op.label, op.isEnabled) { replay(op.children) } + is MenuOp.PainterSubMenu -> + SubMenu(op.label, op.icon, op.iconRenderProperties, op.isEnabled) { replay(op.children) } + is MenuOp.VectorSubMenu -> + SubMenu(op.label, op.icon, op.iconTint, op.iconRenderProperties, op.isEnabled) { replay(op.children) } + MenuOp.Divider -> Divider() + } + } +} + +/** + * Computes a stable structural fingerprint of the recorded operations. Used to feed + * `LaunchedEffect` keys so that menu changes trigger a native menu rebuild while irrelevant + * recompositions don't. + */ +internal fun List.structuralFingerprint(): String { + val sb = StringBuilder() + fingerprint(this, sb) + return sb.toString() +} + +private fun fingerprint( + ops: List, + sb: StringBuilder, +) { + for (op in ops) { + when (op) { + is MenuOp.PlainItem -> + sb.append("I:").append(op.label).append(':').append(op.isEnabled) + .append(':').append(op.shortcut) + is MenuOp.PainterItem -> + sb.append("PI:").append(op.label).append(':').append(op.isEnabled) + .append(':').append(op.icon.hashCode()).append(':').append(op.shortcut) + is MenuOp.VectorItem -> + sb.append("VI:").append(op.label).append(':').append(op.isEnabled) + .append( + ':', + ).append(op.icon.hashCode()).append(':').append(op.iconTint).append(':').append(op.shortcut) + is MenuOp.PlainCheckable -> + sb.append("C:").append(op.label).append(':').append(op.checked) + .append(':').append(op.isEnabled).append(':').append(op.shortcut) + is MenuOp.PainterCheckable -> + sb.append("PC:").append(op.label).append(':').append(op.checked) + .append( + ':', + ).append(op.isEnabled).append(':').append(op.icon.hashCode()).append(':').append(op.shortcut) + is MenuOp.VectorCheckable -> + sb.append("VC:").append(op.label).append(':').append(op.checked) + .append( + ':', + ).append(op.isEnabled).append(':').append(op.icon.hashCode()).append(':').append(op.iconTint) + .append(':').append(op.shortcut) + is MenuOp.PlainSubMenu -> { + sb.append("S:").append(op.label).append(':').append(op.isEnabled).append('{') + fingerprint(op.children, sb) + sb.append('}') + } + is MenuOp.PainterSubMenu -> { + sb.append("PS:").append(op.label).append(':').append(op.isEnabled) + .append(':').append(op.icon.hashCode()).append('{') + fingerprint(op.children, sb) + sb.append('}') + } + is MenuOp.VectorSubMenu -> { + sb.append("VS:").append(op.label).append(':').append(op.isEnabled) + .append(':').append(op.icon.hashCode()).append(':').append(op.iconTint).append('{') + fingerprint(op.children, sb) + sb.append('}') + } + MenuOp.Divider -> sb.append("D") + } + sb.append('|') + } +} diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/ComposableTray.kt b/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/ComposableTray.kt new file mode 100644 index 0000000..996689c --- /dev/null +++ b/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/ComposableTray.kt @@ -0,0 +1,175 @@ +package com.kdroid.composetray.tray.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.window.ApplicationScope +import com.kdroid.composetray.menu.api.ComposableTrayMenuScope +import com.kdroid.composetray.menu.api.TrayMenuBuilder +import com.kdroid.composetray.menu.impl.RecordingComposableScope +import com.kdroid.composetray.menu.impl.asTrayMenuBuilderBlock +import com.kdroid.composetray.menu.impl.structuralFingerprint +import com.kdroid.composetray.utils.IconRenderProperties +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource + +/** + * Records the user's `@Composable` menu lambda into a flat [TrayMenuBuilder] block, stable across + * recompositions when the recorded structure doesn't change. Returns `null` when [menuContent] is + * `null`, so callers can pass the result directly into the existing non-composable `Tray(…)` + * overloads. + */ +@Composable +private fun rememberRecordedMenuContent( + menuContent: (@Composable ComposableTrayMenuScope.() -> Unit)?, +): (TrayMenuBuilder.() -> Unit)? { + if (menuContent == null) return null + val scope = RecordingComposableScope() + scope.menuContent() + val ops = scope.snapshot() + val fingerprint = ops.structuralFingerprint() + // Stabilize the replay lambda so the underlying Tray's LaunchedEffect key only changes when + // the recorded menu structure actually changes. + return remember(fingerprint) { ops.asTrayMenuBuilderBlock() } +} + +/** + * Composable-DSL variant of [Tray]. The [menuContent] lambda runs inside the composition, so + * `painterResource(...)`, state reads and other composable calls work directly — no need to + * hoist a `val icon = painterResource(...)` above `application { … }`. + */ +@Composable +fun ApplicationScope.Tray( + icon: Painter, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), + tooltip: String, + primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, + menuContent: (@Composable ComposableTrayMenuScope.() -> Unit)?, +) { + val recorded = rememberRecordedMenuContent(menuContent) + Tray( + icon = icon, + iconRenderProperties = iconRenderProperties, + tooltip = tooltip, + primaryAction = primaryAction, + onMenuOpened = onMenuOpened, + menuContent = recorded, + ) +} + +@Composable +fun ApplicationScope.Tray( + icon: ImageVector, + tint: Color? = null, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), + tooltip: String, + primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, + menuContent: (@Composable ComposableTrayMenuScope.() -> Unit)?, +) { + val recorded = rememberRecordedMenuContent(menuContent) + Tray( + icon = icon, + tint = tint, + iconRenderProperties = iconRenderProperties, + tooltip = tooltip, + primaryAction = primaryAction, + onMenuOpened = onMenuOpened, + menuContent = recorded, + ) +} + +@Composable +fun ApplicationScope.Tray( + icon: DrawableResource, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), + tooltip: String, + primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, + menuContent: (@Composable ComposableTrayMenuScope.() -> Unit)?, +) { + val painter = painterResource(icon) + val recorded = rememberRecordedMenuContent(menuContent) + Tray( + icon = painter, + iconRenderProperties = iconRenderProperties, + tooltip = tooltip, + primaryAction = primaryAction, + onMenuOpened = onMenuOpened, + menuContent = recorded, + ) +} + +@Composable +fun ApplicationScope.Tray( + iconContent: @Composable () -> Unit, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), + tooltip: String, + primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, + menuContent: (@Composable ComposableTrayMenuScope.() -> Unit)?, +) { + val recorded = rememberRecordedMenuContent(menuContent) + Tray( + iconContent = iconContent, + iconRenderProperties = iconRenderProperties, + tooltip = tooltip, + primaryAction = primaryAction, + onMenuOpened = onMenuOpened, + menuContent = recorded, + ) +} + +/** + * Polymorphic helper: [Painter] icon on Windows, [ImageVector] on macOS/Linux, composable menu. + */ +@Composable +fun ApplicationScope.Tray( + windowsIcon: Painter, + macLinuxIcon: ImageVector, + tint: Color? = null, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), + tooltip: String, + primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, + menuContent: (@Composable ComposableTrayMenuScope.() -> Unit)?, +) { + val recorded = rememberRecordedMenuContent(menuContent) + Tray( + windowsIcon = windowsIcon, + macLinuxIcon = macLinuxIcon, + tint = tint, + iconRenderProperties = iconRenderProperties, + tooltip = tooltip, + primaryAction = primaryAction, + onMenuOpened = onMenuOpened, + menuContent = recorded, + ) +} + +@Composable +fun ApplicationScope.Tray( + windowsIcon: DrawableResource, + macLinuxIcon: ImageVector, + tint: Color? = null, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), + tooltip: String, + primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, + menuContent: (@Composable ComposableTrayMenuScope.() -> Unit)?, +) { + val recorded = rememberRecordedMenuContent(menuContent) + Tray( + windowsIcon = windowsIcon, + macLinuxIcon = macLinuxIcon, + tint = tint, + iconRenderProperties = iconRenderProperties, + tooltip = tooltip, + primaryAction = primaryAction, + onMenuOpened = onMenuOpened, + menuContent = recorded, + ) +} diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt b/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt index 62d7459..45cfae4 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt @@ -1,3 +1,5 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + package com.kdroid.composetray.tray.api import androidx.compose.foundation.Image @@ -37,6 +39,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import java.util.concurrent.atomic.AtomicBoolean +import kotlin.internal.LowPriorityInOverloadResolution internal class NativeTray { private val trayScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -405,6 +408,7 @@ fun ApplicationScope.Tray( } @Composable +@LowPriorityInOverloadResolution fun ApplicationScope.Tray( iconContent: @Composable () -> Unit, iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), @@ -446,6 +450,7 @@ fun ApplicationScope.Tray( } @Composable +@LowPriorityInOverloadResolution fun ApplicationScope.Tray( icon: ImageVector, tint: Color? = null, @@ -541,6 +546,7 @@ fun ApplicationScope.Tray( } @Composable +@LowPriorityInOverloadResolution fun ApplicationScope.Tray( icon: Painter, iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), @@ -596,6 +602,7 @@ fun ApplicationScope.Tray( * Platform-polymorphic helper */ @Composable +@LowPriorityInOverloadResolution fun ApplicationScope.Tray( windowsIcon: Painter, macLinuxIcon: ImageVector, @@ -636,6 +643,7 @@ fun ApplicationScope.Tray( * DrawableResource helpers */ @Composable +@LowPriorityInOverloadResolution fun ApplicationScope.Tray( icon: DrawableResource, iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), @@ -657,6 +665,7 @@ fun ApplicationScope.Tray( } @Composable +@LowPriorityInOverloadResolution fun ApplicationScope.Tray( windowsIcon: DrawableResource, macLinuxIcon: ImageVector,