Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,160 +1,249 @@
package org.jetbrains.jewel.foundation.modifier

import androidx.compose.foundation.focusGroup
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.modifier.ModifierLocalConsumer
import androidx.compose.ui.modifier.ModifierLocalProvider
import androidx.compose.ui.modifier.ModifierLocalReadScope
import androidx.compose.ui.focus.FocusEventModifierNode
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.modifier.ModifierLocalModifierNode
import androidx.compose.ui.modifier.ProvidableModifierLocal
import androidx.compose.ui.modifier.modifierLocalMapOf
import androidx.compose.ui.modifier.modifierLocalOf
import androidx.compose.ui.modifier.modifierLocalProvider
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.InspectorValueInfo
import androidx.compose.ui.platform.debugInspectorInfo
import java.awt.Component
import java.awt.Window
import java.awt.event.FocusEvent
import java.awt.event.FocusListener
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent

@Suppress("ModifierComposed") // To fix in JEWEL-921
public fun Modifier.trackWindowActivation(window: Window): Modifier =
composed(
debugInspectorInfo {
name = "activateRoot"
properties["window"] = window
/**
* Tracks the activation state of the provided AWT [Window].
*
* This modifier listens to the window's "activated" and "deactivated" events. When the window is active, it provides
* `true` to the [ModifierLocalActivated] local, allowing child modifiers (like [onActivated]) to react to the window's
* state.
*
* @param window The AWT Window to observe.
*/
@Stable
public fun Modifier.trackWindowActivation(window: Window): Modifier = this then TrackWindowActivationModifier(window)

/**
* Tracks the focus/activation state of a native AWT [Component].
*
* It listens to the component's focus events and provides the activation state to the Compose hierarchy.
*
* @param awtParent The parent AWT Component to observe for focus events.
*/
@Stable
public fun Modifier.trackComponentActivation(awtParent: Component): Modifier =
this then TrackComponentActivationModifier(awtParent)

/**
* Tracks activation based on the focus state of this modifier's children.
*
* This modifier applies a [focusGroup] to its content. It considers itself "activated" if the parent is activated AND
* any child within this focus group currently holds focus.
*/
@Stable public fun Modifier.trackActivation(): Modifier = this.focusGroup().then(TrackActivationModifier)

/**
* A callback modifier that triggers whenever the activation state changes.
*
* This modifier consumes the value provided by [ModifierLocalActivated] (set by [trackWindowActivation],
* [trackActivation] or [trackWindowActivation]). When that value changes, the [onChanged] lambda is invoked.
*
* @param enabled Whether this callback is active. If `false`, the modifier is effectively a no-op.
* @param onChanged A lambda called with the new activation state (`true` for active, `false` for inactive).
*/
public fun Modifier.onActivated(enabled: Boolean = true, onChanged: (Boolean) -> Unit): Modifier =
if (enabled) {
this then ActivateChangedModifier(onChanged)
} else {
this
}

@Immutable
private data class TrackWindowActivationModifier(val window: Window) :
ModifierNodeElement<TrackWindowActivationNode>() {
override fun create() = TrackWindowActivationNode(window)

override fun update(node: TrackWindowActivationNode) {
node.update(window)
}

override fun InspectorInfo.inspectableProperties() {
name = "trackWindowActivation"
properties["window"] = window
}
}

private class TrackWindowActivationNode(var window: Window) : Modifier.Node(), ModifierLocalModifierNode {
override val providedValues = modifierLocalMapOf(ModifierLocalActivated to false)

private val listener =
object : WindowAdapter() {
override fun windowActivated(e: WindowEvent?) {
provide(ModifierLocalActivated, true)
}

override fun windowDeactivated(e: WindowEvent?) {
provide(ModifierLocalActivated, false)
}
}
) {
var parentActivated by remember { mutableStateOf(false) }

DisposableEffect(window) {
val listener =
object : WindowAdapter() {
override fun windowActivated(e: WindowEvent?) {
parentActivated = true
}

override fun windowDeactivated(e: WindowEvent?) {
parentActivated = false
}
}

override fun onAttach() {
super.onAttach()
window.addWindowListener(listener)
provide(ModifierLocalActivated, window.isActive)
}

override fun onDetach() {
super.onDetach()
window.removeWindowListener(listener)
}

fun update(newWindow: Window) {
if (window != newWindow) {
window.removeWindowListener(listener)
window = newWindow
window.addWindowListener(listener)
onDispose { window.removeWindowListener(listener) }
provide(ModifierLocalActivated, window.isActive)
}
Modifier.modifierLocalProvider(ModifierLocalActivated) { parentActivated }
}
}

@Suppress("ModifierComposed") // To fix in JEWEL-921
public fun Modifier.trackComponentActivation(awtParent: Component): Modifier =
composed(
debugInspectorInfo {
name = "activateRoot"
properties["parent"] = awtParent
}
) {
var parentActivated by remember { mutableStateOf(false) }

DisposableEffect(awtParent) {
val listener =
object : FocusListener {
override fun focusGained(e: FocusEvent?) {
parentActivated = true
}

override fun focusLost(e: FocusEvent?) {
parentActivated = false
}
}
awtParent.addFocusListener(listener)
onDispose { awtParent.removeFocusListener(listener) }
}
@Immutable
private data object TrackActivationModifier : ModifierNodeElement<TrackActivationNode>() {
override fun create() = TrackActivationNode()

Modifier.modifierLocalProvider(ModifierLocalActivated) { parentActivated }
override fun update(node: TrackActivationNode) {
// no-op
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Detekt do be detekting

}

@Suppress("ModifierComposed") // To fix in JEWEL-921
@Stable
public fun Modifier.trackActivation(): Modifier =
composed(debugInspectorInfo { name = "trackActivation" }) {
val activatedModifierLocal = remember { ActivatedModifierLocal() }
Modifier.focusGroup()
.onFocusChanged {
if (it.hasFocus) {
activatedModifierLocal.childGainedFocus()
} else {
activatedModifierLocal.childLostFocus()
}
}
.then(activatedModifierLocal)
override fun InspectorInfo.inspectableProperties() {
name = "trackActivation"
}
}

private class ActivatedModifierLocal : ModifierLocalProvider<Boolean>, ModifierLocalConsumer {
private var parentActivated: Boolean by mutableStateOf(false)
private class TrackActivationNode :
Modifier.Node(), FocusEventModifierNode, ModifierLocalModifierNode, ObserverModifierNode {
Copy link
Collaborator Author

@DanielSouzaBertoldi DanielSouzaBertoldi Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to use both ModifierLocalModifierNode and ObserverModifierNode to provide and consume local modifiers + get notified of changes in said modifiers.

It's basically the equivalent of onModifierLocalsUpdated previously used.

You can find a sample of ObserverModifier Node here

override val providedValues = modifierLocalMapOf(ModifierLocalActivated to false)

private var hasFocus: Boolean by mutableStateOf(false)
private var parentActivated = false
private var isFocused = false

override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) {
with(scope) { parentActivated = ModifierLocalActivated.current }
override fun onAttach() {
super.onAttach()
observeReads { fetchParentActivation() }
}

override val key: ProvidableModifierLocal<Boolean> = ModifierLocalActivated
override val value: Boolean by derivedStateOf(structuralEqualityPolicy()) { parentActivated && hasFocus }
override fun onObservedReadsChanged() {
observeReads { fetchParentActivation() }
}

override fun onFocusEvent(focusState: FocusState) {
if (isFocused != focusState.isFocused) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify - are focusState.isFocused and focusState.hasFocus equivalent in this context?

My initial assumption was that a focusGroup node can never be focused itself, only its children can. This would mean focusState.isFocused is always false on TrackActivationNode, making updateProvidedValue() always compute parentActivated && false = false and silently breaking the activation chain.

To verify, I put together a minimal reproducer simulating trackWindowActivation providing true from above, with onActivated on a child layout node:

private data object SimulatedWindowActivationElement : ModifierNodeElement<SimulatedWindowActivationNode>() {
    override fun create() = SimulatedWindowActivationNode()
    override fun update(node: SimulatedWindowActivationNode) = Unit
    override fun InspectorInfo.inspectableProperties() { name = "simulatedWindowActivation" }
}

private class SimulatedWindowActivationNode : Modifier.Node(), ModifierLocalModifierNode {
    override val providedValues = modifierLocalMapOf(ModifierLocalActivated to true)
}

fun main(): Unit = application {
    Window(onCloseRequest = ::exitApplication) {
        IntUiTheme(theme = JewelTheme.lightThemeDefinition(), styling = ComponentStyling.default()) {
            var activationReceived by remember { mutableStateOf(false) }

            Column(Modifier.then(SimulatedWindowActivationElement).trackActivation()) {
                Text(
                    "Activation received: $activationReceived",
                    modifier = Modifier.onActivated { activationReceived = it },
                )
                OutlinedButton(onClick = {}) { Text("Click to focus") }
            }
        }
    }
}

I also added logging inside TrackActivationNode.onFocusEvent and updateProvidedValue:

override fun onFocusEvent(focusState: FocusState) {
    println("onFocusEvent: isFocused=${focusState.isFocused} hasFocus=${focusState.hasFocus}")
    println("node.isAttached=$isAttached")
    println("focusState=$focusState")
    println("focusState::class=${focusState::class}")
    if (isFocused != focusState.isFocused) {
        isFocused = focusState.isFocused
        updateProvidedValue()
    }
}

private fun updateProvidedValue() {
    println("updateProvidedValue: parentActivated=$parentActivated isFocused=$isFocused result=${parentActivated && isFocused}")
    provide(ModifierLocalActivated, parentActivated && isFocused)
}

Which produced the following output when clicking the button:

updateProvidedValue: parentActivated=true isFocused=false result=false
onFocusEvent: isFocused=false hasFocus=false
node.isAttached=true
focusState=Inactive
focusState::class=class androidx.compose.ui.focus.FocusStateImpl
onFocusEvent: isFocused=true hasFocus=true
node.isAttached=true
focusState=Active
focusState::class=class androidx.compose.ui.focus.FocusStateImpl
updateProvidedValue: parentActivated=true isFocused=true result=true

activationReceived correctly transitions to true, and isFocused and hasFocus move together. The TrackActivationNode receives FocusStateImpl.Active - not ActiveParent - when a child gains focus, which suggests onFocusEvent delivers the bubbled-up state from the event source rather than reflecting the layout node's own focus state in the tree hierarchy.

Is that the right understanding? If so, focusState.isFocused is correct here and the two properties are equivalent in this context.

Copy link
Collaborator

@rock3r rock3r Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tl;dr but isFocused == this node is the one owning the focus, hasFocus == this node or any of its children have the focus

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case we could use focusState.isFocused or focusState.hasFocus interchangeably 😄

Given the nature of the code (active if the parent or children have focus), hasFocus is more semantically aligned with the intent, though.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mine understanding was that the node focusGroup is attached to can't itself be focused but only it's children. That why I though focusState.isFocus would not be true, and focusState.hasFocus is the API we had to use here :)
Thanks for explaining this!
@DanielSouzaBertoldi I agree with you that usage of hasFocus is more semantically aligned and it's helps reader understand the code easier. 👍

isFocused = focusState.isFocused
updateProvidedValue()
}
}

fun childLostFocus() {
hasFocus = false
private fun fetchParentActivation() {
parentActivated = ModifierLocalActivated.current
updateProvidedValue()
}

fun childGainedFocus() {
hasFocus = true
private fun updateProvidedValue() {
provide(ModifierLocalActivated, parentActivated && isFocused)
}
}

public val ModifierLocalActivated: ProvidableModifierLocal<Boolean> = modifierLocalOf { false }
@Immutable
private data class TrackComponentActivationModifier(val awtParent: Component) :
ModifierNodeElement<TrackComponentActivationNode>() {
override fun create() = TrackComponentActivationNode(awtParent)

public fun Modifier.onActivated(enabled: Boolean = true, onChanged: (Boolean) -> Unit): Modifier =
this then
if (enabled) {
ActivateChangedModifierElement(
onChanged,
debugInspectorInfo {
name = "onActivated"
properties["onChanged"] = onChanged
},
)
} else {
Modifier
override fun update(node: TrackComponentActivationNode) {
node.update(awtParent)
}

override fun InspectorInfo.inspectableProperties() {
name = "trackComponentActivation"
properties["awtParent"] = awtParent
}
}

private class TrackComponentActivationNode(var awtParent: Component) : Modifier.Node(), ModifierLocalModifierNode {
override val providedValues = modifierLocalMapOf(ModifierLocalActivated to false)

val listener =
object : FocusListener {
override fun focusGained(e: FocusEvent?) {
provide(ModifierLocalActivated, true)
}

override fun focusLost(e: FocusEvent?) {
provide(ModifierLocalActivated, false)
}
}

private class ActivateChangedModifierElement(
private val onChanged: (Boolean) -> Unit,
inspectorInfo: InspectorInfo.() -> Unit,
) : ModifierLocalConsumer, InspectorValueInfo(inspectorInfo) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ActivateChangedModifierElement) return false
override fun onAttach() {
super.onAttach()
awtParent.addFocusListener(listener)
provide(ModifierLocalActivated, awtParent.hasFocus())
}

if (onChanged != other.onChanged) return false
override fun onDetach() {
super.onDetach()
awtParent.removeFocusListener(listener)
}

return true
fun update(newAwtParent: Component) {
if (awtParent != newAwtParent) {
awtParent.removeFocusListener(listener)
awtParent = newAwtParent
awtParent.addFocusListener(listener)
provide(ModifierLocalActivated, awtParent.hasFocus())
}
}
}

override fun hashCode(): Int = onChanged.hashCode()
@Immutable
private data class ActivateChangedModifier(val onChanged: (Boolean) -> Unit) :
ModifierNodeElement<ActivateChangedNode>() {
override fun create() = ActivateChangedNode(onChanged)

override fun update(node: ActivateChangedNode) {
node.onChanged = onChanged
}

override fun InspectorInfo.inspectableProperties() {
name = "onActivated"
properties["onChanged"] = onChanged
}
}

private class ActivateChangedNode(var onChanged: (Boolean) -> Unit) :
Modifier.Node(), ModifierLocalModifierNode, ObserverModifierNode {
private var currentActivated = false

override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) {
with(scope) {
override fun onAttach() {
super.onAttach()
fetchActivatedValue()
}

override fun onObservedReadsChanged() {
fetchActivatedValue()
}

private fun fetchActivatedValue() {
observeReads {
val activated = ModifierLocalActivated.current
if (activated != currentActivated) {
currentActivated = activated
Expand All @@ -163,3 +252,5 @@ private class ActivateChangedModifierElement(
}
}
}

public val ModifierLocalActivated: ProvidableModifierLocal<Boolean> = modifierLocalOf { false }
Loading
Loading