diff --git a/documentation/content/docs/api/jindong-compose/composable-dsl/clip.mdx b/documentation/content/docs/api/jindong-compose/composable-dsl/clip.mdx new file mode 100644 index 0000000..409bbe0 --- /dev/null +++ b/documentation/content/docs/api/jindong-compose/composable-dsl/clip.mdx @@ -0,0 +1,105 @@ +--- +title: Clip +description: DSL function to place a prebuilt pattern on the timeline +--- + +# Clip + + +**Module**: `jindong-compose` | **Package**: `io.github.compose.jindong.dsl` + + +Places a prebuilt `HapticPattern` on the timeline at the current position. + +## Signature + +```kotlin +@Composable +fun JindongScope.Clip(pattern: HapticPattern) +``` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `pattern` | `HapticPattern` | The prebuilt pattern to place on the timeline | + +## Description + +`Clip` drops a self-contained pattern next to inline nodes. It is the bridge between a `HapticPattern` value, typically an algebra result built with `buildHapticPattern`, and the composable DSL. The clip's events are offset by the nodes that precede it, so it schedules relative to its position like any other node. + +```kotlin +val heartbeat = buildHapticPattern { + haptic(60.ms) + delay(80.ms) + haptic(40.ms) +} + +Jindong(trigger) { + Clip(heartbeat) + Delay(200.ms) + Haptic(60.ms, HapticIntensity.MEDIUM) +} +``` + +## Freezing and re-firing + +A clip captures its `pattern` at compile time. Because `Jindong` compiles content in a single pass with no recomposition (see [The Reactive Contract](/docs/guide/reactive-contract)), swapping the pattern in state alone does not update the clip. Thread the pattern through the trigger keys to make it live: + +```kotlin +Jindong(pattern) { Clip(pattern) } +``` + +`HapticPattern` is a data class, so the key comparison is structural: an equal pattern does not re-fire, a different one does. + + +Do not add a `Jindong(pattern, vararg keys)` overload to skip the `Clip` call. `Jindong(p) { ... }` resolves to the existing `vararg keys` overload with `p` as a key, which compiles but plays the lambda instead of `p`. The `Jindong(pattern) { Clip(pattern) }` idiom is the supported way to embed a value. + + +## Usage + +### Composing with the pattern algebra + +Clips pair well with the pattern transforms, which return new `HapticPattern` values: + +```kotlin +val base = buildHapticPattern { + haptic(50.ms) + delay(50.ms) + haptic(50.ms) +} + +Jindong(speed) { + Clip(base.timeStretch(1f / speed)) +} +``` + +### Reusing a pattern across screens + +```kotlin +private val confirm = buildHapticPattern { + haptic(50.ms) + delay(50.ms) + haptic(100.ms) +} + +@Composable +fun SaveButton(saved: Boolean) { + Jindong(saved) { + if (saved) Clip(confirm) + } +} +``` + +## Notes + +- The pattern is captured when the node is inserted, not on every frame +- Preceding `Delay` and `Haptic` nodes shift the clip's start time +- `HapticPattern` equality is structural, which drives key comparison +- An empty pattern (`HapticPattern.Empty`) places nothing + +## See Also + +- [The Reactive Contract](/docs/guide/reactive-contract) +- [Sequence](/docs/api/jindong-compose/composable-dsl/sequence) +- [Core API](/docs/api/jindong-core/core-api) diff --git a/documentation/content/docs/api/jindong-compose/composable-dsl/meta.json b/documentation/content/docs/api/jindong-compose/composable-dsl/meta.json index fe344e2..b77284c 100644 --- a/documentation/content/docs/api/jindong-compose/composable-dsl/meta.json +++ b/documentation/content/docs/api/jindong-compose/composable-dsl/meta.json @@ -5,6 +5,7 @@ "delay", "sequence", "repeat", - "repeat-with-index" + "repeat-with-index", + "clip" ] } diff --git a/documentation/content/docs/api/jindong-compose/jindong.mdx b/documentation/content/docs/api/jindong-compose/jindong.mdx index ddfbaa3..f1d13e7 100644 --- a/documentation/content/docs/api/jindong-compose/jindong.mdx +++ b/documentation/content/docs/api/jindong-compose/jindong.mdx @@ -76,6 +76,22 @@ This is useful for: The content block is compiled into a `HapticPattern` containing scheduled events. The pattern is then executed by the `HapticExecutor` provided via `LocalHapticExecutor`. +### The Reactive Contract + +`Jindong` reads values and fires playback under three rules: + +1. Values read inside `content` are frozen at compile time. The block is compiled once per key change and does not recompose, so a captured state value is read once. +2. Keys are the playback trigger. Put a value in `keys` when its change should play; keep pattern-shaping values inside the block as parameters. +3. Cancel-and-restart is best effort. A key change cancels the in-flight playback first, but the platform stop calls report no completion, so brief physical overlap is possible. + +To make a value both shape the pattern and re-fire when it changes, pass it as a key and read it inside the block. For a prebuilt pattern, thread it through the keys and place it with `Clip`: + +```kotlin +Jindong(pattern) { Clip(pattern) } +``` + +See [The Reactive Contract](/docs/guide/reactive-contract) for the full explanation. + ## Usage ### Basic Usage @@ -169,6 +185,7 @@ Within the `JindongScope`, you can use: | [`Sequence`](/docs/api/jindong-compose/composable-dsl/sequence) | Group events sequentially | | [`Repeat`](/docs/api/jindong-compose/composable-dsl/repeat) | Repeat content N times | | [`RepeatWithIndex`](/docs/api/jindong-compose/composable-dsl/repeat-with-index) | Repeat with index access | +| [`Clip`](/docs/api/jindong-compose/composable-dsl/clip) | Place a prebuilt pattern on the timeline | ## Notes @@ -179,6 +196,7 @@ Within the `JindongScope`, you can use: ## See Also +- [The Reactive Contract](/docs/guide/reactive-contract) - [JindongProvider](/docs/api/jindong-compose/jindong-provider) - [JindongScope](/docs/api/jindong-compose/jindong-scope) - [Quick Start](/docs/guide/quick-start) diff --git a/documentation/content/docs/api/meta.json b/documentation/content/docs/api/meta.json index 406e9eb..4e3cd2b 100644 --- a/documentation/content/docs/api/meta.json +++ b/documentation/content/docs/api/meta.json @@ -14,6 +14,7 @@ "jindong-compose/composable-dsl/delay", "jindong-compose/composable-dsl/sequence", "jindong-compose/composable-dsl/repeat", - "jindong-compose/composable-dsl/repeat-with-index" + "jindong-compose/composable-dsl/repeat-with-index", + "jindong-compose/composable-dsl/clip" ] } diff --git a/documentation/content/docs/guide/meta.json b/documentation/content/docs/guide/meta.json index 7c4e5f9..0ec889e 100644 --- a/documentation/content/docs/guide/meta.json +++ b/documentation/content/docs/guide/meta.json @@ -3,6 +3,7 @@ "thinking-declarative", "why-jindong", "getting-started", - "quick-start" + "quick-start", + "reactive-contract" ] } diff --git a/documentation/content/docs/guide/quick-start.mdx b/documentation/content/docs/guide/quick-start.mdx index 06a6885..bef0426 100644 --- a/documentation/content/docs/guide/quick-start.mdx +++ b/documentation/content/docs/guide/quick-start.mdx @@ -270,7 +270,8 @@ Jindong(level) { ``` If a captured value is not in the keys, the pattern keeps the value from the last -key change instead of updating. +key change instead of updating. [The Reactive Contract](/docs/guide/reactive-contract) +covers when values are read and when playback fires. ## Next Steps diff --git a/documentation/content/docs/guide/reactive-contract.mdx b/documentation/content/docs/guide/reactive-contract.mdx new file mode 100644 index 0000000..f8ec866 --- /dev/null +++ b/documentation/content/docs/guide/reactive-contract.mdx @@ -0,0 +1,83 @@ +--- +title: The Reactive Contract +description: How Jindong decides when to play and which values it reads +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +# The Reactive Contract + +`Jindong` behaves like `LaunchedEffect`: it plays the pattern in its content block, and a change to any key restarts it. Three rules describe exactly which values it reads and when it fires. Everything about reactive `Jindong` usage follows from them. + +## Rule 1: values in content are frozen at compile time + +When a key changes, `Jindong` compiles the content block into a pattern once and plays it. The compilation runs in a composition that produces the pattern in a single pass and is then torn down, so there is no recomposition. A state value the block reads is read once, at that compile, and does not update on its own afterward. + +```kotlin +var level by remember { mutableStateOf(1) } + +// `level` is read once, when the current key last changed. +Jindong(trigger) { + RepeatWithIndex(level) { index -> + Haptic(50.ms) + } +} +``` + +If `level` changes but `trigger` does not, the played pattern keeps the `level` from the last time `trigger` changed. + +## Rule 2: keys are the playback trigger + +A key change is what fires playback. Put a value in the keys exactly when its change should play. Values that only shape the pattern belong inside the block as parameters. + +The distinction matters most with continuous input. A slider value passed as a key fires a vibration on every drag step: + +```kotlin +// Fires on every value change while dragging. +Jindong(sliderValue) { + Haptic((sliderValue * 100).toInt().ms) +} +``` + +The same value as a parameter shapes the next playback without firing on each step: + +```kotlin +// Reads sliderValue when `commit` changes; does not fire mid-drag. +Jindong(commit) { + Haptic((sliderValue * 100).toInt().ms) +} +``` + +To make a value both shape the pattern and re-fire when it changes, pass it as a key and read it inside the block: + +```kotlin +Jindong(level) { + RepeatWithIndex(level) { index -> + Haptic(50.ms, HapticIntensity.Custom(1f - index * 0.1f)) + } +} +``` + + +Prebuilt patterns follow the same rule. `Clip` captures its pattern at compile time, so thread the pattern through the keys to re-fire on change: `Jindong(pattern) { Clip(pattern) }`. See [Clip](/docs/api/jindong-compose/composable-dsl/clip). + + +## Rule 3: cancel-and-restart is best effort + +A key change cancels the in-flight playback before starting the new one. The manager serializes this with a state lock, so a fast sequence of key changes cancels in order and the last one wins. + +The cancellation itself is best effort. Neither `Vibrator.cancel()` on Android nor `CHHapticPatternPlayer.stop` on iOS reports when the hardware has actually stopped, so a few milliseconds of physical overlap between the old and new playback are possible. Patterns that change keys rapidly should not assume a clean cut between them. + +## Choosing keys + +| Value | Where it goes | Effect | +|-------|---------------|--------| +| Should fire playback when it changes | Key | Each change plays | +| Shapes the pattern, read at the next fire | Parameter in the block | Frozen until a key changes | +| Should both shape and fire | Key and read in the block | Each change plays with the new value | + +## See also + +- [Jindong API](/docs/api/jindong-compose/jindong) +- [Clip](/docs/api/jindong-compose/composable-dsl/clip) +- [Quick Start](/docs/guide/quick-start) diff --git a/documentation/content/docs/guide/thinking-declarative.mdx b/documentation/content/docs/guide/thinking-declarative.mdx index 1fdff71..8374dc8 100644 --- a/documentation/content/docs/guide/thinking-declarative.mdx +++ b/documentation/content/docs/guide/thinking-declarative.mdx @@ -89,4 +89,6 @@ Declarative programming means: 2. **Delegate control** to the framework 3. **Trust the abstraction** to handle details -This mental model—focusing on "what" rather than "how"—is the foundation of effective Jindong usage. +This mental model, focusing on "what" rather than "how", is the foundation of effective Jindong usage. + +The one place the framework's control shows through is timing: `Jindong` reads its content and fires playback under a small set of rules. [The Reactive Contract](/docs/guide/reactive-contract) spells them out. diff --git a/documentation/content/docs/meta.json b/documentation/content/docs/meta.json index fce9c36..e974ee8 100644 --- a/documentation/content/docs/meta.json +++ b/documentation/content/docs/meta.json @@ -16,6 +16,7 @@ "api/jindong-compose/composable-dsl/sequence", "api/jindong-compose/composable-dsl/repeat", "api/jindong-compose/composable-dsl/repeat-with-index", + "api/jindong-compose/composable-dsl/clip", "---Contributing---", "...contributing" ] diff --git a/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt b/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt index 2158e6f..782bdea 100644 --- a/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt +++ b/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt @@ -29,12 +29,24 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** - * Composable that triggers haptic pattern execution when keys change. + * Composable that compiles the haptic pattern in [content] and plays it when [keys] change. * - * This works similarly to [LaunchedEffect] - when any of the keys change, - * the haptic pattern defined in [content] is compiled and executed. + * Like [LaunchedEffect], a change to any key restarts the effect: the pattern is recompiled and + * played. The reactive behavior follows three rules. + * + * 1. Values read inside [content] are frozen at compile time. The pattern is compiled once per key + * change through a single-shot composition that never recomposes (see [compilePattern]), so a + * state value the block captures is read once and does not update on its own. + * 2. Keys are the playback trigger. Put a value in [keys] exactly when its change should fire + * playback. A slider value passed as a key fires on every drag step; state that only shapes the + * pattern belongs inside [content] as a parameter. + * 3. Cancel-and-restart is best effort. A key change cancels the in-flight playback before starting + * the new one, ordered by the manager's state lock. The platform stop calls do not report + * completion, so a few milliseconds of physical overlap are possible. + * + * To make a value both shape the pattern and re-fire when it changes, pass it as a key and read it + * inside the block: * - * Example: * ``` * var count by remember { mutableStateOf(0) } * @@ -48,6 +60,13 @@ import kotlinx.coroutines.launch * } * ``` * + * To embed a prebuilt pattern and re-fire when it changes, thread it through the keys and place it + * with [io.github.compose.jindong.dsl.Clip]: + * + * ``` + * Jindong(pattern) { Clip(pattern) } + * ``` + * * @param keys Keys that trigger re-execution when changed (like [LaunchedEffect]) * @param content DSL block defining the haptic pattern */ diff --git a/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/JindongContractTest.kt b/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/JindongContractTest.kt new file mode 100644 index 0000000..20bc622 --- /dev/null +++ b/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/JindongContractTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalTestApi::class) + +package io.github.compose.jindong + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import io.github.compose.jindong.core.ms +import io.github.compose.jindong.dsl.Haptic +import io.github.compose.jindong.executor.LocalHapticExecutor +import io.github.compose.jindong.executor.RecordingHapticExecutor +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +/** + * Guards the reactive contract at the [Jindong] level: keys are the only playback trigger, and a + * value read inside `content` but left out of the keys is frozen at the last key change. + * + * The Clip-level counterpart lives in [ClipTest]; this covers a parameter read directly by an + * inline node. + */ +class JindongContractTest : + FunSpec({ + test("a parameter read inside content but absent from keys does not re-fire") { + runComposeUiTest { + val recorder = RecordingHapticExecutor() + val triggerKey = mutableStateOf(0) + val durationMs = mutableStateOf(50) + + setContent { + val key by triggerKey + val duration by durationMs + CompositionLocalProvider(LocalHapticExecutor provides recorder) { + // duration shapes the pattern but is not a key, so changing it must not fire. + Jindong(key) { + Haptic(duration.ms) + } + } + } + + waitForIdle() + recorder.executedPatterns.size shouldBe 1 + recorder.executedPatterns.last().events.single().durationMs shouldBe 50 + + durationMs.value = 200 + waitForIdle() + + // Contract: only a key change triggers playback. Mutating a parameter leaves the executor + // untouched, so no new pattern is recorded. + recorder.executedPatterns.size shouldBe 1 + recorder.executedPatterns.last().events.single().durationMs shouldBe 50 + } + } + + test("a key change re-fires and recompiles content with the current parameter") { + runComposeUiTest { + val recorder = RecordingHapticExecutor() + val triggerKey = mutableStateOf(0) + val durationMs = mutableStateOf(50) + + setContent { + val key by triggerKey + val duration by durationMs + CompositionLocalProvider(LocalHapticExecutor provides recorder) { + Jindong(key) { + Haptic(duration.ms) + } + } + } + + waitForIdle() + recorder.executedPatterns.size shouldBe 1 + recorder.executedPatterns.last().events.single().durationMs shouldBe 50 + + // Update the parameter first: it stays frozen until a key change picks it up. + durationMs.value = 200 + triggerKey.value = 1 + waitForIdle() + + // The key changed, so playback fires again and the recompiled pattern reflects the + // parameter value read at that point. + recorder.executedPatterns.size shouldBe 2 + recorder.executedPatterns.last().events.single().durationMs shouldBe 200 + } + } + })