summaryrefslogtreecommitdiff
path: root/ui/src
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:41:10 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:41:10 -0800
commitf95457986531730095b00e2e894e9bf4d9df7166 (patch)
tree8be48ec41f882dba1f55407c6fc38b8bc7c3dcfc /ui/src
parent2a9a3126a4955dffc40bc04ab88a5ccdb3da77c9 (diff)
downloadrookery-f95457986531730095b00e2e894e9bf4d9df7166.tar.gz
rookery-f95457986531730095b00e2e894e9bf4d9df7166.zip
BLD trainers: OP tracing/checking, FSRS drilling, BLD tab
- bld: 3x3 sticker geometry in Speffz order, lettering schemes, the solver's frame (default yellow top / orange front), OP tracing with cycle breaks and twists, and memo checking by executing the typed targets as OP swaps, so any valid memo passes and errors are reported as the pieces left wrong. - srs: FSRS-4.5 memory model and a weighted drill picker that favours forgotten, hard and recently missed items. Reviews are stored one entity per answer so concurrent devices merge cleanly; Letter Pair Gym history seeds pair difficulty. - BLD tab: pair->word (three at a time), word->pair typing, target drills (name the outlined sticker / find a letter's sticker on a scrambled 3D cube), memo trainer on official 3BLD scrambles with hidden recall fields, and a setup page with orientation, buffers and a click-to-edit lettering net. - PuzzleView: tap-to-pick stickers, highlight outline, face cameras. - CubeMapping between renderer stickers and NxN slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'ui/src')
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt1
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt8
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt77
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt619
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt49
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt2
6 files changed, 752 insertions, 4 deletions
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
index 3cc79dd..40b799d 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
@@ -31,6 +31,7 @@ fun RookeryApp(m: AppModel) {
when (m.tab) {
Tab.TIMER -> TimerScreen(m)
Tab.PAIRS -> PairsScreen(m)
+ Tab.BLD -> BldScreen(m)
Tab.IMPORT -> ImportScreen(m)
Tab.SETTINGS -> SettingsScreen(m)
}
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 db34c7b..69e0947 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
@@ -46,7 +46,7 @@ interface Host {
val deviceId: String
}
-enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), IMPORT("Import"), SETTINGS("Settings") }
+enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), BLD("BLD"), IMPORT("Import"), SETTINGS("Settings") }
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** Everything derived from the store, recomputed off the UI thread when it changes. */
@@ -77,6 +77,7 @@ 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)
var derived by mutableStateOf(Derived())
private set
var sessionId by mutableStateOf(repo.pref("lastSession"))
@@ -302,4 +303,9 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host,
repo.setPairImage(pair, repo.store.blobs.put(bytes, path.substringAfterLast('.', "jpg")))
}
fun blob(name: String): ByteArray? = repo.store.blobs.get(name)
+
+ /** A fresh official scramble, generated off the UI thread. */
+ suspend fun freshScramble(e: Event): String = withContext(Dispatchers.Default) { scrambler.next(e) }
+
+ val bld: BldModel by lazy { BldModel(repo) }
}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt
new file mode 100644
index 0000000..e2c0760
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt
@@ -0,0 +1,77 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import coffee.liz.rookery.bld.BldSetup
+import coffee.liz.rookery.bld.Cube3
+import coffee.liz.rookery.bld.PieceType
+import coffee.liz.rookery.epochMillis
+import coffee.liz.rookery.randomId
+import coffee.liz.rookery.srs.DrillPicker
+import coffee.liz.rookery.srs.Fsrs
+import coffee.liz.rookery.srs.Grade
+import coffee.liz.rookery.srs.Memory
+import coffee.liz.rookery.srs.Review
+import coffee.liz.rookery.srs.gradeAnswer
+import coffee.liz.rookery.store.Repo
+
+/**
+ * Shared state for the BLD trainers: the solver's setup and every item's review history.
+ *
+ * Item ids: `p2w:AB` pair→word, `w2p:AB` word→pair, `name:c:15` / `find:e:3` target drills
+ * (type c/e, Speffz position of the sticker's home).
+ */
+class BldModel(private val repo: Repo) {
+ var setup by mutableStateOf(repo.bldSetup())
+ private set
+
+ private val history = HashMap<String, MutableList<Review>>()
+ /** 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()
+
+ 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) ->
+ rec.mapIndexed { i, c ->
+ Review(item, start + i * dayMs, when (c) { 'm' -> Grade.AGAIN; 's' -> Grade.HARD; else -> Grade.GOOD })
+ }
+ }
+ }
+
+ private fun reviewsOf(item: String): List<Review> = history[item] ?: seeds[item].orEmpty()
+
+ fun memory(item: String): Memory? = Fsrs.replay(reviewsOf(item))
+
+ private fun recentMisses(item: String): Int = reviewsOf(item).sortedBy { it.at }.takeLast(3).count { it.grade == Grade.AGAIN }
+
+ /** Next item to drill out of [items], avoiding the most recent few. */
+ fun pick(items: List<String>, recent: List<String>): String? =
+ picker.pick(items, ::memory, ::recentMisses, recent.takeLast(minOf(4, items.size / 2)), epochMillis())
+
+ /** Record an answer; returns its grade. */
+ fun record(item: String, correct: Boolean, ms: Long, goodMs: Long = 2_500): Grade {
+ 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
+ }
+
+ fun updateSetup(s: BldSetup) { repo.setBldSetup(s); setup = s }
+
+ /** Positions you'd ever memo for a type: everything off the buffer piece. */
+ fun memoPositions(type: PieceType): List<Int> {
+ val buffer = Cube3.pieceId(type, setup.buffer(type))
+ return (0 until 24).filter { Cube3.pieceId(type, it) != buffer }
+ }
+
+ /** Session accuracy for the status line. */
+ var sessionRight by mutableStateOf(0)
+ var sessionTotal by mutableStateOf(0)
+ fun tally(correct: Boolean) { sessionTotal++; if (correct) sessionRight++ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt
new file mode 100644
index 0000000..1d5d69d
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt
@@ -0,0 +1,619 @@
+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
+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.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.mutableLongStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.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.style.TextAlign
+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.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")
+}
+
+@Composable
+fun BldScreen(m: AppModel) {
+ val mode = m.bldMode
+ Column(Modifier.fillMaxSize().padding(8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ 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)
+ }
+ Box(Modifier.weight(1f).fillMaxWidth().background(Rk.c.panel).bevel(Rk.c).padding(10.dp)) {
+ when (mode) {
+ BldMode.P2W -> PairToWord(m)
+ BldMode.W2P -> WordToPair(m)
+ BldMode.TARGETS -> Targets(m)
+ BldMode.MEMO -> MemoTrainer(m)
+ BldMode.SETUP -> BldSetupScreen(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. */
+@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() }
+}
+
+// ---------------------------------------------------------------- pair → word
+
+@Composable
+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 }
+
+ 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 }
+ }
+ return out
+ }
+ var batch by remember { mutableStateOf(newBatch()) }
+ var started by remember(batch) { mutableLongStateOf(monoMillis()) }
+ var revealedAfter by remember(batch) { mutableStateOf<Long?>(null) }
+ val missed = remember(batch) { mutableStateListOf<String>() }
+
+ fun reveal() { if (revealedAfter == null) revealedAfter = monoMillis() - started }
+ fun next() {
+ val ms = (revealedAfter ?: (monoMillis() - started)) / batch.size
+ for (p in batch) { val ok = p !in missed; bld.record("p2w:$p", ok, ms); bld.tally(ok) }
+ recent += batch.map { "p2w:$it" }
+ batch = newBatch()
+ }
+
+ KeyBox(batch, { k ->
+ when {
+ revealedAfter == null && (k == Key.Spacebar || k == Key.Enter) -> { reveal(); true }
+ revealedAfter != null && k == Key.Enter -> { 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
+ }
+ 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)
+ }
+ 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() })
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------- word → pair
+
+@Composable
+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 }
+ val scope = rememberCoroutineScope()
+ val recent = remember { mutableStateListOf<String>() }
+ var current by remember { mutableStateOf(bld.pick(keys.map { "w2p:$it" }, emptyList())!!.removePrefix("w2p:")) }
+ var typed by remember(current) { mutableStateOf("") }
+ var started by remember(current) { mutableLongStateOf(monoMillis()) }
+ var verdict by remember(current) { mutableStateOf<Pair<Boolean, Grade>?>(null) }
+ 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).
+ 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)
+ bld.tally(ok)
+ scope.launch { delay(if (ok) 600 else 1_600); advance() }
+ }
+
+ 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)
+ }
+ BasicText(word, Modifier.padding(8.dp), style = Rk.t.mono.copy(fontSize = 56.sp, color = Rk.c.ink, textAlign = TextAlign.Center))
+ 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",
+ )
+ 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),
+ )
+ }
+ }
+}
+
+// ---------------------------------------------------------------- targets
+
+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)]}"
+ }
+ return out.joinToString(" ")
+}
+
+@Composable
+private fun Targets(m: AppModel) {
+ val bld = m.bld
+ val setup = bld.setup
+ val tracer = remember(setup) { Tracer(setup) }
+ val mapping = remember { CubeMapping(Puzzles.cube(3), 3) }
+ val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }
+ var find by remember { mutableStateOf(false) }
+ var types by remember { mutableStateOf(setOf(PieceType.CORNER, PieceType.EDGE)) }
+ val recent = remember { mutableStateListOf<String>() }
+ 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)
+ 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 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)
+ }
+ var q by remember(find, types, setup) { mutableStateOf(newQuestion()) }
+ var started by remember(q) { mutableLongStateOf(monoMillis()) }
+ var verdict by remember(q) { mutableStateOf<Boolean?>(null) }
+ 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)
+ LaunchedEffect(q) {
+ if (!find) cameraFacing(whereSlot / 9).let { (y, p) -> camera.yaw = y; camera.pitch = p }
+ }
+
+ fun answer(ok: Boolean) {
+ if (verdict != null) return
+ verdict = 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() }
+ }
+ 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,
+ )
+ Label("Drag to turn the cube. Shown as you hold it (your orientation from Setup).", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ 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,
+ )
+ }
+ }
+ 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,
+ )
+ }
+ }
+ Label("Weak letters come up more often; every answer is saved and synced.", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ }
+}
+
+// ---------------------------------------------------------------- memo
+
+private enum class MemoPhase { LOADING, MEMO, 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,
+)
+
+@Composable
+private fun MemoTrainer(m: AppModel) {
+ val bld = m.bld
+ val setup = bld.setup
+ val tracer = remember(setup) { Tracer(setup) }
+ 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 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 now by remember { mutableLongStateOf(monoMillis()) }
+
+ LaunchedEffect(round) {
+ scramble = m.freshScramble(Event.E333BF)
+ phase = MemoPhase.MEMO
+ memoStart = monoMillis()
+ }
+ LaunchedEffect(phase) { while (phase == MemoPhase.MEMO) { now = monoMillis(); delay(50) } }
+ val state = remember(scramble, setup) { scramble?.let { setup.stateOf(it) } }
+
+ fun submit() {
+ 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
+ phase = MemoPhase.RESULT
+ val letters = traced.values.sumOf { it.length }
+ val pairsTotal = (letters + 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)),
+ )
+ bld.tally(r.ok.values.all { it })
+ }
+
+ Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(Modifier.weight(1f)) {
+ 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) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Penguin(PenguinAnim.SLEEP, 96.dp, m.reducedMotion)
+ Label("cube hidden — blindfold on", color = Rk.c.inkDim)
+ }
+ }
+ 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")
+ }
+ }
+ }
+ }
+ 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() })
+ }
+ MemoPhase.RESULT -> RetroButton("Next cube", { round++ })
+ }
+ }
+ result?.let { r -> MemoResultBox(r, setup) }
+ }
+ }
+}
+
+@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,
+ )
+ }
+}
+
+// ---------------------------------------------------------------- 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())) })
+ }
+ }
+ 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)
+ }
+ 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)
+ }
+ }
+ }
+}
+
+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),
+ )
+ }
+ }
+ }
+ }
+}
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 41ea00f..f2a8b67 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
@@ -2,6 +2,7 @@ package coffee.liz.rookery.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -46,11 +47,23 @@ fun PuzzleView(
camera: Camera = remember { Camera() },
anim: TurnAnim? = null,
description: String = "3D puzzle view",
+ /** Sticker index to outline, e.g. "what letter is this?" */
+ highlight: Int? = null,
+ highlightColor: Color = Color(0xFFFF2BD6),
+ /** Called with the front-most sticker under a tap/click. */
+ onStickerTap: ((Int) -> Unit)? = null,
) {
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) }
+ }
+ })
.pointerInput(camera) {
detectDragGestures { change, drag ->
change.consume()
@@ -86,7 +99,7 @@ fun PuzzleView(
Triple(a.turn, axis.normal, angle)
}
- class Drawn(val tile: Polygon, val color: Color, val depth: Double, val shade: Float)
+ class Drawn(val index: Int, val tile: Polygon, val color: Color, val depth: Double, val shade: Float)
val drawn = ArrayList<Drawn>(puzzle.stickers.size)
for ((i, s) in puzzle.stickers.withIndex()) {
var poly = s
@@ -98,19 +111,49 @@ fun PuzzleView(
val n = v.normal
if ((n dot toCam) <= 0) continue // back face
val lambert = maxOf(0.0, n dot light).toFloat()
- drawn += Drawn(v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert)
+ drawn += Drawn(i, v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert)
}
drawn.sortBy { it.depth }
+ hits.clear()
for (d in drawn) {
- fill(d.tile.points.map(::project), body)
+ val tile = d.tile.points.map(::project)
+ 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))
+ 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))
}
}
}
+/** Ray-casting point-in-polygon test. */
+private fun inside(p: Offset, poly: List<Offset>): Boolean {
+ var c = false
+ var j = poly.size - 1
+ for (i in poly.indices) {
+ val a = poly[i]; val b = poly[j]
+ if ((a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x) c = !c
+ j = i
+ }
+ 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 2fe6be5..8fb3a18 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
@@ -164,6 +164,7 @@ 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)
@@ -172,6 +173,7 @@ fun RetroField(
modifier.background(p.field).bevel(p, sunken = true).padding(horizontal = 8.dp, vertical = 6.dp)
.semantics { if (description != null) contentDescription = description },
textStyle = st, singleLine = singleLine, cursorBrush = SolidColor(p.ink),
+ visualTransformation = if (password) androidx.compose.ui.text.input.PasswordVisualTransformation('•') else androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions = KeyboardOptions(imeAction = if (onSubmit != null) ImeAction.Done else ImeAction.Default),
keyboardActions = KeyboardActions(onDone = { onSubmit?.invoke() }),
decorationBox = { inner ->