diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index fc2687913f..fda094cd80 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.ui +import android.os.SystemClock import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState @@ -16,12 +17,16 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -35,14 +40,18 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope @@ -50,22 +59,30 @@ import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.core.R import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.reactions.ReactionSettler import com.getcode.theme.CodeTheme import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** * The N-more collapse the pill row uses under a bubble (decision 2): pack pills onto up to @@ -188,6 +205,11 @@ object ReactionPillRowLayout { * rolls up or down, and the row's height follows. The pills showing when the row first composes * appear without animation, unless [animateInitialPills] says they're new — the caller passes it * for a row that composes because its message just got its first reaction. + * + * While the row is on screen a [ReactionSettler] decides what it shows: counts change live, but + * positions, joins, leaves and pill widths change on a once-a-second beat, a touch holds the row + * still, and for [ReactionSettler.HIT_GRACE_MS] after a beat changes the layout a tap lands on + * whatever was under it before. A row coming on screen shows [pills] in their sorted order. */ @Composable fun ReactionPillRow( @@ -203,10 +225,62 @@ fun ReactionPillRow( ) { var expanded by remember { mutableStateOf(false) } + val clock = LocalPillRowClock.current + val settler = remember { ReactionSettler(pills, clock) } + var beat by remember { mutableIntStateOf(0) } + val settled = remember(pills, beat) { + settler.update(pills) + settler.shown + } + LaunchedEffect(settler, pills) { + while (settler.needsBeat) { + delay((settler.nextBeatAt - clock()).coerceAtLeast(0)) + if (settler.settle()) beat++ + } + } + val shown = settled.map { it.pill } + val countDigits = settled.associate { it.pill.emoji to it.countDigits } + val grace = remember { TapGrace(clock) } + val layoutBeat = beat + val scope = rememberCoroutineScope() - val motion = remember { PillRowMotion(if (animateInitialPills) emptyList() else pills, canReact) } - val plusTarget = canReact && pills.isNotEmpty() - val rendered = remember(pills, plusTarget, motion.exitsFinished) { motion.update(pills, plusTarget) } + val motion = remember { PillRowMotion(if (animateInitialPills) emptyList() else shown, canReact) } + val plusTarget = canReact && shown.isNotEmpty() + val rendered = remember(shown, plusTarget, motion.exitsFinished) { motion.update(shown, plusTarget) } + + val currentCanReact by rememberUpdatedState(canReact) + val currentOnToggle by rememberUpdatedState(onToggle) + val currentOnPillLongClick by rememberUpdatedState(onPillLongClick) + val currentOnOpenPicker by rememberUpdatedState(onOpenPicker) + // Watches every touch on the row before the pills see it: each one holds the beat, and one + // that lands during the tap grace on something the beat moved goes to what was there before. + val touches = Modifier.pointerInput(settler, grace) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + settler.touch() + val target = grace.redirect(down.position) ?: return@awaitEachGesture + down.consume() + val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + waitForUpOrCancellation(PointerEventPass.Initial) + } + if (up == null) { + // A long-press, or a cancel: only a pill has anything to do on a long-press. + if (target.key != null && target.key != PLUS_KEY && target.key != MORE_KEY) currentOnPillLongClick() + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } while (event.changes.any { it.pressed }) + return@awaitEachGesture + } + up.consume() + when (target.key) { + null -> Unit + PLUS_KEY -> if (currentCanReact) currentOnOpenPicker() + MORE_KEY -> expanded = true + else -> if (currentCanReact) currentOnToggle(target.key) + } + } + } val pillHeight = 28.dp val pillPadding = 10.dp @@ -214,7 +288,7 @@ fun ReactionPillRow( val plusSize = 28.dp val topGap = 4.dp - SubcomposeLayout(modifier = modifier.animateHeightUnclipped()) { constraints -> + SubcomposeLayout(modifier = modifier.animateHeightUnclipped().then(touches)) { constraints -> val loose = constraints.copy(minWidth = 0, minHeight = 0) val spacingPx = spacingDp.roundToPx() val topGapPx = topGap.roundToPx() @@ -230,6 +304,7 @@ fun ReactionPillRow( ) { motionModifier -> ReactionPillChip( pill = item.pill, + countDigits = countDigits[item.pill.emoji] ?: ReactionSettler.MIN_COUNT_DIGITS, height = pillHeight, horizontalPadding = pillPadding, // A previewer (canReact == false) can still open the reactors sheet @@ -336,12 +411,28 @@ fun ReactionPillRow( if (item.exiting) Item(item.pill.emoji, pillPlaceables[index]) else null } + listOfNotNull(plusPlaceable?.takeIf { rendered.plus?.exiting == true }?.let { Item(PLUS_KEY, it) }) + val targets = placements.map { placement -> + val lineOffset = if (alignEnd) containerWidth - lineWidths[placement.line] else 0 + IntOffset(placement.x + lineOffset, topGapPx + lineY[placement.line]) + } + grace.laidOut( + placements.mapIndexed { index, placement -> + val at = targets[index] + // placeRelative mirrors in RTL, so the hit rects mirror with it. + val x = if (layoutDirection == LayoutDirection.Rtl) { + containerWidth - at.x - placement.item.placeable.width + } else { + at.x + } + placement.item.key to IntRect(x, at.y, x + placement.item.placeable.width, at.y + placement.item.placeable.height) + }, + beat = layoutBeat, + ) + layout(containerWidth, totalHeight) { val placed = HashSet() - for (placement in placements) { - val lineOffset = if (alignEnd) containerWidth - lineWidths[placement.line] else 0 - val target = IntOffset(placement.x + lineOffset, topGapPx + lineY[placement.line]) - placement.item.placeable.placeRelative(motion.slide(placement.item.key, target, scope)) + placements.forEachIndexed { index, placement -> + placement.item.placeable.placeRelative(motion.slide(placement.item.key, targets[index], scope)) placed += placement.item.key } for (item in leaving) { @@ -357,6 +448,48 @@ fun ReactionPillRow( private const val MORE_KEY = "\u0000more" private const val PLUS_KEY = "\u0000plus" +/** The row's clock, in monotonic milliseconds; tests swap it for the compose test clock. */ +internal val LocalPillRowClock = staticCompositionLocalOf<() -> Long> { { SystemClock.uptimeMillis() } } + +/** + * The tap grace: for [ReactionSettler.HIT_GRACE_MS] after a beat changes where anything sits (a + * move, a resize, a pill joining into the "+" slot, one leaving), a tap resolves against the + * layout that was on screen before, since a thumb already on its way down aimed at that. + */ +private class TapGrace(private val clock: () -> Long) { + /** Where a tap should go instead: [key] is a pill's emoji, [PLUS_KEY], [MORE_KEY], or null for empty space. */ + class Redirect(val key: String?) + + private var current: List> = emptyList() + private var currentBeat: Int = 0 + private var previous: List> = emptyList() + private var previousUntil: Long? = null + + /** Records a layout pass's hit rects; a change a beat made opens the grace. */ + fun laidOut(rects: List>, beat: Int) { + if (rects == current) return + if (beat != currentBeat && current.isNotEmpty()) { + previous = current + previousUntil = clock() + ReactionSettler.HIT_GRACE_MS + } + current = rects + currentBeat = beat + } + + /** The redirect for a touch-down at [position], or null when it lands where it would anyway. */ + fun redirect(position: Offset): Redirect? { + val until = previousUntil ?: return null + if (clock() >= until) return null + val before = keyAt(previous, position) + return if (before != keyAt(current, position)) Redirect(before) else null + } + + private fun keyAt(rects: List>, position: Offset): String? = + rects.firstOrNull { (_, rect) -> + position.x >= rect.left && position.x < rect.right && position.y >= rect.top && position.y < rect.bottom + }?.first +} + private class RenderedPill(val pill: ReactionPill, val entering: Boolean, val exiting: Boolean) private class RenderedPlus(val entering: Boolean, val exiting: Boolean) private class RenderedRow(val pills: List, val plus: RenderedPlus?) @@ -492,6 +625,7 @@ private class AnimateHeightNode : Modifier.Node(), LayoutModifierNode { @Composable private fun ReactionPillChip( pill: ReactionPill, + countDigits: Int, height: Dp, horizontalPadding: Dp, onClick: (() -> Unit)?, @@ -535,6 +669,14 @@ private fun ReactionPillChip( verticalAlignment = Alignment.CenterVertically, ) { Text(text = pill.emoji, fontSize = 15.sp) + // Room for [countDigits] tabular digits, the settler's reservation, so the pill's width + // changes only on a beat and never with the count inside it. + val countStyle = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Medium, fontFeatureSettings = "tnum") + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val countWidth = remember(countDigits, measurer, density) { + with(density) { measurer.measure("0".repeat(countDigits), countStyle).size.width.toDp() } + } // The count rolls to its next value like a counter turning: up for a rise, down for a // fall. AnimatedContent( @@ -546,11 +688,12 @@ private fun ReactionPillChip( .using(SizeTransform(clip = true) { _, _ -> ChatAnimations.reactionChangeSize }) }, label = "pill count", + contentAlignment = Alignment.Center, + modifier = Modifier.widthIn(min = countWidth), ) { count -> Text( text = count.toString(), - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + style = countStyle, color = CodeTheme.colors.textMain, ) } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt index 90755bbfa7..2518999bf6 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt @@ -8,6 +8,12 @@ import androidx.compose.ui.platform.testTag import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.click import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotSelected @@ -83,9 +89,11 @@ class ReactionPillRowTest { var pills by mutableStateOf(listOf(pill("😀"), pill("🎉"))) composeTestRule.mainClock.autoAdvance = false composeTestRule.setContent { - DesignSystem { - Box(Modifier.width(400.dp)) { - ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + CompositionLocalProvider(LocalPillRowClock provides { composeTestRule.mainClock.currentTime }) { + DesignSystem { + Box(Modifier.width(400.dp)) { + ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + } } } } @@ -96,6 +104,10 @@ class ReactionPillRowTest { composeTestRule.mainClock.advanceTimeByFrame() composeTestRule.onNodeWithTag("reaction_pill_🎉").assertExists() + // It leaves on the next beat, a second after the row appeared, then fades. + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.onNodeWithTag("reaction_pill_🎉").assertExists() + composeTestRule.mainClock.advanceTimeBy(1_000) composeTestRule.onNodeWithTag("reaction_pill_🎉").assertDoesNotExist() composeTestRule.onNodeWithTag("reaction_pill_😀").assertIsDisplayed() @@ -105,15 +117,19 @@ class ReactionPillRowTest { fun `losing the last pill collapses the row to no height`() { var pills by mutableStateOf(listOf(pill("😀"))) composeTestRule.setContent { - DesignSystem { - Box(Modifier.width(400.dp).testTag("container")) { - ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + CompositionLocalProvider(LocalPillRowClock provides { composeTestRule.mainClock.currentTime }) { + DesignSystem { + Box(Modifier.width(400.dp).testTag("container")) { + ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + } } } } composeTestRule.onNodeWithTag("container").assertHeightIsEqualTo(32.dp) pills = emptyList() + // The emptied pill leaves on the beat. + composeTestRule.mainClock.advanceTimeBy(2_000) composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("container").assertHeightIsEqualTo(0.dp) composeTestRule.onNodeWithTag("reaction_pill_😀").assertDoesNotExist() @@ -246,4 +262,104 @@ class ReactionPillRowTest { composeTestRule.onNodeWithTag("reaction_pill_😇").assertIsDisplayed() } } + + /** A row on the compose test clock, so its beats follow `mainClock`. */ + private fun setSettlingRow(pills: () -> List, onToggle: (String) -> Unit = {}) { + composeTestRule.mainClock.autoAdvance = false + composeTestRule.setContent { + CompositionLocalProvider(LocalPillRowClock provides { composeTestRule.mainClock.currentTime }) { + DesignSystem { + Box(Modifier.width(400.dp)) { + ReactionPillRow( + pills = pills(), + canReact = true, + onToggle = onToggle, + onPillLongClick = {}, + onOpenPicker = {}, + modifier = Modifier.testTag("row"), + ) + } + } + } + } + composeTestRule.mainClock.advanceTimeByFrame() + } + + private fun widthOf(tag: String) = composeTestRule.onNodeWithTag(tag).getBoundsInRoot().let { it.right - it.left } + + @Test + fun `a count crossing 99 keeps the pill's width until the beat, then widens on it`() { + var pills by mutableStateOf(listOf(pill("😀", count = 99))) + setSettlingRow({ pills }) + val before = widthOf("reaction_pill_😀") + + pills = listOf(pill("😀", count = 100)) + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.onNodeWithText("99").assertExists() + assertEquals(before, widthOf("reaction_pill_😀")) + + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.onNodeWithText("100").assertExists() + assertTrue(widthOf("reaction_pill_😀") > before, "the pill widens on the beat") + } + + @Test + fun `a count that fits its reserved room shows at once without changing the width`() { + var pills by mutableStateOf(listOf(pill("😀", count = 5))) + setSettlingRow({ pills }) + val before = widthOf("reaction_pill_😀") + + pills = listOf(pill("😀", count = 42)) + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(100) + composeTestRule.onNodeWithText("42").assertExists() + assertEquals(before, widthOf("reaction_pill_😀")) + } + + @Test + fun `selecting a pill does not change its width`() { + var pills by mutableStateOf(listOf(pill("😀", count = 3))) + setSettlingRow({ pills }) + val before = widthOf("reaction_pill_😀") + + pills = listOf(pill("😀", count = 3, selfReacted = true)) + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(1_000) + assertEquals(before, widthOf("reaction_pill_😀")) + } + + @Test + fun `positions change on the beat, and a tap right after a swap lands on the pill that was there`() { + var pills by mutableStateOf(listOf(pill("😂", count = 5), pill("🔥", count = 4))) + val toggled = mutableListOf() + setSettlingRow({ pills }, onToggle = { toggled += it }) + val rowLeft = composeTestRule.onNodeWithTag("row").getBoundsInRoot() + val firstSlot = composeTestRule.onNodeWithTag("reaction_pill_😂").getBoundsInRoot() + val slotCenter = Offset( + x = ((firstSlot.left + firstSlot.right) / 2 - rowLeft.left).value, + y = ((firstSlot.top + firstSlot.bottom) / 2 - rowLeft.top).value, + ) + + pills = listOf(pill("😂", count = 5), pill("🔥", count = 6)) + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(500) + assertTrue( + composeTestRule.onNodeWithTag("reaction_pill_😂").getBoundsInRoot().left < + composeTestRule.onNodeWithTag("reaction_pill_🔥").getBoundsInRoot().left, + "nothing moves before the beat", + ) + + // The beat swaps them; a tap 100 ms later on the first slot still gets 😂. + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("row").performTouchInput { click(slotCenter * density) } + composeTestRule.mainClock.advanceTimeByFrame() + assertEquals(listOf("😂"), toggled) + + // Past the grace, the same spot is 🔥. + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.onNodeWithTag("row").performTouchInput { click(slotCenter * density) } + composeTestRule.mainClock.advanceTimeByFrame() + assertEquals(listOf("😂", "🔥"), toggled) + } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionSettler.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionSettler.kt new file mode 100644 index 0000000000..553e4724bb --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionSettler.kt @@ -0,0 +1,203 @@ +package com.flipcash.shared.chat.reactions + +import kotlin.math.max +import kotlin.math.min + +/** + * The pills one on-screen row shows, catching up with [ReactionOrdering] on a beat rather than + * jumping the moment a count changes, so a thumb on its way to a pill still lands on it. + * + * This is `gen_reactions.py`'s `Settler`, and `reactions.json`'s `settle` vectors hold it to that. + * Counts and boosts change live through [update]; positions, joins, leaves and widths change only + * in [settle], at most once per [Config.settleMs]: + * - a beat makes at most [Config.maxSwaps] neighbour swaps, nearest the top first; + * - a pill passes its neighbour on any boost lead, or a count lead of [Config.countMargin]; a tie + * never moves one; + * - a pair that swapped cannot swap back for [Config.flapCooldownMs]; + * - a [touch] holds the row for [Config.holdAfterTouchMs], extended by later touches but never + * past [Config.maxHoldMs] from the first; + * - an emptied pill shows 0 in place until the beat, and a new emoji joins at the end on one. + * + * Each shown pill reserves room for its count (at least [MIN_COUNT_DIGITS] digits) and boost as + * they stood at the last beat. A value that fits shows at once; one that needs more room keeps the + * old value until the next beat, so a pill's width never changes between beats. + * + * Pure Kotlin with an injected [clock] (monotonic milliseconds) so it can move into the shared core + * and tests can drive it. Not thread-safe: one row owns it. + */ +class ReactionSettler( + pills: List, + private val clock: () -> Long, + private val config: Config = Config(), +) { + + /** The settle rules, equal to `settleDefaults` in `reactions.json`. */ + data class Config( + val settleMs: Long = 1_000, + val maxSwaps: Int = 3, + val countMargin: Long = 1, + val flapCooldownMs: Long = 3_000, + val holdAfterTouchMs: Long = 1_500, + val maxHoldMs: Long = 4_000, + val hitGraceMs: Long = HIT_GRACE_MS, + ) + + /** + * One pill as the row draws it: [pill] carries the values to show, [countDigits] and + * [boostDigits] the room to reserve for them (a [boostDigits] of 0 reserves none). + */ + data class Shown(val pill: ReactionPill, val countDigits: Int, val boostDigits: Int) + + private class Reservation(val countDigits: Int, val boostDigits: Int) + + private var live: Map = pills.associateBy { it.emoji } + private var order: List = target() + private val reserved = HashMap() + private val displayed = HashMap() + private var lastSettle: Long = clock() + private var holdStart: Long? = null + private var holdUntil: Long? = null + private var hitOrder: List = emptyList() + private var hitUntil: Long? = null + private val pairSwaps = HashMap, Long>() + + init { + reserveAll() + } + + /** The pills to draw, in their settled order. */ + val shown: List + get() = order.map { emoji -> + val reservation = reserved.getValue(emoji) + Shown(displayed.getValue(emoji), reservation.countDigits, reservation.boostDigits) + } + + /** When [settle] can next move anything: the next beat, or the end of a hold. */ + val nextBeatAt: Long + get() = max(lastSettle + config.settleMs, holdUntil ?: Long.MIN_VALUE) + + /** + * Whether a beat has anything to do: a pill to join or leave, a width to change, or a pair out + * of order by more than a tie. The row's beat loop can idle while this is false. + */ + val needsBeat: Boolean + get() { + if (order.any { liveCount(it) <= 0 }) return true + if (live.values.any { it.count > 0 && it.emoji !in order }) return true + if (order.any { !sameReservation(reserved.getValue(it), reservationFor(live.getValue(it))) }) return true + return order.zipWithNext().any { (above, below) -> overtakes(live.getValue(below), live.getValue(above)) } + } + + /** Takes the latest merged pills; their values show at once where they fit. */ + fun update(pills: List) { + live = pills.associateBy { it.emoji } + for (emoji in order) { + val old = displayed.getValue(emoji) + val now = live[emoji]?.takeIf { it.count > 0 } ?: old.copy(count = 0, selfReacted = false, boost = null) + val reservation = reserved.getValue(emoji) + displayed[emoji] = now.copy( + count = if (digits(now.count) <= reservation.countDigits) now.count else old.count, + boost = if (boostDigits(now.boost) <= reservation.boostDigits) now.boost else old.boost, + ) + } + } + + /** A touch-down on the row: nothing moves until the hold this starts or extends ends. */ + fun touch() { + val now = clock() + val until = holdUntil + if (until == null || now >= until) { + holdStart = now + holdUntil = min(now + config.holdAfterTouchMs, now + config.maxHoldMs) + } else { + holdUntil = max(until, min(now + config.holdAfterTouchMs, holdStart!! + config.maxHoldMs)) + } + } + + /** + * Runs a beat if one is due and the row is not held. Returns whether anything shown changed: + * an order, a join or leave, a value that was waiting for room, or a width. + */ + fun settle(): Boolean { + val now = clock() + if (holdUntil?.let { now < it } == true || now - lastSettle < config.settleMs) return false + lastSettle = now + pairSwaps.values.removeAll { now - it >= config.flapCooldownMs } + + val before = order + val next = order.filterTo(ArrayList()) { liveCount(it) > 0 } + target().filterTo(next) { it !in next } + var swaps = 0 + while (swaps < config.maxSwaps) { + val i = (0 until next.size - 1).firstOrNull { i -> + setOf(next[i], next[i + 1]) !in pairSwaps && overtakes(live.getValue(next[i + 1]), live.getValue(next[i])) + } ?: break + pairSwaps[setOf(next[i], next[i + 1])] = now + next[i] = next[i + 1].also { next[i + 1] = next[i] } + swaps++ + } + + val shownBefore = shown + order = next + reserveAll() + if (next != before) { + hitOrder = before + hitUntil = now + config.hitGraceMs + } + return shown != shownBefore + } + + /** + * The emoji a tap on slot [index] lands on, or null for the "+" pill or empty space. For + * [Config.hitGraceMs] after a beat reorders the row, that is the slot in the order before it. + */ + fun hit(index: Int): String? { + val order = if (hitUntil?.let { clock() < it } == true) hitOrder else order + return order.getOrNull(index) + } + + private fun target(): List = + ReactionOrdering.sorted(live.values.filter { it.count > 0 }).map { it.emoji } + + private fun liveCount(emoji: String): Long = live[emoji]?.count ?: 0 + + private fun overtakes(below: ReactionPill, above: ReactionPill): Boolean { + val belowBoost = below.boost?.total ?: 0L + val aboveBoost = above.boost?.total ?: 0L + if (belowBoost != aboveBoost) return belowBoost > aboveBoost + return below.count - above.count >= config.countMargin + } + + private fun reserveAll() { + reserved.keys.retainAll(order.toSet()) + displayed.keys.retainAll(order.toSet()) + for (emoji in order) { + val pill = live.getValue(emoji) + reserved[emoji] = reservationFor(pill) + displayed[emoji] = pill + } + } + + private fun reservationFor(pill: ReactionPill) = + Reservation(countDigits = max(MIN_COUNT_DIGITS, digits(pill.count)), boostDigits = boostDigits(pill.boost)) + + private fun sameReservation(a: Reservation, b: Reservation) = + a.countDigits == b.countDigits && a.boostDigits == b.boostDigits + + companion object { + /** + * How long after a layout change a tap still resolves against the layout before it. Tuned + * in the prototype for a thumb that re-aims mid-travel; nobody has measured real thumbs on + * a phone yet, and a thumb that commits early would want 600 to 700 ms. Keep it here, in + * one place, so that measurement can move it. + */ + const val HIT_GRACE_MS: Long = 450 + + /** The fewest count digits a pill reserves room for, so 9 to 10 does not widen it. */ + const val MIN_COUNT_DIGITS: Int = 2 + + private fun digits(value: Long): Int = value.coerceAtLeast(0).toString().length + + private fun boostDigits(boost: ReactionBoost?): Int = boost?.total?.takeIf { it > 0 }?.let(::digits) ?: 0 + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt index d8ed1c156b..8161526ed8 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt @@ -15,7 +15,7 @@ import kotlin.time.Instant * verbatim copy of the orchestrator's canonical fixture (#17). A failure here is either a real regression or a cross-platform decision that * has to be made in the canonical fixture and re-synced to both platforms — never a local edit. * - * Covers the `merge`, `order` and `strip` sections; `defaults` and `recents` are covered by + * Covers the `merge`, `order`, `settle` and `strip` sections; `defaults` and `recents` are covered by * `libs:emojis`'s `RecentReactionsVectorTest`, and `drawability` by its instrumented * `EmojiDrawabilityVectorTest`. * @@ -136,6 +136,55 @@ class ReactionVectorsTest { } } + @Test + fun `settle defaults match the reactions fixture`() { + val defaults = json().getJSONObject("settleDefaults") + assertEquals(configOf(defaults), ReactionSettler.Config()) + assertEquals(defaults.getLong("hitGraceMs"), ReactionSettler.HIT_GRACE_MS) + } + + @Test + fun `settle vectors match the reactions fixture`() { + val vectors = section("settle") + assertTrue(vectors.length() > 0, "reactions.json loaded no settle vectors") + + for (i in 0 until vectors.length()) { + val vector = vectors.getJSONObject(i) + val name = vector.getString("name") + val note = vector.getString("note") + val config = configOf(vector.getJSONObject("config")) + + var now = 0L + var settler: ReactionSettler? = null + val steps = vector.getJSONArray("steps") + for (s in 0 until steps.length()) { + val step = steps.getJSONObject(s) + now = step.getLong("at") + val where = "vector `$name` step $s at ${now}ms: $note" + when (val op = step.getString("op")) { + "start" -> { + settler = ReactionSettler(settlePills(step.getJSONObject("pills")), clock = { now }, config = config) + assertEquals(strings(step.getJSONArray("expect")), settler.shown.map { it.pill.emoji }, where) + } + + "pills" -> settler!!.update(settlePills(step.getJSONObject("pills"))) + "touch" -> settler!!.touch() + "settle" -> { + settler!!.settle() + assertEquals(strings(step.getJSONArray("expect")), settler.shown.map { it.pill.emoji }, where) + } + + "hit" -> { + val expected = if (step.isNull("expect")) null else step.getString("expect") + assertEquals(expected, settler!!.hit(step.getInt("index")), where) + } + + else -> error("unknown op `$op`") + } + } + } + } + @Test fun `strip vectors match the reactions fixture`() { val vectors = section("strip") @@ -210,11 +259,38 @@ class ReactionVectorsTest { else -> error("unknown result `$result`") } - private fun section(name: String): JSONArray { + private fun configOf(json: JSONObject) = ReactionSettler.Config( + settleMs = json.getLong("settleMs"), + maxSwaps = json.getInt("maxSwaps"), + countMargin = json.getLong("countMargin"), + flapCooldownMs = json.getLong("flapCooldownMs"), + holdAfterTouchMs = json.getLong("holdAfterTouchMs"), + maxHoldMs = json.getLong("maxHoldMs"), + hitGraceMs = json.getLong("hitGraceMs"), + ) + + /** A settle step's pills, keyed by emoji, in the fixture's (unsorted) order. */ + private fun settlePills(json: JSONObject): List = json.keys().asSequence().map { emoji -> + val p = json.getJSONObject(emoji) + val boostTotal = p.getLong("boostTotal") + ReactionPill( + emoji = emoji, + count = p.getLong("count"), + selfReacted = false, + pending = false, + boost = if (boostTotal != 0L) ReactionBoost(boostTotal) else null, + ) + }.toList() + + private fun strings(json: JSONArray): List = (0 until json.length()).map { json.getString(it) } + + private fun section(name: String): JSONArray = json().getJSONArray(name) + + private fun json(): JSONObject { val json = javaClass.classLoader!! .getResourceAsStream("reactions.json")!! .bufferedReader().use { it.readText() } - return JSONObject(json).getJSONArray(name) + return JSONObject(json) } private companion object { diff --git a/test-vectors/reactions.json b/test-vectors/reactions.json index b8b86ea67d..35ff909594 100644 --- a/test-vectors/reactions.json +++ b/test-vectors/reactions.json @@ -1293,6 +1293,1231 @@ "note": "The case that separates byte order from a platform's default string compare. Both start U+261D. Next, ☝️ has U+FE0F (UTF-8 EF B8 8F) and ☝🏻 has U+1F3FB (F0 9F 8F BB): EF < F0 in UTF-8, but in UTF-16 the surrogate D83C < FE0F, which would flip them." } ], + "settleDefaults": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "settle": [ + { + "name": "first-render-is-target-order", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "👍": { + "count": 1, + "boostTotal": 0 + }, + "🔥": { + "count": 3, + "boostTotal": 0 + }, + "😂": { + "count": 2, + "boostTotal": 0 + } + }, + "expect": [ + "🔥", + "😂", + "👍" + ] + }, + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 5, + "boostTotal": 0 + } + }, + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "A row coming on screen shows `order` exactly, UTF-8 tiebreak included. Settling only governs changes after that." + }, + { + "name": "counts-live-positions-on-the-beat", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 7, + "boostTotal": 0 + } + } + }, + { + "at": 600, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "🔥 shows 7 from 300 ms, but moves only on the first beat, 1000 ms after the last one." + }, + { + "name": "climb-three-places-per-beat", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 4, + "boostTotal": 0 + }, + "😂": { + "count": 3, + "boostTotal": 0 + }, + "💯": { + "count": 2, + "boostTotal": 0 + }, + "🔥": { + "count": 1, + "boostTotal": 0 + } + }, + "expect": [ + "❤️", + "👍", + "😂", + "💯", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 4, + "boostTotal": 0 + }, + "😂": { + "count": 3, + "boostTotal": 0 + }, + "💯": { + "count": 2, + "boostTotal": 0 + }, + "🔥": { + "count": 1, + "boostTotal": 10 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "❤️", + "🔥", + "👍", + "😂", + "💯" + ] + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "🔥", + "❤️", + "👍", + "😂", + "💯" + ] + } + ], + "note": "A boost puts 🔥 first in `order`. It climbs three places per beat, so the climb is visible and each move is next door." + }, + { + "name": "top-inversion-first", + "config": { + "settleMs": 1000, + "maxSwaps": 1, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 4, + "boostTotal": 0 + }, + "😂": { + "count": 3, + "boostTotal": 0 + }, + "🔥": { + "count": 2, + "boostTotal": 0 + } + }, + "expect": [ + "❤️", + "👍", + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 6, + "boostTotal": 0 + }, + "😂": { + "count": 3, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "👍", + "❤️", + "😂", + "🔥" + ] + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "👍", + "❤️", + "🔥", + "😂" + ] + } + ], + "note": "Two pairs are out of order and the beat allows one swap: the pair nearer the top goes first." + }, + { + "name": "touch-holds-the-row", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 500, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 900, + "op": "touch" + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 2399, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 2400, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "A touch at 900 ms holds the row until 2400 ms. The beat due at 1000 ms waits for it." + }, + { + "name": "hold-is-capped", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 500, + "op": "touch" + }, + { + "at": 1500, + "op": "touch" + }, + { + "at": 2500, + "op": "touch" + }, + { + "at": 3500, + "op": "touch" + }, + { + "at": 4400, + "op": "touch" + }, + { + "at": 4499, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 4500, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "Each touch extends the hold, but not past 4000 ms from the touch that began it (500 ms). Someone tapping every second cannot freeze the row." + }, + { + "name": "new-hold-after-a-gap", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "touch" + }, + { + "at": 1600, + "op": "touch" + }, + { + "at": 1700, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 3099, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 3100, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "The first hold ends at 1600 ms, so the touch at 1600 ms begins a new one rather than extending the old one." + }, + { + "name": "no-swap-back-inside-cooldown", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 1200, + "op": "pills", + "pills": { + "😂": { + "count": 7, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 3000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 4000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + } + ], + "note": "🔥 and 😂 swapped at 1000 ms, so they cannot swap back until 4000 ms, whatever the counts do." + }, + { + "name": "count-margin", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 2, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 1100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 7, + "boostTotal": 0 + } + } + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "With a margin of 2, a lead of 1 is not enough to pass. The shipped value is 1; this pins the knob for anyone who changes it." + }, + { + "name": "money-ignores-count-margin", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 2, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 1 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "Any boost lead passes, the smallest one included." + }, + { + "name": "live-tie-keeps-place", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 5, + "boostTotal": 0 + } + }, + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 6, + "boostTotal": 0 + }, + "🔥": { + "count": 5, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 1100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 5, + "boostTotal": 0 + } + } + }, + { + "at": 9000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + } + ], + "note": "Back to a tie, 😂 stays ahead of 🔥 on screen although `order` puts 🔥 first. The UTF-8 tiebreak decides where pills start, never a move." + }, + { + "name": "emptied-pill-waits-for-the-beat", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "👍": { + "count": 2, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥", + "👍" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "👍": { + "count": 0, + "boostTotal": 0 + } + } + }, + { + "at": 600, + "op": "settle", + "expect": [ + "😂", + "🔥", + "👍" + ] + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + } + ], + "note": "👍 shows 0 in place until the beat, so the pills after it do not slide early." + }, + { + "name": "new-emoji-joins-at-the-end", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "💯": { + "count": 1, + "boostTotal": 0 + } + } + }, + { + "at": 600, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥", + "💯" + ] + } + ], + "note": "A new emoji appears on the beat, after the last pill, where it moves nothing but the \"+\" pill." + }, + { + "name": "new-boosted-emoji-climbs", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 5, + "boostTotal": 0 + }, + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "❤️", + "👍", + "😂", + "🔥" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 5, + "boostTotal": 0 + }, + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "💯": { + "count": 1, + "boostTotal": 5 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "❤️", + "💯", + "👍", + "😂", + "🔥" + ] + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "💯", + "❤️", + "👍", + "😂", + "🔥" + ] + } + ], + "note": "It joins at the end and spends that beat's three swaps climbing, then climbs on." + }, + { + "name": "tap-grace-hits-the-old-layout", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 1000, + "op": "hit", + "index": 0, + "expect": "😂" + }, + { + "at": 1449, + "op": "hit", + "index": 0, + "expect": "😂" + }, + { + "at": 1449, + "op": "hit", + "index": 2, + "expect": null + }, + { + "at": 1450, + "op": "hit", + "index": 0, + "expect": "🔥" + } + ], + "note": "A thumb already on its way to 😂 in the first slot still gets 😂 for 450 ms after the swap. Slot 2 is the \"+\" pill in both layouts." + }, + { + "name": "tap-grace-covers-the-plus-pill", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "💯": { + "count": 1, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥", + "💯" + ] + }, + { + "at": 1200, + "op": "hit", + "index": 2, + "expect": null + }, + { + "at": 1450, + "op": "hit", + "index": 2, + "expect": "💯" + } + ], + "note": "A joining emoji takes the \"+\" pill's place, so a tap there during the grace still opens the picker." + } + ], "strip": [ { "name": "no-reactions",