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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions documentation/content/docs/api/jindong-compose/composable-dsl/clip.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
title: Clip
description: DSL function to place a prebuilt pattern on the timeline
---

# Clip

<Callout type="info">
**Module**: `jindong-compose` | **Package**: `io.github.compose.jindong.dsl`
</Callout>

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.

<Callout type="warning">
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.
</Callout>

## 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)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"delay",
"sequence",
"repeat",
"repeat-with-index"
"repeat-with-index",
"clip"
]
}
18 changes: 18 additions & 0 deletions documentation/content/docs/api/jindong-compose/jindong.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
3 changes: 2 additions & 1 deletion documentation/content/docs/api/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
3 changes: 2 additions & 1 deletion documentation/content/docs/guide/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"thinking-declarative",
"why-jindong",
"getting-started",
"quick-start"
"quick-start",
"reactive-contract"
]
}
3 changes: 2 additions & 1 deletion documentation/content/docs/guide/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 83 additions & 0 deletions documentation/content/docs/guide/reactive-contract.mdx
Original file line number Diff line number Diff line change
@@ -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))
}
}
```

<Callout type="info">
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).
</Callout>

## 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)
4 changes: 3 additions & 1 deletion documentation/content/docs/guide/thinking-declarative.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions documentation/content/docs/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
*
Expand All @@ -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
*/
Expand Down
Loading
Loading