crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 947af7b7b38c00a1df0d756536f6755002b8ae21
parent bc197e6e676956b5f60e053485caa4e7a495f80a
Author: Michael Camilleri <[email protected]>
Date:   Tue,  2 Jun 2026 21:43:08 +0900

Replay puzzles with a fast-forward control

The replay controls in the Success Panel offered a slider with a thumb.
This commit replaces it with a control that plays the replay back
automatically so a finished puzzle can be watched, not just dragged
through by hand.

A manual scrub always wins. CompactSlider gains an onUserScrub callback
fired from the coordinator's valueChanged — which UIKit raises only for
genuine user interaction, not the programmatic setValue autoplay uses to
follow the thumb — so grabbing the slider calls stopPlayback and the
control falls back to off.

The step intervals (350/160/70 ms) and the unlit-arrow opacity (0.3)
are read from UserDefaults via ReplayTuning, defaulting to those values
when unset, and exposed under a new Replay Tuning screen in the
Debugging menu so they can be dialled in on device without a rebuild.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Views/CompactSlider.swift | 5+++++
MCrossmate/Views/ReplayController.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/SettingsView.swift | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/SuccessPanel.swift | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 212 insertions(+), 5 deletions(-)

diff --git a/Crossmate/Views/CompactSlider.swift b/Crossmate/Views/CompactSlider.swift @@ -16,6 +16,10 @@ struct CompactSlider: UIViewRepresentable { var fillColor: UIColor = .systemGray /// Unfilled (maximum) track colour. Neutral so it reads behind any fill. var trackColor: UIColor = .systemGray4 + /// Called when the *user* moves the thumb. Programmatic `setValue` (e.g. + /// autoplay advancing the position) does not fire `.valueChanged`, so this + /// is exclusively a manual scrub — the cue to cancel autoplay. + var onUserScrub: (() -> Void)? = nil func makeCoordinator() -> Coordinator { Coordinator(self) } @@ -119,6 +123,7 @@ struct CompactSlider: UIViewRepresentable { init(_ parent: CompactSlider) { self.parent = parent } @objc func valueChanged(_ slider: UISlider) { + parent.onUserScrub?() let rounded = Int(slider.value.rounded()) if rounded != parent.value { parent.value = rounded } } diff --git a/Crossmate/Views/ReplayController.swift b/Crossmate/Views/ReplayController.swift @@ -1,6 +1,36 @@ import Foundation import Observation +/// Tunable knobs for the finish-banner replay autoplay, persisted in +/// `UserDefaults` and editable from the Debugging menu. Reads fall back to the +/// shipping defaults whenever a key is unset, so an empty store behaves exactly +/// as it did before these became adjustable. +enum ReplayTuning { + enum Key { + static let speedMs = ["replay.speed1Ms", "replay.speed2Ms", "replay.speed3Ms"] + static let offOpacity = "replay.offOpacity" + } + + /// Default step interval (ms) per speed level, fastest last. + static let defaultSpeedMs = [350, 160, 70] + /// Default contrast of an unlit ("off") fast-forward arrow. + static let defaultOffOpacity = 0.3 + + /// Step interval in milliseconds for `speed` (`1...defaultSpeedMs.count`), + /// from the store or the matching default when unset (stored `0`). + static func stepMilliseconds(forSpeed speed: Int) -> Int { + let index = speed - 1 + guard defaultSpeedMs.indices.contains(index) else { return defaultSpeedMs.last ?? 0 } + let stored = UserDefaults.standard.integer(forKey: Key.speedMs[index]) + return stored > 0 ? stored : defaultSpeedMs[index] + } + + /// Contrast of an unlit arrow, from the store or the default when unset. + static var offOpacity: Double { + UserDefaults.standard.object(forKey: Key.offOpacity) as? Double ?? defaultOffOpacity + } +} + /// An immutable snapshot of the scrubber at one position — exactly what /// `GridView` needs to render a rewound frame, decoupled from the controller's /// mutable scrub/load state. A `nil` frame means "render the live grid". @@ -46,6 +76,43 @@ final class ReplayController { /// hard-right at the end of the game. var position: Int = 0 + /// The number of speed steps the fast-forward control cycles through, and + /// the number of arrows it draws. + static let maxPlaybackSpeed = 3 + + /// Autoplay speed: `0` is stopped; `1...maxPlaybackSpeed` advance the + /// position automatically, faster at each step. The fast-forward control + /// cycles `0 → 1 → … → max → 0`, filling one more arrow per step, and the + /// user grabbing the scrubber resets it to `0`. + var playbackSpeed: Int = 0 + + /// Seconds between autoplay steps at the current speed, or `nil` when + /// stopped. Faster speeds step sooner; the per-speed intervals come from + /// `ReplayTuning` so they can be tuned live from the Debugging menu. + var playbackStepInterval: Duration? { + guard playbackSpeed > 0 else { return nil } + return .milliseconds(ReplayTuning.stepMilliseconds(forSpeed: playbackSpeed)) + } + + /// Advances the fast-forward control one notch, wrapping past the top back + /// to stopped. + func cyclePlaybackSpeed() { + playbackSpeed = (playbackSpeed + 1) % (Self.maxPlaybackSpeed + 1) + } + + /// Stops autoplay — called when the user grabs the scrubber, so a manual + /// scrub always wins and the arrows fall back to off. + func stopPlayback() { + playbackSpeed = 0 + } + + /// Advances one autoplay step, looping back to the start once the head + /// reaches the end. A no-op when stopped or before a timeline loads. + func advancePlayback() { + guard let timeline, playbackSpeed > 0 else { return } + position = position >= timeline.count ? 0 : position + 1 + } + var timeline: ReplayTimeline? { if case .ready(let timeline) = status { return timeline } return nil @@ -118,6 +185,7 @@ final class ReplayController { /// manual "Check again" affordance. func retry() { status = .idle + playbackSpeed = 0 reloadToken += 1 } } diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift @@ -47,6 +47,9 @@ struct SettingsView: View { NavigationLink("Record Editor") { RecordEditorView() } + NavigationLink("Replay Tuning") { + ReplayTuningView() + } Button("Reset Database", role: .destructive) { showResetConfirmation = true @@ -139,3 +142,83 @@ struct SettingsView: View { } } } + +/// Live tuning for the finish-banner replay autoplay, backed by the same +/// `UserDefaults` keys `ReplayController`/`PlaybackControl` read. Edits take +/// effect on the next play, so the knobs can be dialled in without a rebuild. +private struct ReplayTuningView: View { + var body: some View { + Form { + Section { + ForEach(Array(ReplayTuning.defaultSpeedMs.enumerated()), id: \.offset) { index, def in + IntervalSlider( + title: "Speed \(index + 1) step", + key: ReplayTuning.Key.speedMs[index], + defaultMs: def + ) + } + } header: { + Text("Step Interval") + } footer: { + Text("Milliseconds between moves at each fast-forward speed. Lower is faster.") + } + + Section { + OffOpacitySlider() + } header: { + Text("Arrow Contrast") + } footer: { + Text("Opacity of an unlit (off) arrow.") + } + + Section { + Button("Reset to Defaults", role: .destructive) { + let defaults = UserDefaults.standard + for key in ReplayTuning.Key.speedMs { defaults.removeObject(forKey: key) } + defaults.removeObject(forKey: ReplayTuning.Key.offOpacity) + } + } + } + .navigationTitle("Replay Tuning") + .navigationBarTitleDisplayMode(.inline) + } + + private struct IntervalSlider: View { + let title: String + @AppStorage private var milliseconds: Int + + init(title: String, key: String, defaultMs: Int) { + self.title = title + _milliseconds = AppStorage(wrappedValue: defaultMs, key) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + LabeledContent(title) { + Text("\(milliseconds) ms").monospacedDigit() + } + Slider( + value: Binding( + get: { Double(milliseconds) }, + set: { milliseconds = Int($0) } + ), + in: 20...600, + step: 5 + ) + } + } + } + + private struct OffOpacitySlider: View { + @AppStorage(ReplayTuning.Key.offOpacity) private var opacity = ReplayTuning.defaultOffOpacity + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + LabeledContent("Off opacity") { + Text(opacity, format: .number.precision(.fractionLength(2))).monospacedDigit() + } + Slider(value: $opacity, in: 0...1, step: 0.05) + } + } + } +} diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift @@ -272,13 +272,32 @@ private struct ReplayScrubber: View { Image(systemName: "clock.arrow.circlepath") .font(.footnote) .foregroundStyle(.secondary) - CompactSlider(value: $replay.position, range: 0...count, fillColor: UIColor(fillColor)) - Text("\(replay.position)/\(count)") - .font(.caption2.monospacedDigit()) - .foregroundStyle(.secondary) - .frame(minWidth: 48, alignment: .trailing) + CompactSlider( + value: $replay.position, + range: 0...count, + fillColor: UIColor(fillColor), + // A manual scrub always wins: cancel autoplay the moment the + // user grabs the thumb. + onUserScrub: { replay.stopPlayback() } + ) + PlaybackControl( + speed: replay.playbackSpeed, + maxSpeed: ReplayController.maxPlaybackSpeed, + onTap: { replay.cyclePlaybackSpeed() } + ) } .frame(height: Self.rowHeight) + // Drive autoplay: each speed change restarts the loop with that speed's + // interval; speed 0 yields a `nil` interval, so the task exits and the + // grid rests. Cancellation (speed change or banner teardown) stops it. + .task(id: replay.playbackSpeed) { + guard let interval = replay.playbackStepInterval else { return } + while !Task.isCancelled { + try? await Task.sleep(for: interval) + if Task.isCancelled { break } + replay.advancePlayback() + } + } } private var waiting: some View { @@ -304,3 +323,35 @@ private struct ReplayScrubber: View { .frame(maxWidth: .infinity, alignment: .leading) } } + +/// A fast-forward control: `maxSpeed` overlapping right-chevrons inside an +/// ordinary Liquid Glass capsule, so it plainly reads as a button. At rest every +/// arrow is faint ("off"); each tap lights one more arrow as the autoplay speed +/// steps up, wrapping back to off past the top. +private struct PlaybackControl: View { + let speed: Int + let maxSpeed: Int + let onTap: () -> Void + + /// Contrast of an unlit ("off") arrow, tunable from the Debugging menu. + @AppStorage(ReplayTuning.Key.offOpacity) private var offOpacity = ReplayTuning.defaultOffOpacity + + var body: some View { + Button(action: onTap) { + HStack(spacing: -2) { + ForEach(0..<maxSpeed, id: \.self) { index in + Image(systemName: "chevron.right") + .foregroundStyle(index < speed ? Color.primary : .secondary.opacity(offOpacity)) + } + } + .font(.caption2.weight(.heavy)) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .glassEffect(.regular.interactive(), in: .capsule) + .animation(.easeInOut(duration: 0.18), value: speed) + } + .buttonStyle(.plain) + .accessibilityLabel("Play replay") + .accessibilityValue(speed == 0 ? "Stopped" : "Speed \(speed) of \(maxSpeed)") + } +}