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..4c864dc --- /dev/null +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/TextSizeTest.kt @@ -0,0 +1,235 @@ +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. 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 + for (node in compose.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult)).fetchSemanticsNodes()) { + val results = mutableListOf() + node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results) + for (result in results) { + 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()) { + 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..2cd12c4 --- /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, in [all] so that it loads, 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..c3585c8 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,12 @@ 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] + // 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 ?: 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 0609a23..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,7 +120,16 @@ object EngineClient { @Volatile private var attached = false - @Volatile private var viewWidthPx = 0 + /** 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 @@ -188,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) @@ -199,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 { @@ -215,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) @@ -234,9 +261,11 @@ 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) { + val size = columns to rows + if (size == screenSize) return + screenSize = size + if (attached) link.send(Requests.setScreenSize(columns, rows)) } fun runPings(count: Int = 300) { @@ -289,8 +318,9 @@ 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)) + 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 if (!sameInstance) resetStatus() post { @@ -343,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 5454315..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,31 +29,79 @@ 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 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 +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 + +// 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. [onWidth] gets the console's width in pixels. + * 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, onWidth: (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 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) } @@ -83,17 +134,17 @@ fun Console(lines: List, tail: UiLine?, linesAdded: Long, onWidth: (Int) } } - Box(modifier.background(CONSOLE_BACKGROUND)) { + Box(modifier.background(colors.background).pinchToZoom({ latestTextSize }, { latestOnTextSize(it) })) { LazyColumn( - Modifier.fillMaxSize().onSizeChanged { onWidth(it.width) }, + Modifier.fillMaxSize().onSizeChanged { consoleSize = it }, 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) + val text = remember(line, colors) { ansiText(line, colors) } + Text(text, Modifier.fillMaxWidth(), color = colors.foreground, style = style) } } if (!following) { @@ -112,3 +163,36 @@ fun Console(lines: List, tail: UiLine?, linesAdded: Long, onWidth: (Int) } } } + +/** 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 1635337..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 @@ -1,30 +1,77 @@ 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) } + } + } + 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()) { - Console(c.lines, c.tail, c.linesAdded, onWidth = c::setViewWidth, 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/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/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 2e7aaf5..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… @@ -53,7 +67,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..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` | @@ -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. +- 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. @@ -713,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 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..9b64284 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,61 @@ 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) + # 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) + 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()