diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 08:37:53 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 08:37:53 -0800 |
| commit | bf3e85b69811930d01573ae9fc2703f674c0c951 (patch) | |
| tree | 375b3433e9e0a7d312cad7aac998e188a2fddab3 /ui/src/commonMain/kotlin | |
| parent | f95457986531730095b00e2e894e9bf4d9df7166 (diff) | |
| download | rookery-bf3e85b69811930d01573ae9fc2703f674c0c951.tar.gz rookery-bf3e85b69811930d01573ae9fc2703f674c0c951.zip | |
Redesign as a liz.coffee document; type-to-steer memo; image search
Look: drop the Win98 chrome for the site's own style: black page,
serif text, sage links in [brackets], bordered tables, pink accent,
Koch snow, a marquee of colony news, an 88x31 blinkie. A paper theme
for daylight. Pixel fonts removed.
Timer: drawn seven-segment LCD in a timer body with hands-down/ready
lamps, sized to the space it has. Touch starts the timer only on touch
devices. Big display mode (F). Pips at 8 and 12 s of inspection.
Penguin: follows the pointer as on the site, pecks and sleeps when
idle, watches during solves, says a word afterwards. Sprite sizes are
now in dp (was raw pixels, hence tiny on hidpi).
BLD:
- Fix: BLD scrambles end in wide moves; hold the cube by its centres
so tracing and checking use the solver's real frame.
- Memo trainer: typing a target turns the view to that sticker (eight
fixed corner views, animated); arrows step through the memo without
showing letters; optional pause for execution; recall pass; results
separate tracing slips from memory slips (Tracer.firstMistake).
- Password fields show the last typed letter briefly.
- Pair cards show where the pair sits on the cube. Sticker drill uses
the unfolded cube for "where is it".
- Letter pairs live under BLD; BLD setup lives in settings.
- Reviews reload when the store changes (other devices' practice).
Pictures: Brave image search in the pair editor; chosen images are
shrunk to WebP and synced. Key read from the local, unsynced config.
Dev: `--shots <dir>` renders every screen offscreen with sample data.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Diffstat (limited to 'ui/src/commonMain/kotlin')
13 files changed, 1481 insertions, 918 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 index 40b799d..c133046 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt @@ -1,55 +1,145 @@ package coffee.liz.rookery.ui +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.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.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke 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.isAltPressed import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import coffee.liz.rookery.model.Penalty import coffee.liz.rookery.timer.TimerMachine.State +import kotlinx.coroutines.delay @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)) { + val p = Rk.c + Box( + Modifier.fillMaxSize().background(p.bg).pointerInput(Unit) { + // watch the pointer for the penguin without taking events from anything + awaitPointerEventScope { + while (true) { + val e = awaitPointerEvent(PointerEventPass.Initial) + m.pointer = if (e.type == PointerEventType.Exit) null else e.changes.firstOrNull()?.position ?: m.pointer + } + } + }, + ) { + if (m.snow && !m.reducedMotion) Snow(p.ink.copy(alpha = if (p.dark) 0.5f else 0.28f), Modifier.fillMaxSize()) + Column(Modifier.fillMaxSize().padding(horizontal = 14.dp, vertical = 8.dp)) { + if (!m.displayMode) Header(m) + Box(Modifier.weight(1f).fillMaxWidth()) { when (m.tab) { Tab.TIMER -> TimerScreen(m) - Tab.PAIRS -> PairsScreen(m) Tab.BLD -> BldScreen(m) Tab.IMPORT -> ImportScreen(m) Tab.SETTINGS -> SettingsScreen(m) } } + if (!m.displayMode) Footer(m) } + val s = m.timer + val mood = when { + m.tab != Tab.TIMER -> Mood.FREE + s is State.Running -> Mood.SOLVING + s is State.Ready -> Mood.READY + s is State.Inspecting || s is State.Holding -> Mood.WATCHING + m.penguinSays != null && m.lastPbs.isNotEmpty() -> Mood.HAPPY + m.penguinSays != null && s is State.Stopped && s.penalty == Penalty.DNF -> Mood.SAD + else -> Mood.FREE + } + PenguinFriend(m.pointer, mood, m.penguinSays, m.reducedMotion) m.party?.let { PbParty(it.title, it.detail, m.reducedMotion) { m.party = null } } } } } +@Composable +private fun Header(m: AppModel) { + Row(verticalAlignment = Alignment.Bottom) { + BasicText("rookery", style = Rk.t.h1.copy(color = Rk.c.heading)) + Spacer(Modifier.width(18.dp)) + Tabs(Tab.entries, m.tab, { it.label }, { m.tab = it }) + } + var news by remember { mutableStateOf(penguinNews()) } + LaunchedEffect(Unit) { while (true) { delay(90_000); news = penguinNews() } } + Marquee(news, m.reducedMotion, color = Rk.c.inkDim) + Hr() +} + +@Composable +private fun Footer(m: AppModel) { + Hr() + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(84.dp)) // the penguin's spot + Label("this is ${m.host.deviceId}. ", color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic)) + if (m.tab == Tab.TIMER) RetroButton("big display", { m.changeDisplayMode(true) }, style = Rk.t.label) + Spacer(Modifier.weight(1f)) + Blinkie(m.reducedMotion) + } +} + +/** An 88x31 button, as every homepage should have. */ +@Composable +private fun Blinkie(still: Boolean) { + var phase by remember { mutableIntStateOf(0) } + LaunchedEffect(still) { while (!still) { delay(320); phase++ } } + val p = Rk.c + Box(Modifier.size(88.dp, 31.dp).background(p.field).clearAndSetSemantics { }, Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val w = 2.dp.toPx(); val dash = 4.dp.toPx() + drawRect(p.lavender, Offset(w / 2, w / 2), Size(size.width - w, size.height - w), style = Stroke(w)) + drawRect(p.accent, Offset(w / 2, w / 2), Size(size.width - w, size.height - w), + style = Stroke(w, pathEffect = PathEffect.dashPathEffect(floatArrayOf(dash, dash), if (phase % 2 == 0) 0f else dash))) + } + BasicText("waddle on", style = Rk.t.label.copy(color = if (phase % 4 < 2) p.ink else p.accent, fontStyle = FontStyle.Italic)) + } +} + /** - * 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. + * Window-level keys for the timer: space starts, any key stops, Esc throws the attempt away, + * Ctrl+1/2/3 mark the last solve fine/+2/DNF, F toggles the big display. Returns true if consumed. */ fun handleTimerKey(m: AppModel, e: KeyEvent): Boolean { if (m.tab != Tab.TIMER) return false + val down = e.type == KeyEventType.KeyDown val running = m.timer is State.Running - if (e.isCtrlPressed && e.type == KeyEventType.KeyDown) { + if (e.isCtrlPressed && down) { val last = m.lastSolve() ?: return false when (e.key) { Key.One -> m.setPenalty(last.id, Penalty.NONE) @@ -59,13 +149,16 @@ fun handleTimerKey(m: AppModel, e: KeyEvent): Boolean { } return true } + if (e.isMetaPressed || e.isAltPressed) return false // leave Cmd-Q and friends alone + val idle = m.timer is State.Idle || m.timer is State.Stopped 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 } + e.key == Key.Escape && down -> { if (idle && m.displayMode) m.changeDisplayMode(false) else m.cancel(); true } + e.key == Key.F && down && idle -> { m.changeDisplayMode(!m.displayMode); true } + running && down -> { m.press(); true } + e.key == Key.Spacebar && down -> { m.press(); true } + e.key == Key.Spacebar && !down -> { m.release(); true } + // letting go of whatever key stopped the timer + m.timer is State.Stopped && !down -> { 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 index 69e0947..d5b8c34 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt @@ -6,6 +6,7 @@ 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.images.ImageHit import coffee.liz.rookery.importer.Importer import coffee.liz.rookery.model.DNF import coffee.liz.rookery.model.Event @@ -44,9 +45,19 @@ interface Host { val syncFolder: String fun setSyncFolder(path: String) val deviceId: String + /** Key for Brave image search; null if not configured. Local to this machine, never synced. */ + val braveApiKey: String? + /** True where fingers are the main input (phones, tablets): the timer then starts by touch. */ + val touchInput: Boolean + /** Image search for letter pairs. Throws with a readable message if it can't. */ + suspend fun searchImages(query: String): List<ImageHit> + suspend fun fetchBytes(url: String): ByteArray + /** Downscale and re-encode a picture for storage; returns bytes and file extension, or null if it isn't an image. */ + fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? + fun setFullscreen(on: Boolean) } -enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), BLD("BLD"), IMPORT("Import"), SETTINGS("Settings") } +enum class Tab(val label: String) { TIMER("timer"), BLD("blindfolded"), IMPORT("import"), SETTINGS("settings") } enum class ThemeMode { SYSTEM, LIGHT, DARK } /** Everything derived from the store, recomputed off the UI thread when it changes. */ @@ -78,6 +89,24 @@ data class Replay( class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, private val scope: CoroutineScope) { var tab by mutableStateOf(Tab.TIMER) var bldMode by mutableStateOf(BldMode.P2W) + + /** Full-window timer, for a second screen or a phone propped on the table. */ + var displayMode by mutableStateOf(false) + private set + fun changeDisplayMode(on: Boolean) { displayMode = on; if (on) tab = Tab.TIMER; host.setFullscreen(on) } + + /** Where the pointer is, for the penguin to follow. */ + var pointer by mutableStateOf<androidx.compose.ui.geometry.Offset?>(null) + var penguinSays by mutableStateOf<String?>(null) + private set + private var sayJob: kotlinx.coroutines.Job? = null + fun say(text: String, ms: Long = 2600) { + sayJob?.cancel() + penguinSays = text + sayJob = scope.launch { delay(ms); penguinSays = null } + } + var snow by mutableStateOf(repo.boolPref("snow", true)); private set + fun changeSnow(on: Boolean) { snow = on; repo.setPref("snow", on) } var derived by mutableStateOf(Derived()) private set var sessionId by mutableStateOf(repo.pref("lastSession")) @@ -180,11 +209,20 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, fun press() { machine.press(monoMillis()); sync() } fun release() { machine.release(monoMillis()); sync() } fun cancel() { machine.cancel(); sync() } + private var cue = 0 fun frame() { now = monoMillis() val before = machine.state machine.tick(now) if (machine.state != before) sync() + // the judge's calls: "eight seconds", "twelve seconds" + val since = when (val s = machine.state) { is TimerMachine.State.Inspecting -> s.since; is TimerMachine.State.Holding -> s.inspectingSince; is TimerMachine.State.Ready -> s.inspectingSince; else -> null } + if (since == null) cue = 0 + else { + val t = now - since + if (cue < 1 && t >= 8_000) { cue = 1; if (sound) host.playChime(Chime.EIGHT) } + if (cue < 2 && t >= 12_000) { cue = 2; if (sound) host.playChime(Chime.TWELVE) } + } } private fun finishSolve(ms: Long, penalty: Penalty) { @@ -200,14 +238,15 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, // 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) + if (pbs.isNotEmpty()) { celebrate(pbs); say("SQUAWK!!", 4500) } + else if (solve.result == DNF) { if (sound) host.playChime(Chime.DNF); say("peck.") } + else say(listOf("waddle.", "squawk.", "honk.", "${formatTime(solve.result)}. waddle.", "next.").random()) } } 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 title = if (top.kind == StatKind.SINGLE) "new pb" else "new ${top.kind.label} 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) @@ -282,12 +321,12 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, } private suspend fun runImport(what: String, block: suspend () -> Importer.Summary) { - importLog = importLog + "» importing $what" + importLog = importLog + what try { val s = withContext(Dispatchers.Default) { block() } - importLog = importLog + s.lines.map { " ✓ $it" } + s.warnings.map { " ! $it" } + importLog = importLog + s.lines.map { " $it" } + s.warnings.map { " warning: $it" } } catch (e: Exception) { - importLog = importLog + " ✗ failed: ${e.message}" + importLog = importLog + " failed: ${e.message}" } } @@ -300,10 +339,47 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, 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"))) + val (data, ext) = host.shrinkImage(bytes) ?: (bytes to path.substringAfterLast('.', "jpg")) + repo.setPairImage(pair, repo.store.blobs.put(data, ext)) } fun blob(name: String): ByteArray? = repo.store.blobs.get(name) + // ---- image search ---- + + var imageHits by mutableStateOf<List<ImageHit>>(emptyList()); private set + var imageStatus by mutableStateOf(""); private set + val thumbs = androidx.compose.runtime.mutableStateMapOf<String, ByteArray>() + private var imageJob: kotlinx.coroutines.Job? = null + + fun searchImages(query: String) { + imageJob?.cancel() + imageHits = emptyList(); thumbs.clear(); imageStatus = "searching…" + imageJob = scope.launch { + try { + val hits = host.searchImages(query) + imageHits = hits + imageStatus = if (hits.isEmpty()) "nothing found for “$query”." else "" + for (h in hits) launch { runCatching { host.fetchBytes(h.thumbnail) }.onSuccess { thumbs[h.thumbnail] = it } } + } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { + imageStatus = e.message ?: "search failed." + } + } + } + + fun clearImageSearch() { imageJob?.cancel(); imageHits = emptyList(); thumbs.clear(); imageStatus = "" } + + /** Keep a search result as [pair]'s picture: the full image if it downloads, else its thumbnail. */ + fun useImage(pair: String, hit: ImageHit) = scope.launch { + imageStatus = "fetching…" + val stored = withContext(Dispatchers.Default) { + val candidates = listOf(runCatching { host.fetchBytes(hit.full) }.getOrNull(), thumbs[hit.thumbnail]) + candidates.filterNotNull().firstNotNullOfOrNull { host.shrinkImage(it) } + } + if (stored == null) { imageStatus = "couldn't read that one. try another."; return@launch } + repo.setPairImage(pair, repo.store.blobs.put(stored.first, stored.second)) + clearImageSearch() + } + /** A fresh official scramble, generated off the UI thread. */ suspend fun freshScramble(e: Event): String = withContext(Dispatchers.Default) { scrambler.next(e) } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt index e2c0760..7b8d21b 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt @@ -27,12 +27,22 @@ class BldModel(private val repo: Repo) { private set private val history = HashMap<String, MutableList<Review>>() + private var loadedVersion = -1L /** Letter Pair Gym results become synthetic past reviews, so its weak pairs come up first. */ private val seeds: Map<String, List<Review>> private val picker = DrillPicker() + /** Reload reviews when the store changed under us (another device synced, or we just wrote). */ + private fun refresh() { + val v = repo.store.version.value + if (v == loadedVersion) return + loadedVersion = v + history.clear() + for (r in repo.reviews()) history.getOrPut(r.item) { seeds[r.item].orEmpty().toMutableList() } += r + setup = repo.bldSetup() + } + init { - for (r in repo.reviews()) history.getOrPut(r.item) { mutableListOf() } += r val dayMs = Memory.DAY_MS.toLong() val start = epochMillis() - 30 * dayMs seeds = repo.gymHistory().mapKeys { "p2w:${it.key}" }.mapValues { (item, rec) -> @@ -42,7 +52,7 @@ class BldModel(private val repo: Repo) { } } - private fun reviewsOf(item: String): List<Review> = history[item] ?: seeds[item].orEmpty() + private fun reviewsOf(item: String): List<Review> { refresh(); return history[item] ?: seeds[item].orEmpty() } fun memory(item: String): Memory? = Fsrs.replay(reviewsOf(item)) @@ -57,8 +67,6 @@ class BldModel(private val repo: Repo) { val grade = gradeAnswer(correct, ms, goodMs) val r = Review(item, epochMillis(), grade, ms) repo.addReview(randomId(), r) - // the first real review replaces any seeded history - history.getOrPut(item) { seeds[item].orEmpty().toMutableList() } += r return grade } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt index 1d5d69d..99108ba 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt @@ -1,7 +1,6 @@ package coffee.liz.rookery.ui import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement @@ -9,19 +8,20 @@ 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.layout.widthIn 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.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -32,8 +32,7 @@ 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.focus.onFocusChanged import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -41,71 +40,83 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale 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.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coffee.liz.rookery.bld.BldSetup import coffee.liz.rookery.bld.Cube3 -import coffee.liz.rookery.bld.LetterScheme import coffee.liz.rookery.bld.PieceType -import coffee.liz.rookery.bld.Target import coffee.liz.rookery.bld.Tracer +import coffee.liz.rookery.epochMillis import coffee.liz.rookery.model.Event import coffee.liz.rookery.model.MemoSession import coffee.liz.rookery.model.Puzzle import coffee.liz.rookery.model.formatTime import coffee.liz.rookery.monoMillis -import coffee.liz.rookery.epochMillis import coffee.liz.rookery.puzzle.CubeMapping import coffee.liz.rookery.puzzle.Puzzles import coffee.liz.rookery.srs.Grade import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.decodeToImageBitmap import kotlin.random.Random enum class BldMode(val label: String) { - P2W("Pair → word"), W2P("Word → pair"), TARGETS("Targets"), MEMO("Memo"), SETUP("Setup") + P2W("pair → word"), W2P("word → pair"), TARGETS("stickers"), MEMO("memo"), PAIRS("all the pairs") } @Composable fun BldScreen(m: AppModel) { val mode = m.bldMode - Column(Modifier.fillMaxSize().padding(8.dp)) { + Column(Modifier.fillMaxSize()) { Row(verticalAlignment = Alignment.CenterVertically) { + Label("practise: ", color = Rk.c.inkDim) Tabs(BldMode.entries, mode, { it.label }, { m.bldMode = it }) Spacer(Modifier.weight(1f)) val b = m.bld - if (b.sessionTotal > 0) Label("this session: ${b.sessionRight}/${b.sessionTotal}", style = Rk.t.label, color = Rk.c.inkDim) + if (b.sessionTotal > 0) Label("${b.sessionRight} of ${b.sessionTotal} right so far", style = Rk.t.label.copy(fontStyle = FontStyle.Italic), color = Rk.c.inkDim) } - Box(Modifier.weight(1f).fillMaxWidth().background(Rk.c.panel).bevel(Rk.c).padding(10.dp)) { + Gap(6.dp) + Box(Modifier.weight(1f).fillMaxWidth()) { when (mode) { BldMode.P2W -> PairToWord(m) BldMode.W2P -> WordToPair(m) BldMode.TARGETS -> Targets(m) BldMode.MEMO -> MemoTrainer(m) - BldMode.SETUP -> BldSetupScreen(m) + BldMode.PAIRS -> PairsScreen(m) } } } } -@Composable -private fun pairImage(m: AppModel, blob: String?): ImageBitmap? = - remember(blob) { blob?.let { runCatching { m.blob(it)?.decodeToImageBitmap() }.getOrNull() } } - -/** A focus-holding box that routes keys to [onKey]; requests focus whenever [key] changes. */ +/** A focus-holding box that routes key presses to [onKey]; takes focus whenever [key] changes. */ @Composable private fun KeyBox(key: Any?, onKey: (Key) -> Boolean, modifier: Modifier = Modifier, content: @Composable () -> Unit) { val focus = remember { FocusRequester() } LaunchedEffect(key) { runCatching { focus.requestFocus() } } - Box( - modifier.focusRequester(focus).focusable() - .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) onKey(it.key) else false }, - ) { content() } + Box(modifier.focusRequester(focus).focusable().onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) onKey(it.key) else false }) { content() } +} + +/** Where a pair would sit on the cube, as corners or as edges (whichever it can be; random if both). */ +private fun pairOnCube(setup: BldSetup, pair: String, random: Random): Triple<PieceType, Int, Int>? { + for (type in PieceType.entries.shuffled(random)) { + val a = setup.scheme.position(type, pair[0]) ?: continue + val b = setup.scheme.position(type, pair[1]) ?: continue + val buffer = Cube3.pieceId(type, setup.buffer(type)) + if (Cube3.pieceId(type, a) == buffer || Cube3.pieceId(type, b) == buffer || Cube3.pieceId(type, a) == Cube3.pieceId(type, b)) continue + return Triple(type, a, b) + } + return null } // ---------------------------------------------------------------- pair → word @@ -115,15 +126,12 @@ private fun PairToWord(m: AppModel) { val bld = m.bld val pairs = m.derived.pairs.filterValues { it.primary != null } val keys = remember(pairs.keys) { pairs.keys.sorted() } - if (keys.isEmpty()) { Label("No letter pairs with words yet. Import some, or add words in Letter pairs."); return } + if (keys.isEmpty()) { Label("There are no pairs with words yet. Import some, or write a few under “all the pairs”."); return } - val batchSize = 3 val recent = remember { mutableStateListOf<String>() } fun newBatch(): List<String> { val out = mutableListOf<String>() - repeat(minOf(batchSize, keys.size)) { - bld.pick(keys.map { "p2w:$it" }, recent + out.map { "p2w:$it" })?.removePrefix("p2w:")?.let { out += it } - } + repeat(minOf(3, keys.size)) { bld.pick(keys.map { "p2w:$it" }, recent + out.map { "p2w:$it" })?.removePrefix("p2w:")?.let { out += it } } return out } var batch by remember { mutableStateOf(newBatch()) } @@ -142,50 +150,46 @@ private fun PairToWord(m: AppModel) { KeyBox(batch, { k -> when { revealedAfter == null && (k == Key.Spacebar || k == Key.Enter) -> { reveal(); true } - revealedAfter != null && k == Key.Enter -> { next(); true } + revealedAfter != null && (k == Key.Enter || k == Key.Spacebar) -> { next(); true } revealedAfter != null && k in listOf(Key.One, Key.Two, Key.Three) -> { - val p = batch.getOrNull(listOf(Key.One, Key.Two, Key.Three).indexOf(k)) ?: return@KeyBox true - if (p in missed) missed.remove(p) else missed.add(p); true + batch.getOrNull(listOf(Key.One, Key.Two, Key.Three).indexOf(k))?.let { if (it in missed) missed.remove(it) else missed.add(it) }; true } else -> false } }, Modifier.fillMaxSize()) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) { - Label( - if (revealedAfter == null) "Say each word to yourself, then press Space." - else "Mark any you blanked on (1–3 or click), then Enter. ${formatTime(revealedAfter)} for ${batch.size}.", - color = Rk.c.inkDim, - ) - Spacer(Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.weight(1f)) { - for ((i, p) in batch.withIndex()) { - val lp = pairs[p] - val miss = p in missed - Panel( - Modifier.width(260.dp).fillMaxSize() - .clickable(enabled = revealedAfter != null) { if (miss) missed.remove(p) else missed.add(p) }, - sunken = true, - ) { - Label("${i + 1}", style = Rk.t.label, color = Rk.c.inkDim) - BasicText(p, Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 84.sp, color = Rk.c.ink, textAlign = TextAlign.Center)) - if (revealedAfter != null && lp != null) { - val img = pairImage(m, lp.image) - Box(Modifier.fillMaxWidth().height(150.dp), Alignment.Center) { - if (img != null) Image(img, lp.primary, Modifier.fillMaxSize(), contentScale = ContentScale.Fit) + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f)) { + for ((i, pair) in batch.withIndex()) { + val lp = pairs[pair] + val miss = pair in missed + val where = remember(pair, bld.setup) { pairOnCube(bld.setup, pair, Random.Default) } + Table(Modifier.width(280.dp).fillMaxHeight().clickable(enabled = revealedAfter != null) { if (miss) missed.remove(pair) else missed.add(pair) }) { + Column(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + BasicText(pair, style = Rk.t.mono.copy(fontSize = 64.sp, fontWeight = FontWeight.Bold, color = Rk.c.ink)) + if (where != null) { + val (type, a, b) = where + CubeNet(bld.setup.solvedColors, 16.dp, marks = mapOf(Cube3.slotOf(type, a) to "1", Cube3.slotOf(type, b) to "2"), dim = true) + Label("as ${if (type == PieceType.CORNER) "corners" else "edges"}: ${Cube3.stickerName(type, a)}, then ${Cube3.stickerName(type, b)}", color = Rk.c.inkDim, style = Rk.t.label) + } + } + Column(Modifier.fillMaxWidth().weight(1f).edge(Rk.c, sunk = true).padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + if (revealedAfter != null && lp != null) { + val img = pairImage(m, lp.image) + if (img != null) Image(img, lp.primary, Modifier.fillMaxWidth().weight(1f), contentScale = ContentScale.Fit) else Spacer(Modifier.weight(1f)) + BasicText(lp.primary.orEmpty(), style = Rk.t.h1.copy(fontSize = 28.sp, color = if (miss) Rk.c.bad else Rk.c.ink, textAlign = TextAlign.Center, textDecoration = if (miss) TextDecoration.LineThrough else null)) + val others = lp.words - setOfNotNull(lp.primary) + if (others.isNotEmpty()) Label("or " + others.sorted().joinToString(", "), color = Rk.c.inkDim, style = Rk.t.label) + lp.note?.let { Label(it, color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic)) } + Label(if (miss) "blanked on it (${i + 1} to undo)" else "press ${i + 1} if you blanked", color = if (miss) Rk.c.bad else Rk.c.inkDim, style = Rk.t.label) } - BasicText(lp.primary.orEmpty(), Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 34.sp, color = if (miss) Rk.c.bad else Rk.c.ink, textAlign = TextAlign.Center)) - val others = lp.words - setOfNotNull(lp.primary) - if (others.isNotEmpty()) Label(others.sorted().joinToString(" · "), Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center)) - lp.note?.let { Label(it, Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center)) } - Spacer(Modifier.height(6.dp)) - Label(if (miss) "✗ blanked" else "✓ got it", color = if (miss) Rk.c.bad else Rk.c.good, style = Rk.t.heading) } } } } - Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (revealedAfter == null) RetroButton("Reveal (space)", { reveal() }) else RetroButton("Next (enter)", { next() }) + Gap(6.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + if (revealedAfter == null) { Label("Say each word to yourself, then ", color = Rk.c.inkDim); RetroButton("show them", { reveal() }); Label(" (space).", color = Rk.c.inkDim) } + else { Label("${formatTime(revealedAfter)} for the ${batch.size}. ", color = Rk.c.inkDim); RetroButton("next three", { next() }); Label(" (space).", color = Rk.c.inkDim) } } } } @@ -198,7 +202,7 @@ private fun WordToPair(m: AppModel) { val bld = m.bld val pairs = m.derived.pairs.filterValues { it.primary != null } val keys = remember(pairs.keys) { pairs.keys.sorted() } - if (keys.isEmpty()) { Label("No letter pairs with words yet."); return } + if (keys.isEmpty()) { Label("There are no pairs with words yet."); return } val scope = rememberCoroutineScope() val recent = remember { mutableStateListOf<String>() } var current by remember { mutableStateOf(bld.pick(keys.map { "w2p:$it" }, emptyList())!!.removePrefix("w2p:")) } @@ -208,68 +212,61 @@ private fun WordToPair(m: AppModel) { val lp = pairs[current] val word = lp?.primary.orEmpty() - fun advance() { - recent += "w2p:$current" - current = bld.pick(keys.map { "w2p:$it" }, recent)!!.removePrefix("w2p:") - } fun answer(guess: String?) { if (verdict != null) return - // Any pair whose main word is this word counts (duplicate words happen). + // any pair whose main word is this word counts (duplicate words happen) val ok = guess != null && (guess == current || pairs[guess]?.primary?.equals(word, ignoreCase = true) == true) - val ms = monoMillis() - started - verdict = ok to bld.record("w2p:$current", ok, ms, goodMs = 2_000) + verdict = ok to bld.record("w2p:$current", ok, monoMillis() - started, goodMs = 2_000) bld.tally(ok) - scope.launch { delay(if (ok) 600 else 1_600); advance() } + scope.launch { + delay(if (ok) 600 else 1_800) + recent += "w2p:$current" + current = bld.pick(keys.map { "w2p:$it" }, recent)!!.removePrefix("w2p:") + } } val img = pairImage(m, lp?.image) val focus = remember { FocusRequester() } LaunchedEffect(current) { runCatching { focus.requestFocus() } } Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { - Label("Type the pair. Tab to give up and see it.", color = Rk.c.inkDim) - Spacer(Modifier.height(10.dp)) - Box(Modifier.size(320.dp, 240.dp).background(Rk.c.field).bevel(Rk.c, sunken = true), Alignment.Center) { - if (img != null) Image(img, word, Modifier.fillMaxSize().padding(4.dp), contentScale = ContentScale.Fit) - else Label("(no image)", color = Rk.c.inkDim, style = Rk.t.label) + Box(Modifier.weight(1f).fillMaxWidth().padding(bottom = 6.dp), Alignment.Center) { + if (img != null) Image(img, word, Modifier.fillMaxSize(), contentScale = ContentScale.Fit) } - BasicText(word, Modifier.padding(8.dp), style = Rk.t.mono.copy(fontSize = 56.sp, color = Rk.c.ink, textAlign = TextAlign.Center)) + BasicText(word, style = Rk.t.h1.copy(fontSize = 44.sp, color = Rk.c.ink, textAlign = TextAlign.Center)) + lp?.note?.let { Label(it, color = Rk.c.inkDim, style = Rk.t.body.copy(fontStyle = FontStyle.Italic)) } + Gap() RetroField( typed, - { v -> - val clean = v.uppercase().filter { it in LETTERS }.take(2) - typed = clean - if (clean.length == 2) answer(clean) - }, - Modifier.width(140.dp).focusRequester(focus).onPreviewKeyEvent { - if (it.type == KeyEventType.KeyDown && it.key == Key.Tab) { answer(null); true } else false - }, - placeholder = "??", style = Rk.t.mono.copy(fontSize = 48.sp, textAlign = TextAlign.Center), description = "pair for $word", + { v -> val clean = v.uppercase().filter { it in LETTERS }.take(2); typed = clean; if (clean.length == 2) answer(clean) }, + Modifier.width(110.dp).focusRequester(focus).onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown && it.key == Key.Tab) { answer(null); true } else false }, + style = Rk.t.mono.copy(fontSize = 40.sp, textAlign = TextAlign.Center), description = "pair for $word", ) - Spacer(Modifier.height(10.dp)) - verdict?.let { (ok, grade) -> - Label( - if (ok) "✓ $current (${grade.name.lowercase()})" else "✗ it's $current", - Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 22.sp), + Box(Modifier.height(40.dp), Alignment.Center) { + val v = verdict + if (v == null) Label("Which two letters? Tab if it's gone.", color = Rk.c.inkDim, style = Rk.t.label) + else Label( + if (v.first) "$current. ${when (v.second) { Grade.EASY -> "quick."; Grade.GOOD -> "yes."; else -> "yes, slowly." }}" else "no: $current.", + Modifier.semantics { liveRegion = LiveRegionMode.Polite }, color = if (v.first) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 20.sp), ) } } } -// ---------------------------------------------------------------- targets +// ---------------------------------------------------------------- stickers private fun randomScramble(rng: Random = Random.Default): String { val faces = "URFDLB"; val sfx = listOf("", "'", "2") val out = mutableListOf<String>() var last = -1 - while (out.size < 25) { - val f = rng.nextInt(6) - if (f == last) continue - last = f; out += "${faces[f]}${sfx[rng.nextInt(3)]}" - } + while (out.size < 25) { val f = rng.nextInt(6); if (f == last) continue; last = f; out += "${faces[f]}${sfx[rng.nextInt(3)]}" } return out.joinToString(" ") } +/** + * Two drills on a scrambled cube. "what is it": one sticker is ringed (the view turns to show its + * whole piece) and you type the letter it belongs to, which is the reading half of tracing. + * "where is it": you get a letter and click its sticker on the unfolded cube. + */ @Composable private fun Targets(m: AppModel) { val bld = m.bld @@ -283,17 +280,14 @@ private fun Targets(m: AppModel) { val scope = rememberCoroutineScope() val camera = remember { Camera() } - data class Question(val scramble: String, val state: IntArray, val type: PieceType, val home: Int, val where: Int) + class Question(val state: IntArray, val type: PieceType, val home: Int, val where: Int) fun newQuestion(): Question { val items = types.flatMap { t -> bld.memoPositions(t).map { "${if (find) "find" else "name"}:${if (t == PieceType.CORNER) "c" else "e"}:$it" } } - val item = bld.pick(items, recent)!! - val parts = item.split(':') + val parts = bld.pick(items, recent)!!.split(':') val type = if (parts[1] == "c") PieceType.CORNER else PieceType.EDGE val home = parts[2].toInt() - val scr = randomScramble() - val state = setup.stateOf(scr) - val where = tracer.homes(state, type).indexOf(home) - return Question(scr, state, type, home, where) + val state = setup.stateOf(randomScramble()) + return Question(state, type, home, tracer.homes(state, type).indexOf(home)) } var q by remember(find, types, setup) { mutableStateOf(newQuestion()) } var started by remember(q) { mutableLongStateOf(monoMillis()) } @@ -301,98 +295,89 @@ private fun Targets(m: AppModel) { var typed by remember(q) { mutableStateOf("") } val item = "${if (find) "find" else "name"}:${if (q.type == PieceType.CORNER) "c" else "e"}:${q.home}" val whereSlot = Cube3.slotOf(q.type, q.where) + var corner by remember { mutableStateOf(Triple(1, 1, 1)) } LaunchedEffect(q) { - if (!find) cameraFacing(whereSlot / 9).let { (y, p) -> camera.yaw = y; camera.pitch = p } + if (!find) { corner = Cube3.viewCorner(whereSlot, corner); val (y, p) = cornerView(corner); camera.animateTo(y, p, if (m.reducedMotion) 0 else 260) } } fun answer(ok: Boolean) { if (verdict != null) return verdict = ok - bld.record(item, ok, monoMillis() - started, goodMs = 2_000) - bld.tally(ok) + bld.record(item, ok, monoMillis() - started, goodMs = 2_000); bld.tally(ok) recent += item - scope.launch { delay(if (ok) 500 else 1_800); q = newQuestion() } + scope.launch { delay(if (ok) 500 else 2_000); q = newQuestion() } } val letter = setup.scheme.letter(q.type, q.home) val kind = if (q.type == PieceType.CORNER) "corner" else "edge" - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Column(Modifier.weight(1f)) { - PuzzleView( - mapping.state(q.state), colors, Modifier.fillMaxWidth().weight(1f), camera, - description = if (find) "scrambled cube: click where the $kind sticker $letter is" else "scrambled cube with one $kind sticker outlined", - highlight = if (!find || verdict == false) mapping.stickerOfSlot(whereSlot) else null, - highlightColor = if (find && verdict == false) Rk.c.bad else Color(0xFFFF2BD6), - onStickerTap = if (find) { i -> - val slot = mapping.slotOf[i] - val pos = Cube3.positionOf(q.type, slot) - // clicking the wrong piece type just doesn't count - if (pos != null || Cube3.positionOf(if (q.type == PieceType.CORNER) PieceType.EDGE else PieceType.CORNER, slot) != null) answer(slot == whereSlot) - } else null, + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Box(Modifier.weight(1f).fillMaxHeight(), Alignment.Center) { + if (find) CubeNet( + q.state, 44.dp, + selected = whereSlot.takeIf { verdict == false }, + describe = { "sticker" }, + onPick = { slot -> if (Cube3.positionOf(PieceType.CORNER, slot) != null || Cube3.positionOf(PieceType.EDGE, slot) != null) answer(slot == whereSlot) }, ) - Label("Drag to turn the cube. Shown as you hold it (your orientation from Setup).", color = Rk.c.inkDim, style = Rk.t.label) + else PuzzleView(mapping.state(q.state), colors, Modifier.fillMaxSize(), camera, description = "scrambled cube with one $kind sticker ringed", highlight = mapping.stickerOfSlot(whereSlot)) } - Column(Modifier.width(320.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - GroupBox("Drill") { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - RetroButton("Name it", { find = false }, selected = !find, description = "see a sticker, type its letter") - RetroButton("Find it", { find = true }, selected = find, description = "see a letter, click the sticker") - } - Spacer(Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - for (t in PieceType.entries) RetroButton( - t.name.lowercase() + "s", - { types = if (t in types && types.size > 1) types - t else types + t }, - selected = t in types, - ) - } + Column(Modifier.width(330.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Label("drill: ", color = Rk.c.inkDim) + Tabs(listOf(false, true), find, { if (it) "where is it" else "what is it" }, { find = it }) } - GroupBox(if (find) "Find this $kind" else "What letter is the outlined $kind sticker?") { - if (find) { - BasicText("$letter", Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 96.sp, color = Rk.c.ink, textAlign = TextAlign.Center)) - Label("click where that sticker is now", Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center)) - } else { - val focus = remember { FocusRequester() } - LaunchedEffect(q) { runCatching { focus.requestFocus() } } - RetroField( - typed, - { v -> - val c = v.uppercase().filter { it.isLetter() }.take(1) - typed = c - if (c.isNotEmpty()) answer(setup.scheme.position(q.type, c[0]) == q.home) - }, - Modifier.fillMaxWidth().focusRequester(focus), - placeholder = "?", style = Rk.t.mono.copy(fontSize = 64.sp, textAlign = TextAlign.Center), description = "letter for the outlined sticker", - ) - } - Spacer(Modifier.height(8.dp)) - verdict?.let { ok -> - Label( - if (ok) "✓ $letter — ${Cube3.stickerName(q.type, q.home)}" else "✗ that's $letter — ${Cube3.stickerName(q.type, q.home)}", - Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Label("with: ", color = Rk.c.inkDim) + for ((i, t) in PieceType.entries.withIndex()) { + if (i > 0) Label(" · ", color = Rk.c.inkDim) + RetroButton(t.name.lowercase() + "s", { types = if (t in types && types.size > 1) types - t else types + t }, selected = t in types) } } - Label("Weak letters come up more often; every answer is saved and synced.", color = Rk.c.inkDim, style = Rk.t.label) + Hr() + if (find) { + Label("Click the $kind sticker that belongs at") + BasicText("$letter", style = Rk.t.mono.copy(fontSize = 96.sp, fontWeight = FontWeight.Bold, color = Rk.c.ink)) + } else { + Label("The ringed $kind sticker belongs at which letter?") + Gap(6.dp) + val focus = remember { FocusRequester() } + LaunchedEffect(q) { runCatching { focus.requestFocus() } } + RetroField( + typed, + { v -> val c = v.uppercase().filter { it.isLetter() }.take(1); typed = c; if (c.isNotEmpty()) answer(setup.scheme.position(q.type, c[0]) == q.home) }, + Modifier.width(90.dp).focusRequester(focus), style = Rk.t.mono.copy(fontSize = 56.sp, textAlign = TextAlign.Center), description = "letter for the ringed sticker", + ) + } + Gap() + verdict?.let { ok -> + Label( + if (ok) "$letter, yes. (${Cube3.stickerName(q.type, q.home)})" else "that one is $letter, ${Cube3.stickerName(q.type, q.home)}.", + Modifier.semantics { liveRegion = LiveRegionMode.Polite }, color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 20.sp), + ) + } + Spacer(Modifier.weight(1f)) + Label("The ones you miss come back sooner.", color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic)) } } } // ---------------------------------------------------------------- memo -private enum class MemoPhase { LOADING, MEMO, RECALL, RESULT } +private enum class MemoPhase { LOADING, MEMO, EXECUTE, RECALL, RESULT } -private data class MemoResult( - val memoMs: Long, - val ok: Map<PieceType, Boolean>, - val wrong: Map<PieceType, List<String>>, - val yours: Map<PieceType, String>, - val traced: Map<PieceType, String>, - val errors: List<String>, - val parity: Boolean, +private class TypeResult( + val type: PieceType, val traced: String, val recalled: String, + val traceMistake: Tracer.Mistake?, val recallSolves: Boolean, + /** First place the recall differs from the trace. */ + val firstDiff: Int?, + val leftWrong: List<String>, val typos: List<String>, ) +/** + * Memo the way you'd do it with the cube in your hands, minus the hands: type where each piece goes + * and the view turns to that sticker so you can read the next one. Then the cube goes away, an + * optional pause stands in for execution, and you type the memo back from memory. Comparing the two + * passes says whether a miss was a tracing mistake or a memory one. + */ @Composable private fun MemoTrainer(m: AppModel) { val bld = m.bld @@ -401,219 +386,205 @@ private fun MemoTrainer(m: AppModel) { val mapping = remember { CubeMapping(Puzzles.cube(3), 3) } val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } val camera = remember { Camera() } - var round by remember { mutableStateOf(0) } + var round by remember { mutableIntStateOf(0) } var scramble by remember(round) { mutableStateOf<String?>(null) } var phase by remember(round) { mutableStateOf(MemoPhase.LOADING) } var memoStart by remember(round) { mutableLongStateOf(0) } var memoMs by remember(round) { mutableLongStateOf(0) } - var edges by remember(round) { mutableStateOf("") } - var corners by remember(round) { mutableStateOf("") } - var result by remember(round) { mutableStateOf<MemoResult?>(null) } + var executeLeft by remember(round) { mutableLongStateOf(0) } var now by remember { mutableLongStateOf(monoMillis()) } + val trace = remember(round) { mutableStateListOf(TextFieldValue(""), TextFieldValue("")) } // corners, edges + val recall = remember(round) { mutableStateListOf(TextFieldValue(""), TextFieldValue("")) } + var active by remember(round) { mutableStateOf(PieceType.CORNER) } + var results by remember(round) { mutableStateOf<List<TypeResult>?>(null) } + var pause by remember { mutableIntStateOf(m.repo.pref("bld.execPause")?.toIntOrNull() ?: 0) } + val focus = remember { listOf(FocusRequester(), FocusRequester()) } + fun idx(t: PieceType) = if (t == PieceType.CORNER) 0 else 1 LaunchedEffect(round) { + camera.reset() scramble = m.freshScramble(Event.E333BF) phase = MemoPhase.MEMO memoStart = monoMillis() } - LaunchedEffect(phase) { while (phase == MemoPhase.MEMO) { now = monoMillis(); delay(50) } } + LaunchedEffect(phase) { + when (phase) { + MemoPhase.MEMO -> { runCatching { focus[0].requestFocus() }; while (true) { now = monoMillis(); delay(50) } } + MemoPhase.EXECUTE -> { + val until = monoMillis() + pause * 1000L + while (monoMillis() < until) { executeLeft = until - monoMillis(); delay(100) } + phase = MemoPhase.RECALL + } + MemoPhase.RECALL -> runCatching { focus[0].requestFocus() } + else -> {} + } + } val state = remember(scramble, setup) { scramble?.let { setup.stateOf(it) } } - fun submit() { + // The sticker to look at: the buffer to begin with, then wherever the last typed letter points. + val tv = trace[idx(active)] + val cursor = tv.selection.start.coerceIn(0, tv.text.length) + val lookPos = if (cursor == 0) setup.buffer(active) else setup.scheme.position(active, tv.text[cursor - 1]) + val lookSlot = lookPos?.let { Cube3.slotOf(active, it) } + var corner by remember(round) { mutableStateOf(Triple(1, 1, 1)) } + LaunchedEffect(lookSlot, phase) { + if (phase == MemoPhase.MEMO && lookSlot != null) { + corner = Cube3.viewCorner(lookSlot, corner) + val (y, p) = cornerView(corner) + camera.animateTo(y, p, if (m.reducedMotion) 0 else 260) + } + } + fun step(d: Int) { + val i = idx(active) + trace[i] = trace[i].copy(selection = TextRange((cursor + d).coerceIn(0, trace[i].text.length))) + runCatching { focus[i].requestFocus() } + } + + fun check() { val s = state ?: return - val (e, eErr) = tracer.parse(edges, PieceType.EDGE) - val (c, cErr) = tracer.parse(corners, PieceType.CORNER) - val after = tracer.execute(s, c + e) - val traced = PieceType.entries.associateWith { tracer.letters(tracer.trace(s, it)) } - val r = MemoResult( - memoMs, - PieceType.entries.associateWith { tracer.isSolved(after, it) }, - PieceType.entries.associateWith { tracer.wrongPieces(after, it) }, - mapOf(PieceType.EDGE to tracer.letters(e), PieceType.CORNER to tracer.letters(c)), - traced, - eErr + cErr, - tracer.hasParity(tracer.trace(s, PieceType.CORNER)), - ) - result = r + val out = PieceType.entries.map { type -> + val (tt, tErr) = tracer.parse(trace[idx(type)].text, type) + val (rt, rErr) = tracer.parse(recall[idx(type)].text, type) + val after = tracer.execute(s, rt) + val tl = tracer.letters(tt); val rl = tracer.letters(rt) + TypeResult( + type, tl, rl, tracer.firstMistake(s, tt, type), tracer.isSolved(after, type), + (0 until maxOf(tl.length, rl.length)).firstOrNull { tl.getOrNull(it) != rl.getOrNull(it) }, + tracer.wrongPieces(after, type), (tErr + rErr).distinct(), + ) + } + results = out phase = MemoPhase.RESULT - val letters = traced.values.sumOf { it.length } - val pairsTotal = (letters + 1) / 2 + val solved = out.all { it.recallSolves } + val pairsTotal = (PieceType.entries.sumOf { tracer.trace(s, it).size } + 1) / 2 m.repo.addMemoSession( - MemoSession(epochMillis(), pairsTotal, memoMs, if (r.ok.values.all { it }) pairsTotal else 0), - mapOf("scramble" to (scramble ?: ""), "edges" to r.yours.getValue(PieceType.EDGE), "corners" to r.yours.getValue(PieceType.CORNER)), + MemoSession(epochMillis(), pairsTotal, memoMs, if (solved) pairsTotal else 0), + mapOf("scramble" to (scramble ?: ""), "corners" to out[0].recalled, "edges" to out[1].recalled, "tracedCorners" to out[0].traced, "tracedEdges" to out[1].traced), ) - bld.tally(r.ok.values.all { it }) + bld.tally(solved) + m.say(if (solved) "squawk!" else "peck.") } - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Column(Modifier.weight(1f)) { + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Column(Modifier.weight(1f).fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally) { when { state == null -> Box(Modifier.fillMaxSize(), Alignment.Center) { Label("scrambling…", color = Rk.c.inkDim) } - phase == MemoPhase.RECALL -> Box(Modifier.fillMaxSize().background(Rk.c.field).bevel(Rk.c, sunken = true), Alignment.Center) { + phase == MemoPhase.EXECUTE || phase == MemoPhase.RECALL -> Box(Modifier.fillMaxSize(), Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Penguin(PenguinAnim.SLEEP, 96.dp, m.reducedMotion) - Label("cube hidden — blindfold on", color = Rk.c.inkDim) + Penguin(PenguinAnim.SLEEP, 128.dp, m.reducedMotion, frameMs = 700) + Label(if (phase == MemoPhase.EXECUTE) "blindfold on. ${(executeLeft + 999) / 1000}" else "blindfold on.", color = Rk.c.inkDim, style = Rk.t.body.copy(fontStyle = FontStyle.Italic)) + if (phase == MemoPhase.EXECUTE) RetroButton("that's long enough", { phase = MemoPhase.RECALL }) } } else -> { - PuzzleView(mapping.state(state), colors, Modifier.fillMaxWidth().weight(1f), camera, description = "scrambled cube to memorise, drag to turn") - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Label("look at:", style = Rk.t.label) - for ((i, f) in "URFDLB".withIndex()) RetroButton("$f", { cameraFacing(i).let { (y, p) -> camera.yaw = y; camera.pitch = p } }, description = "turn the view to the $f face") + PuzzleView( + mapping.state(state), colors, Modifier.fillMaxWidth().weight(1f), camera, description = "scrambled cube; the view follows what you type", + highlight = lookSlot?.takeIf { phase == MemoPhase.MEMO }?.let { mapping.stickerOfSlot(it) }, + ) + if (phase == MemoPhase.MEMO) Row(verticalAlignment = Alignment.CenterVertically) { + RetroButton("←", { step(-1) }, enabled = cursor > 0, description = "previous target") + Label( + if (cursor == 0) " at the ${if (active == PieceType.CORNER) "corner" else "edge"} buffer " else " target $cursor of ${tv.text.length} ", + style = Rk.t.mono, color = Rk.c.inkDim, + ) + RetroButton("→", { step(1) }, enabled = cursor < tv.text.length, description = "next target") } } } } - Column(Modifier.width(380.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { - GroupBox("Memo") { - Label(scramble ?: "…", style = Rk.t.mono.copy(fontSize = 16.sp), color = Rk.c.inkDim) - Spacer(Modifier.height(6.dp)) - val shown = when (phase) { MemoPhase.MEMO -> now - memoStart; else -> memoMs } - BasicText(formatTime(shown), style = Rk.t.mono.copy(fontSize = 48.sp, color = Rk.c.ink)) - when (phase) { - MemoPhase.LOADING -> {} - MemoPhase.MEMO -> RetroButton("Done memorising", { memoMs = monoMillis() - memoStart; phase = MemoPhase.RECALL }) - MemoPhase.RECALL -> { - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } - Label("Edges", style = Rk.t.label) - RetroField(edges, { edges = it }, Modifier.fillMaxWidth().focusRequester(focus), password = true, style = Rk.t.mono.copy(fontSize = 28.sp), description = "edge memo") - Label("Corners", style = Rk.t.label) - RetroField(corners, { corners = it }, Modifier.fillMaxWidth(), password = true, style = Rk.t.mono.copy(fontSize = 28.sp), onSubmit = { submit() }, description = "corner memo") - Spacer(Modifier.height(6.dp)) - RetroButton("Check", { submit() }) + Column(Modifier.width(400.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { + Label(scramble ?: "…", style = Rk.t.mono.copy(fontSize = 13.sp), color = Rk.c.inkDim) + BasicText(formatTime(if (phase == MemoPhase.MEMO) now - memoStart else memoMs), style = Rk.t.mono.copy(fontSize = 40.sp, color = Rk.c.ink)) + when (phase) { + MemoPhase.LOADING -> {} + MemoPhase.MEMO -> { + Label("Type where each piece goes. The cube turns to that sticker, ringed, so you can read the next. Arrow keys walk back through it.", color = Rk.c.inkDim) + Gap(6.dp) + for (type in PieceType.entries) { + val i = idx(type) + Label(if (type == PieceType.CORNER) "corners" else "edges", color = if (active == type) Rk.c.accent else Rk.c.heading, style = Rk.t.heading) + SecretField( + trace[i], { trace[i] = it }, + Modifier.fillMaxWidth().focusRequester(focus[i]).onFocusChanged { if (it.isFocused) active = type }, + style = Rk.t.mono.copy(fontSize = 24.sp), description = "${type.name.lowercase()} memo", + ) + } + Gap() + Row(verticalAlignment = Alignment.CenterVertically) { + Label("pause for execution: ", color = Rk.c.inkDim) + Tabs(listOf(0, 10, 20, 40), pause, { if (it == 0) "no time" else "${it}s" }, { pause = it; m.repo.setPref("bld.execPause", it.toString()) }, style = Rk.t.label) + } + RetroButton("memorised. hide it", { memoMs = monoMillis() - memoStart; phase = if (pause > 0) MemoPhase.EXECUTE else MemoPhase.RECALL }) + } + MemoPhase.EXECUTE -> Label("Hold it in your head.", color = Rk.c.inkDim) + MemoPhase.RECALL -> { + Label("Now from memory.", color = Rk.c.inkDim) + Gap(6.dp) + for (type in PieceType.entries) { + val i = idx(type) + Label(if (type == PieceType.CORNER) "corners" else "edges", color = Rk.c.heading, style = Rk.t.heading) + SecretField(recall[i], { recall[i] = it }, Modifier.fillMaxWidth().focusRequester(focus[i]), style = Rk.t.mono.copy(fontSize = 24.sp), onSubmit = { check() }, description = "${type.name.lowercase()} recall") } - MemoPhase.RESULT -> RetroButton("Next cube", { round++ }) + Gap() + RetroButton("check it", { check() }) + } + MemoPhase.RESULT -> { + results?.let { MemoResults(it, setup, tracer, state) } + Gap() + RetroButton("another cube", { round++ }) } } - result?.let { r -> MemoResultBox(r, setup) } } } } +/** Letters in pairs, with the letter at [mark] in pink (or a pink gap if the mistake is "stopped here"). */ @Composable -private fun MemoResultBox(r: MemoResult, setup: BldSetup) { - val allOk = r.ok.values.all { it } - GroupBox(if (allOk) "Solved!" else "Not quite") { - if (allOk) Row(verticalAlignment = Alignment.CenterVertically) { - Penguin(PenguinAnim.ALERT, 48.dp) - Label("Executed your memo and the cube came out solved.", color = Rk.c.good) - } - for (t in listOf(PieceType.EDGE, PieceType.CORNER)) { - val name = if (t == PieceType.EDGE) "Edges" else "Corners" - Spacer(Modifier.height(6.dp)) - Label("$name ${if (r.ok.getValue(t)) "✓" else "✗"}", style = Rk.t.heading, color = if (r.ok.getValue(t)) Rk.c.good else Rk.c.bad) - Label("you: ${r.yours.getValue(t).chunked(2).joinToString(" ").ifEmpty { "—" }}", style = Rk.t.mono) - Label("traced: ${r.traced.getValue(t).chunked(2).joinToString(" ").ifEmpty { "—" }}", style = Rk.t.mono, color = Rk.c.inkDim) - val wrong = r.wrong.getValue(t) - if (wrong.isNotEmpty()) Label("left wrong: ${wrong.joinToString(", ")}", color = Rk.c.bad) - } - if (r.errors.isNotEmpty()) { Spacer(Modifier.height(6.dp)); for (e in r.errors) Label("! $e", color = Rk.c.gold) } - Spacer(Modifier.height(6.dp)) - Label( - (if (r.parity) "Parity: yes (odd targets)." else "Parity: no.") + - " Traced memo is one valid answer; any memo that solves counts.", - color = Rk.c.inkDim, style = Rk.t.label, - ) +private fun marked(letters: String, mark: Int?): AnnotatedString = buildAnnotatedString { + val hot = SpanStyle(color = Rk.c.accent, fontWeight = FontWeight.Bold, textDecoration = TextDecoration.Underline) + if (letters.isEmpty() && mark == null) { append("nothing"); return@buildAnnotatedString } + for ((i, c) in letters.withIndex()) { + if (i > 0 && i % 2 == 0) append(' ') + if (i == mark) withStyle(hot) { append(c) } else append(c) } + if (mark != null && mark >= letters.length) { append(' '); withStyle(hot) { append("__") } } } -// ---------------------------------------------------------------- setup - @Composable -private fun BldSetupScreen(m: AppModel) { - val bld = m.bld - val setup = bld.setup - var editing by remember { mutableStateOf<Pair<PieceType, Int>?>(null) } - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - GroupBox("Lettering") { - Label("Click a sticker, then type its letter. Letters swap if already used. Shown in your solving orientation.", color = Rk.c.inkDim) - Spacer(Modifier.height(8.dp)) - LetterNet(setup, editing) { editing = it } - Spacer(Modifier.height(8.dp)) - editing?.let { (type, pos) -> - var v by remember(editing) { mutableStateOf("") } - val focus = remember { FocusRequester() } - LaunchedEffect(editing) { runCatching { focus.requestFocus() } } - Row(verticalAlignment = Alignment.CenterVertically) { - Label("${Cube3.stickerName(type, pos)} (${type.name.lowercase()}) is ${setup.scheme.letter(type, pos)} → ", style = Rk.t.body) - RetroField(v, { nv -> - val c = nv.uppercase().filter { it.isLetter() }.take(1) - v = c - if (c.isNotEmpty()) { bld.updateSetup(setup.copy(scheme = assign(setup.scheme, type, pos, c[0]))); editing = null } - }, Modifier.width(60.dp).focusRequester(focus), placeholder = "?", style = Rk.t.mono.copy(fontSize = 24.sp)) - } - } - Spacer(Modifier.height(6.dp)) - RetroButton("Reset to Speffz", { bld.updateSetup(setup.copy(scheme = LetterScheme())) }) +private fun MemoResults(results: List<TypeResult>, setup: BldSetup, tracer: Tracer, state: IntArray?) { + val solved = results.all { it.recallSolves } + Heading(if (solved) "solved." else "DNF.") + for (r in results) { + val name = if (r.type == PieceType.CORNER) "corners" else "edges" + fun letter(pos: Int) = "${setup.scheme.letter(r.type, pos)} (${Cube3.stickerName(r.type, pos)})" + val tm = r.traceMistake + Table(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Row { HeaderCell(name, 1f); HeaderCell(if (r.recallSolves) "fine" else if (tm == null) "memory" else "tracing", 1f) } + Row(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(4.dp)) { + Label("traced ", Modifier.width(64.dp), color = Rk.c.inkDim) + Label(marked(r.traced, tm?.index), style = Rk.t.mono) } - } - Column(Modifier.width(380.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { - GroupBox("Orientation") { - val presets = listOf("yellow top, orange front" to "x2 y'", "white top, green front (WCA)" to "", "yellow top, green front" to "z2", "white top, orange front" to "y'") - for ((label, rot) in presets) RetroButton(label, { bld.updateSetup(setup.copy(rotation = rot)) }, Modifier.fillMaxWidth().padding(bottom = 3.dp), selected = setup.rotation == rot) - Label("rotation from scrambling orientation: ${setup.rotation.ifEmpty { "none" }}", color = Rk.c.inkDim, style = Rk.t.label) + Row(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(4.dp)) { + Label("recalled ", Modifier.width(64.dp), color = Rk.c.inkDim) + Label(marked(r.recalled, r.firstDiff.takeIf { !r.recallSolves }), style = Rk.t.mono) } - GroupBox("Buffers") { - for (t in listOf(PieceType.CORNER, PieceType.EDGE)) { - val buf = setup.buffer(t) - Label("${if (t == PieceType.CORNER) "Corner" else "Edge"} buffer: ${setup.scheme.letter(t, buf)} (${Cube3.stickerName(t, buf)})", style = Rk.t.body) - Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(bottom = 6.dp)) { - val options = if (t == PieceType.CORNER) listOf(0, 2, 1, 3) else listOf(1, 2, 0, 3) - for (p in options) RetroButton( - Cube3.pieceName(t, p), { bld.updateSetup(if (t == PieceType.CORNER) setup.copy(cornerBuffer = p) else setup.copy(edgeBuffer = p)) }, - selected = Cube3.pieceId(t, p) == Cube3.pieceId(t, buf), - ) - } - } - Label("Method: Old Pochmann corners (helper RDF/P) and OP edges (T/Ja/Jb), R-perm parity. M2 support comes with the solve analyser.", color = Rk.c.inkDim, style = Rk.t.label) + val verdict = when { + r.recallSolves && tm == null -> "Traced and recalled correctly." + r.recallSolves -> "What you recalled works, though the trace you typed first had a slip." + tm == null -> "The trace was right. The recall drifts from it at target ${(r.firstDiff ?: 0) + 1}." + tm.index >= r.traced.length -> "The trace stops early" + (tm.expected?.let { ": the piece in the buffer still wanted ${letter(it)}." } ?: ", with pieces still unsolved.") + tm.brokeIntoSolved -> "Target ${tm.index + 1} breaks into a piece that was already solved." + else -> "Target ${tm.index + 1} should have been ${tm.expected?.let { letter(it) }}." } - } - } -} - -private fun assign(s: LetterScheme, type: PieceType, pos: Int, letter: Char): LetterScheme { - val str = (if (type == PieceType.CORNER) s.corners else s.edges).toCharArray() - val old = str[pos] - val clash = str.indexOf(letter) - if (clash >= 0) str[clash] = old - str[pos] = letter - return if (type == PieceType.CORNER) s.copy(corners = str.concatToString()) else s.copy(edges = str.concatToString()) -} - -/** Flat net (U on top; L F R B; D below) showing every sticker's letter, coloured as you hold the cube. */ -@Composable -private fun LetterNet(setup: BldSetup, editing: Pair<PieceType, Int>?, onPick: (Pair<PieceType, Int>) -> Unit) { - val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } - val solved = setup.solvedColors - val cell = 38.dp - // face → (column, row) on the net, in faces U R F D L B - val at = listOf(1 to 0, 2 to 1, 1 to 1, 1 to 2, 0 to 1, 3 to 1) - Column { - for (netRow in 0 until 9) Row { - for (netCol in 0 until 12) { - val face = at.indexOfFirst { (c, r) -> c == netCol / 3 && r == netRow / 3 } - if (face < 0) { Spacer(Modifier.size(cell)); continue } - val slot = face * 9 + (netRow % 3) * 3 + netCol % 3 - val target = Cube3.positionOf(PieceType.CORNER, slot)?.let { PieceType.CORNER to it } - ?: Cube3.positionOf(PieceType.EDGE, slot)?.let { PieceType.EDGE to it } - val color = colors[solved[slot]] - val sel = target != null && target == editing - val isBuffer = target != null && Cube3.pieceId(target.first, target.second) == Cube3.pieceId(target.first, setup.buffer(target.first)) - Box( - Modifier.size(cell).padding(1.dp).background(Color(0xFF14181C)).padding(2.dp).background(color) - .then(if (sel) Modifier.outline(Color(0xFFFF2BD6)) else Modifier) - .then(if (target != null) Modifier.clickable { onPick(target) } else Modifier) - .semantics { if (target != null) contentDescription = "${Cube3.stickerName(target.first, target.second)} ${setup.scheme.letter(target.first, target.second)}" }, - Alignment.Center, - ) { - if (target != null) BasicText( - "${setup.scheme.letter(target.first, target.second)}", - style = Rk.t.mono.copy(fontSize = 22.sp, color = if (isBuffer) Color(0xFF888888) else Color.Black), - ) - } + Column(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(4.dp)) { + Label(verdict, color = if (r.recallSolves) Rk.c.ink else Rk.c.bad) + if (!r.recallSolves && r.leftWrong.isNotEmpty()) Label("Executed as recalled, this leaves ${r.leftWrong.joinToString(", ")} wrong.", color = Rk.c.inkDim, style = Rk.t.label) + for (t in r.typos) Label(t, color = Rk.c.gold, style = Rk.t.label) } } } + if (state != null) Label( + if (tracer.hasParity(tracer.trace(state, PieceType.CORNER))) "Odd number of targets: this one has parity." else "No parity on this one.", + color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic), + ) } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/CubeNet.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/CubeNet.kt new file mode 100644 index 0000000..9d3310b --- /dev/null +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/CubeNet.kt @@ -0,0 +1,67 @@ +package coffee.liz.rookery.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coffee.liz.rookery.model.Puzzle +import coffee.liz.rookery.puzzle.Puzzles + +/** + * An unfolded 3x3 (U on top; L F R B; D below) where every sticker is visible at once. + * [colors] is a colour index per sticker slot. [text] labels stickers; [marks] are emphasised + * stickers (drawn at full strength with a pink ring) and, when [dim], everything else fades back. + */ +@Composable +fun CubeNet( + colors: IntArray, + cell: Dp, + modifier: Modifier = Modifier, + text: (Int) -> String? = { null }, + marks: Map<Int, String> = emptyMap(), + dim: Boolean = false, + selected: Int? = null, + describe: (Int) -> String? = { null }, + onPick: ((Int) -> Unit)? = null, +) { + val palette = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } + val at = listOf(1 to 0, 2 to 1, 1 to 1, 1 to 2, 0 to 1, 3 to 1) // U R F D L B → (column, row) of the face + val fontSize = with(LocalDensity.current) { (cell * 0.56f).toSp() } + Column(modifier) { + for (netRow in 0 until 9) Row { + for (netCol in 0 until 12) { + val face = at.indexOfFirst { (c, r) -> c == netCol / 3 && r == netRow / 3 } + if (face < 0) { Spacer(Modifier.size(cell)); continue } + val slot = face * 9 + (netRow % 3) * 3 + netCol % 3 + val marked = slot in marks || slot == selected + val color = palette[colors[slot]].let { if (dim && !marked) it.copy(alpha = 0.42f) else it } + val label = marks[slot] ?: text(slot) + Box( + Modifier.size(cell).padding(0.5.dp).background(Color(0xFF14181C)).padding(1.dp).background(color) + .then(if (marked) Modifier.outline(Color(0xFFFF69B4), 2.dp) else Modifier) + .then(if (onPick != null) Modifier.clickable { onPick(slot) } else Modifier) + .semantics { describe(slot)?.let { contentDescription = it } }, + Alignment.Center, + ) { + if (label != null) BasicText(label, style = Rk.t.mono.copy(fontSize = fontSize, fontWeight = if (slot in marks) FontWeight.Bold else FontWeight.Normal, color = Color.Black.copy(alpha = if (dim && !marked) 0.3f else 1f))) + } + } + } + } +} diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt index b861331..da8bb04 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt @@ -1,19 +1,17 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -21,35 +19,35 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coffee.liz.rookery.bld.Cube3 +import coffee.liz.rookery.bld.LetterScheme +import coffee.liz.rookery.bld.PieceType 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) - } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).widthIn(max = 760.dp)) { + Heading("bring your data") + Label("Importing the same file twice is harmless; whatever is already here gets skipped.") + Gap() + Row(verticalAlignment = Alignment.CenterVertically) { + RetroButton("a csTimer export", { m.importCsTimer() }) + Label(" the .txt that csTimer's “export to file” gives you.", 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 }, - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + RetroButton("a Letter Pair Gym export", { m.importLetterPairGym() }) + Label(" the .zip: words, notes, pictures, history, practice stats.", color = Rk.c.inkDim) + } + if (m.importLog.isNotEmpty()) { + Hr() + for (line in m.importLog) Label( + line, style = Rk.t.mono.copy(fontSize = 13.sp), + color = when { "failed" in line -> Rk.c.bad; "warning" in line -> Rk.c.gold; line.startsWith(" ") -> Rk.c.ink; else -> Rk.c.heading }, + ) } } } @@ -59,54 +57,108 @@ 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() }) + Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(28.dp)) { + Column(Modifier.weight(1f).widthIn(max = 560.dp)) { + Heading("timer") + Checkbox("inspect first (15 seconds; +2 past 15, DNF past 17)", m.inspection, m::changeInspection) + Checkbox("make noise (pips at 8 and 12 seconds, a tune for a PB)", m.sound, m::changeSound) + Row { + RetroButton("hear the PB tune", { m.testChime() }) + Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) + RetroButton("see the PB 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) + Hr() + Heading("looks") + Row(verticalAlignment = Alignment.CenterVertically) { + Label("page: ", color = Rk.c.inkDim) + Tabs(ThemeMode.entries, m.themeMode, { when (it) { ThemeMode.SYSTEM -> "whatever the computer says"; ThemeMode.LIGHT -> "paper"; ThemeMode.DARK -> "night" } }, { m.setTheme(it) }) } - 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)) + Checkbox("snow", m.snow, m::changeSnow) + Checkbox("hold still (no snow, no marquee, a penguin that stays put)", m.reducedMotion, m::changeReducedMotion) + Hr() + Heading("syncing") + Label("Everything lives in one folder, as a log per device that only that device writes. Share the folder with Syncthing (or Drive, or a USB stick) and the devices merge on their own.") + Gap(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) + RetroField(syncPath, { syncPath = it }, Modifier.weight(1f), style = Rk.t.mono.copy(fontSize = 13.sp), description = "sync folder") + Spacer(Modifier.width(6.dp)) + RetroButton("browse", { scope.launch { m.host.pickFolder("Sync folder")?.let { syncPath = it } } }) + RetroButton("use it", { 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)) { + if (moved) Label("Noted. Restart rookery to switch folders.", color = Rk.c.gold) + Hr() + Heading("pictures") + Label( + if (m.host.braveApiKey != null) "Brave image search is set up on this machine." + else "No Brave Search key on this machine. Put braveApiKey=… in ~/.config/rookery/config.properties and restart.", + color = if (m.host.braveApiKey != null) Rk.c.ink else Rk.c.gold, + ) + Hr() + Heading("smart cubes") 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) - } + if (devices.isEmpty()) Label("None known yet.", color = Rk.c.inkDim) + else Table { + Row { HeaderCell("name", 2f); HeaderCell("address", 2f); HeaderCell("speaks", 1f) } + for (d in devices) Row { Cell(d.name, 2f, style = Rk.t.body); Cell(d.mac, 2f, style = Rk.t.mono.copy(fontSize = 13.sp)); Cell(d.protocol, 1f, style = Rk.t.body) } } } - 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) + Column(Modifier.weight(1f).widthIn(max = 560.dp)) { BldSetup(m) } + } +} + +/** How you hold the cube, where your buffers are, and what every sticker is called. */ +@Composable +private fun BldSetup(m: AppModel) { + val bld = m.bld + val setup = bld.setup + var editing by remember { mutableStateOf<Pair<PieceType, Int>?>(null) } + Heading("blindfolded") + Row(verticalAlignment = Alignment.CenterVertically) { + Label("you hold it: ", color = Rk.c.inkDim) + val presets = listOf("yellow up, orange front" to "x2 y'", "white up, green front" to "", "yellow up, green front" to "z2", "white up, orange front" to "y'") + Tabs(presets, presets.firstOrNull { it.second == setup.rotation } ?: presets[0], { it.first }, { bld.updateSetup(setup.copy(rotation = it.second)) }, style = Rk.t.label) + } + for (t in listOf(PieceType.CORNER, PieceType.EDGE)) Row(verticalAlignment = Alignment.CenterVertically) { + Label("${if (t == PieceType.CORNER) "corner" else "edge"} buffer: ", color = Rk.c.inkDim) + val options = if (t == PieceType.CORNER) listOf(0, 2, 1, 3) else listOf(1, 2, 0, 3) + val current = options.firstOrNull { Cube3.pieceId(t, it) == Cube3.pieceId(t, setup.buffer(t)) } ?: options[0] + Tabs(options, current, { Cube3.pieceName(t, it) }, { bld.updateSetup(if (t == PieceType.CORNER) setup.copy(cornerBuffer = it) else setup.copy(edgeBuffer = it)) }) + } + Label("Old Pochmann corners and edges for now. M2 arrives with the solve analyser.", color = Rk.c.inkDim, style = Rk.t.label) + Gap() + Label("Lettering. Click a sticker and type its new letter; the two letters trade places.", color = Rk.c.inkDim) + Gap(6.dp) + CubeNet( + setup.solvedColors, 34.dp, + text = { slot -> target(slot)?.let { (t, pos) -> setup.scheme.letter(t, pos).toString() } }, + selected = editing?.let { Cube3.slotOf(it.first, it.second) }, + describe = { slot -> target(slot)?.let { (t, pos) -> "${Cube3.stickerName(t, pos)} ${setup.scheme.letter(t, pos)}" } }, + onPick = { slot -> editing = target(slot) }, + ) + editing?.let { (type, pos) -> + var v by remember(editing) { mutableStateOf("") } + val focus = remember { FocusRequester() } + LaunchedEffect(editing) { runCatching { focus.requestFocus() } } + Row(verticalAlignment = Alignment.CenterVertically) { + Label("${Cube3.stickerName(type, pos)} is ${setup.scheme.letter(type, pos)}. call it ") + RetroField(v, { nv -> + val c = nv.uppercase().filter { it.isLetter() }.take(1) + v = c + if (c.isNotEmpty()) { bld.updateSetup(setup.copy(scheme = assign(setup.scheme, type, pos, c[0]))); editing = null } + }, Modifier.width(48.dp).focusRequester(focus), placeholder = "?", style = Rk.t.mono.copy(fontSize = 18.sp)) } } + RetroButton("back to plain Speffz", { bld.updateSetup(setup.copy(scheme = LetterScheme())) }) +} + +private fun target(slot: Int): Pair<PieceType, Int>? = + Cube3.positionOf(PieceType.CORNER, slot)?.let { PieceType.CORNER to it } ?: Cube3.positionOf(PieceType.EDGE, slot)?.let { PieceType.EDGE to it } + +private fun assign(s: LetterScheme, type: PieceType, pos: Int, letter: Char): LetterScheme { + val str = (if (type == PieceType.CORNER) s.corners else s.edges).toCharArray() + val old = str[pos] + val clash = str.indexOf(letter) + if (clash >= 0) str[clash] = old + str[pos] = letter + return if (type == PieceType.CORNER) s.copy(corners = str.concatToString()) else s.copy(edges = str.concatToString()) } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt index 8bcd47b..f5a1e16 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt @@ -7,6 +7,8 @@ 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.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 @@ -29,12 +31,12 @@ 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.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -45,32 +47,34 @@ import org.jetbrains.compose.resources.decodeToImageBitmap const val LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWX" @Composable +fun decodeImage(bytes: ByteArray?): ImageBitmap? = remember(bytes) { bytes?.let { runCatching { it.decodeToImageBitmap() }.getOrNull() } } + +@Composable +fun pairImage(m: AppModel, blob: String?): ImageBitmap? = decodeImage(remember(blob) { blob?.let { m.blob(it) } }) + +/** Every pair in one table, a two-letter lookup, and the editor for whichever pair is open. */ +@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() } + LaunchedEffect(Unit) { runCatching { 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) - } + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Column(Modifier.weight(1f).fillMaxHeight()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Label("look one up: ") + RetroField(query, { query = it.take(2) }, Modifier.width(72.dp).focusRequester(focus), placeholder = "AB", style = Rk.t.mono.copy(fontSize = 22.sp), description = "type a letter pair") + val n = m.derived.pairs.values.count { it.primary != null } + Label(" $n of ${LETTERS.length * (LETTERS.length - 1)} have a word. pinker means you've been missing it.", color = Rk.c.inkDim, style = Rk.t.label) } - PairGrid(m.derived.pairs, shown) { selected = it; query = "" } + Gap(6.dp) + PairGrid(m.derived.pairs, shown) { selected = it; query = ""; m.clearImageSearch() } } - 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) } + Column(Modifier.width(400.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { + if (shown != null) PairCard(m, shown, m.derived.pairs[shown]) else Label("Pick a cell, or type a pair.", color = Rk.c.inkDim) } } } @@ -78,45 +82,36 @@ fun PairsScreen(m: AppModel) { @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), - ) - } + val cellW = 78.dp + val cellH = 22.dp + Box(Modifier.fillMaxSize().edge(p, sunk = false).padding(3.dp).verticalScroll(rememberScrollState()).horizontalScroll(rememberScrollState())) { + Column { + Row { + Spacer(Modifier.size(24.dp, cellH)) + for (b in LETTERS) Box(Modifier.size(cellW, cellH), Alignment.Center) { BasicText("$b", style = Rk.t.heading.copy(color = p.heading)) } + } + for (a in LETTERS) Row { + Box(Modifier.size(24.dp, cellH), Alignment.Center) { BasicText("$a", style = Rk.t.heading.copy(color = p.heading)) } + for (b in LETTERS) { + val key = "$a$b" + val lp = pairs[key] + val sel = key == selected + Box( + Modifier.size(cellW, cellH).padding(1.dp) + .then(when { + a == b -> Modifier + sel -> Modifier.background(p.select) + else -> Modifier.background(p.accent.copy(alpha = ((lp?.difficulty ?: 0.0) * 0.5).toFloat())) + }) + .then(if (a != b) Modifier.edge(p, sunk = true).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.label.copy(fontSize = 12.sp, color = if (sel) p.selectInk else p.ink), + ) } } } @@ -124,57 +119,85 @@ private fun PairGrid(pairs: Map<String, LetterPair>, selected: String?, onPick: } } -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)) -} - +@OptIn(ExperimentalLayoutApi::class) @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) { + var finding by remember(pair) { mutableStateOf(false) } + var query by remember(pair, lp?.primary) { mutableStateOf(lp?.primary.orEmpty()) } + val image = pairImage(m, lp?.image) + val p = Rk.c + + Table(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().edge(p, sunk = true).padding(6.dp), verticalAlignment = Alignment.CenterVertically) { + BasicText(pair, style = Rk.t.mono.copy(fontSize = 40.sp, fontWeight = FontWeight.Bold, color = p.heading)) + Spacer(Modifier.width(14.dp)) + Column { + BasicText(lp?.primary ?: "no word yet", style = Rk.t.h1.copy(fontSize = 26.sp, color = if (lp?.primary == null) p.inkDim else p.ink)) + lp?.note?.let { Label(it, color = p.inkDim, style = Rk.t.body.copy(fontStyle = FontStyle.Italic)) } + } + } + Box(Modifier.fillMaxWidth().height(230.dp).edge(p, sunk = 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) + else Label("no picture", color = p.inkDim, style = Rk.t.label) + } + Row(Modifier.fillMaxWidth().edge(p, sunk = true).padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Label("picture: ", color = p.inkDim) + RetroButton("find one", { finding = !finding; if (finding && query.isNotBlank()) m.searchImages(query) else m.clearImageSearch() }, selected = finding) + Label(" · ", color = p.inkDim) + RetroButton("from a file", { m.setPairImageFromFile(pair) }) + if (lp?.image != null) { Label(" · ", color = p.inkDim); RetroButton("take it off", { m.repo.setPairImage(pair, null) }) } + } + if (finding) Column(Modifier.fillMaxWidth().edge(p, sunk = true).padding(6.dp)) { + if (m.host.braveApiKey == null) Label("Image search needs a Brave Search key on this machine (see settings).", color = p.gold) + else { + Row(verticalAlignment = Alignment.CenterVertically) { + RetroField(query, { query = it }, Modifier.weight(1f), placeholder = "search for…", onSubmit = { m.searchImages(query) }, description = "image search") + RetroButton("search", { m.searchImages(query) }, enabled = query.isNotBlank()) + } + if (m.imageStatus.isNotEmpty()) Label(m.imageStatus, color = p.inkDim, style = Rk.t.label) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) { + for (hit in m.imageHits) { + val thumb = decodeImage(m.thumbs[hit.thumbnail]) + Box( + Modifier.size(120.dp, 96.dp).background(p.field).edge(p, sunk = true).clickable(enabled = thumb != null) { m.useImage(pair, hit) } + .semantics { contentDescription = "use picture: ${hit.title}" }, + Alignment.Center, + ) { + if (thumb != null) Image(thumb, null, Modifier.fillMaxSize().padding(2.dp), contentScale = ContentScale.Fit) + else Label("…", color = p.inkDim) + } + } + } + if (m.imageHits.isNotEmpty()) Label("click one to keep it.", color = p.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) + Column(Modifier.fillMaxWidth().edge(p, sunk = true).padding(6.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { + Label("word ", Modifier.width(48.dp), color = p.inkDim) 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) }) + RetroButton("keep", { m.setPrimaryWord(pair, word) }, enabled = word.isNotBlank() && word.trim() != lp?.primary) } - Label("Note", style = Rk.t.label) + Gap(4.dp) Row(verticalAlignment = Alignment.CenterVertically) { + Label("note ", Modifier.width(48.dp), color = p.inkDim) 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) }) + RetroButton("keep", { m.setPairNote(pair, note) }, enabled = note.trim() != lp?.note.orEmpty()) } - Label("Other words", style = Rk.t.label) - for (w in lp?.words.orEmpty().filter { it != lp?.primary }.sorted()) Row(verticalAlignment = Alignment.CenterVertically) { + val others = lp?.words.orEmpty().filter { it != lp?.primary }.sorted() + Gap(4.dp) + Label(if (others.isEmpty()) "no spare words." else "spare words:", color = p.inkDim) + for (w in others) 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") + RetroButton("promote", { m.setPrimaryWord(pair, w) }, description = "make $w the main word") + RetroButton("drop", { 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 = "" }) + RetroField(alt, { alt = it }, Modifier.weight(1f), placeholder = "another word…", onSubmit = { m.addWord(pair, alt); alt = "" }) + RetroButton("add", { m.addWord(pair, alt); alt = "" }, enabled = alt.isNotBlank()) } - 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 index 4da7994..32d8141 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt @@ -4,13 +4,18 @@ 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.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth 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.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -19,14 +24,23 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds 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.Path import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -36,39 +50,39 @@ 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.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.roundToInt +import kotlin.math.sin 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. - */ +/** The penguin from liz.coffee: an 8x4 sheet of 32px frames in the oneko.js layout, as (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); + 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); val frames: List<Pair<Int, Int>> = cells.toList() } -const val SPRITE = 32 +private 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) { +/** Draw one frame [sizePx] pixels square, nearest-neighbour so the pixels stay crisp at any density. */ +fun DrawScope.drawPenguin(sheet: ImageBitmap, cell: Pair<Int, Int>, at: Offset, sizePx: Float, alpha: Float = 1f) { 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, + srcOffset = IntOffset(cell.first * SPRITE, cell.second * SPRITE), srcSize = IntSize(SPRITE, SPRITE), + dstOffset = IntOffset(at.x.roundToInt(), at.y.roundToInt()), dstSize = IntSize(sizePx.roundToInt(), sizePx.roundToInt()), + alpha = alpha, filterQuality = FilterQuality.None, ) } -/** A single animated penguin. Static first frame when [reducedMotion]. */ +/** A single penguin doing one thing. */ @Composable fun Penguin(anim: PenguinAnim, size: Dp = 64.dp, reducedMotion: Boolean = false, modifier: Modifier = Modifier, frameMs: Long = 220) { val sheet = penguinSheet() @@ -78,98 +92,224 @@ fun Penguin(anim: PenguinAnim, size: Dp = 64.dp, reducedMotion: Boolean = false, 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) + drawPenguin(sheet, anim.frames[frame % anim.frames.size], Offset.Zero, this.size.width) } } -/** What the mascot is up to, driven by the timer. */ -enum class Mood { IDLE, WATCHING, READY, SOLVING, HAPPY, SAD, SLEEPY } +/** What the penguin is reacting to. */ +enum class Mood { FREE, WATCHING, READY, SOLVING, HAPPY, SAD } -/** The mascot wanders along a strip, reacting to [mood]. */ +/** + * The penguin lives in the window the way it lives on liz.coffee: it waddles after your pointer, stops + * a polite distance away, pecks around when you hold still, and falls asleep if you leave it long + * enough. While a solve is on it stands still and watches, and it says a word when you finish. + */ @Composable -fun MascotStrip(mood: Mood, reducedMotion: Boolean, modifier: Modifier = Modifier, scale: Float = 2f) { +fun PenguinFriend(pointer: Offset?, mood: Mood, says: String?, reducedMotion: Boolean, size: Dp = 64.dp) { 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 + val density = LocalDensity.current + val sizePx = with(density) { size.toPx() } + BoxWithConstraints(Modifier.fillMaxSize().clearAndSetSemantics { }) { + val w = constraints.maxWidth.toFloat(); val h = constraints.maxHeight.toFloat() + val home = Offset(sizePx * 0.9f, h - sizePx * 0.8f) + var pos by remember { mutableStateOf(home) } + var anim by remember { mutableStateOf(PenguinAnim.IDLE) } + var clock by remember { mutableLongStateOf(0) } + LaunchedEffect(mood, reducedMotion, pointer == null, w, h) { + var last = withFrameMillis { it } + var still = 0L // ms spent standing + var startled = 0L // ms left of the "!" before walking + var pecking = 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 + val now = withFrameMillis { it } + val dt = (now - last).coerceAtMost(100); last = now; clock = now + if (reducedMotion) { + pos = home + anim = when (mood) { Mood.READY, Mood.HAPPY -> PenguinAnim.ALERT; Mood.SAD -> PenguinAnim.TIRED; else -> PenguinAnim.IDLE } + continue + } + when (mood) { + Mood.WATCHING, Mood.SOLVING -> { anim = PenguinAnim.IDLE; still = 0; continue } + Mood.READY -> { anim = PenguinAnim.ALERT; still = 0; continue } + else -> {} + } + // stand below-right of the pointer, like a pet at your heel + val target = (pointer?.let { it + Offset(sizePx * 0.7f, sizePx * 0.6f) } ?: home) + .let { Offset(it.x.coerceIn(sizePx / 2, w - sizePx / 2), it.y.coerceIn(sizePx / 2, h - sizePx / 2)) } + val dx = target.x - pos.x; val dy = target.y - pos.y + val dist = hypot(dx, dy) + if (dist < sizePx * 0.35f) { + still += dt; startled = 0 + anim = when { + mood == Mood.HAPPY -> if ((now / 160) % 2 == 0L) PenguinAnim.ALERT else PenguinAnim.IDLE + mood == Mood.SAD -> PenguinAnim.TIRED + still > 25_000 -> PenguinAnim.SLEEP + pecking > 0 -> { pecking -= dt; PenguinAnim.PECK } + else -> { if (still > 3_000 && Random.nextInt(500) == 0) pecking = 1_400; PenguinAnim.IDLE } } - x = x.coerceIn(0f, maxX) + } else { + if (still > 600 && startled == 0L) startled = 260 // notice the pointer before chasing it + still = 0; pecking = 0 + if (startled > 0) { startled = (startled - dt).coerceAtLeast(1).let { if (it <= 1) -1 else it }; anim = PenguinAnim.ALERT; continue } + val step = minOf(dist, sizePx * 3.2f * dt / 1000f) + pos = Offset(pos.x + dx / dist * step, pos.y + dy / dist * step) + val dirs = listOf(PenguinAnim.E, PenguinAnim.SE, PenguinAnim.S, PenguinAnim.SW, PenguinAnim.W, PenguinAnim.NW, PenguinAnim.N, PenguinAnim.NE) + anim = dirs[(((atan2(dy, dx) / (PI / 4)).roundToInt() % 8) + 8) % 8] } } } - 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 frameMs = if (anim == PenguinAnim.SLEEP) 700 else 170 + Canvas(Modifier.fillMaxSize()) { + drawPenguin(sheet, anim.frames[((clock / frameMs) % anim.frames.size).toInt()], Offset(pos.x - sizePx / 2, pos.y - sizePx / 2), sizePx) + } + if (says != null) { + val p = Rk.c + BasicText( + says, + Modifier.offset { IntOffset((pos.x + sizePx * 0.35f).roundToInt().coerceAtMost((w - sizePx * 2.2f).roundToInt()), (pos.y - sizePx * 0.95f).roundToInt().coerceAtLeast(0)) } + .background(p.bg).edge(p, sunk = false).padding(horizontal = 8.dp, vertical = 3.dp), + style = Rk.t.body.copy(color = p.ink, fontStyle = FontStyle.Italic), + ) + } + } +} + +// ---------------------------------------------------------------- snow + +/** Outline of a Koch snowflake with unit circumradius. */ +private fun kochPoints(depth: Int): List<Offset> { + fun sub(a: Offset, b: Offset, d: Int, out: MutableList<Offset>) { + if (d == 0) { out += a; return } + val v = (b - a) / 3f + val p1 = a + v; val p3 = a + v * 2f + val peak = p1 + Offset((v.x * 0.5f + v.y * 0.8660254f), (-v.x * 0.8660254f + v.y * 0.5f)) + sub(a, p1, d - 1, out); sub(p1, peak, d - 1, out); sub(peak, p3, d - 1, out); sub(p3, b, d - 1, out) + } + val tri = (0 until 3).map { Offset(cos(-PI / 2 + it * 2 * PI / 3).toFloat(), sin(-PI / 2 + it * 2 * PI / 3).toFloat()) } + return buildList { for (i in 0 until 3) sub(tri[i], tri[(i + 2) % 3], depth, this) } +} + +/** Koch snowflakes drifting down behind everything, as on the site. Purely decorative. */ +@Composable +fun Snow(color: Color, modifier: Modifier = Modifier, flakes: Int = 34) { + class Flake(var x: Float, var y: Float, val size: Float, val fall: Float, val sway: Float, val phase: Float, val spin: Float, val alpha: Float) + val all = remember { + val sizes = floatArrayOf(3f, 6f, 9f, 13f, 18f) + List(flakes) { + val s = sizes[Random.nextInt(sizes.size)] + Flake(Random.nextFloat(), Random.nextFloat(), s, 0.012f + s * 0.0022f + Random.nextFloat() * 0.01f, 6f + Random.nextFloat() * 14f, Random.nextFloat() * 6.28f, (Random.nextFloat() - 0.5f) * 40f, 0.25f + Random.nextFloat() * 0.5f) + } + } + val outline = remember { Path().apply { val pts = kochPoints(2); moveTo(pts[0].x, pts[0].y); pts.drop(1).forEach { lineTo(it.x, it.y) }; close() } } + var t by remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + var last = withFrameMillis { it } + while (true) { + delay(33) // ~30fps is plenty for snow + val now = withFrameMillis { it } + val dt = (now - last).coerceAtMost(100) / 1000f; last = now + for (f in all) { f.y += f.fall * dt; if (f.y > 1.05f) { f.y = -0.05f; f.x = Random.nextFloat() } } + t += dt + } + } + Canvas(modifier.clearAndSetSemantics { }) { + val time = t + for (f in all) { + val r = f.size.dp.toPx() / 2 + val x = f.x * size.width + sin(time * 0.6f + f.phase) * f.sway.dp.toPx() + translate(x, f.y * size.height) { + rotate(time * f.spin + f.phase * 57f, pivot = Offset.Zero) { + drawContext.transform.scale(r, r, Offset.Zero) + drawPath(outline, color.copy(alpha = f.alpha * 0.22f)) + drawPath(outline, color.copy(alpha = f.alpha), style = Stroke(0.09f)) + } + } } - 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) + } +} + +// ---------------------------------------------------------------- marquee + +/** `<marquee>`. Take it away and she will find you. Stands still when motion is reduced. */ +@Composable +fun Marquee(text: String, reducedMotion: Boolean, modifier: Modifier = Modifier, color: Color? = null) { + var textW by remember(text) { mutableIntStateOf(0) } + var x by remember(text) { mutableFloatStateOf(Float.NaN) } + BoxWithConstraints(modifier.fillMaxWidth().clipToBounds()) { + val w = constraints.maxWidth.toFloat() + val speed = with(LocalDensity.current) { 48.dp.toPx() } + LaunchedEffect(text, reducedMotion, w) { + if (reducedMotion) { x = 0f; return@LaunchedEffect } + if (x.isNaN()) x = w + var last = withFrameMillis { it } + while (true) { + val now = withFrameMillis { it } + x -= speed * (now - last).coerceAtMost(100) / 1000f; last = now + if (textW > 0 && x < -textW) x = w + } } + BasicText( + text, + Modifier.wrapContentWidth(Alignment.Start, unbounded = true).onSizeChanged { textW = it.width } + .offset { IntOffset(if (x.isNaN()) w.roundToInt() else x.roundToInt(), 0) }, + style = Rk.t.body.copy(color = color ?: Rk.c.ink), maxLines = 1, softWrap = false, + ) } } -/** Full-screen PB celebration: penguins rain down with pixel confetti and a blinking banner. */ +/** Colony news in the manner of the site's generated penguin paragraph. */ +fun penguinNews(random: Random = Random.Default, sentences: Int = 4): String { + val subject = listOf("Some windswept colony", "One damp chick", "A well-loved adelie creche", "One fluffy raft", "The flipper", "One beak", "A suspicious gentoo", "Every macaroni penguin", "One penguin", "The smallest rockhopper", "A committee of emperors") + val adverb = listOf("smugly", "quietly", "sideways", "twice", "suspiciously", "almost", "ceremonially", "against advice", "for no reason") + val verb = listOf("polishes", "guards", "toboggans past", "hoards", "counts", "pecks at", "dives for", "times", "scrambles", "inspects", "memorises") + val thing = listOf("three krill", "a shiny fish", "a spare flipper", "the nest", "someone's chick", "a 3x3", "the last pebble", "an ao5", "a stackmat", "a lettering scheme", "the T-perm") + val where = listOf("beneath the waves", "during the blizzard", "on the ice shelf", "before inspection ends", "in under fifteen seconds", "blindfolded", "") + val noise = listOf("Squawk.", "Waddle.", "Waddle.", "Honk.", "") + return (1..sentences).joinToString(" ") { + listOf(subject.random(random), adverb.random(random), verb.random(random), thing.random(random), where.random(random)).filter { it.isNotEmpty() }.joinToString(" ") + ". " + noise.random(random) + }.replace(Regex(" +"), " ").trim() +} + +// ---------------------------------------------------------------- pb party + +/** A new personal best: it snows penguins for a few seconds under a `<blink>`ing notice. */ @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 unit = with(LocalDensity.current) { 1.dp.toPx() } + class Bit(val x: Float, val y: Float, val vx: Float, val vy: Float, val penguin: Boolean, val color: Color, val anim: PenguinAnim, val size: Float) + val colors = listOf(p.accent, p.lavender, p.link, p.gold, p.ink) val bits = remember { - List(70) { i -> + List(80) { 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)) + Bit(Random.nextFloat(), 0.1f - Random.nextFloat() * 0.9f, (Random.nextFloat() - 0.5f) * 0.00005f, 0.00028f + Random.nextFloat() * 0.0004f, + penguin, colors.random(), listOf(PenguinAnim.S, PenguinAnim.ALERT, PenguinAnim.SE, PenguinAnim.SW).random(), if (penguin) 64f else 5f + 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 } + t = withFrameMillis { it } - start + if (t > 4500) { 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 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 * unit) + else drawRect(b.color, Offset(x, y), Size(b.size * unit, b.size * unit)) } } - 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)) + val blinkOn = reducedMotion || (t / 350) % 2 == 0L + Box(Modifier.align(Alignment.Center).background(p.bg)) { + Table { + Column(Modifier.edge(p, sunk = true).padding(horizontal = 36.dp, vertical = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) { + BasicText(title, style = Rk.t.h1.copy(fontSize = 40.sp, color = if (blinkOn) p.accent else p.bg)) + BasicText(detail, Modifier.padding(top = 8.dp), style = Rk.t.mono.copy(fontSize = 24.sp, color = p.ink)) + } } } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt index f2a8b67..c979239 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -17,6 +18,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import coffee.liz.rookery.puzzle.Polygon import coffee.liz.rookery.puzzle.PuzzleState import coffee.liz.rookery.puzzle.Turn @@ -29,15 +31,50 @@ 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) +/** + * Turntable camera: [yaw] about the vertical, then [pitch] towards the viewer, in degrees. "Up" always + * stays up, so you never lose track of the cube. Views are normally driven by the app ([animateTo]); + * dragging is only offered where a view opts in. + */ +class Camera(private val homeYaw: Float = -32f, private val homePitch: Float = 28f) { + var yaw by mutableFloatStateOf(homeYaw) + var pitch by mutableFloatStateOf(homePitch) + + fun look(yaw: Float, pitch: Float) { this.yaw = yaw; this.pitch = pitch } + fun reset() = look(homeYaw, homePitch) + + /** Glide to a view along the shorter way round. */ + suspend fun animateTo(toYaw: Float, toPitch: Float, ms: Int = 260) { + val y0 = yaw; val p0 = pitch + var dy = (toYaw - y0) % 360f + if (dy > 180f) dy -= 360f else if (dy < -180f) dy += 360f + if (ms <= 0 || (dy == 0f && toPitch == p0)) { look(y0 + dy, toPitch); return } + val start = withFrameMillis { it } + while (true) { + val t = ((withFrameMillis { it } - start).toFloat() / ms).coerceIn(0f, 1f) + val e = t * t * (3 - 2 * t) // smoothstep + yaw = y0 + dy * e; pitch = p0 + (toPitch - p0) * e + if (t >= 1f) break + } + } + + fun view(p: V3): V3 { + val y = yaw * PI / 180; val pt = pitch * PI / 180 + val x1 = p.x * cos(y) + p.z * sin(y) + val z1 = -p.x * sin(y) + p.z * cos(y) + return V3(x1, p.y * cos(pt) - z1 * sin(pt), p.y * sin(pt) + z1 * cos(pt)) + } +} + +/** Yaw/pitch that looks at a corner of the cube given by signs along x→R, y→U, z→F. */ +fun cornerView(corner: Triple<Int, Int, Int>): Pair<Float, Float> { + val yaw = if (corner.third > 0) (if (corner.first > 0) -40f else 40f) else (if (corner.first > 0) -140f else 140f) + return yaw to if (corner.second > 0) 30f else -30f } /** - * 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. + * Flat-shaded 3D puzzle on a Canvas (painter's algorithm): black plastic tiles with inset stickers + * under a soft key light. */ @Composable fun PuzzleView( @@ -47,42 +84,32 @@ fun PuzzleView( camera: Camera = remember { Camera() }, anim: TurnAnim? = null, description: String = "3D puzzle view", - /** Sticker index to outline, e.g. "what letter is this?" */ + /** Sticker index to outline. */ highlight: Int? = null, - highlightColor: Color = Color(0xFFFF2BD6), + highlightColor: Color = Color(0xFFFF69B4), /** Called with the front-most sticker under a tap/click. */ onStickerTap: ((Int) -> Unit)? = null, + /** Let the user spin it by dragging (left/right spins, up/down tilts). */ + draggable: Boolean = false, ) { val puzzle = state.puzzle val body = Color(0xFF14181C) - // Screen polygons from the last frame, front-most last, for hit testing taps. val hits = remember { ArrayList<Pair<Int, List<Offset>>>() } Canvas( modifier .then(if (onStickerTap == null) Modifier else Modifier.pointerInput(onStickerTap) { - detectTapGestures { pos -> - hits.lastOrNull { (_, poly) -> inside(pos, poly) }?.let { onStickerTap(it.first) } - } + detectTapGestures { pos -> hits.lastOrNull { (_, poly) -> inside(pos, poly) }?.let { onStickerTap(it.first) } } }) - .pointerInput(camera) { + .then(if (!draggable) Modifier else 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) + camera.yaw += drag.x / 2.dp.toPx() + camera.pitch = (camera.pitch + drag.y / 2.dp.toPx()).coerceIn(-80f, 80f) } - } + }) .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) - } + fun view(p: V3): V3 = camera.view(p) 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 @@ -92,11 +119,9 @@ fun PuzzleView( 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) + Triple(a.turn, axis.normal, -2 * PI * a.turn.amount / axis.order * a.progress) } class Drawn(val index: Int, val tile: Polygon, val color: Color, val depth: Double, val shade: Float) @@ -107,9 +132,8 @@ fun PuzzleView( 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 + if ((n dot (V3(0.0, 0.0, dist) - v.centroid)) <= 0) continue // back face val lambert = maxOf(0.0, n dot light).toFloat() drawn += Drawn(i, v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert) } @@ -120,14 +144,13 @@ fun PuzzleView( fill(tile, 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)) + drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1.dp.toPx())) hits += d.index to tile } if (highlight != null) drawn.firstOrNull { it.index == highlight }?.let { d -> val ring = d.tile.inset(0.97).points.map(::project) - drawPath(path(ring), Color.Black, style = Stroke(7f)) - drawPath(path(ring), highlightColor, style = Stroke(4f)) + drawPath(path(ring), Color.Black, style = Stroke(5.dp.toPx())) + drawPath(path(ring), highlightColor, style = Stroke(3.dp.toPx())) } } } @@ -144,16 +167,6 @@ private fun inside(p: Offset, poly: List<Offset>): Boolean { return c } -/** Camera angles that put a face of a cube (U R F D L B) in view, tilted a little for context. */ -fun cameraFacing(face: Int): Pair<Float, Float> = when (face) { - 0 -> -30f to 62f - 1 -> -68f to 22f - 2 -> -22f to 22f - 3 -> -30f to -62f - 4 -> 68f to 22f - else -> 158f to 22f -} - 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) diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt index 8fb3a18..2deaac7 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt @@ -3,34 +3,37 @@ 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.interaction.collectIsHoveredAsState 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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer 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.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.SolidColor @@ -39,64 +42,64 @@ 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.AnnotatedString +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay -/** 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)) +/** A 1px two-tone edge: raised like a `<table border=1>`, or sunk like one of its cells. */ +fun Modifier.edge(p: Palette, sunk: Boolean): Modifier = drawBehind { + val w = 1.dp.toPx() + val tl = if (sunk) p.borderDark else p.borderLight + val br = if (sunk) p.borderLight else p.borderDark + drawRect(tl, Offset.Zero, Size(size.width, w)); drawRect(tl, Offset.Zero, Size(w, size.height)) + drawRect(br, Offset(0f, size.height - w), Size(size.width, w)); drawRect(br, Offset(size.width - w, 0f), Size(w, size.height)) } -/** Win98-style dotted focus ring, so keyboard users always see where they are. */ +/** Dotted keyboard-focus outline in the cursor's pink. */ 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)))) + drawRect(color, style = Stroke(1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 2.dp.toPx())))) } +fun Modifier.outline(c: Color, width: Dp = 1.dp) = border(width, c) + +/** A bare `<table>`: raised outer border with cellspacing; put [Td]/[Th] or anything else inside. */ @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) +fun Table(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Column(modifier.edge(Rk.c, sunk = false).padding(3.dp), verticalArrangement = Arrangement.spacedBy(2.dp), content = content) } -/** A titled group box, like a <fieldset>. */ +/** A cell with content. [field] gives it the input background. */ @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() - } +fun Panel(modifier: Modifier = Modifier, sunken: Boolean = false, padding: PaddingValues = PaddingValues(8.dp), content: @Composable ColumnScope.() -> Unit) { + Column(modifier.then(if (sunken) Modifier.background(Rk.c.field) else Modifier).edge(Rk.c, sunk = true).padding(padding), content = content) } +/** A one-cell table with a `<th>` caption. */ @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() +fun GroupBox(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Table(modifier) { + Box(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(horizontal = 6.dp, vertical = 2.dp), Alignment.Center) { + BasicText(title, style = Rk.t.heading.copy(color = Rk.c.heading)) + } + Column(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(8.dp), content = content) } } +/** A hyperlink in brackets, the way the site writes its nav: `[ like this ]`. */ @Composable fun RetroButton( text: String, @@ -109,42 +112,39 @@ fun RetroButton( ) { val p = Rk.c val src = remember { MutableInteractionSource() } - val pressed by src.collectIsPressedAsState() + val hovered by src.collectIsHoveredAsState() val focused by src.collectIsFocusedAsState() - val sunken = pressed || selected - Box( + val base = style ?: Rk.t.body + val text2 = buildAnnotatedString { + withStyle(SpanStyle(color = p.inkDim)) { append("[") } + withStyle( + when { + !enabled -> SpanStyle(color = p.inkDim) + selected -> SpanStyle(color = p.accent, fontWeight = FontWeight.Bold) + else -> SpanStyle(color = if (hovered) p.accent else p.link, textDecoration = TextDecoration.Underline) + }, + ) { append(text) } + withStyle(SpanStyle(color = p.inkDim)) { append("]") } + } + BasicText( + text2, modifier - .background(if (selected) p.zebra else p.panel) - .bevel(p, sunken) - .focusRing(focused, p.ink) + .focusRing(focused, p.accent) .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) - } + .padding(horizontal = 2.dp, vertical = 4.dp), + style = base, maxLines = 1, + ) } -/** Tab strip; the selected tab merges into the panel below it. */ +/** A row of bracketed links separated by middle dots; the current one is pink and not a link. */ +@OptIn(ExperimentalLayoutApi::class) @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)) } +fun <T> Tabs(items: List<T>, selected: T, label: (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier, style: TextStyle? = null) { + FlowRow(modifier, verticalArrangement = Arrangement.Center) { + for ((i, item) in items.withIndex()) { + if (i > 0) BasicText(" · ", Modifier.padding(vertical = 4.dp), style = (style ?: Rk.t.body).copy(color = Rk.c.inkDim)) + RetroButton(label(item), { onSelect(item) }, selected = item == selected, style = style) } } } @@ -155,6 +155,17 @@ fun Label(text: String, modifier: Modifier = Modifier, color: Color? = null, sty } @Composable +fun Label(text: AnnotatedString, modifier: Modifier = Modifier, style: TextStyle? = null) { + BasicText(text, modifier, style = (style ?: Rk.t.body).copy(color = Rk.c.ink)) +} + +/** `<h2>`-ish: sage, bold. */ +@Composable +fun Heading(text: String, modifier: Modifier = Modifier) { + BasicText(text, modifier.padding(bottom = 4.dp), style = Rk.t.heading.copy(color = Rk.c.heading, fontSize = Rk.t.heading.fontSize * 1.2f)) +} + +@Composable fun RetroField( value: String, onValueChange: (String) -> Unit, @@ -164,16 +175,14 @@ fun RetroField( singleLine: Boolean = true, onSubmit: (() -> Unit)? = null, description: String? = null, - password: Boolean = false, ) { 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) + modifier.background(p.field).edge(p, sunk = true).padding(horizontal = 8.dp, vertical = 6.dp) .semantics { if (description != null) contentDescription = description }, - textStyle = st, singleLine = singleLine, cursorBrush = SolidColor(p.ink), - visualTransformation = if (password) androidx.compose.ui.text.input.PasswordVisualTransformation('•') else androidx.compose.ui.text.input.VisualTransformation.None, + textStyle = st, singleLine = singleLine, cursorBrush = SolidColor(p.accent), keyboardOptions = KeyboardOptions(imeAction = if (onSubmit != null) ImeAction.Done else ImeAction.Default), keyboardActions = KeyboardActions(onDone = { onSubmit?.invoke() }), decorationBox = { inner -> @@ -185,43 +194,76 @@ fun RetroField( ) } +/** + * A letters-only password field that behaves like a phone's: the letter you just typed shows for a + * moment, then becomes a dot. The caret position is exposed through [value] so callers can follow it. + */ +@Composable +fun SecretField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + style: TextStyle? = null, + onSubmit: (() -> Unit)? = null, + description: String? = null, +) { + val p = Rk.c + val st = (style ?: Rk.t.mono).copy(color = p.ink) + var reveal by remember { mutableIntStateOf(-1) } + LaunchedEffect(reveal, value.text) { if (reveal >= 0) { delay(900); reveal = -1 } } + BasicTextField( + value, + { nv -> + if (nv.text.all { it.isLetter() }) { + reveal = if (nv.text.length == value.text.length + 1) nv.selection.start - 1 else if (nv.text != value.text) -1 else reveal + onValueChange(nv.copy(text = nv.text.uppercase())) + } + }, + modifier.background(p.field).edge(p, sunk = true).padding(horizontal = 8.dp, vertical = 6.dp) + .semantics { if (description != null) contentDescription = description }, + textStyle = st, singleLine = true, cursorBrush = SolidColor(p.accent), + visualTransformation = VisualTransformation { t -> + TransformedText(AnnotatedString(t.text.mapIndexed { i, c -> if (i == reveal) c else '•' }.joinToString("")), OffsetMapping.Identity) + }, + keyboardOptions = KeyboardOptions(imeAction = if (onSubmit != null) ImeAction.Done else ImeAction.Default), + keyboardActions = KeyboardActions(onDone = { onSubmit?.invoke() }), + ) +} + +/** `[x] like a text-mode checkbox` */ @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), + modifier.focusRing(focused, p.accent).clickable(src, null, role = Role.Checkbox) { onChange(!checked) }.padding(vertical = 4.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)) + BasicText(if (checked) "[x] " else "[ ] ", style = Rk.t.mono.copy(color = if (checked) p.accent else p.inkDim)) 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) + Box(modifier.weight(weight).padding(end = 2.dp).edge(Rk.c, sunk = true).padding(horizontal = 5.dp, vertical = 2.dp), Alignment.Center) { + BasicText(text, style = Rk.t.label.copy(color = Rk.c.heading, fontWeight = FontWeight.Bold), 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) + Box(modifier.weight(weight).padding(end = 2.dp, top = 2.dp).edge(Rk.c, sunk = true).padding(horizontal = 5.dp, vertical = 2.dp)) { + BasicText(text, Modifier.fillMaxWidth(), style = (style ?: Rk.t.mono).copy(color = color ?: Rk.c.ink, textAlign = if (style == null) TextAlign.End else TextAlign.Start), maxLines = 1, overflow = TextOverflow.Ellipsis) } } +/** `<hr>` */ @Composable fun Hr(modifier: Modifier = Modifier) { - Box(modifier.fillMaxWidth().padding(vertical = 6.dp).height(2.dp).bevel(Rk.c, sunken = true, width = 2.dp)) + Box(modifier.fillMaxWidth().padding(vertical = 6.dp).height(2.dp).edge(Rk.c, sunk = true)) } -fun Modifier.outline(c: Color) = border(1.dp, c) - -fun Modifier.focusableIf(b: Boolean) = if (b) focusable() else this +@Composable +fun Gap(h: Dp = 8.dp) = Spacer(Modifier.height(h)) diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt new file mode 100644 index 0000000..ae69607 --- /dev/null +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt @@ -0,0 +1,67 @@ +package coffee.liz.rookery.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +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.Path +import androidx.compose.ui.graphics.drawscope.DrawScope + +/** + * A liquid-crystal seven-segment readout, drawn rather than typeset so it scales to whatever space it + * gets and shows the faint unlit segments a real timer display has. Knows digits, `: . + -`, space, + * and enough letters for "dnF". + */ +@Composable +fun SevenSegment(text: String, on: Color, ghost: Color, modifier: Modifier = Modifier) { + Canvas(modifier) { + val cells = text.map { cellWidth(it) } + val gap = 0.22f + val units = cells.sum() + gap * (text.length - 1).coerceAtLeast(0) + val u = minOf(size.width / (units + SKEW * H), size.height / H) + var x = (size.width - units * u) / 2 - SKEW * H * u / 2 + val y = (size.height - H * u) / 2 + for ((i, ch) in text.withIndex()) { + drawGlyph(ch, Offset(x, y), u, on, ghost) + x += (cells[i] + gap) * u + } + } +} + +private const val H = 1.8f +private const val T = 0.17f // segment thickness +private const val SKEW = 0.07f + +private fun cellWidth(c: Char) = when (c) { ':', '.' -> 0.3f; '+' -> 0.7f; else -> 1f } + +// a b c d e f g +private val SEGMENTS = mapOf( + '0' to "abcdef", '1' to "bc", '2' to "abged", '3' to "abgcd", '4' to "fgbc", '5' to "afgcd", '6' to "afgedc", + '7' to "abc", '8' to "abcdefg", '9' to "abcdfg", 'd' to "bcdeg", 'n' to "ceg", 'F' to "aefg", '-' to "g", ' ' to "", +) + +private fun DrawScope.drawGlyph(ch: Char, o: Offset, u: Float, on: Color, ghost: Color) { + // shear so the digits lean like an LCD's + fun pt(x: Float, y: Float) = Offset(o.x + (x + (H - y) * SKEW) * u, o.y + y * u) + fun poly(c: Color, p: List<Offset>) = drawPath(Path().apply { moveTo(p[0].x, p[0].y); p.drop(1).forEach { lineTo(it.x, it.y) }; close() }, c) + val t = T; val h = T / 2; val g = 0.03f + fun horizontal(y: Float, c: Color) = poly(c, listOf(pt(h + g, y), pt(t + g, y - h), pt(1 - t - g, y - h), pt(1 - h - g, y), pt(1 - t - g, y + h), pt(t + g, y + h))) + fun vertical(x: Float, y0: Float, y1: Float, c: Color) = poly(c, listOf(pt(x, y0 + g), pt(x + h, y0 + h + g), pt(x + h, y1 - h - g), pt(x, y1 - g), pt(x - h, y1 - h - g), pt(x - h, y0 + h + g))) + val mid = H / 2 + when (ch) { + '.' -> { drawRect(on, pt(0.02f, H - t), Size(t * u, t * u)); return } + ':' -> { drawRect(on, pt(0.02f, H * 0.3f), Size(t * u, t * u)); drawRect(on, pt(0.02f, H * 0.7f - t), Size(t * u, t * u)); return } + '+' -> { + drawRect(on, pt(0.05f, mid - h), Size(0.6f * u, t * u)) + drawRect(on, pt(0.35f - h, mid - 0.3f), Size(t * u, 0.6f * u)) + return + } + } + val lit = SEGMENTS[ch] ?: "" + fun c(s: Char) = if (s in lit) on else ghost + horizontal(h, c('a')); horizontal(mid, c('g')); horizontal(H - h, c('d')) + vertical(1 - h, h, mid, c('b')); vertical(1 - h, mid, H - h, c('c')) + vertical(h, mid, H - h, c('e')); vertical(h, h, mid, c('f')) +} diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt index d2ef012..dfa631a 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt @@ -7,55 +7,58 @@ 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 +import androidx.compose.ui.unit.sp -/** Colour tokens. Two themes: "ice floe" (light) and "polar night" (dark). */ +/** + * The look is liz.coffee's: an unstyled World Wide Web document. Default serif on a flat page, + * sage links and headings, bordered tables, and the hot pink of the site's cursor as the one accent. + */ @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 bg: Color, val ink: Color, val inkDim: Color, + val link: Color, val heading: Color, val accent: Color, val lavender: Color, + val field: Color, val zebra: Color, + /** Table borders, as old browsers drew them: a light and a dark edge. */ + val borderLight: Color, val borderDark: 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 good: Color, val bad: Color, val gold: Color, 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, + /** liz.coffee as served: black page, white text, --a #c4d0c8, --h #7d978c. */ + val Night = Palette( + bg = Color(0xFF000000), ink = Color(0xFFFFFFFF), inkDim = Color(0xFFA3ADA7), + link = Color(0xFFC4D0C8), heading = Color(0xFF7D978C), accent = Color(0xFFFF69B4), lavender = Color(0xFFB19CD9), + field = Color(0xFF0C0E0D), zebra = Color(0xFF0D1110), + borderLight = Color(0xFF8C948F), borderDark = Color(0xFF3A403D), + select = Color(0xFF7D978C), selectInk = Color(0xFF000000), + good = Color(0xFFC4D0C8), bad = Color(0xFFFF69B4), gold = Color(0xFFFFD75E), dark = true, ) - 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, + /** The same document printed on paper. */ + val Day = Palette( + bg = Color(0xFFFFFDF8), ink = Color(0xFF111111), inkDim = Color(0xFF5A635E), + link = Color(0xFF2F5D50), heading = Color(0xFF47695C), accent = Color(0xFFD6337F), lavender = Color(0xFF6F5BB0), + field = Color(0xFFFFFFFF), zebra = Color(0xFFF1F3EE), + borderLight = Color(0xFFD4D7D2), borderDark = Color(0xFF7C837F), + select = Color(0xFF47695C), selectInk = Color(0xFFFFFFFF), + good = Color(0xFF2F5D50), bad = Color(0xFFC2185B), gold = Color(0xFF946600), dark = false, ) } } @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) +class Type { + val body get() = TextStyle(fontFamily = FontFamily.Serif, fontSize = 16.sp) + val label get() = TextStyle(fontFamily = FontFamily.Serif, fontSize = 13.sp) + val heading get() = TextStyle(fontFamily = FontFamily.Serif, fontSize = 15.sp, fontWeight = FontWeight.Bold) + val h1 get() = TextStyle(fontFamily = FontFamily.Serif, fontSize = 30.sp, fontWeight = FontWeight.Bold) + /** `<tt>`: times, scrambles, letters. */ + val mono get() = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 15.sp) } -val LocalPalette = staticCompositionLocalOf { Palette.IceFloe } -val LocalType = staticCompositionLocalOf<Type> { error("no type") } +val LocalPalette = staticCompositionLocalOf { Palette.Night } +val LocalType = staticCompositionLocalOf { Type() } object Rk { val c: Palette @Composable get() = LocalPalette.current @@ -64,12 +67,5 @@ object Rk { @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() - } + CompositionLocalProvider(LocalPalette provides if (dark) Palette.Night else Palette.Day, content = 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 index e475436..926fe10 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt @@ -1,5 +1,6 @@ package coffee.liz.rookery.ui +import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -19,13 +20,15 @@ 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.size 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.shape.RoundedCornerShape 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 @@ -35,6 +38,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -49,34 +53,33 @@ 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.puzzle.Puzzles 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)) { + if (m.displayMode) { DisplayMode(m); return } + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Column(Modifier.width(280.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(10.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) + TimerDevice(m, Modifier.weight(1f).fillMaxWidth()) + Label(hint(m.timer, m.inspection, m.host.touchInput), Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.label.copy(textAlign = TextAlign.Center)) + LastSolveLine(m) } - Column(Modifier.width(290.dp).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.width(280.dp).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { StatsBox(m) PreviewBox(m) SelectedSolveBox(m) @@ -88,28 +91,24 @@ fun TimerScreen(m: AppModel) { @Composable private fun SessionBox(m: AppModel) { var choosing by remember { mutableStateOf(false) } - GroupBox("Session") { - Column(Modifier.heightIn(max = 160.dp)) { + GroupBox("sessions") { + Column(Modifier.heightIn(max = 150.dp)) { LazyColumn { - itemsIndexed(m.derived.sessions) { i, s -> + itemsIndexed(m.derived.sessions) { _, 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), + Modifier.fillMaxWidth().then(if (sel) Modifier.background(Rk.c.select) else Modifier) + .clickable { m.selectSession(s.id) }.padding(horizontal = 4.dp, vertical = 2.dp), ) { - Label(s.name, Modifier.weight(1f), color = if (sel) Rk.c.selectInk else null, maxLines = 1) + Label(s.name, Modifier.weight(1f), color = if (sel) Rk.c.selectInk else Rk.c.link, maxLines = 1) Label(s.event.short, color = if (sel) Rk.c.selectInk else Rk.c.inkDim, style = Rk.t.label) } } } } - 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") - } + RetroButton(if (choosing) "never mind" else "new session", { choosing = !choosing }) + if (choosing) FlowRow { + for (e in Event.entries) RetroButton(e.short, { m.createSession(e); choosing = false }, description = "new ${e.title} session", style = Rk.t.label) } } } @@ -120,29 +119,26 @@ private fun SolveTable(m: AppModel, modifier: Modifier) { 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) - } + Table(modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth()) { HeaderCell("#", 0.7f); HeaderCell("time", 1.4f); HeaderCell("ao5", 1.3f); HeaderCell("ao12", 1.3f) } + if (solves.isEmpty()) Label("no solves yet.", Modifier.padding(6.dp), color = Rk.c.inkDim) + 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 null + Row( + Modifier.fillMaxWidth().then(if (sel) Modifier.background(Rk.c.select) else Modifier) + .clickable { m.selectedSolveId = if (sel) null else s.id } + .semantics(mergeDescendants = true) { contentDescription = "solve ${i + 1}, ${formatSolve(s)}" }, + ) { + Cell("${i + 1}", 0.7f, color = ink ?: Rk.c.inkDim) + Cell(if (s.penalty == Penalty.DNF) "DNF" else formatSolve(s), 1.4f, color = ink ?: if (s.penalty == Penalty.DNF) Rk.c.bad else null) + Cell(ao5, 1.3f, color = ink) + Cell(ao12, 1.3f, color = ink) } } } @@ -151,94 +147,101 @@ private fun SolveTable(m: AppModel, modifier: Modifier) { @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") + Table(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically) { + BasicText(m.event.title.lowercase(), Modifier.weight(1f), style = Rk.t.heading.copy(color = Rk.c.heading)) + RetroButton("next scramble", { m.newScramble() }) } - Spacer(Modifier.height(6.dp)) - val size = when { m.scramble.length > 300 -> 18.sp; m.scramble.length > 120 -> 22.sp; else -> 30.sp } + val size = when { m.scramble.length > 300 -> 13.sp; m.scramble.length > 120 -> 16.sp; else -> 22.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), + m.scramble, Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(10.dp).semantics { contentDescription = "scramble: ${m.scramble}" }, + style = Rk.t.mono.copy(fontSize = size, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = size * 1.35f), ) } } +/** What the readout shows, in characters the seven-segment display knows. */ +private fun readout(s: State, now: Long, last: String): String = when (s) { + State.Idle -> last + is State.Inspecting -> inspectionText(now - s.since) + is State.Holding -> s.inspectingSince?.let { inspectionText(now - it) } ?: "0.00" + is State.Ready -> "0.00" + is State.Running -> formatTime(now - s.since) + is State.Stopped -> when (s.penalty) { + Penalty.DNF -> "dnF" + Penalty.PLUS2 -> formatTime(s.ms + 2000) + "+" + Penalty.NONE -> formatTime(s.ms) + } +} + +private fun inspectionText(elapsed: Long): String { + val left = 15 - elapsed / 1000 + return if (left > 0) "$left" else if (elapsed <= 17_000) "+2" else "dnF" +} + +private fun hint(s: State, inspection: Boolean, touch: Boolean): String { + val key = if (touch) "the screen" else "space" + return when (s) { + State.Idle, is State.Stopped -> if (inspection) (if (touch) "tap to start inspection." else "space starts inspection. f: big display.") else "hold $key until the green light, then let go." + is State.Inspecting -> "hold $key until the green light." + is State.Holding -> "keep holding." + is State.Ready -> "let go." + is State.Running -> if (touch) "tap anywhere to stop." else "any key stops. esc throws it away." + } +} + +/** + * The timer as an object on the desk: a dark plastic body, a grey-green liquid-crystal window that + * fills whatever room it has, and the two lamps a mat has. Red is "hands down", green is "go". + */ @Composable -private fun TimerDisplay(m: AppModel, modifier: Modifier) { - val p = Rk.c +fun TimerDevice(m: AppModel, modifier: Modifier = Modifier) { 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 - } + val last = m.lastSolve()?.let { if (it.penalty == Penalty.DNF) "dnF" else formatTime(it.result) + if (it.penalty == Penalty.PLUS2) "+" else "" } ?: "0.00" + val text = readout(s, m.now, last) + val glass = Color(0xFFA9B7A1) + val ink = Color(0xFF1A2316) Column( - modifier.fillMaxWidth().background(p.lcd).bevel(p, sunken = true) - .pointerInput(Unit) { - awaitEachGesture { - awaitFirstDown(); m.press() - waitForUpOrCancellation(); m.release() - } - }, + modifier.clip(RoundedCornerShape(18.dp)).background(Color(0xFF1B1D1C)).outline(Color(0xFF3A3D3B)).padding(14.dp) + .then(if (!m.host.touchInput) Modifier else Modifier.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), - ) + Box( + Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(6.dp)).background(glass).outline(Color(0xFF0C0D0C), 2.dp).padding(horizontal = 22.dp, vertical = 14.dp) + .semantics { liveRegion = LiveRegionMode.Polite; contentDescription = "timer ${text.replace("dnF", "DNF")}" }, + ) { + SevenSegment(text, ink, ink.copy(alpha = 0.06f), Modifier.fillMaxSize()) + } + Row(Modifier.fillMaxWidth().padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Lamp(Color(0xFFFF4D4D), lit = s is State.Holding) + Spacer(Modifier.width(8.dp)) + Lamp(Color(0xFF58E06F), lit = s is State.Ready || s is State.Running) + Spacer(Modifier.weight(1f)) + BasicText("rookery", style = Rk.t.label.copy(color = Color(0xFF6A6F6C), letterSpacing = 3.sp)) } - 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 +@Composable +private fun Lamp(color: Color, lit: Boolean) { + Canvas(Modifier.size(12.dp)) { + if (lit) drawCircle(color.copy(alpha = 0.35f), radius = size.minDimension * 0.75f) + drawCircle(if (lit) color else Color(color.red * 0.25f, color.green * 0.25f, color.blue * 0.25f), radius = size.minDimension / 2) + drawCircle(Color.White.copy(alpha = if (lit) 0.6f else 0.12f), radius = size.minDimension * 0.14f, center = center.copy(x = center.x - size.width * 0.14f, y = center.y - size.height * 0.14f)) } - 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) { +private fun LastSolveLine(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") + Row(verticalAlignment = Alignment.CenterVertically) { + Label("that one was: ", color = Rk.c.inkDim) + Tabs(listOf(Penalty.NONE, Penalty.PLUS2, Penalty.DNF), last.penalty, { when (it) { Penalty.NONE -> "fine"; Penalty.PLUS2 -> "+2"; Penalty.DNF -> "dnf" } }, { m.setPenalty(last.id, it) }) + Label(" · ", color = Rk.c.inkDim) + RetroButton("delete it", { 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) + if (m.lastPbs.isNotEmpty()) Label("pb " + m.lastPbs.joinToString(" & ") { it.kind.label } + "!", color = Rk.c.accent, style = Rk.t.heading) } } @@ -246,22 +249,21 @@ private fun LastSolveBar(m: AppModel) { private fun StatsBox(m: AppModel) { val ss = m.derived.sessionStats val es = m.derived.eventStats - GroupBox("Stats") { + Table(Modifier.fillMaxWidth()) { 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()) { + for (k in StatKind.entries) { 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) + Row { + Cell(k.label, 1f, style = Rk.t.label, color = Rk.c.heading) 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) + Label("${ss.solved} of ${ss.count} solved, mean ${formatTime(ss.mean, true)}", Modifier.padding(horizontal = 4.dp), color = Rk.c.inkDim, style = Rk.t.label) } } @@ -273,10 +275,11 @@ private fun PreviewBox(m: AppModel) { val svg = m.previewSvg val density = LocalDensity.current val painter = remember(svg, density) { svg?.let { runCatching { it.encodeToByteArray().decodeToSvgPainter(density) }.getOrNull() } } - GroupBox("Scramble preview") { + if (state == null && painter == null) return + GroupBox("what it should look like") { 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 (state != null) PuzzleView(state, colors, Modifier.fillMaxWidth().height(190.dp), description = "3D scramble preview, drag to spin", draggable = true) + // tnoodle's official 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)) } } @@ -284,18 +287,17 @@ private fun PreviewBox(m: AppModel) { @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) + GroupBox("replay") { + PuzzleView(r.state, remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }, Modifier.fillMaxWidth().height(230.dp), camera, r.anim, "solve replay, drag to spin", draggable = true) + Label("move ${r.index} of ${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) + true -> Label("and it ends solved.", color = Rk.c.good) + false -> Label("it does not end solved.", color = Rk.c.bad) 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() }) + Row { + m.derived.sessionSolves.firstOrNull { it.id == r.solveId }?.let { s -> RetroButton("again", { m.startReplay(s) }); Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) } + RetroButton("close", { m.stopReplay() }) } } } @@ -303,21 +305,34 @@ private fun ReplayBox(m: AppModel, r: Replay) { @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) } + GroupBox("solve") { + Label(formatSolve(s), style = Rk.t.mono.copy(fontSize = 28.sp)) + Label(s.scramble, style = Rk.t.mono.copy(fontSize = 12.sp), 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) }) + Tabs(listOf(Penalty.NONE, Penalty.PLUS2, Penalty.DNF), s.penalty, { when (it) { Penalty.NONE -> "fine"; Penalty.PLUS2 -> "+2"; Penalty.DNF -> "dnf" } }, { m.setPenalty(s.id, it) }) + Row { + s.moves?.let { mv -> + if (s.event.puzzle == Puzzle.CUBE3) { RetroButton("replay the ${mv.split(' ').size} moves", { m.startReplay(s) }); Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) } + } + RetroButton("delete", { m.deleteSolve(s.id) }) } - 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) }) + } +} + +/** The whole window as one timer: for a second monitor, or a phone leaning on the mat. */ +@Composable +private fun DisplayMode(m: AppModel) { + val ss = m.derived.sessionStats + Column(Modifier.fillMaxSize().padding(8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + val size = if (m.scramble.length > 120) 18.sp else 28.sp + BasicText(m.scramble, Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = size, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = size * 1.3f)) + TimerDevice(m, Modifier.weight(1f).fillMaxWidth()) + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(84.dp)) // the penguin's spot + val parts = listOf(StatKind.AO5, StatKind.AO12, StatKind.AO100).mapNotNull { k -> ss.line(k)?.let { "${k.label} ${formatTime(it.current, true)}" } } + Label((listOf(m.event.short.lowercase(), "${ss.count} solves") + parts).joinToString(" · "), Modifier.weight(1f), style = Rk.t.mono.copy(fontSize = 18.sp), color = Rk.c.inkDim) + Label(hint(m.timer, m.inspection, m.host.touchInput) + " ", color = Rk.c.inkDim, style = Rk.t.label) + RetroButton("leave display", { m.changeDisplayMode(false) }) } } } |
