diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6acd6b6..e70f17f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ What the app is and how it is built is in [`README.md`](README.md) and [`docs/ar |---|---| | `app/` | `:app`, the UI process: Kotlin and Compose, no Qt | | `engine-service/` | `:engine-service`: `EngineService` in the `:engine` process, and the Qt libraries `scripts/build-apk.sh` stages into `engine-service/qtdeploy/` | -| `protocol/` | `:protocol`, plain Kotlin/JVM: the socket protocol and `EngineLink`, with the JVM tests | +| `protocol/` | `:protocol`, plain Kotlin/JVM: the socket protocol and `EngineLink`, the script windows' state and layout, what a label draws, with the JVM tests | | `native/` | C++: the engine host (`enginehost/`), the engine interface (`api/`), the stand-in engine (`standin/`), the host tests (`tests/`); `third_party/` is vendored Lua 5.1.5 | | `scripts/` | Builds (APK, release, OpenSSL, libmudlet and its dependencies), `check-apk.sh` and `lint.sh` | | `docs/` | [`architecture.md`](docs/architecture.md), [`protocol.md`](docs/protocol.md), [`ptb.md`](docs/ptb.md), [`decisions/`](docs/decisions), [`foundation-checklist.md`](docs/foundation-checklist.md), [`roadmap.md`](docs/roadmap.md) | diff --git a/README.md b/README.md index 0b42fb1..2b0e93b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ behind "Debug", the engine's start times, heartbeat, process ids and buttons tha |---|---| | [`app/`](app) | `:app`, the UI process: Compose, the app's screens and, in debug builds, the self-test | | [`engine-service/`](engine-service) | `:engine-service`, an Android library: `EngineService` in the `:engine` process, its notifications, the Qt glue, and the Qt libraries staged for the APK | -| [`protocol/`](protocol) | `:protocol`, plain Kotlin/JVM: the socket protocol's wire format and `EngineLink`, the client that reconnects to the engine, and their tests | +| [`protocol/`](protocol) | `:protocol`, plain Kotlin/JVM: the socket protocol's wire format and `EngineLink`, the client that reconnects to the engine, the script windows' state and layout, what a label draws, and their tests | | [`native/`](native) | C++: the engine host (`enginehost/`: `main()`, the socket bridge, libmudlet's adapter, the Android glue), the engine interface (`api/`), the stand-in engine (`standin/`), and the host tests (`tests/`) | | [`scripts/`](scripts) | Building the APK, and libmudlet and its dependencies for Android and the host | | [`docs/`](docs) | [`architecture.md`](docs/architecture.md), [`protocol.md`](docs/protocol.md), [`ptb.md`](docs/ptb.md), [`decisions/`](docs/decisions), [`roadmap.md`](docs/roadmap.md) | diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt index 92ffcec..19032e9 100644 --- a/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/FakeEngine.kt @@ -11,6 +11,7 @@ import java.io.DataInputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.util.Base64 import java.util.UUID import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit @@ -100,7 +101,7 @@ class FakeEngine : AutoCloseable { val hello: JSONObject init { - socket.soTimeout = 10_000 + socket.soTimeout = READ_TIMEOUT_MS hello = readFrame(input) if (hello.optString("t") != "hello") throw IOException("the UI began with $hello") send(JSONObject().put("t", "hello").put("v", 1).put("pid", FAKE_PID).put("instance", instance)) @@ -127,6 +128,29 @@ class FakeEngine : AutoCloseable { throw AssertionError("the UI sent no '$type' within $timeoutMs ms") } + /** + * The last request of type [type] the UI sent before it went quiet for [quietMs]: what it settled on, where it + * may first have sent what it held from before, as ScriptWindowsClient sends a new engine the last size. + */ + fun receiveLast(type: String, quietMs: Int = 1_000): JSONObject { + var last = receive(type) + socket.soTimeout = quietMs + try { + while (true) { + val frame = try { + readFrame(input) + } catch (_: IOException) { + break + } + received.add(frame) + if (frame.optString("t") == type) last = frame + } + } finally { + socket.soTimeout = READ_TIMEOUT_MS + } + return last + } + fun clear() = send(JSONObject().put("t", "clear")) /** [runs]: each [start, length, fg, bg, attributes], as docs/protocol.md's "Runs" says. */ @@ -151,6 +175,46 @@ class FakeEngine : AutoCloseable { fun synced(seq: Long) = send(JSONObject().put("t", "synced").put("seq", seq)) + /** [windows] as [window] makes them; [removed]: each the name and the kind of a window that went. */ + fun windows(windows: List, full: Boolean = false, removed: List> = emptyList()) = send( + JSONObject().put("t", "windows").put("full", full).put("windows", JSONArray(windows)) + .put("removed", JSONArray(removed.map { (name, kind) -> JSONObject().put("name", name).put("kind", kind) })), + ) + + /** The last rows of the console [window], [rows] of them up to its last row, without colours. */ + fun windowText(window: String, rows: List, lineCount: Int = rows.size) = send( + JSONObject().put("t", "windowText").put("window", window).put("lineCount", lineCount).put("first", lineCount - rows.size) + .put("lines", JSONArray(rows.map { JSONObject().put("text", it).put("runs", JSONArray()).put("prompt", false).put("wrapped", false) })), + ) + + fun mainArea(w: Int, h: Int, columns: Int, rows: Int, textX: Int, textY: Int, textW: Int, textH: Int) = send( + JSONObject().put("t", "mainArea").put("w", w).put("h", h).put("columns", columns).put("rows", rows) + .put("textX", textX).put("textY", textY).put("textW", textW).put("textH", textH), + ) + + fun windowCommandLine(name: String, text: String) = send(JSONObject().put("t", "windowCommandLine").put("name", name).put("text", text)) + + /** What the label [name] shows (docs/protocol.md, `label`): [text] as rich text, or the file [image]. */ + fun label( + name: String, + text: String = "", + image: String = "", + toolTip: String = "", + accessibleName: String = "", + accessibleButton: Boolean = false, + styleSheet: String = "", + ) = send( + JSONObject().put("t", "label").put("name", name).put("text", text).put("image", image).put("svg", false) + .put("movie", "").put("moviePaused", false).put("movieFrame", -1).put("movieSpeed", 100).put("toolTip", toolTip) + .put("clickThrough", false).put("accessibleName", accessibleName).put("accessibleButton", accessibleButton) + .put("styleSheet", styleSheet), + ) + + /** The answer to the `getImage` whose id was [re]: [png], a PNG of [w] by [h] pixels. */ + fun image(path: String, png: ByteArray, w: Int, h: Int, re: Any) = send( + JSONObject().put("t", "image").put("path", path).put("png", Base64.getEncoder().encodeToString(png)).put("w", w).put("h", h).put("re", re), + ) + /** As the engine process dying: the UI sees the connection end. */ override fun close() { // shutdownInput wakes the UI's reader at once, where close alone may leave it blocked @@ -167,6 +231,33 @@ class FakeEngine : AutoCloseable { const val FAKE_PID = 4242 const val FIRST_INSTANCE = "fake-1" + // How long a read waits for the UI + const val READ_TIMEOUT_MS = 10_000 + + /** + * A shown window of a `windows` message (docs/protocol.md): a mini console, unless told, in the main window at + * [x], [y] in Mudlet's pixels. A console has [columns] and [rows]; [dockArea] puts a user window in a dock. + */ + fun window( + name: String, + kind: String = "miniConsole", + x: Int = 0, + y: Int = 0, + w: Int = 100, + h: Int = 100, + columns: Int = 40, + rows: Int = 10, + parent: String = "", + parentKind: String = "", + dockArea: String? = null, + ): JSONObject { + val window = JSONObject().put("name", name).put("kind", kind).put("parent", parent).put("parentKind", parentKind) + .put("x", x).put("y", y).put("w", w).put("h", h).put("visible", true).put("shown", true).put("stack", 0) + if (kind == "miniConsole" || kind == "userWindow") window.put("columns", columns).put("rows", rows) + if (dockArea != null) window.put("inDock", true).put("dockArea", dockArea).put("dockFloating", false).put("dockTitle", name) + return window + } + private fun readFrame(input: DataInputStream): JSONObject { val n = input.readInt() val payload = ByteArray(n) diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/ScriptLabelsTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScriptLabelsTest.kt new file mode 100644 index 0000000..02faa61 --- /dev/null +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScriptLabelsTest.kt @@ -0,0 +1,288 @@ +package org.mudlet.android.ui + +import android.Manifest +import android.graphics.Bitmap +import android.os.Build +import androidx.annotation.StringRes +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasAnyAncestor +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.longClick +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.tryPerformAccessibilityChecks +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import org.json.JSONObject +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.protocol.LayoutMode +import org.mudlet.android.protocol.POINTER_LEFT +import org.mudlet.android.protocol.POINTER_RIGHT +import java.io.ByteArrayOutputStream +import kotlin.math.abs + +/** + * The labels a profile's scripts draw (docs/phase-f.md, "Labels" and "Gauges"), against a fake engine: a label's text + * at its scaled place, a clickable label as a button whose tap reaches the engine as `pointer` events, its 48 dp touch + * bounds, a long press as the right button with the tool tip, a drag, a gauge read as its progress, a link's command, + * and the images the engine is asked for. + */ +@RunWith(AndroidJUnit4::class) +class ScriptLabelsTest { + 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 + + @Before + fun openTheGameScreen() { + if (Build.VERSION.SDK_INT >= 34) compose.enableAccessibilityChecks() + // A new engine instance for each test, so no window or label of an earlier test is left + val first = fake.engine.nextSession() + fake.engine.instance = "$INSTANCE-${System.nanoTime()}" + first.close() + session = fake.engine.nextSession() + session.clear() + session.line(1, "the main console") + session.synced(1) + activate(hasText(string(R.string.profiles_open_game))) + compose.onNodeWithText("the main console").assertIsDisplayed() + // Mudlet's 800 by 600 main window is all text, so the console's area is Mudlet's main window, scaled + compose.runOnIdle { ScriptWindowsClient.layoutMode = LayoutMode.FITTED } + session.mainArea(800, 600, 100, 40, 0, 0, 800, 600) + } + + @After + fun automaticLayout() { + compose.runOnIdle { ScriptWindowsClient.layoutMode = LayoutMode.AUTO } + } + + @Test + fun aLabelsTextIsDrawnWhereTheScriptPutItAndReadAfterTheConsole() { + session.windows(listOf(label("status", x = 400, y = 300, w = 400, h = 300)), full = true) + session.label("status", text = "HP full", styleSheet = "qproperty-alignment: 'AlignLeft | AlignTop'") + compose.waitUntil(WAIT_MS) { compose.onAllNodes(hasText("HP full")).fetchSemanticsNodes().isNotEmpty() } + + // The bottom right quarter of Mudlet's main window is the console area's + val area = consoleArea() + val text = bounds(hasText("HP full")) + assertNear("left", area.left + area.width / 2, text.left) + assertNear("top", area.top + area.height / 2, text.top) + assertNear("width", area.width / 2, text.width) + compose.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun aClickableLabelIsAButtonWhoseClickTheEngineGetsAsATap() { + session.windows(listOf(label("go", x = 0, y = 0, w = 200, h = 100)), full = true) + session.label("go", text = "North", accessibleName = "Go north", accessibleButton = true) + val button = hasContentDescription("Go north") and hasClickAction() + compose.waitUntil(WAIT_MS) { compose.onAllNodes(button).fetchSemanticsNodes().isNotEmpty() } + compose.onNode(button).assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + compose.onNode(button).assert(SemanticsMatcher.expectValue(SemanticsProperties.TraversalIndex, 1f)) + + // TalkBack's double tap clicks the label's middle, in its own pixels + activate(button) + val tap = tap() + assertEquals(listOf("enter", "press", "release"), tap.map { it.getString("type") }) + for (event in tap) { + assertEquals("go", event.getString("window")) + assertEquals(100.0, event.getDouble("x"), 0.0) + assertEquals(50.0, event.getDouble("y"), 0.0) + } + assertEquals(POINTER_LEFT, tap[1].getInt("button")) + assertEquals(POINTER_LEFT, tap[1].getInt("buttons")) + assertEquals(0, tap[2].optInt("buttons")) + } + + @Test + fun aSmallClickableLabelIsTouchedWithin48dpAroundItAndTheEngineGetsAPointInsideIt() { + session.windows(listOf(label("tiny", x = 395, y = 295, w = 10, h = 10)), full = true) + session.label("tiny", accessibleName = "Tiny", accessibleButton = true, styleSheet = "background-color: red") + val button = hasContentDescription("Tiny") and hasClickAction() + compose.waitUntil(WAIT_MS) { compose.onAllNodes(button).fetchSemanticsNodes().isNotEmpty() } + + val touch = bounds(button) + val min = with(compose.density) { 48.dp.toPx() } + assertTrue("touch width ${touch.width} < $min", touch.width >= min - 1) + assertTrue("touch height ${touch.height} < $min", touch.height >= min - 1) + compose.onRoot().tryPerformAccessibilityChecks() + + // A finger at the edge of the touch bounds, outside the label, still reaches it at a point inside it + compose.onNode(button).performTouchInput { click(topLeft) } + val tap = tap() + assertEquals(listOf("press", "release"), tap.map { it.getString("type") }.filter { it != "enter" }) + for (event in tap) { + assertTrue("x ${event.getDouble("x")}", event.getDouble("x") in 0.0..9.0) + assertTrue("y ${event.getDouble("y")}", event.getDouble("y") in 0.0..9.0) + } + } + + @Test + fun aLongPressIsTheRightButtonAndShowsAToolTipSetAfterTheLabelWasDrawn() { + session.windows(listOf(label("menu", w = 200, h = 100)), full = true) + session.label("menu", accessibleButton = true, styleSheet = "background-color: navy") + compose.waitUntil(WAIT_MS) { compose.onAllNodes(hasContentDescription("menu") and hasClickAction()).fetchSemanticsNodes().isNotEmpty() } + // The tool tip names the button once it comes + session.label("menu", toolTip = "Open the menu", accessibleButton = true, styleSheet = "background-color: navy") + val button = hasContentDescription("Open the menu") and hasClickAction() + compose.waitUntil(WAIT_MS) { compose.onAllNodes(button).fetchSemanticsNodes().isNotEmpty() } + + compose.onNode(button).performTouchInput { longClick(center) } + val press = tap() + assertEquals(listOf("press", "release"), press.map { it.getString("type") }.filter { it != "enter" }) + assertEquals(POINTER_RIGHT, press.single { it.getString("type") == "press" }.getInt("button")) + assertEquals(POINTER_RIGHT, press.last().getInt("button")) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(hasText("Open the menu")).fetchSemanticsNodes().isNotEmpty() } + } + + @Test + fun aDragAcrossALabelHoldsTheLeftButtonAsItMoves() { + session.windows(listOf(label("volume", w = 200, h = 100)), full = true) + session.label("volume", accessibleName = "Volume", accessibleButton = true, styleSheet = "background-color: teal") + val slider = hasContentDescription("Volume") and hasClickAction() + compose.waitUntil(WAIT_MS) { compose.onAllNodes(slider).fetchSemanticsNodes().isNotEmpty() } + + compose.onNode(slider).performTouchInput { + down(centerLeft + Offset(10f, 0f)) + moveTo(center) + moveTo(centerRight - Offset(10f, 0f)) + up() + } + val drag = tap().filter { it.getString("type") != "enter" } + assertEquals("press", drag.first().getString("type")) + assertEquals(POINTER_LEFT, drag.first().getInt("button")) + val moves = drag.drop(1).dropLast(1) + assertTrue("no move in ${drag.map { it.getString("type") }}", moves.isNotEmpty()) + for (move in moves) { + assertEquals("move", move.getString("type")) + assertEquals(POINTER_LEFT, move.getInt("buttons")) + } + // From near the label's left edge to near its right, in its own 200 pixels + assertTrue("press x ${drag.first().getDouble("x")}", drag.first().getDouble("x") < 50.0) + assertTrue("release x ${drag.last().getDouble("x")}", drag.last().getDouble("x") > 150.0) + assertEquals(POINTER_LEFT, drag.last().getInt("button")) + } + + @Test + fun aGaugeIsReadAsItsProgressWithItsTextAsItsState() { + session.windows( + listOf(label("hp_back", w = 200, h = 20), label("hp_front", w = 50, h = 20), label("hp_text", w = 200, h = 20)), + full = true, + ) + session.label("hp_back", styleSheet = "background-color: #400000") + session.label("hp_front", styleSheet = "background-color: red") + session.label("hp_text", text = "HP 25/100") + val gauge = SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, ProgressBarRangeInfo(25f, 0f..100f)) + compose.waitUntil(WAIT_MS) { + compose.onAllNodes(gauge and SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "HP 25/100")) + .fetchSemanticsNodes().isNotEmpty() + } + compose.onNode(gauge).assert(hasContentDescription("hp")) + // The text label is the gauge's state, not a node of its own + assertTrue(compose.onAllNodes(hasText("HP 25/100")).fetchSemanticsNodes().isEmpty()) + compose.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun aLinkInALabelIsAnActionThatHasTheEngineRunItsCommand() { + session.windows(listOf(label("nav", w = 300, h = 40)), full = true) + session.label("nav", text = """Go north""", accessibleName = "Directions") + val follow = string(R.string.label_follow_link, "north") + val nav = hasContentDescription("Directions") + compose.waitUntil(WAIT_MS) { compose.onAllNodes(nav).fetchSemanticsNodes().isNotEmpty() } + compose.onRoot().tryPerformAccessibilityChecks() + + val action = compose.onNode(nav).fetchSemanticsNode().config[SemanticsActions.CustomActions].single { it.label == follow } + compose.runOnIdle { action.action() } + // Mudlet's send: link expands aliases as typed input does + assertEquals("expandAlias(\"north\")", session.receive("runLua").getString("code")) + } + + @Test + fun anImageLabelAsksTheEngineForItsImageOnce() { + session.windows(listOf(label("key", w = 100, h = 100)), full = true) + session.label("key", image = "/data/key.png", accessibleName = "Map key") + // The test's clock recomposes only while the test waits on Compose, so the label is drawn, and asks, first + compose.waitUntil(WAIT_MS) { compose.onAllNodes(hasContentDescription("Map key")).fetchSemanticsNodes().isNotEmpty() } + val request = session.receive("getImage") + assertEquals("/data/key.png", request.getString("path")) + assertTrue(!request.has("w") && !request.has("frame")) + + session.image("/data/key.png", png(8, 4), 8, 4, request.get("id")) + compose.waitUntil(WAIT_MS) { ScriptWindowsClient.images[ScriptWindowsClient.ImageRequest("/data/key.png")]?.width == 8 } + compose.onRoot().tryPerformAccessibilityChecks() + } + + // The pointer events of one tap, up to its release + private fun tap(): List { + val events = ArrayList() + do { + events.add(session.receive("pointer")) + } while (events.last().getString("type") != "release") + return events + } + + private fun label(name: String, x: Int = 0, y: Int = 0, w: Int, h: Int) = FakeEngine.window(name, kind = "label", x = x, y = y, w = w, h = h) + + private fun png(w: Int, h: Int): ByteArray { + val out = ByteArrayOutputStream() + Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, out) + return out.toByteArray() + } + + // The main console: the one list that is not in a script window, which the fitted layout gives all of Mudlet's + // main window here + private fun consoleArea(): Rect = bounds(hasScrollToIndexAction() and !hasAnyAncestor(hasContentDescription(string(R.string.window_description, ""), substring = true))) + + private fun string(@StringRes id: Int, vararg args: Any) = compose.activity.getString(id, *args) + + private fun bounds(matcher: SemanticsMatcher): Rect = compose.onNode(matcher).fetchSemanticsNode().boundsInRoot + + private fun assertNear(what: String, expected: Float, actual: Float) = assertTrue("$what: $actual, not $expected", abs(expected - actual) <= PIXELS) + + /** 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 INSTANCE = "fake-labels" + + // Rounding to whole pixels, and the text's own padding + const val PIXELS = 3f + const val WAIT_MS = 10_000L + const val TIMEOUT_MS = 240_000L + } +} diff --git a/app/src/androidTest/kotlin/org/mudlet/android/ui/ScriptWindowsTest.kt b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScriptWindowsTest.kt new file mode 100644 index 0000000..446d27c --- /dev/null +++ b/app/src/androidTest/kotlin/org/mudlet/android/ui/ScriptWindowsTest.kt @@ -0,0 +1,252 @@ +package org.mudlet.android.ui + +import android.Manifest +import android.os.Build +import androidx.annotation.StringRes +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.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.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasSetTextAction +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.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.compose.ui.unit.Density +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.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mudlet.android.R +import org.mudlet.android.protocol.LayoutMode +import kotlin.math.abs + +/** + * The windows a profile's scripts draw (docs/phase-f.md), against a fake engine: where the game screen puts them in + * each layout, the size it gives Mudlet's main window, the window switcher, a window's command line and a user window's + * close button. Controls are found by the labels TalkBack reads and activated through their accessibility actions. + */ +@RunWith(AndroidJUnit4::class) +class ScriptWindowsTest { + 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 fun windowNamed(name: String) = hasContentDescription(string(R.string.window_description, name)) + + // The main console: the one list that is not in a script window. A getter, as the strings need the activity + private val console get() = hasScrollToIndexAction() and !hasAnyAncestor(hasContentDescription(string(R.string.window_description, ""), substring = true)) + + @Before + fun openTheGameScreen() { + if (Build.VERSION.SDK_INT >= 34) compose.enableAccessibilityChecks() + // The windows of an earlier test are gone as they would be with a new engine: EngineClient lives as long as + // the process, and this engine is new to it + val first = fake.engine.nextSession() + fake.engine.instance = INSTANCE + first.close() + session = fake.engine.nextSession() + session.clear() + session.line(1, "the main console") + session.synced(1) + activate(hasText(string(R.string.profiles_open_game))) + compose.onNodeWithText("the main console").assertIsDisplayed() + } + + @After + fun automaticLayout() { + compose.runOnIdle { ScriptWindowsClient.layoutMode = LayoutMode.AUTO } + } + + @Test + fun fittedDrawsAMiniConsoleWhereTheScriptPutItAndGivesMudletTheConsolesGrid() { + compose.runOnIdle { ScriptWindowsClient.layoutMode = LayoutMode.FITTED } + // Mudlet's text fills its 800 by 600 main window, 100 columns of about 8 px; the chat is its top right quarter + session.mainArea(800, 600, MUDLET_COLUMNS, 40, 0, 0, MUDLET_TEXT_WIDTH, 600) + session.windows(listOf(FakeEngine.window("chat", x = 400, y = 0, w = 400, h = 300, columns = 50)), full = true) + session.windowText("chat", listOf("hello from chat")) + waitUntilDisplayed("hello from chat") + + val area = bounds(console) + val chat = bounds(windowNamed("chat")) + assertNear("left", area.left + area.width / 2, chat.left) + assertNear("top", area.top, chat.top) + assertNear("width", area.width / 2, chat.width) + assertNear("height", area.height / 2, chat.height) + + // Mudlet's grid as the console's: its main window as many of its characters wide as the console is of the app's; + // the last size sent, as the first may be the one an earlier test left, which a new engine gets as it attaches + val size = session.receiveLast("setMainWindowSize") + val scale = appCharWidth() / mudletCharWidth() + assertNear("Mudlet's width", area.width / scale, size.getInt("w").toFloat(), area.width / scale * TOLERANCE) + assertNear("Mudlet's height", area.height / scale, size.getInt("h").toFloat(), area.height / scale * TOLERANCE) + + // Read after the console, the input line and the bar + compose.onNode(windowNamed("chat")).assert(SemanticsMatcher.expectValue(SemanticsProperties.TraversalIndex, 1f)) + compose.onNode(windowNamed("chat")).assert(SemanticsMatcher.expectValue(SemanticsProperties.IsTraversalGroup, true)) + compose.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun stackedGivesMudletTheLandscapeSizeWithTheBordersAsBandsAndWindowsOverTheText() { + compose.runOnIdle { ScriptWindowsClient.layoutMode = LayoutMode.STACKED } + // Borders of 50 above and below the text + session.mainArea(800, 400, MUDLET_COLUMNS, 30, 0, 50, MUDLET_TEXT_WIDTH, 300) + session.windows( + listOf(FakeEngine.window("title", x = 0, y = 0, w = 800, h = 50), FakeEngine.window("chat", x = 400, y = 50, w = 400, h = 100)), + full = true, + ) + session.windowText("title", listOf("the title bar")) + session.windowText("chat", listOf("chat over the text")) + waitUntilDisplayed("chat over the text") + waitUntilDisplayed("the title bar") + + val text = bounds(console) + // The bands at the width's scale + val b = text.width / 800 + val title = bounds(windowNamed("title")) + assertNear("band top", text.top - 50 * b, title.top) + assertNear("band height", 50 * b, title.height) + assertNear("band width", text.width, title.width) + val chat = bounds(windowNamed("chat")) + assertNear("over the text, from its top", text.top, chat.top) + assertNear("over the text, at its scale", text.width / 2, chat.left - text.left) + assertNear("over the text, its height", 100 * b, chat.height) + + // Mudlet's main window as the area would be turned on its side + val area = Rect(text.left, text.top - 50 * b, text.right, text.bottom + 50 * b) + val size = session.receiveLast("setMainWindowSize") + val scale = appCharWidth() / mudletCharWidth() + assertNear("Mudlet's width", area.height / scale, size.getInt("w").toFloat(), area.height / scale * TOLERANCE) + assertNear("Mudlet's height", area.width / scale, size.getInt("h").toFloat(), area.width / scale * TOLERANCE) + compose.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun aDockIsATabWhenStackedThatOpensItOverTheConsoleWhereItsCloseButtonClosesIt() { + compose.runOnIdle { ScriptWindowsClient.layoutMode = LayoutMode.STACKED } + session.mainArea(800, 400, MUDLET_COLUMNS, 30, 0, 0, MUDLET_TEXT_WIDTH, 400) + session.windows(listOf(FakeEngine.window("side", kind = "userWindow", w = 200, h = 400, dockArea = "right")), full = true) + session.windowText("side", listOf("in the dock")) + val tab = hasText("side") and hasClickAction() + compose.waitUntil(WAIT_MS) { compose.onAllNodes(tab).fetchSemanticsNodes().isNotEmpty() } + assertFalse("a page shows only when its tab is chosen", displayed("in the dock")) + + activate(tab) + waitUntilDisplayed("in the dock") + // TalkBack does not read what the pane covers + assertTrue("the console under the pane is not read", compose.onAllNodesWithText("the main console").fetchSemanticsNodes().isEmpty()) + val pane = bounds(windowNamed("side")) + // The engine learns where the window is, in its pixels + val placement = session.receive("placeUserWindow") + assertEquals("side", placement.getString("name")) + val scale = appCharWidth() / mudletCharWidth() + assertNear("its width", pane.width / scale, placement.getInt("w").toFloat(), pane.width / scale * TOLERANCE) + + activate(hasContentDescription(string(R.string.window_close, "side"))) + assertEquals("side", session.receive("closeUserWindow").getString("name")) + activate(hasContentDescription(string(R.string.window_back_to_console, "side"))) + compose.waitUntil(WAIT_MS) { !displayed("in the dock") } + compose.onNodeWithText("the main console").assertIsDisplayed() + } + + @Test + fun aWindowsCommandLineSendsWhatIsTypedAndShowsWhatAScriptPutsThere() { + compose.runOnIdle { ScriptWindowsClient.layoutMode = LayoutMode.FITTED } + session.mainArea(800, 600, MUDLET_COLUMNS, 40, 0, 0, MUDLET_TEXT_WIDTH, 600) + session.windows( + listOf( + FakeEngine.window("chat", x = 0, y = 300, w = 800, h = 300), + FakeEngine.window("chat", kind = "commandLine", w = 0, h = 0, parent = "chat", parentKind = "miniConsole"), + ), + full = true, + ) + val field = hasSetTextAction() and hasText(string(R.string.window_command_line, "chat")) + compose.waitUntil(WAIT_MS) { compose.onAllNodes(field).fetchSemanticsNodes().isNotEmpty() } + + compose.onNode(field).performTextInput("say hello") + compose.onNode(field).performImeAction() + val sent = session.receive("sendWindowCommandLine") + assertEquals("chat", sent.getString("name")) + assertEquals("say hello", sent.getString("text")) + compose.onNode(field).assert(editableText("")) + // The engine empties the line after a send, as Mudlet does; that is no script's text + session.windowCommandLine("chat", "") + + session.windowCommandLine("chat", "tell bob ") + compose.waitUntil(WAIT_MS) { compose.onAllNodes(field and editableText("tell bob ")).fetchSemanticsNodes().isNotEmpty() } + compose.onRoot().tryPerformAccessibilityChecks() + } + + private fun string(@StringRes id: Int, vararg args: Any) = compose.activity.getString(id, *args) + + private fun editableText(text: String) = SemanticsMatcher("EditableText = '$text'") { + it.config.getOrNull(SemanticsProperties.EditableText)?.text == text + } + + private fun bounds(matcher: SemanticsMatcher): Rect = compose.onNode(matcher).fetchSemanticsNode().boundsInRoot + + private fun displayed(text: String) = try { + compose.onNodeWithText(text).assertIsDisplayed() + true + } catch (_: AssertionError) { + false + } + + private fun waitUntilDisplayed(text: String) = compose.waitUntil(WAIT_MS) { displayed(text) } + + private fun assertNear(what: String, expected: Float, actual: Float, within: Float = PIXELS) = assertTrue("$what: $actual, not $expected", abs(expected - actual) <= within) + + // The main console's character: 14 sp of Android's monospace font, 0.6 em wide, and Material's 0.5 sp between + private fun appCharWidth(): Float = with(Density(compose.activity)) { (14 * 0.6f + 0.5f).sp.toPx() } + + // What the app makes of MUDLET_COLUMNS in MUDLET_TEXT_WIDTH: the middle of (800/101, 800/100] + private fun mudletCharWidth(): Float = (MUDLET_TEXT_WIDTH.toFloat() / (MUDLET_COLUMNS + 1) + MUDLET_TEXT_WIDTH.toFloat() / MUDLET_COLUMNS) / 2 + + /** 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 INSTANCE = "fake-windows" + const val MUDLET_COLUMNS = 100 + const val MUDLET_TEXT_WIDTH = 800 + + // Rounding to whole pixels, here and in Mudlet's sizes + const val PIXELS = 2f + + // The app's character width is worked out here from the font's proportions, not measured + const val TOLERANCE = 0.1f + const val WAIT_MS = 10_000L + const val TIMEOUT_MS = 240_000L + } +} diff --git a/app/src/debug/kotlin/org/mudlet/android/debug/SmokeTestReceiver.kt b/app/src/debug/kotlin/org/mudlet/android/debug/SmokeTestReceiver.kt index de6557c..068cd91 100644 --- a/app/src/debug/kotlin/org/mudlet/android/debug/SmokeTestReceiver.kt +++ b/app/src/debug/kotlin/org/mudlet/android/debug/SmokeTestReceiver.kt @@ -9,6 +9,7 @@ import org.json.JSONArray import org.json.JSONObject import org.mudlet.android.protocol.Requests import org.mudlet.android.ui.EngineClient +import org.mudlet.android.ui.ScriptWindowsClient import org.mudlet.android.ui.UiLine /** @@ -84,6 +85,18 @@ class SmokeTestReceiver : BroadcastReceiver() { .put("status", JSONObject(c.status.toMap())) .put("lines", lines) .put("tail", c.tail?.toJson() ?: JSONObject.NULL) + .put("windows", windows()) + } + + // The script windows the UI knows of, each with the text of its last rows if it is a console + private fun windows(): JSONArray { + val state = ScriptWindowsClient.state + val windows = JSONArray() + for (w in state.windows) { + val rows = state.text[w.name]?.lines.orEmpty().takeLast(WINDOW_ROWS).map { it.text } + windows.put(JSONObject().put("name", w.name).put("kind", w.kind.wire).put("shown", w.shown).put("rows", JSONArray(rows))) + } + return windows } private fun UiLine.toJson() = JSONObject() @@ -95,5 +108,6 @@ class SmokeTestReceiver : BroadcastReceiver() { private companion object { // Enough for the banner and the test's own lines; a broadcast's result travels in one binder transaction const val LINES = 60 + const val WINDOW_ROWS = 5 } } 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..3f37633 100644 --- a/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt +++ b/app/src/main/kotlin/org/mudlet/android/ui/EngineClient.kt @@ -319,6 +319,7 @@ object EngineClient { if (!sameInstance && lines.isNotEmpty()) { addLines(listOf(UiLine(app.getString(R.string.marker_new_engine, hello.instance), IntArray(0), false, marker = true))) } + ScriptWindowsClient.onAttached(sameInstance) } } @@ -359,6 +360,14 @@ object EngineClient { is EngineMessage.CommandLine -> post { onCommandLine(message.text) } + is EngineMessage.Windows, is EngineMessage.WindowText, is EngineMessage.MainArea, is EngineMessage.WindowCommandLine, is EngineMessage.Label -> + post { ScriptWindowsClient.onMessage(message) } + + is EngineMessage.Image -> { + val bitmap = ScriptWindowsClient.decode(message) + post { ScriptWindowsClient.onImage(message, bitmap) } + } + is EngineMessage.Notify -> post { notifyCount++ } is EngineMessage.Gap -> post { diff --git a/app/src/main/kotlin/org/mudlet/android/ui/ScriptWindowsClient.kt b/app/src/main/kotlin/org/mudlet/android/ui/ScriptWindowsClient.kt new file mode 100644 index 0000000..55886f4 --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/ScriptWindowsClient.kt @@ -0,0 +1,196 @@ +package org.mudlet.android.ui + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.net.toUri +import org.mudlet.android.protocol.EngineMessage +import org.mudlet.android.protocol.LabelLink +import org.mudlet.android.protocol.LayoutMode +import org.mudlet.android.protocol.MudletSize +import org.mudlet.android.protocol.Placement +import org.mudlet.android.protocol.PointerType +import org.mudlet.android.protocol.Requests +import org.mudlet.android.protocol.ScriptWindows + +/** + * The script windows' part of [EngineClient] (docs/phase-f.md): the windows the engine describes, for the game screen + * to draw, and the requests about them. Main thread only, as EngineClient's state; EngineClient hands it the engine's + * messages about windows and tells it of each attach. + */ +object ScriptWindowsClient { + var state by mutableStateOf(ScriptWindows()) + private set + + /** Fitted or stacked, or by the game area's width: the setting docs/phase-f.md's "Layout" offers the player. */ + var layoutMode by mutableStateOf(LayoutMode.AUTO) + + // The size the game screen wants Mudlet's main window to be, and the one this engine instance last got + private var wantedSize: MudletSize? = null + private var sentSize: MudletSize? = null + + // Where this engine instance was last told each user window is + private val sentPlacements = HashMap() + + /** An image file of the engine's, as a label shows it: `getImage`'s fields. */ + data class ImageRequest(val path: String, val w: Int? = null, val h: Int? = null, val frame: Int? = null) { + init { + require((w == null) == (h == null)) { "an image is asked for at a size with both w and h, or neither" } + } + } + + /** The images the engine sent, by what was asked; one not there yet is asked for with [requestImage]. */ + val images = mutableStateMapOf() + + // What was asked and not yet answered, by request id, and what of this engine instance's is kept or awaited + private val pendingImages = HashMap() + private val askedImages = HashSet() + + // How many of the labels drawn show each image; one none shows is let go first, the oldest first + private val shownImages = HashMap() + private val imageOrder = LinkedHashSet() + private var nextId = 1L + + // The label the player last touched, which is told when another is + private var touchedLabel: String? = null + + internal fun onMessage(message: EngineMessage) { + state = state.after(message) + } + + /** [message]'s PNG, decoded off the main thread; null for one that does not decode. */ + internal fun decode(message: EngineMessage.Image): ImageBitmap? = BitmapFactory.decodeByteArray(message.png, 0, message.png.size)?.asImageBitmap() + + /** An answer to [requestImage], with what [decode] made of it. */ + internal fun onImage(message: EngineMessage.Image, bitmap: ImageBitmap?) { + val request = pendingImages.remove((message.replyTo as? Number)?.toLong()) ?: return + if (bitmap == null) { + // Not asked again: the same file would not decode better + Log.w(TAG, "the engine's image ${request.path} did not decode") + return + } + images[request] = bitmap + imageOrder.add(request) + trimImages() + } + + /** A label draws [request] until [imageHidden]: it is not let go while one does. */ + fun imageShown(request: ImageRequest) { + shownImages[request] = (shownImages[request] ?: 0) + 1 + } + + fun imageHidden(request: ImageRequest) { + val count = (shownImages[request] ?: return) - 1 + if (count > 0) shownImages[request] = count else shownImages.remove(request) + trimImages() + } + + // Lets go of the oldest images no label shows while there are more than MAX_IMAGES; one let go is asked again + // when a label shows it + private fun trimImages() { + val unshown = imageOrder.iterator() + while (images.size > MAX_IMAGES && unshown.hasNext()) { + val request = unshown.next() + if (request in shownImages) continue + unshown.remove() + images.remove(request) + askedImages.remove(request) + } + } + + /** A new engine instance knows none of the windows, nor the size or placements the last one was given. */ + internal fun onAttached(sameInstance: Boolean) { + if (!sameInstance) { + state = ScriptWindows() + sentSize = null + sentPlacements.clear() + touchedLabel = null + // Its files may not be the last one's + images.clear() + imageOrder.clear() + askedImages.clear() + } + // What the last link did not answer is asked again + pendingImages.clear() + askedImages.retainAll(images.keys) + // Also a size that could not be sent while the link was down + wantedSize?.let(::setMainWindowSize) + } + + /** Gives Mudlet's main window [size], unless this engine instance has it already; again to a new one. */ + fun setMainWindowSize(size: MudletSize) { + wantedSize = size + if (size == sentSize) return + sentSize = if (EngineClient.send(Requests.setMainWindowSize(size.w, size.h))) size else null + } + + /** Tells the engine where the user window [name] is, unless it was told already. */ + fun placeUserWindow(name: String, placement: Placement) { + if (sentPlacements[name] == placement) return + val sent = EngineClient.send(Requests.placeUserWindow(name, placement.x, placement.y, placement.w, placement.h, placement.floating)) + if (sent) sentPlacements[name] = placement else sentPlacements.remove(name) + } + + /** The close button of a user window: the engine hides it. */ + fun closeUserWindow(name: String) { + EngineClient.send(Requests.closeUserWindow(name)) + } + + /** Enter on the command line [name]. False as for [EngineClient.send]. */ + fun sendWindowCommandLine(name: String, text: String): Boolean = EngineClient.send(Requests.sendWindowCommandLine(name, text)) + + fun commandLineShown(name: String) { + state = state.commandLineShown(name) + } + + /** Asks the engine for [request], unless it was asked already; [images] gets it. */ + fun requestImage(request: ImageRequest) { + if (request in images || request in askedImages) return + val id = nextId++ + if (EngineClient.send(Requests.getImage(request.path, request.w, request.h, request.frame, id))) { + askedImages.add(request) + pendingImages[id] = request + } + } + + /** + * What the player's finger did on the label [label], at [x], [y] in its pixels: `pointer`, with [button] that + * changed and [buttons] held after it. The label touched before this one is told the pointer left it. + */ + fun pointer(label: String, type: PointerType, x: Double, y: Double, button: Int = 0, buttons: Int = 0) { + if (type == PointerType.ENTER) { + if (touchedLabel == label) return + touchedLabel?.let { EngineClient.send(Requests.pointer(it, PointerType.LEAVE, 0.0, 0.0, 0, 0, 0, 0)) } + touchedLabel = label + } + EngineClient.send(Requests.pointer(label, type, x, y, button, buttons, 0, 0)) + } + + /** Follows a link the player tapped in a label's text, as [LabelLink] says. */ + fun followLabelLink(context: Context, href: String) { + when (val link = LabelLink.of(href)) { + is LabelLink.Web -> try { + context.startActivity(Intent(Intent.ACTION_VIEW, link.url.toUri())) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "no app opens the label link ${link.url}", e) + } + + is LabelLink.NotRun -> Log.w(TAG, "the label link \"$href\" was not run: ${link.scheme}: is not a scheme Mudlet handles, and the link is not a Lua command") + + else -> link.request(nextId++)?.let(EngineClient::send) + } + } + + private const val TAG = "ScriptWindows" + + // The images kept beyond those shown: few enough that sized ones asked again as labels resize do not pile up + private const val MAX_IMAGES = 64 +} 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..c256257 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 @@ -16,7 +16,8 @@ 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 console with the windows the game's scripts draw around it, the input line and the connection bar, top to + * bottom. TalkBack reads the console, the input line and the bar first, then the script windows and their switcher. * The keyboard pushes them up, so the console shrinks above it. */ @Composable @@ -24,7 +25,9 @@ fun GameScreen(onBack: () -> Unit) { val c = EngineClient Scaffold(topBar = { MudletTopBar(stringResource(R.string.game_title), onBack) }) { padding -> Column(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding).imePadding()) { - Console(c.lines, c.tail, c.linesAdded, onWidth = c::setViewWidth, Modifier.weight(1f).fillMaxWidth()) + ScriptWindowsArea(Modifier.weight(1f).fillMaxWidth()) { consoleModifier -> + Console(c.lines, c.tail, c.linesAdded, onWidth = c::setViewWidth, consoleModifier) + } InputLine( hiddenInput = c.hiddenInput, history = c.history, diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/ScriptLabel.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/ScriptLabel.kt new file mode 100644 index 0000000..b2df931 --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/ScriptLabel.kt @@ -0,0 +1,680 @@ +package org.mudlet.android.ui.game + +import android.util.Log +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.absolutePadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import kotlinx.coroutines.delay +import org.mudlet.android.R +import org.mudlet.android.protocol.Edges +import org.mudlet.android.protocol.Gauge +import org.mudlet.android.protocol.HAlign +import org.mudlet.android.protocol.LabelContent +import org.mudlet.android.protocol.LabelFill +import org.mudlet.android.protocol.LabelStyle +import org.mudlet.android.protocol.POINTER_LEFT +import org.mudlet.android.protocol.POINTER_RIGHT +import org.mudlet.android.protocol.PlacedWindow +import org.mudlet.android.protocol.PointerType +import org.mudlet.android.protocol.PxRect +import org.mudlet.android.protocol.RichText +import org.mudlet.android.protocol.ScriptWindow +import org.mudlet.android.protocol.VAlign +import org.mudlet.android.ui.CONSOLE_FOREGROUND +import org.mudlet.android.ui.EngineClient +import org.mudlet.android.ui.ScriptWindowsClient +import org.mudlet.android.ui.ScriptWindowsClient.ImageRequest +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +private const val TAG = "ScriptLabel" + +// A clickable label's touch bounds are never smaller than this, around it +private val MIN_TOUCH_SIZE = 48.dp + +// Qt's default label font, where neither the label's style sheet nor its window sets one: 10 pt +private const val DEFAULT_FONT_PX = 10f * LabelStyle.PX_PER_PT + +// The colour Qt draws a link in where the text sets none +private val LINK_COLOUR = Color(0xFF4AA3FF) + +private const val TOOLTIP_MILLIS = 4000L + +// A background image tiled more often than this is not drawn, as each frame would draw every tile +private const val MAX_TILES = 4096 + +/** + * A label (docs/phase-f.md, "Labels"): its [content] drawn at [placed]'s place, scaled from Mudlet's pixels as its + * window is, and the player's touches sent back to it as `pointer` events. A clickable one is a button, read by its + * accessible name, and touched within 48 dp around it; one with text is read as text; one with only an image and no + * name is left out. Of a [gauge]'s three labels, its back is read as the gauge's progress, and the other two not at all. + */ +@Composable +internal fun ScriptLabel(placed: PlacedWindow, content: LabelContent?, gauge: Gauge?, density: Density, modifier: Modifier = Modifier) { + val window = placed.window + val rect = placed.rect + val full = placed.full + // Screen pixels per Mudlet pixel, across and down + val sx = if (window.w > 0) full.w / window.w else 1f + val sy = if (window.h > 0) full.h / window.h else 1f + val clickable = content?.accessibleButton == true + val touch = if (clickable) rect.grownTo(with(density) { MIN_TOUCH_SIZE.toPx() }) else rect + val text = rememberLabelText(content, window, min(sx, sy)) + var showTip by remember { mutableStateOf(false) } + val tooltip = remember(content?.toolTip) { content?.toolTip?.let { RichText.parse(it, DEFAULT_FONT_PX).plain }.orEmpty() } + + // Where a touch at a point in the touch bounds is in the label's own pixels, inside it as the engine requires + val toMudlet: (Offset) -> Pair = { at -> + val x = (touch.x + at.x - full.x) / sx + val y = (touch.y + at.y - full.y) / sy + x.toDouble().coerceIn(0.0, (window.w - 1).coerceAtLeast(0).toDouble()) to + y.toDouble().coerceIn(0.0, (window.h - 1).coerceAtLeast(0).toDouble()) + } + val currentToMudlet by rememberUpdatedState(toMudlet) + // A tool tip set after the label was first drawn is shown too + val currentTooltip by rememberUpdatedState(tooltip) + val centre = (window.w / 2.0) to (window.h / 2.0) + + val reading = labelReading(window, content, gauge, text, tooltip, centre) + // A click-through label lets the touch reach what is under it + val touches = if (content?.clickThrough == true) { + Modifier + } else { + Modifier.pointerInput(window.name) { + labelGestures(window.name, { currentToMudlet }, onLongPress = { if (currentTooltip.isNotEmpty()) showTip = true }) + } + } + + Box(modifier.placeAt(touch, density).readAs(reading).then(touches)) { + Box(Modifier.placeIn(rect.x - touch.x, rect.y - touch.y, rect.w, rect.h).clipToBounds()) { + LabelBody(window, content, text, sx, sy, Modifier.placeIn(full.x - rect.x, full.y - rect.y, full.w, full.h)) + } + if (showTip) { + Popup(popupPositionProvider = AboveOrBelow, onDismissRequest = { showTip = false }) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.inverseSurface) { + Text( + tooltip, + Modifier.padding(8.dp).semantics { liveRegion = LiveRegionMode.Polite }, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + } + LaunchedEffect(Unit) { + delay(TOOLTIP_MILLIS) + showTip = false + } + } + } +} + +/** This rectangle grown about its centre to at least [min] pixels each way. */ +private fun PxRect.grownTo(min: Float): PxRect { + val w2 = max(w, min) + val h2 = max(h, min) + return PxRect(x - (w2 - w) / 2, y - (h2 - h) / 2, w2, h2) +} + +/** Lays its content out [w] by [h] pixels at [x], [y] in its parent, whatever the parent's size. */ +private fun Modifier.placeIn(x: Float, y: Float, w: Float, h: Float): Modifier = layout { measurable, constraints -> + val placeable = measurable.measure(Constraints.fixed(w.roundToInt().coerceAtLeast(0), h.roundToInt().coerceAtLeast(0))) + layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(x.roundToInt(), y.roundToInt()) } +} + +@Composable +private fun rememberLabelText(content: LabelContent?, window: ScriptWindow, scale: Float): LabelText { + val styleSheet = content?.styleSheet.orEmpty() + val style = remember(styleSheet) { LabelStyle.parse(styleSheet) } + val basePx = style.fontSizePx ?: window.fontSize.takeIf { it > 0 }?.let { (it * LabelStyle.PX_PER_PT).toFloat() } ?: DEFAULT_FONT_PX + val text = content?.text.orEmpty() + val rich = remember(text, basePx) { if (text.isBlank()) null else RichText.parse(text, basePx) } + return remember(rich, style, scale) { LabelText(style, rich, scale) } +} + +/** A label's style sheet, its text if it has any, and the scale it is drawn at. */ +private class LabelText(val style: LabelStyle, val rich: RichText?, val scale: Float) { + val plain: String = rich?.plain.orEmpty() + val links: List> = rich?.links.orEmpty() +} + +/** + * What a screen reader is told of the label, as docs/phase-f.md's "Touch and TalkBack" says: a button its click and + * right-click, which send the tap's and the long press's `pointer` events to the label's centre, and its links as + * actions; a gauge its progress. Null for a label read by its text, as drawn; nothing set for one left out. + */ +@Composable +private fun labelReading( + window: ScriptWindow, + content: LabelContent?, + gauge: Gauge?, + text: LabelText, + tooltip: String, + centre: Pair, +): (SemanticsPropertyReceiver.() -> Unit)? { + val context = LocalContext.current + val rightClickLabel = stringResource(R.string.label_right_click) + val linkActions = text.links.map { (linkText, href) -> + CustomAccessibilityAction(stringResource(R.string.label_follow_link, linkText.ifEmpty { href })) { + ScriptWindowsClient.followLabelLink(context, href) + true + } + } + val plain = text.plain + val accessibleName = content?.accessibleName.orEmpty() + val button = content?.accessibleButton == true + val name = window.name + + fun SemanticsPropertyReceiver.asButton() { + role = Role.Button + onClick { + click(name, centre, POINTER_LEFT) + true + } + onLongClick(rightClickLabel) { + click(name, centre, POINTER_RIGHT) + true + } + } + + fun SemanticsPropertyReceiver.inOrder() { + traversalIndex = WINDOWS_TRAVERSAL_INDEX + } + + if (gauge != null) { + if (gauge.back.key != window.key) return {} + val gaugeText = gauge.text?.let { ScriptWindowsClient.state.labels[it.name] }?.text?.let { RichText.parse(it, DEFAULT_FONT_PX).plain } + val state = gaugeText?.ifBlank { null } ?: stringResource(R.string.gauge_percent, gauge.percent) + return { + contentDescription = accessibleName.ifEmpty { gauge.name } + progressBarRangeInfo = ProgressBarRangeInfo(gauge.percent.toFloat(), 0f..100f) + stateDescription = state + if (button) asButton() + inOrder() + } + } + if (button) { + val label = accessibleName.ifEmpty { plain }.ifEmpty { tooltip } + if (label.isEmpty()) { + LaunchedEffect(name) { Log.w(TAG, "the clickable label $name has no accessible name, text or tool tip: setLabelAccessibleName() gives it one") } + } + return { + contentDescription = label.ifEmpty { name } + asButton() + if (linkActions.isNotEmpty()) customActions = linkActions + inOrder() + } + } + return when { + // Its text, links and all, read as the text draws it + accessibleName.isEmpty() && plain.isNotEmpty() -> null + + accessibleName.isNotEmpty() -> { + { + contentDescription = accessibleName + if (linkActions.isNotEmpty()) customActions = linkActions + inOrder() + } + } + + // Only a colour or an image: decoration + else -> { + {} + } + } +} + +/** The label read as [reading] says, or by its own text, after the game screen's own controls. */ +private fun Modifier.readAs(reading: (SemanticsPropertyReceiver.() -> Unit)?): Modifier = if (reading == null) { + semantics { + isTraversalGroup = true + traversalIndex = WINDOWS_TRAVERSAL_INDEX + } +} else { + clearAndSetSemantics(reading) +} + +// A click of [button] on [label] at [at], in its pixels, as a tap or long press sends it +private fun click(label: String, at: Pair, button: Int) { + val (x, y) = at + ScriptWindowsClient.pointer(label, PointerType.ENTER, x, y) + ScriptWindowsClient.pointer(label, PointerType.PRESS, x, y, button, button) + ScriptWindowsClient.pointer(label, PointerType.RELEASE, x, y, button, 0) +} + +private enum class Touch { TAP, DRAG, LONG_PRESS, CANCELLED } + +/** + * The player's touches on the label [name] as a mouse's, by docs/phase-f.md's "Labels": a tap is the left button's + * press and release, a second tap soon after a double click, a long press the right button's (and [onLongPress]), and + * a drag the left button held as it moves. Nothing is consumed, so the label's links still see their taps. + */ +private suspend fun PointerInputScope.labelGestures(name: String, mapping: () -> (Offset) -> Pair, onLongPress: () -> Unit) { + val client = ScriptWindowsClient + var lastTap = 0L + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val toMudlet = mapping() + val (x, y) = toMudlet(down.position) + client.pointer(name, PointerType.ENTER, x, y) + var touch = Touch.LONG_PRESS + var last = down.position + withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + while (true) { + val change = awaitChange(down.id) + if (change == null) { + touch = Touch.CANCELLED + break + } + last = change.position + if (!change.pressed) { + touch = Touch.TAP + break + } + if ((change.position - down.position).getDistance() > viewConfiguration.touchSlop) { + touch = Touch.DRAG + break + } + } + } + when (touch) { + Touch.TAP -> { + if (lastTap != 0L && down.uptimeMillis - lastTap <= viewConfiguration.doubleTapTimeoutMillis) { + client.pointer(name, PointerType.DOUBLE_CLICK, x, y, POINTER_LEFT, POINTER_LEFT) + lastTap = 0L + } else { + client.pointer(name, PointerType.PRESS, x, y, POINTER_LEFT, POINTER_LEFT) + lastTap = down.uptimeMillis + } + client.pointer(name, PointerType.RELEASE, x, y, POINTER_LEFT, 0) + } + + Touch.DRAG -> { + lastTap = 0L + client.pointer(name, PointerType.PRESS, x, y, POINTER_LEFT, POINTER_LEFT) + var at = toMudlet(last) + client.pointer(name, PointerType.MOVE, at.first, at.second, 0, POINTER_LEFT) + while (true) { + val change = awaitChange(down.id) ?: break + at = toMudlet(change.position) + if (!change.pressed) break + if (change.positionChanged()) client.pointer(name, PointerType.MOVE, at.first, at.second, 0, POINTER_LEFT) + } + client.pointer(name, PointerType.RELEASE, at.first, at.second, POINTER_LEFT, 0) + } + + Touch.LONG_PRESS -> { + lastTap = 0L + client.pointer(name, PointerType.PRESS, x, y, POINTER_RIGHT, POINTER_RIGHT) + onLongPress() + while (awaitChange(down.id)?.pressed == true) { + // The finger stays down; the right button is let go when it lifts + } + client.pointer(name, PointerType.RELEASE, x, y, POINTER_RIGHT, 0) + } + + Touch.CANCELLED -> lastTap = 0L + } + } +} + +// The next change to the pointer [id], or null once it is gone +private suspend fun AwaitPointerEventScope.awaitChange(id: PointerId): PointerInputChange? = awaitPointerEvent().changes.firstOrNull { it.id == id } + +/** A tool tip's place: above its label, or below it where there is no room above. */ +private object AboveOrBelow : PopupPositionProvider { + override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { + val x = (anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2) + .coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val above = anchorBounds.top - popupContentSize.height + return IntOffset(x, if (above >= 0) above else anchorBounds.bottom) + } +} + +/** + * What a label draws, as Qt's QLabel would at [sx] by [sy] screen pixels to its own: its style sheet's margin, + * background, border and padding, then its raster image or movie at its own size, or its SVG fitted under its text. + */ +@Composable +private fun LabelBody(window: ScriptWindow, content: LabelContent?, text: LabelText, sx: Float, sy: Float, modifier: Modifier = Modifier) { + val style = text.style + val density = LocalDensity.current + val scale = min(sx, sy) + val fill = style.background ?: window.bg.takeIf { it != -1 }?.let { + LabelFill.Solid(windowColour(it, Color.Transparent).copy(alpha = window.bgAlpha / 255f).toArgb()) + } + val borderImage = engineImage(style.borderImage?.let { ImageRequest(it) }) + val backgroundImage = engineImage(style.backgroundImage?.let { ImageRequest(it) }) + val border = style.drawnBorderWidth * scale + val inner = Edges( + style.padding.top + style.drawnBorderWidth, + style.padding.right + style.drawnBorderWidth, + style.padding.bottom + style.drawnBorderWidth, + style.padding.left + style.drawnBorderWidth, + ) + val alignment = BiasAlignment( + when (style.hAlign) { + HAlign.CENTER -> 0f + HAlign.RIGHT -> 1f + else -> -1f + }, + when (style.vAlign) { + VAlign.TOP -> -1f + VAlign.BOTTOM -> 1f + else -> 0f + }, + ) + + Box( + modifier + .edgePadding(style.margin, sx, sy, density) + .drawBehind { + drawLabelFrame(fill, style, border, style.borderRadius * scale, scale, backgroundImage, borderImage) + } + .edgePadding(inner, sx, sy, density), + ) { + val raster = content?.let(::rasterRequest) + if (content != null && content.svg && content.image.isNotEmpty()) LabelSvg(content) + if (raster != null) { + engineImage(raster)?.let { image -> + Box(Modifier.fillMaxSize().wrapContentSize(alignment, unbounded = true)) { + with(density) { + Image( + image, + contentDescription = null, + modifier = Modifier.size((image.width * sx).toDp(), (image.height * sy).toDp()), + contentScale = ContentScale.FillBounds, + ) + } + } + } + } else if (text.rich != null) { + LabelTextView(text, text.rich, style, Modifier.fillMaxWidth().align(alignment)) + } + } +} + +// The image or movie frame shown in place of the label's text +private fun rasterRequest(content: LabelContent): ImageRequest? = when { + content.movie.isNotEmpty() -> { + val sized = content.movieW != null && content.movieH != null + ImageRequest(content.movie, content.movieW.takeIf { sized }, content.movieH.takeIf { sized }, content.movieFrame.takeIf { content.moviePaused && it >= 0 }) + } + + content.image.isNotEmpty() && !content.svg -> ImageRequest(content.image) + + else -> null +} + +private fun Modifier.edgePadding(edges: Edges, sx: Float, sy: Float, density: Density): Modifier = with(density) { + absolutePadding( + left = (edges.left * sx).coerceAtLeast(0f).toDp(), + top = (edges.top * sy).coerceAtLeast(0f).toDp(), + right = (edges.right * sx).coerceAtLeast(0f).toDp(), + bottom = (edges.bottom * sy).coerceAtLeast(0f).toDp(), + ) +} + +/** The engine's image for [request], asked for once of each engine instance; null until it comes, or for no request. */ +@Composable +private fun engineImage(request: ImageRequest?): ImageBitmap? { + // Kept while it is shown + DisposableEffect(request) { + request?.let(ScriptWindowsClient::imageShown) + onDispose { request?.let(ScriptWindowsClient::imageHidden) } + } + // A new engine instance is asked again + LaunchedEffect(request, EngineClient.attachCount) { request?.let(ScriptWindowsClient::requestImage) } + return request?.let { ScriptWindowsClient.images[it] } +} + +private fun DrawScope.drawLabelFrame( + fill: LabelFill?, + style: LabelStyle, + border: Float, + radius: Float, + scale: Float, + backgroundImage: ImageBitmap?, + borderImage: ImageBitmap?, +) { + val corner = CornerRadius(radius) + when (fill) { + is LabelFill.Solid -> drawRoundRect(Color(fill.argb), cornerRadius = corner) + + is LabelFill.Gradient -> drawRoundRect( + Brush.linearGradient( + *fill.stops.map { (at, argb) -> at to Color(argb) }.toTypedArray(), + start = Offset(fill.x1 * size.width, fill.y1 * size.height), + end = Offset(fill.x2 * size.width, fill.y2 * size.height), + ), + cornerRadius = corner, + ) + + null -> {} + } + if (backgroundImage != null) { + // Tiled from the top left, each tile at its own size + val tileW = backgroundImage.width * scale + val tileH = backgroundImage.height * scale + if (tileW >= 1f && tileH >= 1f && (size.width / tileW) * (size.height / tileH) <= MAX_TILES) { + val dst = IntSize(tileW.roundToInt(), tileH.roundToInt()) + var y = 0f + while (y < size.height) { + var x = 0f + while (x < size.width) { + drawImage(backgroundImage, dstOffset = IntOffset(x.roundToInt(), y.roundToInt()), dstSize = dst) + x += tileW + } + y += tileH + } + } + } + if (borderImage != null) drawImage(borderImage, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) + if (border > 0f) { + drawRoundRect( + Color(style.borderColor ?: style.color ?: CONSOLE_FOREGROUND.toArgb()), + topLeft = Offset(border / 2, border / 2), + size = Size((size.width - border).coerceAtLeast(0f), (size.height - border).coerceAtLeast(0f)), + cornerRadius = CornerRadius((radius - border / 2).coerceAtLeast(0f)), + style = Stroke(border), + ) + } +} + +/** + * The label's SVG, drawn under its text as Qt draws it: fitted to the label keeping its shape, centred, turned by its + * rotation and sheared about the centre, and tinted. The engine draws it at the size it is shown, once its own size + * says what that is. + */ +@Composable +private fun LabelSvg(content: LabelContent) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val natural = engineImage(ImageRequest(content.image)) ?: return@BoxWithConstraints + val fit = min(constraints.maxWidth.toFloat() / natural.width, constraints.maxHeight.toFloat() / natural.height) + if (fit <= 0f || fit.isInfinite() || fit.isNaN()) return@BoxWithConstraints + val w = (natural.width * fit).roundToInt().coerceIn(1, 4096) + val h = (natural.height * fit).roundToInt().coerceIn(1, 4096) + val image = engineImage(ImageRequest(content.image, w, h)) ?: natural + val tint = content.svgTint.takeIf { it != -1 }?.let { + ColorFilter.tint(windowColour(it, Color.Transparent).copy(alpha = content.svgTintAlpha / 255f), BlendMode.SrcIn) + } + Canvas(Modifier.fillMaxSize()) { + val topLeft = IntOffset(((size.width - w) / 2).roundToInt(), ((size.height - h) / 2).roundToInt()) + val pivot = center + withTransform({ + rotate(content.svgRotation.toFloat(), pivot) + if (content.svgShearX != 0.0 || content.svgShearY != 0.0) { + translate(pivot.x, pivot.y) + transform( + Matrix().apply { + values[Matrix.SkewX] = content.svgShearX.toFloat() + values[Matrix.SkewY] = content.svgShearY.toFloat() + }, + ) + translate(-pivot.x, -pivot.y) + } + }) { + drawImage(image, dstOffset = topLeft, dstSize = IntSize(w, h), colorFilter = tint) + } + } + } +} + +/** The label's rich text, unwrapped as QLabel draws it, with its links followed when tapped. */ +@Composable +private fun LabelTextView(text: LabelText, rich: RichText, style: LabelStyle, modifier: Modifier = Modifier) { + val context = LocalContext.current + val density = LocalDensity.current + val scale = text.scale + val toSp: (Float) -> TextUnit = { px -> with(density) { (px * scale).toSp() } } + val annotated = remember(rich, style, scale, density) { + buildAnnotatedString { + for (block in rich.blocks) { + withStyle(ParagraphStyle(textAlign = textAlign(block.align ?: style.hAlign))) { + for (span in block.spans) { + val href = span.style.href + if (href == null) { + withStyle(spanStyle(span.style, toSp)) { append(span.text) } + } else { + val link = LinkAnnotation.Clickable(href, linkInteractionListener = { ScriptWindowsClient.followLabelLink(context, href) }) + withLink(link) { + withStyle(spanStyle(span.style, toSp, link = true)) { append(span.text) } + } + } + } + } + } + } + } + val base = TextStyle( + color = style.color?.let { Color(it) } ?: CONSOLE_FOREGROUND, + fontSize = toSp(style.fontSizePx ?: (rich.blocks.firstOrNull()?.spans?.firstOrNull()?.style?.sizePx ?: DEFAULT_FONT_PX)), + fontWeight = style.fontWeight?.let { FontWeight(it.coerceIn(1, 1000)) }, + fontStyle = if (style.italic == true) FontStyle.Italic else null, + fontFamily = style.fontFamily?.let(::fontFamily), + textAlign = textAlign(style.hAlign), + ) + Text(annotated, modifier, style = base, softWrap = false, overflow = TextOverflow.Clip) +} + +private fun textAlign(align: HAlign?): TextAlign = when (align) { + HAlign.CENTER -> TextAlign.Center + HAlign.RIGHT -> TextAlign.Right + HAlign.JUSTIFY -> TextAlign.Justify + else -> TextAlign.Left +} + +private fun spanStyle(style: RichText.Style, toSp: (Float) -> TextUnit, link: Boolean = false): SpanStyle { + val decorations = listOfNotNull( + TextDecoration.Underline.takeIf { style.underline || link }, + TextDecoration.LineThrough.takeIf { style.strike }, + ) + return SpanStyle( + color = style.color?.let { Color(it) } ?: if (link) LINK_COLOUR else Color.Unspecified, + background = style.background?.let { Color(it) } ?: Color.Unspecified, + fontSize = toSp(style.sizePx), + fontWeight = if (style.bold) FontWeight.Bold else null, + fontStyle = if (style.italic) FontStyle.Italic else null, + fontFamily = style.family?.let(::fontFamily), + textDecoration = if (decorations.isEmpty()) null else TextDecoration.combine(decorations), + ) +} + +// The nearest of the device's fonts to a family a script names; null for its default +private fun fontFamily(name: String): FontFamily? { + val n = name.lowercase() + return when { + "mono" in n || "courier" in n || "consol" in n || "fixed" in n -> FontFamily.Monospace + "sans" in n -> FontFamily.SansSerif + "serif" in n || "times" in n || "georgia" in n -> FontFamily.Serif + "cursive" in n || "comic" in n -> FontFamily.Cursive + else -> null + } +} diff --git a/app/src/main/kotlin/org/mudlet/android/ui/game/ScriptWindowsArea.kt b/app/src/main/kotlin/org/mudlet/android/ui/game/ScriptWindowsArea.kt new file mode 100644 index 0000000..d3a23d6 --- /dev/null +++ b/app/src/main/kotlin/org/mudlet/android/ui/game/ScriptWindowsArea.kt @@ -0,0 +1,411 @@ +package org.mudlet.android.ui.game + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.text.TextRange +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.TextFieldValue +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import org.mudlet.android.R +import org.mudlet.android.protocol.EngineMessage +import org.mudlet.android.protocol.PlacedWindow +import org.mudlet.android.protocol.PxRect +import org.mudlet.android.protocol.RUN_RGB +import org.mudlet.android.protocol.ScriptWindow +import org.mudlet.android.protocol.ScriptWindows +import org.mudlet.android.protocol.WindowKey +import org.mudlet.android.protocol.WindowKind +import org.mudlet.android.protocol.WindowLayout +import org.mudlet.android.ui.CONSOLE_BACKGROUND +import org.mudlet.android.ui.CONSOLE_FOREGROUND +import org.mudlet.android.ui.EngineClient +import org.mudlet.android.ui.ScriptWindowsClient +import org.mudlet.android.ui.UiLine +import org.mudlet.android.ui.ansiText +import kotlin.math.roundToInt + +// The main console's text size (Console), which sets the scale Mudlet's windows are drawn at +private val CONSOLE_FONT_SIZE = 14.sp +private const val CHAR_SAMPLE = "MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + +// Script windows' text is never drawn smaller than this; a window that would need it smaller scrolls sideways +private const val MIN_WINDOW_FONT_SP = 9f +private const val MAX_WINDOW_FONT_SP = 28f + +// The row with a dock's or a page's title and buttons, and the window switcher's row of tabs +private val HEADER_HEIGHT = 48.dp +private val SWITCHER_HEIGHT = 56.dp + +// Script windows come after the console, the input line and the screen's buttons, which keep the default of 0 +internal const val WINDOWS_TRAVERSAL_INDEX = 1f + +private val WindowKeySaver = listSaver( + save = { if (it == null) emptyList() else listOf(it.name, it.kind.wire) }, + restore = { saved -> WindowKind.fromWire(saved.getOrNull(1).orEmpty())?.let { WindowKey(saved[0], it) } }, +) + +/** The width of a character of the main console in pixels, and of a script window's glyph per sp of its size. */ +private class ConsoleMetrics(val charWidth: Float, val glyphWidthPerSp: Float) + +/** + * The game area: the main console, drawn by [content] with the modifier it is given, and around and over it the + * windows the profile's scripts made, placed as [WindowLayout] says; below it, while there are any, the window + * switcher's tabs, which open a window in a pane over the console. It gives Mudlet's main window the size that makes + * Mudlet's grid the console's, and tells the engine where it put the user windows. + */ +@Composable +fun ScriptWindowsArea(modifier: Modifier = Modifier, content: @Composable (Modifier) -> Unit) { + val client = ScriptWindowsClient + val windows = client.state + val metrics = rememberConsoleMetrics() + val density = LocalDensity.current + val headerHeight = with(density) { HEADER_HEIGHT.toPx() } + val switcherHeight = with(density) { SWITCHER_HEIGHT.toPx() } + var selected by rememberSaveable(stateSaver = WindowKeySaver) { mutableStateOf(null) } + + BoxWithConstraints(modifier.background(CONSOLE_BACKGROUND).clipToBounds()) { + val w = constraints.maxWidth.toFloat() + val h = constraints.maxHeight.toFloat() + val fitted = WindowLayout.fitted(client.layoutMode, maxWidth.value) + val layout = remember(windows, w, h, fitted, metrics, headerHeight, switcherHeight, selected) { + val full = WindowLayout.of(windows, WindowLayout.Area(w, h, fitted, metrics.charWidth, headerHeight, selected)) + // Which windows are pages does not depend on the height, so the switcher's row can be taken from it + if (full.pages.isEmpty()) { + full + } else { + val rest = (h - switcherHeight).coerceAtLeast(0f) + WindowLayout.of(windows, WindowLayout.Area(w, rest, fitted, metrics.charWidth, headerHeight, selected)) + } + } + // A new engine instance has none of what the last was told + val attaches = EngineClient.attachCount + LaunchedEffect(layout.mainWindowSize, attaches) { layout.mainWindowSize?.let(client::setMainWindowSize) } + LaunchedEffect(layout.placements, attaches) { + for ((name, placement) in layout.placements) client.placeUserWindow(name, placement) + } + // A page the layout no longer offers can no longer be open + LaunchedEffect(layout.pages) { if (layout.pages.none { it.key == selected }) selected = null } + + val page = layout.paneWindows.firstOrNull()?.window + val pane = layout.pane?.takeIf { page != null } + + // What the pane covers is not read either + fun Modifier.coveredBy(rect: PxRect): Modifier = if (pane != null && pane.contains(rect)) clearAndSetSemantics {} else this + + content(Modifier.placeAt(layout.console, density).coveredBy(layout.console)) + for (placed in layout.windows) { + key(placed.window.key) { PlacedWindowView(placed, windows, metrics, density, Modifier.coveredBy(placed.rect)) } + } + for (header in layout.dockHeaders) { + key(header.window.key) { WindowHeader(header.window, onBack = null, Modifier.placeAt(header.rect, density)) } + } + if (pane != null && page != null) { + BackHandler { selected = null } + // Touches on the pane stay off what it covers + Box(Modifier.placeAt(pane, density).background(CONSOLE_BACKGROUND).pointerInput(Unit) { consumeAll() }) + WindowHeader(page, onBack = { selected = null }, Modifier.placeAt(pane.copy(h = headerHeight), density)) + for (placed in layout.paneWindows) { + key(placed.window.key) { PlacedWindowView(placed, windows, metrics, density) } + } + } + if (layout.pages.isNotEmpty()) { + WindowSwitcher( + layout.pages, + selected, + onSelect = { selected = if (selected == it) null else it }, + Modifier.align(Alignment.BottomStart).fillMaxWidth().height(SWITCHER_HEIGHT), + ) + } + } +} + +private suspend fun PointerInputScope.consumeAll() { + awaitPointerEventScope { + while (true) awaitPointerEvent().changes.forEach { it.consume() } + } +} + +private fun PxRect.contains(other: PxRect): Boolean = other.x >= x && other.y >= y && other.right <= right && other.bottom <= bottom + +@Composable +private fun rememberConsoleMetrics(): ConsoleMetrics { + val measurer = rememberTextMeasurer() + val style = LocalTextStyle.current.merge(TextStyle(fontFamily = FontFamily.Monospace, fontSize = CONSOLE_FONT_SIZE)) + return remember(measurer, style) { + val chars = measurer.measure(CHAR_SAMPLE, style).size.width / CHAR_SAMPLE.length.toFloat() + val glyphs = measurer.measure(CHAR_SAMPLE, style.merge(TextStyle(letterSpacing = 0.sp))).size.width / CHAR_SAMPLE.length.toFloat() + ConsoleMetrics(chars, glyphs / CONSOLE_FONT_SIZE.value) + } +} + +internal fun Modifier.placeAt(rect: PxRect, density: Density): Modifier = with(density) { + // Never a negative size, which Compose refuses, should the area be squeezed below a header's height + this@placeAt.offset { IntOffset(rect.x.roundToInt(), rect.y.roundToInt()) } + .size(rect.w.coerceAtLeast(0f).toDp(), rect.h.coerceAtLeast(0f).toDp()) +} + +private fun title(window: ScriptWindow): String = window.dock?.title?.ifEmpty { null } ?: window.name + +internal fun windowColour(code: Int, default: Color): Color = if (code >= RUN_RGB) Color(0xFF000000.toInt() or (code and 0xFFFFFF)) else default + +@Composable +private fun PlacedWindowView(placed: PlacedWindow, windows: ScriptWindows, metrics: ConsoleMetrics, density: Density, modifier: Modifier = Modifier) { + val window = placed.window + val placedModifier = Modifier.placeAt(placed.rect, density).then(modifier) + when (window.kind) { + in ScriptWindows.CONSOLE_KINDS -> { + WindowConsole(placed, windows.text[window.name], windows.commandLineOf(window), windows.commandLines, metrics, density, placedModifier) + } + + // A label places itself, as a clickable one's touch bounds can be larger than it + WindowKind.LABEL -> ScriptLabel(placed, windows.labels[window.name], windows.gaugeParts[window.key], density, modifier) + + // One a script made with createCommandLine(), where it put it + WindowKind.COMMAND_LINE -> { + val description = stringResource(R.string.window_description, title(window)) + WindowCommandLine(window.name, windows.commandLines[window.name], placedModifier.windowSemantics(description)) + } + + // A scroll box only holds windows; maps and text boxes are drawn later + else -> {} + } +} + +/** A script window is read after the game screen's own controls, as one group, by its [description]. */ +private fun Modifier.windowSemantics(description: String): Modifier = semantics { + contentDescription = description + isTraversalGroup = true + traversalIndex = WINDOWS_TRAVERSAL_INDEX +} + +/** + * A mini console, user window or MXP frame: its rows as the main console draws its lines, at the size that fits the + * window's columns in its width. It follows new rows unless the player or a script scrolled it back. Its own + * command line, when shown, goes along its bottom. + */ +@Composable +private fun WindowConsole( + placed: PlacedWindow, + text: EngineMessage.WindowText?, + commandLine: ScriptWindow?, + commandLines: Map, + metrics: ConsoleMetrics, + density: Density, + modifier: Modifier = Modifier, +) { + val window = placed.window + val columns = window.console?.columns ?: 0 + val wantedSp = if (columns > 0) placed.full.w / columns / metrics.glyphWidthPerSp else CONSOLE_FONT_SIZE.value + val fontSp = wantedSp.coerceIn(MIN_WINDOW_FONT_SP, MAX_WINDOW_FONT_SP) + // Too small to read at its own width: drawn at the smallest size, as wide as its rows need, and scrolled sideways + val rowsWidth = if (wantedSp < MIN_WINDOW_FONT_SP) with(density) { (columns * metrics.glyphWidthPerSp * fontSp).toDp() } else null + val style = LocalTextStyle.current.merge( + TextStyle(color = CONSOLE_FOREGROUND, fontFamily = FontFamily.Monospace, fontSize = fontSp.sp, lineHeight = 1.2.em, letterSpacing = 0.sp), + ) + val first = text?.first ?: 0 + val lines = remember(text) { text?.lines.orEmpty().map { UiLine(it.text, it.runs, it.prompt) } } + val listState = rememberLazyListState() + var following by remember { mutableStateOf(true) } + + SideEffect { + if (following && lines.isNotEmpty()) listState.requestScrollToItem(lines.size - 1) + } + // Only a scroll the player makes decides whether it follows: reaching the bottom does, leaving it does not + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress to listState.canScrollForward }.collect { (scrolling, more) -> + if (scrolling) following = !more + } + } + val scrolledBackTo = window.console?.scrolledBackTo + LaunchedEffect(scrolledBackTo) { + if (scrolledBackTo == null) { + following = true + } else { + following = false + val index = scrolledBackTo - first + if (index in lines.indices) listState.scrollToItem(index) + } + } + + val description = stringResource(R.string.window_description, title(window)) + Column(modifier.background(windowColour(window.bg, CONSOLE_BACKGROUND)).windowSemantics(description)) { + val sideways = if (rowsWidth != null) Modifier.horizontalScroll(rememberScrollState()) else Modifier + Box(Modifier.weight(1f).fillMaxWidth().then(sideways)) { + LazyColumn(if (rowsWidth != null) Modifier.width(rowsWidth).fillMaxSize() else Modifier.fillMaxSize(), state = listState) { + // Each row's key is its number in the console, so the rows in view stay put as new ones come + items(lines.size, key = { first + it }) { i -> + val row = remember(lines[i]) { ansiText(lines[i]) } + Text(row, Modifier.fillMaxWidth(), style = style, softWrap = false, maxLines = 1, overflow = TextOverflow.Clip) + } + } + } + if (commandLine != null) WindowCommandLine(commandLine.name, commandLines[commandLine.name], Modifier.fillMaxWidth()) + } +} + +/** + * A window's command line: what is typed there goes to the engine with `sendWindowCommandLine`, and a script's + * [scriptText] replaces it. The engine's emptying of the line after a send is not taken as a script's, as the player + * may already be typing the next. + */ +@Composable +private fun WindowCommandLine(name: String, scriptText: String?, modifier: Modifier = Modifier) { + var text by rememberSaveable(name, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + var notSent by remember { mutableStateOf(false) } + var awaitingClear by remember { mutableStateOf(false) } + + LaunchedEffect(scriptText) { + if (scriptText == null) return@LaunchedEffect + // Only the first text after a send can be Mudlet's emptying of the line + if (!(scriptText.isEmpty() && awaitingClear)) text = TextFieldValue(scriptText, TextRange(scriptText.length)) + awaitingClear = false + ScriptWindowsClient.commandLineShown(name) + } + + fun submit() { + val sent = ScriptWindowsClient.sendWindowCommandLine(name, text.text) + notSent = !sent + if (sent) { + text = TextFieldValue() + awaitingClear = true + } + } + + Row(modifier.background(MaterialTheme.colorScheme.surface), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = text, + onValueChange = { + text = it + notSent = false + }, + modifier = Modifier.weight(1f).padding(start = 4.dp), + label = { Text(stringResource(R.string.window_command_line, name)) }, + supportingText = if (notSent) { + { Text(stringResource(R.string.input_not_sent), Modifier.semantics { liveRegion = LiveRegionMode.Polite }) } + } else { + null + }, + isError = notSent, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { submit() }), + ) + IconButton(::submit) { + Icon(painterResource(R.drawable.ic_send), stringResource(R.string.window_command_send, name)) + } + } +} + +/** + * A dock's or a page's title row: for a page, a button back to the console ([onBack]); for a user window, its close + * button, which hides it as the dock's own would. + */ +@Composable +private fun WindowHeader(window: ScriptWindow, onBack: (() -> Unit)?, modifier: Modifier = Modifier) { + val name = title(window) + Row( + modifier.background(MaterialTheme.colorScheme.surfaceVariant).semantics { + isTraversalGroup = true + traversalIndex = WINDOWS_TRAVERSAL_INDEX + }, + verticalAlignment = Alignment.CenterVertically, + ) { + if (onBack != null) { + IconButton(onBack) { + Icon(painterResource(R.drawable.ic_arrow_back), stringResource(R.string.window_back_to_console, name)) + } + } + Text( + name, + Modifier.weight(1f).padding(horizontal = 8.dp).semantics { heading() }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (window.kind == WindowKind.USER_WINDOW) { + // Off, and read as such, while there is no engine to tell + IconButton({ ScriptWindowsClient.closeUserWindow(window.name) }, enabled = EngineClient.enginePid > 0) { + Icon(painterResource(R.drawable.ic_close), stringResource(R.string.window_close, name)) + } + } + } +} + +/** The window switcher: a tab for each page, which opens it over the console, and closes it again. */ +@Composable +private fun WindowSwitcher(pages: List, selected: WindowKey?, onSelect: (WindowKey) -> Unit, modifier: Modifier = Modifier) { + Row( + modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp).semantics { + isTraversalGroup = true + traversalIndex = WINDOWS_TRAVERSAL_INDEX + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (page in pages) { + key(page.key) { + FilterChip(selected = page.key == selected, onClick = { onSelect(page.key) }, label = { Text(title(page)) }) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e7aaf5..5ab9ac7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,20 @@ Password Close the password field + + %1$s window + Command line of %1$s + Send on %1$s + Close %1$s and go back to the console + Close %1$s + + + Right-click + Follow link: %1$s + %1$d%% + Waiting for the engine… Disconnected diff --git a/docs/architecture.md b/docs/architecture.md index 9a29a89..bb30d4b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,9 +26,9 @@ 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 with the windows the game's scripts draw around it, 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`, with the script windows' part in `ui/ScriptWindowsClient.kt`). Every string the app shows is in `res/values/strings.xml` | | `engine-service/` (`:engine-service`) | The engine process's Kotlin side: `EngineService` (a `QtServiceBase`), its notifications, `EngineNative` (the JNI handshake). Its manifest declares the service and the permissions. It also carries the Qt libraries and jars that `scripts/build-apk.sh` stages into `engine-service/qtdeploy/` | -| `protocol/` (`:protocol`) | Plain Kotlin/JVM with no `android.*`: the frame codec, message types and `EngineLink`, the reconnecting client. Its tests run on the JVM, against the desktop engine too | +| `protocol/` (`:protocol`) | Plain Kotlin/JVM with no `android.*`: the frame codec, message types and `EngineLink`, the reconnecting client; the script windows' state (`ScriptWindows`) and where the game screen puts them (`WindowLayout`); what a label draws, read from its style sheet (`LabelStyle`) and rich text (`RichText`), its links (`LabelLink`) and Geyser's gauges (`Gauge`). 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` | | `native/api/` | `IEngine` and `IFrontend`, between the host and an engine | | `native/standin/` | The stand-in engine for fast tests: minimal telnet, ANSI runs, Lua 5.1.5 (vendored in `native/third_party/lua-5.1.5`, tarball sha256 `2640fc56…695333`), a font check and a heartbeat | @@ -146,6 +146,14 @@ keeps the profiles' passwords in the KeyStore. | `hiddenInputWanted` | `hiddenInput` | | `commandHistory()`, read at `profileLoaded` and when a UI attaches | `history` | | `commandLineTextRequested` (`printCmdLine()` and the like) | `commandLine` | +| `scriptWindows()`, read at `profileLoaded`, then at each `scriptWindowsChanged` | `windows`, only the windows that changed and those `removed`; all of them, with `full`, to a UI that attaches. A close sends an empty list | +| `windowLines()`, the last 300 rows, at `profileLoaded` and at each `windowTextChanged` | `windowText`; any rows on request with `getWindowText` | +| `setMainWindowSize()`, then `mainConsoleGrid()` and `mainConsoleTextRect()` at `profileLoaded` and at each `mainConsoleAreaChanged` | `setMainWindowSize`, kept and given again to each profile as it loads; `mainArea` | +| `setUserWindowGeometry()`, `closeUserWindow()` | `placeUserWindow`, `closeUserWindow` | +| `submitWindowCommandLine()`; `windowCommandLineTextChanged` | `sendWindowCommandLine`; `windowCommandLine` | +| `labelContent()` of each label, read with `scriptWindows()` | `label` after the `windows`, for each label whose content changed; every label to a UI that attaches | +| `pointer()` | `pointer`, answered only when refused | +| none: the engine reads the file itself, with `QImageReader` or as a `QIcon` | `getImage`, answered by `image` as a PNG | The tail is sent once per pass of the event loop, so a flood sends its lines and a single tail. @@ -195,6 +203,17 @@ Android options (`scripts/build-libmudlet-desktop.sh`) to run it. It checks: - the input line: a game's `WILL ECHO` gives `hiddenInput`, `sendHidden` reaches the game and no frame or log, `dismissHidden`, `history` to a UI that attaches and after a restart, `complete` of a word the game sent and from the history, and `printCmdLine()` as `commandLine`; +- script windows: a Geyser `MiniConsole` as `windows` with its geometry, and its `cecho()` as `windowText` with its + colours; a Geyser `UserWindow` placed by `placeUserWindow`, which raises `sysUserWindowResizeEvent`; the size from + `setMainWindowSize` before the profile loads, and `setBorderLeft()`, as `mainArea`; `enableCommandLine()` with + `printCmdLine()` as `windowCommandLine` and `sendWindowCommandLine` running `setCmdLineAction()`'s function; + `getWindowText`; hiding, `closeUserWindow`, a `full` `windows` and each console's text to a UI that attaches, and + `removed`; +- labels: a Geyser `Label`'s `echo()`, `setStyleSheet()`, `setAccessibleName()`, tool tip and click callback as + `label`, its click callback run by a `pointer` press, and `pointer` refused off the window; a new `echo()` as + `label` alone; `setBackgroundImage()` as the label's `image`, and that file as a PNG from `getImage`, scaled; an + SVG one with `setSvgTint()` and `setSvgRotation()`; a Geyser `Gauge`'s three labels, whose front width is its value + and whose text label is transparent; every label to a UI that attaches; `deleteLabel()` as `removed`; - TLS (see "TLS" below): a self-signed certificate refused, TLS to a port that does not speak it, then the same certificate accepted once the profile accepts self-signed ones. @@ -208,6 +227,7 @@ Android options (`scripts/build-libmudlet-desktop.sh`) to run it. It checks: - that connecting to `fake_telnet.py` on the host (`10.0.2.2` from the emulator) starts a profile, so Mudlet's Lua (`luaL_openlibs` and Mudlet's Lua files) is up, and that the banner's coloured lines reach the UI; - that a Lua trigger fires, `cecho()`s on its line and has the engine post its `showNotification()`; +- that a Geyser `MiniConsole` and the line a script `cecho()`s on it reach the UI; - that after `:engine` is killed, the UI starts a new engine and attaches to it. If the engine crashes, it stops with the crash from logcat, symbolised with `ndk-stack`. @@ -222,7 +242,11 @@ device shows, below the UI's debug hook: - `app/src/androidTest` runs the app against a fake engine in the test's process (`EngineClient`'s `connector`): the way from the start screen to the game screen and the self-test and back, through the labels and click actions TalkBack uses; the self-test's colours and styles of each run, in the semantics tree and in the pixels drawn; the - reattach to a new engine after one is lost, and to the same one; and the notification permission, asked on Android + reattach to a new engine after one is lost, and to the same one; the script windows: a mini console at its scaled + place in the fitted and the stacked layout, the size the game screen gives Mudlet's main window, the window + switcher, a window's command line both ways and a user window's close button; the labels: a label's text at its + scaled place, a clickable one as a button whose click and tap reach the engine as `pointer` events, its 48 dp touch + bounds, a gauge read as its progress, a link's command, and a label's image asked of the engine; and the notification permission, asked on Android 13 and later, on a fresh install. Each Compose test rule has the Accessibility Test Framework's checks on (`enableAccessibilityChecks()`), which fail a test on a control with no label or a touch target under 48 dp; the library needs API 34, so they run on the API 36 emulator only. @@ -713,6 +737,24 @@ 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. +17. Script windows, with the libmudlet APK and a game's own GUI package (a Geyser layout with a chat mini console and + a docked user window, for example), in portrait and in landscape, on a phone and on a tablet or a foldable. + - At 600 dp and wider the windows sit where the package put them, the console in the space it left, and the + game's lines fill the console's width without wrapping early or leaving a gap (the game is told the console's + grid). Narrower, the borders become bands above and below the console, and the side windows and docks become + tabs below it; a tab opens its window over the console, its back button or the system's Back closes it. + - A window's text is readable, its newest line in view; scrolled back, it stays put. + - With TalkBack on: swiping goes from the console to the input line and the bar, then the windows, each read as + " window", then the tabs; a window's command line, its Send button and a user window's close button read + their names. +18. Labels and gauges, with the same package and the libmudlet APK. + - Labels look as on the desktop: their colours, borders, rounded corners, text and its alignment, and images, + scaled with their windows. A gauge's front follows its value. + - A tap on a clickable label does what a click does on the desktop, a double tap what a double click does, and a + long press opens a Geyser right-click menu and shows the label's tool tip. A slider made of labels follows a + drag. A link in a label's text runs its command. + - With TalkBack on: a clickable label reads as a button with its name, and its actions offer the right-click and + its links; a label's text reads as text, an image alone not at all; a gauge reads its text, or its percentage. ## 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..21bb6d4 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 **8**. ## Transport @@ -65,10 +65,17 @@ message, in backquotes, with `?` after an optional one, and the fields of the ob | `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. | | `openProfile` | `name`, `password`?, `connect`?, `id`? | Starts the profile `name`. One already running is first closed and saved, as Mudlet closes a profile. `password` is for this run only: Mudlet's auto-login sends the profile's `login` and then this password once it connects, and nothing stores it. `connect`, true if absent, connects the profile to its own address. Opening the profile that is running leaves it running, with the password it was started with, and connects it if `connect` and it is not connected. While a profile is being opened, `connect`, `disconnect` and `runLua` are for it. | +| `setMainWindowSize` | `w`, `h` | The main window's size in Mudlet's pixels, whole numbers above 0. The borders scripts set take their room from it, and the main console's font decides the grid in what is left (`mainArea`). libmudlet gives it to the running profile at once (`THeadlessSession::setMainWindowSize()`, which raises `sysWindowResizeEvent`) and keeps it for each profile that loads later, which may still refuse it: `mainArea` then gives 0 for it. | +| `getWindowText` | `window`, `first`, `count`, `id`? | Up to `count` rows, at most 1000, of the console `window` (a mini console, user window or MXP frame; `main` is the main console, as in Mudlet) from row `first`, counted from 0. A negative `first` counts back from the end, so `-count` is the last `count` rows. Answered by `windowText`, or `error` for a name that is no console's. | +| `placeUserWindow` | `name`, `x`, `y`, `w`, `h`, `floating` | Where the UI put the user window `name`, in the main window's pixels, docked or `floating` (`setUserWindowGeometry()`). A new size raises `sysUserWindowResizeEvent`. `w` and `h` above 0. | +| `closeUserWindow` | `name` | The dock's close button: hides the user window, as `hideWindow()` does. | +| `sendWindowCommandLine` | `name`, `text` | Enter on the command line `name`, a console's own from `enableCommandLine()` or one from `createCommandLine()`, holding `text` (`submitWindowCommandLine()`): the line's action, or else the aliases, take each line of it. | +| `pointer` | `window`, `type`, `x`, `y`, `button`?, `buttons`?, `modifiers`?, `wheelY`? | What the UI's pointer did at `x`, `y` (numbers, which may have fractions) in the pixels of the window `window`, from its top left; `main` is the main console's area (`THeadlessSession::pointer()`). `type` is `press`, `release`, `doubleClick`, `move`, `enter`, `leave` or `wheel`. `button` is the one that changed, for a press, release or double-click, and `buttons` those held after it, as Qt's flags: 1 left, 2 right, 4 middle. `modifiers` are Qt's: 0x02000000 Shift, 0x04000000 Control, 0x08000000 Alt, 0x10000000 Meta. `wheelY` is the wheel's turn in eighths of a degree, 120 a notch, positive away from the player. Each of those four is 0 when absent. A label's callback runs as the GUI's would, with the event table it gives (`button` `LeftButton` and the rest, `x`, `y`); a press or release no label takes goes on to the console under it. | +| `getImage` | `path`, `w`?, `h`?, `frame`?, `id`? | The image file at `path`, as a label's `image` or `movie` names it, as a PNG, answered by `image` or `error`. With `w` and `h`, which go together, from 1 to 4096, it is scaled to that size; a path that is no image file is then taken as an icon file, or the name of one in the icon theme, at that size. `frame`, from 0, is that frame of an animated image. Without `w` and `h`, an image larger than 4096 pixels a side is refused, as is a PNG too large for one frame. | Any request may carry `id`. The engine copies it, as it came, into the `re` of every `status` it sends while -handling that request, of the `profiles` or `error` that answers it, and into `pong`. Statuses that come later, such -as `connection` once a connect succeeds, carry no `re`. +handling that request, of the `profiles`, `windowText`, `image` or `error` that answers it, and into `pong`. +Statuses that come later, such as `connection` once a connect succeeds, carry no `re`. Each profile request is answered with `profiles`, or with `error` whose `message` is the request's name, a colon and why it was refused: a bad name or field, a profile that does not exist, or one that is in use. An `openProfile` @@ -78,6 +85,12 @@ exist, as in version 1; after one, it connects the profile opened. The stand-in `profiles.json` beside its socket, has no login, so a password goes nowhere, and connects a `connect` to the address it names whatever profile is open. +The script windows' requests are answered only when refused, with `error` whose `message` is the request's name, a +colon and why; `getWindowText` and `getImage` are always answered. libmudlet refuses all but `setMainWindowSize` and +`getImage` while no profile is open, and `pointer` as `THeadlessSession::pointer()` does: for a window that is not +shown, a point outside it, or another window than the one a held button went down on. The stand-in has no script +windows and refuses all seven. + ### Engine to UI | Message | Fields | Meaning | @@ -96,6 +109,70 @@ it names whatever profile is open. | `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. | | `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. | +| `windows` | `full`?, `windows`: `name`, `kind`, `parent`, `parentKind`?, `x`, `y`, `w`, `h`, `visible`, `shown`, `stack`, `bg`?, `bgAlpha`?, `fontFamily`?, `fontSize`?, `inDock`, `autoDock`?, `dockArea`?, `dockFloating`?, `dockTitle`?, `scrolledBackTo`?, `columns`?, `rows`?, `scrollBar`?, `timeStamps`?; `removed`?: `name`, `kind` | The windows the profile's scripts made ("Script windows" below). With `full` true, all of them, replacing what the UI has; otherwise those that changed, and in `removed` those gone. | +| `windowText` | `window`, `lineCount`, `first`, `lines`: `text`, `runs`, `prompt`, `wrapped`; `re`? | Rows of a console: it holds `lineCount`, and `lines` starts at row `first`. Each row is a line after the console's wrap; `wrapped` is true for one that carries on from the row above. Sent with the last 300 rows when they may have changed, and for each console to a UI that attaches; with `re`, the answer to `getWindowText`. | +| `mainArea` | `w`, `h`, `columns`, `rows`, `textX`, `textY`, `textW`, `textH` | The main window's area: the size `setMainWindowSize` gave (0 for none), the main console's grid in characters, and the rectangle its text goes in, in Mudlet's pixels, which is the area less the borders scripts set. Sent as a profile loads, on each change and to each UI that attaches. Only libmudlet sends it. | +| `windowCommandLine` | `name`, `text` | A script set the command line `name`, as `printCmdLine()` and the rest do given its name, or Mudlet emptied it after a command: the UI replaces what is on it. Not kept for a UI that attaches later. Only libmudlet sends it. | +| `label` | `name`, `text`, `image`, `svg`, `svgTint`?, `svgTintAlpha`?, `svgRotation`?, `svgShearX`?, `svgShearY`?, `movie`, `moviePaused`, `movieFrame`, `movieSpeed`, `movieW`?, `movieH`?, `toolTip`, `clickThrough`, `accessibleName`, `accessibleButton`, `styleSheet` | What the label `name` shows ("Labels" below), after the `windows` that has it, each time it changes, and for each label to a UI that attaches. Only libmudlet sends it. | +| `image` | `path`, `png`, `w`, `h`, `re`? | The answer to `getImage`: `png` is the image as a PNG, in base64, of `w` by `h` pixels. | + +### Script windows + +What `windows` and `windowText` carry, from libmudlet's `THeadlessSession::scriptWindows()` and `windowLines()`: + +- A window is known by its `name` and `kind`, as a console's own command line has its console's name. `kind` is + upstream's: `miniConsole`, `userWindow`, `label`, `commandLine`, `textBox`, `scrollBox`, `mapper` or `mxpFrame`. + The app leaves out a kind it does not know. +- They come parents first, each parent's windows from the lowest up, those of the main window's area before the + docks. `stack` counts from 0 among a parent's windows; a higher one is drawn over a lower one. +- `parent` is the user window or scroll box a window is in, the console whose command line it is, or the MXP frame it + was opened in, with its kind in `parentKind`. It is empty, with no `parentKind`, for the main window's area, a dock + and an external MXP frame. +- `x`, `y`, `w` and `h` are in the parent's pixels, a dock's in the main window's. They are all 0 for a console's own + command line, which goes along the console's bottom. +- `visible` is the window's own flag, as `hideWindow()` and `showWindow()` set it; `shown` is whether it and every + window it is in are visible. +- `bg` is a console's background colour, or a label's, as a run's colour. `fontFamily` and `fontSize`, in points, are + what a console, label or text box draws its text in. +- `inDock` is true for a dock beside the main window's area rather than a window in it. A user window has `autoDock` + (whether the player may dock it, or only float it), `dockArea` (`left`, `right`, `top` or `bottom`), + `dockFloating` and `dockTitle`. +- A console (a mini console, user window or MXP frame) has `columns` and `rows`, the grid of text it has room for, + `scrollBar` and `timeStamps`, and `scrolledBackTo`, the row it is scrolled back to, while it does not follow new + lines. +- The runs of a console's rows are a `line`'s, with two differences: `fg` is always a colour, never -1, as the UI is + not told a console's own; a `bg` of -1 is the window's `bg`. Their `link` ids are the window's own. + +The engine keeps the list. Between attaches it sends one `windows` for each turn of its event loop that changed +some, with only what changed. libmudlet sends every window of a profile as it loads, and `removed` for all of them +as it closes. `windowText` is not kept: each console's last 300 rows come again on attach. + +A label's `bg` may be partly or wholly transparent: `bgAlpha`, from 0 to 254, is its alpha, absent for an opaque +one. A `Geyser.Gauge` is three labels in the gauge's parent, `_back`, `_front` and `_text`, drawn +in that order: the front's width (or height, for a vertical gauge) against the back's is the gauge's value, and the +text label, transparent, holds the text. + +### Labels + +What `label` carries, from libmudlet's `THeadlessSession::labelContent()`, which scripts set with `echo()`, +`setLabelStyleSheet()`, `setBackgroundImage()` and the rest: + +- `text` is the rich text `echo()` or `setLabelText()` gave it, which Qt's rich text draws; it is empty while an + image or movie shows in its place. +- `image` is the file `setBackgroundImage()` gave it while that shows: a raster image, which text or a movie takes + down, or with `svg` true an SVG drawn under the text. An SVG may have `svgTint`, a colour as a run's with its alpha + in `svgTintAlpha` when below 255, and `svgRotation` in degrees, `svgShearX` and `svgShearY`, each absent for 0. +- `movie` is the file `setMovie()` gave it, shown in place of the text. `moviePaused` is true once `pauseMovie()` + paused it or it played to its end, `movieFrame` the frame it then shows (-1 while it plays), `movieSpeed` its + speed in percent, and `movieW` and `movieH` the size `scaleMovie()` gave it, absent for the file's own. +- `toolTip` is the label's tool tip. With `clickThrough` true the pointer passes through it to what is under it. +- `accessibleName` is what a screen reader says for it, and `accessibleButton` is true once it has a click or release + callback, so a screen reader is told it is a button. +- `styleSheet` is what `setLabelStyleSheet()` gave it, which the engine does not interpret; drawing it is the UI's. + +The files are the engine's: the UI asks for one with `getImage`. A label's cursor is not sent. The engine keeps each +label's content and sends a `label` only when it differs from what it last sent; a label that goes is in the +`windows` message's `removed`, with no `label`. ### Status keys @@ -167,7 +244,8 @@ A colour (`fg`, `bg`) is one of: | fast blink | 128 | | concealed | 256 | -`link` is libmudlet's id of the link the run is part of, 0 for none. Nothing can follow a link over the protocol yet. +`link` is libmudlet's id of the link the run is part of, 0 for none; in a script window's rows, among that window's +links. Nothing can follow a link over the protocol yet. ## Attaching: hello, instance and lastSeq @@ -175,7 +253,9 @@ The engine keeps the last 2000 lines in a ring buffer, its current tail, and the a UI says `hello`, the engine sends, in this order: 1. its own `hello`; -2. every status, each as a `status` without `re`, in no particular order, then `hiddenInput` and `history`; +2. every status, each as a `status` without `re`, in no particular order, then `hiddenInput` and `history`, then + `mainArea` if the engine has sent one, `windows` with `full` true, a `label` for each label, and a `windowText` of + each console's last 300 rows; 3. `clear`, if the console was emptied after the line the UI says it has, or ever, for a UI that has none; 4. `gap`, if lines the UI does not have already fell out of the ring; 5. the lines in the ring after the UI's, each with `replay` true. That is all of them unless the UI's `instance` @@ -200,23 +280,29 @@ The engine never waits for the UI, as Android can freeze a backgrounded UI proce - When more than 4 MiB is queued unsent, the UI is lagging. The engine stops writing lines, tails, clears, notifies and statuses to it, but keeps them in the ring and its status table. -- Once the queue drains below 256 KiB, the engine sends every status, `hiddenInput` and `history` again, then the lines the UI has not had - (with `clear` and `gap` as on attach, but without `replay`), then the tail. -- Answers are always written, even to a lagging UI: `pong`, `profiles`, `error`, and a `status` that carries `re`. +- Once the queue drains below 256 KiB, the engine sends every status, `hiddenInput`, `history` and the script + windows' state again, as on attach, then the lines the UI has not had (with `clear` and `gap` as on attach, but + without `replay`), then the tail. +- Answers are always written, even to a lagging UI: `pong`, `profiles`, `error`, the `windowText` that answers + `getWindowText`, `image`, 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 a `windowCommandLine`. `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 8, which added the labels' messages (`label`, `image`, and `bgAlpha` in `windows`) and +requests (`pointer`, `getImage`). Version 7 added the script windows' requests (`setMainWindowSize`, +`getWindowText`, `placeUserWindow`, `closeUserWindow`, `sendWindowCommandLine`) and messages (`windows`, +`windowText`, `mainArea`, `windowCommandLine`). Versions 4 to 6 are kept for changes still in review, so no engine speaks them. 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=8`) 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/docs/roadmap.md b/docs/roadmap.md index 1490a1e..270ed73 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -27,11 +27,12 @@ repository uses. What Phase E needs, and where it stands: | Lines with colours and styles | `lineShown`, `localTextStyled`, `mainConsoleCleared` | `line`, `tail`, `clear` | | A line changed after it was shown | None: `lineShown` sends each line once | No | | Links (MXP, OSC 8, `echoLink()`) | Ids in runs; `link()`, `followLink()`, `linksChanged` | Ids in runs only | -| Screen size for NAWS | `setScreenSize()`, `setMainWindowSize()` | No: the game is told 90 by 25 | +| Screen size for NAWS | `setScreenSize()`, `setMainWindowSize()` | `setMainWindowSize` (version 7), which regrids the main console the game is told of; the game screen sends the size that gives Mudlet's grid the console's, once the engine's `mainArea` has told it Mudlet's font | | Default colours, font, wrap | `consoleColors*`, `consoleFont()`, `consoleWrap()` and their signals | No | | Command history, completion | `commandHistory()`, `completeCommandLine()`, `completeCommandLineFromHistory()` | `history`, `complete` (version 3) | | Password prompts | `hiddenInputWanted`, `submitHiddenInput()` | `hiddenInput`, `sendHidden`, `dismissHidden` | | Scripts setting the input line | `commandLineTextRequested`, `setCommandLineText()` | `commandLine` | +| Script windows (Phase F) | `scriptWindows()`, `windowLines()`, `mainConsoleGrid()`, `setUserWindowGeometry()`, `closeUserWindow()`, `submitWindowCommandLine()` and their signals; `labelContent()`, `pointer()` | `windows`, `windowText`, `mainArea`, `windowCommandLine`, `getWindowText`, `placeUserWindow`, `closeUserWindow`, `sendWindowCommandLine` (version 7); `label`, `image`, `pointer`, `getImage` (version 8). The game screen draws the mini consoles and user windows, fitted or stacked with a window switcher, and their command lines, and the labels, with their style sheets, text, images and taps, and Geyser's gauges as progress bars; no maps or text boxes yet | | Screen reader announcements | `announcementRequested` (Lua `announce()`) | No | | Backgrounded app | `setApplicationActive()`, `setWindowMinimized()`, `saveAsync()`, `attentionRequested` | No | | Notifications | `notificationRequested` | `notify`, posted by the engine | diff --git a/native/api/IEngine.h b/native/api/IEngine.h index 0b5f1d0..e04f798 100644 --- a/native/api/IEngine.h +++ b/native/api/IEngine.h @@ -1,11 +1,44 @@ #pragma once +#include "IFrontend.h" #include "Profile.h" +#include #include +#include +#include +#include #include #include +#include + +// The UI's pointer on a script window, as THeadlessSession::WindowPointerEvent describes it +struct PointerEvent +{ + // As upstream names them on the wire + enum class Type { press, release, doubleClick, move, enter, leave, wheel }; + Type type = Type::move; + // In the window's pixels, from its top left corner + QPointF position; + // Qt's values: the button that changed, for a press, release or double-click, and those held after it + int button = 0; + int buttons = 0; + // Qt::KeyboardModifiers + int modifiers = 0; + // The wheel's turn in eighths of a degree, as QWheelEvent::angleDelta().y() + int wheelY = 0; +}; + +// An image as PNG, and its size in pixels +struct PngImage +{ + // The largest side IEngine::image() scales to or reads, so that a huge file is not decoded whole for a UI that + // could not take it in one frame + static constexpr int maxSide = 4096; + QByteArray png; + QSize size; +}; // What the host asks of an engine: the stand-in, or libmudlet behind MudletEngine class IEngine @@ -33,4 +66,21 @@ class IEngine virtual QString deleteProfile(const QString& name) = 0; // Starts that profile, first closing and saving another that runs. The password is for this run only. virtual QString openProfile(const QString& name, const QString& password, bool connect) = 0; + + // The script windows'. The main window's size in pixels, kept for the next profile too. + virtual QString setMainWindowSize(int width, int height) = 0; + // Up to count rows of a console from row first, which counts back from the end below 0; none for a name that is + // not a console's, or with no profile open + virtual std::optional windowText(const QString& window, int first, int count) const = 0; + // Where the UI put a user window, in the main window's pixels, docked or floating + virtual QString placeUserWindow(const QString& name, const QRect& geometry, bool floating) = 0; + // The dock's close button: hides the user window + virtual QString closeUserWindow(const QString& name) = 0; + // Enter on a window's command line holding text + virtual QString sendWindowCommandLine(const QString& name, const QString& text) = 0; + // What the UI's pointer did on a window + virtual QString pointer(const QString& window, const PointerEvent& event) = 0; + // The image file, or icon, at path as PNG, scaled to size unless it is empty, or that frame of an animated one; + // else why there is none. Size is at most PngImage::maxSide a side. + virtual std::variant image(const QString& path, const QSize& size, std::optional frame) const = 0; }; diff --git a/native/api/IFrontend.h b/native/api/IFrontend.h index eb11357..52b0b32 100644 --- a/native/api/IFrontend.h +++ b/native/api/IFrontend.h @@ -1,9 +1,13 @@ #pragma once #include +#include +#include #include #include +#include + struct ColorRun { int start = 0; @@ -19,6 +23,116 @@ struct ColorRun bool operator==(const ColorRun&) const = default; }; +// A window a profile's scripts made, as THeadlessSession::ScriptWindow describes it, for the UI to draw +struct ScriptWindowInfo +{ + QString name; + // Upstream's enum name: miniConsole, userWindow, label, commandLine, textBox, scrollBox, mapper or mxpFrame + QString kind; + // The window it is in, and that window's kind; both empty for the main window's area, a dock or an external frame + QString parent; + QString parentKind; + // In the parent's pixels; empty for a console's own command line + QRect geometry; + bool visible = false; + bool shown = false; + int stackOrder = 0; + // As ColorRun's, -1 for none; its alpha from 0 to 255, which a label's may have below 255 + int background = -1; + int backgroundAlpha = 255; + // Empty and 0 for none + QString fontFamily; + double fontSize = 0; + bool inDock = false; + // A user window's + struct Dock + { + bool autoDock = true; + // left, right, top or bottom + QString area; + bool floating = false; + QString title; + bool operator==(const Dock&) const = default; + }; + std::optional dock; + // A mini console's, user window's or MXP frame's + struct Console + { + // The row it is scrolled back to, none while it follows new lines + std::optional scrolledBackTo; + int columns = 0; + int rows = 0; + bool scrollBar = false; + bool timeStamps = false; + bool operator==(const Console&) const = default; + }; + std::optional console; + bool operator==(const ScriptWindowInfo&) const = default; +}; + +// Rows of a mini console, user window or MXP frame, after its wrap +struct WindowText +{ + struct Row + { + QString text; + // A fg is always a colour, as the UI does not know the console's own; a bg of -1 is the window's + QList runs; + bool prompt = false; + // Carries on from the row above + bool wrapped = false; + }; + // How many rows the console holds, and the one rows starts at, from 0 + int lineCount = 0; + int first = 0; + QList rows; + // The last rows a window's text is sent with unasked + static constexpr int recentRows = 300; +}; + +// What a label shows, as THeadlessSession::LabelContent describes it: its geometry, background and font are its +// ScriptWindowInfo's +struct LabelInfo +{ + QString name; + // Qt rich text; empty while an image or movie shows + QString text; + // The file of a raster image while it shows, or of an SVG one, drawn under the text with the tint, rotation and shear + QString image; + bool svg = false; + // As ColorRun's, -1 for none, with its alpha from 0 to 255 + int svgTint = -1; + int svgTintAlpha = 255; + double svgRotation = 0; + double svgShearX = 0; + double svgShearY = 0; + // The file of a movie while it plays or is paused; the frame it shows while paused, -1 while it plays + QString movie; + bool moviePaused = false; + int movieFrame = -1; + // In percent of the file's own speed + int movieSpeed = 100; + // Invalid for the file's own + QSize movieSize; + QString toolTip; + bool clickThrough = false; + QString accessibleName; + bool accessibleButton = false; + // The label's own, which Mudlet does not interpret + QString styleSheet; + bool operator==(const LabelInfo&) const = default; +}; + +// The main window's area: the size the UI gave (empty for none), the main console's grid in characters, and where its +// text goes in pixels, which is the area less the borders scripts set +struct MainArea +{ + QSize size; + QSize grid; + QRect textRect; + bool operator==(const MainArea&) const = default; +}; + // Everything the engine tells its host. Every method must be implementable // over a byte stream, so arguments are plain values only. class IFrontend @@ -38,4 +152,13 @@ class IFrontend virtual void history(const QStringList& lines) = 0; // A script set the whole input line virtual void commandLine(const QString& text) = 0; + // Every window the profile's scripts made, parents first, and what each of its labels shows: whole each time, + // empty with no profile open + virtual void windows(const QList& windows, const QList& labels) = 0; + // A console's last WindowText::recentRows rows, as they may have changed; IEngine::windowText() is read at other times + virtual void windowText(const QString& window, const WindowText& text) = 0; + // Sent when it changes + virtual void mainArea(const MainArea& area) = 0; + // A script set a window's command line, or Mudlet emptied it after a command + virtual void windowCommandLine(const QString& name, const QString& text) = 0; }; diff --git a/native/enginehost/MudletEngine.cpp b/native/enginehost/MudletEngine.cpp index c600de7..bfefef0 100644 --- a/native/enginehost/MudletEngine.cpp +++ b/native/enginehost/MudletEngine.cpp @@ -2,6 +2,7 @@ #include "THeadlessSession.h" +#include #include #include #include @@ -13,10 +14,15 @@ #include #include #include +#include +#include +#include +#include #include namespace { +using ScriptWindow = THeadlessSession::ScriptWindow; using TextRun = THeadlessSession::TextRun; using TextStyle = THeadlessSession::TextStyle; @@ -52,12 +58,14 @@ int attributeBits(THeadlessSession::TextStyles styles) return attributes; } -// Runs that are the profile's own colours with no style or link are left out, as the stand-in leaves them -QList colorRuns(const QList& runs) +// Runs that are the profile's own colours with no style or link are left out, as the stand-in leaves them. +// exactForeground: the default foreground as its colour too, as for a script window, whose own the UI does not know. +QList colorRuns(const QList& runs, bool exactForeground = false) { QList out; for (const TextRun& run : runs) { - const ColorRun converted{run.start, run.length, colourCode(run.foreground, run.defaultForeground), colourCode(run.background, run.defaultBackground), attributeBits(run.styles), run.link}; + const ColorRun converted{ + run.start, run.length, colourCode(run.foreground, run.defaultForeground && !exactForeground), colourCode(run.background, run.defaultBackground), attributeBits(run.styles), run.link}; if (converted.length > 0 && (converted.fg != -1 || converted.bg != -1 || converted.attributes != 0 || converted.link != 0)) { out.append(converted); } @@ -81,6 +89,107 @@ QList sliceRuns(const QList& runs, int from, int to) return out; } +QString kindName(ScriptWindow::Kind kind) +{ + switch (kind) { + case ScriptWindow::Kind::miniConsole: + return QStringLiteral("miniConsole"); + case ScriptWindow::Kind::userWindow: + return QStringLiteral("userWindow"); + case ScriptWindow::Kind::label: + return QStringLiteral("label"); + case ScriptWindow::Kind::commandLine: + return QStringLiteral("commandLine"); + case ScriptWindow::Kind::textBox: + return QStringLiteral("textBox"); + case ScriptWindow::Kind::scrollBox: + return QStringLiteral("scrollBox"); + case ScriptWindow::Kind::mapper: + return QStringLiteral("mapper"); + case ScriptWindow::Kind::mxpFrame: + return QStringLiteral("mxpFrame"); + } + return {}; +} + +QString dockAreaName(Qt::DockWidgetArea area) +{ + switch (area) { + case Qt::LeftDockWidgetArea: + return QStringLiteral("left"); + case Qt::TopDockWidgetArea: + return QStringLiteral("top"); + case Qt::BottomDockWidgetArea: + return QStringLiteral("bottom"); + default: + return QStringLiteral("right"); + } +} + +bool isConsole(ScriptWindow::Kind kind) +{ + return kind == ScriptWindow::Kind::miniConsole || kind == ScriptWindow::Kind::userWindow || kind == ScriptWindow::Kind::mxpFrame; +} + +ScriptWindowInfo windowInfo(const ScriptWindow& window) +{ + ScriptWindowInfo info; + info.name = window.name; + info.kind = kindName(window.kind); + info.parent = window.parent; + if (!window.parent.isEmpty()) { + info.parentKind = kindName(window.parentKind); + } + info.geometry = window.geometry; + info.visible = window.visible; + info.shown = window.shown; + info.stackOrder = window.stackOrder; + info.background = colourCode(window.background, false); + if (info.background >= 0) { + info.backgroundAlpha = window.background.alpha(); + } + // The other kinds have a default QFont, which draws nothing + if (isConsole(window.kind) || window.kind == ScriptWindow::Kind::label || window.kind == ScriptWindow::Kind::textBox) { + info.fontFamily = window.font.family(); + info.fontSize = std::max(window.font.pointSizeF(), 0.0); + } + info.inDock = window.inDock; + if (window.dock) { + info.dock = ScriptWindowInfo::Dock{window.dock->autoDock, dockAreaName(window.dock->area), window.dock->floating, window.dock->title}; + } + if (isConsole(window.kind)) { + info.console = ScriptWindowInfo::Console{window.scrolledBackTo, window.grid.width(), window.grid.height(), window.scrollBar, window.timeStamps}; + } + return info; +} + +LabelInfo labelInfo(const QString& name, const THeadlessSession::LabelContent& content) +{ + LabelInfo info; + info.name = name; + info.text = content.text; + info.image = content.image; + info.svg = content.svg; + info.svgTint = colourCode(content.svgTint, false); + if (info.svgTint >= 0) { + info.svgTintAlpha = content.svgTint.alpha(); + } + info.svgRotation = content.svgRotation; + info.svgShearX = content.svgShearX; + info.svgShearY = content.svgShearY; + info.movie = content.movie; + info.moviePaused = content.moviePaused; + info.movieFrame = content.movieFrame; + info.movieSpeed = content.movieSpeed; + info.movieSize = content.movieSize; + info.toolTip = content.toolTip; + info.clickThrough = content.clickThrough; + info.accessibleName = content.accessibleName; + info.accessibleButton = content.accessibleButton; + info.styleSheet = content.styleSheet; + return info; +} + QString settleConfigDir() { // As the GUI's, as the session reads Mudlet.ini and the keychain under them, and AppDataLocation follows them @@ -221,6 +330,14 @@ void MudletEngine::connectSession() mFrontend->status(QStringLiteral("profile"), QStringLiteral("%1 loaded").arg(profile)); mFrontend->status(QStringLiteral("profileLoadMs"), QString::number(mStartClock.elapsed())); mFrontend->history(mSession->commandHistory()); + mLoaded = true; + if (!mMainWindowSize.isEmpty() && !mSession->setMainWindowSize(mMainWindowSize)) { + qWarning("MudletEngine: the main window's size %dx%d was refused: %s", mMainWindowSize.width(), mMainWindowSize.height(), qPrintable(mSession->errorString())); + // So mainArea does not give it as the profile's + mMainWindowSize = {0, 0}; + } + pushMainArea(); + pushWindows(true); }); connect(session, &THeadlessSession::hiddenInputWanted, this, [this](const QString&, bool wanted) { mFrontend->hiddenInput(wanted); @@ -228,6 +345,25 @@ void MudletEngine::connectSession() connect(session, &THeadlessSession::commandLineTextRequested, this, [this](const QString&, const QString& text) { mFrontend->commandLine(text); }); + // The script windows' signals come from the event loop, so these may call the session + connect(session, &THeadlessSession::scriptWindowsChanged, this, [this] { + pushWindows(false); + }); + connect(session, &THeadlessSession::windowTextChanged, this, [this](const QString&, const QStringList& windows) { + for (const QString& window : windows) { + pushWindowText(window); + } + }); + connect(session, &THeadlessSession::windowCommandLineTextChanged, this, [this](const QString&, const QStringList& commandLines) { + for (const QString& name : commandLines) { + if (const std::optional text = mSession->windowCommandLineText(name)) { + mFrontend->windowCommandLine(name, *text); + } + } + }); + connect(session, &THeadlessSession::mainConsoleAreaChanged, this, [this](const QString&, const QSize& grid, const QRect& textRect) { + mFrontend->mainArea({mMainWindowSize, grid, textRect}); + }); connect(session, &THeadlessSession::connecting, this, [this] { mFrontend->status(QStringLiteral("connection"), QStringLiteral("connecting")); }); @@ -275,6 +411,7 @@ void MudletEngine::connectSession() mKept = {}; persist(); mRestoring = false; + onProfileClosed(); }); } @@ -391,6 +528,7 @@ void MudletEngine::start(const QString& host, quint16 port, bool connect, std::o mKept.tls = tls; persist(); mState = State::starting; + mLoaded = false; mFrontend->status(QStringLiteral("profile"), QStringLiteral("%1 starting").arg(mProfile)); mStartClock.start(); mSession->startAsync(options); @@ -700,6 +838,7 @@ void MudletEngine::onShutdownFinished(bool saved, const QString& error) return; } mState = State::idle; + onProfileClosed(); if (saved) { mFrontend->status(QStringLiteral("profile"), QStringLiteral("%1 closed").arg(mProfile)); } else { @@ -731,6 +870,164 @@ void MudletEngine::keepPending() persist(); } +void MudletEngine::onProfileClosed() +{ + mLoaded = false; + mFrontend->windows({}, {}); +} + +void MudletEngine::pushWindows(bool withText) +{ + QList windows; + QList labels; + for (const ScriptWindow& window : mSession->scriptWindows()) { + windows.append(windowInfo(window)); + if (window.kind != ScriptWindow::Kind::label) { + continue; + } + if (const std::optional content = mSession->labelContent(window.name)) { + labels.append(labelInfo(window.name, *content)); + } + } + mFrontend->windows(windows, labels); + if (!withText) { + return; + } + for (const ScriptWindowInfo& window : std::as_const(windows)) { + if (window.console) { + pushWindowText(window.name); + } + } +} + +void MudletEngine::pushWindowText(const QString& window) +{ + if (const std::optional text = windowText(window, -WindowText::recentRows, WindowText::recentRows)) { + mFrontend->windowText(window, *text); + } +} + +// Also when only the size the UI gave changed, which mainConsoleAreaChanged does not tell +void MudletEngine::pushMainArea() +{ + mFrontend->mainArea({mMainWindowSize, mSession->mainConsoleGrid(), mSession->mainConsoleTextRect()}); +} + +QString MudletEngine::setMainWindowSize(int width, int height) +{ + const QSize before = std::exchange(mMainWindowSize, QSize(width, height)); + // Otherwise taken as the profile loads + if (!profileOpen()) { + return {}; + } + if (!mSession->setMainWindowSize(mMainWindowSize)) { + mMainWindowSize = before; + return mSession->errorString(); + } + pushMainArea(); + return {}; +} + +std::optional MudletEngine::windowText(const QString& window, int first, int count) const +{ + const std::optional lines = mSession->windowLines(window, first, count); + if (!lines) { + return std::nullopt; + } + WindowText text{lines->lineCount, lines->first, {}}; + for (const THeadlessSession::WindowLine& line : lines->lines) { + text.rows.append({line.text, colorRuns(line.runs, true), line.isPrompt, line.wrapped}); + } + return text; +} + +QString MudletEngine::placeUserWindow(const QString& name, const QRect& geometry, bool floating) +{ + if (!profileOpen()) { + return QStringLiteral("no profile running"); + } + return mSession->setUserWindowGeometry(name, geometry, floating) ? QString() : mSession->errorString(); +} + +QString MudletEngine::closeUserWindow(const QString& name) +{ + if (!profileOpen()) { + return QStringLiteral("no profile running"); + } + return mSession->closeUserWindow(name) ? QString() : mSession->errorString(); +} + +QString MudletEngine::sendWindowCommandLine(const QString& name, const QString& text) +{ + if (!profileOpen()) { + return QStringLiteral("no profile running"); + } + return mSession->submitWindowCommandLine(name, text) ? QString() : mSession->errorString(); +} + +QString MudletEngine::pointer(const QString& window, const PointerEvent& event) +{ + if (!profileOpen()) { + return QStringLiteral("no profile running"); + } + static constexpr std::pair types[] = { + {PointerEvent::Type::press, THeadlessSession::WindowPointerEvent::Type::press}, + {PointerEvent::Type::release, THeadlessSession::WindowPointerEvent::Type::release}, + {PointerEvent::Type::doubleClick, THeadlessSession::WindowPointerEvent::Type::doubleClick}, + {PointerEvent::Type::move, THeadlessSession::WindowPointerEvent::Type::move}, + {PointerEvent::Type::enter, THeadlessSession::WindowPointerEvent::Type::enter}, + {PointerEvent::Type::leave, THeadlessSession::WindowPointerEvent::Type::leave}, + {PointerEvent::Type::wheel, THeadlessSession::WindowPointerEvent::Type::wheel}, + }; + THeadlessSession::WindowPointerEvent sessionEvent; + for (const auto& [ours, theirs] : types) { + if (ours == event.type) { + sessionEvent.type = theirs; + break; + } + } + sessionEvent.position = event.position; + sessionEvent.button = Qt::MouseButton(event.button); + sessionEvent.buttons = Qt::MouseButtons::fromInt(event.buttons); + sessionEvent.modifiers = Qt::KeyboardModifiers::fromInt(event.modifiers); + sessionEvent.angleDelta = QPoint(0, event.wheelY); + return mSession->pointer(window, sessionEvent) ? QString() : mSession->errorString(); +} + +std::variant MudletEngine::image(const QString& path, const QSize& size, std::optional frame) const +{ + QImage image; + QImageReader reader(path); + if (reader.canRead()) { + const QSize own = reader.size(); + if (!size.isEmpty()) { + reader.setScaledSize(size); + } else if (own.width() > PngImage::maxSide || own.height() > PngImage::maxSide) { + return QStringLiteral("%1 is larger than %2 pixels a side: ask for a size").arg(path).arg(PngImage::maxSide); + } + // Read up to it, as QMovie reaches a frame, since few formats jump to one + for (int read = 0; read <= frame.value_or(0); ++read) { + if (!reader.read(&image)) { + return read > 0 ? QStringLiteral("no frame %1 in %2").arg(*frame).arg(path) : QStringLiteral("%1: %2").arg(path, reader.errorString()); + } + } + } else if (!size.isEmpty() && !frame) { + // An icon file, or a name in the icon theme + const QIcon icon = QFileInfo::exists(path) ? QIcon(path) : QIcon::fromTheme(path); + image = icon.pixmap(size).toImage(); + } + if (image.isNull()) { + return QStringLiteral("%1: %2").arg(path, reader.errorString()); + } + QByteArray png; + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + if (!image.save(&buffer, "PNG")) { + return QStringLiteral("%1 could not be made a PNG").arg(path); + } + return PngImage{png, image.size()}; +} + void MudletEngine::setViewWidthPx(int px) { const int columns = mAdvancePx > 0 ? px / mAdvancePx : 0; diff --git a/native/enginehost/MudletEngine.h b/native/enginehost/MudletEngine.h index 0ba9584..7c192eb 100644 --- a/native/enginehost/MudletEngine.h +++ b/native/enginehost/MudletEngine.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -40,6 +41,13 @@ class MudletEngine : public QObject, public IEngine QString saveProfile(const Profile& profile) override; QString deleteProfile(const QString& name) override; QString openProfile(const QString& name, const QString& password, bool connect) override; + QString setMainWindowSize(int width, int height) override; + std::optional windowText(const QString& window, int first, int count) const override; + QString placeUserWindow(const QString& name, const QRect& geometry, bool floating) override; + QString closeUserWindow(const QString& name) override; + QString sendWindowCommandLine(const QString& name, const QString& text) override; + QString pointer(const QString& window, const PointerEvent& event) override; + std::variant image(const QString& path, const QSize& size, std::optional frame) const override; private: // stopping: the profile closes, for the one in mPendingOpen to start @@ -93,6 +101,13 @@ class MudletEngine : public QObject, public IEngine void onHeartbeat(); void flushCounters(); void markCountersDirty(); + // Loaded, and not closing: the script windows' calls reach it + bool profileOpen() const { return mLoaded && mState != State::stopping; } + void onProfileClosed(); + // The script windows, and with withText each console's last rows + void pushWindows(bool withText); + void pushWindowText(const QString& window); + void pushMainArea(); void onLineShown(const QString& text, bool isPrompt, const QList& runs, bool continuesLine); void onLocalText(const QString& text, const QList& runs, bool continuesLine); @@ -115,6 +130,10 @@ class MudletEngine : public QObject, public IEngine // What mKept was before a switch began, for a close that is refused SessionStore::Session mKeptBeforeSwitch; State mState = State::idle; + // The profile has loaded, since its start + bool mLoaded = false; + // The main window's size the UI gave, which each profile takes as it loads; empty for none, or once a profile refused it + QSize mMainWindowSize{0, 0}; // The TLS choice the profile was started with, unset when that start made none std::optional mProfileTls; // Lua (connects included) asked for while the profile starts, run once it has diff --git a/native/enginehost/SocketBridge.cpp b/native/enginehost/SocketBridge.cpp index cdb49df..2e86017 100644 --- a/native/enginehost/SocketBridge.cpp +++ b/native/enginehost/SocketBridge.cpp @@ -10,7 +10,11 @@ #include #include +#include #include +#include +#include +#include #include #include @@ -19,6 +23,20 @@ #include "AndroidNotifier.h" #endif +namespace { + +// A JSON number that is a whole number an int holds +std::optional wholeNumber(const QJsonValue& value) +{ + const double number = value.toDouble(); + if (!value.isDouble() || number != std::floor(number) || std::abs(number) > std::numeric_limits::max()) { + return std::nullopt; + } + return int(number); +} + +} // namespace + SocketBridge::SocketBridge(QObject* parent) : QObject(parent) , mServer(new QLocalServer(this)) @@ -152,6 +170,28 @@ void SocketBridge::sendState() if (mEngine) { sendFrame(historyMessage(mEngine->history())); } + if (mMainArea) { + sendFrame(mainAreaMessage(*mMainArea)); + } + QJsonArray windows; + for (const ScriptWindowInfo& window : std::as_const(mWindows)) { + windows.append(windowJson(window)); + } + sendFrame({{QStringLiteral("t"), QStringLiteral("windows")}, {QStringLiteral("full"), true}, {QStringLiteral("windows"), windows}}); + for (const LabelInfo& label : std::as_const(mLabels)) { + sendFrame(labelMessage(label)); + } + if (!mEngine) { + return; + } + for (const ScriptWindowInfo& window : std::as_const(mWindows)) { + if (!window.console) { + continue; + } + if (const std::optional text = mEngine->windowText(window.name, -WindowText::recentRows, WindowText::recentRows)) { + sendFrame(windowTextMessage(window.name, *text)); + } + } } void SocketBridge::sendLinesAfter(qint64 seq, bool replay) @@ -232,6 +272,20 @@ void SocketBridge::handleMessage(const QJsonObject& message) answerProfiles(type, mEngine->deleteProfile(message.value(QLatin1String("name")).toString())); } else if (type == QLatin1String("openProfile")) { answerProfiles(type, openProfile(message)); + } else if (type == QLatin1String("setMainWindowSize")) { + refuse(type, setMainWindowSize(message)); + } else if (type == QLatin1String("getWindowText")) { + getWindowText(message); + } else if (type == QLatin1String("placeUserWindow")) { + refuse(type, placeUserWindow(message)); + } else if (type == QLatin1String("closeUserWindow")) { + refuse(type, mEngine->closeUserWindow(message.value(QLatin1String("name")).toString())); + } else if (type == QLatin1String("sendWindowCommandLine")) { + refuse(type, mEngine->sendWindowCommandLine(message.value(QLatin1String("name")).toString(), message.value(QLatin1String("text")).toString())); + } else if (type == QLatin1String("pointer")) { + refuse(type, pointer(message)); + } else if (type == QLatin1String("getImage")) { + getImage(message); } else { sendError(QStringLiteral("unknown type %1").arg(type)); } @@ -285,6 +339,150 @@ void SocketBridge::complete(const QJsonObject& message) status(QStringLiteral("completion"), mEngine->complete(message.value(QLatin1String("text")).toString(), fromHistory.toBool(false))); } +QString SocketBridge::setMainWindowSize(const QJsonObject& message) +{ + const std::optional width = wholeNumber(message.value(QLatin1String("w"))); + const std::optional height = wholeNumber(message.value(QLatin1String("h"))); + if (!width || !height || *width < 1 || *height < 1) { + return QStringLiteral("w and h must be whole numbers above 0"); + } + return mEngine->setMainWindowSize(*width, *height); +} + +void SocketBridge::getWindowText(const QJsonObject& message) +{ + const QString window = message.value(QLatin1String("window")).toString(); + const std::optional first = wholeNumber(message.value(QLatin1String("first"))); + const std::optional count = wholeNumber(message.value(QLatin1String("count"))); + if (!first || !count || *count < 1) { + sendError(QStringLiteral("getWindowText: first and count must be whole numbers, count above 0")); + return; + } + // An empty name would be the main console's in Mudlet + const std::optional text = window.isEmpty() ? std::nullopt : mEngine->windowText(window, *first, std::min(*count, maxWindowTextRows)); + if (!text) { + sendError(QStringLiteral("getWindowText: there is no console named \"%1\"").arg(window)); + return; + } + sendReply(windowTextMessage(window, *text)); +} + +QString SocketBridge::placeUserWindow(const QJsonObject& message) +{ + const std::optional x = wholeNumber(message.value(QLatin1String("x"))); + const std::optional y = wholeNumber(message.value(QLatin1String("y"))); + const std::optional width = wholeNumber(message.value(QLatin1String("w"))); + const std::optional height = wholeNumber(message.value(QLatin1String("h"))); + const QJsonValue floating = message.value(QLatin1String("floating")); + if (!x || !y || !width || !height || *width < 1 || *height < 1) { + return QStringLiteral("x, y, w and h must be whole numbers, w and h above 0"); + } + if (!floating.isBool()) { + return QStringLiteral("floating must be true or false"); + } + return mEngine->placeUserWindow(message.value(QLatin1String("name")).toString(), QRect(*x, *y, *width, *height), floating.toBool()); +} + +QString SocketBridge::pointer(const QJsonObject& message) +{ + static constexpr std::pair types[] = { + {QLatin1StringView("press"), PointerEvent::Type::press}, + {QLatin1StringView("release"), PointerEvent::Type::release}, + {QLatin1StringView("doubleClick"), PointerEvent::Type::doubleClick}, + {QLatin1StringView("move"), PointerEvent::Type::move}, + {QLatin1StringView("enter"), PointerEvent::Type::enter}, + {QLatin1StringView("leave"), PointerEvent::Type::leave}, + {QLatin1StringView("wheel"), PointerEvent::Type::wheel}, + }; + PointerEvent event; + const QString type = message.value(QLatin1String("type")).toString(); + const auto known = std::find_if(std::begin(types), std::end(types), [&type](const auto& entry) { + return entry.first == type; + }); + if (known == std::end(types)) { + return QStringLiteral("type must be press, release, doubleClick, move, enter, leave or wheel"); + } + event.type = known->second; + const QJsonValue x = message.value(QLatin1String("x")); + const QJsonValue y = message.value(QLatin1String("y")); + if (!x.isDouble() || !y.isDouble()) { + return QStringLiteral("x and y must be numbers"); + } + event.position = QPointF(x.toDouble(), y.toDouble()); + // Absent is 0; the buttons and modifiers are Qt's flags, so never negative + const std::pair optional[] = { + {message.value(QLatin1String("button")), &event.button}, + {message.value(QLatin1String("buttons")), &event.buttons}, + {message.value(QLatin1String("modifiers")), &event.modifiers}, + {message.value(QLatin1String("wheelY")), &event.wheelY}, + }; + for (const auto& [value, target] : optional) { + if (value.isUndefined()) { + continue; + } + const std::optional number = wholeNumber(value); + if (!number || (target != &event.wheelY && *number < 0)) { + return QStringLiteral("button, buttons and modifiers must be whole numbers from 0, wheelY a whole number"); + } + *target = *number; + } + return mEngine->pointer(message.value(QLatin1String("window")).toString(), event); +} + +void SocketBridge::getImage(const QJsonObject& message) +{ + const QString path = message.value(QLatin1String("path")).toString(); + if (path.isEmpty()) { + sendError(QStringLiteral("getImage: path must not be empty")); + return; + } + const QJsonValue widthValue = message.value(QLatin1String("w")); + const QJsonValue heightValue = message.value(QLatin1String("h")); + const QJsonValue frameValue = message.value(QLatin1String("frame")); + QSize size; + if (!widthValue.isUndefined() || !heightValue.isUndefined()) { + const std::optional width = wholeNumber(widthValue); + const std::optional height = wholeNumber(heightValue); + if (!width || !height || *width < 1 || *height < 1 || *width > PngImage::maxSide || *height > PngImage::maxSide) { + sendError(QStringLiteral("getImage: w and h go together, whole numbers from 1 to %1").arg(PngImage::maxSide)); + return; + } + size = QSize(*width, *height); + } + std::optional frame; + if (!frameValue.isUndefined()) { + frame = wholeNumber(frameValue); + if (!frame || *frame < 0) { + sendError(QStringLiteral("getImage: frame must be a whole number from 0")); + return; + } + } + const std::variant image = mEngine->image(path, size, frame); + if (const QString* problem = std::get_if(&image)) { + sendError(QStringLiteral("getImage: %1").arg(*problem)); + return; + } + const PngImage& png = std::get(image); + const QByteArray base64 = png.png.toBase64(); + // With room for the path, each of its characters escaped at worst, and the other fields + if (base64.size() + path.size() * 6 + 1024 > qsizetype(maxFrameBytes)) { + sendError(QStringLiteral("getImage: %1 as a PNG is too large for a frame").arg(path)); + return; + } + sendReply({{QStringLiteral("t"), QStringLiteral("image")}, + {QStringLiteral("path"), path}, + {QStringLiteral("png"), QString::fromLatin1(base64)}, + {QStringLiteral("w"), png.size.width()}, + {QStringLiteral("h"), png.size.height()}}); +} + +void SocketBridge::refuse(const QString& request, const QString& problem) +{ + if (!problem.isEmpty()) { + sendError(QStringLiteral("%1: %2").arg(request, problem)); + } +} + void SocketBridge::answerProfiles(const QString& request, const QString& problem) { if (problem.isEmpty()) { @@ -365,6 +563,116 @@ QJsonObject SocketBridge::historyMessage(const QStringList& lines) return {{QStringLiteral("t"), QStringLiteral("history")}, {QStringLiteral("lines"), QJsonArray::fromStringList(lines)}}; } +QJsonObject SocketBridge::windowJson(const ScriptWindowInfo& window) +{ + QJsonObject json{{QStringLiteral("name"), window.name}, + {QStringLiteral("kind"), window.kind}, + {QStringLiteral("parent"), window.parent}, + {QStringLiteral("x"), window.geometry.x()}, + {QStringLiteral("y"), window.geometry.y()}, + {QStringLiteral("w"), window.geometry.width()}, + {QStringLiteral("h"), window.geometry.height()}, + {QStringLiteral("visible"), window.visible}, + {QStringLiteral("shown"), window.shown}, + {QStringLiteral("stack"), window.stackOrder}, + {QStringLiteral("inDock"), window.inDock}}; + if (!window.parentKind.isEmpty()) { + json.insert(QStringLiteral("parentKind"), window.parentKind); + } + if (window.background != -1) { + json.insert(QStringLiteral("bg"), window.background); + if (window.backgroundAlpha < 255) { + json.insert(QStringLiteral("bgAlpha"), window.backgroundAlpha); + } + } + if (!window.fontFamily.isEmpty()) { + json.insert(QStringLiteral("fontFamily"), window.fontFamily); + } + if (window.fontSize > 0) { + json.insert(QStringLiteral("fontSize"), window.fontSize); + } + if (window.dock) { + json.insert(QStringLiteral("autoDock"), window.dock->autoDock); + json.insert(QStringLiteral("dockArea"), window.dock->area); + json.insert(QStringLiteral("dockFloating"), window.dock->floating); + json.insert(QStringLiteral("dockTitle"), window.dock->title); + } + if (window.console) { + if (window.console->scrolledBackTo) { + json.insert(QStringLiteral("scrolledBackTo"), *window.console->scrolledBackTo); + } + json.insert(QStringLiteral("columns"), window.console->columns); + json.insert(QStringLiteral("rows"), window.console->rows); + json.insert(QStringLiteral("scrollBar"), window.console->scrollBar); + json.insert(QStringLiteral("timeStamps"), window.console->timeStamps); + } + return json; +} + +QJsonObject SocketBridge::labelMessage(const LabelInfo& label) +{ + QJsonObject json{{QStringLiteral("t"), QStringLiteral("label")}, + {QStringLiteral("name"), label.name}, + {QStringLiteral("text"), label.text}, + {QStringLiteral("image"), label.image}, + {QStringLiteral("svg"), label.svg}, + {QStringLiteral("movie"), label.movie}, + {QStringLiteral("moviePaused"), label.moviePaused}, + {QStringLiteral("movieFrame"), label.movieFrame}, + {QStringLiteral("movieSpeed"), label.movieSpeed}, + {QStringLiteral("toolTip"), label.toolTip}, + {QStringLiteral("clickThrough"), label.clickThrough}, + {QStringLiteral("accessibleName"), label.accessibleName}, + {QStringLiteral("accessibleButton"), label.accessibleButton}, + {QStringLiteral("styleSheet"), label.styleSheet}}; + if (label.svgTint != -1) { + json.insert(QStringLiteral("svgTint"), label.svgTint); + if (label.svgTintAlpha < 255) { + json.insert(QStringLiteral("svgTintAlpha"), label.svgTintAlpha); + } + } + if (label.svgRotation != 0) { + json.insert(QStringLiteral("svgRotation"), label.svgRotation); + } + if (label.svgShearX != 0) { + json.insert(QStringLiteral("svgShearX"), label.svgShearX); + } + if (label.svgShearY != 0) { + json.insert(QStringLiteral("svgShearY"), label.svgShearY); + } + if (label.movieSize.isValid()) { + json.insert(QStringLiteral("movieW"), label.movieSize.width()); + json.insert(QStringLiteral("movieH"), label.movieSize.height()); + } + return json; +} + +QJsonObject SocketBridge::windowTextMessage(const QString& window, const WindowText& text) +{ + QJsonArray lines; + for (const WindowText::Row& row : text.rows) { + lines.append(QJsonObject{{QStringLiteral("text"), row.text}, {QStringLiteral("runs"), runsJson(row.runs)}, {QStringLiteral("prompt"), row.prompt}, {QStringLiteral("wrapped"), row.wrapped}}); + } + return {{QStringLiteral("t"), QStringLiteral("windowText")}, + {QStringLiteral("window"), window}, + {QStringLiteral("lineCount"), text.lineCount}, + {QStringLiteral("first"), text.first}, + {QStringLiteral("lines"), lines}}; +} + +QJsonObject SocketBridge::mainAreaMessage(const MainArea& area) +{ + return {{QStringLiteral("t"), QStringLiteral("mainArea")}, + {QStringLiteral("w"), area.size.width()}, + {QStringLiteral("h"), area.size.height()}, + {QStringLiteral("columns"), area.grid.width()}, + {QStringLiteral("rows"), area.grid.height()}, + {QStringLiteral("textX"), area.textRect.x()}, + {QStringLiteral("textY"), area.textRect.y()}, + {QStringLiteral("textW"), area.textRect.width()}, + {QStringLiteral("textH"), area.textRect.height()}}; +} + void SocketBridge::sendFrame(const QJsonObject& message) { if (!mClient || mClient->state() != QLocalSocket::ConnectedState) { @@ -455,3 +763,80 @@ void SocketBridge::commandLine(const QString& text) sendFrame({{QStringLiteral("t"), QStringLiteral("commandLine")}, {QStringLiteral("text"), text}}); } } + +// Only what changed is sent, the windows keyed by name and kind and the labels by name, each label after the windows +// so that the UI knows its window first; a UI that attaches or drains gets them all +void SocketBridge::windows(const QList& windows, const QList& labels) +{ + using Key = std::pair; + QHash before; + for (const ScriptWindowInfo& window : std::as_const(mWindows)) { + before.insert({window.name, window.kind}, &window); + } + QJsonArray changed; + for (const ScriptWindowInfo& window : windows) { + const ScriptWindowInfo* was = before.take({window.name, window.kind}); + if (!was || !(*was == window)) { + changed.append(windowJson(window)); + } + } + QJsonArray removed; + for (const ScriptWindowInfo& window : std::as_const(mWindows)) { + if (before.contains({window.name, window.kind})) { + removed.append(QJsonObject{{QStringLiteral("name"), window.name}, {QStringLiteral("kind"), window.kind}}); + } + } + QHash labelsBefore; + for (const LabelInfo& label : std::as_const(mLabels)) { + labelsBefore.insert(label.name, &label); + } + QList changedLabels; + for (const LabelInfo& label : labels) { + const LabelInfo* was = labelsBefore.value(label.name); + if (!was || *was != label) { + changedLabels.append(labelMessage(label)); + } + } + mWindows = windows; + mLabels = labels; + if (!clientLive()) { + return; + } + if (!changed.isEmpty() || !removed.isEmpty()) { + QJsonObject message{{QStringLiteral("t"), QStringLiteral("windows")}, {QStringLiteral("windows"), changed}}; + if (!removed.isEmpty()) { + message.insert(QStringLiteral("removed"), removed); + } + sendFrame(message); + } + for (const QJsonObject& label : std::as_const(changedLabels)) { + sendFrame(label); + } +} + +// Not kept: a UI that attaches or drains gets every console's from IEngine::windowText() +void SocketBridge::windowText(const QString& window, const WindowText& text) +{ + if (clientLive()) { + sendFrame(windowTextMessage(window, text)); + } +} + +void SocketBridge::mainArea(const MainArea& area) +{ + if (mMainArea == area) { + return; + } + mMainArea = area; + if (clientLive()) { + sendFrame(mainAreaMessage(area)); + } +} + +// Not kept, as commandLine is not +void SocketBridge::windowCommandLine(const QString& name, const QString& text) +{ + if (clientLive()) { + sendFrame({{QStringLiteral("t"), QStringLiteral("windowCommandLine")}, {QStringLiteral("name"), name}, {QStringLiteral("text"), text}}); + } +} diff --git a/native/enginehost/SocketBridge.h b/native/enginehost/SocketBridge.h index 6ade0d1..4208d49 100644 --- a/native/enginehost/SocketBridge.h +++ b/native/enginehost/SocketBridge.h @@ -10,6 +10,7 @@ #include #include #include +#include class IEngine; class QLocalServer; @@ -26,11 +27,13 @@ class SocketBridge : public QObject, public IFrontend Q_OBJECT public: - static constexpr int protocolVersion = 3; + static constexpr int protocolVersion = 8; static constexpr quint32 maxFrameBytes = 16 * 1024 * 1024; static constexpr qsizetype scrollbackLines = 2000; static constexpr qint64 highWaterBytes = 4 * 1024 * 1024; static constexpr qint64 lowWaterBytes = 256 * 1024; + // The most rows one getWindowText is answered with + static constexpr int maxWindowTextRows = 1000; explicit SocketBridge(QObject* parent = nullptr); void setEngine(IEngine* engine) { mEngine = engine; } @@ -44,6 +47,10 @@ class SocketBridge : public QObject, public IFrontend void hiddenInput(bool wanted) override; void history(const QStringList& lines) override; void commandLine(const QString& text) override; + void windows(const QList& windows, const QList& labels) override; + void windowText(const QString& window, const WindowText& text) override; + void mainArea(const MainArea& area) override; + void windowCommandLine(const QString& name, const QString& text) override; private: struct StoredLine @@ -60,7 +67,7 @@ class SocketBridge : public QObject, public IFrontend void handleMessage(const QJsonObject& message); void greet(const QJsonObject& hello); void sendLinesAfter(qint64 seq, bool replay); - // Every status, then the input line's state, as a UI attaching or catching up needs them + // Every status, then the input line's state and the script windows', as a UI attaching or catching up needs them void sendState(); void sendFrame(const QJsonObject& message); // An answer to the request being handled: it carries that request's id as re, and is sent even to a lagging UI @@ -68,9 +75,16 @@ class SocketBridge : public QObject, public IFrontend void sendError(const QString& message); void sendProfiles(); void answerProfiles(const QString& request, const QString& problem); + // An error naming the request, for a problem that is not empty + void refuse(const QString& request, const QString& problem); QString saveProfile(const QJsonObject& message); QString openProfile(const QJsonObject& message); void complete(const QJsonObject& message); + QString setMainWindowSize(const QJsonObject& message); + void getWindowText(const QJsonObject& message); + QString placeUserWindow(const QJsonObject& message); + QString pointer(const QJsonObject& message); + void getImage(const QJsonObject& message); void dropClient(const QString& reason); bool clientLive() const { return mClient && mClientGreeted && !mClientLagging; } bool replying() const { return !mCurrentRequestId.isUndefined() && !mCurrentRequestId.isNull(); } @@ -79,6 +93,10 @@ class SocketBridge : public QObject, public IFrontend QJsonObject tailMessage() const; QJsonObject hiddenInputMessage() const; static QJsonObject historyMessage(const QStringList& lines); + static QJsonObject windowJson(const ScriptWindowInfo& window); + static QJsonObject labelMessage(const LabelInfo& label); + static QJsonObject windowTextMessage(const QString& window, const WindowText& text); + static QJsonObject mainAreaMessage(const MainArea& area); QLocalServer* mServer = nullptr; QPointer mClient; @@ -89,6 +107,11 @@ class SocketBridge : public QObject, public IFrontend IEngine* mEngine = nullptr; QHash mStatus; bool mHiddenInput = false; + // As IFrontend::windows() last gave them + QList mWindows; + QList mLabels; + // None until the engine has sent one + std::optional mMainArea; std::deque mScrollback; StoredLine mTail{0, {}, {}, false}; qint64 mNextSeq = 1; diff --git a/native/standin/Engine.h b/native/standin/Engine.h index f0d571e..e45e68b 100644 --- a/native/standin/Engine.h +++ b/native/standin/Engine.h @@ -32,11 +32,21 @@ class Engine : public QObject, public IEngine QString saveProfile(const Profile& profile) override; QString deleteProfile(const QString& name) override; QString openProfile(const QString& name, const QString& password, bool connect) override; + // No scripts make windows here + QString setMainWindowSize(int, int) override { return noWindows(); } + std::optional windowText(const QString&, int, int) const override { return std::nullopt; } + QString placeUserWindow(const QString&, const QRect&, bool) override { return noWindows(); } + QString closeUserWindow(const QString&) override { return noWindows(); } + QString sendWindowCommandLine(const QString&, const QString&) override { return noWindows(); } + QString pointer(const QString&, const PointerEvent&) override { return noWindows(); } + std::variant image(const QString&, const QSize&, std::optional) const override { return noWindows(); } // Called from Lua's notify() void luaNotify(const QString& title, const QString& text); private: + static QString noWindows() { return QStringLiteral("the stand-in engine has no script windows"); } + enum class TelnetState { Data, Iac, Option, Sub, SubIac }; void onReadyRead(); diff --git a/native/tests/android_smoke.py b/native/tests/android_smoke.py index 4833301..95a4944 100644 --- a/native/tests/android_smoke.py +++ b/native/tests/android_smoke.py @@ -188,6 +188,11 @@ def line_with(state, text): return next((line for line in shown if line["text"] == text), None) +def window_named(state, name, kind): + """A script window the UI knows of, with the text of its last rows.""" + return next((w for w in state.get("windows", []) if w["name"] == name and w["kind"] == kind), None) + + def runs_of(line): flat = line["runs"] return [flat[i:i + RUN_STRIDE] for i in range(0, len(flat) - RUN_STRIDE + 1, RUN_STRIDE)] @@ -291,6 +296,20 @@ def run(dev, apk): time.sleep(0.5) ok("the engine process posted the trigger's notification") + step("a Geyser mini console: the window and its line reach the UI") + dev.command("lua", code='smokechat = Geyser.MiniConsole:new({name = "smokechat", x = 0, y = 0, width = "30%", ' + 'height = "40%"}) smokechat:cecho("smoke window line\\n")') + # luaResult is still the trigger's "ran" until the engine answers this + state = dev.wait("the mini console and its line in the UI", + lambda s: s["status"].get("luaResult", "").startswith("refused") + or "smoke window line" in (window_named(s, "smokechat", "miniConsole") or {}).get("rows", []), 30) + if state["status"].get("luaResult", "").startswith("refused"): + raise SmokeFailure(f"the engine refused the Geyser script: {state['status']['luaResult']}") + chat = window_named(state, "smokechat", "miniConsole") + if not chat["shown"]: + raise SmokeFailure(f"the mini console is hidden: {chat}") + ok(f"the UI has the mini console and its line: {chat}") + step("kill the :engine process: the UI starts a new engine and attaches to it") dev.screenshot("before-kill.png") dev.command("kill") diff --git a/native/tests/protocol_client.py b/native/tests/protocol_client.py index fa212e2..22d35e4 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 = 8 class Closed(Exception): @@ -69,12 +69,12 @@ def wait_for(self, pred, timeout=5.0, collect=None): if pred(msg): return msg - def ask(self, msg, timeout=5.0, collect=None): - """Send a request with a fresh id and return the profiles or error frame that answers it.""" + def ask(self, msg, timeout=5.0, collect=None, answers=("profiles", "error")): + """Send a request with a fresh id and return the frame of one of those types that answers it.""" self.next_id += 1 request_id = f"ask{self.next_id}" self.send(dict(msg, id=request_id)) - return self.wait_for(lambda m: m["t"] in ("profiles", "error") and m.get("re") == request_id, timeout, collect) + return self.wait_for(lambda m: m["t"] in answers and m.get("re") == request_id, timeout, collect) def close(self): self.sock.close() diff --git a/native/tests/test_engine.py b/native/tests/test_engine.py index 7873ed4..54d1c0d 100644 --- a/native/tests/test_engine.py +++ b/native/tests/test_engine.py @@ -145,6 +145,38 @@ def input_line(c): return c +def script_windows(c): + """The stand-in has no script windows or labels: it refuses their requests, once their fields are right.""" + refusal = "the stand-in engine has no script windows" + side = {"name": "side", "x": 0, "y": 0, "w": 300, "h": 200, "floating": False} + for request in ({"t": "setMainWindowSize", "w": 800, "h": 600}, dict(side, t="placeUserWindow"), + {"t": "closeUserWindow", "name": "side"}, {"t": "sendWindowCommandLine", "name": "chat", "text": "hi"}, + {"t": "pointer", "window": "box", "type": "press", "x": 1.5, "y": 2, "button": 1, "buttons": 1}, + {"t": "getImage", "path": "/no/such.png", "w": 16, "h": 16, "frame": 0}): + m = c.ask(request, answers=("error",)) + check(m["message"] == f"{request['t']}: {refusal}", f"{request['t']} is refused: {m}") + m = c.ask({"t": "getWindowText", "window": "chat", "first": -300, "count": 300}, answers=("error", "windowText")) + check(m["t"] == "error" and m["message"] == 'getWindowText: there is no console named "chat"', f"getWindowText answers error: {m}") + for request, message in ( + ({"t": "setMainWindowSize", "w": 0, "h": 600}, "w and h must be whole numbers above 0"), + ({"t": "setMainWindowSize", "w": 800.5, "h": 600}, "w and h must be whole numbers above 0"), + (dict(side, t="placeUserWindow", x="0"), "x, y, w and h must be whole numbers, w and h above 0"), + (dict(side, t="placeUserWindow", floating="no"), "floating must be true or false"), + ({"t": "getWindowText", "window": "chat", "first": 0, "count": 0}, "first and count must be whole numbers, count above 0"), + ({"t": "pointer", "window": "box", "type": "tap", "x": 1, "y": 2}, "type must be press, release, doubleClick, move, enter, leave or wheel"), + ({"t": "pointer", "window": "box", "type": "press", "x": "1", "y": 2}, "x and y must be numbers"), + ({"t": "pointer", "window": "box", "type": "press", "x": 1, "y": 2, "button": -1}, + "button, buttons and modifiers must be whole numbers from 0, wheelY a whole number"), + ({"t": "pointer", "window": "box", "type": "wheel", "x": 1, "y": 2, "wheelY": 0.5}, + "button, buttons and modifiers must be whole numbers from 0, wheelY a whole number"), + ({"t": "getImage", "path": "a.png", "w": 16}, "w and h go together, whole numbers from 1 to 4096"), + ({"t": "getImage", "path": "a.png", "w": 16, "h": 5000}, "w and h go together, whole numbers from 1 to 4096"), + ({"t": "getImage", "path": "a.png", "frame": -1}, "frame must be a whole number from 0"), + ({"t": "getImage", "path": ""}, "path must not be empty")): + m = c.ask(request, answers=("error",)) + check(m["message"] == f"{request['t']}: {message}", f"a bad field is refused first: {request} -> {m['message']}") + + def main(): faulthandler.register(signal.SIGUSR1) port = fake_telnet.start_in_thread() @@ -162,6 +194,9 @@ def main(): check(st.get("connection") == "disconnected", "initial connection status replayed") check(int(st.get("advanceM", "0")) > 0, f"font check: family={st.get('fontFamily')!r} advanceM={st.get('advanceM')} platform={st.get('platform')}") results["font"] = (st.get("fontFamily"), st.get("advanceM")) + windows = [m for m in hs if m["t"] == "windows"] + check(windows == [{"t": "windows", "full": True, "windows": []}], f"a UI that attaches is told there are no script windows: {windows}") + check(not any(m["t"] in ("mainArea", "windowText", "label") for m in hs), "nor a main area, window text or label") # Wrong protocol version is refused bad = Client(SOCK, version=99) @@ -251,6 +286,7 @@ def main(): c.send({"t": "unknownThing", "id": 3}) m = c.wait_for(lambda m: m["t"] == "error") check(m == {"t": "error", "message": "unknown type unknownThing", "re": 3}, f"an error carries the request's id: {m}") + script_windows(c) # Latency samples = [] diff --git a/native/tests/test_mudlet_engine.py b/native/tests/test_mudlet_engine.py index 4ab17a2..5b8ea6b 100644 --- a/native/tests/test_mudlet_engine.py +++ b/native/tests/test_mudlet_engine.py @@ -8,6 +8,7 @@ usage: test_mudlet_engine.py """ +import base64 import glob import os import signal @@ -17,6 +18,7 @@ import sys import tempfile import time +import zlib sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import fake_telnet # noqa: E402 @@ -255,6 +257,8 @@ def until_last_line(m): tls_checks(port) profile_requests(port) input_line() + script_windows() + script_labels() if failures: print(f"\n{len(failures)} FAILED; engine log: {LOG}") with open(LOG, errors="replace") as f: @@ -451,5 +455,333 @@ def input_line(): profile_checks.password_kept_out(check, secret, seen, LOG) +def window_of(msgs, name, kind): + """The last state the windows messages gave of a script window, or None.""" + found = None + for m in msgs: + if m["t"] == "windows": + found = next((w for w in m["windows"] if w["name"] == name and w["kind"] == kind), found) + return found + + +def windows_with(name, kind, pred=lambda w: True): + return lambda m: m["t"] == "windows" and any(w["name"] == name and w["kind"] == kind and pred(w) for w in m["windows"]) + + +def seen_or_next(c, pred, seen, since, timeout=5.0): + """The first message from seen[since:] that pred takes, or else the next one to come. The session sends a turn's + main console lines as it runs and its script windows, their text and command lines from the event loop after it, + so a wait for one of those has already collected a line the same turn wrote. On a timeout, says what came.""" + found = next((m for m in seen[since:] if pred(m)), None) + if found is not None: + return found + try: + return c.wait_for(pred, timeout=timeout, collect=seen) + except TimeoutError: + came = [str(m)[:200] for m in seen[since:]] + raise TimeoutError(f"message not received; since the request: {came}") from None + + +def script_windows(): + """What scripts draw: a Geyser MiniConsole and UserWindow as windows, with the console's text as windowText; the + main window's size from setMainWindowSize, and the borders a script sets, as mainArea; placeUserWindow raising + the resize event a script handles; a console's own command line both ways; hiding, closing and deleting; and all + of it again for a UI that attaches.""" + game = fake_telnet.start_in_thread() + proc, c = start_engine("Window Test") + seen = [] + c.wait_for(lambda m: m["t"] == "synced", collect=seen) + check({"t": "windows", "full": True, "windows": []} in seen and not any(m["t"] == "mainArea" for m in seen), + "with no profile open, a UI that attaches is told of no script windows, and no main area") + m = c.ask({"t": "getWindowText", "window": "chat", "first": 0, "count": 5}, answers=("windowText", "error"), collect=seen) + check(m["t"] == "error", f"getWindowText with no profile open is refused: {m}") + c.send({"t": "placeUserWindow", "name": "side", "x": 0, "y": 0, "w": 10, "h": 10, "floating": False}) + m = c.wait_for(lambda m: m["t"] == "error", collect=seen) + check(m["message"] == "placeUserWindow: no profile running", f"placeUserWindow with no profile open is refused: {m}") + + # Kept until the profile loads, which takes it + c.send({"t": "setMainWindowSize", "w": 1000, "h": 800}) + c.send({"t": "connect", "host": "127.0.0.1", "port": game}) + m = c.wait_for(lambda m: m["t"] == "mainArea", timeout=30, collect=seen) + check(m["w"] == 1000 and m["h"] == 800 and (m["textX"], m["textY"], m["textW"], m["textH"]) == (0, 0, 1000, 800) + and m["columns"] > 0 and m["rows"] > 0, f"the profile takes the size the UI gave before it loaded: {m}") + columns = m["columns"] + wait_status(c, "connection", lambda v: v == "connected", seen) + + since = len(seen) + c.send({"t": "runLua", "code": "\n".join([ + 'chat = Geyser.MiniConsole:new({name = "chat", x = 0, y = 0, width = "30%", height = "40%"})', + 'chat:cecho("red plain\\n")', + 'side = Geyser.UserWindow:new({name = "side"})', + 'registerAnonymousEventHandler("sysUserWindowResizeEvent", function(_, width, height, window)', + ' echo(string.format("resized %s %dx%d\\n", window, width, height))', + 'end)', + 'local mw, mh = getMainWindowSize()', + 'echo(string.format("main window %dx%d\\n", mw, mh))', + ])}) + m = seen_or_next(c, lambda m: m["t"] == "windowText" and m["window"] == "chat" and m["lines"] + and m["lines"][0]["text"] == "red plain", seen, since) + check("re" not in m and m["first"] == 0 and m["lineCount"] >= 1, f"chat:cecho() gives the console's text: {m}") + runs = m["lines"][0]["runs"] + check(bool(runs) and runs[0][:3] == [0, 3, 0x1000000 | 0xFF0000], f"with its colours: {runs}") + m = seen_or_next(c, lambda m: m["t"] in ("line", "tail") and m["text"].startswith("main window "), seen, since) + check(m["text"] == "main window 1000x800", f"scripts read the size the UI gave as the main window's: {m['text']}") + chat = window_of(seen, "chat", "miniConsole") + check(chat is not None and (chat["x"], chat["y"], chat["w"], chat["h"]) == (0, 0, 300, 320) and chat["parent"] == "" + and chat["shown"] and chat["visible"] and not chat["inDock"] and chat.get("columns", 0) > 0 and chat.get("fontSize", 0) > 0, + f"a Geyser MiniConsole is a miniConsole at its place in the main window: {chat}") + side = window_of(seen, "side", "userWindow") + check(side is not None and side["inDock"] and "dockArea" in side and "columns" in side, + f"a Geyser UserWindow is a userWindow in a dock: {side}") + + since = len(seen) + c.send({"t": "placeUserWindow", "name": "side", "x": 10, "y": 20, "w": 250, "h": 400, "floating": False}) + seen_or_next(c, shown("resized side 250x400"), seen, since) + seen_or_next(c, windows_with("side", "userWindow", lambda w: w["w"] == 250), seen, since) + side = window_of(seen, "side", "userWindow") + check((side["x"], side["y"], side["w"], side["h"]) == (10, 20, 250, 400), + f"placeUserWindow places it, and raises sysUserWindowResizeEvent: {side}") + since = len(seen) + c.send({"t": "placeUserWindow", "name": "chat", "x": 0, "y": 0, "w": 10, "h": 10, "floating": False}) + m = seen_or_next(c, lambda m: m["t"] == "error", seen, since) + check(m["message"].startswith("placeUserWindow: ") and "chat" in m["message"], f"a mini console is no user window: {m}") + + since = len(seen) + # The profile's mudlet-base-ui package keeps its container attached to the right border, so the text ends there + c.send({"t": "runLua", "code": 'setBorderLeft(100)\necho(string.format("right border %d\\n", getBorderRight()))'}) + m = seen_or_next(c, lambda m: m["t"] in ("line", "tail") and m["text"].startswith("right border "), seen, since) + right = int(m["text"].split()[-1]) + m = seen_or_next(c, lambda m: m["t"] == "mainArea", seen, since) + check(m["w"] == 1000 and (m["textX"], m["textW"]) == (100, 900 - right) and m["columns"] < columns, + f"setBorderLeft() moves the main console's text: {m}, right border {right}") + since = len(seen) + c.send({"t": "setMainWindowSize", "w": 1, "h": 1}) + m = seen_or_next(c, lambda m: m["t"] in ("error", "mainArea"), seen, since) + check(m["t"] == "error" and m["message"].startswith("setMainWindowSize: "), f"a size with no room for a character is refused: {m}") + since = len(seen) + c.send({"t": "setMainWindowSize", "w": 1200, "h": 800}) + m = seen_or_next(c, lambda m: m["t"] == "mainArea", seen, since) + check((m["w"], m["h"], m["textX"], m["textW"]) == (1200, 800, 100, 1100 - right), + f"a size given while the profile runs is taken at once: {m}") + + since = len(seen) + c.send({"t": "runLua", "code": "\n".join([ + 'enableCommandLine("chat")', + 'setCmdLineAction("chat", function(text) echo("acted " .. text .. "\\n") end)', + 'printCmdLine("chat", "typed")', + ])}) + m = seen_or_next(c, lambda m: m["t"] == "windowCommandLine" and m["text"], seen, since) + check(m == {"t": "windowCommandLine", "name": "chat", "text": "typed"}, f"printCmdLine() on a console's line: {m}") + line = window_of(seen, "chat", "commandLine") + check(line is not None and line["parent"] == "chat" and line.get("parentKind") == "miniConsole" and line["shown"], + f"enableCommandLine() gives the console a commandLine: {line}") + since = len(seen) + c.send({"t": "sendWindowCommandLine", "name": "chat", "text": "look"}) + seen_or_next(c, shown("acted look"), seen, since) + check(True, "sendWindowCommandLine runs the line's action") + + m = c.ask({"t": "getWindowText", "window": "chat", "first": 0, "count": 10}, answers=("windowText", "error"), collect=seen) + check(m["t"] == "windowText" and m["window"] == "chat" and m["first"] == 0 and m["lines"][0]["text"] == "red plain", + f"getWindowText answers with the rows: {m}") + m = c.ask({"t": "getWindowText", "window": "nonesuch", "first": 0, "count": 10}, answers=("windowText", "error"), + collect=seen) + check(m["t"] == "error" and "nonesuch" in m["message"], f"and refuses a console there is not: {m}") + + since = len(seen) + c.send({"t": "runLua", "code": "chat:hide()"}) + seen_or_next(c, windows_with("chat", "miniConsole", lambda w: not w["shown"]), seen, since) + chat = window_of(seen, "chat", "miniConsole") + line = window_of(seen, "chat", "commandLine") + check(not chat["visible"] and line is not None and line["visible"] and not line["shown"], + f"hiding it hides what it holds: {chat} {line}") + since = len(seen) + c.send({"t": "closeUserWindow", "name": "side"}) + seen_or_next(c, windows_with("side", "userWindow", lambda w: not w["visible"]), seen, since) + check(True, "closeUserWindow hides the user window") + + c.close() + c = Client(SOCK) + msgs = [] + c.wait_for(lambda m: m["t"] == "synced", collect=msgs) + seen += msgs + full = [m for m in msgs if m["t"] == "windows"] + keys = {(w["name"], w["kind"]) for w in full[0]["windows"]} if full else set() + check(len(full) == 1 and full[0]["full"] and {("chat", "miniConsole"), ("chat", "commandLine"), ("side", "userWindow")} <= keys, + f"a UI that attaches gets every window: {full}") + texts = {m["window"]: m for m in msgs if m["t"] == "windowText"} + check("chat" in texts and "side" in texts and texts["chat"]["lines"][0]["text"] == "red plain", + f"and each console's text: {list(texts)}") + areas = [m for m in msgs if m["t"] == "mainArea"] + check(len(areas) == 1 and (areas[0]["w"], areas[0]["textX"]) == (1200, 100), f"and the main area: {areas}") + + since = len(seen) + c.send({"t": "runLua", "code": 'deleteMiniConsole("chat")'}) + m = seen_or_next(c, lambda m: m["t"] == "windows" and m.get("removed"), seen, since) + check({"name": "chat", "kind": "miniConsole"} in m["removed"], f"a deleted console is removed: {m}") + code = stop_engine(proc) + check(code == 0, f"the engine exits cleanly on SIGTERM: {code}") + + + +def png_file(path, width, height): + """A PNG of width by height opaque red pixels.""" + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + rows = b"".join(b"\0" + b"\xff\0\0" * width for _ in range(height)) + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")) + + +def png_size(data): + """The width and height in a PNG's header, or None for what is not a PNG.""" + if data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR": + return None + return struct.unpack(">II", data[16:24]) + + +def label_of(msgs, name): + """The last label message for a label, or None.""" + return next((m for m in reversed(msgs) if m["t"] == "label" and m["name"] == name), None) + + +def script_labels(): + """What labels show: a Geyser.Label's text, stylesheet, image and accessible name as label messages, the image as + a PNG from getImage, and its click callback run by pointer; a Geyser.Gauge as three labels whose geometry gives + its value, with its text label transparent; a deleted label removed; and every label again for a UI that + attaches.""" + picture = os.path.join(TMP, "dot.png") + png_file(picture, 4, 3) + vector = os.path.join(TMP, "square.svg") + with open(vector, "w") as f: + f.write('') + game = fake_telnet.start_in_thread() + proc, c = start_engine("Label Test") + seen = [] + c.wait_for(lambda m: m["t"] == "synced", collect=seen) + m = c.ask({"t": "getImage", "path": picture}, answers=("image", "error"), collect=seen) + check(m["t"] == "image" and (m["w"], m["h"]) == (4, 3) and png_size(base64.b64decode(m["png"])) == (4, 3), + f"getImage needs no profile, and answers with the file as a PNG: {str(m)[:200]}") + c.send({"t": "pointer", "window": "main", "type": "move", "x": 1, "y": 1}) + m = c.wait_for(lambda m: m["t"] == "error", collect=seen) + check(m["message"] == "pointer: no profile running", f"pointer with no profile open is refused: {m}") + + c.send({"t": "setMainWindowSize", "w": 1000, "h": 800}) + c.send({"t": "connect", "host": "127.0.0.1", "port": game}) + wait_status(c, "connection", lambda v: v == "connected", seen, timeout=30) + + since = len(seen) + c.send({"t": "runLua", "code": "\n".join([ + 'box = Geyser.Label:new({name = "box", x = 20, y = 20, width = 120, height = 40})', + 'box:echo("hello label")', + 'box:setStyleSheet([[QLabel { border: 2px solid red; }]])', + 'box:setClickCallback(function(event) echo(string.format("clicked %s %d,%d\\n", event.button, event.x, event.y)) end)', + 'box:setAccessibleName("Hit me")', + 'box:setToolTip("a tip")', + 'pic = Geyser.Label:new({name = "pic", x = 200, y = 20, width = 40, height = 30})', + 'pic:echo("covered")', + f'pic:setBackgroundImage([==[{picture}]==])', + 'ico = Geyser.Label:new({name = "ico", x = 260, y = 20, width = 32, height = 32})', + f'ico:setBackgroundImage([==[{vector}]==])', + 'ico:setSvgTint(0, 128, 255)', + 'ico:setSvgRotation(90)', + ])}) + m = seen_or_next(c, lambda m: m["t"] == "label" and m["name"] == "box" and m["accessibleName"] == "Hit me", seen, since) + check("hello label" in m["text"] and m["styleSheet"] == "QLabel { border: 2px solid red; }" and m["accessibleButton"] + and m["toolTip"] == "a tip" and m["image"] == "" and not m["clickThrough"] and m["movie"] == "", + f"a Geyser.Label's echo(), stylesheet, click callback, accessible name and tool tip: {m}") + first_label = next(i for i, m in enumerate(seen) if m["t"] == "label" and m["name"] == "box") + first_window = next((i for i, m in enumerate(seen) if windows_with("box", "label")(m)), len(seen)) + check(first_window < first_label, "the label comes after the windows message that has it") + box = window_of(seen, "box", "label") + check(box is not None and (box["x"], box["y"], box["w"], box["h"]) == (20, 20, 120, 40) and box["shown"], + f"and is a label window at its place: {box}") + m = seen_or_next(c, lambda m: m["t"] == "label" and m["name"] == "pic" and m["image"], seen, since) + check(m["image"] == picture and m["text"] == "" and not m["svg"], f"setBackgroundImage() shows the file in place of the text: {m}") + m = seen_or_next(c, lambda m: m["t"] == "label" and m["name"] == "ico" and m.get("svgRotation"), seen, since) + check(m["image"] == vector and m["svg"] and m.get("svgTint") == 0x1000000 | 0x0080FF and "svgTintAlpha" not in m + and m["svgRotation"] == 90, f"an SVG one with its tint and rotation: {m}") + + m = c.ask({"t": "getImage", "path": picture, "w": 8, "h": 6}, answers=("image", "error"), collect=seen) + check(m["t"] == "image" and (m["w"], m["h"]) == (8, 6) and png_size(base64.b64decode(m["png"])) == (8, 6) + and m["path"] == picture, f"getImage scales it to the size asked: {str(m)[:200]}") + m = c.ask({"t": "getImage", "path": picture, "frame": 0}, answers=("image", "error"), collect=seen) + check(m["t"] == "image" and (m["w"], m["h"]) == (4, 3), f"a still image's frame 0 is the image: {str(m)[:200]}") + m = c.ask({"t": "getImage", "path": picture, "frame": 1}, answers=("image", "error"), collect=seen) + check(m["t"] == "error" and m["message"] == f"getImage: no frame 1 in {picture}", f"and it has no frame 1: {m}") + m = c.ask({"t": "getImage", "path": os.path.join(TMP, "nonesuch.png")}, answers=("image", "error"), collect=seen) + check(m["t"] == "error" and m["message"].startswith("getImage: ") and "nonesuch.png" in m["message"], + f"and refuses a file there is not: {m}") + + since = len(seen) + c.send({"t": "pointer", "window": "box", "type": "press", "x": 5, "y": 3, "button": 1, "buttons": 1}) + c.send({"t": "pointer", "window": "box", "type": "release", "x": 5, "y": 3, "button": 1}) + c.send({"t": "runLua", "code": 'echo("after the click\\n")'}) + seen_or_next(c, shown("after the click"), seen, since) + # A line, as the echo after it ends it + clicks = [m["text"] for m in lines_of(seen[since:]) if m["text"].startswith("clicked")] + errors = [m for m in seen[since:] if m["t"] == "error"] + check(clicks == ["clicked LeftButton 5,3"] and not errors, + f"a pointer press runs the label's click callback, with the event table, and the release does not: {clicks} {errors}") + since = len(seen) + c.send({"t": "pointer", "window": "nonesuch", "type": "press", "x": 5, "y": 3, "button": 1, "buttons": 1}) + m = seen_or_next(c, lambda m: m["t"] == "error", seen, since) + check(m["message"].startswith("pointer: ") and "nonesuch" in m["message"], f"pointer on a window there is not is refused: {m}") + since = len(seen) + c.send({"t": "pointer", "window": "box", "type": "move", "x": 500, "y": 3}) + m = seen_or_next(c, lambda m: m["t"] == "error", seen, since) + check(m["message"].startswith("pointer: "), f"and outside the window: {m}") + + since = len(seen) + c.send({"t": "runLua", "code": 'box:echo("new words")'}) + m = seen_or_next(c, lambda m: m["t"] == "label" and m["name"] == "box" and "new words" in m["text"], seen, since) + check(m["accessibleName"] == "Hit me" and not any(windows_with("box", "label")(w) for w in seen[since:]), + f"a label whose content alone changed is sent again, with no windows message for it: {m}") + + since = len(seen) + c.send({"t": "runLua", "code": "\n".join([ + 'hp = Geyser.Gauge:new({name = "hp", x = 10, y = 600, width = 200, height = 20})', + 'hp:setValue(30, 60, "30/60")', + ])}) + seen_or_next(c, windows_with("hp_front", "label", lambda w: w["w"] == 100), seen, since) + m = seen_or_next(c, lambda m: m["t"] == "label" and m["name"] == "hp_text" and "30/60" in m["text"], seen, since) + back, front, text = (window_of(seen, f"hp_{part}", "label") for part in ("back", "front", "text")) + check(back is not None and (back["parent"], back["x"], back["y"], back["w"], back["h"]) == ("", 10, 600, 200, 20) + and back.get("bgAlpha") == 100 and "bg" in back, f"a Geyser.Gauge's back label is the gauge, half see-through: {back}") + check((front["x"], front["y"], front["w"], front["h"]) == (10, 600, 100, 20) and "bgAlpha" not in front, + f"its front label's width against the back's is the value: {front}") + check(text is not None and (text["x"], text["w"]) == (10, 200) and text.get("bgAlpha") == 0 + and back["stack"] < front["stack"] < text["stack"], f"and its text label is transparent, on top: {text}") + since = len(seen) + c.send({"t": "runLua", "code": "hp:setValue(15, 60)"}) + seen_or_next(c, windows_with("hp_front", "label", lambda w: w["w"] == 50), seen, since) + c.send({"t": "runLua", "code": 'echo("gauge sent\\n")'}) + seen_or_next(c, shown("gauge sent"), seen, since) + front = window_of(seen, "hp_front", "label") + resent = [m["name"] for m in seen[since:] if m["t"] == "label"] + check((front["x"], front["w"]) == (10, 50) and not resent, + f"a new value resizes the front label, and sends no label whose content stayed: {front} {resent}") + + c.close() + c = Client(SOCK) + msgs = [] + c.wait_for(lambda m: m["t"] == "synced", collect=msgs) + seen += msgs + full = next((i for i, m in enumerate(msgs) if m["t"] == "windows" and m.get("full")), None) + names = {m["name"]: i for i, m in enumerate(msgs) if m["t"] == "label"} + check(full is not None and {"box", "pic", "ico", "hp_back", "hp_front", "hp_text"} <= set(names) + and all(i > full for i in names.values()), f"a UI that attaches gets every label after the windows: {sorted(names)}") + box = label_of(msgs, "box") + check(box is not None and box["accessibleName"] == "Hit me" and "new words" in box["text"], f"as it was: {box}") + + since = len(seen) + c.send({"t": "runLua", "code": 'deleteLabel("pic")'}) + m = seen_or_next(c, lambda m: m["t"] == "windows" and m.get("removed"), seen, since) + check({"name": "pic", "kind": "label"} in m["removed"] and label_of(seen[since:], "pic") is None, + f"a deleted label is removed, with no label message: {m}") + 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/Gauge.kt b/protocol/src/main/kotlin/org/mudlet/android/protocol/Gauge.kt new file mode 100644 index 0000000..6a6401d --- /dev/null +++ b/protocol/src/main/kotlin/org/mudlet/android/protocol/Gauge.kt @@ -0,0 +1,44 @@ +package org.mudlet.android.protocol + +/** + * A Geyser gauge (docs/phase-f.md, "Gauges"): the labels `_back`, `_front` and, if there, `_text` + * in one parent. Upstream gives no gauge its value (gap 2), so it is read from the labels: the front's width against + * the back's, or its height in a vertical gauge, from 0 to 1. + */ +data class Gauge(val name: String, val back: ScriptWindow, val front: ScriptWindow, val text: ScriptWindow?, val value: Float) { + /** The value as a whole percentage, as a screen reader says it. */ + val percent: Int get() = Math.round(value * 100) + + /** The gauge's three labels, by key. */ + val parts: Set get() = setOfNotNull(back.key, front.key, text?.key) + + companion object { + const val BACK = "_back" + const val FRONT = "_front" + const val TEXT = "_text" + + /** The gauges among [windows], by their back label's name. */ + fun of(windows: List): Map { + val labels = windows.filter { it.kind == WindowKind.LABEL } + val byName = labels.groupBy { it.name } + val out = HashMap() + for (back in labels) { + if (!back.name.endsWith(BACK) || !back.shown) continue + val name = back.name.removeSuffix(BACK) + fun sibling(suffix: String) = byName[name + suffix]?.firstOrNull { it.parent == back.parent && it.parentKind == back.parentKind } + val front = sibling(FRONT) ?: continue + out[back.name] = Gauge(name, back, front, sibling(TEXT), value(back, front)) + } + return out + } + + // A horizontal gauge's front is as high as its back, less the back's margins, and shorter across; a vertical + // one's the other way round + private fun value(back: ScriptWindow, front: ScriptWindow): Float { + if (!front.shown) return 0f + val vertical = back.w - front.w < back.h - front.h + val (part, whole) = if (vertical) front.h to back.h else front.w to back.w + return if (whole > 0) (part.toFloat() / whole).coerceIn(0f, 1f) else 0f + } + } +} diff --git a/protocol/src/main/kotlin/org/mudlet/android/protocol/LabelLink.kt b/protocol/src/main/kotlin/org/mudlet/android/protocol/LabelLink.kt new file mode 100644 index 0000000..19c1d2a --- /dev/null +++ b/protocol/src/main/kotlin/org/mudlet/android/protocol/LabelLink.kt @@ -0,0 +1,76 @@ +package org.mudlet.android.protocol + +import org.json.JSONObject + +/** + * What a tapped link in a label's text does, as Mudlet's `TLabel::slot_linkActivated()` decides it. Upstream's + * headless session cannot follow a label's link (docs/phase-f.md, "Upstream gaps", 1), so until it can, the app does + * it: [request] is what to send the engine for it, and a web link is the app's to open. A visited link's style is not + * kept. + */ +sealed interface LabelLink { + /** `send:`: the command, through the aliases as the command line sends it. */ + data class Send(val command: String) : LabelLink + + /** `prompt:`: the text, put on the input line for the player to send. */ + data class Prompt(val text: String) : LabelLink + + /** `http:` and `https:`, opened in the browser. */ + data class Web(val url: String) : LabelLink + + /** Anything else that is a Lua command, run as one. */ + data class Lua(val code: String) : LabelLink + + /** A scheme Mudlet does not handle, such as `ftp:` or `mailto:`, on what is no Lua method call: not run. */ + data class NotRun(val scheme: String) : LabelLink + + /** What to send the engine for this link, with [id] for its answer; null for one the engine has no part in. */ + fun request(id: Long): JSONObject? = when (this) { + is Send -> Requests.runLua("expandAlias(${luaString(command)})", id) + is Prompt -> Requests.runLua("printCmdLine(${luaString(text)})", id) + is Lua -> Requests.runLua(code, id) + is Web, is NotRun -> null + } + + companion object { + private val SCHEME_NAME = Regex("^[a-z][a-z0-9+.-]*$") + + // Lua allows whitespace and --[[ ]] comments on either side of a method's name + private val METHOD_CALL = Regex("^(?:\\s|--\\[(=*)\\[.*?\\]\\1\\])*[A-Za-z_][A-Za-z0-9_]*(?:\\s|--\\[(=*)\\[.*?\\]\\2\\])*[(\"'{\\[]") + + /** What following [link], a label link's `href`, does. */ + fun of(link: String): LabelLink { + // A browser also drops the whitespace around an href before reading its scheme + val href = link.trim() + val colon = href.indexOf(':') + if (colon > 0) { + val scheme = href.substring(0, colon).lowercase() + // Untrimmed: a send: or prompt: payload can end in a space the player means to keep + val payload = link.substring(link.indexOf(':') + 1) + when { + scheme == "send" -> return Send(payload) + scheme == "prompt" -> return Prompt(payload) + scheme == "http" || scheme == "https" -> return Web(href) + scheme == "javascript" || (SCHEME_NAME.matches(scheme) && !METHOD_CALL.containsMatchIn(payload)) -> return NotRun(scheme) + } + } + return Lua(link) + } + + /** [text] as a Lua string literal. */ + fun luaString(text: String): String { + val out = StringBuilder("\"") + for (c in text) { + when { + c == '\\' -> out.append("\\\\") + c == '"' -> out.append("\\\"") + c == '\n' -> out.append("\\n") + c == '\r' -> out.append("\\r") + c < ' ' || c == '\u007F' -> out.append('\\').append(c.code.toString().padStart(3, '0')) + else -> out.append(c) + } + } + return out.append('"').toString() + } + } +} diff --git a/protocol/src/main/kotlin/org/mudlet/android/protocol/LabelStyle.kt b/protocol/src/main/kotlin/org/mudlet/android/protocol/LabelStyle.kt new file mode 100644 index 0000000..2d0153e --- /dev/null +++ b/protocol/src/main/kotlin/org/mudlet/android/protocol/LabelStyle.kt @@ -0,0 +1,386 @@ +package org.mudlet.android.protocol + +import kotlin.math.roundToInt + +/** How a label's text sits in it across and down: Qt's alignment flags, from `qproperty-alignment` or `align`. */ +enum class HAlign { LEFT, CENTER, RIGHT, JUSTIFY } + +enum class VAlign { TOP, CENTER, BOTTOM } + +/** What fills a label's background: one colour, or a linear gradient across its box. Colours are 0xAARRGGBB. */ +sealed interface LabelFill { + data class Solid(val argb: Int) : LabelFill + + /** From ([x1], [y1]) to ([x2], [y2]), in fractions of the box; [stops] are positions from 0 to 1 and their colours. */ + data class Gradient(val x1: Float, val y1: Float, val x2: Float, val y2: Float, val stops: List>) : LabelFill +} + +/** Four lengths in Mudlet's pixels, as CSS gives `padding` and `margin`. */ +data class Edges(val top: Float, val right: Float, val bottom: Float, val left: Float) { + companion object { + val ZERO = Edges(0f, 0f, 0f, 0f) + } +} + +/** + * What the app draws of a label's style sheet (docs/phase-f.md, "Labels"): the subset of Qt's style sheets for a + * `QLabel` that Geyser and scripts write, made by [parse]. Lengths are in Mudlet's pixels, colours 0xAARRGGBB; null + * is what the sheet did not set. + */ +data class LabelStyle( + val background: LabelFill? = null, + val color: Int? = null, + val borderWidth: Float = 0f, + /** Qt draws a border only with a style; any but `none` is drawn solid. */ + val borderStyled: Boolean = false, + /** Null for the text's colour, as Qt's. */ + val borderColor: Int? = null, + val borderRadius: Float = 0f, + val padding: Edges = Edges.ZERO, + val margin: Edges = Edges.ZERO, + val fontSizePx: Float? = null, + /** From 100 to 900; 400 is normal, 700 bold. */ + val fontWeight: Int? = null, + val italic: Boolean? = null, + val fontFamily: String? = null, + val hAlign: HAlign? = null, + val vAlign: VAlign? = null, + /** The file `border-image` stretches over the box. */ + val borderImage: String? = null, + /** The file `background-image` tiles the box with, at its own size. */ + val backgroundImage: String? = null, +) { + /** The width of the border drawn: none without a style. */ + val drawnBorderWidth: Float get() = if (borderStyled) borderWidth else 0f + + companion object { + /** + * The label's own [sheet]: its bare declarations and those of a `QLabel` (or `TLabel`, or `*`) rule, in + * order. A rule with any other selector, a state or a sub-control is skipped, as is a declaration the app does + * not draw. + */ + fun parse(sheet: String): LabelStyle { + var style = LabelStyle() + for ((property, value) in declarations(sheet)) style = style.with(property, value) + return style + } + + /** The declarations of [sheet] that apply to a label, in order, each its lower-case property and its value. */ + fun declarations(sheet: String): List> { + val text = COMMENT.replace(sheet, " ") + val out = ArrayList>() + var i = 0 + var start = 0 + while (i < text.length) { + when (text[i]) { + '{' -> { + // What is before the brace since the last declaration is the selector + val head = text.substring(start, i) + val selector = head.substringAfterLast(';') + out.addAll(parseDeclarations(head.substring(0, head.length - selector.length))) + val end = text.indexOf('}', i + 1).let { if (it < 0) text.length else it } + if (appliesToLabel(selector)) out.addAll(parseDeclarations(text.substring(i + 1, end))) + i = end + 1 + start = i + continue + } + + '(' -> i = text.indexOf(')', i + 1).let { if (it < 0) text.length - 1 else it } + + '"', '\'' -> i = text.indexOf(text[i], i + 1).let { if (it < 0) text.length - 1 else it } + } + i++ + } + out.addAll(parseDeclarations(text.substring(start.coerceAtMost(text.length)))) + return out + } + + /** `a: b; c: d`, as a style sheet's block or a rich text's `style` attribute has them. */ + fun parseDeclarations(block: String): List> = splitTopLevel(block, ';').mapNotNull { declaration -> + val colon = declaration.indexOf(':') + if (colon <= 0) return@mapNotNull null + val property = declaration.substring(0, colon).trim().lowercase() + val value = declaration.substring(colon + 1).trim().removeSuffix("!important").trim() + if (property.isEmpty() || value.isEmpty()) null else property to value + } + + private fun appliesToLabel(selector: String): Boolean = selector.split(',').any { it.trim() in LABEL_SELECTORS } + + /** + * A length in Mudlet's pixels: `px` or none, or `pt` at Qt's 96 dots per inch; null for another unit, or for + * what is not a length. + */ + fun length(value: String): Float? { + val match = LENGTH.matchEntire(value.trim().lowercase()) ?: return null + val n = match.groupValues[1].toFloatOrNull() ?: return null + return when (match.groupValues[2]) { + "", "px" -> n + "pt" -> n * PX_PER_PT + else -> null + } + } + + /** A CSS `font-weight`: a keyword or a number from 100 to 900. */ + fun fontWeight(value: String): Int? = when (val v = value.trim().lowercase()) { + "normal" -> 400 + "bold", "bolder" -> 700 + "lighter" -> 300 + else -> v.toIntOrNull()?.takeIf { it in 1..1000 } + } + + private fun alignment(value: String): Pair { + var h: HAlign? = null + var v: VAlign? = null + for (flag in value.split('|', ' ', ',').map { it.trim().trim('\'', '"').removePrefix("Qt::") }) { + when (flag) { + "AlignLeft", "AlignLeading" -> h = HAlign.LEFT + + "AlignRight", "AlignTrailing" -> h = HAlign.RIGHT + + "AlignHCenter" -> h = HAlign.CENTER + + "AlignJustify" -> h = HAlign.JUSTIFY + + "AlignTop" -> v = VAlign.TOP + + "AlignBottom" -> v = VAlign.BOTTOM + + "AlignVCenter" -> v = VAlign.CENTER + + "AlignCenter" -> { + h = HAlign.CENTER + v = VAlign.CENTER + } + } + } + return h to v + } + + private fun edges(value: String): Edges? { + val parts = splitTopLevel(value, ' ').map { length(it) ?: return null } + return when (parts.size) { + 1 -> Edges(parts[0], parts[0], parts[0], parts[0]) + 2 -> Edges(parts[0], parts[1], parts[0], parts[1]) + 3 -> Edges(parts[0], parts[1], parts[2], parts[1]) + 4 -> Edges(parts[0], parts[1], parts[2], parts[3]) + else -> null + } + } + + private fun url(value: String): String? = URL.find(value)?.groupValues?.get(1)?.trim()?.trim('"', '\'') + + private fun LabelStyle.with(property: String, value: String): LabelStyle = when (property) { + "background", "background-color" -> { + var s = this + for (part in splitTopLevel(value, ' ')) { + val fill = fill(part) + when { + fill != null -> s = s.copy(background = fill) + property == "background" && part.startsWith("url(", ignoreCase = true) -> s = s.copy(backgroundImage = url(part)) + part.equals("none", ignoreCase = true) -> s = s.copy(background = null) + } + } + s + } + + "color" -> CssColor.parse(value)?.let { copy(color = it) } ?: this + + "border" -> { + var s = this + for (part in splitTopLevel(value, ' ')) { + val width = length(part) + val color = CssColor.parse(part) + when { + width != null -> s = s.copy(borderWidth = width) + part.lowercase() in BORDER_STYLES -> s = s.copy(borderStyled = part.lowercase() != "none") + color != null -> s = s.copy(borderColor = color) + } + } + s + } + + "border-width" -> length(splitTopLevel(value, ' ').first())?.let { copy(borderWidth = it) } ?: this + + "border-style" -> value.lowercase().takeIf { it in BORDER_STYLES }?.let { copy(borderStyled = it != "none") } ?: this + + "border-color" -> CssColor.parse(splitTopLevel(value, ' ').first())?.let { copy(borderColor = it) } ?: this + + "border-radius" -> length(splitTopLevel(value, ' ').first())?.let { copy(borderRadius = it) } ?: this + + "padding" -> edges(value)?.let { copy(padding = it) } ?: this + + "margin" -> edges(value)?.let { copy(margin = it) } ?: this + + "font-size" -> length(value)?.let { copy(fontSizePx = it) } ?: this + + "font-weight" -> fontWeight(value)?.let { copy(fontWeight = it) } ?: this + + "font-style" -> copy(italic = value.trim().lowercase() in setOf("italic", "oblique")) + + "font-family" -> copy(fontFamily = value.split(',').first().trim().trim('"', '\'')) + + "qproperty-alignment" -> alignment(value).let { (h, v) -> copy(hAlign = h ?: hAlign, vAlign = v ?: vAlign) } + + "border-image" -> copy(borderImage = url(value)) + + "background-image" -> copy(backgroundImage = url(value)) + + else -> this + } + + private fun fill(value: String): LabelFill? { + if (value.startsWith("qlineargradient", ignoreCase = true)) return linearGradient(value) + if (value.startsWith("qradialgradient", ignoreCase = true) || value.startsWith("qconicalgradient", ignoreCase = true)) { + // Drawn as the colour of its first stop + return gradientStops(value).firstOrNull()?.let { LabelFill.Solid(it.second) } + } + return CssColor.parse(value)?.let { LabelFill.Solid(it) } + } + + private fun gradientArguments(value: String): List> { + val inner = value.substringAfter('(', "").substringBeforeLast(')', "") + return splitTopLevel(inner, ',').mapNotNull { argument -> + val colon = argument.indexOf(':') + if (colon <= 0) null else argument.substring(0, colon).trim().lowercase() to argument.substring(colon + 1).trim() + } + } + + private fun gradientStops(value: String): List> = gradientArguments(value).filter { it.first == "stop" }.mapNotNull { (_, stop) -> + val parts = splitTopLevel(stop, ' ') + val position = parts.firstOrNull()?.toFloatOrNull() ?: return@mapNotNull null + val color = CssColor.parse(parts.drop(1).joinToString(" ")) ?: return@mapNotNull null + position.coerceIn(0f, 1f) to color + } + + private fun linearGradient(value: String): LabelFill? { + val args = gradientArguments(value).toMap() + val stops = gradientStops(value) + if (stops.isEmpty()) return null + fun coordinate(name: String) = args[name]?.toFloatOrNull() ?: 0f + return LabelFill.Gradient(coordinate("x1"), coordinate("y1"), coordinate("x2"), coordinate("y2"), stops) + } + + /** [text] split at each [separator] outside parentheses and quotes, the parts trimmed and the empty ones dropped. */ + fun splitTopLevel(text: String, separator: Char): List { + val parts = ArrayList() + var depth = 0 + var quote: Char? = null + var start = 0 + for ((i, c) in text.withIndex()) { + when { + quote != null -> if (c == quote) quote = null + + c == '"' || c == '\'' -> quote = c + + c == '(' -> depth++ + + c == ')' -> depth = (depth - 1).coerceAtLeast(0) + + depth == 0 && (c == separator || (separator == ' ' && c.isWhitespace())) -> { + parts.add(text.substring(start, i)) + start = i + 1 + } + } + } + parts.add(text.substring(start)) + return parts.map { it.trim() }.filter { it.isNotEmpty() } + } + + /** Mudlet's pixels per point, at the 96 dots per inch Qt takes. */ + const val PX_PER_PT = 96f / 72f + + private val LABEL_SELECTORS = setOf("QLabel", "TLabel", "*") + private val BORDER_STYLES = setOf("none", "solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset", "dot-dash", "dot-dot-dash") + private val COMMENT = Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL) + private val LENGTH = Regex("(-?[0-9]*\\.?[0-9]+)\\s*([a-z]*)") + private val URL = Regex("url\\(([^)]*)\\)", RegexOption.IGNORE_CASE) + } +} + +/** The colours Qt's style sheets and rich text take, as 0xAARRGGBB. */ +object CssColor { + /** + * [value] as a colour: a name of SVG's (and `transparent`), `#rgb`, `#rrggbb`, `#aarrggbb` (Qt's order), + * `rgb()` or `rgba()` with numbers or percentages, the alpha from 0 to 255, or from 0 to 1 when written with a + * point; null for anything else. + */ + fun parse(value: String): Int? { + val v = value.trim().lowercase() + if (v.startsWith("#")) return hex(v.substring(1)) + if (v.startsWith("rgb")) return rgb(v) + return NAMES[v.replace(" ", "")] + } + + private fun hex(digits: String): Int? { + if (digits.any { Character.digit(it, 16) < 0 }) return null + val n = digits.toLongOrNull(16) ?: return null + return when (digits.length) { + 3 -> { + val r = (n shr 8 and 0xF) * 17 + val g = (n shr 4 and 0xF) * 17 + val b = (n and 0xF) * 17 + (0xFF000000 or (r shl 16) or (g shl 8) or b).toInt() + } + + 6 -> (0xFF000000 or n).toInt() + + 8 -> n.toInt() + + else -> null + } + } + + private fun rgb(v: String): Int? { + val match = Regex("rgba?\\(([^)]*)\\)").matchEntire(v) ?: return null + val parts = match.groupValues[1].split(',').map { it.trim() } + if (parts.size !in 3..4) return null + val channels = parts.take(3).map { channel(it, 255f) ?: return null } + val alpha = parts.getOrNull(3)?.let { a -> + if (!a.endsWith("%") && a.contains('.')) a.toFloatOrNull()?.let { (it * 255).roundToInt() } else channel(a, 255f) + } ?: if (parts.size == 4) return null else 255 + val (r, g, b) = channels + return (alpha.coerceIn(0, 255) shl 24) or (r shl 16) or (g shl 8) or b + } + + private fun channel(text: String, full: Float): Int? { + val n = if (text.endsWith("%")) text.removeSuffix("%").trim().toFloatOrNull()?.let { it / 100 * full } else text.toFloatOrNull() + return n?.roundToInt()?.coerceIn(0, 255) + } + + private val NAMES: Map by lazy { + val names = HashMap() + names["transparent"] = 0 + for (entry in SVG_NAMES.split(' ')) { + val (name, rgb) = entry.split(':') + names[name] = (0xFF000000 or rgb.toLong(16)).toInt() + } + names + } + + // SVG 1.1's colour keywords, which Qt's QColor takes by name + private const val SVG_NAMES = + "aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc bisque:ffe4c4 " + + "black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a burlywood:deb887 cadetblue:5f9ea0 " + + "chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed cornsilk:fff8dc crimson:dc143c cyan:00ffff " + + "darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b darkgray:a9a9a9 darkgreen:006400 darkgrey:a9a9a9 " + + "darkkhaki:bdb76b darkmagenta:8b008b darkolivegreen:556b2f darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 " + + "darksalmon:e9967a darkseagreen:8fbc8f darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f " + + "darkturquoise:00ced1 darkviolet:9400d3 deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 " + + "dodgerblue:1e90ff firebrick:b22222 floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc " + + "ghostwhite:f8f8ff gold:ffd700 goldenrod:daa520 gray:808080 grey:808080 green:008000 greenyellow:adff2f " + + "honeydew:f0fff0 hotpink:ff69b4 indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa " + + "lavenderblush:fff0f5 lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 lightcyan:e0ffff " + + "lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 lightpink:ffb6c1 " + + "lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 lightslategrey:778899 " + + "lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 magenta:ff00ff maroon:800000 " + + "mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 mediumpurple:9370db mediumseagreen:3cb371 " + + "mediumslateblue:7b68ee mediumspringgreen:00fa9a mediumturquoise:48d1cc mediumvioletred:c71585 " + + "midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 moccasin:ffe4b5 navajowhite:ffdead navy:000080 " + + "oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 orange:ffa500 orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa " + + "palegreen:98fb98 paleturquoise:afeeee palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f " + + "pink:ffc0cb plum:dda0dd powderblue:b0e0e6 purple:800080 red:ff0000 rosybrown:bc8f8f royalblue:4169e1 " + + "saddlebrown:8b4513 salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee sienna:a0522d silver:c0c0c0 " + + "skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa springgreen:00ff7f " + + "steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 turquoise:40e0d0 violet:ee82ee " + + "wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 yellowgreen:9acd32" +} 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..97a0e12 100644 --- a/protocol/src/main/kotlin/org/mudlet/android/protocol/Protocol.kt +++ b/protocol/src/main/kotlin/org/mudlet/android/protocol/Protocol.kt @@ -6,10 +6,11 @@ import java.io.DataInputStream import java.io.EOFException import java.io.InputStream import java.io.OutputStream +import java.util.Base64 // 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 = 8 private const val MAX_FRAME_BYTES = 16 * 1024 * 1024 // A run's colours: -1 default, 0-15 ANSI palette, RUN_RGB | 0xRRGGBB exact @@ -26,9 +27,124 @@ const val RUN_BLINK = 64 const val RUN_FAST_BLINK = 128 const val RUN_CONCEALED = 256 +// Qt's mouse buttons and keyboard modifiers, as Requests.pointer takes them +const val POINTER_LEFT = 1 +const val POINTER_RIGHT = 2 +const val POINTER_MIDDLE = 4 +const val MODIFIER_SHIFT = 0x02000000 +const val MODIFIER_CONTROL = 0x04000000 +const val MODIFIER_ALT = 0x08000000 +const val MODIFIER_META = 0x10000000 + /** A profile as the engine lists it; the password is never part of one. */ data class Profile(val name: String, val host: String, val port: Int, val tls: Boolean, val login: String, val autoReconnect: Boolean) +/** A script window's kind, as upstream names it on the wire. */ +enum class WindowKind(val wire: String) { + MINI_CONSOLE("miniConsole"), + USER_WINDOW("userWindow"), + LABEL("label"), + COMMAND_LINE("commandLine"), + TEXT_BOX("textBox"), + SCROLL_BOX("scrollBox"), + MAPPER("mapper"), + MXP_FRAME("mxpFrame"), + ; + + companion object { + /** Null for a kind this app does not know, whose windows it leaves out. */ + fun fromWire(wire: String): WindowKind? = entries.firstOrNull { it.wire == wire } + } +} + +/** A script window is known by its name and kind, as a console's own command line has its console's name. */ +data class WindowKey(val name: String, val kind: WindowKind) + +/** A window a profile's scripts made, its geometry in Mudlet's pixels within its parent's (docs/protocol.md, `windows`). */ +data class ScriptWindow( + val name: String, + val kind: WindowKind, + /** Empty for the main window's area, a dock or an external MXP frame, which have no [parentKind]. */ + val parent: String, + val parentKind: WindowKind?, + val x: Int, + val y: Int, + val w: Int, + val h: Int, + val visible: Boolean, + val shown: Boolean, + val stack: Int, + /** As a run's colour; -1 for none. */ + val bg: Int, + /** [bg]'s alpha, from 0 (transparent) to 255, which a label's may be below. */ + val bgAlpha: Int, + val fontFamily: String?, + /** In points; 0 for none. */ + val fontSize: Double, + val inDock: Boolean, + /** A user window's. */ + val dock: Dock?, + /** A mini console's, user window's or MXP frame's. */ + val console: Console?, +) { + val key get() = WindowKey(name, kind) + + /** [area] is left, right, top or bottom. */ + data class Dock(val autoDock: Boolean, val area: String, val floating: Boolean, val title: String) + + /** [scrolledBackTo] is the row it is scrolled back to, null while it follows new lines. */ + data class Console(val scrolledBackTo: Int?, val columns: Int, val rows: Int, val scrollBar: Boolean, val timeStamps: Boolean) +} + +/** What a label shows, as THeadlessSession::labelContent() gives it (docs/protocol.md, `label`). */ +data class LabelContent( + val name: String, + /** Rich text, as echo() or setLabelText() gave it; empty while an image or movie shows in its place. */ + val text: String, + /** The file setBackgroundImage() gave it, a raster image or, with [svg], an SVG drawn under the text. */ + val image: String, + val svg: Boolean, + /** As a run's colour, -1 for none, with its alpha from 0 to 255. */ + val svgTint: Int, + val svgTintAlpha: Int, + /** In degrees. */ + val svgRotation: Double, + val svgShearX: Double, + val svgShearY: Double, + /** The file setMovie() gave it, shown in place of the text. */ + val movie: String, + val moviePaused: Boolean, + /** The frame a paused movie shows; -1 while it plays. */ + val movieFrame: Int, + /** In percent of the file's own speed. */ + val movieSpeed: Int, + /** The size scaleMovie() gave it; null for the file's own. */ + val movieW: Int?, + val movieH: Int?, + val toolTip: String, + /** The pointer passes through it to what is under it. */ + val clickThrough: Boolean, + val accessibleName: String, + /** It has a click or release callback, so a screen reader is told it is a button. */ + val accessibleButton: Boolean, + /** As setLabelStyleSheet() gave it, which the engine does not interpret. */ + val styleSheet: String, +) + +/** What a pointer did, as upstream's WindowPointerEvent names it on the wire. */ +enum class PointerType(val wire: String) { + PRESS("press"), + RELEASE("release"), + DOUBLE_CLICK("doubleClick"), + MOVE("move"), + ENTER("enter"), + LEAVE("leave"), + WHEEL("wheel"), +} + +/** A row of a script window's console: [runs] as [EngineMessage.Line]'s, but a bg of -1 is the window's bg. */ +data class WindowRow(val text: String, val runs: IntArray, val prompt: Boolean, val wrapped: Boolean) + sealed interface EngineMessage { data class Hello(val version: Int, val pid: Int, val instance: String) : EngineMessage data class Line(val seq: Long, val text: String, val runs: IntArray, val prompt: Boolean, val replay: Boolean) : EngineMessage @@ -54,6 +170,24 @@ sealed interface EngineMessage { /** A script set the whole input line to [text]. */ data class CommandLine(val text: String) : EngineMessage + + /** The script windows: with [full], all of them; otherwise those that changed, and the keys of those [removed]. */ + data class Windows(val full: Boolean, val windows: List, val removed: List) : EngineMessage + + /** Rows of a script window's console from row [first] of its [lineCount]; [replyTo] is the getWindowText id. */ + data class WindowText(val window: String, val lineCount: Int, val first: Int, val lines: List, val replyTo: Any?) : EngineMessage + + /** The main window's size the UI gave, the main console's grid, and where its text goes, in Mudlet's pixels. */ + data class MainArea(val w: Int, val h: Int, val columns: Int, val rows: Int, val textX: Int, val textY: Int, val textW: Int, val textH: Int) : EngineMessage + + /** A script set the command line [name] of a script window to [text]. */ + data class WindowCommandLine(val name: String, val text: String) : EngineMessage + + /** A label's content, new or changed; it follows the [Windows] that has the label. */ + data class Label(val label: LabelContent) : EngineMessage + + /** The answer to getImage: [png] is a PNG of [w] by [h] pixels, empty if it did not decode. */ + data class Image(val path: String, val png: ByteArray, val w: Int, val h: Int, val replyTo: Any?) : EngineMessage data class Unknown(val type: String) : EngineMessage } @@ -89,6 +223,43 @@ object Requests { fun openProfile(name: String, password: String?, connect: Boolean, id: Long): JSONObject = JSONObject() .put("t", "openProfile").put("name", name).put("connect", connect).put("id", id) .apply { if (password != null) put("password", password) } + + // The script windows'. A refusal is an Error naming the request. + fun setMainWindowSize(w: Int, h: Int): JSONObject = JSONObject().put("t", "setMainWindowSize").put("w", w).put("h", h) + + /** Answered by [EngineMessage.WindowText] with [id] as its replyTo; a negative [first] counts back from the end. */ + fun getWindowText(window: String, first: Int, count: Int, id: Long): JSONObject = JSONObject() + .put("t", "getWindowText").put("window", window).put("first", first).put("count", count).put("id", id) + + fun placeUserWindow(name: String, x: Int, y: Int, w: Int, h: Int, floating: Boolean): JSONObject = JSONObject() + .put("t", "placeUserWindow").put("name", name).put("x", x).put("y", y).put("w", w).put("h", h).put("floating", floating) + + fun closeUserWindow(name: String): JSONObject = JSONObject().put("t", "closeUserWindow").put("name", name) + fun sendWindowCommandLine(name: String, text: String): JSONObject = JSONObject().put("t", "sendWindowCommandLine").put("name", name).put("text", text) + + /** + * What the pointer did at [x], [y] in [window]'s pixels ("main" for the main console's area): [button] that + * changed and [buttons] held after it as POINTER_*, [modifiers] as MODIFIER_*, [wheelY] in eighths of a degree. + * Each of those is sent only when not 0. Answered only by an Error, when refused. + */ + fun pointer(window: String, type: PointerType, x: Double, y: Double, button: Int, buttons: Int, modifiers: Int, wheelY: Int): JSONObject = JSONObject() + .put("t", "pointer").put("window", window).put("type", type.wire).put("x", x).put("y", y) + .apply { + if (button != 0) put("button", button) + if (buttons != 0) put("buttons", buttons) + if (modifiers != 0) put("modifiers", modifiers) + if (wheelY != 0) put("wheelY", wheelY) + } + + /** Answered by [EngineMessage.Image] with [id] as its replyTo: the file at [path], scaled to [w] by [h], given both, or its [frame]. */ + fun getImage(path: String, w: Int?, h: Int?, frame: Int?, id: Long): JSONObject { + require((w == null) == (h == null)) { "getImage takes w and h together" } + return JSONObject().put("t", "getImage").put("path", path).put("id", id) + .apply { + if (w != null && h != null) put("w", w).put("h", h) + if (frame != null) put("frame", frame) + } + } } /** @@ -159,6 +330,37 @@ class FrameReader(input: InputStream) { "commandLine" -> EngineMessage.CommandLine(o.optString("text")) + "windows" -> EngineMessage.Windows( + o.optBoolean("full"), + objects(o.optJSONArray("windows")).mapNotNull(::parseWindow), + objects(o.optJSONArray("removed")).mapNotNull { r -> WindowKind.fromWire(r.optString("kind"))?.let { WindowKey(r.optString("name"), it) } }, + ) + + "windowText" -> EngineMessage.WindowText( + o.optString("window"), + o.optInt("lineCount"), + o.optInt("first"), + objects(o.optJSONArray("lines")).map { WindowRow(it.optString("text"), flattenRuns(it.optJSONArray("runs")), it.optBoolean("prompt"), it.optBoolean("wrapped")) }, + o.opt("re"), + ) + + "mainArea" -> EngineMessage.MainArea( + o.optInt("w"), + o.optInt("h"), + o.optInt("columns"), + o.optInt("rows"), + o.optInt("textX"), + o.optInt("textY"), + o.optInt("textW"), + o.optInt("textH"), + ) + + "windowCommandLine" -> EngineMessage.WindowCommandLine(o.optString("name"), o.optString("text")) + + "label" -> EngineMessage.Label(parseLabel(o)) + + "image" -> EngineMessage.Image(o.optString("path"), decodeBase64(o.optString("png")), o.optInt("w"), o.optInt("h"), o.opt("re")) + else -> EngineMessage.Unknown(t) } @@ -170,6 +372,72 @@ class FrameReader(input: InputStream) { } } + private fun objects(array: JSONArray?): List = if (array == null) emptyList() else List(array.length()) { array.optJSONObject(it) }.filterNotNull() + + // Null for a window of a kind this app does not know + private fun parseWindow(w: JSONObject): ScriptWindow? { + val kind = WindowKind.fromWire(w.optString("kind")) ?: return null + return ScriptWindow( + name = w.optString("name"), + kind = kind, + parent = w.optString("parent"), + parentKind = WindowKind.fromWire(w.optString("parentKind")), + x = w.optInt("x"), + y = w.optInt("y"), + w = w.optInt("w"), + h = w.optInt("h"), + visible = w.optBoolean("visible"), + shown = w.optBoolean("shown"), + stack = w.optInt("stack"), + bg = w.optInt("bg", -1), + bgAlpha = w.optInt("bgAlpha", 255), + fontFamily = w.optString("fontFamily").ifEmpty { null }, + fontSize = w.optDouble("fontSize", 0.0), + inDock = w.optBoolean("inDock"), + dock = if (w.has("dockArea")) { + ScriptWindow.Dock(w.optBoolean("autoDock", true), w.optString("dockArea"), w.optBoolean("dockFloating"), w.optString("dockTitle")) + } else { + null + }, + console = if (w.has("columns")) { + val scrolledBackTo = if (w.has("scrolledBackTo")) w.optInt("scrolledBackTo") else null + ScriptWindow.Console(scrolledBackTo, w.optInt("columns"), w.optInt("rows"), w.optBoolean("scrollBar"), w.optBoolean("timeStamps")) + } else { + null + }, + ) + } + + private fun parseLabel(l: JSONObject) = LabelContent( + name = l.optString("name"), + text = l.optString("text"), + image = l.optString("image"), + svg = l.optBoolean("svg"), + svgTint = l.optInt("svgTint", -1), + svgTintAlpha = l.optInt("svgTintAlpha", 255), + svgRotation = l.optDouble("svgRotation", 0.0), + svgShearX = l.optDouble("svgShearX", 0.0), + svgShearY = l.optDouble("svgShearY", 0.0), + movie = l.optString("movie"), + moviePaused = l.optBoolean("moviePaused"), + movieFrame = l.optInt("movieFrame", -1), + movieSpeed = l.optInt("movieSpeed", 100), + movieW = if (l.has("movieW")) l.optInt("movieW") else null, + movieH = if (l.has("movieH")) l.optInt("movieH") else null, + toolTip = l.optString("toolTip"), + clickThrough = l.optBoolean("clickThrough"), + accessibleName = l.optString("accessibleName"), + accessibleButton = l.optBoolean("accessibleButton"), + styleSheet = l.optString("styleSheet"), + ) + + // Empty for what is not base64, as an engine's bad frame should not end the link + private fun decodeBase64(text: String): ByteArray = try { + Base64.getDecoder().decode(text) + } catch (_: IllegalArgumentException) { + ByteArray(0) + } + private fun parseStrings(strings: JSONArray?): List = if (strings == null) emptyList() else List(strings.length()) { strings.optString(it) } // [[start, length, fg(, bg, attributes(, link))], ...] -> [start0, length0, fg0, bg0, attributes0, link0, start1, ...] diff --git a/protocol/src/main/kotlin/org/mudlet/android/protocol/RichText.kt b/protocol/src/main/kotlin/org/mudlet/android/protocol/RichText.kt new file mode 100644 index 0000000..9d37907 --- /dev/null +++ b/protocol/src/main/kotlin/org/mudlet/android/protocol/RichText.kt @@ -0,0 +1,280 @@ +package org.mudlet.android.protocol + +/** + * A label's text as the app draws it (docs/phase-f.md, "Labels"): Qt's rich text, of which [parse] reads the tags + * Geyser and scripts write, `b`, `i`, `u`, `s`, `font`, `span` and any tag's `style`, `br`, `p`, `div`, `center` and + * `a`. Any other tag keeps its text without its formatting; one that holds no text to show (`head`, `style`, + * `script`, `title`) is dropped with it. + */ +data class RichText(val blocks: List) { + /** A paragraph: [align] across the label, null for the label's own. */ + data class Block(val align: HAlign?, val spans: List) + + data class Span(val text: String, val style: Style) + + /** A span's formatting; colours are 0xAARRGGBB, null for what is inherited from the label. */ + data class Style( + val bold: Boolean = false, + val italic: Boolean = false, + val underline: Boolean = false, + val strike: Boolean = false, + val color: Int? = null, + val background: Int? = null, + /** In Mudlet's pixels. */ + val sizePx: Float, + val family: String? = null, + /** The link's target, for a span in an `a` with an `href`. */ + val href: String? = null, + ) + + /** The text without its formatting, a line for each block, as a screen reader is given it. */ + val plain: String get() = blocks.joinToString("\n") { block -> block.spans.joinToString("") { it.text } } + + /** The links, in order, each its text and its target. */ + val links: List> + get() { + val out = ArrayList>() + for (block in blocks) { + var href: String? = null + val text = StringBuilder() + for (span in block.spans + Span("", Style(sizePx = 0f))) { + if (span.style.href != href) { + if (href != null) out.add(text.toString().trim() to href) + text.clear() + href = span.style.href + } + text.append(span.text) + } + } + return out + } + + companion object { + /** [html] with text [baseSizePx] Mudlet pixels high where it sets no size. */ + fun parse(html: String, baseSizePx: Float): RichText = Parser(html, baseSizePx).parse() + + // HTML's font sizes 1 to 7 against the base size, 3 + private val FONT_SIZE_SCALE = floatArrayOf(0.6f, 0.8f, 1f, 1.2f, 1.5f, 2f, 3f) + private val BLOCK_TAGS = setOf( + "p", "div", "center", "h1", "h2", "h3", "h4", "h5", "h6", "li", "ul", "ol", "tr", "table", "blockquote", + "pre", "hr", "dl", "dt", "dd", "body", "html", + ) + private val VOID_TAGS = setOf("br", "hr", "img", "meta", "link", "input", "col", "area", "base", "wbr") + private val HIDDEN_TAGS = setOf("head", "style", "script", "title") + private val ATTRIBUTE = Regex("([^\\s=/]+)(?:\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+))?") + private val WHITESPACE = Regex("[ \\t\\r\\n\\u000C]+") + private val ENTITIES = mapOf( + "amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'", "nbsp" to "\u00A0", "copy" to "\u00A9", + "reg" to "\u00AE", "deg" to "\u00B0", "middot" to "\u00B7", "bull" to "\u2022", "hellip" to "\u2026", + "mdash" to "\u2014", "ndash" to "\u2013", "laquo" to "\u00AB", "raquo" to "\u00BB", "times" to "\u00D7", + ) + + /** [text] with its character references, `&`, `A` and `A`, as the characters. */ + fun decodeEntities(text: String): String { + if ('&' !in text) return text + val out = StringBuilder() + var i = 0 + while (i < text.length) { + val c = text[i] + val end = if (c == '&') text.indexOf(';', i + 1).takeIf { it in (i + 2)..(i + 10) } else null + val decoded = end?.let { entity(text.substring(i + 1, it)) } + if (end != null && decoded != null) { + out.append(decoded) + i = end + 1 + } else { + out.append(c) + i++ + } + } + return out.toString() + } + + private fun entity(name: String): String? { + if (name.startsWith("#")) { + val code = if (name.startsWith("#x") || name.startsWith("#X")) name.substring(2).toIntOrNull(16) else name.substring(1).toIntOrNull() + return code?.takeIf { Character.isValidCodePoint(it) && it != 0 }?.let { String(Character.toChars(it)) } + } + return ENTITIES[name] + } + } + + // One element open around the text: its style for the text in it, and its alignment if it is a block + private class Open(val tag: String, val style: Style, val align: HAlign?) + + private class Parser(private val html: String, baseSizePx: Float) { + private val blocks = ArrayList() + private val spans = ArrayList() + private val open = ArrayList().apply { add(Open("", Style(sizePx = baseSizePx), null)) } + private var hidden = 0 + + // At a block's start, and after a space or a line break, a space is dropped + private var atSpace = true + + fun parse(): RichText { + var i = 0 + while (i < html.length) { + val lt = html.indexOf('<', i) + if (lt < 0) { + text(html.substring(i)) + break + } + text(html.substring(i, lt)) + i = tag(lt) + } + flush() + return RichText(blocks) + } + + // The tag at [lt]; returns where the text after it starts + private fun tag(lt: Int): Int { + if (html.startsWith("", lt + 4).let { if (it < 0) html.length else it + 3 } + val gt = html.indexOf('>', lt + 1) + if (gt < 0) { + // Not a tag: its text as it is + text(html.substring(lt)) + return html.length + } + val inner = html.substring(lt + 1, gt).trim() + if (inner.startsWith("!") || inner.startsWith("?")) return gt + 1 + val closing = inner.startsWith("/") + val body = inner.removePrefix("/").removeSuffix("/").trim() + val name = body.takeWhile { !it.isWhitespace() }.lowercase() + if (name.isEmpty() || !name[0].isLetter()) { + text(html.substring(lt, gt + 1)) + return gt + 1 + } + if (closing) close(name) else open(name, attributes(body.substring(name.length)), selfClosing = inner.endsWith("/")) + return gt + 1 + } + + private fun attributes(text: String): Map = ATTRIBUTE.findAll(text).associate { match -> + match.groupValues[1].lowercase() to decodeEntities(match.groupValues[2].trim('"', '\'')) + } + + private fun open(name: String, attributes: Map, selfClosing: Boolean) { + if (name in HIDDEN_TAGS) { + if (!selfClosing) hidden++ + return + } + if (name == "br") { + if (hidden == 0) append("\n", current().style) + atSpace = true + return + } + val block = name in BLOCK_TAGS + if (block) flush() + if (name in VOID_TAGS || selfClosing) return + val parent = current() + var style = parent.style + var align = parent.align + when (name) { + "b", "strong" -> style = style.copy(bold = true) + + "i", "em" -> style = style.copy(italic = true) + + "u" -> style = style.copy(underline = true) + + "s", "strike", "del" -> style = style.copy(strike = true) + + "a" -> attributes["href"]?.let { style = style.copy(href = it) } + + "center" -> align = HAlign.CENTER + + "font" -> { + attributes["color"]?.let { CssColor.parse(it) }?.let { style = style.copy(color = it) } + attributes["face"]?.let { style = style.copy(family = it.split(',').first().trim()) } + attributes["size"]?.let { fontSize(it, parent.style.sizePx) }?.let { style = style.copy(sizePx = it) } + } + } + if (block) attributes["align"]?.let(::hAlign)?.let { align = it } + attributes["style"]?.let { css -> + for ((property, value) in LabelStyle.parseDeclarations(css)) { + style = styled(style, property, value) + if (block && property == "text-align") hAlign(value)?.let { align = it } + } + } + open.add(Open(name, style, align)) + } + + private fun close(name: String) { + if (name in HIDDEN_TAGS) { + hidden = (hidden - 1).coerceAtLeast(0) + return + } + // Closes the innermost element of that name, and any left open inside it + val at = open.indexOfLast { it.tag == name } + if (at <= 0) return + if (name in BLOCK_TAGS || open.subList(at, open.size).any { it.tag in BLOCK_TAGS }) flush() + while (open.size > at) open.removeAt(open.size - 1) + } + + private fun current() = open.last() + + private fun text(raw: String) { + if (raw.isEmpty() || hidden > 0) return + var text = WHITESPACE.replace(raw, " ") + if (atSpace) text = text.trimStart(' ') + if (text.isEmpty()) return + text = decodeEntities(text) + append(text, current().style) + atSpace = text.endsWith(' ') + } + + private fun append(text: String, style: Style) { + val last = spans.lastOrNull() + if (last != null && last.style == style) spans[spans.size - 1] = Span(last.text + text, style) else spans.add(Span(text, style)) + } + + // Ends the block being written, without its trailing spaces + private fun flush() { + while (spans.isNotEmpty()) { + val last = spans.last() + val trimmed = last.text.trimEnd(' ') + if (trimmed.isNotEmpty()) { + spans[spans.size - 1] = Span(trimmed, last.style) + break + } + spans.removeAt(spans.size - 1) + } + if (spans.isNotEmpty()) blocks.add(Block(current().align, spans.toList())) + spans.clear() + atSpace = true + } + + private fun hAlign(value: String): HAlign? = when (value.trim().lowercase()) { + "left" -> HAlign.LEFT + "right" -> HAlign.RIGHT + "center", "middle" -> HAlign.CENTER + "justify" -> HAlign.JUSTIFY + else -> null + } + + // HTML's size: 1 to 7, or relative as +1 or -2 from 3 + private fun fontSize(value: String, base: Float): Float? { + val v = value.trim() + val n = v.toIntOrNull() ?: return LabelStyle.length(v) + val logical = if (v.startsWith("+") || v.startsWith("-")) 3 + n else n + return base * FONT_SIZE_SCALE[logical.coerceIn(1, 7) - 1] + } + + private fun styled(style: Style, property: String, value: String): Style = when (property) { + "color" -> CssColor.parse(value)?.let { style.copy(color = it) } ?: style + + "background-color", "background" -> CssColor.parse(value)?.let { style.copy(background = it) } ?: style + + "font-size" -> LabelStyle.length(value)?.let { style.copy(sizePx = it) } ?: style + + "font-weight" -> LabelStyle.fontWeight(value)?.let { style.copy(bold = it >= 600) } ?: style + + "font-style" -> style.copy(italic = value.trim().lowercase() in setOf("italic", "oblique")) + + "font-family" -> style.copy(family = value.split(',').first().trim().trim('"', '\'')) + + "text-decoration" -> value.lowercase().let { + style.copy(underline = "underline" in it, strike = "line-through" in it) + } + + else -> style + } + } +} diff --git a/protocol/src/main/kotlin/org/mudlet/android/protocol/ScriptWindows.kt b/protocol/src/main/kotlin/org/mudlet/android/protocol/ScriptWindows.kt new file mode 100644 index 0000000..1c5eaf6 --- /dev/null +++ b/protocol/src/main/kotlin/org/mudlet/android/protocol/ScriptWindows.kt @@ -0,0 +1,93 @@ +package org.mudlet.android.protocol + +import kotlin.math.max +import kotlin.math.min + +/** + * What the UI knows of a profile's script windows (docs/protocol.md, "Script windows"), from the engine's messages. + * Immutable: the app holds it as one piece of Compose state, replaced by [after] for each message. + */ +data class ScriptWindows( + /** In the order the engine sent them: parents before the windows they hold. */ + val windows: List = emptyList(), + /** The rows the engine last pushed for each console, by its name. */ + val text: Map = emptyMap(), + val mainArea: EngineMessage.MainArea? = null, + /** Mudlet's character width in the main console, as the grids of [mainArea] bound it. */ + val charWidth: CharWidth? = null, + /** What scripts set the windows' command lines to, by name, until the UI shows it and calls [commandLineShown]. */ + val commandLines: Map = emptyMap(), + /** What each label shows, by its name. */ + val labels: Map = emptyMap(), +) { + /** This state after [message]; itself for a message about something else, and for an answer to `getWindowText`. */ + fun after(message: EngineMessage): ScriptWindows = when (message) { + is EngineMessage.Windows -> afterWindows(message) + is EngineMessage.WindowText -> if (message.replyTo != null) this else copy(text = text + (message.window to message)) + is EngineMessage.MainArea -> copy(mainArea = message, charWidth = CharWidth.after(charWidth, message)) + is EngineMessage.WindowCommandLine -> copy(commandLines = commandLines + (message.name to message.text)) + is EngineMessage.Label -> copy(labels = labels + (message.label.name to message.label)) + else -> this + } + + /** The Geyser gauges among the labels, by their back label's name. */ + val gauges: Map by lazy { Gauge.of(windows) } + + /** The gauge each label of one is part of, by the label's key. */ + val gaugeParts: Map by lazy { gauges.values.flatMap { gauge -> gauge.parts.map { it to gauge } }.toMap() } + + fun commandLineShown(name: String): ScriptWindows = copy(commandLines = commandLines - name) + + /** The command line along the bottom of [console], which shares its name, while it is shown. */ + fun commandLineOf(console: ScriptWindow): ScriptWindow? = windows.firstOrNull { + it.kind == WindowKind.COMMAND_LINE && it.shown && it.parent == console.name && it.parentKind == console.kind + } + + private fun afterWindows(message: EngineMessage.Windows): ScriptWindows { + if (message.full) { + val consoles = message.windows.filter { it.console != null }.mapTo(HashSet()) { it.name } + val labelNames = message.windows.filter { it.kind == WindowKind.LABEL }.mapTo(HashSet()) { it.name } + return copy(windows = message.windows, text = text.filterKeys { it in consoles }, labels = labels.filterKeys { it in labelNames }) + } + val changed = message.windows.associateBy { it.key } + val removed = message.removed.toSet() + val kept = windows.mapNotNull { if (it.key in removed) null else changed[it.key] ?: it } + val keptKeys = kept.mapTo(HashSet()) { it.key } + // A console's text goes with it; the text of one not described yet stays, as it may come first + val removedConsoles = removed.filter { it.kind in CONSOLE_KINDS }.mapTo(HashSet()) { it.name } + val removedLabels = removed.filter { it.kind == WindowKind.LABEL }.mapTo(HashSet()) { it.name } + return copy(windows = kept + message.windows.filter { it.key !in keptKeys }, text = text - removedConsoles, labels = labels - removedLabels) + } + + companion object { + /** The kinds with a console of text: `windowText` is theirs. */ + val CONSOLE_KINDS = setOf(WindowKind.MINI_CONSOLE, WindowKind.USER_WINDOW, WindowKind.MXP_FRAME) + } +} + +/** + * The width of a character of Mudlet's main console in its pixels: more than [low] and at most [high]. A grid of + * `columns` in a text area `textW` wide says it is in `(textW / (columns + 1), textW / columns]`, and each `mainArea` + * narrows that down, until one that does not fit says the font changed. + */ +data class CharWidth(val low: Double, val high: Double) { + init { + require(low < high) { "an empty interval: ($low, $high]" } + } + + val estimate: Double get() = (low + high) / 2 + + companion object { + // Mudlet never makes the grid narrower than this, so a grid at the floor says nothing of the font + const val MIN_COLUMNS = 40 + + fun after(known: CharWidth?, area: EngineMessage.MainArea): CharWidth? { + if (area.columns <= MIN_COLUMNS || area.textW <= 0) return known + val sample = CharWidth(area.textW / (area.columns + 1.0), area.textW / area.columns.toDouble()) + if (known == null) return sample + val low = max(known.low, sample.low) + val high = min(known.high, sample.high) + return if (low < high) CharWidth(low, high) else sample + } + } +} diff --git a/protocol/src/main/kotlin/org/mudlet/android/protocol/WindowLayout.kt b/protocol/src/main/kotlin/org/mudlet/android/protocol/WindowLayout.kt new file mode 100644 index 0000000..f1436b4 --- /dev/null +++ b/protocol/src/main/kotlin/org/mudlet/android/protocol/WindowLayout.kt @@ -0,0 +1,252 @@ +package org.mudlet.android.protocol + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** A rectangle in the game area's pixels, its origin at the area's top left. */ +data class PxRect(val x: Float, val y: Float, val w: Float, val h: Float) { + val right: Float get() = x + w + val bottom: Float get() = y + h + + /** The part of this inside [other], or null for none. */ + fun intersect(other: PxRect): PxRect? { + val left = max(x, other.x) + val top = max(y, other.y) + val r = min(right, other.right) + val b = min(bottom, other.bottom) + return if (r > left && b > top) PxRect(left, top, r - left, b - top) else null + } +} + +/** A size in Mudlet's pixels. */ +data class MudletSize(val w: Int, val h: Int) + +/** Where the app put a user window, in Mudlet's pixels from the game area's top left: `placeUserWindow`'s fields. */ +data class Placement(val x: Int, val y: Int, val w: Int, val h: Int, val floating: Boolean) + +/** A window to draw at [rect], cut to what its parents show; [full] is where it is before that cut. */ +data class PlacedWindow(val window: ScriptWindow, val rect: PxRect, val full: PxRect) + +/** A docked user window's title row, with its close button, above its content. */ +data class DockHeader(val window: ScriptWindow, val rect: PxRect) + +/** The fitted layout, or the stacked; [AUTO] picks fitted from [WindowLayout.FITTED_MIN_WIDTH_DP] up. */ +enum class LayoutMode { AUTO, FITTED, STACKED } + +/** + * Where the game screen draws the main console and the script windows (docs/phase-f.md, "Layout"). Made by [of]. + * + * Fitted, the main window's area is drawn as on the desktop, scaled: the console in `mainArea`'s text rectangle, the + * area's windows over it, docked user windows in columns or rows beside it. Stacked, Mudlet is given the area's size + * in landscape; the console fills the width, with the windows over its text over it, those of the top and bottom + * borders above and below it, and those of the left and right borders and the docks are pages. A page shows in a pane + * over the console, chosen by the window switcher; floating user windows, and docks beyond the room for them, are + * pages in either layout. + */ +data class WindowLayout( + val fitted: Boolean, + /** Screen pixels per Mudlet pixel: the app console's character width over Mudlet's; null while Mudlet's is unknown. */ + val scale: Double?, + /** What to give Mudlet with `setMainWindowSize`; null while [scale] is. */ + val mainWindowSize: MudletSize?, + val console: PxRect, + /** Every window to draw outside the pane, children after their parents, each parent's by `stack`. */ + val windows: List, + val dockHeaders: List, + /** The windows the switcher offers, in the engine's order. */ + val pages: List, + /** The selected page's pane, its header row at the top, or null for no page selected. */ + val pane: PxRect?, + /** The selected page's window and its children, inside [pane] below the header. */ + val paneWindows: List, + /** Where the user windows are, by name, for `placeUserWindow`; empty while [scale] is null. */ + val placements: Map, +) { + /** The size of the game area, in pixels, and what the layout depends on from the app. */ + data class Area( + val w: Float, + val h: Float, + val fitted: Boolean, + /** The width of a character of the app's console, in pixels. */ + val appCharWidth: Float, + /** The height of a dock's title row and of the pane's header. */ + val headerHeight: Float, + val selectedPage: WindowKey?, + ) + + companion object { + /** The narrowest game area, in dp, that [LayoutMode.AUTO] draws fitted. */ + const val FITTED_MIN_WIDTH_DP = 600 + + /** Docks take up to this share of the area's width (left and right) or height (top and bottom). */ + private const val DOCK_SHARE = 0.25f + private const val MAX_DOCKS_PER_AXIS = 2 + + // Stacked, the windows above and below the console take at most this share of the area's height + private const val MAX_BANDS_SHARE = 0.5f + + /** Whether [mode] draws a game area [widthDp] wide fitted. */ + fun fitted(mode: LayoutMode, widthDp: Float): Boolean = when (mode) { + LayoutMode.FITTED -> true + LayoutMode.STACKED -> false + LayoutMode.AUTO -> widthDp >= FITTED_MIN_WIDTH_DP + } + + fun of(state: ScriptWindows, area: Area): WindowLayout { + val whole = PxRect(0f, 0f, area.w, area.h) + val scale = state.charWidth?.let { area.appCharWidth / it.estimate }?.takeIf { it > 0 && area.w > 0 && area.h > 0 } + val tree = Tree(state.windows) + val pages = ArrayList() + val placed = ArrayList() + val headers = ArrayList() + val placements = HashMap() + val mainArea = state.mainArea?.takeIf { it.w > 0 && it.h > 0 } + val top = tree.children(null) + + // [full] where the window would be, [clip] what may show of it + fun place(window: ScriptWindow, full: PxRect, clip: PxRect) { + val shown = full.intersect(clip) ?: return + placed.add(PlacedWindow(window, shown, full)) + tree.placeChildren(window, full, shown, placed) + } + + fun placeUserWindow(window: ScriptWindow, rect: PxRect) { + if (scale == null) return + placements[window.name] = Placement( + (rect.x / scale).roundToInt(), + (rect.y / scale).roundToInt(), + (rect.w / scale).roundToInt().coerceAtLeast(1), + (rect.h / scale).roundToInt().coerceAtLeast(1), + window.dock?.floating ?: false, + ) + } + + val docks = top.filter { it.inDock && it.kind == WindowKind.USER_WINDOW && it.shown } + val inArea = top.filter { !it.inDock && it.shown } + val console: PxRect + val pane: PxRect + val mainWindowSize: MudletSize? + if (area.fitted) { + var main = whole + val perAxis = HashMap() + for (dock in docks) { + val side = dock.dock?.area ?: "right" + val horizontal = side == "left" || side == "right" + val n = perAxis[horizontal] ?: 0 + if (dock.dock?.floating != false || n >= MAX_DOCKS_PER_AXIS) { + pages.add(dock) + continue + } + perAxis[horizontal] = n + 1 + val rect = when (side) { + "left" -> PxRect(main.x, main.y, area.w * DOCK_SHARE, main.h).also { main = PxRect(it.right, main.y, main.w - it.w, main.h) } + "top" -> PxRect(main.x, main.y, main.w, area.h * DOCK_SHARE).also { main = PxRect(main.x, it.bottom, main.w, main.h - it.h) } + "bottom" -> PxRect(main.x, main.bottom - area.h * DOCK_SHARE, main.w, area.h * DOCK_SHARE).also { main = main.copy(h = main.h - it.h) } + else -> PxRect(main.right - area.w * DOCK_SHARE, main.y, area.w * DOCK_SHARE, main.h).also { main = main.copy(w = main.w - it.w) } + } + val header = rect.copy(h = min(area.headerHeight, rect.h)) + headers.add(DockHeader(dock, header)) + val content = PxRect(rect.x, header.bottom, rect.w, rect.h - header.h) + place(dock, content, content) + placeUserWindow(dock, content) + } + val sx = if (mainArea != null) main.w / mainArea.w else (scale ?: 1.0).toFloat() + val sy = if (mainArea != null) main.h / mainArea.h else (scale ?: 1.0).toFloat() + console = mainArea?.textRect()?.let { PxRect(main.x + it.x * sx, main.y + it.y * sy, it.w * sx, it.h * sy).intersect(main) } ?: main + for (window in inArea) place(window, PxRect(main.x + window.x * sx, main.y + window.y * sy, window.w * sx, window.h * sy), main) + pane = main + mainWindowSize = scale?.let { MudletSize((main.w / it).roundToInt().coerceAtLeast(1), (main.h / it).roundToInt().coerceAtLeast(1)) } + } else { + val text = mainArea?.textRect() + if (mainArea != null && text != null) { + val topBorder = text.y + val bottomBorder = max(0f, mainArea.h - text.bottom) + var b = area.w / mainArea.w + if ((topBorder + bottomBorder) * b > area.h * MAX_BANDS_SHARE) b = area.h * MAX_BANDS_SHARE / (topBorder + bottomBorder) + val bandX = (area.w - mainArea.w * b) / 2 + val topBand = PxRect(0f, 0f, area.w, topBorder * b) + console = PxRect(0f, topBand.bottom, area.w, area.h - topBand.h - bottomBorder * b) + val bottomBand = PxRect(0f, console.bottom, area.w, bottomBorder * b) + val t = area.w / text.w + for (window in inArea) { + val cx = window.x + window.w / 2f + val cy = window.y + window.h / 2f + when { + cy < text.y -> place(window, PxRect(bandX + window.x * b, window.y * b, window.w * b, window.h * b), topBand) + + cy > text.bottom -> place( + window, + PxRect(bandX + window.x * b, bottomBand.y + (window.y - text.bottom) * b, window.w * b, window.h * b), + bottomBand, + ) + + cx < text.x || cx > text.right -> if (window.kind in ScriptWindows.CONSOLE_KINDS) pages.add(window) + + // Over the text: kept at the top or the bottom of the console, whichever it is nearer + else -> { + val y = if (cy < text.y + text.h / 2) console.y + (window.y - text.y) * t else console.bottom - (text.bottom - window.y) * t + place(window, PxRect((window.x - text.x) * t, y, window.w * t, window.h * t), console) + } + } + } + } else { + console = whole + val s = (scale ?: 1.0).toFloat() + for (window in inArea) place(window, PxRect(window.x * s, window.y * s, window.w * s, window.h * s), whole) + } + pane = console + pages.addAll(docks) + mainWindowSize = scale?.let { MudletSize((area.h / it).roundToInt().coerceAtLeast(1), (area.w / it).roundToInt().coerceAtLeast(1)) } + } + + // A page's window fills the pane below its header + val paneContent = PxRect(pane.x, pane.y + area.headerHeight, pane.w, max(0f, pane.h - area.headerHeight)) + for (page in pages) if (page.kind == WindowKind.USER_WINDOW) placeUserWindow(page, paneContent) + val selected = pages.firstOrNull { it.key == area.selectedPage } + val paneWindows = ArrayList() + if (selected != null && paneContent.h > 0) { + paneWindows.add(PlacedWindow(selected, paneContent, paneContent)) + tree.placeChildren(selected, paneContent, paneContent, paneWindows) + } + return WindowLayout( + fitted = area.fitted, + scale = scale, + mainWindowSize = mainWindowSize, + console = console, + windows = placed, + dockHeaders = headers, + pages = pages, + pane = if (selected != null) pane else null, + paneWindows = paneWindows, + placements = placements, + ) + } + + private fun EngineMessage.MainArea.textRect(): PxRect? = if (textW > 0 && textH > 0) PxRect(textX.toFloat(), textY.toFloat(), textW.toFloat(), textH.toFloat()) else null + } + + // The windows by parent, each parent's by stack, without the consoles' own command lines, which go along their + // console's bottom + private class Tree(windows: List) { + private val byParent: Map> = windows + .filter { !(it.kind == WindowKind.COMMAND_LINE && it.parentKind in ScriptWindows.CONSOLE_KINDS) } + .groupBy { w -> w.parentKind?.let { WindowKey(w.parent, it) } } + .mapValues { (_, list) -> list.sortedBy { it.stack } } + + fun children(parent: WindowKey?): List = byParent[parent].orEmpty() + + /** Adds [parent]'s shown children, and theirs, as drawn in [rect] and cut to [clip]. */ + fun placeChildren(parent: ScriptWindow, rect: PxRect, clip: PxRect, out: MutableList) { + val sx = if (parent.w > 0) rect.w / parent.w else 1f + val sy = if (parent.h > 0) rect.h / parent.h else 1f + for (child in children(parent.key)) { + if (!child.shown) continue + val full = PxRect(rect.x + child.x * sx, rect.y + child.y * sy, child.w * sx, child.h * sy) + val shown = full.intersect(clip) ?: continue + out.add(PlacedWindow(child, shown, full)) + placeChildren(child, full, shown, out) + } + } + } +} 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..6c5e638 100644 --- a/protocol/src/test/kotlin/org/mudlet/android/protocol/EngineLinkJvmTest.kt +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/EngineLinkJvmTest.kt @@ -115,6 +115,28 @@ class EngineLinkJvmTest { private fun percentile(sorted: List, p: Int) = sorted[(sorted.size * p / 100).coerceAtMost(sorted.size - 1)] + @Test + fun theStandInHasNoScriptWindows() { + val link = newLink() + link.start() + next() + assertEquals(EngineMessage.Windows(true, emptyList(), emptyList()), next()) + next() + link.send(Requests.getWindowText("chat", -300, 300, 5)) + val refused = next() + assertEquals("getWindowText: there is no console named \"chat\"", refused.message) + assertEquals(5, (refused.replyTo as Number).toInt()) + link.send(Requests.placeUserWindow("side", 0, 0, 300, 200, false)) + assertEquals("placeUserWindow: the stand-in engine has no script windows", next().message) + link.send(Requests.pointer("box", PointerType.PRESS, 1.5, 2.0, POINTER_LEFT, POINTER_LEFT, 0, 0)) + assertEquals("pointer: the stand-in engine has no script windows", next().message) + link.send(Requests.getImage("/no/such.png", 16, 16, null, 6)) + val noImage = next() + assertEquals("getImage: the stand-in engine has no script windows", noImage.message) + assertEquals(6, (noImage.replyTo as Number).toInt()) + link.stop() + } + @Test fun protocolLatencyThroughputAndEngineDeath() { val link = newLink() diff --git a/protocol/src/test/kotlin/org/mudlet/android/protocol/LabelLinkTest.kt b/protocol/src/test/kotlin/org/mudlet/android/protocol/LabelLinkTest.kt new file mode 100644 index 0000000..1e8d710 --- /dev/null +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/LabelLinkTest.kt @@ -0,0 +1,40 @@ +package org.mudlet.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** What the app does with a tapped label link until upstream can follow one (docs/phase-f.md, "Upstream gaps", 1). */ +class LabelLinkTest { + @Test + fun linksAreSortedAsTLabelSortsThem() { + assertEquals(LabelLink.Send("north "), LabelLink.of("send:north ")) + assertEquals(LabelLink.Send("x"), LabelLink.of(" SEND:x")) + assertEquals(LabelLink.Prompt("say "), LabelLink.of("prompt:say ")) + assertEquals(LabelLink.Web("https://mudlet.org"), LabelLink.of(" https://mudlet.org ")) + assertEquals(LabelLink.Lua("echo('hi')"), LabelLink.of("echo('hi')")) + // A method call after a scheme-shaped prefix is Lua; a URL that is none is not run + assertEquals(LabelLink.Lua("tel:go()"), LabelLink.of("tel:go()")) + assertEquals(LabelLink.Lua("pkg.ui:show \"x\""), LabelLink.of("pkg.ui:show \"x\"")) + assertEquals(LabelLink.NotRun("mailto"), LabelLink.of("mailto:a@b.c")) + assertEquals(LabelLink.NotRun("javascript"), LabelLink.of("javascript:go()")) + assertEquals(LabelLink.Lua("x = {a = 1}"), LabelLink.of("x = {a = 1}")) + } + + @Test + fun whatTheEngineIsAskedToDo() { + assertEquals("expandAlias(\"north \")", LabelLink.Send("north ").request(1)!!.getString("code")) + assertEquals("printCmdLine(\"say \")", LabelLink.Prompt("say ").request(2)!!.getString("code")) + val lua = LabelLink.Lua("echo('hi')").request(3)!! + assertEquals("runLua", lua.getString("t")) + assertEquals("echo('hi')", lua.getString("code")) + assertEquals(3L, lua.getLong("id")) + assertNull(LabelLink.Web("https://mudlet.org").request(4)) + assertNull(LabelLink.NotRun("ftp").request(5)) + } + + @Test + fun luaStrings() { + assertEquals("\"a \\\"b\\\" \\\\ c\\n\\r\\009é\"", LabelLink.luaString("a \"b\" \\ c\n\r\té")) + } +} diff --git a/protocol/src/test/kotlin/org/mudlet/android/protocol/LabelStyleTest.kt b/protocol/src/test/kotlin/org/mudlet/android/protocol/LabelStyleTest.kt new file mode 100644 index 0000000..e940929 --- /dev/null +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/LabelStyleTest.kt @@ -0,0 +1,123 @@ +package org.mudlet.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The style sheet subset the app draws of a label (docs/phase-f.md, "Labels"). */ +class LabelStyleTest { + @Test + fun colours() { + assertEquals(0xFFFF0000.toInt(), CssColor.parse("red")) + assertEquals(0xFF2F4F4F.toInt(), CssColor.parse("DarkSlateGray")) + assertEquals(0, CssColor.parse("transparent")) + assertEquals(0xFFAABBCC.toInt(), CssColor.parse("#abc")) + assertEquals(0xFF102030.toInt(), CssColor.parse("#102030")) + // Qt's eight digits put the alpha first + assertEquals(0x80102030.toInt(), CssColor.parse("#80102030")) + assertEquals(0xFF0A141E.toInt(), CssColor.parse("rgb(10, 20, 30)")) + assertEquals(0xFFFF8000.toInt(), CssColor.parse("rgb(100%, 50%, 0%)")) + assertEquals(0x7F0A141E, CssColor.parse("rgba(10,20,30,127)")) + assertEquals(0x800A141E.toInt(), CssColor.parse("rgba(10, 20, 30, 0.5)")) + assertEquals(0x800A141E.toInt(), CssColor.parse("rgba(10, 20, 30, 50%)")) + assertNull(CssColor.parse("#12345")) + assertNull(CssColor.parse("#ggg")) + assertNull(CssColor.parse("rgb(1, 2)")) + assertNull(CssColor.parse("notacolour")) + } + + @Test + fun aGeyserLabelsSheet() { + val style = LabelStyle.parse( + """ + background-color: rgba(0, 0, 0, 50%); /* half see-through */ + border: 2px solid white; + border-radius: 5px; + color: #ffd700; + font-size: 12pt; + font-weight: bold; + font-style: italic; + font-family: "Bitstream Vera Sans Mono", monospace; + padding: 1px 2px; + margin: 3px; + qproperty-alignment: 'AlignRight | AlignBottom'; + """.trimIndent(), + ) + assertEquals(LabelFill.Solid(0x80000000.toInt()), style.background) + assertEquals(2f, style.borderWidth) + assertTrue(style.borderStyled) + assertEquals(2f, style.drawnBorderWidth) + assertEquals(0xFFFFFFFF.toInt(), style.borderColor) + assertEquals(5f, style.borderRadius) + assertEquals(0xFFFFD700.toInt(), style.color) + assertEquals(16f, style.fontSizePx) + assertEquals(700, style.fontWeight) + assertEquals(true, style.italic) + assertEquals("Bitstream Vera Sans Mono", style.fontFamily) + assertEquals(Edges(1f, 2f, 1f, 2f), style.padding) + assertEquals(Edges(3f, 3f, 3f, 3f), style.margin) + assertEquals(HAlign.RIGHT, style.hAlign) + assertEquals(VAlign.BOTTOM, style.vAlign) + } + + @Test + fun aBorderWithoutAStyleIsNotDrawn() { + val style = LabelStyle.parse("border-width: 3px; border-color: red;") + assertEquals(3f, style.borderWidth) + assertFalse(style.borderStyled) + assertEquals(0f, style.drawnBorderWidth) + assertEquals(3f, LabelStyle.parse("border-width: 3px; border-style: dashed").drawnBorderWidth) + assertEquals(0f, LabelStyle.parse("border: 3px solid red; border-style: none").drawnBorderWidth) + } + + @Test + fun onlyTheLabelsOwnRulesApply() { + val style = LabelStyle.parse( + "color: red; QLabel { background-color: blue; } QLabel:hover { color: green; } QPushButton { color: pink } " + + "QLabel#other { color: gray } TLabel, QFrame { font-size: 10px }", + ) + assertEquals(0xFFFF0000.toInt(), style.color) + assertEquals(LabelFill.Solid(0xFF0000FF.toInt()), style.background) + assertEquals(10f, style.fontSizePx) + } + + @Test + fun gradientsAndImages() { + val style = LabelStyle.parse( + "background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 rgba(255, 0, 0, 255), stop:1 #0000ff);" + + "border-image: url(\"/data/hp.png\") 0 0 0 0 stretch stretch;", + ) + assertEquals(LabelFill.Gradient(0f, 0f, 0f, 1f, listOf(0f to 0xFFFF0000.toInt(), 1f to 0xFF0000FF.toInt())), style.background) + assertEquals("/data/hp.png", style.borderImage) + val tiled = LabelStyle.parse("background: #222 url(tile.png) repeat;") + assertEquals(LabelFill.Solid(0xFF222222.toInt()), tiled.background) + assertEquals("tile.png", tiled.backgroundImage) + // Radial and conical gradients are drawn as their first colour + assertEquals( + LabelFill.Solid(0xFF00FF00.toInt()), + LabelStyle.parse("background: qradialgradient(cx:0.5, cy:0.5, radius:1, stop:0 lime, stop:1 black)").background, + ) + } + + @Test + fun lengthsInPixelsOrPoints() { + assertEquals(4f, LabelStyle.length("4")) + assertEquals(4f, LabelStyle.length("4px")) + assertEquals(12f, LabelStyle.length("9pt")) + assertEquals(1.5f, LabelStyle.length(".5PX")!! * 3) + assertNull(LabelStyle.length("2em")) + assertNull(LabelStyle.length("bold")) + } + + @Test + fun whatItCannotReadLeavesTheRestAlone() { + val style = LabelStyle.parse("color: nonsense; font-size: large; padding: 1px 2px 3px 4px 5px; ; : ; background-color: #00ff00 !important") + assertNull(style.color) + assertNull(style.fontSizePx) + assertEquals(Edges.ZERO, style.padding) + assertEquals(LabelFill.Solid(0xFF00FF00.toInt()), style.background) + assertEquals(LabelStyle(), LabelStyle.parse("")) + } +} diff --git a/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolSpecTest.kt b/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolSpecTest.kt index cd0d46f..38b9008 100644 --- a/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolSpecTest.kt +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolSpecTest.kt @@ -75,10 +75,12 @@ class ProtocolSpecTest { val args: List = method.parameterTypes.map { type -> when (type) { String::class.java -> "x" - Int::class.javaPrimitiveType -> 1 + Int::class.javaPrimitiveType, Int::class.javaObjectType -> 1 + Double::class.javaPrimitiveType -> 1.0 Long::class.javaPrimitiveType -> 1L Boolean::class.javaPrimitiveType -> true Profile::class.java -> Profile("x", "x", 1, true, "x", true) + PointerType::class.java -> PointerType.PRESS else -> error("Requests.${method.name} takes a ${type.name}: teach ProtocolSpecTest about it") } } 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..840172c 100644 --- a/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolTest.kt +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/ProtocolTest.kt @@ -3,9 +3,11 @@ package org.mudlet.android.protocol import org.json.JSONObject import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test import java.io.OutputStream +import java.util.Base64 /** The run encoding and message types of native/enginehost/SocketBridge.cpp, without an engine. */ class ProtocolTest { @@ -90,6 +92,139 @@ class ProtocolTest { assertEquals(true, Requests.complete("say", true, 7).getBoolean("fromHistory")) } + @Test + fun scriptWindowsParseWithTheirOptionalParts() { + val message = FrameReader.parse( + JSONObject( + """{"t":"windows","full":true,"windows":[""" + + """{"name":"side","kind":"userWindow","parent":"","x":0,"y":0,"w":300,"h":200,"visible":true,"shown":true,"stack":0,""" + + """"bg":${RUN_RGB or 0x102030},"fontFamily":"Mono","fontSize":10.5,"inDock":true,"autoDock":false,"dockArea":"left",""" + + """"dockFloating":true,"dockTitle":"Side","scrolledBackTo":12,"columns":40,"rows":10,"scrollBar":true,"timeStamps":false},""" + + """{"name":"side","kind":"commandLine","parent":"side","parentKind":"userWindow","x":0,"y":0,"w":0,"h":0,""" + + """"visible":true,"shown":false,"stack":1,"inDock":false},""" + + """{"name":"hp_text","kind":"label","parent":"","x":10,"y":600,"w":200,"h":20,"visible":true,"shown":true,"stack":3,""" + + """"bg":$RUN_RGB,"bgAlpha":0,"inDock":false},""" + + """{"name":"later","kind":"hologram","parent":"","x":0,"y":0,"w":1,"h":1,"visible":true,"shown":true,"stack":2,"inDock":false}],""" + + """"removed":[{"name":"chat","kind":"miniConsole"},{"name":"x","kind":"hologram"}]}""", + ), + ) as EngineMessage.Windows + assertTrue(message.full) + val side = ScriptWindow( + "side", WindowKind.USER_WINDOW, "", null, 0, 0, 300, 200, true, true, 0, RUN_RGB or 0x102030, 255, "Mono", 10.5, true, + ScriptWindow.Dock(false, "left", true, "Side"), ScriptWindow.Console(12, 40, 10, true, false), + ) + val line = ScriptWindow("side", WindowKind.COMMAND_LINE, "side", WindowKind.USER_WINDOW, 0, 0, 0, 0, true, false, 1, -1, 255, null, 0.0, false, null, null) + // A transparent background, as a gauge's text label has + val text = ScriptWindow("hp_text", WindowKind.LABEL, "", null, 10, 600, 200, 20, true, true, 3, RUN_RGB, 0, null, 0.0, false, null, null) + // A kind the app does not know is left out + assertEquals(listOf(side, line, text), message.windows) + assertEquals(WindowKey("side", WindowKind.COMMAND_LINE), line.key) + assertEquals(listOf(WindowKey("chat", WindowKind.MINI_CONSOLE)), message.removed) + val update = FrameReader.parse(JSONObject("""{"t":"windows","windows":[]}""")) as EngineMessage.Windows + assertEquals(EngineMessage.Windows(false, emptyList(), emptyList()), update) + } + + @Test + fun windowTextMainAreaAndCommandLinesParse() { + val text = FrameReader.parse( + JSONObject( + """{"t":"windowText","window":"chat","lineCount":3,"first":1,"re":"w1","lines":[""" + + """{"text":"hello","runs":[[0,5,${RUN_RGB or 0xff0000}]],"prompt":false,"wrapped":false},""" + + """{"text":"HP>","runs":[],"prompt":true,"wrapped":true}]}""", + ), + ) as EngineMessage.WindowText + assertEquals("chat", text.window) + assertEquals(3, text.lineCount) + assertEquals(1, text.first) + assertEquals("w1", text.replyTo) + assertEquals(listOf("hello", "HP>"), text.lines.map { it.text }) + assertArrayEquals(intArrayOf(0, 5, RUN_RGB or 0xff0000, -1, 0, 0), text.lines[0].runs) + assertTrue(text.lines[1].prompt && text.lines[1].wrapped) + assertEquals( + EngineMessage.MainArea(800, 600, 90, 30, 10, 0, 780, 600), + FrameReader.parse(JSONObject("""{"t":"mainArea","w":800,"h":600,"columns":90,"rows":30,"textX":10,"textY":0,"textW":780,"textH":600}""")), + ) + assertEquals(EngineMessage.WindowCommandLine("chat", "say hi"), FrameReader.parse(JSONObject("""{"t":"windowCommandLine","name":"chat","text":"say hi"}"""))) + } + + @Test + fun theScriptWindowRequestsCarryWhatTheEngineReads() { + assertEquals(mapOf("t" to "setMainWindowSize", "w" to 800, "h" to 600), Requests.setMainWindowSize(800, 600).toMap()) + assertEquals( + mapOf("t" to "getWindowText", "window" to "chat", "first" to -300, "count" to 300, "id" to 8L), + Requests.getWindowText("chat", -300, 300, 8).toMap(), + ) + assertEquals( + mapOf("t" to "placeUserWindow", "name" to "side", "x" to 1, "y" to 2, "w" to 3, "h" to 4, "floating" to true), + Requests.placeUserWindow("side", 1, 2, 3, 4, true).toMap(), + ) + assertEquals(mapOf("t" to "closeUserWindow", "name" to "side"), Requests.closeUserWindow("side").toMap()) + assertEquals(mapOf("t" to "sendWindowCommandLine", "name" to "chat", "text" to "look"), Requests.sendWindowCommandLine("chat", "look").toMap()) + } + + @Test + fun labelsParseWithTheirOptionalParts() { + val full = FrameReader.parse( + JSONObject( + """{"t":"label","name":"box","text":"hi","image":"/data/a.svg","svg":true,"svgTint":${RUN_RGB or 0xff0000},""" + + """"svgTintAlpha":128,"svgRotation":90.5,"svgShearX":0.25,"svgShearY":-0.5,"movie":"/data/b.gif","moviePaused":true,""" + + """"movieFrame":3,"movieSpeed":50,"movieW":64,"movieH":48,"toolTip":"tip","clickThrough":true,"accessibleName":"Box",""" + + """"accessibleButton":true,"styleSheet":"QLabel { color: red; }"}""", + ), + ) + assertEquals( + EngineMessage.Label( + LabelContent( + "box", "hi", "/data/a.svg", true, RUN_RGB or 0xff0000, 128, 90.5, 0.25, -0.5, "/data/b.gif", true, 3, 50, 64, 48, + "tip", true, "Box", true, "QLabel { color: red; }", + ), + ), + full, + ) + // The engine leaves out the parts at their defaults + val plain = FrameReader.parse( + JSONObject( + """{"t":"label","name":"box","text":"","image":"","svg":false,"movie":"","moviePaused":false,"movieFrame":-1,""" + + """"movieSpeed":100,"toolTip":"","clickThrough":false,"accessibleName":"","accessibleButton":false,"styleSheet":""}""", + ), + ) as EngineMessage.Label + assertEquals(LabelContent("box", "", "", false, -1, 255, 0.0, 0.0, 0.0, "", false, -1, 100, null, null, "", false, "", false, ""), plain.label) + } + + @Test + fun anImageDecodesItsPng() { + val png = byteArrayOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte(), 0, -1) + val image = FrameReader.parse( + JSONObject().put("t", "image").put("path", "/data/a.png").put("png", Base64.getEncoder().encodeToString(png)).put("w", 4).put("h", 3).put("re", 9), + ) as EngineMessage.Image + assertEquals("/data/a.png", image.path) + assertArrayEquals(png, image.png) + assertEquals(4 to 3, image.w to image.h) + assertEquals(9, image.replyTo) + // Not base64: empty, and the link goes on + val broken = FrameReader.parse(JSONObject("""{"t":"image","path":"x","png":"not base64!","w":1,"h":1}""")) as EngineMessage.Image + assertEquals(0, broken.png.size) + } + + @Test + fun thePointerAndImageRequestsCarryWhatTheEngineReads() { + assertEquals( + mapOf("t" to "pointer", "window" to "box", "type" to "press", "x" to 1.5, "y" to 2.0, "button" to POINTER_LEFT, "buttons" to POINTER_LEFT), + Requests.pointer("box", PointerType.PRESS, 1.5, 2.0, POINTER_LEFT, POINTER_LEFT, 0, 0).toMap(), + ) + assertEquals( + mapOf("t" to "pointer", "window" to "main", "type" to "wheel", "x" to 0.0, "y" to 0.0, "modifiers" to (MODIFIER_SHIFT or MODIFIER_CONTROL), "wheelY" to -120), + Requests.pointer("main", PointerType.WHEEL, 0.0, 0.0, 0, 0, MODIFIER_SHIFT or MODIFIER_CONTROL, -120).toMap(), + ) + assertEquals("doubleClick", PointerType.DOUBLE_CLICK.wire) + assertEquals(mapOf("t" to "getImage", "path" to "a.png", "id" to 3L), Requests.getImage("a.png", null, null, null, 3).toMap()) + assertEquals( + mapOf("t" to "getImage", "path" to "a.gif", "w" to 16, "h" to 8, "frame" to 2, "id" to 4L), + Requests.getImage("a.gif", 16, 8, 2, 4).toMap(), + ) + assertThrows(IllegalArgumentException::class.java) { Requests.getImage("a.png", 16, null, null, 5) } + } + @Test fun aFrameIsOneWriteAndNeverFlushed() { val writes = ArrayList() diff --git a/protocol/src/test/kotlin/org/mudlet/android/protocol/RichTextTest.kt b/protocol/src/test/kotlin/org/mudlet/android/protocol/RichTextTest.kt new file mode 100644 index 0000000..198f866 --- /dev/null +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/RichTextTest.kt @@ -0,0 +1,81 @@ +package org.mudlet.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The rich text subset the app draws of a label (docs/phase-f.md, "Labels"). */ +class RichTextTest { + private val base = RichText.Style(sizePx = 12f) + + private fun parse(html: String) = RichText.parse(html, 12f) + + @Test + fun whatGeyserLabelEchoWrites() { + // Geyser.Label:echo("HP full") with the label's format centred and its colour gold at 9 pt + val text = parse("""
HP full
""") + val style = base.copy(color = 0xFFFFD700.toInt(), sizePx = 12f, family = "Ubuntu Mono", italic = true) + assertEquals( + listOf(RichText.Block(HAlign.CENTER, listOf(RichText.Span("HP ", style), RichText.Span("full", style.copy(bold = true))))), + text.blocks, + ) + assertEquals("HP full", text.plain) + } + + @Test + fun spacesCollapseAsInHtmlAndLineBreaksAndParagraphsSplitLines() { + val text = parse(" one \n two
three

four

five
six  seven") + assertEquals("one two\nthree\nfour\nfive\nsix  seven", text.plain) + assertEquals(listOf(null, null, HAlign.CENTER, null), text.blocks.map { it.align }) + } + + @Test + fun spansAndFonts() { + val text = parse( + "usbig" + + "greensmall" + + "x", + ) + assertEquals( + listOf( + RichText.Span("u", base.copy(underline = true)), + RichText.Span("s", base.copy(strike = true)), + RichText.Span("big", base.copy(background = 0xFFFF0000.toInt(), bold = true, sizePx = 6f)), + RichText.Span("green", base.copy(color = 0xFF00FF00.toInt(), sizePx = 18f)), + RichText.Span("small", base.copy(sizePx = 12f * 0.6f)), + RichText.Span("x", base.copy(strike = true, italic = true)), + ), + text.blocks.single().spans, + ) + } + + @Test + fun linksKeepTheirTargets() { + val text = parse("""Go north or speak.here""") + assertEquals(listOf("north" to "send:north", "speak" to "prompt:say "), text.links) + assertEquals("Go north or speak.here", text.plain) + } + + @Test + fun entitiesAndWhatIsNotATag() { + val text = parse("a <b> & AB &unknown; 3 < 4 > 2 <3") + assertEquals("a & AB &unknown; 3 < 4 > 2 <3", text.plain) + } + + @Test + fun aWholeDocumentShowsOnlyItsBody() { + val text = parse( + "t" + + "

body

", + ) + assertEquals(listOf(RichText.Block(HAlign.RIGHT, listOf(RichText.Span("body", base.copy(sizePx = 15f))))), text.blocks) + } + + @Test + fun otherTagsKeepTheirTextAndUnclosedOnesEndWithTheText() { + val text = parse("
celltwo
onbold") + assertEquals("celltwo\nonbold", text.plain) + assertTrue(text.blocks.last().spans.last().style.bold) + assertEquals(RichText(emptyList()), parse("

")) + } +} diff --git a/protocol/src/test/kotlin/org/mudlet/android/protocol/ScriptWindowsTest.kt b/protocol/src/test/kotlin/org/mudlet/android/protocol/ScriptWindowsTest.kt new file mode 100644 index 0000000..7f94cf2 --- /dev/null +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/ScriptWindowsTest.kt @@ -0,0 +1,198 @@ +package org.mudlet.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +/** A window for these tests and [WindowLayoutTest]: a shown mini console in the main window's area, unless told. */ +fun window( + name: String, + kind: WindowKind = WindowKind.MINI_CONSOLE, + x: Int = 0, + y: Int = 0, + w: Int = 100, + h: Int = 100, + parent: String = "", + parentKind: WindowKind? = null, + shown: Boolean = true, + stack: Int = 0, + dock: ScriptWindow.Dock? = null, +) = ScriptWindow( + name = name, + kind = kind, + parent = parent, + parentKind = parentKind, + x = x, + y = y, + w = w, + h = h, + visible = shown, + shown = shown, + stack = stack, + bg = -1, + bgAlpha = 255, + fontFamily = null, + fontSize = 0.0, + inDock = dock != null, + dock = dock, + console = if (kind in ScriptWindows.CONSOLE_KINDS) ScriptWindow.Console(null, 80, 20, false, false) else null, +) + +/** A label's content with [text] and nothing else set. */ +fun label(name: String, text: String = "", styleSheet: String = "", accessibleButton: Boolean = false) = LabelContent( + name = name, + text = text, + image = "", + svg = false, + svgTint = -1, + svgTintAlpha = 255, + svgRotation = 0.0, + svgShearX = 0.0, + svgShearY = 0.0, + movie = "", + moviePaused = false, + movieFrame = -1, + movieSpeed = 100, + movieW = null, + movieH = null, + toolTip = "", + clickThrough = false, + accessibleName = "", + accessibleButton = accessibleButton, + styleSheet = styleSheet, +) + +fun text(window: String, vararg rows: String, replyTo: Any? = null) = EngineMessage.WindowText(window, rows.size, 0, rows.map { WindowRow(it, IntArray(0), false, false) }, replyTo) + +fun mainArea(w: Int, h: Int, columns: Int, rows: Int, textX: Int, textY: Int, textW: Int, textH: Int) = EngineMessage.MainArea(w, h, columns, rows, textX, textY, textW, textH) + +class ScriptWindowsTest { + private fun windows(vararg windows: ScriptWindow, full: Boolean = false, removed: List = emptyList()) = EngineMessage.Windows(full, windows.toList(), removed) + + @Test + fun aChangeReplacesItsWindowInPlaceAndANewOneComesLast() { + val state = ScriptWindows() + .after(windows(window("a"), window("b"), full = true)) + .after(windows(window("a", x = 5), window("c"))) + assertEquals(listOf("a", "b", "c"), state.windows.map { it.name }) + assertEquals(5, state.windows[0].x) + } + + @Test + fun aWindowIsKnownByItsNameAndKind() { + val line = window("chat", WindowKind.COMMAND_LINE, parent = "chat", parentKind = WindowKind.MINI_CONSOLE) + val state = ScriptWindows().after(windows(window("chat"), line)) + assertEquals(2, state.windows.size) + assertEquals(line, state.commandLineOf(state.windows[0])) + val hidden = state.after(windows(line.copy(shown = false))) + assertNull(hidden.commandLineOf(hidden.windows[0])) + val gone = state.after(windows(removed = listOf(WindowKey("chat", WindowKind.COMMAND_LINE)))) + assertEquals(listOf(WindowKind.MINI_CONSOLE), gone.windows.map { it.kind }) + assertNull(gone.commandLineOf(gone.windows[0])) + } + + @Test + fun fullReplacesEverythingAndDropsTheTextOfConsolesNoLongerThere() { + val state = ScriptWindows() + .after(windows(window("a"), window("b"))) + .after(text("a", "one")) + .after(text("b", "two")) + .after(windows(window("b"), window("c"), full = true)) + assertEquals(listOf("b", "c"), state.windows.map { it.name }) + assertEquals(setOf("b"), state.text.keys) + } + + @Test + fun aRemovedConsoleTakesItsTextButTextMayComeBeforeItsWindow() { + val early = ScriptWindows().after(text("chat", "hello")) + assertEquals("hello", early.text.getValue("chat").lines.single().text) + val described = early.after(windows(window("other"))) + assertEquals(setOf("chat"), described.text.keys) + val removed = described.after(windows(window("chat"))).after(windows(removed = listOf(WindowKey("chat", WindowKind.MINI_CONSOLE)))) + assertEquals(emptySet(), removed.text.keys) + assertEquals(listOf("other"), removed.windows.map { it.name }) + } + + @Test + fun theLatestPushedRowsWinAndAnswersToGetWindowTextAreLeftAlone() { + val state = ScriptWindows().after(text("chat", "one")).after(text("chat", "one", "two")) + assertEquals(listOf("one", "two"), state.text.getValue("chat").lines.map { it.text }) + assertSame(state, state.after(text("chat", "old", replyTo = 7L))) + } + + @Test + fun aCommandLineTextWaitsUntilShown() { + val state = ScriptWindows().after(EngineMessage.WindowCommandLine("chat", "say hi")) + assertEquals(mapOf("chat" to "say hi"), state.commandLines) + assertEquals(emptyMap(), state.commandLineShown("chat").commandLines) + } + + @Test + fun aLabelsContentIsKeptUntilItGoesOrAFullListLeavesItOut() { + val hp = window("hp", WindowKind.LABEL) + val state = ScriptWindows() + .after(windows(hp, window("mp", WindowKind.LABEL))) + .after(EngineMessage.Label(label("hp", "100"))) + .after(EngineMessage.Label(label("mp", "50"))) + .after(EngineMessage.Label(label("hp", "90"))) + assertEquals(mapOf("hp" to "90", "mp" to "50"), state.labels.mapValues { it.value.text }) + // A console of the same name is another window + val consoleGone = state.after(windows(removed = listOf(WindowKey("hp", WindowKind.MINI_CONSOLE)))) + assertEquals(setOf("hp", "mp"), consoleGone.labels.keys) + assertEquals(setOf("mp"), state.after(windows(removed = listOf(hp.key))).labels.keys) + assertEquals(setOf("hp"), state.after(windows(hp, full = true)).labels.keys) + } + + @Test + fun aGaugeIsItsThreeLabelsWithTheFrontsShareOfTheBackAsItsValue() { + val back = window("hp_back", WindowKind.LABEL, x = 10, y = 10, w = 200, h = 20) + val front = window("hp_front", WindowKind.LABEL, x = 10, y = 10, w = 50, h = 20) + val text = window("hp_text", WindowKind.LABEL, x = 10, y = 10, w = 200, h = 20) + val state = ScriptWindows().after(windows(back, front, text, window("other", WindowKind.LABEL))) + val gauge = state.gauges.getValue("hp_back") + assertEquals(mapOf(back.key to gauge, front.key to gauge, text.key to gauge), state.gaugeParts) + assertEquals("hp", gauge.name) + assertEquals(0.25f, gauge.value, 1e-6f) + assertEquals(25, gauge.percent) + assertEquals(setOf(back.key, front.key, text.key), gauge.parts) + + // Vertical, inset by the back's margins of 2 px: the height decides + val vertical = Gauge.of(listOf(window("mp_back", WindowKind.LABEL, w = 24, h = 100), window("mp_front", WindowKind.LABEL, x = 2, y = 62, w = 20, h = 36))) + assertEquals(0.36f, vertical.getValue("mp_back").value, 1e-6f) + assertNull(vertical.getValue("mp_back").text) + + // A hidden front is empty; a front in another parent, or with no back, is no gauge + assertEquals(0f, Gauge.of(listOf(back, front.copy(shown = false))).getValue("hp_back").value) + assertEquals(emptyMap(), Gauge.of(listOf(back, front.copy(parent = "box", parentKind = WindowKind.LABEL)))) + assertEquals(emptyMap(), Gauge.of(listOf(front, text))) + assertEquals(emptyMap(), Gauge.of(listOf(back.copy(kind = WindowKind.MINI_CONSOLE), front))) + } + + @Test + fun otherMessagesLeaveItAsItIs() { + val state = ScriptWindows().after(windows(window("a"))) + assertSame(state, state.after(EngineMessage.Clear)) + } + + @Test + fun theCharacterWidthNarrowsWithEachGridAndStartsAgainWhenTheFontChanges() { + // 100 columns in 800 px: more than 800/101, at most 8 + var state = ScriptWindows().after(mainArea(0, 0, 100, 30, 0, 0, 800, 600)) + assertEquals(CharWidth(800 / 101.0, 8.0), state.charWidth) + // 120 columns in 970 px: (970/121, 970/120] = (8.016, 8.083], which leaves (8.016, 8] empty: a new font + state = state.after(mainArea(0, 0, 120, 30, 0, 0, 970, 600)) + assertEquals(CharWidth(970 / 121.0, 970 / 120.0), state.charWidth) + // 110 columns in 885 px: (7.98, 8.045], narrowing it to (8.016, 8.045] + state = state.after(mainArea(0, 0, 110, 30, 0, 0, 885, 600)) + assertEquals(CharWidth(970 / 121.0, 885 / 110.0), state.charWidth) + assertEquals((970 / 121.0 + 885 / 110.0) / 2, state.charWidth!!.estimate, 1e-9) + } + + @Test + fun aGridAtMudletsFloorSaysNothingOfTheFont() { + val state = ScriptWindows().after(mainArea(300, 200, CharWidth.MIN_COLUMNS, 10, 0, 0, 200, 200)) + assertNull(state.charWidth) + assertEquals(300, state.mainArea!!.w) + } +} diff --git a/protocol/src/test/kotlin/org/mudlet/android/protocol/WindowLayoutTest.kt b/protocol/src/test/kotlin/org/mudlet/android/protocol/WindowLayoutTest.kt new file mode 100644 index 0000000..d167085 --- /dev/null +++ b/protocol/src/test/kotlin/org/mudlet/android/protocol/WindowLayoutTest.kt @@ -0,0 +1,161 @@ +package org.mudlet.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WindowLayoutTest { + // Mudlet's characters 8 px wide (the estimate of (7.9, 8.1]) and the app's 16: 2 screen pixels per Mudlet pixel + private val font = CharWidth(7.9, 8.1) + + private fun state(area: EngineMessage.MainArea?, vararg windows: ScriptWindow) = ScriptWindows(windows.toList(), mainArea = area, charWidth = font) + + private fun area(w: Float, h: Float, fitted: Boolean, selectedPage: WindowKey? = null) = WindowLayout.Area(w, h, fitted, 16f, HEADER, selectedPage) + + private fun WindowLayout.rectOf(name: String) = windows.single { it.window.name == name }.rect + + @Test + fun autoIsFittedFrom600dp() { + assertTrue(WindowLayout.fitted(LayoutMode.AUTO, 600f)) + assertFalse(WindowLayout.fitted(LayoutMode.AUTO, 599f)) + assertTrue(WindowLayout.fitted(LayoutMode.FITTED, 300f)) + assertFalse(WindowLayout.fitted(LayoutMode.STACKED, 900f)) + } + + @Test + fun fittedDrawsTheMainAreaScaledWithTheConsoleInItsTextRectangle() { + val layout = WindowLayout.of( + state(mainArea(800, 500, 75, 25, 100, 50, 600, 400), window("chat", x = 0, y = 0, w = 240, h = 200)), + area(1600f, 1000f, fitted = true), + ) + assertEquals(2.0, layout.scale!!, 1e-9) + assertEquals(MudletSize(800, 500), layout.mainWindowSize) + assertEquals(PxRect(200f, 100f, 1200f, 800f), layout.console) + assertEquals(PxRect(0f, 0f, 480f, 400f), layout.rectOf("chat")) + assertTrue(layout.pages.isEmpty()) + } + + @Test + fun fittedDocksTakeAQuarterEachUpToHalfAndTheRestArePages() { + val left = window("left", WindowKind.USER_WINDOW, dock = ScriptWindow.Dock(true, "left", false, "Left")) + val right = window("right", WindowKind.USER_WINDOW, dock = ScriptWindow.Dock(true, "right", false, "Right")) + val more = window("more", WindowKind.USER_WINDOW, dock = ScriptWindow.Dock(true, "right", false, "More")) + val floating = window("floating", WindowKind.USER_WINDOW, dock = ScriptWindow.Dock(true, "right", true, "Floating")) + val inLeft = window("inLeft", x = 0, y = 0, w = 50, h = 50, parent = "left", parentKind = WindowKind.USER_WINDOW) + // The left dock as the engine has it after the placement below: 200 by 450 + val layout = WindowLayout.of(state(null, left.copy(w = 200, h = 450), right, more, floating, inLeft), area(1600f, 1000f, fitted = true)) + + assertEquals(listOf("left", "right"), layout.dockHeaders.map { it.window.name }) + assertEquals(PxRect(0f, 0f, 400f, HEADER), layout.dockHeaders[0].rect) + assertEquals(PxRect(0f, HEADER, 400f, 1000f - HEADER), layout.rectOf("left")) + assertEquals(PxRect(1200f, HEADER, 400f, 1000f - HEADER), layout.rectOf("right")) + // A window in a dock, in the dock's pixels: 2 screen pixels to each here + assertEquals(PxRect(0f, HEADER, 100f, 100f), layout.rectOf("inLeft")) + assertEquals(listOf("more", "floating"), layout.pages.map { it.name }) + // The main window has what is left, and without mainArea the console fills it + assertEquals(PxRect(400f, 0f, 800f, 1000f), layout.console) + assertEquals(MudletSize(400, 500), layout.mainWindowSize) + assertEquals(Placement(0, 50, 200, 450, false), layout.placements["left"]) + assertEquals(Placement(600, 50, 200, 450, false), layout.placements["right"]) + // Pages fill the pane, over the main window's area, below its header + assertEquals(Placement(200, 50, 400, 450, false), layout.placements["more"]) + assertEquals(Placement(200, 50, 400, 450, true), layout.placements["floating"]) + } + + @Test + fun stackedPutsTheBordersAboveAndBelowTheWindowsOverTheTextOverItAndTheSidesInPages() { + // A 1000 by 500 main window with borders of 100 left and right and 50 top and bottom + val main = mainArea(1000, 500, 100, 25, 100, 50, 800, 400) + val layout = WindowLayout.of( + state( + main, + window("title", x = 0, y = 0, w = 1000, h = 50), + window("status", x = 0, y = 450, w = 500, h = 50), + window("map", x = 0, y = 50, w = 100, h = 400), + window("gauge", WindowKind.LABEL, x = 900, y = 50, w = 100, h = 400), + window("chat", x = 500, y = 50, w = 400, h = 100), + window("log", x = 100, y = 350, w = 200, h = 100), + window("side", WindowKind.USER_WINDOW, dock = ScriptWindow.Dock(true, "right", false, "Side")), + ), + area(800f, 1600f, fitted = false, selectedPage = WindowKey("map", WindowKind.MINI_CONSOLE)), + ) + // Mudlet gets the area as it would be in landscape + assertEquals(MudletSize(800, 400), layout.mainWindowSize) + // The borders at the width's scale, 0.8; the console fills the width between them + assertEquals(PxRect(0f, 40f, 800f, 1520f), layout.console) + assertEquals(PxRect(0f, 0f, 800f, 40f), layout.rectOf("title")) + assertEquals(PxRect(0f, 1560f, 400f, 40f), layout.rectOf("status")) + // Over the text at the text's scale, 1 here, from the console's top or bottom, whichever is nearer + assertEquals(PxRect(400f, 40f, 400f, 100f), layout.rectOf("chat")) + assertEquals(PxRect(0f, 1460f, 200f, 100f), layout.rectOf("log")) + // The left border's console and the dock are pages; a label in a side border is not one + assertEquals(listOf("map", "side"), layout.pages.map { it.name }) + assertTrue(layout.windows.none { it.window.name in setOf("map", "gauge", "side") }) + assertEquals(PxRect(0f, 40f, 800f, 1520f), layout.pane) + assertEquals(listOf(PlacedWindow(layout.pages[0], PxRect(0f, 140f, 800f, 1420f), PxRect(0f, 140f, 800f, 1420f))), layout.paneWindows) + assertEquals(Placement(0, 70, 400, 710, false), layout.placements["side"]) + } + + @Test + fun stackedBordersTakeAtMostHalfTheHeight() { + // Borders of 200 at the top and the bottom of a 1000 by 500 window, in an area 800 by 400: 0.8 would give them 320 + val layout = WindowLayout.of(state(mainArea(1000, 500, 75, 12, 0, 200, 1000, 100)), area(800f, 400f, fitted = false)) + assertEquals(PxRect(0f, 100f, 800f, 200f), layout.console) + } + + @Test + fun withoutMudletsFontNothingIsSentButTheWindowsAreDrawn() { + val layout = WindowLayout.of( + ScriptWindows( + listOf(window("chat", w = 400, h = 250), window("side", WindowKind.USER_WINDOW, dock = ScriptWindow.Dock(true, "left", false, ""))), + mainArea = mainArea(800, 500, 40, 10, 0, 0, 800, 500), + ), + area(1600f, 1000f, fitted = true), + ) + assertNull(layout.scale) + assertNull(layout.mainWindowSize) + assertTrue(layout.placements.isEmpty()) + // The main window has 1200 of the 1600 beside the dock, for Mudlet's 800 + assertEquals(PxRect(400f, 0f, 600f, 500f), layout.rectOf("chat")) + } + + @Test + fun windowsAreCutToTheirAreaAndToTheirParentsAndHiddenOnesLeftOut() { + val layout = WindowLayout.of( + state( + mainArea(800, 500, 100, 25, 0, 0, 800, 500), + window("box", WindowKind.SCROLL_BOX, x = 700, y = 0, w = 200, h = 100), + window("inside", x = 0, y = 0, w = 200, h = 50, parent = "box", parentKind = WindowKind.SCROLL_BOX), + window("hidden", x = 0, y = 0, w = 10, h = 10, shown = false), + ), + area(1600f, 1000f, fitted = true), + ) + assertEquals(PxRect(1400f, 0f, 200f, 200f), layout.rectOf("box")) + val inside = layout.windows.single { it.window.name == "inside" } + assertEquals(PxRect(1400f, 0f, 200f, 100f), inside.rect) + assertEquals(400f, inside.full.w) + assertEquals(PxRect(1400f, 0f, 400f, 100f), inside.full) + assertTrue(layout.windows.none { it.window.name == "hidden" }) + } + + @Test + fun parentsComeBeforeTheirChildrenAndSiblingsByStackWithoutConsolesOwnCommandLines() { + val layout = WindowLayout.of( + state( + mainArea(800, 500, 100, 25, 0, 0, 800, 500), + window("high", stack = 1), + window("low", stack = 0), + window("high", WindowKind.COMMAND_LINE, w = 0, h = 0, parent = "high", parentKind = WindowKind.MINI_CONSOLE), + window("inLow", WindowKind.LABEL, w = 10, h = 10, parent = "low", parentKind = WindowKind.MINI_CONSOLE), + ), + area(1600f, 1000f, fitted = true), + ) + assertEquals(listOf("low", "inLow", "high"), layout.windows.map { it.window.name }) + } + + private companion object { + const val HEADER = 100f + } +}