From 77b8918fd9ed446c7e462645d5e1257c7d8d2857 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 21:13:31 +0000 Subject: [PATCH 1/4] Command suggestions' ranking core: the :suggest module A plain Kotlin module the input line will use to offer commands as the player types: history matched as a subsequence and ranked by frecency, the command that usually follows the previous one or the latest game lines, and words from recent output completing the word at the cursor. One suggester per profile, bounded, saved as a line-based text; hidden input is never stored. Its tests run in apks.yml and with kotlinc alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeGtuaeaa9oac3KmLFo --- .github/workflows/apks.yml | 13 +- .github/workflows/ci.yml | 3 +- CONTRIBUTING.md | 5 + docs/architecture.md | 30 ++ docs/roadmap.md | 7 +- settings.gradle.kts | 2 +- suggest/build.gradle.kts | 25 ++ suggest/run-tests.sh | 19 + .../android/suggest/CommandSuggester.kt | 312 ++++++++++++++++ .../org/mudlet/android/suggest/FuzzyMatch.kt | 69 ++++ .../org/mudlet/android/suggest/OutputWords.kt | 58 +++ .../mudlet/android/suggest/SuggestCodec.kt | 129 +++++++ .../android/suggest/SuggesterRegistry.kt | 18 + .../org/mudlet/android/suggest/Suggestion.kt | 53 +++ .../mudlet/android/suggest/TransitionTable.kt | 69 ++++ .../android/suggest/CommandSuggesterTest.kt | 343 ++++++++++++++++++ .../mudlet/android/suggest/FuzzyMatchTest.kt | 53 +++ .../android/suggest/SuggestCodecTest.kt | 106 ++++++ .../android/suggest/SuggesterRegistryTest.kt | 35 ++ 19 files changed, 1342 insertions(+), 7 deletions(-) create mode 100644 suggest/build.gradle.kts create mode 100755 suggest/run-tests.sh create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/FuzzyMatch.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/OutputWords.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestCodec.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/SuggesterRegistry.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/Suggestion.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/TransitionTable.kt create mode 100644 suggest/src/test/kotlin/org/mudlet/android/suggest/CommandSuggesterTest.kt create mode 100644 suggest/src/test/kotlin/org/mudlet/android/suggest/FuzzyMatchTest.kt create mode 100644 suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestCodecTest.kt create mode 100644 suggest/src/test/kotlin/org/mudlet/android/suggest/SuggesterRegistryTest.kt diff --git a/.github/workflows/apks.yml b/.github/workflows/apks.yml index 22c35ce..5094e49 100644 --- a/.github/workflows/apks.yml +++ b/.github/workflows/apks.yml @@ -1,9 +1,9 @@ name: APKs -# The app's debug APK for arm64-v8a with the stand-in engine, after the stand-in's desktop test and the Kotlin -# protocol tests (against that desktop engine too), and a second time with its libraries extracted at install. Both -# carry OpenSSL for Qt's TLS backend. Android lint runs once the first APK has staged the Qt jars it needs. The APK -# with libmudlet as its engine is built in libmudlet.yml. +# The app's debug APK for arm64-v8a with the stand-in engine, after the stand-in's desktop test, the Kotlin +# protocol tests (against that desktop engine too) and the command suggestions' tests, and a second time with its +# libraries extracted at install. Both carry OpenSSL for Qt's TLS backend. Android lint runs once the first APK has +# staged the Qt jars it needs. The APK with libmudlet as its engine is built in libmudlet.yml. # ci.yml calls it when a file it builds from changed; a manual run builds them on any branch. on: workflow_call: @@ -59,6 +59,11 @@ jobs: QT_QPA_PLATFORM: offscreen run: native/tests/run_jvm_client_test.sh -PqtRoot="$QT_ROOT" -PandroidAbi=arm64-v8a + # :suggest's tests: ranking, prediction, the saved form, hidden input never stored + - name: Kotlin suggestion tests + if: matrix.tests + run: ./gradlew -PqtRoot="$QT_ROOT" :suggest:test + - uses: ./.github/actions/android-openssl with: ndk-version: ${{ steps.toolchain.outputs.ndk-version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09c41d4..f21a23c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ name: CI # What runs for what changed (the filters in "changes" below say it exactly): # docs, README and the like nothing but this workflow's own small jobs (docs/protocol.md: the APKs, whose # ProtocolSpecTest reads it) -# Kotlin (app/, engine-service/, protocol/) +# Kotlin (app/, engine-service/, protocol/, suggest/) # ktlint, the APKs with Android lint, and the emulator suite (smoke test and the # instrumented tests, on minSdk and the newest API) with libmudlet from its cache # native/, the shared actions their lint check (clang-format, actionlint), the APKs, the emulator suite, the @@ -93,6 +93,7 @@ jobs: - app/** - engine-service/** - protocol/** + - suggest/** ktlint: - "**/*.kt" - "**/*.kts" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6acd6b6..f673336 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ What the app is and how it is built is in [`README.md`](README.md) and [`docs/ar | `app/` | `:app`, the UI process: Kotlin and Compose, no Qt | | `engine-service/` | `:engine-service`: `EngineService` in the `:engine` process, and the Qt libraries `scripts/build-apk.sh` stages into `engine-service/qtdeploy/` | | `protocol/` | `:protocol`, plain Kotlin/JVM: the socket protocol and `EngineLink`, with the JVM tests | +| `suggest/` | `:suggest`, plain Kotlin/JVM with no dependencies: the input line's command suggestions, with the JVM tests | | `native/` | C++: the engine host (`enginehost/`), the engine interface (`api/`), the stand-in engine (`standin/`), the host tests (`tests/`); `third_party/` is vendored Lua 5.1.5 | | `scripts/` | Builds (APK, release, OpenSSL, libmudlet and its dependencies), `check-apk.sh` and `lint.sh` | | `docs/` | [`architecture.md`](docs/architecture.md), [`protocol.md`](docs/protocol.md), [`ptb.md`](docs/ptb.md), [`decisions/`](docs/decisions), [`foundation-checklist.md`](docs/foundation-checklist.md), [`roadmap.md`](docs/roadmap.md) | @@ -65,6 +66,10 @@ The public test builds, their versions and signing, and what the maintainer sets # :protocol's JVM tests without an engine (EngineLinkJvmTest is then skipped) ./gradlew :protocol:test +# :suggest's tests, with Gradle or with kotlinc and JUnit 4 alone (no Android SDK) +./gradlew :suggest:test +KOTLIN_HOME=path/to/kotlinc JUNIT_JARS=junit-4.13.2.jar:hamcrest-core-1.3.jar suggest/run-tests.sh + # The stand-in engine on the host, then :protocol's tests with the Kotlin client against it cmake -S native -B native/build-desktop -G Ninja -DCMAKE_PREFIX_PATH=$QT_ROOT/gcc_64 cmake --build native/build-desktop --target engine diff --git a/docs/architecture.md b/docs/architecture.md index 9a29a89..8c57125 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,6 +29,7 @@ Both rely on Qt private API (`Qt6::CorePrivate`), which ties the build to one ex | `app/` (`:app`) | The UI process: `MudletApp`, `ui/MainActivity.kt` with the screens it navigates between (`ui/AppNavigation.kt`: the profile list, the game screen in `ui/game/` (the console, the input line and the connection bar) and, in debug builds, the self-test in `ui/selftest/`), the renderer of game text (`ui/AnsiText.kt`), the theme (`ui/theme/`), and the model of the engine (`ui/EngineClient.kt`). Every string the app shows is in `res/values/strings.xml` | | `engine-service/` (`:engine-service`) | The engine process's Kotlin side: `EngineService` (a `QtServiceBase`), its notifications, `EngineNative` (the JNI handshake). Its manifest declares the service and the permissions. It also carries the Qt libraries and jars that `scripts/build-apk.sh` stages into `engine-service/qtdeploy/` | | `protocol/` (`:protocol`) | Plain Kotlin/JVM with no `android.*`: the frame codec, message types and `EngineLink`, the reconnecting client. Its tests run on the JVM, against the desktop engine too | +| `suggest/` (`:suggest`) | Plain Kotlin/JVM with no dependencies: the input line's command suggestions ("Command suggestions" below). Its tests run on the JVM, with Gradle or with kotlinc alone (`suggest/run-tests.sh`) | | `native/enginehost/` | `libengine`'s `main()`, `SocketBridge` (the protocol's engine side), `MudletEngine` (libmudlet through `THeadlessSession`), and the Android glue: `AndroidServiceSetup` (the only private Qt API, so a rename in Qt fails the build) and `AndroidNotifier` | | `native/api/` | `IEngine` and `IFrontend`, between the host and an engine | | `native/standin/` | The stand-in engine for fast tests: minimal telnet, ANSI runs, Lua 5.1.5 (vendored in `native/third_party/lua-5.1.5`, tarball sha256 `2640fc56…695333`), a font check and a heartbeat | @@ -108,6 +109,35 @@ backgrounded UI process mid-read. Verified on the host: - With a stalled reader, the engine kept running at 42-60 MB RSS. When the reader resumed it received 1999 lines and one `gap`. +### Command suggestions +`:suggest` ranks what the input line offers as the player types. It runs in the UI process and only on the +device: no network, no engine round trip, so it can answer every (debounced) keystroke. One `CommandSuggester` +per profile, from `SuggesterRegistry.forProfile(name)`: + +- `recordSent(text, hidden)` after each send. With `hidden = true` (the game asked for a password) it stores + nothing, and the next command is not linked to the one before it. +- `observeLine(text)` for each game line, without its colours. +- `suggest(input, cursor, limit)` returns `Suggestions`: `items`, best first and one per resulting text, each a + `Suggestion` with `text` (the whole input line it leads to), `cursor`, `label`, `kind` (`COMMAND` or `WORD`) and + `source` (`HISTORY`, `SEQUENCE`, `LINE` or `OUTPUT`); and `ghost`, the text to show greyed after the cursor + when the best item only extends the input. +- `save(Appendable)` and `load(Reader)`, a line-based text (`SuggestCodec`) the app keeps in the profile's + private storage; a damaged line is skipped, and `load`'s `LoadResult` says how many were and whether the text + was this format at all. `forget(command)` and `clear()` for the player. + +What it ranks: + +| Source | When | How | +|---|---|---| +| History | Input typed | Case-insensitive subsequence match (`FuzzyMatch`): a prefix first, then word starts, then runs; plus frecency, each use weighing half as much every 7 days | +| Sequence | Empty input; lifts history while typing | Counts of what the player sent after the previous command, and after any command with its first word ("kill rat", "kill orc", then "loot") | +| Line | Empty input; lifts history while typing | Counts of what the player sent after a game line, the last three lines since the previous send, with numbers as `#` and capitalised words after the first as `*` | +| Output | A word at the cursor | Words of the last 2000 words of output that extend it, newest first | + +Everything is bounded (5000 commands, 4000 and 2000 prediction keys of 8 commands each), and counts halve as they +grow, so recent play outweighs old. All calls take one lock; `suggest` is O(entries × command length): 1 to 4 ms +for 5000 entries on a desktop JVM, so the app calls it debounced and off the main thread. + ## libmudlet as the engine Built with `LIBMUDLET_BUILD_DIR`, the engine is `MudletEngine` (`native/enginehost/MudletEngine.*`): one profile at a time in a `THeadlessSession` behind the same socket protocol. It uses only that class's diff --git a/docs/roadmap.md b/docs/roadmap.md index 1490a1e..b7fac56 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -52,7 +52,10 @@ The first usable client has: the bottom" button with a count of new lines. - The input line: Send on the keyboard's action key, the keyboard staying open after a send, history (the engine's, so the profile's earlier sessions count), Tab completion as a button, the text a script sets - (`printCmdLine()`), and a masked field while the game hides input. + (`printCmdLine()`), and a masked field while the game hides input. Suggestions as the player types, since typing + is the slowest part of playing on a phone: history matched loosely ("gc" for "get coins from corpse"), the + command that usually comes next, and words from the game's output (`:suggest`, architecture.md, "Command + suggestions"). - The soft keyboard: the console shrinks above it and keeps its bottom line in view; rotation and split screen keep the scroll position and the typed text. - A connection bar: connecting, connected, disconnected, with Reconnect and Disconnect. @@ -176,6 +179,8 @@ come after #31, whose fake engine (`FakeEngine`) and emulator jobs run the app's | U7 | Links: tap, menu, accessibility actions | U2, P4 | | P5 | App state and save on background | P4 | | U8 | Background: notification with profile and connection state, Disconnect action, app state to the engine | U2, P5 | +| S1 | Command suggestions' ranking core, `:suggest`: plain Kotlin, no UI | - | +| S2 | Suggestions in the input line: a row of candidates and ghost text, saved per profile | U4, S1 | ``` #31 ─┬─ U1 ─┬─ U2 ─┬───────────────── U4, U5, U6, U7, U8 (each also needs its P) diff --git a/settings.gradle.kts b/settings.gradle.kts index 6aad58b..19653fe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,4 +17,4 @@ dependencyResolutionManagement { } } rootProject.name = "mudlet-android" -include(":protocol", ":engine-service", ":app") +include(":protocol", ":suggest", ":engine-service", ":app") diff --git a/suggest/build.gradle.kts b/suggest/build.gradle.kts new file mode 100644 index 0000000..f94f96e --- /dev/null +++ b/suggest/build.gradle.kts @@ -0,0 +1,25 @@ +// Command-entry suggestions (docs/architecture.md, "Command suggestions"): plain Kotlin with no android.* and no +// dependencies, so its tests run on the JVM, with Gradle or with kotlinc alone (suggest/run-tests.sh) +plugins { + alias(libs.plugins.kotlin.jvm) +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) +} + +kotlin { + jvmToolchain(libs.versions.jdk.get().toInt()) + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(libs.versions.jvmTarget.get())) } +} + +dependencies { + testImplementation(libs.junit) +} + +tasks.test { + testLogging { + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/suggest/run-tests.sh b/suggest/run-tests.sh new file mode 100755 index 0000000..d9418bc --- /dev/null +++ b/suggest/run-tests.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Runs :suggest's unit tests with kotlinc and JUnit 4 alone, without Gradle or the Android SDK. CI runs them +# with ./gradlew :suggest:test. +# KOTLIN_HOME the unpacked kotlin-compiler release (default: kotlinc on PATH) +# JUNIT_JARS junit-4.13.2.jar and hamcrest-core-1.3.jar, colon separated +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +kotlinc=${KOTLIN_HOME:+$KOTLIN_HOME/bin/}kotlinc +stdlib=${KOTLIN_HOME:+$KOTLIN_HOME/lib/kotlin-stdlib.jar} +stdlib=${stdlib:-$(dirname "$(readlink -f "$(command -v kotlinc)")")/../lib/kotlin-stdlib.jar} +: "${JUNIT_JARS:?set JUNIT_JARS to junit-4.13.2.jar:hamcrest-core-1.3.jar}" +out=$(mktemp -d) +trap 'rm -rf "$out"' EXIT +"$kotlinc" -nowarn -cp "$JUNIT_JARS" -d "$out" "$here/src/main/kotlin" "$here/src/test/kotlin" 2>&1 \ + | grep -v '^warning: unable to find kotlin-stdlib' || true +[ -n "$(find "$out" -name '*Test.class' -print -quit)" ] || { echo "compilation failed" >&2; exit 1; } +classes=$(cd "$out" && find . -name '*Test.class' | sed 's|^\./||; s|\.class$||; s|/|.|g' | sort) +# shellcheck disable=SC2086 # one argument per test class +java -cp "$out:$stdlib:$JUNIT_JARS" org.junit.runner.JUnitCore $classes diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt new file mode 100644 index 0000000..44a4466 --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt @@ -0,0 +1,312 @@ +package org.mudlet.android.suggest + +import java.io.Reader +import kotlin.math.exp +import kotlin.math.ln + +/** + * Suggestions for one profile's input line. It learns from what the player sends ([recordSent]) and what + * the game shows ([observeLine]), and answers [suggest] with candidates from four sources: + * + * - history, fuzzy matched against the input and ranked by frecency (use count, decayed by age); + * - with an empty input, the commands the player usually sends after their previous command ("kill rat", + * then "loot"), and after game lines like the latest ones ("The door is closed.", then "open door"); + * with an input, those predictions lift the history entries they name; + * - words from recent game output that complete the word at the cursor. + * + * Hidden input (a password) goes through `recordSent(text, hidden = true)`, which stores nothing at all. + * The state stays in memory and in what [save] writes; nothing here touches the network or the disk. + * + * Thread-safe: every call holds one lock. [suggest] costs O(H·m) for H history entries of m characters to + * reject the ones the input is not a subsequence of, plus O(q·m) per entry that matches a q-character input: + * 1 to 4 ms for 5000 entries on a desktop JVM, so call it debounced and off the main thread. + * [recordSent] is O(1) amortised, plus O(H + K) for K prediction keys when the history is full and one entry + * is evicted. + */ +class CommandSuggester( + private val config: Config = Config(), + private val clock: () -> Long = System::currentTimeMillis, +) { + /** + * The caps that bound memory and the saved state, and the frecency half-life. + * + * @property maxHistory commands kept; the least frecent goes first + * @property maxCommandLength longer commands are not recorded + * @property maxSequenceKeys previous commands (and first words) whose successors are counted + * @property maxLineKeys game lines whose successors are counted + * @property maxNextPerKey successors counted per previous command or game line + * @property outputWordWindow how many words of recent output offer completions + * @property lineContext how many of the game lines since the last send predict the next one + * @property halfLifeMillis how long until a use counts half in the frecency + */ + data class Config( + val maxHistory: Int = 5000, + val maxCommandLength: Int = 256, + val maxSequenceKeys: Int = 4000, + val maxLineKeys: Int = 2000, + val maxNextPerKey: Int = 8, + val outputWordWindow: Int = 2000, + val lineContext: Int = 3, + val halfLifeMillis: Long = 7L * 24 * 60 * 60 * 1000, + ) { + init { + require(maxHistory > 0 && maxCommandLength > 0 && maxSequenceKeys > 0 && maxLineKeys > 0) + require(maxNextPerKey > 0 && outputWordWindow > 0 && lineContext > 0 && halfLifeMillis > 0) + } + } + + internal class HistoryEntry(var text: String, var weight: Double, var weightAt: Long, var count: Int) + + private val lock = Any() + + // normalised command -> entry + private val history = HashMap() + private val sequences = TransitionTable(config.maxSequenceKeys, config.maxNextPerKey) + private val lineResponses = TransitionTable(config.maxLineKeys, config.maxNextPerKey) + private val outputWords = OutputWords(config.outputWordWindow) + + // the normalised previous command, null after hidden input or before the first send + private var previous: String? = null + + // normalised game lines since the last send, newest last + private val recentLines = ArrayDeque() + + /** + * Learns from a command the player sent. With [hidden] (the game asked for a password) nothing is + * stored, and the next command is not linked to the one before the hidden input either. + */ + fun recordSent(text: String, hidden: Boolean = false) = synchronized(lock) { + if (hidden) { + previous = null + recentLines.clear() + return@synchronized + } + val display = text.trim() + val key = normalizeCommand(display) + if (key.isEmpty() || display.length > config.maxCommandLength) return@synchronized + val now = clock() + val entry = history[key] + if (entry == null) { + history[key] = HistoryEntry(display, 1.0, now, 1) + if (history.size > config.maxHistory) evictHistory(now) + } else { + entry.weight = decayed(entry, now) + 1.0 + entry.weightAt = now + entry.count++ + entry.text = display + } + previous?.let { prev -> + sequences.add(SEQUENCE_KEY + prev, key) + sequences.add(FIRST_WORD_KEY + firstWord(prev), key) + } + for (line in recentLines) lineResponses.add(line, key) + previous = key + recentLines.clear() + } + + /** Learns from a line the game showed: its words for completion, and it as context for the next send. */ + fun observeLine(line: String) = synchronized(lock) { + outputWords.add(line) + val key = normalizeLine(line) ?: return@synchronized + recentLines.remove(key) + recentLines.addLast(key) + while (recentLines.size > config.lineContext) recentLines.removeFirst() + } + + /** The candidates for [input] with the cursor at [cursor], best first, at most [limit]. */ + fun suggest(input: String, cursor: Int = input.length, limit: Int = 8): Suggestions = synchronized(lock) { + require(cursor in 0..input.length) { "cursor $cursor outside the input" } + require(limit >= 0) { "negative limit $limit" } + val now = clock() + val predicted = predictions() + val best = HashMap() + + fun offer(s: Suggestion) { + if (s.text == input) return + val old = best[s.text] + if (old == null || s.score > old.score) best[s.text] = s + } + + fun offerCommand(text: String, source: SuggestionSource, score: Double) = offer(Suggestion(text, text.length, text, SuggestionKind.COMMAND, source, score)) + if (input.isBlank()) { + for ((key, prediction) in predicted) { + offerCommand(history[key]?.text ?: key, prediction.source, PREDICTION_WEIGHT * prediction.score) + } + for ((key, entry) in history) { + if (key !in predicted) offerCommand(entry.text, SuggestionSource.HISTORY, frecencyScore(entry, now)) + } + } else { + for ((key, entry) in history) { + val match = FuzzyMatch.score(input, entry.text) ?: continue + val prediction = predicted[key] + val score = match + frecencyScore(entry, now) + CONTEXT_WEIGHT * (prediction?.score ?: 0.0) + val source = if (prediction != null && prediction.score >= CONTEXT_SOURCE_MIN) prediction.source else SuggestionSource.HISTORY + offerCommand(entry.text, source, score) + } + wordCompletions(input, cursor).forEach(::offer) + } + val items = best.values.sortedWith(compareByDescending { it.score }.thenBy { it.text }).take(limit) + Suggestions.of(input, cursor, items) + } + + /** Forgets a command: its history entry and every prediction of it. */ + fun forget(command: String) = synchronized(lock) { + val key = normalizeCommand(command) + history.remove(key) + sequences.removeNext(key) + sequences.removeKey(SEQUENCE_KEY + key) + lineResponses.removeNext(key) + if (previous == key) previous = null + } + + /** Forgets everything this profile learned. */ + fun clear() = synchronized(lock) { + history.clear() + sequences.clear() + lineResponses.clear() + outputWords.clear() + previous = null + recentLines.clear() + } + + /** + * Writes what was learned for [load]: the history, the predictions and the previous command, but not the + * session's output words or game lines. + */ + fun save(out: Appendable) = synchronized(lock) { + SuggestCodec.write(out, history.values, previous, sequences, lineResponses) + } + + /** What [load] made of a saved text. */ + data class LoadResult( + /** False when the text was not [save]'s format or version: the state is then empty. */ + val recognised: Boolean, + /** Lines that did not parse and were skipped. */ + val skippedLines: Int, + ) + + /** + * Replaces the state with what [save] wrote. Lines that do not parse are skipped, and a text of another + * format or version leaves the state empty; the caps apply as entries are read. An I/O error from [input] + * propagates, with what was read before it kept. + */ + fun load(input: Reader): LoadResult = synchronized(lock) { + clear() + val result = SuggestCodec.read(input) { record -> + when (record) { + is SuggestCodec.Record.History -> { + val key = normalizeCommand(record.text) + if (key.isNotEmpty() && record.text.length <= config.maxCommandLength && key !in history) { + history[key] = HistoryEntry(record.text, record.weight, record.weightAt, record.count) + } + } + + is SuggestCodec.Record.Previous -> previous = normalizeCommand(record.key).ifEmpty { null } + + is SuggestCodec.Record.Sequence -> sequences.restore(record.key, record.next, record.count) + + is SuggestCodec.Record.Line -> lineResponses.restore(record.key, record.next, record.count) + } + } + val now = clock() + while (history.size > config.maxHistory) evictHistory(now) + result + } + + internal val historySize: Int get() = synchronized(lock) { history.size } + + private class Prediction(var score: Double, var source: SuggestionSource, var best: Double) + + // normalised command -> how strongly the previous command and the recent lines predict it, 0 to about 3 + private fun predictions(): Map { + val result = HashMap() + + fun add(next: String, p: Double, source: SuggestionSource) { + val entry = result.getOrPut(next) { Prediction(0.0, source, 0.0) } + entry.score += p + if (p > entry.best) { + entry.best = p + entry.source = source + } + } + previous?.let { prev -> + sequences.probabilities(SEQUENCE_KEY + prev).forEach { (next, p) -> add(next, FULL_WEIGHT * p, SuggestionSource.SEQUENCE) } + sequences.probabilities(FIRST_WORD_KEY + firstWord(prev)).forEach { (next, p) -> add(next, WORD_WEIGHT * p, SuggestionSource.SEQUENCE) } + } + var weight = 1.0 + for (line in recentLines.asReversed()) { + lineResponses.probabilities(line).forEach { (next, p) -> add(next, weight * p, SuggestionSource.LINE) } + weight *= LINE_DECAY + } + return result + } + + private fun wordCompletions(input: String, cursor: Int): List { + var start = cursor + while (start > 0 && !input[start - 1].isWhitespace()) start-- + var end = cursor + while (end < input.length && !input[end].isWhitespace()) end++ + val prefix = input.substring(start, cursor) + if (prefix.isEmpty()) return emptyList() + val whole = input.substring(start, end) + val penalty = if (start == 0) FIRST_WORD_PENALTY else 0.0 + return outputWords.completions(prefix).filterNot { it.first.equals(whole, ignoreCase = true) }.map { (word, recency) -> + val text = input.substring(0, start) + word + input.substring(end) + Suggestion(text, start + word.length, word, SuggestionKind.WORD, SuggestionSource.OUTPUT, WORD_BASE + WORD_RECENCY * recency - penalty) + } + } + + private fun decayed(entry: HistoryEntry, now: Long): Double { + val age = (now - entry.weightAt).coerceAtLeast(0) + return entry.weight * exp(-LN2 * age / config.halfLifeMillis) + } + + private fun frecencyScore(entry: HistoryEntry, now: Long) = FRECENCY_WEIGHT * ln(1.0 + decayed(entry, now)) + + private fun evictHistory(now: Long) { + val victim = history.entries.minByOrNull { decayed(it.value, now) }?.key ?: return + history.remove(victim) + sequences.removeNext(victim) + sequences.removeKey(SEQUENCE_KEY + victim) + lineResponses.removeNext(victim) + } + + internal companion object { + const val SEQUENCE_KEY = "c:" + const val FIRST_WORD_KEY = "w:" + const val FULL_WEIGHT = 1.0 + const val WORD_WEIGHT = 0.4 + const val LINE_DECAY = 0.5 + const val PREDICTION_WEIGHT = 100.0 + const val CONTEXT_WEIGHT = 40.0 + const val CONTEXT_SOURCE_MIN = 0.25 + const val FRECENCY_WEIGHT = 8.0 + const val WORD_BASE = 60.0 + const val WORD_RECENCY = 20.0 + const val FIRST_WORD_PENALTY = 20.0 + val LN2 = ln(2.0) + private val SPACES = Regex("\\s+") + private val DIGITS = Regex("\\d+") + + /** Trimmed, lower case, runs of white space as one space: "Kill Rat" and "kill rat" are one command. */ + fun normalizeCommand(text: String) = text.trim().replace(SPACES, " ").lowercase() + + fun firstWord(normalized: String) = normalized.substringBefore(' ') + + /** + * A game line as a key that recurs: lower case, numbers as "#", and capitalised words after the first + * as "*", since those are mostly names: "Ann hits Bob for 12." gives "ann hits * for #.". Null for a + * line with too few letters to mean much (a bare prompt like "<120 45>"). + */ + fun normalizeLine(line: String): String? { + val words = line.trim().split(SPACES).filter { it.isNotEmpty() }.take(MAX_LINE_WORDS) + val key = words.mapIndexed { i, word -> + if (i > 0 && word[0].isUpperCase()) "*" + word.dropWhile(Char::isLetter) else DIGITS.replace(word.lowercase(), "#") + }.joinToString(" ") + return key.takeIf { it.count(Char::isLetter) >= MIN_LINE_LETTERS } + } + + private const val MAX_LINE_WORDS = 16 + private const val MIN_LINE_LETTERS = 3 + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/FuzzyMatch.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/FuzzyMatch.kt new file mode 100644 index 0000000..55fea61 --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/FuzzyMatch.kt @@ -0,0 +1,69 @@ +package org.mudlet.android.suggest + +/** + * Scores how well a typed query matches a candidate as a case-insensitive subsequence: "gc" matches + * "get coins from corpse". A prefix scores highest, then matches on word starts, then runs of consecutive + * characters; scattered characters and gaps score least. `null` means no match. + * + * O(q·m) time and O(m) memory for a query of q characters and a candidate of m, after an O(m) check that + * rejects most candidates first. + */ +object FuzzyMatch { + /** What a candidate that starts with the query gets on top of its character scores. */ + const val PREFIX_BONUS = 100.0 + + private const val MATCH = 1.0 + private const val WORD_START = 6.0 + private const val TEXT_START = 4.0 + private const val CONSECUTIVE = 4.0 + private const val GAP = 1.0 + private const val LEADING = 0.2 + private const val NONE = -1e9 + + fun score(query: String, candidate: String): Double? { + if (query.isEmpty()) return 0.0 + val q = CharArray(query.length) { query[it].lowercaseChar() } + val c = CharArray(candidate.length) { candidate[it].lowercaseChar() } + if (!isSubsequence(q, c)) return null + // best[j]: the best score for matching q[0..i] with q[i] at c[j] + var previous = DoubleArray(c.size) { NONE } + var current = DoubleArray(c.size) + for (i in q.indices) { + // max over k <= j-2 of previous[k] minus the gap between k and j + var gapped = NONE + for (j in c.indices) { + if (j >= 2) gapped = maxOf(gapped, previous[j - 2]) - GAP + current[j] = NONE + if (c[j] != q[i]) continue + val bonus = charScore(c, j) + if (i == 0) { + current[j] = bonus - LEADING * j + } else { + val adjacent = if (j >= 1) previous[j - 1] + CONSECUTIVE else NONE + val best = maxOf(gapped, adjacent) + if (best > NONE / 2) current[j] = best + bonus + } + } + val swap = previous + previous = current + current = swap + } + val best = previous.max() + val prefix = candidate.regionMatches(0, query, 0, query.length, ignoreCase = true) + return best + (if (prefix) PREFIX_BONUS else 0.0) + } + + private fun isSubsequence(q: CharArray, c: CharArray): Boolean { + var i = 0 + for (ch in c) { + if (ch == q[i] && ++i == q.size) return true + } + return false + } + + private fun charScore(c: CharArray, j: Int): Double = when { + j == 0 -> MATCH + WORD_START + TEXT_START + !c[j - 1].isLetterOrDigit() && c[j].isLetterOrDigit() -> MATCH + WORD_START + else -> MATCH + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/OutputWords.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/OutputWords.kt new file mode 100644 index 0000000..96e4dcf --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/OutputWords.kt @@ -0,0 +1,58 @@ +package org.mudlet.android.suggest + +/** + * The words of the last [window] words of game output, for completing the word at the cursor (what Mudlet's + * Tab does, but several candidates at once). Adding a line is O(its words); [completions] is O(distinct words + * in the window). Not thread-safe; [CommandSuggester] holds the lock. + */ +internal class OutputWords(private val window: Int) { + private class Word(var display: String, var count: Int, var lastSeen: Long) + + // lower case word, oldest first + private val order = ArrayDeque() + private val words = HashMap() + private var seen = 0L + + fun add(line: String) { + for (match in WORD.findAll(line)) { + val display = match.value.trim('\'', '-') + if (display.length < MIN_LENGTH || display.all(Char::isDigit)) continue + val key = display.lowercase() + val word = words.getOrPut(key) { Word(display, 0, 0) } + word.display = display + word.count++ + word.lastSeen = ++seen + order.addLast(key) + if (order.size > window) { + val old = order.removeFirst() + val evicted = words.getValue(old) + if (--evicted.count == 0) words.remove(old) + } + } + } + + /** + * The words that start with [prefix] (ignoring case) and are longer, newest first, each with its recency: + * 1 for the newest word in the window, near 0 for the oldest. + */ + fun completions(prefix: String, limit: Int = MAX_COMPLETIONS): List> { + if (words.isEmpty()) return emptyList() + val oldest = seen - order.size + return words.values + .filter { it.display.length > prefix.length && it.display.startsWith(prefix, ignoreCase = true) } + .sortedByDescending { it.lastSeen } + .take(limit) + .map { it.display to (it.lastSeen - oldest).toDouble() / order.size } + } + + fun clear() { + order.clear() + words.clear() + } + + private companion object { + val WORD = Regex("[\\p{L}\\p{N}_'-]+") + const val MIN_LENGTH = 3 + const val MAX_COMPLETIONS = 8 + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestCodec.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestCodec.kt new file mode 100644 index 0000000..c4224c0 --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestCodec.kt @@ -0,0 +1,129 @@ +package org.mudlet.android.suggest + +import java.io.BufferedReader +import java.io.Reader + +/** + * The saved form of a [CommandSuggester]: UTF-8 text, one record per line, fields separated by tabs, with + * backslash, tab, newline and carriage return escaped as `\\`, `\t`, `\n` and `\r`: + * + * ``` + * mudlet-suggest 1 + * h + * p + * s + * l + * ``` + * + * A reader skips any line that does not parse, and reads nothing from a text whose first line is not the + * header, so a damaged file loses only its damaged records. + */ +internal object SuggestCodec { + const val HEADER = "mudlet-suggest 1" + + sealed interface Record { + data class History(val text: String, val count: Int, val weight: Double, val weightAt: Long) : Record + + data class Previous(val key: String) : Record + + data class Sequence(val key: String, val next: String, val count: Double) : Record + + data class Line(val key: String, val next: String, val count: Double) : Record + } + + fun write( + out: Appendable, + history: Collection, + previous: String?, + sequences: TransitionTable, + lines: TransitionTable, + ) { + out.append(HEADER).append('\n') + for (entry in history) { + line(out, "h", entry.count.toString(), entry.weight.toString(), entry.weightAt.toString(), entry.text) + } + previous?.let { line(out, "p", it) } + for ((key, next, count) in sequences.entries()) line(out, "s", key, next, count.toString()) + for ((key, next, count) in lines.entries()) line(out, "l", key, next, count.toString()) + } + + fun read(input: Reader, onRecord: (Record) -> Unit): CommandSuggester.LoadResult { + val reader = input as? BufferedReader ?: BufferedReader(input) + if (reader.readLine() != HEADER) return CommandSuggester.LoadResult(recognised = false, skippedLines = 0) + var skipped = 0 + while (true) { + val line = reader.readLine() ?: break + val record = parse(line) + if (record == null) skipped++ else onRecord(record) + } + return CommandSuggester.LoadResult(recognised = true, skippedLines = skipped) + } + + internal fun parse(line: String): Record? { + val fields = line.split('\t').map { unescape(it) ?: return null } + return when (fields.firstOrNull()) { + "h" -> { + if (fields.size != 5) return null + val count = fields[1].toIntOrNull()?.takeIf { it > 0 } ?: return null + val weight = fields[2].toDoubleOrNull()?.takeIf { it.isFinite() && it > 0 } ?: return null + val weightAt = fields[3].toLongOrNull() ?: return null + fields[4].takeIf { it.isNotBlank() }?.let { Record.History(it, count, weight, weightAt) } + } + + "p" -> fields.takeIf { it.size == 2 && it[1].isNotBlank() }?.let { Record.Previous(it[1]) } + + "s", "l" -> { + if (fields.size != 4 || fields[1].isEmpty() || fields[2].isEmpty()) return null + val count = fields[3].toDoubleOrNull()?.takeIf { it.isFinite() } ?: return null + if (fields[0] == "s") Record.Sequence(fields[1], fields[2], count) else Record.Line(fields[1], fields[2], count) + } + + else -> null + } + } + + private fun line(out: Appendable, vararg fields: String) { + fields.forEachIndexed { i, field -> + if (i > 0) out.append('\t') + escape(out, field) + } + out.append('\n') + } + + private fun escape(out: Appendable, field: String) { + for (ch in field) { + when (ch) { + '\\' -> out.append("\\\\") + '\t' -> out.append("\\t") + '\n' -> out.append("\\n") + '\r' -> out.append("\\r") + else -> out.append(ch) + } + } + } + + // null for a dangling or unknown escape + private fun unescape(field: String): String? { + if ('\\' !in field) return field + val out = StringBuilder(field.length) + var i = 0 + while (i < field.length) { + val ch = field[i++] + if (ch != '\\') { + out.append(ch) + continue + } + if (i == field.length) return null + out.append( + when (field[i++]) { + '\\' -> '\\' + 't' -> '\t' + 'n' -> '\n' + 'r' -> '\r' + else -> return null + }, + ) + } + return out.toString() + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggesterRegistry.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggesterRegistry.kt new file mode 100644 index 0000000..43323ff --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggesterRegistry.kt @@ -0,0 +1,18 @@ +package org.mudlet.android.suggest + +import java.util.concurrent.ConcurrentHashMap + +/** + * One [CommandSuggester] per profile, so what one game taught never shows in another. The app loads each + * from, and saves it to, that profile's private storage ([CommandSuggester.load], [CommandSuggester.save]). + */ +class SuggesterRegistry(private val create: (profile: String) -> CommandSuggester = { CommandSuggester() }) { + private val suggesters = ConcurrentHashMap() + + fun forProfile(profile: String): CommandSuggester = suggesters.computeIfAbsent(profile, create) + + /** Drops a profile's suggester, for a deleted profile; its saved file is the app's to delete. */ + fun remove(profile: String) { + suggesters.remove(profile) + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggestion.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggestion.kt new file mode 100644 index 0000000..ebadaec --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggestion.kt @@ -0,0 +1,53 @@ +package org.mudlet.android.suggest + +/** Whether a suggestion is a whole command or completes the word at the cursor. */ +enum class SuggestionKind { COMMAND, WORD } + +/** Where a suggestion came from; the best-scoring source wins when two give the same text. */ +enum class SuggestionSource { + /** A command the player sent before, matched against the input. */ + HISTORY, + + /** What the player usually sends after their previous command. */ + SEQUENCE, + + /** What the player sent before after a game line like the latest ones. */ + LINE, + + /** A word from recent game output, completing the word at the cursor. */ + OUTPUT, +} + +/** + * One candidate for the input line. [text] is the whole input line it leads to, with the cursor at [cursor]; + * [label] is what to show (the command, or the completed word). + */ +data class Suggestion( + val text: String, + val cursor: Int, + val label: String, + val kind: SuggestionKind, + val source: SuggestionSource, + val score: Double, +) + +/** + * The ranked, de-duplicated candidates for an input, best first. [ghost] is set when the best one only adds + * text after the cursor at the end of the input, so the UI can show that text greyed after the cursor (fish + * style) and accept it with one key: it is the text to append. + */ +data class Suggestions( + val input: String, + val items: List, + val ghost: String?, +) { + companion object { + internal fun of(input: String, cursor: Int, items: List): Suggestions { + val top = items.firstOrNull() + val ghost = top?.text?.takeIf { + cursor == input.length && input.isNotBlank() && it.length > input.length && it.startsWith(input) + }?.substring(input.length) + return Suggestions(input, items, ghost) + } + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/TransitionTable.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/TransitionTable.kt new file mode 100644 index 0000000..69a1ad8 --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/TransitionTable.kt @@ -0,0 +1,69 @@ +package org.mudlet.android.suggest + +/** + * Counts of what followed what: a key (a previous command, a game line) to the commands sent next. Bounded: + * at most [maxKeys] keys, the least recently used (updated or asked about) evicted first, and [maxNextPerKey] commands per key, the + * least counted evicted first. A key's counts halve once they add up to more than [AGE_AT], so what the player + * does now outweighs what they did months ago. Not thread-safe; [CommandSuggester] holds the lock. + */ +internal class TransitionTable(private val maxKeys: Int, private val maxNextPerKey: Int) { + // access order, so the eldest entry is the least recently used key + private val table = object : LinkedHashMap>(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>) = size > maxKeys + } + + val keys: Int get() = table.size + + fun add(key: String, next: String) { + val counts = table.getOrPut(key) { LinkedHashMap() } + counts[next] = (counts[next] ?: 0.0) + 1.0 + if (counts.size > maxNextPerKey) { + val weakest = counts.entries.filter { it.key != next }.minBy { it.value }.key + counts.remove(weakest) + } + if (counts.values.sum() > AGE_AT) { + counts.replaceAll { _, count -> count / 2 } + counts.values.removeAll { it < MIN_COUNT } + } + } + + /** Puts back a count [entries] gave, for loading saved state; bad counts are ignored. */ + fun restore(key: String, next: String, count: Double) { + if (!(count >= MIN_COUNT && count <= AGE_AT)) return + val counts = table.getOrPut(key) { LinkedHashMap() } + if (counts.size >= maxNextPerKey && next !in counts) return + counts[next] = count + } + + /** What followed [key], each with its share smoothed by one unseen observation: one sighting gives 0.5. */ + fun probabilities(key: String): Map { + val counts = table[key] ?: return emptyMap() + val total = counts.values.sum() + 1.0 + return counts.mapValues { it.value / total } + } + + fun removeNext(next: String) { + val iterator = table.values.iterator() + while (iterator.hasNext()) { + val counts = iterator.next() + counts.remove(next) + if (counts.isEmpty()) iterator.remove() + } + } + + fun removeKey(key: String) { + table.remove(key) + } + + fun clear() = table.clear() + + /** Every (key, next, count), least recently used key first, so restoring them keeps the order. */ + fun entries(): Sequence> = table.entries.asSequence().flatMap { (key, counts) -> + counts.entries.asSequence().map { Triple(key, it.key, it.value) } + } + + private companion object { + const val AGE_AT = 64.0 + const val MIN_COUNT = 0.25 + } +} diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/CommandSuggesterTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/CommandSuggesterTest.kt new file mode 100644 index 0000000..ba80a24 --- /dev/null +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/CommandSuggesterTest.kt @@ -0,0 +1,343 @@ +package org.mudlet.android.suggest + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CommandSuggesterTest { + private var now = 1_000_000_000L + private val day = 24L * 60 * 60 * 1000 + + private fun suggester(config: CommandSuggester.Config = CommandSuggester.Config()) = CommandSuggester(config) { now } + + private fun CommandSuggester.texts(input: String, cursor: Int = input.length) = suggest(input, cursor).items.map { it.text } + + private fun CommandSuggester.send(vararg commands: String) = commands.forEach { + recordSent(it) + now += 1000 + } + + @Test + fun historyIsFuzzyMatched() { + val s = suggester() + s.send("get coins from corpse", "look", "say hello") + assertEquals("get coins from corpse", s.texts("gc").first()) + assertFalse("say hello" in s.texts("gc")) + assertEquals(listOf("look"), s.texts("lk")) + } + + @Test + fun aPrefixMatchOutranksAMoreFrequentFuzzyOne() { + val s = suggester() + repeat(20) { s.send("get coins") } + s.send("gc on") + assertEquals("gc on", s.texts("gc").first()) + } + + @Test + fun frecencyRanksFrequentAndRecentFirst() { + val s = suggester() + repeat(5) { s.send("kill rat") } + s.send("kick door") + // used more often + assertEquals(listOf("kill rat", "kick door"), s.texts("ki")) + // after a month the five old uses weigh less than two new ones + now += 30 * day + s.send("kick door", "kick door") + assertEquals(listOf("kick door", "kill rat"), s.texts("ki")) + } + + @Test + fun theSameCommandInAnotherCaseOrSpacingIsOneEntryShownAsLastTyped() { + val s = suggester() + s.send("Kill Rat", "kill rat ") + assertEquals(1, s.historySize) + assertEquals(listOf("kill rat"), s.texts("k")) + } + + @Test + fun theInputItselfIsNotSuggested() { + val s = suggester() + s.send("look", "look at sign") + assertEquals(listOf("look at sign"), s.texts("look")) + } + + @Test + fun ghostTextOnlyForAPrefixExtensionAtTheEnd() { + val s = suggester() + s.send("open door") + val result = s.suggest("op") + assertEquals("en door", result.ghost) + // a fuzzy match is no extension + assertNull(s.suggest("od").ghost) + // the case differs, so appending would not give the suggestion + assertNull(s.suggest("Op").ghost) + // the cursor is not at the end + assertNull(s.suggest("op", cursor = 1).ghost) + assertNull(s.suggest("").ghost) + } + + @Test + fun theNextCommandIsPredictedFromThePreviousOne() { + val s = suggester() + repeat(3) { s.send("kill rat", "loot", "north") } + s.send("kill rat") + val top = s.suggest("").items.first() + assertEquals("loot", top.text) + assertEquals(SuggestionSource.SEQUENCE, top.source) + assertEquals(SuggestionKind.COMMAND, top.kind) + } + + @Test + fun aPredictionCarriesOverToAnotherTargetByTheFirstWord() { + val s = suggester() + s.send("kill rat", "loot", "kill snake", "loot", "kill orc", "loot", "west", "east") + s.send("kill goblin") + val top = s.suggest("").items.first() + assertEquals("loot", top.text) + assertEquals(SuggestionSource.SEQUENCE, top.source) + } + + @Test + fun theNextCommandIsPredictedFromTheLastGameLine() { + val s = suggester() + s.send("east", "north", "say hi", "look") + s.observeLine("The door is closed.") + s.send("open door") + s.observeLine("You are in a kitchen.") + s.send("west") + s.observeLine("The door is closed.") + val top = s.suggest("").items.first() + assertEquals("open door", top.text) + assertEquals(SuggestionSource.LINE, top.source) + } + + @Test + fun lineAssociationsIgnoreNumbersAndNames() { + val s = suggester() + s.send("look") + s.observeLine("You see Ann wave at Bob.") + s.send("wave") + s.observeLine("You see Cid wave at Dee.") + assertEquals("wave", s.suggest("").items.first().text) + assertEquals(SuggestionSource.LINE, s.suggest("").items.first().source) + assertEquals("you see * wave at *.", CommandSuggester.normalizeLine("You see Cid wave at Dee.")) + assertEquals("ann gives you # gold coins.", CommandSuggester.normalizeLine("Ann gives you 12 gold coins.")) + assertNull(CommandSuggester.normalizeLine("<100 50>")) + } + + @Test + fun onlyLinesSinceTheLastSendArePredictionContext() { + val s = suggester() + s.observeLine("You are in a hall.") + s.observeLine("The door is closed.") + s.send("open door") + s.send("look") + // the hall and the door came before the last send, so it predicts nothing now + assertTrue(s.suggest("").items.none { it.source == SuggestionSource.LINE }) + } + + @Test + fun predictionsLiftMatchingHistoryWhileTyping() { + val s = suggester() + repeat(5) { s.send("order ale") } + s.observeLine("The door is closed.") + s.send("open door") + s.send("look") + s.observeLine("The door is closed.") + assertEquals("open door", s.texts("o").first()) + } + + @Test + fun anEmptyInputFallsBackToFrecentHistory() { + val s = suggester() + s.send("score", "score", "inventory") + val items = s.suggest("").items + assertEquals(setOf("score", "inventory"), items.map { it.text }.toSet()) + assertTrue(items.all { it.kind == SuggestionKind.COMMAND }) + } + + @Test + fun outputWordsCompleteTheWordAtTheCursor() { + val s = suggester() + s.observeLine("A grizzled goblin stands here, guarding a golden key.") + s.observeLine("The goblin snarls at you.") + val words = s.suggest("kill go").items.filter { it.kind == SuggestionKind.WORD } + // the newest first + assertEquals(listOf("kill goblin", "kill golden"), words.map { it.text }) + assertEquals(listOf("goblin", "golden"), words.map { it.label }) + assertTrue(words.all { it.source == SuggestionSource.OUTPUT }) + assertEquals("ghost text for the top word", "blin", s.suggest("kill go").ghost) + } + + @Test + fun aWordInTheMiddleIsReplacedWholeAndTheCursorFollowsIt() { + val s = suggester() + s.observeLine("You see a goblin.") + // the cursor after "go": the whole "gob" is replaced + val word = s.suggest("kill gob now", cursor = 7).items.single { it.kind == SuggestionKind.WORD } + assertEquals("kill goblin now", word.text) + assertEquals(11, word.cursor) + // no ghost text: the cursor is not at the end + assertNull(s.suggest("kill gob now", cursor = 7).ghost) + // a space before the cursor: no word to complete + assertTrue(s.suggest("kill ").items.none { it.kind == SuggestionKind.WORD }) + } + + @Test + fun outputWordsAreBoundedByTheWindow() { + val s = suggester(CommandSuggester.Config(outputWordWindow = 3)) + s.observeLine("dragon") + s.observeLine("one two three") + assertTrue(s.suggest("dr").items.isEmpty()) + s.observeLine("dragon") + assertEquals(listOf("dragon"), s.texts("dr")) + } + + @Test + fun theSameTextFromTwoSourcesIsListedOnceWithTheBetterScore() { + val s = suggester() + s.send("kill goblin") + s.observeLine("A goblin is here.") + val items = s.suggest("kill gob").items + assertEquals(1, items.count { it.text == "kill goblin" }) + // the history prefix match outscores the word + assertEquals(SuggestionSource.HISTORY, items.single { it.text == "kill goblin" }.source) + } + + @Test + fun theLimitHolds() { + val s = suggester() + repeat(30) { s.send("say $it") } + assertEquals(5, s.suggest("say", limit = 5).items.size) + } + + @Test + fun theHistoryCapEvictsTheLeastFrecent() { + val s = suggester(CommandSuggester.Config(maxHistory = 3)) + s.send("north", "north", "south", "south", "east") + now += 10 * day + s.send("west", "up") + assertEquals(3, s.historySize) + val left = s.texts("").toSet() + assertEquals(setOf("west", "up", "south"), left - "north") + assertFalse("east" in left) + } + + @Test + fun overlongCommandsAreNotRecorded() { + val s = suggester(CommandSuggester.Config(maxCommandLength = 10)) + s.send("say this is far too long") + assertEquals(0, s.historySize) + } + + @Test + fun theSequenceTablesAreBounded() { + val s = suggester(CommandSuggester.Config(maxNextPerKey = 2)) + s.send("look", "a1", "look", "a2", "look", "a3", "look") + val predicted = s.suggest("").items.filter { it.source == SuggestionSource.SEQUENCE }.map { it.text } + assertEquals(listOf("a3", "a2"), predicted.sorted().reversed()) + } + + @Test + fun hiddenInputNeverEntersAnyStoreOrPrediction() { + val s = suggester() + val secret = "hunter2 secret" + s.observeLine("What is your password?") + s.send("connect bob") + s.observeLine("What is your password?") + s.recordSent(secret, hidden = true) + s.observeLine("Welcome back!") + s.send("look") + s.recordSent(secret, hidden = true) + s.send("score") + s.observeLine("What is your password?") + + for (input in listOf("", "h", "hu", "hunter", "hs", "secret", "s")) { + val texts = s.suggest(input).items.map { it.text } + assertTrue("'$input' suggested the secret: $texts", texts.none { "hunter" in it || "secret" in it }) + } + val saved = StringBuilder().also { s.save(it) }.toString() + assertFalse(saved, "hunter" in saved || "secret" in saved) + assertEquals(3, s.historySize) + } + + @Test + fun hiddenInputBreaksTheSequence() { + val s = suggester() + s.send("connect bob") + s.recordSent("hunter2", hidden = true) + s.send("look") + s.send("connect bob") + // "look" never followed "connect bob" directly + assertTrue(s.suggest("").items.none { it.text == "look" && it.source == SuggestionSource.SEQUENCE }) + } + + @Test + fun forgetRemovesACommandEverywhere() { + val s = suggester() + s.send("kill rat", "loot", "kill rat") + s.forget("LOOT") + assertTrue(s.texts("").none { it == "loot" }) + assertTrue(s.texts("lo").isEmpty()) + } + + @Test + fun clearForgetsEverything() { + val s = suggester() + s.send("kill rat", "loot") + s.observeLine("A goblin is here.") + s.clear() + assertTrue(s.suggest("").items.isEmpty()) + assertTrue(s.suggest("gob").items.isEmpty()) + } + + @Test(expected = IllegalArgumentException::class) + fun aCursorOutsideTheInputIsRefused() { + suggester().suggest("abc", cursor = 4) + } + + // without the lock, HashMap can loop forever + @Test(timeout = 60_000) + fun concurrentCallsAreSafe() { + val s = suggester() + val failures = java.util.concurrent.ConcurrentLinkedQueue() + val threads = (0 until 4).map { t -> + Thread { + try { + // suggest is the slow one + repeat(if (t == 2) 200 else 2000) { i -> + when (t) { + 0 -> s.recordSent("command $i") + 1 -> s.observeLine("A goblin $i stands here.") + 2 -> s.suggest("co") + else -> s.save(StringBuilder()) + } + } + } catch (e: Throwable) { + failures.add(e) + } + } + } + threads.forEach(Thread::start) + threads.forEach(Thread::join) + assertTrue(failures.toString(), failures.isEmpty()) + assertEquals(2000, s.historySize) + } + + @Test + fun thousandsOfEntriesStayFast() { + val s = suggester() + val verbs = listOf("kill", "get", "put", "open", "close", "look at", "cast", "say") + val things = (1..500).map { "thing$it" } + for (verb in verbs) for (thing in things) s.recordSent("$verb $thing") + assertEquals(4000, s.historySize) + repeat(20) { s.suggest("gt") } + val start = System.nanoTime() + repeat(50) { s.suggest("gt1") } + val perCall = (System.nanoTime() - start) / 50 / 1_000_000.0 + assertTrue("$perCall ms per call", perCall < 50.0) + } +} diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/FuzzyMatchTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/FuzzyMatchTest.kt new file mode 100644 index 0000000..07c028a --- /dev/null +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/FuzzyMatchTest.kt @@ -0,0 +1,53 @@ +package org.mudlet.android.suggest + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FuzzyMatchTest { + private fun score(query: String, candidate: String): Double { + val score = FuzzyMatch.score(query, candidate) + assertNotNull("'$query' should match '$candidate'", score) + return score!! + } + + @Test + fun subsequenceMatchesAndOthersDoNot() { + score("gc", "get coins from corpse") + score("GCFC", "get coins from corpse") + assertNull(FuzzyMatch.score("cg", "get coins")) + assertNull(FuzzyMatch.score("x", "get coins")) + assertNull(FuzzyMatch.score("get coins!", "get coins")) + assertEquals(0.0, FuzzyMatch.score("", "anything")!!, 0.0) + } + + @Test + fun wordStartsBeatScatteredCharacters() { + assertTrue(score("gc", "get coins") > score("gc", "magic")) + assertTrue(score("gc", "get coins") > score("gc", "eggcup")) + assertTrue(score("ks", "kill snake") > score("ks", "kicks")) + } + + @Test + fun aPrefixBeatsEverythingElse() { + val prefix = score("ge", "get everything") + assertTrue(prefix >= FuzzyMatch.PREFIX_BONUS) + assertTrue(prefix > score("ge", "gaze east")) + assertTrue(prefix > score("ge", "say get")) + assertTrue(score("Ki", "kill rat") >= FuzzyMatch.PREFIX_BONUS) + } + + @Test + fun consecutiveCharactersBeatGaps() { + assertTrue(score("cor", "get corpse") > score("cor", "get cxoxrpse")) + } + + @Test + fun theBestAlignmentIsFoundNotTheFirstGreedyOne() { + // Greedy takes the "c" of "scan" (5 points in all); the best alignment takes the word start "coins": + // g at the start 11, a gap of 8 then c at a word start 7 - 8, o right after it 1 + 4 + assertEquals(15.0, score("gco", "get scan coins"), 1e-9) + } +} diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestCodecTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestCodecTest.kt new file mode 100644 index 0000000..04fb6b4 --- /dev/null +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestCodecTest.kt @@ -0,0 +1,106 @@ +package org.mudlet.android.suggest + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.StringReader + +class SuggestCodecTest { + private var now = 5_000_000L + + private fun suggester(config: CommandSuggester.Config = CommandSuggester.Config()) = CommandSuggester(config) { now } + + private fun CommandSuggester.saved() = StringBuilder().also { save(it) }.toString() + + private fun CommandSuggester.snapshot(vararg inputs: String) = inputs.map { suggest(it).items } + + @Test + fun aRoundTripKeepsHistoryRankingAndPredictions() { + val s = suggester() + for (command in listOf("kill rat", "loot", "say tab\there", "say a\\b", "kill rat", "loot", "open door")) { + s.recordSent(command) + now += 1000 + } + s.observeLine("The door is closed.") + s.recordSent("open door") + s.recordSent("kill rat") + val text = s.saved() + assertTrue(text.startsWith(SuggestCodec.HEADER + "\n")) + + val copy = suggester().apply { load(StringReader(text)) } + assertEquals(text, copy.saved()) + val inputs = arrayOf("", "k", "lo", "say", "od") + assertEquals(s.snapshot(*inputs), copy.snapshot(*inputs)) + assertTrue("say tab\there" in copy.suggest("say").items.map { it.text }) + assertTrue("say a\\b" in copy.suggest("say").items.map { it.text }) + } + + @Test + fun loadingReplacesTheState() { + val s = suggester() + s.recordSent("north") + s.load(StringReader(suggester().apply { recordSent("south") }.saved())) + assertEquals(listOf("south"), s.suggest("").items.map { it.text }) + } + + @Test + fun damagedLinesAreSkippedAndTheRestKept() { + val text = listOf( + SuggestCodec.HEADER, + "h\t2\t1.5\t5000000\tlook", + "h\tx\t1.5\t5000000\tbad count", + "h\t1\tNaN\t5000000\tbad weight", + "h\t1\t-1\t5000000\tnegative weight", + "h\t1\t1.0\t5000000", + "h\t1\t1.0\t5000000\tbad\\qescape", + "h\t1\t1.0\t5000000\tdangling\\", + "h\t1\t1.0\t5000000\t ", + "s\tc:look\tnorth\tInfinity", + "s\tc:look\t\t1.0", + "z\twhat\tis\tthis", + "", + "garbage", + "h\t1\t1.0\t5000000\tnorth", + "s\tc:look\tnorth\t2.0", + "p\t", + "p\tlook\textra", + ).joinToString("\n") + val s = suggester() + assertEquals(CommandSuggester.LoadResult(recognised = true, skippedLines = 14), s.load(StringReader(text))) + assertEquals(2, s.historySize) + s.recordSent("look") + assertEquals("north", s.suggest("").items.first().text) + assertEquals(SuggestionSource.SEQUENCE, s.suggest("").items.first().source) + } + + @Test + fun anotherFormatOrVersionLoadsNothing() { + for (text in listOf("", "mudlet-suggest 2\nh\t1\t1.0\t0\tlook", "h\t1\t1.0\t0\tlook", "\u0000\u0001binary")) { + val s = suggester() + s.recordSent("old") + assertEquals(text, CommandSuggester.LoadResult(recognised = false, skippedLines = 0), s.load(StringReader(text))) + assertEquals(text, 0, s.historySize) + } + } + + @Test + fun theCapsApplyWhileLoading() { + val big = suggester() + repeat(50) { + big.recordSent("say $it") + now += 1000 + } + val small = suggester(CommandSuggester.Config(maxHistory = 10)).apply { load(StringReader(big.saved())) } + assertEquals(10, small.historySize) + // the most recent are kept + assertEquals((40..49).map { "say $it" }.toSet(), small.suggest("", limit = 20).items.map { it.text }.toSet()) + } + + @Test + fun parseRefusesWhatWriteNeverWrites() { + assertNull(SuggestCodec.parse("h\t1\t1.0\t0\ta\tb")) + assertNull(SuggestCodec.parse("l\tkey\tnext")) + assertEquals(SuggestCodec.Record.Line("a\tb", "c\nd", 1.0), SuggestCodec.parse("l\ta\\tb\tc\\nd\t1.0")) + } +} diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggesterRegistryTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggesterRegistryTest.kt new file mode 100644 index 0000000..d2fe74f --- /dev/null +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggesterRegistryTest.kt @@ -0,0 +1,35 @@ +package org.mudlet.android.suggest + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SuggesterRegistryTest { + @Test + fun eachProfileLearnsAlone() { + val registry = SuggesterRegistry() + val achaea = registry.forProfile("Achaea") + val discworld = registry.forProfile("Discworld") + assertSame(achaea, registry.forProfile("Achaea")) + assertNotSame(achaea, discworld) + + achaea.recordSent("kill rat") + achaea.recordSent("loot") + achaea.observeLine("A goblin is here.") + discworld.recordSent("kill rat") + + assertEquals(listOf("kill rat", "loot").sorted(), achaea.suggest("").items.map { it.text }.sorted()) + assertTrue(discworld.suggest("lo").items.isEmpty()) + assertTrue(discworld.suggest("kill gob").items.isEmpty()) + } + + @Test + fun aRemovedProfileStartsAfresh() { + val registry = SuggesterRegistry() + registry.forProfile("a").recordSent("look") + registry.remove("a") + assertTrue(registry.forProfile("a").suggest("lo").items.isEmpty()) + } +} From e19e6218db3fda30cba4a6ebe198d37f5745a447 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 23:36:58 +0000 Subject: [PATCH 2/4] Suggestions in the input line: a strip of candidates and ghost text The input line now offers what :suggest ranks as the player types: up to five buttons directly above the line, updated 120 ms after the typing pauses and computed off the main thread, and the rest of the best command greyed after the cursor (a VisualTransformation, so the text and what TalkBack reads stay as typed). A button puts its suggestion on the line without sending; an accept button, or a hardware right arrow at the end of the line, takes the ghost text. An empty line after a send offers what usually came next. Each profile's suggestions are a SuggestionSession, loaded from files/suggest/.txt when the game screen first shows the profile, fed every send and every game line but the echo of a send, and saved a few seconds after a send and when the app leaves the front. An I/O error is logged, without any command, and the suggestions go on in memory. While the game hides input there is neither strip nor ghost, and nothing typed in the masked field reaches the session. Autocorrection and the capital first letter are off on the command line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeGtuaeaa9oac3KmLFo --- .github/workflows/apks.yml | 2 +- CONTRIBUTING.md | 2 +- app/build.gradle.kts | 1 + .../mudlet/android/ui/SuggestionStripTest.kt | 235 ++++++++++++++ .../org/mudlet/android/ui/MainActivity.kt | 3 + .../android/ui/game/CommandSuggestions.kt | 131 ++++++++ .../org/mudlet/android/ui/game/GameScreen.kt | 10 +- .../org/mudlet/android/ui/game/InputLine.kt | 217 ++++++++----- .../mudlet/android/ui/game/SuggestionStrip.kt | 87 ++++++ .../main/res/drawable/ic_arrow_forward.xml | 12 + app/src/main/res/values/strings.xml | 6 + docs/architecture.md | 45 ++- docs/roadmap.md | 2 +- .../android/suggest/CommandSuggester.kt | 12 +- .../mudlet/android/suggest/SuggestStore.kt | 43 +++ .../org/mudlet/android/suggest/Suggester.kt | 19 ++ .../android/suggest/SuggestionSession.kt | 162 ++++++++++ .../org/mudlet/android/suggest/Worker.kt | 35 +++ .../mudlet/android/suggest/SuggestFileTest.kt | 73 +++++ .../android/suggest/SuggestionSessionTest.kt | 287 ++++++++++++++++++ 20 files changed, 1292 insertions(+), 92 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/mudlet/android/ui/SuggestionStripTest.kt create mode 100644 app/src/main/kotlin/org/mudlet/android/ui/game/CommandSuggestions.kt create mode 100644 app/src/main/kotlin/org/mudlet/android/ui/game/SuggestionStrip.kt create mode 100644 app/src/main/res/drawable/ic_arrow_forward.xml create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestStore.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/Suggester.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestionSession.kt create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/Worker.kt create mode 100644 suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestFileTest.kt create mode 100644 suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestionSessionTest.kt diff --git a/.github/workflows/apks.yml b/.github/workflows/apks.yml index 5094e49..b005d1d 100644 --- a/.github/workflows/apks.yml +++ b/.github/workflows/apks.yml @@ -59,7 +59,7 @@ jobs: QT_QPA_PLATFORM: offscreen run: native/tests/run_jvm_client_test.sh -PqtRoot="$QT_ROOT" -PandroidAbi=arm64-v8a - # :suggest's tests: ranking, prediction, the saved form, hidden input never stored + # :suggest's tests: ranking, prediction, the saved form and file, the session's timing, hidden input never stored - name: Kotlin suggestion tests if: matrix.tests run: ./gradlew -PqtRoot="$QT_ROOT" :suggest:test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f673336..9fb3a76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ What the app is and how it is built is in [`README.md`](README.md) and [`docs/ar | `app/` | `:app`, the UI process: Kotlin and Compose, no Qt | | `engine-service/` | `:engine-service`: `EngineService` in the `:engine` process, and the Qt libraries `scripts/build-apk.sh` stages into `engine-service/qtdeploy/` | | `protocol/` | `:protocol`, plain Kotlin/JVM: the socket protocol and `EngineLink`, with the JVM tests | -| `suggest/` | `:suggest`, plain Kotlin/JVM with no dependencies: the input line's command suggestions, with the JVM tests | +| `suggest/` | `:suggest`, plain Kotlin/JVM with no dependencies: the input line's command suggestions and the session that loads, feeds and saves them, with the JVM tests | | `native/` | C++: the engine host (`enginehost/`), the engine interface (`api/`), the stand-in engine (`standin/`), the host tests (`tests/`); `third_party/` is vendored Lua 5.1.5 | | `scripts/` | Builds (APK, release, OpenSSL, libmudlet and its dependencies), `check-apk.sh` and `lint.sh` | | `docs/` | [`architecture.md`](docs/architecture.md), [`protocol.md`](docs/protocol.md), [`ptb.md`](docs/ptb.md), [`decisions/`](docs/decisions), [`foundation-checklist.md`](docs/foundation-checklist.md), [`roadmap.md`](docs/roadmap.md) | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d50f59e..5d5854b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -135,6 +135,7 @@ kotlin { dependencies { implementation(project(":protocol")) + implementation(project(":suggest")) implementation(project(":engine-service")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/SuggestionStripTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/SuggestionStripTest.kt new file mode 100644 index 0000000..ed7723d --- /dev/null +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/SuggestionStripTest.kt @@ -0,0 +1,235 @@ +package org.mudlet.android.ui + +import android.Manifest +import android.os.Build +import androidx.annotation.StringRes +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mudlet.android.R +import org.mudlet.android.suggest.SuggestFile +import org.mudlet.android.suggest.SuggestionSession +import org.mudlet.android.ui.game.CommandSuggestions +import org.mudlet.android.ui.game.SUGGESTION_STRIP_TAG +import java.io.File + +/** + * The input line's suggestions against a fake engine: the strip of suggestions above the line, the ghost text and + * its accept button, and neither while the game hides input. Each test has a profile of its own, so it starts with + * nothing learned. Controls are found by the labels TalkBack reads and activated through their accessibility actions. + */ +@RunWith(AndroidJUnit4::class) +class SuggestionStripTest { + private val fake = FakeEngineRule() + private val compose = createAndroidComposeRule() + + @get:Rule + val rules: RuleChain = RuleChain.outerRule(MainThreadTimeout(TIMEOUT_MS)).around(fake) + .around(if (Build.VERSION.SDK_INT >= 33) GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) else RuleChain.emptyRuleChain()) + .around(compose) + + private lateinit var session: FakeEngine.Session + private val profile = "suggestions ${System.nanoTime()}" + private lateinit var saved: File + + private val password = SemanticsMatcher.keyIsDefined(SemanticsProperties.Password) + private val input = hasSetTextAction() and !password + private val strip = hasTestTag(SUGGESTION_STRIP_TAG) + + @Before + fun openTheGameScreenOnANewProfile() { + // enableAccessibilityChecks() needs API 34: the API 36 emulator runs the checks, API 28 does not + if (Build.VERSION.SDK_INT >= 34) compose.enableAccessibilityChecks() + session = fake.engine.nextSession() + // EngineClient lives as long as the process, so an earlier test's lines would still be there + session.clear() + session.synced(0) + saved = SuggestFile.forProfile(CommandSuggestions.dir(compose.activity), profile).file + activate(hasText(string(R.string.profiles_open_game))) + compose.onNodeWithText(string(R.string.game_title)).assertIsDisplayed() + session.status("profile", "$profile loaded") + compose.waitUntil(WAIT_MS) { compose.runOnIdle { CommandSuggestions.profile } == profile } + compose.waitForIdle() + } + + @After + fun forgetTheProfile() { + saved.delete() + } + + @Test + fun typingShowsSuggestionsAndOneFillsTheLineWithoutSending() { + send("open door") + send("north") + compose.onNode(input).performTextInput("op") + waitFor(suggestion("open door")) + compose.onNode(strip).assertIsDisplayed() + + activate(suggestion("open door")) + compose.onNode(input).assertIsFocused().assert(editableText("open door")) + // A request after the tap comes next: nothing was sent in between + activate(hasContentDescription(string(R.string.input_complete))) + assertEquals("open door", session.receive("complete").getString("text")) + assertEquals(2, session.received.count { it.optString("t") == "send" }) + } + + @Test + fun wordsTheGameShowedCompleteTheWordBeingTyped() { + session.line(1, "You see a lantern.") + compose.onNode(input).performTextInput("get lan") + waitFor(suggestion("lantern")) + activate(suggestion("lantern")) + compose.onNode(input).assert(editableText("get lantern")) + } + + @Test + fun anEmptyLineAfterASendOffersTheCommandThatUsuallyFollows() { + for (command in listOf("kill rat", "loot", "kill rat")) send(command) + compose.onNode(input).assert(editableText("")) + waitFor(suggestion("loot")) + activate(suggestion("loot")) + compose.onNode(input).assert(editableText("loot")) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun theGhostTextIsAcceptedByItsButtonOrTheRightArrow() { + send("open door") + compose.onNode(input).performTextInput("op") + val accept = hasContentDescription(compose.activity.getString(R.string.input_accept_suggestion, "open door")) + waitFor(accept) + // The ghost shows after the cursor but is not in the line: TalkBack reads only what was typed + compose.onNode(input).assert(editableText("op")) + activate(accept) + compose.onNode(input).assertIsFocused().assert(editableText("open door")) + + compose.onNode(input).performTextClearance() + compose.onNode(input).performTextInput("op") + waitFor(accept) + compose.onNode(input).performKeyInput { pressKey(Key.DirectionRight) } + compose.onNode(input).assert(editableText("open door")) + } + + @Test + fun whileTheGameHidesInputThereAreNoSuggestionsAndThePasswordIsKeptNowhere() { + send("north") + compose.onNode(input).performTextInput("n") + waitFor(suggestion("north")) + + session.hiddenInput(true) + waitFor(password) + compose.onNode(strip).assertDoesNotExist() + compose.onNode(password).performTextInput("hunter2") + // Long enough for an answer to the typing, were there one + Thread.sleep(SuggestionSession.TYPING_DELAY_MS * 4) + compose.onNode(strip).assertDoesNotExist() + compose.onNode(password).performImeAction() + assertEquals("hunter2", session.receive("sendHidden").getString("text")) + + session.hiddenInput(false) + waitFor(input) + compose.onNode(input).performTextClearance() + send("east") + // Saved after the masked send, so the file holds whatever it left + compose.runOnIdle { CommandSuggestions.saveAll() } + compose.waitUntil(WAIT_MS) { saved.isFile && "east" in saved.readText() } + val text = saved.readText() + assertTrue("north" in text) + assertFalse("the password is not in the saved suggestions", "hunter" in text) + } + + @Test + fun theSuggestionsAreLabelledButtonsReadAfterTheLineAndBeforeItsButtons() { + send("open door") + compose.onNode(input).performTextInput("o") + waitFor(suggestion("open door")) + compose.onRoot().tryPerformAccessibilityChecks() + + val chip = compose.onNode(suggestion("open door") and hasClickAction()).fetchSemanticsNode() + assertEquals(Role.Button, chip.config.getOrNull(SemanticsProperties.Role)) + val minimum = with(compose.density) { 48.dp.toPx() } + assertTrue("a ${chip.boundsInRoot.height} px tall button, at least 48 dp ($minimum px)", chip.boundsInRoot.height >= minimum) + + // Directly above the line + val stripBounds = bounds(strip) + val inputBounds = bounds(input) + assertTrue("strip $stripBounds above the input line $inputBounds", stripBounds.bottom <= inputBounds.top) + + // TalkBack's order: the line, then the strip as a group, then the line's buttons + val line = traversalIndex(input) + val suggestions = traversalIndex(strip) + val send = traversalIndex(hasContentDescription(string(R.string.input_send)) and hasClickAction()) + assertTrue("line $line, strip $suggestions, send $send", line < suggestions && suggestions < send) + assertEquals(true, compose.onNode(strip).fetchSemanticsNode().config.getOrNull(SemanticsProperties.IsTraversalGroup)) + } + + private fun string(@StringRes id: Int) = compose.activity.getString(id) + + private fun suggestion(text: String) = hasContentDescription(compose.activity.getString(R.string.input_suggestion, text)) + + private fun send(command: String) { + compose.onNode(input).performTextInput(command) + compose.onNode(input).performImeAction() + assertEquals(command, session.receive("send").getString("text")) + } + + // As plain text: the field's EditableText can carry spans, so comparing AnnotatedStrings fails on equal text + private fun editableText(text: String) = SemanticsMatcher("EditableText = '$text'") { + it.config.getOrNull(SemanticsProperties.EditableText)?.text == text + } + + private fun bounds(matcher: SemanticsMatcher): Rect = compose.onNode(matcher).fetchSemanticsNode().boundsInRoot + + private fun traversalIndex(matcher: SemanticsMatcher) = compose.onNode(matcher).fetchSemanticsNode().config.getOrNull(SemanticsProperties.TraversalIndex) ?: 0f + + private fun waitFor(matcher: SemanticsMatcher) = compose.waitUntil(WAIT_MS) { compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() } + + /** What a double tap does under TalkBack: the node's accessibility click, after the checks on the whole screen. */ + private fun activate(label: SemanticsMatcher) { + compose.onRoot().tryPerformAccessibilityChecks() + compose.onNode(label and hasClickAction()).assertIsDisplayed().performSemanticsAction(SemanticsActions.OnClick) + } + + private companion object { + const val WAIT_MS = 10_000L + + // Below the per-test timeout native/tests/android_instrumented.py gives the runner, so this one reports first + const val TIMEOUT_MS = 240_000L + } +} diff --git a/app/src/main/kotlin/org/mudlet/android/ui/MainActivity.kt b/app/src/main/kotlin/org/mudlet/android/ui/MainActivity.kt index 6d53162..2990ede 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import org.mudlet.android.ui.game.CommandSuggestions import org.mudlet.android.ui.theme.MudletTheme class MainActivity : ComponentActivity() { @@ -32,6 +33,8 @@ class MainActivity : ComponentActivity() { } override fun onStop() { + // The process may be killed in the background: keep what the suggestions learned + CommandSuggestions.saveAll() EngineClient.detach() super.onStop() } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/CommandSuggestions.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/CommandSuggestions.kt new file mode 100644 index 0000000..093a773 --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/CommandSuggestions.kt @@ -0,0 +1,131 @@ +package org.mudlet.android.ui.game + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalContext +import org.mudlet.android.suggest.ExecutorWorker +import org.mudlet.android.suggest.SuggestFile +import org.mudlet.android.suggest.SuggesterRegistry +import org.mudlet.android.suggest.SuggestionSession +import org.mudlet.android.ui.EngineClient +import java.io.File + +/** + * Each profile's command suggestions (`:suggest`, docs/architecture.md, "Command suggestions") for the UI process's + * life: one [SuggestionSession] per profile, loaded the first time the game screen shows that profile, and kept in + * the app's private storage as `files/suggest/.txt`. Main thread only. + */ +object CommandSuggestions { + private const val TAG = "MudletSuggest" + private const val LOADED = " loaded" + + /** The profile libmudlet starts when none was opened (`MUDLET_PROFILE`), until the engine names one. */ + const val DEFAULT_PROFILE = "Android" + + // Its one thread runs every suggestion's work, loading and saving included, off the main thread + private val worker by lazy { ExecutorWorker("suggestions") { Log.e(TAG, "a suggestion task failed: ${it.javaClass.name}") } } + private val registry = SuggesterRegistry() + private val sessions = HashMap() + + /** The profile whose suggestions the input line shows: the one the engine last said it loaded. */ + var profile by mutableStateOf(DEFAULT_PROFILE) + private set + + /** Takes the profile from the engine's status `profile` (" loaded"); its other values change nothing. */ + fun profileStatus(value: String?) { + if (value != null && value.endsWith(LOADED) && value.length > LOADED.length) profile = value.removeSuffix(LOADED) + } + + // The number (EngineClient.linesAdded) after the last console line given to a session, so none is given twice + private var linesSeen = 0L + + /** Where the profiles' suggestions are saved. */ + fun dir(context: Context) = File(context.filesDir, "suggest") + + fun forProfile(context: Context, profile: String): SuggestionSession = sessions.getOrPut(profile) { + SuggestionSession(registry.forProfile(profile), SuggestFile.forProfile(dir(context), profile), worker, ::log).apply { open() } + } + + /** Gives [state] the game's lines that came since the last call, leaving out the app's markers. */ + internal fun linesShown(state: SuggestionState) { + val c = EngineClient + val added = c.linesAdded + val first = added - c.lines.size + for (n in maxOf(linesSeen, first) until added) { + val line = c.lines[(n - first).toInt()] + if (!line.marker) state.lineShown(line.text) + } + linesSeen = added + } + + /** Saves what each profile learned since its last save, as the app leaves the front and may be killed. */ + fun saveAll() = sessions.values.forEach(SuggestionSession::save) + + // Never a command or a line: SuggestionSession gives none + private fun log(message: String, error: Throwable?) { + Log.w(TAG, message, error) + } +} + +/** The input line's suggestions for one profile, for the game screen: what was last answered, and what to tell it. */ +@Stable +class SuggestionState internal constructor(internal val session: SuggestionSession) { + /** The suggestions for the input line as it was a moment ago; it shows their ghost text only if it still is so. */ + var answer by mutableStateOf(null) + internal set + + fun inputChanged(text: String, cursor: Int) = session.inputChanged(text, cursor) + + fun hiddenChanged(hidden: Boolean) = session.hiddenChanged(hidden) + + fun sent(command: String) = session.sent(command) + + fun sentHidden() = session.sentHidden() + + fun lineShown(line: String) = session.lineShown(line) +} + +/** [profile]'s suggestions while the caller is composed; leaving saves what was learned. */ +@Composable +fun rememberSuggestionState(profile: String): SuggestionState { + val context = LocalContext.current.applicationContext + val state = remember(profile) { SuggestionState(CommandSuggestions.forProfile(context, profile)) } + DisposableEffect(state) { + val main = Handler(Looper.getMainLooper()) + var active = true + val listener: (SuggestionSession.Answer) -> Unit = { answer -> main.post { if (active) state.answer = answer } } + state.session.listener = listener + onDispose { + active = false + if (state.session.listener === listener) state.session.listener = null + state.session.save() + } + } + return state +} + +/** + * The suggestions for the profile the engine runs, kept up to date with [EngineClient]: whether input is hidden, and + * the game's lines as they come. The caller tells it what the input line sends. + */ +@Composable +fun rememberEngineSuggestions(): SuggestionState { + val c = EngineClient + val profileStatus = c.status["profile"] + LaunchedEffect(profileStatus) { CommandSuggestions.profileStatus(profileStatus) } + val state = rememberSuggestionState(CommandSuggestions.profile) + LaunchedEffect(state) { snapshotFlow { c.hiddenInput }.collect(state::hiddenChanged) } + LaunchedEffect(state) { snapshotFlow { c.linesAdded }.collect { CommandSuggestions.linesShown(state) } } + return state +} diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/GameScreen.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/GameScreen.kt index 1635337..bf23e9e 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/GameScreen.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/GameScreen.kt @@ -17,11 +17,13 @@ import org.mudlet.android.ui.MudletTopBar /** * The console, the input line and the connection bar, top to bottom, which is also the order TalkBack reads them in. - * The keyboard pushes them up, so the console shrinks above it. + * The keyboard pushes them up, so the console shrinks above it. The input line's suggestions come from the profile + * the engine runs ([rememberEngineSuggestions]), which learns from what the line sends. */ @Composable fun GameScreen(onBack: () -> Unit) { val c = EngineClient + val suggestions = rememberEngineSuggestions() Scaffold(topBar = { MudletTopBar(stringResource(R.string.game_title), onBack) }) { padding -> Column(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding).imePadding()) { Console(c.lines, c.tail, c.linesAdded, onWidth = c::setViewWidth, Modifier.weight(1f).fillMaxWidth()) @@ -30,11 +32,13 @@ fun GameScreen(onBack: () -> Unit) { history = c.history, commandLine = c.commandLine, onCommandLineShown = c::commandLineShown, - onSend = c::sendCommand, - onSendHidden = c::sendHidden, + onSend = { line -> c.sendCommand(line).also { if (it) suggestions.sent(line) } }, + onSendHidden = { secret -> c.sendHidden(secret).also { if (it) suggestions.sentHidden() } }, onDismissHidden = c::dismissHidden, onComplete = c::complete, modifier = Modifier.fillMaxWidth().padding(start = 8.dp), + suggestions = suggestions.answer, + onInputChanged = suggestions::inputChanged, ) ConnectionBar( ConnectionState.of(engineAttached = c.enginePid > 0, connection = c.status["connection"]), diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt index fd3d581..09a234d 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt @@ -1,17 +1,22 @@ package org.mudlet.android.ui.game +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text 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.rememberUpdatedState import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -29,14 +34,22 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.editableText +import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import org.mudlet.android.R +import org.mudlet.android.suggest.Suggestion +import org.mudlet.android.suggest.SuggestionSession /** * Where the up and down buttons have got to in the history, which is newest first: [position] is an index into it, @@ -64,6 +77,11 @@ data class HistoryPosition(val position: Int = -1, val draft: String = "") { private enum class Notice { NOT_SENT, NO_COMPLETION } +// TalkBack's order within the input line: the field, the suggestion strip above it, then the buttons beside it +private const val FIELD_ORDER = 0f +private const val STRIP_ORDER = 1f +private const val BUTTON_ORDER = 2f + /** * The line the player types commands in. The keyboard's Send key or the Send button hands the line to [onSend] and * leaves the keyboard open; an `on...` that returns false could not send its request, and the line then stays. @@ -73,6 +91,12 @@ private enum class Notice { NOT_SENT, NO_COMPLETION } * * While [hiddenInput] holds, a masked password field takes the line's place and sends with [onSendHidden]; its close * button calls [onDismissHidden]. What is typed there is not saved across a rotation, to keep it out of saved state. + * + * Each change of the line or its cursor goes to [onInputChanged], whose [suggestions] show in a strip above the line; + * a button there puts its suggestion on the line, without sending it. When the answer is for the line as it is and + * its best suggestion only extends it, the rest shows greyed after the cursor (ghost text), and the accept button + * at the start of the strip, or a hardware right arrow at the end of the line, puts it on the line. While input is + * hidden there is neither strip nor ghost text. */ @Composable fun InputLine( @@ -85,17 +109,21 @@ fun InputLine( onDismissHidden: () -> Boolean, onComplete: (String, (String) -> Unit) -> Boolean, modifier: Modifier = Modifier, + suggestions: SuggestionSession.Answer? = null, + onInputChanged: (text: String, cursor: Int) -> Unit = { _, _ -> }, ) { var text by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } var secret by remember { mutableStateOf(TextFieldValue()) } var at by rememberSaveable(stateSaver = HistoryPosition.Saver) { mutableStateOf(HistoryPosition()) } var notice by rememberSaveable { mutableStateOf(null) } var focused by remember { mutableStateOf(false) } + // Counts the sends, so that sending an empty line, which leaves the line as it was, still asks for suggestions + var sends by remember { mutableIntStateOf(0) } val focus = remember { FocusRequester() } val hiddenFocus = remember { FocusRequester() } - fun show(line: String) { - text = TextFieldValue(line, TextRange(line.length)) + fun show(line: String, cursor: Int = line.length) { + text = TextFieldValue(line, TextRange(cursor)) notice = null } @@ -110,6 +138,7 @@ fun InputLine( if (sent) { at = HistoryPosition() text = TextFieldValue() + sends++ } } @@ -129,6 +158,26 @@ fun InputLine( notice = if (sent) null else Notice.NOT_SENT } + val items = if (hiddenInput) emptyList() else suggestions?.suggestions?.items.orEmpty() + // Only for the line as it is now: an answer for an older one would put the wrong text after the cursor + val ghost = suggestions?.takeIf { !hiddenInput && it.input == text.text && text.selection.collapsed && it.cursor == text.selection.end }?.suggestions?.ghost + + fun accept() { + if (ghost == null) return + show(text.text + ghost) + focus.requestFocus() + } + + fun pick(suggestion: Suggestion) { + show(suggestion.text, suggestion.cursor) + focus.requestFocus() + } + + val inputChanged by rememberUpdatedState(onInputChanged) + LaunchedEffect(text.text, text.selection, hiddenInput, sends) { + if (!hiddenInput) inputChanged(text.text, text.selection.end) + } + // Read as the field swaps, before the old one's focus is gone: the new one takes the focus it had val hadFocus = remember(hiddenInput) { focused } LaunchedEffect(hiddenInput) { @@ -150,78 +199,104 @@ fun InputLine( } } val trackFocus = Modifier.onFocusChanged { focused = it.isFocused } + val buttonOrder = Modifier.semantics { traversalIndex = BUTTON_ORDER } - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - if (hiddenInput) { - OutlinedTextField( - value = secret, - onValueChange = { - secret = it - notice = null + Column(modifier.semantics { isTraversalGroup = true }) { + if (items.isNotEmpty()) { + SuggestionStrip( + items, + accepted = ghost?.let { text.text + it }, + onAccept = ::accept, + onPick = ::pick, + Modifier.fillMaxWidth().semantics { + isTraversalGroup = true + traversalIndex = STRIP_ORDER }, - modifier = Modifier.weight(1f).focusRequester(hiddenFocus).then(trackFocus), - label = { Text(stringResource(R.string.input_password_label)) }, - supportingText = supportingText, - isError = notice == Notice.NOT_SENT, - singleLine = true, - // Password semantics: TalkBack says "password", not what is typed - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password, imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { submitHidden() }), ) - IconButton({ if (!onDismissHidden()) notice = Notice.NOT_SENT }) { - Icon(painterResource(R.drawable.ic_close), stringResource(R.string.input_password_close)) - } - IconButton({ - submitHidden() - hiddenFocus.requestFocus() - }) { - Icon(painterResource(R.drawable.ic_send), stringResource(R.string.input_send)) - } - } else { - OutlinedTextField( - value = text, - onValueChange = { - text = it - notice = null - }, - modifier = Modifier.weight(1f).focusRequester(focus).then(trackFocus).onPreviewKeyEvent { event -> - val tab = event.key == Key.Tab && !event.isShiftPressed - // Both halves of Tab's press, so neither the field nor the focus acts on it - if (tab && event.type == KeyEventType.KeyUp) return@onPreviewKeyEvent true - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when { - tab -> complete() - event.key == Key.DirectionUp && at.canGoBack(history) -> move(at.back(history, text.text)) - event.key == Key.DirectionDown && at.canGoForward -> move(at.forward()) - else -> return@onPreviewKeyEvent false - } - true - }, - label = { Text(stringResource(R.string.input_label)) }, - trailingIcon = { - IconButton({ complete() }) { - Icon(painterResource(R.drawable.ic_keyboard_tab), stringResource(R.string.input_complete)) - } - }, - supportingText = supportingText, - isError = notice == Notice.NOT_SENT, - singleLine = true, - // Game commands are not prose: corrections would change them - keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { submit() }), - ) - IconButton({ move(at.back(history, text.text)) }, enabled = at.canGoBack(history)) { - Icon(painterResource(R.drawable.ic_arrow_upward), stringResource(R.string.input_history_previous)) - } - IconButton({ move(at.forward()) }, enabled = at.canGoForward) { - Icon(painterResource(R.drawable.ic_arrow_downward), stringResource(R.string.input_history_next)) - } - IconButton({ - submit() - focus.requestFocus() - }) { - Icon(painterResource(R.drawable.ic_send), stringResource(R.string.input_send)) + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (hiddenInput) { + OutlinedTextField( + value = secret, + onValueChange = { + secret = it + notice = null + }, + modifier = Modifier.weight(1f).focusRequester(hiddenFocus).then(trackFocus).semantics { traversalIndex = FIELD_ORDER }, + label = { Text(stringResource(R.string.input_password_label)) }, + supportingText = supportingText, + isError = notice == Notice.NOT_SENT, + singleLine = true, + // Password semantics: TalkBack says "password", not what is typed + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password, imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { submitHidden() }), + ) + IconButton({ if (!onDismissHidden()) notice = Notice.NOT_SENT }, buttonOrder) { + Icon(painterResource(R.drawable.ic_close), stringResource(R.string.input_password_close)) + } + IconButton({ + submitHidden() + hiddenFocus.requestFocus() + }, buttonOrder) { + Icon(painterResource(R.drawable.ic_send), stringResource(R.string.input_send)) + } + } else { + OutlinedTextField( + value = text, + onValueChange = { + text = it + notice = null + }, + modifier = Modifier.weight(1f).focusRequester(focus).then(trackFocus).semantics { + traversalIndex = FIELD_ORDER + // The typed text, not the ghost the transformation adds: what TalkBack reads and tests see + if (ghost != null) editableText = AnnotatedString(text.text) + }.onPreviewKeyEvent { event -> + val tab = event.key == Key.Tab && !event.isShiftPressed + // Both halves of Tab's press, so neither the field nor the focus acts on it + if (tab && event.type == KeyEventType.KeyUp) return@onPreviewKeyEvent true + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + // Right takes the ghost, which is only there with the cursor at the end, where it does nothing else + when { + tab -> complete() + event.key == Key.DirectionUp && at.canGoBack(history) -> move(at.back(history, text.text)) + event.key == Key.DirectionDown && at.canGoForward -> move(at.forward()) + event.key == Key.DirectionRight && ghost != null -> accept() + else -> return@onPreviewKeyEvent false + } + true + }, + label = { Text(stringResource(R.string.input_label)) }, + trailingIcon = { + IconButton({ complete() }) { + Icon(painterResource(R.drawable.ic_keyboard_tab), stringResource(R.string.input_complete)) + } + }, + supportingText = supportingText, + isError = notice == Notice.NOT_SENT, + visualTransformation = ghost?.let { GhostTransformation(it, MaterialTheme.colorScheme.onSurfaceVariant) } ?: VisualTransformation.None, + singleLine = true, + // Game commands are not prose: corrections, suggestions and capitals would change them ("n" to "In") + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + imeAction = ImeAction.Send, + ), + keyboardActions = KeyboardActions(onSend = { submit() }), + ) + IconButton({ move(at.back(history, text.text)) }, buttonOrder, enabled = at.canGoBack(history)) { + Icon(painterResource(R.drawable.ic_arrow_upward), stringResource(R.string.input_history_previous)) + } + IconButton({ move(at.forward()) }, buttonOrder, enabled = at.canGoForward) { + Icon(painterResource(R.drawable.ic_arrow_downward), stringResource(R.string.input_history_next)) + } + IconButton({ + submit() + focus.requestFocus() + }, buttonOrder) { + Icon(painterResource(R.drawable.ic_send), stringResource(R.string.input_send)) + } } } } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/SuggestionStrip.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/SuggestionStrip.kt new file mode 100644 index 0000000..e77bcce --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/SuggestionStrip.kt @@ -0,0 +1,87 @@ +package org.mudlet.android.ui.game + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import org.mudlet.android.R +import org.mudlet.android.suggest.Suggestion + +/** The suggestion strip's test tag. */ +const val SUGGESTION_STRIP_TAG = "suggestionStrip" + +/** + * The row of suggestions above the input line: with [accepted] (the line the ghost text gives) first a button that + * accepts it, then a button for each of [items], which [onPick] puts on the line. Neither sends anything. + */ +@Composable +fun SuggestionStrip(items: List, accepted: String?, onAccept: () -> Unit, onPick: (Suggestion) -> Unit, modifier: Modifier = Modifier) { + Row( + modifier.testTag(SUGGESTION_STRIP_TAG).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (accepted != null) { + IconButton(onAccept) { + Icon(painterResource(R.drawable.ic_arrow_forward), stringResource(R.string.input_accept_suggestion, accepted)) + } + } + for (item in items) { + val label = stringResource(R.string.input_suggestion, item.label) + OutlinedButton( + onClick = { onPick(item) }, + modifier = Modifier.heightIn(min = 48.dp).semantics { contentDescription = label }, + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + Text(item.label, Modifier.widthIn(max = 200.dp), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +/** + * Shows [ghost] in [color] after the text, fish style, without changing the text: the cursor stays within what was + * typed. The field's semantics keep the typed text, so TalkBack does not read the ghost as part of it. + */ +data class GhostTransformation(val ghost: String, val color: Color) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val typed = text.length + val shown = buildAnnotatedString { + append(text) + withStyle(SpanStyle(color = color)) { append(ghost) } + } + return TransformedText( + shown, + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + + override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(typed) + }, + ) + } +} diff --git a/app/src/main/res/drawable/ic_arrow_forward.xml b/app/src/main/res/drawable/ic_arrow_forward.xml new file mode 100644 index 0000000..02e0353 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_forward.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e7aaf5..47c80f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,12 @@ Password Close the password field + + Suggestion: %1$s + + Accept the suggestion: %1$s Waiting for the engine… diff --git a/docs/architecture.md b/docs/architecture.md index 8c57125..8d37359 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,10 +26,10 @@ Both rely on Qt private API (`Qt6::CorePrivate`), which ties the build to one ex | Where | What | |---|---| -| `app/` (`:app`) | The UI process: `MudletApp`, `ui/MainActivity.kt` with the screens it navigates between (`ui/AppNavigation.kt`: the profile list, the game screen in `ui/game/` (the console, the input line and the connection bar) and, in debug builds, the self-test in `ui/selftest/`), the renderer of game text (`ui/AnsiText.kt`), the theme (`ui/theme/`), and the model of the engine (`ui/EngineClient.kt`). Every string the app shows is in `res/values/strings.xml` | +| `app/` (`:app`) | The UI process: `MudletApp`, `ui/MainActivity.kt` with the screens it navigates between (`ui/AppNavigation.kt`: the profile list, the game screen in `ui/game/` (the console, the input line with its suggestions and the connection bar) and, in debug builds, the self-test in `ui/selftest/`), the renderer of game text (`ui/AnsiText.kt`), the theme (`ui/theme/`), and the model of the engine (`ui/EngineClient.kt`). Every string the app shows is in `res/values/strings.xml` | | `engine-service/` (`:engine-service`) | The engine process's Kotlin side: `EngineService` (a `QtServiceBase`), its notifications, `EngineNative` (the JNI handshake). Its manifest declares the service and the permissions. It also carries the Qt libraries and jars that `scripts/build-apk.sh` stages into `engine-service/qtdeploy/` | | `protocol/` (`:protocol`) | Plain Kotlin/JVM with no `android.*`: the frame codec, message types and `EngineLink`, the reconnecting client. Its tests run on the JVM, against the desktop engine too | -| `suggest/` (`:suggest`) | Plain Kotlin/JVM with no dependencies: the input line's command suggestions ("Command suggestions" below). Its tests run on the JVM, with Gradle or with kotlinc alone (`suggest/run-tests.sh`) | +| `suggest/` (`:suggest`) | Plain Kotlin/JVM with no dependencies: the input line's command suggestions and the session that loads, feeds and saves them ("Command suggestions" below). Its tests run on the JVM, with Gradle or with kotlinc alone (`suggest/run-tests.sh`) | | `native/enginehost/` | `libengine`'s `main()`, `SocketBridge` (the protocol's engine side), `MudletEngine` (libmudlet through `THeadlessSession`), and the Android glue: `AndroidServiceSetup` (the only private Qt API, so a rename in Qt fails the build) and `AndroidNotifier` | | `native/api/` | `IEngine` and `IFrontend`, between the host and an engine | | `native/standin/` | The stand-in engine for fast tests: minimal telnet, ANSI runs, Lua 5.1.5 (vendored in `native/third_party/lua-5.1.5`, tarball sha256 `2640fc56…695333`), a font check and a heartbeat | @@ -121,8 +121,8 @@ per profile, from `SuggesterRegistry.forProfile(name)`: `Suggestion` with `text` (the whole input line it leads to), `cursor`, `label`, `kind` (`COMMAND` or `WORD`) and `source` (`HISTORY`, `SEQUENCE`, `LINE` or `OUTPUT`); and `ghost`, the text to show greyed after the cursor when the best item only extends the input. -- `save(Appendable)` and `load(Reader)`, a line-based text (`SuggestCodec`) the app keeps in the profile's - private storage; a damaged line is skipped, and `load`'s `LoadResult` says how many were and whether the text +- `save(Appendable)` and `load(Reader)`, a line-based text (`SuggestCodec`) the app keeps in its private storage, + one file per profile; a damaged line is skipped, and `load`'s `LoadResult` says how many were and whether the text was this format at all. `forget(command)` and `clear()` for the player. What it ranks: @@ -138,6 +138,24 @@ Everything is bounded (5000 commands, 4000 and 2000 prediction keys of 8 command grow, so recent play outweighs old. All calls take one lock; `suggest` is O(entries × command length): 1 to 4 ms for 5000 entries on a desktop JVM, so the app calls it debounced and off the main thread. +In the app, `ui/game/CommandSuggestions.kt` keeps a `SuggestionSession` (`:suggest`) for each profile the engine +has said it loaded (status `profile`, " loaded"; `Android` until then), saved as `files/suggest/.txt` +(the name URL-encoded) in the app's private storage. The session does all its work on one background thread, in the +order the UI asked for it: + +- It loads the file when the game screen first shows the profile; a command sent meanwhile is learned on top. +- It records each send, and observes each game line but the echo of a send: one of the three lines after the send + that ends with the command as a whole word. +- It answers the input line once the typing has paused for 120 ms, with up to five suggestions. `SuggestionStrip` + shows them as buttons above the line, after an accept button for the ghost text. The ghost shows greyed through a + `VisualTransformation`, and only for the line as it is now; the field's semantics keep just the typed text, so + TalkBack does not read it. A hardware right arrow at the end of the line also accepts it. +- It saves 5 s after a send and when the app leaves the front (`MainActivity.onStop`), to a temporary file that + then replaces the old one. An I/O error is logged (tag `MudletSuggest`) without any command and the suggestions + carry on in memory; after a failed load nothing is saved, so the file is not replaced with less. +- While the game hides input there is neither strip nor ghost: nothing typed reaches the session, and the masked + send is recorded only as `recordSent("", hidden = true)`. + ## libmudlet as the engine Built with `LIBMUDLET_BUILD_DIR`, the engine is `MudletEngine` (`native/enginehost/MudletEngine.*`): one profile at a time in a `THeadlessSession` behind the same socket protocol. It uses only that class's @@ -724,19 +742,28 @@ Item 13 of the on-device checklist covers this. 15. The game screen, connected to a game (connect from the self-test first, then open the game screen). - The keyboard: tapping the input line opens it, and the console shrinks above it with its newest line still in view. Send on the keyboard sends the line and leaves the keyboard open, ready for the next. Note the keyboard's - action key: it should read Send, and suggestions or corrections should not change what is typed. Type half a - word, then the up button: the earlier command replaces it whole, and typing goes on from the end of it. + action key: it should read Send, and suggestions, corrections or a capital first letter should not change what is + typed. Type half a word, then the up button: the earlier command replaces it whole, and typing goes on from the + end of it. + - The suggestions: send a few commands, then type the start of one. Up to five buttons above the line offer the + commands that match, and the rest of the best one shows greyed after the cursor. A button puts its command on + the line without sending it; the arrow button at the start of the row, or a hardware keyboard's right arrow at + the end of the line, takes the greyed text. After a send, the empty line's buttons offer what usually came next. + With nothing to offer the row is gone. Close the app from the recent apps and reopen it: they are still there. - Scroll up while the game sends text: the console stays put and "Jump to the bottom: N new lines" counts up; the button goes back to the newest line, which the console then follows again. Rotate the phone while scrolled up, and again with a half-typed line: both stay. The same in split screen. - With TalkBack on: the input line reads as "Command, edit box"; the up, down and Send buttons read their names; the connection state is read out when it changes (connecting, connected, disconnected), without moving focus. - Swiping goes from the console's lines to the input line, then the buttons, then the connection bar. + The line reads only what was typed, never the greyed text. Swiping goes from the console's lines to the input + line, then "Accept the suggestion: ..." and each "Suggestion: ...", then the up, down and Send buttons, then the + connection bar. 16. The input line's engine features, connected to a game with the libmudlet APK. - At a password prompt (any game's login), the input line becomes a "Password" field with the keyboard's password layout, still open. TalkBack reads it as a password field and says "dot" or nothing for what is typed, never the - letters. Send logs in; the field goes back to the command line, with what was typed there before. Its close - button gives the command line back without sending. + letters. There are no suggestions and no greyed text while it shows. Send logs in; the field goes back to the + command line, with what was typed there before, and the password is not among the suggestions afterwards. Its + close button gives the command line back without sending. - The up button walks the commands of the profile's earlier sessions too (close and reopen the app first). - Type the start of a word the game sent, then the Tab button (or Tab on a hardware keyboard): the word is completed, and again goes on to the next match. TalkBack reads "Complete the word, button", and "No completion" diff --git a/docs/roadmap.md b/docs/roadmap.md index b7fac56..2a55055 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -180,7 +180,7 @@ come after #31, whose fake engine (`FakeEngine`) and emulator jobs run the app's | P5 | App state and save on background | P4 | | U8 | Background: notification with profile and connection state, Disconnect action, app state to the engine | U2, P5 | | S1 | Command suggestions' ranking core, `:suggest`: plain Kotlin, no UI | - | -| S2 | Suggestions in the input line: a row of candidates and ghost text, saved per profile | U4, S1 | +| S2 | Suggestions in the input line: a row of candidates above it and ghost text with an accept button, learned and saved per profile on the device, none while input is hidden | U4, S1 | ``` #31 ─┬─ U1 ─┬─ U2 ─┬───────────────── U4, U5, U6, U7, U8 (each also needs its P) diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt index 44a4466..58ba289 100644 --- a/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt @@ -26,7 +26,7 @@ import kotlin.math.ln class CommandSuggester( private val config: Config = Config(), private val clock: () -> Long = System::currentTimeMillis, -) { +) : Suggester { /** * The caps that bound memory and the saved state, and the frecency half-life. * @@ -75,7 +75,7 @@ class CommandSuggester( * Learns from a command the player sent. With [hidden] (the game asked for a password) nothing is * stored, and the next command is not linked to the one before the hidden input either. */ - fun recordSent(text: String, hidden: Boolean = false) = synchronized(lock) { + override fun recordSent(text: String, hidden: Boolean): Unit = synchronized(lock) { if (hidden) { previous = null recentLines.clear() @@ -105,7 +105,7 @@ class CommandSuggester( } /** Learns from a line the game showed: its words for completion, and it as context for the next send. */ - fun observeLine(line: String) = synchronized(lock) { + override fun observeLine(line: String): Unit = synchronized(lock) { outputWords.add(line) val key = normalizeLine(line) ?: return@synchronized recentLines.remove(key) @@ -114,7 +114,7 @@ class CommandSuggester( } /** The candidates for [input] with the cursor at [cursor], best first, at most [limit]. */ - fun suggest(input: String, cursor: Int = input.length, limit: Int = 8): Suggestions = synchronized(lock) { + override fun suggest(input: String, cursor: Int, limit: Int): Suggestions = synchronized(lock) { require(cursor in 0..input.length) { "cursor $cursor outside the input" } require(limit >= 0) { "negative limit $limit" } val now = clock() @@ -173,7 +173,7 @@ class CommandSuggester( * Writes what was learned for [load]: the history, the predictions and the previous command, but not the * session's output words or game lines. */ - fun save(out: Appendable) = synchronized(lock) { + override fun save(out: Appendable): Unit = synchronized(lock) { SuggestCodec.write(out, history.values, previous, sequences, lineResponses) } @@ -190,7 +190,7 @@ class CommandSuggester( * format or version leaves the state empty; the caps apply as entries are read. An I/O error from [input] * propagates, with what was read before it kept. */ - fun load(input: Reader): LoadResult = synchronized(lock) { + override fun load(input: Reader): LoadResult = synchronized(lock) { clear() val result = SuggestCodec.read(input) { record -> when (record) { diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestStore.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestStore.kt new file mode 100644 index 0000000..f3df92a --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestStore.kt @@ -0,0 +1,43 @@ +package org.mudlet.android.suggest + +import java.io.File +import java.io.IOException +import java.io.Reader +import java.io.Writer +import java.net.URLEncoder +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** Where a profile's suggestions are kept between runs of the app. Both calls may throw [IOException]. */ +interface SuggestStore { + /** Hands the saved text to [read] and returns its result, or returns null when nothing is saved. */ + fun load(read: (Reader) -> T): T? + + /** Replaces the saved text with what [write] writes: whole, or, when it fails, not at all. */ + fun save(write: (Writer) -> Unit) +} + +/** A [SuggestStore] in one UTF-8 file, replaced through a temporary file beside it so a failed save leaves the old one. */ +class SuggestFile(val file: File) : SuggestStore { + override fun load(read: (Reader) -> T): T? { + if (!file.exists()) return null + return file.bufferedReader().use(read) + } + + override fun save(write: (Writer) -> Unit) { + val dir = file.absoluteFile.parentFile + if (!dir.isDirectory && !dir.mkdirs()) throw IOException("cannot create $dir") + val temporary = File(dir, file.name + ".tmp") + try { + temporary.bufferedWriter().use(write) + Files.move(temporary.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } finally { + temporary.delete() + } + } + + companion object { + /** The file for [profile] in [dir]: its name URL-encoded, so any profile name is one file name. */ + fun forProfile(dir: File, profile: String) = SuggestFile(File(dir, URLEncoder.encode(profile, "UTF-8") + ".txt")) + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggester.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggester.kt new file mode 100644 index 0000000..4583e25 --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggester.kt @@ -0,0 +1,19 @@ +package org.mudlet.android.suggest + +import java.io.Reader + +/** + * What a [SuggestionSession] needs of a profile's suggestions: [CommandSuggester], or a stand-in in a test. + * The calls are [CommandSuggester]'s, which documents them. + */ +interface Suggester { + fun recordSent(text: String, hidden: Boolean = false) + + fun observeLine(line: String) + + fun suggest(input: String, cursor: Int = input.length, limit: Int = 8): Suggestions + + fun save(out: Appendable) + + fun load(input: Reader): CommandSuggester.LoadResult +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestionSession.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestionSession.kt new file mode 100644 index 0000000..158e9ff --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestionSession.kt @@ -0,0 +1,162 @@ +package org.mudlet.android.suggest + +import java.io.IOException + +/** + * One profile's suggestions while the app runs. [open] loads what [store] kept; then the session learns from what + * the input line sends ([sent]) and what the game shows ([lineShown]), answers the input as it is typed + * ([inputChanged]), and saves what it learned a while after a send and on [save]. + * + * Every call to [suggester] and [store] runs on [worker], in the order the calls here were made: a command sent + * while the saved state still loads is learned on top of it. The calls here come from one thread, the UI's, and + * return at once; [listener] gets the answers on the worker's thread. + * + * Hidden input (a password) never reaches the session: [sentHidden] takes no text, and nothing is answered while + * [hiddenChanged] says the game hides input. An I/O error goes to [log] and the session carries on in memory; + * after a failed load it saves nothing, so a file it could not read is not replaced with less. [log] is never given + * a command or a game line. + */ +class SuggestionSession( + private val suggester: Suggester, + private val store: SuggestStore, + private val worker: Worker, + private val log: (message: String, error: Throwable?) -> Unit, + private val limit: Int = LIMIT, +) { + /** What the suggester answered for [input] with the cursor at [cursor]. */ + data class Answer(val input: String, val cursor: Int, val suggestions: Suggestions) + + /** Gets each answer, on the worker's thread. */ + @Volatile var listener: ((Answer) -> Unit)? = null + + // The calling thread's + private var opened = false + private var pendingAnswer: Cancellable? = null + + @Volatile private var hidden = false + + // The worker's + private var loadFailed = false + private var changed = false + private var savePending = false + private val echoes = ArrayDeque() + + private class Echo(val text: String, var linesLeft: Int) + + /** Loads what the store kept; only the first call does anything. */ + fun open() { + if (opened) return + opened = true + worker.schedule(0) { + val result = try { + store.load(suggester::load) + } catch (e: IOException) { + loadFailed = true + log("could not load the saved suggestions; nothing is saved until the app restarts", e) + return@schedule + } + if (result != null && (!result.recognised || result.skippedLines > 0)) { + log("the saved suggestions were damaged: recognised ${result.recognised}, ${result.skippedLines} lines skipped", null) + } + } + } + + /** + * The input line holds [input] with the cursor at [cursor]. It is answered once it has stayed so for + * [TYPING_DELAY_MS]: a newer call drops the answer an older one was waiting for. + */ + fun inputChanged(input: String, cursor: Int) { + dropPendingAnswer() + if (hidden) return + pendingAnswer = worker.schedule(TYPING_DELAY_MS) { + if (!hidden) listener?.invoke(Answer(input, cursor, suggester.suggest(input, cursor.coerceIn(0, input.length), limit))) + } + } + + /** Whether the game hides what is typed (a password). While it does, nothing is answered. */ + fun hiddenChanged(hidden: Boolean) { + this.hidden = hidden + if (hidden) dropPendingAnswer() + } + + private fun dropPendingAnswer() { + pendingAnswer?.cancel() + pendingAnswer = null + } + + /** The input line sent [command]. The game's echo of it, the next line or one of the two after, is not learned from. */ + fun sent(command: String) { + worker.schedule(0) { + suggester.recordSent(command) + val echo = command.trim() + if (echo.isNotEmpty()) echoes.addLast(Echo(echo, ECHO_LINES)) + learned() + } + } + + /** The masked field sent something. What it was is not passed: it is kept nowhere, and the command before it no longer predicts the next one. */ + fun sentHidden() { + worker.schedule(0) { + suggester.recordSent("", hidden = true) + learned() + } + } + + /** The game showed [line], as plain text. */ + fun lineShown(line: String) { + worker.schedule(0) { if (!isEcho(line)) suggester.observeLine(line) } + } + + /** Saves now if anything was learned since the last save, as when the app leaves the front. */ + fun save() { + worker.schedule(0) { saveIfChanged() } + } + + private fun learned() { + changed = true + if (savePending) return + savePending = true + worker.schedule(SAVE_DELAY_MS) { + savePending = false + saveIfChanged() + } + } + + private fun saveIfChanged() { + if (!changed || loadFailed) return + try { + store.save(suggester::save) + changed = false + } catch (e: IOException) { + log("could not save the suggestions; trying again after the next send", e) + } + } + + // Mudlet echoes a sent command on the console, after the prompt if there is one: the line ends with the + // command, whole ("n" is not the end of "plain") + private fun isEcho(line: String): Boolean { + val shown = line.trimEnd() + val match = echoes.indexOfFirst { shown.endsWith(it.text) && shown.getOrNull(shown.length - it.text.length - 1)?.isLetterOrDigit() != true } + if (match >= 0) { + repeat(match + 1) { echoes.removeFirst() } + return true + } + echoes.forEach { it.linesLeft-- } + echoes.removeAll { it.linesLeft <= 0 } + return false + } + + companion object { + /** How many suggestions an answer has at most. */ + const val LIMIT = 5 + + /** How long the input stays unchanged before it is answered. */ + const val TYPING_DELAY_MS = 120L + + /** How long after a send what was learned is saved, at the latest. */ + const val SAVE_DELAY_MS = 5_000L + + /** How many lines after a send may be its echo. */ + const val ECHO_LINES = 3 + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/Worker.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/Worker.kt new file mode 100644 index 0000000..ce8d15c --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/Worker.kt @@ -0,0 +1,35 @@ +package org.mudlet.android.suggest + +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit + +/** Runs tasks one at a time, in the order they fall due, on one thread of its own. */ +interface Worker { + /** Runs [task] [delayMillis] from now, after the tasks due before it; the result cancels it until it begins. */ + fun schedule(delayMillis: Long, task: () -> Unit): Cancellable +} + +fun interface Cancellable { + fun cancel() +} + +/** + * A [Worker] on a daemon thread named [name]. A task that throws is reported to [onFailure] and the next one runs. + * Tasks that fall due together run in the order they were scheduled. + */ +class ExecutorWorker(name: String, private val onFailure: (Throwable) -> Unit) : Worker { + private val executor = ScheduledThreadPoolExecutor(1) { runnable -> Thread(runnable, name).apply { isDaemon = true } }.apply { + removeOnCancelPolicy = true + } + + override fun schedule(delayMillis: Long, task: () -> Unit): Cancellable { + val future = executor.schedule({ + try { + task() + } catch (e: Exception) { + onFailure(e) + } + }, delayMillis, TimeUnit.MILLISECONDS) + return Cancellable { future.cancel(false) } + } +} diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestFileTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestFileTest.kt new file mode 100644 index 0000000..39a1339 --- /dev/null +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestFileTest.kt @@ -0,0 +1,73 @@ +package org.mudlet.android.suggest + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException +import java.nio.file.Files + +class SuggestFileTest { + private val dir: File = Files.createTempDirectory("suggest").toFile() + + @After + fun removeTheDirectory() { + dir.deleteRecursively() + } + + @Test + fun whatIsSavedLoadsBack() { + val file = SuggestFile.forProfile(File(dir, "suggest"), "Achaea") + val s = CommandSuggester().apply { recordSent("look") } + file.save(s::save) + val copy = CommandSuggester() + val result = file.load(copy::load) + assertEquals(CommandSuggester.LoadResult(recognised = true, skippedLines = 0), result) + assertEquals(listOf("look"), copy.suggest("").items.map { it.text }) + assertEquals(listOf("Achaea.txt"), file.file.parentFile.list()!!.toList()) + } + + @Test + fun nothingSavedLoadsNothing() { + assertNull(SuggestFile.forProfile(dir, "new").load { fail("read with no file") }) + } + + @Test + fun aFailedSaveLeavesTheOldFile() { + val file = SuggestFile.forProfile(dir, "p") + file.save { it.write("old") } + try { + file.save { + it.write("half") + throw IOException("disk full") + } + fail("the save did not fail") + } catch (_: IOException) { + } + assertEquals("old", file.file.readText()) + assertEquals(listOf("p.txt"), dir.list()!!.toList()) + } + + @Test + fun anyProfileNameIsOneFileInTheDirectory() { + for (name in listOf("a/b", "..", "Ünïcode game", "C:\\x")) { + val file = SuggestFile.forProfile(dir, name).file + assertEquals(dir, file.parentFile) + assertTrue(file.name, file.name.endsWith(".txt")) + } + assertEquals("a%2Fb.txt", SuggestFile.forProfile(dir, "a/b").file.name) + } + + @Test + fun aDirectoryThatCannotBeMadeFailsTheSave() { + val blocked = File(dir, "blocked").apply { writeText("a file") } + try { + SuggestFile.forProfile(File(blocked, "suggest"), "p").save { it.write("x") } + fail("saved under a file") + } catch (_: IOException) { + } + } +} diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestionSessionTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestionSessionTest.kt new file mode 100644 index 0000000..4d1686e --- /dev/null +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestionSessionTest.kt @@ -0,0 +1,287 @@ +package org.mudlet.android.suggest + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.io.Reader +import java.io.Writer + +class SuggestionSessionTest { + /** A [Worker] on a clock the test moves: [advance] runs what falls due, in order. */ + private class FakeWorker : Worker { + var now = 0L + private var scheduled = 0L + + private class Task(val due: Long, val order: Long, val run: () -> Unit) { + var cancelled = false + } + + private val tasks = mutableListOf() + + override fun schedule(delayMillis: Long, task: () -> Unit): Cancellable { + val t = Task(now + delayMillis, scheduled++, task) + tasks += t + return Cancellable { t.cancelled = true } + } + + fun advance(millis: Long = 0) { + val end = now + millis + while (true) { + val next = tasks.filter { !it.cancelled && it.due <= end }.minWithOrNull(compareBy({ it.due }, { it.order })) ?: break + tasks.remove(next) + now = next.due + next.run() + } + tasks.removeAll { it.cancelled } + now = end + } + + val pending get() = tasks.count { !it.cancelled } + } + + /** A [CommandSuggester] that remembers every text it was given. */ + private class RecordingSuggester(private val real: CommandSuggester = CommandSuggester()) : Suggester by real { + val asked = mutableListOf() + val recorded = mutableListOf>() + val observed = mutableListOf() + + override fun suggest(input: String, cursor: Int, limit: Int): Suggestions { + asked += input + return real.suggest(input, cursor, limit) + } + + override fun recordSent(text: String, hidden: Boolean) { + recorded += text to hidden + real.recordSent(text, hidden) + } + + override fun observeLine(line: String) { + observed += line + real.observeLine(line) + } + + fun everything() = asked + recorded.map { it.first } + observed + } + + private class MemoryStore(var text: String? = null) : SuggestStore { + var failLoad = false + var failSave = false + var saves = 0 + + override fun load(read: (Reader) -> T): T? { + if (failLoad) throw IOException("no access") + return text?.let { read(it.reader()) } + } + + override fun save(write: (Writer) -> Unit) { + if (failSave) throw IOException("disk full") + val out = java.io.StringWriter() + write(out) + text = out.toString() + saves++ + } + } + + private val worker = FakeWorker() + private val suggester = RecordingSuggester() + private val store = MemoryStore() + private val logged = mutableListOf() + private val answers = mutableListOf() + + private fun session(s: Suggester = suggester) = SuggestionSession(s, store, worker, { message, _ -> logged += message }).also { + it.listener = { answer -> answers += answer } + it.open() + } + + private fun savedWith(vararg commands: String): String { + val s = CommandSuggester() + commands.forEach { s.recordSent(it) } + return StringBuilder().also { s.save(it) }.toString() + } + + @Test + fun typingIsAnsweredOnceItPauses() { + val session = session() + session.sent("look") + worker.advance() + for (typed in listOf("l", "lo", "loo")) { + session.inputChanged(typed, typed.length) + worker.advance(SuggestionSession.TYPING_DELAY_MS - 1) + } + assertEquals("nothing asked while typing goes on", emptyList(), suggester.asked) + worker.advance(1) + assertEquals(listOf("loo"), suggester.asked) + assertEquals(1, answers.size) + val answer = answers.single() + assertEquals("loo" to 3, answer.input to answer.cursor) + assertEquals(listOf("look"), answer.suggestions.items.map { it.text }) + assertEquals("k", answer.suggestions.ghost) + } + + @Test + fun anAnswerHasAtMostTheLimit() { + val session = session() + for (n in 1..9) session.sent("say $n") + session.inputChanged("say", 3) + worker.advance(SuggestionSession.TYPING_DELAY_MS) + assertEquals(SuggestionSession.LIMIT, answers.single().suggestions.items.size) + } + + @Test + fun anEmptyLineAfterASendShowsTheNextCommand() { + val session = session() + for (command in listOf("kill rat", "loot", "kill rat")) { + session.sent(command) + session.inputChanged("", 0) + } + worker.advance(SuggestionSession.TYPING_DELAY_MS) + assertEquals("loot", answers.single().suggestions.items.first().text) + } + + @Test + fun aSendWhileTheSavedStateLoadsIsLearnedOnTopOfIt() { + store.text = savedWith("open door") + val session = session() + session.sent("north") + session.inputChanged("", 0) + worker.advance(SuggestionSession.TYPING_DELAY_MS) + assertEquals(setOf("open door", "north"), answers.single().suggestions.items.map { it.text }.toSet()) + } + + @Test + fun hiddenInputIsNeverAnsweredOrKept() { + val session = session() + session.sent("north") + session.inputChanged("hunt", 4) + session.hiddenChanged(true) + session.inputChanged("hunter2", 7) + session.sentHidden() + worker.advance(SuggestionSession.SAVE_DELAY_MS) + assertEquals("nothing answered while input is hidden", emptyList(), answers) + session.hiddenChanged(false) + session.inputChanged("", 0) + session.save() + worker.advance(SuggestionSession.TYPING_DELAY_MS) + + assertEquals(listOf("north" to false, "" to true), suggester.recorded) + assertTrue(suggester.everything().none { "hunt" in it }) + assertFalse("hunt" in store.text!!) + // The hidden send ends the sequence: "north" no longer predicts anything, so the empty line offers history + assertEquals(SuggestionSource.HISTORY, answers.single().suggestions.items.single().source) + } + + @Test + fun whatWasLearnedIsSavedAWhileAfterASend() { + val session = session() + session.sent("look") + worker.advance(SuggestionSession.SAVE_DELAY_MS - 1) + session.sent("north") + assertEquals(0, store.saves) + worker.advance(1) + assertEquals("one save for both sends", 1, store.saves) + assertTrue("look" in store.text!! && "north" in store.text!!) + worker.advance(SuggestionSession.SAVE_DELAY_MS) + assertEquals(1, store.saves) + + session.save() + worker.advance() + assertEquals("nothing new to save", 1, store.saves) + session.sent("east") + session.save() + worker.advance() + assertEquals(2, store.saves) + assertTrue("east" in store.text!!) + } + + @Test + fun aFailedSaveIsLoggedWithoutTheCommandAndTriedAgain() { + val session = session() + store.failSave = true + session.sent("tell bob my secret") + worker.advance(SuggestionSession.SAVE_DELAY_MS) + assertEquals(1, logged.size) + assertTrue(logged.none { "secret" in it }) + + store.failSave = false + session.sent("look") + worker.advance(SuggestionSession.SAVE_DELAY_MS) + assertTrue("tell bob my secret" in store.text!!) + } + + @Test + fun afterAFailedLoadNothingIsSavedButSuggestionsGoOn() { + store.text = "kept" + store.failLoad = true + val session = session() + session.sent("look") + session.inputChanged("l", 1) + session.save() + worker.advance(SuggestionSession.SAVE_DELAY_MS) + assertEquals(1, logged.size) + assertEquals("kept", store.text) + assertEquals(listOf("look"), answers.single().suggestions.items.map { it.text }) + } + + @Test + fun aDamagedSaveIsLoggedAndTheRestKept() { + store.text = savedWith("look") + "nonsense\n" + val session = session() + session.inputChanged("l", 1) + worker.advance(SuggestionSession.TYPING_DELAY_MS) + assertEquals(1, logged.size) + assertEquals(listOf("look"), answers.single().suggestions.items.map { it.text }) + } + + @Test + fun theEchoOfASendIsNotLearnedFrom() { + val session = session() + session.sent("look") + session.lineShown("HP:100> look") + session.lineShown("You see a door.") + session.sent("north") + session.lineShown("A dark room.") + session.lineShown("north") + worker.advance() + assertEquals(listOf("You see a door.", "A dark room."), suggester.observed) + + // An echo that does not come within three lines is not looked for any more + session.sent("east") + for (n in 1..3) session.lineShown("line $n") + session.lineShown("> east") + worker.advance() + assertEquals(listOf("line 1", "line 2", "line 3", "> east"), suggester.observed.drop(2)) + + // Only the whole command at the end of a line is its echo + session.sent("n") + session.lineShown("An open plain") + session.lineShown("HP:9>n") + worker.advance() + assertEquals(listOf("An open plain"), suggester.observed.drop(6)) + } + + @Test + fun openLoadsOnce() { + store.text = savedWith("look") + val session = session() + worker.advance() + session.sent("north") + session.open() + session.inputChanged("", 0) + worker.advance(SuggestionSession.TYPING_DELAY_MS) + assertEquals(setOf("look", "north"), answers.single().suggestions.items.map { it.text }.toSet()) + } + + @Test + fun withoutAListenerAnswersAreDropped() { + val session = session() + session.listener = null + session.inputChanged("x", 1) + worker.advance(SuggestionSession.TYPING_DELAY_MS) + assertEquals(0, worker.pending) + assertTrue(answers.isEmpty()) + assertNull(store.text) + } +} From e118b527b2516b56e1d213889573f67eb7b4a623 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Nov 2026 00:20:23 +0000 Subject: [PATCH 3/4] History search and pinned quick buttons A History button below the input line, or Ctrl+R on a hardware keyboard, opens a sheet that searches the profile's history as the suggestions match it (CommandSuggester.search). A row puts its command on the line without sending; its buttons send, pin or unpin, and forget it. Up to 12 pinned commands per profile show as buttons below the line that send at a tap, saved beside the suggestions as files/suggest/.pins. Neither the quick bar nor the sheet shows while the game hides input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeGtuaeaa9oac3KmLFo --- .../org/mudlet/android/ui/HistorySheetTest.kt | 338 ++++++++++++++++++ .../android/ui/game/CommandSuggestions.kt | 62 +++- .../org/mudlet/android/ui/game/GameScreen.kt | 64 +++- .../mudlet/android/ui/game/HistorySheet.kt | 215 +++++++++++ .../org/mudlet/android/ui/game/InputLine.kt | 13 +- .../org/mudlet/android/ui/game/QuickBar.kt | 58 +++ app/src/main/res/drawable/ic_delete.xml | 11 + app/src/main/res/drawable/ic_history.xml | 11 + app/src/main/res/drawable/ic_push_pin.xml | 11 + .../main/res/drawable/ic_push_pin_outline.xml | 11 + app/src/main/res/values/strings.xml | 25 ++ docs/architecture.md | 37 +- docs/roadmap.md | 4 +- .../android/suggest/CommandSuggester.kt | 18 +- .../mudlet/android/suggest/PinnedCommands.kt | 160 +++++++++ .../mudlet/android/suggest/SuggestCodec.kt | 4 +- .../mudlet/android/suggest/SuggestStore.kt | 10 +- .../org/mudlet/android/suggest/Suggester.kt | 4 + .../android/suggest/SuggestionSession.kt | 53 ++- .../android/suggest/CommandSuggesterTest.kt | 52 +++ .../org/mudlet/android/suggest/Fakes.kt | 58 +++ .../android/suggest/PinnedCommandsTest.kt | 186 ++++++++++ .../android/suggest/SuggestionSessionTest.kt | 111 +++--- 23 files changed, 1427 insertions(+), 89 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/mudlet/android/ui/HistorySheetTest.kt create mode 100644 app/src/main/kotlin/org/mudlet/android/ui/game/HistorySheet.kt create mode 100644 app/src/main/kotlin/org/mudlet/android/ui/game/QuickBar.kt create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/drawable/ic_history.xml create mode 100644 app/src/main/res/drawable/ic_push_pin.xml create mode 100644 app/src/main/res/drawable/ic_push_pin_outline.xml create mode 100644 suggest/src/main/kotlin/org/mudlet/android/suggest/PinnedCommands.kt create mode 100644 suggest/src/test/kotlin/org/mudlet/android/suggest/Fakes.kt create mode 100644 suggest/src/test/kotlin/org/mudlet/android/suggest/PinnedCommandsTest.kt diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/HistorySheetTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/HistorySheetTest.kt new file mode 100644 index 0000000..043310e --- /dev/null +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/HistorySheetTest.kt @@ -0,0 +1,338 @@ +package org.mudlet.android.ui + +import android.Manifest +import android.os.Build +import androidx.annotation.StringRes +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.ComposeTimeoutException +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isFocused +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.compose.ui.test.withKeyDown +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.UiDevice +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mudlet.android.R +import org.mudlet.android.suggest.PinnedCommands +import org.mudlet.android.suggest.SuggestFile +import org.mudlet.android.ui.game.CommandSuggestions +import org.mudlet.android.ui.game.HISTORY_SHEET_TAG +import org.mudlet.android.ui.game.QUICK_BAR_TAG +import java.io.File + +/** + * The command history and the pinned commands against a fake engine: the sheet the History button and Ctrl+R open, + * its search, its rows' actions, the quick bar's pinned buttons, and none of them while the game hides input. Each + * test has a profile of its own, so it starts with nothing sent or pinned. Controls are found by the labels TalkBack + * reads and activated through their accessibility actions. + */ +@RunWith(AndroidJUnit4::class) +class HistorySheetTest { + private val fake = FakeEngineRule() + private val compose = createAndroidComposeRule() + + @get:Rule + val rules: RuleChain = RuleChain.outerRule(MainThreadTimeout(TIMEOUT_MS)).around(fake) + .around(if (Build.VERSION.SDK_INT >= 33) GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) else RuleChain.emptyRuleChain()) + .around(compose) + + private lateinit var session: FakeEngine.Session + private val profile = "history ${System.nanoTime()}" + private lateinit var savedSuggestions: File + private lateinit var savedPins: File + + private val password = SemanticsMatcher.keyIsDefined(SemanticsProperties.Password) + private val sheet = hasTestTag(HISTORY_SHEET_TAG) + private val inSheet = hasAnyAncestor(sheet) + private val input = hasSetTextAction() and !password and !inSheet + private val search = hasSetTextAction() and inSheet + private val quickBar = hasTestTag(QUICK_BAR_TAG) + + @Before + fun openTheGameScreenOnANewProfile() { + // enableAccessibilityChecks() needs API 34: the API 36 emulator runs the checks, API 28 does not + if (Build.VERSION.SDK_INT >= 34) compose.enableAccessibilityChecks() + session = fake.engine.nextSession() + // EngineClient lives as long as the process, so an earlier test's lines would still be there + session.clear() + session.synced(0) + val dir = CommandSuggestions.dir(compose.activity) + savedSuggestions = SuggestFile.forProfile(dir, profile).file + savedPins = SuggestFile.pinsForProfile(dir, profile).file + activate(hasText(string(R.string.profiles_open_game))) + compose.onNodeWithText(string(R.string.game_title)).assertIsDisplayed() + session.status("profile", "$profile loaded") + compose.waitUntil(WAIT_MS) { compose.runOnIdle { CommandSuggestions.profile } == profile } + compose.waitForIdle() + } + + @After + fun forgetTheProfile() { + savedSuggestions.delete() + savedPins.delete() + } + + @Test + fun theHistoryButtonOpensASearchThatFiltersAndARowFillsTheLineWithoutSending() { + for (command in listOf("open door", "north", "look")) send(command) + activate(historyButton) + waitFor(row("north")) + // The focus moves into the sheet, to its search field + waitFor(search and isFocused()) + + compose.onNode(search).performTextInput("od") + waitUntilGone(row("north")) + compose.onNode(row("look")).assertDoesNotExist() + activate(row("open door")) + + waitUntilGone(sheet) + waitFor(input and isFocused()) + compose.onNode(input).assert(editableText("open door")) + // A request after the tap comes next: nothing was sent in between + activate(hasContentDescription(string(R.string.input_complete))) + assertEquals("open door", session.receive("complete").getString("text")) + assertEquals(3, session.received.count { it.optString("t") == "send" }) + } + + @Test + fun aRowsSendButtonSendsAtOnceAndClosesTheSheet() { + send("open door") + activate(historyButton) + activate(labelled(R.string.history_send, "open door")) + assertEquals("open door", session.receive("send").getString("text")) + waitUntilGone(sheet) + compose.onNode(input).assert(editableText("")) + } + + @Test + fun aPinnedCommandIsAButtonThatSendsItUntilItIsUnpinned() { + send("open door") + activate(historyButton) + activate(labelled(R.string.history_pin, "open door")) + // Listed as pinned, with its unpin button, and saved at once beside the suggestions + waitFor(labelled(R.string.history_unpin, "open door")) + compose.waitUntil(WAIT_MS) { savedPins.isFile && "open door" in savedPins.readText() } + activate(hasContentDescription(string(R.string.history_close))) + waitUntilGone(sheet) + + val pinned = labelled(R.string.pinned_command, "open door") + waitFor(pinned) + val button = compose.onNode(pinned and hasClickAction()).fetchSemanticsNode() + val minimum = with(compose.density) { 48.dp.toPx() } + assertTrue("a ${button.boundsInRoot.height} px tall button, at least 48 dp ($minimum px)", button.boundsInRoot.height >= minimum) + activate(pinned) + assertEquals("open door", session.receive("send").getString("text")) + + activate(historyButton) + activate(labelled(R.string.history_unpin, "open door")) + waitFor(labelled(R.string.history_pin, "open door")) + compose.waitUntil(WAIT_MS) { "open door" !in savedPins.readText() } + activate(hasContentDescription(string(R.string.history_close))) + waitUntilGone(sheet) + compose.onNode(pinned).assertDoesNotExist() + } + + @Test + fun forgetRemovesACommandFromTheHistory() { + send("open door") + send("north") + activate(historyButton) + activate(labelled(R.string.history_forget, "north")) + waitUntilGone(row("north")) + compose.onNode(row("open door")).assertIsDisplayed() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun ctrlROpensTheSheetAndBackClosesIt() { + send("look") + compose.onNode(input).requestFocus() + compose.onNode(input).performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.R) } } + waitFor(row("look")) + waitFor(search and isFocused()) + + // Back closes it: a second Back when the first only put away the keyboard the search field opened + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.pressBack() + try { + waitUntilGone(sheet, BACK_MS) + } catch (_: ComposeTimeoutException) { + device.pressBack() + waitUntilGone(sheet) + } + compose.onNodeWithText(string(R.string.game_title)).assertIsDisplayed() + // The R went to neither field + compose.onNode(input).assert(editableText("")) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun whileTheGameHidesInputThereIsNeitherHistoryNorPinsAndThePasswordIsKeptOutOfBoth() { + send("north") + activate(historyButton) + activate(labelled(R.string.history_pin, "north")) + waitFor(labelled(R.string.history_unpin, "north")) + + session.hiddenInput(true) + waitFor(password) + waitUntilGone(sheet) + compose.onNode(quickBar).assertDoesNotExist() + compose.onNode(historyButton).assertDoesNotExist() + compose.onNode(labelled(R.string.pinned_command, "north")).assertDoesNotExist() + compose.onNode(password).performTextInput("hunter2") + compose.onNode(password).performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.R) } } + compose.waitForIdle() + compose.onNode(sheet).assertDoesNotExist() + compose.onNode(password).performImeAction() + assertEquals("hunter2", session.receive("sendHidden").getString("text")) + + session.hiddenInput(false) + waitFor(quickBar) + waitFor(labelled(R.string.pinned_command, "north")) + activate(historyButton) + compose.onNode(search).performTextInput("hunter") + waitFor(hasText(string(R.string.history_no_match)) and inSheet) + assertFalse("the password is not pinned", "hunter" in savedPins.readText()) + } + + @Test + fun aProfileWithAsManyPinsAsCanBeLoadsThemAndRefusesOneMore() { + // Another profile, whose file is there before the game screen first shows it + val full = "$profile full" + val dir = CommandSuggestions.dir(compose.activity) + val files = listOf(SuggestFile.forProfile(dir, full).file, SuggestFile.pinsForProfile(dir, full).file) + val commands = (1..PinnedCommands.MAX).map { "say $it" } + try { + dir.mkdirs() + files[1].writeText((listOf(PinnedCommands.HEADER) + commands).joinToString("\n", postfix = "\n")) + session.status("profile", "$full loaded") + compose.waitUntil(WAIT_MS) { compose.runOnIdle { CommandSuggestions.profile } == full } + // All of them, in the order pinned; the quick bar scrolls to the last + waitFor(labelled(R.string.pinned_command, commands.last())) + compose.onNode(labelled(R.string.pinned_command, commands.last())).performScrollTo().assertIsDisplayed() + + send("north") + compose.onNode(historyButton).performScrollTo() + activate(historyButton) + // Below the twelve pins, the history's rows are off the sheet until the search leaves them out + compose.onNode(search).performTextInput("north") + activate(labelled(R.string.history_pin, "north")) + val refused = compose.activity.resources.getQuantityString(R.plurals.history_pins_full, PinnedCommands.MAX, PinnedCommands.MAX) + waitFor(hasText(refused) and inSheet) + compose.onNode(labelled(R.string.history_unpin, "north")).assertDoesNotExist() + compose.onNode(labelled(R.string.history_pin, "north")).assertIsDisplayed() + assertFalse("not saved", "north" in files[1].readText()) + } finally { + files.forEach { it.delete() } + } + } + + @Test + fun theQuickBarIsBelowTheInputLineAndAboveTheConnectionBar() { + send("open door") + activate(historyButton) + activate(labelled(R.string.history_pin, "open door")) + activate(hasContentDescription(string(R.string.history_close))) + waitUntilGone(sheet) + waitFor(labelled(R.string.pinned_command, "open door")) + checkAccessibility() + + // TalkBack goes by position, so the quick bar is read after the input line's buttons and before the bar below + val line = bounds(input) + val send = bounds(hasContentDescription(string(R.string.input_send)) and hasClickAction()) + val history = bounds(historyButton) + val pinned = bounds(labelled(R.string.pinned_command, "open door")) + val reconnect = bounds(hasText(string(R.string.connection_reconnect))) + assertTrue("history $history below the input line $line and its Send $send", history.top >= line.bottom && history.top >= send.bottom) + assertTrue("pinned $pinned beside history $history", pinned.left >= history.right && pinned.top < history.bottom) + assertTrue("connection bar $reconnect below the quick bar $history", reconnect.top >= history.bottom) + val minimum = with(compose.density) { 48.dp.toPx() } + assertTrue("a ${history.height} px tall History button, at least 48 dp ($minimum px)", history.height >= minimum) + } + + private val historyButton get() = hasText(string(R.string.history_open)) and hasClickAction() + + private fun string(@StringRes id: Int) = compose.activity.getString(id) + + private fun labelled(@StringRes id: Int, command: String) = hasContentDescription(compose.activity.getString(id, command)) + + /** The row in the sheet that puts [command] on the input line. */ + private fun row(command: String) = hasText(command) and inSheet and hasClickAction() + + private fun send(command: String) { + compose.onNode(input).performTextInput(command) + compose.onNode(input).performImeAction() + assertEquals(command, session.receive("send").getString("text")) + } + + // As plain text: the field's EditableText can carry spans, so comparing AnnotatedStrings fails on equal text + private fun editableText(text: String) = SemanticsMatcher("EditableText = '$text'") { + it.config.getOrNull(SemanticsProperties.EditableText)?.text == text + } + + private fun bounds(matcher: SemanticsMatcher): Rect = compose.onNode(matcher).fetchSemanticsNode().boundsInRoot + + private fun waitFor(matcher: SemanticsMatcher) = compose.waitUntil(WAIT_MS) { compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() } + + private fun waitUntilGone(matcher: SemanticsMatcher, timeoutMs: Long = WAIT_MS) = compose.waitUntil(timeoutMs) { + compose.onAllNodes(matcher).fetchSemanticsNodes().isEmpty() + } + + // Each window's: the sheet is a window of its own over the activity's + private fun checkAccessibility() { + val roots = compose.onAllNodes(isRoot()) + for (i in roots.fetchSemanticsNodes().indices) roots[i].tryPerformAccessibilityChecks() + } + + /** What a double tap does under TalkBack: the node's accessibility click, after the checks on every window. */ + private fun activate(label: SemanticsMatcher) { + waitFor(label and hasClickAction()) + checkAccessibility() + compose.onNode(label and hasClickAction()).assertIsDisplayed().performSemanticsAction(SemanticsActions.OnClick) + } + + private companion object { + const val WAIT_MS = 10_000L + + // Long enough for the sheet's closing animation: a sheet still open after it had only its keyboard put away + const val BACK_MS = 3_000L + + // Below the per-test timeout native/tests/android_instrumented.py gives the runner, so this one reports first + const val TIMEOUT_MS = 240_000L + } +} diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/CommandSuggestions.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/CommandSuggestions.kt index 093a773..0855611 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/CommandSuggestions.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/CommandSuggestions.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalContext import org.mudlet.android.suggest.ExecutorWorker +import org.mudlet.android.suggest.PinnedCommands import org.mudlet.android.suggest.SuggestFile import org.mudlet.android.suggest.SuggesterRegistry import org.mudlet.android.suggest.SuggestionSession @@ -22,9 +23,10 @@ import org.mudlet.android.ui.EngineClient import java.io.File /** - * Each profile's command suggestions (`:suggest`, docs/architecture.md, "Command suggestions") for the UI process's - * life: one [SuggestionSession] per profile, loaded the first time the game screen shows that profile, and kept in - * the app's private storage as `files/suggest/.txt`. Main thread only. + * Each profile's command suggestions and pinned commands (`:suggest`, docs/architecture.md, "Command suggestions") + * for the UI process's life: one [SuggestionSession] and one [PinnedCommands] per profile, loaded the first time the + * game screen shows that profile, and kept in the app's private storage as `files/suggest/.txt` and + * `files/suggest/.pins`. Main thread only. */ object CommandSuggestions { private const val TAG = "MudletSuggest" @@ -37,6 +39,7 @@ object CommandSuggestions { private val worker by lazy { ExecutorWorker("suggestions") { Log.e(TAG, "a suggestion task failed: ${it.javaClass.name}") } } private val registry = SuggesterRegistry() private val sessions = HashMap() + private val pins = HashMap() /** The profile whose suggestions the input line shows: the one the engine last said it loaded. */ var profile by mutableStateOf(DEFAULT_PROFILE) @@ -50,13 +53,17 @@ object CommandSuggestions { // The number (EngineClient.linesAdded) after the last console line given to a session, so none is given twice private var linesSeen = 0L - /** Where the profiles' suggestions are saved. */ + /** Where the profiles' suggestions and pinned commands are saved. */ fun dir(context: Context) = File(context.filesDir, "suggest") fun forProfile(context: Context, profile: String): SuggestionSession = sessions.getOrPut(profile) { SuggestionSession(registry.forProfile(profile), SuggestFile.forProfile(dir(context), profile), worker, ::log).apply { open() } } + fun pinsForProfile(context: Context, profile: String): PinnedCommands = pins.getOrPut(profile) { + PinnedCommands(SuggestFile.pinsForProfile(dir(context), profile), worker, ::log).apply { open() } + } + /** Gives [state] the game's lines that came since the last call, leaving out the app's markers. */ internal fun linesShown(state: SuggestionState) { val c = EngineClient @@ -78,16 +85,49 @@ object CommandSuggestions { } } -/** The input line's suggestions for one profile, for the game screen: what was last answered, and what to tell it. */ +/** + * The input line's suggestions and pinned commands for one profile, for the game screen: what was last answered, the + * pins, and what to tell them. + */ @Stable -class SuggestionState internal constructor(internal val session: SuggestionSession) { +class SuggestionState internal constructor(internal val session: SuggestionSession, internal val pins: PinnedCommands) { /** The suggestions for the input line as it was a moment ago; it shows their ghost text only if it still is so. */ var answer by mutableStateOf(null) internal set + /** The answer to the latest [search], or null before the first. */ + var searchAnswer by mutableStateOf(null) + internal set + + /** The pinned commands, in the order they were pinned: empty until the profile's file has loaded. */ + var pinned by mutableStateOf(emptyList()) + internal set + + // While the game hides input nothing is pinned, though nothing offers a pin then either + private var hidden = false + fun inputChanged(text: String, cursor: Int) = session.inputChanged(text, cursor) - fun hiddenChanged(hidden: Boolean) = session.hiddenChanged(hidden) + fun hiddenChanged(hidden: Boolean) { + this.hidden = hidden + session.hiddenChanged(hidden) + } + + fun search(query: String) = session.search(query) + + fun forget(command: String) = session.forget(command) + + /** How many commands can be pinned. */ + val maxPins get() = pins.max + + /** Whether one more command can be pinned. */ + val canPin get() = pinned.size < maxPins + + fun pin(command: String) { + if (!hidden) pins.pin(command) + } + + fun unpin(command: String) = pins.unpin(command) fun sent(command: String) = session.sent(command) @@ -100,15 +140,21 @@ class SuggestionState internal constructor(internal val session: SuggestionSessi @Composable fun rememberSuggestionState(profile: String): SuggestionState { val context = LocalContext.current.applicationContext - val state = remember(profile) { SuggestionState(CommandSuggestions.forProfile(context, profile)) } + val state = remember(profile) { SuggestionState(CommandSuggestions.forProfile(context, profile), CommandSuggestions.pinsForProfile(context, profile)) } DisposableEffect(state) { val main = Handler(Looper.getMainLooper()) var active = true val listener: (SuggestionSession.Answer) -> Unit = { answer -> main.post { if (active) state.answer = answer } } + val searchListener: (SuggestionSession.SearchAnswer) -> Unit = { answer -> main.post { if (active) state.searchAnswer = answer } } + val pinsListener: (List) -> Unit = { pinned -> main.post { if (active) state.pinned = pinned } } state.session.listener = listener + state.session.searchListener = searchListener + state.pins.listener = pinsListener onDispose { active = false if (state.session.listener === listener) state.session.listener = null + if (state.session.searchListener === searchListener) state.session.searchListener = null + if (state.pins.listener === pinsListener) state.pins.listener = null state.session.save() } } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/GameScreen.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/GameScreen.kt index bf23e9e..46f78a7 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/GameScreen.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/GameScreen.kt @@ -7,28 +7,67 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import org.mudlet.android.R import org.mudlet.android.ui.EngineClient import org.mudlet.android.ui.MudletTopBar /** - * The console, the input line and the connection bar, top to bottom, which is also the order TalkBack reads them in. - * The keyboard pushes them up, so the console shrinks above it. The input line's suggestions come from the profile - * the engine runs ([rememberEngineSuggestions]), which learns from what the line sends. + * The console, the input line, the quick bar and the connection bar, top to bottom, which is also the order TalkBack + * reads them in. The keyboard pushes them up, so the console shrinks above it. The input line's suggestions and the + * quick bar's pinned commands come from the profile the engine runs ([rememberEngineSuggestions]), which learns from + * what is sent. The quick bar's History button, or Ctrl+R on a hardware keyboard, opens the command history + * ([HistorySheet]). While the game hides input there is neither quick bar nor history. */ @Composable fun GameScreen(onBack: () -> Unit) { val c = EngineClient val suggestions = rememberEngineSuggestions() - Scaffold(topBar = { MudletTopBar(stringResource(R.string.game_title), onBack) }) { padding -> - Column(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding).imePadding()) { + var historyOpen by rememberSaveable { mutableStateOf(false) } + var picked by remember { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val notSent = stringResource(R.string.input_not_sent) + val hidden = c.hiddenInput + // Hidden input closes the history for good: it does not open again when the password field goes + LaunchedEffect(hidden) { if (hidden) historyOpen = false } + + // A command from the quick bar or the history, sent as the input line sends + fun sendNow(command: String) { + if (c.sendCommand(command)) suggestions.sent(command) else scope.launch { snackbar.showSnackbar(notSent) } + } + + Scaffold(topBar = { MudletTopBar(stringResource(R.string.game_title), onBack) }, snackbarHost = { SnackbarHost(snackbar) }) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding).imePadding().onPreviewKeyEvent { event -> + val ctrlR = event.key == Key.R && event.isCtrlPressed && !hidden + // Both halves of the press, so the focused field types no "r" + if (ctrlR && event.type == KeyEventType.KeyDown) historyOpen = true + ctrlR + }, + ) { Console(c.lines, c.tail, c.linesAdded, onWidth = c::setViewWidth, Modifier.weight(1f).fillMaxWidth()) InputLine( - hiddenInput = c.hiddenInput, + hiddenInput = hidden, history = c.history, commandLine = c.commandLine, onCommandLineShown = c::commandLineShown, @@ -39,7 +78,17 @@ fun GameScreen(onBack: () -> Unit) { modifier = Modifier.fillMaxWidth().padding(start = 8.dp), suggestions = suggestions.answer, onInputChanged = suggestions::inputChanged, + picked = picked, + onPickedShown = { picked = null }, ) + if (!hidden) { + QuickBar( + suggestions.pinned, + onHistory = { historyOpen = true }, + onSend = ::sendNow, + Modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) + } ConnectionBar( ConnectionState.of(engineAttached = c.enginePid > 0, connection = c.status["connection"]), canReconnect = c.lastAddress != null, @@ -49,4 +98,7 @@ fun GameScreen(onBack: () -> Unit) { ) } } + if (historyOpen && !hidden) { + HistorySheet(suggestions, onPick = { picked = it }, onSend = ::sendNow, onDismiss = { historyOpen = false }) + } } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/HistorySheet.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/HistorySheet.kt new file mode 100644 index 0000000..df78c5f --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/HistorySheet.kt @@ -0,0 +1,215 @@ +package org.mudlet.android.ui.game + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.mudlet.android.R +import org.mudlet.android.suggest.FuzzyMatch +import org.mudlet.android.suggest.PinnedCommands + +/** The command history's test tag. */ +const val HISTORY_SHEET_TAG = "historySheet" + +/** + * The command history, a sheet over the game screen: a search field, then the pinned commands and the history's + * commands that match what is typed there, as [SuggestionState.search] ranks them. A row's command goes on the input + * line with [onPick]; its buttons send it at once with [onSend], pin or unpin it, and forget it. Picking or sending + * closes the sheet, as do its close button, Back and a tap outside it, each calling [onDismiss] once it has gone. + * + * The search field takes the focus as the sheet opens, so typing, and TalkBack, start there. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HistorySheet(state: SuggestionState, onPick: (String) -> Unit, onSend: (String) -> Unit, onDismiss: () -> Unit) { + val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + var query by rememberSaveable { mutableStateOf("") } + var full by remember { mutableStateOf(false) } + val searchFocus = remember { FocusRequester() } + LaunchedEffect(query) { state.search(query) } + // Once the sheet is up: its window takes no focus before it is shown + LaunchedEffect(Unit) { + snapshotFlow { sheet.currentValue }.first { it == SheetValue.Expanded } + searchFocus.requestFocus() + } + + fun close() { + scope.launch { sheet.hide() }.invokeOnCompletion { if (!sheet.isVisible) onDismiss() } + } + + // What the row's buttons do; each acts at once, so nothing waits on the sheet's animation + fun pick(command: String) { + onPick(command) + close() + } + + fun send(command: String) { + onSend(command) + close() + } + + fun pin(command: String) { + full = !state.canPin + if (!full) state.pin(command) + } + + fun unpin(command: String) { + full = false + state.unpin(command) + } + + fun forget(command: String) { + state.forget(command) + state.search(query) + } + + val typed = query.trim() + val pinned = state.pinned.filter { FuzzyMatch.score(typed, it) != null } + val answer = state.searchAnswer + val sent = answer?.commands.orEmpty().filterNot { command -> state.pinned.any { PinnedCommands.same(it, command) } } + + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(HISTORY_SHEET_TAG), + sheetState = sheet, + // No handle: its full-width target is shorter than 48 dp, and the close button and Back do what it offers + dragHandle = null, + ) { + Row(Modifier.fillMaxWidth().padding(start = 16.dp, top = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.history_title), + Modifier.weight(1f).semantics { heading() }, + style = MaterialTheme.typography.titleLarge, + ) + IconButton(::close) { + Icon(painterResource(R.drawable.ic_close), stringResource(R.string.history_close)) + } + } + OutlinedTextField( + value = query, + onValueChange = { + query = it + full = false + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).focusRequester(searchFocus), + label = { Text(stringResource(R.string.history_search)) }, + supportingText = if (full) { + { + Text( + pluralStringResource(R.plurals.history_pins_full, state.maxPins, state.maxPins), + Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + } else { + null + }, + singleLine = true, + // Commands, as on the input line: no corrections or capitals + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, imeAction = ImeAction.Search), + ) + LazyColumn(Modifier.fillMaxWidth()) { + if (pinned.isNotEmpty()) { + item(key = "pinned") { Heading(R.string.history_pinned) } + items(pinned, key = { "pinned:$it" }) { command -> + CommandRow(command, onPick = { pick(command) }) { + Action(R.drawable.ic_send, stringResource(R.string.history_send, command)) { send(command) } + Action(R.drawable.ic_push_pin, stringResource(R.string.history_unpin, command)) { unpin(command) } + } + } + } + item(key = "sent") { Heading(R.string.history_sent) } + if (sent.isEmpty() && answer?.query == query) { + item(key = "none") { + Text( + stringResource(if (typed.isEmpty()) R.string.history_empty else R.string.history_no_match), + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + } + items(sent, key = { "sent:$it" }) { command -> + CommandRow(command, onPick = { pick(command) }) { + Action(R.drawable.ic_send, stringResource(R.string.history_send, command)) { send(command) } + Action(R.drawable.ic_push_pin_outline, stringResource(R.string.history_pin, command)) { pin(command) } + Action(R.drawable.ic_delete, stringResource(R.string.history_forget, command)) { forget(command) } + } + } + } + } +} + +@Composable +private fun Heading(@StringRes text: Int) { + Text( + stringResource(text), + Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp).semantics { heading() }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) +} + +/** A row of the history: [command], which a tap puts on the input line with [onPick], then its [actions]' buttons. */ +@Composable +private fun CommandRow(command: String, onPick: () -> Unit, actions: @Composable RowScope.() -> Unit) { + Row(Modifier.fillMaxWidth().padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.weight(1f).heightIn(min = 48.dp) + .clickable(onClickLabel = stringResource(R.string.history_put_on_line), role = Role.Button, onClick = onPick) + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text(command, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + actions() + } +} + +@Composable +private fun Action(@DrawableRes icon: Int, label: String, onClick: () -> Unit) { + IconButton(onClick) { Icon(painterResource(icon), label) } +} diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt index 09a234d..b941a59 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt @@ -87,7 +87,8 @@ private const val BUTTON_ORDER = 2f * leaves the keyboard open; an `on...` that returns false could not send its request, and the line then stays. * The up and down buttons, and a hardware keyboard's arrow keys, step through [history] (newest first). The completion * button, or a hardware Tab, hands the line to [onComplete], whose answer replaces it. A [commandLine] from a script - * replaces the line, which then calls [onCommandLineShown]. + * replaces the line, which then calls [onCommandLineShown]; a [picked] command (from the command history) replaces it + * too and takes the focus, and then calls [onPickedShown]. * * While [hiddenInput] holds, a masked password field takes the line's place and sends with [onSendHidden]; its close * button calls [onDismissHidden]. What is typed there is not saved across a rotation, to keep it out of saved state. @@ -111,6 +112,8 @@ fun InputLine( modifier: Modifier = Modifier, suggestions: SuggestionSession.Answer? = null, onInputChanged: (text: String, cursor: Int) -> Unit = { _, _ -> }, + picked: String? = null, + onPickedShown: () -> Unit = {}, ) { var text by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } var secret by remember { mutableStateOf(TextFieldValue()) } @@ -191,6 +194,14 @@ fun InputLine( show(commandLine) onCommandLineShown() } + LaunchedEffect(picked) { + if (picked == null) return@LaunchedEffect + at = HistoryPosition() + show(picked) + // Only the command line has this requester: the masked field has a focus of its own + if (!hiddenInput) focus.requestFocus() + onPickedShown() + } val supportingText: (@Composable () -> Unit)? = notice?.let { { diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/QuickBar.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/QuickBar.kt new file mode 100644 index 0000000..462a80c --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/QuickBar.kt @@ -0,0 +1,58 @@ +package org.mudlet.android.ui.game + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.mudlet.android.R + +/** The quick bar's test tag. */ +const val QUICK_BAR_TAG = "quickBar" + +/** + * The row below the input line: the History button, which [onHistory] opens the command history with, then a button + * for each of [pinned], which [onSend] sends at once. It scrolls sideways when the buttons do not fit. + */ +@Composable +fun QuickBar(pinned: List, onHistory: () -> Unit, onSend: (String) -> Unit, modifier: Modifier = Modifier) { + Row( + modifier.testTag(QUICK_BAR_TAG).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onHistory, Modifier.heightIn(min = 48.dp)) { + Icon(painterResource(R.drawable.ic_history), contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.history_open)) + } + for (command in pinned) { + val label = stringResource(R.string.pinned_command, command) + FilledTonalButton( + onClick = { onSend(command) }, + modifier = Modifier.heightIn(min = 48.dp).semantics { contentDescription = label }, + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + Text(command, Modifier.widthIn(max = 160.dp), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..c5590e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 0000000..33937cf --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_push_pin.xml b/app/src/main/res/drawable/ic_push_pin.xml new file mode 100644 index 0000000..de100b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_push_pin.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_push_pin_outline.xml b/app/src/main/res/drawable/ic_push_pin_outline.xml new file mode 100644 index 0000000..793126b --- /dev/null +++ b/app/src/main/res/drawable/ic_push_pin_outline.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47c80f8..1ffafcf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,31 @@ greyed after it; %1$s is the whole line it gives --> Accept the suggestion: %1$s + + History + Pinned: %1$s + + Command history + Close the history + Search the history + Pinned + Sent + No commands sent yet + No command matches + put it on the command line + Send %1$s + Pin %1$s + Unpin %1$s + Forget %1$s + + + %1$d command is pinned, the most there can be: unpin it first + %1$d commands are pinned, the most there can be: unpin one first + + Waiting for the engine… Disconnected diff --git a/docs/architecture.md b/docs/architecture.md index 8d37359..0419243 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,10 +26,10 @@ Both rely on Qt private API (`Qt6::CorePrivate`), which ties the build to one ex | Where | What | |---|---| -| `app/` (`:app`) | The UI process: `MudletApp`, `ui/MainActivity.kt` with the screens it navigates between (`ui/AppNavigation.kt`: the profile list, the game screen in `ui/game/` (the console, the input line with its suggestions and the connection bar) and, in debug builds, the self-test in `ui/selftest/`), the renderer of game text (`ui/AnsiText.kt`), the theme (`ui/theme/`), and the model of the engine (`ui/EngineClient.kt`). Every string the app shows is in `res/values/strings.xml` | +| `app/` (`:app`) | The UI process: `MudletApp`, `ui/MainActivity.kt` with the screens it navigates between (`ui/AppNavigation.kt`: the profile list, the game screen in `ui/game/` (the console, the input line with its suggestions, the quick bar with the history sheet and pinned commands, and the connection bar) and, in debug builds, the self-test in `ui/selftest/`), the renderer of game text (`ui/AnsiText.kt`), the theme (`ui/theme/`), and the model of the engine (`ui/EngineClient.kt`). Every string the app shows is in `res/values/strings.xml` | | `engine-service/` (`:engine-service`) | The engine process's Kotlin side: `EngineService` (a `QtServiceBase`), its notifications, `EngineNative` (the JNI handshake). Its manifest declares the service and the permissions. It also carries the Qt libraries and jars that `scripts/build-apk.sh` stages into `engine-service/qtdeploy/` | | `protocol/` (`:protocol`) | Plain Kotlin/JVM with no `android.*`: the frame codec, message types and `EngineLink`, the reconnecting client. Its tests run on the JVM, against the desktop engine too | -| `suggest/` (`:suggest`) | Plain Kotlin/JVM with no dependencies: the input line's command suggestions and the session that loads, feeds and saves them ("Command suggestions" below). Its tests run on the JVM, with Gradle or with kotlinc alone (`suggest/run-tests.sh`) | +| `suggest/` (`:suggest`) | Plain Kotlin/JVM with no dependencies: the input line's command suggestions, the session that loads, feeds and saves them, and the pinned commands ("Command suggestions" below). Its tests run on the JVM, with Gradle or with kotlinc alone (`suggest/run-tests.sh`) | | `native/enginehost/` | `libengine`'s `main()`, `SocketBridge` (the protocol's engine side), `MudletEngine` (libmudlet through `THeadlessSession`), and the Android glue: `AndroidServiceSetup` (the only private Qt API, so a rename in Qt fails the build) and `AndroidNotifier` | | `native/api/` | `IEngine` and `IFrontend`, between the host and an engine | | `native/standin/` | The stand-in engine for fast tests: minimal telnet, ANSI runs, Lua 5.1.5 (vendored in `native/third_party/lua-5.1.5`, tarball sha256 `2640fc56…695333`), a font check and a heartbeat | @@ -124,6 +124,8 @@ per profile, from `SuggesterRegistry.forProfile(name)`: - `save(Appendable)` and `load(Reader)`, a line-based text (`SuggestCodec`) the app keeps in its private storage, one file per profile; a damaged line is skipped, and `load`'s `LoadResult` says how many were and whether the text was this format at all. `forget(command)` and `clear()` for the player. +- `search(query, limit)` for the history sheet: the commands sent, matched as the history source below and ranked by + match plus frecency; a blank query lists them all, most frecent first. What it ranks: @@ -156,6 +158,19 @@ order the UI asked for it: - While the game hides input there is neither strip nor ghost: nothing typed reaches the session, and the masked send is recorded only as `recordSent("", hidden = true)`. +The History button below the input line, or Ctrl+R on a hardware keyboard, opens `HistorySheet`, a +`ModalBottomSheet` whose search field takes the focus. The session answers each change of it at once with up to 100 +of `search`'s commands, under the pinned ones that match. A row's text puts its command on the input line without +sending it; its buttons send it, pin or unpin it, and forget it (`forget`, saved as a send is). Picking, sending, +Back and the close button close the sheet. + +Pinned commands (`PinnedCommands`, `:suggest`) are the buttons after History in `QuickBar`, which scrolls sideways; +one sends its command. Up to 12 per profile, in the order pinned, saved whole on the session's thread as +`files/suggest/.pins` beside the suggestions, the same way and with the same handling of a damaged file or an +I/O error. They are unpinned from the sheet's Pinned section. While the game hides input there is no quick bar, +the sheet closes and Ctrl+R does nothing; nothing reaches the search, and a pin can only come from the history, +which a hidden send never enters. + ## libmudlet as the engine Built with `LIBMUDLET_BUILD_DIR`, the engine is `MudletEngine` (`native/enginehost/MudletEngine.*`): one profile at a time in a `THeadlessSession` behind the same socket protocol. It uses only that class's @@ -750,20 +765,28 @@ Item 13 of the on-device checklist covers this. the line without sending it; the arrow button at the start of the row, or a hardware keyboard's right arrow at the end of the line, takes the greyed text. After a send, the empty line's buttons offer what usually came next. With nothing to offer the row is gone. Close the app from the recent apps and reopen it: they are still there. + - The history: "History" below the line opens a sheet with the commands sent, best first, and the keyboard on + its search field; typing "od" keeps "open door". A row's text puts the command on the line without sending it + and closes the sheet; its Send button sends it. Pin one: it shows as a button below the line, which sends it + at a tap, and it is still there after the app is closed from the recent apps and reopened. Unpin it from the + sheet's Pinned section. With a hardware keyboard, Ctrl+R opens the sheet; Back closes it. - Scroll up while the game sends text: the console stays put and "Jump to the bottom: N new lines" counts up; the button goes back to the newest line, which the console then follows again. Rotate the phone while scrolled up, and again with a half-typed line: both stay. The same in split screen. - With TalkBack on: the input line reads as "Command, edit box"; the up, down and Send buttons read their names; the connection state is read out when it changes (connecting, connected, disconnected), without moving focus. The line reads only what was typed, never the greyed text. Swiping goes from the console's lines to the input - line, then "Accept the suggestion: ..." and each "Suggestion: ...", then the up, down and Send buttons, then the - connection bar. + line, then "Accept the suggestion: ..." and each "Suggestion: ...", then the up, down and Send buttons, then + "History" and each "Pinned: ...", then the connection bar. History moves the focus into the sheet: its title, + "Close the history", "Search the history", then each row's command ("double tap to put it on the command line") + and its "Send ...", "Pin ..." or "Unpin ..." and "Forget ..." buttons. Back closes it and the focus goes back. 16. The input line's engine features, connected to a game with the libmudlet APK. - At a password prompt (any game's login), the input line becomes a "Password" field with the keyboard's password layout, still open. TalkBack reads it as a password field and says "dot" or nothing for what is typed, never the - letters. There are no suggestions and no greyed text while it shows. Send logs in; the field goes back to the - command line, with what was typed there before, and the password is not among the suggestions afterwards. Its - close button gives the command line back without sending. + letters. There are no suggestions, no greyed text and no quick bar while it shows, and Ctrl+R opens nothing. + Send logs in; the field goes back to the command line, with what was typed there before, and the password is + not among the suggestions or in the history sheet afterwards. Its close button gives the command line back + without sending. - The up button walks the commands of the profile's earlier sessions too (close and reopen the app first). - Type the start of a word the game sent, then the Tab button (or Tab on a hardware keyboard): the word is completed, and again goes on to the next match. TalkBack reads "Complete the word, button", and "No completion" diff --git a/docs/roadmap.md b/docs/roadmap.md index 2a55055..ddf3bb4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,7 +55,8 @@ The first usable client has: (`printCmdLine()`), and a masked field while the game hides input. Suggestions as the player types, since typing is the slowest part of playing on a phone: history matched loosely ("gc" for "get coins from corpse"), the command that usually comes next, and words from the game's output (`:suggest`, architecture.md, "Command - suggestions"). + suggestions"). A searchable history (the History button, or Ctrl+R on a hardware keyboard) and up to 12 pinned + commands as one-tap buttons below the line. - The soft keyboard: the console shrinks above it and keeps its bottom line in view; rotation and split screen keep the scroll position and the typed text. - A connection bar: connecting, connected, disconnected, with Reconnect and Disconnect. @@ -181,6 +182,7 @@ come after #31, whose fake engine (`FakeEngine`) and emulator jobs run the app's | U8 | Background: notification with profile and connection state, Disconnect action, app state to the engine | U2, P5 | | S1 | Command suggestions' ranking core, `:suggest`: plain Kotlin, no UI | - | | S2 | Suggestions in the input line: a row of candidates above it and ghost text with an accept button, learned and saved per profile on the device, none while input is hidden | U4, S1 | +| S3 | History search and pinned quick buttons: a sheet that searches the profile's history and sends, pins or forgets a command; pinned commands as buttons below the input line, saved per profile; neither while input is hidden | S2 | ``` #31 ─┬─ U1 ─┬─ U2 ─┬───────────────── U4, U5, U6, U7, U8 (each also needs its P) diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt index 58ba289..5f96e66 100644 --- a/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/CommandSuggester.kt @@ -149,8 +149,24 @@ class CommandSuggester( Suggestions.of(input, cursor, items) } + /** + * The history's commands that [query] matches, as [suggest] matches them (a loose subsequence, ranked by the + * match and frecency), best first, at most [limit]; for a blank query every command, most frecent first. Unlike + * [suggest] it offers only what was sent: no predictions lift an entry and no words from the output are added. + */ + override fun search(query: String, limit: Int): List = synchronized(lock) { + require(limit >= 0) { "negative limit $limit" } + val now = clock() + val q = query.trim() + val scored = history.values.mapNotNull { entry -> + val match = FuzzyMatch.score(q, entry.text) ?: return@mapNotNull null + entry.text to match + frecencyScore(entry, now) + } + scored.sortedWith(compareByDescending> { it.second }.thenBy { it.first }).take(limit).map { it.first } + } + /** Forgets a command: its history entry and every prediction of it. */ - fun forget(command: String) = synchronized(lock) { + override fun forget(command: String): Unit = synchronized(lock) { val key = normalizeCommand(command) history.remove(key) sequences.removeNext(key) diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/PinnedCommands.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/PinnedCommands.kt new file mode 100644 index 0000000..4f34070 --- /dev/null +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/PinnedCommands.kt @@ -0,0 +1,160 @@ +package org.mudlet.android.suggest + +import java.io.BufferedReader +import java.io.IOException +import java.io.Reader + +/** + * One profile's pinned commands: the quick buttons the game screen shows, in the order they were pinned, at most + * [max] of them, each once (as [same] compares them). [open] loads what [store] kept; each change is saved at once, + * whole, as the store saves. + * + * The list lives on [worker], which runs every load, change and save in the order they were asked for, so a pin + * asked for while the file still loads is added to what it held. The calls here come from one thread and return at + * once; [listener] gets the list on the worker's thread. + * + * Commands come only from what the player sent, which hidden input (a password) never is. An I/O error goes to [log], + * never with a command, and the list carries on in memory; after a failed load nothing is saved, so a file that + * could not be read is not replaced with less. + */ +class PinnedCommands( + private val store: SuggestStore, + private val worker: Worker, + private val log: (message: String, error: Throwable?) -> Unit, + val max: Int = MAX, +) { + init { + require(max > 0) { "max $max" } + } + + /** + * Gets the list after the load and after each change, on the worker's thread. A new listener also gets the list as + * it is once the tasks asked for before it have run, so one set after the load still gets what was loaded. + */ + @Volatile var listener: ((List) -> Unit)? = null + set(value) { + field = value + if (value != null) worker.schedule(0) { tell() } + } + + // The calling thread's + private var opened = false + + // The worker's + private var pins = emptyList() + private var loadFailed = false + + // The listener that has the list, and which list: one per load or change, so none is told the same list twice + private var version = 0 + private var told: Pair<(List) -> Unit, Int>? = null + + private fun tell() { + val to = listener ?: return + if (told?.first === to && told?.second == version) return + told = to to version + to(pins) + } + + /** Loads what the store kept; only the first call does anything. */ + fun open() { + if (opened) return + opened = true + worker.schedule(0) { + try { + val loaded = store.load { read(it, max) } + if (loaded != null) { + pins = loaded.commands + if (!loaded.result.recognised || loaded.result.skippedLines > 0) { + log("the saved pinned commands were damaged: recognised ${loaded.result.recognised}, ${loaded.result.skippedLines} lines skipped", null) + } + } + } catch (e: IOException) { + loadFailed = true + log("could not load the pinned commands; nothing is saved until the app restarts", e) + } + version++ + tell() + } + } + + /** Pins [command] last, unless it is blank, longer than [MAX_LENGTH], already pinned, or [max] are. */ + fun pin(command: String) { + worker.schedule(0) { + val text = command.trim() + if (text.isEmpty() || text.length > MAX_LENGTH || pins.size >= max || pins.any { same(it, text) }) return@schedule + changed(pins + text) + } + } + + /** Unpins [command], if it is pinned. */ + fun unpin(command: String) { + worker.schedule(0) { + val left = pins.filterNot { same(it, command) } + if (left.size != pins.size) changed(left) + } + } + + private fun changed(now: List) { + pins = now + version++ + tell() + if (loadFailed) return + try { + store.save { out -> write(out, now) } + } catch (e: IOException) { + log("could not save the pinned commands; trying again at the next change", e) + } + } + + /** What [read] made of a saved text: the commands, and whether the text was this format and how much was skipped. */ + internal class Loaded(val commands: List, val result: CommandSuggester.LoadResult) + + companion object { + /** How many commands a profile pins at most. */ + const val MAX = 12 + + /** The longest command that can be pinned: as long as [CommandSuggester.Config.maxCommandLength]'s default. */ + const val MAX_LENGTH = 256 + + /** Whether [a] and [b] are one command, as pinning tells them apart: in case and runs of spaces they are. */ + fun same(a: String, b: String) = CommandSuggester.normalizeCommand(a) == CommandSuggester.normalizeCommand(b) + + /** The saved form's first line, which names it and its version. */ + const val HEADER = "mudlet-pins 1" + + /** + * Writes [commands] as UTF-8 text: [HEADER], then one command per line, escaped as [SuggestCodec] escapes a + * field, so a command with a line break stays one line. + */ + internal fun write(out: Appendable, commands: List) { + out.append(HEADER).append('\n') + for (command in commands) { + SuggestCodec.escape(out, command) + out.append('\n') + } + } + + /** + * Reads what [write] wrote, keeping at most [max] commands. A line that does not unescape, is blank, too long + * or a command already read is skipped, and a text whose first line is not [HEADER] gives no commands. + */ + internal fun read(input: Reader, max: Int): Loaded { + val reader = input as? BufferedReader ?: BufferedReader(input) + if (reader.readLine() != HEADER) return Loaded(emptyList(), CommandSuggester.LoadResult(recognised = false, skippedLines = 0)) + val commands = mutableListOf() + val keys = HashSet() + var skipped = 0 + while (true) { + val line = reader.readLine() ?: break + val command = SuggestCodec.unescape(line)?.trim() + val key = command?.let { CommandSuggester.normalizeCommand(it) } + if (command == null || key.isNullOrEmpty() || command.length > MAX_LENGTH || commands.size >= max || !keys.add(key)) { + skipped++ + } else { + commands += command + } + } + return Loaded(commands, CommandSuggester.LoadResult(recognised = true, skippedLines = skipped)) + } + } +} diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestCodec.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestCodec.kt index c4224c0..87d723d 100644 --- a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestCodec.kt +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestCodec.kt @@ -90,7 +90,7 @@ internal object SuggestCodec { out.append('\n') } - private fun escape(out: Appendable, field: String) { + fun escape(out: Appendable, field: String) { for (ch in field) { when (ch) { '\\' -> out.append("\\\\") @@ -103,7 +103,7 @@ internal object SuggestCodec { } // null for a dangling or unknown escape - private fun unescape(field: String): String? { + fun unescape(field: String): String? { if ('\\' !in field) return field val out = StringBuilder(field.length) var i = 0 diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestStore.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestStore.kt index f3df92a..8dc7a65 100644 --- a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestStore.kt +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestStore.kt @@ -37,7 +37,13 @@ class SuggestFile(val file: File) : SuggestStore { } companion object { - /** The file for [profile] in [dir]: its name URL-encoded, so any profile name is one file name. */ - fun forProfile(dir: File, profile: String) = SuggestFile(File(dir, URLEncoder.encode(profile, "UTF-8") + ".txt")) + /** The suggestions' file for [profile] in [dir]: its name URL-encoded, so any profile name is one file name. */ + fun forProfile(dir: File, profile: String) = named(dir, profile, ".txt") + + /** The pinned commands' file for [profile] in [dir], beside its suggestions' file. */ + fun pinsForProfile(dir: File, profile: String) = named(dir, profile, ".pins") + + // Each kind ends in its own extension, so no profile's pins are another profile's suggestions ("a.pins" + ".txt") + private fun named(dir: File, profile: String, extension: String) = SuggestFile(File(dir, URLEncoder.encode(profile, "UTF-8") + extension)) } } diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggester.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggester.kt index 4583e25..a19722f 100644 --- a/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggester.kt +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/Suggester.kt @@ -13,6 +13,10 @@ interface Suggester { fun suggest(input: String, cursor: Int = input.length, limit: Int = 8): Suggestions + fun search(query: String, limit: Int): List + + fun forget(command: String) + fun save(out: Appendable) fun load(input: Reader): CommandSuggester.LoadResult diff --git a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestionSession.kt b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestionSession.kt index 158e9ff..554e1a7 100644 --- a/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestionSession.kt +++ b/suggest/src/main/kotlin/org/mudlet/android/suggest/SuggestionSession.kt @@ -5,16 +5,17 @@ import java.io.IOException /** * One profile's suggestions while the app runs. [open] loads what [store] kept; then the session learns from what * the input line sends ([sent]) and what the game shows ([lineShown]), answers the input as it is typed - * ([inputChanged]), and saves what it learned a while after a send and on [save]. + * ([inputChanged]) and searches through the history ([search]), forgets a command the player asks it to + * ([forget]), and saves what it learned a while after a send or a forget, and on [save]. * * Every call to [suggester] and [store] runs on [worker], in the order the calls here were made: a command sent * while the saved state still loads is learned on top of it. The calls here come from one thread, the UI's, and - * return at once; [listener] gets the answers on the worker's thread. + * return at once; [listener] and [searchListener] get the answers on the worker's thread. * * Hidden input (a password) never reaches the session: [sentHidden] takes no text, and nothing is answered while - * [hiddenChanged] says the game hides input. An I/O error goes to [log] and the session carries on in memory; - * after a failed load it saves nothing, so a file it could not read is not replaced with less. [log] is never given - * a command or a game line. + * [hiddenChanged] says the game hides input, not even a search. An I/O error goes to [log] and the session carries on + * in memory; after a failed load it saves nothing, so a file it could not read is not replaced with less. [log] is + * never given a command or a game line. */ class SuggestionSession( private val suggester: Suggester, @@ -29,9 +30,16 @@ class SuggestionSession( /** Gets each answer, on the worker's thread. */ @Volatile var listener: ((Answer) -> Unit)? = null + /** The history's commands that [query] matched, best first. */ + data class SearchAnswer(val query: String, val commands: List) + + /** Gets each search's answer, on the worker's thread. */ + @Volatile var searchListener: ((SearchAnswer) -> Unit)? = null + // The calling thread's private var opened = false private var pendingAnswer: Cancellable? = null + private var pendingSearch: Cancellable? = null @Volatile private var hidden = false @@ -73,10 +81,33 @@ class SuggestionSession( } } + /** + * Searches the history for [query], as [CommandSuggester.search] does, for at most [SEARCH_LIMIT] commands. A newer + * call drops the answer an older one has not begun on. + */ + fun search(query: String) { + dropPendingSearch() + if (hidden) return + pendingSearch = worker.schedule(0) { + if (!hidden) searchListener?.invoke(SearchAnswer(query, suggester.search(query, SEARCH_LIMIT))) + } + } + + /** Forgets [command], as [CommandSuggester.forget] does, and saves that a while later. */ + fun forget(command: String) { + worker.schedule(0) { + suggester.forget(command) + learned() + } + } + /** Whether the game hides what is typed (a password). While it does, nothing is answered. */ fun hiddenChanged(hidden: Boolean) { this.hidden = hidden - if (hidden) dropPendingAnswer() + if (hidden) { + dropPendingAnswer() + dropPendingSearch() + } } private fun dropPendingAnswer() { @@ -84,6 +115,11 @@ class SuggestionSession( pendingAnswer = null } + private fun dropPendingSearch() { + pendingSearch?.cancel() + pendingSearch = null + } + /** The input line sent [command]. The game's echo of it, the next line or one of the two after, is not learned from. */ fun sent(command: String) { worker.schedule(0) { @@ -150,10 +186,13 @@ class SuggestionSession( /** How many suggestions an answer has at most. */ const val LIMIT = 5 + /** How many commands a search answers with at most. */ + const val SEARCH_LIMIT = 100 + /** How long the input stays unchanged before it is answered. */ const val TYPING_DELAY_MS = 120L - /** How long after a send what was learned is saved, at the latest. */ + /** How long after a send or a forget what was learned is saved, at the latest. */ const val SAVE_DELAY_MS = 5_000L /** How many lines after a send may be its echo. */ diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/CommandSuggesterTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/CommandSuggesterTest.kt index ba80a24..aedb83f 100644 --- a/suggest/src/test/kotlin/org/mudlet/android/suggest/CommandSuggesterTest.kt +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/CommandSuggesterTest.kt @@ -284,6 +284,58 @@ class CommandSuggesterTest { assertTrue(s.texts("lo").isEmpty()) } + @Test + fun searchFindsHistoryLooselyBestMatchFirst() { + val s = suggester() + s.send("get coins from corpse", "look", "say hello", "go") + assertEquals(listOf("get coins from corpse"), s.search("gc", 10)) + // A prefix outranks a scattered match, and a query that is no subsequence finds nothing + assertEquals(listOf("go", "get coins from corpse"), s.search("go", 10)) + assertEquals(emptyList(), s.search("xyz", 10)) + assertEquals(listOf("say hello"), s.search(" SAY ", 10)) + } + + @Test + fun searchRanksEqualMatchesByFrecency() { + val s = suggester() + s.send("open gate") + repeat(5) { s.send("open door") } + assertEquals(listOf("open door", "open gate"), s.search("open", 10)) + // Used often but long ago counts less than once just now + now += 60 * day + s.send("open gate") + assertEquals(listOf("open gate", "open door"), s.search("open", 10)) + } + + @Test + fun aBlankSearchListsTheWholeHistoryMostFrecentFirstUpToTheLimit() { + val s = suggester() + s.send("look", "north") + repeat(3) { s.send("score") } + assertEquals(listOf("score", "north", "look"), s.search("", 10)) + assertEquals(listOf("score", "north"), s.search(" ", 2)) + assertEquals(emptyList(), s.search("", 0)) + } + + @Test + fun searchOffersOnlyWhatWasSent() { + val s = suggester() + s.send("kill rat", "loot", "kill rat") + s.observeLine("You see a lantern.") + assertEquals(emptyList(), s.search("lan", 10)) + // No prediction lifts "loot", the usual next command, over the more frecent "kill rat" + assertEquals(listOf("kill rat", "loot"), s.search("", 10)) + s.recordSent("hunter2", hidden = true) + assertEquals(emptyList(), s.search("hunter", 10)) + s.forget("Loot") + assertEquals(listOf("kill rat"), s.search("", 10)) + } + + @Test(expected = IllegalArgumentException::class) + fun aNegativeSearchLimitIsRefused() { + suggester().search("a", -1) + } + @Test fun clearForgetsEverything() { val s = suggester() diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/Fakes.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/Fakes.kt new file mode 100644 index 0000000..249e836 --- /dev/null +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/Fakes.kt @@ -0,0 +1,58 @@ +package org.mudlet.android.suggest + +import java.io.IOException +import java.io.Reader +import java.io.StringWriter +import java.io.Writer + +/** A [Worker] on a clock the test moves: [advance] runs what falls due, in order. */ +internal class FakeWorker : Worker { + var now = 0L + private var scheduled = 0L + + private class Task(val due: Long, val order: Long, val run: () -> Unit) { + var cancelled = false + } + + private val tasks = mutableListOf() + + override fun schedule(delayMillis: Long, task: () -> Unit): Cancellable { + val t = Task(now + delayMillis, scheduled++, task) + tasks += t + return Cancellable { t.cancelled = true } + } + + fun advance(millis: Long = 0) { + val end = now + millis + while (true) { + val next = tasks.filter { !it.cancelled && it.due <= end }.minWithOrNull(compareBy({ it.due }, { it.order })) ?: break + tasks.remove(next) + now = next.due + next.run() + } + tasks.removeAll { it.cancelled } + now = end + } + + val pending get() = tasks.count { !it.cancelled } +} + +/** A [SuggestStore] in memory, which fails a load or a save when told to. */ +internal class MemoryStore(var text: String? = null) : SuggestStore { + var failLoad = false + var failSave = false + var saves = 0 + + override fun load(read: (Reader) -> T): T? { + if (failLoad) throw IOException("no access") + return text?.let { read(it.reader()) } + } + + override fun save(write: (Writer) -> Unit) { + if (failSave) throw IOException("disk full") + val out = StringWriter() + write(out) + text = out.toString() + saves++ + } +} diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/PinnedCommandsTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/PinnedCommandsTest.kt new file mode 100644 index 0000000..e7b53d2 --- /dev/null +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/PinnedCommandsTest.kt @@ -0,0 +1,186 @@ +package org.mudlet.android.suggest + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class PinnedCommandsTest { + private val worker = FakeWorker() + private val store = MemoryStore() + private val logged = mutableListOf() + private val shown = mutableListOf>() + private val dir: File = Files.createTempDirectory("pins").toFile() + + @After + fun removeTheDirectory() { + dir.deleteRecursively() + } + + // Opened before the listener is set, as the app does + private fun pins(s: SuggestStore = store, max: Int = PinnedCommands.MAX) = PinnedCommands(s, worker, { message, _ -> logged += message }, max).also { + it.open() + it.listener = { list -> shown += list } + } + + private fun saved(vararg lines: String) = (listOf(PinnedCommands.HEADER) + lines).joinToString("\n", postfix = "\n") + + @Test + fun pinsAreListedInTheOrderPinnedAndSavedAtOnce() { + val pins = pins() + worker.advance() + assertEquals("the load is listed, empty", listOf(emptyList()), shown) + pins.pin("open door") + pins.pin(" kill rat ") + assertEquals("changed on the worker, not in the call", 1, shown.size) + worker.advance() + assertEquals(listOf(emptyList(), listOf("open door"), listOf("open door", "kill rat")), shown) + assertEquals(2, store.saves) + assertEquals(saved("open door", "kill rat"), store.text) + } + + @Test + fun whatIsSavedLoadsBackFromTheProfilesFile() { + val file = SuggestFile.pinsForProfile(dir, "Achaea") + val first = pins(file) + for (command in listOf("open door", "say a\tb\\c", "line\nbreak")) first.pin(command) + worker.advance() + shown.clear() + pins(file) + worker.advance() + assertEquals(listOf(listOf("open door", "say a\tb\\c", "line\nbreak")), shown) + assertEquals("one line per command", 4, file.file.readLines().size) + assertEquals(listOf("Achaea.pins"), dir.list()!!.toList()) + assertTrue(logged.isEmpty()) + } + + @Test + fun eachProfileHasItsOwnPinsBesideItsSuggestions() { + val achaea = pins(SuggestFile.pinsForProfile(dir, "Achaea")) + achaea.pin("open door") + worker.advance() + SuggestFile.forProfile(dir, "Achaea").save { it.write("suggestions") } + shown.clear() + pins(SuggestFile.pinsForProfile(dir, "Aardwolf")) + worker.advance() + assertEquals(listOf(emptyList()), shown) + // A profile named like another's pins file has files of its own + assertEquals("a.pins.txt", SuggestFile.forProfile(dir, "a.pins").file.name) + assertEquals("a.pins", SuggestFile.pinsForProfile(dir, "a").file.name) + assertEquals(setOf("Achaea.pins", "Achaea.txt"), dir.list()!!.toSet()) + assertEquals("suggestions", SuggestFile.forProfile(dir, "Achaea").file.readText()) + } + + @Test + fun aCommandIsPinnedOnceAndABlankOrOverlongOneNever() { + val pins = pins() + pins.pin("open door") + pins.pin("Open Door") + pins.pin(" ") + pins.pin("x".repeat(PinnedCommands.MAX_LENGTH + 1)) + pins.pin("x".repeat(PinnedCommands.MAX_LENGTH)) + worker.advance() + assertEquals(listOf("open door", "x".repeat(PinnedCommands.MAX_LENGTH)), shown.last()) + assertEquals("only real changes are saved", 2, store.saves) + } + + @Test + fun theCapHolds() { + val pins = pins(max = 3) + for (n in 1..4) pins.pin("say $n") + worker.advance() + assertEquals(listOf("say 1", "say 2", "say 3"), shown.last()) + assertEquals(3, store.saves) + pins.unpin("say 2") + pins.pin("say 4") + worker.advance() + assertEquals(listOf("say 1", "say 3", "say 4"), shown.last()) + assertEquals(12, PinnedCommands.MAX) + } + + @Test + fun unpinRemovesTheCommandAndSaves() { + store.text = saved("open door", "kill rat") + val pins = pins() + worker.advance() + pins.unpin("OPEN DOOR") + pins.unpin("not pinned") + worker.advance() + assertEquals(listOf("kill rat"), shown.last()) + assertEquals(1, store.saves) + assertEquals(saved("kill rat"), store.text) + } + + @Test + fun aNewListenerGetsTheListAsItIs() { + store.text = saved("look") + val pins = pins() + worker.advance() + val later = mutableListOf>() + pins.listener = { later += it } + assertTrue("on the worker", later.isEmpty()) + worker.advance() + assertEquals(listOf(listOf("look")), later) + assertEquals("the old listener gets nothing more", 1, shown.size) + } + + @Test + fun aPinAskedForWhileTheFileLoadsIsAddedToWhatItHeld() { + store.text = saved("look") + val pins = pins() + pins.pin("north") + worker.advance() + assertEquals(listOf("look", "north"), shown.last()) + } + + @Test + fun aDamagedFileLosesOnlyItsDamagedLines() { + val tooLong = "x".repeat(PinnedCommands.MAX_LENGTH + 1) + store.text = saved("look", "bad \\q escape", " ", "LOOK", tooLong, "say 1", "say 2", "say 3") + pins(max = 3) + worker.advance() + assertEquals(listOf(listOf("look", "say 1", "say 2")), shown) + assertEquals(listOf("the saved pinned commands were damaged: recognised true, 5 lines skipped"), logged) + } + + @Test + fun aFileOfAnotherFormatGivesNoPinsAndIsReplacedAtTheNextChange() { + store.text = "mudlet-pins 999\nlook\n" + val pins = pins() + worker.advance() + assertEquals(listOf(emptyList()), shown) + assertEquals(listOf("the saved pinned commands were damaged: recognised false, 0 lines skipped"), logged) + pins.pin("north") + worker.advance() + assertEquals(saved("north"), store.text) + } + + @Test + fun afterAFailedLoadNothingIsSavedButThePinsWork() { + store.failLoad = true + val pins = pins() + pins.pin("north") + worker.advance() + assertEquals(listOf("north"), shown.last()) + assertEquals(0, store.saves) + assertEquals(1, logged.size) + } + + @Test + fun aFailedSaveIsLoggedWithoutTheCommandAndTriedAgainAtTheNextChange() { + val pins = pins() + store.failSave = true + pins.pin("secret plan") + worker.advance() + assertEquals(listOf("secret plan"), shown.last()) + assertEquals(1, logged.size) + assertFalse(logged.single(), "secret" in logged.single()) + store.failSave = false + pins.pin("north") + worker.advance() + assertEquals(saved("secret plan", "north"), store.text) + } +} diff --git a/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestionSessionTest.kt b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestionSessionTest.kt index 4d1686e..eed693d 100644 --- a/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestionSessionTest.kt +++ b/suggest/src/test/kotlin/org/mudlet/android/suggest/SuggestionSessionTest.kt @@ -5,43 +5,8 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import java.io.IOException -import java.io.Reader -import java.io.Writer class SuggestionSessionTest { - /** A [Worker] on a clock the test moves: [advance] runs what falls due, in order. */ - private class FakeWorker : Worker { - var now = 0L - private var scheduled = 0L - - private class Task(val due: Long, val order: Long, val run: () -> Unit) { - var cancelled = false - } - - private val tasks = mutableListOf() - - override fun schedule(delayMillis: Long, task: () -> Unit): Cancellable { - val t = Task(now + delayMillis, scheduled++, task) - tasks += t - return Cancellable { t.cancelled = true } - } - - fun advance(millis: Long = 0) { - val end = now + millis - while (true) { - val next = tasks.filter { !it.cancelled && it.due <= end }.minWithOrNull(compareBy({ it.due }, { it.order })) ?: break - tasks.remove(next) - now = next.due - next.run() - } - tasks.removeAll { it.cancelled } - now = end - } - - val pending get() = tasks.count { !it.cancelled } - } - /** A [CommandSuggester] that remembers every text it was given. */ private class RecordingSuggester(private val real: CommandSuggester = CommandSuggester()) : Suggester by real { val asked = mutableListOf() @@ -66,25 +31,6 @@ class SuggestionSessionTest { fun everything() = asked + recorded.map { it.first } + observed } - private class MemoryStore(var text: String? = null) : SuggestStore { - var failLoad = false - var failSave = false - var saves = 0 - - override fun load(read: (Reader) -> T): T? { - if (failLoad) throw IOException("no access") - return text?.let { read(it.reader()) } - } - - override fun save(write: (Writer) -> Unit) { - if (failSave) throw IOException("disk full") - val out = java.io.StringWriter() - write(out) - text = out.toString() - saves++ - } - } - private val worker = FakeWorker() private val suggester = RecordingSuggester() private val store = MemoryStore() @@ -262,6 +208,63 @@ class SuggestionSessionTest { assertEquals(listOf("An open plain"), suggester.observed.drop(6)) } + @Test + fun aSearchIsAnsweredOnTheWorkerAndANewerOneDropsAnOlder() { + val searched = mutableListOf() + val session = session().apply { searchListener = { searched += it } } + for (command in listOf("open door", "look", "open gate", "open door")) session.sent(command) + session.search("o") + session.search("op") + assertTrue("answered on the worker, not in the call", searched.isEmpty()) + worker.advance() + assertEquals(listOf(SuggestionSession.SearchAnswer("op", listOf("open door", "open gate"))), searched) + } + + @Test + fun aSearchHasAtMostTheSearchLimit() { + val searched = mutableListOf() + val session = session().apply { searchListener = { searched += it } } + for (n in 1..SuggestionSession.SEARCH_LIMIT + 5) session.sent("say $n") + session.search("") + worker.advance() + assertEquals(SuggestionSession.SEARCH_LIMIT, searched.single().commands.size) + } + + @Test + fun nothingIsSearchedWhileInputIsHidden() { + val searched = mutableListOf() + val session = session().apply { searchListener = { searched += it } } + session.sent("north") + session.search("n") + session.hiddenChanged(true) + session.search("") + worker.advance() + assertEquals("a search asked for before input was hidden is dropped too", emptyList(), searched) + session.hiddenChanged(false) + session.search("") + worker.advance() + assertEquals(listOf("north"), searched.single().commands) + } + + @Test + fun aForgottenCommandIsGoneAndTheForgettingSaved() { + val searched = mutableListOf() + val session = session().apply { searchListener = { searched += it } } + session.sent("look") + session.sent("loot") + worker.advance(SuggestionSession.SAVE_DELAY_MS) + val saves = store.saves + session.forget("loot") + session.search("lo") + worker.advance() + assertEquals(listOf("look"), searched.single().commands) + assertEquals("not saved at once", saves, store.saves) + worker.advance(SuggestionSession.SAVE_DELAY_MS) + assertEquals(saves + 1, store.saves) + assertFalse("loot" in store.text!!) + assertTrue("look" in store.text!!) + } + @Test fun openLoadsOnce() { store.text = savedWith("look") From 831de682f4ce0b0b191364aace637f8cfbf60caa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Nov 2026 02:14:33 +0000 Subject: [PATCH 4/4] Input line: keep a sent line cleared against a late keyboard edit The input line took a TextFieldValue and onValueChange. The text field's copy of the text, which the keyboard edits, catches up with the value only when it recomposes, so a keyboard edit that landed between a send's clear and the next frame was applied to the old text and onValueChange put the sent line back (GameScreenTest, once on API 28: "northdraft"). Pressing Send right after a history recall or a completion does the same. Both fields now use TextFieldState, which the keyboard edits directly: a send clears it with clearText(); history, completion, a picked command and a script's line set it with edit(). Suggestions follow the state through snapshotFlow. The player's own edits clear a notice through an InputTransformation, at once as onValueChange did, so a notice from a send right after typing is not lost. The ghost text is an OutputTransformation, so the state and the keyboard keep only what was typed, and the masked field is an OutlinedSecureTextField on a TextFieldState that is not saved. A new test sends and makes a keyboard edit in one turn of the main thread; the history test checks the line is empty after each send, and the completion test that typing clears its notice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeGtuaeaa9oac3KmLFo --- .../org/mudlet/android/ui/GameScreenTest.kt | 17 ++++ .../org/mudlet/android/ui/InputLineTest.kt | 5 ++ .../org/mudlet/android/ui/game/InputLine.kt | 88 ++++++++++--------- .../mudlet/android/ui/game/SuggestionStrip.kt | 33 +++---- docs/architecture.md | 12 ++- 5 files changed, 89 insertions(+), 66 deletions(-) diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/GameScreenTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/GameScreenTest.kt index 595e92e..c27d5cf 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/GameScreenTest.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/GameScreenTest.kt @@ -2,6 +2,7 @@ package org.mudlet.android.ui import android.Manifest import android.os.Build +import android.view.inputmethod.EditorInfo import androidx.annotation.StringRes import androidx.compose.ui.geometry.Rect import androidx.compose.ui.semantics.SemanticsActions @@ -137,12 +138,28 @@ class GameScreenTest { compose.onNode(input).assertIsFocused().assert(editableText("")) } + @Test + fun anEditTheKeyboardSendsJustAfterASendLeavesTheLineEmpty() { + compose.onNode(input).performTextInput("north") + val send = compose.onNode(input).assertIsFocused().fetchSemanticsNode().config[SemanticsActions.OnImeAction].action + compose.runOnUiThread { + val keyboard = checkNotNull(compose.activity.window.decorView.findFocus()?.onCreateInputConnection(EditorInfo())) { "no input connection" } + // In one turn of the main thread, so no frame comes between them: a keyboard's edit that reaches the field + // before it is drawn again, as the keyboard marking the word before the cursor does after a send + checkNotNull(send)() + keyboard.setComposingRegion(0, "north".length) + } + assertEquals("north", session.receive("send").getString("text")) + compose.onNode(input).assert(editableText("")) + } + @Test fun theHistoryButtonsRecallEarlierCommands() { for (command in listOf("look", "north")) { compose.onNode(input).performTextInput(command) compose.onNode(input).performImeAction() assertEquals(command, session.receive("send").getString("text")) + compose.onNode(input).assert(editableText("")) } compose.onNode(input).performTextInput("draft") diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/InputLineTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/InputLineTest.kt index 1cfbe45..65548ac 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/InputLineTest.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/InputLineTest.kt @@ -154,6 +154,11 @@ class InputLineTest { session.reply("completion", "", again.get("id")) compose.waitUntil(WAIT_MS) { compose.onAllNodesWithText(string(R.string.input_no_completion)).fetchSemanticsNodes().isNotEmpty() } compose.onNode(input).assertIsFocused().assert(editableText("look")) + + // Typing on clears the notice + compose.onNode(input).performTextInput("s") + compose.onNode(input).assert(editableText("looks")) + assertTrue("no notice once the player types", compose.onAllNodesWithText(string(R.string.input_no_completion)).fetchSemanticsNodes().isEmpty()) } private fun string(@StringRes id: Int) = compose.activity.getString(id) diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt index b941a59..dd22157 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/InputLine.kt @@ -3,11 +3,17 @@ package org.mudlet.android.ui.game import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,6 +26,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -44,9 +51,6 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation import org.mudlet.android.R import org.mudlet.android.suggest.Suggestion import org.mudlet.android.suggest.SuggestionSession @@ -115,8 +119,10 @@ fun InputLine( picked: String? = null, onPickedShown: () -> Unit = {}, ) { - var text by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } - var secret by remember { mutableStateOf(TextFieldValue()) } + // State-based fields: what the keyboard edits is this state itself, so a clear holds against an edit the + // keyboard sends before the next frame, which a value and onValueChange field would apply to the old text + val text = rememberTextFieldState() + val secret = remember { TextFieldState() } var at by rememberSaveable(stateSaver = HistoryPosition.Saver) { mutableStateOf(HistoryPosition()) } var notice by rememberSaveable { mutableStateOf(null) } var focused by remember { mutableStateOf(false) } @@ -124,9 +130,15 @@ fun InputLine( var sends by remember { mutableIntStateOf(0) } val focus = remember { FocusRequester() } val hiddenFocus = remember { FocusRequester() } + // The player's own edits of the text or the cursor clear a notice, at once and so before any send they precede; + // the line's other changes (a send, a recall, a completion) set the notice themselves + val clearsNotice = remember { InputTransformation { notice = null } } fun show(line: String, cursor: Int = line.length) { - text = TextFieldValue(line, TextRange(cursor)) + text.edit { + replace(0, length, line) + selection = TextRange(cursor.coerceIn(0, line.length)) + } notice = null } @@ -136,38 +148,40 @@ fun InputLine( } fun submit() { - val sent = onSend(text.text) + val sent = onSend(text.text.toString()) notice = if (sent) null else Notice.NOT_SENT if (sent) { at = HistoryPosition() - text = TextFieldValue() + text.clearText() sends++ } } fun submitHidden() { - val sent = onSendHidden(secret.text) + val sent = onSendHidden(secret.text.toString()) notice = if (sent) null else Notice.NOT_SENT - if (sent) secret = TextFieldValue() + if (sent) secret.clearText() } fun complete() { - val asked = text.text + val asked = text.text.toString() val sent = onComplete(asked) { completed -> // The player typed on meanwhile: their line wins - if (text.text != asked) return@onComplete + if (text.text.toString() != asked) return@onComplete if (completed.isEmpty()) notice = Notice.NO_COMPLETION else show(completed) } notice = if (sent) null else Notice.NOT_SENT } val items = if (hiddenInput) emptyList() else suggestions?.suggestions?.items.orEmpty() + val line = text.text.toString() + val cursor = text.selection // Only for the line as it is now: an answer for an older one would put the wrong text after the cursor - val ghost = suggestions?.takeIf { !hiddenInput && it.input == text.text && text.selection.collapsed && it.cursor == text.selection.end }?.suggestions?.ghost + val ghost = suggestions?.takeIf { !hiddenInput && it.input == line && cursor.collapsed && it.cursor == cursor.end }?.suggestions?.ghost fun accept() { if (ghost == null) return - show(text.text + ghost) + show(line + ghost) focus.requestFocus() } @@ -177,14 +191,15 @@ fun InputLine( } val inputChanged by rememberUpdatedState(onInputChanged) - LaunchedEffect(text.text, text.selection, hiddenInput, sends) { - if (!hiddenInput) inputChanged(text.text, text.selection.end) + LaunchedEffect(hiddenInput) { + if (hiddenInput) return@LaunchedEffect + snapshotFlow { Triple(text.text.toString(), text.selection.end, sends) }.collect { (changed, end) -> inputChanged(changed, end) } } // Read as the field swaps, before the old one's focus is gone: the new one takes the focus it had val hadFocus = remember(hiddenInput) { focused } LaunchedEffect(hiddenInput) { - if (!hiddenInput) secret = TextFieldValue() + if (!hiddenInput) secret.clearText() notice = null if (hadFocus) (if (hiddenInput) hiddenFocus else focus).requestFocus() } @@ -216,7 +231,7 @@ fun InputLine( if (items.isNotEmpty()) { SuggestionStrip( items, - accepted = ghost?.let { text.text + it }, + accepted = ghost?.let { line + it }, onAccept = ::accept, onPick = ::pick, Modifier.fillMaxWidth().semantics { @@ -227,21 +242,17 @@ fun InputLine( } Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { if (hiddenInput) { - OutlinedTextField( - value = secret, - onValueChange = { - secret = it - notice = null - }, + // Password semantics: TalkBack says "password", not what is typed + OutlinedSecureTextField( + state = secret, modifier = Modifier.weight(1f).focusRequester(hiddenFocus).then(trackFocus).semantics { traversalIndex = FIELD_ORDER }, label = { Text(stringResource(R.string.input_password_label)) }, supportingText = supportingText, isError = notice == Notice.NOT_SENT, - singleLine = true, - // Password semantics: TalkBack says "password", not what is typed - visualTransformation = PasswordVisualTransformation(), + inputTransformation = clearsNotice, + textObfuscationMode = TextObfuscationMode.Hidden, keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password, imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { submitHidden() }), + onKeyboardAction = { submitHidden() }, ) IconButton({ if (!onDismissHidden()) notice = Notice.NOT_SENT }, buttonOrder) { Icon(painterResource(R.drawable.ic_close), stringResource(R.string.input_password_close)) @@ -254,15 +265,11 @@ fun InputLine( } } else { OutlinedTextField( - value = text, - onValueChange = { - text = it - notice = null - }, + state = text, modifier = Modifier.weight(1f).focusRequester(focus).then(trackFocus).semantics { traversalIndex = FIELD_ORDER // The typed text, not the ghost the transformation adds: what TalkBack reads and tests see - if (ghost != null) editableText = AnnotatedString(text.text) + if (ghost != null) editableText = AnnotatedString(line) }.onPreviewKeyEvent { event -> val tab = event.key == Key.Tab && !event.isShiftPressed // Both halves of Tab's press, so neither the field nor the focus acts on it @@ -271,7 +278,7 @@ fun InputLine( // Right takes the ghost, which is only there with the cursor at the end, where it does nothing else when { tab -> complete() - event.key == Key.DirectionUp && at.canGoBack(history) -> move(at.back(history, text.text)) + event.key == Key.DirectionUp && at.canGoBack(history) -> move(at.back(history, text.text.toString())) event.key == Key.DirectionDown && at.canGoForward -> move(at.forward()) event.key == Key.DirectionRight && ghost != null -> accept() else -> return@onPreviewKeyEvent false @@ -286,17 +293,18 @@ fun InputLine( }, supportingText = supportingText, isError = notice == Notice.NOT_SENT, - visualTransformation = ghost?.let { GhostTransformation(it, MaterialTheme.colorScheme.onSurfaceVariant) } ?: VisualTransformation.None, - singleLine = true, + inputTransformation = clearsNotice, + outputTransformation = ghost?.let { GhostTransformation(it, MaterialTheme.colorScheme.onSurfaceVariant) }, // Game commands are not prose: corrections, suggestions and capitals would change them ("n" to "In") keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, imeAction = ImeAction.Send, ), - keyboardActions = KeyboardActions(onSend = { submit() }), + onKeyboardAction = { submit() }, + lineLimits = TextFieldLineLimits.SingleLine, ) - IconButton({ move(at.back(history, text.text)) }, buttonOrder, enabled = at.canGoBack(history)) { + IconButton({ move(at.back(history, text.text.toString())) }, buttonOrder, enabled = at.canGoBack(history)) { Icon(painterResource(R.drawable.ic_arrow_upward), stringResource(R.string.input_history_previous)) } IconButton({ move(at.forward()) }, buttonOrder, enabled = at.canGoForward) { diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/SuggestionStrip.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/SuggestionStrip.kt index e77bcce..7424463 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/SuggestionStrip.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/SuggestionStrip.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedButton @@ -20,14 +22,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import org.mudlet.android.R import org.mudlet.android.suggest.Suggestion @@ -65,23 +61,14 @@ fun SuggestionStrip(items: List, accepted: String?, onAccept: () -> } /** - * Shows [ghost] in [color] after the text, fish style, without changing the text: the cursor stays within what was - * typed. The field's semantics keep the typed text, so TalkBack does not read the ghost as part of it. + * Shows [ghost] in [color] after the text, fish style, without changing the text: the field's state, and so the + * keyboard, holds only what was typed, and the cursor stays within it. InputLine sets the field's semantics to the + * typed text, so TalkBack does not read the ghost as part of it. */ -data class GhostTransformation(val ghost: String, val color: Color) : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText { - val typed = text.length - val shown = buildAnnotatedString { - append(text) - withStyle(SpanStyle(color = color)) { append(ghost) } - } - return TransformedText( - shown, - object : OffsetMapping { - override fun originalToTransformed(offset: Int) = offset - - override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(typed) - }, - ) +data class GhostTransformation(val ghost: String, val color: Color) : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + val typed = length + append(ghost) + addStyle(SpanStyle(color = color), typed, length) } } diff --git a/docs/architecture.md b/docs/architecture.md index 0419243..0835756 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,9 +149,9 @@ order the UI asked for it: - It records each send, and observes each game line but the echo of a send: one of the three lines after the send that ends with the command as a whole word. - It answers the input line once the typing has paused for 120 ms, with up to five suggestions. `SuggestionStrip` - shows them as buttons above the line, after an accept button for the ghost text. The ghost shows greyed through a - `VisualTransformation`, and only for the line as it is now; the field's semantics keep just the typed text, so - TalkBack does not read it. A hardware right arrow at the end of the line also accepts it. + shows them as buttons above the line, after an accept button for the ghost text. The ghost shows greyed through an + `OutputTransformation`, and only for the line as it is now; the field's state and semantics keep just the typed + text, so the keyboard does not see it and TalkBack does not read it. A hardware right arrow at the end of the line also accepts it. - It saves 5 s after a send and when the app leaves the front (`MainActivity.onStop`), to a temporary file that then replaces the old one. An I/O error is logged (tag `MudletSuggest`) without any command and the suggestions carry on in memory; after a failed load nothing is saved, so the file is not replaced with less. @@ -844,6 +844,12 @@ Item 13 of the on-device checklist covers this. - **`pkill -f test_engine.py` from a shell whose own command line contains that string kills the shell.** Use the pid. - **`/usr/bin/time` is not installed.** Use the bash `time` builtin. +- **A sent line came back on the input line** (`GameScreenTest`, once on API 28: "northdraft"). The field took a + `TextFieldValue` and `onValueChange`; its copy of the text, which the keyboard edits, catches up with the value only + when it recomposes. A keyboard edit between the send's clear and the next frame (here LatinIME marking the word + before the cursor) was applied to the old text, and `onValueChange` put it back. `InputLine` now uses + `TextFieldState` fields, which the keyboard edits directly; `anEditTheKeyboardSendsJustAfterASendLeavesTheLineEmpty` + sends and edits in one turn of the main thread. ## Measurements (prototype C, October 2026)