From 8f466867171a486c3923fb1775056488391acee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 20:05:28 +0000 Subject: [PATCH 1/9] Screen size, console colours and announcements over the protocol (version 4) setScreenSize (columns, rows) replaces setViewWidthPx and is told to the game as NAWS by libmudlet and the stand-in; the statuses columns and viewWidthPx go. libmudlet reports consoleColors at load and on change, and passes a script's announce() on as an announce message. The self-test sends its measured console size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- .../org/mudlet/android/ui/EngineClient.kt | 13 ++-- .../org/mudlet/android/ui/game/Console.kt | 23 ++++-- .../org/mudlet/android/ui/game/GameScreen.kt | 2 +- .../android/ui/selftest/SelfTestScreen.kt | 24 +++++-- app/src/main/res/values/strings.xml | 2 +- docs/architecture.md | 5 +- docs/protocol.md | 22 +++--- native/api/IEngine.h | 3 +- native/api/IFrontend.h | 2 + native/enginehost/MudletEngine.cpp | 40 +++++++++-- native/enginehost/MudletEngine.h | 10 ++- native/enginehost/SocketBridge.cpp | 27 ++++++- native/enginehost/SocketBridge.h | 4 +- native/standin/Engine.cpp | 71 ++++++++++++++++--- native/standin/Engine.h | 13 ++-- native/tests/fake_telnet.py | 20 +++++- native/tests/protocol_client.py | 2 +- native/tests/test_engine.py | 29 ++++++-- native/tests/test_mudlet_engine.py | 56 +++++++++++++++ .../org/mudlet/android/protocol/Protocol.kt | 11 ++- .../android/protocol/EngineLinkJvmTest.kt | 9 ++- .../mudlet/android/protocol/ProtocolTest.kt | 11 +++ 22 files changed, 336 insertions(+), 63 deletions(-) diff --git a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt index 0609a23..3597321 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt @@ -113,7 +113,8 @@ object EngineClient { @Volatile private var attached = false - @Volatile private var viewWidthPx = 0 + // The console's columns and rows, as setScreenSize last sent them; null until the UI has measured it + @Volatile private var screenSize: Pair? = null private var startRequestedAt = 0L private var detachStartedAt = 0L private var lostAt = 0L @@ -234,9 +235,9 @@ object EngineClient { fun disconnect(): Boolean = send(Requests.disconnect()) - fun setViewWidth(px: Int) { - viewWidthPx = px - if (attached) link.send(Requests.setViewWidthPx(px)) + fun setScreenSize(columns: Int, rows: Int) { + screenSize = columns to rows + if (attached) link.send(Requests.setScreenSize(columns, rows)) } fun runPings(count: Int = 300) { @@ -289,8 +290,8 @@ object EngineClient { private val listener = object : EngineLink.Listener { override fun onAttached(hello: EngineMessage.Hello, sameInstance: Boolean) { - // The engine forgets nothing across a reattach, but a new engine instance needs the width again - if (viewWidthPx > 0) link.send(Requests.setViewWidthPx(viewWidthPx)) + // The engine forgets nothing across a reattach, but a new engine instance needs the size again + screenSize?.let { (columns, rows) -> link.send(Requests.setScreenSize(columns, rows)) } // The engine resends its whole status after hello, so the old instance's values must go first if (!sameInstance) resetStatus() post { diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt index 5454315..e5243ca 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt @@ -27,10 +27,13 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -41,15 +44,21 @@ import org.mudlet.android.ui.UiLine import org.mudlet.android.ui.ansiText private const val TAIL_KEY = "tail" +private val CONSOLE_SIDE_PADDING = 4.dp +private val consoleStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp) /** * The game's text: [lines], then [tail] (the prompt), in their colours and styles. At the bottom it follows new lines; * scrolled up, it stays where it is and offers a button back to the bottom that counts the lines that came meanwhile. - * [linesAdded] is EngineClient's count, which numbers the lines. [onWidth] gets the console's width in pixels. + * [linesAdded] is EngineClient's count, which numbers the lines. [onScreenSize] gets the columns and rows that fit. */ @Composable -fun Console(lines: List, tail: UiLine?, linesAdded: Long, onWidth: (Int) -> Unit, modifier: Modifier = Modifier) { +fun Console(lines: List, tail: UiLine?, linesAdded: Long, onScreenSize: (columns: Int, rows: Int) -> Unit, modifier: Modifier = Modifier) { val listState = rememberLazyListState() + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val cell = remember(measurer, density) { measurer.measure("M", consoleStyle).size } + val sidePaddingPx = with(density) { (CONSOLE_SIDE_PADDING * 2).roundToPx() } var following by rememberSaveable { mutableStateOf(true) } // linesAdded when the player scrolled away from the bottom: the lines since then are new to them @@ -85,15 +94,19 @@ fun Console(lines: List, tail: UiLine?, linesAdded: Long, onWidth: (Int) Box(modifier.background(CONSOLE_BACKGROUND)) { LazyColumn( - Modifier.fillMaxSize().onSizeChanged { onWidth(it.width) }, + Modifier.fillMaxSize().onSizeChanged { + if (cell.width > 0 && cell.height > 0) { + onScreenSize(((it.width - sidePaddingPx) / cell.width).coerceIn(1, 65535), (it.height / cell.height).coerceIn(1, 65535)) + } + }, state = listState, - contentPadding = PaddingValues(horizontal = 4.dp), + contentPadding = PaddingValues(horizontal = CONSOLE_SIDE_PADDING), ) { // Each line's key is its number, so the lines in view stay put as the oldest are dropped items(itemCount, key = { i -> if (i < lines.size) firstLine + i else TAIL_KEY }) { i -> val line = if (i < lines.size) lines[i] else tail ?: return@items val text = remember(line) { ansiText(line) } - Text(text, Modifier.fillMaxWidth(), color = CONSOLE_FOREGROUND, fontFamily = FontFamily.Monospace, fontSize = 14.sp) + Text(text, Modifier.fillMaxWidth(), color = CONSOLE_FOREGROUND, style = consoleStyle) } } if (!following) { 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..cc02643 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 @@ -24,7 +24,7 @@ fun GameScreen(onBack: () -> Unit) { val c = EngineClient 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()) + Console(c.lines, c.tail, c.linesAdded, onScreenSize = c::setScreenSize, Modifier.weight(1f).fillMaxWidth()) InputLine( hiddenInput = c.hiddenInput, history = c.history, diff --git a/app/src/main/kotlin/org/mudlet/android/ui/selftest/SelfTestScreen.kt b/app/src/main/kotlin/org/mudlet/android/ui/selftest/SelfTestScreen.kt index 9f862ff..7075f82 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/selftest/SelfTestScreen.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/selftest/SelfTestScreen.kt @@ -37,11 +37,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.edit @@ -55,6 +59,8 @@ import org.mudlet.android.ui.EngineClient import org.mudlet.android.ui.MudletTopBar import org.mudlet.android.ui.ansiText +private val consoleStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp) + /** * The developers' screen, in debug builds only: connect to a game, see its coloured output, send commands and Lua, * and, behind "Debug", the engine's start times, heartbeat, process ids and buttons that stop or kill either process. @@ -70,6 +76,11 @@ fun SelfTestScreen(onBack: () -> Unit, onKillUi: () -> Unit) { var command by rememberSaveable { mutableStateOf("") } var showDebug by rememberSaveable { mutableStateOf(false) } val listState = rememberLazyListState() + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + // The console's character cell, from which the columns and rows that fit go to the engine + val cell = remember(measurer, density) { measurer.measure("M", consoleStyle).size } + var screen by remember { mutableStateOf(IntSize.Zero) } val context = LocalContext.current val prefs = remember { context.getSharedPreferences(EngineService.PREFS, Context.MODE_PRIVATE) } var platform by remember { mutableStateOf(prefs.getString(EngineService.PREF_PLATFORM, "android") ?: "android") } @@ -109,8 +120,8 @@ fun SelfTestScreen(onBack: () -> Unit, onKillUi: () -> Unit) { R.string.selftest_font, s["fontFamily"] ?: "?", s["advanceM"] ?: "?", - s["columns"] ?: "?", - s["viewWidthPx"] ?: "?", + screen.width.toString(), + screen.height.toString(), s["platform"] ?: "?", ), style = small, @@ -225,12 +236,17 @@ fun SelfTestScreen(onBack: () -> Unit, onKillUi: () -> Unit) { } LazyColumn( - Modifier.weight(1f).fillMaxWidth().background(CONSOLE_BACKGROUND).onSizeChanged { c.setViewWidth(it.width) }, + Modifier.weight(1f).fillMaxWidth().background(CONSOLE_BACKGROUND).onSizeChanged { + if (cell.width > 0 && cell.height > 0) { + screen = IntSize((it.width / cell.width).coerceIn(1, 65535), (it.height / cell.height).coerceIn(1, 65535)) + c.setScreenSize(screen.width, screen.height) + } + }, state = listState, ) { items(itemCount) { i -> val line = if (i < c.lines.size) c.lines[i] else tail ?: return@items - Text(ansiText(line), fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = CONSOLE_FOREGROUND) + Text(ansiText(line), color = CONSOLE_FOREGROUND, style = consoleStyle) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e7aaf5..b66df2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,7 +53,7 @@ Qt up %1$s ms after Application.onCreate (:engine), %2$s ms after Service.onCreate; UI start->hello %3$s %1$s ms - (engine was already up) - Font %1$s, \'M\' %2$s px @14px, columns %3$s (%4$s px), QPA %5$s + Font %1$s, \'M\' %2$s px @14px, screen %3$s x %4$s characters, QPA %5$s Lines %1$s, prompts %2$s, Lua onLine %3$s, notifications posted %4$s (UI saw %5$s) Heartbeat ticks %1$s, last gap %2$s ms, largest gap %3$s ms Qt appState %1$s (Qt::ApplicationState; 1 = Hidden), service-ready handshake %2$s diff --git a/docs/architecture.md b/docs/architecture.md index 9a29a89..c5897d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -238,8 +238,9 @@ CI runs both on API 28, the app's minSdk, and on API 36. needs new `THeadlessSession` API; a Mudlet PR for it is being prepared. - Links are marked by id, but the UI can't follow them yet (`link()`, `followLink()`, `linksChanged`). -- The main window's size (`setScreenSize()`) is not passed on, so the game is told 90 by 25 if - it asks. +- The game screen and the self-test each send `setScreenSize` from their own console, so the one laid out last + sets what a game that asks is told. +- `consoleColors` and `announce` reach the app, which does not use them yet. - No bundled fonts. - TLS is chosen when the profile starts (see "TLS" below). - A change `saveProfile` makes to the running profile takes effect at its next start, as Mudlet read its details then. diff --git a/docs/protocol.md b/docs/protocol.md index ba1f394..5254c84 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -10,7 +10,7 @@ How the UI process talks to the engine process, as implemented by: the same messages and fields, and that all of them, and the Python client, use the same protocol version. Change this file in the same commit as the code. -The current version is **3**. +The current version is **4**. ## Transport @@ -60,7 +60,7 @@ message, in backquotes, with `?` after an optional one, and the fields of the ob | `complete` | `text`, `fromHistory`?, `id`? | Completes the input line `text`, answered by status `completion`: the whole completed line, or empty for none. `fromHistory` true completes it from the history, as the up arrow does in Mudlet with "Highlight history" on: the newest line that starts with `text`. Otherwise, as Tab, it completes the last word from the words the game sent lately and those scripts suggested. A `complete` whose `text` is the line the last one answered goes on to the next word or older line. libmudlet runs `setCommandLineText()`, then `completeCommandLine()` or `completeCommandLineFromHistory()`; nothing completes while input is hidden. The stand-in completes from the lines `send` sent, both ways. A `fromHistory` that is not a boolean gets `error`. | | `disconnect` | | Closes the game connection. | | `runLua` | `code`, `id` | Runs Lua in the engine. The result comes back as status `luaResult` with `re` set to `id`. | -| `setViewWidthPx` | `px` | The width of the UI's output view in pixels, from which the engine works out its columns (status `columns`). | +| `setScreenSize` | `columns`, `rows` | The UI's console in characters of the font it draws in, each a whole number from 1 to 65535 (else `error`): the UI does the measuring. A game that asks (telnet NAWS) is told it. libmudlet gives it to `setScreenSize()` now and to each profile as it loads, so the profile's scripts read it as the main window's size; as in Mudlet, a width below 40 counts as 40, the game is told no more than the profile's wrap width, and of a change once the size has held for 0.3 s. Until the UI sends one, a game is told 90 by 25. The stand-in tells the game at once, as it was given. | | `listProfiles` | `id`? | Asks for `profiles`. | | `saveProfile` | `name`, `host`, `port`, `tls`, `login`, `autoReconnect`, `id`? | Creates the profile `name`, or updates the one of that name, ignoring case; an update keeps the name it was created with. All the fields are needed: `host` not empty, `port` from 1 to 65535, `tls` and `autoReconnect` booleans, `login` a string, which may be empty. A new `name` must be one Mudlet can make a folder of: only the letters A to Z, the digits, spaces and `. _ ( ) - # &`, no space at either end, and neither `.` nor `..` in it. A running profile read its details when it started, so a change takes effect at its next start. | | `deleteProfile` | `name`, `id`? | Deletes the profile, with everything Mudlet keeps for it. Refused while it is running, being opened or closed, or waited for by a restore. | @@ -95,6 +95,7 @@ it names whatever profile is open. | `profiles` | `profiles`: `name`, `host`, `port`, `tls`, `login`, `autoReconnect`; `re`? | Every profile, the last opened or created first, then any the engine has not opened, by name. Each is an object with the fields `saveProfile` takes; there is no password in it. | | `hiddenInput` | `wanted` | The game hides what the player types, as for a password (`wanted` true), or has stopped: the UI masks its input line and sends what is typed there with `sendHidden`. libmudlet sends it from `hiddenInputWanted`, which a disconnect or the profile closing also ends, and never while the profile shows passwords as they are typed; the stand-in from the game's telnet `WILL ECHO` and `WONT ECHO`. Sent on each change, and to each UI that attaches. | | `history` | `lines` | The input line's history, newest first, each line once, as Mudlet's up arrow walks it (`commandHistory()`): the lines `send` sent, as their aliases left them, and those the profile's earlier sessions kept, but nothing sent while input was hidden. Sent to each UI that attaches, and by libmudlet when a profile has loaded. In between, the UI adds the lines it sends itself; the next attach corrects what it guessed. The stand-in's is the lines `send` sent since it started. | +| `announce` | `text`, `assertive` | What a script's `announce()` asks screen readers to say, as plain text (libmudlet's `announcementRequested`, which also carries Mudlet's own, as when a game's link is hidden or revealed). `assertive` is true for the `importantall` and `importantmostrecent` styles, which interrupt what is being said; otherwise it waits its turn. Not kept for a UI that attaches later. The stand-in's Lua has `announce()` too. | | `commandLine` | `text` | A script set the whole input line, as `printCmdLine()`, `appendCmdLine()` and `clearCmdLine()` do, or Mudlet emptied it after a `send` (`commandLineTextRequested`): the UI replaces what is on its line. Not kept for a UI that attaches later. Only libmudlet sends it. | ### Status keys @@ -103,7 +104,7 @@ it names whatever profile is open. a UI shows what it knows and ignores the rest. Now in use: - **Both engines:** `connection` (`connecting` or `connecting :`, `connected`, `disconnected`, `error: ...`), `luaResult`, - `luaError`, `sendError`, `completion`, `columns`, `viewWidthPx`, `fontFamily`, `advanceM`, `platform`, `linesReceived`, + `luaError`, `sendError`, `completion`, `fontFamily`, `advanceM`, `platform`, `linesReceived`, `promptsReceived`, `heartbeatTicks`, `heartbeatLastGapMs`, `heartbeatMaxGapMs`, `profile` (` loaded`; libmudlet also sends ` (not started)`, ` starting`, ` closing`, ` closed`, ` closed without its save: ...`, ` not closed: ...` and `start failed ...`). @@ -111,7 +112,10 @@ a UI shows what it knows and ignores the rest. Now in use: `qtStartMsAfterServiceOnCreate`, and on Android `serviceReadyWait` (`ok` or `timeout`). - **The stand-in:** `luaOnLineCalls`, `notificationsPosted`, `fontError`. - **libmudlet (`MudletEngine`):** `engineKind` (`libmudlet`), `configDir`, `profileLoadMs`, `startMs`, - `localLines`, `errorConsoleLines`, `restore`. + `localLines`, `errorConsoleLines`, `restore`, and `consoleColors`: ` `, each `#rrggbb`, the colours of the + console where the game asks for none (the runs' -1), from `consoleForeground()` and `consoleBackground()` when a + profile loads, and on each change (`consoleColorsChanged`: a script's `setBackgroundColor()` on the main console, + or a package or save with colours of its own). ### The restore status @@ -205,18 +209,20 @@ The engine never waits for the UI, as Android can freeze a backgrounded UI proce - Answers are always written, even to a lagging UI: `pong`, `profiles`, `error`, and a `status` that carries `re`. A `notify` sent while the UI lags is not sent again, but the engine has already posted the notification on Android; -nor is a `commandLine`. +nor is a `commandLine` or an `announce`. `synced.seq` is the newest line when greeting even if lag cut the replay short; the rest follows once the UI drains, and `EngineLink` then tracks `lastSeq` from those lines. ## Versioning and compatibility -As implemented in version 3, which added the input line's requests (`sendHidden`, `dismissHidden`, `complete`) and -messages (`hiddenInput`, `history`, `commandLine`); version 2 added the profile requests and `profiles`: +As implemented in version 4, which replaced `setViewWidthPx` (and its statuses `columns` and `viewWidthPx`) with +`setScreenSize` and added `announce`; version 3 added the input line's requests (`sendHidden`, `dismissHidden`, +`complete`) and messages (`hiddenInput`, `history`, `commandLine`); version 2 added the profile requests and +`profiles`: - The UI sends its version in `hello` `v`, and the engine sends its own in its `hello`. - The engine accepts only a `hello` whose `v` equals its own. Otherwise it sends - `error` (`expected hello with v=3`) and closes the connection. A first frame that is not `hello` is refused the + `error` (`expected hello with v=4`) and closes the connection. A first frame that is not `hello` is refused the same way. - The app does not check the engine's `v`. A refused `hello` reaches `EngineLink` as an `error` where it expects `hello`, so it reports the link lost and keeps retrying. diff --git a/native/api/IEngine.h b/native/api/IEngine.h index 0b5f1d0..6846d95 100644 --- a/native/api/IEngine.h +++ b/native/api/IEngine.h @@ -24,7 +24,8 @@ class IEngine virtual QStringList history() const = 0; virtual void disconnectFromGame() = 0; virtual void runLua(const QString& code) = 0; - virtual void setViewWidthPx(int px) = 0; + // The UI's console in characters, which a game that asks is told (NAWS) + virtual void setScreenSize(int columns, int rows) = 0; // The last opened or created first. The others return why they did nothing, empty once done. virtual QList profiles() const = 0; diff --git a/native/api/IFrontend.h b/native/api/IFrontend.h index eb11357..feaeec1 100644 --- a/native/api/IFrontend.h +++ b/native/api/IFrontend.h @@ -38,4 +38,6 @@ class IFrontend virtual void history(const QStringList& lines) = 0; // A script set the whole input line virtual void commandLine(const QString& text) = 0; + // What a script asks screen readers to say; assertive interrupts what they are saying + virtual void announce(const QString& text, bool assertive) = 0; }; diff --git a/native/enginehost/MudletEngine.cpp b/native/enginehost/MudletEngine.cpp index c600de7..2241471 100644 --- a/native/enginehost/MudletEngine.cpp +++ b/native/enginehost/MudletEngine.cpp @@ -216,11 +216,20 @@ void MudletEngine::connectSession() connect(session, &THeadlessSession::notificationRequested, this, [this](const QString&, const QString& title, const QString& text) { mFrontend->notify(title, text); }); + connect(session, &THeadlessSession::announcementRequested, this, [this](const QString&, const QString& text, bool assertive) { + mFrontend->announce(text, assertive); + }); + connect(session, &THeadlessSession::consoleColorsChanged, this, [this](const QString&, const QColor& foreground, const QColor& background) { + reportConsoleColors(foreground, background); + }); connect(session, &THeadlessSession::profileLoaded, this, [this](const QString& profile) { mProfiles.markOpened(profile); mFrontend->status(QStringLiteral("profile"), QStringLiteral("%1 loaded").arg(profile)); mFrontend->status(QStringLiteral("profileLoadMs"), QString::number(mStartClock.elapsed())); mFrontend->history(mSession->commandHistory()); + reportConsoleColors(mSession->consoleForeground(), mSession->consoleBackground()); + // As early as the profile takes it, so a game that asks as it connects is not first told 90 by 25 + applyScreenSize(); }); connect(session, &THeadlessSession::hiddenInputWanted, this, [this](const QString&, bool wanted) { mFrontend->hiddenInput(wanted); @@ -365,9 +374,8 @@ void MudletEngine::reportFont() { QFont font = QFontDatabase::systemFont(QFontDatabase::FixedFont); font.setPixelSize(14); - mAdvancePx = QFontMetrics(font).horizontalAdvance(QLatin1Char('M')); mFrontend->status(QStringLiteral("fontFamily"), QFontInfo(font).family()); - mFrontend->status(QStringLiteral("advanceM"), QString::number(mAdvancePx)); + mFrontend->status(QStringLiteral("advanceM"), QString::number(QFontMetrics(font).horizontalAdvance(QLatin1Char('M')))); mFrontend->status(QStringLiteral("platform"), QGuiApplication::platformName()); } @@ -418,6 +426,8 @@ void MudletEngine::onStartFinished(int result, const QString& error) return; } mState = State::started; + // Again, for a setScreenSize that came between profileLoaded and now; the same size changes nothing + applyScreenSize(); if (mRestoring && !mKept.connect) { mRestoring = false; restoreStatus(QStringLiteral("restored"), mProfile); @@ -731,11 +741,29 @@ void MudletEngine::keepPending() persist(); } -void MudletEngine::setViewWidthPx(int px) +void MudletEngine::setScreenSize(int columns, int rows) { - const int columns = mAdvancePx > 0 ? px / mAdvancePx : 0; - mFrontend->status(QStringLiteral("viewWidthPx"), QString::number(px)); - mFrontend->status(QStringLiteral("columns"), QString::number(columns)); + mScreenSize = QSize(columns, rows); + if (mState == State::started) { + applyScreenSize(); + } +} + +void MudletEngine::applyScreenSize() +{ + if (mScreenSize.isEmpty()) { + return; + } + if (!mSession->setScreenSize(mScreenSize.width(), mScreenSize.height())) { + qWarning("MudletEngine: setScreenSize(%d, %d) refused: %s", mScreenSize.width(), mScreenSize.height(), qPrintable(mSession->errorString())); + } +} + +void MudletEngine::reportConsoleColors(const QColor& foreground, const QColor& background) +{ + if (foreground.isValid() && background.isValid()) { + mFrontend->status(QStringLiteral("consoleColors"), QStringLiteral("%1 %2").arg(foreground.name(), background.name())); + } } // A line as the main console shows it once its triggers ran. It stays open, as the tail, until the next line or diff --git a/native/enginehost/MudletEngine.h b/native/enginehost/MudletEngine.h index 0ba9584..6d1dfd0 100644 --- a/native/enginehost/MudletEngine.h +++ b/native/enginehost/MudletEngine.h @@ -8,12 +8,14 @@ #include #include #include +#include #include #include #include #include +class QColor; class THeadlessSession; // libmudlet behind the same host protocol as the stand-in: one profile at a time in a THeadlessSession, started by @@ -35,7 +37,7 @@ class MudletEngine : public QObject, public IEngine QStringList history() const override; void disconnectFromGame() override; void runLua(const QString& code) override; - void setViewWidthPx(int px) override; + void setScreenSize(int columns, int rows) override; QList profiles() const override { return mProfiles.profiles(); } QString saveProfile(const Profile& profile) override; QString deleteProfile(const QString& name) override; @@ -90,6 +92,8 @@ class MudletEngine : public QObject, public IEngine void onStartFinished(int result, const QString& error); void runNow(const QString& code); void reportFont(); + void applyScreenSize(); + void reportConsoleColors(const QColor& foreground, const QColor& background); void onHeartbeat(); void flushCounters(); void markCountersDirty(); @@ -142,9 +146,11 @@ class MudletEngine : public QObject, public IEngine // What the frontend was last sent as the tail; its kind is not used OpenLine mSentTail; + // The UI's last setScreenSize, given to each profile as it loads; empty until then + QSize mScreenSize; + QTimer mHeartbeat; QTimer mCounterTimer; - int mAdvancePx = 0; qint64 mLinesReceived = 0; qint64 mPromptsReceived = 0; qint64 mLocalLines = 0; diff --git a/native/enginehost/SocketBridge.cpp b/native/enginehost/SocketBridge.cpp index cdb49df..fa37da3 100644 --- a/native/enginehost/SocketBridge.cpp +++ b/native/enginehost/SocketBridge.cpp @@ -222,8 +222,8 @@ void SocketBridge::handleMessage(const QJsonObject& message) mEngine->disconnectFromGame(); } else if (type == QLatin1String("runLua")) { mEngine->runLua(message.value(QLatin1String("code")).toString()); - } else if (type == QLatin1String("setViewWidthPx")) { - mEngine->setViewWidthPx(message.value(QLatin1String("px")).toInt()); + } else if (type == QLatin1String("setScreenSize")) { + setScreenSize(message); } else if (type == QLatin1String("listProfiles")) { sendProfiles(); } else if (type == QLatin1String("saveProfile")) { @@ -285,6 +285,21 @@ void SocketBridge::complete(const QJsonObject& message) status(QStringLiteral("completion"), mEngine->complete(message.value(QLatin1String("text")).toString(), fromHistory.toBool(false))); } +void SocketBridge::setScreenSize(const QJsonObject& message) +{ + const double columns = message.value(QLatin1String("columns")).toDouble(-1); + const double rows = message.value(QLatin1String("rows")).toDouble(-1); + // NAWS carries each in 16 bits + const auto fits = [](double n) { + return n == std::floor(n) && n >= 1 && n <= 65535; + }; + if (!fits(columns) || !fits(rows)) { + sendError(QStringLiteral("setScreenSize: columns and rows must be whole numbers from 1 to 65535")); + return; + } + mEngine->setScreenSize(int(columns), int(rows)); +} + void SocketBridge::answerProfiles(const QString& request, const QString& problem) { if (problem.isEmpty()) { @@ -455,3 +470,11 @@ void SocketBridge::commandLine(const QString& text) sendFrame({{QStringLiteral("t"), QStringLiteral("commandLine")}, {QStringLiteral("text"), text}}); } } + +// Not kept, as notify: a UI that attaches later has missed the moment it was for +void SocketBridge::announce(const QString& text, bool assertive) +{ + if (clientLive()) { + sendFrame({{QStringLiteral("t"), QStringLiteral("announce")}, {QStringLiteral("text"), text}, {QStringLiteral("assertive"), assertive}}); + } +} diff --git a/native/enginehost/SocketBridge.h b/native/enginehost/SocketBridge.h index 6ade0d1..5e06aed 100644 --- a/native/enginehost/SocketBridge.h +++ b/native/enginehost/SocketBridge.h @@ -26,7 +26,7 @@ class SocketBridge : public QObject, public IFrontend Q_OBJECT public: - static constexpr int protocolVersion = 3; + static constexpr int protocolVersion = 4; static constexpr quint32 maxFrameBytes = 16 * 1024 * 1024; static constexpr qsizetype scrollbackLines = 2000; static constexpr qint64 highWaterBytes = 4 * 1024 * 1024; @@ -44,6 +44,7 @@ class SocketBridge : public QObject, public IFrontend void hiddenInput(bool wanted) override; void history(const QStringList& lines) override; void commandLine(const QString& text) override; + void announce(const QString& text, bool assertive) override; private: struct StoredLine @@ -71,6 +72,7 @@ class SocketBridge : public QObject, public IFrontend QString saveProfile(const QJsonObject& message); QString openProfile(const QJsonObject& message); void complete(const QJsonObject& message); + void setScreenSize(const QJsonObject& message); void dropClient(const QString& reason); bool clientLive() const { return mClient && mClientGreeted && !mClientLagging; } bool replying() const { return !mCurrentRequestId.isUndefined() && !mCurrentRequestId.isNull(); } diff --git a/native/standin/Engine.cpp b/native/standin/Engine.cpp index 6abf27c..0e4cf7d 100644 --- a/native/standin/Engine.cpp +++ b/native/standin/Engine.cpp @@ -30,6 +30,7 @@ constexpr unsigned char GA = 249; constexpr unsigned char SE = 240; constexpr unsigned char EOR = 239; constexpr unsigned char TELOPT_ECHO = 1; +constexpr unsigned char TELOPT_NAWS = 31; constexpr int promptSilenceMs = 500; // As Mudlet's default for the lines of history it saves @@ -54,6 +55,16 @@ int luaNotifyFn(lua_State* L) return 0; } +// announce(text[, style]): assertive for Mudlet's "importantall" and "importantmostrecent" styles, as libmudlet's +int luaAnnounceFn(lua_State* L) +{ + auto* engine = static_cast(lua_touserdata(L, lua_upvalueindex(1))); + const QString text = QString::fromUtf8(luaL_checkstring(L, 1)); + const QString style = QString::fromUtf8(luaL_optstring(L, 2, "")); + engine->luaAnnounce(text, style == QLatin1String("importantall") || style == QLatin1String("importantmostrecent")); + return 0; +} + } // namespace Engine::Engine(IFrontend* frontend, int heartbeatMs, const QString& profilesPath, QObject* parent) @@ -70,6 +81,7 @@ Engine::Engine(IFrontend* frontend, int heartbeatMs, const QString& profilesPath if (!mLineBuffer.isEmpty()) { emitLine(true); } + mNaws = false; setHiddenInput(false); mFrontend->status(QStringLiteral("connection"), QStringLiteral("disconnected")); }); @@ -95,6 +107,9 @@ Engine::Engine(IFrontend* frontend, int heartbeatMs, const QString& profilesPath lua_pushlightuserdata(mLua, this); lua_pushcclosure(mLua, luaNotifyFn, 1); lua_setglobal(mLua, "notify"); + lua_pushlightuserdata(mLua, this); + lua_pushcclosure(mLua, luaAnnounceFn, 1); + lua_setglobal(mLua, "announce"); if (luaL_dostring(mLua, defaultScript) != 0) { mFrontend->status(QStringLiteral("luaError"), QString::fromUtf8(lua_tostring(mLua, -1))); lua_pop(mLua, 1); @@ -122,10 +137,8 @@ void Engine::reportFont() } QFont font = QFontDatabase::systemFont(QFontDatabase::FixedFont); font.setPixelSize(14); - const QFontMetrics metrics(font); - mAdvancePx = metrics.horizontalAdvance(QLatin1Char('M')); mFrontend->status(QStringLiteral("fontFamily"), QFontInfo(font).family()); - mFrontend->status(QStringLiteral("advanceM"), QString::number(mAdvancePx)); + mFrontend->status(QStringLiteral("advanceM"), QString::number(QFontMetrics(font).horizontalAdvance(QLatin1Char('M')))); mFrontend->status(QStringLiteral("platform"), QGuiApplication::platformName()); } @@ -140,6 +153,7 @@ void Engine::connectTo(const QString& host, quint16 port, std::optional tl mLineBuffer.clear(); mFgBase = -1; mBold = false; + mNaws = false; setHiddenInput(false); mFrontend->status(QStringLiteral("connection"), QStringLiteral("connecting %1:%2").arg(host).arg(port)); mSocket.connectToHost(host, port); @@ -261,12 +275,31 @@ void Engine::runLua(const QString& code) mFrontend->status(QStringLiteral("luaResult"), result); } -void Engine::setViewWidthPx(int px) +void Engine::setScreenSize(int columns, int rows) { - mViewWidthPx = px; - const int columns = mAdvancePx > 0 ? px / mAdvancePx : 0; - mFrontend->status(QStringLiteral("viewWidthPx"), QString::number(px)); - mFrontend->status(QStringLiteral("columns"), QString::number(columns)); + if (columns == mColumns && rows == mRows) { + return; + } + mColumns = columns; + mRows = rows; + if (mNaws) { + sendNaws(); + } +} + +// RFC 1073: IAC SB NAWS, the width and the height in two bytes each, IAC SE; a 255 byte doubled +void Engine::sendNaws() +{ + QByteArray message; + message.append(char(IAC)).append(char(SB)).append(char(TELOPT_NAWS)); + for (const int value : {mColumns >> 8, mColumns & 0xff, mRows >> 8, mRows & 0xff}) { + message.append(char(value)); + if (value == IAC) { + message.append(char(IAC)); + } + } + message.append(char(IAC)).append(char(SE)); + mSocket.write(message); } // {"v": 1, "profiles": [{"name", "host", "port", "tls", "login", "autoReconnect"}, ...]}, the last opened first @@ -394,6 +427,11 @@ void Engine::luaNotify(const QString& title, const QString& text) mFrontend->notify(title, text); } +void Engine::luaAnnounce(const QString& text, bool assertive) +{ + mFrontend->announce(text, assertive); +} + void Engine::onReadyRead() { const QByteArray data = mSocket.readAll(); @@ -456,6 +494,23 @@ void Engine::processByte(unsigned char byte) } return; } + // Accepted, as Mudlet accepts it, and answered with the size at once + if (byte == TELOPT_NAWS && mTelnetCommand == DO) { + if (!mNaws) { + const char reply[3] = {char(IAC), char(WILL), char(TELOPT_NAWS)}; + mSocket.write(reply, 3); + mNaws = true; + } + sendNaws(); + return; + } + if (byte == TELOPT_NAWS && mTelnetCommand == DONT) { + if (std::exchange(mNaws, false)) { + const char reply[3] = {char(IAC), char(WONT), char(TELOPT_NAWS)}; + mSocket.write(reply, 3); + } + return; + } if (mTelnetCommand == DO || mTelnetCommand == WILL) { const char reply[3] = {char(IAC), char(mTelnetCommand == DO ? WONT : DONT), char(byte)}; mSocket.write(reply, 3); diff --git a/native/standin/Engine.h b/native/standin/Engine.h index f0d571e..38a055a 100644 --- a/native/standin/Engine.h +++ b/native/standin/Engine.h @@ -27,14 +27,15 @@ class Engine : public QObject, public IEngine QStringList history() const override { return mHistory; } void disconnectFromGame() override; void runLua(const QString& code) override; - void setViewWidthPx(int px) override; + void setScreenSize(int columns, int rows) override; QList profiles() const override { return mProfiles; } QString saveProfile(const Profile& profile) override; QString deleteProfile(const QString& name) override; QString openProfile(const QString& name, const QString& password, bool connect) override; - // Called from Lua's notify() + // Called from Lua's notify() and announce() void luaNotify(const QString& title, const QString& text); + void luaAnnounce(const QString& text, bool assertive); private: enum class TelnetState { Data, Iac, Option, Sub, SubIac }; @@ -48,6 +49,7 @@ class Engine : public QObject, public IEngine void flushCounters(); void markCountersDirty(); void setHiddenInput(bool wanted); + void sendNaws(); void writeLine(const QString& line); void applySgr(const QByteArray& params); int effectiveFg() const; @@ -80,8 +82,11 @@ class Engine : public QObject, public IEngine int mFgBase = -1; bool mBold = false; - int mAdvancePx = 0; - int mViewWidthPx = 0; + // What a game that asks is told (NAWS): Mudlet's 90 by 25 until the UI gives its size + int mColumns = 90; + int mRows = 25; + // The game asked for the size (telnet DO NAWS), so each change is sent + bool mNaws = false; qint64 mLinesReceived = 0; qint64 mPromptsReceived = 0; diff --git a/native/tests/fake_telnet.py b/native/tests/fake_telnet.py index 99830c7..184db2a 100644 --- a/native/tests/fake_telnet.py +++ b/native/tests/fake_telnet.py @@ -11,6 +11,8 @@ tls -> "TLS: " of the connection, or "TLS: none" password -> "Password: " and IAC WILL ECHO, as a game hides a password; the next line, whatever it is, gets IAC WONT ECHO and "Password accepted" + naws -> IAC DO NAWS, then "NAWS asked" + size -> "SIZE: x" of the client's last NAWS, or "SIZE: none" quit -> close Any other line gets "Huh?", without the line, so a password the client sends never comes back as text. @@ -24,7 +26,7 @@ import time IAC, DONT, DO, WONT, WILL, SB, GA, SE = 255, 254, 253, 252, 251, 250, 249, 240 -TTYPE, ECHO, GMCP = 24, 1, 201 +TTYPE, ECHO, NAWS, GMCP = 24, 1, 31, 201 # (lines, start, end) of each flood, as seen by the sender FLOOD_TIMES = [] # When each game connection was accepted (time.monotonic()) @@ -35,6 +37,8 @@ TLS_HANDSHAKES = [] # Every line any client sent, in order RECEIVED = [] +# (columns, rows) of every NAWS any client sent, in order +NAWS_SIZES = [] BANNER = ( bytes([IAC, DO, TTYPE, IAC, WILL, ECHO, IAC, WILL, GMCP]) @@ -58,6 +62,7 @@ def __init__(self, conn, banner, tls=None): self.tls = tls self.negotiation = bytearray() self.password_next = False + self.size = None def run(self): conn = self.conn @@ -87,10 +92,12 @@ def run(self): self.negotiation.append(b) if b == SE and self.negotiation[-2:-1] == bytes([IAC]): in_sb = False + self.subnegotiated(bytes(self.negotiation[sb_start:])) i += 1 continue if b == IAC and i + 1 < len(data) and data[i + 1] == SB: in_sb = True + sb_start = len(self.negotiation) self.negotiation += data[i:i + 2] i += 2 continue @@ -110,6 +117,13 @@ def run(self): finally: conn.close() + def subnegotiated(self, sb): + """One IAC SB ... IAC SE the client sent: a NAWS one is kept, its doubled 255 bytes undone.""" + body = sb[2:-2].replace(bytes([IAC, IAC]), bytes([IAC])) + if body[:1] == bytes([NAWS]) and len(body) == 5: + self.size = (body[1] << 8 | body[2], body[3] << 8 | body[4]) + NAWS_SIZES.append(self.size) + def handle(self, line): conn = self.conn RECEIVED.append(line) @@ -164,6 +178,10 @@ def handle(self, line): elif line == "password": self.password_next = True conn.sendall(b"Password: " + bytes([IAC, WILL, ECHO])) + elif line == "naws": + conn.sendall(bytes([IAC, DO, NAWS]) + b"NAWS asked\r\n") + elif line == "size": + conn.sendall(f"SIZE: {self.size[0]}x{self.size[1]}\r\n".encode() if self.size else b"SIZE: none\r\n") elif line == "quit": conn.sendall(b"Bye\r\n") return False diff --git a/native/tests/protocol_client.py b/native/tests/protocol_client.py index fa212e2..baf581a 100644 --- a/native/tests/protocol_client.py +++ b/native/tests/protocol_client.py @@ -5,7 +5,7 @@ import struct import time -PROTOCOL_VERSION = 3 +PROTOCOL_VERSION = 4 class Closed(Exception): diff --git a/native/tests/test_engine.py b/native/tests/test_engine.py index 7873ed4..84c1264 100644 --- a/native/tests/test_engine.py +++ b/native/tests/test_engine.py @@ -139,7 +139,7 @@ def input_line(c): seen += msgs check({"t": "hiddenInput", "wanted": False} in msgs, "a UI that attaches is told whether input is hidden") history = [m["lines"] for m in msgs if m["t"] == "history"] - check(history == [["echo dismissed", "password", "prompt-nogo", "negotiation"]], + check(history == [["echo dismissed", "password", "prompt-nogo", "size", "naws", "negotiation"]], f"and the history, what send sent, newest first, each once, without what sendHidden sent: {history}") profile_checks.password_kept_out(check, secret, seen, os.path.join(TMP, "engine.log")) return c @@ -180,10 +180,13 @@ def main(): c = Client(SOCK) handshake(c) - # Columns - c.send({"t": "setViewWidthPx", "px": 1080}) - m = c.wait_for(lambda m: m["t"] == "status" and m["key"] == "columns") - check(int(m["value"]) == 1080 // int(st["advanceM"]), f"columns for 1080px = {m['value']}") + # The screen size, kept for the game's NAWS below + for bad in ({"columns": 0, "rows": 25}, {"columns": 80, "rows": 65536}, {"columns": 80.5, "rows": 25}, {"columns": "80", "rows": 25}, {"columns": 80}): + c.send(dict(bad, t="setScreenSize", id="size")) + m = c.wait_for(lambda m: m.get("re") == "size") + check(m == {"t": "error", "message": "setScreenSize: columns and rows must be whole numbers from 1 to 65535", "re": "size"}, + f"setScreenSize {bad} is refused: {m}") + c.send({"t": "setScreenSize", "columns": 100, "rows": 30}) # The stand-in has no TLS, and says so rather than connecting in the clear c.send({"t": "connect", "host": "127.0.0.1", "port": port, "tls": True}) @@ -220,6 +223,22 @@ def main(): neg = bytes.fromhex(m["text"][5:]) check(neg == bytes([255, 252, 24, 255, 253, 1, 255, 254, 201]), f"DO->WONT, WILL->DONT replies, but WILL ECHO->DO: {neg.hex()}") + c.send({"t": "send", "text": "naws"}) + c.wait_for(lambda m: m["t"] == "line" and m["text"] == "NAWS asked") + c.send({"t": "send", "text": "size"}) + m = c.wait_for(lambda m: m["t"] == "line" and m["text"].startswith("SIZE:")) + check(m["text"] == "SIZE: 100x30", f"the game's DO NAWS is answered with the size setScreenSize gave: {m['text']}") + # 255 is sent doubled, as telnet's IAC + c.send({"t": "setScreenSize", "columns": 300, "rows": 255}) + c.send({"t": "send", "text": "size"}) + m = c.wait_for(lambda m: m["t"] == "line" and m["text"].startswith("SIZE:")) + check(m["text"] == "SIZE: 300x255", f"and a new size is sent at once: {m['text']}") + + c.send({"t": "runLua", "code": 'announce("polite") announce("now", "importantall")'}) + got = [c.wait_for(lambda m: m["t"] == "announce") for _ in range(2)] + check(got == [{"t": "announce", "text": "polite", "assertive": False}, {"t": "announce", "text": "now", "assertive": True}], + f"Lua announce() gives announce, assertive for an important style: {got}") + c.send({"t": "send", "text": "prompt-nogo"}) t0 = time.monotonic() m = c.wait_for(lambda m: m["t"] == "line") diff --git a/native/tests/test_mudlet_engine.py b/native/tests/test_mudlet_engine.py index 4ab17a2..07917d8 100644 --- a/native/tests/test_mudlet_engine.py +++ b/native/tests/test_mudlet_engine.py @@ -10,6 +10,7 @@ """ import glob import os +import re import signal import ssl import struct @@ -255,6 +256,7 @@ def until_last_line(m): tls_checks(port) profile_requests(port) input_line() + console() if failures: print(f"\n{len(failures)} FAILED; engine log: {LOG}") with open(LOG, errors="replace") as f: @@ -451,5 +453,59 @@ def input_line(): profile_checks.password_kept_out(check, secret, seen, LOG) + +def wait_naws(size, timeout=5): + """Whether the game's last NAWS becomes size within timeout: Mudlet tells it of a change once it has held 0.3 s.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if fake_telnet.NAWS_SIZES[-1:] == [size]: + return True + time.sleep(0.05) + return False + + +def console(): + """setScreenSize to the game's NAWS, consoleColors from the profile and a script's setBackgroundColor(), and a + script's announce().""" + game = fake_telnet.start_in_thread(fake_telnet.LOGIN_BANNER) + profile = "Console Test" + proc, c = start_engine(profile) + msgs = [] + c.wait_for(lambda m: m["t"] == "synced", collect=msgs) + # Kept until the profile has loaded + c.send({"t": "setScreenSize", "columns": 80, "rows": 30}) + c.send({"t": "connect", "host": "127.0.0.1", "port": game}) + m = wait_status(c, "consoleColors", lambda v: True, msgs) + check(re.fullmatch(r"#[0-9a-f]{6} #[0-9a-f]{6}", m["value"]) is not None, f"consoleColors when the profile loads: {m}") + wait_status(c, "connection", lambda v: v == "connected", msgs) + + c.send({"t": "send", "text": "naws"}) + c.wait_for(shown("NAWS asked"), collect=msgs) + check(wait_naws((80, 30)), f"the game's DO NAWS is answered with the size given before the profile loaded: {fake_telnet.NAWS_SIZES}") + c.send({"t": "setScreenSize", "columns": 72, "rows": 20}) + check(wait_naws((72, 20)), f"a new size reaches the game: {fake_telnet.NAWS_SIZES}") + c.send({"t": "setScreenSize", "columns": 30, "rows": 20}) + check(wait_naws((40, 20)), f"a width below 40 is told as 40, as Mudlet does: {fake_telnet.NAWS_SIZES}") + + c.send({"t": "runLua", "id": "bg", "code": 'setBackgroundColor("main", 10, 20, 30)'}) + m = wait_status(c, "consoleColors", lambda v: True, msgs, timeout=10) + check(m["value"].endswith(" #0a141e"), f"setBackgroundColor() on the main console changes consoleColors: {m}") + + c.send({"t": "runLua", "id": "announce", "code": 'announce("hi") announce("now", "importantmostrecent")'}) + got = [c.wait_for(lambda m: m["t"] == "announce", collect=msgs) for _ in range(2)] + check(got == [{"t": "announce", "text": "hi", "assertive": False}, {"t": "announce", "text": "now", "assertive": True}], + f"announce() gives announce, assertive for an important style: {got}") + + c.close() + c = Client(SOCK) + msgs = [] + c.wait_for(lambda m: m["t"] == "synced", collect=msgs) + colours = [m["value"] for m in msgs if m["t"] == "status" and m["key"] == "consoleColors"] + check(len(colours) == 1 and colours[0].endswith(" #0a141e"), f"a UI that attaches is told the colours: {colours}") + check(not any(m["t"] == "announce" for m in msgs), "but not the announcements it missed") + code = stop_engine(proc) + check(code == 0, f"the engine exits cleanly on SIGTERM: {code}") + + if __name__ == "__main__": main() diff --git a/protocol/src/main/kotlin/org/mudlet/android/protocol/Protocol.kt b/protocol/src/main/kotlin/org/mudlet/android/protocol/Protocol.kt index bb2bff6..9b7126b 100644 --- a/protocol/src/main/kotlin/org/mudlet/android/protocol/Protocol.kt +++ b/protocol/src/main/kotlin/org/mudlet/android/protocol/Protocol.kt @@ -9,7 +9,7 @@ import java.io.OutputStream // Wire format shared with native/enginehost/SocketBridge.cpp, specified in docs/protocol.md: a 4-byte // big-endian length, then one UTF-8 JSON object whose "t" field is the message type. -const val PROTOCOL_VERSION = 3 +const val PROTOCOL_VERSION = 4 private const val MAX_FRAME_BYTES = 16 * 1024 * 1024 // A run's colours: -1 default, 0-15 ANSI palette, RUN_RGB | 0xRRGGBB exact @@ -54,6 +54,9 @@ sealed interface EngineMessage { /** A script set the whole input line to [text]. */ data class CommandLine(val text: String) : EngineMessage + + /** What a script asks screen readers to say; [assertive] interrupts what they are saying. */ + data class Announce(val text: String, val assertive: Boolean) : EngineMessage data class Unknown(val type: String) : EngineMessage } @@ -74,7 +77,9 @@ object Requests { .put("t", "complete").put("text", text).put("fromHistory", fromHistory).put("id", id) fun disconnect(): JSONObject = JSONObject().put("t", "disconnect") fun runLua(code: String, id: Long): JSONObject = JSONObject().put("t", "runLua").put("code", code).put("id", id) - fun setViewWidthPx(px: Int): JSONObject = JSONObject().put("t", "setViewWidthPx").put("px", px) + + /** The console's size in characters, which a game that asks is told (NAWS); each from 1 to 65535. */ + fun setScreenSize(columns: Int, rows: Int): JSONObject = JSONObject().put("t", "setScreenSize").put("columns", columns).put("rows", rows) fun ping(id: Long): JSONObject = JSONObject().put("t", "ping").put("id", id) // The profile requests: [id] comes back as the replyTo of the Profiles or Error that answers each @@ -159,6 +164,8 @@ class FrameReader(input: InputStream) { "commandLine" -> EngineMessage.CommandLine(o.optString("text")) + "announce" -> EngineMessage.Announce(o.optString("text"), o.optBoolean("assertive")) + else -> EngineMessage.Unknown(t) } diff --git a/protocol/src/test/kotlin/org/mudlet/android/protocol/EngineLinkJvmTest.kt b/protocol/src/test/kotlin/org/mudlet/android/protocol/EngineLinkJvmTest.kt index 3536da4..1f3713f 100644 --- a/protocol/src/test/kotlin/org/mudlet/android/protocol/EngineLinkJvmTest.kt +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/EngineLinkJvmTest.kt @@ -123,9 +123,7 @@ class EngineLinkJvmTest { assertEquals(PROTOCOL_VERSION, first.version) next() - link.send(Requests.setViewWidthPx(1080)) - val cols = next { it.key == "columns" } - println("JVM: columns for 1080 px = ${cols.value}") + link.send(Requests.setScreenSize(100, 30)) link.send(Requests.connect("127.0.0.1", fakePort, false)) // The tell line comes before the banner's prompt, and next() discards what it skips @@ -133,6 +131,11 @@ class EngineLinkJvmTest { val banner = next { it.prompt } assertEquals("HP:100> ", banner.text) + link.send(Requests.send("naws")) + next { it.text == "NAWS asked" } + link.send(Requests.send("size")) + assertEquals("the game was told the size setScreenSize gave", "SIZE: 100x30", next { it.text.startsWith("SIZE:") }.text) + link.send(Requests.runLua("return 6 * 7", 99)) val lua = next { it.key == "luaResult" } assertEquals("42", lua.value) diff --git a/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolTest.kt b/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolTest.kt index bdf646b..49cb346 100644 --- a/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolTest.kt +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolTest.kt @@ -90,6 +90,17 @@ class ProtocolTest { assertEquals(true, Requests.complete("say", true, 7).getBoolean("fromHistory")) } + @Test + fun announceParses() { + assertEquals(EngineMessage.Announce("hi", true), FrameReader.parse(JSONObject("""{"t":"announce","text":"hi","assertive":true}"""))) + assertEquals(EngineMessage.Announce("hi", false), FrameReader.parse(JSONObject("""{"t":"announce","text":"hi","assertive":false}"""))) + } + + @Test + fun setScreenSizeCarriesColumnsAndRows() { + assertEquals(mapOf("t" to "setScreenSize", "columns" to 100, "rows" to 30), Requests.setScreenSize(100, 30).toMap()) + } + @Test fun aFrameIsOneWriteAndNeverFlushed() { val writes = ArrayList() From de8a593d6fa1302ef8af0a40fd9b1d895610392e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 20:48:18 +0000 Subject: [PATCH 2/9] test_mudlet_engine: ask for NAWS once the login prompt has ended its line Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- native/tests/test_mudlet_engine.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/native/tests/test_mudlet_engine.py b/native/tests/test_mudlet_engine.py index 07917d8..9b64284 100644 --- a/native/tests/test_mudlet_engine.py +++ b/native/tests/test_mudlet_engine.py @@ -478,6 +478,8 @@ def console(): m = wait_status(c, "consoleColors", lambda v: True, msgs) check(re.fullmatch(r"#[0-9a-f]{6} #[0-9a-f]{6}", m["value"]) is not None, f"consoleColors when the profile loads: {m}") wait_status(c, "connection", lambda v: v == "connected", msgs) + # Else the game's answer would continue the open "Login: " line + c.wait_for(shown("Login: "), timeout=10, collect=msgs) c.send({"t": "send", "text": "naws"}) c.wait_for(shown("NAWS asked"), collect=msgs) From 1311c7236d5d5b9e2ce1418919b882e06e091229 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 21:28:26 +0000 Subject: [PATCH 3/9] Text size and the screen size the game is told A text size setting, on a new settings screen reached from the game screen's title bar, sizes the console's text in sp, so the system font scale scales it too; a two-finger pinch on the console changes the same setting, and a slider with Smaller and Larger buttons is its accessible equivalent. The console measures its font's cell and sends the columns and rows that fit with setScreenSize once they have held for 200 ms, after a layout, rotation, keyboard or size change. Its default colours come from status consoleColors. Why the engine could not send something (status sendError, as for a failed sendHidden) now shows in a snackbar over the console's foot, which TalkBack reads. The sendError the engine resends after hello is not shown again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- .../org/mudlet/android/ui/GameScreenTest.kt | 23 ++ .../org/mudlet/android/ui/InputLineTest.kt | 27 ++- .../org/mudlet/android/ui/TextSizeTest.kt | 228 ++++++++++++++++++ .../org/mudlet/android/data/Settings.kt | 64 +++++ .../kotlin/org/mudlet/android/ui/AnsiText.kt | 26 +- .../org/mudlet/android/ui/AppNavigation.kt | 10 +- .../org/mudlet/android/ui/EngineClient.kt | 46 +++- .../org/mudlet/android/ui/MainActivity.kt | 2 + .../org/mudlet/android/ui/MudletTopBar.kt | 5 +- .../org/mudlet/android/ui/game/Console.kt | 105 ++++++-- .../org/mudlet/android/ui/game/GameScreen.kt | 52 +++- .../android/ui/settings/SettingsScreen.kt | 86 +++++++ app/src/main/res/drawable/ic_add.xml | 11 + app/src/main/res/drawable/ic_remove.xml | 11 + app/src/main/res/drawable/ic_settings.xml | 11 + app/src/main/res/values/strings.xml | 14 ++ docs/architecture.md | 18 +- 17 files changed, 700 insertions(+), 39 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt create mode 100644 app/src/main/kotlin/org/mudlet/android/data/Settings.kt create mode 100644 app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_remove.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml 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..74fd8c8 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/GameScreenTest.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/GameScreenTest.kt @@ -4,6 +4,8 @@ import android.Manifest import android.os.Build import androidx.annotation.StringRes import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull @@ -11,6 +13,7 @@ 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.captureToImage import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasScrollToIndexAction @@ -26,6 +29,7 @@ import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.compose.ui.text.TextLayoutResult import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import org.junit.Assert.assertEquals @@ -37,6 +41,7 @@ import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith import org.mudlet.android.R +import org.mudlet.android.protocol.RUN_REVERSE /** * The game screen against a fake engine: the console's lines and how it follows them, the input line, the connection @@ -216,6 +221,24 @@ class GameScreenTest { compose.onRoot().tryPerformAccessibilityChecks() } + @Test + fun theConsoleTakesItsColoursFromTheEngine() { + // Reversed, the spaces are drawn in the console's foreground colour + val reversed = "[ ]" + session.line(1, reversed, listOf(listOf(0, reversed.length, -1, -1, RUN_REVERSE))) + session.status("consoleColors", "#102030 #405060") + waitUntilDisplayed(reversed) + compose.waitUntil(WAIT_MS) { + val image = compose.onNode(console).captureToImage().toPixelMap() + image[image.width - 2, image.height - 2] == Color(0xFF405060) + } + val results = mutableListOf() + compose.onNodeWithText(reversed).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + val space = results.single().getBoundingBox(2).center + val image = compose.onNodeWithText(reversed).captureToImage().toPixelMap() + assertEquals(Color(0xFF102030), image[space.x.toInt(), space.y.toInt()]) + } + private fun string(@StringRes id: Int) = compose.activity.getString(id) // As plain text: the field's EditableText can carry spans, so comparing AnnotatedStrings fails on equal text 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..4f14dee 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/InputLineTest.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/InputLineTest.kt @@ -13,6 +13,7 @@ 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.hasAnyAncestor import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasSetTextAction @@ -42,7 +43,7 @@ import org.mudlet.android.R /** * The input line's engine features against a fake engine: the masked field while the game hides input, the engine's - * history, a line a script sets, and completion. Controls are found by the labels TalkBack reads and activated through + * history, a line a script sets, completion, and why the engine could not send. Controls are found by the labels TalkBack reads and activated through * their accessibility actions. */ @RunWith(AndroidJUnit4::class) @@ -156,6 +157,30 @@ class InputLineTest { compose.onNode(input).assertIsFocused().assert(editableText("look")) } + @Test + fun whyTheEngineCouldNotSendIsShownAndReadOut() { + session.synced(0) + compose.onNode(input).performTextInput("north") + compose.onNode(input).performImeAction() + session.receive("send") + session.status("sendError", "not connected") + val shown = compose.activity.getString(R.string.send_error, "not connected") + // Inside a live region, so TalkBack reads it without moving focus + val readOut = hasText(shown) and hasAnyAncestor(SemanticsMatcher.keyIsDefined(SemanticsProperties.LiveRegion)) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(readOut, useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() } + compose.onRoot().tryPerformAccessibilityChecks() + + // A new engine connection resends the engine's status, the old sendError with it: that is not news + session.close() + session = fake.engine.nextSession() + session.status("sendError", "an old reason") + session.synced(0) + session.status("connection", "connected") + compose.waitUntil(WAIT_MS) { compose.onAllNodesWithText(string(R.string.connection_connected)).fetchSemanticsNodes().isNotEmpty() } + val old = compose.activity.getString(R.string.send_error, "an old reason") + assertTrue("a resent sendError is not shown", compose.onAllNodesWithText(old, useUnmergedTree = true).fetchSemanticsNodes().isEmpty()) + } + private fun string(@StringRes id: Int) = compose.activity.getString(id) private fun previous() = activate(hasContentDescription(string(R.string.input_history_previous))) diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt new file mode 100644 index 0000000..e41faf1 --- /dev/null +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt @@ -0,0 +1,228 @@ +package org.mudlet.android.ui + +import android.Manifest +import android.os.Build +import android.os.SystemClock +import androidx.activity.compose.setContent +import androidx.annotation.StringRes +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasScrollToIndexAction +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.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pinch +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +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.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.data.Settings +import org.mudlet.android.ui.theme.MudletTheme + +/** + * The console's text size against a fake engine: the setting, reached from the game screen's title bar, and a pinch + * both change it, and the columns and rows that then fit go to the engine with setScreenSize. At twice the system font + * size, nothing on the game or settings screen is cut off. + */ +@RunWith(AndroidJUnit4::class) +class TextSizeTest { + 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 console = hasScrollToIndexAction() + private var nextSeq = 1L + + @Before + fun attach() { + // enableAccessibilityChecks() needs API 34: the API 36 emulator runs the checks, API 28 does not + if (Build.VERSION.SDK_INT >= 34) compose.enableAccessibilityChecks() + resetTextSize() + 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) + } + + // The setting is stored, so it would outlast the test + @After + fun resetTextSize() = compose.runOnIdle { Settings.textSize.set(Settings.textSize.default) } + + @Test + fun theSettingChangesTheTextSizeAndSendsTheScreenSize() { + openTheGameScreen() + val before = settledScreenSize() + assertConsoleFits(before) + assertEquals(DEFAULT_SIZE.sp, consoleFontSize()) + + activate(hasContentDescription(string(R.string.settings_title))) + val slider = hasContentDescription(string(R.string.settings_text_size)) and SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress) + compose.onNode(slider).performSemanticsAction(SemanticsActions.SetProgress) { it(28f) } + activate(hasContentDescription(string(R.string.settings_text_smaller))) + compose.runOnIdle { assertEquals(27, Settings.textSize.value) } + compose.onNode(slider).assert(stateDescription(compose.activity.getString(R.string.settings_text_size_value, 27))) + activate(hasContentDescription(string(R.string.navigate_back))) + + compose.onNodeWithText(string(R.string.game_title)).assertIsDisplayed() + assertEquals(27.sp, consoleFontSize()) + val after = settledScreenSize() + assertTrue("larger text, fewer columns and rows: $before, then $after", after.first < before.first && after.second < before.second) + assertConsoleFits(after) + } + + @Test + fun aPinchChangesTheSettingAndSendsTheScreenSize() { + openTheGameScreen() + val before = settledScreenSize() + + // Fingers 120 px apart moving to 240: twice the size + compose.onNode(console).performTouchInput { + pinch(center - Offset(60f, 0f), center - Offset(120f, 0f), center + Offset(60f, 0f), center + Offset(120f, 0f)) + } + val size = compose.runOnIdle { Settings.textSize.value } + assertTrue("a pinch outward makes the text larger: $size", size in DEFAULT_SIZE + 4..Settings.textSize.range.last) + assertEquals(size.sp, consoleFontSize()) + val after = settledScreenSize() + assertTrue("larger text, fewer columns: $before, then $after", after.first < before.first) + assertConsoleFits(after) + } + + @Test + fun atTwiceTheSystemFontSizeNothingIsCutOff() { + compose.runOnUiThread { + compose.activity.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 2f)) { + MudletTheme { AppNavigation(onKillUi = {}) } + } + } + } + openTheGameScreen() + assertEquals(DEFAULT_SIZE.sp, consoleFontSize()) + assertConsoleFits(settledScreenSize()) + assertNothingCutOff() + + activate(hasContentDescription(string(R.string.settings_title))) + compose.onNodeWithText(string(R.string.settings_text_size_sample)).assertIsDisplayed() + assertNothingCutOff() + } + + private fun openTheGameScreen() { + activate(hasText(string(R.string.profiles_open_game))) + compose.onNodeWithText(string(R.string.game_title)).assertIsDisplayed() + // A line to measure the console's text by + line("size") + } + + private fun line(text: String) { + session.line(nextSeq++, text) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(console).fetchSemanticsNodes().isNotEmpty() && compose.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() } + } + + /** The screen size once it has held for a second, which the engine has been sent. */ + private fun settledScreenSize(): Pair { + var last: Pair? = null + var since = SystemClock.elapsedRealtime() + compose.waitUntil(WAIT_MS) { + val now = compose.runOnIdle { EngineClient.screenSize } + if (now != last) { + last = now + since = SystemClock.elapsedRealtime() + } + now != null && SystemClock.elapsedRealtime() - since >= SETTLE_MS + } + val size = last!! + while (true) { + val sent = session.receive("setScreenSize") + if (sent.getInt("columns") to sent.getInt("rows") == size) return size + } + } + + /** A line as many characters wide as [size] says fits on one line of the console, and one more does not. */ + private fun assertConsoleFits(size: Pair) { + val columns = size.first + val full = "x".repeat(columns) + val over = "y".repeat(columns + 1) + line(full) + line(over) + assertEquals("$columns characters on one line", 1, layout(full).lineCount) + assertEquals("${columns + 1} characters wrap", 2, layout(over).lineCount) + } + + private fun consoleFontSize(): TextUnit = layout("size").layoutInput.style.fontSize + + private fun layout(text: String): TextLayoutResult { + val results = mutableListOf() + compose.onNodeWithText(text).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + return results.single() + } + + /** No text is clipped or cut short, and every control is within the window's width. */ + private fun assertNothingCutOff() { + compose.onRoot().tryPerformAccessibilityChecks() + val width = compose.onRoot().fetchSemanticsNode().boundsInRoot.width + for (node in compose.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult)).fetchSemanticsNodes()) { + val results = mutableListOf() + node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results) + for (result in results) { + assertTrue("'${result.layoutInput.text}' overflows its space", !result.hasVisualOverflow) + } + } + for (node in compose.onAllNodes(hasClickAction()).fetchSemanticsNodes()) { + val bounds = node.boundsInRoot + val label = node.config.getOrNull(SemanticsProperties.ContentDescription) + assertTrue("control $label at $bounds within the window's width $width", bounds.left >= 0f && bounds.right <= width) + } + } + + private fun stateDescription(text: String) = SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, text) + + private fun string(@StringRes id: Int) = compose.activity.getString(id) + + /** 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 DEFAULT_SIZE = 14 + const val WAIT_MS = 10_000L + + // Well past the console's own wait before it sends a size + const val SETTLE_MS = 1_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/data/Settings.kt b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt new file mode 100644 index 0000000..df56c9a --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt @@ -0,0 +1,64 @@ +package org.mudlet.android.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit + +/** + * The app's own settings, kept in SharedPreferences. Each one is Compose state, read and set on the main thread. A new + * setting is a property here and a row on the settings screen. + */ +object Settings { + private const val PREFS = "settings" + + private var prefs: SharedPreferences? = null + + /** The console's text size in sp, so the system font scale scales it too. */ + val textSize = IntSetting("textSizeSp", default = 14, range = 8..32) + + private val all = listOf(textSize) + + fun init(context: Context) { + if (prefs != null) return + val p = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs = p + all.forEach { it.load(p) } + } + + /** One setting: [value] is the current one, [set] changes and stores it. */ + abstract class Setting(protected val key: String, val default: T) { + var value by mutableStateOf(default) + private set + + fun set(new: T) { + val v = normalise(new) + if (v == value) return + value = v + prefs?.edit { write(this, v) } + } + + internal fun load(prefs: SharedPreferences) { + value = normalise(read(prefs)) + } + + protected open fun normalise(v: T): T = v + + protected abstract fun read(prefs: SharedPreferences): T + + protected abstract fun write(editor: SharedPreferences.Editor, v: T) + } + + /** A whole number within [range]: a value outside it is taken as the nearest end. */ + class IntSetting(key: String, default: Int, val range: IntRange) : Setting(key, default) { + override fun normalise(v: Int) = v.coerceIn(range) + + override fun read(prefs: SharedPreferences) = prefs.getInt(key, default) + + override fun write(editor: SharedPreferences.Editor, v: Int) { + editor.putInt(key, v) + } + } +} diff --git a/app/src/main/kotlin/org/mudlet/android/ui/AnsiText.kt b/app/src/main/kotlin/org/mudlet/android/ui/AnsiText.kt index bf6f7c0..a272042 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/AnsiText.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/AnsiText.kt @@ -16,10 +16,23 @@ import org.mudlet.android.protocol.RUN_STRIDE import org.mudlet.android.protocol.RUN_STRIKE import org.mudlet.android.protocol.RUN_UNDERLINE -/** The colours game text is drawn in: on [CONSOLE_BACKGROUND], in [CONSOLE_FOREGROUND] where a run sets none. */ +/** The colours game text is drawn in until the engine says otherwise: on [CONSOLE_BACKGROUND], in [CONSOLE_FOREGROUND]. */ val CONSOLE_BACKGROUND = Color.Black val CONSOLE_FOREGROUND = Color(0xFFC0C0C0) +/** The console's colours where a run sets none (its -1): status `consoleColors`, or the defaults above. */ +data class ConsoleColors(val foreground: Color = CONSOLE_FOREGROUND, val background: Color = CONSOLE_BACKGROUND) { + companion object { + private val format = Regex("#([0-9a-fA-F]{6}) #([0-9a-fA-F]{6})") + + /** Status `consoleColors`, ` ` each `#rrggbb`; the defaults when it is missing or malformed. */ + fun of(status: String?): ConsoleColors { + val (fg, bg) = status?.let { format.matchEntire(it) }?.destructured ?: return ConsoleColors() + return ConsoleColors(Color(0xFF000000 or fg.toLong(16)), Color(0xFF000000 or bg.toLong(16))) + } + } +} + // ANSI black is lifted to dark grey so it stays readable on the black console private val ansi = listOf( 0xFF5C5C5C, 0xFFCD0000, 0xFF00CD00, 0xFFCDCD00, 0xFF2060EE, 0xFFCD00CD, 0xFF00CDCD, 0xFFE5E5E5, @@ -33,8 +46,11 @@ private fun runColour(code: Int): Color? = when { else -> null } -/** A line with its runs' colours and styles (docs/protocol.md, "Runs"); the app's own marker lines in amber. */ -fun ansiText(line: UiLine): AnnotatedString = buildAnnotatedString { +/** + * A line with its runs' colours and styles (docs/protocol.md, "Runs"), on [colors] where they set none; the app's own + * marker lines in amber. + */ +fun ansiText(line: UiLine, colors: ConsoleColors = ConsoleColors()): AnnotatedString = buildAnnotatedString { if (line.marker) { pushStyle(SpanStyle(color = markerColour)) append(line.text) @@ -47,11 +63,11 @@ fun ansiText(line: UiLine): AnnotatedString = buildAnnotatedString { val start = line.runs[i] val end = (start + line.runs[i + 1]).coerceAtMost(line.text.length) val attributes = line.runs[i + 4] - var fg = runColour(line.runs[i + 2]) ?: CONSOLE_FOREGROUND + var fg = runColour(line.runs[i + 2]) ?: colors.foreground var bg = runColour(line.runs[i + 3]) if (bg == null && fg == Color.Black) fg = ansi[0] if (attributes and RUN_REVERSE != 0) { - val swapped = bg ?: CONSOLE_BACKGROUND + val swapped = bg ?: colors.background bg = fg fg = swapped } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/AppNavigation.kt b/app/src/main/kotlin/org/mudlet/android/ui/AppNavigation.kt index 7af1514..5fafadf 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/AppNavigation.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/AppNavigation.kt @@ -8,15 +8,18 @@ import org.mudlet.android.BuildConfig import org.mudlet.android.ui.game.GameScreen import org.mudlet.android.ui.profiles.ProfileListScreen import org.mudlet.android.ui.selftest.SelfTestScreen +import org.mudlet.android.ui.settings.SettingsScreen private object Routes { const val PROFILES = "profiles" const val GAME = "game" + const val SETTINGS = "settings" const val SELF_TEST = "selfTest" } /** - * The app's screens: the profile list, where it starts, the game screen and, in debug builds only, the self-test. + * The app's screens: the profile list, where it starts, the game screen, the settings and, in debug builds only, the + * self-test. * A release build has no route to the self-test, so R8 drops it. */ @Composable @@ -29,7 +32,10 @@ fun AppNavigation(onKillUi: () -> Unit) { onOpenSelfTest = if (BuildConfig.DEBUG) ({ nav.navigate(Routes.SELF_TEST) { launchSingleTop = true } }) else null, ) } - composable(Routes.GAME) { GameScreen(onBack = { nav.navigateUp() }) } + composable(Routes.GAME) { + GameScreen(onBack = { nav.navigateUp() }, onOpenSettings = { nav.navigate(Routes.SETTINGS) { launchSingleTop = true } }) + } + composable(Routes.SETTINGS) { SettingsScreen(onBack = { nav.navigateUp() }) } if (BuildConfig.DEBUG) { composable(Routes.SELF_TEST) { SelfTestScreen(onBack = { nav.navigateUp() }, onKillUi = onKillUi) } } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt index 3597321..226f4aa 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt @@ -57,6 +57,9 @@ object EngineClient { // How long after a send the engine's emptying of its line (an empty commandLine) is taken as that send's private const val SEND_CLEAR_WINDOW_MS = 3000L + // How long after a send, sendHidden or dismissHidden a status sendError is taken as its answer + private const val SEND_ERROR_WINDOW_MS = 5000L + val lines = mutableStateListOf() /** @@ -98,6 +101,10 @@ object EngineClient { var commandLine by mutableStateOf(null) private set + /** Why the engine could not send the last input request (status `sendError`), until the screen shows it and calls [sendErrorShown]. */ + var sendError by mutableStateOf(null) + private set + private val main = Handler(Looper.getMainLooper()) // The application's Context, which lives as long as the process @@ -113,8 +120,16 @@ object EngineClient { @Volatile private var attached = false - // The console's columns and rows, as setScreenSize last sent them; null until the UI has measured it - @Volatile private var screenSize: Pair? = null + /** The console's columns and rows, as [setScreenSize] last had them; null until the UI has measured it. */ + @Volatile + var screenSize: Pair? = null + private set + + // When an input request last went out; the engine keeps sendError in its status, which it sends again on attach + @Volatile private var inputSentAt = 0L + + // Reader thread only: from hello to synced, the engine resends what it holds, which is not news + private var replaying = false private var startRequestedAt = 0L private var detachStartedAt = 0L private var lostAt = 0L @@ -189,9 +204,16 @@ object EngineClient { return sent } + // A request the engine answers with status sendError when it cannot carry it out + private fun sendInput(message: org.json.JSONObject): Boolean { + if (!send(message)) return false + inputSentAt = SystemClock.elapsedRealtime() + return true + } + /** A command typed on the input line: sent, and put first in [history]. False as for [send]. */ fun sendCommand(line: String): Boolean { - if (!send(Requests.send(line))) return false + if (!sendInput(Requests.send(line))) return false val now = SystemClock.elapsedRealtime() dropExpiredSends(now) sendsAwaitingClear.addLast(now) @@ -200,9 +222,9 @@ object EngineClient { } /** What the player typed while [hiddenInput] holds. False as for [send]. */ - fun sendHidden(text: String): Boolean = send(Requests.sendHidden(text)) + fun sendHidden(text: String): Boolean = sendInput(Requests.sendHidden(text)) - fun dismissHidden(): Boolean = send(Requests.dismissHidden()) + fun dismissHidden(): Boolean = sendInput(Requests.dismissHidden()) /** Asks the engine to complete [text]; [onResult] gets the completed line, or empty for none. False as for [send]. */ fun complete(text: String, onResult: (String) -> Unit): Boolean { @@ -216,6 +238,10 @@ object EngineClient { commandLine = null } + fun sendErrorShown() { + sendError = null + } + /** Connects the engine to a game and remembers the address for [reconnect]. False as for [send]. */ fun connect(host: String, port: Int, tls: Boolean): Boolean { lastAddress = GameAddress(host, port, tls) @@ -236,7 +262,9 @@ object EngineClient { fun disconnect(): Boolean = send(Requests.disconnect()) fun setScreenSize(columns: Int, rows: Int) { - screenSize = columns to rows + val size = columns to rows + if (size == screenSize) return + screenSize = size if (attached) link.send(Requests.setScreenSize(columns, rows)) } @@ -290,6 +318,7 @@ object EngineClient { private val listener = object : EngineLink.Listener { override fun onAttached(hello: EngineMessage.Hello, sameInstance: Boolean) { + replaying = true // The engine forgets nothing across a reattach, but a new engine instance needs the size again screenSize?.let { (columns, rows) -> link.send(Requests.setScreenSize(columns, rows)) } // The engine resends its whole status after hello, so the old instance's values must go first @@ -344,10 +373,15 @@ object EngineClient { scheduleFlush() } + is EngineMessage.Synced -> replaying = false + is EngineMessage.Status -> if (message.key == "completion") { val id = (message.replyTo as? Number)?.toLong() if (id != null) post { completions.remove(id)?.invoke(message.value) } } else { + if (message.key == "sendError" && !replaying && inputSentAt > 0 && SystemClock.elapsedRealtime() - inputSentAt < SEND_ERROR_WINDOW_MS) { + post { sendError = message.value } + } synchronized(pendingLock) { pendingStatus[message.key] = message.value scheduleFlush() 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..a8eaa46 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.data.Settings import org.mudlet.android.ui.theme.MudletTheme class MainActivity : ComponentActivity() { @@ -16,6 +17,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) EngineClient.init(this) + Settings.init(this) if (Build.VERSION.SDK_INT >= 33) askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) EngineClient.refreshServiceState() if (EngineClient.engineWanted && EngineClient.fgsState == "not running") EngineClient.startEngine() diff --git a/app/src/main/kotlin/org/mudlet/android/ui/MudletTopBar.kt b/app/src/main/kotlin/org/mudlet/android/ui/MudletTopBar.kt index 991b42d..181f13a 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/MudletTopBar.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/MudletTopBar.kt @@ -10,10 +10,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import org.mudlet.android.R -/** Every screen's title bar, with a Back button when [onBack] is given. */ +/** Every screen's title bar, with a Back button when [onBack] is given, and the screen's own buttons, [actions], at its end. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MudletTopBar(title: String, onBack: (() -> Unit)? = null) { +fun MudletTopBar(title: String, onBack: (() -> Unit)? = null, actions: @Composable () -> Unit = {}) { TopAppBar( title = { Text(title) }, navigationIcon = { @@ -23,5 +23,6 @@ fun MudletTopBar(title: String, onBack: (() -> Unit)? = null) { } } }, + actions = { actions() }, ) } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt index e5243ca..7895727 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt @@ -1,6 +1,9 @@ package org.mudlet.android.ui.game import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -26,6 +29,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -37,29 +42,66 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import org.mudlet.android.R -import org.mudlet.android.ui.CONSOLE_BACKGROUND -import org.mudlet.android.ui.CONSOLE_FOREGROUND +import org.mudlet.android.ui.ConsoleColors import org.mudlet.android.ui.UiLine import org.mudlet.android.ui.ansiText +import kotlin.math.roundToInt private const val TAIL_KEY = "tail" private val CONSOLE_SIDE_PADDING = 4.dp -private val consoleStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp) + +// The keyboard and a pinch resize the console in many small steps: only where it comes to rest goes to the game +private const val SCREEN_SIZE_SETTLE_MS = 200L + +// A run of characters, shaped as a line of game text is, rather than one on its own +private const val CELLS_MEASURED = 100 /** - * The game's text: [lines], then [tail] (the prompt), in their colours and styles. At the bottom it follows new lines; - * scrolled up, it stays where it is and offers a button back to the bottom that counts the lines that came meanwhile. - * [linesAdded] is EngineClient's count, which numbers the lines. [onScreenSize] gets the columns and rows that fit. + * The game's text: [lines], then [tail] (the prompt), in their colours and styles, at [textSizeSp] in [colors]. At the + * bottom it follows new lines; scrolled up, it stays where it is and offers a button back to the bottom that counts the + * lines that came meanwhile. [linesAdded] is EngineClient's count, which numbers the lines. + * + * A two-finger pinch hands [onTextSize] a new size. [onScreenSize] gets the columns and rows that fit, once they have + * held for a moment. */ @Composable -fun Console(lines: List, tail: UiLine?, linesAdded: Long, onScreenSize: (columns: Int, rows: Int) -> Unit, modifier: Modifier = Modifier) { +fun Console( + lines: List, + tail: UiLine?, + linesAdded: Long, + textSizeSp: Int, + colors: ConsoleColors, + onTextSize: (Int) -> Unit, + onScreenSize: (columns: Int, rows: Int) -> Unit, + modifier: Modifier = Modifier, +) { val listState = rememberLazyListState() val measurer = rememberTextMeasurer() val density = LocalDensity.current - val cell = remember(measurer, density) { measurer.measure("M", consoleStyle).size } - val sidePaddingPx = with(density) { (CONSOLE_SIDE_PADDING * 2).roundToPx() } + val style = remember(textSizeSp) { TextStyle(fontFamily = FontFamily.Monospace, fontSize = textSizeSp.sp) } + val cell = remember(measurer, density, style) { + val row = measurer.measure("M".repeat(CELLS_MEASURED), style, softWrap = false) + CellSize(row.getLineRight(0) / CELLS_MEASURED, row.size.height) + } + // Each side rounded on its own, as contentPadding does + val sidePaddingPx = with(density) { CONSOLE_SIDE_PADDING.roundToPx() * 2 } + var consoleSize by remember { mutableStateOf(IntSize.Zero) } var following by rememberSaveable { mutableStateOf(true) } + val latestTextSize by rememberUpdatedState(textSizeSp) + val latestOnTextSize by rememberUpdatedState(onTextSize) + val latestOnScreenSize by rememberUpdatedState(onScreenSize) + + LaunchedEffect(cell, sidePaddingPx) { + snapshotFlow { cell.fit(consoleSize, sidePaddingPx) }.filterNotNull().distinctUntilChanged().collectLatest { (columns, rows) -> + delay(SCREEN_SIZE_SETTLE_MS) + latestOnScreenSize(columns, rows) + } + } // linesAdded when the player scrolled away from the bottom: the lines since then are new to them var seenUpTo by rememberSaveable { mutableLongStateOf(linesAdded) } @@ -92,21 +134,17 @@ fun Console(lines: List, tail: UiLine?, linesAdded: Long, onScreenSize: } } - Box(modifier.background(CONSOLE_BACKGROUND)) { + Box(modifier.background(colors.background).pinchToZoom({ latestTextSize }, { latestOnTextSize(it) })) { LazyColumn( - Modifier.fillMaxSize().onSizeChanged { - if (cell.width > 0 && cell.height > 0) { - onScreenSize(((it.width - sidePaddingPx) / cell.width).coerceIn(1, 65535), (it.height / cell.height).coerceIn(1, 65535)) - } - }, + Modifier.fillMaxSize().onSizeChanged { consoleSize = it }, state = listState, contentPadding = PaddingValues(horizontal = CONSOLE_SIDE_PADDING), ) { // Each line's key is its number, so the lines in view stay put as the oldest are dropped items(itemCount, key = { i -> if (i < lines.size) firstLine + i else TAIL_KEY }) { i -> val line = if (i < lines.size) lines[i] else tail ?: return@items - val text = remember(line) { ansiText(line) } - Text(text, Modifier.fillMaxWidth(), color = CONSOLE_FOREGROUND, style = consoleStyle) + val text = remember(line, colors) { ansiText(line, colors) } + Text(text, Modifier.fillMaxWidth(), color = colors.foreground, style = style) } } if (!following) { @@ -125,3 +163,36 @@ fun Console(lines: List, tail: UiLine?, linesAdded: Long, onScreenSize: } } } + +/** One character of the console's monospaced font: its advance, which may be a fraction of a pixel, and line height. */ +private data class CellSize(val width: Float, val height: Int) { + /** The columns and rows that fit in [console], [sidePaddingPx] narrower; null before the console is laid out. */ + fun fit(console: IntSize, sidePaddingPx: Int): Pair? { + if (console == IntSize.Zero || width <= 0f || height <= 0) return null + val columns = ((console.width - sidePaddingPx) / width).toInt() + return columns.coerceIn(1, 65535) to (console.height / height).coerceIn(1, 65535) + } +} + +/** + * Two fingers spreading or closing scale [textSize] by how far they moved since they came down, and hand the result to + * [onTextSize]. One finger is left to the list, to scroll. + */ +private fun Modifier.pinchToZoom(textSize: () -> Int, onTextSize: (Int) -> Unit) = pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + var start = 0 + var zoom = 1f + while (true) { + // Seen before the list, so a pinch never scrolls it + val event = awaitPointerEvent(PointerEventPass.Initial) + val pressed = event.changes.count { it.pressed } + if (pressed == 0) break + if (pressed < 2) continue + if (start == 0) start = textSize() + zoom *= event.calculateZoom() + onTextSize((start * zoom).roundToInt()) + event.changes.forEach { it.consume() } + } + } +} 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 cc02643..aaef489 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 @@ -1,30 +1,74 @@ package org.mudlet.android.ui.game +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource 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.data.Settings +import org.mudlet.android.ui.ConsoleColors 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 keyboard pushes them up, so the console shrinks above it. Why the engine could not send something shows over + * the console's foot, where the keyboard does not hide it. */ @Composable -fun GameScreen(onBack: () -> Unit) { +fun GameScreen(onBack: () -> Unit, onOpenSettings: () -> Unit) { val c = EngineClient - Scaffold(topBar = { MudletTopBar(stringResource(R.string.game_title), onBack) }) { padding -> + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val sendError = c.sendError + if (sendError != null) { + val message = stringResource(R.string.send_error, sendError) + LaunchedEffect(sendError) { + c.sendErrorShown() + // The newest reason replaces one still showing; launched apart, as sendErrorShown ends this effect + snackbars.currentSnackbarData?.dismiss() + scope.launch { snackbars.showSnackbar(message, withDismissAction = true, duration = SnackbarDuration.Long) } + } + } + val settingsButton = @Composable { + IconButton(onOpenSettings) { + Icon(painterResource(R.drawable.ic_settings), stringResource(R.string.settings_title)) + } + } + Scaffold(topBar = { MudletTopBar(stringResource(R.string.game_title), onBack, actions = settingsButton) }) { padding -> Column(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding).imePadding()) { - Console(c.lines, c.tail, c.linesAdded, onScreenSize = c::setScreenSize, Modifier.weight(1f).fillMaxWidth()) + Box(Modifier.weight(1f).fillMaxWidth()) { + Console( + c.lines, + c.tail, + c.linesAdded, + textSizeSp = Settings.textSize.value, + colors = ConsoleColors.of(c.status["consoleColors"]), + onTextSize = Settings.textSize::set, + onScreenSize = c::setScreenSize, + Modifier.fillMaxSize(), + ) + SnackbarHost(snackbars, Modifier.align(Alignment.BottomCenter)) + } InputLine( hiddenInput = c.hiddenInput, history = c.history, diff --git a/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..c35929b --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt @@ -0,0 +1,86 @@ +package org.mudlet.android.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.semantics.stateDescription +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.mudlet.android.R +import org.mudlet.android.data.Settings +import org.mudlet.android.ui.CONSOLE_BACKGROUND +import org.mudlet.android.ui.CONSOLE_FOREGROUND +import org.mudlet.android.ui.MudletTopBar +import kotlin.math.roundToInt + +/** The app's settings, one section each, in a column that scrolls at large font sizes. */ +@Composable +fun SettingsScreen(onBack: () -> Unit) { + Scaffold(topBar = { MudletTopBar(stringResource(R.string.settings_title), onBack) }) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + TextSize(Settings.textSize) + } + } +} + +/** The console's text size: a slider, with buttons a step either way, and a sample at that size. */ +@Composable +private fun TextSize(setting: Settings.IntSetting) { + val size = setting.value + val range = setting.range + val label = stringResource(R.string.settings_text_size) + val value = stringResource(R.string.settings_text_size_value, size) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.settings_text_size_hint), style = MaterialTheme.typography.bodyMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton({ setting.set(size - 1) }, enabled = size > range.first) { + Icon(painterResource(R.drawable.ic_remove), stringResource(R.string.settings_text_smaller)) + } + Slider( + value = size.toFloat(), + onValueChange = { setting.set(it.roundToInt()) }, + modifier = Modifier.weight(1f).semantics { + contentDescription = label + stateDescription = value + }, + valueRange = range.first.toFloat()..range.last.toFloat(), + steps = range.last - range.first - 1, + ) + IconButton({ setting.set(size + 1) }, enabled = size < range.last) { + Icon(painterResource(R.drawable.ic_add), stringResource(R.string.settings_text_larger)) + } + } + Text(value, style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(R.string.settings_text_size_sample), + Modifier.fillMaxWidth().background(CONSOLE_BACKGROUND).padding(8.dp), + color = CONSOLE_FOREGROUND, + fontFamily = FontFamily.Monospace, + fontSize = size.sp, + ) + } +} diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..239a604 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_remove.xml b/app/src/main/res/drawable/ic_remove.xml new file mode 100644 index 0000000..cce6e3d --- /dev/null +++ b/app/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..8cd5b8c --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b66df2a..761b0ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,17 @@ Game + + Settings + Text size + The size of the game\'s text. Pinching the text with two fingers changes it too, and the system\'s font size scales it. + + Size %1$d + Smaller text + Larger text + + You see a brass lantern here. + Jump to the bottom @@ -28,6 +39,9 @@ Password Close the password field + + Not sent: %1$s Waiting for the engine… diff --git a/docs/architecture.md b/docs/architecture.md index c5897d2..c11375e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ 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 and the connection bar), the settings in `ui/settings/` and, in debug builds, the self-test in `ui/selftest/`), the renderer of game text (`ui/AnsiText.kt`), the theme (`ui/theme/`), the model of the engine (`ui/EngineClient.kt`) and the app's own settings (`data/Settings.kt`, in SharedPreferences). 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 | | `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` | @@ -240,7 +240,7 @@ CI runs both on API 28, the app's minSdk, and on API 36. `linksChanged`). - The game screen and the self-test each send `setScreenSize` from their own console, so the one laid out last sets what a game that asks is told. -- `consoleColors` and `announce` reach the app, which does not use them yet. +- The game screen's console draws in `consoleColors`; `announce` reaches the app, which does not use it yet. - No bundled fonts. - TLS is chosen when the profile starts (see "TLS" below). - A change `saveProfile` makes to the running profile takes effect at its next start, as Mudlet read its details then. @@ -714,6 +714,20 @@ Item 13 of the on-device checklist covers this. when nothing matches. - `lua printCmdLine("say hi")` puts "say hi" on the line. Type a command and start the next one at once: what was typed is not wiped when the game answers. + - Disconnect, then send a command: "Not sent: ..." shows over the foot of the console, above the keyboard, and + TalkBack reads it without moving focus. +17. Text size and the screen size the game is told, connected to a game with the libmudlet APK. + - Pinch the game's text with two fingers: it grows and shrinks smoothly, and one finger still scrolls. The + settings button in the game screen's title bar opens "Settings", whose text size slider, "Smaller text" and + "Larger text" buttons change it too; TalkBack reads the slider as "Text size", with its size, and swiping up or + down on it changes the size. + - On a game that shows the width it was told (many have a screen-width option), a line of that many characters + fills the console's width. Open and close the keyboard, and rotate the phone: the game is told the new size + once, after the change, not at every step. + - At the system's largest font size, the console's text is larger, the game is told fewer columns, and the + settings screen's text is not cut off. + - `lua setBackgroundColor("main", 0, 0, 80)` turns the console's background dark blue; text in default colours + keeps them. ## Problems hit - **`QJsonDocument` sorts keys**, so `"t"` is not the first key. A test that From e4ff2f1c26d9f645e13a9c60dd1200ede1cc91b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 21:31:55 +0000 Subject: [PATCH 4/9] Review: text in the console's own background colour is lifted, as black on black was Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- .../kotlin/org/mudlet/android/data/Settings.kt | 2 +- .../main/kotlin/org/mudlet/android/ui/AnsiText.kt | 3 ++- .../org/mudlet/android/ui/game/GameScreen.kt | 15 +++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/org/mudlet/android/data/Settings.kt b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt index df56c9a..2cd12c4 100644 --- a/app/src/main/kotlin/org/mudlet/android/data/Settings.kt +++ b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt @@ -9,7 +9,7 @@ import androidx.core.content.edit /** * The app's own settings, kept in SharedPreferences. Each one is Compose state, read and set on the main thread. A new - * setting is a property here and a row on the settings screen. + * setting is a property here, in [all] so that it loads, and a row on the settings screen. */ object Settings { private const val PREFS = "settings" diff --git a/app/src/main/kotlin/org/mudlet/android/ui/AnsiText.kt b/app/src/main/kotlin/org/mudlet/android/ui/AnsiText.kt index a272042..c3585c8 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/AnsiText.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/AnsiText.kt @@ -65,7 +65,8 @@ fun ansiText(line: UiLine, colors: ConsoleColors = ConsoleColors()): AnnotatedSt val attributes = line.runs[i + 4] var fg = runColour(line.runs[i + 2]) ?: colors.foreground var bg = runColour(line.runs[i + 3]) - if (bg == null && fg == Color.Black) fg = ansi[0] + // Text in the console's own background colour, as black on the default black, would not show + if (bg == null && fg == colors.background) fg = ansi[0] if (attributes and RUN_REVERSE != 0) { val swapped = bg ?: colors.background bg = fg 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 aaef489..6e6376c 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 @@ -49,12 +49,15 @@ fun GameScreen(onBack: () -> Unit, onOpenSettings: () -> Unit) { scope.launch { snackbars.showSnackbar(message, withDismissAction = true, duration = SnackbarDuration.Long) } } } - val settingsButton = @Composable { - IconButton(onOpenSettings) { - Icon(painterResource(R.drawable.ic_settings), stringResource(R.string.settings_title)) - } - } - Scaffold(topBar = { MudletTopBar(stringResource(R.string.game_title), onBack, actions = settingsButton) }) { padding -> + Scaffold( + topBar = { + MudletTopBar(stringResource(R.string.game_title), onBack) { + IconButton(onOpenSettings) { + Icon(painterResource(R.drawable.ic_settings), stringResource(R.string.settings_title)) + } + } + }, + ) { padding -> Column(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding).imePadding()) { Box(Modifier.weight(1f).fillMaxWidth()) { Console( From 4fdd971516d85d38b0b392981e6b11edd1368799 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Nov 2026 01:25:29 +0000 Subject: [PATCH 5/9] TextSizeTest: judge overflow by the text's lines, not hasVisualOverflow atTwiceTheSystemFontSizeNothingIsCutOff failed on API 28 and 36 with "'Disconnected' overflows its space", though the connection bar's state fits with room to spare. A String Text answers GetTextLayoutResult from ParagraphLayoutCache.slowCreateTextLayoutResultOrNull, which lays the text out again as a MultiParagraph at the last maximum width; MultiParagraph.width is that maximum while the result's size is the text's own width, so didOverflowWidth holds for every String Text narrower than its space. The check now takes didOverflowHeight, which still sees text squeezed or cut short, and text too wide for its space (the relayout wraps it into lines the real layout lacks), plus the widest line against the text's width. A failure names the size, the widest line and the height. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- .../kotlin/org/mudlet/android/ui/TextSizeTest.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt index e41faf1..4c864dc 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt @@ -187,7 +187,12 @@ class TextSizeTest { return results.single() } - /** No text is clipped or cut short, and every control is within the window's width. */ + /** + * No text is clipped or cut short, and every control is within the window's width. Not hasVisualOverflow: a String + * Text answers GetTextLayoutResult with its text laid out again at its whole maximum width, so didOverflowWidth + * holds for any such Text narrower than that maximum. That layout wraps what the real one did not, so text too + * wide for its space overflows its height there. + */ private fun assertNothingCutOff() { compose.onRoot().tryPerformAccessibilityChecks() val width = compose.onRoot().fetchSemanticsNode().boundsInRoot.width @@ -195,7 +200,9 @@ class TextSizeTest { val results = mutableListOf() node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results) for (result in results) { - assertTrue("'${result.layoutInput.text}' overflows its space", !result.hasVisualOverflow) + val widestLine = (0 until result.lineCount).maxOf { result.getLineRight(it) - result.getLineLeft(it) } + val fits = !result.didOverflowHeight && widestLine <= result.size.width + assertTrue("'${result.layoutInput.text}' overflows its space ${result.size}: widest line $widestLine, height ${result.multiParagraph.height}", fits) } } for (node in compose.onAllNodes(hasClickAction()).fetchSemanticsNodes()) { From b0e335a1f14bfa774d4d20e3af6e280feceffe7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 23:07:00 +0000 Subject: [PATCH 6/9] Screen reader output: announcements, plain-text console lines, the settings While TalkBack is on and "Read new text aloud" holds, new game lines are read out through a live region, those within 300 ms as one announcement and a burst as its count and last lines. A script's announce() is read, assertive or polite. Each console line is a node whose text is the line without its styles. Settings gains "Read new text aloud", "Tell games I use a screen reader" (offered on the first run with TalkBack on) and "Blank lines from the game", which go to each profile that loads as setConfig() calls (announceIncomingText, advertiseScreenReader, blankLinesBehaviour, as upstream's TLuaInterpreter names them). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- .../org/mudlet/android/ui/FakeEngine.kt | 2 + .../org/mudlet/android/ui/ScreenReaderTest.kt | 202 ++++++++++++++++++ .../org/mudlet/android/data/Settings.kt | 67 +++++- .../org/mudlet/android/ui/AppNavigation.kt | 62 ++++-- .../org/mudlet/android/ui/EngineClient.kt | 40 ++++ .../org/mudlet/android/ui/MainActivity.kt | 2 + .../org/mudlet/android/ui/game/Announcer.kt | 150 +++++++++++++ .../org/mudlet/android/ui/game/Console.kt | 7 +- .../android/ui/settings/SettingsScreen.kt | 61 ++++++ app/src/main/res/values/strings.xml | 22 ++ docs/architecture.md | 30 ++- 11 files changed, 619 insertions(+), 26 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt create mode 100644 app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt index 92ffcec..20310d9 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt @@ -149,6 +149,8 @@ class FakeEngine : AutoCloseable { fun commandLine(text: String) = send(JSONObject().put("t", "commandLine").put("text", text)) + fun announce(text: String, assertive: Boolean) = send(JSONObject().put("t", "announce").put("text", text).put("assertive", assertive)) + fun synced(seq: Long) = send(JSONObject().put("t", "synced").put("seq", seq)) /** As the engine process dying: the UI sees the connection end. */ diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt new file mode 100644 index 0000000..42fbd64 --- /dev/null +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt @@ -0,0 +1,202 @@ +package org.mudlet.android.ui + +import android.Manifest +import android.os.Build +import android.os.SystemClock +import androidx.annotation.StringRes +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +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.onRoot +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.tryPerformAccessibilityChecks +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.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.data.Settings +import org.mudlet.android.protocol.RUN_BOLD +import org.mudlet.android.ui.game.Announcer + +/** + * What TalkBack is given, against a fake engine, as the semantics tree shows it: new lines read as one announcement + * while touch exploration is on (the Announcer's switch stands in for the system's), a script's announce(), the + * console's lines as plain text, and the screen reader settings, which go to the profile with runLua. + */ +@RunWith(AndroidJUnit4::class) +class ScreenReaderTest { + 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 var nextSeq = 1L + + @Before + fun openTheGameScreen() { + // enableAccessibilityChecks() needs API 34: the API 36 emulator runs the checks, API 28 does not + if (Build.VERSION.SDK_INT >= 34) compose.enableAccessibilityChecks() + reset() + compose.runOnIdle { Settings.screenReaderOffered.set(true) } + 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) + activate(hasText(string(R.string.profiles_open_game))) + compose.onNodeWithText(string(R.string.game_title)).assertIsDisplayed() + } + + // The settings are stored and the Announcer lives as long as the process, so both would outlast the test + @After + fun reset() = compose.runOnIdle { + Announcer.touchExplorationOverride = null + for (setting in listOf(Settings.readNewText, Settings.advertiseScreenReader, Settings.screenReaderOffered)) setting.set(setting.default) + Settings.blankLines.set(Settings.blankLines.default) + } + + @Test + fun aBurstOfNewLinesIsReadOnceWhileTouchExplorationIsOn() { + val before = serial() + line("not read: touch exploration is off") + SystemClock.sleep(QUIET_MS) + assertEquals("no announcement while touch exploration is off", before, serial()) + + compose.runOnIdle { Announcer.touchExplorationOverride = true } + val burst = (1..8).map { "burst line $it" } + burst.forEach { session.line(nextSeq++, it) } + val expected = compose.activity.resources.getQuantityString(R.plurals.announce_burst, 8, 8, burst.takeLast(3).joinToString("\n")) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(announcement(expected, LiveRegionMode.Polite)).fetchSemanticsNodes().size == 1 } + SystemClock.sleep(QUIET_MS) + assertEquals("the burst is one announcement", before + 1, serial()) + + compose.runOnIdle { Settings.readNewText.set(false) } + line("not read: the setting is off") + SystemClock.sleep(QUIET_MS) + assertEquals("no announcement with the setting off", before + 1, serial()) + } + + @Test + fun announceIsReadAssertiveOrPolite() { + session.announce("You are hungry.", assertive = true) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(announcement("You are hungry.", LiveRegionMode.Assertive)).fetchSemanticsNodes().size == 1 } + val first = serial() + // The same words again are a second announcement, in the other live region + SystemClock.sleep(QUIET_MS) + session.announce("You are hungry.", assertive = false) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(announcement("You are hungry.", LiveRegionMode.Polite)).fetchSemanticsNodes().size == 1 } + assertEquals(first + 1, serial()) + assertEquals("the first one's live region is empty", 0, compose.onAllNodes(announcement("You are hungry.", LiveRegionMode.Assertive)).fetchSemanticsNodes().size) + } + + @Test + fun eachConsoleLineIsANodeWithItsPlainText() { + val styled = "Red and bold, then plain" + line(styled, listOf(listOf(0, 12, 1, -1, RUN_BOLD))) + line("a second line") + for (text in listOf(styled, "a second line")) { + val node = compose.onNodeWithText(text).fetchSemanticsNode() + val shown = node.config[SemanticsProperties.Text].single() + assertEquals(text, shown.text) + assertTrue("'$text' is read without its styles: ${shown.spanStyles}", shown.spanStyles.isEmpty() && shown.paragraphStyles.isEmpty()) + } + } + + @Test + fun theSettingsGoToTheProfileWithRunLua() { + session.status("profile", "Test loaded") + assertEquals( + "setConfig(\"announceIncomingText\", true)\nsetConfig(\"advertiseScreenReader\", false)\nsetConfig(\"blankLinesBehaviour\", \"show\")", + session.receive("runLua").getString("code"), + ) + + activate(hasContentDescription(string(R.string.settings_title))) + activate(hasText(string(R.string.settings_read_new_text)), scroll = true) + assertEquals("setConfig(\"announceIncomingText\", false)", session.receive("runLua").getString("code")) + activate(hasText(string(R.string.settings_advertise_screen_reader)), scroll = true) + assertEquals("setConfig(\"advertiseScreenReader\", true)", session.receive("runLua").getString("code")) + activate(hasText(string(R.string.settings_blank_lines_hide)), scroll = true) + assertEquals("setConfig(\"blankLinesBehaviour\", \"hide\")", session.receive("runLua").getString("code")) + compose.runOnIdle { + assertEquals(false, Settings.readNewText.value) + assertEquals(true, Settings.advertiseScreenReader.value) + assertEquals(Settings.BlankLines.HIDE, Settings.blankLines.value) + } + } + + @Test + fun theFirstRunWithTouchExplorationOffersToTellGames() { + session.status("profile", "Test loaded") + session.receive("runLua") + compose.runOnIdle { + Settings.screenReaderOffered.set(false) + Announcer.touchExplorationOverride = true + } + val title = hasText(string(R.string.screen_reader_offer_title)) + compose.onNode(title).assertIsDisplayed() + // The dialog is a window of its own, with a root of its own + compose.onNode(isRoot() and hasAnyDescendant(title)).tryPerformAccessibilityChecks() + compose.onNode(hasText(string(R.string.screen_reader_offer_yes)) and hasClickAction()).performSemanticsAction(SemanticsActions.OnClick) + assertEquals("setConfig(\"advertiseScreenReader\", true)", session.receive("runLua").getString("code")) + compose.runOnIdle { + assertEquals(true, Settings.advertiseScreenReader.value) + assertEquals(true, Settings.screenReaderOffered.value) + } + compose.waitUntil(WAIT_MS) { compose.onAllNodes(title).fetchSemanticsNodes().isEmpty() } + } + + private fun serial() = compose.runOnIdle { Announcer.current?.serial ?: 0L } + + private fun line(text: String, runs: List> = emptyList()) { + session.line(nextSeq++, text, runs) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() } + } + + /** A live region of [mode] that TalkBack would read as [text]. */ + private fun announcement(text: String, mode: LiveRegionMode) = SemanticsMatcher("live region $mode reading '$text'") { + it.config.getOrNull(SemanticsProperties.LiveRegion) == mode && it.config.getOrNull(SemanticsProperties.ContentDescription) == listOf(text) + } + + private fun string(@StringRes id: Int) = compose.activity.getString(id) + + /** What a double tap does under TalkBack: the node's accessibility click, after the checks on the whole screen. */ + private fun activate(label: SemanticsMatcher, scroll: Boolean = false) { + compose.onRoot().tryPerformAccessibilityChecks() + val node = compose.onNode(label and hasClickAction()) + // The settings screen scrolls, and TalkBack scrolls to what it moves to + if (scroll) node.performScrollTo() + node.assertIsDisplayed().performSemanticsAction(SemanticsActions.OnClick) + } + + private companion object { + const val WAIT_MS = 10_000L + + // Well past the Announcer's wait for more lines + const val QUIET_MS = 1_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/data/Settings.kt b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt index 2cd12c4..c8cdd70 100644 --- a/app/src/main/kotlin/org/mudlet/android/data/Settings.kt +++ b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt @@ -19,7 +19,25 @@ object Settings { /** The console's text size in sp, so the system font scale scales it too. */ val textSize = IntSetting("textSizeSp", default = 14, range = 8..32) - private val all = listOf(textSize) + /** "Read new text aloud": new game lines are announced while TalkBack is on, as Mudlet's own setting has it. */ + val readNewText = BoolSetting("announceIncomingText", default = true, mudlet = true) + + /** "Tell games I use a screen reader", in telnet's MTTS and NEW-ENVIRON. */ + val advertiseScreenReader = BoolSetting("advertiseScreenReader", default = false, mudlet = true) + + /** What becomes of the blank lines a game sends. */ + val blankLines = EnumSetting("blankLinesBehaviour", BlankLines.SHOW, BlankLines.entries, mudlet = true) { it.mudletValue } + + /** Whether the first run with TalkBack on has asked about [advertiseScreenReader] yet. */ + val screenReaderOffered = BoolSetting("screenReaderOffered", default = false) + + private val all = listOf(textSize, readNewText, advertiseScreenReader, blankLines, screenReaderOffered) + + /** + * The settings Mudlet keeps for each profile too. The app keeps its own copy, as `runLua` returns no values, and + * gives each profile that loads the app's values. + */ + val mudletSettings = all.filter { it.mudlet } fun init(context: Context) { if (prefs != null) return @@ -28,8 +46,18 @@ object Settings { all.forEach { it.load(p) } } - /** One setting: [value] is the current one, [set] changes and stores it. */ - abstract class Setting(protected val key: String, val default: T) { + /** The values upstream Mudlet's `setConfig("blankLinesBehaviour", ...)` takes. */ + enum class BlankLines(val mudletValue: String) { + SHOW("show"), + HIDE("hide"), + SPACE("replacewithspace"), + } + + /** + * One setting: [value] is the current one, [set] changes and stores it. A [mudlet] setting's [key] is also the + * name of the `setConfig()` key it sets in Mudlet. + */ + abstract class Setting(protected val key: String, val default: T, val mudlet: Boolean = false) { var value by mutableStateOf(default) private set @@ -40,12 +68,17 @@ object Settings { prefs?.edit { write(this, v) } } + /** The Lua that gives Mudlet's profile this setting's value. */ + fun setConfigLua(): String = "setConfig(\"$key\", ${lua(value)})" + internal fun load(prefs: SharedPreferences) { value = normalise(read(prefs)) } protected open fun normalise(v: T): T = v + protected open fun lua(v: T): String = throw UnsupportedOperationException("$key is not one of Mudlet's settings") + protected abstract fun read(prefs: SharedPreferences): T protected abstract fun write(editor: SharedPreferences.Editor, v: T) @@ -61,4 +94,32 @@ object Settings { editor.putInt(key, v) } } + + class BoolSetting(key: String, default: Boolean, mudlet: Boolean = false) : Setting(key, default, mudlet) { + override fun lua(v: Boolean) = v.toString() + + override fun read(prefs: SharedPreferences) = prefs.getBoolean(key, default) + + override fun write(editor: SharedPreferences.Editor, v: Boolean) { + editor.putBoolean(key, v) + } + } + + /** One of [entries], stored by name; [mudletValue] is the string Mudlet's `setConfig()` takes for each. */ + class EnumSetting>( + key: String, + default: E, + val entries: List, + mudlet: Boolean = false, + private val mudletValue: (E) -> String, + ) : Setting(key, default, mudlet) { + // The values are fixed strings of letters, which need no escaping + override fun lua(v: E) = "\"${mudletValue(v)}\"" + + override fun read(prefs: SharedPreferences) = prefs.getString(key, null)?.let { name -> entries.firstOrNull { it.name == name } } ?: default + + override fun write(editor: SharedPreferences.Editor, v: E) { + editor.putString(key, v.name) + } + } } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/AppNavigation.kt b/app/src/main/kotlin/org/mudlet/android/ui/AppNavigation.kt index 5fafadf..795b438 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/AppNavigation.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/AppNavigation.kt @@ -1,10 +1,22 @@ package org.mudlet.android.ui +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.AlertDialog +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.res.stringResource import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import org.mudlet.android.BuildConfig +import org.mudlet.android.R +import org.mudlet.android.data.Settings +import org.mudlet.android.ui.game.AnnouncementRegion +import org.mudlet.android.ui.game.Announcer import org.mudlet.android.ui.game.GameScreen import org.mudlet.android.ui.profiles.ProfileListScreen import org.mudlet.android.ui.selftest.SelfTestScreen @@ -21,23 +33,47 @@ private object Routes { * The app's screens: the profile list, where it starts, the game screen, the settings and, in debug builds only, the * self-test. * A release build has no route to the self-test, so R8 drops it. + * + * On every screen, TalkBack says what the Announcer has to say; the first time TalkBack is on, the app asks whether + * to tell games a screen reader is in use. */ @Composable fun AppNavigation(onKillUi: () -> Unit) { val nav = rememberNavController() - NavHost(nav, startDestination = Routes.PROFILES) { - composable(Routes.PROFILES) { - ProfileListScreen( - onOpenGame = { nav.navigate(Routes.GAME) { launchSingleTop = true } }, - onOpenSelfTest = if (BuildConfig.DEBUG) ({ nav.navigate(Routes.SELF_TEST) { launchSingleTop = true } }) else null, - ) - } - composable(Routes.GAME) { - GameScreen(onBack = { nav.navigateUp() }, onOpenSettings = { nav.navigate(Routes.SETTINGS) { launchSingleTop = true } }) - } - composable(Routes.SETTINGS) { SettingsScreen(onBack = { nav.navigateUp() }) } - if (BuildConfig.DEBUG) { - composable(Routes.SELF_TEST) { SelfTestScreen(onBack = { nav.navigateUp() }, onKillUi = onKillUi) } + Box(Modifier.fillMaxSize()) { + NavHost(nav, startDestination = Routes.PROFILES) { + composable(Routes.PROFILES) { + ProfileListScreen( + onOpenGame = { nav.navigate(Routes.GAME) { launchSingleTop = true } }, + onOpenSelfTest = if (BuildConfig.DEBUG) ({ nav.navigate(Routes.SELF_TEST) { launchSingleTop = true } }) else null, + ) + } + composable(Routes.GAME) { + GameScreen(onBack = { nav.navigateUp() }, onOpenSettings = { nav.navigate(Routes.SETTINGS) { launchSingleTop = true } }) + } + composable(Routes.SETTINGS) { SettingsScreen(onBack = { nav.navigateUp() }) } + if (BuildConfig.DEBUG) { + composable(Routes.SELF_TEST) { SelfTestScreen(onBack = { nav.navigateUp() }, onKillUi = onKillUi) } + } } + // At the foot, so it comes last for TalkBack and is out of the way of a finger exploring the screen + AnnouncementRegion(Modifier.align(Alignment.BottomStart)) + } + if (Announcer.touchExploration && !Settings.screenReaderOffered.value) OfferScreenReaderSetting() +} + +/** Asks once whether games are to be told a screen reader is in use; the answer is the setting, which can change later. */ +@Composable +private fun OfferScreenReaderSetting() { + fun answer(yes: Boolean) { + EngineClient.setMudletSetting(Settings.advertiseScreenReader, yes) + Settings.screenReaderOffered.set(true) } + AlertDialog( + onDismissRequest = { Settings.screenReaderOffered.set(true) }, + confirmButton = { TextButton({ answer(true) }) { Text(stringResource(R.string.screen_reader_offer_yes)) } }, + dismissButton = { TextButton({ answer(false) }) { Text(stringResource(R.string.screen_reader_offer_no)) } }, + title = { Text(stringResource(R.string.screen_reader_offer_title)) }, + text = { Text(stringResource(R.string.screen_reader_offer_text)) }, + ) } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt index 226f4aa..31c27da 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt @@ -9,6 +9,7 @@ import android.net.LocalSocketAddress import android.os.Handler import android.os.Looper import android.os.SystemClock +import android.util.Log import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.edit import org.mudlet.android.R +import org.mudlet.android.data.Settings import org.mudlet.android.engine.EngineService import org.mudlet.android.engine.EngineSession import org.mudlet.android.engine.EngineSocket @@ -27,9 +29,11 @@ import org.mudlet.android.protocol.EngineMessage import org.mudlet.android.protocol.EngineRestore import org.mudlet.android.protocol.Requests import org.mudlet.android.protocol.Transport +import org.mudlet.android.ui.game.Announcer import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.util.concurrent.ConcurrentHashMap import kotlin.concurrent.thread class UiLine(val text: String, val runs: IntArray, val prompt: Boolean, val marker: Boolean = false) @@ -47,6 +51,8 @@ data class GameAddress(val host: String, val port: Int, val tls: Boolean) object EngineClient { const val MAX_LINES = 2000 + private const val TAG = "EngineClient" + // About 300 ms of failed connects at EngineLink's fast retry interval private const val RESTART_AFTER_FAILED_ATTEMPTS = 6 @@ -140,9 +146,15 @@ object EngineClient { private val completions = HashMap Unit>() private val sendsAwaitingClear = ArrayDeque() + // The ids of the runLua requests that carry the app's Mudlet settings, whose luaResult is no script's + private val settingsRequests: MutableSet = ConcurrentHashMap.newKeySet() + // Reader-thread batches, drained on the main thread private val pendingLock = Any() private var pendingLines = ArrayList() + + // The text of the lines new to the engine too, not replayed, for the Announcer + private var pendingNews = ArrayList() private var pendingTail: UiLine? = null private var pendingTailSet = false private var pendingClear = false @@ -261,6 +273,21 @@ object EngineClient { fun disconnect(): Boolean = send(Requests.disconnect()) + /** Changes [setting], one of [Settings.mudletSettings], and gives the profile that is loaded the new value. */ + fun setMudletSetting(setting: Settings.Setting, value: T) { + val old = setting.value + setting.set(value) + if (setting.value != old) sendMudletSettings(listOf(setting)) + } + + // Lost when no profile is loaded or the link is down: the next profile to load, or the next attach, gets them all + private fun sendMudletSettings(settings: List>) { + if (status["profile"]?.endsWith(" loaded") != true) return + val id = nextRequestId++ + settingsRequests.add(id) + if (!link.send(Requests.runLua(settings.joinToString("\n") { it.setConfigLua() }, id))) settingsRequests.remove(id) + } + fun setScreenSize(columns: Int, rows: Int) { val size = columns to rows if (size == screenSize) return @@ -356,11 +383,13 @@ object EngineClient { when (message) { is EngineMessage.Line -> synchronized(pendingLock) { pendingLines.add(UiLine(message.text, message.runs, message.prompt)) + if (!message.replay) pendingNews.add(message.text) scheduleFlush() } is EngineMessage.Clear -> synchronized(pendingLock) { pendingLines.clear() + pendingNews.clear() pendingClear = true pendingTail = null pendingTailSet = true @@ -378,6 +407,9 @@ object EngineClient { is EngineMessage.Status -> if (message.key == "completion") { val id = (message.replyTo as? Number)?.toLong() if (id != null) post { completions.remove(id)?.invoke(message.value) } + } else if (message.key == "luaResult" && (message.replyTo as? Number)?.toLong()?.let(settingsRequests::remove) == true) { + // libmudlet answers "ran", the stand-in nothing, when the chunk ran + if (message.value != "ran" && message.value.isNotEmpty()) Log.w(TAG, "Mudlet did not take the app's settings: ${message.value}") } else { if (message.key == "sendError" && !replaying && inputSentAt > 0 && SystemClock.elapsedRealtime() - inputSentAt < SEND_ERROR_WINDOW_MS) { post { sendError = message.value } @@ -394,6 +426,8 @@ object EngineClient { is EngineMessage.CommandLine -> post { onCommandLine(message.text) } + is EngineMessage.Announce -> post { Announcer.announce(message.text, message.assertive) } + is EngineMessage.Notify -> post { notifyCount++ } is EngineMessage.Gap -> post { @@ -473,6 +507,7 @@ object EngineClient { private fun flush() { val newLines: List + val news: List val newStatus: Map val statusReset: Boolean val newTail: UiLine? @@ -486,6 +521,8 @@ object EngineClient { pendingTail = null pendingTailSet = false newLines = pendingLines + news = pendingNews + pendingNews = ArrayList() newStatus = pendingStatus statusReset = pendingStatusReset pendingLines = ArrayList() @@ -501,6 +538,9 @@ object EngineClient { if (restore != null) addLines(listOf(UiLine(restore, IntArray(0), false, marker = true))) addLines(newLines) if (tailSet) tail = newTail + if (news.isNotEmpty()) Announcer.newLines(news) + // Also when an attach resends it: a setting may have changed while the link was down + if (newStatus["profile"]?.endsWith(" loaded") == true) sendMudletSettings(Settings.mudletSettings) } private fun addLines(newLines: List) { 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 a8eaa46..860169a 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import org.mudlet.android.data.Settings +import org.mudlet.android.ui.game.Announcer import org.mudlet.android.ui.theme.MudletTheme class MainActivity : ComponentActivity() { @@ -18,6 +19,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) EngineClient.init(this) Settings.init(this) + Announcer.init(this) if (Build.VERSION.SDK_INT >= 33) askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) EngineClient.refreshServiceState() if (EngineClient.engineWanted && EngineClient.fgsState == "not running") EngineClient.startEngine() diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt new file mode 100644 index 0000000..f73036a --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt @@ -0,0 +1,150 @@ +package org.mudlet.android.ui.game + +import android.content.Context +import android.content.res.Resources +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.accessibility.AccessibilityManager +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.unit.dp +import org.mudlet.android.R +import org.mudlet.android.data.Settings + +/** Something for TalkBack to say. [serial] numbers them, so the same words twice are said twice. */ +class Announcement(val text: String, val assertive: Boolean, val serial: Long) + +/** + * What the app has TalkBack say, as Mudlet's desktop console does: new game lines, while TalkBack is on and "Read new + * text aloud" holds, and what a script's `announce()` asks for. Lines that arrive close together are read as one + * announcement, and a burst as its count and its last lines. [AnnouncementRegion] says [current]. Main thread only. + */ +object Announcer { + // Lines this close together are one announcement; a steady stream is still read every MAX_WAIT_MS + private const val QUIET_MS = 300L + private const val MAX_WAIT_MS = 2000L + + // A burst of more lines than BURST_LINES is read as its count and its last BURST_TAIL lines + private const val BURST_LINES = 5 + private const val BURST_TAIL = 3 + + // An announcement this soon after the last is added to it: the live region would show only the later one + private const val MERGE_MS = 200L + + /** The last announcement, which [AnnouncementRegion] shows; null before the first. */ + var current by mutableStateOf(null) + private set + + private var systemTouchExploration by mutableStateOf(false) + + /** Takes the place of the system's touch exploration (TalkBack) when not null: the tests' switch. */ + @VisibleForTesting + var touchExplorationOverride by mutableStateOf(null) + + /** TalkBack, or another screen reader that explores by touch, is on. */ + val touchExploration: Boolean get() = touchExplorationOverride ?: systemTouchExploration + + private val main = Handler(Looper.getMainLooper()) + private var resources: Resources? = null + private var saidAt = 0L + + // The lines waiting to be read: the last BURST_LINES of them, and how many there are + private val waiting = ArrayDeque() + private var waitingCount = 0 + private var waitingSince = 0L + + fun init(context: Context) { + if (resources != null) return + val app = context.applicationContext + resources = app.resources + val accessibility = app.getSystemService(AccessibilityManager::class.java) + systemTouchExploration = accessibility.isTouchExplorationEnabled + // Called on the main thread + accessibility.addTouchExplorationStateChangeListener { systemTouchExploration = it } + } + + /** Game lines as they arrive, not those replayed to a UI that attached. */ + fun newLines(lines: List) { + if (!reading()) return + for (line in lines) { + if (line.isBlank()) continue + if (waiting.size == BURST_LINES) waiting.removeFirst() + waiting.addLast(line) + waitingCount++ + } + if (waitingCount == 0) return + val now = SystemClock.uptimeMillis() + if (waitingSince == 0L) waitingSince = now + main.removeCallbacks(readWaiting) + main.postAtTime(readWaiting, minOf(now + QUIET_MS, waitingSince + MAX_WAIT_MS)) + } + + /** What a script's `announce()` asks for; [assertive] interrupts what TalkBack is saying. */ + fun announce(text: String, assertive: Boolean) = say(text, assertive) + + private fun reading() = touchExploration && Settings.readNewText.value + + private val readWaiting = Runnable { + val count = waitingCount + val lines = waiting.toList() + waiting.clear() + waitingCount = 0 + waitingSince = 0L + val res = resources + if (!reading() || count == 0 || res == null) return@Runnable + val text = if (count <= BURST_LINES) { + lines.joinToString("\n") + } else { + res.getQuantityString(R.plurals.announce_burst, count, count, lines.takeLast(BURST_TAIL).joinToString("\n")) + } + say(text, assertive = false) + } + + private fun say(text: String, assertive: Boolean) { + if (text.isBlank()) return + val now = SystemClock.uptimeMillis() + val last = current + current = if (last != null && now - saidAt < MERGE_MS) { + Announcement(last.text + "\n" + text, last.assertive || assertive, last.serial) + } else { + Announcement(text, assertive, (last?.serial ?: 0L) + 1) + } + saidAt = now + } +} + +/** + * Has TalkBack say [Announcer.current], through two live regions, each a node of one pixel that shows nothing: an + * announcement goes to the one the last did not use, so a repeat is a change TalkBack reads. Placed last in TalkBack's + * order, where swiping to it reads the last announcement again. + */ +@Composable +fun AnnouncementRegion(modifier: Modifier = Modifier) { + val announcement = Announcer.current + // Side by side: TalkBack is told only of nodes that others drawn over them do not cover + Row(modifier) { + for (slot in 0..1) { + val mine = announcement?.takeIf { it.serial % 2 == slot.toLong() } + Box( + Modifier.size(1.dp).semantics { + traversalIndex = 1f + liveRegion = if (mine?.assertive == true) LiveRegionMode.Assertive else LiveRegionMode.Polite + if (mine != null) contentDescription = mine.text + }, + ) + } + } +} diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt index 7895727..6cad013 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/Console.kt @@ -36,6 +36,9 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.rememberTextMeasurer @@ -66,6 +69,8 @@ private const val CELLS_MEASURED = 100 * bottom it follows new lines; scrolled up, it stays where it is and offers a button back to the bottom that counts the * lines that came meanwhile. [linesAdded] is EngineClient's count, which numbers the lines. * + * Each line is one node for TalkBack, whose text is the line's without its styles. + * * A two-finger pinch hands [onTextSize] a new size. [onScreenSize] gets the columns and rows that fit, once they have * held for a moment. */ @@ -144,7 +149,7 @@ fun Console( items(itemCount, key = { i -> if (i < lines.size) firstLine + i else TAIL_KEY }) { i -> val line = if (i < lines.size) lines[i] else tail ?: return@items val text = remember(line, colors) { ansiText(line, colors) } - Text(text, Modifier.fillMaxWidth(), color = colors.foreground, style = style) + Text(text, Modifier.fillMaxWidth().semantics { this.text = AnnotatedString(line.text) }, color = colors.foreground, style = style) } } if (!following) { diff --git a/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt index c35929b..b1d9bc7 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt @@ -1,25 +1,33 @@ package org.mudlet.android.ui.settings +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription @@ -30,6 +38,7 @@ import org.mudlet.android.R import org.mudlet.android.data.Settings import org.mudlet.android.ui.CONSOLE_BACKGROUND import org.mudlet.android.ui.CONSOLE_FOREGROUND +import org.mudlet.android.ui.EngineClient import org.mudlet.android.ui.MudletTopBar import kotlin.math.roundToInt @@ -42,10 +51,62 @@ fun SettingsScreen(onBack: () -> Unit) { verticalArrangement = Arrangement.spacedBy(24.dp), ) { TextSize(Settings.textSize) + ScreenReader() } } } +/** What TalkBack is told, and what games are: Mudlet's own settings, given to each profile that loads. */ +@Composable +private fun ScreenReader() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.settings_screen_reader), style = MaterialTheme.typography.titleMedium) + SwitchRow(Settings.readNewText, R.string.settings_read_new_text, R.string.settings_read_new_text_hint) + SwitchRow(Settings.advertiseScreenReader, R.string.settings_advertise_screen_reader, R.string.settings_advertise_screen_reader_hint) + BlankLines(Settings.blankLines) + } +} + +/** A switch the whole row turns, which TalkBack reads as [label], then [hint]. */ +@Composable +private fun SwitchRow(setting: Settings.BoolSetting, @StringRes label: Int, @StringRes hint: Int) { + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(setting.value, role = Role.Switch) { EngineClient.setMudletSetting(setting, it) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f).padding(end = 16.dp)) { + Text(stringResource(label), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(hint), style = MaterialTheme.typography.bodyMedium) + } + Switch(setting.value, onCheckedChange = null) + } +} + +/** Show, hide or replace with a space the blank lines a game sends: one radio button each, in a group. */ +@Composable +private fun BlankLines(setting: Settings.EnumSetting) { + Text(stringResource(R.string.settings_blank_lines), style = MaterialTheme.typography.bodyLarge) + Column(Modifier.selectableGroup()) { + for (choice in setting.entries) { + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp) + .selectable(choice == setting.value, role = Role.RadioButton) { EngineClient.setMudletSetting(setting, choice) }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(choice == setting.value, onClick = null) + Text(stringResource(blankLinesLabel(choice)), Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyLarge) + } + } + } +} + +@StringRes +private fun blankLinesLabel(choice: Settings.BlankLines) = when (choice) { + Settings.BlankLines.SHOW -> R.string.settings_blank_lines_show + Settings.BlankLines.HIDE -> R.string.settings_blank_lines_hide + Settings.BlankLines.SPACE -> R.string.settings_blank_lines_space +} + /** The console's text size: a slider, with buttons a step either way, and a sample at that size. */ @Composable private fun TextSize(setting: Settings.IntSetting) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 761b0ab..987bb94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,28 @@ Larger text You see a brass lantern here. + Screen reader + Read new text aloud + While TalkBack is on, the game\'s new text is read out as it arrives. + Tell games I use a screen reader + Games that ask are told, and some then send text laid out for screen readers. + + Blank lines from the game + Show them + Hide them + Replace them with a space + + + Tell games you use a screen reader? + Games that ask are told, and some then send text laid out for screen readers. You can change this in the settings. + Tell games + Don\'t tell + + + + %1$d new line: %2$s + %1$d new lines, ending: %2$s + Jump to the bottom diff --git a/docs/architecture.md b/docs/architecture.md index c11375e..ef50e4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ 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), the settings in `ui/settings/` and, in debug builds, the self-test in `ui/selftest/`), the renderer of game text (`ui/AnsiText.kt`), the theme (`ui/theme/`), the model of the engine (`ui/EngineClient.kt`) and the app's own settings (`data/Settings.kt`, in SharedPreferences). 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, the connection bar and the `Announcer`, which has TalkBack read new lines and `announce`), the settings in `ui/settings/` and, in debug builds, the self-test in `ui/selftest/`), the renderer of game text (`ui/AnsiText.kt`), the theme (`ui/theme/`), the model of the engine (`ui/EngineClient.kt`) and the app's own settings (`data/Settings.kt`, in SharedPreferences). 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 | | `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` | @@ -219,13 +219,14 @@ device shows, below the UI's debug hook: socket handshake; the engine notification's Stop, after which the next start restores nothing; a killed engine, after which the next one reconnects to the game (see "Restoring the session" below); and a TLS connect whose first bytes are a ClientHello, so Qt's backend loaded OpenSSL. -- `app/src/androidTest` runs the app against a fake engine in the test's process (`EngineClient`'s `connector`): the - way from the start screen to the game screen and the self-test and back, through the labels and click actions - TalkBack uses; the self-test's colours and styles of each run, in the semantics tree and in the pixels drawn; the - reattach to a new engine after one is lost, and to the same one; and the notification permission, asked on Android - 13 and later, on a fresh install. Each Compose test rule has the Accessibility Test Framework's checks on - (`enableAccessibilityChecks()`), which fail a test on a control with no label or a touch target under 48 dp; the - library needs API 34, so they run on the API 36 emulator only. +- `app/src/androidTest` runs the app against a fake engine in the test's process (`EngineClient`'s `connector`): the way + from the start screen to the game screen and the self-test and back, through the labels and click actions TalkBack + uses; the self-test's colours and styles of each run, in the semantics tree and in the pixels drawn; the reattach to a + new engine after one is lost, and to the same one; what TalkBack is given (new lines read as one announcement, + `announce`, each console line as its plain text) and the `setConfig` calls the screen reader settings send; and the + notification permission, asked on Android 13 and later, on a fresh install. Each Compose test rule has the + Accessibility Test Framework's checks on (`enableAccessibilityChecks()`), which fail a test on a control with no label + or a touch target under 48 dp; the library needs API 34, so they run on the API 36 emulator only. CI runs both on API 28, the app's minSdk, and on API 36. @@ -240,7 +241,6 @@ CI runs both on API 28, the app's minSdk, and on API 36. `linksChanged`). - The game screen and the self-test each send `setScreenSize` from their own console, so the one laid out last sets what a game that asks is told. -- The game screen's console draws in `consoleColors`; `announce` reaches the app, which does not use it yet. - No bundled fonts. - TLS is chosen when the profile starts (see "TLS" below). - A change `saveProfile` makes to the running profile takes effect at its next start, as Mudlet read its details then. @@ -728,6 +728,18 @@ Item 13 of the on-device checklist covers this. settings screen's text is not cut off. - `lua setBackgroundColor("main", 0, 0, 80)` turns the console's background dark blue; text in default colours keeps them. +18. Screen reader output, with TalkBack on, connected to a game with the libmudlet APK. + - On the first run with TalkBack on (clear the app's data first), the app asks whether to tell games you use a + screen reader; the answer shows in Settings as "Tell games I use a screen reader". + - New text is read as it arrives, without moving focus. A burst (`look` in a busy room, or + `lua for i = 1, 20 do echo("line " .. i .. "\n") end`) is read once, as its count and its last lines, not + queued line by line. With "Read new text aloud" off in Settings, nothing is read. + - `lua announce("polite")` is read after what TalkBack is saying; `lua announce("now", "importantall")` + interrupts it. The same announcement twice is read twice. + - Swiping through the console moves line by line, and each line is read as its text, without colour or style + names. Swiping past the connection bar reaches the last announcement. + - "Blank lines from the game": Hide them, then send a command whose answer has blank lines: they are gone from + the console, and TalkBack does not stop on them. ## Problems hit - **`QJsonDocument` sorts keys**, so `"t"` is not the first key. A test that From d3fc116a19db2cef5c53d082020d4892f1e4afb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 23:08:47 +0000 Subject: [PATCH 7/9] Review: replayed lines and the app's own luaResult are tested; Lua for every setting Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- .../kotlin/org/mudlet/android/ui/FakeEngine.kt | 9 ++++++--- .../org/mudlet/android/ui/ScreenReaderTest.kt | 16 +++++++++++++--- .../kotlin/org/mudlet/android/data/Settings.kt | 4 +++- .../kotlin/org/mudlet/android/ui/EngineClient.kt | 2 +- .../org/mudlet/android/ui/game/Announcer.kt | 10 ++++------ 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt index 20310d9..76199f7 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt @@ -129,9 +129,12 @@ class FakeEngine : AutoCloseable { fun clear() = send(JSONObject().put("t", "clear")) - /** [runs]: each [start, length, fg, bg, attributes], as docs/protocol.md's "Runs" says. */ - fun line(seq: Long, text: String, runs: List> = emptyList(), prompt: Boolean = false) = send( - JSONObject().put("t", "line").put("seq", seq).put("text", text).put("prompt", prompt) + /** + * [runs]: each [start, length, fg, bg, attributes], as docs/protocol.md's "Runs" says. [replay]: one of the + * lines an engine sends again to a UI that attached. + */ + fun line(seq: Long, text: String, runs: List> = emptyList(), prompt: Boolean = false, replay: Boolean = false) = send( + JSONObject().put("t", "line").put("seq", seq).put("text", text).put("prompt", prompt).put("replay", replay) .put("runs", JSONArray(runs.map { JSONArray(it) })), ) diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt index 42fbd64..4e45335 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt @@ -85,6 +85,10 @@ class ScreenReaderTest { assertEquals("no announcement while touch exploration is off", before, serial()) compose.runOnIdle { Announcer.touchExplorationOverride = true } + line("not read: replayed", replay = true) + SystemClock.sleep(QUIET_MS) + assertEquals("no announcement of a replayed line", before, serial()) + val burst = (1..8).map { "burst line $it" } burst.forEach { session.line(nextSeq++, it) } val expected = compose.activity.resources.getQuantityString(R.plurals.announce_burst, 8, 8, burst.takeLast(3).joinToString("\n")) @@ -126,11 +130,17 @@ class ScreenReaderTest { @Test fun theSettingsGoToTheProfileWithRunLua() { + session.status("luaResult", "a script's") session.status("profile", "Test loaded") + val all = session.receive("runLua") assertEquals( "setConfig(\"announceIncomingText\", true)\nsetConfig(\"advertiseScreenReader\", false)\nsetConfig(\"blankLinesBehaviour\", \"show\")", - session.receive("runLua").getString("code"), + all.getString("code"), ) + // The answer to the app's own runLua is no script's result + session.reply("luaResult", "ran", all.getLong("id")) + line("after the answer") + compose.runOnIdle { assertEquals("a script's", EngineClient.lastLuaResult) } activate(hasContentDescription(string(R.string.settings_title))) activate(hasText(string(R.string.settings_read_new_text)), scroll = true) @@ -169,8 +179,8 @@ class ScreenReaderTest { private fun serial() = compose.runOnIdle { Announcer.current?.serial ?: 0L } - private fun line(text: String, runs: List> = emptyList()) { - session.line(nextSeq++, text, runs) + private fun line(text: String, runs: List> = emptyList(), replay: Boolean = false) { + session.line(nextSeq++, text, runs, replay = replay) compose.waitUntil(WAIT_MS) { compose.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() } } diff --git a/app/src/main/kotlin/org/mudlet/android/data/Settings.kt b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt index c8cdd70..8caf55b 100644 --- a/app/src/main/kotlin/org/mudlet/android/data/Settings.kt +++ b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt @@ -77,7 +77,7 @@ object Settings { protected open fun normalise(v: T): T = v - protected open fun lua(v: T): String = throw UnsupportedOperationException("$key is not one of Mudlet's settings") + protected abstract fun lua(v: T): String protected abstract fun read(prefs: SharedPreferences): T @@ -88,6 +88,8 @@ object Settings { class IntSetting(key: String, default: Int, val range: IntRange) : Setting(key, default) { override fun normalise(v: Int) = v.coerceIn(range) + override fun lua(v: Int) = v.toString() + override fun read(prefs: SharedPreferences) = prefs.getInt(key, default) override fun write(editor: SharedPreferences.Editor, v: Int) { diff --git a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt index 31c27da..9480199 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt @@ -153,7 +153,7 @@ object EngineClient { private val pendingLock = Any() private var pendingLines = ArrayList() - // The text of the lines new to the engine too, not replayed, for the Announcer + // The text of the new lines, not those replayed, for the Announcer private var pendingNews = ArrayList() private var pendingTail: UiLine? = null private var pendingTailSet = false diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt index f73036a..57005bc 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt @@ -92,9 +92,6 @@ object Announcer { main.postAtTime(readWaiting, minOf(now + QUIET_MS, waitingSince + MAX_WAIT_MS)) } - /** What a script's `announce()` asks for; [assertive] interrupts what TalkBack is saying. */ - fun announce(text: String, assertive: Boolean) = say(text, assertive) - private fun reading() = touchExploration && Settings.readNewText.value private val readWaiting = Runnable { @@ -110,10 +107,11 @@ object Announcer { } else { res.getQuantityString(R.plurals.announce_burst, count, count, lines.takeLast(BURST_TAIL).joinToString("\n")) } - say(text, assertive = false) + announce(text, assertive = false) } - private fun say(text: String, assertive: Boolean) { + /** What a script's `announce()` asks for, or the lines waiting; [assertive] interrupts what TalkBack is saying. */ + fun announce(text: String, assertive: Boolean) { if (text.isBlank()) return val now = SystemClock.uptimeMillis() val last = current @@ -127,7 +125,7 @@ object Announcer { } /** - * Has TalkBack say [Announcer.current], through two live regions, each a node of one pixel that shows nothing: an + * Has TalkBack say [Announcer.current], through two live regions, each a node 1 dp square that shows nothing: an * announcement goes to the one the last did not use, so a repeat is a change TalkBack reads. Placed last in TalkBack's * order, where swiping to it reads the last announcement again. */ From 45495623dfd2f74dd288215eb54aee5d44a10703 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 23:15:25 +0000 Subject: [PATCH 8/9] Review: a short group of lines is tested; only Mudlet settings reach Mudlet - A group of lines up to the burst size is read as it is, without blank lines, and the test now checks it. - setMudletSetting sends setConfig only for a setting Mudlet keeps; Lua values default to toString(). - Announcer's resources are lateinit, so a missing init fails loudly. - Settings.reset() for the tests; comments say what the code does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- .../org/mudlet/android/ui/ScreenReaderTest.kt | 15 ++++++++++----- .../kotlin/org/mudlet/android/data/Settings.kt | 14 +++++++------- .../kotlin/org/mudlet/android/ui/EngineClient.kt | 6 +++--- .../org/mudlet/android/ui/game/Announcer.kt | 11 +++++------ .../mudlet/android/ui/settings/SettingsScreen.kt | 2 +- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt index 4e45335..1f640b6 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScreenReaderTest.kt @@ -73,12 +73,11 @@ class ScreenReaderTest { @After fun reset() = compose.runOnIdle { Announcer.touchExplorationOverride = null - for (setting in listOf(Settings.readNewText, Settings.advertiseScreenReader, Settings.screenReaderOffered)) setting.set(setting.default) - Settings.blankLines.set(Settings.blankLines.default) + for (setting in Settings.mudletSettings + Settings.screenReaderOffered) setting.reset() } @Test - fun aBurstOfNewLinesIsReadOnceWhileTouchExplorationIsOn() { + fun newLinesAreReadTogetherWhileTouchExplorationIsOn() { val before = serial() line("not read: touch exploration is off") SystemClock.sleep(QUIET_MS) @@ -89,17 +88,23 @@ class ScreenReaderTest { SystemClock.sleep(QUIET_MS) assertEquals("no announcement of a replayed line", before, serial()) + // A few lines are read as they are, without the blank one + for (text in listOf("first of two", "", "second of two")) session.line(nextSeq++, text) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(announcement("first of two\nsecond of two", LiveRegionMode.Polite)).fetchSemanticsNodes().size == 1 } + SystemClock.sleep(QUIET_MS) + assertEquals("the lines are one announcement", before + 1, serial()) + val burst = (1..8).map { "burst line $it" } burst.forEach { session.line(nextSeq++, it) } val expected = compose.activity.resources.getQuantityString(R.plurals.announce_burst, 8, 8, burst.takeLast(3).joinToString("\n")) compose.waitUntil(WAIT_MS) { compose.onAllNodes(announcement(expected, LiveRegionMode.Polite)).fetchSemanticsNodes().size == 1 } SystemClock.sleep(QUIET_MS) - assertEquals("the burst is one announcement", before + 1, serial()) + assertEquals("the burst is one announcement", before + 2, serial()) compose.runOnIdle { Settings.readNewText.set(false) } line("not read: the setting is off") SystemClock.sleep(QUIET_MS) - assertEquals("no announcement with the setting off", before + 1, serial()) + assertEquals("no announcement with the setting off", before + 2, serial()) } @Test diff --git a/app/src/main/kotlin/org/mudlet/android/data/Settings.kt b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt index 8caf55b..d736d4e 100644 --- a/app/src/main/kotlin/org/mudlet/android/data/Settings.kt +++ b/app/src/main/kotlin/org/mudlet/android/data/Settings.kt @@ -2,6 +2,7 @@ package org.mudlet.android.data import android.content.Context import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -34,8 +35,8 @@ object Settings { private val all = listOf(textSize, readNewText, advertiseScreenReader, blankLines, screenReaderOffered) /** - * The settings Mudlet keeps for each profile too. The app keeps its own copy, as `runLua` returns no values, and - * gives each profile that loads the app's values. + * The settings Mudlet keeps for each profile too. The app keeps its own copy, as libmudlet's `luaResult` says only + * whether the Lua ran, and gives each profile that loads the app's values. */ val mudletSettings = all.filter { it.mudlet } @@ -68,6 +69,9 @@ object Settings { prefs?.edit { write(this, v) } } + @VisibleForTesting + fun reset() = set(default) + /** The Lua that gives Mudlet's profile this setting's value. */ fun setConfigLua(): String = "setConfig(\"$key\", ${lua(value)})" @@ -77,7 +81,7 @@ object Settings { protected open fun normalise(v: T): T = v - protected abstract fun lua(v: T): String + protected open fun lua(v: T): String = v.toString() protected abstract fun read(prefs: SharedPreferences): T @@ -88,8 +92,6 @@ object Settings { class IntSetting(key: String, default: Int, val range: IntRange) : Setting(key, default) { override fun normalise(v: Int) = v.coerceIn(range) - override fun lua(v: Int) = v.toString() - override fun read(prefs: SharedPreferences) = prefs.getInt(key, default) override fun write(editor: SharedPreferences.Editor, v: Int) { @@ -98,8 +100,6 @@ object Settings { } class BoolSetting(key: String, default: Boolean, mudlet: Boolean = false) : Setting(key, default, mudlet) { - override fun lua(v: Boolean) = v.toString() - override fun read(prefs: SharedPreferences) = prefs.getBoolean(key, default) override fun write(editor: SharedPreferences.Editor, v: Boolean) { diff --git a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt index 9480199..f7a371f 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt @@ -273,11 +273,11 @@ object EngineClient { fun disconnect(): Boolean = send(Requests.disconnect()) - /** Changes [setting], one of [Settings.mudletSettings], and gives the profile that is loaded the new value. */ + /** Changes [setting] and, if it is one of [Settings.mudletSettings], gives the profile that is loaded the new value. */ fun setMudletSetting(setting: Settings.Setting, value: T) { val old = setting.value setting.set(value) - if (setting.value != old) sendMudletSettings(listOf(setting)) + if (setting.mudlet && setting.value != old) sendMudletSettings(listOf(setting)) } // Lost when no profile is loaded or the link is down: the next profile to load, or the next attach, gets them all @@ -539,7 +539,7 @@ object EngineClient { addLines(newLines) if (tailSet) tail = newTail if (news.isNotEmpty()) Announcer.newLines(news) - // Also when an attach resends it: a setting may have changed while the link was down + // Also when an attach resends the profile status: a setting may have changed while the link was down if (newStatus["profile"]?.endsWith(" loaded") == true) sendMudletSettings(Settings.mudletSettings) } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt index 57005bc..fbfeb70 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/Announcer.kt @@ -58,7 +58,7 @@ object Announcer { val touchExploration: Boolean get() = touchExplorationOverride ?: systemTouchExploration private val main = Handler(Looper.getMainLooper()) - private var resources: Resources? = null + private lateinit var resources: Resources private var saidAt = 0L // The lines waiting to be read: the last BURST_LINES of them, and how many there are @@ -67,12 +67,12 @@ object Announcer { private var waitingSince = 0L fun init(context: Context) { - if (resources != null) return + if (::resources.isInitialized) return val app = context.applicationContext resources = app.resources val accessibility = app.getSystemService(AccessibilityManager::class.java) systemTouchExploration = accessibility.isTouchExplorationEnabled - // Called on the main thread + // Called on the main thread, so it may set Compose state accessibility.addTouchExplorationStateChangeListener { systemTouchExploration = it } } @@ -100,12 +100,11 @@ object Announcer { waiting.clear() waitingCount = 0 waitingSince = 0L - val res = resources - if (!reading() || count == 0 || res == null) return@Runnable + if (!reading()) return@Runnable val text = if (count <= BURST_LINES) { lines.joinToString("\n") } else { - res.getQuantityString(R.plurals.announce_burst, count, count, lines.takeLast(BURST_TAIL).joinToString("\n")) + resources.getQuantityString(R.plurals.announce_burst, count, count, lines.takeLast(BURST_TAIL).joinToString("\n")) } announce(text, assertive = false) } diff --git a/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt index b1d9bc7..7fa81e4 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/settings/SettingsScreen.kt @@ -56,7 +56,7 @@ fun SettingsScreen(onBack: () -> Unit) { } } -/** What TalkBack is told, and what games are: Mudlet's own settings, given to each profile that loads. */ +/** The screen reader settings, which are Mudlet's own: each profile that loads is given the app's values. */ @Composable private fun ScreenReader() { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { From 64384826e2dbe53a07f1037cf89279aec28cb1b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Nov 2026 02:52:19 +0000 Subject: [PATCH 9/9] Host tests: wait for the fake game to count a connection the engine reported The engine reports "connected" as soon as the kernel has made the connection, which can be before fake_telnet's accept thread returns from accept() and counts it. profile_checks read the count straight after the "connected" frame, so "connects it again, disconnected, without closing it" (and, more rarely, "the profile switched to is connected to its game") failed now and then. Both wait, up to the test's timeout, for the count to rise, and print the counts. The restore test's one positive count, read right after "reconnected", waits too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016ujaeFyaqXXDx8xjoThE6J --- native/tests/profile_checks.py | 18 +++++++++++++++--- native/tests/test_mudlet_restore.py | 14 +++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/native/tests/profile_checks.py b/native/tests/profile_checks.py index 068ba4b..010363c 100644 --- a/native/tests/profile_checks.py +++ b/native/tests/profile_checks.py @@ -1,5 +1,6 @@ """The profile requests (listProfiles, saveProfile, deleteProfile, openProfile) through the protocol, as both host tests run them: the stand-in's (test_engine.py) and libmudlet's (test_mudlet_engine.py).""" +import time def profile(name, port, login="", auto_reconnect=False): @@ -30,6 +31,14 @@ def wait_since(mark, pred): return m return c.wait_for(pred, timeout, seen) + def wait_connections(beyond): + """game_connections(), waiting up to the timeout for it to pass beyond: the engine reports "connected" once the + kernel has made the connection, which can be before the game's accept thread counts it.""" + deadline = time.monotonic() + timeout + while game_connections() <= beyond and time.monotonic() < deadline: + time.sleep(0.01) + return game_connections() + m = ask({"t": "listProfiles"}) check(m["t"] == "profiles" and "Alpha" not in names(m), f"listProfiles answers with the profiles: {names(m)}") @@ -73,7 +82,8 @@ def wait_since(mark, pred): check(m["t"] == "profiles" and names(m)[:2] == ["Beta", "Alpha"], f"openProfile switches profiles: {names(m)}") wait_since(mark, is_status("profile", "Beta loaded")) wait_since(mark, is_status("connection", "connected")) - check(game_connections() > connections, "the profile switched to is connected to its game") + count = wait_connections(connections) + check(count > connections, f"the profile switched to is connected to its game: {count} connections, {connections} before") m = ask({"t": "deleteProfile", "name": "alpha"}) check(m["t"] == "profiles" and "Alpha" not in names(m) and names(m)[:1] == ["Beta"], @@ -96,8 +106,10 @@ def wait_since(mark, pred): m = ask({"t": "openProfile", "name": "Beta"}) check(m["t"] == "profiles", f"openProfile of the running profile: {m}") wait_since(mark, is_status("connection", "connected")) - check(game_connections() > connections and not [x for x in seen[mark:] if is_status("profile", "Beta closing")(x)], - "connects it again, disconnected, without closing it") + closed = [x for x in seen[mark:] if is_status("profile", "Beta closing")(x)] + count = wait_connections(connections) + check(count > connections and not closed, + f"connects it again, disconnected, without closing it: {count} connections, {connections} before; {closed}") return seen diff --git a/native/tests/test_mudlet_restore.py b/native/tests/test_mudlet_restore.py index dca0dcb..d179baa 100644 --- a/native/tests/test_mudlet_restore.py +++ b/native/tests/test_mudlet_restore.py @@ -171,8 +171,15 @@ def start_game(port): return proc -def accepted_since(t): - return sum(1 for a in fake_telnet.ACCEPTED if a >= t) +def accepted_since(t, at_least=0): + """The game's connections since t. With at_least, waits up to 5 s for that many: the engine reports a connection + once the kernel has made it, which can be before the game's accept thread counts it.""" + deadline = time.monotonic() + 5 + while True: + n = sum(1 for a in fake_telnet.ACCEPTED if a >= t) + if n >= at_least or time.monotonic() >= deadline: + return n + time.sleep(0.01) def restarted_after_kill(port): @@ -196,7 +203,8 @@ def restarted_after_kill(port): check(value == f"reconnected {target}", f"the restore reports the reconnect: {value}") check(f"restore: reconnecting {target}\n" in e.log(), "and reported reconnecting first") check(e.wait_status("profile", lambda v: v.endswith("loaded")) == f"{PROFILE} loaded", "the profile was loaded again") - check(accepted_since(t0) == 2, f"the game saw one new connection: {accepted_since(t0)} in all") + count = accepted_since(t0, at_least=2) + check(count == 2, f"the game saw one new connection: {count} in all") e.send({"t": "send", "text": "echo after the restore"}) e.wait(lambda m: m["t"] in ("line", "tail") and m["text"] == "You said: after the restore") check(True, f"the restored profile takes commands; engine start to reconnected {reconnect_ms:.0f} ms")