summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 08:37:53 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 08:37:53 -0800
commitbf3e85b69811930d01573ae9fc2703f674c0c951 (patch)
tree375b3433e9e0a7d312cad7aac998e188a2fddab3 /ui
parentf95457986531730095b00e2e894e9bf4d9df7166 (diff)
downloadrookery-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')
-rw-r--r--ui/src/commonMain/composeResources/font/silkscreen.ttfbin32220 -> 0 bytes
-rw-r--r--ui/src/commonMain/composeResources/font/silkscreen_bold.ttfbin30632 -> 0 bytes
-rw-r--r--ui/src/commonMain/composeResources/font/vt323.ttfbin153116 -> 0 bytes
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt123
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt92
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt16
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt651
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/CubeNet.kt67
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt186
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt207
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt284
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt103
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt220
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt67
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt78
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt305
16 files changed, 1481 insertions, 918 deletions
diff --git a/ui/src/commonMain/composeResources/font/silkscreen.ttf b/ui/src/commonMain/composeResources/font/silkscreen.ttf
deleted file mode 100644
index 8caff81..0000000
--- a/ui/src/commonMain/composeResources/font/silkscreen.ttf
+++ /dev/null
Binary files differ
diff --git a/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf b/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf
deleted file mode 100644
index 6771252..0000000
--- a/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf
+++ /dev/null
Binary files differ
diff --git a/ui/src/commonMain/composeResources/font/vt323.ttf b/ui/src/commonMain/composeResources/font/vt323.ttf
deleted file mode 100644
index afa6909..0000000
--- a/ui/src/commonMain/composeResources/font/vt323.ttf
+++ /dev/null
Binary files differ
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) })
}
}
}