Update GUI-System

The GUI-System now gets its messages from the en_US.yml file for better customizability
This commit is contained in:
TDSTOS
2026-03-28 17:32:23 +01:00
parent bab703601e
commit 5b00b51193
5 changed files with 141 additions and 228 deletions

View File

@@ -12,107 +12,53 @@ import org.bukkit.inventory.AnvilInventory
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.view.AnvilView
/**
* Suchmenü auf Basis der **Paper Anvil-API** (kein NMS!).
*
* ## Wie Paper's Anvil-API funktioniert
*
* Paper stellt `player.openAnvil(location, force)` bereit. Diese Methode
* gibt eine `InventoryView` zurück. Da wir uns nicht für die physische Position
* interessieren (nur das Inventar-UI), übergeben wir `null` als Ort und
* `true` als Force-Flag (öffnet auch ohne physischen Amboss in der Nähe).
*
* ```
* Slot 0 (INPUT_LEFT) → Das "Such-Icon" mit dem Placeholder-Text
* Slot 1 (INPUT_RIGHT) → Leer (wird vom Spieler nicht benutzt)
* Slot 2 (OUTPUT) → Das Ergebnis-Item → Klick bestätigt die Suche
* ```
*
* Der Spieler schreibt in das Umbenennen-Feld des Amboss. Wir lesen den
* eingegebenen Text über `AnvilView.renameText` aus. Paper aktualisiert
* diesen Wert in Echtzeit. Beim Klick auf Slot 2 (Output) schließen wir
* den Amboss und übergeben den Text an [KitSelectorMenu.applySearch].
*
* **Warum kein `PrepareAnvilEvent` + Fake-Item?**
* Für unsere reine Text-Eingabe brauchen wir kein Live-Feedback im Output-Slot.
* Wir setzen ein statisches Bestätigungs-Item und lesen den Rename-Text beim
* Klick aus. Das ist einfacher und stabiler.
*
* @param player Der suchende Spieler.
* @param returnMenu Das [KitSelectorMenu] zu dem wir nach der Suche zurückspringen.
* @param initialText Vorausgefüllter Text (wird als Rename-Text gesetzt).
*/
@Suppress("DEPRECATION")
class AnvilSearchMenu(
private val player: Player,
private val returnMenu: KitSelectorMenu,
private val initialText: String = ""
) {
private val plugin = SpeedHG.instance
private val plugin get() = SpeedHG.instance
private val lm get() = plugin.languageManager
private val mm = MiniMessage.miniMessage()
/** Öffnet das Anvil-Inventar. */
fun open(player: Player) {
// Paper API: öffnet einen Amboss ohne physischen Block
val view = player.openAnvil(null, true) ?: return
val anvilInv = view.topInventory as? AnvilInventory ?: return
// ── Slot 0: Such-Icon (Input) ──────────────────────────────────────
anvilInv.setItem(0, buildInputItem())
// ── Slot 2: Bestätigungs-Icon (Output) ────────────────────────────
anvilInv.setItem(2, buildConfirmItem())
// Kosten auf 0 setzen — Spieler soll kein XP zahlen
anvilInv.repairCost = 0
// Klick-Listener merken uns über die AnvilTracker-Map
AnvilSearchTracker.register(player, this)
}
/**
* Verarbeitet einen Klick im Anvil-Inventory.
* Wird vom [MenuListener] über [AnvilSearchTracker] weitergeleitet.
*
* @param event Das Click-Event (bereits gecancelt).
* @param view Die geöffnete [AnvilView] mit dem Eingabetext.
*/
fun onClick(event: InventoryClickEvent, view: AnvilView) {
event.isCancelled = true
// Nur Klick auf Output-Slot (Slot 2) bestätigt die Suche
if (event.rawSlot != 2) return
val query = view.renameText ?: ""
// Amboss schließen und zurück zum Kit-Menü
player.closeInventory()
AnvilSearchTracker.unregister(player)
// Suchbegriff anwenden → öffnet KitSelectorMenu neu
returnMenu.applySearch(query)
}
/** Cleanup wenn Spieler Amboss schließt ohne zu bestätigen. */
fun onClose() {
AnvilSearchTracker.unregister(player)
// Zurück zum Kit-Menü ohne Suchbegriff zu ändern
// Einen Tick warten, damit das aktuelle Close-Event fertig ist
plugin.server.scheduler.runTask(plugin) { ->
returnMenu.open(player)
}
}
// -------------------------------------------------------------------------
// Item-Builder
// -------------------------------------------------------------------------
// ── Item-Builder ──────────────────────────────────────────────────────────
private fun buildInputItem(): ItemStack {
val rawPlaceholder = initialText.ifEmpty { lm.getRawMessage(player, "gui.anvil_search.input_placeholder") }
val item = ItemStack(Material.NAME_TAG)
item.editMeta { meta ->
meta.displayName(
// Der Name des Input-Items wird als Placeholder-Text im Umbenennungsfeld angezeigt
mm.deserialize(initialText.ifEmpty { "<gray>Suchbegriff eingeben...</gray>" })
mm.deserialize(rawPlaceholder)
.decoration(TextDecoration.ITALIC, false)
)
}
@@ -123,31 +69,24 @@ class AnvilSearchMenu(
val item = ItemStack(Material.NAME_TAG)
item.editMeta { meta ->
meta.displayName(
mm.deserialize("<green>✔ Suche bestätigen</green>")
mm.deserialize(lm.getRawMessage(player, "gui.anvil_search.confirm_name"))
.decoration(TextDecoration.ITALIC, false)
)
val lore = listOf(
meta.lore(listOf(
Component.empty(),
mm.deserialize("<gray>Klicken um zu suchen</gray>")
mm.deserialize(lm.getRawMessage(player, "gui.anvil_search.confirm_click"))
.decoration(TextDecoration.ITALIC, false),
Component.empty()
)
meta.lore(lore)
))
}
return item
}
}
/**
* Hält die Zuordnung `Player → AnvilSearchMenu` für alle aktuell offenen
* Anvil-Suchen. Wird benötigt, weil Anvil-Inventories keinen eigenen
* [MenuHolder] haben — sie werden direkt von Bukkit verwaltet.
*
* Die Map wird im [MenuListener] abgefragt.
*/
object AnvilSearchTracker {
private val openSearches = java.util.concurrent.ConcurrentHashMap<java.util.UUID, AnvilSearchMenu>()
private val openSearches =
java.util.concurrent.ConcurrentHashMap<java.util.UUID, AnvilSearchMenu>()
fun register(player: Player, menu: AnvilSearchMenu) {
openSearches[player.uniqueId] = menu

View File

@@ -1,94 +1,69 @@
package club.mcscrims.speedhg.gui.factory
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import net.kyori.adventure.text.format.TextDecoration
import net.kyori.adventure.text.minimessage.MiniMessage
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder
import org.bukkit.Material
import org.bukkit.enchantments.Enchantment
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemFlag
import org.bukkit.inventory.ItemStack
/**
* Erstellt dynamisch die [ItemStack]s für das Kit-Auswahl-Menü.
*
* ## Warum eine eigene Factory?
*
* Die Item-Generierung (Icon, Name, Lore, Enchants) ist von [KitSelectorMenu]
* trennbar und testbar. Außerdem kann sie wiederverwendet werden (z.B. für
* Hotbar-Items oder Chat-Vorschauen).
*
* ## Lore-Aufbau
* ```
* ─────────────────────────
* <Zeile 1 aus Kit.lore>
* <Zeile 2 aus Kit.lore>
*
* Playstyle: ► Aggressive
*
* [L-Klick] Kit auswählen
* [R-Klick] Playstyle wechseln
* ─────────────────────────
* ```
*/
object KitItemFactory {
private val plugin get() = SpeedHG.instance
private val mm = MiniMessage.miniMessage()
/**
* Baut das vollständige Kit-Item inklusive dynamischer Lore.
*
* @param kit Das Kit, das dargestellt werden soll.
* @param currentStyle Der aktuell gewählte [Playstyle] des Spielers.
* @param isSelected Ob dieses Kit gerade das aktiv gewählte ist.
*/
// ── Kit-Item ──────────────────────────────────────────────────────────────
fun buildKitItem(
kit: Kit,
currentStyle: Playstyle,
isSelected: Boolean
isSelected: Boolean,
player: Player
): ItemStack {
val lm = plugin.languageManager
val item = ItemStack(kit.icon)
item.editMeta { meta ->
// ── Name ──────────────────────────────────────────────────────────
meta.displayName(
kit.displayName
.decoration(TextDecoration.ITALIC, false)
kit.displayName.decoration(TextDecoration.ITALIC, false)
)
// ── Lore ──────────────────────────────────────────────────────────
val lore = mutableListOf<Component>()
// Trennlinie
lore += separator()
// Kit-eigene Lore-Zeilen
kit.lore.forEach { line ->
lore += mm.deserialize(line)
.decoration(TextDecoration.ITALIC, false)
lore += mm.deserialize(line).decoration(TextDecoration.ITALIC, false)
}
lore += Component.empty()
// Playstyle-Anzeige
lore += mm.deserialize(
"<gray>Playstyle: <white>► ${currentStyle.displayName}"
lm.getRawMessage(player, "gui.kit_selector.kit_item.playstyle"),
Placeholder.parsed("playstyle", currentStyle.displayName)
).decoration(TextDecoration.ITALIC, false)
lore += Component.empty()
// Interaktions-Hints
lore += mm.deserialize("<yellow>[L-Click]</yellow> <gray>Select Kit</gray>")
.decoration(TextDecoration.ITALIC, false)
lore += mm.deserialize("<gold>[R-Click]</gold> <gray>Change playstyle</gray>")
.decoration(TextDecoration.ITALIC, false)
lore += mm.deserialize(
lm.getRawMessage(player, "gui.kit_selector.kit_item.left_click")
).decoration(TextDecoration.ITALIC, false)
lore += mm.deserialize(
lm.getRawMessage(player, "gui.kit_selector.kit_item.right_click")
).decoration(TextDecoration.ITALIC, false)
lore += separator()
meta.lore(lore)
// ── Ausgewählt-Effekt ──────────────────────────────────────────────
if (isSelected) {
meta.addEnchant(Enchantment.UNBREAKING, 1, true)
meta.addItemFlags(ItemFlag.HIDE_ENCHANTS)
@@ -103,66 +78,77 @@ object KitItemFactory {
return item
}
// ── Dekorations-Items ─────────────────────────────────────────────────────
// ── Dekorations- und Navigations-Items ───────────────────────────────────
fun buildFillerItem(): ItemStack {
val item = ItemStack(org.bukkit.Material.GRAY_STAINED_GLASS_PANE)
val item = ItemStack(Material.GRAY_STAINED_GLASS_PANE)
item.editMeta { meta ->
meta.displayName(Component.text(" ").decoration(TextDecoration.ITALIC, false))
meta.displayName(
Component.text(" ").decoration(TextDecoration.ITALIC, false)
)
}
return item
}
fun buildPrevPageItem(): ItemStack = buildNavItem(
org.bukkit.Material.ARROW,
"<red>◄ Previous"
fun buildPrevPageItem(player: Player): ItemStack = buildNavItem(
Material.ARROW,
plugin.languageManager.getRawMessage(player, "gui.kit_selector.prev_page")
)
fun buildNextPageItem(): ItemStack = buildNavItem(
org.bukkit.Material.ARROW,
"<green>Next ►"
fun buildNextPageItem(player: Player): ItemStack = buildNavItem(
Material.ARROW,
plugin.languageManager.getRawMessage(player, "gui.kit_selector.next_page")
)
fun buildSearchItem(currentQuery: String): ItemStack {
val item = ItemStack(org.bukkit.Material.NAME_TAG)
fun buildSearchItem(currentQuery: String, player: Player): ItemStack {
val lm = plugin.languageManager
val item = ItemStack(Material.NAME_TAG)
item.editMeta { meta ->
meta.displayName(
mm.deserialize("<yellow>🔍 Search")
mm.deserialize(lm.getRawMessage(player, "gui.kit_selector.search.name"))
.decoration(TextDecoration.ITALIC, false)
)
val lore = mutableListOf<Component>()
lore += separator()
if (currentQuery.isNotEmpty()) {
lore += mm.deserialize("<gray>Current: <white>\"$currentQuery\"")
.decoration(TextDecoration.ITALIC, false)
lore += mm.deserialize(
lm.getRawMessage(player, "gui.kit_selector.search.current"),
Placeholder.parsed("query", currentQuery)
).decoration(TextDecoration.ITALIC, false)
lore += Component.empty()
}
lore += mm.deserialize("<gray>Click to search</gray>")
.decoration(TextDecoration.ITALIC, false)
lore += mm.deserialize(
lm.getRawMessage(player, "gui.kit_selector.search.click")
).decoration(TextDecoration.ITALIC, false)
lore += separator()
meta.lore(lore)
}
return item
}
fun buildClearSearchItem(): ItemStack = buildNavItem(
org.bukkit.Material.BARRIER,
"<red>✕ Reset search"
fun buildClearSearchItem(player: Player): ItemStack = buildNavItem(
Material.BARRIER,
plugin.languageManager.getRawMessage(player, "gui.kit_selector.clear_search")
)
fun buildCloseItem(): ItemStack = buildNavItem(
org.bukkit.Material.DARK_OAK_DOOR,
"<red>✕ Close"
fun buildCloseItem(player: Player): ItemStack = buildNavItem(
Material.DARK_OAK_DOOR,
plugin.languageManager.getRawMessage(player, "gui.kit_selector.close")
)
// ── Interne Hilfsmittel ───────────────────────────────────────────────────
private fun buildNavItem(material: org.bukkit.Material, title: String): ItemStack {
private fun buildNavItem(material: Material, rawTitle: String): ItemStack {
val item = ItemStack(material)
item.editMeta { meta ->
meta.displayName(
mm.deserialize(title)
.decoration(TextDecoration.ITALIC, false)
mm.deserialize(rawTitle).decoration(TextDecoration.ITALIC, false)
)
meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP)
}

View File

@@ -5,7 +5,7 @@ import club.mcscrims.speedhg.gui.anvil.AnvilSearchMenu
import club.mcscrims.speedhg.gui.factory.KitItemFactory
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import net.kyori.adventure.text.minimessage.MiniMessage
import club.mcscrims.speedhg.util.trans
import org.bukkit.entity.Player
import org.bukkit.event.inventory.ClickType
import org.bukkit.event.inventory.InventoryClickEvent
@@ -32,23 +32,19 @@ import org.bukkit.inventory.Inventory
class KitSelectorMenu(
private val player: Player,
private var searchQuery: String = ""
) : Menu(rows = 6, title = "<gradient:red:gold><bold>Kit-Auswahl</bold></gradient>") {
) : Menu(
rows = 6,
title = player.trans( "gui.kit_selector.title" )
) {
private val plugin = SpeedHG.instance
private val mm = MiniMessage.miniMessage()
private val plugin get() = SpeedHG.instance
private val lm get() = plugin.languageManager
// Pagination
private val kitSlots = 45 // Slots 044 für Kits
private var currentPage = 0
// ── Slot-Konstanten ────────────────────────────────────────────────────────
private val slotPrevPage = 45
private val slotSearch = 48
private val slotClearSearch = 50
private val slotClose = 53
private val slotNextPage = 53 // wird durch slotClose ersetzt wenn letzte Seite
// ── Navigation-Slots ──────────────────────────────────────────────────────
private val navSlotPrev = 45
private val navSlotSearch = 48
private val navSlotClear = 50
@@ -59,7 +55,7 @@ class KitSelectorMenu(
// -------------------------------------------------------------------------
override fun build(): Inventory {
val inv = createInventory(mm.deserialize(title))
val inv = createInventory(title) // title ist jetzt bereits ein Component
populate(inv)
return inv
}
@@ -67,15 +63,14 @@ class KitSelectorMenu(
private fun populate(inv: Inventory) {
inv.clear()
// ── Filler ────────────────────────────────────────────────────────────
// Filler-Reihe
val filler = KitItemFactory.buildFillerItem()
(45 until 54).forEach { inv.setItem(it, filler) }
// ── Kits ──────────────────────────────────────────────────────────────
// Kits
val filteredKits = getFilteredKits()
val totalPages = ((filteredKits.size - 1) / kitSlots + 1).coerceAtLeast(1)
// Seiten-Clamp: wenn Suche Kits reduziert, nicht auf nicht-existenter Seite bleiben
currentPage = currentPage.coerceIn(0, totalPages - 1)
val pageStart = currentPage * kitSlots
@@ -87,25 +82,26 @@ class KitSelectorMenu(
pageKits.forEachIndexed { slotIndex, kit ->
val currentStyle = plugin.kitManager.getSelectedPlaystyle(player)
val selectedKit = plugin.kitManager.getSelectedKit(player)
inv.setItem(slotIndex, KitItemFactory.buildKitItem(kit, currentStyle, selectedKit?.id == kit.id))
inv.setItem(
slotIndex,
KitItemFactory.buildKitItem(kit, currentStyle, selectedKit?.id == kit.id, player)
)
}
// ── Navigation ────────────────────────────────────────────────────────
// Navigation
if (currentPage > 0)
inv.setItem(navSlotPrev, KitItemFactory.buildPrevPageItem())
inv.setItem(navSlotPrev, KitItemFactory.buildPrevPageItem(player))
if (currentPage < totalPages - 1)
inv.setItem(navSlotNext, KitItemFactory.buildNextPageItem())
inv.setItem(navSlotNext, KitItemFactory.buildNextPageItem(player))
inv.setItem(navSlotSearch, KitItemFactory.buildSearchItem(searchQuery))
inv.setItem(navSlotSearch, KitItemFactory.buildSearchItem(searchQuery, player))
if (searchQuery.isNotEmpty())
inv.setItem(navSlotClear, KitItemFactory.buildClearSearchItem())
inv.setItem(navSlotClear, KitItemFactory.buildClearSearchItem(player))
}
// -------------------------------------------------------------------------
// Klick-Handling
// -------------------------------------------------------------------------
// ── Klick-Handling ────────────────────────────────────────────────────────
override fun onClick(event: InventoryClickEvent, player: Player) {
val slot = event.rawSlot
@@ -120,17 +116,12 @@ class KitSelectorMenu(
}
}
// -------------------------------------------------------------------------
// Kit-Interaktionen
// -------------------------------------------------------------------------
// ── Kit-Interaktionen ─────────────────────────────────────────────────────
private fun handleKitClick(slot: Int, clickType: ClickType) {
val kit = getKitAtSlot(slot) ?: return
when {
// Linksklick → Kit auswählen
clickType.isLeftClick -> selectKit(kit)
// Rechtsklick → Playstyle toggeln (und Menü refreshen)
clickType.isRightClick -> togglePlaystyle(kit)
}
}
@@ -138,11 +129,11 @@ class KitSelectorMenu(
private fun selectKit(kit: Kit) {
plugin.kitManager.selectKit(player, kit)
player.sendActionBar(
MiniMessage.miniMessage().deserialize(
"<green>Kit <white>${kit.displayName}</white> gewählt!</green>"
lm.getComponent(
player, "gui.kit_selector.kit_selected",
mapOf(), mapOf("kit" to kit.displayName)
)
)
// Menü aktualisieren um Auswahl-Glanz zu zeigen
refresh()
}
@@ -154,11 +145,11 @@ class KitSelectorMenu(
}
plugin.kitManager.selectPlaystyle(player, next)
// Wenn dieses Kit gerade ausgewählt ist, Playstyle-Änderung übernehmen
if (plugin.kitManager.getSelectedKit(player)?.id == kit.id) {
player.sendActionBar(
MiniMessage.miniMessage().deserialize(
"<gold>Playstyle: <white>► ${next.displayName}</white>"
lm.getComponent(
player, "gui.kit_selector.playstyle_changed",
mapOf("playstyle" to next.displayName)
)
)
}
@@ -166,32 +157,20 @@ class KitSelectorMenu(
refresh()
}
// -------------------------------------------------------------------------
// Navigation
// -------------------------------------------------------------------------
// ── Navigation ────────────────────────────────────────────────────────────
private fun handlePrevPage() {
if (currentPage > 0) {
currentPage--
refresh()
}
if (currentPage > 0) { currentPage--; refresh() }
}
private fun handleNextPage() {
val totalPages = ((getFilteredKits().size - 1) / kitSlots + 1).coerceAtLeast(1)
if (currentPage < totalPages - 1) {
currentPage++
refresh()
}
if (currentPage < totalPages - 1) { currentPage++; refresh() }
}
private fun handleSearchClick() {
// Öffne das Anvil-Suchmenü; schließt dieses Menü temporär
AnvilSearchMenu(
player = player,
returnMenu = this,
initialText = searchQuery
).open(player)
AnvilSearchMenu(player = player, returnMenu = this, initialText = searchQuery)
.open(player)
}
private fun handleClearSearch() {
@@ -200,23 +179,14 @@ class KitSelectorMenu(
refresh()
}
// -------------------------------------------------------------------------
// Hilfsmittel
// -------------------------------------------------------------------------
// ── Hilfsmittel ───────────────────────────────────────────────────────────
/** Aktualisiert das geöffnete Inventory in-place ohne es neu zu öffnen. */
fun refresh() {
populate(inventory)
}
fun refresh() { populate(inventory) }
/**
* Aktualisiert den Suchbegriff und baut das Menü neu auf.
* Wird vom [AnvilSearchMenu] aufgerufen nachdem der Spieler einen Begriff eingegeben hat.
*/
fun applySearch(query: String) {
searchQuery = query.trim()
currentPage = 0
open(player) // neu öffnen (Anvil hat das vorherige Inventory geschlossen)
open(player)
}
private fun getFilteredKits(): List<Kit> {
@@ -225,19 +195,14 @@ class KitSelectorMenu(
val q = searchQuery.lowercase()
return allKits.filter { kit ->
// Suche im Plain-Text des Adventure-Components
kit.id.lowercase().contains(q) ||
net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
.plainText()
.serialize(kit.displayName)
.lowercase()
.contains(q)
.plainText().serialize(kit.displayName).lowercase().contains(q)
}
}
private fun getKitAtSlot(slot: Int): Kit? {
val filteredKits = getFilteredKits()
val absoluteIndex = currentPage * kitSlots + slot
return filteredKits.getOrNull(absoluteIndex)
return getFilteredKits().getOrNull(absoluteIndex)
}
}

View File

@@ -1,5 +1,6 @@
package club.mcscrims.speedhg.gui.menu
import net.kyori.adventure.text.Component
import org.bukkit.Bukkit
import org.bukkit.entity.Player
import org.bukkit.event.inventory.InventoryClickEvent
@@ -25,7 +26,7 @@ import org.bukkit.inventory.Inventory
*/
abstract class Menu(
protected val rows: Int,
protected val title: String
protected val title: Component
) {
protected val size: Int = rows * 9
internal lateinit var inventory: Inventory
@@ -56,7 +57,7 @@ abstract class Menu(
* Erstellt ein Inventory mit dem korrekten [MenuHolder].
* Immer statt `Bukkit.createInventory(null, ...)` verwenden.
*/
protected fun createInventory(parsedTitle: net.kyori.adventure.text.Component): Inventory {
protected fun createInventory(parsedTitle: Component): Inventory {
val holder = MenuHolder(this)
val inv = Bukkit.createInventory(holder, size, parsedTitle)
holder.bind(inv)

View File

@@ -95,6 +95,28 @@ scoreboard:
- ""
- "<yellow>play.mcscrims.club"
gui:
kit_selector:
title: '<gradient:red:gold><bold>Kit Selection</bold></gradient>'
kit_selected: '<green>Kit <kit> selected!</green>'
playstyle_changed: '<gold>Playstyle: <white>► <playstyle></white>'
prev_page: '<red>◄ Previous'
next_page: '<green>Next ►'
clear_search: '<red>✕ Reset Search'
close: '<red>✕ Close'
kit_item:
playstyle: '<gray>Playstyle: <white>► <playstyle>'
left_click: '<yellow>[L-Click]</yellow> <gray>Select Kit</gray>'
right_click: '<gold>[R-Click]</gold> <gray>Change Playstyle</gray>'
search:
name: '<yellow>🔍 Search'
current: '<gray>Current: <white>"<query>"'
click: '<gray>Click to search</gray>'
anvil_search:
input_placeholder: '<gray>Enter search term...</gray>'
confirm_name: '<green>✔ Confirm Search</green>'
confirm_click: '<gray>Click to confirm</gray>'
kits:
backup:
name: '<gradient:gold:#ff841f><bold>Backup</bold></gradient>'