diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-12 21:11:08 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-12 21:11:08 -0800 |
| commit | 3afb04de43726d71580b626e856c68b6ed79984e (patch) | |
| tree | 33f7bcb227ff8cfcb046a10036d2faa69c2edd1f /ui/src/commonMain/kotlin | |
| download | rookery-3afb04de43726d71580b626e856c68b6ed79984e.tar.gz rookery-3afb04de43726d71580b626e856c68b6ed79984e.zip | |
Rookery: penguin-themed cubing toolkit, phases 0-1 and GAN BLE
Kotlin Multiplatform + Compose desktop app.
- Sync: per-device append-only op logs (HLC-stamped LWW) in a plain
folder, safe under Syncthing/Drive; content-addressed image blobs.
- Timer: WCA inspection, +2/DNF, solve table, WCA-correct aoN/moN,
per-event lifetime PBs with a pixel-penguin party and synthesized
8-bit chime. Mascot sprite from liz.coffee.
- Scrambles for all WCA events + FTO via tnoodle-lib, with previews.
- Importers: csTimer exports (incl. smart-cube reconstructions and
cube MACs) and the Letter Pair Gym prototype (words, notes, images,
history, practice stats). Idempotent; headless `--import`.
- Letter-pair grid with miss heatmap, glance lookup and editor.
- 3D puzzle engine (polyhedron + cut planes) and Canvas renderer:
NxN 2-7, Pyraminx, Skewb, verified sticker-for-sticker against
tnoodle; 3D replay of smart-cube solves.
- GAN Gen2/3/4 smart-cube protocol (ported from gan-web-bluetooth,
MIT) with a BlueZ D-Bus transport and `--cube scan|monitor` CLI.
Not yet tested against hardware.
- lizCI pipeline (.ci) building a test+package Docker target.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'ui/src/commonMain/kotlin')
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) }) + } + } +} |
