Pages: Dialogs

Dialogs

Paper's dialogs are the pop-up screens with text, inputs and buttons. They're great for forms, confirmations and info screens, but the raw API is a pile of builders. SporeAPI wraps it so a dialog is just a few lines:

player.openDialog {
    title("Rename your pet")
    message("Pick a new name for <white>${pet.name}</white>.")

    val name = textField("Name") {
        initial = pet.name
        maxLength = 16
    }
    val glowing = checkbox("Glowing")

    button("Save") { click ->
        pet.rename(click[name])
        pet.isGlowing = click[glowing]
    }
}

All text is MiniMessage, with the same tags as .mm(). Every input gives you back a handle, so click[name] is a String and click[glowing] is a Boolean - no keys to keep track of.

Reusable dialogs

For a dialog you open from more than one place, make it a class or an object. build runs every time it opens, so the content can depend on the player:

object SettingsDialog : SporeDialog() {

    override fun DialogBuilder.build(player: Player) {
        val settings = Settings.of(player)

        title("Settings")
        val volume = slider("Volume", 0..100) { initial = settings.volume }
        val mode = choice("Mode") {
            option("easy", "<green>Easy", selected = settings.mode == "easy")
            option("hard", "<red>Hard", selected = settings.mode == "hard")
        }

        button("Save") { click ->
            settings.volume = click[volume]
            settings.mode = click[mode]
        }
    }

    override fun onClose(player: Player, reason: DialogCloseReason) {
        // saved, cancelled, replaced, left...
    }
}

SettingsDialog.open(player)

val shop = dialog { player -> ... } does the same without a class.

Inputs

InputGives youOptions
textField("Name")Stringinitial, maxLength, width, showLabel, multiline(maxLines, height)
checkbox("Glowing")Booleaninitial
slider("Volume", 0..100)Intinitial, step, width, format
slider("Speed", 0.5f..2f)Floatthe same
choice("Mode") { option(...) }the chosen option's idwidth, showLabel

There's also message("...") for text and item(itemStack, description = "...") for an item in the body.

Buttons

  • button("Save") { click -> } runs your code on the server.
  • commandButton("Spawn", "/spawn") runs a command as the player.
  • linkButton("Website", "https://...") opens a link. The server isn't told about this one.
  • exitButton("Cancel") { } is the footer button, and it's also what Escape does. Dialogs with buttons get a "Close" one by default; noExitButton() removes it, but then closing with Escape can't be noticed.
  • confirm(yes = "Delete", no = "Keep") { click -> } makes a yes/no dialog instead of a button list.
  • A dialog without any buttons is a notice with a single OK button. Change it with okButton("Got it") { }.

columns sets how many buttons fit on a row (2 by default).

Closing

A dialog closes when a button is clicked. Inside a handler you can also send the player somewhere else:

button("Save") { click ->
    if (click[name].isBlank()) {
        click.player.sendMessage("You need a name!")
        click.reopen()
        return@button
    }

    click.open(ConfirmRenameDialog)
}

Set afterClick = AfterClick.KEEP_OPEN to leave it open after a click, or AfterClick.WAIT_FOR_RESPONSE to show a waiting screen while you check something. It closes on its own if your handler doesn't open or reopen anything.

From anywhere else, use SporeDialogs.close(player), SporeDialogs.isOpen(player) and SporeDialogs.current(player).

onClose { reason -> }, or overriding onClose in a class, tells you why it closed: BUTTON, EXIT (the exit button, "no", or Escape), CLOSED, REPLACED or QUIT. Open dialogs are closed when your plugin disables, and errors in your handlers show up as incidents.

Loading dialogs

Some dialogs need data first - a player's profile from the database, a page of punishments. openLoaded shows a loading dialog straight away, runs your loader in a coroutine, and swaps in the real dialog once it's done:

openLoaded(viewer, "<s_blue>$name", "<gray>Loading <white>$name</white>...", "profile.open", back = { LookupDialog() }) {
    val user = withAsyncCtx { UserRepository.findBlocking(uuid.toString()) }
        ?: return@openLoaded noticeDialog("<s_blue>$name", "<s_red>Couldn't load $name's data.") { LookupDialog() }

    ProfileDialog(user)
}
  • The loader starts on the main thread, so do your database work in withAsyncCtx (or through a SuspendCache).
  • If the player clicks Cancel, closes the dialog, opens something else or leaves before it finishes, the result is thrown away instead of popping up over whatever they're doing now.
  • If the loader throws, it becomes an incident under operation, and the player gets a notice with the ref.
  • back is where Cancel and the error notice take the player. Leave it out and they just close the dialog.

noticeDialog(title, text) { back } is the small "message + one button" dialog it uses for errors. It's handy on its own for "No access" or "Not found" screens.