Changed()` 를 호출한다. 코루틴 재구독이나 effect 재시작에 쓴다 |
+
+`InvalidationScope` 는 `Measure`, `Placement`, `Draw`, `Semantics`, `ParentData`, `None`.
+생성된 `update()` 는 계층을 접는다 — `Measure > Placement > Draw` 중 가장 상위 하나만 호출된다.
+
+### `shouldAutoInvalidate = false`
+
+Compose 는 `update()` 직후 노드의 모든 capability 를 자동 무효화한다. 이 override 가 없으면
+`@Invalidates` 의 파라미터 단위 제어가 no-op 이 되므로, 프로세서가 선언을 강제한다:
+
+```kotlin
+override val shouldAutoInvalidate: Boolean get() = false
+```
+
+지원되는 문서화된 API 이며, Compose 자체 `graphicsLayer` / `paint` 모디파이어가 같은 패턴을 쓴다.
+
+### 한계
+
+- 생성 함수에 default argument 를 재현하지 못한다 (KSP 가 기본값 표현식을 못 읽는다) — 호출부에서 전 인자를 넘겨야 한다.
+- 지원하는 노드 인터페이스는 `DrawModifierNode` 와 `LayoutModifierNode` 뿐이다.
+- 제네릭 Node 와 다중 타입 파라미터는 검증되지 않았다.
+- `equals` 는 파라미터 타입의 `equals` 안정성에 의존한다. 함수 타입은 경고하지만 그 밖의 불안정 타입은 사용자 책임이다.
+
+---
+
## 📄 라이센스
```
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 81f98b0..1e01916 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.ksp)
}
android {
@@ -56,12 +57,18 @@ dependencies {
implementation(project(":wheelpicker"))
implementation(project(":ratingbar"))
+ // Modifier.Node 코드젠 — 노드 클래스에 @ModifierNodeFactory 를 붙이면
+ // Modifier 확장 함수 + ModifierNodeElement 가 생성된다. (modifier-node-sample 참고)
+ implementation(project(":modifier-node-annotations"))
+ ksp(project(":modifier-node-processor"))
+
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
testImplementation(libs.junit)
diff --git a/app/src/main/java/com/sonms/velvetcompose/MainActivity.kt b/app/src/main/java/com/sonms/velvetcompose/MainActivity.kt
index 34f901b..05a9a37 100644
--- a/app/src/main/java/com/sonms/velvetcompose/MainActivity.kt
+++ b/app/src/main/java/com/sonms/velvetcompose/MainActivity.kt
@@ -42,7 +42,7 @@ import com.sonms.wheelpicker.state.rememberWheelPickerState
import com.sonms.wheelpicker.style.WheelPickerDefaults
enum class SampleType {
- VERTICAL_TIME, HORIZONTAL_TIME, RATING_BAR
+ VERTICAL_TIME, HORIZONTAL_TIME, RATING_BAR, MODIFIER_NODE
}
class MainActivity : ComponentActivity() {
@@ -79,12 +79,19 @@ class MainActivity : ComponentActivity() {
Button(onClick = { selectedSample = SampleType.RATING_BAR }) {
Text(text = "Toggle Rating Star")
}
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Button(onClick = { selectedSample = SampleType.MODIFIER_NODE }) {
+ Text(text = "Modifier.Node")
+ }
}
when (selectedSample) {
SampleType.VERTICAL_TIME -> VerticalTimePickerSample()
SampleType.HORIZONTAL_TIME -> HorizontalTimePickerSample()
SampleType.RATING_BAR -> VelvetRatingBarSample()
+ SampleType.MODIFIER_NODE -> ModifierNodeShowcase()
}
}
}
diff --git a/app/src/main/java/com/sonms/velvetcompose/ModifierNodeShowcase.kt b/app/src/main/java/com/sonms/velvetcompose/ModifierNodeShowcase.kt
new file mode 100644
index 0000000..c9cd08d
--- /dev/null
+++ b/app/src/main/java/com/sonms/velvetcompose/ModifierNodeShowcase.kt
@@ -0,0 +1,105 @@
+package com.sonms.velvetcompose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.sonms.velvetcompose.modifiernode.gridOverlay
+import com.sonms.velvetcompose.modifiernode.squareThumbnail
+
+/**
+ * `:modifier-node-processor` (KSP) 사용 예. 노드 정의는 `com.sonms.velvetcompose.modifiernode` 패키지에,
+ * 아래에서 쓰는 `Modifier.gridOverlay(...)` / `Modifier.squareThumbnail(...)` 는 코드젠 산출물이다.
+ *
+ * A usage sample for the KSP codegen. The nodes live in `com.sonms.velvetcompose.modifiernode`;
+ * `Modifier.gridOverlay(...)` and `Modifier.squareThumbnail(...)` used below are generated.
+ */
+private val lineColors = listOf(Color.White, Color.Black, Color.Cyan, Color.Yellow)
+private val borderColors = listOf(Color.Magenta, Color.Green, Color(0xFFFF9800), Color.Transparent)
+
+@Composable
+fun ModifierNodeShowcase() {
+ var gridVisible by remember { mutableStateOf(true) }
+ var step by remember { mutableFloatStateOf(24f) }
+ var side by remember { mutableFloatStateOf(200f) }
+ var lineColorIndex by remember { mutableIntStateOf(0) }
+ var borderColorIndex by remember { mutableIntStateOf(0) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ // 두 modifier 모두 코드젠으로 만들어진 확장 함수.
+ // squareThumbnail: side 변경 → remeasure / border 변경 → redraw (계층 접힘)
+ // gridOverlay: gridVisible=false 면 노드 미attach (@SkipWhenFalse)
+ .squareThumbnail(
+ side = side.dp,
+ border = borderColors[borderColorIndex],
+ )
+ .gridOverlay(
+ visible = gridVisible,
+ step = step.dp,
+ lineColor = lineColors[lineColorIndex],
+ )
+ .background(
+ Brush.linearGradient(
+ listOf(Color(0xFF6A1B9A), Color(0xFF283593)),
+ ),
+ ),
+ )
+
+ Text("grid step: ${step.toInt()}dp", style = MaterialTheme.typography.labelLarge)
+ Slider(value = step, onValueChange = { step = it }, valueRange = 8f..48f)
+
+ Text("square side: ${side.toInt()}dp", style = MaterialTheme.typography.labelLarge)
+ Slider(value = side, onValueChange = { side = it }, valueRange = 120f..260f)
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("show grid", style = MaterialTheme.typography.labelLarge)
+ Switch(checked = gridVisible, onCheckedChange = { gridVisible = it })
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ FilledTonalButton(
+ onClick = { lineColorIndex = (lineColorIndex + 1) % lineColors.size },
+ ) { Text("line color") }
+ FilledTonalButton(
+ onClick = { borderColorIndex = (borderColorIndex + 1) % borderColors.size },
+ ) { Text("border color") }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sonms/velvetcompose/modifiernode/GridOverlayNode.kt b/app/src/main/java/com/sonms/velvetcompose/modifiernode/GridOverlayNode.kt
new file mode 100644
index 0000000..d41c9c0
--- /dev/null
+++ b/app/src/main/java/com/sonms/velvetcompose/modifiernode/GridOverlayNode.kt
@@ -0,0 +1,51 @@
+package com.sonms.velvetcompose.modifiernode
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.unit.Dp
+import com.sonms.modifiernode.annotations.Invalidates
+import com.sonms.modifiernode.annotations.InvalidationScope.Draw
+import com.sonms.modifiernode.annotations.InvalidationScope.None
+import com.sonms.modifiernode.annotations.ModifierNodeFactory
+import com.sonms.modifiernode.annotations.SkipWhenFalse
+
+/**
+ * 컨텐츠 위에 일정 간격 격자선을 덧그리는 draw 전용 노드.
+ * Draw-only node that paints a grid over the content.
+ *
+ * 코드젠 산출물 / what codegen produces (see build/generated/ksp/.../GridOverlayElement.kt):
+ * - `fun Modifier.gridOverlay(visible: Boolean, step: Dp, lineColor: Color): Modifier`
+ * - `GridOverlayElement` — 안정 타입 파라미터 기반 equals/hashCode + 계층 접힌 update()
+ *
+ * `visible == false` 면 노드를 아예 attach 하지 않는다 (`@SkipWhenFalse` → 생성 함수 앞 `if (!visible) return this`).
+ * `step` / `lineColor` 변경은 redraw 만 유발한다 (`@Invalidates(Draw)`).
+ */
+@ModifierNodeFactory(name = "gridOverlay")
+internal class GridOverlayNode(
+ @SkipWhenFalse @Invalidates(None) var visible: Boolean,
+ @Invalidates(Draw) var step: Dp,
+ @Invalidates(Draw) var lineColor: Color,
+) : Modifier.Node(), DrawModifierNode {
+
+ // @Invalidates 를 쓰려면 자동 무효화를 꺼야 한다 (§4).
+ override val shouldAutoInvalidate: Boolean get() = false
+
+ override fun ContentDrawScope.draw() {
+ drawContent()
+ val stepPx = step.toPx().coerceAtLeast(1f)
+
+ var x = stepPx
+ while (x < size.width) {
+ drawLine(lineColor, Offset(x, 0f), Offset(x, size.height))
+ x += stepPx
+ }
+ var y = stepPx
+ while (y < size.height) {
+ drawLine(lineColor, Offset(0f, y), Offset(size.width, y))
+ y += stepPx
+ }
+ }
+}
diff --git a/app/src/main/java/com/sonms/velvetcompose/modifiernode/SquareThumbnailNode.kt b/app/src/main/java/com/sonms/velvetcompose/modifiernode/SquareThumbnailNode.kt
new file mode 100644
index 0000000..a0eab44
--- /dev/null
+++ b/app/src/main/java/com/sonms/velvetcompose/modifiernode/SquareThumbnailNode.kt
@@ -0,0 +1,49 @@
+package com.sonms.velvetcompose.modifiernode
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import com.sonms.modifiernode.annotations.Invalidates
+import com.sonms.modifiernode.annotations.InvalidationScope.Draw
+import com.sonms.modifiernode.annotations.InvalidationScope.Measure
+import com.sonms.modifiernode.annotations.ModifierNodeFactory
+
+/**
+ * 컨텐츠를 정사각형 크기로 강제하고 테두리를 그리는 layout + draw 노드.
+ * Layout + draw node that clamps content to a square and strokes a border.
+ *
+ * 계층 접힘 시연 / demonstrates update() folding:
+ * - `side` 변경 → `@Invalidates(Measure)` → `invalidateMeasurement()` (remeasure, redraw 포함)
+ * - `border` 변경 → `@Invalidates(Draw)` → `invalidateDraw()` 만
+ * - 둘 다 바뀌면 상위 하나(Measure)만 호출된다.
+ */
+@ModifierNodeFactory(name = "squareThumbnail")
+internal class SquareThumbnailNode(
+ @Invalidates(Measure) var side: Dp,
+ @Invalidates(Draw) var border: Color,
+) : Modifier.Node(), LayoutModifierNode, DrawModifierNode {
+
+ override val shouldAutoInvalidate: Boolean get() = false
+
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints,
+ ): MeasureResult {
+ val px = side.roundToPx()
+ val placeable = measurable.measure(Constraints.fixed(px, px))
+ return layout(px, px) { placeable.place(0, 0) }
+ }
+
+ override fun ContentDrawScope.draw() {
+ drawContent()
+ drawRect(color = border, style = Stroke(width = 4f))
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 0024322..78e7700 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,4 +5,7 @@ plugins {
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.dokka) apply false
+ alias(libs.plugins.kotlin.jvm) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.vanniktech) apply false
}
\ No newline at end of file
diff --git a/docs/index.html b/docs/index.html
index 8758d3e..3bb9e23 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -73,6 +73,12 @@ 🎡 Velvet-Compose
Customizable rating bar with spring animation
v0.0.1
+
+ 🧩
+ Modifier.Node Codegen
+ KSP annotations that generate ModifierNodeElement + Modifier extension
+ v0.0.1
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index adc8aa2..5f1f30b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -9,10 +9,13 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.04.01"
kotlinxCollectionsImmutable = "0.4.0"
-vanniktech = "0.36.0"
+vanniktech = "0.34.0"
dokka = "2.2.0"
appcompat = "1.7.1"
material = "1.13.0"
+ksp = "2.0.21-1.0.28"
+kotlinpoet = "1.18.1"
+robolectric = "4.13"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -33,11 +36,18 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+ksp-symbol-processing-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" }
+kotlinpoet = { group = "com.squareup", name = "kotlinpoet", version.ref = "kotlinpoet" }
+kotlinpoet-ksp = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref = "kotlinpoet" }
+robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
+kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
vanniktech = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
\ No newline at end of file
diff --git a/modifier-node-annotations/README.md b/modifier-node-annotations/README.md
new file mode 100644
index 0000000..40370c0
--- /dev/null
+++ b/modifier-node-annotations/README.md
@@ -0,0 +1,112 @@
+# modifier-node-codegen
+
+`@ModifierNodeFactory` 를 붙인 `Modifier.Node` 서브클래스로부터 `ModifierNodeElement` 와
+`Modifier` 확장 함수를 KSP 로 생성한다. `composed { }` 를 벗어나 `Modifier.Node` 를 쓸 때
+반복되는 보일러플레이트(Element / `equals` / `hashCode` / `update` / `inspectableProperties`)를 없앤다.
+
+> **포지셔닝.** Velvet-Compose 내부 인프라 + `Modifier.Node` 학습 목적의 도구다.
+> `Modifier.Node` 를 직접 작성하는 사람(라이브러리 저자 등)만 대상이고, 니치가 좁다.
+> 1인 유지보수 · SLA 없음 · API 호환성 보장 없음. 설계 배경과 판단 근거는
+> [`ARCHITECTURE.md`](../ARCHITECTURE.md), `composed` 와의 비교는
+> [`COMPARISON.md`](../modifier-node-sample/COMPARISON.md) 참고.
+
+## Setup
+
+```kotlin
+// build.gradle.kts
+plugins {
+ alias(libs.plugins.ksp)
+}
+dependencies {
+ implementation(project(":modifier-node-annotations"))
+ ksp(project(":modifier-node-processor"))
+}
+```
+
+배포 좌표는 `io.github.sonms:modifier-node-annotations` / `io.github.sonms:modifier-node-processor` 로
+잡혀 있다(**아직 Maven Central 에 올라가지 않았다**). 올라간 뒤에는 이렇게 쓴다:
+
+```kotlin
+dependencies {
+ // 어노테이션은 전부 SOURCE retention 이라 compileOnly 로 충분하다.
+ compileOnly("io.github.sonms:modifier-node-annotations:0.0.1")
+ ksp("io.github.sonms:modifier-node-processor:0.0.1")
+}
+```
+
+## 사용법
+
+`Modifier.Node` 서브클래스를 평소대로 작성하고 `@ModifierNodeFactory` 를 붙인다.
+파라미터는 **주 생성자의 `var` 프로퍼티 파라미터**여야 한다.
+
+```kotlin
+@ModifierNodeFactory(name = "debugTint")
+internal class DebugTintNode(
+ @Invalidates(Draw) var color: Color,
+) : Modifier.Node(), DrawModifierNode {
+ override val shouldAutoInvalidate: Boolean get() = false // @Invalidates 를 쓰면 필수
+
+ override fun ContentDrawScope.draw() {
+ drawContent()
+ drawRect(color)
+ }
+}
+```
+
+생성물 (`DebugTintElement.kt`):
+
+```kotlin
+public fun Modifier.debugTint(color: Color): Modifier = this.then(DebugTintElement(color))
+
+internal class DebugTintElement(
+ private val color: Color,
+) : ModifierNodeElement() {
+ override fun create() = DebugTintNode(color)
+ override fun update(node: DebugTintNode) {
+ if (node.color != color) { node.color = color; node.invalidateDraw() }
+ }
+ override fun InspectorInfo.inspectableProperties() { name = "debugTint"; properties["color"] = color }
+ override fun equals(other: Any?): Boolean { /* per-field */ }
+ override fun hashCode(): Int { /* per-field */ }
+}
+```
+
+Node 클래스의 그리기/측정/포인터 **행동 코드만** 직접 쓰면 된다.
+
+## 어노테이션
+
+| 어노테이션 | 대상 | 하는 일 |
+|---|---|---|
+| `@ModifierNodeFactory(name, visibility)` | Node 클래스 | 생성 트리거. `name` 비우면 클래스명(`-Node`)에서 유도. `visibility` 는 생성 함수의 가시성(기본 `Public`), Element 는 항상 `internal` |
+| `@Invalidates(vararg InvalidationScope)` | `var` 파라미터 | 이 파라미터가 바뀔 때 무효화할 범위. 미지정 시 노드가 구현한 모든 범위. `None` 은 무효화 없음 |
+| `@SkipWhenFalse` / `@SkipWhenTrue` | `Boolean` 파라미터 | 조건 불충족 시 생성 함수가 `return this` — 노드 미attach. 여럿이면 OR |
+| `@OnChange` | `var` 파라미터 | 값이 바뀌면 생성 `update()` 가 노드의 `fun onChanged()` 호출(재구독·effect 재시작용). 해당 함수 선언 필수 |
+
+### `shouldAutoInvalidate = false`
+
+Compose 는 `update()` 직후 노드의 모든 capability 를 자동 무효화한다. `@Invalidates` 의
+파라미터 단위 제어가 실제로 먹으려면 Node 가 다음을 선언해야 한다(`@Invalidates` 사용 시 KSP 가 강제):
+
+```kotlin
+override val shouldAutoInvalidate: Boolean get() = false
+```
+
+지원되는 문서화된 API 이며, Compose 자체 `graphicsLayer` / `paint` 모디파이어가 같은 패턴을 쓴다.
+
+## 성능에 대한 현실적 기대
+
+`Modifier.Node` 성능의 대부분은 **equals 스킵**(파라미터 불변 시 `update()` 자체가 안 불림)이고,
+이건 `@Invalidates` 없이도 codegen 이 정확한 `equals` 를 보장해서 얻는다. `@Invalidates` 의
+파라미터 단위 invalidation 은 micro-optimization 이다 — Compose 조차 극소수 모디파이어에만 적용했고,
+"파라미터가 자주 바뀌는데 그 변경이 draw 만 요구하는" 좁은 경우에만 이득이 있다.
+
+즉 이 도구의 실질 가치는 **보일러플레이트 제거 + equals 안정성 보장 + ABI 표면 축소**다.
+
+## 한계
+
+- 생성 함수에 default argument 를 재현하지 못한다 (KSP 가 기본값 표현식을 못 읽음). 호출부에서 전 인자 전달.
+- 지원 노드 인터페이스는 `DrawModifierNode`, `LayoutModifierNode` 뿐.
+- 제네릭 Node, 다중 타입 파라미터 미검증.
+- `equals` 는 참조형 파라미터의 `equals` 안정성에 의존한다. 함수 타입 파라미터는 경고하지만
+ 그 밖의 불안정 타입은 사용자 책임.
+- 프로세서 자체 단위 테스트가 없다 (`:modifier-node-sample` 생성물 + Robolectric 로 간접 검증).
diff --git a/modifier-node-annotations/build.gradle.kts b/modifier-node-annotations/build.gradle.kts
new file mode 100644
index 0000000..23ed30b
--- /dev/null
+++ b/modifier-node-annotations/build.gradle.kts
@@ -0,0 +1,63 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.vanniktech)
+ alias(libs.plugins.dokka)
+}
+
+// Consumed by Android library modules that target JVM 11.
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+kotlin {
+ explicitApi()
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ }
+}
+
+// Dokka 를 붙이면 vanniktech 가 빈 javadoc jar 대신 Dokka 산출물을 배포에 싣는다.
+// Applying Dokka makes vanniktech publish the Dokka output instead of an empty javadoc jar.
+
+val velvetVersion = "0.0.1"
+
+mavenPublishing {
+ coordinates(
+ groupId = "io.github.sonms",
+ artifactId = "modifier-node-annotations",
+ version = velvetVersion
+ )
+
+ pom {
+ name.set("VelvetCompose Modifier Node Annotations")
+ description.set("Annotations that drive Modifier.Node codegen: @ModifierNodeFactory, @Invalidates, @SkipWhenFalse/@SkipWhenTrue and @OnChange. SOURCE retention only 🧩")
+ url.set("https://github.com/sonms/Velvet-Compose")
+
+ licenses {
+ license {
+ name.set("Apache License 2.0")
+ url.set("https://www.apache.org/licenses/LICENSE-2.0")
+ }
+ }
+
+ developers {
+ developer {
+ id.set("sonms")
+ name.set("sonms")
+ email.set("smsms5676@naver.com")
+ }
+ }
+
+ scm {
+ url.set("https://github.com/sonms/Velvet-Compose")
+ connection.set("scm:git:git://github.com/sonms/Velvet-Compose.git")
+ developerConnection.set("scm:git:ssh://git@github.com/sonms/Velvet-Compose.git")
+ }
+ }
+
+ publishToMavenCentral()
+ signAllPublications()
+}
diff --git a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt
new file mode 100644
index 0000000..63ee237
--- /dev/null
+++ b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt
@@ -0,0 +1,78 @@
+package com.sonms.modifiernode.annotations
+
+/**
+ * 이 `Modifier.Node` 서브클래스로부터 `ModifierNodeElement` 와 `Modifier` 확장 함수를 생성한다.
+ * Generates a `ModifierNodeElement` and a `Modifier` extension function from this `Modifier.Node` subclass.
+ *
+ * 파라미터는 주 생성자에서 읽으며, 각각 `var` 프로퍼티 파라미터여야 한다.
+ * Parameters are read from the primary constructor and must each be a `var` property parameter.
+ *
+ * @param name 생성될 확장 함수 이름. 비우면 클래스명에서 `Node` 접미사를 떼어 유도한다.
+ * Name of the generated extension function. Derived from the class name (minus `Node`) if blank.
+ * @param visibility 생성될 확장 함수의 가시성. 생성된 Element 는 항상 `internal` 이다.
+ * Visibility of the generated extension function. The generated Element is always `internal`.
+ */
+@Target(AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.SOURCE)
+public annotation class ModifierNodeFactory(
+ public val name: String = "",
+ public val visibility: GeneratedVisibility = GeneratedVisibility.Public,
+)
+
+/** [ModifierNodeFactory.visibility] 값. Value for [ModifierNodeFactory.visibility]. */
+public enum class GeneratedVisibility { Public, Internal }
+
+/**
+ * 이 파라미터가 바뀌었을 때 무효화할 범위. 미지정 시 노드가 구현한 모든 범위를 무효화한다.
+ * Invalidation scopes to trigger when this parameter changes. Defaults to every scope the node implements.
+ *
+ * `@Invalidates` 를 쓰려면 노드가 `override val shouldAutoInvalidate get() = false` 를 선언해야 한다.
+ * Using `@Invalidates` requires the node to declare `override val shouldAutoInvalidate get() = false`.
+ */
+@Target(AnnotationTarget.VALUE_PARAMETER)
+@Retention(AnnotationRetention.SOURCE)
+public annotation class Invalidates(public vararg val scopes: InvalidationScope)
+
+/**
+ * 무효화 범위. 계층은 [Measure] > [Placement] > [Draw] (상위 하나만 호출).
+ * Invalidation scope. Hierarchy is [Measure] > [Placement] > [Draw] (only the highest one fires).
+ *
+ * [None] 은 무효화 없음. 생성 시점에만 쓰거나 `observeReads` 로 읽는 값에 사용한다.
+ * [None] means no invalidation — for values used only on create, or read via `observeReads`.
+ */
+public enum class InvalidationScope { Measure, Placement, Draw, Semantics, ParentData, None }
+
+/**
+ * 이 `Boolean` 파라미터가 `false` 면 modifier 를 적용하지 않는다 (노드 미attach).
+ * The modifier is not applied when this `Boolean` parameter is `false` (no node attached).
+ *
+ * 생성 함수 앞에 `if (!param) return this` 가 삽입된다. 여러 개면 OR 로 묶인다.
+ * Prepends `if (!param) return this` to the generated function. Multiple markers are OR-joined.
+ */
+@Target(AnnotationTarget.VALUE_PARAMETER)
+@Retention(AnnotationRetention.SOURCE)
+public annotation class SkipWhenFalse
+
+/**
+ * [SkipWhenFalse] 의 반대 — 이 `Boolean` 파라미터가 `true` 면 modifier 를 적용하지 않는다.
+ * Opposite of [SkipWhenFalse] — the modifier is not applied when this `Boolean` parameter is `true`.
+ */
+@Target(AnnotationTarget.VALUE_PARAMETER)
+@Retention(AnnotationRetention.SOURCE)
+public annotation class SkipWhenTrue
+
+/**
+ * 이 파라미터가 바뀌면 생성된 `update()` 가 노드 필드 갱신 후 `onChanged()` 를 호출한다.
+ * When this parameter changes, the generated `update()` calls `onChanged()` after updating the node field.
+ *
+ * 노드는 인자 없이 `Unit` 을 반환하는 `fun onChanged()` 를 선언해야 한다.
+ * The node must declare `fun onChanged()` taking no arguments and returning `Unit`.
+ * 예: `interactionSource` → `fun onInteractionSourceChanged()`.
+ * e.g. `interactionSource` → `fun onInteractionSourceChanged()`.
+ *
+ * 콜백 시점에는 노드 필드가 이미 새 값이다. 코루틴 재구독, effect 재시작 등에 쓴다.
+ * The node field already holds the new value when the callback runs. Use it to re-subscribe a coroutine, restart an effect, etc.
+ */
+@Target(AnnotationTarget.VALUE_PARAMETER)
+@Retention(AnnotationRetention.SOURCE)
+public annotation class OnChange
diff --git a/modifier-node-processor/build.gradle.kts b/modifier-node-processor/build.gradle.kts
new file mode 100644
index 0000000..dfb03ad
--- /dev/null
+++ b/modifier-node-processor/build.gradle.kts
@@ -0,0 +1,55 @@
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.vanniktech)
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+
+dependencies {
+ implementation(project(":modifier-node-annotations"))
+ implementation(libs.ksp.symbol.processing.api)
+ implementation(libs.kotlinpoet)
+ implementation(libs.kotlinpoet.ksp)
+}
+
+val velvetVersion = "0.0.1"
+
+mavenPublishing {
+ coordinates(
+ groupId = "io.github.sonms",
+ artifactId = "modifier-node-processor",
+ version = velvetVersion
+ )
+
+ pom {
+ name.set("VelvetCompose Modifier Node Processor")
+ description.set("KSP processor that generates the ModifierNodeElement and the Modifier extension function for every @ModifierNodeFactory node 🚀")
+ url.set("https://github.com/sonms/Velvet-Compose")
+
+ licenses {
+ license {
+ name.set("Apache License 2.0")
+ url.set("https://www.apache.org/licenses/LICENSE-2.0")
+ }
+ }
+
+ developers {
+ developer {
+ id.set("sonms")
+ name.set("sonms")
+ email.set("smsms5676@naver.com")
+ }
+ }
+
+ scm {
+ url.set("https://github.com/sonms/Velvet-Compose")
+ connection.set("scm:git:git://github.com/sonms/Velvet-Compose.git")
+ developerConnection.set("scm:git:ssh://git@github.com/sonms/Velvet-Compose.git")
+ }
+ }
+
+ publishToMavenCentral()
+ signAllPublications()
+}
diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt
new file mode 100644
index 0000000..00d2b00
--- /dev/null
+++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt
@@ -0,0 +1,456 @@
+package com.sonms.modifiernode.processor
+
+import com.google.devtools.ksp.getAllSuperTypes
+import com.google.devtools.ksp.getDeclaredProperties
+import com.google.devtools.ksp.processing.CodeGenerator
+import com.google.devtools.ksp.processing.KSPLogger
+import com.google.devtools.ksp.processing.Resolver
+import com.google.devtools.ksp.processing.SymbolProcessor
+import com.google.devtools.ksp.symbol.KSAnnotated
+import com.google.devtools.ksp.symbol.KSAnnotation
+import com.google.devtools.ksp.symbol.KSClassDeclaration
+import com.google.devtools.ksp.symbol.KSType
+import com.google.devtools.ksp.symbol.KSValueParameter
+import com.google.devtools.ksp.validate
+import com.squareup.kotlinpoet.ClassName
+import com.squareup.kotlinpoet.FileSpec
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.MemberName
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.TypeSpec
+import com.squareup.kotlinpoet.ksp.toTypeName
+import com.squareup.kotlinpoet.ksp.writeTo
+
+private const val FACTORY_ANNOTATION = "com.sonms.modifiernode.annotations.ModifierNodeFactory"
+
+private val MODIFIER = ClassName("androidx.compose.ui", "Modifier")
+private val MODIFIER_NODE_ELEMENT = ClassName("androidx.compose.ui.node", "ModifierNodeElement")
+private val INSPECTOR_INFO = ClassName("androidx.compose.ui.platform", "InspectorInfo")
+private val INVALIDATE_DRAW = MemberName("androidx.compose.ui.node", "invalidateDraw")
+private val INVALIDATE_MEASUREMENT = MemberName("androidx.compose.ui.node", "invalidateMeasurement")
+private val INVALIDATE_PLACEMENT = MemberName("androidx.compose.ui.node", "invalidatePlacement")
+
+private const val DRAW_MODIFIER_NODE = "androidx.compose.ui.node.DrawModifierNode"
+private const val LAYOUT_MODIFIER_NODE = "androidx.compose.ui.node.LayoutModifierNode"
+
+/** 무효화 범위. 처리기 내부 표현. */
+private enum class Scope { Measure, Placement, Draw, Semantics, ParentData, None }
+
+/** @SkipWhenFalse / @SkipWhenTrue 처리기 내부 표현. */
+private enum class SkipKind { WhenFalse, WhenTrue }
+
+class ModifierNodeProcessor(
+ private val codeGenerator: CodeGenerator,
+ private val logger: KSPLogger,
+) : SymbolProcessor {
+
+ override fun process(resolver: Resolver): List {
+ val symbols = resolver.getSymbolsWithAnnotation(FACTORY_ANNOTATION).toList()
+ val deferred = symbols.filterNot { it.validate() }
+
+ symbols.filter { it.validate() }
+ .filterIsInstance()
+ .forEach { runCatching { generate(it) }.onFailure { e -> logger.error("codegen failed: ${e.message}", it) } }
+
+ return deferred
+ }
+
+ private fun generate(node: KSClassDeclaration) {
+ val pkg = node.packageName.asString()
+ val nodeName = node.simpleName.asString()
+ val nodeClass = ClassName(pkg, nodeName)
+
+ val factoryAnn = node.annotations.first { it.shortName.asString() == "ModifierNodeFactory" }
+ val explicitName = (factoryAnn.argValue("name") as? String).orEmpty()
+ val base = nodeName.removeSuffix("Node")
+ val funName = explicitName.ifBlank { base.replaceFirstChar { it.lowercase() } }
+ if (funName.isBlank()) {
+ logger.error("Cannot derive a function name from '$nodeName' — pass @ModifierNodeFactory(name = \"...\")", node)
+ return
+ }
+ val elementName = "${base.ifBlank { nodeName }}Element"
+ val elementClass = ClassName(pkg, elementName)
+
+ // 확장 함수 가시성: @ModifierNodeFactory(visibility=...), 기본 Public.
+ val funVisibility = when (factoryAnn.enumArgName("visibility")) {
+ "Internal" -> KModifier.INTERNAL
+ else -> KModifier.PUBLIC
+ }
+ // Element 는 노드 가시성과 무관하게 항상 internal (ABI 표면에서 제외).
+ val elementVisibility = KModifier.INTERNAL
+
+ // Node 가시성 검증.
+ when {
+ node.isPrivate() -> {
+ logger.error(
+ "@ModifierNodeFactory node must not be private — the generated $elementName " +
+ "(separate file) cannot reference it. Use 'internal'.",
+ node,
+ )
+ return
+ }
+ node.isPublic() -> logger.warn(
+ "@ModifierNodeFactory node '$nodeName' is public → it is part of your binary API. " +
+ "Make it 'internal' so only Modifier.$funName(...) is exposed.",
+ node,
+ )
+ }
+
+ val ctor = node.primaryConstructor
+ if (ctor == null) {
+ logger.error("@ModifierNodeFactory requires a primary constructor", node)
+ return
+ }
+
+ val supported = node.supportedScopes()
+ if (supported.isEmpty()) {
+ logger.error(
+ "@ModifierNodeFactory node must implement DrawModifierNode and/or LayoutModifierNode (MVP)",
+ node,
+ )
+ return
+ }
+
+ var paramsValid = true
+ val params = ctor.parameters.map { p ->
+ val (param, valid) = resolveParam(p, node, supported)
+ if (!valid) paramsValid = false
+ param
+ }
+ // 하드 에러(non-var, 잘못된 마커 등)가 있으면 생성물을 만들지 않는다 — 2차 에러 방지.
+ if (!paramsValid) return
+
+ // @Invalidates 는 Node 가 shouldAutoInvalidate=false 를 선언해야 실효가 있다 (ARCHITECTURE.md §10).
+ // codegen 은 Node 클래스를 수정 못 하므로 여기서 요구한다.
+ val hasExplicitScopes = ctor.parameters.any { p ->
+ p.annotations.any { it.shortName.asString() == "Invalidates" }
+ }
+ if (hasExplicitScopes && !nodeDeclaresShouldAutoInvalidate(node)) {
+ logger.error(
+ "@Invalidates requires the node to declare:\n" +
+ " override val shouldAutoInvalidate: Boolean get() = false\n" +
+ "Otherwise Compose auto-invalidates every capability after update() and @Invalidates is a no-op.",
+ node,
+ )
+ return
+ }
+
+ // @SkipWhenFalse / @SkipWhenTrue → 확장 함수 앞에 가드 삽입.
+ val skipConditions = params.mapNotNull { p ->
+ when (p.skip) {
+ SkipKind.WhenFalse -> "!${p.name}"
+ SkipKind.WhenTrue -> p.name
+ null -> null
+ }
+ }
+
+ // --- 확장 함수: fun Modifier.(...): Modifier { if () return this; return this.then(...) } ---
+ val extFun = FunSpec.builder(funName)
+ .addModifiers(funVisibility)
+ .receiver(MODIFIER)
+ .returns(MODIFIER)
+ .apply { params.forEach { addParameter(it.name, it.type) } }
+ .apply {
+ if (skipConditions.isNotEmpty()) {
+ addStatement("if (%L) return this", skipConditions.joinToString(" || "))
+ }
+ }
+ .addStatement(
+ "return this.then(%T(%L))",
+ elementClass,
+ params.joinToString(", ") { it.name },
+ )
+ .build()
+
+ // --- Element 클래스 ---
+ val elementType = TypeSpec.classBuilder(elementClass)
+ .addModifiers(elementVisibility)
+ .superclass(MODIFIER_NODE_ELEMENT.parameterizedBy(nodeClass))
+ .primaryConstructor(
+ FunSpec.constructorBuilder()
+ .apply { params.forEach { addParameter(it.name, it.type) } }
+ .build(),
+ )
+ .apply {
+ params.forEach {
+ addProperty(
+ PropertySpec.builder(it.name, it.type, KModifier.PRIVATE)
+ .initializer(it.name)
+ .build(),
+ )
+ }
+ }
+ .addFunction(createFun(nodeClass, params))
+ .addFunction(updateFun(nodeClass, params))
+ .addFunction(inspectableFun(funName, params))
+ .addFunction(equalsFun(elementClass, params))
+ .addFunction(hashCodeFun(params))
+ .build()
+
+ FileSpec.builder(pkg, elementName)
+ .addFunction(extFun)
+ .addType(elementType)
+ .build()
+ .writeTo(codeGenerator, aggregating = false, originatingKSFiles = listOfNotNull(node.containingFile))
+ }
+
+ private data class Param(
+ val name: String,
+ val type: com.squareup.kotlinpoet.TypeName,
+ val scopes: Set,
+ val skip: SkipKind?,
+ val onChange: Boolean,
+ ) {
+ /** `@OnChange` 콜백 이름. e.g. `interactionSource` → `onInteractionSourceChanged`. */
+ val changedCallback: String get() = "on${name.replaceFirstChar { it.uppercase() }}Changed"
+ }
+
+ /** @return 파라미터 모델과, 하드 에러가 없어 생성 가능한지 여부. */
+ private fun resolveParam(p: KSValueParameter, node: KSClassDeclaration, supported: Set): Pair {
+ val name = p.name!!.asString()
+ var valid = true
+
+ if (!p.isVar) {
+ logger.error("Parameter '$name' must be a 'var' property parameter", p)
+ valid = false
+ }
+
+ // 함수 타입은 equals 가 참조 동일성 → composed 와 같은 재구성 함정. 경고만.
+ val typeFqn = p.type.resolve().declaration.qualifiedName?.asString().orEmpty()
+ if (typeFqn.startsWith("kotlin.Function") || typeFqn.startsWith("kotlin.coroutines.SuspendFunction")) {
+ logger.warn(
+ "Parameter '$name' is a function type — the generated equals() uses reference identity, " +
+ "so a fresh lambda each recomposition defeats element reuse. Hoist it or wrap in a stable holder.",
+ p,
+ )
+ }
+
+ val skipFalse = p.annotations.any { it.shortName.asString() == "SkipWhenFalse" }
+ val skipTrue = p.annotations.any { it.shortName.asString() == "SkipWhenTrue" }
+ val isBoolean = p.type.resolve().declaration.qualifiedName?.asString() == "kotlin.Boolean"
+ if ((skipFalse || skipTrue) && !isBoolean) {
+ logger.error("@SkipWhen* requires a Boolean parameter; '$name' is not Boolean", p)
+ valid = false
+ }
+ if (skipFalse && skipTrue) {
+ logger.error("'$name' has both @SkipWhenFalse and @SkipWhenTrue", p)
+ valid = false
+ }
+ val skip = when {
+ skipFalse -> SkipKind.WhenFalse
+ skipTrue -> SkipKind.WhenTrue
+ else -> null
+ }
+
+ val onChange = p.annotations.any { it.shortName.asString() == "OnChange" }
+ if (onChange) {
+ val cb = "on${name.replaceFirstChar { it.uppercase() }}Changed"
+ val declared = node.getAllFunctions().any { fn ->
+ fn.simpleName.asString() == cb && fn.parameters.isEmpty()
+ }
+ if (!declared) {
+ logger.error("@OnChange on '$name' requires the node to declare 'fun $cb()'", p)
+ valid = false
+ }
+ }
+
+ val invAnn = p.annotations.firstOrNull { it.shortName.asString() == "Invalidates" }
+ val scopes: Set = when {
+ invAnn == null -> supported // 안전 기본값
+ else -> {
+ val declared = invAnn.enumArgNames("scopes").mapNotNull { runCatching { Scope.valueOf(it) }.getOrNull() }.toSet()
+ when {
+ Scope.None in declared -> emptySet()
+ else -> {
+ declared.forEach { s ->
+ val ok = when (s) {
+ Scope.Draw -> Scope.Draw in supported
+ Scope.Measure, Scope.Placement -> Scope.Measure in supported
+ Scope.Semantics, Scope.ParentData ->
+ false.also { logger.error("@Invalidates($s) not supported yet (MVP)", p) }
+ Scope.None -> true
+ }
+ if (!ok) {
+ if (s != Scope.Semantics && s != Scope.ParentData) {
+ logger.error(
+ "@Invalidates($s) on '$name' but ${node.simpleName.asString()} does not implement the matching node interface",
+ p,
+ )
+ }
+ // 잘못된 범위로 생성하면 컴파일 안 되는 invalidate 호출이 나온다 — 방출 자체를 막는다.
+ valid = false
+ }
+ }
+ declared
+ }
+ }
+ }
+ }
+ return Param(name, p.type.toTypeName(), scopes, skip, onChange) to valid
+ }
+
+ private fun createFun(nodeClass: ClassName, params: List) = FunSpec.builder("create")
+ .addModifiers(KModifier.OVERRIDE)
+ .returns(nodeClass)
+ .addStatement("return %T(%L)", nodeClass, params.joinToString(", ") { it.name })
+ .build()
+
+ private fun updateFun(nodeClass: ClassName, params: List): FunSpec {
+ val b = FunSpec.builder("update")
+ .addModifiers(KModifier.OVERRIDE)
+ .addParameter("node", nodeClass)
+
+ val anyDraw = params.any { Scope.Draw in it.scopes }
+ val anyMeasure = params.any { Scope.Measure in it.scopes }
+ val anyPlacement = params.any { Scope.Placement in it.scopes && Scope.Measure !in it.scopes }
+
+ if (anyDraw) b.addStatement("var redraw = false")
+ if (anyMeasure) b.addStatement("var remeasure = false")
+ if (anyPlacement) b.addStatement("var replace = false")
+
+ params.forEach { p ->
+ if (p.scopes.isEmpty() && !p.onChange) {
+ b.addStatement("if (node.%N != %N) node.%N = %N", p.name, p.name, p.name, p.name)
+ } else {
+ b.beginControlFlow("if (node.%N != %N)", p.name, p.name)
+ b.addStatement("node.%N = %N", p.name, p.name)
+ if (Scope.Draw in p.scopes) b.addStatement("redraw = true")
+ if (Scope.Measure in p.scopes) b.addStatement("remeasure = true")
+ else if (Scope.Placement in p.scopes) b.addStatement("replace = true")
+ if (p.onChange) b.addStatement("node.%N()", p.changedCallback)
+ b.endControlFlow()
+ }
+ }
+
+ // 계층: Measure > Placement > Draw. 상위 하나만 호출.
+ when {
+ anyMeasure && (anyPlacement || anyDraw) -> {
+ b.beginControlFlow("if (remeasure)")
+ b.addStatement("node.%M()", INVALIDATE_MEASUREMENT)
+ if (anyPlacement) {
+ b.nextControlFlow("else if (replace)")
+ b.addStatement("node.%M()", INVALIDATE_PLACEMENT)
+ }
+ if (anyDraw) {
+ b.nextControlFlow("else if (redraw)")
+ b.addStatement("node.%M()", INVALIDATE_DRAW)
+ }
+ b.endControlFlow()
+ }
+ anyMeasure -> {
+ b.beginControlFlow("if (remeasure)")
+ b.addStatement("node.%M()", INVALIDATE_MEASUREMENT)
+ b.endControlFlow()
+ }
+ anyPlacement && anyDraw -> {
+ b.beginControlFlow("if (replace)")
+ b.addStatement("node.%M()", INVALIDATE_PLACEMENT)
+ b.nextControlFlow("else if (redraw)")
+ b.addStatement("node.%M()", INVALIDATE_DRAW)
+ b.endControlFlow()
+ }
+ anyPlacement -> {
+ b.beginControlFlow("if (replace)")
+ b.addStatement("node.%M()", INVALIDATE_PLACEMENT)
+ b.endControlFlow()
+ }
+ anyDraw -> {
+ b.beginControlFlow("if (redraw)")
+ b.addStatement("node.%M()", INVALIDATE_DRAW)
+ b.endControlFlow()
+ }
+ }
+ return b.build()
+ }
+
+ private fun inspectableFun(name: String, params: List) = FunSpec.builder("inspectableProperties")
+ .addModifiers(KModifier.OVERRIDE)
+ .receiver(INSPECTOR_INFO)
+ .addStatement("name = %S", name)
+ .apply { params.forEach { addStatement("properties[%S] = %N", it.name, it.name) } }
+ .build()
+
+ private fun equalsFun(elementClass: ClassName, params: List): FunSpec {
+ val b = FunSpec.builder("equals")
+ .addModifiers(KModifier.OVERRIDE)
+ .addParameter("other", ANY_NULLABLE)
+ .returns(Boolean::class)
+ .addStatement("if (this === other) return true")
+ .addStatement("if (other !is %T) return false", elementClass)
+ params.forEach { b.addStatement("if (%N != other.%N) return false", it.name, it.name) }
+ b.addStatement("return true")
+ return b.build()
+ }
+
+ private fun hashCodeFun(params: List): FunSpec {
+ val b = FunSpec.builder("hashCode")
+ .addModifiers(KModifier.OVERRIDE)
+ .returns(Int::class)
+ if (params.isEmpty()) {
+ b.addStatement("return %T::class.hashCode()", MODIFIER)
+ return b.build()
+ }
+ b.addStatement("var result = %N.hashCode()", params.first().name)
+ params.drop(1).forEach { b.addStatement("result = 31 * result + %N.hashCode()", it.name) }
+ b.addStatement("return result")
+ return b.build()
+ }
+
+ private fun KSClassDeclaration.supportedScopes(): Set {
+ val supers = (listOf(this) + getAllSuperTypes().mapNotNull { it.declaration as? KSClassDeclaration })
+ .mapNotNull { it.qualifiedName?.asString() }
+ .toSet()
+ return buildSet {
+ if (DRAW_MODIFIER_NODE in supers) add(Scope.Draw)
+ if (LAYOUT_MODIFIER_NODE in supers) {
+ add(Scope.Measure)
+ add(Scope.Placement)
+ }
+ }
+ }
+
+ private fun KSClassDeclaration.isPublic(): Boolean =
+ modifiers.none { it.name == "INTERNAL" || it.name == "PRIVATE" || it.name == "PROTECTED" }
+
+ private fun KSClassDeclaration.isPrivate(): Boolean =
+ modifiers.any { it.name == "PRIVATE" }
+
+ /** Node 클래스가 자체적으로 `shouldAutoInvalidate` 를 override 선언했는지 (값 false 여부는 신뢰). */
+ private fun nodeDeclaresShouldAutoInvalidate(node: KSClassDeclaration): Boolean =
+ node.getDeclaredProperties().any { it.simpleName.asString() == "shouldAutoInvalidate" }
+
+ companion object {
+ private val ANY_NULLABLE = ClassName("kotlin", "Any").copy(nullable = true)
+ }
+}
+
+/** 이름 있는 인자 값 조회. */
+private fun KSAnnotation.argValue(name: String): Any? =
+ arguments.firstOrNull { it.name?.asString() == name }?.value
+
+/** 단일 enum 인자를 엔트리 simpleName 으로 (없으면 null). */
+private fun KSAnnotation.enumArgName(name: String): String? = when (val raw = argValue(name)) {
+ is KSType -> raw.declaration.simpleName.asString()
+ is KSClassDeclaration -> raw.simpleName.asString()
+ null -> null
+ else -> raw.toString().substringAfterLast('.')
+}
+
+/** enum vararg 인자를 엔트리 simpleName 리스트로. */
+private fun KSAnnotation.enumArgNames(name: String): List {
+ val raw = argValue(name) ?: return emptyList()
+ val list = when (raw) {
+ is List<*> -> raw
+ is Array<*> -> raw.toList()
+ else -> listOf(raw)
+ }
+ return list.mapNotNull { entry ->
+ when (entry) {
+ is KSType -> entry.declaration.simpleName.asString()
+ is KSClassDeclaration -> entry.simpleName.asString()
+ else -> entry?.toString()?.substringAfterLast('.')
+ }
+ }
+}
diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessorProvider.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessorProvider.kt
new file mode 100644
index 0000000..b813095
--- /dev/null
+++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessorProvider.kt
@@ -0,0 +1,10 @@
+package com.sonms.modifiernode.processor
+
+import com.google.devtools.ksp.processing.SymbolProcessor
+import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
+import com.google.devtools.ksp.processing.SymbolProcessorProvider
+
+class ModifierNodeProcessorProvider : SymbolProcessorProvider {
+ override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
+ ModifierNodeProcessor(environment.codeGenerator, environment.logger)
+}
diff --git a/modifier-node-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/modifier-node-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
new file mode 100644
index 0000000..cf9f1f5
--- /dev/null
+++ b/modifier-node-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
@@ -0,0 +1 @@
+com.sonms.modifiernode.processor.ModifierNodeProcessorProvider
diff --git a/modifier-node-sample/COMPARISON.md b/modifier-node-sample/COMPARISON.md
new file mode 100644
index 0000000..7ebcb0f
--- /dev/null
+++ b/modifier-node-sample/COMPARISON.md
@@ -0,0 +1,137 @@
+# composed vs Modifier.Node(+codegen) — before/after
+
+두 케이스로 비교한다. 소스: `src/main/kotlin/.../compare/`.
+
+- **Case A — `accentOverlay`**: CompositionLocal 하나 읽어서 draw. (전형적 `composed` 사용)
+- **Case B — `pressScale`**: `InteractionSource` 구독 + 애니메이션. (무거운 `composed` 사용)
+
+손으로 쓴 코드 줄 수만 센다(주석 제외, 대략치). 생성 코드는 0으로 친다.
+
+| | composed | Node(손) | codegen 없이 Node(손) | 생성물 |
+|---|---:|---:|---:|---:|
+| Case A | ~7 | **~10** | ~55 | ~46 |
+| Case B | ~10 | **~35** | ~85 | ~53 |
+
+---
+
+## Case A — `accentOverlay`
+
+### BEFORE
+
+```kotlin
+fun Modifier.accentOverlayComposed(alpha: Float): Modifier = composed {
+ val color = LocalAccentColor.current
+ drawWithContent {
+ drawContent()
+ drawRect(color.copy(alpha = alpha))
+ }
+}
+```
+
+숨은 비용: 반환 Modifier `equals` 불안정 → 부모 재구성 시 하위 재구성, `composed` 람다 매번
+re-materialize, composition 밖 사용 불가, layout inspector 에 이름 안 뜸.
+
+### AFTER
+
+```kotlin
+@ModifierNodeFactory(name = "accentOverlay")
+internal class AccentOverlayNode(
+ @Invalidates(Draw) var alpha: Float,
+) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode {
+ override fun ContentDrawScope.draw() {
+ val color = currentValueOf(LocalAccentColor) // draw 에서 읽으면 관찰됨 → 로컬 변경 시 자동 redraw
+ drawContent()
+ drawRect(color.copy(alpha = alpha))
+ }
+}
+```
+
+생성: `fun Modifier.accentOverlay(alpha)` + `AccentOverlayElement`(create/update/equals/hashCode/inspector).
+
+### 판정 A — **명확한 이득**
+
+- 손 코드 7 → 10줄. 늘어난 3줄은 전부 "실제 정보"(무슨 노드인지 선언 + `var` 선언).
+- codegen 없으면 7 → ~55줄. 이 55줄이 싫어서 다들 `composed` 에 머문다. **codegen 이 정확히 그 벽을 없앤다.**
+- 덤: 안정 equals, inspector 이름, composition 밖 사용 가능, `@Invalidates(Draw)` ↔ `DrawModifierNode` 정합성 컴파일 체크.
+
+---
+
+## Case B — `pressScale`
+
+### BEFORE
+
+```kotlin
+fun Modifier.pressScaleComposed(
+ interactionSource: InteractionSource,
+ pressedScale: Float,
+): Modifier = composed {
+ val pressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(if (pressed) pressedScale else 1f, label = "pressScale")
+ graphicsLayer { scaleX = scale; scaleY = scale }
+}
+```
+
+`composed` 가 공짜로 해주는 것: 구독 lifecycle, 애니메이션 상태 remember,
+`interactionSource` 키가 바뀌면 재구독.
+
+### AFTER
+
+```kotlin
+@ModifierNodeFactory(name = "pressScale")
+internal class PressScaleNode(
+ @Invalidates(Draw) var pressedScale: Float,
+ @Invalidates(None) @OnChange var interactionSource: InteractionSource,
+) : Modifier.Node(), DrawModifierNode {
+
+ private val scaleAnim = Animatable(1f)
+ private var collectJob: Job? = null
+
+ override fun onAttach() = subscribe()
+ override fun onDetach() { collectJob?.cancel(); collectJob = null }
+ fun onInteractionSourceChanged() = subscribe() // 생성된 update() 가 호출
+
+ private fun subscribe() {
+ collectJob?.cancel()
+ collectJob = coroutineScope.launch {
+ val presses = ArrayList()
+ interactionSource.interactions.collect { i ->
+ when (i) {
+ is PressInteraction.Press -> presses.add(i)
+ is PressInteraction.Release -> presses.remove(i.press)
+ is PressInteraction.Cancel -> presses.remove(i.press)
+ }
+ launch { scaleAnim.animateTo(if (presses.isNotEmpty()) pressedScale else 1f) }
+ }
+ }
+ }
+
+ override fun ContentDrawScope.draw() {
+ val s = scaleAnim.value // snapshot 상태 → 프레임마다 자동 redraw
+ scale(s, s, center) { this@draw.drawContent() }
+ }
+}
+```
+
+### 판정 B — codegen + `@OnChange` 로 격차 축소
+
+- 손 코드 10 → ~35줄. Element(~50줄) + 재구독 dedup(`boundSource` 필드, `draw()` 의 `rebind()`)
+ 이 사라졌다. 남은 장황함은 `Animatable` / press 카운팅 — 진짜 노드 행동 코드다.
+- `@OnChange var interactionSource` → 생성된 `update()` 가 필드 갱신 후 `onInteractionSourceChanged()`
+ 를 호출한다. `composed` 의 `remember(key)` 재구독에 대응.
+- `Animatable` / press 카운팅 보일러플레이트는 여전히 codegen 범위 밖 (노드 유틸이 있으면 더 줄겠지만 별개).
+
+---
+
+## 종합
+
+| 모디파이어 부류 | codegen 효과 |
+|---|---|
+| **값(파라미터/CompositionLocal) 읽고 draw/measure 에서 반응** — 커스텀 모디파이어의 다수 | **결정적.** 7→10 vs 7→55. `composed` 를 벗어나게 해줌 |
+| **interaction / coroutine / animation** | `@OnChange` 로 재구독 격차 축소(10→35). 남은 장황함(`Animatable`/카운팅)은 진짜 노드 코드 |
+| 모든 부류 공통 | 안정 equals·inspector·`@Invalidates` 정합성 체크·ABI 표면 축소·`@SkipWhen*` 가드 |
+
+- **값 읽기 부류**: 결정적. `fadingEdge`(composition-free 체인)는 `composed` 케이스가 아니었고
+ raw 노드로 내려서 손해 — `../ARCHITECTURE.md` §7.
+- **interaction 부류**: `@OnChange` 이후 근접(값 읽기만큼 결정적이진 않음).
+- 파라미터 단위 invalidation 이 실제로 작동하는지(그리고 성능 이득이 왜 좁은지)는
+ `../ARCHITECTURE.md` §4·§5.
diff --git a/modifier-node-sample/README.md b/modifier-node-sample/README.md
new file mode 100644
index 0000000..caf504d
--- /dev/null
+++ b/modifier-node-sample/README.md
@@ -0,0 +1,21 @@
+# modifier-node-sample
+
+`modifier-node-codegen` 의 예제 + 통합 테스트 모듈. **배포하지 않는다.**
+This is the examples + integration test module for `modifier-node-codegen`. **Not published.**
+
+## 구성
+
+| 파일 | 내용 |
+|---|---|
+| `DebugTint.kt` | 가장 단순한 예 — draw 전용 노드 |
+| `FixedSquare.kt` | draw + layout 노드 — `update()` 계층 접기 |
+| `FadingEdge.kt` | dogfood. `graphicsLayer` 오프스크린 + `DstIn` 마스크. composition-free 체인을 raw 노드로 내렸을 때의 손익을 보여주는 케이스 (`../ARCHITECTURE.md` §7) |
+| `compare/AccentOverlay.kt` | `composed` vs Node — CompositionLocal 읽기 (Case A) |
+| `compare/PressScale.kt` | `composed` vs Node — InteractionSource + 애니메이션 (Case B) |
+| `COMPARISON.md` | 위 두 케이스의 before/after 분석 |
+
+## 테스트
+
+- `GeneratedElementTest` — equals/hashCode/create/update 계약, Element `internal` 가시성 (순수 JUnit)
+- `InvalidationContractTest` — equals 스킵, `@SkipWhen*` 가드, `@OnChange` 콜백 (Robolectric)
+- `AutoInvalidateProbeTest` — `shouldAutoInvalidate = false` opt-out 동작 통제 실험 (Robolectric)
diff --git a/modifier-node-sample/build.gradle.kts b/modifier-node-sample/build.gradle.kts
new file mode 100644
index 0000000..4bce872
--- /dev/null
+++ b/modifier-node-sample/build.gradle.kts
@@ -0,0 +1,46 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "com.sonms.modifiernode.sample"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ }
+}
+
+dependencies {
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.foundation)
+
+ implementation(project(":modifier-node-annotations"))
+ ksp(project(":modifier-node-processor"))
+ kspTest(project(":modifier-node-processor"))
+
+ testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.kotlin.reflect)
+ testImplementation(platform(libs.androidx.compose.bom))
+ testImplementation(libs.androidx.compose.ui.test.junit4)
+ // Robolectric 이 두 변형 모두에서 ComponentActivity 를 찾을 수 있어야 한다 (debug 전용이면 release 테스트가 깨진다).
+ // Robolectric needs ComponentActivity in both variants — a debug-only manifest breaks the release unit tests.
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+ releaseImplementation(libs.androidx.compose.ui.test.manifest)
+}
diff --git a/modifier-node-sample/src/main/AndroidManifest.xml b/modifier-node-sample/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..94cbbcf
--- /dev/null
+++ b/modifier-node-sample/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/DebugTint.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/DebugTint.kt
new file mode 100644
index 0000000..21ebc9f
--- /dev/null
+++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/DebugTint.kt
@@ -0,0 +1,25 @@
+package com.sonms.modifiernode.sample
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.node.DrawModifierNode
+import com.sonms.modifiernode.annotations.Invalidates
+import com.sonms.modifiernode.annotations.InvalidationScope.Draw
+import com.sonms.modifiernode.annotations.ModifierNodeFactory
+
+/**
+ * 컨텐츠 위에 색을 덧칠하는 draw 전용 노드. 가장 단순한 생성 예.
+ * Draw-only node that tints the content — the simplest codegen example.
+ */
+@ModifierNodeFactory(name = "debugTint")
+internal class DebugTintNode(
+ @Invalidates(Draw) var color: Color,
+) : Modifier.Node(), DrawModifierNode {
+ override val shouldAutoInvalidate: Boolean get() = false
+
+ override fun ContentDrawScope.draw() {
+ drawContent()
+ drawRect(color)
+ }
+}
diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt
new file mode 100644
index 0000000..5c184ae
--- /dev/null
+++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt
@@ -0,0 +1,77 @@
+package com.sonms.modifiernode.sample
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.layer.GraphicsLayer
+import androidx.compose.ui.graphics.layer.drawLayer
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.requireGraphicsContext
+import androidx.compose.ui.unit.toIntSize
+import com.sonms.modifiernode.annotations.Invalidates
+import com.sonms.modifiernode.annotations.InvalidationScope.Draw
+import com.sonms.modifiernode.annotations.InvalidationScope.None
+import com.sonms.modifiernode.annotations.ModifierNodeFactory
+import com.sonms.modifiernode.annotations.SkipWhenFalse
+
+/**
+ * 가장자리를 서서히 투명하게 만드는 페이드 엣지 효과.
+ * Fading edge effect that fades the edges to transparent.
+ *
+ * 오프스크린 [GraphicsLayer] 에 컨텐츠를 그린 뒤 같은 버퍼에 `DstIn` 그라디언트 마스크를 얹는다.
+ * Draws the content into an offscreen [GraphicsLayer], then applies a `DstIn` gradient mask to it.
+ */
+@ModifierNodeFactory(name = "fadingEdge")
+internal class FadingEdgeNode(
+ @Invalidates(Draw) var isVertical: Boolean,
+ @Invalidates(Draw) var fraction: Float,
+ @SkipWhenFalse @Invalidates(None) var enabled: Boolean,
+) : Modifier.Node(), DrawModifierNode {
+
+ override val shouldAutoInvalidate: Boolean get() = false
+
+ private var layer: GraphicsLayer? = null
+
+ override fun onAttach() {
+ layer = requireGraphicsContext().createGraphicsLayer()
+ }
+
+ override fun onDetach() {
+ layer?.let { requireGraphicsContext().releaseGraphicsLayer(it) }
+ layer = null
+ }
+
+ override fun ContentDrawScope.draw() {
+ val gl = layer
+ if (gl == null) {
+ drawContent()
+ return
+ }
+
+ val f = fraction
+ val maskBrush = if (isVertical) {
+ Brush.verticalGradient(
+ 0f to Color.Transparent,
+ f to Color.Black,
+ (1f - f) to Color.Black,
+ 1f to Color.Transparent,
+ )
+ } else {
+ Brush.horizontalGradient(
+ 0f to Color.Transparent,
+ f to Color.Black,
+ (1f - f) to Color.Black,
+ 1f to Color.Transparent,
+ )
+ }
+
+ // DrawScope 스코프의 record — drawContent() 를 레이어에 올바르게 기록한다.
+ gl.record(size.toIntSize()) {
+ this@draw.drawContent()
+ drawRect(brush = maskBrush, blendMode = BlendMode.DstIn)
+ }
+ drawLayer(gl)
+ }
+}
diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FixedSquare.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FixedSquare.kt
new file mode 100644
index 0000000..b79f0c5
--- /dev/null
+++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FixedSquare.kt
@@ -0,0 +1,43 @@
+package com.sonms.modifiernode.sample
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import com.sonms.modifiernode.annotations.Invalidates
+import com.sonms.modifiernode.annotations.InvalidationScope.Draw
+import com.sonms.modifiernode.annotations.InvalidationScope.Measure
+import com.sonms.modifiernode.annotations.ModifierNodeFactory
+
+/**
+ * 정사각형 크기로 강제하고 그 위에 색을 덧칠하는 draw + layout 노드.
+ * `side` 변경은 remeasure, `overlay` 변경은 redraw 만 — 생성된 `update()` 의 계층 접기 예.
+ *
+ * Draw + layout node that forces a square size and tints over it.
+ * Changing `side` remeasures; changing `overlay` only redraws — shows the generated `update()` folding.
+ */
+@ModifierNodeFactory(name = "fixedSquare")
+internal class FixedSquareNode(
+ @Invalidates(Measure) var side: Dp,
+ @Invalidates(Draw) var overlay: Color,
+) : Modifier.Node(), DrawModifierNode, LayoutModifierNode {
+
+ override val shouldAutoInvalidate: Boolean get() = false
+
+ override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
+ val px = side.roundToPx()
+ val placeable = measurable.measure(Constraints.fixed(px, px))
+ return layout(px, px) { placeable.place(0, 0) }
+ }
+
+ override fun ContentDrawScope.draw() {
+ drawContent()
+ drawRect(overlay)
+ }
+}
diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/AccentOverlay.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/AccentOverlay.kt
new file mode 100644
index 0000000..f97151d
--- /dev/null
+++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/AccentOverlay.kt
@@ -0,0 +1,39 @@
+package com.sonms.modifiernode.sample.compare
+
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.currentValueOf
+import com.sonms.modifiernode.annotations.Invalidates
+import com.sonms.modifiernode.annotations.InvalidationScope.Draw
+import com.sonms.modifiernode.annotations.ModifierNodeFactory
+
+val LocalAccentColor = compositionLocalOf { Color.Magenta }
+
+/** BEFORE — `composed { }` 로 CompositionLocal 을 읽는다. `COMPARISON.md` Case A 참고. */
+fun Modifier.accentOverlayComposed(alpha: Float): Modifier = composed {
+ val color = LocalAccentColor.current
+ drawWithContent {
+ drawContent()
+ drawRect(color.copy(alpha = alpha))
+ }
+}
+
+/** AFTER — 같은 효과를 `Modifier.Node` 로. `draw()` 안의 `currentValueOf` 는 관찰되므로 로컬 변경 시 자동 redraw. */
+@ModifierNodeFactory(name = "accentOverlay")
+internal class AccentOverlayNode(
+ @Invalidates(Draw) var alpha: Float,
+) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode {
+ override val shouldAutoInvalidate: Boolean get() = false
+
+ override fun ContentDrawScope.draw() {
+ val color = currentValueOf(LocalAccentColor)
+ drawContent()
+ drawRect(color.copy(alpha = alpha))
+ }
+}
diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt
new file mode 100644
index 0000000..321fba2
--- /dev/null
+++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt
@@ -0,0 +1,85 @@
+package com.sonms.modifiernode.sample.compare
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.node.DrawModifierNode
+import com.sonms.modifiernode.annotations.Invalidates
+import com.sonms.modifiernode.annotations.InvalidationScope.Draw
+import com.sonms.modifiernode.annotations.InvalidationScope.None
+import com.sonms.modifiernode.annotations.ModifierNodeFactory
+import com.sonms.modifiernode.annotations.OnChange
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+/** BEFORE — `composed { }` 로 InteractionSource 구독 + 애니메이션. `COMPARISON.md` Case B 참고. */
+fun Modifier.pressScaleComposed(
+ interactionSource: InteractionSource,
+ pressedScale: Float,
+): Modifier = composed {
+ val pressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (pressed) pressedScale else 1f,
+ label = "pressScale",
+ )
+ graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
+}
+
+/**
+ * AFTER — 같은 효과를 `Modifier.Node` 로. `interactionSource` 는 `@OnChange` — 바뀌면
+ * 생성된 `update()` 가 [onInteractionSourceChanged] 를 불러 재구독한다.
+ */
+@ModifierNodeFactory(name = "pressScale")
+internal class PressScaleNode(
+ @Invalidates(Draw) var pressedScale: Float,
+ @Invalidates(None) @OnChange var interactionSource: InteractionSource,
+) : Modifier.Node(), DrawModifierNode {
+
+ override val shouldAutoInvalidate: Boolean get() = false
+
+ private val scaleAnim = Animatable(1f)
+ private var collectJob: Job? = null
+
+ override fun onAttach() = subscribe()
+
+ override fun onDetach() {
+ collectJob?.cancel()
+ collectJob = null
+ }
+
+ fun onInteractionSourceChanged() = subscribe()
+
+ private fun subscribe() {
+ collectJob?.cancel()
+ collectJob = coroutineScope.launch {
+ val presses = ArrayList()
+ interactionSource.interactions.collect { interaction ->
+ when (interaction) {
+ is PressInteraction.Press -> presses.add(interaction)
+ is PressInteraction.Release -> presses.remove(interaction.press)
+ is PressInteraction.Cancel -> presses.remove(interaction.press)
+ }
+ val target = if (presses.isNotEmpty()) pressedScale else 1f
+ launch { scaleAnim.animateTo(target) }
+ }
+ }
+ }
+
+ override fun ContentDrawScope.draw() {
+ val s = scaleAnim.value // snapshot 상태 → 애니메이션 프레임마다 자동 redraw
+ scale(s, s, center) {
+ this@draw.drawContent()
+ }
+ }
+}
diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt
new file mode 100644
index 0000000..4dcb564
--- /dev/null
+++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt
@@ -0,0 +1,149 @@
+package com.sonms.modifiernode.sample
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.invalidateDraw
+import androidx.compose.ui.node.invalidateMeasurement
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+/**
+ * `shouldAutoInvalidate = false` 노드는 `update()` 가 부른 것만 무효화한다 (ARCHITECTURE.md §10).
+ * 동일 하네스에서 auto ON/OFF 두 노드를 비교하고, 카운터는 노드 자신의 measure()/draw() 에서 증가시킨다.
+ */
+@RunWith(RobolectricTestRunner::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34])
+class AutoInvalidateProbeTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ private class Counters {
+ var measures = 0
+ var draws = 0
+ }
+
+ private val onC = Counters()
+ private val offC = Counters()
+
+ // autoInvalidate ON (기본)
+ private inner class AutoOnNode(var drawKey: Int, var measureKey: Int) :
+ Modifier.Node(), DrawModifierNode, LayoutModifierNode {
+ override fun MeasureScope.measure(m: Measurable, c: Constraints): MeasureResult {
+ onC.measures++
+ val p = m.measure(c)
+ return layout(p.width, p.height) { p.place(0, 0) }
+ }
+ override fun ContentDrawScope.draw() { onC.draws++; drawContent() }
+ }
+
+ private inner class AutoOnElement(val drawKey: Int, val measureKey: Int) :
+ ModifierNodeElement() {
+ override fun create() = AutoOnNode(drawKey, measureKey)
+ override fun update(node: AutoOnNode) {
+ val d = node.drawKey != drawKey
+ val m = node.measureKey != measureKey
+ node.drawKey = drawKey
+ node.measureKey = measureKey
+ if (m) node.invalidateMeasurement() else if (d) node.invalidateDraw()
+ }
+ override fun InspectorInfo.inspectableProperties() { name = "autoOn" }
+ override fun equals(other: Any?) =
+ other is AutoOnElement && other.drawKey == drawKey && other.measureKey == measureKey
+ override fun hashCode() = drawKey * 31 + measureKey
+ }
+
+ // autoInvalidate OFF
+ private inner class AutoOffNode(var drawKey: Int, var measureKey: Int) :
+ Modifier.Node(), DrawModifierNode, LayoutModifierNode {
+ override val shouldAutoInvalidate: Boolean get() = false
+ override fun MeasureScope.measure(m: Measurable, c: Constraints): MeasureResult {
+ offC.measures++
+ val p = m.measure(c)
+ return layout(p.width, p.height) { p.place(0, 0) }
+ }
+ override fun ContentDrawScope.draw() { offC.draws++; drawContent() }
+ }
+
+ private inner class AutoOffElement(val drawKey: Int, val measureKey: Int) :
+ ModifierNodeElement() {
+ override fun create() = AutoOffNode(drawKey, measureKey)
+ override fun update(node: AutoOffNode) {
+ val d = node.drawKey != drawKey
+ val m = node.measureKey != measureKey
+ node.drawKey = drawKey
+ node.measureKey = measureKey
+ if (m) node.invalidateMeasurement() else if (d) node.invalidateDraw()
+ }
+ override fun InspectorInfo.inspectableProperties() { name = "autoOff" }
+ override fun equals(other: Any?) =
+ other is AutoOffElement && other.drawKey == drawKey && other.measureKey == measureKey
+ override fun hashCode() = drawKey * 31 + measureKey
+ }
+
+ /** @param mutate 0 = drawKey 변경, 1 = measureKey 변경 */
+ private fun run(makeOn: Boolean, mutate: Int): Counters {
+ val dk = mutableIntStateOf(0)
+ val mk = mutableIntStateOf(0)
+ val counters = if (makeOn) onC else offC
+
+ rule.setContent {
+ val d = dk.intValue
+ val m = mk.intValue
+ val mod: Modifier = if (makeOn) {
+ Modifier.size(24.dp).then(AutoOnElement(d, m))
+ } else {
+ Modifier.size(24.dp).then(AutoOffElement(d, m))
+ }
+ Box(mod)
+ }
+ rule.waitForIdle()
+ counters.measures = 0
+ counters.draws = 0
+
+ if (mutate == 0) dk.intValue = 1 else mk.intValue = 1
+ rule.waitForIdle()
+ return counters
+ }
+
+ /** 기준선: auto ON 이면 draw 파라미터만 바꿔도 remeasure. */
+ @Test
+ fun autoOn_drawParam_change_remeasures() {
+ val c = run(makeOn = true, mutate = 0)
+ assertTrue(c.measures >= 1)
+ }
+
+ /** auto OFF: draw 파라미터 변경은 remeasure 를 유발하지 않는다. */
+ @Test
+ fun autoOff_drawParam_change_does_NOT_remeasure() {
+ val c = run(makeOn = false, mutate = 0)
+ assertEquals(0, c.measures)
+ }
+
+ /** auto OFF: measure 파라미터 변경 시 update() 의 invalidateMeasurement() 는 정상 작동. */
+ @Test
+ fun autoOff_measureParam_change_DOES_remeasure_via_update() {
+ val c = run(makeOn = false, mutate = 1)
+ assertTrue(c.measures >= 1)
+ }
+}
diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt
new file mode 100644
index 0000000..3f603dc
--- /dev/null
+++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt
@@ -0,0 +1,60 @@
+package com.sonms.modifiernode.sample
+
+import androidx.compose.ui.graphics.Color
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.reflect.KVisibility
+
+/** 생성된 Element 의 equals/hashCode/create/update 계약과 가시성. Compose 런타임 불필요. */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class GeneratedElementTest {
+
+ @Test
+ fun equal_args_produce_equal_elements() {
+ assertEquals(DebugTintElement(Color.Red), DebugTintElement(Color.Red))
+ assertEquals(
+ DebugTintElement(Color.Red).hashCode(),
+ DebugTintElement(Color.Red).hashCode(),
+ )
+ }
+
+ @Test
+ fun differing_args_produce_non_equal_elements() {
+ assertNotEquals(DebugTintElement(Color.Red), DebugTintElement(Color.Blue))
+ }
+
+ @Test
+ fun multi_param_equality_is_per_field() {
+ assertEquals(FixedSquareElement(mkDp(8), Color.Red), FixedSquareElement(mkDp(8), Color.Red))
+ assertNotEquals(FixedSquareElement(mkDp(8), Color.Red), FixedSquareElement(mkDp(9), Color.Red))
+ assertNotEquals(FixedSquareElement(mkDp(8), Color.Red), FixedSquareElement(mkDp(8), Color.Blue))
+ }
+
+ @Test
+ fun update_syncs_changed_field_to_node() {
+ val node = DebugTintNode(Color.Red)
+ DebugTintElement(Color.Blue).update(node) // detached 노드에서 invalidateDraw() 는 no-op
+
+ assertEquals(Color.Blue, node.color)
+ }
+
+ @Test
+ fun create_seeds_node_from_element_args() {
+ val node = DebugTintElement(Color.Green).create()
+ assertEquals(Color.Green, node.color)
+ }
+
+ /** ABI: 생성된 Element 는 노드 가시성과 무관하게 internal. */
+ @Test
+ fun generated_element_is_internal() {
+ assertEquals(KVisibility.INTERNAL, DebugTintElement::class.visibility)
+ assertEquals(KVisibility.INTERNAL, FixedSquareElement::class.visibility)
+ }
+
+ private fun mkDp(v: Int) = androidx.compose.ui.unit.Dp(v.toFloat())
+}
diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt
new file mode 100644
index 0000000..a890a85
--- /dev/null
+++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt
@@ -0,0 +1,139 @@
+package com.sonms.modifiernode.sample
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import com.sonms.modifiernode.annotations.Invalidates
+import com.sonms.modifiernode.annotations.InvalidationScope.Draw
+import com.sonms.modifiernode.annotations.InvalidationScope.Measure
+import com.sonms.modifiernode.annotations.ModifierNodeFactory
+import com.sonms.modifiernode.annotations.OnChange
+import com.sonms.modifiernode.annotations.SkipWhenFalse
+import com.sonms.modifiernode.annotations.SkipWhenTrue
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertSame
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+/** 노드가 자기 measure/draw 호출 횟수를 기록. */
+object ProbeCounters {
+ var measures = 0
+ var draws = 0
+ fun reset() {
+ measures = 0
+ draws = 0
+ }
+}
+
+@ModifierNodeFactory(name = "probe")
+internal class ProbeNode(
+ @Invalidates(Draw) var drawKey: Int,
+ @Invalidates(Measure) var measureKey: Int,
+) : Modifier.Node(), DrawModifierNode, LayoutModifierNode {
+
+ override val shouldAutoInvalidate: Boolean get() = false
+
+ override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
+ ProbeCounters.measures++
+ val placeable = measurable.measure(constraints)
+ return layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ }
+
+ override fun ContentDrawScope.draw() {
+ ProbeCounters.draws++
+ drawContent()
+ }
+}
+
+@ModifierNodeFactory(name = "skipProbe")
+internal class SkipProbeNode(
+ @SkipWhenFalse var on: Boolean,
+ @SkipWhenTrue var hidden: Boolean,
+ @Invalidates(Draw) var tag: Int,
+) : Modifier.Node(), DrawModifierNode {
+ override val shouldAutoInvalidate: Boolean get() = false
+ override fun ContentDrawScope.draw() = drawContent()
+}
+
+object OnChangeCount {
+ var value = 0
+}
+
+@ModifierNodeFactory(name = "onChangeProbe")
+internal class OnChangeProbeNode(
+ @OnChange @Invalidates(Draw) var key: Int,
+) : Modifier.Node(), DrawModifierNode {
+ override val shouldAutoInvalidate: Boolean get() = false
+ fun onKeyChanged() { OnChangeCount.value++ }
+ override fun ContentDrawScope.draw() = drawContent()
+}
+
+/** equals 스킵과 `@SkipWhenFalse` 가드. Element 가 equal 이면 Compose 는 `update()` 를 부르지 않는다. */
+@RunWith(RobolectricTestRunner::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34])
+class InvalidationContractTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun unchanged_params_skip_update_entirely() {
+ val drawKey = mutableIntStateOf(0)
+ val tick = mutableIntStateOf(0)
+
+ rule.setContent {
+ tick.intValue // 상위 재구성만 유발, probe 파라미터는 고정
+ Box(Modifier.size(24.dp).probe(drawKey.intValue, 0))
+ }
+ rule.waitForIdle()
+ ProbeCounters.reset()
+
+ tick.intValue = 1
+ rule.waitForIdle()
+
+ assertEquals(0, ProbeCounters.measures)
+ assertEquals(0, ProbeCounters.draws)
+ }
+
+ @Test
+ fun skip_markers_gate_application() {
+ // 적용: on=true 이고 hidden=false
+ assertNotSame(Modifier, Modifier.skipProbe(on = true, hidden = false, tag = 0))
+ // @SkipWhenFalse: on=false → skip
+ assertSame(Modifier, Modifier.skipProbe(on = false, hidden = false, tag = 0))
+ // @SkipWhenTrue: hidden=true → skip
+ assertSame(Modifier, Modifier.skipProbe(on = true, hidden = true, tag = 0))
+ }
+
+ @Test
+ fun onChange_fires_only_when_param_changes() {
+ val key = mutableIntStateOf(0)
+ rule.setContent { Box(Modifier.size(24.dp).onChangeProbe(key.intValue)) }
+ rule.waitForIdle()
+ OnChangeCount.value = 0
+
+ key.intValue = 1
+ rule.waitForIdle()
+ assertEquals(1, OnChangeCount.value)
+
+ key.intValue = 1 // 같은 값 → update() 미호출
+ rule.waitForIdle()
+ assertEquals(1, OnChangeCount.value)
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index efe9548..dadf91f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -23,3 +23,6 @@ rootProject.name = "VelvetCompose"
include(":app")
include(":wheelpicker")
include(":ratingbar")
+include(":modifier-node-annotations")
+include(":modifier-node-processor")
+include(":modifier-node-sample")