summaryrefslogtreecommitdiff
path: root/ui/src/commonMain/kotlin/coffee/liz/rookery
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/commonMain/kotlin/coffee/liz/rookery')
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt70
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt305
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt112
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt180
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt176
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt124
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt225
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt75
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt323
9 files changed, 1590 insertions, 0 deletions
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
new file mode 100644
index 0000000..3cc79dd
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
@@ -0,0 +1,70 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.unit.dp
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.timer.TimerMachine.State
+
+@Composable
+fun RookeryApp(m: AppModel) {
+ val dark = when (m.themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
+ RookeryTheme(dark) {
+ Box(Modifier.fillMaxSize().background(Rk.c.bg)) {
+ Column(Modifier.fillMaxSize()) {
+ TitleBar("🐧 rookery · ${m.tab.label.lowercase()}")
+ Tabs(Tab.entries, m.tab, { it.label }, { m.tab = it })
+ Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 6.dp).padding(bottom = 6.dp).background(Rk.c.panel).bevel(Rk.c)) {
+ when (m.tab) {
+ Tab.TIMER -> TimerScreen(m)
+ Tab.PAIRS -> PairsScreen(m)
+ Tab.IMPORT -> ImportScreen(m)
+ Tab.SETTINGS -> SettingsScreen(m)
+ }
+ }
+ }
+ m.party?.let { PbParty(it.title, it.detail, m.reducedMotion) { m.party = null } }
+ }
+ }
+}
+
+/**
+ * Window-level keys for the timer, csTimer-style: space starts, any key stops, Esc cancels,
+ * Ctrl+1/2/3 set OK/+2/DNF on the last solve. Returns true if consumed.
+ */
+fun handleTimerKey(m: AppModel, e: KeyEvent): Boolean {
+ if (m.tab != Tab.TIMER) return false
+ val running = m.timer is State.Running
+ if (e.isCtrlPressed && e.type == KeyEventType.KeyDown) {
+ val last = m.lastSolve() ?: return false
+ when (e.key) {
+ Key.One -> m.setPenalty(last.id, Penalty.NONE)
+ Key.Two -> m.setPenalty(last.id, Penalty.PLUS2)
+ Key.Three -> m.setPenalty(last.id, Penalty.DNF)
+ else -> return false
+ }
+ return true
+ }
+ return when {
+ e.key == Key.Escape && e.type == KeyEventType.KeyDown -> { m.cancel(); true }
+ running && e.type == KeyEventType.KeyDown -> { m.press(); true }
+ e.key == Key.Spacebar && e.type == KeyEventType.KeyDown -> { m.press(); true }
+ e.key == Key.Spacebar && e.type == KeyEventType.KeyUp -> { m.release(); true }
+ // release after stopping with a non-space key
+ m.timer is State.Stopped && e.type == KeyEventType.KeyUp -> { m.release(); true }
+ else -> false
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
new file mode 100644
index 0000000..db34c7b
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
@@ -0,0 +1,305 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import coffee.liz.rookery.audio.Chime
+import coffee.liz.rookery.epochMillis
+import coffee.liz.rookery.importer.Importer
+import coffee.liz.rookery.model.DNF
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.LetterPair
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Session
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.monoMillis
+import coffee.liz.rookery.puzzle.PuzzleState
+import coffee.liz.rookery.puzzle.Puzzles
+import coffee.liz.rookery.puzzle.Turn
+import coffee.liz.rookery.randomId
+import coffee.liz.rookery.scramble.Scrambler
+import coffee.liz.rookery.stats.PersonalBest
+import coffee.liz.rookery.stats.StatKind
+import coffee.liz.rookery.stats.StatSummary
+import coffee.liz.rookery.stats.newPersonalBests
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.timer.TimerMachine
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** What the UI needs from the platform it runs on. */
+interface Host {
+ fun playChime(notes: List<Chime.Note>)
+ /** Ask the user for a file; returns its path, or null if cancelled. */
+ suspend fun pickFile(title: String, extensions: List<String>): String?
+ suspend fun pickFolder(title: String): String?
+ /** Read an import file, handling zips: returns the main text and a loader for files inside it. */
+ suspend fun readImport(path: String, mainEntry: String?): Pair<String, (String) -> ByteArray?>
+ suspend fun readFile(path: String): ByteArray
+ val syncFolder: String
+ fun setSyncFolder(path: String)
+ val deviceId: String
+}
+
+enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), IMPORT("Import"), SETTINGS("Settings") }
+enum class ThemeMode { SYSTEM, LIGHT, DARK }
+
+/** Everything derived from the store, recomputed off the UI thread when it changes. */
+data class Derived(
+ val sessions: List<Session> = emptyList(),
+ val session: Session? = null,
+ val sessionSolves: List<Solve> = emptyList(),
+ val sessionStats: StatSummary = StatSummary.of(emptyList()),
+ /** Lifetime stats for the session's event, across all sessions: what PBs are measured against. */
+ val eventStats: StatSummary = StatSummary.of(emptyList()),
+ val pairs: Map<String, LetterPair> = emptyMap(),
+)
+
+data class Party(val title: String, val detail: String)
+
+/** A smart-cube solve being played back in 3D. */
+data class Replay(
+ val solveId: String,
+ val state: PuzzleState,
+ val anim: TurnAnim?,
+ val index: Int,
+ val total: Int,
+ val elapsedMs: Long,
+ val playing: Boolean,
+ /** Set when playback finishes: did the moves actually solve the scramble? */
+ val endedSolved: Boolean? = null,
+)
+
+class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, private val scope: CoroutineScope) {
+ var tab by mutableStateOf(Tab.TIMER)
+ var derived by mutableStateOf(Derived())
+ private set
+ var sessionId by mutableStateOf(repo.pref("lastSession"))
+ private set
+
+ val machine = TimerMachine()
+ var timer by mutableStateOf<TimerMachine.State>(TimerMachine.State.Idle)
+ private set
+ var now by mutableLongStateOf(0)
+ private set
+ var lastActivity by mutableLongStateOf(monoMillis())
+ private set
+
+ var scramble by mutableStateOf("")
+ private set
+ var previewSvg by mutableStateOf<String?>(null)
+ private set
+ /** 3D state of the current scramble, for puzzles that have a model. */
+ var previewState by mutableStateOf<PuzzleState?>(null)
+ private set
+ var replay by mutableStateOf<Replay?>(null)
+ private set
+ private var replayJob: kotlinx.coroutines.Job? = null
+ var party by mutableStateOf<Party?>(null)
+ var lastPbs by mutableStateOf<List<PersonalBest>>(emptyList())
+ private set
+ var lastSolveId by mutableStateOf<String?>(null)
+ private set
+ var selectedSolveId by mutableStateOf<String?>(null)
+
+ var inspection by mutableStateOf(repo.boolPref("inspection", true)); private set
+ var sound by mutableStateOf(repo.boolPref("sound", true)); private set
+ var reducedMotion by mutableStateOf(repo.boolPref("reducedMotion", false)); private set
+ var themeMode by mutableStateOf(ThemeMode.entries.firstOrNull { it.name == repo.pref("theme") } ?: ThemeMode.SYSTEM); private set
+
+ var importLog by mutableStateOf<List<String>>(emptyList())
+
+ init {
+ machine.inspection = inspection
+ machine.onFinish = ::finishSolve
+ scope.launch { repo.store.version.collect { refresh() } }
+ scope.launch {
+ // Pick up ops other devices wrote into the sync folder.
+ while (true) { delay(2000); withContext(Dispatchers.Default) { repo.store.sync() } }
+ }
+ }
+
+ val event: Event get() = derived.session?.event ?: Event.E333
+
+ private suspend fun refresh() {
+ val wanted = sessionId
+ val d = withContext(Dispatchers.Default) {
+ val sessions = repo.sessions()
+ val session = sessions.firstOrNull { it.id == wanted } ?: sessions.lastOrNull()
+ val all = repo.solves()
+ val inSession = all.filter { it.sessionId == session?.id }
+ val inEvent = all.filter { it.event == session?.event }
+ Derived(sessions, session, inSession, StatSummary.of(inSession.map { it.result }), StatSummary.of(inEvent.map { it.result }), repo.letterPairs())
+ }
+ if (d.session == null) {
+ createSession(Event.E333)
+ return
+ }
+ val eventChanged = d.session.event != derived.session?.event
+ derived = d
+ if (sessionId != d.session.id) sessionId = d.session.id
+ if (eventChanged || scramble.isEmpty()) newScramble()
+ }
+
+ fun selectSession(id: String) {
+ sessionId = id
+ repo.setPref("lastSession", id)
+ scope.launch { refresh() }
+ }
+
+ 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())
+ selectSession(id)
+ }
+
+ fun newScramble() {
+ val e = event
+ scramble = "…"
+ 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 }
+ }
+ }
+
+ // ---- timer input ----
+
+ private fun sync() { timer = machine.state; lastActivity = monoMillis() }
+
+ fun press() { machine.press(monoMillis()); sync() }
+ fun release() { machine.release(monoMillis()); sync() }
+ fun cancel() { machine.cancel(); sync() }
+ fun frame() {
+ now = monoMillis()
+ val before = machine.state
+ machine.tick(now)
+ if (machine.state != before) sync()
+ }
+
+ 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 before = derived.eventStats
+ val priorResults = repo.solves().filter { it.event == session.event }.map { it.result }
+ repo.addSolve(solve)
+ lastSolveId = solve.id
+ newScramble()
+ scope.launch {
+ val after = withContext(Dispatchers.Default) { StatSummary.of(priorResults + solve.result) }
+ // Celebrate only beating a real previous best, and skip mo3 (too frequent to be special).
+ val pbs = newPersonalBests(before, after).filter { it.previous != null && it.kind != StatKind.MO3 }
+ lastPbs = pbs
+ if (pbs.isNotEmpty()) celebrate(pbs)
+ else if (solve.result == DNF && sound) host.playChime(Chime.DNF)
+ }
+ }
+
+ private fun celebrate(pbs: List<PersonalBest>) {
+ val top = pbs.maxBy { it.kind.n }
+ val title = if (top.kind == StatKind.SINGLE) "NEW PB!" else "NEW ${top.kind.label.uppercase()} PB!"
+ val detail = pbs.joinToString(" ") { "${it.kind.label} ${formatTime(it.value, round = it.kind != StatKind.SINGLE)}" }
+ party = Party(title, detail)
+ if (sound) host.playChime(if (top.kind == StatKind.SINGLE) Chime.PB else Chime.AVERAGE_PB)
+ }
+
+ // ---- smart-cube replay ----
+
+ /** Play back a solve's recorded moves (csTimer `R@0 U'@120 ...`) at real speed. */
+ fun startReplay(solve: Solve) {
+ val moves = solve.moves ?: return
+ replayJob?.cancel()
+ replayJob = scope.launch {
+ val puzzle = Puzzles.cube(3)
+ val timed: List<Pair<Turn, Long>> = withContext(Dispatchers.Default) {
+ moves.split(' ').filter { '@' in it }.mapNotNull { tok ->
+ val (mv, t) = tok.split('@', limit = 2)
+ runCatching { puzzle.parse(mv).single() to t.toLong() }.getOrNull()
+ }
+ }
+ var st = puzzle.solved().apply(solve.scramble)
+ replay = Replay(solve.id, st, null, 0, timed.size, 0, playing = true)
+ val start = monoMillis()
+ for ((k, pair) in timed.withIndex()) {
+ val (turn, at) = pair
+ while (monoMillis() - start < at) { replay = replay?.copy(elapsedMs = monoMillis() - start); delay(16) }
+ val next = timed.getOrNull(k + 1)?.second ?: (at + 200)
+ val dur = (next - at).coerceIn(30, 110)
+ val t0 = monoMillis()
+ while (true) {
+ val f = ((monoMillis() - t0).toFloat() / dur).coerceAtMost(1f)
+ replay = replay?.copy(anim = TurnAnim(turn, f), elapsedMs = monoMillis() - start)
+ if (f >= 1f) break
+ delay(16)
+ }
+ st = st.apply(turn)
+ replay = replay?.copy(state = st, anim = null, index = k + 1)
+ }
+ replay = replay?.copy(playing = false, endedSolved = st.isSolved, elapsedMs = timed.lastOrNull()?.second ?: 0)
+ }
+ }
+
+ fun stopReplay() { replayJob?.cancel(); replay = null }
+
+ // ---- solve edits ----
+
+ fun setPenalty(id: String, p: Penalty) = repo.setPenalty(id, p)
+ fun deleteSolve(id: String) { repo.deleteSolve(id); if (selectedSolveId == id) selectedSolveId = null }
+ fun lastSolve(): Solve? = derived.sessionSolves.lastOrNull()
+
+ // ---- prefs ----
+
+ fun changeInspection(on: Boolean) { inspection = on; machine.inspection = on; repo.setPref("inspection", on) }
+ fun changeSound(on: Boolean) { sound = on; repo.setPref("sound", on) }
+ fun changeReducedMotion(on: Boolean) { reducedMotion = on; repo.setPref("reducedMotion", on) }
+ fun setTheme(m: ThemeMode) { themeMode = m; repo.setPref("theme", m.name) }
+ fun testChime() = host.playChime(Chime.PB)
+ fun testParty() = celebrate(listOf(PersonalBest(StatKind.SINGLE, 9_990, 10_420)))
+
+ // ---- imports ----
+
+ fun importCsTimer() = scope.launch {
+ val path = host.pickFile("Import csTimer export", listOf("txt", "json")) ?: return@launch
+ runImport("csTimer: $path") { Importer(repo).csTimer(host.readFile(path).decodeToString()) }
+ }
+
+ fun importLetterPairGym() = scope.launch {
+ val path = host.pickFile("Import Letter Pair Gym export", listOf("zip", "json")) ?: return@launch
+ runImport("Letter Pair Gym: $path") {
+ val (text, files) = host.readImport(path, "backup.json")
+ Importer(repo).letterPairGym(text, files)
+ }
+ }
+
+ private suspend fun runImport(what: String, block: suspend () -> Importer.Summary) {
+ importLog = importLog + "» importing $what"
+ try {
+ val s = withContext(Dispatchers.Default) { block() }
+ importLog = importLog + s.lines.map { " ✓ $it" } + s.warnings.map { " ! $it" }
+ } catch (e: Exception) {
+ importLog = importLog + " ✗ failed: ${e.message}"
+ }
+ }
+
+ // ---- letter pairs ----
+
+ fun setPrimaryWord(pair: String, word: String) = repo.setPrimaryWord(pair, word.trim(), epochMillis())
+ fun setPairNote(pair: String, note: String) = repo.setPairNote(pair, note.ifBlank { null })
+ fun removeWord(pair: String, word: String) = repo.setWordInSet(pair, word, false)
+ fun addWord(pair: String, word: String) { if (word.isNotBlank()) repo.setWordInSet(pair, word.trim(), true) }
+ fun setPairImageFromFile(pair: String) = scope.launch {
+ val path = host.pickFile("Image for $pair", listOf("jpg", "jpeg", "png", "webp", "gif")) ?: return@launch
+ val bytes = host.readFile(path)
+ repo.setPairImage(pair, repo.store.blobs.put(bytes, path.substringAfterLast('.', "jpg")))
+ }
+ fun blob(name: String): ByteArray? = repo.store.blobs.get(name)
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt
new file mode 100644
index 0000000..b861331
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt
@@ -0,0 +1,112 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+
+@Composable
+fun ImportScreen(m: AppModel) {
+ Column(Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ GroupBox("Import", Modifier.widthIn(max = 720.dp)) {
+ Label("Imports are safe to repeat: anything already here is skipped.", color = Rk.c.inkDim)
+ Spacer(Modifier.height(8.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroButton("csTimer export…", { m.importCsTimer() })
+ Spacer(Modifier.width(8.dp))
+ Label("the .txt from csTimer's Export → \"Export to file\"", color = Rk.c.inkDim)
+ }
+ Spacer(Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroButton("Letter Pair Gym export…", { m.importLetterPairGym() })
+ Spacer(Modifier.width(8.dp))
+ Label("the .zip (words, notes, images, history, stats)", color = Rk.c.inkDim)
+ }
+ }
+ if (m.importLog.isNotEmpty()) GroupBox("Log", Modifier.widthIn(max = 720.dp)) {
+ Column(Modifier.fillMaxWidth().background(Rk.c.field).bevel(Rk.c, sunken = true).padding(8.dp)) {
+ for (line in m.importLog) Label(
+ line, style = Rk.t.mono.copy(fontSize = Rk.t.mono.fontSize * 0.9f),
+ color = when { line.trimStart().startsWith("✗") -> Rk.c.bad; line.trimStart().startsWith("!") -> Rk.c.gold; else -> Rk.c.ink },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SettingsScreen(m: AppModel) {
+ val scope = rememberCoroutineScope()
+ var syncPath by remember { mutableStateOf(m.host.syncFolder) }
+ var moved by remember { mutableStateOf(false) }
+ Column(Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ GroupBox("Timer", Modifier.widthIn(max = 720.dp)) {
+ Checkbox("WCA inspection (15s, +2 after 15s, DNF after 17s)", m.inspection, m::changeInspection)
+ Checkbox("Sounds (PB chime, DNF blip)", m.sound, m::changeSound)
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ RetroButton("Play PB chime", { m.testChime() })
+ RetroButton("Throw a test party", { m.testParty() })
+ }
+ }
+ GroupBox("Look", Modifier.widthIn(max = 720.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
+ Label("Theme:", style = Rk.t.label)
+ for (t in ThemeMode.entries) RetroButton(t.name.lowercase(), { m.setTheme(t) }, selected = m.themeMode == t)
+ }
+ Checkbox("Reduce motion (no confetti, still penguins)", m.reducedMotion, m::changeReducedMotion)
+ }
+ GroupBox("Sync", Modifier.widthIn(max = 720.dp)) {
+ Label(
+ "Everything lives in this folder as per-device append-only logs. Point it at a Syncthing (or Drive, Dropbox, …) " +
+ "folder and every device merges automatically. Each device only writes its own files, so syncing never conflicts.",
+ color = Rk.c.inkDim,
+ )
+ Spacer(Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(syncPath, { syncPath = it }, Modifier.weight(1f), description = "sync folder")
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Browse…", { scope.launch { m.host.pickFolder("Sync folder")?.let { syncPath = it } } })
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Apply", { m.host.setSyncFolder(syncPath); moved = true }, enabled = syncPath != m.host.syncFolder)
+ }
+ if (moved) Label("Saved. Restart Rookery to switch folders.", color = Rk.c.gold)
+ Label("This device: ${m.host.deviceId}", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ GroupBox("Smart cubes", Modifier.widthIn(max = 720.dp)) {
+ val devices = m.repo.devices()
+ if (devices.isEmpty()) Label("None yet. Import from csTimer, or pair one (coming in the smart-cube phase).", color = Rk.c.inkDim)
+ else {
+ Row { HeaderCell("name", 2f); HeaderCell("MAC", 2f); HeaderCell("protocol", 1f) }
+ for ((i, d) in devices.withIndex()) Row(Modifier.background(if (i % 2 == 1) Rk.c.zebra else Color.Transparent)) {
+ Cell(d.name, 2f, style = Rk.t.body); Cell(d.mac, 2f); Cell(d.protocol, 1f)
+ }
+ }
+ }
+ GroupBox("Blindfolded", Modifier.widthIn(max = 720.dp)) {
+ Label("Orientation: yellow top, orange front · Speffz lettering", style = Rk.t.body)
+ Label("Corners: Old Pochmann, buffer UBL (A), target RDF (P)", style = Rk.t.body)
+ Label("Edges: buffer UR (B), targets UL (D, T-perm) · UB (A, Ja) · UF (C, Jb) · parity: R-perm", style = Rk.t.body)
+ Label("Editable when the BLD trainers land.", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt
new file mode 100644
index 0000000..8bcd47b
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt
@@ -0,0 +1,180 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.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.getValue
+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.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.model.LetterPair
+import org.jetbrains.compose.resources.decodeToImageBitmap
+
+const val LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWX"
+
+@Composable
+fun PairsScreen(m: AppModel) {
+ var query by remember { mutableStateOf("") }
+ var selected by remember { mutableStateOf<String?>(null) }
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ val glance = query.uppercase().filter { it in LETTERS }.take(2)
+ val shown = if (glance.length == 2) glance else selected
+
+ Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ GroupBox("Glance", Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(
+ query, { query = it.take(2) }, Modifier.width(120.dp).focusRequester(focus), placeholder = "AB",
+ style = Rk.t.mono.copy(fontSize = 34.sp), description = "type a letter pair",
+ )
+ Spacer(Modifier.width(12.dp))
+ val n = m.derived.pairs.values.count { it.primary != null }
+ Label("Type two letters to look up a pair. $n of ${LETTERS.length * (LETTERS.length - 1)} pairs have words.", color = Rk.c.inkDim)
+ }
+ }
+ PairGrid(m.derived.pairs, shown) { selected = it; query = "" }
+ }
+ Column(Modifier.width(380.dp).fillMaxHeight()) {
+ if (shown != null) PairCard(m, shown, m.derived.pairs[shown])
+ else GroupBox("Pair", Modifier.fillMaxWidth()) { Label("Pick a cell, or type a pair.", color = Rk.c.inkDim) }
+ }
+ }
+}
+
+@Composable
+private fun PairGrid(pairs: Map<String, LetterPair>, selected: String?, onPick: (String) -> Unit) {
+ val p = Rk.c
+ val cellW = 74.dp
+ val cellH = 24.dp
+ Panel(Modifier.fillMaxSize(), padding = androidx.compose.foundation.layout.PaddingValues(2.dp)) {
+ Row(Modifier.padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
+ Label("heat = recent misses", style = Rk.t.label, color = p.inkDim)
+ for (d in listOf(0.0, 0.33, 0.66, 1.0)) Box(Modifier.padding(start = 4.dp).size(14.dp).background(heat(p, d)).outline(p.bevelLo))
+ }
+ Box(Modifier.fillMaxSize().background(p.field).bevel(p, sunken = true).verticalScroll(rememberScrollState()).horizontalScroll(rememberScrollState())) {
+ Column {
+ Row {
+ Box(Modifier.size(28.dp, cellH).background(p.panel).bevel(p))
+ for (b in LETTERS) Box(Modifier.size(cellW, cellH).background(p.panel).bevel(p), Alignment.Center) {
+ BasicText("$b", style = Rk.t.heading.copy(color = p.ink))
+ }
+ }
+ for (a in LETTERS) Row {
+ Box(Modifier.size(28.dp, cellH).background(p.panel).bevel(p), Alignment.Center) { BasicText("$a", style = Rk.t.heading.copy(color = p.ink)) }
+ for (b in LETTERS) {
+ val key = "$a$b"
+ val lp = pairs[key]
+ val sel = key == selected
+ val bg = when {
+ a == b -> p.panel
+ sel -> p.select
+ lp?.difficulty != null -> heat(p, lp.difficulty!!)
+ else -> Color.Transparent
+ }
+ Box(
+ Modifier.size(cellW, cellH).background(bg).outline(p.zebra)
+ .then(if (a != b) Modifier.clickable { onPick(key) } else Modifier)
+ .semantics { contentDescription = "$key ${lp?.primary ?: "no word"}" }
+ .padding(horizontal = 3.dp),
+ Alignment.CenterStart,
+ ) {
+ if (a != b) BasicText(
+ lp?.primary ?: "·", maxLines = 1, overflow = TextOverflow.Ellipsis,
+ style = Rk.t.body.copy(fontSize = 11.sp, color = if (sel) p.selectInk else if (lp?.primary == null) p.inkDim else p.ink),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun heat(p: Palette, d: Double): Color {
+ val cold = if (p.dark) Color(0xFF15303F) else Color(0xFFDDEFF8)
+ val hot = if (p.dark) Color(0xFF6B2020) else Color(0xFFF4B4AE)
+ return lerp(cold, hot, d.toFloat().coerceIn(0f, 1f))
+}
+
+@Composable
+private fun PairCard(m: AppModel, pair: String, lp: LetterPair?) {
+ var word by remember(pair, lp?.primary) { mutableStateOf(lp?.primary.orEmpty()) }
+ var note by remember(pair, lp?.note) { mutableStateOf(lp?.note.orEmpty()) }
+ var alt by remember(pair) { mutableStateOf("") }
+ val image: ImageBitmap? = remember(lp?.image) {
+ lp?.image?.let { name -> runCatching { m.blob(name)?.decodeToImageBitmap() }.getOrNull() }
+ }
+ Panel(Modifier.fillMaxSize()) {
+ TitleBar(pair)
+ Spacer(Modifier.height(8.dp))
+ Box(Modifier.fillMaxWidth().height(240.dp).background(Rk.c.field).bevel(Rk.c, sunken = true), Alignment.Center) {
+ if (image != null) Image(image, lp?.primary ?: pair, Modifier.fillMaxSize().padding(4.dp), contentScale = ContentScale.Fit)
+ else Label("no image", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ Spacer(Modifier.height(6.dp))
+ BasicText(lp?.primary ?: "—", Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 44.sp, color = Rk.c.ink, textAlign = TextAlign.Center))
+ lp?.note?.let { Label(it, Modifier.fillMaxWidth(), color = Rk.c.inkDim) }
+ Hr()
+ Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Label("Main word", style = Rk.t.label)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(word, { word = it }, Modifier.weight(1f), onSubmit = { m.setPrimaryWord(pair, word) }, description = "main word for $pair")
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Save", { m.setPrimaryWord(pair, word) })
+ }
+ Label("Note", style = Rk.t.label)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(note, { note = it }, Modifier.weight(1f), onSubmit = { m.setPairNote(pair, note) }, description = "note for $pair")
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Save", { m.setPairNote(pair, note) })
+ }
+ Label("Other words", style = Rk.t.label)
+ for (w in lp?.words.orEmpty().filter { it != lp?.primary }.sorted()) Row(verticalAlignment = Alignment.CenterVertically) {
+ Label(w, Modifier.weight(1f))
+ RetroButton("Use", { m.setPrimaryWord(pair, w) }, description = "make $w the main word")
+ Spacer(Modifier.width(4.dp))
+ RetroButton("✕", { m.removeWord(pair, w) }, description = "remove $w")
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(alt, { alt = it }, Modifier.weight(1f), placeholder = "add a word…", onSubmit = { m.addWord(pair, alt); alt = "" })
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Add", { m.addWord(pair, alt); alt = "" })
+ }
+ RetroButton("Image from file…", { m.setPairImageFromFile(pair) })
+ }
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt
new file mode 100644
index 0000000..4da7994
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt
@@ -0,0 +1,176 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameMillis
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.ui.res.Res
+import coffee.liz.rookery.ui.res.penguin_sheet
+import kotlinx.coroutines.delay
+import org.jetbrains.compose.resources.imageResource
+import kotlin.random.Random
+
+/**
+ * The penguin from liz.coffee: a 8x4 sheet of 32px frames in the oneko.js layout.
+ * Each animation is a list of (col, row) cells.
+ */
+enum class PenguinAnim(vararg cells: Pair<Int, Int>) {
+ IDLE(3 to 3), ALERT(7 to 3), PECK(5 to 0, 6 to 0, 7 to 0), TIRED(3 to 2), SLEEP(2 to 0, 2 to 1),
+ N(1 to 2, 1 to 3), NE(0 to 2, 0 to 3), E(3 to 0, 3 to 1), SE(5 to 1, 5 to 2),
+ S(6 to 3, 7 to 2), SW(5 to 3, 6 to 1), W(4 to 2, 4 to 3), NW(1 to 0, 1 to 1),
+ WALL_N(0 to 0, 0 to 1);
+
+ val frames: List<Pair<Int, Int>> = cells.toList()
+}
+
+const val SPRITE = 32
+
+@Composable
+fun penguinSheet(): ImageBitmap = imageResource(Res.drawable.penguin_sheet)
+
+fun DrawScope.drawPenguin(sheet: ImageBitmap, cell: Pair<Int, Int>, at: Offset, scale: Float, alpha: Float = 1f) {
+ drawImage(
+ sheet,
+ srcOffset = IntOffset(cell.first * SPRITE, cell.second * SPRITE),
+ srcSize = IntSize(SPRITE, SPRITE),
+ dstOffset = IntOffset(at.x.toInt(), at.y.toInt()),
+ dstSize = IntSize((SPRITE * scale).toInt(), (SPRITE * scale).toInt()),
+ alpha = alpha,
+ filterQuality = FilterQuality.None,
+ )
+}
+
+/** A single animated penguin. Static first frame when [reducedMotion]. */
+@Composable
+fun Penguin(anim: PenguinAnim, size: Dp = 64.dp, reducedMotion: Boolean = false, modifier: Modifier = Modifier, frameMs: Long = 220) {
+ val sheet = penguinSheet()
+ var frame by remember(anim) { mutableIntStateOf(0) }
+ LaunchedEffect(anim, reducedMotion) {
+ if (reducedMotion || anim.frames.size == 1) return@LaunchedEffect
+ while (true) { delay(frameMs); frame++ }
+ }
+ Canvas(modifier.size(size).semantics { contentDescription = "penguin, ${anim.name.lowercase()}" }) {
+ drawPenguin(sheet, anim.frames[frame % anim.frames.size], Offset.Zero, this.size.width / SPRITE)
+ }
+}
+
+/** What the mascot is up to, driven by the timer. */
+enum class Mood { IDLE, WATCHING, READY, SOLVING, HAPPY, SAD, SLEEPY }
+
+/** The mascot wanders along a strip, reacting to [mood]. */
+@Composable
+fun MascotStrip(mood: Mood, reducedMotion: Boolean, modifier: Modifier = Modifier, scale: Float = 2f) {
+ val sheet = penguinSheet()
+ BoxWithConstraints(modifier) {
+ val widthPx = constraints.maxWidth.toFloat()
+ var x by remember { mutableStateOf(40f) }
+ var target by remember { mutableStateOf(40f) }
+ var tick by remember { mutableLongStateOf(0) }
+ LaunchedEffect(mood, reducedMotion, widthPx) {
+ var last = 0L
+ while (true) {
+ withFrameMillis { t ->
+ if (t - last > 120) { tick++; last = t }
+ val spriteW = SPRITE * scale
+ val maxX = (widthPx - spriteW).coerceAtLeast(0f)
+ when (mood) {
+ Mood.IDLE -> {
+ if (!reducedMotion && kotlin.math.abs(target - x) < 2f && Random.nextInt(400) == 0) target = Random.nextFloat() * maxX
+ if (!reducedMotion) x += (target - x).coerceIn(-1.6f, 1.6f)
+ }
+ Mood.HAPPY -> if (!reducedMotion) { target = (x + 3f).let { if (it > maxX) 0f else it }; x = target }
+ else -> target = x
+ }
+ x = x.coerceIn(0f, maxX)
+ }
+ }
+ }
+ val anim = when (mood) {
+ Mood.IDLE -> if (kotlin.math.abs(target - x) >= 2f) (if (target > x) PenguinAnim.E else PenguinAnim.W) else PenguinAnim.IDLE
+ Mood.WATCHING -> PenguinAnim.S
+ Mood.READY -> PenguinAnim.ALERT
+ Mood.SOLVING -> PenguinAnim.N
+ Mood.HAPPY -> PenguinAnim.E
+ Mood.SAD -> PenguinAnim.PECK
+ Mood.SLEEPY -> PenguinAnim.SLEEP
+ }
+ val slow = if (anim == PenguinAnim.SLEEP) 6 else 2
+ Canvas(Modifier.fillMaxSize().semantics { contentDescription = "penguin mascot, ${mood.name.lowercase()}" }) {
+ val f = if (reducedMotion) 0 else ((tick / slow) % anim.frames.size).toInt()
+ drawPenguin(sheet, anim.frames[f], Offset(x, size.height - SPRITE * scale), scale)
+ }
+ }
+}
+
+/** Full-screen PB celebration: penguins rain down with pixel confetti and a blinking banner. */
+@Composable
+fun PbParty(title: String, detail: String, reducedMotion: Boolean, onDone: () -> Unit) {
+ val sheet = penguinSheet()
+ val p = Rk.c
+ class Bit(var x: Float, var y: Float, val vx: Float, val vy: Float, val penguin: Boolean, val color: Color, val anim: PenguinAnim, val size: Float)
+ val palette = listOf(Color(0xFFFFD54F), Color(0xFF7CC4FF), Color(0xFFFF8A80), Color(0xFF8CFF9E), Color(0xFFE1BEE7), Color.White)
+ val bits = remember {
+ List(70) { i ->
+ val penguin = i % 4 == 0
+ Bit(Random.nextFloat(), 0.1f - Random.nextFloat() * 0.8f, (Random.nextFloat() - 0.5f) * 0.0004f, 0.00025f + Random.nextFloat() * 0.0005f,
+ penguin, palette.random(), listOf(PenguinAnim.S, PenguinAnim.ALERT, PenguinAnim.SE, PenguinAnim.SW).random(), if (penguin) 2f else 6f + Random.nextInt(6))
+ }
+ }
+ var t by remember { mutableLongStateOf(0) }
+ LaunchedEffect(Unit) {
+ val start = withFrameMillis { it }
+ while (true) {
+ val now = withFrameMillis { it }
+ t = now - start
+ if (t > 4200) { onDone(); break }
+ }
+ }
+ Box(Modifier.fillMaxSize().semantics { contentDescription = "$title $detail" }) {
+ if (!reducedMotion) Canvas(Modifier.fillMaxSize()) {
+ for (b in bits) {
+ val x = (b.x + b.vx * t) * size.width
+ val y = (b.y + b.vy * t) * size.height
+ if (b.penguin) {
+ drawPenguin(sheet, b.anim.frames[((t / 200) % b.anim.frames.size).toInt()], Offset(x, y), b.size)
+ } else {
+ drawRect(b.color, Offset(x, y), Size(b.size, b.size))
+ }
+ }
+ }
+ val blinkOn = reducedMotion || (t / 300) % 2 == 0L
+ Box(Modifier.align(Alignment.Center).background(p.titleA).bevel(p).padding(horizontal = 28.dp, vertical = 16.dp), contentAlignment = Alignment.Center) {
+ androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ BasicText("★ $title ★", style = Rk.t.heading.copy(fontSize = 28.sp, color = if (blinkOn) p.gold else p.titleInk))
+ BasicText(detail, Modifier.padding(top = 6.dp), style = Rk.t.mono.copy(fontSize = 30.sp, color = p.titleInk))
+ }
+ }
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
new file mode 100644
index 0000000..41ea00f
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
@@ -0,0 +1,124 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.DrawScope
+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 coffee.liz.rookery.puzzle.Polygon
+import coffee.liz.rookery.puzzle.PuzzleState
+import coffee.liz.rookery.puzzle.Turn
+import coffee.liz.rookery.puzzle.V3
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.min
+import kotlin.math.sin
+
+/** A turn in flight: draw [turn]'s layers rotated [progress] (0..1) of the way. */
+data class TurnAnim(val turn: Turn, val progress: Float)
+
+/** Camera angles in degrees; hoisted so callers can reset or remember them. */
+class Camera(yaw: Float = -32f, pitch: Float = 28f) {
+ var yaw by mutableFloatStateOf(yaw)
+ var pitch by mutableFloatStateOf(pitch)
+}
+
+/**
+ * Flat-shaded 3D puzzle, drawn with the painter's algorithm on a Canvas: each sticker is a black
+ * "plastic" tile with an inset coloured sticker, lit by a soft key light. Drag to orbit.
+ */
+@Composable
+fun PuzzleView(
+ state: PuzzleState,
+ faceColors: List<Color>,
+ modifier: Modifier = Modifier,
+ camera: Camera = remember { Camera() },
+ anim: TurnAnim? = null,
+ description: String = "3D puzzle view",
+) {
+ val puzzle = state.puzzle
+ val body = Color(0xFF14181C)
+ Canvas(
+ modifier
+ .pointerInput(camera) {
+ detectDragGestures { change, drag ->
+ change.consume()
+ camera.yaw += drag.x * 0.45f
+ camera.pitch = (camera.pitch + drag.y * 0.45f).coerceIn(-89f, 89f)
+ }
+ }
+ .semantics { contentDescription = description },
+ ) {
+ val yaw = camera.yaw * PI / 180
+ val pitch = camera.pitch * PI / 180
+ // world → view: yaw about y, then pitch about x
+ fun view(p: V3): V3 {
+ val x1 = p.x * cos(yaw) + p.z * sin(yaw)
+ val z1 = -p.x * sin(yaw) + p.z * cos(yaw)
+ val y2 = p.y * cos(pitch) - z1 * sin(pitch)
+ val z2 = p.y * sin(pitch) + z1 * cos(pitch)
+ return V3(x1, y2, z2)
+ }
+ val radius = puzzle.stickers.maxOf { s -> s.points.maxOf { it.length } }
+ val dist = radius * 4.5
+ val scale = min(size.width, size.height) / 2f / radius.toFloat() * 0.80f
+ val cx = size.width / 2; val cy = size.height / 2
+ fun project(p: V3): Offset {
+ val k = (dist / (dist - p.z)).toFloat()
+ return Offset(cx + p.x.toFloat() * scale * k, cy - p.y.toFloat() * scale * k)
+ }
+ val light = V3(-0.4, 0.75, 0.55).unit()
+
+ val turning = anim?.let { a ->
+ val axis = puzzle.axes[a.turn.axis]
+ val angle = -2 * PI * a.turn.amount / axis.order * a.progress
+ Triple(a.turn, axis.normal, angle)
+ }
+
+ class Drawn(val tile: Polygon, val color: Color, val depth: Double, val shade: Float)
+ val drawn = ArrayList<Drawn>(puzzle.stickers.size)
+ for ((i, s) in puzzle.stickers.withIndex()) {
+ var poly = s
+ if (turning != null && puzzle.slabOf(turning.first.axis, i) in turning.first.layers) {
+ poly = poly.map { it.rotate(turning.second, turning.third) }
+ }
+ val v = poly.map(::view)
+ val toCam = V3(0.0, 0.0, dist) - v.centroid
+ val n = v.normal
+ if ((n dot toCam) <= 0) continue // back face
+ val lambert = maxOf(0.0, n dot light).toFloat()
+ drawn += Drawn(v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert)
+ }
+ drawn.sortBy { it.depth }
+ for (d in drawn) {
+ fill(d.tile.points.map(::project), body)
+ val sticker = d.tile.inset(0.86).points.map(::project)
+ fill(sticker, d.color.shaded(d.shade))
+ // a thin highlight on the sticker's upper edge for a slightly glossy, skeuomorphic feel
+ drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1f))
+ }
+ }
+}
+
+private fun path(pts: List<Offset>) = Path().apply {
+ moveTo(pts[0].x, pts[0].y)
+ for (k in 1 until pts.size) lineTo(pts[k].x, pts[k].y)
+ close()
+}
+
+private fun DrawScope.fill(pts: List<Offset>, c: Color) = drawPath(path(pts), c)
+
+private fun Color.shaded(k: Float) = Color(red * k, green * k, blue * k, alpha)
+
+fun rgb(hex: Long) = Color(0xFF000000 or hex)
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
new file mode 100644
index 0000000..2fe6be5
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
@@ -0,0 +1,225 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** Classic two-tone 3D border: light top-left, dark bottom-right (swapped when [sunken]). */
+fun Modifier.bevel(p: Palette, sunken: Boolean = false, width: Dp = 2.dp): Modifier = drawBehind {
+ val w = width.toPx()
+ val hi = if (sunken) p.bevelLo else p.bevelHi
+ val lo = if (sunken) p.bevelHi else p.bevelLo
+ val outer = if (sunken) p.bevelDark else p.bevelDark
+ // outer frame
+ drawRect(if (sunken) outer else hi, Offset.Zero, androidx.compose.ui.geometry.Size(size.width, w / 2))
+ drawRect(if (sunken) outer else hi, Offset.Zero, androidx.compose.ui.geometry.Size(w / 2, size.height))
+ drawRect(if (sunken) hi else outer, Offset(0f, size.height - w / 2), androidx.compose.ui.geometry.Size(size.width, w / 2))
+ drawRect(if (sunken) hi else outer, Offset(size.width - w / 2, 0f), androidx.compose.ui.geometry.Size(w / 2, size.height))
+ // inner frame
+ drawRect(if (sunken) lo else hi, Offset(w / 2, w / 2), androidx.compose.ui.geometry.Size(size.width - w, w / 2))
+ drawRect(if (sunken) lo else hi, Offset(w / 2, w / 2), androidx.compose.ui.geometry.Size(w / 2, size.height - w))
+ drawRect(if (sunken) hi else lo, Offset(w / 2, size.height - w), androidx.compose.ui.geometry.Size(size.width - w, w / 2))
+ drawRect(if (sunken) hi else lo, Offset(size.width - w, w / 2), androidx.compose.ui.geometry.Size(w / 2, size.height - w))
+}
+
+/** Win98-style dotted focus ring, so keyboard users always see where they are. */
+fun Modifier.focusRing(focused: Boolean, color: Color): Modifier = if (!focused) this else drawBehind {
+ drawRect(color, topLeft = Offset(4f, 4f), size = androidx.compose.ui.geometry.Size(size.width - 8f, size.height - 8f),
+ style = Stroke(1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 2f))))
+}
+
+@Composable
+fun Panel(modifier: Modifier = Modifier, sunken: Boolean = false, padding: PaddingValues = PaddingValues(8.dp), content: @Composable ColumnScope.() -> Unit) {
+ val p = Rk.c
+ Column(modifier.background(if (sunken) p.field else p.panel).bevel(p, sunken).padding(padding), content = content)
+}
+
+/** A titled group box, like a <fieldset>. */
+@Composable
+fun GroupBox(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
+ Panel(modifier) {
+ BasicText(title.uppercase(), style = Rk.t.heading.copy(color = Rk.c.ink))
+ Box(Modifier.height(6.dp))
+ content()
+ }
+}
+
+@Composable
+fun TitleBar(text: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
+ val p = Rk.c
+ Row(
+ modifier.fillMaxWidth().background(Brush.horizontalGradient(listOf(p.titleA, p.titleB))).padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BasicText(text, Modifier.weight(1f), style = Rk.t.heading.copy(color = p.titleInk))
+ trailing()
+ }
+}
+
+@Composable
+fun RetroButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ selected: Boolean = false,
+ description: String? = null,
+ style: TextStyle? = null,
+) {
+ val p = Rk.c
+ val src = remember { MutableInteractionSource() }
+ val pressed by src.collectIsPressedAsState()
+ val focused by src.collectIsFocusedAsState()
+ val sunken = pressed || selected
+ Box(
+ modifier
+ .background(if (selected) p.zebra else p.panel)
+ .bevel(p, sunken)
+ .focusRing(focused, p.ink)
+ .clickable(src, indication = null, enabled = enabled, role = Role.Button, onClick = onClick)
+ .semantics { if (description != null) contentDescription = description; this.selected = selected }
+ .padding(horizontal = 12.dp, vertical = 6.dp)
+ .padding(start = if (sunken) 1.dp else 0.dp, top = if (sunken) 1.dp else 0.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ BasicText(text, style = (style ?: Rk.t.label).copy(color = if (enabled) p.ink else p.inkDim), maxLines = 1)
+ }
+}
+
+/** Tab strip; the selected tab merges into the panel below it. */
+@Composable
+fun <T> Tabs(items: List<T>, selected: T, label: (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier) {
+ val p = Rk.c
+ Row(modifier.padding(start = 6.dp, top = 6.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.Bottom) {
+ for (item in items) {
+ val sel = item == selected
+ val src = remember { MutableInteractionSource() }
+ val focused by src.collectIsFocusedAsState()
+ Box(
+ Modifier
+ .background(if (sel) p.panel else p.zebra)
+ .bevel(p)
+ .focusRing(focused, p.ink)
+ .clickable(src, null, role = Role.Tab) { onSelect(item) }
+ .semantics { this.selected = sel }
+ .padding(horizontal = 14.dp, vertical = if (sel) 8.dp else 5.dp),
+ ) { BasicText(label(item), style = Rk.t.label.copy(color = p.ink)) }
+ }
+ }
+}
+
+@Composable
+fun Label(text: String, modifier: Modifier = Modifier, color: Color? = null, style: TextStyle? = null, maxLines: Int = Int.MAX_VALUE) {
+ BasicText(text, modifier, style = (style ?: Rk.t.body).copy(color = color ?: Rk.c.ink), maxLines = maxLines, overflow = TextOverflow.Ellipsis)
+}
+
+@Composable
+fun RetroField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ placeholder: String = "",
+ style: TextStyle? = null,
+ singleLine: Boolean = true,
+ onSubmit: (() -> Unit)? = null,
+ description: String? = null,
+) {
+ val p = Rk.c
+ val st = (style ?: Rk.t.body).copy(color = p.ink)
+ BasicTextField(
+ value, onValueChange,
+ modifier.background(p.field).bevel(p, sunken = true).padding(horizontal = 8.dp, vertical = 6.dp)
+ .semantics { if (description != null) contentDescription = description },
+ textStyle = st, singleLine = singleLine, cursorBrush = SolidColor(p.ink),
+ keyboardOptions = KeyboardOptions(imeAction = if (onSubmit != null) ImeAction.Done else ImeAction.Default),
+ keyboardActions = KeyboardActions(onDone = { onSubmit?.invoke() }),
+ decorationBox = { inner ->
+ Box {
+ if (value.isEmpty()) BasicText(placeholder, style = st.copy(color = p.inkDim))
+ inner()
+ }
+ },
+ )
+}
+
+@Composable
+fun Checkbox(label: String, checked: Boolean, onChange: (Boolean) -> Unit, modifier: Modifier = Modifier) {
+ val p = Rk.c
+ val src = remember { MutableInteractionSource() }
+ val focused by src.collectIsFocusedAsState()
+ Row(
+ modifier.focusRing(focused, p.ink).clickable(src, null, role = Role.Checkbox) { onChange(!checked) }.padding(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.size(16.dp).background(p.field).bevel(p, sunken = true), contentAlignment = Alignment.Center) {
+ if (checked) BasicText("✓", style = Rk.t.body.copy(color = p.ink))
+ }
+ Box(Modifier.width(8.dp))
+ Label(label)
+ }
+}
+
+/** Header cell for <table>-style grids. */
+@Composable
+fun RowScope.HeaderCell(text: String, weight: Float, modifier: Modifier = Modifier) {
+ Box(modifier.weight(weight).background(Rk.c.panel).bevel(Rk.c).padding(horizontal = 6.dp, vertical = 3.dp)) {
+ BasicText(text, style = Rk.t.label.copy(color = Rk.c.ink), maxLines = 1)
+ }
+}
+
+@Composable
+fun RowScope.Cell(text: String, weight: Float, color: Color? = null, style: TextStyle? = null, modifier: Modifier = Modifier) {
+ Box(modifier.weight(weight).padding(horizontal = 6.dp, vertical = 3.dp)) {
+ BasicText(text, style = (style ?: Rk.t.mono.copy(fontSize = Rk.t.mono.fontSize)).copy(color = color ?: Rk.c.ink), maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+@Composable
+fun Hr(modifier: Modifier = Modifier) {
+ Box(modifier.fillMaxWidth().padding(vertical = 6.dp).height(2.dp).bevel(Rk.c, sunken = true, width = 2.dp))
+}
+
+fun Modifier.outline(c: Color) = border(1.dp, c)
+
+fun Modifier.focusableIf(b: Boolean) = if (b) focusable() else this
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt
new file mode 100644
index 0000000..d2ef012
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt
@@ -0,0 +1,75 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.ui.res.Res
+import coffee.liz.rookery.ui.res.silkscreen
+import coffee.liz.rookery.ui.res.silkscreen_bold
+import coffee.liz.rookery.ui.res.vt323
+import org.jetbrains.compose.resources.Font
+import androidx.compose.ui.text.font.FontWeight
+
+/** Colour tokens. Two themes: "ice floe" (light) and "polar night" (dark). */
+@Immutable
+data class Palette(
+ val bg: Color, val panel: Color, val bevelHi: Color, val bevelLo: Color, val bevelDark: Color,
+ val ink: Color, val inkDim: Color, val field: Color, val zebra: Color,
+ val titleA: Color, val titleB: Color, val titleInk: Color,
+ val select: Color, val selectInk: Color,
+ val good: Color, val bad: Color, val gold: Color, val lcd: Color, val lcdInk: Color,
+ /** Sticker colours U R F D L B for scramble previews in this orientation. */
+ val dark: Boolean,
+) {
+ companion object {
+ val IceFloe = Palette(
+ bg = Color(0xFFDFE7EC), panel = Color(0xFFC9D3DA), bevelHi = Color(0xFFFFFFFF), bevelLo = Color(0xFF6B7A86), bevelDark = Color(0xFF26313A),
+ ink = Color(0xFF101820), inkDim = Color(0xFF45525D), field = Color(0xFFFFFFFF), zebra = Color(0xFFE8EEF2),
+ titleA = Color(0xFF123A57), titleB = Color(0xFF3F88C5), titleInk = Color.White,
+ select = Color(0xFF1F5F8F), selectInk = Color.White,
+ good = Color(0xFF256B2A), bad = Color(0xFFB3261E), gold = Color(0xFFB07A00),
+ lcd = Color(0xFF9DB89A), lcdInk = Color(0xFF14220F), dark = false,
+ )
+ val PolarNight = Palette(
+ bg = Color(0xFF0C1216), panel = Color(0xFF17212A), bevelHi = Color(0xFF34465A), bevelLo = Color(0xFF070A0D), bevelDark = Color(0xFF000000),
+ ink = Color(0xFFE3ECF1), inkDim = Color(0xFF9AACB8), field = Color(0xFF0A0F13), zebra = Color(0xFF131C23),
+ titleA = Color(0xFF0B2233), titleB = Color(0xFF2B6C9E), titleInk = Color(0xFFE3ECF1),
+ select = Color(0xFF2B6C9E), selectInk = Color.White,
+ good = Color(0xFF7AD67F), bad = Color(0xFFFF8A80), gold = Color(0xFFFFD54F),
+ lcd = Color(0xFF0F1E12), lcdInk = Color(0xFF8CFF9E), dark = true,
+ )
+ }
+}
+
+@Immutable
+data class Type(val pixel: FontFamily, val pixelBold: FontFamily, val lcd: FontFamily) {
+ val heading get() = TextStyle(fontFamily = pixelBold, fontSize = 14.sp, letterSpacing = 0.5.sp)
+ val label get() = TextStyle(fontFamily = pixel, fontSize = 12.sp)
+ val body get() = TextStyle(fontSize = 14.sp)
+ val mono get() = TextStyle(fontFamily = lcd, fontSize = 20.sp)
+}
+
+val LocalPalette = staticCompositionLocalOf { Palette.IceFloe }
+val LocalType = staticCompositionLocalOf<Type> { error("no type") }
+
+object Rk {
+ val c: Palette @Composable get() = LocalPalette.current
+ val t: Type @Composable get() = LocalType.current
+}
+
+@Composable
+fun RookeryTheme(dark: Boolean, content: @Composable () -> Unit) {
+ val type = Type(
+ pixel = FontFamily(Font(Res.font.silkscreen)),
+ pixelBold = FontFamily(Font(Res.font.silkscreen_bold, FontWeight.Bold)),
+ lcd = FontFamily(Font(Res.font.vt323)),
+ )
+ CompositionLocalProvider(LocalPalette provides if (dark) Palette.PolarNight else Palette.IceFloe, LocalType provides type) {
+ content()
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt
new file mode 100644
index 0000000..e475436
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt
@@ -0,0 +1,323 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+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.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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameMillis
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+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.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.model.DNF
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Puzzle
+import coffee.liz.rookery.puzzle.Puzzles
+import coffee.liz.rookery.model.formatSolve
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.stats.Averages
+import coffee.liz.rookery.stats.StatKind
+import coffee.liz.rookery.timer.TimerMachine
+import coffee.liz.rookery.timer.TimerMachine.State
+import org.jetbrains.compose.resources.ExperimentalResourceApi
+import org.jetbrains.compose.resources.decodeToSvgPainter
+
+@Composable
+fun TimerScreen(m: AppModel) {
+ // drive the clock while something is ticking
+ val ticking = m.timer !is State.Idle && m.timer !is State.Stopped
+ LaunchedEffect(ticking) { while (ticking) withFrameMillis { m.frame() } }
+ LaunchedEffect(Unit) { while (true) { kotlinx.coroutines.delay(1000); m.frame() } }
+
+ Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Column(Modifier.width(290.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SessionBox(m)
+ SolveTable(m, Modifier.weight(1f))
+ }
+ Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ ScrambleBox(m)
+ TimerDisplay(m, Modifier.weight(1f))
+ LastSolveBar(m)
+ }
+ Column(Modifier.width(290.dp).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ StatsBox(m)
+ PreviewBox(m)
+ SelectedSolveBox(m)
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun SessionBox(m: AppModel) {
+ var choosing by remember { mutableStateOf(false) }
+ GroupBox("Session") {
+ Column(Modifier.heightIn(max = 160.dp)) {
+ LazyColumn {
+ itemsIndexed(m.derived.sessions) { i, s ->
+ val sel = s.id == m.derived.session?.id
+ Row(
+ Modifier.fillMaxWidth().background(if (sel) Rk.c.select else if (i % 2 == 1) Rk.c.zebra else Color.Transparent)
+ .clickable { m.selectSession(s.id) }.padding(horizontal = 6.dp, vertical = 3.dp),
+ ) {
+ Label(s.name, Modifier.weight(1f), color = if (sel) Rk.c.selectInk else null, maxLines = 1)
+ Label(s.event.short, color = if (sel) Rk.c.selectInk else Rk.c.inkDim, style = Rk.t.label)
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(6.dp))
+ RetroButton(if (choosing) "Cancel" else "New session…", { choosing = !choosing })
+ if (choosing) {
+ Spacer(Modifier.height(6.dp))
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ for (e in Event.entries) RetroButton(e.short, { m.createSession(e); choosing = false }, description = "New ${e.title} session")
+ }
+ }
+ }
+}
+
+@Composable
+private fun SolveTable(m: AppModel, modifier: Modifier) {
+ val solves = m.derived.sessionSolves
+ val results = remember(solves) { solves.map { it.result } }
+ val list = rememberLazyListState()
+ LaunchedEffect(solves.size) { if (solves.isNotEmpty()) list.scrollToItem(0) }
+ Panel(modifier.fillMaxWidth(), padding = androidx.compose.foundation.layout.PaddingValues(2.dp)) {
+ Row(Modifier.fillMaxWidth()) {
+ HeaderCell("#", 0.6f); HeaderCell("time", 1.4f); HeaderCell("ao5", 1.2f); HeaderCell("ao12", 1.2f)
+ }
+ Box(Modifier.fillMaxSize().background(Rk.c.field).bevel(Rk.c, sunken = true)) {
+ LazyColumn(state = list) {
+ val n = solves.size
+ itemsIndexed(solves.asReversed(), key = { _, s -> s.id }) { ri, s ->
+ val i = n - 1 - ri
+ val sel = s.id == m.selectedSolveId
+ val ao5 = if (i >= 4) formatTime(Averages.average(results.subList(i - 4, i + 1)), true) else ""
+ val ao12 = if (i >= 11) formatTime(Averages.average(results.subList(i - 11, i + 1)), true) else ""
+ val ink = if (sel) Rk.c.selectInk else Rk.c.ink
+ Row(
+ Modifier.fillMaxWidth().background(if (sel) Rk.c.select else if (ri % 2 == 1) Rk.c.zebra else Color.Transparent)
+ .clickable { m.selectedSolveId = if (sel) null else s.id }
+ .semantics(mergeDescendants = true) { contentDescription = "solve ${i + 1}, ${formatSolve(s)}" },
+ ) {
+ Cell("${i + 1}", 0.6f, color = if (sel) ink else Rk.c.inkDim)
+ Cell(if (s.penalty == Penalty.DNF) "DNF" else formatSolve(s), 1.4f, color = when { sel -> ink; s.penalty == Penalty.DNF -> Rk.c.bad; else -> ink })
+ Cell(ao5, 1.2f, color = ink)
+ Cell(ao12, 1.2f, color = ink)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ScrambleBox(m: AppModel) {
+ Panel(Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Label(m.event.title.uppercase(), Modifier.weight(1f), style = Rk.t.heading)
+ RetroButton("Next ▸", { m.newScramble() }, description = "next scramble")
+ }
+ Spacer(Modifier.height(6.dp))
+ val size = when { m.scramble.length > 300 -> 18.sp; m.scramble.length > 120 -> 22.sp; else -> 30.sp }
+ BasicText(
+ m.scramble, Modifier.fillMaxWidth().background(Rk.c.field).bevel(Rk.c, sunken = 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.15f),
+ )
+ }
+}
+
+@Composable
+private fun TimerDisplay(m: AppModel, modifier: Modifier) {
+ val p = Rk.c
+ val s = m.timer
+ val (text, color) = timerText(s, m.now, m.lastSolve()?.let { formatSolve(it) }, p)
+ val mood = when {
+ s is State.Running -> Mood.SOLVING
+ s is State.Ready -> Mood.READY
+ s is State.Inspecting || s is State.Holding -> Mood.WATCHING
+ s is State.Stopped && m.lastPbs.isNotEmpty() -> Mood.HAPPY
+ s is State.Stopped && s.penalty == Penalty.DNF -> Mood.SAD
+ m.now - m.lastActivity > 90_000 -> Mood.SLEEPY
+ else -> Mood.IDLE
+ }
+ Column(
+ modifier.fillMaxWidth().background(p.lcd).bevel(p, sunken = true)
+ .pointerInput(Unit) {
+ awaitEachGesture {
+ awaitFirstDown(); m.press()
+ waitForUpOrCancellation(); m.release()
+ }
+ },
+ ) {
+ Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
+ BasicText(
+ text,
+ Modifier.semantics { liveRegion = LiveRegionMode.Polite; contentDescription = "timer $text" },
+ style = Rk.t.mono.copy(fontSize = 150.sp, color = color, textAlign = TextAlign.Center),
+ )
+ }
+ BasicText(
+ hint(s, m.inspection), Modifier.fillMaxWidth().padding(4.dp),
+ style = Rk.t.label.copy(color = p.lcdInk.copy(alpha = 0.7f), textAlign = TextAlign.Center),
+ )
+ MascotStrip(mood, m.reducedMotion, Modifier.fillMaxWidth().height(72.dp))
+ }
+}
+
+private fun timerText(s: State, now: Long, last: String?, p: Palette): Pair<String, Color> = when (s) {
+ State.Idle -> (last ?: "0.00") to p.lcdInk
+ is State.Inspecting -> {
+ val left = 15 - (now - s.since) / 1000
+ (if (left > 0) "$left" else if (left > -2) "+2" else "DNF") to if (left <= 3) p.bad else p.lcdInk
+ }
+ is State.Holding -> (s.inspectingSince?.let { "${(15 - (now - it) / 1000).coerceAtLeast(0)}" } ?: "0.00") to p.bad
+ is State.Ready -> "0.00" to p.good
+ is State.Running -> formatTime(now - s.since).let { if (s.penalty == Penalty.PLUS2) "$it+" else it } to p.lcdInk
+ is State.Stopped -> when (s.penalty) {
+ Penalty.DNF -> "DNF"
+ Penalty.PLUS2 -> formatTime(s.ms + 2000) + "+"
+ Penalty.NONE -> formatTime(s.ms)
+ } to p.lcdInk
+}
+
+private fun hint(s: State, inspection: Boolean) = when (s) {
+ State.Idle, is State.Stopped -> if (inspection) "space / tap: start inspection" else "hold space / tap and hold: get ready"
+ is State.Inspecting -> "hold space to get ready"
+ is State.Holding -> "keep holding…"
+ is State.Ready -> "release to start!"
+ is State.Running -> "any key / tap to stop"
+}
+
+@Composable
+private fun LastSolveBar(m: AppModel) {
+ val last = m.lastSolve() ?: return
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
+ Label("last solve:", style = Rk.t.label)
+ RetroButton("OK", { m.setPenalty(last.id, Penalty.NONE) }, selected = last.penalty == Penalty.NONE, description = "no penalty (ctrl+1)")
+ RetroButton("+2", { m.setPenalty(last.id, Penalty.PLUS2) }, selected = last.penalty == Penalty.PLUS2, description = "plus two (ctrl+2)")
+ RetroButton("DNF", { m.setPenalty(last.id, Penalty.DNF) }, selected = last.penalty == Penalty.DNF, description = "DNF (ctrl+3)")
+ RetroButton("Delete", { m.deleteSolve(last.id) }, description = "delete last solve")
+ Spacer(Modifier.weight(1f))
+ if (m.lastPbs.isNotEmpty()) Label("★ " + m.lastPbs.joinToString { it.kind.label } + " PB", color = Rk.c.gold, style = Rk.t.heading)
+ }
+}
+
+@Composable
+private fun StatsBox(m: AppModel) {
+ val ss = m.derived.sessionStats
+ val es = m.derived.eventStats
+ GroupBox("Stats") {
+ Row { HeaderCell("", 1f); HeaderCell("now", 1.2f); HeaderCell("best", 1.2f); HeaderCell("ever", 1.2f) }
+ for ((i, k) in listOf(StatKind.SINGLE, StatKind.MO3, StatKind.AO5, StatKind.AO12, StatKind.AO50, StatKind.AO100, StatKind.AO1000).withIndex()) {
+ val line = ss.line(k)
+ val life = es.line(k)
+ if (line == null && life == null) continue
+ val round = k != StatKind.SINGLE
+ Row(Modifier.background(if (i % 2 == 1) Rk.c.zebra else Rk.c.field)) {
+ Cell(k.label, 1f, style = Rk.t.label)
+ Cell(formatTime(line?.current, round), 1.2f)
+ Cell(formatTime(line?.best, round), 1.2f)
+ Cell(formatTime(life?.best, round), 1.2f, color = Rk.c.gold.takeIf { life?.best != null && life.best == line?.best && life.best != DNF })
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ Label("${ss.solved}/${ss.count} solved · mean ${formatTime(ss.mean, true)}", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+}
+
+@OptIn(ExperimentalResourceApi::class)
+@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() } }
+ GroupBox("Scramble preview") {
+ val colors = remember(m.event) { Puzzles.colors(m.event.puzzle).map(::rgb) }
+ if (state != null) PuzzleView(state, colors, Modifier.fillMaxWidth().height(200.dp), description = "3D scramble preview, drag to rotate")
+ // tnoodle's official 2D drawing: the reference for every event
+ if (painter != null) Image(painter, "scramble drawing for ${m.event.title}", Modifier.fillMaxWidth().height(if (state != null) 120.dp else 170.dp))
+ }
+}
+
+@Composable
+private fun ReplayBox(m: AppModel, r: Replay) {
+ val camera = remember(r.solveId) { Camera() }
+ GroupBox("Replay") {
+ PuzzleView(r.state, remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }, Modifier.fillMaxWidth().height(240.dp), camera, r.anim, "solve replay, drag to rotate")
+ Label("move ${r.index}/${r.total} · ${formatTime(r.elapsedMs)}", style = Rk.t.mono)
+ when (r.endedSolved) {
+ true -> Label("✓ moves solve the scramble", color = Rk.c.good, style = Rk.t.label)
+ false -> Label("✗ cube not solved at the end", color = Rk.c.bad, style = Rk.t.label)
+ null -> {}
+ }
+ Spacer(Modifier.height(4.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ m.derived.sessionSolves.firstOrNull { it.id == r.solveId }?.let { s -> RetroButton("↺ Again", { m.startReplay(s) }) }
+ RetroButton("Close", { m.stopReplay() })
+ }
+ }
+}
+
+@Composable
+private fun SelectedSolveBox(m: AppModel) {
+ val s = m.derived.sessionSolves.firstOrNull { it.id == m.selectedSolveId } ?: return
+ GroupBox("Solve") {
+ Label(formatSolve(s), style = Rk.t.mono.copy(fontSize = 36.sp))
+ Label(s.scramble, style = Rk.t.mono.copy(fontSize = 16.sp), color = Rk.c.inkDim)
+ s.moves?.let { mv -> Label("smart-cube reconstruction · ${mv.split(' ').size} moves", style = Rk.t.label, color = Rk.c.inkDim) }
+ if (s.comment.isNotEmpty()) Label("“${s.comment}”")
+ if (s.moves != null && s.event.puzzle == Puzzle.CUBE3) {
+ Spacer(Modifier.height(4.dp))
+ RetroButton("▶ Replay in 3D", { m.startReplay(s) })
+ }
+ Spacer(Modifier.height(6.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ RetroButton("OK", { m.setPenalty(s.id, Penalty.NONE) }, selected = s.penalty == Penalty.NONE)
+ RetroButton("+2", { m.setPenalty(s.id, Penalty.PLUS2) }, selected = s.penalty == Penalty.PLUS2)
+ RetroButton("DNF", { m.setPenalty(s.id, Penalty.DNF) }, selected = s.penalty == Penalty.DNF)
+ RetroButton("Delete", { m.deleteSolve(s.id) })
+ }
+ }
+}