@@ -120,10 +179,14 @@ try {
return Collections.emptyList();
})
+ // REQUIRED for Folia (also binds advanced region scheduling)
+ // RECOMMENDED for Paper / CanvasMC / Archlight / Bukkit thread safety
+ .callHandlerSynchronously(this) // "this" = your JavaPlugin instance
+
// build the SignGUI
.build();
- // open the sign
+ // open the sign (on Folia this is scheduled on the player's entity/region thread)
gui.open(player);
// you can also open the sign for multiple players
@@ -133,10 +196,17 @@ try {
}
```
-You don't have to call all methods. Only `setHandler` is mandatory.
+You don't have to call all methods. Only `setHandler` is mandatory. On **Folia**, `callHandlerSynchronously(plugin)` is also mandatory.
+
+**Important:** `callHandlerSynchronously(plugin)` is **REQUIRED for Folia** and **RECOMMENDED for all other platforms**. It:
+- hops finish handling off the Netty thread onto the player's **EntityScheduler** (Folia/Paper) or the main thread (Bukkit)
+- schedules sign opens on the correct region thread
+- routes delayed reopens (~4 ticks) through region schedulers instead of a raw thread pool
+- makes `SignGUIAction.openInventory` / `runSync` Folia-safe
+
+Public helpers also available: `SignGUIScheduler.run(player, task)`, `runAt(location, task)`, `runGlobal(task)`, `runAsync(task)`.
-By default, the handler is called by an asynchronous thread. You can change that behaviour by calling the method `callHandlerSynchronously` of the builder.
-An explanation for the different methods can be found on the [Javadoc](https://javadoc.io/doc/de.rapha149.signgui/signgui).
+By default (non-Folia, without `callHandlerSynchronously`), the finish handler runs on the Netty thread. Prefer binding your plugin for thread safety.
Also: as you can see the code catches the `SignGUIVersionException` which could be thrown by `SignGUI.builder()` if the api does not support the Minecraft version of the server.
If you're interested in why this exception exists, check these [release notes](https://github.com/Rapha149/SignGUI/releases/tag/v2.5.0).
diff --git a/api/build.gradle.kts b/api/build.gradle.kts
new file mode 100644
index 00000000..38d4286c
--- /dev/null
+++ b/api/build.gradle.kts
@@ -0,0 +1,9 @@
+plugins {
+ id("module-java-versions")
+}
+
+dependencies {
+ api(project(":wrapper"))
+ compileOnly("org.spigotmc:spigot:1.8-R0.1-SNAPSHOT")
+ compileOnly(libs.folia.api)
+}
diff --git a/api/pom.xml b/api/pom.xml
deleted file mode 100644
index 243b55f8..00000000
--- a/api/pom.xml
+++ /dev/null
@@ -1,461 +0,0 @@
-
-
- 4.0.0
-
-
- signgui-parent
- 2.5.4
- de.rapha149.signgui
-
-
- signgui
-
- ${project.groupId}:signgui
- An api to get input text via a sign in Minecraft.
- https://github.com/Rapha149/SignGUI
-
-
-
- Rapha149
- rapha@rapha149.de
- https://github.com/Rapha149
-
-
-
-
-
- MIT License
- http://www.opensource.org/licenses/mit-license.php
-
-
-
-
- scm:git:git://github.com/Rapha149/SignGUI.git
- scm:git:ssh://github.com:Rapha149/SignGUI.git
- http://github.com/Rapha149/SignGUI/tree/main
-
-
-
-
-
- org.spigotmc
- spigot
- 1.8-R0.1-SNAPSHOT
- provided
-
-
- de.rapha149.signgui
- signgui-wrapper
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_8_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_8_R2
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_8_R3
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_9_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_9_R2
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_10_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_11_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_12_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_13_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_13_R2
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_14_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_15_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_16_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_16_R2
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_16_R3
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_17_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_18_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_18_R2
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_19_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_19_R2
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_19_R3
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_20_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_20_R2
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_20_R3
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_20_R4
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_20_R4-mojang
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R1
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R1-mojang
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R2
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R2-mojang
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R3
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R3-mojang
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R4
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R4-mojang
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R5
- ${project.parent.version}
- compile
-
-
- de.rapha149.signgui
- signgui-1_21_R5-mojang
- ${project.parent.version}
- compile
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-shade-plugin
- 3.5.3
-
-
- package
-
- shade
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-javadoc-plugin
- 3.2.0
-
- 8
-
-
-
- attach-javadocs
-
- jar
-
-
- true
-
- de.rapha149.signgui:signgui-wrapper
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-dependency-plugin
- 3.6.0
-
-
- src-dependencies
- package
-
- unpack-dependencies
-
-
- sources
- false
- ${project.build.directory}/external-sources
- de.rapha149.signgui
-
-
-
-
-
- org.codehaus.mojo
- build-helper-maven-plugin
- 3.4.0
-
-
- add-source
- generate-sources
-
- add-source
-
-
-
- ${project.build.directory}/external-sources
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-source-plugin
- 3.3.0
-
-
- attach-sources
-
- jar
-
-
-
-
-
- maven-clean-plugin
- 2.5
-
-
- auto-clean
- package
-
- clean
-
-
- true
-
-
- ${project.build.directory}/external-sources
-
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-gpg-plugin
- 3.1.0
-
-
- sign-artifacts
- verify
-
- sign
-
-
-
-
- --pinentry-mode
- loopback
-
-
-
-
-
-
- org.codehaus.mojo
- flatten-maven-plugin
- 1.6.0
-
- oss
-
-
-
-
- flatten
- process-resources
-
- flatten
-
-
-
-
- flatten.clean
- clean
-
- clean
-
-
-
-
-
- org.sonatype.central
- central-publishing-maven-plugin
- 0.1.2
- true
-
- central
- true
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
-
- 8
- 8
-
-
-
-
-
diff --git a/api/src/main/java/de/rapha149/signgui/SignGUI.java b/api/src/main/java/de/rapha149/signgui/SignGUI.java
index 1f37daa0..ec0398b6 100644
--- a/api/src/main/java/de/rapha149/signgui/SignGUI.java
+++ b/api/src/main/java/de/rapha149/signgui/SignGUI.java
@@ -3,9 +3,11 @@
import de.rapha149.signgui.SignGUIAction.SignGUIActionInfo;
import de.rapha149.signgui.exception.SignGUIException;
import de.rapha149.signgui.exception.SignGUIVersionException;
+import de.rapha149.signgui.util.PlatformDetector;
+import de.rapha149.signgui.util.SignGUIScheduler;
+import de.rapha149.signgui.util.scheduler.SchedulerFactory;
import de.rapha149.signgui.version.VersionMatcher;
import org.apache.commons.lang.Validate;
-import org.bukkit.Bukkit;
import org.bukkit.DyeColor;
import org.bukkit.Location;
import org.bukkit.Material;
@@ -59,13 +61,27 @@ public static SignGUIBuilder builder() throws SignGUIVersionException {
*
* Note: if there already is a sign gui open for the player, it will be closed and the {@link SignGUIFinishHandler} will not be called.
* It is recommended to avoid opening a sign gui for a player that already has one open.
+ *
+ * On Folia (and when a plugin is bound), the open is scheduled on the player's entity/region thread.
*
* @param player The player to open the gui for.
* @throws de.rapha149.signgui.exception.SignGUIException If an error occurs while opening the gui.
*/
public void open(Player player) throws SignGUIException {
Validate.notNull(player, "The player cannot be null");
+ if (PlatformDetector.isFolia() && plugin == null) {
+ throw new SignGUIException("Folia requires callHandlerSynchronously(plugin) before opening a SignGUI");
+ }
+
+ if (plugin != null) {
+ SignGUIScheduler.setPlugin(plugin);
+ SchedulerFactory.getScheduler().runTask(plugin, player, () -> openNow(player));
+ } else {
+ openNow(player);
+ }
+ }
+ private void openNow(Player player) {
try {
VersionMatcher.getWrapper().openSignEditor(player, lines, adventureLines, type, color, glow, signLoc, (signEditor, resultLines) -> {
Runnable runnable = () -> {
@@ -107,11 +123,18 @@ public void open(Player player) throws SignGUIException {
action.execute(this, signEditor, player);
};
- if (callHandlerSynchronously)
- Bukkit.getScheduler().runTask(plugin, runnable);
- else
+ // Folia always hops to the entity scheduler; elsewhere only when requested.
+ if (callHandlerSynchronously || PlatformDetector.isFolia()) {
+ if (plugin == null) {
+ throw new SignGUIException("callHandlerSynchronously(plugin) is required for synchronous / Folia finish handling");
+ }
+ SchedulerFactory.getScheduler().runTask(plugin, player, runnable);
+ } else {
runnable.run();
+ }
});
+ } catch (SignGUIException e) {
+ throw e;
} catch (Exception e) {
throw new SignGUIException("Failed to open sign gui", e);
}
diff --git a/api/src/main/java/de/rapha149/signgui/SignGUIAction.java b/api/src/main/java/de/rapha149/signgui/SignGUIAction.java
index 16175475..27f3debd 100644
--- a/api/src/main/java/de/rapha149/signgui/SignGUIAction.java
+++ b/api/src/main/java/de/rapha149/signgui/SignGUIAction.java
@@ -1,7 +1,8 @@
package de.rapha149.signgui;
+import de.rapha149.signgui.util.SignGUIScheduler;
+import de.rapha149.signgui.util.scheduler.SchedulerFactory;
import org.apache.commons.lang.Validate;
-import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.plugin.java.JavaPlugin;
@@ -101,7 +102,7 @@ public void execute(SignGUI gui, SignEditor signEditor, Player player) {
/**
* Creates a new SignGUIAction that opens an inventory.
- * The inventory is opened synchronously by calling the method {@link org.bukkit.scheduler.BukkitScheduler#runTask(org.bukkit.plugin.Plugin, Runnable)}
+ * The inventory is opened on the player's entity/region thread (Folia/Paper) or the main thread (Bukkit).
*
* @param plugin Your {@link org.bukkit.plugin.java.JavaPlugin} instance
* @param inventory The inventory to open
@@ -122,7 +123,8 @@ public SignGUIActionInfo getInfo() {
@Override
public void execute(SignGUI gui, SignEditor signEditor, Player player) {
- Bukkit.getScheduler().runTask(plugin, () -> player.openInventory(inventory));
+ SignGUIScheduler.setPlugin(plugin);
+ SchedulerFactory.getScheduler().runTask(plugin, player, () -> player.openInventory(inventory));
}
};
}
@@ -154,7 +156,8 @@ public void execute(SignGUI gui, SignEditor signEditor, Player player) {
}
/**
- * Creates a new SignGUIAction that runs a runnable synchronously.
+ * Creates a new SignGUIAction that runs a runnable on the player's entity/region thread
+ * (Folia/Paper) or the main thread (Bukkit).
*
* @param plugin Your {@link org.bukkit.plugin.java.JavaPlugin} instance
* @param runnable The runnable to run
@@ -175,7 +178,8 @@ public SignGUIActionInfo getInfo() {
@Override
public void execute(SignGUI gui, SignEditor signEditor, Player player) {
- Bukkit.getScheduler().runTask(plugin, runnable);
+ SignGUIScheduler.setPlugin(plugin);
+ SchedulerFactory.getScheduler().runTask(plugin, player, runnable);
}
};
}
diff --git a/api/src/main/java/de/rapha149/signgui/SignGUIBuilder.java b/api/src/main/java/de/rapha149/signgui/SignGUIBuilder.java
index 33babccc..31efcae3 100644
--- a/api/src/main/java/de/rapha149/signgui/SignGUIBuilder.java
+++ b/api/src/main/java/de/rapha149/signgui/SignGUIBuilder.java
@@ -1,12 +1,13 @@
package de.rapha149.signgui;
import de.rapha149.signgui.version.VersionWrapper;
+import de.rapha149.signgui.util.PlatformDetector;
+import de.rapha149.signgui.util.SignGUIScheduler;
import org.apache.commons.lang.Validate;
import org.bukkit.DyeColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
-import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.Arrays;
@@ -168,14 +169,37 @@ public SignGUIBuilder setHandler(SignGUIFinishHandler handler) {
}
/**
- * If called the handler will be called synchronously by calling the method {@link org.bukkit.scheduler.BukkitScheduler#runTask(Plugin, Runnable)}
+ * Binds your plugin and schedules the finish handler on the correct thread.
+ *
+ * Required on Folia. Recommended on all platforms.
+ * On Folia/Paper region platforms, work runs on the player's {@code EntityScheduler}.
+ * On Bukkit/Spigot, work runs on the main thread.
+ *
+ * Also enables delayed sign reopens (same block position) via the region scheduler
+ * instead of a raw thread pool.
*
* @param plugin Your {@link org.bukkit.plugin.java.JavaPlugin} instance.
* @return The {@link SignGUIBuilder} instance
*/
public SignGUIBuilder callHandlerSynchronously(JavaPlugin plugin) {
+ Validate.notNull(plugin, "The plugin cannot be null");
this.callHandlerSynchronously = true;
this.plugin = plugin;
+ SignGUIScheduler.setPlugin(plugin);
+ return this;
+ }
+
+ /**
+ * Binds your plugin for Folia/Paper region scheduling without forcing the finish
+ * handler onto the entity thread. Prefer {@link #callHandlerSynchronously(JavaPlugin)}.
+ *
+ * @param plugin Your {@link org.bukkit.plugin.java.JavaPlugin} instance.
+ * @return The {@link SignGUIBuilder} instance
+ */
+ public SignGUIBuilder setPlugin(JavaPlugin plugin) {
+ Validate.notNull(plugin, "The plugin cannot be null");
+ this.plugin = plugin;
+ SignGUIScheduler.setPlugin(plugin);
return this;
}
@@ -186,6 +210,11 @@ public SignGUIBuilder callHandlerSynchronously(JavaPlugin plugin) {
*/
public SignGUI build() {
Validate.notNull(handler, "handler must be set");
+ if (PlatformDetector.isFolia()) {
+ Validate.notNull(plugin, "Folia requires callHandlerSynchronously(plugin) (or setPlugin(plugin))");
+ // Finish handling must always hop off the Netty thread on Folia.
+ callHandlerSynchronously = true;
+ }
return new SignGUI(lines, adventureLines, type, color, glow, loc, handler, callHandlerSynchronously, plugin);
}
}
diff --git a/api/src/main/java/de/rapha149/signgui/util/PlatformDetector.java b/api/src/main/java/de/rapha149/signgui/util/PlatformDetector.java
new file mode 100644
index 00000000..9b6b1af1
--- /dev/null
+++ b/api/src/main/java/de/rapha149/signgui/util/PlatformDetector.java
@@ -0,0 +1,103 @@
+package de.rapha149.signgui.util;
+
+import org.bukkit.Bukkit;
+
+/**
+ * Utility class for detecting the server platform.
+ */
+public class PlatformDetector {
+
+ private static PlatformType detectedPlatform;
+ private static boolean hasFoliaClasses;
+ private static boolean hasRegionScheduler;
+
+ static {
+ detectPlatform();
+ }
+
+ /**
+ * Detects the server platform.
+ */
+ private static void detectPlatform() {
+ try {
+ Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
+ hasFoliaClasses = true;
+ } catch (ClassNotFoundException ignored) {
+ hasFoliaClasses = false;
+ }
+
+ try {
+ Class.forName("io.papermc.paper.threadedregions.scheduler.RegionScheduler");
+ hasRegionScheduler = true;
+ } catch (ClassNotFoundException ignored) {
+ hasRegionScheduler = false;
+ }
+
+ String serverVersion = Bukkit.getVersion();
+ String serverName = Bukkit.getName();
+
+ if (hasFoliaClasses && hasRegionScheduler) {
+ detectedPlatform = PlatformType.FOLIA;
+ } else if (serverVersion.contains("Canvas") || serverName.contains("Canvas")) {
+ detectedPlatform = PlatformType.CANVAS;
+ } else if (serverVersion.contains("Archlight") || serverName.contains("Archlight")) {
+ detectedPlatform = PlatformType.ARCHLIGHT;
+ } else {
+ detectedPlatform = PlatformType.BUKKIT;
+ }
+ }
+
+ /**
+ * Gets the detected platform type.
+ *
+ * @return The detected platform type
+ */
+ public static PlatformType getPlatformType() {
+ return detectedPlatform;
+ }
+
+ /**
+ * Checks if the server is running on Folia.
+ *
+ * @return true if running on Folia, false otherwise
+ */
+ public static boolean isFolia() {
+ return detectedPlatform == PlatformType.FOLIA;
+ }
+
+ /**
+ * Checks if the server is running on CanvasMC.
+ *
+ * @return true if running on CanvasMC, false otherwise
+ */
+ public static boolean isCanvas() {
+ return detectedPlatform == PlatformType.CANVAS;
+ }
+
+ /**
+ * Checks if the server is running on Archlight.
+ *
+ * @return true if running on Archlight, false otherwise
+ */
+ public static boolean isArchlight() {
+ return detectedPlatform == PlatformType.ARCHLIGHT;
+ }
+
+ /**
+ * Checks if the server supports region-based scheduling (Folia).
+ *
+ * @return true if region scheduling is supported, false otherwise
+ */
+ public static boolean hasRegionScheduler() {
+ return hasRegionScheduler;
+ }
+
+ /**
+ * Checks if the server has Folia classes available.
+ *
+ * @return true if Folia classes are available, false otherwise
+ */
+ public static boolean hasFoliaClasses() {
+ return hasFoliaClasses;
+ }
+}
diff --git a/api/src/main/java/de/rapha149/signgui/util/PlatformType.java b/api/src/main/java/de/rapha149/signgui/util/PlatformType.java
new file mode 100644
index 00000000..0f394f50
--- /dev/null
+++ b/api/src/main/java/de/rapha149/signgui/util/PlatformType.java
@@ -0,0 +1,26 @@
+package de.rapha149.signgui.util;
+
+/**
+ * Enum representing different server platforms.
+ */
+public enum PlatformType {
+ /**
+ * Folia - Paper's regionized multithreaded server
+ */
+ FOLIA,
+
+ /**
+ * CanvasMC - Fork of Paper
+ */
+ CANVAS,
+
+ /**
+ * Archlight - Forge+Bukkit hybrid server
+ */
+ ARCHLIGHT,
+
+ /**
+ * Standard Bukkit/Spigot/Paper server
+ */
+ BUKKIT
+}
diff --git a/api/src/main/java/de/rapha149/signgui/util/SchedulerAdapter.java b/api/src/main/java/de/rapha149/signgui/util/SchedulerAdapter.java
new file mode 100644
index 00000000..9c78c23c
--- /dev/null
+++ b/api/src/main/java/de/rapha149/signgui/util/SchedulerAdapter.java
@@ -0,0 +1,81 @@
+package de.rapha149.signgui.util;
+
+import org.bukkit.Location;
+import org.bukkit.entity.Entity;
+import org.bukkit.plugin.java.JavaPlugin;
+
+/**
+ * Adapter interface for scheduling tasks across different server platforms.
+ */
+public interface SchedulerAdapter {
+
+ /**
+ * Runs a task synchronously on the main thread or appropriate region thread.
+ *
+ * @param plugin The plugin instance
+ * @param task The task to run
+ */
+ void runTask(JavaPlugin plugin, Runnable task);
+
+ /**
+ * Runs a task synchronously on the main thread or appropriate region thread.
+ *
+ * @param plugin The plugin instance
+ * @param entity The entity to run the task for (used for Folia region scheduling)
+ * @param task The task to run
+ */
+ void runTask(JavaPlugin plugin, Entity entity, Runnable task);
+
+ /**
+ * Runs a task synchronously at a specific location.
+ *
+ * @param plugin The plugin instance
+ * @param location The location to run the task at (used for Folia region scheduling)
+ * @param task The task to run
+ */
+ void runTask(JavaPlugin plugin, Location location, Runnable task);
+
+ /**
+ * Runs a task asynchronously.
+ *
+ * @param plugin The plugin instance
+ * @param task The task to run
+ */
+ void runTaskAsynchronously(JavaPlugin plugin, Runnable task);
+
+ /**
+ * Schedules a delayed task synchronously.
+ *
+ * @param plugin The plugin instance
+ * @param task The task to run
+ * @param delayTicks The delay in ticks
+ */
+ void runTaskLater(JavaPlugin plugin, Runnable task, long delayTicks);
+
+ /**
+ * Schedules a delayed task synchronously for an entity.
+ *
+ * @param plugin The plugin instance
+ * @param entity The entity to run the task for (used for Folia region scheduling)
+ * @param task The task to run
+ * @param delayTicks The delay in ticks
+ */
+ void runTaskLater(JavaPlugin plugin, Entity entity, Runnable task, long delayTicks);
+
+ /**
+ * Schedules a delayed task synchronously at a location.
+ *
+ * @param plugin The plugin instance
+ * @param location The location to run the task at (used for Folia region scheduling)
+ * @param task The task to run
+ * @param delayTicks The delay in ticks
+ */
+ void runTaskLater(JavaPlugin plugin, Location location, Runnable task, long delayTicks);
+
+ /**
+ * Checks if the current thread is the main server thread or appropriate region thread.
+ *
+ * @return true if on the main/region thread, false otherwise
+ */
+ boolean isOnMainThread();
+}
diff --git a/api/src/main/java/de/rapha149/signgui/util/SignGUIScheduler.java b/api/src/main/java/de/rapha149/signgui/util/SignGUIScheduler.java
new file mode 100644
index 00000000..0eb866ff
--- /dev/null
+++ b/api/src/main/java/de/rapha149/signgui/util/SignGUIScheduler.java
@@ -0,0 +1,150 @@
+package de.rapha149.signgui.util;
+
+import de.rapha149.signgui.SignGUIBridge;
+import de.rapha149.signgui.util.scheduler.SchedulerFactory;
+import org.bukkit.Location;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Advanced SignGUI scheduling facade for Folia, Paper, and Bukkit.
+ *
+ * Prefers Paper/Folia region schedulers ({@code EntityScheduler}, {@code RegionScheduler},
+ * {@code GlobalRegionScheduler}, {@code AsyncScheduler}) and falls back to the Bukkit
+ * scheduler on platforms that do not expose them.
+ *
+ * On Folia, a plugin must be bound via {@link #setPlugin(JavaPlugin)} (usually through
+ * {@code SignGUIBuilder#callHandlerSynchronously(JavaPlugin)}) before opening a sign GUI.
+ */
+public final class SignGUIScheduler {
+
+ private static final ScheduledExecutorService LEGACY_FALLBACK =
+ Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "SignGUI-LegacyScheduler");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private static volatile JavaPlugin plugin;
+
+ private SignGUIScheduler() {
+ }
+
+ /**
+ * Binds the owning plugin used for Folia/Paper region scheduling.
+ *
+ * @param plugin the plugin instance, may be null to clear
+ */
+ public static void setPlugin(JavaPlugin plugin) {
+ SignGUIScheduler.plugin = plugin;
+ if (plugin != null) {
+ // Wire NMS reopen delays through Folia/Paper entity schedulers.
+ SignGUIBridge.setDelayedScheduler(SignGUIScheduler::runLater);
+ }
+ }
+
+ /**
+ * @return the bound plugin, or null if none was set
+ */
+ public static JavaPlugin getPlugin() {
+ return plugin;
+ }
+
+ /**
+ * Runs {@code task} on the player's owning region/entity thread (or the main thread on Bukkit).
+ */
+ public static void run(Player player, Runnable task) {
+ JavaPlugin p = requirePluginForFolia();
+ if (p != null && player != null) {
+ SchedulerFactory.getScheduler().runTask(p, player, task);
+ return;
+ }
+ if (p != null) {
+ SchedulerFactory.getScheduler().runTask(p, task);
+ return;
+ }
+ task.run();
+ }
+
+ /**
+ * Runs {@code task} on the region that owns {@code location}.
+ */
+ public static void runAt(Location location, Runnable task) {
+ JavaPlugin p = requirePluginForFolia();
+ if (p != null && location != null && location.getWorld() != null) {
+ SchedulerFactory.getScheduler().runTask(p, location, task);
+ return;
+ }
+ if (p != null) {
+ SchedulerFactory.getScheduler().runTask(p, task);
+ return;
+ }
+ task.run();
+ }
+
+ /**
+ * Runs {@code task} on the global region / main thread.
+ */
+ public static void runGlobal(Runnable task) {
+ JavaPlugin p = requirePluginForFolia();
+ if (p != null) {
+ SchedulerFactory.getScheduler().runTask(p, task);
+ return;
+ }
+ task.run();
+ }
+
+ /**
+ * Runs {@code task} off the tick threads (Folia {@code AsyncScheduler} / Bukkit async).
+ */
+ public static void runAsync(Runnable task) {
+ JavaPlugin p = requirePluginForFolia();
+ if (p != null) {
+ SchedulerFactory.getScheduler().runTaskAsynchronously(p, task);
+ return;
+ }
+ LEGACY_FALLBACK.execute(task);
+ }
+
+ /**
+ * Delayed entity-owned execution used by NMS wrappers when reopening a sign (~4 ticks ≈ 200ms).
+ */
+ public static void runLater(Player player, Location location, Runnable task, long delayTicks) {
+ long delay = Math.max(1L, delayTicks);
+ JavaPlugin p = plugin;
+ de.rapha149.signgui.util.SchedulerAdapter scheduler = SchedulerFactory.getScheduler();
+
+ if (p != null && player != null && player.isOnline()) {
+ scheduler.runTaskLater(p, player, task, delay);
+ return;
+ }
+ if (p != null && location != null && location.getWorld() != null) {
+ scheduler.runTaskLater(p, location, task, delay);
+ return;
+ }
+ if (p != null) {
+ scheduler.runTaskLater(p, task, delay);
+ return;
+ }
+
+ if (PlatformDetector.isFolia()) {
+ throw new IllegalStateException(
+ "SignGUI on Folia requires callHandlerSynchronously(plugin) so delayed opens can use the entity scheduler.");
+ }
+
+ LEGACY_FALLBACK.schedule(task, delay * 50L, TimeUnit.MILLISECONDS);
+ }
+
+ private static JavaPlugin requirePluginForFolia() {
+ JavaPlugin p = plugin;
+ if (p == null && PlatformDetector.isFolia()) {
+ throw new IllegalStateException(
+ "SignGUI on Folia requires callHandlerSynchronously(plugin) (or SignGUIScheduler.setPlugin).");
+ }
+ return p;
+ }
+}
diff --git a/api/src/main/java/de/rapha149/signgui/util/scheduler/BukkitSchedulerAdapter.java b/api/src/main/java/de/rapha149/signgui/util/scheduler/BukkitSchedulerAdapter.java
new file mode 100644
index 00000000..95e97413
--- /dev/null
+++ b/api/src/main/java/de/rapha149/signgui/util/scheduler/BukkitSchedulerAdapter.java
@@ -0,0 +1,62 @@
+package de.rapha149.signgui.util.scheduler;
+
+import de.rapha149.signgui.util.SchedulerAdapter;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.entity.Entity;
+import org.bukkit.plugin.java.JavaPlugin;
+
+/**
+ * Standard Bukkit/Spigot/Paper scheduler implementation.
+ * Also used for CanvasMC and Archlight which are compatible with Bukkit API.
+ */
+public class BukkitSchedulerAdapter implements SchedulerAdapter {
+
+ @Override
+ public void runTask(JavaPlugin plugin, Runnable task) {
+ if (Bukkit.isPrimaryThread()) {
+ task.run();
+ } else {
+ Bukkit.getScheduler().runTask(plugin, task);
+ }
+ }
+
+ @Override
+ public void runTask(JavaPlugin plugin, Entity entity, Runnable task) {
+ runTask(plugin, task);
+ }
+
+ @Override
+ public void runTask(JavaPlugin plugin, Location location, Runnable task) {
+ runTask(plugin, task);
+ }
+
+ @Override
+ public void runTaskAsynchronously(JavaPlugin plugin, Runnable task) {
+ if (Bukkit.isPrimaryThread()) {
+ Bukkit.getScheduler().runTaskAsynchronously(plugin, task);
+ } else {
+ task.run();
+ }
+ }
+
+ @Override
+ public void runTaskLater(JavaPlugin plugin, Runnable task, long delayTicks) {
+ Bukkit.getScheduler().runTaskLater(plugin, task, delayTicks);
+ }
+
+ @Override
+ public void runTaskLater(JavaPlugin plugin, Entity entity, Runnable task, long delayTicks) {
+ runTaskLater(plugin, task, delayTicks);
+ }
+
+ @Override
+ public void runTaskLater(JavaPlugin plugin, Location location, Runnable task, long delayTicks) {
+ runTaskLater(plugin, task, delayTicks);
+ }
+
+ @Override
+ public boolean isOnMainThread() {
+ return Bukkit.isPrimaryThread();
+ }
+}
diff --git a/api/src/main/java/de/rapha149/signgui/util/scheduler/FoliaSchedulerAdapter.java b/api/src/main/java/de/rapha149/signgui/util/scheduler/FoliaSchedulerAdapter.java
new file mode 100644
index 00000000..458eeac8
--- /dev/null
+++ b/api/src/main/java/de/rapha149/signgui/util/scheduler/FoliaSchedulerAdapter.java
@@ -0,0 +1,218 @@
+package de.rapha149.signgui.util.scheduler;
+
+import de.rapha149.signgui.util.PlatformDetector;
+import de.rapha149.signgui.util.SchedulerAdapter;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.entity.Entity;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.lang.reflect.Method;
+import java.util.function.Consumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Region-aware scheduler for Folia and modern Paper.
+ *
+ * Uses reflection so the API module can still compile against older Spigot baselines
+ * while calling {@code EntityScheduler} / {@code RegionScheduler} /
+ * {@code GlobalRegionScheduler} / {@code AsyncScheduler} at runtime.
+ *
+ * On Folia, never falls back to {@link BukkitSchedulerAdapter} (that API is unsafe there).
+ */
+public class FoliaSchedulerAdapter implements SchedulerAdapter {
+
+ private static final Logger LOGGER = Logger.getLogger("SignGUI");
+
+ private static final Method GET_REGION_SCHEDULER;
+ private static final Method GET_GLOBAL_REGION_SCHEDULER;
+ private static final Method GET_ASYNC_SCHEDULER;
+ private static final Method REGION_EXECUTE;
+ private static final Method REGION_RUN_DELAYED;
+ private static final Method GLOBAL_RUN;
+ private static final Method GLOBAL_RUN_DELAYED;
+ private static final Method ASYNC_RUN_NOW;
+ private static final Method ENTITY_GET_SCHEDULER;
+ private static final Method ENTITY_EXECUTE;
+ private static final Method ENTITY_RUN_DELAYED;
+ private static final Method IS_OWNED_BY_CURRENT_REGION;
+ private static final boolean AVAILABLE;
+
+ static {
+ Method getRegionScheduler = null;
+ Method getGlobalRegionScheduler = null;
+ Method getAsyncScheduler = null;
+ Method regionExecute = null;
+ Method regionRunDelayed = null;
+ Method globalRun = null;
+ Method globalRunDelayed = null;
+ Method asyncRunNow = null;
+ Method entityGetScheduler = null;
+ Method entityExecute = null;
+ Method entityRunDelayed = null;
+ Method isOwnedByCurrentRegion = null;
+ boolean available = false;
+
+ try {
+ Class> regionSchedulerClass = Class.forName("io.papermc.paper.threadedregions.scheduler.RegionScheduler");
+ Class> globalRegionSchedulerClass = Class.forName("io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler");
+ Class> entitySchedulerClass = Class.forName("io.papermc.paper.threadedregions.scheduler.EntityScheduler");
+ Class> asyncSchedulerClass = Class.forName("io.papermc.paper.threadedregions.scheduler.AsyncScheduler");
+
+ Class> serverClass = org.bukkit.Server.class;
+ getRegionScheduler = serverClass.getMethod("getRegionScheduler");
+ getGlobalRegionScheduler = serverClass.getMethod("getGlobalRegionScheduler");
+ getAsyncScheduler = serverClass.getMethod("getAsyncScheduler");
+
+ regionExecute = regionSchedulerClass.getMethod("execute", org.bukkit.plugin.Plugin.class, Location.class, Runnable.class);
+ regionRunDelayed = regionSchedulerClass.getMethod("runDelayed", org.bukkit.plugin.Plugin.class, Location.class,
+ Consumer.class, long.class);
+ globalRun = globalRegionSchedulerClass.getMethod("run", org.bukkit.plugin.Plugin.class, Consumer.class);
+ globalRunDelayed = globalRegionSchedulerClass.getMethod("runDelayed", org.bukkit.plugin.Plugin.class,
+ Consumer.class, long.class);
+ asyncRunNow = asyncSchedulerClass.getMethod("runNow", org.bukkit.plugin.Plugin.class, Consumer.class);
+
+ entityGetScheduler = Entity.class.getMethod("getScheduler");
+ entityExecute = entitySchedulerClass.getMethod("execute", org.bukkit.plugin.Plugin.class, Runnable.class,
+ Runnable.class, long.class);
+ entityRunDelayed = entitySchedulerClass.getMethod("runDelayed", org.bukkit.plugin.Plugin.class,
+ Consumer.class, Runnable.class, long.class);
+
+ try {
+ Class> regionizedServerClass = Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
+ isOwnedByCurrentRegion = regionizedServerClass.getMethod("isOwnedByCurrentRegion", Location.class);
+ } catch (ClassNotFoundException | NoSuchMethodException ignored) {
+ // Paper without Folia still exposes region schedulers; ownership check is Folia-only.
+ }
+
+ available = true;
+ } catch (ReflectiveOperationException e) {
+ LOGGER.log(Level.WARNING, "SignGUI: Paper/Folia region schedulers unavailable: " + e.getMessage());
+ }
+
+ GET_REGION_SCHEDULER = getRegionScheduler;
+ GET_GLOBAL_REGION_SCHEDULER = getGlobalRegionScheduler;
+ GET_ASYNC_SCHEDULER = getAsyncScheduler;
+ REGION_EXECUTE = regionExecute;
+ REGION_RUN_DELAYED = regionRunDelayed;
+ GLOBAL_RUN = globalRun;
+ GLOBAL_RUN_DELAYED = globalRunDelayed;
+ ASYNC_RUN_NOW = asyncRunNow;
+ ENTITY_GET_SCHEDULER = entityGetScheduler;
+ ENTITY_EXECUTE = entityExecute;
+ ENTITY_RUN_DELAYED = entityRunDelayed;
+ IS_OWNED_BY_CURRENT_REGION = isOwnedByCurrentRegion;
+ AVAILABLE = available;
+ }
+
+ public static boolean isAvailable() {
+ return AVAILABLE;
+ }
+
+ @Override
+ public void runTask(JavaPlugin plugin, Runnable task) {
+ try {
+ Object globalScheduler = GET_GLOBAL_REGION_SCHEDULER.invoke(Bukkit.getServer());
+ GLOBAL_RUN.invoke(globalScheduler, plugin, (Consumer