Skip to content

Commit 6ae88ac

Browse files
authored
feat(compose): add Clip node for embedding pattern values in the DSL (#97)
* feat(core): add internal HapticPattern.spanMs and route rawSpanMs and PatternElement duration through it * feat(core): add HapticPattern.scaleIntensity transform * feat(core): add HapticPattern.timeStretch transform * feat(core): add HapticPattern.reversed transform * feat(core): add HapticPattern.then and plus concatenation transforms * feat(core): add HapticPattern.repeated transform * test(core): add property tests for HapticPattern algebra transforms * chore(core): apiDump for HapticPattern algebra transforms * feat(core): add clip alias for include on HapticPatternScope * feat(compose): add Clip node for embedding a pattern on the timeline * test(compose): add Clip acceptance tests for key-driven re-fire and freeze * chore: apiDump for clip alias and Clip node * docs(sample): drop stale merged-issue references from screen comments * feat(sample): add algebra demo screen (#99) * feat(sample): add algebra demo screen * docs(sample): drop stale merged-issue reference from algebra screen comment
1 parent af42057 commit 6ae88ac

18 files changed

Lines changed: 455 additions & 24 deletions

File tree

jindong-compose/api/jindong-compose.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public abstract interface class io/github/compose/jindong/JindongScope {
4040
public abstract interface annotation class io/github/compose/jindong/JindongScopeMarker : java/lang/annotation/Annotation {
4141
}
4242

43+
public final class io/github/compose/jindong/dsl/ClipKt {
44+
public static final fun Clip (Lio/github/compose/jindong/JindongScope;Lio/github/compose/jindong/core/model/HapticPattern;Landroidx/compose/runtime/Composer;I)V
45+
}
46+
4347
public final class io/github/compose/jindong/dsl/DelayKt {
4448
public static final fun Delay-dWUq8MI (Lio/github/compose/jindong/JindongScope;JLandroidx/compose/runtime/Composer;I)V
4549
}

jindong-compose/api/jindong-compose.klib.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ final class io.github.compose.jindong/HapticCapabilities { // io.github.compose.
4444

4545
final val io.github.compose.jindong/io_github_compose_jindong_HapticCapabilities$stableprop // io.github.compose.jindong/io_github_compose_jindong_HapticCapabilities$stableprop|#static{}io_github_compose_jindong_HapticCapabilities$stableprop[0]
4646

47+
final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl/Clip(io.github.compose.jindong.core.model/HapticPattern, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.compose.jindong.dsl/Clip|Clip@io.github.compose.jindong.JindongScope(io.github.compose.jindong.core.model.HapticPattern;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
4748
final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl/Delay(kotlin.time/Duration, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.compose.jindong.dsl/Delay|Delay@io.github.compose.jindong.JindongScope(kotlin.time.Duration;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
4849
final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl/Haptic(kotlin.time/Duration, io.github.compose.jindong.core.model/HapticIntensity?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // io.github.compose.jindong.dsl/Haptic|Haptic@io.github.compose.jindong.JindongScope(kotlin.time.Duration;io.github.compose.jindong.core.model.HapticIntensity?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
4950
final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl/Repeat(kotlin/Int, kotlin/Function3<io.github.compose.jindong/JindongScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.compose.jindong.dsl/Repeat|Repeat@io.github.compose.jindong.JindongScope(kotlin.Int;kotlin.Function3<io.github.compose.jindong.JindongScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright (C) 2026 compose-jindong
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.compose.jindong.dsl
17+
18+
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.ComposeNode
20+
import io.github.compose.jindong.JindongScope
21+
import io.github.compose.jindong.compose.JindongApplier
22+
import io.github.compose.jindong.core.element.PatternElement
23+
import io.github.compose.jindong.core.model.HapticPattern
24+
25+
/**
26+
* Places a prebuilt [pattern] on the timeline at the current position.
27+
*
28+
* A clip is a self-contained unit, typically an algebra result, dropped next to inline nodes:
29+
*
30+
* ```
31+
* Jindong(speed) {
32+
* Sequence {
33+
* Clip(heartbeat.timeStretch(1f / speed))
34+
* Delay(200.ms)
35+
* Haptic(60.ms, HapticIntensity.MEDIUM)
36+
* }
37+
* }
38+
* ```
39+
*
40+
* The value is captured when the node is inserted. [Jindong]'s composition is single-shot, so a new
41+
* [pattern] is only picked up when the whole pattern recompiles; thread it through the trigger keys
42+
* to make it live:
43+
*
44+
* ```
45+
* Jindong(pattern) { Clip(pattern) }
46+
* ```
47+
*
48+
* [HapticPattern] is a data class, so the key comparison is structural.
49+
*
50+
* @param pattern The prebuilt pattern to place on the timeline
51+
*/
52+
@Composable
53+
fun JindongScope.Clip(pattern: HapticPattern) {
54+
// update is empty because the composition never recomposes: the value captured by factory is final
55+
// for this compile pass (see Jindong's single-shot compilePattern).
56+
ComposeNode<PatternElement, JindongApplier>(
57+
factory = { PatternElement(pattern) },
58+
update = { },
59+
)
60+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright (C) 2026 compose-jindong
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
@file:OptIn(ExperimentalTestApi::class)
17+
18+
package io.github.compose.jindong
19+
20+
import androidx.compose.runtime.CompositionLocalProvider
21+
import androidx.compose.runtime.getValue
22+
import androidx.compose.runtime.mutableStateOf
23+
import androidx.compose.ui.test.ExperimentalTestApi
24+
import androidx.compose.ui.test.runComposeUiTest
25+
import io.github.compose.jindong.core.dsl.buildHapticPattern
26+
import io.github.compose.jindong.core.ms
27+
import io.github.compose.jindong.dsl.Clip
28+
import io.github.compose.jindong.dsl.Delay
29+
import io.github.compose.jindong.executor.LocalHapticExecutor
30+
import io.github.compose.jindong.executor.RecordingHapticExecutor
31+
import io.kotest.core.spec.style.FunSpec
32+
import io.kotest.matchers.shouldBe
33+
34+
private val onePulse = buildHapticPattern { haptic(50.ms) }
35+
private val twoPulses = buildHapticPattern {
36+
haptic(50.ms)
37+
delay(50.ms)
38+
haptic(50.ms)
39+
}
40+
41+
class ClipTest :
42+
FunSpec({
43+
test("Clip emits the clipped pattern's events, offset by preceding nodes") {
44+
runComposeUiTest {
45+
val recorder = RecordingHapticExecutor()
46+
47+
setContent {
48+
CompositionLocalProvider(LocalHapticExecutor provides recorder) {
49+
Jindong(Unit) {
50+
Delay(200.ms)
51+
Clip(onePulse)
52+
}
53+
}
54+
}
55+
56+
waitForIdle()
57+
58+
val events = recorder.executedPatterns.last().events
59+
events.size shouldBe 1
60+
// PatternElement.collectEvents offsets the clip by the 200ms Delay preceding it.
61+
events.single().startTimeMs shouldBe 200
62+
events.single().durationMs shouldBe 50
63+
}
64+
}
65+
66+
test("Clip re-fires with the new pattern when it is threaded through the key") {
67+
runComposeUiTest {
68+
val recorder = RecordingHapticExecutor()
69+
val patternState = mutableStateOf(onePulse)
70+
71+
setContent {
72+
val pattern by patternState
73+
CompositionLocalProvider(LocalHapticExecutor provides recorder) {
74+
Jindong(pattern) {
75+
Clip(pattern)
76+
}
77+
}
78+
}
79+
80+
waitForIdle()
81+
recorder.executedPatterns.last().events.size shouldBe 1
82+
83+
patternState.value = twoPulses
84+
waitForIdle()
85+
86+
// The pattern is a key, so the composition recompiles and the new clip's events are emitted.
87+
recorder.executedPatterns.last().events.size shouldBe 2
88+
}
89+
}
90+
91+
test("Clip freezes on the first pattern when it is not a key") {
92+
runComposeUiTest {
93+
val recorder = RecordingHapticExecutor()
94+
val patternState = mutableStateOf(onePulse)
95+
96+
setContent {
97+
val pattern by patternState
98+
CompositionLocalProvider(LocalHapticExecutor provides recorder) {
99+
// Unit is the only key: swapping patternState never invalidates the compiled result.
100+
Jindong(Unit) {
101+
Clip(pattern)
102+
}
103+
}
104+
}
105+
106+
waitForIdle()
107+
recorder.executedPatterns.last().events.size shouldBe 1
108+
109+
patternState.value = twoPulses
110+
waitForIdle()
111+
112+
// Contract guard: without threading the pattern through the key the first compile is stale,
113+
// so the executor still holds the one-event pattern.
114+
recorder.executedPatterns.last().events.size shouldBe 1
115+
}
116+
}
117+
})

jindong-core/api/jindong-core.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public abstract interface annotation class io/github/compose/jindong/core/dsl/Ha
2424
}
2525

2626
public final class io/github/compose/jindong/core/dsl/HapticPatternScope {
27+
public final fun clip (Lio/github/compose/jindong/core/model/HapticPattern;)V
2728
public final fun delay-LRDsOJo (J)V
2829
public final fun haptic-KLykuaI (JLio/github/compose/jindong/core/model/HapticIntensity;Lio/github/compose/jindong/core/model/IosHapticParameters;)V
2930
public static synthetic fun haptic-KLykuaI$default (Lio/github/compose/jindong/core/dsl/HapticPatternScope;JLio/github/compose/jindong/core/model/HapticIntensity;Lio/github/compose/jindong/core/model/IosHapticParameters;ILjava/lang/Object;)V

jindong-core/api/jindong-core.klib.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ abstract interface io.github.compose.jindong.core.executor/HapticHandle { // io.
3737
}
3838

3939
final class io.github.compose.jindong.core.dsl/HapticPatternScope { // io.github.compose.jindong.core.dsl/HapticPatternScope|null[0]
40+
final fun clip(io.github.compose.jindong.core.model/HapticPattern) // io.github.compose.jindong.core.dsl/HapticPatternScope.clip|clip(io.github.compose.jindong.core.model.HapticPattern){}[0]
4041
final fun delay(kotlin.time/Duration) // io.github.compose.jindong.core.dsl/HapticPatternScope.delay|delay(kotlin.time.Duration){}[0]
4142
final fun haptic(kotlin.time/Duration, io.github.compose.jindong.core.model/HapticIntensity = ..., io.github.compose.jindong.core.model/IosHapticParameters? = ...) // io.github.compose.jindong.core.dsl/HapticPatternScope.haptic|haptic(kotlin.time.Duration;io.github.compose.jindong.core.model.HapticIntensity;io.github.compose.jindong.core.model.IosHapticParameters?){}[0]
4243
final fun include(io.github.compose.jindong.core.model/HapticPattern) // io.github.compose.jindong.core.dsl/HapticPatternScope.include|include(io.github.compose.jindong.core.model.HapticPattern){}[0]

jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/dsl/HapticPatternScope.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,25 @@ class HapticPatternScope internal constructor() {
176176
rootElement.children.add(PatternElement(pattern))
177177
}
178178

179+
/**
180+
* Places a prebuilt [pattern] on the timeline at the current position.
181+
*
182+
* Alias for [include] that shares the vocabulary of the Compose `Clip` node, so a pattern reads the
183+
* same whether it is assembled with the builder DSL or the Compose DSL.
184+
*
185+
* Example:
186+
* ```kotlin
187+
* val composedPattern = buildHapticPattern {
188+
* clip(clickPattern)
189+
* delay(100.ms)
190+
* clip(clickPattern)
191+
* }
192+
* ```
193+
*
194+
* @param pattern The pattern to place on the timeline
195+
*/
196+
fun clip(pattern: HapticPattern) = include(pattern)
197+
179198
internal fun build(): HapticPattern {
180199
val events = rootElement.collectEvents(startTimeMs = 0L)
181200
return HapticPattern(events = events, rootElement = rootElement)

sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/JindongScaffold.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import io.github.compose.jindong.sample.theme.JindongTheme
6262
* App bar layout:
6363
* - left: back chevron `‹` (only when [screen] is not the hub) tapping [onBack].
6464
* - center: [Screen.title] (the Home title doubles as the wordmark).
65-
* - right: a mono counter (`진동` on Home, else `NN / 07`) plus a 30x30 theme toggle (`☾` light /
65+
* - right: a mono counter (`진동` on Home, else `NN / <module count>`) plus a 30x30 theme toggle (`☾` light /
6666
* `☀` dark) calling [onToggleTheme]. Both controls keep a >= 48dp hit area.
6767
*
6868
* The body uses [Dimens.screenPadH] horizontal padding, [Dimens.bodyTop] top and [Dimens.bodyBottom]
@@ -177,7 +177,7 @@ private fun AppBar(
177177
verticalAlignment = Alignment.CenterVertically,
178178
) {
179179
Text(
180-
text = if (screen.index == 0) "진동" else "${pad2(screen.index)} / 07",
180+
text = if (screen.index == 0) "진동" else "${pad2(screen.index)} / ${pad2(Screen.modules.size)}",
181181
style = JindongTheme.typography.monoMicro.copy(fontWeight = FontWeight.Normal),
182182
color = colors.text3,
183183
)

sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/AppRoot.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import androidx.compose.runtime.saveable.Saver
2323
import androidx.compose.runtime.saveable.rememberSaveable
2424
import androidx.compose.runtime.setValue
2525
import io.github.compose.jindong.sample.components.JindongScaffold
26+
import io.github.compose.jindong.sample.screens.AlgebraScreen
2627
import io.github.compose.jindong.sample.screens.HomeScreen
2728
import io.github.compose.jindong.sample.screens.IntensityLabScreen
2829
import io.github.compose.jindong.sample.screens.PresetGalleryScreen
@@ -34,7 +35,7 @@ import io.github.compose.jindong.sample.screens.TimingScreen
3435
import io.github.compose.jindong.sample.theme.JindongTheme
3536

3637
/**
37-
* Single-level navigation host. Home lists the seven modules; tapping one swaps [current], the app
38+
* Single-level navigation host. Home lists the feature modules; tapping one swaps [current], the app
3839
* bar back chevron returns to Home. The theme follows the system preference unless [darkOverride] is
3940
* set by the app-bar toggle.
4041
*
@@ -69,6 +70,7 @@ fun AppRoot() {
6970
Screen.RepeatIdx -> RepeatWithIndexScreen()
7071
Screen.Preset -> PresetGalleryScreen()
7172
Screen.Reactive -> ReactiveScreen()
73+
Screen.Algebra -> AlgebraScreen()
7274
}
7375
}
7476
}

sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/Screen.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
package io.github.compose.jindong.sample.nav
1717

1818
/**
19-
* Harness destinations. [Home] is the hub (index 0); [modules] lists the seven feature screens that
20-
* Home links to, in display order. [index] doubles as the app-bar counter value ("NN / 07").
19+
* Harness destinations. [Home] is the hub (index 0); [modules] lists the feature screens that
20+
* Home links to, in display order. [index] doubles as the app-bar counter value ("NN / <count>").
2121
*/
2222
sealed interface Screen {
2323
val routeId: String
@@ -81,8 +81,15 @@ sealed interface Screen {
8181
override val subtitle = "State-driven · no buttons ★"
8282
}
8383

84+
data object Algebra : Screen {
85+
override val routeId = "algebra"
86+
override val title = "Algebra"
87+
override val index = 8
88+
override val subtitle = "Transform a pattern, see the diff"
89+
}
90+
8491
companion object {
8592
val modules: List<Screen> =
86-
listOf(Single, Intensity, Timing, Repeat, RepeatIdx, Preset, Reactive)
93+
listOf(Single, Intensity, Timing, Repeat, RepeatIdx, Preset, Reactive, Algebra)
8794
}
8895
}

0 commit comments

Comments
 (0)