diff --git a/gradle.properties b/gradle.properties index d492717f..70dcbe0d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,6 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled javaVersion=25 mcVersion=26.2 group=dev.slne.surf.api -version=3.38.0 +version=3.39.0 relocationPrefix=dev.slne.surf.api.libs snapshot=false diff --git a/surf-api-minestom/api/surf-api-minestom.api b/surf-api-minestom/api/surf-api-minestom.api index b38f96d7..3f02e062 100644 --- a/surf-api-minestom/api/surf-api-minestom.api +++ b/surf-api-minestom/api/surf-api-minestom.api @@ -433,6 +433,49 @@ public final class dev/slne/surf/api/minestom/dialog/callback/DialogResponseView public final fun of-VLTPHq8 (Lnet/kyori/adventure/nbt/BinaryTag;)Lnet/kyori/adventure/nbt/CompoundBinaryTag; } +public final class dev/slne/surf/api/minestom/dialog/search/SearchDialogAction { + public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public final fun component1 ()Lkotlin/jvm/functions/Function1; + public final fun component2 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/minestom/dialog/search/SearchDialogAction; + public static synthetic fun copy$default (Ldev/slne/surf/api/minestom/dialog/search/SearchDialogAction;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/minestom/dialog/search/SearchDialogAction; + public fun equals (Ljava/lang/Object;)Z + public final fun getLabel ()Lkotlin/jvm/functions/Function1; + public final fun getTooltip ()Lkotlin/jvm/functions/Function1; + public fun hashCode ()I + public final fun setLabel (Lkotlin/jvm/functions/Function1;)V + public final fun setTooltip (Lkotlin/jvm/functions/Function1;)V + public fun toString ()Ljava/lang/String; +} + +public final class dev/slne/surf/api/minestom/dialog/search/SearchDialogKt { + public static final fun searchDialog (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLnet/minestom/server/dialog/DialogAfterAction;)Lnet/minestom/server/dialog/Dialog; + public static synthetic fun searchDialog$default (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLnet/minestom/server/dialog/DialogAfterAction;ILjava/lang/Object;)Lnet/minestom/server/dialog/Dialog; +} + +public final class dev/slne/surf/api/minestom/dialog/search/SearchInput { + public fun ()V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lkotlin/jvm/functions/Function1; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/minestom/dialog/search/SearchInput; + public static synthetic fun copy$default (Ldev/slne/surf/api/minestom/dialog/search/SearchInput;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/minestom/dialog/search/SearchInput; + public fun equals (Ljava/lang/Object;)Z + public final fun getInitialValue ()Ljava/lang/String; + public final fun getInputModifier ()Lkotlin/jvm/functions/Function1; + public final fun getKey ()Ljava/lang/String; + public final fun getLabel ()Lkotlin/jvm/functions/Function1; + public fun hashCode ()I + public final fun setInitialValue (Ljava/lang/String;)V + public final fun setInputModifier (Lkotlin/jvm/functions/Function1;)V + public final fun setKey (Ljava/lang/String;)V + public final fun setLabel (Lkotlin/jvm/functions/Function1;)V + public fun toString ()Ljava/lang/String; +} + public final class dev/slne/surf/api/minestom/impl/inventory/framework/MinestomInventoryLoader : dev/slne/minestom/lobby/api/event/EventRegistrar { public static final field Companion Ldev/slne/surf/api/minestom/impl/inventory/framework/MinestomInventoryLoader$Companion; public fun ()V diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/dialog/builder/DialogTypeBuilder.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/dialog/builder/DialogTypeBuilder.kt index 91f6f78d..4565b63f 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/dialog/builder/DialogTypeBuilder.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/dialog/builder/DialogTypeBuilder.kt @@ -1,7 +1,7 @@ package dev.slne.surf.api.minestom.dialog.builder import dev.slne.surf.api.core.util.mutableObjectListOf -import dev.slne.surf.api.core.util.mutableObjectSetOf +import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet import net.minestom.server.dialog.Dialog import net.minestom.server.dialog.DialogActionButton import net.minestom.server.dialog.DialogMetadata @@ -114,7 +114,8 @@ class DialogTypeBuilder { * Collects the dialogs a dialog list offers and how they are laid out. */ class DialogListTypeBuilder { - private val dialogs = mutableObjectSetOf() + /** Keeps the order the dialogs were added in, which is the order the client shows them in. */ + private val dialogs = ObjectLinkedOpenHashSet() var exitAction: DialogActionButton? = null var columns: @Range(from = 1, to = Int.MAX_VALUE.toLong()) Int = DEFAULT_COLUMNS var buttonWidth: @Range(from = 1, to = 1024) Int = DEFAULT_BUTTON_WIDTH diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/dialog/search/SearchDialog.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/dialog/search/SearchDialog.kt new file mode 100644 index 00000000..37db21a8 --- /dev/null +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/dialog/search/SearchDialog.kt @@ -0,0 +1,151 @@ +package dev.slne.surf.api.minestom.dialog.search + +import dev.slne.surf.api.core.messages.builder.SurfComponentBuilder +import dev.slne.surf.api.minestom.dialog.base +import dev.slne.surf.api.minestom.dialog.builder.DialogBodyBuilder +import dev.slne.surf.api.minestom.dialog.builder.DialogInputBuilder +import dev.slne.surf.api.minestom.dialog.dialog +import dev.slne.surf.api.minestom.dialog.type +import net.minestom.server.dialog.Dialog +import net.minestom.server.dialog.DialogAfterAction +import net.minestom.server.entity.Player + +/** + * The label and tooltip of one of a search dialog's buttons. + */ +data class SearchDialogAction( + var label: SurfComponentBuilder.() -> Unit, + var tooltip: SurfComponentBuilder.() -> Unit +) + +/** + * The text field a search dialog collects its query in. + * + * @property key the key the query is reported back under + * @property label the label shown next to the field + * @property initialValue the query the field starts out with + * @property inputModifier applied to the field last, for anything the other properties do not cover + */ +data class SearchInput( + var key: String = "search", + var label: SurfComponentBuilder.() -> Unit = { + text("Suche") + }, + var initialValue: String = "", + var inputModifier: DialogInputBuilder.TextInputBuilder.() -> Unit = {} +) + +/** + * Builds a dialog that asks for a single query and hands it to [onSearch] or [onClose]. + * + * The dialog closes itself before either callback runs, so a callback is free to open an inventory + * or another dialog straight away. + * + * ``` + * player.showDialog( + * searchDialog( + * title = { primary("Search a player...") }, + * onSearch = { player, query -> showResults(player, query) }, + * onClose = { player, _ -> showOverview(player) }, + * searchInput = { initialValue = lastQuery }, + * ) + * ) + * ``` + * + * @param title the title shown above the dialog + * @param externalTitle the title shown wherever the dialog is listed, defaulting to [title] + * @param onSearch called with the query once the search button is pressed + * @param onClose called with the query once the cancel button is pressed + * @param body anything shown above the search field + * @param searchButton the button that runs the search + * @param cancelButton the button that abandons the search + * @param searchInput the text field the query is collected in + * @param canCloseWithEscape whether the dialog can be dismissed without pressing a button + * @param afterAction what the client does with the dialog once a button was pressed + */ +fun searchDialog( + title: SurfComponentBuilder.() -> Unit, + externalTitle: SurfComponentBuilder.() -> Unit = title, + onSearch: (player: Player, query: String) -> Unit, + onClose: (player: Player, query: String) -> Unit, + body: DialogBodyBuilder.() -> Unit = { }, + searchButton: SearchDialogAction.() -> Unit = { + label = { + text("Suchen") + } + tooltip = { + text("Klicke hier, um die Suche zu starten.") + } + }, + cancelButton: SearchDialogAction.() -> Unit = { + label = { + text("Abbrechen") + } + tooltip = { + text("Klicke hier, um die Suche abzubrechen.") + } + }, + searchInput: SearchInput.() -> Unit, + canCloseWithEscape: Boolean = false, + afterAction: DialogAfterAction = DialogAfterAction.NONE +): Dialog = dialog { + val input = SearchInput().apply(searchInput) + + val search = SearchDialogAction( + label = { }, + tooltip = { } + ).apply(searchButton) + + val cancel = SearchDialogAction( + label = { }, + tooltip = { } + ).apply(cancelButton) + + base { + title(title) + externalTitle(externalTitle) + canCloseWithEscape(canCloseWithEscape) + afterAction(afterAction) + + body(body) + + input { + text(input.key) { + label(input.label) + initial(input.initialValue) + + input.inputModifier(this) + } + } + } + + type { + confirmation { + no { + label(search.label) + tooltip(search.tooltip) + + action { + customPlayerClick { response, player -> + player.closeDialog() + + onSearch(player, response.getText(input.key) ?: "") + } + } + } + + yes { + label(cancel.label) + tooltip(cancel.tooltip) + + action { + customPlayerClick { response, player -> + player.closeDialog() + + onClose(player, response.getText(input.key) ?: "") + } + } + } + } + } +} diff --git a/surf-api-minestom/src/test/kotlin/dev/slne/surf/api/minestom/dialog/search/SearchDialogTest.kt b/surf-api-minestom/src/test/kotlin/dev/slne/surf/api/minestom/dialog/search/SearchDialogTest.kt new file mode 100644 index 00000000..9a78bb2a --- /dev/null +++ b/surf-api-minestom/src/test/kotlin/dev/slne/surf/api/minestom/dialog/search/SearchDialogTest.kt @@ -0,0 +1,84 @@ +package dev.slne.surf.api.minestom.dialog.search + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import net.minestom.server.dialog.Dialog +import net.minestom.server.dialog.DialogAfterAction +import net.minestom.server.dialog.DialogBody +import net.minestom.server.dialog.DialogInput +import net.minestom.testing.EnvTest +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs + +@EnvTest +class SearchDialogTest { + + @Test + fun `a search dialog asks for a single query`() { + val dialog = searchDialog( + title = { text("Search") }, + onSearch = { _, _ -> }, + onClose = { _, _ -> }, + searchInput = { initialValue = "red" }, + ) + + val confirmation = assertIs(dialog) + val input = assertIs(confirmation.metadata().inputs().single()) + + assertEquals("search", input.key()) + assertEquals("red", input.initial()) + } + + @Test + fun `a search dialog keeps its title and body`() { + val dialog = searchDialog( + title = { text("Search") }, + onSearch = { _, _ -> }, + onClose = { _, _ -> }, + body = { plainMessage(Component.text("Who are you looking for?")) }, + searchInput = { }, + ) + + assertEquals("Search", plain(dialog.metadata().title())) + assertEquals( + listOf(DialogBody.PlainMessage(Component.text("Who are you looking for?"), 200)), + dialog.metadata().body() + ) + } + + private fun plain(component: Component) = + PlainTextComponentSerializer.plainText().serialize(component) + + @Test + fun `a search dialog stays open until a button is pressed`() { + val dialog = searchDialog( + title = { text("Search") }, + onSearch = { _, _ -> }, + onClose = { _, _ -> }, + searchInput = { }, + ) + + assertFalse(dialog.metadata().canCloseWithEscape()) + assertEquals(DialogAfterAction.NONE, dialog.metadata().afterAction()) + } + + @Test + fun `the query is collected under the key it was asked for`() { + val dialog = searchDialog( + title = { text("Search") }, + onSearch = { _, _ -> }, + onClose = { _, _ -> }, + searchInput = { + key = "player" + inputModifier = { maxLength(16) } + }, + ) + + val input = assertIs(dialog.metadata().inputs().single()) + + assertEquals("player", input.key()) + assertEquals(16, input.maxLength()) + } +}