From bf3e85b69811930d01573ae9fc2703f674c0c951 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 13 Nov 2026 08:37:53 -0800 Subject: 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 ` renders every screen offscreen with sample data. Co-Authored-By: Claude Fable 5.2 --- desktop/build.gradle.kts | 2 +- .../main/kotlin/coffee/liz/rookery/desktop/Main.kt | 68 ++++++++++++++++- .../kotlin/coffee/liz/rookery/desktop/Shots.kt | 86 ++++++++++++++++++++++ 3 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt (limited to 'desktop') diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index e0fd832..44a8554 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -20,7 +20,7 @@ compose.desktop { jvmArgs += listOf("-Xmx256m", "-XX:+UseSerialGC") nativeDistributions { // From `./gradlew :desktop:suggestRuntimeModules`; jdk.security.auth is needed by dbus-java (Bluetooth). - modules("java.instrument", "jdk.security.auth", "jdk.unsupported") + modules("java.instrument", "java.net.http", "jdk.security.auth", "jdk.unsupported") targetFormats(TargetFormat.AppImage, TargetFormat.Deb) packageName = "rookery" packageVersion = "0.1.0" diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt index 5a5de5e..c2f65ca 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import coffee.liz.rookery.audio.Chime import coffee.liz.rookery.audio.ChimePlayer +import coffee.liz.rookery.images.BraveImages +import coffee.liz.rookery.images.ImageHit import coffee.liz.rookery.model.Event import coffee.liz.rookery.scramble.TnoodleScrambler import coffee.liz.rookery.store.FolderBlobStore @@ -41,7 +43,15 @@ class LocalConfig { private val file = dir.resolve("config.properties") private val props = Properties().apply { if (file.exists()) file.inputStream().use { load(it) } } - private fun save() { dir.createDirectories(); file.outputStream().use { props.store(it, "rookery local config") } } + private fun save() { + dir.createDirectories() + file.outputStream().use { props.store(it, "rookery local config") } + // Holds API keys: owner-only where the filesystem supports it. + runCatching { java.nio.file.Files.setPosixFilePermissions(file, java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")) } + } + + /** Brave Search API key: env ROOKERY_BRAVE_KEY, else `braveApiKey` in this (never synced) file. */ + val braveApiKey: String? get() = System.getenv("ROOKERY_BRAVE_KEY")?.takeIf { it.isNotBlank() } ?: props.getProperty("braveApiKey")?.takeIf { it.isNotBlank() } val deviceId: String = props.getProperty("deviceId") ?: run { val host = runCatching { java.net.InetAddress.getLocalHost().hostName }.getOrNull()?.filter { it.isLetterOrDigit() || it == '-' }?.take(24) ?: "desktop" @@ -90,12 +100,62 @@ class DesktopHost(private val config: LocalConfig) : Host { override val syncFolder: String get() = config.syncFolder override fun setSyncFolder(path: String) { config.syncFolder = path } override val deviceId: String get() = config.deviceId + override val braveApiKey: String? get() = config.braveApiKey + override val touchInput: Boolean get() = false + + private val http: java.net.http.HttpClient by lazy { + java.net.http.HttpClient.newBuilder().connectTimeout(java.time.Duration.ofSeconds(10)) + .followRedirects(java.net.http.HttpClient.Redirect.NORMAL).build() + } + + private fun get(url: String, headers: Map = emptyMap()): ByteArray { + require(url.startsWith("https://")) { "refusing a non-https address" } + val req = java.net.http.HttpRequest.newBuilder(java.net.URI(url)).timeout(java.time.Duration.ofSeconds(20)) + .header("User-Agent", "rookery").apply { headers.forEach { (k, v) -> header(k, v) } }.GET().build() + val res = http.send(req, java.net.http.HttpResponse.BodyHandlers.ofInputStream()) + if (res.statusCode() !in 200..299) { res.body().close(); error("the server said ${res.statusCode()}") } + // images only need to be so big; stop reading rather than fill memory + return res.body().use { it.readNBytes(MAX_DOWNLOAD + 1) }.also { check(it.size <= MAX_DOWNLOAD) { "that file is too large" } } + } + + override suspend fun searchImages(query: String): List = withContext(Dispatchers.IO) { + val key = config.braveApiKey ?: error("No Brave Search key on this machine.") + val body = try { + get(BraveImages.url(query), mapOf("Accept" to "application/json", BraveImages.KEY_HEADER to key)).decodeToString() + } catch (e: java.io.IOException) { error("couldn't reach Brave (${e.message ?: "offline?"}).") } + BraveImages.parse(body) + } + + override suspend fun fetchBytes(url: String): ByteArray = withContext(Dispatchers.IO) { get(url) } + + /** Fit inside 640px and re-encode as WebP, which is what gets synced. */ + override fun shrinkImage(bytes: ByteArray): Pair? = runCatching { + val img = org.jetbrains.skia.Image.makeFromEncoded(bytes) + val k = minOf(1f, 640f / maxOf(img.width, img.height)) + val w = (img.width * k).toInt().coerceAtLeast(1); val h = (img.height * k).toInt().coerceAtLeast(1) + val surface = org.jetbrains.skia.Surface.makeRasterN32Premul(w, h) + surface.canvas.drawImageRect( + img, org.jetbrains.skia.Rect.makeWH(img.width.toFloat(), img.height.toFloat()), org.jetbrains.skia.Rect.makeWH(w.toFloat(), h.toFloat()), + org.jetbrains.skia.FilterMipmap(org.jetbrains.skia.FilterMode.LINEAR, org.jetbrains.skia.MipmapMode.LINEAR), null, true, + ) + val data = surface.makeImageSnapshot().encodeToData(org.jetbrains.skia.EncodedImageFormat.WEBP, 82) ?: return null + data.bytes to "webp" + }.getOrNull() + + var onFullscreen: (Boolean) -> Unit = {} + override fun setFullscreen(on: Boolean) = onFullscreen(on) + + private companion object { const val MAX_DOWNLOAD = 12 * 1024 * 1024 } } fun main(args: Array) { if (args.firstOrNull() == "--import") return headlessImport(args.drop(1)) if (args.firstOrNull() == "--cube") return cubeCli(args.drop(1)) - System.setProperty("skiko.renderApi", System.getProperty("skiko.renderApi") ?: "OPENGL") + if (args.firstOrNull() == "--shots") return shots(args.drop(1)) + // OpenGL is the reliable choice on Linux; elsewhere (Metal on macOS, Direct3D on Windows) keep Skiko's default. + if (System.getProperty("os.name").lowercase().contains("linux") && System.getProperty("skiko.renderApi") == null) { + System.setProperty("skiko.renderApi", "OPENGL") + } val config = LocalConfig() val root = Paths.get(config.syncFolder).also { it.createDirectories() } val store = Store(config.deviceId, FolderLogStorage(root, config.deviceId), FolderBlobStore(root), System::currentTimeMillis) @@ -106,7 +166,9 @@ fun main(args: Array) { val state = rememberWindowState(size = DpSize(1280.dp, 820.dp)) val scope = rememberCoroutineScope() val model = remember { - AppModel(repo, scrambler, DesktopHost(config), scope).apply { + val host = DesktopHost(config) + host.onFullscreen = { on -> state.placement = if (on) androidx.compose.ui.window.WindowPlacement.Fullscreen else androidx.compose.ui.window.WindowPlacement.Floating } + AppModel(repo, scrambler, host, scope).apply { // dev hooks for screenshots: ROOKERY_TAB=PAIRS, ROOKERY_PARTY=1, ROOKERY_REPLAY=1, ROOKERY_EVENT=pyram System.getenv("ROOKERY_TAB")?.let { t -> coffee.liz.rookery.ui.Tab.entries.firstOrNull { it.name == t }?.let { tab = it } } System.getenv("ROOKERY_BLD")?.let { b -> coffee.liz.rookery.ui.BldMode.entries.firstOrNull { it.name == b }?.let { bldMode = it } } diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt new file mode 100644 index 0000000..d7621eb --- /dev/null +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt @@ -0,0 +1,86 @@ +package coffee.liz.rookery.desktop + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import coffee.liz.rookery.audio.Chime +import coffee.liz.rookery.images.ImageHit +import coffee.liz.rookery.model.Event +import coffee.liz.rookery.model.Penalty +import coffee.liz.rookery.model.Solve +import coffee.liz.rookery.scramble.TnoodleScrambler +import coffee.liz.rookery.store.MemoryBlobStore +import coffee.liz.rookery.store.MemoryLogStorage +import coffee.liz.rookery.store.Repo +import coffee.liz.rookery.store.Store +import coffee.liz.rookery.ui.AppModel +import coffee.liz.rookery.ui.BldMode +import coffee.liz.rookery.ui.Host +import coffee.liz.rookery.ui.RookeryApp +import coffee.liz.rookery.ui.Tab +import coffee.liz.rookery.ui.ThemeMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.jetbrains.skia.EncodedImageFormat +import java.io.File +import kotlin.random.Random + +/** + * `rookery --shots `: draws each screen offscreen with made-up data and writes PNGs. A development + * aid for looking at the UI without a window (or anyone's real data) involved. + */ +fun shots(args: List) { + val out = File(args.firstOrNull() ?: "shots").apply { mkdirs() } + val repo = Repo(Store("shots", MemoryLogStorage(), MemoryBlobStore(), System::currentTimeMillis)) + val rng = Random(5) + repo.createSession("s1", "Main 3x3", Event.E333, 1) + repo.createSession("s2", "Main Pyra", Event.PYRAM, 2) + repeat(40) { i -> + repo.addSolve(Solve("x$i", "s1", Event.E333, 11_000 + rng.nextLong(6_000), if (i == 17) Penalty.DNF else if (i == 30) Penalty.PLUS2 else Penalty.NONE, "R U R' U' F2 D L2 B", "", 1_000L + i)) + } + val words = listOf("AB" to "abacus", "AD" to "adelie", "CH" to "cheese", "GN" to "gentoo", "LT" to "latte", "MA" to "macaroni", "CI" to "cider", "PG" to "penguin", "WX" to "wax", "KR" to "krill", "FS" to "fish", "IG" to "igloo") + for ((p, w) in words) repo.setPrimaryWord(p, w, 5) + repo.setPairNote("AD", "the penguin") + + val host = object : Host { + override fun playChime(notes: List) {} + override suspend fun pickFile(title: String, extensions: List): String? = null + override suspend fun pickFolder(title: String): String? = null + override suspend fun readImport(path: String, mainEntry: String?) = "" to { _: String -> null as ByteArray? } + override suspend fun readFile(path: String) = ByteArray(0) + override val syncFolder = "~/Sync/rookery" + override fun setSyncFolder(path: String) {} + override val deviceId = "sample-device" + override val braveApiKey: String? = "x" + override val touchInput = false + override suspend fun searchImages(query: String) = emptyList() + override suspend fun fetchBytes(url: String) = ByteArray(0) + override fun shrinkImage(bytes: ByteArray): Pair? = null + override fun setFullscreen(on: Boolean) {} + } + val model = AppModel(repo, TnoodleScrambler(), host, CoroutineScope(SupervisorJob() + Dispatchers.Default)) + model.selectSession("s1") + + fun shoot(name: String, w: Int = 1280, h: Int = 800, settle: Long = 2500, setup: () -> Unit) { + setup() + val scene = ImageComposeScene(w * 2, h * 2, Density(2f)) { RookeryApp(model) } + val start = System.nanoTime() + var img = scene.render(0) + while (System.nanoTime() - start < settle * 1_000_000) { Thread.sleep(30); img = scene.render(System.nanoTime() - start) } + File(out, "$name.png").writeBytes(img.encodeToData(EncodedImageFormat.PNG)!!.bytes) + scene.close() + println("wrote $name.png") + } + model.setTheme(ThemeMode.DARK) + shoot("timer") { model.tab = Tab.TIMER } + shoot("display") { model.changeDisplayMode(true) } + model.changeDisplayMode(false) + shoot("party", settle = 1800) { model.testParty() } + model.party = null + for (mode in BldMode.entries) shoot("bld-${mode.name.lowercase()}", settle = 4000) { model.tab = Tab.BLD; model.bldMode = mode } + shoot("settings") { model.tab = Tab.SETTINGS } + shoot("import") { model.tab = Tab.IMPORT } + model.setTheme(ThemeMode.LIGHT) + shoot("timer-paper") { model.tab = Tab.TIMER } + kotlin.system.exitProcess(0) +} -- cgit v1.3