From b2eb0399c63117ec28c1f913ee5d86400f8e9f51 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:33:13 -0400 Subject: [PATCH 1/8] feat(runtime): add scroll, right click, and anvil text input to full-screen windows Held-slot changes become signed steps the shortest way around the hotbar, main-hand interactions with the click entity become right clicks, and promptText opens an anvil over the canvas whose rename field is reported, reopening a fresh anvil each time the 50-character field fills. --- .../minestom/internal/MinestomFullscreen.kt | 60 ++++++- .../minestom/internal/MinestomTextInput.kt | 152 ++++++++++++++++++ .../window/minestom/MinestomFullscreenTest.kt | 126 +++++++++++++++ .../com/chunkzero/window/FullscreenScope.kt | 17 ++ .../com/chunkzero/window/FullscreenSession.kt | 86 ++++++++++ .../com/chunkzero/window/FullscreenView.kt | 27 +++- .../chunkzero/window/host/FullscreenHost.kt | 44 ++++- .../chunkzero/window/FullscreenSessionTest.kt | 61 +++++++ 8 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index 48713d37..df09d404 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -2,6 +2,7 @@ package com.chunkzero.window.minestom.internal import com.chunkzero.window.FullscreenDefinition import com.chunkzero.window.host.FullscreenListener +import com.chunkzero.window.host.FullscreenTextListener import com.chunkzero.window.host.OpenFullscreen import net.kyori.adventure.text.Component import net.minestom.server.component.DataComponents @@ -13,6 +14,7 @@ import net.minestom.server.entity.GameMode import net.minestom.server.entity.Metadata import net.minestom.server.entity.MetadataDef import net.minestom.server.entity.Player +import net.minestom.server.entity.PlayerHand import net.minestom.server.entity.attribute.Attribute import net.minestom.server.entity.metadata.display.AbstractDisplayMeta import net.minestom.server.entity.metadata.display.TextDisplayMeta @@ -28,6 +30,7 @@ import net.minestom.server.item.Material import net.minestom.server.network.packet.client.play.ClientAnimationPacket import net.minestom.server.network.packet.client.play.ClientAttackPacket import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket +import net.minestom.server.network.packet.client.play.ClientHeldItemChangePacket import net.minestom.server.network.packet.client.play.ClientInputPacket import net.minestom.server.network.packet.client.play.ClientInteractEntityPacket import net.minestom.server.network.packet.client.play.ClientPlayerActionPacket @@ -39,6 +42,7 @@ import net.minestom.server.network.packet.server.play.CloseWindowPacket import net.minestom.server.network.packet.server.play.DestroyEntitiesPacket import net.minestom.server.network.packet.server.play.EntityAttributesPacket import net.minestom.server.network.packet.server.play.EntityMetaDataPacket +import net.minestom.server.network.packet.server.play.HeldItemChangePacket import net.minestom.server.network.packet.server.play.OpenWindowPacket import net.minestom.server.network.packet.server.play.PlayerAbilitiesPacket import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket @@ -58,8 +62,9 @@ import net.minestom.server.world.clock.WorldClock * child of their event node. * * The server keeps the player in spectator mode, pinned at their position facing yaw and pitch 0. Their client alone is - * told it is in survival, flying at zero speed, so the scroll wheel and number keys change the held slot. A client-only - * interaction entity encloses the eye, so every left click attacks it without a miss cooldown. + * told it is in survival, flying at zero speed, so the scroll wheel and number keys change the held slot, which is + * reported as steps and never applied to the server's player. A client-only interaction entity encloses the eye, so + * every left click attacks it without a miss cooldown, and every right click interacts with it once, with the main hand. * * The HUD is hidden: the hotbar holds blank items, which also hide the hand, and the client's world age is held * negative, which the pack's GUI shader reads as "clip the bottom-centre HUD strip". So the hearts fit that strip, the @@ -105,6 +110,10 @@ internal class MinestomFullscreen private constructor( private val lock = Any() private var open = false + /** The held slot the client has selected, which held-slot changes are measured from. Requires [lock]. */ + private var clientSlot = heldSlot.toInt() + private val textInput = MinestomTextInput(player, ::deferWhileOpen) + /** The window id of the server container the client has open, or [NO_CONTAINER]. */ private var containerWindowId = NO_CONTAINER private val containerOpen get() = containerWindowId != NO_CONTAINER @@ -127,6 +136,15 @@ internal class MinestomFullscreen private constructor( player.sendPacket(PlayerRotationPacket(yaw, true, pitch, true)) } + override fun openTextInput( + title: Component, + text: String, + listener: FullscreenTextListener, + ) { + check(isOpen()) { "The full-screen window of ${player.username} is closed" } + textInput.open(title, text, listener) + } + override fun close() { if (stop()) restore() } @@ -266,8 +284,21 @@ internal class MinestomFullscreen private constructor( player.scheduler().scheduleEndOfTick { if (isOpen()) listener.onClick() } } + is ClientInteractEntityPacket -> { + event.isCancelled = true + // The interaction entity consumes a main-hand use, so the client never tries the off hand. + if (packet.hand() == PlayerHand.MAIN) { + player.scheduler().scheduleEndOfTick { if (isOpen()) listener.onRightClick() } + } + } + + is ClientHeldItemChangePacket -> { + event.isCancelled = true + scroll(packet.slot().toInt()) + } + // Dropping or swapping hands would resync the real hotbar over the blank items. - is ClientAnimationPacket, is ClientInteractEntityPacket, is ClientPlayerActionPacket -> { + is ClientAnimationPacket, is ClientPlayerActionPacket -> { event.isCancelled = true } @@ -281,11 +312,23 @@ internal class MinestomFullscreen private constructor( } else -> { - Unit + textInput.onPacket(event) } } } + /** Reports the client selecting [slot] as steps the shortest way around the hotbar from the slot it had. */ + private fun scroll(slot: Int) { + if (slot !in 0 until HOTBAR_SIZE) return + val steps = + synchronized(lock) { + val from = clientSlot + clientSlot = slot + Math.floorMod(slot - from + HOTBAR_SIZE / 2, HOTBAR_SIZE) - HOTBAR_SIZE / 2 + } + if (steps != 0) listener.onScroll(steps) + } + /** Keeps the HUD hidden and the client in fake survival after each of the server's own updates. */ private fun onPacketOut(event: PlayerPacketOutEvent) { whileOpen { @@ -324,6 +367,10 @@ internal class MinestomFullscreen private constructor( if (packet != abilities) player.sendPacket(abilities) } + is HeldItemChangePacket -> { + clientSlot = packet.slot().toInt() + } + is SetPlayerInventorySlotPacket, is SetSlotPacket, is WindowItemsPacket -> { if (!containerOpen) blank(realHotbarSlots(packet)) } @@ -390,6 +437,10 @@ internal class MinestomFullscreen private constructor( private fun isOpen() = synchronized(lock) { open } + private fun deferWhileOpen(task: () -> Unit) { + player.scheduler().scheduleNextTick { if (isOpen()) task() } + } + /** Runs [action] under [lock] unless the window has stopped, so it cannot enqueue a packet after restoring. */ private inline fun whileOpen(action: () -> Unit) { synchronized(lock) { if (open) action() } @@ -413,6 +464,7 @@ internal class MinestomFullscreen private constructor( } private fun restore() { + textInput.close() player.sendPacket(DestroyEntitiesPacket(listOf(display.entityId, clickTarget.entityId))) // A mode the server set while the window was open replaces the one it began with. val mode = player.gameMode.takeUnless { it == GameMode.SPECTATOR } ?: gameMode diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt new file mode 100644 index 00000000..c1791cd3 --- /dev/null +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt @@ -0,0 +1,152 @@ +package com.chunkzero.window.minestom.internal + +import com.chunkzero.window.host.FullscreenTextListener +import net.kyori.adventure.text.Component +import net.minestom.server.component.DataComponents +import net.minestom.server.entity.Player +import net.minestom.server.event.player.PlayerPacketEvent +import net.minestom.server.inventory.type.AnvilInventory +import net.minestom.server.item.ItemStack +import net.minestom.server.item.Material +import net.minestom.server.network.packet.client.common.ClientPongPacket +import net.minestom.server.network.packet.client.play.ClientClickWindowPacket +import net.minestom.server.network.packet.client.play.ClientNameItemPacket +import net.minestom.server.network.packet.server.common.PingPacket +import net.minestom.server.network.packet.server.play.BundlePacket +import java.util.concurrent.atomic.AtomicInteger + +/** + * The anvil a full-screen window opens over its canvas for [player] to type into, reporting its rename field. + * + * The anvil holds a paper named after the field's text, which vanilla copies into the field. Every open, including a + * reopen over the current anvil, is bundled with a ping: the client answers it after echoing the paper's name, so + * rename packets before the pong come from an earlier anvil or are the echo, and are dropped. Every click on the anvil + * is refused and resynced; one on the result slot submits. [defer] runs a task on the next tick while the window is + * open, since an anvil can be replaced while Minestom is still closing it. + */ +internal class MinestomTextInput( + private val player: Player, + private val defer: (() -> Unit) -> Unit, +) { + private var anvil: PromptAnvil? = null + private var listener: FullscreenTextListener? = null + + /** The rename field's text as of the latest edit reported, or the text the anvil opened with. */ + private var field = "" + + /** The ping that settles the latest open, until its pong arrives. */ + private var awaiting: Int? = null + + fun open( + title: Component, + text: String, + listener: FullscreenTextListener, + ) { + val current = anvil?.takeIf { player.openInventory === it } + check(current != null || player.openInventory == null) { + "${player.username} has another container open, so no text input can open over the full-screen window" + } + val target = current ?: PromptAnvil(title) + target.setItemStack(0, seed(text), false) + this.listener = listener + field = text + anvil = target + val id = PINGS.getAndIncrement() + awaiting = id + player.sendPacket(BundlePacket()) + try { + if (current != null) { + target.setTitle(title) + } else { + check(player.openInventory(target) && player.openInventory === target) { + "Opening a text input for ${player.username} was cancelled or redirected by an inventory open listener" + } + } + player.sendPacket(PingPacket(id)) + } catch (failure: Throwable) { + reset() + throw failure + } finally { + player.sendPacket(BundlePacket()) + } + } + + /** Closes the anvil, if one is open, without reporting it. */ + fun close() { + val current = anvil ?: return + reset() + if (player.openInventory === current) player.closeInventory() + } + + /** Handles a packet from the client while the full-screen window is open. */ + fun onPacket(event: PlayerPacketEvent) { + val current = anvil ?: return + when (val packet = event.packet) { + is ClientPongPacket -> { + if (packet.id() == awaiting) awaiting = null + } + + is ClientNameItemPacket -> { + event.isCancelled = true + if (awaiting != null || packet.itemName() == field) return + field = packet.itemName() + listener?.onEdit(field) + } + + is ClientClickWindowPacket -> { + if (packet.windowId() != current.windowId.toInt()) return + event.isCancelled = true + if (packet.slot() == RESULT_SLOT) { + val submitted = listener + close() + submitted?.onSubmit() + } else { + // Renaming the paper to the field keeps the resync from resetting what the player typed. + current.setItemStack(0, seed(field), false) + current.update(player) + player.inventory.update(player) + } + } + + else -> { + Unit + } + } + } + + private fun ended(closing: PromptAnvil) { + if (closing !== anvil) return + val cancelled = listener + reset() + defer { cancelled?.onCancel() } + } + + private fun reset() { + anvil = null + listener = null + awaiting = null + } + + private fun seed(text: String): ItemStack = + ItemStack + .builder(Material.PAPER) + .set(DataComponents.CUSTOM_NAME, Component.text(text)) + .build() + + /** An anvil that reports when its viewer is removed, whether it closes or another inventory replaces it. */ + private inner class PromptAnvil( + title: Component, + ) : AnvilInventory(title) { + override fun removeViewer(player: Player): Boolean { + if (player in viewers) ended(this) + return super.removeViewer(player) + } + } + + private companion object { + const val RESULT_SLOT: Short = 2 + + /** Ping ids start in a range unlikely to collide with other users of the ping packet. */ + val PINGS = AtomicInteger(0x57_46_00_00) + } +} diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index cb5f7b2b..7553ecb7 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -16,14 +16,17 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import net.minestom.server.MinecraftServer import net.minestom.server.component.DataComponents import net.minestom.server.coordinate.Pos +import net.minestom.server.coordinate.Vec import net.minestom.server.entity.EntityType import net.minestom.server.entity.GameMode import net.minestom.server.entity.Metadata import net.minestom.server.entity.MetadataDef import net.minestom.server.entity.Player +import net.minestom.server.entity.PlayerHand import net.minestom.server.entity.attribute.Attribute import net.minestom.server.event.EventDispatcher import net.minestom.server.event.player.PlayerPacketEvent @@ -34,17 +37,24 @@ import net.minestom.server.item.ItemStack import net.minestom.server.item.Material import net.minestom.server.network.ConnectionState import net.minestom.server.network.packet.client.ClientPacket +import net.minestom.server.network.packet.client.common.ClientPongPacket import net.minestom.server.network.packet.client.play.ClientAttackPacket +import net.minestom.server.network.packet.client.play.ClientClickWindowPacket import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket +import net.minestom.server.network.packet.client.play.ClientHeldItemChangePacket import net.minestom.server.network.packet.client.play.ClientInputPacket +import net.minestom.server.network.packet.client.play.ClientInteractEntityPacket +import net.minestom.server.network.packet.client.play.ClientNameItemPacket import net.minestom.server.network.packet.client.play.ClientPlayerRotationPacket import net.minestom.server.network.packet.server.SendablePacket import net.minestom.server.network.packet.server.ServerPacket +import net.minestom.server.network.packet.server.common.PingPacket import net.minestom.server.network.packet.server.play.ChangeGameStatePacket import net.minestom.server.network.packet.server.play.CloseWindowPacket import net.minestom.server.network.packet.server.play.DestroyEntitiesPacket import net.minestom.server.network.packet.server.play.EntityAttributesPacket import net.minestom.server.network.packet.server.play.EntityMetaDataPacket +import net.minestom.server.network.packet.server.play.HeldItemChangePacket import net.minestom.server.network.packet.server.play.OpenWindowPacket import net.minestom.server.network.packet.server.play.PlayerAbilitiesPacket import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket @@ -136,6 +146,93 @@ class MinestomFullscreenTest : menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) } + "held-slot changes are steps the shortest way around the hotbar, from the slot the client last had" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + + fixture.receive(ClientHeldItemChangePacket(1)) + fixture.receive(ClientHeldItemChangePacket(8)) + fixture.send(HeldItemChangePacket(5)) + fixture.receive(ClientHeldItemChangePacket(6)) + + menu.scrolls shouldContainExactly listOf(1, -2, 1) + fixture.player.heldSlot shouldBe 0.toByte() + } + + "a main-hand right click is reported at the end of the player's tick" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + + fixture.receive(ClientInteractEntityPacket(0, PlayerHand.MAIN, Vec.ZERO, false)) + fixture.receive(ClientInteractEntityPacket(0, PlayerHand.OFF, Vec.ZERO, false)) + fixture.receive(ClientPlayerRotationPacket(-44f, -24.375f, 0.toByte())) + fixture.player.scheduler().processTickEnd() + + menu.rightClicks shouldContainExactly listOf(FullscreenClick(5, 5)) + } + + "a text input opens an anvil, ignores renames until its ping returns, and submits from the result slot" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player) + menu.open() + fixture.tick() + fixture.clear() + + menu.prompt("hi") + val anvil = fixture.sent().single() + anvil.windowType() shouldBe InventoryType.ANVIL.windowType + fixture.sent().single() shouldBe player.instance.createTimePacket() + fixture.seed(anvil.windowId()) shouldBe "hi" + fixture.receive(ClientNameItemPacket("earlier")) + fixture.receive(ClientPongPacket(fixture.sent().single().id())) + fixture.receive(ClientNameItemPacket("hi")) + fixture.receive(ClientNameItemPacket("hey")) + menu.edits shouldContainExactly listOf("hey") + + // A full field reopens the same anvil with an empty one. + val full = "a".repeat(50) + fixture.clear() + fixture.receive(ClientNameItemPacket(full)) + fixture.sent().single().windowId() shouldBe anvil.windowId() + fixture.seed(anvil.windowId()) shouldBe "" + fixture.receive(ClientNameItemPacket(full.drop(1))) + fixture.receive(ClientPongPacket(fixture.sent().single().id())) + fixture.receive(ClientNameItemPacket("b")) + menu.edits shouldContainExactly listOf("hey", full, full + "b") + + fixture.clear() + fixture.receive(fixture.click(anvil.windowId(), 0)) + fixture.seed(anvil.windowId()) shouldBe "b" + fixture.receive(fixture.click(anvil.windowId(), 2)) + menu.prompts shouldContainExactly listOf(full + "b") + player.openInventory shouldBe null + fixture.clear() + fixture.tick() + fixture.sent().single().gameTime() shouldBe HIDDEN_AGE + } + + "closing the anvil cancels the prompt on the next tick, and closing the window closes the anvil" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player) + val session = menu.open() + + menu.prompt("") + player.closeInventory() + menu.prompts shouldBe emptyList() + fixture.tick() + menu.prompts shouldContainExactly listOf(null) + + menu.prompt("") + session.close() + player.openInventory shouldBe null + fixture.tick() + menu.prompts shouldContainExactly listOf(null) + } + "sneaking leaves the window and restores the player" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) @@ -312,12 +409,20 @@ private class Menu( player: Player, ) : FullscreenView(FullscreenDefinition(manifest(), "menu"), MinestomFullscreenHost(player)) { val clicks = mutableListOf() + val rightClicks = mutableListOf() + val scrolls = mutableListOf() + val edits = mutableListOf() + val prompts = mutableListOf() var closed = false override fun FullscreenScope.bind() { button("press") { clicks += it } + rightClick { rightClicks += it } + scroll { scrolls += it } } + fun prompt(initial: String) = promptText(initial = initial, onEdit = { edits += it }) { prompts += it } + override fun onClose() { closed = true } @@ -358,6 +463,27 @@ private class FullscreenFixture { fun receive(packet: ClientPacket) = EventDispatcher.call(PlayerPacketEvent(player, packet)) + fun click( + windowId: Int, + slot: Int, + ) = ClientClickWindowPacket( + windowId, + 0, + slot.toShort(), + 0, + ClientClickWindowPacket.ClickType.PICKUP, + emptyMap(), + ItemStack.Hash.AIR, + ) + + /** The name of the paper in the first slot of window [windowId] as last sent. */ + fun seed(windowId: Int): String? = + sent() + .last { it.windowId() == windowId } + .items()[0] + .get(DataComponents.CUSTOM_NAME) + ?.let { PlainTextComponentSerializer.plainText().serialize(it) } + /** Queues [packets] back to back, then writes them out the way the connection's writer thread does. */ fun send(vararg packets: ServerPacket) { packets.forEach(connection::sendPacket) diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenScope.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenScope.kt index 32fd2f58..a7680595 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenScope.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenScope.kt @@ -49,6 +49,23 @@ public interface FullscreenScope { name: String, handler: (FullscreenClick) -> Unit, ) + + /** + * Binds [handler] to right clicks anywhere on the canvas, at the cursor. Holding the use key repeats a right click + * every 4 ticks. + */ + public fun rightClick(handler: (FullscreenClick) -> Unit) + + /** + * Binds [handler] to the scroll wheel, which reaches the server as changes to the client's held hotbar slot. Each + * change becomes a signed step count, taken the shortest way around the 9-slot hotbar: positive when the selection + * moves right, which scrolling down does by default. + * + * The client sends at most one held-slot change per tick, so notches scrolled within one tick arrive as one call, + * and more than 4 of them read as a scroll the other way. The number keys also select a slot, and the server cannot + * tell them apart from scrolling. + */ + public fun scroll(handler: (steps: Int) -> Unit) } /** diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt index 1c28e8b4..c0616d7e 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -2,7 +2,9 @@ package com.chunkzero.window import com.chunkzero.window.host.FullscreenHost import com.chunkzero.window.host.FullscreenListener +import com.chunkzero.window.host.FullscreenTextListener import com.chunkzero.window.host.OpenFullscreen +import com.chunkzero.window.internal.AnvilReopenGate import com.chunkzero.window.internal.CanvasPoint import com.chunkzero.window.internal.FullscreenMapping import com.chunkzero.window.internal.HudLayers @@ -48,6 +50,7 @@ internal class FullscreenWindowSession( private var yaw = 0f private var pitch = 0f private var pullBackAfter = Long.MIN_VALUE + private var prompt: TextPrompt? = null private var closed = false private val listener = @@ -59,9 +62,20 @@ internal class FullscreenWindowSession( override fun onClick() = click() + override fun onRightClick() { + if (closed) return + val point = cursor() + bindings.rightClick?.invoke(FullscreenClick(point.x, point.y)) + } + + override fun onScroll(steps: Int) { + if (!closed) bindings.scroll?.invoke(steps) + } + override fun onClose() { if (closed) return closed = true + prompt = null view.invokeOnClose() } } @@ -89,10 +103,30 @@ internal class FullscreenWindowSession( override fun close() { if (closed) return closed = true + prompt = null view.invokeOnClose() screen.close() } + fun promptText( + title: Component, + initial: String, + onEdit: (String) -> Unit, + onDone: (String?) -> Unit, + ) { + check(!closed) { "Fullscreen view is not open" } + check(prompt == null) { "A text prompt is already open in fullscreen '${definition.name}'" } + val kept = initial.length - initial.length % AnvilReopenGate.MAX_NAME_LENGTH + val next = TextPrompt(title, initial.take(kept), onEdit, onDone) + prompt = next + try { + next.open(initial.drop(kept)) + } catch (failure: Throwable) { + prompt = null + throw failure + } + } + fun refreshAll() { reactivity.markAllDirty(layers.renderKeys()) } @@ -171,10 +205,50 @@ internal class FullscreenWindowSession( sent = content } + /** + * A [promptText] anvil: [kept] is the text of earlier anvils whose field filled, and [field] the current anvil's. + */ + private inner class TextPrompt( + private val title: Component, + private var kept: String, + private val edited: (String) -> Unit, + private val done: (String?) -> Unit, + ) : FullscreenTextListener { + private var field = "" + + fun open(text: String) { + field = text + screen.openTextInput(title, text, this) + } + + override fun onEdit(text: String) { + if (prompt !== this) return + if (text.length >= AnvilReopenGate.MAX_NAME_LENGTH) { + kept += text + open("") + } else { + field = text + } + edited(kept + field) + } + + override fun onSubmit() = finish(kept + field) + + override fun onCancel() = finish(null) + + private fun finish(text: String?) { + if (prompt !== this) return + prompt = null + done(text) + } + } + /** The [FullscreenScope] passed to `bind`, and the check that every control requiring a binding received one. */ private inner class Bindings : FullscreenScope { var sealed = false val buttons = HashMap Unit>() + var rightClick: ((FullscreenClick) -> Unit)? = null + var scroll: ((Int) -> Unit)? = null /** The action ids controls name that a handler can bind; runtime actions are run by the runtime. */ private val actions: Set = @@ -225,6 +299,18 @@ internal class FullscreenWindowSession( require(buttons.put(name, handler) == null) { "Button '$name' bound more than once" } } + override fun rightClick(handler: (FullscreenClick) -> Unit) { + requireBindable("rightClick") + require(rightClick == null) { "rightClick bound more than once" } + rightClick = handler + } + + override fun scroll(handler: (steps: Int) -> Unit) { + requireBindable("scroll") + require(scroll == null) { "scroll bound more than once" } + scroll = handler + } + fun validate() { layers.validate() val controls = entry.controls.values diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index 271bfb10..51148bf3 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -12,8 +12,8 @@ import kotlin.properties.ReadWriteProperty * reacts to lifecycle events. * * A view shows [definition] to the player of [host] and is single-use: [open] it once. The player aims a cursor by - * turning, clicks with the attack key, and leaves by sneaking. Reactive [state] cells read inside render lambdas - * re-render the window when written. + * turning, clicks with the attack and use keys, scrolls, types into a [promptText] anvil, and leaves by sneaking. + * Reactive [state] cells read inside render lambdas re-render the window when written. */ public abstract class FullscreenView( private val definition: FullscreenDefinition, @@ -74,6 +74,29 @@ public abstract class FullscreenView( requireSession().refreshAll() } + /** + * Opens an anvil titled [title] over the canvas for the player to type into, starting from [initial]. + * + * [onEdit] receives the whole text after each edit. [onDone] receives it once the player clicks the anvil's result + * slot, or `null` once they close the anvil; the canvas is back by then. Neither runs after the window closes. While + * the anvil is open, the player sees their real hotbar and HUD, and the cursor stays where it was. + * + * The anvil's rename field holds 50 characters. When it fills, its text is kept and a fresh anvil opens with an empty + * field, so the text can grow past 50 characters, but only the part after the last 50-character boundary is shown + * and editable. Keys pressed before the fresh anvil arrives are lost. + * + * @throws IllegalStateException if the view is not open, a prompt is already open, or the host could not open the + * anvil, for example while another container is open. + */ + protected fun promptText( + title: Component = Component.empty(), + initial: String = "", + onEdit: (String) -> Unit = {}, + onDone: (String?) -> Unit, + ) { + requireSession().promptText(title, initial, onEdit, onDone) + } + /** * [value] shortened with an ellipsis so that it, followed by [suffix], fits the width of slot [slot] when drawn in * its font and style; followed by [suffix], which is kept even when it alone is wider. Content that already fits is diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt index 6e6837bf..528cb1d8 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt @@ -41,8 +41,24 @@ public interface OpenFullscreen { ) /** - * Server-initiated close: stops listening, removes the display, and restores the player's game mode, abilities, - * position, held slot, and HUD. Does not call [FullscreenListener.onClose]. + * Opens an anvil titled [title] over the canvas, with [text] in its rename field, and reports its input to + * [listener] until it closes. While it is open, the client has its real world age and hotbar; the HUD is hidden + * again once it closes. + * + * Called while an anvil is open, it opens a fresh one in its place reporting to [listener], without reporting the + * old one's end. Edits made on an earlier anvil, and the client's echo of [text], are not reported. + * + * @throws IllegalStateException if another container is open or an inventory listener cancelled the anvil. + */ + public fun openTextInput( + title: Component, + text: String, + listener: FullscreenTextListener, + ) + + /** + * Server-initiated close: stops listening, closes an open anvil, removes the display, and restores the player's game + * mode, abilities, position, held slot, and HUD. Does not call [FullscreenListener.onClose]. */ public fun close() } @@ -58,9 +74,33 @@ public interface FullscreenListener { /** A left click, reported after every rotation the client sent in the same tick. */ public fun onClick() + /** A right click, reported after every rotation the client sent in the same tick. */ + public fun onRightClick() + + /** + * The client's held slot moved by [steps], from -4 to 4 and never 0, taken the shortest way around the hotbar; + * positive steps move right. The scroll wheel and the number keys both move it, at most once per tick. + */ + public fun onScroll(steps: Int) + /** * The player left the window, by sneaking or disconnecting. Listening has stopped, and the player is restored * unless they disconnected. */ public fun onClose() } + +/** Input from the anvil that [OpenFullscreen.openTextInput] opened. */ +public interface FullscreenTextListener { + /** The player changed the rename field to [text]. */ + public fun onEdit(text: String) + + /** The player clicked the anvil's result slot. The anvil has closed. */ + public fun onSubmit() + + /** + * The anvil closed without a submit: the player closed it, or another inventory replaced it. Reported on the next + * tick, and only while the full-screen window is open. + */ + public fun onCancel() +} diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt index 8d64a48c..97213dec 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt @@ -2,6 +2,7 @@ package com.chunkzero.window import com.chunkzero.window.host.FullscreenHost import com.chunkzero.window.host.FullscreenListener +import com.chunkzero.window.host.FullscreenTextListener import com.chunkzero.window.host.OpenFullscreen import com.chunkzero.window.internal.Turn import com.chunkzero.window.manifest.FullscreenMode @@ -130,6 +131,40 @@ class FullscreenSessionTest : host.turns shouldContainExactly listOf(Turn(-5f, 0f), Turn(-5f, 0f)) } + "right clicks and scroll steps reach their handlers, right clicks at the cursor" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + host.look(200, 200) + host.listener.onRightClick() + host.listener.onScroll(1) + host.listener.onScroll(-3) + + menu.rightClicks shouldContainExactly listOf(FullscreenClick(200, 200)) + menu.scrolls shouldContainExactly listOf(1, -3) + } + + "a text prompt keeps each full field and opens a fresh anvil, reporting the whole text" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + val full = "a".repeat(50) + + menu.prompt(full + "bc") + host.inputs.single().text shouldBe "bc" + host.text.onEdit("bcd") + host.text.onEdit(full) + host.inputs.map { it.text } shouldContainExactly listOf("bc", "") + host.text.onEdit("e") + host.text.onSubmit() + + menu.edits shouldContainExactly listOf(full + "bcd", full + full, full + full + "e") + menu.prompts shouldContainExactly listOf(full + full + "e") + menu.prompt("x") + shouldThrow { menu.prompt("y") } + host.text.onCancel() + menu.prompts shouldContainExactly listOf(full + full + "e", null) + } + "horse mode is refused" { val host = FakeFullscreenHost() shouldThrow { Menu(host, FullscreenMode.HORSE).open() } @@ -146,6 +181,10 @@ private class Menu( var plays = 0 var secrets = 0 var closes = 0 + val rightClicks = mutableListOf() + val scrolls = mutableListOf() + val edits = mutableListOf() + val prompts = mutableListOf() override fun FullscreenScope.bind() { slot("count") { Component.text("N$count") } @@ -155,8 +194,12 @@ private class Menu( count++ } button("secret") { secrets++ } + rightClick { rightClicks += it } + scroll { scrolls += it } } + fun prompt(initial: String) = promptText(initial = initial, onEdit = { edits += it }) { prompts += it } + override fun onClose() { closes++ } @@ -168,9 +211,14 @@ private class FakeFullscreenHost : val scheduler = ManualScheduler() val shown = mutableListOf() val turns = mutableListOf() + val inputs = mutableListOf() var closes = 0 lateinit var listener: FullscreenListener + /** The listener of the latest text input. */ + val text: FullscreenTextListener + get() = inputs.last().listener + override val latencyMillis = 50 override fun open( @@ -196,6 +244,14 @@ private class FakeFullscreenHost : turns += Turn(yaw, pitch) } + override fun openTextInput( + title: Component, + text: String, + listener: FullscreenTextListener, + ) { + inputs += TextInput(text, listener) + } + override fun close() { closes++ } @@ -210,6 +266,11 @@ private class FakeFullscreenHost : } } +private data class TextInput( + val text: String, + val listener: FullscreenTextListener, +) + private data class Run( val color: TextColor?, val shadow: ShadowColor?, From da5d20185a3436ecd17349095e42574c7acd5c19 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:17:15 -0400 Subject: [PATCH 2/8] fix(minestom): keep the HUD hidden behind the text prompt and gate its click resyncs The prompt anvil is the window's own UI, so it no longer hands the client the real world age and hotbar. Its paper takes the blank item model and carries no enchantments, so the client computes no result or cost label, and the repair cost is sent as 0. Click resyncs ride a ping like opens do, and text typed before one reached the client is kept. Taking the result resyncs the player's experience. Generated full-screen classes bind scroll and right click to open onScroll and onRightClick hooks. --- crates/window-core/src/codegen/naming.rs | 3 + crates/window-core/src/codegen/tests.rs | 4 + crates/window-core/src/codegen/view/class.rs | 16 ++- crates/window-core/src/codegen/view/mod.rs | 8 +- .../minestom/internal/MinestomFullscreen.kt | 18 ++- .../minestom/internal/MinestomTextInput.kt | 129 ++++++++++++++---- .../window/minestom/MinestomFullscreenTest.kt | 78 +++++++++-- .../com/chunkzero/window/FullscreenView.kt | 2 +- .../chunkzero/window/host/FullscreenHost.kt | 4 +- plugin/test/golden/fullscreen/expected.sha256 | 4 +- 10 files changed, 212 insertions(+), 54 deletions(-) diff --git a/crates/window-core/src/codegen/naming.rs b/crates/window-core/src/codegen/naming.rs index f36137e7..e79dc8cf 100644 --- a/crates/window-core/src/codegen/naming.rs +++ b/crates/window-core/src/codegen/naming.rs @@ -103,6 +103,9 @@ pub(super) fn is_valid_identifier(name: &str) -> bool { /// Members `HudView` declares beyond the shared set. pub(super) const HUD_RESERVED_MEMBERS: &[&str] = &["render", "channel"]; +/// Members `FullscreenView` generated classes declare beyond the shared set. +pub(super) const FULLSCREEN_RESERVED_MEMBERS: &[&str] = &["onScroll", "onRightClick"]; + const RESERVED_MEMBERS: &[&str] = &[ "player", "host", diff --git a/crates/window-core/src/codegen/tests.rs b/crates/window-core/src/codegen/tests.rs index fdb54764..99e1d5a4 100644 --- a/crates/window-core/src/codegen/tests.rs +++ b/crates/window-core/src/codegen/tests.rs @@ -494,4 +494,8 @@ fn fullscreen_views_take_a_fullscreen_host_and_receive_fullscreen_clicks() { assert!(content.contains("button(\"buy[$index]\") { click ->\n"), "{content}"); assert!(content.contains("onBuy(index, click)\n"), "{content}"); assert!(content.contains("button(\"play\", ::onPlay)"), "{content}"); + assert!(content.contains("protected open fun onScroll(steps: Int) {}"), "{content}"); + assert!(content.contains("protected open fun onRightClick(click: FullscreenClick) {}"), "{content}"); + assert!(content.contains("scroll(::onScroll)\n"), "{content}"); + assert!(content.contains("rightClick(::onRightClick)\n"), "{content}"); } diff --git a/crates/window-core/src/codegen/view/class.rs b/crates/window-core/src/codegen/view/class.rs index cfdf62af..0005e068 100644 --- a/crates/window-core/src/codegen/view/class.rs +++ b/crates/window-core/src/codegen/view/class.rs @@ -59,11 +59,24 @@ pub(super) fn render( for handle in handles { handle.declare(&mut w, item); } - if handles.iter().all(|handle| handle.handle.uses.is_empty()) { + let fullscreen = base.host == ViewHost::Fullscreen; + if fullscreen { + w.doc("Handle a scroll of the wheel, as the signed steps the hotbar selection moved."); + w.line("protected open fun onScroll(steps: Int) {}"); + w.blank(); + w.doc("Handle a right click anywhere on the canvas."); + w.line("protected open fun onRightClick(click: FullscreenClick) {}"); + w.blank(); + } + if !fullscreen && handles.iter().all(|handle| handle.handle.uses.is_empty()) { w.line(format_args!("final override fun {prefix}Scope{type_args}.bind() {{}}")); } else { w.open(format_args!("final override fun {prefix}Scope{type_args}.bind() {{")); handles::bind(&mut w, handles); + if fullscreen { + w.line("scroll(::onScroll)"); + w.line("rightClick(::onRightClick)"); + } w.close("}"); } handles::constants(&mut w, handles); @@ -86,6 +99,7 @@ fn imports(base: &ViewBase, target: KotlinTarget, handles: &[HandleMember]) -> V let mut imports: Vec = [ (base.host == ViewHost::Window && host.is_none(), "com.chunkzero.window.host.WindowHost"), (base.host == ViewHost::Fullscreen && host.is_none(), "com.chunkzero.window.host.FullscreenHost"), + (base.host == ViewHost::Fullscreen, "com.chunkzero.window.FullscreenClick"), (host.is_some(), "net.minestom.server.entity.Player"), (host.is_some() && base.host == ViewHost::Window, "net.minestom.server.item.ItemStack"), ] diff --git a/crates/window-core/src/codegen/view/mod.rs b/crates/window-core/src/codegen/view/mod.rs index e95b0dde..f55c914d 100644 --- a/crates/window-core/src/codegen/view/mod.rs +++ b/crates/window-core/src/codegen/view/mod.rs @@ -58,7 +58,13 @@ pub(super) fn generate_fullscreen( target: KotlinTarget, ) -> Result { let class_name = naming::fullscreen_class_name(name); - let handles = HandleMember::all(&class_name, &[], ClickKind::Fullscreen, &fullscreen.handles, |_| None)?; + let handles = HandleMember::all( + &class_name, + naming::FULLSCREEN_RESERVED_MEMBERS, + ClickKind::Fullscreen, + &fullscreen.handles, + |_| None, + )?; Ok(OutputFile::text( format!("{class_name}.kt"), class::render(package_name, &FULLSCREEN, target, name, &class_name, &handles), diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index df09d404..b23aeaa1 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -69,7 +69,9 @@ import net.minestom.server.world.clock.WorldClock * The HUD is hidden: the hotbar holds blank items, which also hide the hand, and the client's world age is held * negative, which the pack's GUI shader reads as "clip the bottom-centre HUD strip". So the hearts fit that strip, the * client is told its max health is at most the canvas's `hudMaxHealth` and its absorption is 0. While the server has a - * container open, the client gets the real world age back and its real hotbar so the strip and slots show. + * container open, the client gets the real world age back and its real hotbar so the strip and slots show. The text + * prompt's anvil is this window's own UI rather than a server container, so it keeps the HUD hidden and the hotbar + * blank. * * While the window is open, a server update to the game mode, abilities, hotbar, time, max health, or absorption passes * through unchanged and is followed by a packet that puts the hidden state back. Replacing the update instead would @@ -112,7 +114,7 @@ internal class MinestomFullscreen private constructor( /** The held slot the client has selected, which held-slot changes are measured from. Requires [lock]. */ private var clientSlot = heldSlot.toInt() - private val textInput = MinestomTextInput(player, ::deferWhileOpen) + private val textInput = MinestomTextInput(player, blank, ::deferWhileOpen) /** The window id of the server container the client has open, or [NO_CONTAINER]. */ private var containerWindowId = NO_CONTAINER @@ -376,11 +378,13 @@ internal class MinestomFullscreen private constructor( } is OpenWindowPacket -> { - containerWindowId = packet.windowId() - containerGeneration++ - player.sendPacket(realTime()) - // Opening a container does not resync the player inventory, whose hotbar still holds the blanks. - player.inventory.update(player) + if (!textInput.owns(packet.windowId())) { + containerWindowId = packet.windowId() + containerGeneration++ + player.sendPacket(realTime()) + // Opening a container does not resync the player inventory, whose hotbar still holds the blanks. + player.inventory.update(player) + } } is CloseWindowPacket -> { diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt index c1791cd3..bd706c08 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt @@ -7,25 +7,30 @@ import net.minestom.server.entity.Player import net.minestom.server.event.player.PlayerPacketEvent import net.minestom.server.inventory.type.AnvilInventory import net.minestom.server.item.ItemStack -import net.minestom.server.item.Material import net.minestom.server.network.packet.client.common.ClientPongPacket import net.minestom.server.network.packet.client.play.ClientClickWindowPacket import net.minestom.server.network.packet.client.play.ClientNameItemPacket import net.minestom.server.network.packet.server.common.PingPacket import net.minestom.server.network.packet.server.play.BundlePacket +import net.minestom.server.network.packet.server.play.SetExperiencePacket +import net.minestom.server.network.packet.server.play.WindowPropertyPacket import java.util.concurrent.atomic.AtomicInteger /** * The anvil a full-screen window opens over its canvas for [player] to type into, reporting its rename field. * - * The anvil holds a paper named after the field's text, which vanilla copies into the field. Every open, including a - * reopen over the current anvil, is bundled with a ping: the client answers it after echoing the paper's name, so - * rename packets before the pong come from an earlier anvil or are the echo, and are dropped. Every click on the anvil - * is refused and resynced; one on the result slot submits. [defer] runs a task on the next tick while the window is - * open, since an anvil can be replaced while Minestom is still closing it. + * The anvil holds a paper named after the field's text, which vanilla copies into the field. The paper is [blank] with + * its enchantments removed, so it draws nothing and the client computes no result and no repair cost; the cost is + * sent as 0 anyway. Every open, including a reopen over the current anvil, and every resync after a refused click is + * bundled with a ping: the client answers it after echoing the paper's name, so rename packets before the pong come + * from an earlier anvil or are the echo, and are dropped. The exception is text typed before a resync reached the + * client: the latest such rename is applied as an edit once the pong arrives, and the field is resynced to it. Every + * click on the anvil is refused; one on the result slot submits. [defer] runs a task on the next tick while the window + * is open, since an anvil can be replaced while Minestom is still closing it. */ internal class MinestomTextInput( private val player: Player, + private val blank: ItemStack, private val defer: (() -> Unit) -> Unit, ) { private var anvil: PromptAnvil? = null @@ -34,9 +39,20 @@ internal class MinestomTextInput( /** The rename field's text as of the latest edit reported, or the text the anvil opened with. */ private var field = "" - /** The ping that settles the latest open, until its pong arrives. */ + /** The ping that settles the latest open or resync, until its pong arrives. */ private var awaiting: Int? = null + /** The latest rename that differs from the seed of the resync awaiting its pong, which the player typed first. */ + private var typed: String? = null + private var rebasing = false + + /** The window id of the open anvil; read by the connection's writer thread. */ + @Volatile + private var window = NO_WINDOW + + /** Whether [windowId] is the window of the open anvil. */ + fun owns(windowId: Int) = windowId == window + fun open( title: Component, text: String, @@ -51,23 +67,21 @@ internal class MinestomTextInput( this.listener = listener field = text anvil = target - val id = PINGS.getAndIncrement() - awaiting = id - player.sendPacket(BundlePacket()) + window = target.windowId.toInt() try { - if (current != null) { - target.setTitle(title) - } else { - check(player.openInventory(target) && player.openInventory === target) { - "Opening a text input for ${player.username} was cancelled or redirected by an inventory open listener" + synced(rebase = false) { + if (current != null) { + target.setTitle(title) + } else { + check(player.openInventory(target) && player.openInventory === target) { + "Opening a text input for ${player.username} was cancelled or redirected by an inventory open listener" + } } + sendCost(target) } - player.sendPacket(PingPacket(id)) } catch (failure: Throwable) { reset() throw failure - } finally { - player.sendPacket(BundlePacket()) } } @@ -83,28 +97,29 @@ internal class MinestomTextInput( val current = anvil ?: return when (val packet = event.packet) { is ClientPongPacket -> { - if (packet.id() == awaiting) awaiting = null + if (packet.id() == awaiting) settled(current) } is ClientNameItemPacket -> { event.isCancelled = true - if (awaiting != null || packet.itemName() == field) return - field = packet.itemName() - listener?.onEdit(field) + if (awaiting == null) { + edit(packet.itemName()) + } else if (rebasing && packet.itemName() != field) { + typed = packet.itemName() + } } is ClientClickWindowPacket -> { if (packet.windowId() != current.windowId.toInt()) return event.isCancelled = true if (packet.slot() == RESULT_SLOT) { + // The client may have predicted paying the result's cost before sending this click. + player.sendPacket(SetExperiencePacket(player.exp, player.level, 0)) val submitted = listener close() submitted?.onSubmit() } else { - // Renaming the paper to the field keeps the resync from resetting what the player typed. - current.setItemStack(0, seed(field), false) - current.update(player) - player.inventory.update(player) + resync(current) } } @@ -114,6 +129,55 @@ internal class MinestomTextInput( } } + private fun edit(text: String) { + if (text == field) return + field = text + listener?.onEdit(text) + } + + /** Applies the text typed before the resync that just settled reached the client, then shows it there. */ + private fun settled(current: PromptAnvil) { + awaiting = null + val pending = typed + typed = null + if (pending == null) return + edit(pending) + // An edit can reopen or close the anvil, which settles its own text. + if (awaiting == null && anvil === current) resync(current) + } + + /** Restores the anvil's slots and the cursor after a refused click, with the paper named after the field. */ + private fun resync(current: PromptAnvil) { + current.setItemStack(0, seed(field), false) + synced(rebase = true) { + current.update(player) + player.inventory.update(player) + sendCost(current) + } + } + + /** Runs [send] in a bundle ending in the ping that settles it. */ + private inline fun synced( + rebase: Boolean, + send: () -> Unit, + ) { + val id = PINGS.getAndIncrement() + awaiting = id + rebasing = rebase + if (!rebase) typed = null + player.sendPacket(BundlePacket()) + try { + send() + player.sendPacket(PingPacket(id)) + } finally { + player.sendPacket(BundlePacket()) + } + } + + private fun sendCost(target: PromptAnvil) { + player.sendPacket(WindowPropertyPacket(target.windowId.toInt(), REPAIR_COST, 0)) + } + private fun ended(closing: PromptAnvil) { if (closing !== anvil) return val cancelled = listener @@ -125,13 +189,14 @@ internal class MinestomTextInput( anvil = null listener = null awaiting = null + typed = null + window = NO_WINDOW } private fun seed(text: String): ItemStack = - ItemStack - .builder(Material.PAPER) - .set(DataComponents.CUSTOM_NAME, Component.text(text)) - .build() + blank + .with { it.set(DataComponents.CUSTOM_NAME, Component.text(text)) } + .without(DataComponents.ENCHANTMENTS) /** An anvil that reports when its viewer is removed, whether it closes or another inventory replaces it. */ private inner class PromptAnvil( @@ -146,6 +211,10 @@ internal class MinestomTextInput( private companion object { const val RESULT_SLOT: Short = 2 + /** The anvil window property carrying the repair cost. */ + const val REPAIR_COST: Short = 0 + const val NO_WINDOW = Int.MIN_VALUE + /** Ping ids start in a range unlikely to collide with other users of the ping packet. */ val PINGS = AtomicInteger(0x57_46_00_00) } diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index 7553ecb7..202b553c 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -58,11 +58,13 @@ import net.minestom.server.network.packet.server.play.HeldItemChangePacket import net.minestom.server.network.packet.server.play.OpenWindowPacket import net.minestom.server.network.packet.server.play.PlayerAbilitiesPacket import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket +import net.minestom.server.network.packet.server.play.SetExperiencePacket import net.minestom.server.network.packet.server.play.SetPlayerInventorySlotPacket import net.minestom.server.network.packet.server.play.SetSlotPacket import net.minestom.server.network.packet.server.play.SetTimePacket import net.minestom.server.network.packet.server.play.SpawnEntityPacket import net.minestom.server.network.packet.server.play.WindowItemsPacket +import net.minestom.server.network.packet.server.play.WindowPropertyPacket import net.minestom.server.network.player.GameProfile import net.minestom.server.network.player.PlayerConnection import java.net.InetSocketAddress @@ -151,7 +153,7 @@ class MinestomFullscreenTest : val menu = Menu(fixture.player) menu.open() - fixture.receive(ClientHeldItemChangePacket(1)) + fixture.receive(ClientHeldItemChangePacket(1)).isCancelled shouldBe true fixture.receive(ClientHeldItemChangePacket(8)) fixture.send(HeldItemChangePacket(5)) fixture.receive(ClientHeldItemChangePacket(6)) @@ -165,7 +167,7 @@ class MinestomFullscreenTest : val menu = Menu(fixture.player) menu.open() - fixture.receive(ClientInteractEntityPacket(0, PlayerHand.MAIN, Vec.ZERO, false)) + fixture.receive(ClientInteractEntityPacket(0, PlayerHand.MAIN, Vec.ZERO, false)).isCancelled shouldBe true fixture.receive(ClientInteractEntityPacket(0, PlayerHand.OFF, Vec.ZERO, false)) fixture.receive(ClientPlayerRotationPacket(-44f, -24.375f, 0.toByte())) fixture.player.scheduler().processTickEnd() @@ -173,9 +175,10 @@ class MinestomFullscreenTest : menu.rightClicks shouldContainExactly listOf(FullscreenClick(5, 5)) } - "a text input opens an anvil, ignores renames until its ping returns, and submits from the result slot" { + "a text input opens a blank anvil, ignores renames until its ping returns, and submits from the result slot" { val fixture = FullscreenFixture() val player = fixture.player + player.inventory.setItemStack(2, ItemStack.of(Material.DIAMOND)) val menu = Menu(player) menu.open() fixture.tick() @@ -184,8 +187,14 @@ class MinestomFullscreenTest : menu.prompt("hi") val anvil = fixture.sent().single() anvil.windowType() shouldBe InventoryType.ANVIL.windowType - fixture.sent().single() shouldBe player.instance.createTimePacket() + // The HUD stays hidden, the hotbar stays blank, and the anvil shows no cost. + fixture.sent() shouldBe emptyList() + fixture.hotbar() shouldBe List(9) { Material.PAPER } fixture.seed(anvil.windowId()) shouldBe "hi" + val paper = fixture.seedItem(anvil.windowId()) + paper.get(DataComponents.ITEM_MODEL) shouldBe "window:fullscreen/blank" + paper.has(DataComponents.ENCHANTMENTS) shouldBe false + fixture.sent().single().value() shouldBe 0 fixture.receive(ClientNameItemPacket("earlier")) fixture.receive(ClientPongPacket(fixture.sent().single().id())) fixture.receive(ClientNameItemPacket("hi")) @@ -211,7 +220,52 @@ class MinestomFullscreenTest : player.openInventory shouldBe null fixture.clear() fixture.tick() - fixture.sent().single().gameTime() shouldBe HIDDEN_AGE + fixture.sent() shouldBe emptyList() + fixture.hotbar() shouldBe List(9) { Material.PAPER } + } + + "a click resyncs the anvil behind a ping, and text typed before the resync arrived is kept" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("hey") + val anvil = fixture.sent().single().windowId() + fixture.receive(ClientPongPacket(fixture.sent().single().id())) + + fixture.clear() + fixture.receive(fixture.click(anvil, 0)) + val resync = fixture.sent().single() + fixture.seed(anvil) shouldBe "hey" + fixture.sent().single().value() shouldBe 0 + // The player typed "heya" before the resync reached the client, which then echoed its seed. + fixture.receive(ClientNameItemPacket("heya")) + fixture.receive(ClientNameItemPacket("hey")) + menu.edits shouldBe emptyList() + + fixture.clear() + fixture.receive(ClientPongPacket(resync.id())) + menu.edits shouldContainExactly listOf("heya") + fixture.seed(anvil) shouldBe "heya" + val rebased = fixture.sent().single() + fixture.receive(ClientNameItemPacket("heya")) + fixture.receive(ClientPongPacket(rebased.id())) + fixture.receive(ClientNameItemPacket("heyab")) + menu.edits shouldContainExactly listOf("heya", "heyab") + } + + "taking the result resyncs the player's experience" { + val fixture = FullscreenFixture() + val player = fixture.player + player.level = 3 + val menu = Menu(player) + menu.open() + menu.prompt("hi") + val anvil = fixture.sent().single().windowId() + + fixture.clear() + fixture.receive(fixture.click(anvil, 2)) + + fixture.sent().single().level() shouldBe 3 } "closing the anvil cancels the prompt on the next tick, and closing the window closes the anvil" { @@ -461,7 +515,7 @@ private class FullscreenFixture { fun tick() = player.scheduler().processTick() - fun receive(packet: ClientPacket) = EventDispatcher.call(PlayerPacketEvent(player, packet)) + fun receive(packet: ClientPacket) = PlayerPacketEvent(player, packet).also(EventDispatcher::call) fun click( windowId: Int, @@ -476,13 +530,17 @@ private class FullscreenFixture { ItemStack.Hash.AIR, ) - /** The name of the paper in the first slot of window [windowId] as last sent. */ - fun seed(windowId: Int): String? = + /** The paper in the first slot of window [windowId] as last sent. */ + fun seedItem(windowId: Int): ItemStack = sent() .last { it.windowId() == windowId } .items()[0] - .get(DataComponents.CUSTOM_NAME) - ?.let { PlainTextComponentSerializer.plainText().serialize(it) } + + /** The name of the paper in the first slot of window [windowId] as last sent. */ + fun seed(windowId: Int): String? { + val name = seedItem(windowId).get(DataComponents.CUSTOM_NAME) ?: return null + return PlainTextComponentSerializer.plainText().serialize(name) + } /** Queues [packets] back to back, then writes them out the way the connection's writer thread does. */ fun send(vararg packets: ServerPacket) { diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index 51148bf3..fffe804b 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -79,7 +79,7 @@ public abstract class FullscreenView( * * [onEdit] receives the whole text after each edit. [onDone] receives it once the player clicks the anvil's result * slot, or `null` once they close the anvil; the canvas is back by then. Neither runs after the window closes. While - * the anvil is open, the player sees their real hotbar and HUD, and the cursor stays where it was. + * the anvil is open, the HUD stays hidden and the cursor stays where it was. * * The anvil's rename field holds 50 characters. When it fills, its text is kept and a fresh anvil opens with an empty * field, so the text can grow past 50 characters, but only the part after the last 50-character boundary is shown diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt index 528cb1d8..13d18622 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt @@ -42,8 +42,8 @@ public interface OpenFullscreen { /** * Opens an anvil titled [title] over the canvas, with [text] in its rename field, and reports its input to - * [listener] until it closes. While it is open, the client has its real world age and hotbar; the HUD is hidden - * again once it closes. + * [listener] until it closes. The HUD stays hidden and the hotbar blank while it is open; the anvil + * shows no items and no cost. * * Called while an anvil is open, it opens a fresh one in its place reporting to [listener], without reporting the * old one's end. Edits made on an earlier anvil, and the client's echo of [text], are not reported. diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index 8fd0acf6..ee2c86ce 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -21,8 +21,8 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png 9481e534191064dd3f83ebb97a6ba3ca0b967c442808258b0817defd0ef13d61 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta -dfda9d83b4d15cc55829592524be316592ec273f851c54a0991791f026dee0ae kotlin-out/LookoutFullscreen.kt -3983b91d0826ee55ddd9b07526240d8a32c42e16023fec957917bbdb5759c367 kotlin-out/MenuFullscreen.kt +23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt +92d2f1d01598c590df7cd43619bd3b83d47048a6955a714733dabe7a25d64fc4 kotlin-out/MenuFullscreen.kt f30b961fdca503fa68f87b140e31c8a42b48ad830a84c029fc7a7435760bfff9 kotlin-out/WindowDefinitions.kt 2c40c9884ffe5ae1a29707d1195e2a15ad469816e166981e0be0b73b1e1a3696 kotlin-out/WindowEntries.kt 87e25adf61686af7eaad080edd2f923102aab006421bef2c7663dfd8e725e74e kotlin-out/WindowFonts.kt From 207e6e1e2495f0359d174032c660745ddf739ee8 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:39:57 -0400 Subject: [PATCH 3/8] fix(minestom): leave the anvil's rename seed alone on clicks that do not change the input slot --- .../minestom/internal/MinestomTextInput.kt | 72 +++++++-------- .../window/minestom/MinestomFullscreenTest.kt | 90 +++++++++++++++---- 2 files changed, 105 insertions(+), 57 deletions(-) diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt index bd706c08..1e721526 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt @@ -21,12 +21,15 @@ import java.util.concurrent.atomic.AtomicInteger * * The anvil holds a paper named after the field's text, which vanilla copies into the field. The paper is [blank] with * its enchantments removed, so it draws nothing and the client computes no result and no repair cost; the cost is - * sent as 0 anyway. Every open, including a reopen over the current anvil, and every resync after a refused click is - * bundled with a ping: the client answers it after echoing the paper's name, so rename packets before the pong come - * from an earlier anvil or are the echo, and are dropped. The exception is text typed before a resync reached the - * client: the latest such rename is applied as an edit once the pong arrives, and the field is resynced to it. Every - * click on the anvil is refused; one on the result slot submits. [defer] runs a task on the next tick while the window - * is open, since an anvil can be replaced while Minestom is still closing it. + * sent as 0 anyway. + * + * The client resets its field only when the stack in the input slot changes, and the paper is never updated as the + * player types. A seeding send, which is an open (including a reopen over the current anvil) or a resync after a click + * that changed the input slot, is bundled with a ping. The client answers it after echoing the paper's name, so rename + * packets before the latest pong come from an earlier anvil or are the echo, and are dropped. Every click on the anvil + * is refused and the slots are resent; a click that leaves the input slot alone resends the same paper, which does not + * touch the field, so renames keep flowing. One on the result slot submits. [defer] runs a task on the next tick while + * the window is open, since an anvil can be replaced while Minestom is still closing it. */ internal class MinestomTextInput( private val player: Player, @@ -39,13 +42,9 @@ internal class MinestomTextInput( /** The rename field's text as of the latest edit reported, or the text the anvil opened with. */ private var field = "" - /** The ping that settles the latest open or resync, until its pong arrives. */ + /** The ping that settles the latest seeding send, until its pong arrives. */ private var awaiting: Int? = null - /** The latest rename that differs from the seed of the resync awaiting its pong, which the player typed first. */ - private var typed: String? = null - private var rebasing = false - /** The window id of the open anvil; read by the connection's writer thread. */ @Volatile private var window = NO_WINDOW @@ -69,7 +68,7 @@ internal class MinestomTextInput( anvil = target window = target.windowId.toInt() try { - synced(rebase = false) { + synced(seeding = true) { if (current != null) { target.setTitle(title) } else { @@ -97,16 +96,12 @@ internal class MinestomTextInput( val current = anvil ?: return when (val packet = event.packet) { is ClientPongPacket -> { - if (packet.id() == awaiting) settled(current) + if (packet.id() == awaiting) awaiting = null } is ClientNameItemPacket -> { event.isCancelled = true - if (awaiting == null) { - edit(packet.itemName()) - } else if (rebasing && packet.itemName() != field) { - typed = packet.itemName() - } + if (awaiting == null) edit(packet.itemName()) } is ClientClickWindowPacket -> { @@ -119,7 +114,7 @@ internal class MinestomTextInput( close() submitted?.onSubmit() } else { - resync(current) + resync(current, reseed = packet.slot() == INPUT_SLOT || INPUT_SLOT in packet.changedSlots()) } } @@ -135,40 +130,33 @@ internal class MinestomTextInput( listener?.onEdit(text) } - /** Applies the text typed before the resync that just settled reached the client, then shows it there. */ - private fun settled(current: PromptAnvil) { - awaiting = null - val pending = typed - typed = null - if (pending == null) return - edit(pending) - // An edit can reopen or close the anvil, which settles its own text. - if (awaiting == null && anvil === current) resync(current) - } - - /** Restores the anvil's slots and the cursor after a refused click, with the paper named after the field. */ - private fun resync(current: PromptAnvil) { - current.setItemStack(0, seed(field), false) - synced(rebase = true) { + /** + * Restores the anvil's slots and the cursor after a refused click. The input slot is reseeded, with the paper named + * after the field, only when the click changed it on the client, which clears the field. + */ + private fun resync( + current: PromptAnvil, + reseed: Boolean, + ) { + if (reseed) current.setItemStack(0, seed(field), false) + synced(seeding = reseed) { current.update(player) player.inventory.update(player) sendCost(current) } } - /** Runs [send] in a bundle ending in the ping that settles it. */ + /** Runs [send] in a bundle, ending in the ping that settles it when [seeding]. */ private inline fun synced( - rebase: Boolean, + seeding: Boolean, send: () -> Unit, ) { - val id = PINGS.getAndIncrement() - awaiting = id - rebasing = rebase - if (!rebase) typed = null + val id = if (seeding) PINGS.getAndIncrement() else null + if (id != null) awaiting = id player.sendPacket(BundlePacket()) try { send() - player.sendPacket(PingPacket(id)) + if (id != null) player.sendPacket(PingPacket(id)) } finally { player.sendPacket(BundlePacket()) } @@ -189,7 +177,6 @@ internal class MinestomTextInput( anvil = null listener = null awaiting = null - typed = null window = NO_WINDOW } @@ -209,6 +196,7 @@ internal class MinestomTextInput( } private companion object { + const val INPUT_SLOT: Short = 0 const val RESULT_SLOT: Short = 2 /** The anvil window property carrying the repair cost. */ diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index 202b553c..94ec99a7 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -224,7 +224,7 @@ class MinestomFullscreenTest : fixture.hotbar() shouldBe List(9) { Material.PAPER } } - "a click resyncs the anvil behind a ping, and text typed before the resync arrived is kept" { + "a click that leaves the input slot alone resends the slots without a ping, and later renames are kept" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() @@ -233,24 +233,83 @@ class MinestomFullscreenTest : fixture.receive(ClientPongPacket(fixture.sent().single().id())) fixture.clear() - fixture.receive(fixture.click(anvil, 0)) - val resync = fixture.sent().single() + fixture.receive(fixture.click(anvil, 1)) + fixture.sent() shouldBe emptyList() fixture.seed(anvil) shouldBe "hey" fixture.sent().single().value() shouldBe 0 - // The player typed "heya" before the resync reached the client, which then echoed its seed. + + // Text typed while the resync was in flight is reported, and a submit sees it. fixture.receive(ClientNameItemPacket("heya")) - fixture.receive(ClientNameItemPacket("hey")) - menu.edits shouldBe emptyList() + menu.edits shouldContainExactly listOf("heya") + fixture.receive(fixture.click(anvil, 2)) + menu.prompts shouldContainExactly listOf("heya") + } + + "a click that changes the input slot reseeds it behind a ping, and renames before the pong are dropped" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("hey") + val anvil = fixture.sent().single().windowId() + fixture.receive(ClientPongPacket(fixture.sent().single().id())) + fixture.receive(ClientNameItemPacket("hey!")) fixture.clear() - fixture.receive(ClientPongPacket(resync.id())) - menu.edits shouldContainExactly listOf("heya") - fixture.seed(anvil) shouldBe "heya" - val rebased = fixture.sent().single() - fixture.receive(ClientNameItemPacket("heya")) - fixture.receive(ClientPongPacket(rebased.id())) - fixture.receive(ClientNameItemPacket("heyab")) - menu.edits shouldContainExactly listOf("heya", "heyab") + fixture.receive(fixture.click(anvil, 0)) + fixture.seed(anvil) shouldBe "hey!" + fixture.receive(ClientNameItemPacket("stale")) + fixture.receive(ClientPongPacket(fixture.sent().single().id())) + fixture.receive(ClientNameItemPacket("hey!!")) + menu.edits shouldContainExactly listOf("hey!", "hey!!") + + // A shift-click that moved the input paper out reports it among the changed slots. + fixture.clear() + fixture.receive(fixture.click(anvil, 3, changed = mapOf(0.toShort() to ItemStack.Hash.AIR))) + fixture.seed(anvil) shouldBe "hey!!" + fixture.sent().size shouldBe 1 + } + + "a click during a reopen does not admit the old field's renames into the new segment" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + val almost = "a".repeat(49) + menu.prompt(almost) + val anvil = fixture.sent().single().windowId() + fixture.receive(ClientPongPacket(fixture.sent().single().id())) + + fixture.clear() + fixture.receive(ClientNameItemPacket(almost + "a")) + val reopen = fixture.sent().single() + fixture.clear() + fixture.receive(fixture.click(anvil, 0)) + val reseed = fixture.sent().single() + fixture.receive(ClientNameItemPacket(almost)) + fixture.receive(ClientPongPacket(reopen.id())) + fixture.receive(ClientNameItemPacket(almost)) + fixture.receive(ClientPongPacket(reseed.id())) + fixture.receive(ClientNameItemPacket("b")) + + menu.edits shouldContainExactly listOf(almost + "a", almost + "ab") + } + + "a submit in flight after a reopen and a click submits the kept text" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + val almost = "a".repeat(49) + menu.prompt(almost) + val anvil = fixture.sent().single().windowId() + fixture.receive(ClientPongPacket(fixture.sent().single().id())) + + fixture.clear() + fixture.receive(ClientNameItemPacket(almost + "a")) + fixture.receive(fixture.click(anvil, 1)) + fixture.receive(ClientNameItemPacket(almost)) + fixture.receive(fixture.click(anvil, 2)) + + menu.prompts shouldContainExactly listOf(almost + "a") + fixture.player.openInventory shouldBe null } "taking the result resyncs the player's experience" { @@ -520,13 +579,14 @@ private class FullscreenFixture { fun click( windowId: Int, slot: Int, + changed: Map = emptyMap(), ) = ClientClickWindowPacket( windowId, 0, slot.toShort(), 0, ClientClickWindowPacket.ClickType.PICKUP, - emptyMap(), + changed, ItemStack.Hash.AIR, ) From d60cfbf319f36034ba94d94f755ac9015e90222e Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:44:56 -0400 Subject: [PATCH 4/8] fix(minestom): reseed the anvil input only when a click changed slot 0 --- .../minestom/internal/MinestomTextInput.kt | 2 +- .../window/minestom/MinestomFullscreenTest.kt | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt index 1e721526..58fcde53 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt @@ -114,7 +114,7 @@ internal class MinestomTextInput( close() submitted?.onSubmit() } else { - resync(current, reseed = packet.slot() == INPUT_SLOT || INPUT_SLOT in packet.changedSlots()) + resync(current, reseed = INPUT_SLOT in packet.changedSlots()) } } diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index 94ec99a7..b848b0c4 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -213,7 +213,7 @@ class MinestomFullscreenTest : menu.edits shouldContainExactly listOf("hey", full, full + "b") fixture.clear() - fixture.receive(fixture.click(anvil.windowId(), 0)) + fixture.receive(fixture.click(anvil.windowId(), 0, changed = inputChanged)) fixture.seed(anvil.windowId()) shouldBe "b" fixture.receive(fixture.click(anvil.windowId(), 2)) menu.prompts shouldContainExactly listOf(full + "b") @@ -245,6 +245,22 @@ class MinestomFullscreenTest : menu.prompts shouldContainExactly listOf("heya") } + "a click that cannot change the input slot keeps accepting renames while the slots are resent" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("hey") + val anvil = fixture.sent().single().windowId() + fixture.receive(ClientPongPacket(fixture.sent().single().id())) + + fixture.clear() + fixture.receive(fixture.click(anvil, 0)) + fixture.sent() shouldBe emptyList() + fixture.receive(ClientNameItemPacket("heya")) + fixture.receive(fixture.click(anvil, 2)) + menu.prompts shouldContainExactly listOf("heya") + } + "a click that changes the input slot reseeds it behind a ping, and renames before the pong are dropped" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) @@ -255,7 +271,7 @@ class MinestomFullscreenTest : fixture.receive(ClientNameItemPacket("hey!")) fixture.clear() - fixture.receive(fixture.click(anvil, 0)) + fixture.receive(fixture.click(anvil, 0, changed = inputChanged)) fixture.seed(anvil) shouldBe "hey!" fixture.receive(ClientNameItemPacket("stale")) fixture.receive(ClientPongPacket(fixture.sent().single().id())) @@ -264,7 +280,7 @@ class MinestomFullscreenTest : // A shift-click that moved the input paper out reports it among the changed slots. fixture.clear() - fixture.receive(fixture.click(anvil, 3, changed = mapOf(0.toShort() to ItemStack.Hash.AIR))) + fixture.receive(fixture.click(anvil, 3, changed = inputChanged)) fixture.seed(anvil) shouldBe "hey!!" fixture.sent().size shouldBe 1 } @@ -282,7 +298,7 @@ class MinestomFullscreenTest : fixture.receive(ClientNameItemPacket(almost + "a")) val reopen = fixture.sent().single() fixture.clear() - fixture.receive(fixture.click(anvil, 0)) + fixture.receive(fixture.click(anvil, 0, changed = inputChanged)) val reseed = fixture.sent().single() fixture.receive(ClientNameItemPacket(almost)) fixture.receive(ClientPongPacket(reopen.id())) @@ -541,6 +557,9 @@ private class Menu( } } +/** The changed slots a click reports when it moved the input paper. */ +private val inputChanged = mapOf(0.toShort() to ItemStack.Hash.AIR) + /** A player in an instance, with 40 max health, whose connection records every packet that reaches its client. */ private class FullscreenFixture { private val connection = PacketLog() From 8f887fad5c5afb881db7af38f80aa996ca97d27e Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:47:10 -0400 Subject: [PATCH 5/8] feat(minestom): prompt for text with a native dialog instead of a disguised anvil promptText(title, initial, onDone) now shows a dialog with one text field and Confirm and Cancel buttons. Each prompt has its own custom action ids, so stale or foreign responses are ignored, and onDone runs once: Confirm delivers the text, and Cancel, a replacing prompt, or the window ending deliver null. Escape does not dismiss the dialog. Canvas input is ignored while a prompt is open. Removes the anvil prompt: blank paper seed, repair cost and XP resyncs, the 50-character reopen, rename echo gating, and the anvil branch of container handoff. --- .../minestom/internal/MinestomFullscreen.kt | 38 ++- .../minestom/internal/MinestomTextInput.kt | 209 --------------- .../minestom/internal/MinestomTextPrompt.kt | 113 ++++++++ .../window/minestom/MinestomFullscreenTest.kt | 242 ++++-------------- .../com/chunkzero/window/FullscreenSession.kt | 70 +---- .../com/chunkzero/window/FullscreenView.kt | 27 +- .../chunkzero/window/host/FullscreenHost.kt | 35 +-- .../chunkzero/window/FullscreenSessionTest.kt | 61 ++--- 8 files changed, 246 insertions(+), 549 deletions(-) delete mode 100644 jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt create mode 100644 jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index b23aeaa1..db6a1ab3 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -2,7 +2,6 @@ package com.chunkzero.window.minestom.internal import com.chunkzero.window.FullscreenDefinition import com.chunkzero.window.host.FullscreenListener -import com.chunkzero.window.host.FullscreenTextListener import com.chunkzero.window.host.OpenFullscreen import net.kyori.adventure.text.Component import net.minestom.server.component.DataComponents @@ -21,6 +20,7 @@ import net.minestom.server.entity.metadata.display.TextDisplayMeta import net.minestom.server.entity.metadata.other.InteractionMeta import net.minestom.server.event.EventFilter import net.minestom.server.event.EventNode +import net.minestom.server.event.player.PlayerCustomClickEvent import net.minestom.server.event.player.PlayerDisconnectEvent import net.minestom.server.event.player.PlayerMoveEvent import net.minestom.server.event.player.PlayerPacketEvent @@ -70,8 +70,7 @@ import net.minestom.server.world.clock.WorldClock * negative, which the pack's GUI shader reads as "clip the bottom-centre HUD strip". So the hearts fit that strip, the * client is told its max health is at most the canvas's `hudMaxHealth` and its absorption is 0. While the server has a * container open, the client gets the real world age back and its real hotbar so the strip and slots show. The text - * prompt's anvil is this window's own UI rather than a server container, so it keeps the HUD hidden and the hotbar - * blank. + * prompt's dialog is a client screen rather than a server container, so it keeps the HUD hidden and the hotbar blank. * * While the window is open, a server update to the game mode, abilities, hotbar, time, max health, or absorption passes * through unchanged and is followed by a packet that puts the hidden state back. Replacing the update instead would @@ -114,7 +113,7 @@ internal class MinestomFullscreen private constructor( /** The held slot the client has selected, which held-slot changes are measured from. Requires [lock]. */ private var clientSlot = heldSlot.toInt() - private val textInput = MinestomTextInput(player, blank, ::deferWhileOpen) + private val textPrompt = MinestomTextPrompt(player) /** The window id of the server container the client has open, or [NO_CONTAINER]. */ private var containerWindowId = NO_CONTAINER @@ -138,13 +137,13 @@ internal class MinestomFullscreen private constructor( player.sendPacket(PlayerRotationPacket(yaw, true, pitch, true)) } - override fun openTextInput( + override fun promptText( title: Component, - text: String, - listener: FullscreenTextListener, + initial: String, + onDone: (String?) -> Unit, ) { check(isOpen()) { "The full-screen window of ${player.username} is closed" } - textInput.open(title, text, listener) + textPrompt.open(title, initial, onDone) } override fun close() { @@ -266,6 +265,7 @@ internal class MinestomFullscreen private constructor( val next = event.newPosition if (!next.samePoint(anchor)) event.newPosition = anchor.withView(next.yaw(), next.pitch()) } + node.addListener(PlayerCustomClickEvent::class.java, textPrompt::onClick) node.addListener(PlayerDisconnectEvent::class.java) { end(restore = false) } player.eventNode().addChild(node) } @@ -314,7 +314,7 @@ internal class MinestomFullscreen private constructor( } else -> { - textInput.onPacket(event) + Unit } } } @@ -378,13 +378,11 @@ internal class MinestomFullscreen private constructor( } is OpenWindowPacket -> { - if (!textInput.owns(packet.windowId())) { - containerWindowId = packet.windowId() - containerGeneration++ - player.sendPacket(realTime()) - // Opening a container does not resync the player inventory, whose hotbar still holds the blanks. - player.inventory.update(player) - } + containerWindowId = packet.windowId() + containerGeneration++ + player.sendPacket(realTime()) + // Opening a container does not resync the player inventory, whose hotbar still holds the blanks. + player.inventory.update(player) } is CloseWindowPacket -> { @@ -441,10 +439,6 @@ internal class MinestomFullscreen private constructor( private fun isOpen() = synchronized(lock) { open } - private fun deferWhileOpen(task: () -> Unit) { - player.scheduler().scheduleNextTick { if (isOpen()) task() } - } - /** Runs [action] under [lock] unless the window has stopped, so it cannot enqueue a packet after restoring. */ private inline fun whileOpen(action: () -> Unit) { synchronized(lock) { if (open) action() } @@ -453,7 +447,7 @@ internal class MinestomFullscreen private constructor( /** Ends the window after the player left it, restoring them unless they disconnected. */ private fun end(restore: Boolean) { if (!stop()) return - if (restore) restore() + if (restore) restore() else textPrompt.close(clear = false) listener.onClose() } @@ -468,7 +462,7 @@ internal class MinestomFullscreen private constructor( } private fun restore() { - textInput.close() + textPrompt.close(clear = true) player.sendPacket(DestroyEntitiesPacket(listOf(display.entityId, clickTarget.entityId))) // A mode the server set while the window was open replaces the one it began with. val mode = player.gameMode.takeUnless { it == GameMode.SPECTATOR } ?: gameMode diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt deleted file mode 100644 index 58fcde53..00000000 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextInput.kt +++ /dev/null @@ -1,209 +0,0 @@ -package com.chunkzero.window.minestom.internal - -import com.chunkzero.window.host.FullscreenTextListener -import net.kyori.adventure.text.Component -import net.minestom.server.component.DataComponents -import net.minestom.server.entity.Player -import net.minestom.server.event.player.PlayerPacketEvent -import net.minestom.server.inventory.type.AnvilInventory -import net.minestom.server.item.ItemStack -import net.minestom.server.network.packet.client.common.ClientPongPacket -import net.minestom.server.network.packet.client.play.ClientClickWindowPacket -import net.minestom.server.network.packet.client.play.ClientNameItemPacket -import net.minestom.server.network.packet.server.common.PingPacket -import net.minestom.server.network.packet.server.play.BundlePacket -import net.minestom.server.network.packet.server.play.SetExperiencePacket -import net.minestom.server.network.packet.server.play.WindowPropertyPacket -import java.util.concurrent.atomic.AtomicInteger - -/** - * The anvil a full-screen window opens over its canvas for [player] to type into, reporting its rename field. - * - * The anvil holds a paper named after the field's text, which vanilla copies into the field. The paper is [blank] with - * its enchantments removed, so it draws nothing and the client computes no result and no repair cost; the cost is - * sent as 0 anyway. - * - * The client resets its field only when the stack in the input slot changes, and the paper is never updated as the - * player types. A seeding send, which is an open (including a reopen over the current anvil) or a resync after a click - * that changed the input slot, is bundled with a ping. The client answers it after echoing the paper's name, so rename - * packets before the latest pong come from an earlier anvil or are the echo, and are dropped. Every click on the anvil - * is refused and the slots are resent; a click that leaves the input slot alone resends the same paper, which does not - * touch the field, so renames keep flowing. One on the result slot submits. [defer] runs a task on the next tick while - * the window is open, since an anvil can be replaced while Minestom is still closing it. - */ -internal class MinestomTextInput( - private val player: Player, - private val blank: ItemStack, - private val defer: (() -> Unit) -> Unit, -) { - private var anvil: PromptAnvil? = null - private var listener: FullscreenTextListener? = null - - /** The rename field's text as of the latest edit reported, or the text the anvil opened with. */ - private var field = "" - - /** The ping that settles the latest seeding send, until its pong arrives. */ - private var awaiting: Int? = null - - /** The window id of the open anvil; read by the connection's writer thread. */ - @Volatile - private var window = NO_WINDOW - - /** Whether [windowId] is the window of the open anvil. */ - fun owns(windowId: Int) = windowId == window - - fun open( - title: Component, - text: String, - listener: FullscreenTextListener, - ) { - val current = anvil?.takeIf { player.openInventory === it } - check(current != null || player.openInventory == null) { - "${player.username} has another container open, so no text input can open over the full-screen window" - } - val target = current ?: PromptAnvil(title) - target.setItemStack(0, seed(text), false) - this.listener = listener - field = text - anvil = target - window = target.windowId.toInt() - try { - synced(seeding = true) { - if (current != null) { - target.setTitle(title) - } else { - check(player.openInventory(target) && player.openInventory === target) { - "Opening a text input for ${player.username} was cancelled or redirected by an inventory open listener" - } - } - sendCost(target) - } - } catch (failure: Throwable) { - reset() - throw failure - } - } - - /** Closes the anvil, if one is open, without reporting it. */ - fun close() { - val current = anvil ?: return - reset() - if (player.openInventory === current) player.closeInventory() - } - - /** Handles a packet from the client while the full-screen window is open. */ - fun onPacket(event: PlayerPacketEvent) { - val current = anvil ?: return - when (val packet = event.packet) { - is ClientPongPacket -> { - if (packet.id() == awaiting) awaiting = null - } - - is ClientNameItemPacket -> { - event.isCancelled = true - if (awaiting == null) edit(packet.itemName()) - } - - is ClientClickWindowPacket -> { - if (packet.windowId() != current.windowId.toInt()) return - event.isCancelled = true - if (packet.slot() == RESULT_SLOT) { - // The client may have predicted paying the result's cost before sending this click. - player.sendPacket(SetExperiencePacket(player.exp, player.level, 0)) - val submitted = listener - close() - submitted?.onSubmit() - } else { - resync(current, reseed = INPUT_SLOT in packet.changedSlots()) - } - } - - else -> { - Unit - } - } - } - - private fun edit(text: String) { - if (text == field) return - field = text - listener?.onEdit(text) - } - - /** - * Restores the anvil's slots and the cursor after a refused click. The input slot is reseeded, with the paper named - * after the field, only when the click changed it on the client, which clears the field. - */ - private fun resync( - current: PromptAnvil, - reseed: Boolean, - ) { - if (reseed) current.setItemStack(0, seed(field), false) - synced(seeding = reseed) { - current.update(player) - player.inventory.update(player) - sendCost(current) - } - } - - /** Runs [send] in a bundle, ending in the ping that settles it when [seeding]. */ - private inline fun synced( - seeding: Boolean, - send: () -> Unit, - ) { - val id = if (seeding) PINGS.getAndIncrement() else null - if (id != null) awaiting = id - player.sendPacket(BundlePacket()) - try { - send() - if (id != null) player.sendPacket(PingPacket(id)) - } finally { - player.sendPacket(BundlePacket()) - } - } - - private fun sendCost(target: PromptAnvil) { - player.sendPacket(WindowPropertyPacket(target.windowId.toInt(), REPAIR_COST, 0)) - } - - private fun ended(closing: PromptAnvil) { - if (closing !== anvil) return - val cancelled = listener - reset() - defer { cancelled?.onCancel() } - } - - private fun reset() { - anvil = null - listener = null - awaiting = null - window = NO_WINDOW - } - - private fun seed(text: String): ItemStack = - blank - .with { it.set(DataComponents.CUSTOM_NAME, Component.text(text)) } - .without(DataComponents.ENCHANTMENTS) - - /** An anvil that reports when its viewer is removed, whether it closes or another inventory replaces it. */ - private inner class PromptAnvil( - title: Component, - ) : AnvilInventory(title) { - override fun removeViewer(player: Player): Boolean { - if (player in viewers) ended(this) - return super.removeViewer(player) - } - } - - private companion object { - const val INPUT_SLOT: Short = 0 - const val RESULT_SLOT: Short = 2 - - /** The anvil window property carrying the repair cost. */ - const val REPAIR_COST: Short = 0 - const val NO_WINDOW = Int.MIN_VALUE - - /** Ping ids start in a range unlikely to collide with other users of the ping packet. */ - val PINGS = AtomicInteger(0x57_46_00_00) - } -} diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt new file mode 100644 index 00000000..e4f58a5d --- /dev/null +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt @@ -0,0 +1,113 @@ +package com.chunkzero.window.minestom.internal + +import net.kyori.adventure.key.Key +import net.kyori.adventure.nbt.CompoundBinaryTag +import net.kyori.adventure.nbt.StringBinaryTag +import net.kyori.adventure.text.Component +import net.minestom.server.dialog.Dialog +import net.minestom.server.dialog.DialogAction +import net.minestom.server.dialog.DialogActionButton +import net.minestom.server.dialog.DialogAfterAction +import net.minestom.server.dialog.DialogInput +import net.minestom.server.dialog.DialogMetadata +import net.minestom.server.entity.Player +import net.minestom.server.event.player.PlayerCustomClickEvent +import net.minestom.server.network.packet.server.common.ClearDialogPacket +import net.minestom.server.network.packet.server.common.ShowDialogPacket +import java.util.concurrent.atomic.AtomicLong + +/** + * The dialog a full-screen window shows [player] to type into: a text field over a Confirm and a Cancel button, which + * report through custom click actions whose ids are unique to the prompt, so responses to any other dialog are ignored. + * + * The client closes the dialog when a button is pressed, and Escape does nothing, so a prompt ends with a button, with + * another prompt replacing it, or with [close]. Each of those reports to its callback once. The buttons sit in the + * dialog's body, clear of the footer, which falls inside the strip the pack's shader clips while the HUD is hidden. + */ +internal class MinestomTextPrompt( + private val player: Player, +) { + private var pending: Pending? = null + + /** Shows the dialog, first reporting `null` to the prompt it replaces. */ + fun open( + title: Component, + initial: String, + onDone: (String?) -> Unit, + ) { + val id = PROMPTS.getAndIncrement() + val next = Pending(Key.key("window", "prompt/$id/confirm"), Key.key("window", "prompt/$id/cancel"), onDone) + val replaced = pending + pending = next + player.sendPacket(ShowDialogPacket(dialog(title, initial.take(MAX_LENGTH), next))) + replaced?.onDone?.invoke(null) + } + + /** Reports a button press of the open prompt, and nothing else. */ + fun onClick(event: PlayerCustomClickEvent) { + val current = pending ?: return + val text = + when (event.key) { + current.confirm -> { + ((event.payload as? CompoundBinaryTag)?.get(FIELD) as? StringBinaryTag)?.value()?.take(MAX_LENGTH) + ?: return + } + + current.cancel -> { + null + } + + else -> { + return + } + } + pending = null + current.onDone(text) + } + + /** Reports `null` to the open prompt, if any, and takes its dialog off the client's screen when [clear]. */ + fun close(clear: Boolean) { + val current = pending ?: return + pending = null + if (clear) player.sendPacket(ClearDialogPacket()) + current.onDone(null) + } + + private fun dialog( + title: Component, + initial: String, + prompt: Pending, + ): Dialog = + Dialog.MultiAction( + DialogMetadata( + title, + null, + false, + false, + DialogAfterAction.CLOSE, + emptyList(), + listOf(DialogInput.Text(FIELD, DialogInput.DEFAULT_WIDTH, title, false, initial, MAX_LENGTH, null)), + ), + listOf(button("Confirm", prompt.confirm), button("Cancel", prompt.cancel)), + null, + 2, + ) + + private fun button( + label: String, + id: Key, + ) = DialogActionButton(Component.text(label), null, BUTTON_WIDTH, DialogAction.DynamicCustom(id, null)) + + private class Pending( + val confirm: Key, + val cancel: Key, + val onDone: (String?) -> Unit, + ) + + private companion object { + const val FIELD = "text" + const val MAX_LENGTH = 256 + const val BUTTON_WIDTH = 100 + val PROMPTS = AtomicLong() + } +} diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index b848b0c4..c75ddaf5 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -15,12 +15,16 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe +import net.kyori.adventure.key.Key +import net.kyori.adventure.nbt.CompoundBinaryTag import net.kyori.adventure.text.Component -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import net.minestom.server.MinecraftServer import net.minestom.server.component.DataComponents import net.minestom.server.coordinate.Pos import net.minestom.server.coordinate.Vec +import net.minestom.server.dialog.Dialog +import net.minestom.server.dialog.DialogAction +import net.minestom.server.dialog.DialogInput import net.minestom.server.entity.EntityType import net.minestom.server.entity.GameMode import net.minestom.server.entity.Metadata @@ -29,6 +33,7 @@ import net.minestom.server.entity.Player import net.minestom.server.entity.PlayerHand import net.minestom.server.entity.attribute.Attribute import net.minestom.server.event.EventDispatcher +import net.minestom.server.event.player.PlayerCustomClickEvent import net.minestom.server.event.player.PlayerPacketEvent import net.minestom.server.event.player.PlayerPacketOutEvent import net.minestom.server.inventory.Inventory @@ -37,18 +42,16 @@ import net.minestom.server.item.ItemStack import net.minestom.server.item.Material import net.minestom.server.network.ConnectionState import net.minestom.server.network.packet.client.ClientPacket -import net.minestom.server.network.packet.client.common.ClientPongPacket import net.minestom.server.network.packet.client.play.ClientAttackPacket -import net.minestom.server.network.packet.client.play.ClientClickWindowPacket import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket import net.minestom.server.network.packet.client.play.ClientHeldItemChangePacket import net.minestom.server.network.packet.client.play.ClientInputPacket import net.minestom.server.network.packet.client.play.ClientInteractEntityPacket -import net.minestom.server.network.packet.client.play.ClientNameItemPacket import net.minestom.server.network.packet.client.play.ClientPlayerRotationPacket import net.minestom.server.network.packet.server.SendablePacket import net.minestom.server.network.packet.server.ServerPacket -import net.minestom.server.network.packet.server.common.PingPacket +import net.minestom.server.network.packet.server.common.ClearDialogPacket +import net.minestom.server.network.packet.server.common.ShowDialogPacket import net.minestom.server.network.packet.server.play.ChangeGameStatePacket import net.minestom.server.network.packet.server.play.CloseWindowPacket import net.minestom.server.network.packet.server.play.DestroyEntitiesPacket @@ -58,13 +61,11 @@ import net.minestom.server.network.packet.server.play.HeldItemChangePacket import net.minestom.server.network.packet.server.play.OpenWindowPacket import net.minestom.server.network.packet.server.play.PlayerAbilitiesPacket import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket -import net.minestom.server.network.packet.server.play.SetExperiencePacket import net.minestom.server.network.packet.server.play.SetPlayerInventorySlotPacket import net.minestom.server.network.packet.server.play.SetSlotPacket import net.minestom.server.network.packet.server.play.SetTimePacket import net.minestom.server.network.packet.server.play.SpawnEntityPacket import net.minestom.server.network.packet.server.play.WindowItemsPacket -import net.minestom.server.network.packet.server.play.WindowPropertyPacket import net.minestom.server.network.player.GameProfile import net.minestom.server.network.player.PlayerConnection import java.net.InetSocketAddress @@ -175,190 +176,67 @@ class MinestomFullscreenTest : menu.rightClicks shouldContainExactly listOf(FullscreenClick(5, 5)) } - "a text input opens a blank anvil, ignores renames until its ping returns, and submits from the result slot" { + "a prompt shows a dialog that keeps the HUD hidden, and Confirm delivers the field's text" { val fixture = FullscreenFixture() - val player = fixture.player - player.inventory.setItemStack(2, ItemStack.of(Material.DIAMOND)) - val menu = Menu(player) + val menu = Menu(fixture.player) menu.open() fixture.tick() fixture.clear() menu.prompt("hi") - val anvil = fixture.sent().single() - anvil.windowType() shouldBe InventoryType.ANVIL.windowType - // The HUD stays hidden, the hotbar stays blank, and the anvil shows no cost. + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction + (dialog.metadata().inputs().single() as DialogInput.Text).initial() shouldBe "hi" + dialog.metadata().canCloseWithEscape() shouldBe false fixture.sent() shouldBe emptyList() - fixture.hotbar() shouldBe List(9) { Material.PAPER } - fixture.seed(anvil.windowId()) shouldBe "hi" - val paper = fixture.seedItem(anvil.windowId()) - paper.get(DataComponents.ITEM_MODEL) shouldBe "window:fullscreen/blank" - paper.has(DataComponents.ENCHANTMENTS) shouldBe false - fixture.sent().single().value() shouldBe 0 - fixture.receive(ClientNameItemPacket("earlier")) - fixture.receive(ClientPongPacket(fixture.sent().single().id())) - fixture.receive(ClientNameItemPacket("hi")) - fixture.receive(ClientNameItemPacket("hey")) - menu.edits shouldContainExactly listOf("hey") - - // A full field reopens the same anvil with an empty one. - val full = "a".repeat(50) - fixture.clear() - fixture.receive(ClientNameItemPacket(full)) - fixture.sent().single().windowId() shouldBe anvil.windowId() - fixture.seed(anvil.windowId()) shouldBe "" - fixture.receive(ClientNameItemPacket(full.drop(1))) - fixture.receive(ClientPongPacket(fixture.sent().single().id())) - fixture.receive(ClientNameItemPacket("b")) - menu.edits shouldContainExactly listOf("hey", full, full + "b") - fixture.clear() - fixture.receive(fixture.click(anvil.windowId(), 0, changed = inputChanged)) - fixture.seed(anvil.windowId()) shouldBe "b" - fixture.receive(fixture.click(anvil.windowId(), 2)) - menu.prompts shouldContainExactly listOf(full + "b") - player.openInventory shouldBe null - fixture.clear() - fixture.tick() - fixture.sent() shouldBe emptyList() - fixture.hotbar() shouldBe List(9) { Material.PAPER } + fixture.click(dialog, 0, "text" to "hello") + menu.prompts shouldContainExactly listOf("hello") + fixture.click(dialog, 0, "text" to "again") + menu.prompts shouldContainExactly listOf("hello") } - "a click that leaves the input slot alone resends the slots without a ping, and later renames are kept" { + "Cancel delivers null" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() - menu.prompt("hey") - val anvil = fixture.sent().single().windowId() - fixture.receive(ClientPongPacket(fixture.sent().single().id())) - - fixture.clear() - fixture.receive(fixture.click(anvil, 1)) - fixture.sent() shouldBe emptyList() - fixture.seed(anvil) shouldBe "hey" - fixture.sent().single().value() shouldBe 0 - - // Text typed while the resync was in flight is reported, and a submit sees it. - fixture.receive(ClientNameItemPacket("heya")) - menu.edits shouldContainExactly listOf("heya") - fixture.receive(fixture.click(anvil, 2)) - menu.prompts shouldContainExactly listOf("heya") - } + menu.prompt("hi") + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction - "a click that cannot change the input slot keeps accepting renames while the slots are resent" { - val fixture = FullscreenFixture() - val menu = Menu(fixture.player) - menu.open() - menu.prompt("hey") - val anvil = fixture.sent().single().windowId() - fixture.receive(ClientPongPacket(fixture.sent().single().id())) + fixture.click(dialog, 1) - fixture.clear() - fixture.receive(fixture.click(anvil, 0)) - fixture.sent() shouldBe emptyList() - fixture.receive(ClientNameItemPacket("heya")) - fixture.receive(fixture.click(anvil, 2)) - menu.prompts shouldContainExactly listOf("heya") + menu.prompts shouldContainExactly listOf(null) } - "a click that changes the input slot reseeds it behind a ping, and renames before the pong are dropped" { + "a response to another prompt's dialog or a foreign action is ignored" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) menu.open() - menu.prompt("hey") - val anvil = fixture.sent().single().windowId() - fixture.receive(ClientPongPacket(fixture.sent().single().id())) - fixture.receive(ClientNameItemPacket("hey!")) - - fixture.clear() - fixture.receive(fixture.click(anvil, 0, changed = inputChanged)) - fixture.seed(anvil) shouldBe "hey!" - fixture.receive(ClientNameItemPacket("stale")) - fixture.receive(ClientPongPacket(fixture.sent().single().id())) - fixture.receive(ClientNameItemPacket("hey!!")) - menu.edits shouldContainExactly listOf("hey!", "hey!!") - - // A shift-click that moved the input paper out reports it among the changed slots. - fixture.clear() - fixture.receive(fixture.click(anvil, 3, changed = inputChanged)) - fixture.seed(anvil) shouldBe "hey!!" - fixture.sent().size shouldBe 1 - } + menu.prompt("first") + val first = fixture.sent().single().dialog() as Dialog.MultiAction + menu.prompt("second") + menu.prompts shouldContainExactly listOf(null) + val second = fixture.sent().last().dialog() as Dialog.MultiAction - "a click during a reopen does not admit the old field's renames into the new segment" { - val fixture = FullscreenFixture() - val menu = Menu(fixture.player) - menu.open() - val almost = "a".repeat(49) - menu.prompt(almost) - val anvil = fixture.sent().single().windowId() - fixture.receive(ClientPongPacket(fixture.sent().single().id())) + fixture.click(first, 0, "text" to "stale") + fixture.respond(Key.key("other", "confirm"), "text" to "foreign") + menu.prompts shouldContainExactly listOf(null) + fixture.click(second, 0, "text" to "now") - fixture.clear() - fixture.receive(ClientNameItemPacket(almost + "a")) - val reopen = fixture.sent().single() - fixture.clear() - fixture.receive(fixture.click(anvil, 0, changed = inputChanged)) - val reseed = fixture.sent().single() - fixture.receive(ClientNameItemPacket(almost)) - fixture.receive(ClientPongPacket(reopen.id())) - fixture.receive(ClientNameItemPacket(almost)) - fixture.receive(ClientPongPacket(reseed.id())) - fixture.receive(ClientNameItemPacket("b")) - - menu.edits shouldContainExactly listOf(almost + "a", almost + "ab") + menu.prompts shouldContainExactly listOf(null, "now") } - "a submit in flight after a reopen and a click submits the kept text" { + "closing the window with a prompt open resolves it with null once and clears the dialog" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) - menu.open() - val almost = "a".repeat(49) - menu.prompt(almost) - val anvil = fixture.sent().single().windowId() - fixture.receive(ClientPongPacket(fixture.sent().single().id())) - - fixture.clear() - fixture.receive(ClientNameItemPacket(almost + "a")) - fixture.receive(fixture.click(anvil, 1)) - fixture.receive(ClientNameItemPacket(almost)) - fixture.receive(fixture.click(anvil, 2)) - - menu.prompts shouldContainExactly listOf(almost + "a") - fixture.player.openInventory shouldBe null - } - - "taking the result resyncs the player's experience" { - val fixture = FullscreenFixture() - val player = fixture.player - player.level = 3 - val menu = Menu(player) - menu.open() + val session = menu.open() menu.prompt("hi") - val anvil = fixture.sent().single().windowId() - + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction fixture.clear() - fixture.receive(fixture.click(anvil, 2)) - - fixture.sent().single().level() shouldBe 3 - } - - "closing the anvil cancels the prompt on the next tick, and closing the window closes the anvil" { - val fixture = FullscreenFixture() - val player = fixture.player - val menu = Menu(player) - val session = menu.open() - - menu.prompt("") - player.closeInventory() - menu.prompts shouldBe emptyList() - fixture.tick() - menu.prompts shouldContainExactly listOf(null) - menu.prompt("") session.close() - player.openInventory shouldBe null - fixture.tick() + fixture.sent().size shouldBe 1 + fixture.click(dialog, 0, "text" to "late") + menu.prompts shouldContainExactly listOf(null) } @@ -540,7 +418,6 @@ private class Menu( val clicks = mutableListOf() val rightClicks = mutableListOf() val scrolls = mutableListOf() - val edits = mutableListOf() val prompts = mutableListOf() var closed = false @@ -550,16 +427,13 @@ private class Menu( scroll { scrolls += it } } - fun prompt(initial: String) = promptText(initial = initial, onEdit = { edits += it }) { prompts += it } + fun prompt(initial: String) = promptText(initial = initial) { prompts += it } override fun onClose() { closed = true } } -/** The changed slots a click reports when it moved the input paper. */ -private val inputChanged = mapOf(0.toShort() to ItemStack.Hash.AIR) - /** A player in an instance, with 40 max health, whose connection records every packet that reaches its client. */ private class FullscreenFixture { private val connection = PacketLog() @@ -595,30 +469,20 @@ private class FullscreenFixture { fun receive(packet: ClientPacket) = PlayerPacketEvent(player, packet).also(EventDispatcher::call) + /** Presses [dialog]'s button [index], answering with [fields]. */ fun click( - windowId: Int, - slot: Int, - changed: Map = emptyMap(), - ) = ClientClickWindowPacket( - windowId, - 0, - slot.toShort(), - 0, - ClientClickWindowPacket.ClickType.PICKUP, - changed, - ItemStack.Hash.AIR, - ) - - /** The paper in the first slot of window [windowId] as last sent. */ - fun seedItem(windowId: Int): ItemStack = - sent() - .last { it.windowId() == windowId } - .items()[0] - - /** The name of the paper in the first slot of window [windowId] as last sent. */ - fun seed(windowId: Int): String? { - val name = seedItem(windowId).get(DataComponents.CUSTOM_NAME) ?: return null - return PlainTextComponentSerializer.plainText().serialize(name) + dialog: Dialog.MultiAction, + index: Int, + vararg fields: Pair, + ) = respond((dialog.actions()[index].action() as DialogAction.DynamicCustom).key(), *fields) + + fun respond( + key: Key, + vararg fields: Pair, + ) { + val payload = CompoundBinaryTag.builder() + for ((name, value) in fields) payload.putString(name, value) + EventDispatcher.call(PlayerCustomClickEvent(player, key, payload.build())) } /** Queues [packets] back to back, then writes them out the way the connection's writer thread does. */ diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt index c0616d7e..ab77463b 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -2,9 +2,7 @@ package com.chunkzero.window import com.chunkzero.window.host.FullscreenHost import com.chunkzero.window.host.FullscreenListener -import com.chunkzero.window.host.FullscreenTextListener import com.chunkzero.window.host.OpenFullscreen -import com.chunkzero.window.internal.AnvilReopenGate import com.chunkzero.window.internal.CanvasPoint import com.chunkzero.window.internal.FullscreenMapping import com.chunkzero.window.internal.HudLayers @@ -50,7 +48,7 @@ internal class FullscreenWindowSession( private var yaw = 0f private var pitch = 0f private var pullBackAfter = Long.MIN_VALUE - private var prompt: TextPrompt? = null + private var prompt: Any? = null private var closed = false private val listener = @@ -63,23 +61,25 @@ internal class FullscreenWindowSession( override fun onClick() = click() override fun onRightClick() { - if (closed) return + if (!listening) return val point = cursor() bindings.rightClick?.invoke(FullscreenClick(point.x, point.y)) } override fun onScroll(steps: Int) { - if (!closed) bindings.scroll?.invoke(steps) + if (listening) bindings.scroll?.invoke(steps) } override fun onClose() { if (closed) return closed = true - prompt = null view.invokeOnClose() } } + /** Whether canvas input counts: the window is open and no prompt covers it. */ + private val listening get() = !closed && prompt == null + fun open() { try { check(entry.surface.mode == FullscreenMode.UNLOCKED) { @@ -103,7 +103,6 @@ internal class FullscreenWindowSession( override fun close() { if (closed) return closed = true - prompt = null view.invokeOnClose() screen.close() } @@ -111,20 +110,15 @@ internal class FullscreenWindowSession( fun promptText( title: Component, initial: String, - onEdit: (String) -> Unit, onDone: (String?) -> Unit, ) { check(!closed) { "Fullscreen view is not open" } - check(prompt == null) { "A text prompt is already open in fullscreen '${definition.name}'" } - val kept = initial.length - initial.length % AnvilReopenGate.MAX_NAME_LENGTH - val next = TextPrompt(title, initial.take(kept), onEdit, onDone) - prompt = next - try { - next.open(initial.drop(kept)) - } catch (failure: Throwable) { - prompt = null - throw failure + val token = Any() + screen.promptText(title, initial) { text -> + if (prompt === token) prompt = null + onDone(text) } + prompt = token } fun refreshAll() { @@ -142,7 +136,7 @@ internal class FullscreenWindowSession( yaw: Float, pitch: Float, ) { - if (closed) return + if (!listening) return this.yaw = yaw this.pitch = pitch mapping.pullBack(yaw, pitch)?.let { turn -> @@ -160,7 +154,7 @@ internal class FullscreenWindowSession( } private fun click() { - if (closed) return + if (!listening) return val point = cursor() val current = layers.switches.current() val actions = @@ -205,44 +199,6 @@ internal class FullscreenWindowSession( sent = content } - /** - * A [promptText] anvil: [kept] is the text of earlier anvils whose field filled, and [field] the current anvil's. - */ - private inner class TextPrompt( - private val title: Component, - private var kept: String, - private val edited: (String) -> Unit, - private val done: (String?) -> Unit, - ) : FullscreenTextListener { - private var field = "" - - fun open(text: String) { - field = text - screen.openTextInput(title, text, this) - } - - override fun onEdit(text: String) { - if (prompt !== this) return - if (text.length >= AnvilReopenGate.MAX_NAME_LENGTH) { - kept += text - open("") - } else { - field = text - } - edited(kept + field) - } - - override fun onSubmit() = finish(kept + field) - - override fun onCancel() = finish(null) - - private fun finish(text: String?) { - if (prompt !== this) return - prompt = null - done(text) - } - } - /** The [FullscreenScope] passed to `bind`, and the check that every control requiring a binding received one. */ private inner class Bindings : FullscreenScope { var sealed = false diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index fffe804b..d29b17ff 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -12,7 +12,7 @@ import kotlin.properties.ReadWriteProperty * reacts to lifecycle events. * * A view shows [definition] to the player of [host] and is single-use: [open] it once. The player aims a cursor by - * turning, clicks with the attack and use keys, scrolls, types into a [promptText] anvil, and leaves by sneaking. + * turning, clicks with the attack and use keys, scrolls, answers a [promptText] dialog, and leaves by sneaking. * Reactive [state] cells read inside render lambdas re-render the window when written. */ public abstract class FullscreenView( @@ -75,26 +75,29 @@ public abstract class FullscreenView( } /** - * Opens an anvil titled [title] over the canvas for the player to type into, starting from [initial]. + * Opens a dialog titled [title] over the canvas for the player to type into, starting from [initial], and calls + * [onDone] once with the text when they confirm, or `null` when they cancel. The dialog closes with the button and + * the canvas is back. The text holds at most 256 characters, and [initial] is cut to fit. Escape does not dismiss + * the dialog. While it is open the HUD stays hidden and canvas input is ignored, so the cursor stays where it was. * - * [onEdit] receives the whole text after each edit. [onDone] receives it once the player clicks the anvil's result - * slot, or `null` once they close the anvil; the canvas is back by then. Neither runs after the window closes. While - * the anvil is open, the HUD stays hidden and the cursor stays where it was. + * Opening a prompt while one is open replaces it, and the replaced prompt's [onDone] receives `null`. When the + * window closes with a prompt open, its [onDone] receives `null` as well. * - * The anvil's rename field holds 50 characters. When it fills, its text is kept and a fresh anvil opens with an empty - * field, so the text can grow past 50 characters, but only the part after the last 50-character boundary is shown - * and editable. Keys pressed before the fresh anvil arrives are lost. + * To prompt on a click, call this from the control's click handler: + * ``` + * override fun onRename(click: FullscreenClick) { + * promptText(Component.text("Rename"), name) { it?.let { name = it } } + * } + * ``` * - * @throws IllegalStateException if the view is not open, a prompt is already open, or the host could not open the - * anvil, for example while another container is open. + * @throws IllegalStateException if the view is not open. */ protected fun promptText( title: Component = Component.empty(), initial: String = "", - onEdit: (String) -> Unit = {}, onDone: (String?) -> Unit, ) { - requireSession().promptText(title, initial, onEdit, onDone) + requireSession().promptText(title, initial, onDone) } /** diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt index 13d18622..8348e3fb 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt @@ -41,23 +41,21 @@ public interface OpenFullscreen { ) /** - * Opens an anvil titled [title] over the canvas, with [text] in its rename field, and reports its input to - * [listener] until it closes. The HUD stays hidden and the hotbar blank while it is open; the anvil - * shows no items and no cost. + * Shows a dialog titled [title] over the canvas with a text field holding [initial] and a Confirm and a Cancel + * button, and reports the outcome to [onDone] exactly once: the field's text on Confirm, `null` on Cancel, and `null` + * when another prompt replaces this one or the window ends. The dialog closes with the button, back to the canvas, + * and cannot be dismissed with Escape. The text holds at most 256 characters; [initial] is cut to fit. The HUD stays hidden while the dialog is open. * - * Called while an anvil is open, it opens a fresh one in its place reporting to [listener], without reporting the - * old one's end. Edits made on an earlier anvil, and the client's echo of [text], are not reported. - * - * @throws IllegalStateException if another container is open or an inventory listener cancelled the anvil. + * @throws IllegalStateException if the window is closed. */ - public fun openTextInput( + public fun promptText( title: Component, - text: String, - listener: FullscreenTextListener, + initial: String, + onDone: (String?) -> Unit, ) /** - * Server-initiated close: stops listening, closes an open anvil, removes the display, and restores the player's game + * Server-initiated close: stops listening, clears an open prompt, removes the display, and restores the player's game * mode, abilities, position, held slot, and HUD. Does not call [FullscreenListener.onClose]. */ public fun close() @@ -89,18 +87,3 @@ public interface FullscreenListener { */ public fun onClose() } - -/** Input from the anvil that [OpenFullscreen.openTextInput] opened. */ -public interface FullscreenTextListener { - /** The player changed the rename field to [text]. */ - public fun onEdit(text: String) - - /** The player clicked the anvil's result slot. The anvil has closed. */ - public fun onSubmit() - - /** - * The anvil closed without a submit: the player closed it, or another inventory replaced it. Reported on the next - * tick, and only while the full-screen window is open. - */ - public fun onCancel() -} diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt index 97213dec..68ef4807 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt @@ -2,7 +2,6 @@ package com.chunkzero.window import com.chunkzero.window.host.FullscreenHost import com.chunkzero.window.host.FullscreenListener -import com.chunkzero.window.host.FullscreenTextListener import com.chunkzero.window.host.OpenFullscreen import com.chunkzero.window.internal.Turn import com.chunkzero.window.manifest.FullscreenMode @@ -144,25 +143,28 @@ class FullscreenSessionTest : menu.scrolls shouldContainExactly listOf(1, -3) } - "a text prompt keeps each full field and opens a fresh anvil, reporting the whole text" { + "a prompt ignores canvas input until it is answered, and a new prompt replaces it" { val host = FakeFullscreenHost() val menu = Menu(host).also { it.open() } - val full = "a".repeat(50) - - menu.prompt(full + "bc") - host.inputs.single().text shouldBe "bc" - host.text.onEdit("bcd") - host.text.onEdit(full) - host.inputs.map { it.text } shouldContainExactly listOf("bc", "") - host.text.onEdit("e") - host.text.onSubmit() - - menu.edits shouldContainExactly listOf(full + "bcd", full + full, full + full + "e") - menu.prompts shouldContainExactly listOf(full + full + "e") - menu.prompt("x") - shouldThrow { menu.prompt("y") } - host.text.onCancel() - menu.prompts shouldContainExactly listOf(full + full + "e", null) + + menu.mode = "on" + host.look(110, 170) + menu.prompt("a") + host.listener.onClick() + host.listener.onRightClick() + host.listener.onScroll(1) + menu.secrets shouldBe 0 + menu.rightClicks shouldBe emptyList() + menu.scrolls shouldBe emptyList() + + menu.prompt("b") + menu.prompts shouldContainExactly listOf(null) + host.answers.last()("text") + menu.prompts shouldContainExactly listOf(null, "text") + host.listener.onClick() + host.listener.onScroll(1) + menu.secrets shouldBe 1 + menu.scrolls shouldContainExactly listOf(1) } "horse mode is refused" { @@ -183,7 +185,6 @@ private class Menu( var closes = 0 val rightClicks = mutableListOf() val scrolls = mutableListOf() - val edits = mutableListOf() val prompts = mutableListOf() override fun FullscreenScope.bind() { @@ -198,7 +199,7 @@ private class Menu( scroll { scrolls += it } } - fun prompt(initial: String) = promptText(initial = initial, onEdit = { edits += it }) { prompts += it } + fun prompt(initial: String) = promptText(initial = initial) { prompts += it } override fun onClose() { closes++ @@ -211,14 +212,10 @@ private class FakeFullscreenHost : val scheduler = ManualScheduler() val shown = mutableListOf() val turns = mutableListOf() - val inputs = mutableListOf() + val answers = mutableListOf<(String?) -> Unit>() var closes = 0 lateinit var listener: FullscreenListener - /** The listener of the latest text input. */ - val text: FullscreenTextListener - get() = inputs.last().listener - override val latencyMillis = 50 override fun open( @@ -244,12 +241,13 @@ private class FakeFullscreenHost : turns += Turn(yaw, pitch) } - override fun openTextInput( + override fun promptText( title: Component, - text: String, - listener: FullscreenTextListener, + initial: String, + onDone: (String?) -> Unit, ) { - inputs += TextInput(text, listener) + answers.lastOrNull()?.invoke(null) + answers += onDone } override fun close() { @@ -266,11 +264,6 @@ private class FakeFullscreenHost : } } -private data class TextInput( - val text: String, - val listener: FullscreenTextListener, -) - private data class Run( val color: TextColor?, val shadow: ShadowColor?, From 829c1b8c947439c09b02d0b25dafdece3cbdff4d Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:53:35 -0400 Subject: [PATCH 6/8] fix(minestom): keep the text prompt in one place and resolve it after the state it reports The host alone holds the pending prompt and the session derives whether canvas input counts from it. Every ending (answer, replacement, container takeover, window end) clears the prompt before calling its callback. A container opening over the dialog cancels the prompt on the player's next tick, and a window ending restores the player first, then cancels the prompt, then calls onClose, even if the callback throws. --- .../minestom/internal/MinestomFullscreen.kt | 34 +++++++-- .../minestom/internal/MinestomTextPrompt.kt | 28 +++++--- .../window/minestom/MinestomFullscreenTest.kt | 59 +++++++++++++++- .../com/chunkzero/window/FullscreenSession.kt | 18 ++--- .../com/chunkzero/window/FullscreenView.kt | 8 ++- .../chunkzero/window/host/FullscreenHost.kt | 22 +++--- .../chunkzero/window/FullscreenSessionTest.kt | 69 ++++++++++++++++--- 7 files changed, 190 insertions(+), 48 deletions(-) diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index db6a1ab3..ab4f8a77 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -81,6 +81,10 @@ import net.minestom.server.world.clock.WorldClock * The client advances every world clock by the world age delta of each time packet before applying the packet's clock * snapshots, so every packet that changes the world age carries the real clock values, or the hidden age would move * the clocks with it. + * + * A prompt's callback runs on the player's thread after the state it reports has changed, never under [lock]: a server + * container opening over the dialog cancels it on the next tick, and a window ending cancels it once the player is + * restored, just before [FullscreenListener.onClose]. */ internal class MinestomFullscreen private constructor( private val player: Player, @@ -137,17 +141,24 @@ internal class MinestomFullscreen private constructor( player.sendPacket(PlayerRotationPacket(yaw, true, pitch, true)) } + override val promptOpen: Boolean + get() = textPrompt.current != null + override fun promptText( title: Component, initial: String, onDone: (String?) -> Unit, ) { - check(isOpen()) { "The full-screen window of ${player.username} is closed" } - textPrompt.open(title, initial, onDone) + if (isOpen()) textPrompt.open(title, initial, onDone) else onDone(null) } override fun close() { - if (stop()) restore() + if (!stop()) return + try { + restore() + } finally { + textPrompt.cancel() + } } private fun start(content: Component) { @@ -380,6 +391,10 @@ internal class MinestomFullscreen private constructor( is OpenWindowPacket -> { containerWindowId = packet.windowId() containerGeneration++ + // The container replaces the client's dialog without any answer. + textPrompt.current?.let { covered -> + player.scheduler().scheduleNextTick { textPrompt.cancel(covered) } + } player.sendPacket(realTime()) // Opening a container does not resync the player inventory, whose hotbar still holds the blanks. player.inventory.update(player) @@ -447,8 +462,15 @@ internal class MinestomFullscreen private constructor( /** Ends the window after the player left it, restoring them unless they disconnected. */ private fun end(restore: Boolean) { if (!stop()) return - if (restore) restore() else textPrompt.close(clear = false) - listener.onClose() + try { + if (restore) restore() + } finally { + try { + textPrompt.cancel() + } finally { + listener.onClose() + } + } } /** Stops listening; false when already stopped. */ @@ -462,7 +484,7 @@ internal class MinestomFullscreen private constructor( } private fun restore() { - textPrompt.close(clear = true) + textPrompt.clear() player.sendPacket(DestroyEntitiesPacket(listOf(display.entityId, clickTarget.entityId))) // A mode the server set while the window was open replaces the one it began with. val mode = player.gameMode.takeUnless { it == GameMode.SPECTATOR } ?: gameMode diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt index e4f58a5d..b2158abe 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt @@ -21,14 +21,19 @@ import java.util.concurrent.atomic.AtomicLong * report through custom click actions whose ids are unique to the prompt, so responses to any other dialog are ignored. * * The client closes the dialog when a button is pressed, and Escape does nothing, so a prompt ends with a button, with - * another prompt replacing it, or with [close]. Each of those reports to its callback once. The buttons sit in the - * dialog's body, clear of the footer, which falls inside the strip the pack's shader clips while the HUD is hidden. + * another prompt replacing it, or with [cancel]. Each of those reports to its callback once, after the prompt has left + * [current], so a callback may open the next prompt. The buttons sit in the dialog's body, clear of the footer, which + * falls inside the strip the pack's shader clips while the HUD is hidden. */ internal class MinestomTextPrompt( private val player: Player, ) { + @Volatile private var pending: Pending? = null + /** The prompt awaiting an answer, if any. */ + val current: Pending? get() = pending + /** Shows the dialog, first reporting `null` to the prompt it replaces. */ fun open( title: Component, @@ -65,11 +70,16 @@ internal class MinestomTextPrompt( current.onDone(text) } - /** Reports `null` to the open prompt, if any, and takes its dialog off the client's screen when [clear]. */ - fun close(clear: Boolean) { + /** Takes the open prompt's dialog, if any, off the client's screen; the prompt itself ends with [cancel]. */ + fun clear() { + if (pending != null) player.sendPacket(ClearDialogPacket()) + } + + /** Reports `null` to the open prompt, if any; with [only], just when that prompt is still the open one. */ + fun cancel(only: Pending? = null) { val current = pending ?: return + if (only != null && only !== current) return pending = null - if (clear) player.sendPacket(ClearDialogPacket()) current.onDone(null) } @@ -98,10 +108,10 @@ internal class MinestomTextPrompt( id: Key, ) = DialogActionButton(Component.text(label), null, BUTTON_WIDTH, DialogAction.DynamicCustom(id, null)) - private class Pending( - val confirm: Key, - val cancel: Key, - val onDone: (String?) -> Unit, + class Pending internal constructor( + internal val confirm: Key, + internal val cancel: Key, + internal val onDone: (String?) -> Unit, ) private companion object { diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index c75ddaf5..11f14921 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -225,7 +225,26 @@ class MinestomFullscreenTest : menu.prompts shouldContainExactly listOf(null, "now") } - "closing the window with a prompt open resolves it with null once and clears the dialog" { + "a container opening over the dialog cancels the prompt on the next tick and re-enables the canvas" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("hi") + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction + + fixture.clickCanvas() + menu.clicks shouldBe emptyList() + fixture.send(OpenWindowPacket(1, InventoryType.CHEST_1_ROW.ordinal, Component.empty())) + menu.prompts shouldBe emptyList() + fixture.tick() + fixture.click(dialog, 0, "text" to "late") + fixture.clickCanvas() + + menu.prompts shouldContainExactly listOf(null) + menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) + } + + "closing with a prompt open restores first, then reports null and onClose, even if the callback fails" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) val session = menu.open() @@ -236,8 +255,29 @@ class MinestomFullscreenTest : session.close() fixture.sent().size shouldBe 1 fixture.click(dialog, 0, "text" to "late") - menu.prompts shouldContainExactly listOf(null) + + val sneaking = FullscreenFixture() + val other = Menu(sneaking.player) + other.open() + var mode: GameMode? = null + other.prompt("hi") { + mode = sneaking.player.gameMode + other.prompt("again") + } + sneaking.receive(ClientInputPacket(false, false, false, false, false, true, false)) + mode shouldBe GameMode.SURVIVAL + other.prompts shouldContainExactly listOf(null, null) + other.closed shouldBe true + sneaking.sent().size shouldBe 1 + + val failing = FullscreenFixture() + val broken = Menu(failing.player) + val failed = broken.open() + broken.prompt("hi") { error("boom") } + shouldThrow { failed.close() } + failing.player.gameMode shouldBe GameMode.SURVIVAL + broken.closed shouldBe true } "sneaking leaves the window and restores the player" { @@ -427,7 +467,13 @@ private class Menu( scroll { scrolls += it } } - fun prompt(initial: String) = promptText(initial = initial) { prompts += it } + fun prompt( + initial: String, + then: () -> Unit = {}, + ) = promptText(initial = initial) { + prompts += it + then() + } override fun onClose() { closed = true @@ -467,6 +513,13 @@ private class FullscreenFixture { fun tick() = player.scheduler().processTick() + /** Clicks the menu's button, at canvas pixel (5, 5), and runs the end of the player's tick. */ + fun clickCanvas() { + receive(ClientAttackPacket(0)) + receive(ClientPlayerRotationPacket(-44f, -24.375f, 0.toByte())) + player.scheduler().processTickEnd() + } + fun receive(packet: ClientPacket) = PlayerPacketEvent(player, packet).also(EventDispatcher::call) /** Presses [dialog]'s button [index], answering with [fields]. */ diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt index ab77463b..879d09c3 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -48,7 +48,6 @@ internal class FullscreenWindowSession( private var yaw = 0f private var pitch = 0f private var pullBackAfter = Long.MIN_VALUE - private var prompt: Any? = null private var closed = false private val listener = @@ -78,7 +77,7 @@ internal class FullscreenWindowSession( } /** Whether canvas input counts: the window is open and no prompt covers it. */ - private val listening get() = !closed && prompt == null + private val listening get() = !closed && !screen.promptOpen fun open() { try { @@ -103,8 +102,11 @@ internal class FullscreenWindowSession( override fun close() { if (closed) return closed = true - view.invokeOnClose() - screen.close() + try { + screen.close() + } finally { + view.invokeOnClose() + } } fun promptText( @@ -112,13 +114,7 @@ internal class FullscreenWindowSession( initial: String, onDone: (String?) -> Unit, ) { - check(!closed) { "Fullscreen view is not open" } - val token = Any() - screen.promptText(title, initial) { text -> - if (prompt === token) prompt = null - onDone(text) - } - prompt = token + if (closed) onDone(null) else screen.promptText(title, initial, onDone) } fun refreshAll() { diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index d29b17ff..1d5bce76 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -80,8 +80,10 @@ public abstract class FullscreenView( * the canvas is back. The text holds at most 256 characters, and [initial] is cut to fit. Escape does not dismiss * the dialog. While it is open the HUD stays hidden and canvas input is ignored, so the cursor stays where it was. * - * Opening a prompt while one is open replaces it, and the replaced prompt's [onDone] receives `null`. When the - * window closes with a prompt open, its [onDone] receives `null` as well. + * Opening a prompt while one is open replaces it, and the replaced prompt's [onDone] receives `null`. When a server + * container opens over the dialog or the window closes, the open prompt's [onDone] receives `null` as well, once the + * player is restored. [onDone] runs after its prompt has ended, so it may open another one; on a closed window it + * receives `null` at once. * * To prompt on a click, call this from the control's click handler: * ``` @@ -90,7 +92,7 @@ public abstract class FullscreenView( * } * ``` * - * @throws IllegalStateException if the view is not open. + * @throws IllegalStateException if the view was never opened. */ protected fun promptText( title: Component = Component.empty(), diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt index 8348e3fb..604d60b2 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt @@ -40,13 +40,18 @@ public interface OpenFullscreen { pitch: Float, ) + /** Whether a [promptText] dialog is open and unanswered. */ + public val promptOpen: Boolean + /** * Shows a dialog titled [title] over the canvas with a text field holding [initial] and a Confirm and a Cancel - * button, and reports the outcome to [onDone] exactly once: the field's text on Confirm, `null` on Cancel, and `null` - * when another prompt replaces this one or the window ends. The dialog closes with the button, back to the canvas, - * and cannot be dismissed with Escape. The text holds at most 256 characters; [initial] is cut to fit. The HUD stays hidden while the dialog is open. + * button, and reports the outcome to [onDone] exactly once, on the player's thread: the field's text on Confirm, + * `null` on Cancel, and `null` when another prompt replaces this one, a server container opens over the dialog, or + * the window ends. The dialog closes with the button, back to the canvas, and cannot be dismissed with Escape. The + * text holds at most 256 characters; [initial] is cut to fit. The HUD stays hidden while the dialog is open. * - * @throws IllegalStateException if the window is closed. + * The prompt has left [promptOpen] by the time [onDone] runs, so [onDone] may open the next prompt. On a closed + * window this reports `null` at once. */ public fun promptText( title: Component, @@ -55,8 +60,9 @@ public interface OpenFullscreen { ) /** - * Server-initiated close: stops listening, clears an open prompt, removes the display, and restores the player's game - * mode, abilities, position, held slot, and HUD. Does not call [FullscreenListener.onClose]. + * Server-initiated close: stops listening, clears an open prompt's dialog, removes the display, restores the player's + * game mode, abilities, position, held slot, and HUD, and only then reports `null` to the open prompt. Does not call + * [FullscreenListener.onClose]. */ public fun close() } @@ -82,8 +88,8 @@ public interface FullscreenListener { public fun onScroll(steps: Int) /** - * The player left the window, by sneaking or disconnecting. Listening has stopped, and the player is restored - * unless they disconnected. + * The player left the window, by sneaking or disconnecting. Listening has stopped, the player is restored unless + * they disconnected, and an open prompt has been reported `null`. */ public fun onClose() } diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt index 68ef4807..6ee66707 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt @@ -143,7 +143,7 @@ class FullscreenSessionTest : menu.scrolls shouldContainExactly listOf(1, -3) } - "a prompt ignores canvas input until it is answered, and a new prompt replaces it" { + "a prompt ignores canvas input until it ends, including when a container covers it" { val host = FakeFullscreenHost() val menu = Menu(host).also { it.open() } @@ -157,16 +157,49 @@ class FullscreenSessionTest : menu.rightClicks shouldBe emptyList() menu.scrolls shouldBe emptyList() - menu.prompt("b") + host.answer(null) menu.prompts shouldContainExactly listOf(null) - host.answers.last()("text") - menu.prompts shouldContainExactly listOf(null, "text") host.listener.onClick() host.listener.onScroll(1) menu.secrets shouldBe 1 menu.scrolls shouldContainExactly listOf(1) } + "a callback that opens a prompt while another replaces it leaves that prompt pending" { + val host = FakeFullscreenHost() + val menu = Menu(host).also { it.open() } + + menu.mode = "on" + host.look(110, 170) + menu.prompt("a") { menu.prompt("c") } + menu.prompt("b") + menu.prompts shouldContainExactly listOf(null, null) + host.listener.onClick() + menu.secrets shouldBe 0 + + host.answer("text") + menu.prompts shouldContainExactly listOf(null, null, "text") + host.listener.onClick() + menu.secrets shouldBe 1 + } + + "closing with a prompt open reports null and runs onClose even when the callback prompts again or throws" { + val host = FakeFullscreenHost() + val menu = Menu(host) + val session = menu.open() + + menu.prompt("a") { + menu.prompt("b") + error("boom") + } + shouldThrow { session.close() } + + menu.prompts shouldContainExactly listOf(null, null) + menu.closes shouldBe 1 + host.closes shouldBe 1 + host.pending shouldBe null + } + "horse mode is refused" { val host = FakeFullscreenHost() shouldThrow { Menu(host, FullscreenMode.HORSE).open() } @@ -199,7 +232,13 @@ private class Menu( scroll { scrolls += it } } - fun prompt(initial: String) = promptText(initial = initial) { prompts += it } + fun prompt( + initial: String, + then: () -> Unit = {}, + ) = promptText(initial = initial) { + prompts += it + then() + } override fun onClose() { closes++ @@ -212,8 +251,9 @@ private class FakeFullscreenHost : val scheduler = ManualScheduler() val shown = mutableListOf() val turns = mutableListOf() - val answers = mutableListOf<(String?) -> Unit>() + var pending: ((String?) -> Unit)? = null var closes = 0 + private var open = true lateinit var listener: FullscreenListener override val latencyMillis = 50 @@ -241,17 +281,30 @@ private class FakeFullscreenHost : turns += Turn(yaw, pitch) } + override val promptOpen get() = pending != null + override fun promptText( title: Component, initial: String, onDone: (String?) -> Unit, ) { - answers.lastOrNull()?.invoke(null) - answers += onDone + if (!open) return onDone(null) + val replaced = pending + pending = onDone + replaced?.invoke(null) } override fun close() { closes++ + open = false + answer(null) + } + + /** Ends the pending prompt with [text], as a button press or a container takeover does. */ + fun answer(text: String?) { + val done = pending ?: return + pending = null + done(text) } /** Turns the player so the cursor lands on canvas pixel ([x], [y]). */ From 4474bab149e013a0586d66541fe0bf126986fc34 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:57:02 -0400 Subject: [PATCH 7/8] fix(minestom): cancel only the prompt whose dialog a container actually covers --- .../minestom/internal/MinestomFullscreen.kt | 10 +++-- .../minestom/internal/MinestomTextPrompt.kt | 37 +++++++++++++++++-- .../window/minestom/MinestomFullscreenTest.kt | 32 ++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index ab4f8a77..54638a8e 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -37,6 +37,8 @@ import net.minestom.server.network.packet.client.play.ClientPlayerActionPacket import net.minestom.server.network.packet.client.play.ClientPlayerPositionAndRotationPacket import net.minestom.server.network.packet.client.play.ClientPlayerRotationPacket import net.minestom.server.network.packet.server.ServerPacket +import net.minestom.server.network.packet.server.common.ClearDialogPacket +import net.minestom.server.network.packet.server.common.ShowDialogPacket import net.minestom.server.network.packet.server.play.ChangeGameStatePacket import net.minestom.server.network.packet.server.play.CloseWindowPacket import net.minestom.server.network.packet.server.play.DestroyEntitiesPacket @@ -388,13 +390,15 @@ internal class MinestomFullscreen private constructor( if (!containerOpen) blank(realHotbarSlots(packet)) } + is ShowDialogPacket, is ClearDialogPacket -> { + textPrompt.onWritten(packet) + } + is OpenWindowPacket -> { containerWindowId = packet.windowId() containerGeneration++ // The container replaces the client's dialog without any answer. - textPrompt.current?.let { covered -> - player.scheduler().scheduleNextTick { textPrompt.cancel(covered) } - } + textPrompt.coverByContainer() player.sendPacket(realTime()) // Opening a container does not resync the player inventory, whose hotbar still holds the blanks. player.inventory.update(player) diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt index b2158abe..c3789ae4 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomTextPrompt.kt @@ -12,6 +12,7 @@ import net.minestom.server.dialog.DialogInput import net.minestom.server.dialog.DialogMetadata import net.minestom.server.entity.Player import net.minestom.server.event.player.PlayerCustomClickEvent +import net.minestom.server.network.packet.server.ServerPacket import net.minestom.server.network.packet.server.common.ClearDialogPacket import net.minestom.server.network.packet.server.common.ShowDialogPacket import java.util.concurrent.atomic.AtomicLong @@ -31,6 +32,10 @@ internal class MinestomTextPrompt( @Volatile private var pending: Pending? = null + /** The prompt whose dialog the writer has sent most recently and not cleared, which may no longer be [current]. */ + @Volatile + private var shown: Pending? = null + /** The prompt awaiting an answer, if any. */ val current: Pending? get() = pending @@ -41,10 +46,13 @@ internal class MinestomTextPrompt( onDone: (String?) -> Unit, ) { val id = PROMPTS.getAndIncrement() - val next = Pending(Key.key("window", "prompt/$id/confirm"), Key.key("window", "prompt/$id/cancel"), onDone) + val confirm = Key.key("window", "prompt/$id/confirm") + val cancel = Key.key("window", "prompt/$id/cancel") + val show = ShowDialogPacket(dialog(title, initial.take(MAX_LENGTH), confirm, cancel)) + val next = Pending(confirm, cancel, show, onDone) val replaced = pending pending = next - player.sendPacket(ShowDialogPacket(dialog(title, initial.take(MAX_LENGTH), next))) + player.sendPacket(next.show) replaced?.onDone?.invoke(null) } @@ -75,6 +83,25 @@ internal class MinestomTextPrompt( if (pending != null) player.sendPacket(ClearDialogPacket()) } + /** Records, from the packets as the writer sends them, which prompt's dialog the client is showing. */ + fun onWritten(packet: ServerPacket) { + when (packet) { + is ShowDialogPacket -> shown = pending?.takeIf { it.show == packet } + is ClearDialogPacket -> shown = null + else -> Unit + } + } + + /** + * Cancels the open prompt on the player's next tick when a container replaces the dialog the client is showing. + * A prompt whose dialog has not been sent yet is unaffected, as its dialog arrives after the container. + */ + fun coverByContainer() { + val covered = shown?.takeIf { it === pending } ?: return + shown = null + player.scheduler().scheduleNextTick { cancel(covered) } + } + /** Reports `null` to the open prompt, if any; with [only], just when that prompt is still the open one. */ fun cancel(only: Pending? = null) { val current = pending ?: return @@ -86,7 +113,8 @@ internal class MinestomTextPrompt( private fun dialog( title: Component, initial: String, - prompt: Pending, + confirm: Key, + cancel: Key, ): Dialog = Dialog.MultiAction( DialogMetadata( @@ -98,7 +126,7 @@ internal class MinestomTextPrompt( emptyList(), listOf(DialogInput.Text(FIELD, DialogInput.DEFAULT_WIDTH, title, false, initial, MAX_LENGTH, null)), ), - listOf(button("Confirm", prompt.confirm), button("Cancel", prompt.cancel)), + listOf(button("Confirm", confirm), button("Cancel", cancel)), null, 2, ) @@ -111,6 +139,7 @@ internal class MinestomTextPrompt( class Pending internal constructor( internal val confirm: Key, internal val cancel: Key, + internal val show: ShowDialogPacket, internal val onDone: (String?) -> Unit, ) diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index 11f14921..d7b8d750 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -34,6 +34,7 @@ import net.minestom.server.entity.PlayerHand import net.minestom.server.entity.attribute.Attribute import net.minestom.server.event.EventDispatcher import net.minestom.server.event.player.PlayerCustomClickEvent +import net.minestom.server.event.player.PlayerDisconnectEvent import net.minestom.server.event.player.PlayerPacketEvent import net.minestom.server.event.player.PlayerPacketOutEvent import net.minestom.server.inventory.Inventory @@ -244,6 +245,35 @@ class MinestomFullscreenTest : menu.clicks shouldContainExactly listOf(FullscreenClick(5, 5)) } + "a container opened before a prompt's dialog is written leaves that prompt pending" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + fixture.tick() + fixture.clear() + + fixture.player.openInventory(Inventory(InventoryType.CHEST_1_ROW, Component.empty())) + menu.prompt("hi") + val dialog = fixture.sent().single().dialog() as Dialog.MultiAction + fixture.tick() + fixture.click(dialog, 0, "text" to "kept") + + menu.prompts shouldContainExactly listOf("kept") + } + + "disconnecting with a prompt open reports null and closes once" { + val fixture = FullscreenFixture() + val menu = Menu(fixture.player) + menu.open() + menu.prompt("hi") + + EventDispatcher.call(PlayerDisconnectEvent(fixture.player)) + EventDispatcher.call(PlayerDisconnectEvent(fixture.player)) + + menu.prompts shouldContainExactly listOf(null) + menu.closeCount shouldBe 1 + } + "closing with a prompt open restores first, then reports null and onClose, even if the callback fails" { val fixture = FullscreenFixture() val menu = Menu(fixture.player) @@ -460,6 +490,7 @@ private class Menu( val scrolls = mutableListOf() val prompts = mutableListOf() var closed = false + var closeCount = 0 override fun FullscreenScope.bind() { button("press") { clicks += it } @@ -477,6 +508,7 @@ private class Menu( override fun onClose() { closed = true + closeCount++ } } From 80282ae4a6756100a026a55a251d4bd083283599 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:00:15 -0400 Subject: [PATCH 8/8] docs(runtime): limit prompt restoration wording to window closure --- .../src/main/kotlin/com/chunkzero/window/FullscreenView.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index 1d5bce76..80936e7c 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -81,9 +81,9 @@ public abstract class FullscreenView( * the dialog. While it is open the HUD stays hidden and canvas input is ignored, so the cursor stays where it was. * * Opening a prompt while one is open replaces it, and the replaced prompt's [onDone] receives `null`. When a server - * container opens over the dialog or the window closes, the open prompt's [onDone] receives `null` as well, once the - * player is restored. [onDone] runs after its prompt has ended, so it may open another one; on a closed window it - * receives `null` at once. + * container opens over the dialog, the open prompt's [onDone] receives `null` as well, on the next tick, and the + * window stays open. When the window closes, it receives `null` once the player is restored. [onDone] runs after its + * prompt has ended, so it may open another one; on a closed window it receives `null` at once. * * To prompt on a click, call this from the control's click handler: * ```