diff options
46 files changed, 1932 insertions, 152 deletions
@@ -1,15 +1,22 @@ # Build + test stage: a failing test fails the image build, which is what lizCI checks. FROM eclipse-temurin:21-jdk AS build WORKDIR /src +# Android SDK: core and ui have Android targets, so Gradle needs one even for the desktop build. +ENV ANDROID_HOME=/opt/android-sdk +RUN apt-get update && apt-get install -y --no-install-recommends unzip curl && rm -rf /var/lib/apt/lists/* \ + && mkdir -p $ANDROID_HOME/cmdline-tools && curl -fsSL -o /tmp/clt.zip https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip \ + && unzip -q /tmp/clt.zip -d $ANDROID_HOME/cmdline-tools && mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest && rm /tmp/clt.zip \ + && yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null # Warm the Gradle wrapper and dependency cache before copying sources. COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./ COPY gradle gradle COPY core/build.gradle.kts core/ COPY ui/build.gradle.kts ui/ COPY desktop/build.gradle.kts desktop/ +COPY android/build.gradle.kts android/ RUN ./gradlew --no-daemon -q help COPY . . -RUN ./gradlew --no-daemon check :desktop:createDistributable +RUN ./gradlew --no-daemon :core:jvmTest :desktop:createDistributable # Artifact image: just the packaged Linux desktop app (with its bundled runtime). FROM alpine:3.20 AS rookery diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..0c7509f --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "coffee.liz.rookery.android" + compileSdk = 37 + defaultConfig { + applicationId = "coffee.liz.rookery" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + } + buildFeatures { compose = true } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + packaging { resources { excludes += setOf("META-INF/{AL2.0,LGPL2.1}", "META-INF/INDEX.LIST", "META-INF/DEPENDENCIES") } } +} + +kotlin { jvmToolchain(21) } + +dependencies { + implementation(project(":ui")) + implementation(libs.activity.compose) + implementation(libs.coroutines.android) +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7266865 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <!-- so the sync folder can be an ordinary shared folder that Syncthing also sees --> + <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> + <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" /> + <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> + <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> + <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> + <uses-feature android:name="android.hardware.bluetooth_le" android:required="false" /> + <uses-feature android:name="android.hardware.microphone" android:required="false" /> + + <application android:label="rookery" android:theme="@android:style/Theme.Black.NoTitleBar" android:allowBackup="true"> + <activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|uiMode"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt b/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt new file mode 100644 index 0000000..f4cde6c --- /dev/null +++ b/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt @@ -0,0 +1,281 @@ +package coffee.liz.rookery.android + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.MediaRecorder +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.ui.Modifier +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import coffee.liz.rookery.audio.Chime +import coffee.liz.rookery.bt.BleAdvertisement +import coffee.liz.rookery.bt.CubeBackend +import coffee.liz.rookery.bt.GanCubeConnector +import coffee.liz.rookery.bt.JvmAes128 +import coffee.liz.rookery.bt.KableTransport +import coffee.liz.rookery.bt.SmartCube +import coffee.liz.rookery.images.BraveImages +import coffee.liz.rookery.images.ImageHit +import coffee.liz.rookery.model.Event +import coffee.liz.rookery.scramble.TnoodleScrambler +import coffee.liz.rookery.store.FolderBlobStore +import coffee.liz.rookery.store.FolderLogStorage +import coffee.liz.rookery.store.Repo +import coffee.liz.rookery.store.Store +import coffee.liz.rookery.ui.AppModel +import coffee.liz.rookery.ui.Host +import coffee.liz.rookery.ui.RookeryApp +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import java.util.zip.ZipFile + +class MainActivity : ComponentActivity() { + private var pendingFile: CompletableDeferred<Uri?>? = null + private val openDocument = registerForActivityResult(ActivityResultContracts.OpenDocument()) { pendingFile?.complete(it) } + private var pendingPermissions: CompletableDeferred<Boolean>? = null + private val askPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { r -> pendingPermissions?.complete(r.values.all { it }) } + + private var pendingTree: CompletableDeferred<Uri?>? = null + private val openTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { pendingTree?.complete(it) } + private var pendingSettings: CompletableDeferred<Unit>? = null + private val openSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { pendingSettings?.complete(Unit) } + + /** + * Choose a folder in shared storage and return it as a plain path. The data is ordinary files + * that a sync app must also reach, so this needs Android's "all files access", asked for first. + */ + suspend fun pickFolder(): String? { + if (Build.VERSION.SDK_INT >= 30 && !android.os.Environment.isExternalStorageManager()) { + val d = CompletableDeferred<Unit>(); pendingSettings = d + val intent = android.content.Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName")) + runCatching { openSettings.launch(intent) }.onFailure { openSettings.launch(android.content.Intent(android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) } + d.await() + if (!android.os.Environment.isExternalStorageManager()) return null + } else if (Build.VERSION.SDK_INT < 30 && !ensure(Manifest.permission.WRITE_EXTERNAL_STORAGE)) return null + val d = CompletableDeferred<Uri?>(); pendingTree = d + openTree.launch(null) + val uri = d.await() ?: return null + // tree ids look like "primary:Sync/rookery" or "1A2B-3C4D:Sync/rookery" + val id = android.provider.DocumentsContract.getTreeDocumentId(uri) + val volume = id.substringBefore(':'); val rel = id.substringAfter(':', "") + val base = if (volume.equals("primary", true)) android.os.Environment.getExternalStorageDirectory() else File("/storage/$volume") + return File(base, rel).path + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val model = holder ?: run { + val host = AndroidHost(applicationContext) + val root = host.root.toPath() + val store = Store(host.deviceId, FolderLogStorage(root, host.deviceId), FolderBlobStore(root), System::currentTimeMillis) + AppModel(Repo(store), TnoodleScrambler().apply { warm(Event.E333) }, host, MainScope()).also { holder = it } + } + (model.host as AndroidHost).activity = this + enableEdgeToEdge() + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // a timer mustn't go dark mid-solve + setContent { RookeryApp(model, Modifier.windowInsetsPadding(WindowInsets.safeDrawing)) } + } + + override fun onDestroy() { + (holder?.host as? AndroidHost)?.let { if (it.activity === this) it.activity = null } + super.onDestroy() + } + + suspend fun pick(mimeTypes: Array<String>): Uri? { + val d = CompletableDeferred<Uri?>(); pendingFile = d + openDocument.launch(mimeTypes) + return d.await() + } + + suspend fun ensure(vararg permissions: String): Boolean { + val missing = permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } + if (missing.isEmpty()) return true + val d = CompletableDeferred<Boolean>(); pendingPermissions = d + askPermissions.launch(missing.toTypedArray()) + return d.await() + } + + fun fullscreen(on: Boolean) { + val c = WindowInsetsControllerCompat(window, window.decorView) + if (on) { c.hide(WindowInsetsCompat.Type.systemBars()); c.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } + else c.show(WindowInsetsCompat.Type.systemBars()) + } + + companion object { private var holder: AppModel? = null } // survives the activity being recreated +} + +/** What rookery needs from Android. */ +class AndroidHost(private val context: Context) : Host { + var activity: MainActivity? = null + private val prefs = context.getSharedPreferences("local", Context.MODE_PRIVATE) + + /** All data: app storage that file managers (and sync apps given access) can see, else private storage. */ + val root: File get() { + val own = (context.getExternalFilesDir(null) ?: context.filesDir).resolve("sync") + val chosen = localPref("syncFolder")?.let(::File) + // fall back to the app's own folder if the chosen one can't be written (permission withdrawn, card removed) + return (chosen?.takeIf { (it.isDirectory || it.mkdirs()) && it.canWrite() } ?: own).apply { mkdirs() } + } + + override val deviceId: String = prefs.getString("deviceId", null) + ?: "${Build.MODEL.filter { it.isLetterOrDigit() }.take(16).lowercase()}-${UUID.randomUUID().toString().take(6)}".also { prefs.edit().putString("deviceId", it).apply() } + override val syncFolder: String get() = root.path + override fun setSyncFolder(path: String) = setLocalPref("syncFolder", path) + override fun localPref(key: String): String? = prefs.getString("local.$key", null) + override fun setLocalPref(key: String, value: String?) { prefs.edit().apply { if (value == null) remove("local.$key") else putString("local.$key", value) }.apply() } + override val braveApiKey: String? get() = localPref("braveApiKey")?.takeIf { it.isNotBlank() } + override fun setBraveApiKey(key: String?) = setLocalPref("braveApiKey", key) + override val touchInput: Boolean get() = true + + override fun playChime(notes: List<Chime.Note>) { + Thread { + runCatching { + val pcm = Chime.render(notes) + val track = AudioTrack.Builder() + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_GAME).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build()) + .setAudioFormat(AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_PCM_16BIT).setSampleRate(Chime.SAMPLE_RATE).setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build()) + .setBufferSizeInBytes(pcm.size * 2).setTransferMode(AudioTrack.MODE_STATIC).build() + track.write(pcm, 0, pcm.size); track.play() + Thread.sleep(pcm.size * 1000L / Chime.SAMPLE_RATE + 200) + track.release() + } + }.apply { isDaemon = true }.start() + } + + /** Copies the chosen document into the cache and returns that path, so the rest of the app can use plain files. */ + override suspend fun pickFile(title: String, extensions: List<String>): String? { + val uri = activity?.pick(arrayOf("*/*")) ?: return null + return withContext(Dispatchers.IO) { + val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> if (c.moveToFirst()) c.getString(0) else null } ?: "import" + val out = File(context.cacheDir, "picked").apply { mkdirs() }.resolve(name.replace('/', '_')) + context.contentResolver.openInputStream(uri)?.use { i -> out.outputStream().use { i.copyTo(it) } } ?: return@withContext null + out.path + } + } + + override suspend fun pickFolder(title: String): String? = activity?.pickFolder() + override suspend fun readFile(path: String): ByteArray = withContext(Dispatchers.IO) { File(path).readBytes() } + + override suspend fun readImport(path: String, mainEntry: String?): Pair<String, (String) -> ByteArray?> = withContext(Dispatchers.IO) { + if (!path.lowercase().endsWith(".zip")) File(path).readText() to { _: String -> null } + else { + val entries = ZipFile(path).use { z -> z.entries().toList().filter { !it.isDirectory }.associate { it.name to z.getInputStream(it).readBytes() } } + val main = entries.entries.firstOrNull { it.key == mainEntry || it.key.endsWith("/$mainEntry") } ?: error("no $mainEntry in the zip") + val prefix = main.key.removeSuffix(mainEntry ?: "") + main.value.decodeToString() to { rel: String -> entries[prefix + rel] } + } + } + + private fun get(url: String, headers: Map<String, String> = emptyMap()): ByteArray { + require(url.startsWith("https://")) { "refusing a non-https address" } + val c = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000; readTimeout = 20_000; setRequestProperty("User-Agent", "rookery") + headers.forEach { (k, v) -> setRequestProperty(k, v) } + } + try { + if (c.responseCode !in 200..299) error("the server said ${c.responseCode}") + val out = ByteArrayOutputStream(); val buf = ByteArray(16384) + c.inputStream.use { i -> while (true) { val n = i.read(buf); if (n < 0) break; out.write(buf, 0, n); check(out.size() <= 12 * 1024 * 1024) { "that file is too large" } } } + return out.toByteArray() + } finally { c.disconnect() } + } + + override suspend fun searchImages(query: String, key: String): List<ImageHit> = withContext(Dispatchers.IO) { + BraveImages.parse(get(BraveImages.url(query), mapOf("Accept" to "application/json", BraveImages.KEY_HEADER to key)).decodeToString()) + } + + override suspend fun fetchBytes(url: String): ByteArray = withContext(Dispatchers.IO) { get(url) } + + /** Picture bytes from the clipboard, which other apps leave as a content uri. */ + override suspend fun clipboardImage(): ByteArray? = withContext(Dispatchers.Main) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = runCatching { cm.primaryClip }.getOrNull() ?: return@withContext null + if (clip.description?.hasMimeType("image/*") != true) return@withContext null + val uri = clip.getItemAt(0)?.uri ?: return@withContext null + withContext(Dispatchers.IO) { runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() } + } + + override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = runCatching { + val src = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + val k = minOf(1f, 640f / maxOf(src.width, src.height)) + val scaled = if (k < 1f) Bitmap.createScaledBitmap(src, (src.width * k).toInt().coerceAtLeast(1), (src.height * k).toInt().coerceAtLeast(1), true) else src + val out = ByteArrayOutputStream() + @Suppress("DEPRECATION") + scaled.compress(if (Build.VERSION.SDK_INT >= 30) Bitmap.CompressFormat.WEBP_LOSSY else Bitmap.CompressFormat.WEBP, 82, out) + out.toByteArray() to "webp" + }.getOrNull() + + override fun setFullscreen(on: Boolean) { activity?.fullscreen(on) } + + override val cubes: CubeBackend = object : CubeBackend { + private val transport by lazy { KableTransport() } + private suspend fun allowed(): Boolean { + val a = activity ?: return false + return if (Build.VERSION.SDK_INT >= 31) a.ensure(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else a.ensure(Manifest.permission.ACCESS_FINE_LOCATION) + } + override fun scan(): Flow<BleAdvertisement> = flow { + if (!allowed()) error("Bluetooth permission was not granted") + emitAll(transport.scan().filter { GanCubeConnector.isGanCube(it) }) + } + override suspend fun connect(scope: CoroutineScope, address: String, mac: String): SmartCube { + if (!allowed()) error("Bluetooth permission was not granted") + return GanCubeConnector(transport, JvmAes128).connect(scope, address, mac) + } + } + + override fun audioInputs(): List<String> = emptyList() + override val audioSampleRate: Int get() = 44_100 + + @android.annotation.SuppressLint("MissingPermission") + override fun openAudio(input: String?, onSamples: (FloatArray, Int) -> Unit): AutoCloseable { + if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + activity?.let { a -> MainScope().launch { a.ensure(Manifest.permission.RECORD_AUDIO) } } + error("allow the microphone, then switch the stackmat on again") + } + val size = maxOf(AudioRecord.getMinBufferSize(44_100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT), 8192) + // UNPROCESSED where available: the timer's signal must not be "cleaned up" as if it were speech + val rec = AudioRecord(MediaRecorder.AudioSource.UNPROCESSED, 44_100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, size) + check(rec.state == AudioRecord.STATE_INITIALIZED) { "couldn't open the audio input" } + rec.startRecording() + val thread = Thread { + val buf = ShortArray(1024); val samples = FloatArray(1024) + while (rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + val n = rec.read(buf, 0, buf.size) + if (n <= 0) break + for (i in 0 until n) samples[i] = buf[i] / 32768f + onSamples(samples, n) + } + }.apply { isDaemon = true; name = "stackmat-audio"; start() } + return AutoCloseable { runCatching { rec.stop() }; rec.release(); thread.interrupt() } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 1efb0bc..1887745 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,6 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.compose) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.kmp.library) apply false } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 718fdaa..1dcf4db 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,26 +1,45 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.kmp.library) } kotlin { jvmToolchain(21) jvm() + androidLibrary { + namespace = "coffee.liz.rookery.core" + compileSdk = 37 + minSdk = 26 + } sourceSets { commonMain.dependencies { implementation(libs.coroutines.core) api(libs.serialization.json) + implementation(libs.kable) // Bluetooth LE on desktop (macOS, Windows, Linux) and Android } commonTest.dependencies { implementation(kotlin("test")) implementation(libs.coroutines.test) } - jvmMain.dependencies { - implementation(libs.tnoodle.scrambles) - implementation(libs.dbus.java.core) - implementation(libs.dbus.java.transport.native) + // Plain-JVM code that desktop and Android share: files, tnoodle, AES. + val jvmShared by creating { + dependsOn(commonMain.get()) + dependencies { + implementation(libs.tnoodle.scrambles) + implementation(libs.tnoodle.min2phase) // two-phase solver, for smart-cube scramble corrections + } + } + jvmMain { + dependsOn(jvmShared) + dependencies { + // BlueZ over D-Bus: Linux desktop only + implementation(libs.dbus.java.core) + implementation(libs.dbus.java.transport.native) + } } + androidMain { dependsOn(jvmShared) } } } diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt index 954e423..efcb3c0 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt @@ -9,5 +9,8 @@ expect fun monoMillis(): Long /** A random identifier, unique enough to name devices and new entities. */ expect fun randomId(): String +/** Today's date here, as yyyy-mm-dd. */ +expect fun today(): String + /** Mutual exclusion on [lock] (reentrant). */ expect fun <T> locked(lock: Any, block: () -> T): T diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt new file mode 100644 index 0000000..b12c033 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt @@ -0,0 +1,89 @@ +package coffee.liz.rookery.algs + +// Case names, groups and algorithms as listed on jperm.net/algs (J Perm's algorithm sheets). +// Generated from that site's data; edit AlgSets.kt for behaviour, not this file. + +internal val ALG_DATA: Map<String, List<AlgCase>> = mapOf( + "pll" to listOf( + AlgCase("pll", "H", "Edges Only", 1, listOf("M2 U M2 U2 M2 U M2", "M2 U' M2 U2 M2 U' M2")), + AlgCase("pll", "Z", "Edges Only", 2, listOf("M' U M2 U M2 U M' U2 M2", "y M' U' M2 U' M2 U' M' U2 M2", "y M2 U M2 U M' U2 M2 U2 M'", "M2 U' M2 U' M' U2 M2 U2 M'")), + AlgCase("pll", "Ua", "Edges Only", 4, listOf("M2 U M U2 M' U M2", "R U' R U R U R U' R' U' R2", "y2 R2 U' R' U' R U R U R U' R")), + AlgCase("pll", "Ub", "Edges Only", 4, listOf("M2 U' M U2 M' U' M2", "R2 U (R U R' U') R' U' R' U R'", "y2 R' U R' U' R' U' (R' U R U) R2", "y2 R' U R' U' R3 U' (R' U R U) R2")), + AlgCase("pll", "Aa", "Adjacent Corner Swap", 4, listOf("x L2 D2 L' U' L D2 L' U L'", "y' x' L' U L' D2 L U' L' D2 L2", "y x R' U R' D2 R U' R' D2 R2", "y2 x' R2 D2 R' U' R D2 R' U R'")), + AlgCase("pll", "Ab", "Adjacent Corner Swap", 4, listOf("x' L2 D2 L U L' D2 L U' L", "y x L U' L D2 L' U L D2 L2", "y2 x R2 D2 R U R' D2 R U' R", "y' x' R U' R D2 R' U R D2 R2")), + AlgCase("pll", "E", "Diagonal Corner Swap", 2, listOf("x' L' U L D' L' U' L D L' U' L D' L' U L D", "x' R U' R' D R U R' D' R U R' D R U' R' D'")), + AlgCase("pll", "F", "Adjacent Corner Swap", 4, listOf("R' U' F' R U R' U' R' F R2 U' R' U' R U R' U R")), + AlgCase("pll", "Ja", "Adjacent Corner Swap", 4, listOf("x R2 F R F' R U2 r' U r U2", "y2 L' U' L F L' U' L U L F' L2 U L", "y' R' U L' U2 R U' R' U2 R L")), + AlgCase("pll", "Jb", "Adjacent Corner Swap", 4, listOf("R U R' F' R U R' U' R' F R2 U' R'")), + AlgCase("pll", "Ra", "Adjacent Corner Swap", 4, listOf("R U' R' U' R U R D R' U' R D' R' U2 R'", "R U R' F' R U2 R' U2 R' F R U R U2 R'", "y' L U2 L' U2 L F' L' U' L U L F L2")), + AlgCase("pll", "Rb", "Adjacent Corner Swap", 4, listOf("R2 F R U R U' R' F' R U2 R' U2 R", "y' R' U2 R U2 R' F R U R' U' R' F' R2", "R' U2 R' D' R U' R' D R U R U' R' U' R")), + AlgCase("pll", "T", "Adjacent Corner Swap", 4, listOf("R U R' U' R' F R2 U' R' U' (R U R') F'")), + AlgCase("pll", "Y", "Diagonal Corner Swap", 4, listOf("F R U' R' U' R U R' F' R U R' U' R' F R F'", "F R' F R2 U' R' U' R U R' F' R U R' U' F'")), + AlgCase("pll", "V", "Diagonal Corner Swap", 4, listOf("R' U R' U' y R' F' R2 U' R' U R' F R F", "R' U R' U' R D' R' D R' U D' R2 U' R2 D R2", "z D' R2 D R2 U R' D' R U' R U R' D R U' z'", "R U2 R' D R U' R U' R U R2 D R' U' R D2", "x' R' F R F' U R U2 R' U' R U' R' U2 R U R' U'")), + AlgCase("pll", "Na", "Diagonal Corner Swap", 1, listOf("R U R' U R U R' F' R U R' U' R' F R2 U' R' U2 R U' R'", "z U R' D R2 U' R D' U R' D R2 U' R D'")), + AlgCase("pll", "Nb", "Diagonal Corner Swap", 1, listOf("R' (U R U' R') F' U' F R U R' F R' F' R U' R", "z D' R U' R2 D R' U D' R U' R2 D R' U")), + AlgCase("pll", "Ga", "Adjacent Corner Swap", 4, listOf("R2 U R' U R' U' R U' R2 (U' D) R' U R D'", "R2 u R' U R' U' R u' R2 y' R' U R")), + AlgCase("pll", "Gb", "Adjacent Corner Swap", 4, listOf("R' U' R (U D') R2 U R' U R U' R U' R2 D", "y F' U' F R2 u R' U R U' R u' R2")), + AlgCase("pll", "Gc", "Adjacent Corner Swap", 4, listOf("R2 U' R U' R U R' U R2 (U D') R U' R' D", "y2 R2 F2 R U2 R U2 R' F R U R' U' R' F R2", "R2 u' R U' R U R' u R2 y R U' R'")), + AlgCase("pll", "Gd", "Adjacent Corner Swap", 4, listOf("R U R' (U' D) R2 U' R U' R' U R' U R2 D'", "R U R' y' R2 u' R U' R' U R' u R2")), + ), + "oll" to listOf( + AlgCase("oll", "1", "Dot", 2, listOf("R U2 R' R' F R F' U2 R' F R F'")), + AlgCase("oll", "2", "Dot", 4, listOf("r U r' U2 r U2 R' U2 R U' r'", "y' F R U R' U' F' f R U R' U' f'", "y' F R U R' U' S R U R' U' f'")), + AlgCase("oll", "3", "Dot", 4, listOf("r' R2 U R' U r U2 r' U M'", "y F U R U' R' F' U F R U R' U' F'", "y' f R U R' U' f' U' F R U R' U' F'")), + AlgCase("oll", "4", "Dot", 4, listOf("M U' r U2 r' U' R U' R' M'", "y F U R U' R' F' U' F R U R' U' F'", "y' f R U R' U' f' U F R U R' U' F'")), + AlgCase("oll", "5", "Square Shape", 4, listOf("l' U2 L U L' U l", "y2 r' U2 R U R' U r")), + AlgCase("oll", "6", "Square Shape", 4, listOf("r U2 R' U' R U' r'")), + AlgCase("oll", "7", "Small Lightning Bolt", 4, listOf("r U R' U R U2 r'")), + AlgCase("oll", "8", "Small Lightning Bolt", 4, listOf("l' U' L U' L' U2 l", "R U2 R' U2 R' F R F'", "y2 r' U' R U' R' U2 r")), + AlgCase("oll", "9", "Fish Shape", 4, listOf("R U R' U' R' F R2 U R' U' F'")), + AlgCase("oll", "10", "Fish Shape", 4, listOf("R U R' U R' F R F' R U2 R'", "y2 r U R' U R U' R' U' r' R (U R U' R')")), + AlgCase("oll", "11", "Small Lightning Bolt", 4, listOf("r U R' U R' F R F' R U2 r'", "y2 r' R2 U R' U R U2 R' U M'")), + AlgCase("oll", "12", "Small Lightning Bolt", 4, listOf("M' R' U' R U' R' U2 R U' R r'")), + AlgCase("oll", "13", "Knight Move Shape", 4, listOf("F U R U' R2 F' R U R U' R'", "r U' r' U' r U r' y' R' U R")), + AlgCase("oll", "14", "Knight Move Shape", 4, listOf("R' F R U R' F' R F U' F'")), + AlgCase("oll", "15", "Knight Move Shape", 4, listOf("l' U' l L' U' L U l' U l", "y2 r' U' r R' U' R U r' U r")), + AlgCase("oll", "16", "Knight Move Shape", 4, listOf("r U r' R U R' U' r U' r'")), + AlgCase("oll", "17", "Dot", 4, listOf("F R' F' R2 r' U R U' R' U' M'", "y2 R U R' U R' F R F' U2 R' F R F'")), + AlgCase("oll", "18", "Dot", 4, listOf("r U R' U R U2 r' r' U' R U' R' U2 r", "y R U2 R' R' F R F' U2 M' (U R U' r')")), + AlgCase("oll", "19", "Dot", 4, listOf("r' R U R U R' U' M' R' F R F'")), + AlgCase("oll", "20", "Dot", 1, listOf("r U R' U' M2 U R U' R' U' M'", "r' R U (R U R' U') M2 U R U' r'")), + AlgCase("oll", "21", "Cross", 2, listOf("R U2 R' U' R U R' U' R U' R'", "y R U R' U R U' R' U R U2 R'")), + AlgCase("oll", "22", "Cross", 4, listOf("R U2 (R2 U' R2 U' R2) U2 R")), + AlgCase("oll", "23", "Cross", 4, listOf("R2 D' R U2 R' D R U2 R", "y2 R2 D R' U2 R D' R' U2 R'")), + AlgCase("oll", "24", "Cross", 4, listOf("r U R' U' r' F R F'", "y R U R D R' U' R D' R2")), + AlgCase("oll", "25", "Cross", 4, listOf("F' r U R' U' r' F R", "y' R' F R B' R' F' R B")), + AlgCase("oll", "26", "Cross", 4, listOf("(R U2 R') U' R U' R'", "y' R' U' R U' R' U2 R")), + AlgCase("oll", "27", "Cross", 4, listOf("R U R' U R U2 R'", "y' R' U2 (R U R' U) R")), + AlgCase("oll", "28", "Corners Oriented", 4, listOf("r U R' U' r' R U R U' R'")), + AlgCase("oll", "29", "Awkward Shape", 4, listOf("R U R' U' R U' R' F' U' F R U R'")), + AlgCase("oll", "30", "Awkward Shape", 4, listOf("F R' F R2 U' R' U' R U R' F2", "F U (R U2 R' U') R U2 R' U' F'")), + AlgCase("oll", "31", "P Shape", 4, listOf("R' U' F U R U' R' F' R")), + AlgCase("oll", "32", "P Shape", 4, listOf("L U F' U' L' U L F L'", "y2 S R U R' U' R' F R f'")), + AlgCase("oll", "33", "T Shape", 4, listOf("R U R' U' R' F R F'")), + AlgCase("oll", "34", "C Shape", 4, listOf("R U R2 U' R' F R U R U' F'", "R U R' U' B' R' F R F' B")), + AlgCase("oll", "35", "Fish Shape", 4, listOf("R U2 R' R' F R F' R U2 R'")), + AlgCase("oll", "36", "W Shape", 4, listOf("L' U' L U' L' U L U L F' L' F", "y2 R' U' R U' R' U R U R B' R' B")), + AlgCase("oll", "37", "Fish Shape", 4, listOf("F R' F' R U R U' R'", "F R U' R' U' R U R' F'")), + AlgCase("oll", "38", "W Shape", 4, listOf("R U R' U R U' R' U' R' F R F'")), + AlgCase("oll", "39", "Big Lightning Bolt", 4, listOf("L F' L' U' L U F U' L'", "y2 R B' R' U' R U B U' R'")), + AlgCase("oll", "40", "Big Lightning Bolt", 4, listOf("R' F R U R' U' F' U R")), + AlgCase("oll", "41", "Awkward Shape", 4, listOf("R U R' U R U2 R' F R U R' U' F'")), + AlgCase("oll", "42", "Awkward Shape", 4, listOf("R' U' R U' R' U2 R F R U R' U' F'")), + AlgCase("oll", "43", "P Shape", 4, listOf("F' U' L' U L F", "R' U' F R' F' R U R")), + AlgCase("oll", "44", "P Shape", 4, listOf("F U R U' R' F'", "y2 f R U R' U' f'")), + AlgCase("oll", "45", "T Shape", 4, listOf("F R U R' U' F'")), + AlgCase("oll", "46", "C Shape", 4, listOf("R' U' R' F R F' U R")), + AlgCase("oll", "47", "Small L Shape", 4, listOf("R' U' R' F R F' R' F R F' U R", "F' L' U' L U L' U' L U F", "y' F U R U' R' F' R U R' U R U2 R'")), + AlgCase("oll", "48", "Small L Shape", 4, listOf("F R U R' U' R U R' U' F'")), + AlgCase("oll", "49", "Small L Shape", 4, listOf("r U' r2 U r2 U r2 U' r")), + AlgCase("oll", "50", "Small L Shape", 4, listOf("r' U r2 U' r2 U' r2 U r'")), + AlgCase("oll", "51", "I Shape", 4, listOf("F U R U' R' U R U' R' F'", "y2 f R U R' U' R U R' U' f'")), + AlgCase("oll", "52", "I Shape", 4, listOf("R U R' U R U' B U' B' R'", "y2 R' F' U' F U' (R U R' U) R", "R U R' U R U' y R U' R' F'")), + AlgCase("oll", "53", "Small L Shape", 4, listOf("l' U2 L U L' U' L U L' U l", "y2 r' U2 (R U R' U') R U R' U r", "y r' U' R U' R' U R U' R' U2 r")), + AlgCase("oll", "54", "Small L Shape", 4, listOf("(r U2 R' U') R U R' U' R U' r'", "y r U R' U R U' R' U R U2 r'")), + AlgCase("oll", "55", "I Shape", 2, listOf("R' F R U R U' R2 F' R2 U' R' U R U R'", "y R U2 R2 U' R U' R' U2 F R F'")), + AlgCase("oll", "56", "I Shape", 2, listOf("(r' U' r) U' R' U R U' R' U R r' U r", "(r U r') U R U' R' U R U' R' (r U' r')", "(r U r') U R U' R' U R U' M' U' r'")), + AlgCase("oll", "57", "Corners Oriented", 2, listOf("R U R' U' M' U R U' r'")), + ), +) diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt new file mode 100644 index 0000000..2309d97 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt @@ -0,0 +1,54 @@ +package coffee.liz.rookery.algs + +import coffee.liz.rookery.puzzle.Notation +import coffee.liz.rookery.puzzle.NxnCube + +/** One case of an algorithm set, with the algorithms people use for it (the first is the usual one). */ +data class AlgCase(val set: String, val name: String, val group: String, val probability: Int, val algs: List<String>) { + val id: String get() = "$set:$name" + + /** + * The cube showing this case, last layer up, as you would hold it before doing [alg]: a solved + * cube with the algorithm undone. An algorithm with a net rotation (say it starts with `x`) + * leaves the centres somewhere else, so colours are renamed after whichever centre they sit on. + */ + fun state(alg: String = algs.first()): NxnCube { + val c = NxnCube(3).apply(Notation.invert(clean(alg))) + val rename = IntArray(6) + for (f in 0 until 6) rename[c.stickers[f * 9 + 4]] = f + for (i in c.stickers.indices) c.stickers[i] = rename[c.stickers[i]] + return c + } + + /** Moves that set this case up from a solved cube. */ + fun setup(alg: String = algs.first()): String = Notation.invert(clean(alg)) + + companion object { + fun clean(alg: String) = alg.replace("(", " ").replace(")", " ").trim().replace(Regex("\\s+"), " ") + } +} + +enum class AlgStatus { NEW, LEARNING, KNOWN } + +object AlgSets { + val names: List<String> get() = ALG_DATA.keys.toList() + fun cases(set: String): List<AlgCase> = ALG_DATA[set].orEmpty() + fun find(id: String): AlgCase? = cases(id.substringBefore(':')).firstOrNull { it.id == id } + + /** + * The last layer seen from above, as 5x5 cells of colour indices (-1 = empty corner): the U face + * in the middle and the top row of each side around it, front at the bottom. + */ + fun plan(c: NxnCube): Array<IntArray> { + val s = c.stickers + val g = Array(5) { IntArray(5) { -1 } } + for (r in 0 until 3) for (k in 0 until 3) g[r + 1][k + 1] = s[r * 3 + k] // U + for (k in 0 until 3) { + g[4][k + 1] = s[2 * 9 + k] // F, left to right + g[0][k + 1] = s[5 * 9 + (2 - k)] // B, seen from above + g[k + 1][4] = s[1 * 9 + (2 - k)] // R, back to front + g[k + 1][0] = s[4 * 9 + k] // L, back to front + } + return g + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt index f27079d..614cc53 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt @@ -67,6 +67,15 @@ object Cube3 { return Triple(pick(c.first, prefer.first), pick(c.second, prefer.second), pick(c.third, prefer.third)) } + /** The corner view (signs along x, y, z) from which the most of [slots] can be seen, preferring the usual front-top-right. */ + fun bestView(slots: List<Int>): Triple<Int, Int, Int> { + val corners = listOf(1, -1).flatMap { x -> listOf(1, -1).flatMap { y -> listOf(1, -1).map { z -> Triple(x, y, z) } } } + return corners.maxBy { c -> + slots.count { s -> val n = geo.normalOf(s); n.first * c.first + n.second * c.second + n.third * c.third > 0 } * 8 + + (if (c.second > 0) 4 else 0) + (if (c.third > 0) 2 else 0) + (if (c.first > 0) 1 else 0) + } + } + /** The piece's lowest position, as a stable id for the piece. */ fun pieceId(type: PieceType, pos: Int): Int = pieceFrom(type, pos).min() diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt new file mode 100644 index 0000000..9c36517 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt @@ -0,0 +1,73 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package coffee.liz.rookery.bt + +import com.juul.kable.Peripheral +import com.juul.kable.PlatformAdvertisement +import com.juul.kable.Scanner +import com.juul.kable.State +import com.juul.kable.WriteType +import com.juul.kable.characteristicOf +import com.juul.kable.write +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Bluetooth LE through Kable, which speaks CoreBluetooth on macOS, WinRT on Windows, BlueZ on Linux + * and the platform stack on Android, behind one API. + * + * Addresses are whatever the platform uses to name a device: a MAC on Linux, Windows and Android, + * an opaque UUID on macOS. Callers that need the real MAC (GAN cubes do) must get it elsewhere. + */ +class KableTransport : BleTransport { + private val seen = HashMap<String, PlatformAdvertisement>() + + override fun scan(): Flow<BleAdvertisement> = Scanner().advertisements.map { a -> + val id = a.identifier.toString() + synchronized(seen) { seen[id] = a } + BleAdvertisement( + address = id, name = a.name ?: a.peripheralName, rssi = a.rssi, + manufacturerData = a.manufacturerData?.let { mapOf(it.code to it.data) } ?: emptyMap(), + serviceUuids = a.uuids.map { it.toString() }, + ) + } + + override suspend fun connect(address: String): BleConnection { + // Peripherals are made from an advertisement, so one must have been heard (now or just before). + val adv = synchronized(seen) { seen[address] } + ?: withTimeoutOrNull(12_000) { Scanner().advertisements.first { it.identifier.toString() == address } } + ?: error("can't hear that cube. turn it to wake it, then try again") + val peripheral = Peripheral(adv) + try { peripheral.connect() } catch (e: Exception) { runCatching { peripheral.close() }; throw e } + return KableConnection(peripheral, address, adv.name ?: adv.peripheralName) + } + + private fun <T> synchronized(lock: Any, block: () -> T): T = coffee.liz.rookery.locked(lock, block) +} + +private fun char(service: String, characteristic: String) = characteristicOf(kotlin.uuid.Uuid.parse(service), kotlin.uuid.Uuid.parse(characteristic)) + +private class KableConnection(private val p: Peripheral, override val address: String, override val name: String?) : BleConnection { + override suspend fun discoverServices(): List<BleService> = p.services.value.orEmpty().map { s -> + BleService(s.serviceUuid.toString(), s.characteristics.map { BleCharacteristic(it.characteristicUuid.toString()) }) + } + + override suspend fun read(service: String, characteristic: String): ByteArray = p.read(char(service, characteristic)) + + override suspend fun write(service: String, characteristic: String, value: ByteArray) { + val known = p.services.value.orEmpty().firstOrNull { it.serviceUuid.toString().equals(service, true) } + ?.characteristics?.firstOrNull { it.characteristicUuid.toString().equals(characteristic, true) } + // prefer an acknowledged write where the characteristic allows one + val type = if (known != null && !known.properties.write) WriteType.WithoutResponse else WriteType.WithResponse + p.write(char(service, characteristic), value, type) + } + + override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> = p.observe(char(service, characteristic)) + + override val disconnected: Flow<Unit> = p.state.filterIsInstance<State.Disconnected>().map { } + + override suspend fun disconnect() { runCatching { p.disconnect() }; runCatching { p.close() } } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt index 5090ee0..8aa1b6b 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt @@ -23,6 +23,8 @@ data class Solve( val at: Long, /** Smart-cube reconstruction, csTimer style: `R@0 U'@120 ...` */ val moves: String? = null, + /** Blindfolded with a smart cube: time until the first turn. */ + val memoMs: Long? = null, ) { val result: Long get() = when (penalty) { Penalty.NONE -> ms diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt new file mode 100644 index 0000000..68890b4 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt @@ -0,0 +1,156 @@ +package coffee.liz.rookery.puzzle + +/** 3x3 helpers in the frame a smart cube reports in: centres fixed, face turns only. */ +object Cube3x3 { + private const val FACES = "URFDLB" + const val SOLVED = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" + + fun facelets(c: NxnCube): String = buildString(54) { for (s in c.stickers) append(FACES[s]) } + + /** + * Rewrite a scramble as plain face turns. A wide move is the opposite face plus a whole-cube + * rotation (Rw = L then x), and a smart cube can't see rotations, so later moves are renamed + * through them. The result reaches the same cube, just held differently. + */ + fun faceTurns(scramble: String): List<String> { + val orient = NxnCube(3) // only its centres matter: which physical face sits where + val out = mutableListOf<String>() + val re = Regex("""^([URFDLB])(w?)([2']?)$|^([xyz])([2']?)$""") + for (t in scramble.split(Regex("\\s+")).filter { it.isNotEmpty() }) { + val m = re.matchEntire(t) ?: throw IllegalArgumentException("can't follow move '$t' on a smart cube") + if (m.groupValues[4].isNotEmpty()) { orient.apply(t); continue } + val face = FACES.indexOf(m.groupValues[1]); val suffix = m.groupValues[3] + if (m.groupValues[2].isEmpty()) out += "${FACES[orient.stickers[face * 9 + 4]]}$suffix" + else { + val opposite = (face + 3) % 6 + out += "${FACES[orient.stickers[opposite * 9 + 4]]}$suffix" + val (rot, flip) = when (FACES[face]) { 'R' -> "x" to false; 'L' -> "x" to true; 'U' -> "y" to false; 'D' -> "y" to true; 'F' -> "z" to false; else -> "z" to true } + val sfx = if (!flip) suffix else when (suffix) { "" -> "'"; "'" -> ""; else -> "2" } + orient.apply(rot + sfx) + } + } + return out + } +} + +/** + * Follows a smart cube towards a scramble, the way csTimer does: shows what is left to turn, takes + * each move as it comes, and when the cube strays works out the shortest way from where it is to the + * scrambled state. The plan is kept until the next stray move, so it doesn't change under your hands. + * + * [solver] returns moves that solve a facelet string (null if it can't). + */ +class ScrambleTracker(private val solver: (facelets: String) -> String?) { + private var target: List<String> = emptyList() + private var cube = NxnCube(3) + private var targetFacelets = Cube3x3.SOLVED + + /** Moves still to do. Empty with [known] true means the cube is scrambled. */ + var plan: List<String> = emptyList() + private set(value) { field = value } + + /** + * The whole sequence being worked through (the scramble, or the current fix), which stays put + * while [done] fills in, so a display can mark progress without the text moving. + */ + var sequence: List<String> = emptyList() + private set + /** Indices into [sequence] already turned. */ + var done: Set<Int> = emptySet() + private set + /** Index in [sequence] of the move in hand, or -1. */ + val current: Int get() = remaining.firstOrNull() ?: -1 + private var remaining = mutableListOf<Int>() + + private fun begin(moves: List<String>) { plan = moves; sequence = moves; done = emptySet(); remaining = moves.indices.toMutableList() } + /** False while the plan is the scramble as written; true once it is a computed correction. */ + var corrected = false + private set + /** False if we couldn't work out a path (cube state unknown or unsolvable). */ + var known = false + private set + val scrambled: Boolean get() = known && plan.isEmpty() && target.isNotEmpty() + val total: Int get() = target.size + + /** + * Begin tracking a new [scramble]. If we have been following the cube we already know where it + * is; otherwise [facelets] (the cube's last report, possibly null) says. + */ + fun reset(scramble: String, facelets: String?) { + target = runCatching { Cube3x3.faceTurns(scramble) }.getOrDefault(emptyList()) + targetFacelets = Cube3x3.facelets(NxnCube(3).apply(target.joinToString(" "))) + if (known) start() else sync(facelets) + } + + /** Learn the cube's state from a facelets report. Only needed while it is not [known]. */ + fun sync(facelets: String?) { + if (facelets == null) { known = false; begin(emptyList()); return } + val solution = solver(facelets) + if (solution == null) { known = false; begin(emptyList()); return } + cube = NxnCube(3).apply(Notation.invert(solution)) + known = true + start() + } + + private fun start() { + if (target.isEmpty()) { begin(emptyList()); return } + if (cube.isSolved) { begin(target); corrected = false } else replan() + } + + fun move(m: String) { + if (!known) return + cube.apply(m) + val face = m[0]; val amount = amount(m) + // the next move, or the one after it if the two are opposite faces (they commute) + val at = when { + plan.isNotEmpty() && plan[0][0] == face -> 0 + plan.size > 1 && plan[1][0] == face && opposite(plan[0][0]) == face -> 1 + else -> -1 + } + if (at < 0) { replan(); return } + val left = ((amount(plan[at]) - amount) % 4 + 4) % 4 + plan = plan.toMutableList().also { if (left == 0) it.removeAt(at) else it[at] = face + suffix(left) } + if (left == 0) done = done + remaining.removeAt(at) + } + + /** The cube as we believe it is, 54 facelets U R F D L B; null while unknown. */ + val facelets: String? get() = if (known) Cube3x3.facelets(cube) else null + + /** + * The cube itself says where it is, and that outranks our move-by-move bookkeeping (a turn can be + * lost over Bluetooth). Returns true if we were wrong. The plan is left alone: use [reset] or + * [move] afterwards if a scramble is in progress. + */ + fun correct(reported: String): Boolean { + if (known && Cube3x3.facelets(cube) == reported) return false + if (reported == Cube3x3.SOLVED) { cube = NxnCube(3); known = true; return true } + val solution = solver(reported) ?: return false + cube = NxnCube(3).apply(Notation.invert(solution)); known = true + return true + } + + /** Work out the plan again from wherever the cube is now. */ + fun replanFromHere() { if (known) start() } + + /** A turn made while solving: keep the cube up to date, leave the plan alone. */ + fun turn(m: String) { if (known) cube.apply(m) } + + val solved: Boolean get() = known && cube.isSolved + + private fun replan() { + if (Cube3x3.facelets(cube) == targetFacelets) { plan = emptyList(); done = sequence.indices.toSet(); remaining.clear(); known = true; return } + // the cube that, once solved, has taken ours to the target: undo the target, then redo what we have + val relative = NxnCube(3).apply(Notation.invert(target.joinToString(" "))) + val here = solver(Cube3x3.facelets(cube)) + if (here == null) { known = false; begin(emptyList()); return } + relative.apply(Notation.invert(here)) + val path = solver(Cube3x3.facelets(relative)) + if (path == null) { known = false; begin(emptyList()); return } + begin(path.split(Regex("\\s+")).filter { it.isNotEmpty() }) + corrected = true; known = true + } + + private fun amount(m: String) = when { m.endsWith("2") -> 2; m.endsWith("'") -> 3; else -> 1 } + private fun suffix(n: Int) = when (n) { 2 -> "2"; 3 -> "'"; else -> "" } + private fun opposite(f: Char) = "URFDLB"[("URFDLB".indexOf(f) + 3) % 6] +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt index b056e91..c112d7b 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt @@ -9,6 +9,9 @@ interface Scrambler { /** An SVG drawing of the scrambled puzzle, or null if unavailable. */ fun previewSvg(event: Event, scramble: String): String? + /** Moves that solve a 3x3 given as 54 facelets (U R F D L B), or null if unavailable or unsolvable. */ + fun solve3x3(facelets: String): String? = null + /** Start building solver tables / a scramble ahead of time. */ fun warm(event: Event) {} } diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt index a7a0994..21cee38 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt @@ -26,6 +26,8 @@ object Coll { const val PREFS = "prefs" /** Append-only trainer answers; one entity per answer so concurrent devices never conflict. */ const val REVIEW = "review" + /** Per-case algorithm choices and learning status. */ + const val ALG = "alg" } private fun Map<String, JsonElement>.str(f: String): String? = (this[f] as? JsonPrimitive)?.takeIf { this[f] !is JsonNull }?.contentOrNull @@ -56,6 +58,7 @@ class Repo(val store: Store) { comment = f.str("note").orEmpty(), at = f.long("at") ?: 0, moves = f.str("moves"), + memoMs = f.long("memo"), ) }.sortedWith(compareBy({ it.at }, { it.id })) } @@ -87,6 +90,7 @@ class Repo(val store: Store) { if (s.comment.isNotEmpty()) set(Coll.SOLVE, s.id, "note", s.comment) set(Coll.SOLVE, s.id, "at", s.at) if (s.moves != null) set(Coll.SOLVE, s.id, "moves", s.moves) + if (s.memoMs != null) set(Coll.SOLVE, s.id, "memo", s.memoMs) } fun setPenalty(id: String, p: Penalty) = store.transact { set(Coll.SOLVE, id, "pen", p.code) } @@ -160,6 +164,17 @@ class Repo(val store: Store) { }.toMap() } + // ---- algorithms ---- + + /** For each case id (`pll:T`): how well you know it, and which algorithm you've chosen (null = the usual one). */ + fun algPrefs(): Map<String, Pair<coffee.liz.rookery.algs.AlgStatus, String?>> = store.read { r -> + r.entities(Coll.ALG).mapValues { (_, f) -> + (coffee.liz.rookery.algs.AlgStatus.entries.firstOrNull { it.name == f.str("status") } ?: coffee.liz.rookery.algs.AlgStatus.NEW) to f.str("alg")?.takeIf { it.isNotBlank() } + } + } + fun setAlgStatus(id: String, status: coffee.liz.rookery.algs.AlgStatus) = store.transact { set(Coll.ALG, id, "status", status.name) } + fun setAlgChoice(id: String, alg: String?) = store.transact { set(Coll.ALG, id, "alg", alg) } + // ---- BLD setup ---- fun bldSetup(): coffee.liz.rookery.bld.BldSetup { diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt index f08a550..aab40db 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt @@ -63,6 +63,26 @@ class TimerMachine(var inspection: Boolean = true, var holdMs: Long = 300) { fun cancel() { state = State.Idle } + /** Start inspection without a key press (a smart cube has just been scrambled). */ + fun beginInspection(now: Long) { if (state is State.Idle || state is State.Stopped) state = State.Inspecting(now) } + + /** Start the clock without a key press (a smart cube made its first move, or blind memo began). */ + fun beginRunning(now: Long) { + state = when (val s = state) { + is State.Inspecting -> State.Running(now, inspectionPenalty(s.since, now)) + State.Idle, is State.Stopped -> State.Running(now, Penalty.NONE) + else -> return + } + } + + /** Stop the clock without a key press (the smart cube is solved). */ + fun finish(now: Long) { + val s = state as? State.Running ?: return + val ms = now - s.since + state = State.Stopped(ms, s.penalty, awaitingRelease = false) + onFinish(ms, s.penalty) + } + private fun inspectionPenalty(since: Long?, now: Long): Penalty { if (since == null) return Penalty.NONE val t = now - since diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/AlgSetsTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/AlgSetsTest.kt new file mode 100644 index 0000000..eadef06 --- /dev/null +++ b/core/src/commonTest/kotlin/coffee/liz/rookery/AlgSetsTest.kt @@ -0,0 +1,37 @@ +package coffee.liz.rookery + +import coffee.liz.rookery.algs.AlgCase +import coffee.liz.rookery.algs.AlgSets +import coffee.liz.rookery.puzzle.NxnCube +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AlgSetsTest { + private fun f2lSolved(c: NxnCube) = (1..5).all { f -> f == 3 || (3 until 9).all { c.stickers[f * 9 + it] == f } } && (0 until 9).all { c.stickers[3 * 9 + it] == 3 } + private fun topOriented(c: NxnCube) = (0 until 9).all { c.stickers[it] == 0 } + + @Test fun setsAreComplete() { + assertEquals(21, AlgSets.cases("pll").size) + assertEquals(57, AlgSets.cases("oll").size) + } + + @Test fun everyAlgorithmSolvesItsOwnCase() { + for (set in AlgSets.names) for (case in AlgSets.cases(set)) for (alg in case.algs) { + val state = case.state(alg) + assertTrue(f2lSolved(state), "${case.id}: '$alg' disturbs the first two layers") + if (set == "pll") assertTrue(topOriented(state), "${case.id}: '$alg' is not a pure permutation") + if (set == "oll") assertTrue(!topOriented(state), "${case.id}: '$alg' does nothing to orientation") + // doing the algorithm on its own setup returns a solved cube (possibly held differently) + val done = NxnCube(3).apply(case.setup(alg)).apply(AlgCase.clean(alg)) + assertTrue((0 until 6).all { f -> (0 until 9).all { done.stickers[f * 9 + it] == done.stickers[f * 9 + 4] } }, "${case.id}: setup then '$alg' is not solved") + } + } + + @Test fun planPutsFrontAtTheBottom() { + val p = AlgSets.plan(NxnCube(3)) + assertEquals(listOf(-1, 5, 5, 5, -1), p[0].toList()) + assertEquals(listOf(4, 0, 0, 0, 1), p[2].toList()) + assertEquals(listOf(-1, 2, 2, 2, -1), p[4].toList()) + } +} diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt index bbccb57..14d35d5 100644 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt +++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt @@ -1,5 +1,6 @@ package coffee.liz.rookery +actual fun today(): String = java.time.LocalDate.now().toString() actual fun epochMillis(): Long = System.currentTimeMillis() actual fun monoMillis(): Long = System.nanoTime() / 1_000_000 actual fun randomId(): String = java.util.UUID.randomUUID().toString().replace("-", "").take(16) diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt index b96748c..b96748c 100644 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt +++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt index e0e7b31..4427177 100644 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt +++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt @@ -44,6 +44,14 @@ class TnoodleScrambler : Scrambler { return s } + private val search by lazy { cs.min2phase.Search() } + + override fun solve3x3(facelets: String): String? { + if (facelets == "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB") return "" + val s = synchronized(search) { search.solution(facelets, 21, 100_000, 0, 0) } + return if (s.startsWith("Error")) null else s.trim().replace(Regex("\\s+"), " ") + } + override fun previewSvg(event: Event, scramble: String): String? = runCatching { registry(event).scrambler.drawScramble(scramble, null).toString() }.getOrNull() diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt index 64064b5..64064b5 100644 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt +++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/ScrambleTrackerTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/ScrambleTrackerTest.kt new file mode 100644 index 0000000..896aaa4 --- /dev/null +++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/ScrambleTrackerTest.kt @@ -0,0 +1,78 @@ +package coffee.liz.rookery + +import coffee.liz.rookery.puzzle.Cube3x3 +import coffee.liz.rookery.puzzle.NxnCube +import coffee.liz.rookery.puzzle.ScrambleTracker +import coffee.liz.rookery.scramble.TnoodleScrambler +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ScrambleTrackerTest { + private val solver = TnoodleScrambler() + private fun tracker() = ScrambleTracker(solver::solve3x3) + private val scramble = "R U2 F' L D B2 R' U L2 F" + + @Test fun followingTheScrambleWalksThePlanDown() { + val t = tracker() + t.reset(scramble, Cube3x3.SOLVED) + assertEquals(scramble.split(" "), t.plan) + t.move("R"); assertEquals("U2", t.plan[0]) + assertEquals(scramble.split(" "), t.sequence); assertEquals(setOf(0), t.done); assertEquals(1, t.current) + t.move("U"); assertEquals("U", t.plan[0]) // half of a double turn + assertEquals(1, t.current) // still on the same move of the sequence + t.move("U"); assertEquals("F'", t.plan[0]) + t.move("F"); assertEquals("F2", t.plan[0]) // wrong way round: two more fixes it, no solver needed + assertFalse(t.corrected) + t.move("F2") + for (m in listOf("L", "D", "B2", "R'", "U", "L2", "F")) t.move(m) + assertTrue(t.scrambled) + assertEquals(scramble.split(" "), t.sequence); assertEquals(t.sequence.indices.toSet(), t.done) + } + + @Test fun oppositeFacesCanBeDoneInEitherOrder() { + val t = tracker() + t.reset("R L U", Cube3x3.SOLVED) + t.move("L"); assertEquals(listOf("R", "U"), t.plan) + assertFalse(t.corrected) + } + + @Test fun strayMoveGetsAShortFixThatStaysPutAndReachesTheScramble() { + val t = tracker() + t.reset(scramble, Cube3x3.SOLVED) + t.move("R"); t.move("U2"); t.move("D") // D is not next + assertTrue(t.corrected) + val fix = t.plan + assertTrue(fix.size <= 21, "fix is ${fix.size} moves") + // following the fix: the rest of the plan is exactly its tail each time (cached, not re-solved) + val cube = NxnCube(3).apply("R U2 D") + for ((i, m) in fix.withIndex()) { t.move(m); cube.apply(m); assertEquals(fix.drop(i + 1), t.plan) } + assertTrue(t.scrambled) + assertEquals(Cube3x3.facelets(NxnCube(3).apply(scramble)), Cube3x3.facelets(cube)) + } + + @Test fun startsFromWhateverStateTheCubeIsIn() { + val t = tracker() + t.reset(scramble, Cube3x3.facelets(NxnCube(3).apply("F R U"))) + assertTrue(t.known && t.corrected) + val cube = NxnCube(3).apply("F R U") + for (m in t.plan.toList()) { t.move(m); cube.apply(m) } + assertTrue(t.scrambled) + assertEquals(Cube3x3.facelets(NxnCube(3).apply(scramble)), Cube3x3.facelets(cube)) + // then solving it is noticed + for (m in solver.solve3x3(Cube3x3.facelets(cube))!!.split(" ")) t.move(m) + assertTrue(t.solved) + } + + @Test fun wideMovesBecomeFaceTurnsReachingTheSameCubeUpToHowItIsHeld() { + val bld = "R U F2 Rw Uw'" + val faces = Cube3x3.faceTurns(bld) + assertEquals(listOf("R", "U", "F2", "L"), faces.take(4)) + // same cube: rotating the wide-move result back onto its centres gives the face-turn result + val wide = NxnCube(3).apply(bld) + val plain = Cube3x3.facelets(NxnCube(3).apply(faces.joinToString(" "))) + val rotations = listOf("", "x", "x2", "x'", "z", "z'").flatMap { a -> listOf("", "y", "y2", "y'").map { "$a $it".trim() } } + assertTrue(rotations.any { Cube3x3.facelets(wide.copy().apply(it)) == plain }) + } +} diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 44a8554..2705014 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -21,7 +21,15 @@ compose.desktop { nativeDistributions { // From `./gradlew :desktop:suggestRuntimeModules`; jdk.security.auth is needed by dbus-java (Bluetooth). modules("java.instrument", "java.net.http", "jdk.security.auth", "jdk.unsupported") - targetFormats(TargetFormat.AppImage, TargetFormat.Deb) + targetFormats(TargetFormat.AppImage, TargetFormat.Deb, TargetFormat.Dmg) + macOS { + infoPlist { + extraKeysRawXml = """ + <key>NSBluetoothAlwaysUsageDescription</key><string>Connects to your smart cube.</string> + <key>NSMicrophoneUsageDescription</key><string>Listens to a stackmat timer on an audio input.</string> + """ + } + } packageName = "rookery" packageVersion = "0.1.0" } diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt index 171a6d9..7c9b65f 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt @@ -1,7 +1,6 @@ package coffee.liz.rookery.desktop import coffee.liz.rookery.bt.BleAdvertisement -import coffee.liz.rookery.bt.BlueZTransport import coffee.liz.rookery.bt.GanCubeConnector import coffee.liz.rookery.bt.GanSmartCube import coffee.liz.rookery.bt.JvmAes128 @@ -9,6 +8,7 @@ import coffee.liz.rookery.bt.SmartCubeEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull @@ -28,7 +28,7 @@ fun cubeCli(args: List<String>) { exitProcess(0) } -private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport -> +private fun scan(seconds: Long, all: Boolean) = bleTransport().let { transport -> println("Scanning for ${if (all) "BLE devices" else "GAN cubes"} for $seconds s...") val seen = LinkedHashMap<String, BleAdvertisement>() runBlocking { @@ -49,10 +49,17 @@ private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport private const val SOLVED_FACELETS = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" -private fun monitor(mac: String) = BlueZTransport().use { transport -> +private fun monitor(mac: String) = bleTransport().let { transport -> runBlocking { println("Connecting to $mac...") - val cube = GanCubeConnector(transport, JvmAes128).connect(this, mac) + // Where the platform hides MACs (macOS), find the cube by the MAC inside its advertisement. + val address = if (transport is coffee.liz.rookery.bt.KableTransport) { + println("Listening for it (turn the cube to wake it)...") + withTimeoutOrNull(20_000) { + transport.scan().first { coffee.liz.rookery.bt.GanDefinitions.macFromManufacturerData(it.manufacturerData)?.equals(mac, true) == true || it.address.equals(mac, true) } + }?.address ?: run { println("Didn't hear a cube with that MAC."); return@runBlocking } + } else mac + val cube = GanCubeConnector(transport, JvmAes128).connect(this, address, mac) println("Connected: ${cube.name ?: "?"} (${(cube as? GanSmartCube)?.generation ?: "?"})") val done = CompletableDeferred<Unit>() Runtime.getRuntime().addShutdownHook(Thread { runBlocking { withTimeoutOrNull(2000) { cube.disconnect() } } }) diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt index 085ad9a..12a856a 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt @@ -75,6 +75,10 @@ class LocalConfig { (System.getenv("XDG_DATA_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".local", "share")).resolve("rookery") } +/** Linux talks to BlueZ directly (tested against a real cube); macOS and Windows go through Kable. `ROOKERY_BLE=kable` forces Kable. */ +fun bleTransport(): coffee.liz.rookery.bt.BleTransport = + if (System.getProperty("os.name").lowercase().contains("linux") && System.getenv("ROOKERY_BLE") != "kable") BlueZTransport() else coffee.liz.rookery.bt.KableTransport() + class DesktopHost(private val config: LocalConfig) : Host { override fun playChime(notes: List<Chime.Note>) = ChimePlayer.play(notes) @@ -110,6 +114,7 @@ class DesktopHost(private val config: LocalConfig) : Host { override fun setSyncFolder(path: String) { config.syncFolder = path } override val deviceId: String get() = config.deviceId override val braveApiKey: String? get() = config.braveApiKey + override fun setBraveApiKey(key: String?) = config.set("braveApiKey", key) override val touchInput: Boolean get() = false private val http: java.net.http.HttpClient by lazy { @@ -127,8 +132,7 @@ class DesktopHost(private val config: LocalConfig) : Host { return res.body().use { it.readNBytes(MAX_DOWNLOAD + 1) }.also { check(it.size <= MAX_DOWNLOAD) { "that file is too large" } } } - override suspend fun searchImages(query: String): List<ImageHit> = withContext(Dispatchers.IO) { - val key = config.braveApiKey ?: error("No Brave Search key on this machine.") + override suspend fun searchImages(query: String, key: String): List<ImageHit> = withContext(Dispatchers.IO) { val body = try { get(BraveImages.url(query), mapOf("Accept" to "application/json", BraveImages.KEY_HEADER to key)).decodeToString() } catch (e: java.io.IOException) { error("couldn't reach Brave (${e.message ?: "offline?"}).") } @@ -137,6 +141,35 @@ class DesktopHost(private val config: LocalConfig) : Host { override suspend fun fetchBytes(url: String): ByteArray = withContext(Dispatchers.IO) { get(url) } + /** Picture bytes from the clipboard: an image that was pasted, else a picture file that was copied. */ + override suspend fun clipboardImage(): ByteArray? = withContext(Dispatchers.Main) { + val contents = runCatching { java.awt.Toolkit.getDefaultToolkit().systemClipboard.getContents(null) }.getOrNull() ?: return@withContext null + val flavors = runCatching { contents.transferDataFlavors }.getOrNull() ?: return@withContext null + when { + flavors.contains(java.awt.datatransfer.DataFlavor.imageFlavor) -> withContext(Dispatchers.IO) { + // a pasted image arrives as an AWT Image; re-encode it as PNG so shrinkImage can read it + runCatching { + val img = contents.getTransferData(java.awt.datatransfer.DataFlavor.imageFlavor) as java.awt.Image + val w = img.getWidth(null); val h = img.getHeight(null) + if (w < 1 || h < 1) return@runCatching null + val bmp = java.awt.image.BufferedImage(w, h, java.awt.image.BufferedImage.TYPE_INT_ARGB) + val g = bmp.createGraphics() + g.drawImage(img, 0, 0, null); g.dispose() + java.io.ByteArrayOutputStream().use { out -> javax.imageio.ImageIO.write(bmp, "png", out); out.toByteArray() } + }.getOrNull() + } + flavors.contains(java.awt.datatransfer.DataFlavor.javaFileListFlavor) -> withContext(Dispatchers.IO) { + runCatching { + @Suppress("UNCHECKED_CAST") + val files = contents.getTransferData(java.awt.datatransfer.DataFlavor.javaFileListFlavor) as List<java.io.File> + val exts = listOf("jpg", "jpeg", "png", "webp", "gif") + files.firstOrNull { it.extension.lowercase() in exts }?.readBytes() + }.getOrNull() + } + else -> null + } + } + /** Fit inside 640px and re-encode as WebP, which is what gets synced. */ override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = runCatching { val img = org.jetbrains.skia.Image.makeFromEncoded(bytes) @@ -151,9 +184,9 @@ class DesktopHost(private val config: LocalConfig) : Host { data.bytes to "webp" }.getOrNull() - /** Bluetooth goes through BlueZ, so Linux only for now. */ - override val cubes: CubeBackend? = if (!System.getProperty("os.name").lowercase().contains("linux")) null else object : CubeBackend { - private val transport by lazy { BlueZTransport() } + /** Linux talks to BlueZ directly (tested against a real cube); macOS and Windows go through Kable. */ + override val cubes: CubeBackend? = object : CubeBackend { + private val transport by lazy { bleTransport() } override fun scan() = transport.scan().filter { GanCubeConnector.isGanCube(it) } override suspend fun connect(scope: CoroutineScope, address: String, mac: String) = GanCubeConnector(transport, JvmAes128).connect(scope, address, mac) } diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt index 6a00c24..1846c46 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt @@ -29,6 +29,8 @@ import kotlin.random.Random * `rookery --shots <dir>`: draws each screen offscreen with made-up data and writes PNGs. A development * aid for looking at the UI without a window (or anyone's real data) involved. */ +val fakeCubeEvents = kotlinx.coroutines.flow.MutableSharedFlow<coffee.liz.rookery.bt.SmartCubeEvent>(extraBufferCapacity = 256) + fun shots(args: List<String>) { val out = File(args.firstOrNull() ?: "shots").apply { mkdirs() } val repo = Repo(Store("shots", MemoryLogStorage(), MemoryBlobStore(), System::currentTimeMillis)) @@ -52,20 +54,18 @@ fun shots(args: List<String>) { override fun setSyncFolder(path: String) {} override val deviceId = "sample-device" override val braveApiKey: String? = "x" + override fun setBraveApiKey(key: String?) {} override val touchInput = false - override suspend fun searchImages(query: String) = emptyList<ImageHit>() + override suspend fun searchImages(query: String, key: String) = emptyList<ImageHit>() override suspend fun fetchBytes(url: String) = ByteArray(0) + override suspend fun clipboardImage(): ByteArray? = null override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = null override fun setFullscreen(on: Boolean) {} override val cubes = object : coffee.liz.rookery.bt.CubeBackend { override fun scan() = kotlinx.coroutines.flow.flowOf(coffee.liz.rookery.bt.BleAdvertisement("AA:BB:CC:00:11:22", "GANicE3_1122", -58)) override suspend fun connect(scope: CoroutineScope, address: String, mac: String) = object : coffee.liz.rookery.bt.SmartCube { override val name = "GANicE3_1122"; override val address = address - override val events = kotlinx.coroutines.flow.flow { - emit(coffee.liz.rookery.bt.SmartCubeEvent.Battery(87)) - emit(coffee.liz.rookery.bt.SmartCubeEvent.Facelets("UUUUUUFFFRRRRRRRRRFFDFFDFFDDDDDDDBBBLLLLLLLLLUBBUBBUBB")) - emit(coffee.liz.rookery.bt.SmartCubeEvent.Move("F", null, 0)); kotlinx.coroutines.awaitCancellation() - } + override val events = fakeCubeEvents override suspend fun requestFacelets() {}; override suspend fun requestBattery() {}; override suspend fun requestHardware() {} override suspend fun resetToSolved() {}; override suspend fun disconnect() {} } @@ -96,9 +96,53 @@ fun shots(args: List<String>) { shoot("party", settle = 1800) { model.testParty() } model.party = null for (mode in BldMode.entries) shoot("bld-${mode.name.lowercase()}", settle = 4000) { model.tab = Tab.BLD; model.bldMode = mode } + // phone-sized renders + shoot("phone-timer", w = 411, h = 890) { model.tab = Tab.TIMER } + shoot("phone-sessions", w = 411, h = 890, settle = 800) { model.timerPane = "solves" } + model.timerPane = "timer" + shoot("phone-targets", w = 411, h = 890, settle = 3000) { model.tab = Tab.BLD; model.bldMode = BldMode.TARGETS } + shoot("phone-display-landscape", w = 890, h = 411) { model.changeDisplayMode(true) } + model.changeDisplayMode(false) + shoot("algs") { model.tab = Tab.ALGS } + shoot("phone-algs", w = 411, h = 890) { model.tab = Tab.ALGS } shoot("settings") { model.tab = Tab.SETTINGS; model.cube.scan(1) } - shoot("settings-cube") { model.cube.connect("AA:BB:CC:00:11:22", "GANicE3_1122"); model.stackmat.change(true) } + shoot("settings-cube") { + model.cube.connect("AA:BB:CC:00:11:22", "GANicE3_1122"); model.stackmat.change(true) + Thread.sleep(300) + fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Battery(87)) + fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Facelets(coffee.liz.rookery.puzzle.Cube3x3.SOLVED)) + } model.stackmat.change(false) + + // Walk a whole smart-cube solve through the model with the fake cube. + fun turn(m: String) { fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Move(m, null, 0)); Thread.sleep(60) } + fun check(what: String, ok: Boolean) = println((if (ok) "ok " else "FAIL ") + what) + model.changeAutoStart(0) + for ((label, ev) in listOf("3x3" to Event.E333, "3bld" to Event.E333BF)) { + model.createSession(ev); Thread.sleep(2500) + val plan0 = model.smartPlan + check("$label: plan follows the scramble (${plan0.size} moves)", model.smartKnown && plan0.isNotEmpty() && !model.smartCorrected) + turn(plan0[0]); check("$label: first move consumed", model.smartPlan.size == plan0.size - 1 || model.smartPlan[0][0] == plan0[0][0]) + val next = model.smartPlan[0][0] + val wrong = "URFDLB".first { it != next && "URFDLB"[("URFDLB".indexOf(it) + 3) % 6] != next }.toString() + turn(wrong); check("$label: stray move gives a fix (${model.smartPlan.size} moves)", model.smartCorrected && model.smartPlan.isNotEmpty()) + if (label == "3x3") shoot("timer-smart", settle = 600) { model.tab = Tab.TIMER } + for (mv in model.smartPlan.toList()) turn(mv) + check("$label: scrambled", model.smartScrambled) + Thread.sleep(700) + val st = model.timer + if (ev.blind) { + check("$label: memo started by itself", st is coffee.liz.rookery.timer.TimerMachine.State.Running && model.memoMs == null) + shoot("timer-memo", settle = 900) { } + } else check("$label: inspection started by itself", st is coffee.liz.rookery.timer.TimerMachine.State.Inspecting) + val before = repo.solves().size + val solution = TnoodleScrambler().solve3x3(coffee.liz.rookery.puzzle.Cube3x3.facelets(coffee.liz.rookery.puzzle.NxnCube(3).apply(coffee.liz.rookery.puzzle.Cube3x3.faceTurns(model.scramble).joinToString(" "))))!!.split(" ") + for (mv in solution) turn(mv) + Thread.sleep(500) + val solve = repo.solves().maxByOrNull { it.at } + check("$label: solve recorded when the cube was solved", repo.solves().size == before + 1 && solve?.penalty == Penalty.NONE && solve.moves?.split(' ')?.size == solution.size) + if (ev.blind) check("$label: memo split stored (${solve?.memoMs} ms)", solve?.memoMs != null) + } shoot("import") { model.tab = Tab.IMPORT } model.setTheme(ThemeMode.LIGHT) shoot("timer-paper") { model.tab = Tab.TIMER } diff --git a/gradle.properties b/gradle.properties index 85a128d..e09a7c6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ # Sized for small machines (this is developed on a 4 GB laptop): one modest Gradle heap, # Kotlin compiled inside it rather than in a second daemon, and only two workers at a time. -org.gradle.jvmargs=-Xmx1280m -XX:MaxMetaspaceSize=384m -XX:+UseSerialGC -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx1280m -XX:MaxMetaspaceSize=640m -XX:+UseSerialGC -Dfile.encoding=UTF-8 kotlin.compiler.execution.strategy=in-process org.gradle.workers.max=2 org.gradle.caching=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index afef406..dffb52f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,14 +4,19 @@ compose = "1.12.2" coroutines = "1.11.0" serialization = "1.13.0" tnoodle = "0.20.0" +kable = "0.45.0" +agp = "9.5.1" +activity = "1.13.0" dbus-java = "5.2.2" [libraries] coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } tnoodle-scrambles = { module = "org.worldcubeassociation.tnoodle:lib-scrambles", version.ref = "tnoodle" } +tnoodle-min2phase = { module = "org.worldcubeassociation.tnoodle:scrambler-min2phase", version.ref = "tnoodle" } dbus-java-core = { module = "com.github.hypfvieh:dbus-java-core", version.ref = "dbus-java" } dbus-java-transport-native = { module = "com.github.hypfvieh:dbus-java-transport-native-unixsocket", version.ref = "dbus-java" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } @@ -19,7 +24,13 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" } compose-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose" } +kable = { module = "com.juul.kable:kable-core", version.ref = "kable" } + +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } + [plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index cae62dd..435cf5f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,4 +15,4 @@ dependencyResolutionManagement { } } -include(":core", ":ui", ":desktop") +include(":core", ":ui", ":desktop", ":android") diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 28e4ef6..76c559c 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -2,11 +2,18 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.compose) alias(libs.plugins.compose) + alias(libs.plugins.android.kmp.library) } kotlin { jvmToolchain(21) jvm() + androidLibrary { + namespace = "coffee.liz.rookery.ui" + compileSdk = 37 + minSdk = 26 + androidResources { enable = true } // for Compose resources (the penguin) + } sourceSets { commonMain.dependencies { diff --git a/ui/src/androidMain/kotlin/coffee/liz/rookery/ui/Svg.android.kt b/ui/src/androidMain/kotlin/coffee/liz/rookery/ui/Svg.android.kt new file mode 100644 index 0000000..4463466 --- /dev/null +++ b/ui/src/androidMain/kotlin/coffee/liz/rookery/ui/Svg.android.kt @@ -0,0 +1,7 @@ +package coffee.liz.rookery.ui + +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Density + +/** Android has no built-in SVG renderer; scramble drawings fall back to the 3D view where there is one. */ +actual fun svgPainter(svg: String, density: Density): Painter? = null diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AlgScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AlgScreen.kt new file mode 100644 index 0000000..0a64d24 --- /dev/null +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AlgScreen.kt @@ -0,0 +1,222 @@ +package coffee.liz.rookery.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coffee.liz.rookery.algs.AlgCase +import coffee.liz.rookery.algs.AlgSets +import coffee.liz.rookery.algs.AlgStatus +import coffee.liz.rookery.model.formatTime +import coffee.liz.rookery.monoMillis + +/** Last-layer colours with yellow on top and green in front, indexed U R F D L B. */ +private val PLAN_COLORS = listOf(Color(0xFFFFD500), Color(0xFFFF8A00), Color(0xFF2DB84D), Color(0xFFF4F4F4), Color(0xFFE02424), Color(0xFF2266E0)) +private val PLAN_OFF = Color(0xFF5A5F5C) + +/** + * A case seen from above: the top face with the side stickers folded out around it. For + * orientation sets only "top colour or not" matters, so everything else is grey. + */ +@Composable +fun CasePlan(case: AlgCase, alg: String, size: Dp, modifier: Modifier = Modifier) { + val plan = remember(case.id, alg) { AlgSets.plan(case.state(alg)) } + val orientationOnly = case.set == "oll" + Canvas(modifier.size(size).semantics { contentDescription = "${case.set} ${case.name}" }) { + val thin = 0.34f // side strips are slivers + val unit = this.size.width / (3 + 2 * thin) + fun span(i: Int) = if (i == 0 || i == 4) thin * unit else unit + fun start(i: Int) = when (i) { 0 -> 0f; else -> thin * unit + (i - 1) * unit } + val gap = unit * 0.06f + for (r in 0 until 5) for (c in 0 until 5) { + val v = plan[r][c] + if (v < 0) continue + val color = if (orientationOnly) (if (v == 0) PLAN_COLORS[0] else PLAN_OFF) else PLAN_COLORS[v] + drawRect(Color(0xFF14181C), Offset(start(c), start(r)), Size(span(c), span(r))) + drawRect(color, Offset(start(c) + gap, start(r) + gap), Size(span(c) - 2 * gap, span(r) - 2 * gap)) + } + } +} + +/** Algorithm sets: look cases up, choose your algorithm for each, mark what you're learning, and drill it. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AlgScreen(m: AppModel) { + var set by remember { mutableStateOf(AlgSets.names.first()) } + var drilling by remember { mutableStateOf(false) } + var selected by remember(set) { mutableStateOf<String?>(null) } + val version by m.repo.store.version.collectAsState() + val prefs = remember(version) { m.repo.algPrefs() } + fun status(c: AlgCase) = prefs[c.id]?.first ?: AlgStatus.NEW + fun alg(c: AlgCase) = prefs[c.id]?.second ?: c.algs.first() + val cases = AlgSets.cases(set) + + Column(Modifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Tabs(AlgSets.names, set, { it.uppercase() }, { set = it }) + Label(" ", color = Rk.c.inkDim) + Tabs(listOf(false, true), drilling, { if (it) "drill" else "cases" }, { drilling = it }) + Spacer(Modifier.weight(1f)) + val known = cases.count { status(it) == AlgStatus.KNOWN }; val learning = cases.count { status(it) == AlgStatus.LEARNING } + Label("$known known · $learning learning · ${cases.size}", style = Rk.t.label, color = Rk.c.inkDim) + } + Gap(6.dp) + if (drilling) { AlgDrill(m, cases, ::status, ::alg); return@Column } + Panes { + Column(Modifier.main().verticalScroll(rememberScrollState())) { + for ((group, inGroup) in cases.groupBy { it.group }) { + Label(group.lowercase(), color = Rk.c.heading, style = Rk.t.heading) + FlowRow(Modifier.padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + for (c in inGroup) { + val st = status(c) + Column( + Modifier.then(if (c.id == selected) Modifier.outline(Rk.c.accent, 2.dp) else Modifier.edge(Rk.c, sunk = true)) + .clickable { selected = c.id }.padding(5.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CasePlan(c, alg(c), 58.dp) + Label(c.name, style = Rk.t.label, color = when (st) { AlgStatus.KNOWN -> Rk.c.good; AlgStatus.LEARNING -> Rk.c.gold; AlgStatus.NEW -> Rk.c.inkDim }) + } + } + } + } + } + Column(Modifier.side(400.dp).sideScroll()) { + val c = cases.firstOrNull { it.id == selected } + if (c == null) Label("no case selected", color = Rk.c.inkDim) else CaseDetail(m, c, status(c), alg(c)) + } + } + } +} + +@Composable +private fun CaseDetail(m: AppModel, c: AlgCase, status: AlgStatus, chosen: String) { + var custom by remember(c.id) { mutableStateOf(chosen.takeIf { it !in c.algs }.orEmpty()) } + Row(verticalAlignment = Alignment.CenterVertically) { + CasePlan(c, chosen, 120.dp) + Spacer(Modifier.width(14.dp)) + Column { + BasicText("${c.set.uppercase()} ${c.name}", style = Rk.t.h1.copy(fontSize = 26.sp, color = Rk.c.ink)) + Label(c.group.lowercase(), color = Rk.c.inkDim) + Tabs(AlgStatus.entries, status, { it.name.lowercase() }, { m.repo.setAlgStatus(c.id, it) }) + } + } + Hr() + for (a in c.algs) { + val sel = a == chosen + Label( + a, Modifier.fillMaxWidth().clickable { m.repo.setAlgChoice(c.id, a.takeIf { it != c.algs.first() }) }.padding(vertical = 4.dp), + style = Rk.t.mono.copy(fontSize = 15.sp), color = if (sel) Rk.c.accent else Rk.c.link, + ) + } + Gap(4.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + RetroField(custom, { custom = it }, Modifier.weight(1f), placeholder = "your own", style = Rk.t.mono.copy(fontSize = 14.sp), description = "your own algorithm for ${c.name}") + val valid = remember(custom) { + // it has to actually solve this case: undo the usual algorithm, do yours, and the cube must be solved (any way up) + custom.isNotBlank() && runCatching { + val cube = coffee.liz.rookery.puzzle.NxnCube(3).apply(c.setup()).apply(AlgCase.clean(custom)) + (0 until 6).all { f -> (0 until 9).all { cube.stickers[f * 9 + it] == cube.stickers[f * 9 + 4] } } || + // or solved but for a turn of the top layer + listOf("U", "U2", "U'").any { u -> cube.copy().apply(u).let { t -> (0 until 6).all { f -> (0 until 9).all { t.stickers[f * 9 + it] == t.stickers[f * 9 + 4] } } } } + }.getOrDefault(false) + } + RetroButton("use", { m.repo.setAlgChoice(c.id, AlgCase.clean(custom)) }, enabled = valid) + } + if (custom.isNotBlank() && custom !in c.algs && chosen != AlgCase.clean(custom)) Label("only saved if it solves this case", color = Rk.c.inkDim, style = Rk.t.label) + Hr() + Label("setup ${c.setup(chosen)}", style = Rk.t.mono.copy(fontSize = 13.sp), color = Rk.c.inkDim) +} + +/** + * Drill: a case comes up (weak and unseen ones more often), you do it, then show the algorithm and + * say whether you had it. Cases marked "learning" are drilled; with none marked, the whole set is. + */ +@Composable +private fun AlgDrill(m: AppModel, cases: List<AlgCase>, status: (AlgCase) -> AlgStatus, alg: (AlgCase) -> String) { + val pool = cases.filter { status(it) == AlgStatus.LEARNING }.ifEmpty { cases } + val ids = pool.map { "alg:${it.id}" } + val recent = remember { mutableStateListOf<String>() } + var current by remember(ids) { mutableStateOf(m.bld.pick(ids, recent)?.removePrefix("alg:")) } + var started by remember(current) { mutableLongStateOf(monoMillis()) } + var shownAfter by remember(current) { mutableStateOf<Long?>(null) } + val c = pool.firstOrNull { it.id == current } ?: run { Label("nothing to drill"); return } + + fun reveal() { if (shownAfter == null) shownAfter = monoMillis() - started } + fun grade(ok: Boolean) { + m.bld.record("alg:${c.id}", ok, shownAfter ?: (monoMillis() - started), goodMs = 4_000); m.bld.tally(ok) + recent += "alg:${c.id}" + current = m.bld.pick(ids, recent)?.removePrefix("alg:") + } + val focus = remember { FocusRequester() } + LaunchedEffect(current) { runCatching { focus.requestFocus() } } + Column( + Modifier.fillMaxSize().focusRequester(focus).focusable().onPreviewKeyEvent { + if (it.type != KeyEventType.KeyDown) false else when { + shownAfter == null && (it.key == Key.Spacebar || it.key == Key.Enter) -> { reveal(); true } + shownAfter != null && (it.key == Key.Spacebar || it.key == Key.Enter) -> { grade(true); true } + shownAfter != null && (it.key == Key.X || it.key == Key.Backspace) -> { grade(false); true } + else -> false + } + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(Modifier.weight(1f), Alignment.Center) { CasePlan(c, alg(c), if (LocalNarrow.current) 220.dp else 280.dp) } + Label("setup ${c.setup(alg(c))}", style = Rk.t.mono.copy(fontSize = 14.sp), color = Rk.c.inkDim) + Gap() + val after = shownAfter + if (after == null) RetroButton("show", { reveal() }, description = "show the algorithm (space)") + else { + BasicText("${c.set.uppercase()} ${c.name}", style = Rk.t.heading.copy(color = Rk.c.heading)) + BasicText(alg(c), style = Rk.t.mono.copy(fontSize = 22.sp, color = Rk.c.ink)) + Row(verticalAlignment = Alignment.CenterVertically) { + Label("${formatTime(after)} ", style = Rk.t.mono, color = Rk.c.inkDim) + RetroButton("had it", { grade(true) }, description = "had it (space)") + Label(" · ", color = Rk.c.inkDim) + RetroButton("missed", { grade(false) }, description = "missed (x)") + } + } + Gap() + if (pool.size == cases.size && cases.none { status(it) == AlgStatus.LEARNING }) Label("drilling the whole set; mark cases as learning to narrow it", color = Rk.c.inkDim, style = Rk.t.label) + } +} diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt index 505a9d1..c714ba9 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -41,22 +43,27 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coffee.liz.rookery.model.Penalty import coffee.liz.rookery.timer.TimerMachine.State import kotlinx.coroutines.delay @Composable -fun RookeryApp(m: AppModel) { +fun RookeryApp(m: AppModel, insets: Modifier = Modifier) { val dark = when (m.themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true } RookeryTheme(dark) { val p = Rk.c - Box(Modifier.fillMaxSize().background(p.bg)) { - Column(Modifier.fillMaxSize().padding(horizontal = 14.dp, vertical = 8.dp)) { + // the page colour runs edge to edge; [insets] keeps content clear of system bars and the keyboard + // in display mode the timer takes the whole glass, camera cut-out and all + BoxWithConstraints(Modifier.fillMaxSize().background(p.bg).then(if (m.displayMode) Modifier else insets)) { + CompositionLocalProvider(LocalNarrow provides (maxWidth < 700.dp)) { + Column(Modifier.fillMaxSize().then(if (m.displayMode) Modifier else Modifier.padding(horizontal = if (LocalNarrow.current) 8.dp else 14.dp, vertical = if (LocalNarrow.current) 4.dp else 8.dp))) { if (!m.displayMode) Header(m) Box(Modifier.weight(1f).fillMaxWidth()) { when (m.tab) { Tab.TIMER -> TimerScreen(m) Tab.BLD -> BldScreen(m) + Tab.ALGS -> AlgScreen(m) Tab.IMPORT -> ImportScreen(m) Tab.SETTINGS -> SettingsScreen(m) } @@ -73,28 +80,39 @@ fun RookeryApp(m: AppModel) { m.penguinSays != null && s is State.Stopped && s.penalty == Penalty.DNF -> Mood.SAD else -> Mood.FREE } - PenguinFriend(mood, null, m.reducedMotion) + if (!m.displayMode) PenguinFriend(mood, null, m.reducedMotion, size = if (LocalNarrow.current) 40.dp else 64.dp) // the display is all timer m.party?.let { PbParty(it.title, it.detail, m.reducedMotion) { m.party = null } } + } } } } @Composable private fun Header(m: AppModel) { - Row(verticalAlignment = Alignment.Bottom) { - BasicText("rookery", style = Rk.t.h1.copy(color = Rk.c.heading)) - Spacer(Modifier.width(18.dp)) - Tabs(Tab.entries, m.tab, { it.label }, { m.tab = it }) + val narrow = LocalNarrow.current + // one line on a phone: small title, short names + Row(verticalAlignment = Alignment.CenterVertically) { + BasicText("rookery", style = Rk.t.h1.copy(color = Rk.c.heading, fontSize = if (narrow) 18.sp else Rk.t.h1.fontSize)) + Spacer(Modifier.width(if (narrow) 8.dp else 18.dp)) + Tabs(Tab.entries, m.tab, { if (narrow) it.short else it.label }, { m.tab = it }, style = if (narrow) Rk.t.body.copy(fontSize = 13.5.sp) else null) } - Hr() + if (narrow) Box(Modifier.fillMaxWidth().padding(vertical = 2.dp).height(2.dp).edge(Rk.c, sunk = true)) else Hr() } @Composable private fun Footer(m: AppModel) { - Hr() + val narrow = LocalNarrow.current + if (narrow) Box(Modifier.fillMaxWidth().padding(top = 2.dp).height(2.dp).edge(Rk.c, sunk = true)) else Hr() Row(Modifier.height(31.dp), verticalAlignment = Alignment.CenterVertically) { - Spacer(Modifier.width(84.dp)) // the penguin's spot - if (m.tab == Tab.TIMER) RetroButton("display mode", { m.changeDisplayMode(true) }, style = Rk.t.label) + Spacer(Modifier.width(if (narrow) 58.dp else 84.dp)) // the penguin's spot + when (m.tab) { + Tab.TIMER -> { + if (narrow) { Tabs(listOf("timer", "solves", "stats"), m.timerPane, { it }, { m.timerPane = it }, style = Rk.t.label); Spacer(Modifier.weight(1f)) } + RetroButton(if (narrow) "display" else "display mode", { m.changeDisplayMode(true) }, style = Rk.t.label) + } + Tab.BLD, Tab.ALGS -> if (narrow && m.bld.sessionTotal > 0) Label("${m.bld.sessionRight}/${m.bld.sessionTotal}", style = Rk.t.mono, color = Rk.c.inkDim) + else -> {} + } } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt index cbd31bd..58e7259 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt @@ -48,11 +48,14 @@ interface Host { val deviceId: String /** Key for Brave image search; null if not configured. Local to this machine, never synced. */ val braveApiKey: String? + fun setBraveApiKey(key: String?) /** True where fingers are the main input (phones, tablets): the timer then starts by touch. */ val touchInput: Boolean /** Image search for letter pairs. Throws with a readable message if it can't. */ - suspend fun searchImages(query: String): List<ImageHit> + suspend fun searchImages(query: String, key: String): List<ImageHit> suspend fun fetchBytes(url: String): ByteArray + /** Picture bytes from the system clipboard, or null if it holds no image. */ + suspend fun clipboardImage(): ByteArray? /** Downscale and re-encode a picture for storage; returns bytes and file extension, or null if it isn't an image. */ fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? fun setFullscreen(on: Boolean) @@ -68,7 +71,7 @@ interface Host { fun setLocalPref(key: String, value: String?) } -enum class Tab(val label: String) { TIMER("timer"), BLD("blindfolded"), IMPORT("import"), SETTINGS("settings") } +enum class Tab(val label: String, val short: String = label) { TIMER("timer"), BLD("blindfolded", "bld"), ALGS("algs"), IMPORT("import"), SETTINGS("settings") } enum class ThemeMode { SYSTEM, LIGHT, DARK } /** Everything derived from the store, recomputed off the UI thread when it changes. */ @@ -155,7 +158,7 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, init { machine.inspection = inspection machine.onFinish = ::finishSolve - scope.launch { repo.store.version.collect { refresh() } } + scope.launch { repo.store.version.collect { refresh(); if (braveKey == null) braveKey = repo.pref("braveApiKey")?.takeIf { it.isNotBlank() } } } scope.launch { // Pick up ops other devices wrote into the sync folder. while (true) { delay(2000); withContext(Dispatchers.Default) { repo.store.sync() } } @@ -192,27 +195,180 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, fun createSession(event: Event) { val id = randomId() - val count = derived.sessions.count { it.event == event } - repo.createSession(id, if (count == 0) "Main ${event.short}" else "${event.short} #${count + 1}", event, epochMillis()) + // named for what and when, e.g. "3x3-2026-11-13"; a second one that day gets a number + val base = "${event.short.lowercase()}-${coffee.liz.rookery.today()}" + val taken = derived.sessions.map { it.name }.toSet() + val name = if (base !in taken) base else generateSequence(2) { it + 1 }.map { "$base-$it" }.first { it !in taken } + repo.createSession(id, name, event, epochMillis()) selectSession(id) } + fun renameSession(id: String, name: String) { if (name.isNotBlank()) repo.renameSession(id, name.trim()) } + + fun deleteSession(id: String) { + val next = derived.sessions.lastOrNull { it.id != id } + repo.deleteSession(id) + if (next != null) selectSession(next.id) // with none left, a fresh 3x3 session is made + } + + /** The scramble before the current one, for when you moved on too soon. */ + var previousScramble by mutableStateOf<String?>(null); private set + private var scrambleEvent: Event? = null + fun newScramble() { val e = event - scramble = "…" - previewSvg = null - previewState = null + // Leave the current scramble on screen until the next is ready, so nothing blanks or jumps; + // only a change of event clears it, because the old one would be for the wrong puzzle. + if (scrambleEvent != e) { scramble = ""; previousScramble = null; previewSvg = null; previewState = null } scope.launch { val s = withContext(Dispatchers.Default) { scrambler.next(e) } val svg = withContext(Dispatchers.Default) { scrambler.previewSvg(e, s) } val st = withContext(Dispatchers.Default) { runCatching { Puzzles.of(e.puzzle)?.solved()?.apply(s) }.getOrNull() } - if (event == e) { scramble = s; previewSvg = svg; previewState = st } + if (event == e) { + if (scrambleEvent == e && scramble.isNotEmpty()) previousScramble = scramble + scrambleEvent = e + scramble = s; previewSvg = svg; previewState = st; resetTracker() + } } } + /** Which part of the timer a phone shows: "timer", "solves" or "stats". */ + var timerPane by mutableStateOf("timer") + /** Show hundredths while the clock runs (off: whole seconds, which is calmer). */ + var runningHundredths by mutableStateOf(repo.boolPref("runningHundredths", false)); private set + fun changeRunningHundredths(on: Boolean) { runningHundredths = on; repo.setPref("runningHundredths", on) } + // ---- timer input ---- - private fun sync() { timer = machine.state; lastActivity = monoMillis() } + private fun sync() { + val was = timer + timer = machine.state; lastActivity = monoMillis() + if (timer is TimerMachine.State.Running && was !is TimerMachine.State.Running) { + // a run has begun, however it was started: follow it on the cube if we can + // follow the run on the cube only if the cube is actually scrambled, and the mat isn't the timer + smartRun = smartActive && tracker.known && !tracker.solved && !stackmat.enabled + smartMoves.clear(); memoMs = null + countdownJob?.cancel(); countdownJob = null + } + } + + // ---- timing with a smart cube ---- + + private val tracker = coffee.liz.rookery.puzzle.ScrambleTracker(scrambler::solve3x3) + /** What is left of the scramble on the connected cube. */ + var smartPlan by mutableStateOf<List<String>>(emptyList()); private set + var smartCorrected by mutableStateOf(false); private set + /** The full sequence on show, which moves in it are done, and which one is in hand. */ + var smartSequence by mutableStateOf<List<String>>(emptyList()); private set + var smartDone by mutableStateOf<Set<Int>>(emptySet()); private set + var smartCurrent by mutableStateOf(-1); private set + var smartKnown by mutableStateOf(false); private set + var smartScrambled by mutableStateOf(false); private set + /** True while the running solve is being followed on the cube. */ + var smartRun by mutableStateOf(false); private set + /** Blind solves: when memo ended (first turn), in ms from the start. */ + var memoMs by mutableStateOf<Long?>(null); private set + /** Seconds to wait after the cube is scrambled before inspection (or blind memo) starts by itself; -1 = never. */ + var autoStart by mutableStateOf(repo.pref("cube.autoStart")?.toIntOrNull() ?: 2); private set + fun changeAutoStart(seconds: Int) { autoStart = seconds; repo.setPref("cube.autoStart", seconds.toString()) } + private val smartMoves = StringBuilder() + private var countdownJob: kotlinx.coroutines.Job? = null + + val smartActive: Boolean + get() = cube.connected != null && event.puzzle == coffee.liz.rookery.model.Puzzle.CUBE3 && event != Event.E333FM && event != Event.E333MBF + + private fun publish() { + smartSequence = tracker.sequence; smartDone = tracker.done; smartCurrent = tracker.current + smartPlan = tracker.plan; smartCorrected = tracker.corrected; smartKnown = tracker.known; smartScrambled = tracker.scrambled + } + + private fun resetTracker() { + countdownJob?.cancel(); countdownJob = null + if (smartActive && scramble.isNotEmpty()) tracker.reset(scramble, cube.facelets) + publish() + } + + private var lastMoveAt = 0L + + /** + * The cube reports its whole state every so often. Moves can go missing over Bluetooth, so this + * is the final word on where the cube is, once it has been still long enough that the report + * can't be older than the last move we saw. + */ + private fun onCubeFacelets(facelets: String) { + val now = monoMillis() + if (!tracker.known) { + if (smartActive) { tracker.reset(scramble, facelets); publish(); if (tracker.scrambled) onScrambled() } else tracker.correct(facelets) + liveFacelets = tracker.facelets + return + } + val settled = now - lastMoveAt > 400 + val running = machine.state as? TimerMachine.State.Running + if (running != null && smartRun) { + // solved, whatever our own count of the moves says: stop at the last turn + if (facelets == coffee.liz.rookery.puzzle.Cube3x3.SOLVED && (settled || tracker.solved)) { + tracker.correct(facelets); liveFacelets = facelets + machine.finish(maxOf(lastMoveAt, running.since)); sync() + } else if (settled && tracker.correct(facelets)) liveFacelets = facelets + return + } + if (settled && tracker.correct(facelets)) { + liveFacelets = facelets + if (smartActive && running == null) { countdownJob?.cancel(); countdownJob = null; tracker.replanFromHere(); publish(); if (tracker.scrambled) onScrambled() } + } + } + + /** The connected cube as it is right now (54 facelets), for drawing. */ + var liveFacelets by mutableStateOf<String?>(null); private set + + private fun onCubeMove(move: String) { + lastMoveAt = monoMillis() + if (!smartActive) { tracker.turn(move); liveFacelets = tracker.facelets; return } // keep our picture of the cube current regardless + val now = lastMoveAt + try { handleCubeMove(move, now) } finally { liveFacelets = tracker.facelets } + } + + private fun handleCubeMove(move: String, now: Long) { + if (stackmat.enabled) { + // the mat does the timing; the cube only shows scramble progress + if (tracker.scrambled) tracker.turn(move) else { tracker.move(move); publish() } + return + } + fun record(since: Long) { smartMoves.append(move).append('@').append(now - since).append(' ') } + when (val s = machine.state) { + is TimerMachine.State.Running -> if (!smartRun) tracker.turn(move) else { + if (event.blind && memoMs == null) memoMs = now - s.since + record(s.since); tracker.turn(move) + if (tracker.solved) { machine.finish(now); sync() } + } + is TimerMachine.State.Inspecting -> { machine.beginRunning(now); sync(); record(now); tracker.turn(move) } + else -> { + val wasScrambled = tracker.scrambled + if (wasScrambled && countdownJob == null && !event.blind) { + // scrambled and nothing pending: the first turn starts the clock + machine.beginRunning(now); sync(); record(now); tracker.turn(move) + return + } + countdownJob?.cancel(); countdownJob = null + tracker.move(move); publish() + if (!wasScrambled && tracker.scrambled) onScrambled() + } + } + } + + private fun onScrambled() { + if (stackmat.enabled) return + if (autoStart < 0 || (!event.blind && !inspection)) return + countdownJob?.cancel() + countdownJob = scope.launch { + delay(autoStart * 1000L) + countdownJob = null + if (!tracker.scrambled || !(machine.state is TimerMachine.State.Idle || machine.state is TimerMachine.State.Stopped)) return@launch + val now = monoMillis() + if (event.blind) machine.beginRunning(now) else machine.beginInspection(now) + sync() + } + } fun press() { machine.press(monoMillis()); sync() } fun release() { machine.release(monoMillis()); sync() } @@ -235,7 +391,18 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, private fun finishSolve(ms: Long, penalty: Penalty) { val session = derived.session ?: return - val solve = Solve(randomId(), session.id, session.event, ms, penalty, scramble, "", epochMillis()) + val followed = smartRun + smartRun = false + // a start and stop in the same breath is a slip of the finger, not a solve + if (ms < 250 && penalty != Penalty.DNF) return + val solve = Solve( + randomId(), session.id, session.event, ms, + // stopped by hand with the cube still unsolved + if (followed && !tracker.solved && penalty != Penalty.DNF) Penalty.DNF else penalty, + scramble, "", epochMillis(), + moves = smartMoves.toString().trim().takeIf { followed && it.isNotEmpty() }, + memoMs = memoMs.takeIf { followed }, + ) val before = derived.eventStats val priorResults = repo.solves().filter { it.event == session.event }.map { it.result } repo.addSolve(solve) @@ -350,10 +517,28 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, val (data, ext) = host.shrinkImage(bytes) ?: (bytes to path.substringAfterLast('.', "jpg")) repo.setPairImage(pair, repo.store.blobs.put(data, ext)) } + + /** Keep whatever picture is on the clipboard as [pair]'s image; reports through [imageStatus]. */ + fun setPairImageFromClipboard(pair: String) = scope.launch { + imageStatus = "pasting…" + val stored = withContext(Dispatchers.Default) { host.clipboardImage()?.let { host.shrinkImage(it) } } + if (stored == null) { imageStatus = "no picture on the clipboard."; return@launch } + repo.setPairImage(pair, repo.store.blobs.put(stored.first, stored.second)) + imageStatus = "" + } fun blob(name: String): ByteArray? = repo.store.blobs.get(name) // ---- image search ---- + /** + * The Brave Search key travels with the synced data, so entering it once covers every device. + * A key in this machine's own config (or environment) still works and is copied into the sync + * the first time it is seen. + */ + var braveKey by mutableStateOf(repo.pref("braveApiKey")?.takeIf { it.isNotBlank() } ?: host.braveApiKey?.also { repo.setPref("braveApiKey", it) }) + private set + fun changeBraveKey(key: String?) { repo.setPref("braveApiKey", key?.trim()?.takeIf { it.isNotEmpty() }); if (key == null) host.setBraveApiKey(null); braveKey = repo.pref("braveApiKey") } + var imageHits by mutableStateOf<List<ImageHit>>(emptyList()); private set var imageStatus by mutableStateOf(""); private set val thumbs = androidx.compose.runtime.mutableStateMapOf<String, ByteArray>() @@ -364,7 +549,7 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, imageHits = emptyList(); thumbs.clear(); imageStatus = "searching…" imageJob = scope.launch { try { - val hits = host.searchImages(query) + val hits = host.searchImages(query, braveKey ?: error("No Brave Search key (see settings).")) imageHits = hits imageStatus = if (hits.isEmpty()) "nothing found for “$query”." else "" for (h in hits) launch { runCatching { host.fetchBytes(h.thumbnail) }.onSuccess { thumbs[h.thumbnail] = it } } @@ -392,6 +577,9 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, suspend fun freshScramble(e: Event): String = withContext(Dispatchers.Default) { scrambler.next(e) } val bld: BldModel by lazy { BldModel(repo) } - val cube = CubeModel(repo, host.cubes, scope) + val cube = CubeModel(repo, host.cubes, scope).also { + it.onMove = { e -> onCubeMove(e.move) } + it.onFacelets = ::onCubeFacelets + } val stackmat = StackmatModel(host, scope) { ms -> if (tab == Tab.TIMER) finishSolve(ms, Penalty.NONE) } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt index f5c9dba..3695abb 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt @@ -71,19 +71,20 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.random.Random -enum class BldMode(val label: String) { - P2W("pair → word"), W2P("word → pair"), TARGETS("targets"), MEMO("memo"), PAIRS("letter pairs") +enum class BldMode(val label: String, val short: String = label) { + P2W("pair → word", "p→w"), W2P("word → pair", "w→p"), TARGETS("targets"), MEMO("memo"), PAIRS("letter pairs", "pairs") } @Composable fun BldScreen(m: AppModel) { val mode = m.bldMode Column(Modifier.fillMaxSize()) { + val narrow = LocalNarrow.current Row(verticalAlignment = Alignment.CenterVertically) { - Tabs(BldMode.entries, mode, { it.label }, { m.bldMode = it }) - Spacer(Modifier.weight(1f)) + Tabs(BldMode.entries, mode, { if (narrow) it.short else it.label }, { m.bldMode = it }, Modifier.weight(1f), style = if (narrow) Rk.t.label else null) val b = m.bld - if (b.sessionTotal > 0) Label("${b.sessionRight}/${b.sessionTotal}", style = Rk.t.mono, color = Rk.c.inkDim) + // on a phone the score sits in the footer, out of the tabs' way + if (!narrow && b.sessionTotal > 0) Label("${b.sessionRight}/${b.sessionTotal}", style = Rk.t.mono, color = Rk.c.inkDim) } Gap(6.dp) Box(Modifier.weight(1f).fillMaxWidth()) { @@ -128,9 +129,14 @@ private fun PairToWord(m: AppModel) { if (keys.isEmpty()) { Label("no letter pairs yet"); return } val recent = remember { mutableStateListOf<String>() } + val narrow = LocalNarrow.current + val cubeMapping = remember { CubeMapping(Puzzles.cube(3), 3) } + val cubeColors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } + val solvedCube = remember(bld.setup) { cubeMapping.state(bld.setup.solvedColors) } + val perBatch = if (narrow) 1 else 3 fun newBatch(): List<String> { val out = mutableListOf<String>() - repeat(minOf(3, keys.size)) { bld.pick(keys.map { "p2w:$it" }, recent + out.map { "p2w:$it" })?.removePrefix("p2w:")?.let { out += it } } + repeat(minOf(perBatch, keys.size)) { bld.pick(keys.map { "p2w:$it" }, recent + out.map { "p2w:$it" })?.removePrefix("p2w:")?.let { out += it } } return out } var batch by remember { mutableStateOf(newBatch()) } @@ -162,13 +168,22 @@ private fun PairToWord(m: AppModel) { val lp = pairs[pair] val miss = pair in missed val where = remember(pair, bld.setup) { pairOnCube(bld.setup, pair, Random.Default) } - Table(Modifier.width(280.dp).fillMaxHeight().clickable(enabled = revealedAfter != null) { if (miss) missed.remove(pair) else missed.add(pair) }) { + Table((if (narrow) Modifier.weight(1f) else Modifier.width(280.dp)).fillMaxHeight().clickable(enabled = revealedAfter != null) { if (miss) missed.remove(pair) else missed.add(pair) }) { Column(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { BasicText(pair, style = Rk.t.mono.copy(fontSize = 64.sp, fontWeight = FontWeight.Bold, color = Rk.c.ink)) if (where != null) { val (type, a, b) = where - CubeNet(bld.setup.solvedColors, 16.dp, marks = mapOf(Cube3.slotOf(type, a) to "1", Cube3.slotOf(type, b) to "2"), dim = true) + val slots = listOf(Cube3.slotOf(type, a), Cube3.slotOf(type, b)) + val camera = remember(pair, where) { cornerView(Cube3.bestView(slots)).let { (y, p) -> Camera(y, p) } } + PuzzleView( + solvedCube, cubeColors, Modifier.size(180.dp), camera, description = "$pair on the cube; drag to spin", draggable = true, + marks = mapOf(cubeMapping.stickerOfSlot(slots[0]) to "1", cubeMapping.stickerOfSlot(slots[1]) to "2"), ghost = true, + arrow = cubeMapping.stickerOfSlot(slots[0]) to cubeMapping.stickerOfSlot(slots[1]), + ) Label("${if (type == PieceType.CORNER) "corners" else "edges"}: ${Cube3.stickerName(type, a)} → ${Cube3.stickerName(type, b)}", color = Rk.c.inkDim, style = Rk.t.label) + } else { + // same piece, or on a buffer, both as corners and as edges: it never comes up in a solve + Label("not a possible pair with your buffers", color = Rk.c.inkDim, style = Rk.t.label) } } Column(Modifier.fillMaxWidth().weight(1f).edge(Rk.c, sunk = true).padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { @@ -179,7 +194,7 @@ private fun PairToWord(m: AppModel) { val others = lp.words - setOfNotNull(lp.primary) if (others.isNotEmpty()) Label(others.sorted().joinToString(", "), color = Rk.c.inkDim, style = Rk.t.label) lp.note?.let { Label(it, color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic)) } - Label(if (miss) "missed" else "${i + 1}: mark missed", color = if (miss) Rk.c.bad else Rk.c.inkDim, style = Rk.t.label) + Label(if (miss) "missed" else if (narrow) "tap: mark missed" else "${i + 1}: mark missed", color = if (miss) Rk.c.bad else Rk.c.inkDim, style = Rk.t.label) } } } @@ -308,47 +323,42 @@ private fun Targets(m: AppModel) { val letter = setup.scheme.letter(q.type, q.home) val kind = if (q.type == PieceType.CORNER) "corner" else "edge" - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Box(Modifier.weight(1f).fillMaxHeight(), Alignment.Center) { + Panes { + Box(Modifier.main(), Alignment.Center) { if (find) CubeNet( - q.state, 44.dp, + q.state, if (LocalNarrow.current) 30.dp else 44.dp, selected = whereSlot.takeIf { verdict == false }, describe = { "sticker" }, onPick = { slot -> if (Cube3.positionOf(PieceType.CORNER, slot) != null || Cube3.positionOf(PieceType.EDGE, slot) != null) answer(slot == whereSlot) }, ) else PuzzleView(mapping.state(q.state), colors, Modifier.fillMaxSize(), camera, description = "scrambled cube with one $kind sticker ringed", highlight = mapping.stickerOfSlot(whereSlot)) } - Column(Modifier.width(330.dp)) { + Column(Modifier.side(330.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Tabs(listOf(false, true), find, { if (it) "find" else "name" }, { find = it }) - } - Row(verticalAlignment = Alignment.CenterVertically) { - for ((i, t) in PieceType.entries.withIndex()) { - if (i > 0) Label(" · ", color = Rk.c.inkDim) - RetroButton(t.name.lowercase() + "s", { types = if (t in types && types.size > 1) types - t else types + t }, selected = t in types) + Label(if (find) "find the $kind" else "name the ringed $kind", Modifier.weight(1f), color = Rk.c.inkDim) + // the answer shows here, above the input, where a keyboard can't cover it + verdict?.let { ok -> + Label("$letter ${Cube3.stickerName(q.type, q.home)}", Modifier.semantics { liveRegion = LiveRegionMode.Polite }, color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 20.sp)) } } - Hr() - if (find) { - Label(kind, color = Rk.c.inkDim) - BasicText("$letter", style = Rk.t.mono.copy(fontSize = 96.sp, fontWeight = FontWeight.Bold, color = Rk.c.ink)) - } else { - Label(kind, color = Rk.c.inkDim) - Gap(6.dp) + if (find) BasicText("$letter", style = Rk.t.mono.copy(fontSize = 72.sp, fontWeight = FontWeight.Bold, color = Rk.c.ink)) + else { val focus = remember { FocusRequester() } LaunchedEffect(q) { runCatching { focus.requestFocus() } } RetroField( typed, { v -> val c = v.uppercase().filter { it.isLetter() }.take(1); typed = c; if (c.isNotEmpty()) answer(setup.scheme.position(q.type, c[0]) == q.home) }, - Modifier.width(90.dp).focusRequester(focus), style = Rk.t.mono.copy(fontSize = 56.sp, textAlign = TextAlign.Center), description = "letter for the ringed sticker", + Modifier.width(90.dp).focusRequester(focus), style = Rk.t.mono.copy(fontSize = 48.sp, textAlign = TextAlign.Center), description = "letter for the ringed sticker", ) } - Gap() - verdict?.let { ok -> - Label( - "$letter ${Cube3.stickerName(q.type, q.home)}", - Modifier.semantics { liveRegion = LiveRegionMode.Polite }, color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 20.sp), - ) + Hr() + Row(verticalAlignment = Alignment.CenterVertically) { + Tabs(listOf(false, true), find, { if (it) "find" else "name" }, { find = it }) + Label(" ", color = Rk.c.inkDim) + for ((i, t) in PieceType.entries.withIndex()) { + if (i > 0) Label(" · ", color = Rk.c.inkDim) + RetroButton(t.name.lowercase() + "s", { types = if (t in types && types.size > 1) types - t else types + t }, selected = t in types) + } } } } @@ -459,8 +469,8 @@ private fun MemoTrainer(m: AppModel) { m.say(if (solved) "squawk!" else "peck.") } - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Column(Modifier.weight(1f).fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally) { + Panes { + Column(Modifier.main(), horizontalAlignment = Alignment.CenterHorizontally) { when { state == null -> Box(Modifier.fillMaxSize(), Alignment.Center) { Label("…", color = Rk.c.inkDim) } phase == MemoPhase.EXECUTE || phase == MemoPhase.RECALL -> Box(Modifier.fillMaxSize(), Alignment.Center) { @@ -472,7 +482,8 @@ private fun MemoTrainer(m: AppModel) { } else -> { PuzzleView( - mapping.state(state), colors, Modifier.fillMaxWidth().weight(1f), camera, description = "scrambled cube; the view follows what you type", + mapping.state(state), colors, Modifier.fillMaxWidth().weight(1f), camera, description = "scrambled cube; the view follows what you type, and you can drag it", + draggable = true, // look around freely; the next letter typed brings the view back highlight = lookSlot?.takeIf { phase == MemoPhase.MEMO }?.let { mapping.stickerOfSlot(it) }, ) if (phase == MemoPhase.MEMO) Row(verticalAlignment = Alignment.CenterVertically) { @@ -486,7 +497,7 @@ private fun MemoTrainer(m: AppModel) { } } } - Column(Modifier.width(400.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { + Column(Modifier.side(400.dp).sideScroll()) { Label(scramble ?: "…", style = Rk.t.mono.copy(fontSize = 13.sp), color = Rk.c.inkDim) BasicText(formatTime(if (phase == MemoPhase.MEMO) now - memoStart else memoMs), style = Rk.t.mono.copy(fontSize = 40.sp, color = Rk.c.ink)) when (phase) { diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt index a2c2857..ac609f0 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import coffee.liz.rookery.bt.BleAdvertisement import coffee.liz.rookery.bt.CubeBackend +import coffee.liz.rookery.bt.GanDefinitions +import kotlinx.coroutines.flow.first import coffee.liz.rookery.bt.SmartCube import coffee.liz.rookery.bt.SmartCubeEvent import coffee.liz.rookery.model.CubeDevice @@ -36,6 +38,10 @@ class CubeModel(private val repo: Repo, val backend: CubeBackend?, private val s var lastMove by mutableStateOf<String?>(null); private set private var scanJob: Job? = null private var linkJob: Job? = null + /** Hooks for whoever is timing with the cube. */ + var onMove: (SmartCubeEvent.Move) -> Unit = {} + var onFacelets: (String) -> Unit = {} + private var pollJob: Job? = null fun scan(seconds: Long = 15) { val b = backend ?: return @@ -51,20 +57,52 @@ class CubeModel(private val repo: Repo, val backend: CubeBackend?, private val s fun stopScan() { scanJob?.cancel(); scanning = false } - fun connect(address: String, name: String?, mac: String = address) { + private val macShape = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") + + /** The cube's real MAC, which its encryption is keyed on: from its advertisement, its address, or a cube we already know by name. */ + private fun macOf(adv: BleAdvertisement): String? = + GanDefinitions.macFromManufacturerData(adv.manufacturerData) + ?: adv.address.takeIf { macShape.matches(it) } + ?: repo.devices().firstOrNull { it.name == adv.name }?.mac + + /** Pair with a cube heard while scanning. */ + fun pair(adv: BleAdvertisement) { + val mac = macOf(adv) + if (mac == null) { status = "can't read this cube's MAC from its advertisement"; return } + connect(adv.address, adv.name, mac) + } + + /** Connect to a saved cube: listen for it first, since its address differs per platform. */ + fun connectKnown(d: CubeDevice) { val b = backend ?: return stopScan(); linkJob?.cancel() + status = "looking for ${d.name}" + linkJob = scope.launch { + val adv = try { + withTimeoutOrNull(12_000) { b.scan().first { a -> macOf(a)?.equals(d.mac, true) == true || (a.name != null && a.name == d.name) } } + } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { status = e.message ?: "scan failed"; return@launch } + if (adv == null) { status = "can't hear ${d.name}. turn it to wake it"; return@launch } + connect(adv.address, adv.name ?: d.name, d.mac) + } + } + + fun connect(address: String, name: String?, mac: String = address) { + val b = backend ?: return + stopScan(); val previous = linkJob status = "connecting" linkJob = scope.launch { + if (previous != kotlin.coroutines.coroutineContext[Job]) previous?.cancel() try { val cube = withContext(Dispatchers.Default) { b.connect(scope, address, mac) } connected = cube; status = ""; battery = null; hardware = null; facelets = null; moves = 0; lastMove = null repo.saveDevice(CubeDevice(mac.uppercase(), cube.name ?: name ?: mac, "gan")) launch { delay(200); cube.requestHardware(); delay(300); cube.requestBattery(); delay(300); cube.requestFacelets() } + // ask for the whole state regularly: it is what catches a lost move and confirms "solved" + launch { delay(2500); while (true) { runCatching { cube.requestFacelets() }; delay(2500) } } cube.events.collect { e -> when (e) { - is SmartCubeEvent.Move -> { moves++; lastMove = e.move } - is SmartCubeEvent.Facelets -> facelets = e.facelets + is SmartCubeEvent.Move -> { moves++; lastMove = e.move; onMove(e); if (e.afterGap) launch { runCatching { cube.requestFacelets() } } } + is SmartCubeEvent.Facelets -> { facelets = e.facelets; onFacelets(e.facelets) } is SmartCubeEvent.Battery -> battery = e.percent is SmartCubeEvent.Hardware -> hardware = listOfNotNull(e.name, e.softwareVersion?.let { "sw $it" }).joinToString(" ") is SmartCubeEvent.Gyro -> {} @@ -114,7 +152,7 @@ class StackmatModel(private val host: Host, private val scope: CoroutineScope, o scope.launch { packet = p; live = true; session.packet(p) } } line = host.openAudio(input) { samples, n -> decoder.feed(samples, n); level = decoder.signal } - scope.launch { while (enabled) { delay(500); if (coffee.liz.rookery.monoMillis() - lastPacketAt > 1000) live = false } } + scope.launch { while (enabled) { delay(500); if (coffee.liz.rookery.monoMillis() - lastPacketAt > 3000) live = false } } } catch (e: Exception) { enabled = false; error = e.message ?: "couldn't open the input" } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt index 6ed1cdf..f0d1778 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -53,10 +54,12 @@ fun SettingsScreen(m: AppModel) { val scope = rememberCoroutineScope() var syncPath by remember { mutableStateOf(m.host.syncFolder) } var moved by remember { mutableStateOf(false) } - Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(28.dp)) { - Column(Modifier.weight(1f).widthIn(max = 560.dp)) { + val narrow = LocalNarrow.current + val body: @Composable (Modifier, Modifier) -> Unit = { left, right -> + Column(left) { Heading("timer") Checkbox("WCA inspection", m.inspection, m::changeInspection) + Checkbox("show hundredths while running", m.runningHundredths, m::changeRunningHundredths) Checkbox("sound", m.sound, m::changeSound) Row { RetroButton("test PB sound", { m.testChime() }) @@ -81,25 +84,34 @@ fun SettingsScreen(m: AppModel) { if (moved) Label("Restart to apply.", color = Rk.c.gold) Hr() Heading("image search") - Label( - if (m.host.braveApiKey != null) "Brave key found." - else "No key. Set braveApiKey in ~/.config/rookery/config.properties.", - color = if (m.host.braveApiKey != null) Rk.c.ink else Rk.c.gold, - ) + var key by remember { mutableStateOf("") } + if (m.braveKey != null) Row(verticalAlignment = Alignment.CenterVertically) { + Label("Brave key set (synced) ") + RetroButton("remove", { m.changeBraveKey(null) }) + } else Row(verticalAlignment = Alignment.CenterVertically) { + RetroField(key, { key = it.trim() }, Modifier.weight(1f), placeholder = "Brave Search API key", style = Rk.t.mono.copy(fontSize = 13.sp), description = "Brave Search API key") + RetroButton("save", { m.changeBraveKey(key); key = "" }, enabled = key.isNotBlank()) + } Hr() SmartCubes(m) Hr() Stackmat(m) } - Column(Modifier.weight(1f).widthIn(max = 560.dp)) { BldSetup(m) } + Column(right) { if (narrow) Hr(); BldSetup(m) } } + if (narrow) Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { body(Modifier.fillMaxWidth(), Modifier.fillMaxWidth()) } + else Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(28.dp)) { body(Modifier.weight(1f).widthIn(max = 560.dp), Modifier.weight(1f).widthIn(max = 560.dp)) } } @Composable private fun SmartCubes(m: AppModel) { val c = m.cube Heading("smart cube") - if (c.backend == null) { Label("Bluetooth isn't supported on this platform yet.", color = Rk.c.inkDim); return } + if (c.backend == null) { Label("no Bluetooth on this platform", color = Rk.c.inkDim); return } + Row(verticalAlignment = Alignment.CenterVertically) { + Label("auto-start after scramble: ", color = Rk.c.inkDim) + Tabs(listOf(-1, 0, 1, 2, 3), m.autoStart, { if (it < 0) "off" else "${it}s" }, { m.changeAutoStart(it) }, style = Rk.t.label) + } val cube = c.connected if (cube != null) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -119,16 +131,16 @@ private fun SmartCubes(m: AppModel) { } val known = m.repo.devices() val knownMacs = known.map { it.mac.uppercase() }.toSet() - val nearby = c.found.values.filter { it.address.uppercase() !in knownMacs }.sortedByDescending { it.rssi ?: -999 } + val nearby = c.found.values.filter { it.address.uppercase() !in knownMacs && known.none { k -> k.name == it.name } }.sortedByDescending { it.rssi ?: -999 } if (known.isNotEmpty() || nearby.isNotEmpty()) Table { Row { HeaderCell("name", 2f); HeaderCell("address", 2f); HeaderCell("", 2f) } for (d in known) Row(verticalAlignment = Alignment.CenterVertically) { Cell(d.name, 2f, style = Rk.t.body); Cell(d.mac, 2f, style = Rk.t.mono.copy(fontSize = 12.sp)) - Row(Modifier.weight(2f)) { RetroButton("connect", { c.connect(d.mac, d.name) }, style = Rk.t.label); RetroButton("forget", { c.forget(d.mac) }, style = Rk.t.label) } + Row(Modifier.weight(2f)) { RetroButton("connect", { c.connectKnown(d) }, style = Rk.t.label); RetroButton("forget", { c.forget(d.mac) }, style = Rk.t.label) } } for (a in nearby) Row(verticalAlignment = Alignment.CenterVertically) { Cell(a.name ?: "?", 2f, style = Rk.t.body); Cell(a.address, 2f, style = Rk.t.mono.copy(fontSize = 12.sp)) - Row(Modifier.weight(2f)) { RetroButton("pair", { c.connect(a.address, a.name) }, style = Rk.t.label); a.rssi?.let { Label(" $it dBm", color = Rk.c.inkDim, style = Rk.t.label, modifier = Modifier.padding(vertical = 4.dp)) } } + Row(Modifier.weight(2f)) { RetroButton("pair", { c.pair(a) }, style = Rk.t.label); a.rssi?.let { Label(" $it dBm", color = Rk.c.inkDim, style = Rk.t.label, modifier = Modifier.padding(vertical = 4.dp)) } } } } Row(verticalAlignment = Alignment.CenterVertically) { @@ -180,7 +192,7 @@ private fun BldSetup(m: AppModel) { Label("lettering (click a sticker to change it)", color = Rk.c.inkDim) Gap(6.dp) CubeNet( - setup.solvedColors, 34.dp, + setup.solvedColors, if (LocalNarrow.current) 27.dp else 34.dp, text = { slot -> target(slot)?.let { (t, pos) -> setup.scheme.letter(t, pos).toString() } }, selected = editing?.let { Cube3.slotOf(it.first, it.second) }, describe = { slot -> target(slot)?.let { (t, pos) -> "${Cube3.stickerName(t, pos)} ${setup.scheme.letter(t, pos)}" } }, diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt index dfa241c..2a5a791 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt @@ -62,8 +62,8 @@ fun PairsScreen(m: AppModel) { val glance = query.uppercase().filter { it in LETTERS }.take(2) val shown = if (glance.length == 2) glance else selected - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Column(Modifier.weight(1f).fillMaxHeight()) { + Panes { + Column(Modifier.main()) { Row(verticalAlignment = Alignment.CenterVertically) { Label("pair: ") RetroField(query, { query = it.take(2) }, Modifier.width(72.dp).focusRequester(focus), placeholder = "AB", style = Rk.t.mono.copy(fontSize = 22.sp), description = "type a letter pair") @@ -73,7 +73,7 @@ fun PairsScreen(m: AppModel) { Gap(6.dp) PairGrid(m.derived.pairs, shown) { selected = it; query = ""; m.clearImageSearch() } } - Column(Modifier.width(400.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { + Column(Modifier.side(400.dp).sideScroll()) { if (shown != null) PairCard(m, shown, m.derived.pairs[shown]) else Label("no pair selected", color = Rk.c.inkDim) } } @@ -148,10 +148,13 @@ private fun PairCard(m: AppModel, pair: String, lp: LetterPair?) { RetroButton("search", { finding = !finding; if (finding && query.isNotBlank()) m.searchImages(query) else m.clearImageSearch() }, selected = finding) Label(" · ", color = p.inkDim) RetroButton("file", { m.setPairImageFromFile(pair) }) + Label(" · ", color = p.inkDim) + RetroButton("paste", { m.setPairImageFromClipboard(pair) }, description = "use the picture on the clipboard") if (lp?.image != null) { Label(" · ", color = p.inkDim); RetroButton("remove", { m.repo.setPairImage(pair, null) }) } } + if (!finding && m.imageStatus.isNotEmpty()) Label(m.imageStatus, Modifier.padding(horizontal = 6.dp, vertical = 2.dp), color = p.gold, style = Rk.t.label) if (finding) Column(Modifier.fillMaxWidth().edge(p, sunk = true).padding(6.dp)) { - if (m.host.braveApiKey == null) Label("No Brave key (see settings).", color = p.gold) + if (m.braveKey == null) Label("No Brave key (see settings).", color = p.gold) else { Row(verticalAlignment = Alignment.CenterVertically) { RetroField(query, { query = it }, Modifier.weight(1f), placeholder = "search", onSubmit = { m.searchImages(query) }, description = "image search") diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Panes.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Panes.kt new file mode 100644 index 0000000..8ff13ab --- /dev/null +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Panes.kt @@ -0,0 +1,50 @@ +package coffee.liz.rookery.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** True on phone-width windows, where side-by-side panes stack instead. */ +val LocalNarrow = staticCompositionLocalOf { false } + +interface PaneScope { + /** The pane that takes whatever room is left. */ + fun Modifier.main(): Modifier + /** The fixed pane: [width] wide beside the main one, or full width under it when stacked. */ + fun Modifier.side(width: Dp): Modifier + /** Lets the side pane scroll by itself when panes sit side by side (stacked, the whole page scrolls instead). */ + fun Modifier.sideScroll(): Modifier +} + +/** Two panes: side by side on a wide window, stacked on a narrow one. */ +@Composable +fun Panes(modifier: Modifier = Modifier, content: @Composable PaneScope.() -> Unit) { + // Stacked, the whole thing scrolls, so an on-screen keyboard can't squeeze the main pane away. + if (LocalNarrow.current) Column(modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + object : PaneScope { + override fun Modifier.main() = fillMaxWidth().height(300.dp) + override fun Modifier.side(width: Dp) = fillMaxWidth() + override fun Modifier.sideScroll() = this + }.content() + } else Row(modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { + val scope = this + val scroll = rememberScrollState() + object : PaneScope { + override fun Modifier.main() = with(scope) { weight(1f) }.fillMaxHeight() + override fun Modifier.side(width: Dp) = width(width).fillMaxHeight() + override fun Modifier.sideScroll() = verticalScroll(scroll) + }.content() + } +} diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt index 548451f..5dca583 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt @@ -73,12 +73,12 @@ private const val SPRITE = 32 fun penguinSheet(): ImageBitmap = imageResource(Res.drawable.penguin_sheet) /** Draw one frame [sizePx] pixels square, nearest-neighbour so the pixels stay crisp at any density. */ -fun DrawScope.drawPenguin(sheet: ImageBitmap, cell: Pair<Int, Int>, at: Offset, sizePx: Float, alpha: Float = 1f) { +fun DrawScope.drawPenguin(sheet: ImageBitmap, cell: Pair<Int, Int>, at: Offset, sizePx: Float, alpha: Float = 1f, colorFilter: androidx.compose.ui.graphics.ColorFilter? = null) { drawImage( sheet, srcOffset = IntOffset(cell.first * SPRITE, cell.second * SPRITE), srcSize = IntSize(SPRITE, SPRITE), dstOffset = IntOffset(at.x.roundToInt(), at.y.roundToInt()), dstSize = IntSize(sizePx.roundToInt(), sizePx.roundToInt()), - alpha = alpha, filterQuality = FilterQuality.None, + alpha = alpha, colorFilter = colorFilter, filterQuality = FilterQuality.None, ) } @@ -109,7 +109,7 @@ fun PenguinFriend(mood: Mood, says: String?, reducedMotion: Boolean, size: Dp = val sizePx = with(LocalDensity.current) { size.toPx() } BoxWithConstraints(Modifier.fillMaxSize().clearAndSetSemantics { }) { val h = constraints.maxHeight.toFloat() - val pos = Offset(sizePx * 0.9f, h - sizePx * 0.8f) + val pos = Offset(sizePx * 0.9f, h - sizePx * 0.62f) var anim by remember { mutableStateOf(PenguinAnim.IDLE) } var frame by remember { mutableIntStateOf(0) } LaunchedEffect(mood, reducedMotion) { diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt index c979239..6258513 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt @@ -18,7 +18,9 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.drawText import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coffee.liz.rookery.puzzle.Polygon import coffee.liz.rookery.puzzle.PuzzleState import coffee.liz.rookery.puzzle.Turn @@ -91,7 +93,13 @@ fun PuzzleView( onStickerTap: ((Int) -> Unit)? = null, /** Let the user spin it by dragging (left/right spins, up/down tilts). */ draggable: Boolean = false, + /** Stickers to label. With [ghost], everything else is drawn faint and see-through so these show even from behind. */ + marks: Map<Int, String> = emptyMap(), + ghost: Boolean = false, + /** Draw an arrow from one sticker to another, through the cube if need be. */ + arrow: Pair<Int, Int>? = null, ) { + val measurer = androidx.compose.ui.text.rememberTextMeasurer() val puzzle = state.puzzle val body = Color(0xFF14181C) val hits = remember { ArrayList<Pair<Int, List<Offset>>>() } @@ -124,7 +132,7 @@ fun PuzzleView( Triple(a.turn, axis.normal, -2 * PI * a.turn.amount / axis.order * a.progress) } - class Drawn(val index: Int, val tile: Polygon, val color: Color, val depth: Double, val shade: Float) + class Drawn(val index: Int, val tile: Polygon, val color: Color, val depth: Double, val shade: Float, val front: Boolean) val drawn = ArrayList<Drawn>(puzzle.stickers.size) for ((i, s) in puzzle.stickers.withIndex()) { var poly = s @@ -133,25 +141,54 @@ fun PuzzleView( } val v = poly.map(::view) val n = v.normal - if ((n dot (V3(0.0, 0.0, dist) - v.centroid)) <= 0) continue // back face + val front = (n dot (V3(0.0, 0.0, dist) - v.centroid)) > 0 + if (!front && !(ghost && i in marks)) continue // back faces only matter when a marked sticker is on one val lambert = maxOf(0.0, n dot light).toFloat() - drawn += Drawn(i, v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert) + drawn += Drawn(i, v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, if (front) 0.62f + 0.38f * lambert else 0.8f, front) } drawn.sortBy { it.depth } hits.clear() for (d in drawn) { val tile = d.tile.points.map(::project) - fill(tile, body) + val faint = ghost && d.index !in marks + fill(tile, body.copy(alpha = if (faint) 0.55f else 1f)) val sticker = d.tile.inset(0.86).points.map(::project) - fill(sticker, d.color.shaded(d.shade)) - drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1.dp.toPx())) - hits += d.index to tile + fill(sticker, d.color.shaded(d.shade).copy(alpha = if (faint) 0.45f else 1f)) + if (!faint) drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1.dp.toPx())) + if (d.front) hits += d.index to tile } if (highlight != null) drawn.firstOrNull { it.index == highlight }?.let { d -> val ring = d.tile.inset(0.97).points.map(::project) drawPath(path(ring), Color.Black, style = Stroke(5.dp.toPx())) drawPath(path(ring), highlightColor, style = Stroke(3.dp.toPx())) } + fun centre(i: Int) = project(view(puzzle.stickers[i].centroid)) + if (arrow != null) { + val from = centre(arrow.first); val to = centre(arrow.second) + val d = to - from + val len = d.getDistance() + if (len > 1f) { + val u = d / len; val normal = Offset(-u.y, u.x) + val head = 11.dp.toPx() + val tip = to - u * 6.dp.toPx() // stop just short, so the label under it stays readable + val base = tip - u * head + val shaft = Path().apply { moveTo(from.x, from.y); lineTo(base.x, base.y) } + val arrowHead = Path().apply { + moveTo(tip.x, tip.y); lineTo(base.x + normal.x * head * 0.5f, base.y + normal.y * head * 0.5f) + lineTo(base.x - normal.x * head * 0.5f, base.y - normal.y * head * 0.5f); close() + } + drawPath(shaft, Color.Black, style = Stroke(6.dp.toPx())); drawPath(arrowHead, Color.Black, style = Stroke(3.dp.toPx())) + drawPath(shaft, highlightColor, style = Stroke(3.dp.toPx())); drawPath(arrowHead, highlightColor) + drawCircle(Color.Black, 5.dp.toPx(), from); drawCircle(highlightColor, 3.5.dp.toPx(), from) + } + } + for ((i, label) in marks) { + val at = centre(i) + val layout = measurer.measure(label, androidx.compose.ui.text.TextStyle(color = Color.Black, fontSize = 11.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)) + val r = maxOf(layout.size.width, layout.size.height) * 0.55f + drawCircle(Color.Black, r + 1.5.dp.toPx(), at); drawCircle(Color.White, r, at) + drawText(layout, topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f)) + } } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt index 2deaac7..9bf6685 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt @@ -120,7 +120,7 @@ fun RetroButton( withStyle( when { !enabled -> SpanStyle(color = p.inkDim) - selected -> SpanStyle(color = p.accent, fontWeight = FontWeight.Bold) + selected -> SpanStyle(color = p.accent) // colour only: bold would change the width and re-wrap the row else -> SpanStyle(color = if (hovered) p.accent else p.link, textDecoration = TextDecoration.Underline) }, ) { append(text) } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt index ae69607..b5a5529 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt @@ -15,12 +15,14 @@ import androidx.compose.ui.graphics.drawscope.DrawScope * and enough letters for "dnF". */ @Composable -fun SevenSegment(text: String, on: Color, ghost: Color, modifier: Modifier = Modifier) { +fun SevenSegment(text: String, on: Color, ghost: Color, modifier: Modifier = Modifier, fitTo: String = text) { Canvas(modifier) { val cells = text.map { cellWidth(it) } val gap = 0.22f val units = cells.sum() + gap * (text.length - 1).coerceAtLeast(0) - val u = minOf(size.width / (units + SKEW * H), size.height / H) + // digits are sized for [fitTo] (or the text itself if that is wider), then the text is centred + val template = fitTo.sumOf { cellWidth(it).toDouble() }.toFloat() + gap * (fitTo.length - 1).coerceAtLeast(0) + val u = minOf(size.width / (maxOf(units, template) + SKEW * H), size.height / H) var x = (size.width - units * u) / 2 - SKEW * H * u / 2 val y = (size.height - H * u) / 2 for ((i, ch) in text.withIndex()) { diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Svg.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Svg.kt new file mode 100644 index 0000000..d97626a --- /dev/null +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Svg.kt @@ -0,0 +1,7 @@ +package coffee.liz.rookery.ui + +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Density + +/** A painter for an SVG document, or null where this platform can't draw one. */ +expect fun svgPainter(svg: String, density: Density): Painter? diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt index ed6f403..3d5115a 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -39,6 +40,7 @@ import androidx.compose.runtime.withFrameMillis 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -46,7 +48,11 @@ import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coffee.liz.rookery.model.DNF @@ -59,8 +65,6 @@ import coffee.liz.rookery.puzzle.Puzzles import coffee.liz.rookery.stats.Averages import coffee.liz.rookery.stats.StatKind import coffee.liz.rookery.timer.TimerMachine.State -import org.jetbrains.compose.resources.ExperimentalResourceApi -import org.jetbrains.compose.resources.decodeToSvgPainter @Composable fun TimerScreen(m: AppModel) { @@ -68,6 +72,7 @@ fun TimerScreen(m: AppModel) { LaunchedEffect(ticking) { while (ticking) withFrameMillis { m.frame() } } if (m.displayMode) { DisplayMode(m); return } + if (LocalNarrow.current) { NarrowTimer(m); return } Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.width(280.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(10.dp)) { SessionBox(m) @@ -80,24 +85,51 @@ fun TimerScreen(m: AppModel) { } Column(Modifier.width(280.dp).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { StatsBox(m) + LiveCube(m) PreviewBox(m) SelectedSolveBox(m) } } } -@OptIn(ExperimentalLayoutApi::class) +/** Phone layout: one line of session and averages, then the timer gets everything else. Solves and stats are in the footer. */ @Composable -private fun SessionBox(m: AppModel) { +private fun NarrowTimer(m: AppModel) { var choosing by remember { mutableStateOf(false) } + val ss = m.derived.sessionStats + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + RetroButton(m.derived.session?.name ?: "session", { choosing = !choosing }, selected = choosing, style = Rk.t.label, description = "choose session") + Spacer(Modifier.weight(1f)) + val parts = listOf(StatKind.AO5, StatKind.AO12).mapNotNull { k -> ss.line(k)?.let { "${k.label} ${formatTime(it.current, true)}" } } + Label((parts + "${ss.count}").joinToString(" "), style = Rk.t.mono.copy(fontSize = 12.sp), color = Rk.c.inkDim, maxLines = 1) + } + if (choosing) { SessionBox(m) { choosing = false }; return@Column } + when (m.timerPane) { + "solves" -> { SolveTable(m, Modifier.weight(1f)); SelectedSolveBox(m) } + "stats" -> Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { StatsBox(m); LiveCube(m); PreviewBox(m) } + else -> { + ScrambleBox(m) + TimerDevice(m, Modifier.weight(1f).fillMaxWidth()) + Row(Modifier.horizontalScroll(rememberScrollState())) { LastSolveLine(m) } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SessionBox(m: AppModel, onPicked: () -> Unit = {}) { + var mode by remember { mutableStateOf("") } // "", "new", "rename", "delete" + val current = m.derived.session GroupBox("sessions") { - Column(Modifier.heightIn(max = 150.dp)) { + Column(Modifier.heightIn(max = 170.dp)) { LazyColumn { itemsIndexed(m.derived.sessions) { _, s -> - val sel = s.id == m.derived.session?.id + val sel = s.id == current?.id Row( Modifier.fillMaxWidth().then(if (sel) Modifier.background(Rk.c.select) else Modifier) - .clickable { m.selectSession(s.id) }.padding(horizontal = 4.dp, vertical = 2.dp), + .clickable { m.selectSession(s.id); mode = ""; onPicked() }.padding(horizontal = 4.dp, vertical = 3.dp), ) { Label(s.name, Modifier.weight(1f), color = if (sel) Rk.c.selectInk else Rk.c.link, maxLines = 1) Label(s.event.short, color = if (sel) Rk.c.selectInk else Rk.c.inkDim, style = Rk.t.label) @@ -105,9 +137,27 @@ private fun SessionBox(m: AppModel) { } } } - RetroButton(if (choosing) "cancel" else "new session", { choosing = !choosing }) - if (choosing) FlowRow { - for (e in Event.entries) RetroButton(e.short, { m.createSession(e); choosing = false }, description = "new ${e.title} session", style = Rk.t.label) + Tabs(listOf("new", "rename", "delete"), mode, { it }, { mode = if (mode == it) "" else it }, style = Rk.t.label) + when (mode) { + "new" -> FlowRow { + for (e in Event.entries) RetroButton(e.short, { m.createSession(e); mode = ""; onPicked() }, description = "new ${e.title} session", style = Rk.t.label) + } + "rename" -> if (current != null) { + var name by remember(current.id) { mutableStateOf(current.name) } + Row(verticalAlignment = Alignment.CenterVertically) { + RetroField(name, { name = it }, Modifier.weight(1f), onSubmit = { m.renameSession(current.id, name); mode = "" }, description = "session name") + RetroButton("save", { m.renameSession(current.id, name); mode = "" }, enabled = name.isNotBlank()) + } + } + "delete" -> if (current != null) Column { + // the question wraps; the buttons stay short + Label("${current.name} and its ${m.derived.sessionSolves.size} solves?", color = Rk.c.bad) + Row { + RetroButton("yes, delete", { m.deleteSession(current.id); mode = "" }) + Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) + RetroButton("keep", { mode = "" }) + } + } } } } @@ -152,10 +202,33 @@ private fun ScrambleBox(m: AppModel) { RetroButton("next", { m.newScramble() }) } val size = when { m.scramble.length > 300 -> 13.sp; m.scramble.length > 120 -> 16.sp; else -> 22.sp } - BasicText( - m.scramble, Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(10.dp).semantics { contentDescription = "scramble: ${m.scramble}" }, + if (m.smartActive && m.smartKnown && m.timer !is State.Running) { + // following the scramble on the cube: what is left, next move first + val plan = m.smartPlan + // the sequence stays where it is; turned moves fade and the one in hand is pink + val text = buildAnnotatedString { + if (m.smartCorrected) withStyle(SpanStyle(color = Rk.c.gold, fontSize = size * 0.6f)) { append("fix ") } + for ((i, t) in m.smartSequence.withIndex()) { + if (i > 0) append(" ") + when { + i in m.smartDone -> withStyle(SpanStyle(color = Rk.c.inkDim.copy(alpha = 0.45f))) { append(t) } + i == m.smartCurrent -> withStyle(SpanStyle(color = Rk.c.accent, fontWeight = FontWeight.Bold)) { append(t) } + else -> append(t) + } + } + if (m.smartSequence.isEmpty()) withStyle(SpanStyle(color = Rk.c.good)) { append("scrambled") } + } + BasicText( + text, Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(10.dp).semantics { contentDescription = if (plan.isEmpty()) "scrambled" else "next ${plan[0]}, ${plan.size} moves left" }, + style = Rk.t.mono.copy(fontSize = 22.sp, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = 30.sp), + ) + } else BasicText( + m.scramble.ifEmpty { " " }, Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(10.dp).semantics { contentDescription = "scramble: ${m.scramble}" }, style = Rk.t.mono.copy(fontSize = size, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = size * 1.35f), ) + m.previousScramble?.let { + BasicText("previous $it", Modifier.fillMaxWidth().padding(horizontal = 6.dp), style = Rk.t.mono.copy(fontSize = 11.sp, color = Rk.c.inkDim), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) + } } } @@ -183,16 +256,23 @@ private fun inspectionText(elapsed: Long): String { * fills whatever room it has, and the two lamps a mat has. Red is "hands down", green is "go". */ @Composable -fun TimerDevice(m: AppModel, modifier: Modifier = Modifier) { +fun TimerDevice(m: AppModel, modifier: Modifier = Modifier, bleed: Boolean = false) { val s = m.timer val last = m.lastSolve()?.let { if (it.penalty == Penalty.DNF) "dnF" else formatTime(it.result) + if (it.penalty == Penalty.PLUS2) "+" else "" } ?: "0.00" - val mat = m.stackmat.packet.takeIf { m.stackmat.enabled && m.stackmat.live } - val text = if (mat != null) formatTime(mat.ms) else readout(s, m.now, last) + // With the mat as the timer, show the mat and nothing else, even through a gap in its signal. + val mat = m.stackmat.packet.takeIf { m.stackmat.enabled } + val running = mat?.running ?: (s is State.Running) + val raw = if (m.stackmat.enabled) (mat?.let { formatTime(it.ms) } ?: "0.00") else readout(s, m.now, last) + val text = when { + running && !m.runningHundredths -> raw.substringBefore('.') // whole seconds while the clock runs + Regex("^\\d\\.\\d\\d.*").matches(raw) -> "0$raw" // always a tens-of-seconds digit + else -> raw + } val glass = Color(0xFFA9B7A1) val ink = Color(0xFF1A2316) Column( - modifier.clip(RoundedCornerShape(18.dp)).background(Color(0xFF1B1D1C)).outline(Color(0xFF3A3D3B)).padding(14.dp) - .then(if (!m.host.touchInput) Modifier else Modifier.pointerInput(Unit) { + modifier.clip(RoundedCornerShape(if (bleed) 0.dp else 18.dp)).background(Color(0xFF1B1D1C)).outline(Color(0xFF3A3D3B)).padding(if (bleed) 6.dp else 14.dp) + .then(if (!m.host.touchInput || m.stackmat.enabled) Modifier else Modifier.pointerInput(Unit) { awaitEachGesture { awaitFirstDown(); m.press(); waitForUpOrCancellation(); m.release() } }), ) { @@ -200,14 +280,50 @@ fun TimerDevice(m: AppModel, modifier: Modifier = Modifier) { Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(6.dp)).background(glass).outline(Color(0xFF0C0D0C), 2.dp).padding(horizontal = 22.dp, vertical = 14.dp) .semantics { liveRegion = LiveRegionMode.Polite; contentDescription = "timer ${text.replace("dnF", "DNF")}" }, ) { - SevenSegment(text, ink, ink.copy(alpha = 0.06f), Modifier.fillMaxSize()) + // digits are sized for "00.00" whatever is showing, so 9 and 12 and 12.34 are all the same height + SevenSegment(text, ink, ink.copy(alpha = 0.06f), Modifier.fillMaxSize(), fitTo = "00.00") + if (m.smartRun && m.event.blind && s is State.Running) MemoWalker(memorizing = m.memoMs == null, still = m.reducedMotion, ink) } - Row(Modifier.fillMaxWidth().padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.fillMaxWidth().padding(top = if (bleed) 6.dp else 10.dp), verticalAlignment = Alignment.CenterVertically) { Lamp(Color(0xFFFF4D4D), lit = if (mat != null) mat.handsDown && !mat.ready else s is State.Holding) Spacer(Modifier.width(8.dp)) Lamp(Color(0xFF58E06F), lit = if (mat != null) mat.ready || mat.running else s is State.Ready || s is State.Running) Spacer(Modifier.weight(1f)) - BasicText(if (m.stackmat.enabled) (if (mat != null) "stackmat" else "stackmat: no signal") else "rookery", style = Rk.t.label.copy(color = Color(0xFF6A6F6C), letterSpacing = 3.sp)) + if (m.stackmat.enabled) BasicText(if (m.stackmat.live) "stackmat" else "stackmat: no signal", style = Rk.t.label.copy(color = Color(0xFF6A6F6C))) + } + } +} + +/** + * Blind solves on a smart cube: while you memorise, a little LCD penguin paces along the top of the + * glass, the way a handheld game's one character would; when you start turning it stops and watches. + */ +@Composable +private fun MemoWalker(memorizing: Boolean, still: Boolean, ink: Color) { + val sheet = penguinSheet() + var t by remember { mutableStateOf(0L) } + LaunchedEffect(memorizing, still) { + if (!memorizing || still) return@LaunchedEffect + val start = withFrameMillis { it } + while (true) t = withFrameMillis { it } - start + } + // draw the sprite as liquid crystal: dark where the penguin is dark, clear where it is white + val lcd = remember(ink) { + androidx.compose.ui.graphics.ColorFilter.colorMatrix(androidx.compose.ui.graphics.ColorMatrix(floatArrayOf( + 0f, 0f, 0f, 0f, ink.red * 255, 0f, 0f, 0f, 0f, ink.green * 255, 0f, 0f, 0f, 0f, ink.blue * 255, -0.4f, -0.4f, -0.4f, 1.2f, 0f, + ))) + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + BasicText(if (memorizing) "MEMO" else "EXEC", style = Rk.t.mono.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = ink, letterSpacing = 2.sp)) + Canvas(Modifier.weight(1f).padding(start = 12.dp).height(40.dp)) { + val sz = size.height + val span = (size.width - sz).coerceAtLeast(0f) + val lap = 5200L + val phase = (t % (2 * lap)).toFloat() / lap // 0..2: there and back + val x = if (!memorizing) 0f else if (phase < 1f) phase * span else (2f - phase) * span + val anim = if (!memorizing) PenguinAnim.ALERT else if (still) PenguinAnim.IDLE else if (phase < 1f) PenguinAnim.E else PenguinAnim.W + drawRect(ink.copy(alpha = 0.10f), Offset(0f, sz - 2.dp.toPx()), androidx.compose.ui.geometry.Size(size.width, 2.dp.toPx())) // the ground + drawPenguin(sheet, anim.frames[((t / 190) % anim.frames.size).toInt()], Offset(x, 0f), sz, colorFilter = lcd) } } } @@ -229,7 +345,8 @@ private fun LastSolveLine(m: AppModel) { Tabs(listOf(Penalty.NONE, Penalty.PLUS2, Penalty.DNF), last.penalty, { when (it) { Penalty.NONE -> "ok"; Penalty.PLUS2 -> "+2"; Penalty.DNF -> "dnf" } }, { m.setPenalty(last.id, it) }) Label(" · ", color = Rk.c.inkDim) RetroButton("delete", { m.deleteSolve(last.id) }, description = "delete last solve") - Spacer(Modifier.weight(1f)) + last.memoMs?.let { memo -> if (last.penalty != Penalty.DNF || last.ms > memo) Label(" memo ${formatTime(memo)} + exec ${formatTime(last.ms - memo)}", style = Rk.t.mono.copy(fontSize = 13.sp), color = Rk.c.inkDim) } + Spacer(Modifier.width(12.dp)) if (m.lastPbs.isNotEmpty()) Label("PB " + m.lastPbs.joinToString(", ") { it.kind.label }, color = Rk.c.accent, style = Rk.t.heading) } } @@ -256,14 +373,35 @@ private fun StatsBox(m: AppModel) { } } -@OptIn(ExperimentalResourceApi::class) +/** The connected cube as it is now, held the way you hold it; null when there is none. */ +@Composable +private fun liveCubeState(m: AppModel): coffee.liz.rookery.puzzle.PuzzleState? { + val f = m.liveFacelets?.takeIf { m.cube.connected != null && it.length == 54 } ?: return null + val mapping = remember { coffee.liz.rookery.puzzle.CubeMapping(Puzzles.cube(3), 3) } + val rotation = m.bld.setup.rotation + return remember(f, rotation) { + val c = coffee.liz.rookery.puzzle.NxnCube(3) + for (i in 0 until 54) c.stickers[i] = "URFDLB".indexOf(f[i]).coerceAtLeast(0) + mapping.state(c.apply(rotation).stickers) + } +} + +@Composable +private fun LiveCube(m: AppModel) { + val state = liveCubeState(m) ?: return + val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } + GroupBox("cube") { + PuzzleView(state, colors, Modifier.fillMaxWidth().height(200.dp), description = "your cube, live; drag to spin", draggable = true) + } +} + @Composable private fun PreviewBox(m: AppModel) { m.replay?.let { ReplayBox(m, it); return } val state = m.previewState val svg = m.previewSvg val density = LocalDensity.current - val painter = remember(svg, density) { svg?.let { runCatching { it.encodeToByteArray().decodeToSvgPainter(density) }.getOrNull() } } + val painter = remember(svg, density) { svg?.let { svgPainter(it, density) } } if (state == null && painter == null) return GroupBox("scramble") { val colors = remember(m.event) { Puzzles.colors(m.event.puzzle).map(::rgb) } @@ -312,15 +450,31 @@ private fun SelectedSolveBox(m: AppModel) { @Composable private fun DisplayMode(m: AppModel) { val ss = m.derived.sessionStats - Column(Modifier.fillMaxSize().padding(8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - val size = if (m.scramble.length > 120) 18.sp else 28.sp - BasicText(m.scramble, Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = size, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = size * 1.3f)) - TimerDevice(m, Modifier.weight(1f).fillMaxWidth()) - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(Modifier.width(84.dp)) // the penguin's spot - val parts = listOf(StatKind.AO5, StatKind.AO12, StatKind.AO100).mapNotNull { k -> ss.line(k)?.let { "${k.label} ${formatTime(it.current, true)}" } } - Label((listOf(m.event.short.lowercase(), "${ss.count} solves") + parts).joinToString(" · "), Modifier.weight(1f), style = Rk.t.mono.copy(fontSize = 18.sp), color = Rk.c.inkDim) - RetroButton("exit", { m.changeDisplayMode(false) }) + val cube = liveCubeState(m) + val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } + androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) { + val landscape = maxWidth > maxHeight + val cubeWidth = maxWidth * 0.24f + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + // one line where there's width for it, shrinking the type to fit + BasicText( + m.scramble.ifEmpty { " " }, Modifier.fillMaxWidth().padding(horizontal = 8.dp), + style = Rk.t.mono.copy(color = Rk.c.ink, textAlign = TextAlign.Center), + maxLines = if (landscape) 1 else 2, softWrap = !landscape, + autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 9.sp, maxFontSize = 26.sp), + ) + if (landscape) Row(Modifier.weight(1f).fillMaxWidth()) { + TimerDevice(m, Modifier.weight(1f).fillMaxHeight(), bleed = true) + if (cube != null) PuzzleView(cube, colors, Modifier.width(cubeWidth).fillMaxHeight(), description = "your cube, live", draggable = true) + } else { + TimerDevice(m, Modifier.weight(1f).fillMaxWidth(), bleed = true) + if (cube != null) PuzzleView(cube, colors, Modifier.fillMaxWidth().height(170.dp), description = "your cube, live", draggable = true) + } + Row(Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { + val parts = listOf(StatKind.AO5, StatKind.AO12, StatKind.AO100).mapNotNull { k -> ss.line(k)?.let { "${k.label} ${formatTime(it.current, true)}" } } + Label((listOf(m.event.short.lowercase(), "${ss.count}") + parts).joinToString(" · "), Modifier.weight(1f), style = Rk.t.mono.copy(fontSize = 14.sp), color = Rk.c.inkDim, maxLines = 1) + RetroButton("exit", { m.changeDisplayMode(false) }, style = Rk.t.label) + } } } } diff --git a/ui/src/jvmMain/kotlin/coffee/liz/rookery/ui/Svg.jvm.kt b/ui/src/jvmMain/kotlin/coffee/liz/rookery/ui/Svg.jvm.kt new file mode 100644 index 0000000..520e6a9 --- /dev/null +++ b/ui/src/jvmMain/kotlin/coffee/liz/rookery/ui/Svg.jvm.kt @@ -0,0 +1,9 @@ +package coffee.liz.rookery.ui + +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Density +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.decodeToSvgPainter + +@OptIn(ExperimentalResourceApi::class) +actual fun svgPainter(svg: String, density: Density): Painter? = runCatching { svg.encodeToByteArray().decodeToSvgPainter(density) }.getOrNull() |
