Skip to content
21 changes: 21 additions & 0 deletions jindong-core/api/jindong-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ public final class io/github/compose/jindong/core/model/IosHapticParameters {
public fun toString ()Ljava/lang/String;
}

public final class io/github/compose/jindong/core/model/RepeatedKt {
public static final fun repeated (Lio/github/compose/jindong/core/model/HapticPattern;I)Lio/github/compose/jindong/core/model/HapticPattern;
}

public final class io/github/compose/jindong/core/model/ReversedKt {
public static final fun reversed (Lio/github/compose/jindong/core/model/HapticPattern;)Lio/github/compose/jindong/core/model/HapticPattern;
}

public final class io/github/compose/jindong/core/model/ScaleIntensityKt {
public static final fun scaleIntensity (Lio/github/compose/jindong/core/model/HapticPattern;F)Lio/github/compose/jindong/core/model/HapticPattern;
}

public final class io/github/compose/jindong/core/model/ScheduledHapticEvent {
public fun <init> (JJLio/github/compose/jindong/core/model/HapticIntensity;Lio/github/compose/jindong/core/model/IosHapticParameters;)V
public synthetic fun <init> (JJLio/github/compose/jindong/core/model/HapticIntensity;Lio/github/compose/jindong/core/model/IosHapticParameters;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand All @@ -204,3 +216,12 @@ public final class io/github/compose/jindong/core/model/ScheduledHapticEvent {
public fun toString ()Ljava/lang/String;
}

public final class io/github/compose/jindong/core/model/ThenKt {
public static final fun plus (Lio/github/compose/jindong/core/model/HapticPattern;Lio/github/compose/jindong/core/model/HapticPattern;)Lio/github/compose/jindong/core/model/HapticPattern;
public static final fun then (Lio/github/compose/jindong/core/model/HapticPattern;Lio/github/compose/jindong/core/model/HapticPattern;)Lio/github/compose/jindong/core/model/HapticPattern;
}

public final class io/github/compose/jindong/core/model/TimeStretchKt {
public static final fun timeStretch (Lio/github/compose/jindong/core/model/HapticPattern;F)Lio/github/compose/jindong/core/model/HapticPattern;
}

6 changes: 6 additions & 0 deletions jindong-core/api/jindong-core.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ final val io.github.compose.jindong.core/ms // io.github.compose.jindong.core/ms
final val io.github.compose.jindong.core/ms // io.github.compose.jindong.core/ms|@kotlin.Long{}ms[0]
final fun (kotlin/Long).<get-ms>(): kotlin.time/Duration // io.github.compose.jindong.core/ms.<get-ms>|<get-ms>@kotlin.Long(){}[0]

final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/plus(io.github.compose.jindong.core.model/HapticPattern): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/plus|plus@io.github.compose.jindong.core.model.HapticPattern(io.github.compose.jindong.core.model.HapticPattern){}[0]
final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/repeated(kotlin/Int): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/repeated|repeated@io.github.compose.jindong.core.model.HapticPattern(kotlin.Int){}[0]
final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/reversed(): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/reversed|reversed@io.github.compose.jindong.core.model.HapticPattern(){}[0]
final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/scaleIntensity(kotlin/Float): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/scaleIntensity|scaleIntensity@io.github.compose.jindong.core.model.HapticPattern(kotlin.Float){}[0]
final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/then(io.github.compose.jindong.core.model/HapticPattern): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/then|then@io.github.compose.jindong.core.model.HapticPattern(io.github.compose.jindong.core.model.HapticPattern){}[0]
final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core.model/timeStretch(kotlin/Float): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.model/timeStretch|timeStretch@io.github.compose.jindong.core.model.HapticPattern(kotlin.Float){}[0]
final fun (io.github.compose.jindong.core.model/HapticPattern).io.github.compose.jindong.core/playAsync(): io.github.compose.jindong.core.executor/HapticHandle // io.github.compose.jindong.core/playAsync|playAsync@io.github.compose.jindong.core.model.HapticPattern(){}[0]
final fun io.github.compose.jindong.core.dsl/buildHapticPattern(kotlin/Function1<io.github.compose.jindong.core.dsl/HapticPatternScope, kotlin/Unit>): io.github.compose.jindong.core.model/HapticPattern // io.github.compose.jindong.core.dsl/buildHapticPattern|buildHapticPattern(kotlin.Function1<io.github.compose.jindong.core.dsl.HapticPatternScope,kotlin.Unit>){}[0]
final fun io.github.compose.jindong.core.executor/createHapticExecutor(kotlin/Any? = ...): io.github.compose.jindong.core.executor/HapticExecutor // io.github.compose.jindong.core.executor/createHapticExecutor|createHapticExecutor(kotlin.Any?){}[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package io.github.compose.jindong.core.element

import io.github.compose.jindong.core.model.HapticPattern
import io.github.compose.jindong.core.model.ScheduledHapticEvent
import io.github.compose.jindong.core.model.spanMs

/**
* An element that includes an existing [HapticPattern].
Expand Down Expand Up @@ -52,12 +53,7 @@ class PatternElement(
}
}

override fun totalDurationMs(startTimeMs: Long): Long {
if (pattern.events.isEmpty()) return 0L

// Find the maximum end time of all events in the pattern
return pattern.events.maxOf { event ->
event.startTimeMs + event.durationMs
}
}
// An included pattern's own span is its duration; startTimeMs is the caller's offset and is not
// folded in here (unlike sequential tree-sum elements).
override fun totalDurationMs(startTimeMs: Long): Long = pattern.spanMs()
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package io.github.compose.jindong.core.executor

import io.github.compose.jindong.core.model.HapticPattern
import io.github.compose.jindong.core.model.spanMs

/**
* The raw span of a pattern: the latest event end on its timeline, or 0 for an empty pattern.
Expand All @@ -24,4 +25,4 @@ import io.github.compose.jindong.core.model.HapticPattern
* (Android compat segments, actuator quirks). Each executor derives its own playback length from this
* according to its physics — see the platform `playbackDurationMs()` helpers.
*/
internal fun HapticPattern.rawSpanMs(): Long = events.maxOfOrNull { it.startTimeMs + it.durationMs } ?: 0L
internal fun HapticPattern.rawSpanMs(): Long = spanMs()
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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.
*/
package io.github.compose.jindong.core.model

/**
* The span of a pattern: the latest event end on its timeline, or 0 for an empty pattern.
*
* This is the raw timeline extent of the scheduled events, independent of any tree structure or
* platform post-processing. It is the shared basis for [reversed] mirroring, [then] offsetting,
* executor playback length, and [io.github.compose.jindong.core.element.PatternElement] duration.
*/
internal fun HapticPattern.spanMs(): Long = events.maxOfOrNull { it.startTimeMs + it.durationMs } ?: 0L
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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.
*/
package io.github.compose.jindong.core.model

/**
* Returns a new pattern that plays this pattern back-to-back [times] times.
*
* Defined by folding [then], so `repeated(n)` equals `this then this then ...` n copies:
* `repeated(1)` is this pattern, `repeated(0)` is [HapticPattern.Empty], and each copy after the
* first is offset by one span so the copies never overlap.
*
* @param times non-negative repeat count
*/
public fun HapticPattern.repeated(times: Int): HapticPattern {
require(times >= 0) { "times must be non-negative, was $times" }
return (1 until times).fold(if (times == 0) HapticPattern.Empty else this) { acc, _ -> acc then this }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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.
*/
package io.github.compose.jindong.core.model

/**
* Returns a new pattern with events mirrored across the pattern span.
*
* Each event is reflected on `[0, span]`: `newStart = span - (start + duration)`, keeping its
* duration. Intensity and iOS parameters are unchanged. Interior gaps make the mirror overlap
* neighbors; overlaps are left as-is and resolved at playback by the serial merge (highest intensity
* wins), so this stays a pure timeline reflection. Compiled patterns are anchored at 0, so reflecting
* twice recovers the original; a leading gap (earliest event not at 0) would fold events off the
* front and is not a shape the builder produces.
*/
public fun HapticPattern.reversed(): HapticPattern {
val span = spanMs()
return copy(
events = events.map { event ->
event.copy(startTimeMs = span - (event.startTimeMs + event.durationMs))
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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.
*/
package io.github.compose.jindong.core.model

/**
* Returns a new pattern with every event's intensity multiplied by [factor].
*
* Every level is mapped to [HapticIntensity.Custom], since a scaled predefined level (a dimmed
* `STRONG`, say) has no predefined equivalent. `Custom` clamps to [0, 1], so `factor` above 1 is
* safe and saturates rather than overflowing. Timing and iOS parameters are unchanged.
*
* @param factor non-negative intensity multiplier
*/
public fun HapticPattern.scaleIntensity(factor: Float): HapticPattern {
require(factor >= 0f) { "factor must be non-negative, was $factor" }
return copy(
events = events.map { event ->
event.copy(intensity = HapticIntensity.Custom(event.intensity.value * factor))
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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.
*/
package io.github.compose.jindong.core.model

/**
* Returns a new pattern that plays this pattern, then [other] appended after it.
*
* Every event of [other] is shifted by this pattern's span, so the two timelines never overlap and
* `span(a then b) == span(a) + span(b)`. Intensity and iOS parameters are carried unchanged. The
* empty pattern is the identity for [then].
*/
public infix fun HapticPattern.then(other: HapticPattern): HapticPattern {
val offset = spanMs()
return copy(
events = events + other.events.map { event ->
event.copy(startTimeMs = event.startTimeMs + offset)
},
)
}

/**
* Operator alias for [then], so patterns can be concatenated with `a + b`.
*/
public operator fun HapticPattern.plus(other: HapticPattern): HapticPattern = this then other
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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.
*/
package io.github.compose.jindong.core.model

import kotlin.math.roundToLong

/**
* Returns a new pattern with every event's timeline scaled by [factor].
*
* Each event's edges are scaled and rounded independently, then the duration is derived as the gap
* between them: `newDuration = round((start + duration) * f) - round(start * f)`. Rounding the two
* edges rather than start-and-duration keeps adjacent event boundaries aligned — a shared boundary
* rounds to one value, so events that touched before still touch after. Intensity and iOS parameters
* (including ADSR envelope times) are carried unchanged: the envelope shapes each event internally
* and is independent of where the event sits on the pattern timeline.
*
* @param factor positive timeline multiplier
*/
public fun HapticPattern.timeStretch(factor: Float): HapticPattern {
require(factor > 0f) { "factor must be positive, was $factor" }
return copy(
events = events.map { event ->
val newStart = (event.startTimeMs * factor).roundToLong()
val newEnd = ((event.startTimeMs + event.durationMs) * factor).roundToLong()
event.copy(startTimeMs = newStart, durationMs = newEnd - newStart)
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.
*/
package io.github.compose.jindong.core.model

import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.arbitrary.bind
import io.kotest.property.arbitrary.float
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.long

/**
* Generates arbitrary haptic patterns for property tests: 0..5 events, each with a positive duration
* (1..200ms), intensities in [0, 1], and interior offsets that may overlap or leave gaps — exactly
* what the transforms must tolerate.
*
* The timeline is anchored at 0 (the earliest event starts at 0), which is what compiled patterns
* look like: `buildHapticPattern` collects events from `startTimeMs = 0`. `reversed()` reflects on
* `[0, span]`, so its involution `reversed().reversed() == p` holds precisely for anchored patterns;
* a leading gap would let a reflection fold events off the front and lose the anchor.
*/
internal fun patterns(): Arb<HapticPattern> = arbitrary { rs ->
val count = Arb.int(0..5).bind()
val offsets = List(count) { Arb.long(0L..500L).bind() }
val floor = offsets.minOrNull() ?: 0L
val events = offsets.map { offset ->
ScheduledHapticEvent(
startTimeMs = offset - floor,
durationMs = Arb.long(1L..200L).bind(),
intensity = HapticIntensity.Custom(Arb.float(0f..1f).bind()),
)
}
HapticPattern(events)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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.
*/
package io.github.compose.jindong.core.model

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll

private fun repeatCounts(): Arb<Int> = Arb.int(0..5)

class RepeatedTest :
FunSpec({
test("repeating once is the identity") {
checkAll(patterns()) { pattern ->
pattern.repeated(1) shouldBe pattern
}
}

test("repeating zero times is the empty pattern") {
checkAll(patterns()) { pattern ->
pattern.repeated(0) shouldBe HapticPattern.Empty
}
}

test("repeated event count is the source count times the repeat count") {
checkAll(patterns(), repeatCounts()) { pattern, times ->
pattern.repeated(times).events.size shouldBe pattern.events.size * times
}
}

test("repeated equals folding then n times") {
checkAll(patterns(), repeatCounts()) { pattern, times ->
val folded = (1 until times).fold(
if (times == 0) HapticPattern.Empty else pattern,
) { acc, _ -> acc then pattern }
pattern.repeated(times) shouldBe folded
}
}
})
Loading
Loading