From 77662ef4ad22c3fd7cf77753ca6a769ba0c32d16 Mon Sep 17 00:00:00 2001 From: TheBjoRedCraft Date: Wed, 19 Aug 2026 16:42:50 +0200 Subject: [PATCH 1/2] fix: fix view texture alignment and implement new toggle buttons --- gradle.properties | 2 +- .../surf-api-core/api/surf-api-core.api | 36 ++- .../framework/internal/TextAlignmentMath.kt | 81 +++-- .../framework/internal/ViewButtonStates.kt | 84 +++++ .../internal/ViewContainerRendering.kt | 62 +++- .../internal/ViewButtonSessionTest.kt | 147 +++++++++ .../internal/ViewTitleRenderingTest.kt | 175 +++++++++++ surf-api-minestom/api/surf-api-minestom.api | 67 +++- .../view/PaginatedSurfViewDSLImpl.kt | 5 + .../framework/view/SurfViewContext.kt | 26 ++ .../framework/view/SurfViewDSLImpl.kt | 5 + .../framework/view/button/ViewButton.kt | 279 +++++++++++++++++ .../framework/view/button/ViewButtonDsl.kt | 212 +++++++++++++ .../framework/view/button/ViewButtonHandle.kt | 142 +++++++++ .../framework/view/container/ViewContainer.kt | 11 +- .../component/ViewContainerComponent.kt | 19 ++ .../components/ViewContainerTitleComponent.kt | 45 ++- .../view/settings/align/TextAlignment.kt | 48 +-- .../settings/align/TextAlignmentOptions.kt | 10 +- .../subcommands/inventory/TestButtonView.kt | 122 ++++++++ .../surf-api-paper/api/surf-api-paper.api | 69 +++- .../view/PaginatedSurfViewDSLImpl.kt | 5 + .../framework/view/SurfViewContext.kt | 26 ++ .../framework/view/SurfViewDSLImpl.kt | 5 + .../framework/view/button/ViewButton.kt | 295 ++++++++++++++++++ .../framework/view/button/ViewButtonDsl.kt | 212 +++++++++++++ .../framework/view/button/ViewButtonHandle.kt | 142 +++++++++ .../framework/view/container/ViewContainer.kt | 11 +- .../component/ViewContainerComponent.kt | 19 ++ .../components/ViewContainerTitleComponent.kt | 45 ++- .../view/settings/align/TextAlignment.kt | 48 +-- .../settings/align/TextAlignmentOptions.kt | 10 +- 32 files changed, 2345 insertions(+), 120 deletions(-) create mode 100644 surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewButtonStates.kt create mode 100644 surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewButtonSessionTest.kt create mode 100644 surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewTitleRenderingTest.kt create mode 100644 surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButton.kt create mode 100644 surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonDsl.kt create mode 100644 surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle.kt create mode 100644 surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/inventory/TestButtonView.kt create mode 100644 surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButton.kt create mode 100644 surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonDsl.kt create mode 100644 surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle.kt diff --git a/gradle.properties b/gradle.properties index d492717f9..72f48e733 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.38.1 relocationPrefix=dev.slne.surf.api.libs snapshot=false diff --git a/surf-api-core/surf-api-core/api/surf-api-core.api b/surf-api-core/surf-api-core/api/surf-api-core.api index d39d05d83..bf35222c9 100644 --- a/surf-api-core/surf-api-core/api/surf-api-core.api +++ b/surf-api-core/surf-api-core/api/surf-api-core.api @@ -7244,6 +7244,19 @@ public abstract class dev/slne/surf/api/core/inventory/framework/internal/Pagina public static fun values ()[Ldev/slne/surf/api/core/inventory/framework/internal/PaginationButtonGlyphs; } +public final class dev/slne/surf/api/core/inventory/framework/internal/RenderedViewTitle { + public fun (Ljava/lang/String;I)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()I + public final fun copy (Ljava/lang/String;I)Ldev/slne/surf/api/core/inventory/framework/internal/RenderedViewTitle; + public static synthetic fun copy$default (Ldev/slne/surf/api/core/inventory/framework/internal/RenderedViewTitle;Ljava/lang/String;IILjava/lang/Object;)Ldev/slne/surf/api/core/inventory/framework/internal/RenderedViewTitle; + public fun equals (Ljava/lang/Object;)Z + public final fun getText ()Ljava/lang/String; + public final fun getWidth ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/slne/surf/api/core/inventory/framework/internal/ShiftGlyphs { public static final field INSTANCE Ldev/slne/surf/api/core/inventory/framework/internal/ShiftGlyphs; public final fun renderShift (I)Ljava/lang/String; @@ -7251,15 +7264,30 @@ public final class dev/slne/surf/api/core/inventory/framework/internal/ShiftGlyp public final class dev/slne/surf/api/core/inventory/framework/internal/TextAlignmentMath { public static final field INSTANCE Ldev/slne/surf/api/core/inventory/framework/internal/TextAlignmentMath; - public final fun centerAlignedShift (Ljava/lang/String;IIIII)I + public final fun centerAlignedShift (IIII)I + public final fun charWidth (IILit/unimi/dsi/fastutil/ints/Int2IntMap;)I + public static synthetic fun charWidth$default (Ldev/slne/surf/api/core/inventory/framework/internal/TextAlignmentMath;IILit/unimi/dsi/fastutil/ints/Int2IntMap;ILjava/lang/Object;)I public final fun leftAlignedShift (II)I - public final fun rightAlignedShift (Ljava/lang/String;IIIII)I - public final fun textWidth (Ljava/lang/String;II)I + public final fun rightAlignedShift (IIII)I + public final fun textWidth (Ljava/lang/String;IILit/unimi/dsi/fastutil/ints/Int2IntMap;)I + public static synthetic fun textWidth$default (Ldev/slne/surf/api/core/inventory/framework/internal/TextAlignmentMath;Ljava/lang/String;IILit/unimi/dsi/fastutil/ints/Int2IntMap;ILjava/lang/Object;)I +} + +public final class dev/slne/surf/api/core/inventory/framework/internal/ViewButtonSession { + public fun (Ljava/util/List;Ljava/lang/Object;)V + public final fun advance (I)Z + public final fun getCurrent ()Ljava/lang/Object; + public final fun getCurrentIndex ()I + public final fun getHasChanged ()Z + public final fun getInitial ()Ljava/lang/Object; + public final fun getStates ()Ljava/util/List; + public final fun select (Ljava/lang/Object;)Z } public final class dev/slne/surf/api/core/inventory/framework/internal/ViewContainerRenderingKt { public static final fun appendShiftedComponent (Ldev/slne/surf/api/core/messages/builder/SurfComponentBuilder;IILkotlin/jvm/functions/Function1;)V - public static final fun formatViewTitle (Ljava/lang/String;IZ)Ljava/lang/String; + public static final fun renderViewTitle (Ljava/lang/String;IIZLit/unimi/dsi/fastutil/ints/Int2IntMap;)Ldev/slne/surf/api/core/inventory/framework/internal/RenderedViewTitle; + public static synthetic fun renderViewTitle$default (Ljava/lang/String;IIZLit/unimi/dsi/fastutil/ints/Int2IntMap;ILjava/lang/Object;)Ldev/slne/surf/api/core/inventory/framework/internal/RenderedViewTitle; } public final class dev/slne/surf/api/core/invoker/HiddenInvokerUtil { diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/TextAlignmentMath.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/TextAlignmentMath.kt index c15fd4dc6..ab0c4e384 100644 --- a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/TextAlignmentMath.kt +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/TextAlignmentMath.kt @@ -1,6 +1,8 @@ package dev.slne.surf.api.core.inventory.framework.internal import dev.slne.surf.api.shared.api.util.InternalSurfApi +import it.unimi.dsi.fastutil.ints.Int2IntMap +import it.unimi.dsi.fastutil.ints.Int2IntMaps /** * Shared pixel math behind the per-platform `TextAlignment` enums. @@ -14,22 +16,55 @@ import dev.slne.surf.api.shared.api.util.InternalSurfApi @InternalSurfApi object TextAlignmentMath { + /** + * Returns the rendered pixel width of the glyph for [codePoint]. + * + * Looks [codePoint] up in [charWidths] and falls back to [charSize] when the font renders + * it at the default width. + * + * @param codePoint the Unicode code point to measure + * @param charSize the width in pixels of a single character in the title font + * @param charWidths per-code-point width overrides for glyphs that are not [charSize] wide + */ + fun charWidth( + codePoint: Int, + charSize: Int, + charWidths: Int2IntMap = Int2IntMaps.EMPTY_MAP, + ): Int = if (charWidths.containsKey(codePoint)) charWidths.get(codePoint) else charSize + /** * Computes the total rendered pixel width of [text]. * - * Uses the formula: `text.length * charSize + (text.length - 1) * charSpacing`. - * Returns `0` for an empty string. + * Sums the width of every glyph (see [charWidth]) and adds [charSpacing] between each pair of + * adjacent glyphs. Returns `0` for an empty string. + * + * Measurement is done per Unicode **code point**, not per `Char`, so surrogate pairs count as + * the single glyph they render as. * - * @param text the string to measure + * @param text the string to measure, exactly as it is rendered * @param charSize the width in pixels of a single character * @param charSpacing the inter-character spacing in pixels + * @param charWidths per-code-point width overrides for glyphs that are not [charSize] wide * @return the total pixel width of the text */ - fun textWidth(text: String, charSize: Int, charSpacing: Int): Int { - if (text.isEmpty()) return 0 - val n = text.length + fun textWidth( + text: String, + charSize: Int, + charSpacing: Int, + charWidths: Int2IntMap = Int2IntMaps.EMPTY_MAP, + ): Int { + val glyphs = text.codePointCount(0, text.length) + if (glyphs == 0) return 0 + + var width = (glyphs - 1) * charSpacing + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + width += charWidth(codePoint, charSize, charWidths) + index += Character.charCount(codePoint) + } - return (n * charSize) + ((n - 1) * charSpacing) + return width } /** @@ -41,48 +76,46 @@ object TextAlignmentMath { fun leftAlignedShift(leftShift: Int, padding: Int): Int = leftShift + padding /** - * Pixel shift that places [text] at the right edge of the container area. + * Pixel shift that places a run of [textWidth] pixels at the right edge of the container area. * - * @param text the string whose width is taken into account + * @param textWidth the rendered pixel width of the text, as returned by [textWidth] * @param leftShift the base pixel offset of the container area * @param padding horizontal padding applied on each side within the container * @param containerWidth the total usable pixel width of the container area - * @param charSize the width in pixels of a single character - * @param charSpacing the inter-character spacing in pixels */ fun rightAlignedShift( - text: String, + textWidth: Int, leftShift: Int, padding: Int, containerWidth: Int, - charSize: Int, - charSpacing: Int, ): Int { val usableWidth = containerWidth - (padding * 2) - val freeSpace = usableWidth - textWidth(text, charSize, charSpacing) + val freeSpace = usableWidth - textWidth + return leftShift + freeSpace + 1 + padding } /** - * Pixel shift that centers [text] horizontally within the container area. + * Pixel shift that centers a run of [textWidth] pixels horizontally within the container area. + * + * The free space is halved with [Math.floorDiv] so that a title wider than the container (which + * yields a negative free space) overflows evenly instead of being biased to one side by + * truncation towards zero. * - * @param text the string whose width is taken into account + * @param textWidth the rendered pixel width of the text, as returned by [textWidth] * @param leftShift the base pixel offset of the container area * @param padding horizontal padding applied on each side within the container * @param containerWidth the total usable pixel width of the container area - * @param charSize the width in pixels of a single character - * @param charSpacing the inter-character spacing in pixels */ fun centerAlignedShift( - text: String, + textWidth: Int, leftShift: Int, padding: Int, containerWidth: Int, - charSize: Int, - charSpacing: Int, ): Int { val usableWidth = containerWidth - (padding * 2) - val freeSpace = usableWidth - textWidth(text, charSize, charSpacing) - return leftShift + (freeSpace / 2) + 1 + padding + val freeSpace = usableWidth - textWidth + + return leftShift + Math.floorDiv(freeSpace, 2) + 1 + padding } } diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewButtonStates.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewButtonStates.kt new file mode 100644 index 000000000..68997cea5 --- /dev/null +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewButtonStates.kt @@ -0,0 +1,84 @@ +package dev.slne.surf.api.core.inventory.framework.internal + +import dev.slne.surf.api.shared.api.util.InternalSurfApi + +/** + * Per-viewer state of a stateful inventory button. + * + * A session owns the ordered [states] a button can cycle through, the [initial] value it started + * out with, and the value it currently shows. It is created once per view session (per player) and + * mutated in place whenever the button is clicked, which is what allows the view to tell on close + * whether the button was actually used — see [hasChanged]. + * + * All of the cycling logic lives here so the per-platform button implementations cannot drift + * apart. This is internal infrastructure — use the platform `toggleButton` / `statefulButton` / + * `tripleButton` DSL instead. + * + * @param T the type identifying one button state + * @property states the ordered states the button cycles through; must be non-empty and is not + * copied, so callers must pass an immutable list + * @property initial the state the button started out with for this viewer + */ +@InternalSurfApi +class ViewButtonSession( + val states: List, + val initial: T, +) { + /** Index into [states] of the currently shown state. */ + var currentIndex: Int = states.indexOf(initial) + private set + + init { + require(states.isNotEmpty()) { "A stateful button must declare at least one state" } + require(currentIndex >= 0) { + "The initial value '$initial' is not one of the button's declared states $states" + } + } + + /** The currently shown state. */ + val current: T get() = states[currentIndex] + + /** + * Whether the button is currently showing a state other than the one it started out with. + * + * Note that this compares against [initial] rather than counting clicks, so cycling all the way + * back to the starting state counts as *unchanged*. + */ + val hasChanged: Boolean get() = current != initial + + /** + * Moves [step] states forward (or backward for a negative [step]), wrapping around at both + * ends of [states]. + * + * @param step how many states to move; `0` and buttons with a single state never move + * @return `true` if the current state actually changed + */ + fun advance(step: Int): Boolean { + if (step == 0 || states.size < 2) return false + + val next = Math.floorMod(currentIndex + step, states.size) + if (next == currentIndex) return false + + currentIndex = next + return true + } + + /** + * Jumps directly to [value]. + * + * @param value the state to show; must be one of [states] + * @return `true` if the current state actually changed + * @throws IllegalArgumentException if [value] is not one of [states] + */ + fun select(value: T): Boolean { + val index = states.indexOf(value) + require(index >= 0) { + "The value '$value' is not one of the button's declared states $states" + } + + if (index == currentIndex) return false + + currentIndex = index + return true + } +} diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewContainerRendering.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewContainerRendering.kt index 5e5fb5e06..42d9722af 100644 --- a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewContainerRendering.kt +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewContainerRendering.kt @@ -2,6 +2,8 @@ package dev.slne.surf.api.core.inventory.framework.internal import dev.slne.surf.api.core.messages.builder.SurfComponentBuilder import dev.slne.surf.api.shared.api.util.InternalSurfApi +import it.unimi.dsi.fastutil.ints.Int2IntMap +import it.unimi.dsi.fastutil.ints.Int2IntMaps /** * Appends a single container component to this builder, surrounded by the shift glyphs that @@ -12,6 +14,10 @@ import dev.slne.surf.api.shared.api.util.InternalSurfApi * 2. Emit [renderComponent]. * 3. Shift the cursor back by `-(textureWidth + positionalShift)` pixels. * + * Step 3 only returns the cursor to the container origin if [textureWidth] matches the number of + * pixels [renderComponent] actually advanced it by. Components that cannot guarantee that must + * report `hasExactWidth = false` so the platform `ViewContainer` renders them last. + * * Shared by the per-platform `ViewContainer.render()` implementations. This is internal * infrastructure — use the platform DSL instead. * @@ -38,27 +44,61 @@ fun SurfComponentBuilder.appendShiftedComponent( } /** - * Builds the rendered inventory title string: every character is uppercased and inter-character - * spacing glyphs are inserted between them. + * An inventory title that has been laid out for rendering. + * + * @property text the glyph-interleaved string to emit + * @property width the exact number of pixels [text] advances the render cursor by + */ +@InternalSurfApi +data class RenderedViewTitle(val text: String, val width: Int) + +/** + * Lays out the rendered inventory title: every character is uppercased and inter-character + * spacing glyphs are inserted between them. Unless the title is right-aligned, a leading spacing + * glyph is prepended as well. * - * Unless the title is right-aligned, a leading spacing glyph is prepended as well. + * The returned [RenderedViewTitle.width] is measured on the string that is actually emitted, so it + * accounts for the leading spacing glyph and for uppercasing that changes the glyph count (`ß` + * uppercases to `SS`). Text is walked per Unicode **code point**, so surrogate pairs are neither + * split apart nor counted twice. * * Shared by the per-platform `ViewContainerTitleComponent` implementations. This is internal * infrastructure — use the platform DSL instead. * * @param title the plain-text inventory title + * @param charSize the pixel width of a single character in the title font * @param charSpacing the pixel spacing to insert between each character * @param alignRight `true` when the title is right-aligned - * @return the glyph-interleaved title string + * @param charWidths per-code-point width overrides for glyphs that are not [charSize] wide + * @return the string to render together with its exact pixel width */ @InternalSurfApi -fun formatViewTitle(title: String, charSpacing: Int, alignRight: Boolean): String { - val shifted = title.map { it.uppercase() } - .joinToString(ShiftGlyphs.renderShift(charSpacing)) +fun renderViewTitle( + title: String, + charSize: Int, + charSpacing: Int, + alignRight: Boolean, + charWidths: Int2IntMap = Int2IntMaps.EMPTY_MAP, +): RenderedViewTitle { + val uppercased = title.uppercase() + if (uppercased.isEmpty()) return RenderedViewTitle("", 0) + + val spacing = ShiftGlyphs.renderShift(charSpacing) + val text = buildString { + if (!alignRight) append(spacing) + + var index = 0 + while (index < uppercased.length) { + if (index > 0) append(spacing) - return if (alignRight) { - shifted - } else { - shifted.prependIndent(ShiftGlyphs.renderShift(charSpacing)) + val codePoint = uppercased.codePointAt(index) + appendCodePoint(codePoint) + index += Character.charCount(codePoint) + } } + + val width = TextAlignmentMath.textWidth(uppercased, charSize, charSpacing, charWidths) + + if (alignRight) 0 else charSpacing + + return RenderedViewTitle(text, width) } diff --git a/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewButtonSessionTest.kt b/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewButtonSessionTest.kt new file mode 100644 index 000000000..9c3a86912 --- /dev/null +++ b/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewButtonSessionTest.kt @@ -0,0 +1,147 @@ +package dev.slne.surf.api.core.inventory.framework.internal + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ViewButtonSessionTest { + + private enum class Difficulty { EASY, NORMAL, HARD } + + private fun difficulty(initial: Difficulty = Difficulty.EASY) = + ViewButtonSession(Difficulty.entries.toList(), initial) + + private fun toggle(initial: Boolean = false) = + ViewButtonSession(listOf(false, true), initial) + + @Test + fun `starts on the initial state`() { + val session = difficulty(Difficulty.NORMAL) + + assertEquals(Difficulty.NORMAL, session.current) + assertEquals(Difficulty.NORMAL, session.initial) + assertFalse(session.hasChanged) + } + + @Test + fun `advancing moves forward through the declared order`() { + val session = difficulty() + + assertTrue(session.advance(1)) + assertEquals(Difficulty.NORMAL, session.current) + + assertTrue(session.advance(1)) + assertEquals(Difficulty.HARD, session.current) + } + + @Test + fun `advancing wraps around at both ends`() { + val session = difficulty() + + assertTrue(session.advance(-1)) + assertEquals(Difficulty.HARD, session.current, "stepping back from the first state wraps") + + assertTrue(session.advance(1)) + assertEquals(Difficulty.EASY, session.current, "stepping forward from the last state wraps") + } + + @Test + fun `a full cycle counts as unchanged`() { + val session = difficulty() + + repeat(Difficulty.entries.size) { session.advance(1) } + + assertEquals(Difficulty.EASY, session.current) + assertFalse(session.hasChanged, "cycling back to the initial state is not a change") + } + + @Test + fun `hasChanged reports any state other than the initial one`() { + val session = difficulty() + + session.advance(1) + + assertTrue(session.hasChanged) + } + + @Test + fun `a single state button never moves`() { + val session = ViewButtonSession(listOf(Difficulty.EASY), Difficulty.EASY) + + assertFalse(session.advance(1)) + assertFalse(session.advance(-1)) + assertEquals(Difficulty.EASY, session.current) + assertFalse(session.hasChanged) + } + + @Test + fun `advancing by zero is a no-op`() { + val session = difficulty() + + assertFalse(session.advance(0)) + assertEquals(Difficulty.EASY, session.current) + } + + @Test + fun `selecting jumps straight to a state`() { + val session = difficulty() + + assertTrue(session.select(Difficulty.HARD)) + assertEquals(Difficulty.HARD, session.current) + assertTrue(session.hasChanged) + } + + @Test + fun `selecting the current state reports no change`() { + val session = difficulty() + + assertFalse(session.select(Difficulty.EASY)) + assertEquals(Difficulty.EASY, session.current) + } + + @Test + fun `selecting an undeclared state fails`() { + val session = ViewButtonSession(listOf(Difficulty.EASY, Difficulty.NORMAL), Difficulty.EASY) + + assertFailsWith { session.select(Difficulty.HARD) } + } + + @Test + fun `an initial value outside the declared states fails`() { + assertFailsWith { + ViewButtonSession(listOf(Difficulty.EASY, Difficulty.NORMAL), Difficulty.HARD) + } + } + + @Test + fun `a button without states fails`() { + assertFailsWith { + ViewButtonSession(emptyList(), Difficulty.EASY) + } + } + + @Test + fun `a toggle flips in both click directions`() { + val forward = toggle() + assertTrue(forward.advance(1)) + assertTrue(forward.current) + + val backward = toggle() + assertTrue(backward.advance(-1)) + assertTrue(backward.current, "with two states a right click lands on the same state") + } + + @Test + fun `a toggle switched twice ends up unchanged`() { + val session = toggle(initial = true) + + session.advance(1) + assertTrue(session.hasChanged) + + session.advance(1) + assertTrue(session.current) + assertFalse(session.hasChanged) + } +} diff --git a/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewTitleRenderingTest.kt b/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewTitleRenderingTest.kt new file mode 100644 index 000000000..ae54ead74 --- /dev/null +++ b/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/inventory/framework/internal/ViewTitleRenderingTest.kt @@ -0,0 +1,175 @@ +package dev.slne.surf.api.core.inventory.framework.internal + +import it.unimi.dsi.fastutil.ints.Int2IntMap +import it.unimi.dsi.fastutil.ints.Int2IntMaps +import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Guards the invariant the whole header layout rests on: the width [renderViewTitle] reports must + * equal the number of pixels the string it returns actually advances the render cursor by. + * + * `ViewContainer` resets the cursor with `-(textureWidth + positionalShift)` after every component, + * so any mismatch here shifts every texture that follows the title. + */ +class ViewTitleRenderingTest { + + private val charSize = 9 + private val charSpacing = -1 + + /** Reverse of [ShiftGlyphs.renderShift]: maps each single-glyph shift back to its pixel amount. */ + private val shiftAmounts: Map = buildMap { + for (exponent in 0..9) { + val amount = 1 shl exponent + for (signed in intArrayOf(amount, -amount)) { + val glyph = ShiftGlyphs.renderShift(signed) + check(glyph.length == 1) { "expected a single glyph for $signed, got '$glyph'" } + put(glyph[0], signed) + } + } + } + + /** + * Independently measures how far [text] advances the cursor: shift glyphs contribute their own + * amount, every other code point contributes its glyph width. + */ + private fun advanceOf(text: String, charWidths: Int2IntMap = Int2IntMaps.EMPTY_MAP): Int { + var advance = 0 + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + val shift = if (Character.charCount(codePoint) == 1) { + shiftAmounts[codePoint.toChar()] + } else { + null + } + + advance += shift + ?: if (charWidths.containsKey(codePoint)) charWidths.get(codePoint) else charSize + index += Character.charCount(codePoint) + } + + return advance + } + + private val titles = listOf( + "", + "A", + "Shop", + "Straße", + "Grüße", + "Äpfel", + "Öl", + "Menü", + "Ein sehr langer Titel für das Menü", + "Werkzeug & Rüstung", + "Statistiken 🎉", + "ẞ", + ) + + @Test + fun `reported width matches the rendered advance`() { + for (title in titles) { + for (alignRight in listOf(false, true)) { + val rendered = renderViewTitle(title, charSize, charSpacing, alignRight) + + assertEquals( + advanceOf(rendered.text), + rendered.width, + "width mismatch for '$title' (alignRight=$alignRight)" + ) + } + } + } + + @Test + fun `reported width matches the rendered advance with per-glyph overrides`() { + val charWidths = Int2IntOpenHashMap().apply { + put('Ä'.code, 11) + put('Ü'.code, 11) + put('I'.code, 4) + put(' '.code, 4) + } + + for (title in titles) { + for (alignRight in listOf(false, true)) { + val rendered = renderViewTitle(title, charSize, charSpacing, alignRight, charWidths) + + assertEquals( + advanceOf(rendered.text, charWidths), + rendered.width, + "width mismatch for '$title' (alignRight=$alignRight)" + ) + } + } + } + + @Test + fun `eszett is measured as the two glyphs it uppercases to`() { + // "Straße" renders as STRASSE: 7 glyphs, 6 gaps, plus the leading spacing glyph. + val rendered = renderViewTitle("Straße", charSize, charSpacing, alignRight = false) + + assertEquals((7 * charSize) + (6 * charSpacing) + charSpacing, rendered.width) + } + + @Test + fun `right aligned titles omit the leading spacing glyph`() { + val left = renderViewTitle("Straße", charSize, charSpacing, alignRight = false) + val right = renderViewTitle("Straße", charSize, charSpacing, alignRight = true) + + assertEquals(left.width - charSpacing, right.width) + } + + @Test + fun `empty titles render nothing`() { + val rendered = renderViewTitle("", charSize, charSpacing, alignRight = false) + + assertEquals("", rendered.text) + assertEquals(0, rendered.width) + } + + @Test + fun `surrogate pairs count as a single glyph`() { + val rendered = renderViewTitle("🎉", charSize, charSpacing, alignRight = true) + + assertEquals(charSize, rendered.width) + } + + @Test + fun `text width is measured per code point`() { + // Two code points, one of which is a surrogate pair — four UTF-16 chars in total. + assertEquals( + (2 * charSize) + charSpacing, + TextAlignmentMath.textWidth("A🎉", charSize, charSpacing) + ) + } + + @Test + fun `centered titles stay centred whatever they contain`() { + // Geometry of the real header container, see ViewContainerTitleComponent. + val leftShift = 31 + val padding = 2 + val containerWidth = 100 + + for (title in titles) { + val rendered = renderViewTitle(title, charSize, charSpacing, alignRight = false) + val shift = TextAlignmentMath.centerAlignedShift( + rendered.width, + leftShift, + padding, + containerWidth + ) + + // Doubled to compare half-pixel centres in integer math. + val textCentre = (2 * shift) + rendered.width + val containerCentre = (2 * leftShift) + containerWidth + + assertTrue( + (textCentre - containerCentre) in -2..2, + "'$title' is off centre by ${(textCentre - containerCentre) / 2.0}px" + ) + } + } +} diff --git a/surf-api-minestom/api/surf-api-minestom.api b/surf-api-minestom/api/surf-api-minestom.api index b38f96d75..dbfecb1d7 100644 --- a/surf-api-minestom/api/surf-api-minestom.api +++ b/surf-api-minestom/api/surf-api-minestom.api @@ -660,6 +660,51 @@ public final class dev/slne/surf/api/minestom/inventory/framework/view/SurfViewR public static final fun getView (Ldev/slne/surf/api/minestom/inventory/framework/view/SurfViewRef;)Ldev/slne/surf/api/minestom/inventory/framework/view/SurfViewDSLImpl; } +public final class dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonBuilder { + public final fun onChange (Lkotlin/jvm/functions/Function3;)V + public final fun onCloseChanged (Lkotlin/jvm/functions/Function3;)V + public final fun reverseOnRightClick (Z)V + public static synthetic fun reverseOnRightClick$default (Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonBuilder;ZILjava/lang/Object;)V + public final fun state (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun state$default (Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonBuilder;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V +} + +public final class dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonDslKt { + public static final fun computedStatefulButton (Ldev/slne/surf/api/minestom/inventory/framework/view/AbstractSurfViewContext;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle; + public static final fun computedToggleButton (Ldev/slne/surf/api/minestom/inventory/framework/view/AbstractSurfViewContext;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle; + public static final fun computedTripleButton (Ldev/slne/surf/api/minestom/inventory/framework/view/AbstractSurfViewContext;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle; + public static final fun statefulButton (Ldev/slne/surf/api/minestom/inventory/framework/view/AbstractSurfViewContext;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle; + public static final fun toggleButton (Ldev/slne/surf/api/minestom/inventory/framework/view/AbstractSurfViewContext;ZLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle; + public static synthetic fun toggleButton$default (Ldev/slne/surf/api/minestom/inventory/framework/view/AbstractSurfViewContext;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle; + public static final fun tripleButton (Ldev/slne/surf/api/minestom/inventory/framework/view/AbstractSurfViewContext;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle; +} + +public final class dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle { + public final fun get (Lme/devnatan/inventoryframework/context/Context;)Ljava/lang/Object; + public final fun hasChanged (Lme/devnatan/inventoryframework/context/Context;)Z + public final fun initial (Lme/devnatan/inventoryframework/context/Context;)Ljava/lang/Object; + public final fun set (Lme/devnatan/inventoryframework/context/Context;Ljava/lang/Object;)V +} + +public final class dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandleKt { + public static final fun button (Lme/devnatan/inventoryframework/component/MinestomItemComponentBuilder;Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle;)Lme/devnatan/inventoryframework/component/MinestomItemComponentBuilder; +} + +public final class dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonStateScope { + public final fun getValue ()Ljava/lang/Object; + public final fun item (Lnet/minestom/server/item/Material;ILkotlin/jvm/functions/Function1;)V + public static synthetic fun item$default (Ldev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonStateScope;Lnet/minestom/server/item/Material;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public final fun onEnter (Lkotlin/jvm/functions/Function2;)V + public final fun renderItem (Lkotlin/jvm/functions/Function1;)V +} + +public final class dev/slne/surf/api/minestom/inventory/framework/view/button/ViewToggleButtonBuilder { + public final fun onCloseChanged (Lkotlin/jvm/functions/Function3;)V + public final fun onToggle (Lkotlin/jvm/functions/Function2;)V + public final fun whenOff (Lkotlin/jvm/functions/Function1;)V + public final fun whenOn (Lkotlin/jvm/functions/Function1;)V +} + public final class dev/slne/surf/api/minestom/inventory/framework/view/container/ViewContainer { public fun ()V public final fun addChild (Ldev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent;)V @@ -672,16 +717,22 @@ public final class dev/slne/surf/api/minestom/inventory/framework/view/container public abstract interface class dev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent { public abstract fun equals (Ljava/lang/Object;)Z + public fun getHasExactWidth ()Z public abstract fun getPositionalShift ()I public abstract fun getTextureWidth ()I public abstract fun hashCode ()I public abstract fun renderComponent (Ldev/slne/surf/api/core/messages/builder/SurfComponentBuilder;)V } +public final class dev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent$DefaultImpls { + public static fun getHasExactWidth (Ldev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent;)Z +} + public final class dev/slne/surf/api/minestom/inventory/framework/view/container/component/components/ViewBlockCellComponent : dev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent { public fun (II)V public fun equals (Ljava/lang/Object;)Z public final fun getColumn ()I + public fun getHasExactWidth ()Z public fun getPositionalShift ()I public final fun getRow ()I public fun getTextureWidth ()I @@ -692,6 +743,7 @@ public final class dev/slne/surf/api/minestom/inventory/framework/view/container public final class dev/slne/surf/api/minestom/inventory/framework/view/container/component/components/ViewContainerBackHintComponent : dev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent { public static final field INSTANCE Ldev/slne/surf/api/minestom/inventory/framework/view/container/component/components/ViewContainerBackHintComponent; public fun equals (Ljava/lang/Object;)Z + public fun getHasExactWidth ()Z public fun getPositionalShift ()I public fun getTextureWidth ()I public fun hashCode ()I @@ -706,8 +758,10 @@ public final class dev/slne/surf/api/minestom/inventory/framework/view/container public static final field Companion Ldev/slne/surf/api/minestom/inventory/framework/view/container/component/components/ViewContainerTitleComponent$Companion; public static final field LEFT_SHIFT I public static final field PADDING I - public fun (Ljava/lang/String;Lnet/kyori/adventure/key/Key;ILdev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment;)V + public fun (Ljava/lang/String;Lnet/kyori/adventure/key/Key;ILdev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment;Lit/unimi/dsi/fastutil/ints/Int2IntMap;)V + public synthetic fun (Ljava/lang/String;Lnet/kyori/adventure/key/Key;ILdev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment;Lit/unimi/dsi/fastutil/ints/Int2IntMap;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun equals (Ljava/lang/Object;)Z + public fun getHasExactWidth ()Z public fun getPositionalShift ()I public fun getTextureWidth ()I public fun hashCode ()I @@ -1012,7 +1066,7 @@ public abstract class dev/slne/surf/api/minestom/inventory/framework/view/settin public static final field Companion Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment$Companion; public static final field LEFT Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment; public static final field RIGHT Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment; - public abstract fun calculateShift (Ljava/lang/String;Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions;)I + public abstract fun calculateShift (ILdev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions;)I public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment; public static fun values ()[Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment; @@ -1023,17 +1077,20 @@ public final class dev/slne/surf/api/minestom/inventory/framework/view/settings/ } public final class dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions { - public fun (IIIII)V + public fun (IIIIILit/unimi/dsi/fastutil/ints/Int2IntMap;)V + public synthetic fun (IIIIILit/unimi/dsi/fastutil/ints/Int2IntMap;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2 ()I public final fun component3 ()I public final fun component4 ()I public final fun component5 ()I - public final fun copy (IIIII)Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions; - public static synthetic fun copy$default (Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions;IIIIIILjava/lang/Object;)Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions; + public final fun component6 ()Lit/unimi/dsi/fastutil/ints/Int2IntMap; + public final fun copy (IIIIILit/unimi/dsi/fastutil/ints/Int2IntMap;)Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions; + public static synthetic fun copy$default (Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions;IIIIILit/unimi/dsi/fastutil/ints/Int2IntMap;ILjava/lang/Object;)Ldev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions; public fun equals (Ljava/lang/Object;)Z public final fun getCharSize ()I public final fun getCharSpacing ()I + public final fun getCharWidths ()Lit/unimi/dsi/fastutil/ints/Int2IntMap; public final fun getContainerWidth ()I public final fun getLeftShift ()I public final fun getPadding ()I diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/PaginatedSurfViewDSLImpl.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/PaginatedSurfViewDSLImpl.kt index be13a2de2..25f1bd7da 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/PaginatedSurfViewDSLImpl.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/PaginatedSurfViewDSLImpl.kt @@ -62,6 +62,11 @@ abstract class PaginatedSurfViewDSLImpl @PublishedApi internal constructor( override fun onPaginatedClose(close: CloseContext) { ctx.onClose?.invoke(ref, close) + + // Stateful buttons persist on close, so skip them when the view refused to close. + if (close.isCancelled) return + + ctx.fireButtonCloseHandlers(close) } context(modificationCtx: ViewContainerModificationContext) diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/SurfViewContext.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/SurfViewContext.kt index 179f88443..c56298dbd 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/SurfViewContext.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/SurfViewContext.kt @@ -53,6 +53,32 @@ abstract class AbstractSurfViewContext @Published @PublishedApi internal var containerDefaults: (context (ViewContainerModificationContext, ViewRef) () -> Unit)? = null + + private val buttonCloseHandlers = mutableListOf<(CloseContext) -> Unit>() + + /** + * Registers a close hook for a stateful button declared in this view's DSL block. + * + * Called by the button factories in + * [ViewButtonDsl][dev.slne.surf.api.minestom.inventory.framework.view.button.statefulButton]; the + * hooks are fired by [fireButtonCloseHandlers]. + */ + internal fun registerButtonCloseHandler(handler: (CloseContext) -> Unit) { + buttonCloseHandlers.add(handler) + } + + /** + * Fires the close hooks of every stateful button declared in this view's DSL block, so that + * buttons whose state differs from the one they started out with can persist it. + * + * Invoked by the view implementations after the view's own `onClose` callback ran and only if + * that callback did not cancel the close. + */ + internal fun fireButtonCloseHandlers(close: CloseContext) { + for (handler in buttonCloseHandlers) { + handler(close) + } + } } /** diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/SurfViewDSLImpl.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/SurfViewDSLImpl.kt index 439214182..ff72febdb 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/SurfViewDSLImpl.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/SurfViewDSLImpl.kt @@ -57,6 +57,11 @@ abstract class SurfViewDSLImpl @PublishedApi internal constructor( override fun onViewClose(close: CloseContext) { ctx.onClose?.invoke(ref, close) + + // Stateful buttons persist on close, so skip them when the view refused to close. + if (close.isCancelled) return + + ctx.fireButtonCloseHandlers(close) } context(modificationCtx: ViewContainerModificationContext) diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButton.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButton.kt new file mode 100644 index 000000000..8ba300f51 --- /dev/null +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButton.kt @@ -0,0 +1,279 @@ +package dev.slne.surf.api.minestom.inventory.framework.view.button + +import dev.slne.surf.api.minestom.builder.ItemDsl +import dev.slne.surf.api.minestom.builder.ItemDslMarker +import dev.slne.surf.api.minestom.builder.buildItem +import dev.slne.surf.api.minestom.inventory.framework.view.InventoryFrameworkDSL +import me.devnatan.inventoryframework.context.CloseContext +import me.devnatan.inventoryframework.context.SlotClickContext +import me.devnatan.inventoryframework.context.SlotRenderContext +import net.minestom.server.item.ItemStack +import net.minestom.server.item.Material + +/** + * One state of a stateful button: the value it represents, how it looks, and what happens when the + * button switches into it. + * + * Built through the `state { }` / `whenOn { }` / `whenOff { }` blocks of [ViewButtonBuilder] and + * [ViewToggleButtonBuilder]. + * + * @param T the type identifying the button's states + * @property value the value this state represents + */ +@InventoryFrameworkDSL +class ViewButtonStateScope internal constructor(val value: T) { + private var itemFactory: (SlotRenderContext.() -> ItemStack)? = null + private var enterHandler: (SlotClickContext.(from: T) -> Unit)? = null + + /** + * Renders this state as [material]. + * + * The item is rebuilt on every render, so [init] may read from the surrounding context. + * + * ```kotlin + * whenOn { + * item(Material.LIME_DYE) { displayName { primary("Sound: an") } } + * } + * ``` + * + * @param material the [Material] shown while the button is in this state + * @param amount the stack size; defaults to `1` + * @param init customization block applied to the [ItemDsl] building the item + */ + fun item( + material: Material, + amount: Int = 1, + init: (@ItemDslMarker ItemDsl).() -> Unit = {} + ) { + itemFactory = { buildItem(material, amount, init) } + } + + /** + * Renders this state with a fully custom factory that receives the [SlotRenderContext]. + * + * Use this when the appearance depends on the viewer or on other view state. + * + * ```kotlin + * state(Difficulty.HARD) { + * renderItem { viewIcon(ViewIconType.SWORD, ViewIconColor.RED) { amount = player.level } } + * } + * ``` + * + * @param factory builds the [ItemStack] shown while the button is in this state + */ + fun renderItem(factory: @InventoryFrameworkDSL SlotRenderContext.() -> ItemStack) { + itemFactory = factory + } + + /** + * Runs [action] whenever the button switches **into** this state. + * + * The previously shown state is passed as `from`. Not called for the state the button starts + * out in, only for actual transitions. + * + * ```kotlin + * whenOff { + * item(Material.GRAY_DYE) + * onEnter { player.playSound(...) } + * } + * ``` + * + * @param action the callback invoked with the [SlotClickContext] of the triggering click + */ + fun onEnter(action: @InventoryFrameworkDSL SlotClickContext.(from: T) -> Unit) { + enterHandler = action + } + + internal fun build(): ViewButtonStateSpec { + val factory = checkNotNull(itemFactory) { + "Button state '$value' has no appearance. " + + "Declare one with item(...) { } or renderItem { }." + } + + return ViewButtonStateSpec(value, factory, enterHandler) + } +} + +/** Immutable per-state configuration produced by [ViewButtonStateScope]. */ +internal class ViewButtonStateSpec( + val value: T, + val itemFactory: SlotRenderContext.() -> ItemStack, + val enterHandler: (SlotClickContext.(from: T) -> Unit)?, +) + +/** Immutable button configuration produced by [ViewButtonBuilder]. */ +internal class ViewButtonSpec( + val states: List>, + val values: List, + val changeHandler: (SlotClickContext.(from: T, to: T) -> Unit)?, + val closeChangedHandler: (CloseContext.(initial: T, current: T) -> Unit)?, + val reverseOnRightClick: Boolean, +) + +/** + * Builder for a button that cycles through an arbitrary number of states. + * + * States are cycled in declaration order: a left click moves one state forward and wraps around at + * the end, a right click moves one state backward unless [reverseOnRightClick] is disabled. + * + * ```kotlin + * val difficulty = statefulButton(Difficulty.EASY) { + * state(Difficulty.EASY) { item(Material.LIME_DYE) { displayName { primary("Einfach") } } } + * state(Difficulty.NORMAL) { item(Material.YELLOW_DYE) { displayName { primary("Normal") } } } + * state(Difficulty.HARD) { item(Material.RED_DYE) { displayName { primary("Schwer") } } } + * + * onChange { from, to -> player.sendMessage("$from -> $to") } + * onCloseChanged { initial, current -> repository.save(player, current) } + * } + * ``` + * + * @param T the type identifying the button's states + * @see dev.slne.surf.api.minestom.inventory.framework.view.button.statefulButton + */ +@InventoryFrameworkDSL +class ViewButtonBuilder internal constructor() { + private val scopes = mutableListOf>() + private var changeHandler: (SlotClickContext.(from: T, to: T) -> Unit)? = null + private var closeChangedHandler: (CloseContext.(initial: T, current: T) -> Unit)? = null + private var reverseOnRightClickEnabled = true + + /** + * Declares the state [value] and configures its appearance and enter callback. + * + * Declaration order is the cycle order. Declaring the same [value] twice reconfigures the + * existing state rather than adding a second one. + * + * @param value the value this state represents + * @param block configuration block applied to the state's [ViewButtonStateScope] + */ + fun state(value: T, block: ViewButtonStateScope.() -> Unit = {}) { + scopeOf(value).apply(block) + } + + internal fun scopeOf(value: T): ViewButtonStateScope = + scopes.firstOrNull { it.value == value } + ?: ViewButtonStateScope(value).also { scopes.add(it) } + + /** + * Runs [action] on every state change, regardless of which state was entered. + * + * Runs after the per-state [ViewButtonStateScope.onEnter] callback. + * + * @param action the callback invoked with the [SlotClickContext] of the triggering click + */ + fun onChange(action: @InventoryFrameworkDSL SlotClickContext.(from: T, to: T) -> Unit) { + changeHandler = action + } + + /** + * Runs [action] when the view is closed **and** the button ends up on a different state than + * the one it started out with. + * + * This is the hook for persisting what the player configured. It is not called when the button + * was never used, nor when it was cycled all the way back to its initial state, and it is + * skipped entirely if the view's own `onClose` callback cancelled the close. + * + * ```kotlin + * onCloseChanged { initial, current -> + * settingsRepository.update(player, current) + * } + * ``` + * + * @param action the callback invoked with the [CloseContext], the initial and the final state + */ + fun onCloseChanged(action: @InventoryFrameworkDSL CloseContext.(initial: T, current: T) -> Unit) { + closeChangedHandler = action + } + + /** + * Controls whether a right click cycles backwards through the states. + * + * Enabled by default. Disable it to make every click move forward. + * + * @param enabled `true` to cycle backwards on right click + */ + fun reverseOnRightClick(enabled: Boolean = true) { + reverseOnRightClickEnabled = enabled + } + + internal fun build(): ViewButtonSpec { + check(scopes.isNotEmpty()) { + "A stateful button must declare at least one state using state(...) { }" + } + + val states = scopes.map { it.build() } + + return ViewButtonSpec( + states = states, + values = states.map { it.value }, + changeHandler = changeHandler, + closeChangedHandler = closeChangedHandler, + reverseOnRightClick = reverseOnRightClickEnabled, + ) + } +} + +/** + * Builder for an on/off button — a [ViewButtonBuilder] specialised to `Boolean` with a fixed + * `off -> on -> off` cycle, so the declaration order of [whenOn] and [whenOff] does not matter. + * + * ```kotlin + * val sound = toggleButton(initial = true) { + * whenOn { item(Material.NOTE_BLOCK) { displayName { primary("Sound: an") } } } + * whenOff { item(Material.BARRIER) { displayName { primary("Sound: aus") } } } + * + * onToggle { enabled -> player.sendMessage("Sound ${if (enabled) "an" else "aus"}") } + * onCloseChanged { _, current -> repository.saveSound(player, current) } + * } + * ``` + * + * @see dev.slne.surf.api.minestom.inventory.framework.view.button.toggleButton + */ +@InventoryFrameworkDSL +class ViewToggleButtonBuilder internal constructor( + private val delegate: ViewButtonBuilder +) { + init { + // Pin the cycle order to off -> on so it does not depend on which block is declared first. + delegate.state(false) + delegate.state(true) + } + + /** + * Configures how the button looks and behaves while it is **off**. + * + * @param block configuration block applied to the off state's [ViewButtonStateScope] + */ + fun whenOff(block: ViewButtonStateScope.() -> Unit) { + delegate.state(false, block) + } + + /** + * Configures how the button looks and behaves while it is **on**. + * + * @param block configuration block applied to the on state's [ViewButtonStateScope] + */ + fun whenOn(block: ViewButtonStateScope.() -> Unit) { + delegate.state(true, block) + } + + /** + * Runs [action] whenever the button is toggled, passing the new value. + * + * @param action the callback invoked with the [SlotClickContext] of the triggering click + */ + fun onToggle(action: @InventoryFrameworkDSL SlotClickContext.(enabled: Boolean) -> Unit) { + delegate.onChange { _, to -> action(this, to) } + } + + /** + * Runs [action] when the view is closed and the button ends up on the opposite value of the + * one it started out with. + * + * @param action the callback invoked with the [CloseContext], the initial and the final value + * @see ViewButtonBuilder.onCloseChanged + */ + fun onCloseChanged(action: @InventoryFrameworkDSL CloseContext.(initial: Boolean, current: Boolean) -> Unit) { + delegate.onCloseChanged(action) + } +} diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonDsl.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonDsl.kt new file mode 100644 index 000000000..91825065a --- /dev/null +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonDsl.kt @@ -0,0 +1,212 @@ +package dev.slne.surf.api.minestom.inventory.framework.view.button + +import dev.slne.surf.api.core.inventory.framework.internal.ViewButtonSession +import dev.slne.surf.api.minestom.inventory.framework.view.AbstractSurfViewContext +import dev.slne.surf.api.minestom.inventory.framework.view.state.lazyState +import me.devnatan.inventoryframework.context.Context + +/** + * Declares a button that cycles through an arbitrary number of states, starting at [initial]. + * + * Must be called inside a `surfView { }` or `paginatedSurfView { }` block; place the returned + * handle into a slot with [button][me.devnatan.inventoryframework.component.MinestomItemComponentBuilder.button]. + * Every viewer gets their own copy of the button's value. + * + * ```kotlin + * surfView("Einstellungen") { + * val difficulty = statefulButton(Difficulty.EASY) { + * state(Difficulty.EASY) { item(Material.LIME_DYE) { displayName { primary("Einfach") } } } + * state(Difficulty.NORMAL) { item(Material.YELLOW_DYE) { displayName { primary("Normal") } } } + * state(Difficulty.HARD) { item(Material.RED_DYE) { displayName { primary("Schwer") } } } + * + * onChange { from, to -> player.sendMessage("$from -> $to") } + * onCloseChanged { _, current -> repository.save(player, current) } + * } + * + * onFirstRender { slot(1, 4) { button(difficulty) } } + * } + * ``` + * + * @param T the type identifying the button's states + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial the state every viewer starts on; must be one of the declared states + * @param block configuration block applied to the [ViewButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @see computedStatefulButton + * @see toggleButton + * @see tripleButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun statefulButton( + initial: T, + block: ViewButtonBuilder.() -> Unit +): ViewButtonHandle = computedStatefulButton({ initial }, block) + +/** + * Declares a button that cycles through an arbitrary number of states, resolving its starting + * state per viewer. + * + * Use this over [statefulButton] whenever the button reflects something already stored for the + * player, so that "changed" on close means "changed relative to what the player had". + * + * ```kotlin + * val difficulty = computedStatefulButton({ context -> repository.difficultyOf(context.player) }) { + * state(Difficulty.EASY) { item(Material.LIME_DYE) } + * state(Difficulty.NORMAL) { item(Material.YELLOW_DYE) } + * state(Difficulty.HARD) { item(Material.RED_DYE) } + * + * onCloseChanged { _, current -> repository.save(player, current) } + * } + * ``` + * + * @param T the type identifying the button's states + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial resolves the state a viewer starts on; called once per view session + * @param block configuration block applied to the [ViewButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @see statefulButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun computedStatefulButton( + initial: (Context) -> T, + block: ViewButtonBuilder.() -> Unit +): ViewButtonHandle = registerButton(ViewButtonBuilder().apply(block).build(), initial) + +/** + * Declares an on/off button starting at [initial]. + * + * A specialisation of [statefulButton] for the common two-state case, with a fixed `off -> on` + * cycle and `whenOn` / `whenOff` blocks instead of `state(...)`. + * + * ```kotlin + * surfView("Einstellungen") { + * val sound = toggleButton(initial = true) { + * whenOn { item(Material.NOTE_BLOCK) { displayName { primary("Sound: an") } } } + * whenOff { item(Material.BARRIER) { displayName { primary("Sound: aus") } } } + * + * onToggle { enabled -> player.sendMessage("Sound ${if (enabled) "an" else "aus"}") } + * onCloseChanged { _, current -> repository.saveSound(player, current) } + * } + * + * onFirstRender { slot(1, 1) { button(sound) } } + * } + * ``` + * + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial whether the button starts out on; defaults to `false` + * @param block configuration block applied to the [ViewToggleButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @see computedToggleButton + * @see statefulButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun toggleButton( + initial: Boolean = false, + block: ViewToggleButtonBuilder.() -> Unit +): ViewButtonHandle = computedToggleButton({ initial }, block) + +/** + * Declares an on/off button that resolves whether it starts out on per viewer. + * + * ```kotlin + * val sound = computedToggleButton({ context -> settings.soundEnabled(context.player) }) { + * whenOn { item(Material.NOTE_BLOCK) } + * whenOff { item(Material.BARRIER) } + * + * onCloseChanged { _, current -> settings.setSoundEnabled(player, current) } + * } + * ``` + * + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial resolves whether a viewer starts out on; called once per view session + * @param block configuration block applied to the [ViewToggleButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @see toggleButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun computedToggleButton( + initial: (Context) -> Boolean, + block: ViewToggleButtonBuilder.() -> Unit +): ViewButtonHandle { + val delegate = ViewButtonBuilder() + ViewToggleButtonBuilder(delegate).apply(block) + + return registerButton(delegate.build(), initial) +} + +/** + * Declares a button with exactly three states, starting at [initial]. + * + * Behaves exactly like [statefulButton] but rejects a configuration that does not declare three + * states, so a missing or surplus `state(...)` block fails when the view is built instead of + * silently shipping a two- or four-state button. + * + * ```kotlin + * val visibility = tripleButton(Visibility.ALL) { + * state(Visibility.ALL) { item(Material.LIME_DYE) { displayName { primary("Alle") } } } + * state(Visibility.FRIENDS) { item(Material.YELLOW_DYE) { displayName { primary("Freunde") } } } + * state(Visibility.NONE) { item(Material.RED_DYE) { displayName { primary("Niemand") } } } + * + * onCloseChanged { _, current -> repository.save(player, current) } + * } + * ``` + * + * @param T the type identifying the button's states + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial the state every viewer starts on; must be one of the three declared states + * @param block configuration block applied to the [ViewButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @throws IllegalArgumentException if [block] does not declare exactly three states + * @see computedTripleButton + * @see statefulButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun tripleButton( + initial: T, + block: ViewButtonBuilder.() -> Unit +): ViewButtonHandle = computedTripleButton({ initial }, block) + +/** + * Declares a button with exactly three states that resolves its starting state per viewer. + * + * @param T the type identifying the button's states + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial resolves the state a viewer starts on; called once per view session + * @param block configuration block applied to the [ViewButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @throws IllegalArgumentException if [block] does not declare exactly three states + * @see tripleButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun computedTripleButton( + initial: (Context) -> T, + block: ViewButtonBuilder.() -> Unit +): ViewButtonHandle { + val spec = ViewButtonBuilder().apply(block).build() + + require(spec.values.size == 3) { + "A triple button must declare exactly three states, but ${spec.values.size} " + + "were declared: ${spec.values}" + } + + return registerButton(spec, initial) +} + +/** + * Allocates the per-session state for [spec] and registers its close hook on the view context. + * + * The session is a lazy state, so [initial] is resolved once per view session on first access and + * the value survives re-renders — which is what lets the close hook compare against it. + */ +context(ctx: AbstractSurfViewContext<*>) +private fun registerButton( + spec: ViewButtonSpec, + initial: (Context) -> T +): ViewButtonHandle { + val sessionState = lazyState { context -> ViewButtonSession(spec.values, initial(context)) } + val handle = ViewButtonHandle(sessionState, spec) + + ctx.registerButtonCloseHandler { close -> handle.handleClose(close) } + + return handle +} diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle.kt new file mode 100644 index 000000000..2ec207966 --- /dev/null +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/button/ViewButtonHandle.kt @@ -0,0 +1,142 @@ +package dev.slne.surf.api.minestom.inventory.framework.view.button + +import dev.slne.surf.api.core.inventory.framework.internal.ViewButtonSession +import dev.slne.surf.api.minestom.inventory.framework.view.state.StateHandle +import dev.slne.surf.api.minestom.inventory.framework.view.state.get +import me.devnatan.inventoryframework.component.MinestomItemComponentBuilder +import me.devnatan.inventoryframework.context.CloseContext +import me.devnatan.inventoryframework.context.Context +import me.devnatan.inventoryframework.context.SlotClickContext +import me.devnatan.inventoryframework.context.SlotRenderContext +import me.devnatan.inventoryframework.state.State + +/** + * A handle to a stateful button declared in a view's DSL block. + * + * The handle is returned by [statefulButton], [toggleButton] and [tripleButton] and is what + * connects the button's configuration to an actual inventory slot: place it with + * [MinestomItemComponentBuilder.button] inside a render callback. + * + * The button's value is stored per view session, so every viewer cycles their own copy. Read and + * write it from any [Context] with the indexed operators: + * + * ```kotlin + * val sound = toggleButton(initial = true) { ... } + * + * onFirstRender { + * slot(1, 1) { button(sound) } + * slot(1, 3) { + * withItem(Material.PAPER) + * onItemClick { player.sendMessage("Sound ist ${sound[this]}") } + * } + * } + * ``` + * + * @param T the type identifying the button's states + * @see statefulButton + * @see toggleButton + * @see tripleButton + */ +class ViewButtonHandle internal constructor( + private val sessionState: StateHandle>>, + private val spec: ViewButtonSpec, +) { + private fun session(context: Context): ViewButtonSession = sessionState[context] + + /** + * Reads the state this button currently shows for [context]'s viewer. + * + * @param context the current [Context] + * @return the currently shown state + */ + operator fun get(context: Context): T = session(context).current + + /** + * Jumps this button to [value] for [context]'s viewer. + * + * Neither the per-state [ViewButtonStateScope.onEnter] nor [ViewButtonBuilder.onChange] + * callbacks fire — those are reserved for actual clicks — and the slot is not re-rendered. + * Call [Context.update][me.devnatan.inventoryframework.context.IFContext.update] afterwards to + * make the change visible. + * + * @param context the current [Context] + * @param value the state to jump to; must be one of the button's declared states + * @throws IllegalArgumentException if [value] is not a declared state + */ + operator fun set(context: Context, value: T) { + session(context).select(value) + } + + /** + * Returns the state this button started out with for [context]'s viewer. + * + * @param context the current [Context] + * @return the initial state + */ + fun initial(context: Context): T = session(context).initial + + /** + * Returns whether this button currently shows a different state than the one it started out + * with for [context]'s viewer. + * + * @param context the current [Context] + * @return `true` if the button was used to move to another state + */ + fun hasChanged(context: Context): Boolean = session(context).hasChanged + + internal fun renderInto(context: SlotRenderContext) { + val session = session(context) + context.item = spec.states[session.currentIndex].itemFactory(context) + } + + internal fun handleClick(context: SlotClickContext) { + val session = session(context) + val step = if (spec.reverseOnRightClick && context.isRightClick) -1 else 1 + + val from = session.current + if (!session.advance(step)) return + val to = session.current + + // Repaint before the callbacks run: they are allowed to navigate away, which would leave + // nothing sensible to update afterwards. + context.component?.update() + + spec.states[session.currentIndex].enterHandler?.invoke(context, from) + spec.changeHandler?.invoke(context, from, to) + } + + internal fun handleClose(context: CloseContext) { + val handler = spec.closeChangedHandler ?: return + val session = session(context) + + if (!session.hasChanged) return + + handler(context, session.initial, session.current) + } +} + +/** + * Places [handle]'s button in this slot. + * + * Wires up the three things a stateful button needs: it renders the appearance of the currently + * shown state, cycles to the next state on click, and cancels the click so the item cannot be + * picked up. + * + * ```kotlin + * onFirstRender { + * slot(1, 1) { button(sound) } + * slot(1, 3) { button(difficulty) } + * } + * ``` + * + * @receiver the [MinestomItemComponentBuilder] of the slot the button should occupy + * @param handle the button declared in the view's DSL block + * @return this builder for chaining + */ +fun MinestomItemComponentBuilder.button(handle: ViewButtonHandle): MinestomItemComponentBuilder { + cancelOnClick() + onRender { context -> handle.renderInto(context) } + onClick { context -> handle.handleClick(context) } + + return this +} diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/ViewContainer.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/ViewContainer.kt index f9a56feca..f7707d6f7 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/ViewContainer.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/ViewContainer.kt @@ -13,7 +13,9 @@ import java.util.concurrent.CopyOnWriteArrayList * Components are stored in a [CopyOnWriteArrayList] to allow concurrent iteration and * modification. Duplicate components (by [equals]/[hashCode]) are silently ignored on * [addChild]. The [render] method builds the final Adventure [Component][net.kyori.adventure.text.Component] - * by iterating each child and applying its positional shift glyphs around its visual. + * by iterating each child and applying its positional shift glyphs around its visual. Children with + * [ViewContainerComponent.hasExactWidth] `false` are rendered last so their inexact cursor reset + * cannot displace any other component. * * This class is `@PublishedApi internal` — it is not part of the public API. Use * [ViewContainerModificationContext] and the DSL helpers in `ViewContainerDSL.kt` instead. @@ -51,7 +53,12 @@ internal class ViewContainer { } fun render() = buildText { - for (component in children) { + // Components that only estimate their textureWidth leave the cursor slightly off the + // container origin, so render them after every exact one instead of letting the following + // components inherit that drift. partition() keeps the relative insertion order of both. + val (exact, estimated) = children.partition { it.hasExactWidth } + + for (component in exact + estimated) { append { appendShiftedComponent(component.positionalShift, component.textureWidth) { with(component) { renderComponent() } diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent.kt index ed91a9f38..e53a552f9 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/component/ViewContainerComponent.kt @@ -15,18 +15,37 @@ import dev.slne.surf.api.core.messages.builder.SurfComponentBuilder * 3. Shift the cursor back by `-(textureWidth + positionalShift)` pixels to reset the position * for the next component. * + * Step 3 only lands back on the container origin when [textureWidth] equals the number of pixels + * [renderComponent] actually advanced the cursor by; otherwise every following component inherits + * the difference. Components that cannot guarantee an exact width must report [hasExactWidth] as + * `false`, which makes [ViewContainer] render them after all exact ones. + * * All implementations must correctly override [equals] and [hashCode] so that * [CopyOnWriteArrayList.addIfAbsent][java.util.concurrent.CopyOnWriteArrayList.addIfAbsent] * works as expected in [ViewContainer]. * * @property positionalShift the pixel offset applied before rendering the component * @property textureWidth the width in pixels of the rendered texture; used to calculate the reset shift + * @property hasExactWidth whether [textureWidth] is guaranteed to match the rendered advance * @see ViewContainer */ interface ViewContainerComponent { val positionalShift: Int val textureWidth: Int + /** + * Whether [textureWidth] is guaranteed to equal the number of pixels this component advances + * the render cursor by. + * + * Components backed by a fixed resource-pack texture know their width exactly and keep the + * default `true`. Components that render arbitrary text can only derive their width from the + * font metrics they were configured with, so a character the font renders at an unexpected + * width (or does not cover at all, falling back to the client's default font) makes the value + * an estimate. Those return `false` and [ViewContainer] renders them last, so a mis-measured + * title can never displace another component's texture. + */ + val hasExactWidth: Boolean get() = true + /** * Emits this component's visual into the given [SurfComponentBuilder]. * diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/component/components/ViewContainerTitleComponent.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/component/components/ViewContainerTitleComponent.kt index 7d86f3b5e..05c54b8d9 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/component/components/ViewContainerTitleComponent.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/container/component/components/ViewContainerTitleComponent.kt @@ -1,19 +1,24 @@ package dev.slne.surf.api.minestom.inventory.framework.view.container.component.components -import dev.slne.surf.api.core.inventory.framework.internal.formatViewTitle +import dev.slne.surf.api.core.inventory.framework.internal.renderViewTitle import dev.slne.surf.api.core.messages.Colors import dev.slne.surf.api.core.messages.builder.SurfComponentBuilder import dev.slne.surf.api.minestom.inventory.framework.view.container.component.ViewContainerComponent import dev.slne.surf.api.minestom.inventory.framework.view.settings.align.TextAlignment import dev.slne.surf.api.minestom.inventory.framework.view.settings.align.TextAlignmentOptions +import it.unimi.dsi.fastutil.ints.Int2IntMap +import it.unimi.dsi.fastutil.ints.Int2IntMaps import net.kyori.adventure.key.Key /** * A [ViewContainerComponent] that renders the inventory title text using a custom font. * * The title string is converted to uppercase and inter-character spacing glyphs are inserted - * between each letter using [formatViewTitle]. The horizontal position is calculated from the - * [textAlignment] so that the text is positioned correctly within the background texture. + * between each letter using [renderViewTitle]. That same call reports the exact pixel width of the + * string it produced, which is used both to position the text according to [textAlignment] and to + * reset the render cursor afterwards — measuring the raw title instead would drift as soon as + * uppercasing changes the glyph count (`ß` uppercases to `SS`) or the leading spacing glyph is + * emitted. * * The component uses constants from its companion object to define the geometry of the * container area: @@ -27,26 +32,44 @@ import net.kyori.adventure.key.Key * @param font the Adventure [Key] identifying the resource-pack font to use * @param charSpacing the pixel spacing to insert between each character * @param textAlignment the [TextAlignment] controlling horizontal positioning + * @param charWidths per-code-point pixel width overrides for glyphs the [font] does not render at + * [CHAR_SIZE] pixels; see [TextAlignmentOptions.charWidths] */ class ViewContainerTitleComponent( title: String, private val font: Key, charSpacing: Int, - textAlignment: TextAlignment + textAlignment: TextAlignment, + charWidths: Int2IntMap = Int2IntMaps.EMPTY_MAP ) : ViewContainerComponent { - private val formattedTitle: String = - formatViewTitle(title, charSpacing, textAlignment == TextAlignment.RIGHT) - private val alignmentOptions = TextAlignmentOptions( leftShift = LEFT_SHIFT, padding = PADDING, containerWidth = CONTAINER_WIDTH, charSize = CHAR_SIZE, - charSpacing = charSpacing + charSpacing = charSpacing, + charWidths = charWidths + ) + + private val rendered = renderViewTitle( + title = title, + charSize = CHAR_SIZE, + charSpacing = charSpacing, + alignRight = textAlignment == TextAlignment.RIGHT, + charWidths = charWidths ) - override val positionalShift = textAlignment.calculateShift(title, alignmentOptions) - override val textureWidth = TextAlignment.calculateTextWidth(title, alignmentOptions) + private val formattedTitle = rendered.text + + override val textureWidth = rendered.width + override val positionalShift = textAlignment.calculateShift(rendered.width, alignmentOptions) + + /** + * `false`: the width is derived from the configured font metrics, so a glyph the font renders + * at an unexpected width would make it an estimate. Keeps the title from displacing the header + * textures — see [ViewContainerComponent.hasExactWidth]. + */ + override val hasExactWidth = false override fun SurfComponentBuilder.renderComponent() { text(formattedTitle) @@ -84,4 +107,4 @@ class ViewContainerTitleComponent( /** Default inter-character spacing (negative = tighter). */ const val CHAR_SPACING = -1 } -} \ No newline at end of file +} diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment.kt index 704492c62..be47ccfc8 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignment.kt @@ -8,8 +8,9 @@ import dev.slne.surf.api.core.inventory.framework.internal.TextAlignmentMath * Each entry implements [calculateShift] to return the pixel offset that positions the text * at the correct horizontal position given the container geometry described by [TextAlignmentOptions]. * - * The companion object provides [calculateTextWidth] which computes the total pixel width of a - * text string based on the character size and spacing configured in [TextAlignmentOptions]. + * Alignment is driven by the **rendered** pixel width of the title rather than by its character + * count, so uppercasing, per-character width overrides and the leading spacing glyph are all + * accounted for. Use [calculateTextWidth] to measure a string that is rendered verbatim. * * @see TextAlignmentOptions * @see dev.slne.surf.api.minestom.api.inventory.framework.view.settings.SurfViewSettings.headerTextAlignment @@ -19,7 +20,7 @@ enum class TextAlignment { * Aligns the title to the left edge of the container area (plus [TextAlignmentOptions.padding]). */ LEFT { - override fun calculateShift(text: String, options: TextAlignmentOptions): Int = + override fun calculateShift(textWidth: Int, options: TextAlignmentOptions): Int = TextAlignmentMath.leftAlignedShift(options.leftShift, options.padding) }, @@ -28,15 +29,13 @@ enum class TextAlignment { */ RIGHT { override fun calculateShift( - text: String, + textWidth: Int, options: TextAlignmentOptions ): Int = TextAlignmentMath.rightAlignedShift( - text, + textWidth, options.leftShift, options.padding, - options.containerWidth, - options.charSize, - options.charSpacing + options.containerWidth ) }, @@ -45,40 +44,47 @@ enum class TextAlignment { */ CENTER { override fun calculateShift( - text: String, + textWidth: Int, options: TextAlignmentOptions ): Int = TextAlignmentMath.centerAlignedShift( - text, + textWidth, options.leftShift, options.padding, - options.containerWidth, - options.charSize, - options.charSpacing + options.containerWidth ) }; /** - * Calculates the pixel shift (offset from the left edge) required to place [text] at this alignment - * within the container described by [options]. + * Calculates the pixel shift (offset from the left edge) required to place a text run of + * [textWidth] pixels at this alignment within the container described by [options]. * - * @param text the title string whose width is taken into account + * @param textWidth the rendered pixel width of the title, as measured by [calculateTextWidth] * @param options the container geometry options * @return the pixel shift value to apply before rendering the text */ - abstract fun calculateShift(text: String, options: TextAlignmentOptions): Int + abstract fun calculateShift(textWidth: Int, options: TextAlignmentOptions): Int companion object { /** * Computes the total rendered pixel width of [text] given [options]. * - * Uses the formula: `text.length * charSize + (text.length - 1) * charSpacing`. - * Returns `0` for an empty string. + * Sums the per-glyph widths from [TextAlignmentOptions.charWidths] (falling back to + * [TextAlignmentOptions.charSize]) and adds [TextAlignmentOptions.charSpacing] between + * adjacent glyphs. Measured per Unicode code point. Returns `0` for an empty string. + * + * [text] must be the string as it is actually rendered — measuring a string that is + * transformed before rendering (for example uppercased) yields the wrong width. * * @param text the string to measure * @param options the character size and spacing options * @return the total pixel width of the text */ fun calculateTextWidth(text: String, options: TextAlignmentOptions): Int = - TextAlignmentMath.textWidth(text, options.charSize, options.charSpacing) + TextAlignmentMath.textWidth( + text, + options.charSize, + options.charSpacing, + options.charWidths + ) } -} \ No newline at end of file +} diff --git a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions.kt b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions.kt index 984d21c17..3c3ebf99c 100644 --- a/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions.kt +++ b/surf-api-minestom/src/main/kotlin/dev/slne/surf/api/minestom/inventory/framework/view/settings/align/TextAlignmentOptions.kt @@ -1,5 +1,8 @@ package dev.slne.surf.api.minestom.inventory.framework.view.settings.align +import it.unimi.dsi.fastutil.ints.Int2IntMap +import it.unimi.dsi.fastutil.ints.Int2IntMaps + /** * Encapsulates the geometric parameters of the inventory title container area * used by [TextAlignment.calculateShift] and [TextAlignment.calculateTextWidth]. @@ -10,6 +13,10 @@ package dev.slne.surf.api.minestom.inventory.framework.view.settings.align * @property containerWidth the total usable pixel width of the title container area * @property charSize the width in pixels of a single character in the title font * @property charSpacing the inter-character spacing in pixels (negative = tighter, positive = looser) + * @property charWidths per-code-point pixel width overrides, keyed by Unicode code point, for the + * glyphs the title font does not render at [charSize] pixels. Empty by default, which treats the + * font as fixed-width. Populate it for a proportional title font, or for characters the font does + * not cover and that therefore fall back to the client's default font at a different width. * @see TextAlignment */ data class TextAlignmentOptions( @@ -17,5 +24,6 @@ data class TextAlignmentOptions( val padding: Int, val containerWidth: Int, val charSize: Int, - val charSpacing: Int + val charSpacing: Int, + val charWidths: Int2IntMap = Int2IntMaps.EMPTY_MAP ) diff --git a/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/inventory/TestButtonView.kt b/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/inventory/TestButtonView.kt new file mode 100644 index 000000000..dfe0a2a93 --- /dev/null +++ b/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/inventory/TestButtonView.kt @@ -0,0 +1,122 @@ +package dev.slne.surf.surfapi.bukkit.test.command.subcommands.inventory + +import dev.slne.surf.api.core.messages.adventure.text +import dev.slne.surf.api.paper.builder.buildItem +import dev.slne.surf.api.paper.builder.displayName +import dev.slne.surf.api.paper.inventory.framework.dsl.slot +import dev.slne.surf.api.paper.inventory.framework.dsl.withItem +import dev.slne.surf.api.paper.inventory.framework.view.button.button +import dev.slne.surf.api.paper.inventory.framework.view.button.computedToggleButton +import dev.slne.surf.api.paper.inventory.framework.view.button.statefulButton +import dev.slne.surf.api.paper.inventory.framework.view.button.toggleButton +import dev.slne.surf.api.paper.inventory.framework.view.button.tripleButton +import dev.slne.surf.api.paper.inventory.framework.view.onFirstRender +import dev.slne.surf.api.paper.inventory.framework.view.surfView +import org.bukkit.inventory.ItemType + +/** Sichtbarkeitsstufen für den Tripple-Button. */ +enum class TestVisibility { ALL, FRIENDS, NONE } + +/** Schwierigkeitsgrade für den Multi-State-Button. */ +enum class TestDifficulty { EASY, NORMAL, HARD, INSANE } + +/** + * In-memory stand-in for whatever would normally persist a player's settings. + */ +object TestSettingsStore { + private val sounds = mutableMapOf() + + fun soundEnabled(player: String) = sounds.getOrDefault(player, true) + fun setSoundEnabled(player: String, enabled: Boolean) { + sounds[player] = enabled + } +} + +val testButtonView = surfView("Einstellungen") { + + // An/Aus-Button mit fest vorgegebenem Startwert. + val particles = toggleButton(initial = true) { + whenOn { + item(ItemType.LIME_DYE) { displayName { text("Partikel: an") } } + onEnter { player.sendMessage(text("Partikel eingeschaltet")) } + } + whenOff { + item(ItemType.GRAY_DYE) { displayName { text("Partikel: aus") } } + } + + onToggle { enabled -> player.sendMessage(text("Partikel: $enabled")) } + onCloseChanged { initial, current -> + player.sendMessage(text("Partikel von $initial auf $current geändert")) + } + } + + // An/Aus-Button, dessen Startwert pro Spieler geladen wird. + val sound = computedToggleButton({ context -> + TestSettingsStore.soundEnabled(context.player.name) + }) { + whenOn { item(ItemType.NOTE_BLOCK) { displayName { text("Sound: an") } } } + whenOff { item(ItemType.BARRIER) { displayName { text("Sound: aus") } } } + + onCloseChanged { _, current -> + TestSettingsStore.setSoundEnabled(player.name, current) + player.sendMessage(text("Sound gespeichert: $current")) + } + } + + // Tripple-Button: genau drei Stufen. + val visibility = tripleButton(TestVisibility.ALL) { + state(TestVisibility.ALL) { + item(ItemType.LIME_DYE) { displayName { text("Sichtbar für alle") } } + } + state(TestVisibility.FRIENDS) { + item(ItemType.YELLOW_DYE) { displayName { text("Sichtbar für Freunde") } } + } + state(TestVisibility.NONE) { + item(ItemType.RED_DYE) { displayName { text("Für niemanden sichtbar") } } + } + + onCloseChanged { initial, current -> + player.sendMessage(text("Sichtbarkeit: $initial -> $current")) + } + } + + // Beliebig viele Stufen, Rechtsklick rückwärts ist hier abgeschaltet. + val difficulty = statefulButton(TestDifficulty.NORMAL) { + reverseOnRightClick(false) + + state(TestDifficulty.EASY) { item(ItemType.WHITE_WOOL) { displayName { text("Einfach") } } } + state(TestDifficulty.NORMAL) { item(ItemType.YELLOW_WOOL) { displayName { text("Normal") } } } + state(TestDifficulty.HARD) { item(ItemType.ORANGE_WOOL) { displayName { text("Schwer") } } } + state(TestDifficulty.INSANE) { + // Vollständig dynamisches Aussehen. + renderItem { + buildItem(ItemType.RED_WOOL, 1) { + displayName { text("Wahnsinn (${player.name})") } + } + } + } + + onChange { from, to -> player.sendMessage(text("Schwierigkeit $from -> $to")) } + onCloseChanged { _, current -> player.sendMessage(text("Gespeichert: $current")) } + } + + onFirstRender { + slot(1, 1) { button(particles) } + slot(1, 3) { button(sound) } + slot(1, 5) { button(visibility) } + slot(1, 7) { button(difficulty) } + + // Buttonwerte lassen sich aus jedem Context lesen. + slot(3, 4) { + withItem(ItemType.PAPER) { displayName { text("Übersicht") } } + onClick { click -> + click.player.sendMessage( + text( + "Partikel=${particles[click]}, Sound=${sound[click]}, " + + "Sicht=${visibility[click]}, Schwierigkeit=${difficulty[click]}" + ) + ) + } + } + } +} diff --git a/surf-api-paper/surf-api-paper/api/surf-api-paper.api b/surf-api-paper/surf-api-paper/api/surf-api-paper.api index c748205ac..5cd6ac69e 100644 --- a/surf-api-paper/surf-api-paper/api/surf-api-paper.api +++ b/surf-api-paper/surf-api-paper/api/surf-api-paper.api @@ -1117,6 +1117,53 @@ public final class dev/slne/surf/api/paper/inventory/framework/view/SurfViewRefK public static final fun getView (Ldev/slne/surf/api/paper/inventory/framework/view/SurfViewRef;)Ldev/slne/surf/api/paper/inventory/framework/view/SurfViewDSLImpl; } +public final class dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonBuilder { + public final fun onChange (Lkotlin/jvm/functions/Function3;)V + public final fun onCloseChanged (Lkotlin/jvm/functions/Function3;)V + public final fun reverseOnRightClick (Z)V + public static synthetic fun reverseOnRightClick$default (Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonBuilder;ZILjava/lang/Object;)V + public final fun state (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun state$default (Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonBuilder;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V +} + +public final class dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonDslKt { + public static final fun computedStatefulButton (Ldev/slne/surf/api/paper/inventory/framework/view/AbstractSurfViewContext;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle; + public static final fun computedToggleButton (Ldev/slne/surf/api/paper/inventory/framework/view/AbstractSurfViewContext;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle; + public static final fun computedTripleButton (Ldev/slne/surf/api/paper/inventory/framework/view/AbstractSurfViewContext;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle; + public static final fun statefulButton (Ldev/slne/surf/api/paper/inventory/framework/view/AbstractSurfViewContext;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle; + public static final fun toggleButton (Ldev/slne/surf/api/paper/inventory/framework/view/AbstractSurfViewContext;ZLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle; + public static synthetic fun toggleButton$default (Ldev/slne/surf/api/paper/inventory/framework/view/AbstractSurfViewContext;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle; + public static final fun tripleButton (Ldev/slne/surf/api/paper/inventory/framework/view/AbstractSurfViewContext;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle; +} + +public final class dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle { + public final fun get (Lme/devnatan/inventoryframework/context/Context;)Ljava/lang/Object; + public final fun hasChanged (Lme/devnatan/inventoryframework/context/Context;)Z + public final fun initial (Lme/devnatan/inventoryframework/context/Context;)Ljava/lang/Object; + public final fun set (Lme/devnatan/inventoryframework/context/Context;Ljava/lang/Object;)V +} + +public final class dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandleKt { + public static final fun button (Lme/devnatan/inventoryframework/component/BukkitItemComponentBuilder;Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle;)Lme/devnatan/inventoryframework/component/BukkitItemComponentBuilder; +} + +public final class dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonStateScope { + public final fun getValue ()Ljava/lang/Object; + public final fun item (Lorg/bukkit/Material;ILkotlin/jvm/functions/Function1;)V + public final fun item (Lorg/bukkit/inventory/ItemType;ILkotlin/jvm/functions/Function1;)V + public static synthetic fun item$default (Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonStateScope;Lorg/bukkit/Material;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun item$default (Ldev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonStateScope;Lorg/bukkit/inventory/ItemType;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public final fun onEnter (Lkotlin/jvm/functions/Function2;)V + public final fun renderItem (Lkotlin/jvm/functions/Function1;)V +} + +public final class dev/slne/surf/api/paper/inventory/framework/view/button/ViewToggleButtonBuilder { + public final fun onCloseChanged (Lkotlin/jvm/functions/Function3;)V + public final fun onToggle (Lkotlin/jvm/functions/Function2;)V + public final fun whenOff (Lkotlin/jvm/functions/Function1;)V + public final fun whenOn (Lkotlin/jvm/functions/Function1;)V +} + public final class dev/slne/surf/api/paper/inventory/framework/view/container/ViewContainer { public fun ()V public final fun addChild (Ldev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent;)V @@ -1129,16 +1176,22 @@ public final class dev/slne/surf/api/paper/inventory/framework/view/container/Vi public abstract interface class dev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent { public abstract fun equals (Ljava/lang/Object;)Z + public fun getHasExactWidth ()Z public abstract fun getPositionalShift ()I public abstract fun getTextureWidth ()I public abstract fun hashCode ()I public abstract fun renderComponent (Ldev/slne/surf/api/core/messages/builder/SurfComponentBuilder;)V } +public final class dev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent$DefaultImpls { + public static fun getHasExactWidth (Ldev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent;)Z +} + public final class dev/slne/surf/api/paper/inventory/framework/view/container/component/components/ViewBlockCellComponent : dev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent { public fun (II)V public fun equals (Ljava/lang/Object;)Z public final fun getColumn ()I + public fun getHasExactWidth ()Z public fun getPositionalShift ()I public final fun getRow ()I public fun getTextureWidth ()I @@ -1149,6 +1202,7 @@ public final class dev/slne/surf/api/paper/inventory/framework/view/container/co public final class dev/slne/surf/api/paper/inventory/framework/view/container/component/components/ViewContainerBackHintComponent : dev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent { public static final field INSTANCE Ldev/slne/surf/api/paper/inventory/framework/view/container/component/components/ViewContainerBackHintComponent; public fun equals (Ljava/lang/Object;)Z + public fun getHasExactWidth ()Z public fun getPositionalShift ()I public fun getTextureWidth ()I public fun hashCode ()I @@ -1163,8 +1217,10 @@ public final class dev/slne/surf/api/paper/inventory/framework/view/container/co public static final field Companion Ldev/slne/surf/api/paper/inventory/framework/view/container/component/components/ViewContainerTitleComponent$Companion; public static final field LEFT_SHIFT I public static final field PADDING I - public fun (Ljava/lang/String;Lnet/kyori/adventure/key/Key;ILdev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment;)V + public fun (Ljava/lang/String;Lnet/kyori/adventure/key/Key;ILdev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment;Lit/unimi/dsi/fastutil/ints/Int2IntMap;)V + public synthetic fun (Ljava/lang/String;Lnet/kyori/adventure/key/Key;ILdev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment;Lit/unimi/dsi/fastutil/ints/Int2IntMap;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun equals (Ljava/lang/Object;)Z + public fun getHasExactWidth ()Z public fun getPositionalShift ()I public fun getTextureWidth ()I public fun hashCode ()I @@ -1471,7 +1527,7 @@ public abstract class dev/slne/surf/api/paper/inventory/framework/view/settings/ public static final field Companion Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment$Companion; public static final field LEFT Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment; public static final field RIGHT Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment; - public abstract fun calculateShift (Ljava/lang/String;Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions;)I + public abstract fun calculateShift (ILdev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions;)I public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment; public static fun values ()[Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment; @@ -1482,17 +1538,20 @@ public final class dev/slne/surf/api/paper/inventory/framework/view/settings/ali } public final class dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions { - public fun (IIIII)V + public fun (IIIIILit/unimi/dsi/fastutil/ints/Int2IntMap;)V + public synthetic fun (IIIIILit/unimi/dsi/fastutil/ints/Int2IntMap;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2 ()I public final fun component3 ()I public final fun component4 ()I public final fun component5 ()I - public final fun copy (IIIII)Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions; - public static synthetic fun copy$default (Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions;IIIIIILjava/lang/Object;)Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions; + public final fun component6 ()Lit/unimi/dsi/fastutil/ints/Int2IntMap; + public final fun copy (IIIIILit/unimi/dsi/fastutil/ints/Int2IntMap;)Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions; + public static synthetic fun copy$default (Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions;IIIIILit/unimi/dsi/fastutil/ints/Int2IntMap;ILjava/lang/Object;)Ldev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions; public fun equals (Ljava/lang/Object;)Z public final fun getCharSize ()I public final fun getCharSpacing ()I + public final fun getCharWidths ()Lit/unimi/dsi/fastutil/ints/Int2IntMap; public final fun getContainerWidth ()I public final fun getLeftShift ()I public final fun getPadding ()I diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/PaginatedSurfViewDSLImpl.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/PaginatedSurfViewDSLImpl.kt index 62e680669..2eff18202 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/PaginatedSurfViewDSLImpl.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/PaginatedSurfViewDSLImpl.kt @@ -62,6 +62,11 @@ abstract class PaginatedSurfViewDSLImpl @PublishedApi internal constructor( override fun onPaginatedClose(close: CloseContext) { ctx.onClose?.invoke(ref, close) + + // Stateful buttons persist on close, so skip them when the view refused to close. + if (close.isCancelled) return + + ctx.fireButtonCloseHandlers(close) } context(modificationCtx: ViewContainerModificationContext) diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/SurfViewContext.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/SurfViewContext.kt index 33c6aa88b..c0cc8e156 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/SurfViewContext.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/SurfViewContext.kt @@ -53,6 +53,32 @@ abstract class AbstractSurfViewContext @Published @PublishedApi internal var containerDefaults: (context (ViewContainerModificationContext, ViewRef) () -> Unit)? = null + + private val buttonCloseHandlers = mutableListOf<(CloseContext) -> Unit>() + + /** + * Registers a close hook for a stateful button declared in this view's DSL block. + * + * Called by the button factories in + * [ViewButtonDsl][dev.slne.surf.api.paper.inventory.framework.view.button.statefulButton]; the + * hooks are fired by [fireButtonCloseHandlers]. + */ + internal fun registerButtonCloseHandler(handler: (CloseContext) -> Unit) { + buttonCloseHandlers.add(handler) + } + + /** + * Fires the close hooks of every stateful button declared in this view's DSL block, so that + * buttons whose state differs from the one they started out with can persist it. + * + * Invoked by the view implementations after the view's own `onClose` callback ran and only if + * that callback did not cancel the close. + */ + internal fun fireButtonCloseHandlers(close: CloseContext) { + for (handler in buttonCloseHandlers) { + handler(close) + } + } } /** diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/SurfViewDSLImpl.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/SurfViewDSLImpl.kt index bcab65bf1..3f6d22ab9 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/SurfViewDSLImpl.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/SurfViewDSLImpl.kt @@ -57,6 +57,11 @@ abstract class SurfViewDSLImpl @PublishedApi internal constructor( override fun onViewClose(close: CloseContext) { ctx.onClose?.invoke(ref, close) + + // Stateful buttons persist on close, so skip them when the view refused to close. + if (close.isCancelled) return + + ctx.fireButtonCloseHandlers(close) } context(modificationCtx: ViewContainerModificationContext) diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButton.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButton.kt new file mode 100644 index 000000000..d8d95d144 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButton.kt @@ -0,0 +1,295 @@ +package dev.slne.surf.api.paper.inventory.framework.view.button + +import dev.slne.surf.api.paper.builder.ItemDsl +import dev.slne.surf.api.paper.builder.buildItem +import dev.slne.surf.api.paper.inventory.framework.view.InventoryFrameworkDSL +import me.devnatan.inventoryframework.context.CloseContext +import me.devnatan.inventoryframework.context.SlotClickContext +import me.devnatan.inventoryframework.context.SlotRenderContext +import org.bukkit.Material +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.ItemType + +/** + * One state of a stateful button: the value it represents, how it looks, and what happens when the + * button switches into it. + * + * Built through the `state { }` / `whenOn { }` / `whenOff { }` blocks of [ViewButtonBuilder] and + * [ViewToggleButtonBuilder]. + * + * @param T the type identifying the button's states + * @property value the value this state represents + */ +@InventoryFrameworkDSL +class ViewButtonStateScope internal constructor(val value: T) { + private var itemFactory: (SlotRenderContext.() -> ItemStack)? = null + private var enterHandler: (SlotClickContext.(from: T) -> Unit)? = null + + /** + * Renders this state as [material]. + * + * The item is rebuilt on every render, so [init] may read from the surrounding context. + * + * ```kotlin + * whenOn { + * item(Material.LIME_DYE) { displayName { primary("Sound: an") } } + * } + * ``` + * + * @param material the [Material] shown while the button is in this state + * @param amount the stack size; defaults to `1` + * @param init customization block applied to the freshly built [ItemStack] + */ + fun item( + material: Material, + amount: Int = 1, + init: (@ItemDsl ItemStack).() -> Unit = {} + ) { + itemFactory = { buildItem(material, amount, init) } + } + + /** + * Renders this state as [type]. + * + * @param type the [ItemType] shown while the button is in this state + * @param amount the stack size; defaults to `1` + * @param init customization block applied to the freshly built [ItemStack] + * @see item + */ + fun item( + type: ItemType, + amount: Int = 1, + init: (@ItemDsl ItemStack).() -> Unit = {} + ) { + itemFactory = { buildItem(type, amount, init) } + } + + /** + * Renders this state with a fully custom factory that receives the [SlotRenderContext]. + * + * Use this when the appearance depends on the viewer or on other view state. + * + * ```kotlin + * state(Difficulty.HARD) { + * renderItem { viewIcon(ViewIconType.SWORD, ViewIconColor.RED) { amount = player.level } } + * } + * ``` + * + * @param factory builds the [ItemStack] shown while the button is in this state + */ + fun renderItem(factory: @InventoryFrameworkDSL SlotRenderContext.() -> ItemStack) { + itemFactory = factory + } + + /** + * Runs [action] whenever the button switches **into** this state. + * + * The previously shown state is passed as `from`. Not called for the state the button starts + * out in, only for actual transitions. + * + * ```kotlin + * whenOff { + * item(Material.GRAY_DYE) + * onEnter { player.playSound(...) } + * } + * ``` + * + * @param action the callback invoked with the [SlotClickContext] of the triggering click + */ + fun onEnter(action: @InventoryFrameworkDSL SlotClickContext.(from: T) -> Unit) { + enterHandler = action + } + + internal fun build(): ViewButtonStateSpec { + val factory = checkNotNull(itemFactory) { + "Button state '$value' has no appearance. " + + "Declare one with item(...) { } or renderItem { }." + } + + return ViewButtonStateSpec(value, factory, enterHandler) + } +} + +/** Immutable per-state configuration produced by [ViewButtonStateScope]. */ +internal class ViewButtonStateSpec( + val value: T, + val itemFactory: SlotRenderContext.() -> ItemStack, + val enterHandler: (SlotClickContext.(from: T) -> Unit)?, +) + +/** Immutable button configuration produced by [ViewButtonBuilder]. */ +internal class ViewButtonSpec( + val states: List>, + val values: List, + val changeHandler: (SlotClickContext.(from: T, to: T) -> Unit)?, + val closeChangedHandler: (CloseContext.(initial: T, current: T) -> Unit)?, + val reverseOnRightClick: Boolean, +) + +/** + * Builder for a button that cycles through an arbitrary number of states. + * + * States are cycled in declaration order: a left click moves one state forward and wraps around at + * the end, a right click moves one state backward unless [reverseOnRightClick] is disabled. + * + * ```kotlin + * val difficulty = statefulButton(Difficulty.EASY) { + * state(Difficulty.EASY) { item(Material.LIME_DYE) { displayName { primary("Einfach") } } } + * state(Difficulty.NORMAL) { item(Material.YELLOW_DYE) { displayName { primary("Normal") } } } + * state(Difficulty.HARD) { item(Material.RED_DYE) { displayName { primary("Schwer") } } } + * + * onChange { from, to -> player.sendMessage("$from -> $to") } + * onCloseChanged { initial, current -> repository.save(player, current) } + * } + * ``` + * + * @param T the type identifying the button's states + * @see dev.slne.surf.api.paper.inventory.framework.view.button.statefulButton + */ +@InventoryFrameworkDSL +class ViewButtonBuilder internal constructor() { + private val scopes = mutableListOf>() + private var changeHandler: (SlotClickContext.(from: T, to: T) -> Unit)? = null + private var closeChangedHandler: (CloseContext.(initial: T, current: T) -> Unit)? = null + private var reverseOnRightClickEnabled = true + + /** + * Declares the state [value] and configures its appearance and enter callback. + * + * Declaration order is the cycle order. Declaring the same [value] twice reconfigures the + * existing state rather than adding a second one. + * + * @param value the value this state represents + * @param block configuration block applied to the state's [ViewButtonStateScope] + */ + fun state(value: T, block: ViewButtonStateScope.() -> Unit = {}) { + scopeOf(value).apply(block) + } + + internal fun scopeOf(value: T): ViewButtonStateScope = + scopes.firstOrNull { it.value == value } + ?: ViewButtonStateScope(value).also { scopes.add(it) } + + /** + * Runs [action] on every state change, regardless of which state was entered. + * + * Runs after the per-state [ViewButtonStateScope.onEnter] callback. + * + * @param action the callback invoked with the [SlotClickContext] of the triggering click + */ + fun onChange(action: @InventoryFrameworkDSL SlotClickContext.(from: T, to: T) -> Unit) { + changeHandler = action + } + + /** + * Runs [action] when the view is closed **and** the button ends up on a different state than + * the one it started out with. + * + * This is the hook for persisting what the player configured. It is not called when the button + * was never used, nor when it was cycled all the way back to its initial state, and it is + * skipped entirely if the view's own `onClose` callback cancelled the close. + * + * ```kotlin + * onCloseChanged { initial, current -> + * settingsRepository.update(player, current) + * } + * ``` + * + * @param action the callback invoked with the [CloseContext], the initial and the final state + */ + fun onCloseChanged(action: @InventoryFrameworkDSL CloseContext.(initial: T, current: T) -> Unit) { + closeChangedHandler = action + } + + /** + * Controls whether a right click cycles backwards through the states. + * + * Enabled by default. Disable it to make every click move forward. + * + * @param enabled `true` to cycle backwards on right click + */ + fun reverseOnRightClick(enabled: Boolean = true) { + reverseOnRightClickEnabled = enabled + } + + internal fun build(): ViewButtonSpec { + check(scopes.isNotEmpty()) { + "A stateful button must declare at least one state using state(...) { }" + } + + val states = scopes.map { it.build() } + + return ViewButtonSpec( + states = states, + values = states.map { it.value }, + changeHandler = changeHandler, + closeChangedHandler = closeChangedHandler, + reverseOnRightClick = reverseOnRightClickEnabled, + ) + } +} + +/** + * Builder for an on/off button — a [ViewButtonBuilder] specialised to `Boolean` with a fixed + * `off -> on -> off` cycle, so the declaration order of [whenOn] and [whenOff] does not matter. + * + * ```kotlin + * val sound = toggleButton(initial = true) { + * whenOn { item(Material.NOTE_BLOCK) { displayName { primary("Sound: an") } } } + * whenOff { item(Material.BARRIER) { displayName { primary("Sound: aus") } } } + * + * onToggle { enabled -> player.sendMessage("Sound ${if (enabled) "an" else "aus"}") } + * onCloseChanged { _, current -> repository.saveSound(player, current) } + * } + * ``` + * + * @see dev.slne.surf.api.paper.inventory.framework.view.button.toggleButton + */ +@InventoryFrameworkDSL +class ViewToggleButtonBuilder internal constructor( + private val delegate: ViewButtonBuilder +) { + init { + // Pin the cycle order to off -> on so it does not depend on which block is declared first. + delegate.state(false) + delegate.state(true) + } + + /** + * Configures how the button looks and behaves while it is **off**. + * + * @param block configuration block applied to the off state's [ViewButtonStateScope] + */ + fun whenOff(block: ViewButtonStateScope.() -> Unit) { + delegate.state(false, block) + } + + /** + * Configures how the button looks and behaves while it is **on**. + * + * @param block configuration block applied to the on state's [ViewButtonStateScope] + */ + fun whenOn(block: ViewButtonStateScope.() -> Unit) { + delegate.state(true, block) + } + + /** + * Runs [action] whenever the button is toggled, passing the new value. + * + * @param action the callback invoked with the [SlotClickContext] of the triggering click + */ + fun onToggle(action: @InventoryFrameworkDSL SlotClickContext.(enabled: Boolean) -> Unit) { + delegate.onChange { _, to -> action(this, to) } + } + + /** + * Runs [action] when the view is closed and the button ends up on the opposite value of the + * one it started out with. + * + * @param action the callback invoked with the [CloseContext], the initial and the final value + * @see ViewButtonBuilder.onCloseChanged + */ + fun onCloseChanged(action: @InventoryFrameworkDSL CloseContext.(initial: Boolean, current: Boolean) -> Unit) { + delegate.onCloseChanged(action) + } +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonDsl.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonDsl.kt new file mode 100644 index 000000000..057b9ea7f --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonDsl.kt @@ -0,0 +1,212 @@ +package dev.slne.surf.api.paper.inventory.framework.view.button + +import dev.slne.surf.api.core.inventory.framework.internal.ViewButtonSession +import dev.slne.surf.api.paper.inventory.framework.view.AbstractSurfViewContext +import dev.slne.surf.api.paper.inventory.framework.view.state.lazyState +import me.devnatan.inventoryframework.context.Context + +/** + * Declares a button that cycles through an arbitrary number of states, starting at [initial]. + * + * Must be called inside a `surfView { }` or `paginatedSurfView { }` block; place the returned + * handle into a slot with [button][me.devnatan.inventoryframework.component.BukkitItemComponentBuilder.button]. + * Every viewer gets their own copy of the button's value. + * + * ```kotlin + * surfView("Einstellungen") { + * val difficulty = statefulButton(Difficulty.EASY) { + * state(Difficulty.EASY) { item(Material.LIME_DYE) { displayName { primary("Einfach") } } } + * state(Difficulty.NORMAL) { item(Material.YELLOW_DYE) { displayName { primary("Normal") } } } + * state(Difficulty.HARD) { item(Material.RED_DYE) { displayName { primary("Schwer") } } } + * + * onChange { from, to -> player.sendMessage("$from -> $to") } + * onCloseChanged { _, current -> repository.save(player, current) } + * } + * + * onFirstRender { slot(1, 4) { button(difficulty) } } + * } + * ``` + * + * @param T the type identifying the button's states + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial the state every viewer starts on; must be one of the declared states + * @param block configuration block applied to the [ViewButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @see computedStatefulButton + * @see toggleButton + * @see tripleButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun statefulButton( + initial: T, + block: ViewButtonBuilder.() -> Unit +): ViewButtonHandle = computedStatefulButton({ initial }, block) + +/** + * Declares a button that cycles through an arbitrary number of states, resolving its starting + * state per viewer. + * + * Use this over [statefulButton] whenever the button reflects something already stored for the + * player, so that "changed" on close means "changed relative to what the player had". + * + * ```kotlin + * val difficulty = computedStatefulButton({ context -> repository.difficultyOf(context.player) }) { + * state(Difficulty.EASY) { item(Material.LIME_DYE) } + * state(Difficulty.NORMAL) { item(Material.YELLOW_DYE) } + * state(Difficulty.HARD) { item(Material.RED_DYE) } + * + * onCloseChanged { _, current -> repository.save(player, current) } + * } + * ``` + * + * @param T the type identifying the button's states + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial resolves the state a viewer starts on; called once per view session + * @param block configuration block applied to the [ViewButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @see statefulButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun computedStatefulButton( + initial: (Context) -> T, + block: ViewButtonBuilder.() -> Unit +): ViewButtonHandle = registerButton(ViewButtonBuilder().apply(block).build(), initial) + +/** + * Declares an on/off button starting at [initial]. + * + * A specialisation of [statefulButton] for the common two-state case, with a fixed `off -> on` + * cycle and `whenOn` / `whenOff` blocks instead of `state(...)`. + * + * ```kotlin + * surfView("Einstellungen") { + * val sound = toggleButton(initial = true) { + * whenOn { item(Material.NOTE_BLOCK) { displayName { primary("Sound: an") } } } + * whenOff { item(Material.BARRIER) { displayName { primary("Sound: aus") } } } + * + * onToggle { enabled -> player.sendMessage("Sound ${if (enabled) "an" else "aus"}") } + * onCloseChanged { _, current -> repository.saveSound(player, current) } + * } + * + * onFirstRender { slot(1, 1) { button(sound) } } + * } + * ``` + * + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial whether the button starts out on; defaults to `false` + * @param block configuration block applied to the [ViewToggleButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @see computedToggleButton + * @see statefulButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun toggleButton( + initial: Boolean = false, + block: ViewToggleButtonBuilder.() -> Unit +): ViewButtonHandle = computedToggleButton({ initial }, block) + +/** + * Declares an on/off button that resolves whether it starts out on per viewer. + * + * ```kotlin + * val sound = computedToggleButton({ context -> settings.soundEnabled(context.player) }) { + * whenOn { item(Material.NOTE_BLOCK) } + * whenOff { item(Material.BARRIER) } + * + * onCloseChanged { _, current -> settings.setSoundEnabled(player, current) } + * } + * ``` + * + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial resolves whether a viewer starts out on; called once per view session + * @param block configuration block applied to the [ViewToggleButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @see toggleButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun computedToggleButton( + initial: (Context) -> Boolean, + block: ViewToggleButtonBuilder.() -> Unit +): ViewButtonHandle { + val delegate = ViewButtonBuilder() + ViewToggleButtonBuilder(delegate).apply(block) + + return registerButton(delegate.build(), initial) +} + +/** + * Declares a button with exactly three states, starting at [initial]. + * + * Behaves exactly like [statefulButton] but rejects a configuration that does not declare three + * states, so a missing or surplus `state(...)` block fails when the view is built instead of + * silently shipping a two- or four-state button. + * + * ```kotlin + * val visibility = tripleButton(Visibility.ALL) { + * state(Visibility.ALL) { item(Material.LIME_DYE) { displayName { primary("Alle") } } } + * state(Visibility.FRIENDS) { item(Material.YELLOW_DYE) { displayName { primary("Freunde") } } } + * state(Visibility.NONE) { item(Material.RED_DYE) { displayName { primary("Niemand") } } } + * + * onCloseChanged { _, current -> repository.save(player, current) } + * } + * ``` + * + * @param T the type identifying the button's states + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial the state every viewer starts on; must be one of the three declared states + * @param block configuration block applied to the [ViewButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @throws IllegalArgumentException if [block] does not declare exactly three states + * @see computedTripleButton + * @see statefulButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun tripleButton( + initial: T, + block: ViewButtonBuilder.() -> Unit +): ViewButtonHandle = computedTripleButton({ initial }, block) + +/** + * Declares a button with exactly three states that resolves its starting state per viewer. + * + * @param T the type identifying the button's states + * @receiver the [AbstractSurfViewContext] for the current view DSL scope + * @param initial resolves the state a viewer starts on; called once per view session + * @param block configuration block applied to the [ViewButtonBuilder] + * @return a [ViewButtonHandle] to place into a slot and to read the value from + * @throws IllegalArgumentException if [block] does not declare exactly three states + * @see tripleButton + */ +context(ctx: AbstractSurfViewContext<*>) +fun computedTripleButton( + initial: (Context) -> T, + block: ViewButtonBuilder.() -> Unit +): ViewButtonHandle { + val spec = ViewButtonBuilder().apply(block).build() + + require(spec.values.size == 3) { + "A triple button must declare exactly three states, but ${spec.values.size} " + + "were declared: ${spec.values}" + } + + return registerButton(spec, initial) +} + +/** + * Allocates the per-session state for [spec] and registers its close hook on the view context. + * + * The session is a lazy state, so [initial] is resolved once per view session on first access and + * the value survives re-renders — which is what lets the close hook compare against it. + */ +context(ctx: AbstractSurfViewContext<*>) +private fun registerButton( + spec: ViewButtonSpec, + initial: (Context) -> T +): ViewButtonHandle { + val sessionState = lazyState { context -> ViewButtonSession(spec.values, initial(context)) } + val handle = ViewButtonHandle(sessionState, spec) + + ctx.registerButtonCloseHandler { close -> handle.handleClose(close) } + + return handle +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle.kt new file mode 100644 index 000000000..f6907465c --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/button/ViewButtonHandle.kt @@ -0,0 +1,142 @@ +package dev.slne.surf.api.paper.inventory.framework.view.button + +import dev.slne.surf.api.core.inventory.framework.internal.ViewButtonSession +import dev.slne.surf.api.paper.inventory.framework.view.state.StateHandle +import dev.slne.surf.api.paper.inventory.framework.view.state.get +import me.devnatan.inventoryframework.component.BukkitItemComponentBuilder +import me.devnatan.inventoryframework.context.CloseContext +import me.devnatan.inventoryframework.context.Context +import me.devnatan.inventoryframework.context.SlotClickContext +import me.devnatan.inventoryframework.context.SlotRenderContext +import me.devnatan.inventoryframework.state.State + +/** + * A handle to a stateful button declared in a view's DSL block. + * + * The handle is returned by [statefulButton], [toggleButton] and [tripleButton] and is what + * connects the button's configuration to an actual inventory slot: place it with + * [BukkitItemComponentBuilder.button] inside a render callback. + * + * The button's value is stored per view session, so every viewer cycles their own copy. Read and + * write it from any [Context] with the indexed operators: + * + * ```kotlin + * val sound = toggleButton(initial = true) { ... } + * + * onFirstRender { + * slot(1, 1) { button(sound) } + * slot(1, 3) { + * withItem(Material.PAPER) + * onItemClick { player.sendMessage("Sound ist ${sound[this]}") } + * } + * } + * ``` + * + * @param T the type identifying the button's states + * @see statefulButton + * @see toggleButton + * @see tripleButton + */ +class ViewButtonHandle internal constructor( + private val sessionState: StateHandle>>, + private val spec: ViewButtonSpec, +) { + private fun session(context: Context): ViewButtonSession = sessionState[context] + + /** + * Reads the state this button currently shows for [context]'s viewer. + * + * @param context the current [Context] + * @return the currently shown state + */ + operator fun get(context: Context): T = session(context).current + + /** + * Jumps this button to [value] for [context]'s viewer. + * + * Neither the per-state [ViewButtonStateScope.onEnter] nor [ViewButtonBuilder.onChange] + * callbacks fire — those are reserved for actual clicks — and the slot is not re-rendered. + * Call [Context.update][me.devnatan.inventoryframework.context.IFContext.update] afterwards to + * make the change visible. + * + * @param context the current [Context] + * @param value the state to jump to; must be one of the button's declared states + * @throws IllegalArgumentException if [value] is not a declared state + */ + operator fun set(context: Context, value: T) { + session(context).select(value) + } + + /** + * Returns the state this button started out with for [context]'s viewer. + * + * @param context the current [Context] + * @return the initial state + */ + fun initial(context: Context): T = session(context).initial + + /** + * Returns whether this button currently shows a different state than the one it started out + * with for [context]'s viewer. + * + * @param context the current [Context] + * @return `true` if the button was used to move to another state + */ + fun hasChanged(context: Context): Boolean = session(context).hasChanged + + internal fun renderInto(context: SlotRenderContext) { + val session = session(context) + context.item = spec.states[session.currentIndex].itemFactory(context) + } + + internal fun handleClick(context: SlotClickContext) { + val session = session(context) + val step = if (spec.reverseOnRightClick && context.isRightClick) -1 else 1 + + val from = session.current + if (!session.advance(step)) return + val to = session.current + + // Repaint before the callbacks run: they are allowed to navigate away, which would leave + // nothing sensible to update afterwards. + context.component?.update() + + spec.states[session.currentIndex].enterHandler?.invoke(context, from) + spec.changeHandler?.invoke(context, from, to) + } + + internal fun handleClose(context: CloseContext) { + val handler = spec.closeChangedHandler ?: return + val session = session(context) + + if (!session.hasChanged) return + + handler(context, session.initial, session.current) + } +} + +/** + * Places [handle]'s button in this slot. + * + * Wires up the three things a stateful button needs: it renders the appearance of the currently + * shown state, cycles to the next state on click, and cancels the click so the item cannot be + * picked up. + * + * ```kotlin + * onFirstRender { + * slot(1, 1) { button(sound) } + * slot(1, 3) { button(difficulty) } + * } + * ``` + * + * @receiver the [BukkitItemComponentBuilder] of the slot the button should occupy + * @param handle the button declared in the view's DSL block + * @return this builder for chaining + */ +fun BukkitItemComponentBuilder.button(handle: ViewButtonHandle): BukkitItemComponentBuilder { + cancelOnClick() + onRender { context -> handle.renderInto(context) } + onClick { context -> handle.handleClick(context) } + + return this +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/ViewContainer.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/ViewContainer.kt index 7c19f4f42..858828f2a 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/ViewContainer.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/ViewContainer.kt @@ -13,7 +13,9 @@ import java.util.concurrent.CopyOnWriteArrayList * Components are stored in a [CopyOnWriteArrayList] to allow concurrent iteration and * modification. Duplicate components (by [equals]/[hashCode]) are silently ignored on * [addChild]. The [render] method builds the final Adventure [Component][net.kyori.adventure.text.Component] - * by iterating each child and applying its positional shift glyphs around its visual. + * by iterating each child and applying its positional shift glyphs around its visual. Children with + * [ViewContainerComponent.hasExactWidth] `false` are rendered last so their inexact cursor reset + * cannot displace any other component. * * This class is `@PublishedApi internal` — it is not part of the public API. Use * [ViewContainerModificationContext] and the DSL helpers in `ViewContainerDSL.kt` instead. @@ -51,7 +53,12 @@ internal class ViewContainer { } fun render() = buildText { - for (component in children) { + // Components that only estimate their textureWidth leave the cursor slightly off the + // container origin, so render them after every exact one instead of letting the following + // components inherit that drift. partition() keeps the relative insertion order of both. + val (exact, estimated) = children.partition { it.hasExactWidth } + + for (component in exact + estimated) { append { appendShiftedComponent(component.positionalShift, component.textureWidth) { with(component) { renderComponent() } diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent.kt index 8e3e520ea..4a6286198 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/component/ViewContainerComponent.kt @@ -15,18 +15,37 @@ import dev.slne.surf.api.core.messages.builder.SurfComponentBuilder * 3. Shift the cursor back by `-(textureWidth + positionalShift)` pixels to reset the position * for the next component. * + * Step 3 only lands back on the container origin when [textureWidth] equals the number of pixels + * [renderComponent] actually advanced the cursor by; otherwise every following component inherits + * the difference. Components that cannot guarantee an exact width must report [hasExactWidth] as + * `false`, which makes [ViewContainer] render them after all exact ones. + * * All implementations must correctly override [equals] and [hashCode] so that * [CopyOnWriteArrayList.addIfAbsent][java.util.concurrent.CopyOnWriteArrayList.addIfAbsent] * works as expected in [ViewContainer]. * * @property positionalShift the pixel offset applied before rendering the component * @property textureWidth the width in pixels of the rendered texture; used to calculate the reset shift + * @property hasExactWidth whether [textureWidth] is guaranteed to match the rendered advance * @see ViewContainer */ interface ViewContainerComponent { val positionalShift: Int val textureWidth: Int + /** + * Whether [textureWidth] is guaranteed to equal the number of pixels this component advances + * the render cursor by. + * + * Components backed by a fixed resource-pack texture know their width exactly and keep the + * default `true`. Components that render arbitrary text can only derive their width from the + * font metrics they were configured with, so a character the font renders at an unexpected + * width (or does not cover at all, falling back to the client's default font) makes the value + * an estimate. Those return `false` and [ViewContainer] renders them last, so a mis-measured + * title can never displace another component's texture. + */ + val hasExactWidth: Boolean get() = true + /** * Emits this component's visual into the given [SurfComponentBuilder]. * diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/component/components/ViewContainerTitleComponent.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/component/components/ViewContainerTitleComponent.kt index 8d1cf64c2..d8701e6bc 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/component/components/ViewContainerTitleComponent.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/container/component/components/ViewContainerTitleComponent.kt @@ -1,19 +1,24 @@ package dev.slne.surf.api.paper.inventory.framework.view.container.component.components -import dev.slne.surf.api.core.inventory.framework.internal.formatViewTitle +import dev.slne.surf.api.core.inventory.framework.internal.renderViewTitle import dev.slne.surf.api.core.messages.Colors import dev.slne.surf.api.core.messages.builder.SurfComponentBuilder import dev.slne.surf.api.paper.inventory.framework.view.container.component.ViewContainerComponent import dev.slne.surf.api.paper.inventory.framework.view.settings.align.TextAlignment import dev.slne.surf.api.paper.inventory.framework.view.settings.align.TextAlignmentOptions +import it.unimi.dsi.fastutil.ints.Int2IntMap +import it.unimi.dsi.fastutil.ints.Int2IntMaps import net.kyori.adventure.key.Key /** * A [ViewContainerComponent] that renders the inventory title text using a custom font. * * The title string is converted to uppercase and inter-character spacing glyphs are inserted - * between each letter using [formatViewTitle]. The horizontal position is calculated from the - * [textAlignment] so that the text is positioned correctly within the background texture. + * between each letter using [renderViewTitle]. That same call reports the exact pixel width of the + * string it produced, which is used both to position the text according to [textAlignment] and to + * reset the render cursor afterwards — measuring the raw title instead would drift as soon as + * uppercasing changes the glyph count (`ß` uppercases to `SS`) or the leading spacing glyph is + * emitted. * * The component uses constants from its companion object to define the geometry of the * container area: @@ -27,26 +32,44 @@ import net.kyori.adventure.key.Key * @param font the Adventure [Key] identifying the resource-pack font to use * @param charSpacing the pixel spacing to insert between each character * @param textAlignment the [TextAlignment] controlling horizontal positioning + * @param charWidths per-code-point pixel width overrides for glyphs the [font] does not render at + * [CHAR_SIZE] pixels; see [TextAlignmentOptions.charWidths] */ class ViewContainerTitleComponent( title: String, private val font: Key, charSpacing: Int, - textAlignment: TextAlignment + textAlignment: TextAlignment, + charWidths: Int2IntMap = Int2IntMaps.EMPTY_MAP ) : ViewContainerComponent { - private val formattedTitle: String = - formatViewTitle(title, charSpacing, textAlignment == TextAlignment.RIGHT) - private val alignmentOptions = TextAlignmentOptions( leftShift = LEFT_SHIFT, padding = PADDING, containerWidth = CONTAINER_WIDTH, charSize = CHAR_SIZE, - charSpacing = charSpacing + charSpacing = charSpacing, + charWidths = charWidths + ) + + private val rendered = renderViewTitle( + title = title, + charSize = CHAR_SIZE, + charSpacing = charSpacing, + alignRight = textAlignment == TextAlignment.RIGHT, + charWidths = charWidths ) - override val positionalShift = textAlignment.calculateShift(title, alignmentOptions) - override val textureWidth = TextAlignment.calculateTextWidth(title, alignmentOptions) + private val formattedTitle = rendered.text + + override val textureWidth = rendered.width + override val positionalShift = textAlignment.calculateShift(rendered.width, alignmentOptions) + + /** + * `false`: the width is derived from the configured font metrics, so a glyph the font renders + * at an unexpected width would make it an estimate. Keeps the title from displacing the header + * textures — see [ViewContainerComponent.hasExactWidth]. + */ + override val hasExactWidth = false override fun SurfComponentBuilder.renderComponent() { text(formattedTitle) @@ -84,4 +107,4 @@ class ViewContainerTitleComponent( /** Default inter-character spacing (negative = tighter). */ const val CHAR_SPACING = -1 } -} \ No newline at end of file +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment.kt index ae6f28a64..495fe132d 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignment.kt @@ -8,8 +8,9 @@ import dev.slne.surf.api.core.inventory.framework.internal.TextAlignmentMath * Each entry implements [calculateShift] to return the pixel offset that positions the text * at the correct horizontal position given the container geometry described by [TextAlignmentOptions]. * - * The companion object provides [calculateTextWidth] which computes the total pixel width of a - * text string based on the character size and spacing configured in [TextAlignmentOptions]. + * Alignment is driven by the **rendered** pixel width of the title rather than by its character + * count, so uppercasing, per-character width overrides and the leading spacing glyph are all + * accounted for. Use [calculateTextWidth] to measure a string that is rendered verbatim. * * @see TextAlignmentOptions * @see dev.slne.surf.api.paper.api.inventory.framework.view.settings.SurfViewSettings.headerTextAlignment @@ -19,7 +20,7 @@ enum class TextAlignment { * Aligns the title to the left edge of the container area (plus [TextAlignmentOptions.padding]). */ LEFT { - override fun calculateShift(text: String, options: TextAlignmentOptions): Int = + override fun calculateShift(textWidth: Int, options: TextAlignmentOptions): Int = TextAlignmentMath.leftAlignedShift(options.leftShift, options.padding) }, @@ -28,15 +29,13 @@ enum class TextAlignment { */ RIGHT { override fun calculateShift( - text: String, + textWidth: Int, options: TextAlignmentOptions ): Int = TextAlignmentMath.rightAlignedShift( - text, + textWidth, options.leftShift, options.padding, - options.containerWidth, - options.charSize, - options.charSpacing + options.containerWidth ) }, @@ -45,40 +44,47 @@ enum class TextAlignment { */ CENTER { override fun calculateShift( - text: String, + textWidth: Int, options: TextAlignmentOptions ): Int = TextAlignmentMath.centerAlignedShift( - text, + textWidth, options.leftShift, options.padding, - options.containerWidth, - options.charSize, - options.charSpacing + options.containerWidth ) }; /** - * Calculates the pixel shift (offset from the left edge) required to place [text] at this alignment - * within the container described by [options]. + * Calculates the pixel shift (offset from the left edge) required to place a text run of + * [textWidth] pixels at this alignment within the container described by [options]. * - * @param text the title string whose width is taken into account + * @param textWidth the rendered pixel width of the title, as measured by [calculateTextWidth] * @param options the container geometry options * @return the pixel shift value to apply before rendering the text */ - abstract fun calculateShift(text: String, options: TextAlignmentOptions): Int + abstract fun calculateShift(textWidth: Int, options: TextAlignmentOptions): Int companion object { /** * Computes the total rendered pixel width of [text] given [options]. * - * Uses the formula: `text.length * charSize + (text.length - 1) * charSpacing`. - * Returns `0` for an empty string. + * Sums the per-glyph widths from [TextAlignmentOptions.charWidths] (falling back to + * [TextAlignmentOptions.charSize]) and adds [TextAlignmentOptions.charSpacing] between + * adjacent glyphs. Measured per Unicode code point. Returns `0` for an empty string. + * + * [text] must be the string as it is actually rendered — measuring a string that is + * transformed before rendering (for example uppercased) yields the wrong width. * * @param text the string to measure * @param options the character size and spacing options * @return the total pixel width of the text */ fun calculateTextWidth(text: String, options: TextAlignmentOptions): Int = - TextAlignmentMath.textWidth(text, options.charSize, options.charSpacing) + TextAlignmentMath.textWidth( + text, + options.charSize, + options.charSpacing, + options.charWidths + ) } -} \ No newline at end of file +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions.kt index 0c156556d..3defe5b07 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/inventory/framework/view/settings/align/TextAlignmentOptions.kt @@ -1,5 +1,8 @@ package dev.slne.surf.api.paper.inventory.framework.view.settings.align +import it.unimi.dsi.fastutil.ints.Int2IntMap +import it.unimi.dsi.fastutil.ints.Int2IntMaps + /** * Encapsulates the geometric parameters of the inventory title container area * used by [TextAlignment.calculateShift] and [TextAlignment.calculateTextWidth]. @@ -10,6 +13,10 @@ package dev.slne.surf.api.paper.inventory.framework.view.settings.align * @property containerWidth the total usable pixel width of the title container area * @property charSize the width in pixels of a single character in the title font * @property charSpacing the inter-character spacing in pixels (negative = tighter, positive = looser) + * @property charWidths per-code-point pixel width overrides, keyed by Unicode code point, for the + * glyphs the title font does not render at [charSize] pixels. Empty by default, which treats the + * font as fixed-width. Populate it for a proportional title font, or for characters the font does + * not cover and that therefore fall back to the client's default font at a different width. * @see TextAlignment */ data class TextAlignmentOptions( @@ -17,5 +24,6 @@ data class TextAlignmentOptions( val padding: Int, val containerWidth: Int, val charSize: Int, - val charSpacing: Int + val charSpacing: Int, + val charWidths: Int2IntMap = Int2IntMaps.EMPTY_MAP ) From 1cd0c113b9b7dcdf47a55e990ef0241af13a67c1 Mon Sep 17 00:00:00 2001 From: TheBjoRedCraft Date: Fri, 28 Aug 2026 17:11:58 +0200 Subject: [PATCH 2/2] chore: version bump --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 4e09c2df0..5eb19632c 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.41.0 +version=3.42.0 relocationPrefix=dev.slne.surf.api.libs snapshot=false