diff --git a/surf-api-paper/surf-api-paper-plugin-test/build.gradle.kts b/surf-api-paper/surf-api-paper-plugin-test/build.gradle.kts index 3ab84a640..945b0e580 100644 --- a/surf-api-paper/surf-api-paper-plugin-test/build.gradle.kts +++ b/surf-api-paper/surf-api-paper-plugin-test/build.gradle.kts @@ -13,6 +13,7 @@ description = "surf-api-paper-plugin-test" dependencies { compileOnlyApi(projects.surfApiPaper.surfApiPaper) + compileOnlyApi(projects.surfApiPaper.surfApiPaperServer) compileOnlyApi(libs.commandapi.paper) paperweight.paperDevBundle(libs.paper.api.get().version) diff --git a/surf-api-paper/surf-api-paper-plugin-test/src/main/java/dev/slne/surf/surfapi/bukkit/test/command/SurfApiTestCommand.java b/surf-api-paper/surf-api-paper-plugin-test/src/main/java/dev/slne/surf/surfapi/bukkit/test/command/SurfApiTestCommand.java index 1672dd4a2..688a0b584 100644 --- a/surf-api-paper/surf-api-paper-plugin-test/src/main/java/dev/slne/surf/surfapi/bukkit/test/command/SurfApiTestCommand.java +++ b/surf-api-paper/surf-api-paper-plugin-test/src/main/java/dev/slne/surf/surfapi/bukkit/test/command/SurfApiTestCommand.java @@ -16,6 +16,7 @@ import dev.slne.surf.api.paper.test.command.subcommands.SuspendCommandExecutionTest; import dev.slne.surf.api.paper.test.command.subcommands.ToastTest; import dev.slne.surf.api.paper.test.command.subcommands.VisualizerTest; +import dev.slne.surf.surfapi.bukkit.test.command.subcommands.display.DisplayTest; public class SurfApiTestCommand extends CommandAPICommand { @@ -39,7 +40,8 @@ public SurfApiTestCommand() { new InventoryTest("inventory"), new ToastTest("toast"), new SuspendCommandExecutionTest("suspendCommandExecution"), - new SummonCommandTest("summoncommand") + new SummonCommandTest("summoncommand"), + new DisplayTest("display") ); } } diff --git a/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/display/DisplayTest.kt b/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/display/DisplayTest.kt new file mode 100644 index 000000000..eeebe4aa7 --- /dev/null +++ b/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/display/DisplayTest.kt @@ -0,0 +1,254 @@ +package dev.slne.surf.surfapi.bukkit.test.command.subcommands.display + +import dev.jorel.commandapi.CommandAPICommand +import dev.jorel.commandapi.kotlindsl.playerExecutor +import dev.jorel.commandapi.kotlindsl.subcommand +import dev.slne.surf.api.paper.display.color +import dev.slne.surf.api.paper.display.cursor.CursorStyle +import dev.slne.surf.api.paper.display.document.document +import dev.slne.surf.api.paper.display.shape.Shape +import dev.slne.surf.api.paper.display.style.* +import dev.slne.surf.api.paper.server.display.Display +import dev.slne.surf.api.paper.server.display.DisplayManager +import dev.slne.surf.api.paper.server.display.modal.confirmDialog + +/** + * Test command for the Display/UI API framework. + * + * Usage: + * - `/surfapitest display open` — Opens a demo display with styled UI elements + * - `/surfapitest display close` — Closes the active display + */ +class DisplayTest(name: String) : CommandAPICommand(name) { + + // Catppuccin Mocha palette + companion object { + private val BG = color(0x1E1E2E) + private val SURFACE = color(0x313244) + private val OVERLAY = color(0x45475A) + private val TEXT = color(0xCDD6F4) + private val SUBTEXT = color(0x9399B2) + private val BLUE = color(0x89B4FA) + private val GREEN = color(0xA6E3A1) + private val RED = color(0xF38BA8) + private val YELLOW = color(0xF9E2AF) + private val MAUVE = color(0xCBA6F7) + private val BORDER = color(0x585B70) + } + + init { + subcommand("open") { + playerExecutor { player, _ -> + // Create a 384x256 pixel document (3x2 map tiles) + val doc = document(384, 256) { + style { + backgroundColor = BG + padding = Insets.all(8) + gap = 6 + } + + // --- Title Bar --- + div { + style { + backgroundColor = SURFACE + padding = Insets(6, 10, 6, 10) + flexDirection = FlexDirection.ROW + alignItems = AlignItems.CENTER + gap = 8 + } + shape(Shape.circle(4, filled = true)) { + style { color = BLUE } + } + label("Surf Display API Demo") { + style { color = TEXT; fontSize = 16 } + } + } + + // --- Content Area --- + div { + style { + flexDirection = FlexDirection.ROW + gap = 8 + } + + // Left column - Shape showcase + div { + style { + width = 120 + backgroundColor = SURFACE + padding = Insets.all(6) + gap = 4 + border = Border(1, BORDER) + } + label("Shapes") { + style { color = BLUE; fontSize = 12 } + } + div { + style { + flexDirection = FlexDirection.ROW + gap = 6 + alignItems = AlignItems.CENTER + } + shape(Shape.circle(8, filled = true)) { + style { color = GREEN } + } + shape(Shape.rectangle(16, 16, filled = true)) { + style { color = RED } + } + shape(Shape.triangle(16, 14, filled = true)) { + style { color = YELLOW } + } + } + div { + style { + flexDirection = FlexDirection.ROW + gap = 6 + alignItems = AlignItems.CENTER + } + shape(Shape.ellipse(10, 6, filled = true)) { + style { color = MAUVE } + } + shape(Shape.roundedRectangle(20, 14, 4, filled = true)) { + style { color = BLUE } + } + } + } + + // Right column - Interactive elements + div { + style { + backgroundColor = SURFACE + padding = Insets.all(6) + gap = 4 + border = Border(1, BORDER) + } + label("Interactive Elements") { + style { color = BLUE; fontSize = 12 } + } + + // Clickable button + div { + style { + backgroundColor = OVERLAY + padding = Insets(3, 8, 3, 8) + border = Border(1, GREEN) + cursor = CursorStyle.POINTER + } + label("Click Me!") { + style { color = GREEN; fontSize = 11 } + } + hoverable( + onEnter = { _ -> + style.backgroundColor = color(0x585B70) + }, + onExit = { _ -> + style.backgroundColor = OVERLAY + } + ) + onClick { ctx -> + player.sendMessage("Display clicked at (${ctx.pixelX}, ${ctx.pixelY})!") + } + } + + // Another button that opens a modal + div { + style { + backgroundColor = OVERLAY + padding = Insets(3, 8, 3, 8) + border = Border(1, MAUVE) + cursor = CursorStyle.POINTER + } + label("Show Modal") { + style { color = MAUVE; fontSize = 11 } + } + hoverable( + onEnter = { _ -> + style.backgroundColor = color(0x585B70) + }, + onExit = { _ -> + style.backgroundColor = OVERLAY + } + ) + } + + label("Text alignment:") { + style { color = SUBTEXT; fontSize = 10 } + } + + // Text alignment demo + div { + style { + backgroundColor = color(0x11111B) + padding = Insets.all(3) + gap = 1 + } + label("Left aligned") { + style { color = TEXT; fontSize = 10; textAlign = TextAlign.LEFT } + } + label("Center aligned") { + style { color = TEXT; fontSize = 10; textAlign = TextAlign.CENTER } + } + label("Right aligned") { + style { color = TEXT; fontSize = 10; textAlign = TextAlign.RIGHT } + } + } + } + } + + // --- Footer --- + div { + style { + backgroundColor = SURFACE + padding = Insets(4, 10, 4, 10) + flexDirection = FlexDirection.ROW + justifyContent = JustifyContent.SPACE_BETWEEN + } + label("Sneak to close") { + style { color = SUBTEXT; fontSize = 10 } + } + label("Surf API v1.0") { + style { color = SUBTEXT; fontSize = 10 } + } + } + } + + val display = Display(doc) + + // Wire up the "Show Modal" button to actually show a modal + // The second child of the content area's right column (index 1) is the modal button + val contentArea = doc.root.children[1] // content row + val rightColumn = contentArea.children[1] // right column div + val modalButton = rightColumn.children[1] // "Show Modal" button + modalButton.onClick { _ -> + val modal = display.confirmDialog( + title = "Confirm Action", + message = "This is a modal dialog demo.\nDo you want to proceed?", + onConfirm = { + player.sendMessage("Confirmed!") + display.dismissModal() + }, + onCancel = { + player.sendMessage("Cancelled!") + display.dismissModal() + } + ) + display.showModal(modal) + } + + DisplayManager.open(player, display) + player.sendMessage("Display opened! Look around to move cursor. Sneak to close.") + } + } + + subcommand("close") { + playerExecutor { player, _ -> + if (DisplayManager.hasDisplay(player.uniqueId)) { + DisplayManager.close(player) + player.sendMessage("Display closed.") + } else { + player.sendMessage("No active display.") + } + } + } + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/PaperInstance.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/PaperInstance.kt index 8336850df..dbbb74ccf 100644 --- a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/PaperInstance.kt +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/PaperInstance.kt @@ -2,6 +2,7 @@ package dev.slne.surf.api.paper.server import dev.slne.surf.api.core.server.CoreInstance import dev.slne.surf.api.paper.SurfApiPaper +import dev.slne.surf.api.paper.server.display.DisplayLoader import dev.slne.surf.api.paper.server.impl.SurfApiPaperImpl import dev.slne.surf.api.paper.server.inventory.framework.InventoryLoader import dev.slne.surf.api.paper.server.listener.ListenerManager @@ -22,6 +23,7 @@ object PaperInstance : CoreInstance() { super.onEnable() PacketApiLoader.onEnable() + DisplayLoader.onEnable() InventoryLoader.enable() ListenerManager.registerListeners() (SurfApiPaper.INSTANCE as SurfApiPaperImpl).onEnable() @@ -30,6 +32,7 @@ object PaperInstance : CoreInstance() { override suspend fun onDisable() { super.onDisable() + DisplayLoader.onDisable() ListenerManager.unregisterListeners() PacketApiLoader.onDisable() InventoryLoader.disable() diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/Display.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/Display.kt new file mode 100644 index 000000000..fdc8357bb --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/Display.kt @@ -0,0 +1,501 @@ +package dev.slne.surf.api.paper.server.display + +import com.github.retrooper.packetevents.util.Vector3d +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBundle +import dev.slne.surf.api.paper.display.argb +import dev.slne.surf.api.paper.display.behavior.Clickable +import dev.slne.surf.api.paper.display.behavior.Hoverable +import dev.slne.surf.api.paper.display.behavior.InteractionContext +import dev.slne.surf.api.paper.server.display.map.DisplayMap +import dev.slne.surf.api.paper.display.document.Document +import dev.slne.surf.api.paper.display.element.Div +import dev.slne.surf.api.paper.display.element.Element +import dev.slne.surf.api.paper.server.display.frame.DisplayItemFrame +import dev.slne.surf.api.paper.display.render.Canvas +import dev.slne.surf.api.paper.display.render.Renderer +import dev.slne.surf.api.paper.server.display.user.DisplayUser +import org.bukkit.entity.Player +import java.util.* +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.atan +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt + +/** + * A display that renders a [Document] as a wall of map item frames in front of the player. + * + * The display is placed as a vertical wall at a fixed distance in front of the camera, + * oriented based on the player's cardinal facing direction. The player's yaw/pitch rotation + * maps to cursor position on the display. + * + * Features a software-rendered cursor that is drawn directly on the map tiles, + * eliminating the need for a resource pack. + * + * Supports a modal overlay system for dialogs and confirmations. + * + * Usage: + * ```kotlin + * val doc = document(384, 256) { ... } + * val display = Display(doc) + * DisplayManager.open(player, display) + * ``` + */ +class Display( + val document: Document, +) { + internal val frames = mutableListOf() + internal var session: DisplaySession? = null + internal var viewer: UUID? = null + + val cols = ceil(document.width / 128.0).toInt() + val rows = ceil(document.height / 128.0).toInt() + + lateinit var cardinal: CardinalInfo + private set + + private var lastHoveredPath = emptyList() + + internal var cachedCanvas: Canvas? = null + + private var prevCursorX = -1 + private var prevCursorY = -1 + + private var viewDistance: Double = FRAME_DISTANCE.toDouble() + + var webDisplay: dev.slne.surf.api.paper.server.display.web.WebDisplay? = null + + // --- Modal System --- + private val modalStack = mutableListOf
() + + val hasModal: Boolean get() = modalStack.isNotEmpty() + + fun showModal(content: Div) { + content.style.width = document.width + content.style.height = document.height + modalStack.add(content) + lastHoveredPath = emptyList() + update() + } + + fun dismissModal() { + if (modalStack.isNotEmpty()) { + modalStack.removeAt(modalStack.size - 1) + lastHoveredPath = emptyList() + update() + } + } + + fun dismissAllModals() { + modalStack.clear() + lastHoveredPath = emptyList() + update() + } + + data class CardinalInfo( + val centerYaw: Float, + val forwardX: Int, + val forwardZ: Int, + val rightX: Int, + val rightZ: Int, + val frameFacing: DisplayItemFrame.Direction + ) + + fun spawn(player: Player) { + val user = DisplayUser.of(player.uniqueId) + + session?.close() + if (frames.isNotEmpty()) { + frames.forEach { it.despawn(user) } + frames.clear() + } + + cardinal = nearestCardinal(player.location.yaw) + + cachedCanvas = renderWithModals() + + val eyeLoc = player.eyeLocation + val eyePos = Vector3d(eyeLoc.x, eyeLoc.y, eyeLoc.z) + val newFrames = renderFrames(eyePos) + frames.addAll(newFrames) + + val cameraEyePos = computeCenteredCameraPosition() + viewDistance = computeViewDistance(cameraEyePos) + + val newSession = DisplaySession(user, cardinal.centerYaw, cameraEyePos) + newSession.open() + session = newSession + user.session = newSession + viewer = player.uniqueId + + user.sendPacket(WrapperPlayServerBundle()) + frames.forEach { it.spawn(user) } + user.sendPacket(WrapperPlayServerBundle()) + } + + fun despawn(player: Player) { + val user = DisplayUser.of(player.uniqueId) + frames.forEach { it.despawn(user) } + frames.clear() + session?.close() + session = null + user.session = null + viewer = null + lastHoveredPath = emptyList() + cachedCanvas = null + prevCursorX = -1 + prevCursorY = -1 + modalStack.clear() + + webDisplay?.dispose() + webDisplay = null + } + + fun update() { + val uuid = viewer ?: return + val user = DisplayUser.of(uuid) + val sess = session ?: return + + if (webDisplay == null) { + cachedCanvas = renderWithModals() + } + val cached = cachedCanvas ?: return + + user.sendPacket(WrapperPlayServerBundle()) + var frameIndex = 0 + for (row in 0 until rows) { + for (col in 0 until cols) { + if (frameIndex < frames.size) { + val mapData = tileDataWithCursor(cached, col, row, sess.cursorX, sess.cursorY) + frames[frameIndex].map.updateData(mapData) + frames[frameIndex].sendMapUpdate(user) + } + frameIndex++ + } + } + user.sendPacket(WrapperPlayServerBundle()) + } + + fun onCursorMove(yaw: Float, pitch: Float) { + val uuid = viewer ?: return + val sess = session ?: return + val cached = cachedCanvas ?: return + + val pixel = rotationToPixel(yaw, pitch) ?: return + val newX = pixel.first + val newY = pixel.second + + if (newX == prevCursorX && newY == prevCursorY) return + + sess.cursorX = newX + sess.cursorY = newY + + val tilesToUpdate = mutableSetOf() + + if (prevCursorX >= 0) { + addCursorTiles(tilesToUpdate, prevCursorX, prevCursorY) + } + addCursorTiles(tilesToUpdate, newX, newY) + + prevCursorX = newX + prevCursorY = newY + + val user = DisplayUser.of(uuid) + user.sendPacket(WrapperPlayServerBundle()) + for (frameIndex in tilesToUpdate) { + if (frameIndex in frames.indices) { + val tileCol = frameIndex % cols + val tileRow = frameIndex / cols + val mapData = tileDataWithCursor(cached, tileCol, tileRow, newX, newY) + frames[frameIndex].map.updateData(mapData) + frames[frameIndex].sendMapUpdate(user) + } + } + user.sendPacket(WrapperPlayServerBundle()) + + webDisplay?.onCursorMove(newX, newY) + + val targetRoot = if (modalStack.isNotEmpty()) modalStack.last() else document.root + val newPath = mutableListOf() + collectElementPath(targetRoot, newX, newY, 0, 0, newPath) + val newPathSet = newPath.toSet() + val oldPathSet = lastHoveredPath.toSet() + + for (old in lastHoveredPath) { + if (old !in newPathSet) { + old.findBehaviors().forEach { hoverable -> + hoverable.onExit(InteractionContext(uuid, old, newX, newY)) + } + } + } + + for (new in newPath) { + if (new !in oldPathSet) { + new.findBehaviors().forEach { hoverable -> + hoverable.onEnter(InteractionContext(uuid, new, newX, newY)) + } + } + } + + lastHoveredPath = newPath + } + + fun onClick(isLeftClick: Boolean) { + val uuid = viewer ?: return + val sess = session ?: return + + webDisplay?.onClick(sess.cursorX, sess.cursorY, isLeftClick) + + val targetRoot = if (modalStack.isNotEmpty()) modalStack.last() else document.root + val path = mutableListOf() + collectElementPath(targetRoot, sess.cursorX, sess.cursorY, 0, 0, path) + + for (element in path.asReversed()) { + val clickables = element.findBehaviors() + if (clickables.isNotEmpty()) { + clickables.forEach { clickable -> + val ctx = InteractionContext(uuid, element, sess.cursorX, sess.cursorY) + if (isLeftClick) { + clickable.onClick(ctx) + } else { + clickable.onRightClick(ctx) + } + } + return + } + } + } + + private fun renderWithModals(): Canvas { + val base = document.render() + for (modal in modalStack) { + val modalCanvas = Canvas(document.width, document.height) + Renderer.render(modal, modalCanvas) + base.blend(modalCanvas, 0, 0) + } + return base + } + + private fun addCursorTiles(tiles: MutableSet, cx: Int, cy: Int) { + val cursorW = CURSOR_ARROW[0].size.coerceAtLeast(CURSOR_ARROW.maxOf { it.size }) + val cursorH = CURSOR_ARROW.size + + val minTileCol = (cx / 128).coerceIn(0, cols - 1) + val maxTileCol = ((cx + cursorW) / 128).coerceIn(0, cols - 1) + val minTileRow = (cy / 128).coerceIn(0, rows - 1) + val maxTileRow = ((cy + cursorH) / 128).coerceIn(0, rows - 1) + + for (tr in minTileRow..maxTileRow) { + for (tc in minTileCol..maxTileCol) { + tiles.add(tr * cols + tc) + } + } + } + + internal fun tileDataWithCursor(cached: Canvas, tileCol: Int, tileRow: Int, cursorX: Int, cursorY: Int): ByteArray { + val offsetX = tileCol * 128 + val offsetY = tileRow * 128 + + val tile = Canvas(128, 128) + for (y in 0 until 128) { + for (x in 0 until 128) { + val px = offsetX + x + val py = offsetY + y + if (px < cached.width && py < cached.height) { + tile.pixels[y * 128 + x] = cached.pixels[py * cached.width + px] + } + } + } + + val localCX = cursorX - offsetX + val localCY = cursorY - offsetY + drawCursor(tile, localCX, localCY) + + return tile.toMapColors(0, 0) + } + + private fun drawCursor(canvas: Canvas, x: Int, y: Int) { + for (row in CURSOR_ARROW.indices) { + val line = CURSOR_ARROW[row] + for (col in line.indices) { + val pixel = line[col] + if (pixel != 0) { + val color = if (pixel == 1) CURSOR_OUTLINE else CURSOR_FILL + canvas.setPixelUnclipped(x + col, y + row, color) + } + } + } + } + + private fun renderFrames(eyeLocation: Vector3d): List { + val cached = cachedCanvas ?: return emptyList() + val newFrames = mutableListOf() + + for (row in 0 until rows) { + for (col in 0 until cols) { + val mapData = cached.toMapColors(col * 128, row * 128) + val mapId = nextMapId() + val map = DisplayMap(mapId, mapData) + val pos = framePosition(eyeLocation, col, row) + newFrames.add(DisplayItemFrame(pos, map, cardinal.frameFacing)) + } + } + return newFrames + } + + private fun computeCenteredCameraPosition(): Vector3d { + if (frames.isEmpty()) return Vector3d.zero() + + val wallCenterX = frames.map { it.location.x + 0.5 }.average() + val wallCenterY = frames.map { it.location.y + 0.5 }.average() + val wallCenterZ = frames.map { it.location.z + 0.5 }.average() + + val camX = wallCenterX - cardinal.forwardX * FRAME_DISTANCE + val camY = wallCenterY + val camZ = wallCenterZ - cardinal.forwardZ * FRAME_DISTANCE + + return Vector3d(camX, camY, camZ) + } + + private fun computeViewDistance(cameraEyePos: Vector3d): Double { + if (frames.isEmpty()) return FRAME_DISTANCE.toDouble() + + val wallCenterX = frames.map { it.location.x + 0.5 }.average() + val wallCenterZ = frames.map { it.location.z + 0.5 }.average() + + val dx = wallCenterX - cameraEyePos.x + val dz = wallCenterZ - cameraEyePos.z + + return (dx * cardinal.forwardX + dz * cardinal.forwardZ).coerceAtLeast(1.0) + } + + private fun rotationToPixel(yaw: Float, pitch: Float): Pair? { + var relativeYaw = yaw - cardinal.centerYaw + if (relativeYaw > 180f) relativeYaw -= 360f + if (relativeYaw < -180f) relativeYaw += 360f + + if (relativeYaw > 90f || relativeYaw < -90f) return null + + val halfHorizAngle = Math.toDegrees(atan(cols / 2.0 / viewDistance)).toFloat() + val halfVertAngle = Math.toDegrees(atan(rows / 2.0 / viewDistance)).toFloat() + + val clampedYaw = relativeYaw.coerceIn(-halfHorizAngle, halfHorizAngle) + val clampedPitch = pitch.coerceIn(-halfVertAngle, halfVertAngle) + + val normalizedX = clampedYaw / halfHorizAngle + val normalizedY = clampedPitch / halfVertAngle + + val pixelX = ((normalizedX + 1f) / 2f * document.width).roundToInt() + .coerceIn(0, document.width - 1) + val pixelY = ((normalizedY + 1f) / 2f * document.height).roundToInt() + .coerceIn(0, document.height - 1) + + return pixelX to pixelY + } + + private fun collectElementPath( + node: Element, px: Int, py: Int, offsetX: Int, offsetY: Int, path: MutableList + ): Boolean { + if (!node.style.visible) return false + + val s = node.style + val bw = s.border?.width ?: 0 + val absX = offsetX + node.bounds.x + val absY = offsetY + node.bounds.y + + if (px < absX || px >= absX + node.bounds.width || py < absY || py >= absY + node.bounds.height) { + return false + } + + path.add(node) + + val cx = absX + s.padding.left + bw + val cy = absY + s.padding.top + bw + + for (child in node.children.reversed()) { + if (collectElementPath(child, px, py, cx, cy, path)) { + return true + } + } + + return true + } + + private fun framePosition(eyeLocation: Vector3d, col: Int, row: Int): Vector3d { + val dir = cardinal + + val baseX = floor(eyeLocation.x).toInt() + val baseY = floor(eyeLocation.y).toInt() + val baseZ = floor(eyeLocation.z).toInt() + + val colOffset = col - cols / 2 + val topRow = (rows - 1) / 2 + val rowOffset = topRow - row + + val frameX = baseX + dir.forwardX * FRAME_DISTANCE + dir.rightX * colOffset + val frameY = baseY + rowOffset + val frameZ = baseZ + dir.forwardZ * FRAME_DISTANCE + dir.rightZ * colOffset + + return Vector3d(frameX.toDouble(), frameY.toDouble(), frameZ.toDouble()) + } + + companion object { + const val FRAME_DISTANCE = 2 + + private val mapIdCounter = AtomicInteger(10000) + fun nextMapId() = mapIdCounter.getAndIncrement() + + private val CURSOR_OUTLINE = argb(0, 0, 0) + private val CURSOR_FILL = argb(255, 255, 255) + + private val CURSOR_ARROW = arrayOf( + intArrayOf(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), + intArrayOf(1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), + intArrayOf(1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0), + intArrayOf(1, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0), + intArrayOf(1, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0), + intArrayOf(1, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0), + intArrayOf(1, 2, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0), + intArrayOf(1, 2, 2, 2, 2, 2, 2, 1, 0, 0, 0, 0), + intArrayOf(1, 2, 2, 2, 2, 2, 2, 2, 1, 0, 0, 0), + intArrayOf(1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 0, 0), + intArrayOf(1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0), + intArrayOf(1, 2, 2, 1, 2, 2, 1, 0, 0, 0, 0, 0), + intArrayOf(1, 2, 1, 0, 1, 2, 2, 1, 0, 0, 0, 0), + intArrayOf(1, 1, 0, 0, 1, 2, 2, 1, 0, 0, 0, 0), + intArrayOf(1, 0, 0, 0, 0, 1, 2, 2, 1, 0, 0, 0), + intArrayOf(0, 0, 0, 0, 0, 1, 2, 2, 1, 0, 0, 0), + intArrayOf(0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0), + ) + + fun nearestCardinal(yaw: Float): CardinalInfo { + val normalized = ((yaw % 360f) + 360f) % 360f + return when { + normalized < 45f || normalized >= 315f -> CardinalInfo( + centerYaw = 0f, + forwardX = 0, forwardZ = 1, + rightX = -1, rightZ = 0, + frameFacing = DisplayItemFrame.Direction.NORTH + ) + normalized < 135f -> CardinalInfo( + centerYaw = 90f, + forwardX = -1, forwardZ = 0, + rightX = 0, rightZ = -1, + frameFacing = DisplayItemFrame.Direction.EAST + ) + normalized < 225f -> CardinalInfo( + centerYaw = 180f, + forwardX = 0, forwardZ = -1, + rightX = 1, rightZ = 0, + frameFacing = DisplayItemFrame.Direction.SOUTH + ) + else -> CardinalInfo( + centerYaw = 270f, + forwardX = 1, forwardZ = 0, + rightX = 0, rightZ = 1, + frameFacing = DisplayItemFrame.Direction.WEST + ) + } + } + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplayLoader.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplayLoader.kt new file mode 100644 index 000000000..e66b925e4 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplayLoader.kt @@ -0,0 +1,24 @@ +package dev.slne.surf.api.paper.server.display + +import com.github.retrooper.packetevents.event.PacketListenerPriority +import dev.slne.surf.api.core.extensions.packetEvents +import dev.slne.surf.api.paper.server.display.protocol.DisplayProtocolListener + +/** + * Handles registration and lifecycle of the display subsystem. + */ +object DisplayLoader { + private val protocolListener = DisplayProtocolListener() + + fun onEnable() { + packetEvents.eventManager.registerListener( + protocolListener, + PacketListenerPriority.LOW + ) + } + + fun onDisable() { + DisplayManager.closeAll() + packetEvents.eventManager.unregisterListener(protocolListener) + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplayManager.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplayManager.kt new file mode 100644 index 000000000..381b3d2da --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplayManager.kt @@ -0,0 +1,37 @@ +package dev.slne.surf.api.paper.server.display + +import dev.slne.surf.api.paper.server.display.user.DisplayUser +import org.bukkit.entity.Player +import java.util.* + +/** + * Manages active display sessions for players. + * + * Maintains a registry of active [Display] instances per player UUID. + * Provides methods to open, close, and query displays. + */ +object DisplayManager { + private val activeDisplays = mutableMapOf() + + fun open(player: Player, display: Display) { + close(player) + display.spawn(player) + activeDisplays[player.uniqueId] = display + } + + fun close(player: Player) { + activeDisplays.remove(player.uniqueId)?.despawn(player) + } + + fun getDisplay(uuid: UUID): Display? = activeDisplays[uuid] + + fun hasDisplay(uuid: UUID): Boolean = activeDisplays.containsKey(uuid) + + fun closeAll() { + for ((uuid, display) in activeDisplays.toMap()) { + val user = DisplayUser.of(uuid) + user.player?.let { display.despawn(it) } + } + activeDisplays.clear() + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplaySession.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplaySession.kt new file mode 100644 index 000000000..74e8e7503 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplaySession.kt @@ -0,0 +1,297 @@ +package dev.slne.surf.api.paper.server.display + +import com.github.retrooper.packetevents.protocol.attribute.Attributes +import com.github.retrooper.packetevents.protocol.component.ComponentTypes +import com.github.retrooper.packetevents.protocol.component.builtin.item.ItemModel +import com.github.retrooper.packetevents.protocol.entity.data.EntityData +import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes +import com.github.retrooper.packetevents.protocol.entity.type.EntityTypes +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.item.type.ItemTypes +import com.github.retrooper.packetevents.protocol.player.* +import com.github.retrooper.packetevents.protocol.potion.PotionTypes +import com.github.retrooper.packetevents.protocol.world.states.WrappedBlockState +import com.github.retrooper.packetevents.protocol.world.states.type.StateTypes +import com.github.retrooper.packetevents.resources.ResourceLocation +import com.github.retrooper.packetevents.util.Vector3d +import com.github.retrooper.packetevents.util.Vector3i +import com.github.retrooper.packetevents.wrapper.play.server.* +import dev.slne.surf.api.paper.server.display.cursor.Cursor +import dev.slne.surf.api.paper.server.display.user.DisplayUser +import net.kyori.adventure.text.Component +import org.bukkit.GameMode +import java.util.* +import java.util.concurrent.atomic.AtomicInteger + +/** + * Manages a display session for a player. + * + * A session mounts the player on an invisible horse for yaw/pitch cursor tracking, + * creates a fake player entity as a camera, and applies visual effects (invisibility, + * empty inventory) to create a clean display viewing experience. + * + * Lifecycle: [open] → player interacts with display → [close] restores original state. + */ +class DisplaySession( + val user: DisplayUser, + private val centerYaw: Float = 0f, + private val cameraEyePosition: Vector3d? = null +) { + val horseEntityId = nextEntityId() + val fakePlayerEntityId = CAMERA_ENTITY_ID + + var isActive = false + private set + + lateinit var cursor: Cursor + private set + + private var initialYaw = 0f + private var initialPitch = 0f + private var initialGameMode = GameMode.SURVIVAL + private var initialPosition = Vector3d.zero() + + var copperGratePos = Vector3i.zero() + internal set + + var cursorX = 0 + internal set + var cursorY = 0 + internal set + + fun open() { + val player = user.player ?: return + + initialYaw = player.location.yaw + initialPitch = player.location.pitch + initialGameMode = player.gameMode + initialPosition = Vector3d(player.location.x, player.location.y, player.location.z) + + val eyeLoc = player.eyeLocation + val playerEyePos = Vector3d(eyeLoc.x, eyeLoc.y, eyeLoc.z) + val cameraPos = cameraEyePosition ?: playerEyePos + + cursor = Cursor(horseEntityId, user) + spawnHorse(playerEyePos) + mountPlayer() + spawnFakePlayerAndCamera(cameraPos, player) + applyVisualEffects(player) + + isActive = true + } + + fun close() { + if (!isActive) return + isActive = false + val player = user.player ?: return + + user.sendPacket(WrapperPlayServerCamera(player.entityId)) + user.sendPacket(WrapperPlayServerDestroyEntities(horseEntityId)) + user.sendPacket(WrapperPlayServerDestroyEntities(fakePlayerEntityId)) + + val gmValue = when (initialGameMode) { + GameMode.SURVIVAL -> 0 + GameMode.CREATIVE -> 1 + GameMode.ADVENTURE -> 2 + GameMode.SPECTATOR -> 3 + } + user.sendPacket( + WrapperPlayServerChangeGameState( + WrapperPlayServerChangeGameState.Reason.CHANGE_GAME_MODE, + gmValue.toFloat() + ) + ) + + player.isInvisible = false + user.sendPacket(WrapperPlayServerRemoveEntityEffect(player.entityId, PotionTypes.INVISIBILITY)) + + user.sendPacket( + WrapperPlayServerPlayerPositionAndLook( + initialPosition.x, initialPosition.y, initialPosition.z, + initialYaw, initialPitch, + 0.toByte(), 0, false + ) + ) + + user.sendPacket( + WrapperPlayServerBlockChange( + copperGratePos, + WrappedBlockState.getDefaultState(StateTypes.AIR) + ) + ) + + user.sendPacket( + WrapperPlayServerTimeUpdate( + player.world.gameTime, + player.playerTime + ) + ) + + player.updateInventory() + } + + private fun spawnHorse(pos: Vector3d) { + val spawnPacket = WrapperPlayServerSpawnEntity( + horseEntityId, + Optional.of(UUID.randomUUID()), + EntityTypes.HORSE, + pos, + 0f, + 0f, + 0f, + 0, + Optional.of(Vector3d.zero()) + ) + user.sendPacket(spawnPacket) + + val metadata = WrapperPlayServerEntityMetadata( + horseEntityId, + listOf( + EntityData(0, EntityDataTypes.BYTE, (0x20 or 0x02).toByte()), + EntityData(17, EntityDataTypes.BYTE, 0x04.toByte()), + ) + ) + user.sendPacket(metadata) + + val attributes = listOf( + WrapperPlayServerUpdateAttributes.Property( + Attributes.JUMP_STRENGTH, + 0.0, + listOf( + WrapperPlayServerUpdateAttributes.PropertyModifier( + UUID.randomUUID(), 0.0, + WrapperPlayServerUpdateAttributes.PropertyModifier.Operation.MULTIPLY_BASE + ) + ) + ), + WrapperPlayServerUpdateAttributes.Property( + Attributes.SCALE, + 0.01, + listOf( + WrapperPlayServerUpdateAttributes.PropertyModifier( + UUID.randomUUID(), 0.0, + WrapperPlayServerUpdateAttributes.PropertyModifier.Operation.MULTIPLY_BASE + ) + ) + ) + ) + user.sendPacket(WrapperPlayServerUpdateAttributes(horseEntityId, attributes)) + + user.sendPacket( + WrapperPlayServerEntityTeleport( + horseEntityId, + Vector3d(pos.x, pos.y - 1.7, pos.z), + 0f, 180f, false + ) + ) + + cursor.sendCursorUpdate() + } + + private fun mountPlayer() { + val player = user.player ?: return + + user.sendPacket(WrapperPlayServerPlayerRotation(centerYaw, 0f)) + + user.sendPacket( + WrapperPlayServerSetPassengers( + horseEntityId, + intArrayOf(player.entityId) + ) + ) + + user.sendPacket(WrapperPlayServerPlayerRotation(centerYaw, 0f)) + } + + private fun spawnFakePlayerAndCamera(pos: Vector3d, player: org.bukkit.entity.Player) { + val uuid = UUID.randomUUID() + + val properties = mutableListOf() + for (property in player.playerProfile.properties) { + properties.add(TextureProperty(property.name, property.value, property.signature)) + } + + val profile = UserProfile(uuid, player.name, properties) + + user.sendPacket( + WrapperPlayServerPlayerInfoUpdate( + WrapperPlayServerPlayerInfoUpdate.Action.ADD_PLAYER, + WrapperPlayServerPlayerInfoUpdate.PlayerInfo( + profile, false, 0, com.github.retrooper.packetevents.protocol.player.GameMode.CREATIVE, + null, null, 0, true + ) + ) + ) + + val feetPos = Vector3d(pos.x, pos.y - 1.62, pos.z) + user.sendPacket( + WrapperPlayServerSpawnEntity( + fakePlayerEntityId, + uuid, + EntityTypes.PLAYER, + com.github.retrooper.packetevents.protocol.world.Location( + feetPos, + centerYaw, + 0f + ), + centerYaw, + 0, + Vector3d.zero() + ) + ) + + user.sendPacket(WrapperPlayServerCamera(fakePlayerEntityId)) + + copperGratePos = Vector3i( + kotlin.math.floor(pos.x).toInt(), + kotlin.math.floor(pos.y).toInt(), + kotlin.math.floor(pos.z).toInt() + ) + user.sendPacket( + WrapperPlayServerBlockChange( + copperGratePos, + WrappedBlockState.getDefaultState(StateTypes.EXPOSED_COPPER_GRATE) + ) + ) + } + + private fun applyVisualEffects(player: org.bukkit.entity.Player) { + player.isInvisible = true + user.sendPacket( + WrapperPlayServerEntityEffect( + player.entityId, + PotionTypes.INVISIBILITY, + 255, + -1, + 0.toByte() + ) + ) + + val emptyItem = ItemStack.builder() + .type(ItemTypes.TRIDENT) + .component(ComponentTypes.ITEM_NAME, Component.empty()) + .component(ComponentTypes.ITEM_MODEL, ItemModel(ResourceLocation.minecraft("air"))) + .build() + user.sendPacket( + WrapperPlayServerWindowItems( + 0, 0, + java.util.Collections.nCopies(44, emptyItem), + emptyItem + ) + ) + user.sendPacket(WrapperPlayServerSetSlot(0, 0, 45, emptyItem)) + + user.sendPacket( + WrapperPlayServerChangeGameState( + WrapperPlayServerChangeGameState.Reason.CHANGE_GAME_MODE, + 0f + ) + ) + } + + companion object { + private const val CAMERA_ENTITY_ID = -10_000 + private val entityIdCounter = AtomicInteger(2_000_000) + fun nextEntityId() = entityIdCounter.getAndIncrement() + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/cursor/Cursor.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/cursor/Cursor.kt new file mode 100644 index 000000000..8d533b7f9 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/cursor/Cursor.kt @@ -0,0 +1,71 @@ +package dev.slne.surf.api.paper.server.display.cursor + +import com.github.retrooper.packetevents.protocol.component.ComponentTypes +import com.github.retrooper.packetevents.protocol.component.builtin.item.ItemEquippable +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.item.type.ItemTypes +import com.github.retrooper.packetevents.protocol.player.Equipment +import com.github.retrooper.packetevents.protocol.player.EquipmentSlot +import com.github.retrooper.packetevents.protocol.sound.Sounds +import com.github.retrooper.packetevents.resources.ResourceLocation +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityEquipment +import dev.slne.surf.api.paper.display.cursor.CursorStyle +import dev.slne.surf.api.paper.server.display.user.DisplayUser + +class Cursor( + private val horseEntityId: Int, + private val user: DisplayUser +) { + var currentStyle: CursorStyle = CursorStyle.DEFAULT + private set + + fun setCursor(style: CursorStyle) { + if (style == currentStyle) return + currentStyle = style + sendCursorUpdate() + } + + fun reset() { + setCursor(CursorStyle.DEFAULT) + } + + fun sendCursorUpdate() { + val equipment = createHorseEquipment(currentStyle.texturePath, horseEntityId) + user.sendPacket(equipment) + } + + companion object { + private const val NAMESPACE = "surf-display" + + fun createHorseEquipment(texturePath: String, entityId: Int): WrapperPlayServerEntityEquipment { + return WrapperPlayServerEntityEquipment( + entityId, + listOf( + Equipment( + EquipmentSlot.SADDLE, + ItemStack.builder().type(ItemTypes.SADDLE).build() + ), + Equipment( + EquipmentSlot.BODY, + ItemStack.builder() + .type(ItemTypes.COPPER_HORSE_ARMOR) + .component( + ComponentTypes.EQUIPPABLE, + ItemEquippable( + EquipmentSlot.BODY, + Sounds.ITEM_ARMOR_EQUIP_GENERIC, + ResourceLocation(NAMESPACE, texturePath), + null, + null, + false, + false, + false + ) + ) + .build() + ) + ) + ) + } + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/frame/DisplayItemFrame.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/frame/DisplayItemFrame.kt new file mode 100644 index 000000000..a7ad038c1 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/frame/DisplayItemFrame.kt @@ -0,0 +1,86 @@ +package dev.slne.surf.api.paper.server.display.frame + +import com.github.retrooper.packetevents.protocol.component.ComponentTypes +import com.github.retrooper.packetevents.protocol.entity.data.EntityData +import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes +import com.github.retrooper.packetevents.protocol.entity.type.EntityTypes +import com.github.retrooper.packetevents.protocol.item.ItemStack +import com.github.retrooper.packetevents.protocol.item.type.ItemTypes +import com.github.retrooper.packetevents.util.Vector3d +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerDestroyEntities +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSpawnEntity +import dev.slne.surf.api.paper.server.display.map.DisplayMap +import dev.slne.surf.api.paper.server.display.user.DisplayUser +import java.util.* +import java.util.concurrent.atomic.AtomicInteger + +class DisplayItemFrame( + val location: Vector3d, + val map: DisplayMap, + val facing: Direction = Direction.SOUTH +) { + val entityId = nextEntityId() + + fun spawn(user: DisplayUser) { + user.sendPacket(map.createPacket()) + user.sendPacket(createSpawnPacket()) + user.sendPacket(createMetadataPacket()) + } + + fun despawn(user: DisplayUser) { + user.sendPacket(WrapperPlayServerDestroyEntities(entityId)) + } + + fun sendMapUpdate(user: DisplayUser) { + user.sendPacket(map.createPacket()) + } + + private fun createSpawnPacket(): WrapperPlayServerSpawnEntity { + return WrapperPlayServerSpawnEntity( + entityId, + Optional.of(UUID.randomUUID()), + EntityTypes.ITEM_FRAME, + location, + 0.0f, + 0.0f, + 0.0f, + facing.value, + Optional.empty() + ) + } + + private fun createMetadataPacket(): WrapperPlayServerEntityMetadata { + val itemStack = ItemStack.builder() + .type(ItemTypes.FILLED_MAP) + .component(ComponentTypes.MAP_ID, map.mapId) + .build() + + return WrapperPlayServerEntityMetadata( + entityId, + listOf( + EntityData(0, EntityDataTypes.BYTE, 0x20.toByte()), + EntityData(9, EntityDataTypes.ITEMSTACK, itemStack), + ) + ) + } + + enum class Direction(val value: Int) { + DOWN(0), UP(1), NORTH(2), SOUTH(3), WEST(4), EAST(5); + + val opposite: Direction + get() = when (this) { + DOWN -> UP + UP -> DOWN + NORTH -> SOUTH + SOUTH -> NORTH + WEST -> EAST + EAST -> WEST + } + } + + companion object { + private val entityIdCounter = AtomicInteger(1_000_000) + fun nextEntityId() = entityIdCounter.getAndIncrement() + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/map/DisplayMap.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/map/DisplayMap.kt new file mode 100644 index 000000000..d1e0008d7 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/map/DisplayMap.kt @@ -0,0 +1,28 @@ +package dev.slne.surf.api.paper.server.display.map + +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerMapData +import dev.slne.surf.api.paper.server.display.user.DisplayUser + +class DisplayMap( + val mapId: Int, + data: ByteArray +) { + private var _data = data + + val data + get() = ByteArray(_data.size).apply { + _data.copyInto(this) + } + + fun createPacket(): WrapperPlayServerMapData { + return WrapperPlayServerMapData(mapId, 0, false, false, null, 128, 128, 0, 0, _data) + } + + fun update(user: DisplayUser) { + user.sendPacket(createPacket()) + } + + fun updateData(newData: ByteArray) { + _data = newData + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/modal/Modal.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/modal/Modal.kt new file mode 100644 index 000000000..c709b6791 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/modal/Modal.kt @@ -0,0 +1,203 @@ +package dev.slne.surf.api.paper.server.display.modal + +import dev.slne.surf.api.paper.display.color +import dev.slne.surf.api.paper.display.cursor.CursorStyle +import dev.slne.surf.api.paper.server.display.Display +import dev.slne.surf.api.paper.display.element.Div +import dev.slne.surf.api.paper.display.shape.Shape +import dev.slne.surf.api.paper.display.style.* + +private val MODAL_OVERLAY = color(0, 0, 0, 160) +private val MODAL_BG = color(0x1E1E2E) +private val MODAL_SURFACE = color(0x313244) +private val MODAL_BORDER = color(0x585B70) +private val MODAL_TEXT = color(0xCDD6F4) +private val MODAL_SUBTEXT = color(0x9399B2) +private val MODAL_GREEN = color(0xA6E3A1) +private val MODAL_RED = color(0xF38BA8) +private val MODAL_BLUE = color(0x89B4FA) +private val MODAL_YELLOW = color(0xF9E2AF) + +fun Display.confirmDialog( + title: String, + message: String, + confirmText: String = "Bestätigen", + cancelText: String = "Abbrechen", + confirmColor: Int = MODAL_GREEN, + onConfirm: () -> Unit, + onCancel: () -> Unit = { dismissModal() } +): Div { + val display = this + return Div().apply { + style { + backgroundColor = MODAL_OVERLAY + justifyContent = JustifyContent.CENTER + alignItems = AlignItems.CENTER + } + + div { + style { + width = 320 + backgroundColor = MODAL_BG + border = Border(2, MODAL_BORDER) + padding = Insets(12, 16, 12, 16) + gap = 10 + } + + div { + style { + backgroundColor = MODAL_SURFACE + padding = Insets(6, 8, 6, 8) + } + label(title) { + style { color = MODAL_TEXT; fontSize = 16 } + } + } + + label(message) { + style { color = MODAL_SUBTEXT; fontSize = 13 } + } + + div { + style { + flexDirection = FlexDirection.ROW + gap = 8 + justifyContent = JustifyContent.CENTER + } + + div { + style { + backgroundColor = MODAL_SURFACE + padding = Insets(4, 12, 4, 12) + border = Border(1, confirmColor) + cursor = CursorStyle.POINTER + } + label(confirmText) { + style { color = confirmColor; fontSize = 13 } + } + hoverable( + onEnter = { _ -> + style.backgroundColor = color(0x45475A) + display.update() + }, + onExit = { _ -> + style.backgroundColor = MODAL_SURFACE + display.update() + } + ) + onClick { _ -> onConfirm() } + } + + div { + style { + backgroundColor = MODAL_SURFACE + padding = Insets(4, 12, 4, 12) + border = Border(1, MODAL_RED) + cursor = CursorStyle.POINTER + } + label(cancelText) { + style { color = MODAL_RED; fontSize = 13 } + } + hoverable( + onEnter = { _ -> + style.backgroundColor = color(0x45475A) + display.update() + }, + onExit = { _ -> + style.backgroundColor = MODAL_SURFACE + display.update() + } + ) + onClick { _ -> onCancel() } + } + } + } + } +} + +fun Display.alertDialog( + title: String, + message: String, + buttonText: String = "OK", + buttonColor: Int = MODAL_BLUE, + onDismiss: () -> Unit = { dismissModal() } +): Div { + val display = this + return Div().apply { + style { + backgroundColor = MODAL_OVERLAY + justifyContent = JustifyContent.CENTER + alignItems = AlignItems.CENTER + } + + div { + style { + width = 300 + backgroundColor = MODAL_BG + border = Border(2, MODAL_BORDER) + padding = Insets(12, 16, 12, 16) + gap = 10 + } + + div { + style { + backgroundColor = MODAL_SURFACE + padding = Insets(6, 8, 6, 8) + } + label(title) { + style { color = MODAL_TEXT; fontSize = 16 } + } + } + + label(message) { + style { color = MODAL_SUBTEXT; fontSize = 13 } + } + + div { + style { + alignItems = AlignItems.CENTER + } + div { + style { + backgroundColor = MODAL_SURFACE + padding = Insets(4, 16, 4, 16) + border = Border(1, buttonColor) + cursor = CursorStyle.POINTER + } + label(buttonText) { + style { color = buttonColor; fontSize = 13 } + } + hoverable( + onEnter = { _ -> + style.backgroundColor = color(0x45475A) + display.update() + }, + onExit = { _ -> + style.backgroundColor = MODAL_SURFACE + display.update() + } + ) + onClick { _ -> onDismiss() } + } + } + } + } +} + +fun Display.successDialog( + title: String = "Erfolg!", + message: String, + onDismiss: () -> Unit = { dismissModal() } +): Div = alertDialog(title, message, "OK", MODAL_GREEN, onDismiss) + +fun Display.errorDialog( + title: String = "Fehler", + message: String, + onDismiss: () -> Unit = { dismissModal() } +): Div = alertDialog(title, message, "OK", MODAL_RED, onDismiss) + +fun Display.warningDialog( + title: String = "Warnung", + message: String, + onDismiss: () -> Unit = { dismissModal() } +): Div = alertDialog(title, message, "OK", MODAL_YELLOW, onDismiss) diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/protocol/DisplayProtocolListener.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/protocol/DisplayProtocolListener.kt new file mode 100644 index 000000000..4df653564 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/protocol/DisplayProtocolListener.kt @@ -0,0 +1,124 @@ +package dev.slne.surf.api.paper.server.display.protocol + +import com.github.retrooper.packetevents.event.PacketListener +import com.github.retrooper.packetevents.event.PacketReceiveEvent +import com.github.retrooper.packetevents.event.PacketSendEvent +import com.github.retrooper.packetevents.protocol.packettype.PacketType +import com.github.retrooper.packetevents.protocol.player.DiggingAction +import com.github.retrooper.packetevents.protocol.player.InteractionHand +import com.github.retrooper.packetevents.wrapper.play.client.* +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBlockChange +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTimeUpdate +import dev.slne.surf.api.paper.server.display.DisplayManager +import dev.slne.surf.api.paper.server.display.user.DisplayUser + +class DisplayProtocolListener : PacketListener { + + override fun onPacketReceive(event: PacketReceiveEvent) { + val uuid = event.user.uuid ?: return + val user = DisplayUser.get(uuid) ?: return + if (!user.inSession) return + + when (event.packetType) { + PacketType.Play.Client.PLAYER_ROTATION -> handleRotation( + WrapperPlayClientPlayerRotation(event), user + ) + PacketType.Play.Client.PLAYER_POSITION_AND_ROTATION -> { + val packet = WrapperPlayClientPlayerPositionAndRotation(event) + handleRotationRaw(packet.yaw, packet.pitch, user) + } + PacketType.Play.Client.PLAYER_POSITION -> { + event.isCancelled = true + } + PacketType.Play.Client.PLAYER_DIGGING -> handleDigging(event, user) + PacketType.Play.Client.USE_ITEM -> handleUseItem(event, user) + PacketType.Play.Client.PLAYER_INPUT -> handleInput( + WrapperPlayClientPlayerInput(event), user + ) + PacketType.Play.Client.HELD_ITEM_CHANGE -> handleSlotChange( + WrapperPlayClientHeldItemChange(event), user + ) + PacketType.Play.Client.INTERACT_ENTITY -> handleInteractEntity(event, user) + } + } + + override fun onPacketSend(event: PacketSendEvent) { + val uuid = event.user.uuid ?: return + val user = DisplayUser.get(uuid) ?: return + if (!user.inSession) return + + when (event.packetType) { + PacketType.Play.Server.TIME_UPDATE -> { + val timeUpdate = WrapperPlayServerTimeUpdate(event) + timeUpdate.worldAge = -2000 + } + PacketType.Play.Server.BLOCK_CHANGE -> { + val blockChange = WrapperPlayServerBlockChange(event) + val session = user.session ?: return + if (blockChange.blockPosition == session.copperGratePos) { + event.isCancelled = true + } + } + } + } + + private fun handleRotation(packet: WrapperPlayClientPlayerRotation, user: DisplayUser) { + handleRotationRaw(packet.yaw, packet.pitch, user) + } + + private fun handleRotationRaw(yaw: Float, pitch: Float, user: DisplayUser) { + val display = DisplayManager.getDisplay(user.uuid) ?: return + display.onCursorMove(yaw, pitch) + } + + private fun handleDigging(event: PacketReceiveEvent, user: DisplayUser) { + val packet = WrapperPlayClientPlayerDigging(event) + event.isCancelled = true + + val display = DisplayManager.getDisplay(user.uuid) ?: return + + when (packet.action) { + DiggingAction.START_DIGGING -> display.onClick(isLeftClick = true) + else -> {} + } + } + + private fun handleUseItem(event: PacketReceiveEvent, user: DisplayUser) { + val packet = WrapperPlayClientUseItem(event) + event.isCancelled = true + if (packet.hand != InteractionHand.MAIN_HAND) return + + val display = DisplayManager.getDisplay(user.uuid) ?: return + display.onClick(isLeftClick = false) + } + + private fun handleInput(packet: WrapperPlayClientPlayerInput, user: DisplayUser) { + if (packet.isShift) { + val display = DisplayManager.getDisplay(user.uuid) ?: return + val player = user.player ?: return + DisplayManager.close(player) + } + } + + @Suppress("UNUSED_PARAMETER") + private fun handleSlotChange(packet: WrapperPlayClientHeldItemChange, user: DisplayUser) { + // Reserved for future scroll event handling + } + + private fun handleInteractEntity(event: PacketReceiveEvent, user: DisplayUser) { + val packet = WrapperPlayClientInteractEntity(event) + event.isCancelled = true + + val display = DisplayManager.getDisplay(user.uuid) ?: return + + when (packet.action) { + WrapperPlayClientInteractEntity.InteractAction.ATTACK -> { + display.onClick(isLeftClick = true) + } + WrapperPlayClientInteractEntity.InteractAction.INTERACT, + WrapperPlayClientInteractEntity.InteractAction.INTERACT_AT -> { + display.onClick(isLeftClick = false) + } + } + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/user/DisplayUser.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/user/DisplayUser.kt new file mode 100644 index 000000000..ff4935c57 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/user/DisplayUser.kt @@ -0,0 +1,35 @@ +package dev.slne.surf.api.paper.server.display.user + +import com.github.retrooper.packetevents.wrapper.PacketWrapper +import dev.slne.surf.api.paper.server.display.DisplaySession +import dev.slne.surf.api.paper.extensions.server +import dev.slne.surf.api.core.extensions.sendPacket +import java.util.* + +class DisplayUser( + val uuid: UUID +) { + val player get() = server.getPlayer(uuid) + + val entityId: Int get() = player?.entityId ?: -1 + + var session: DisplaySession? = null + + val inSession: Boolean get() = session?.isActive == true + + fun sendPacket(packet: PacketWrapper<*>) { + player?.let { packet.sendPacket(it) } + } + + companion object { + private val users = mutableMapOf() + + fun of(uuid: UUID): DisplayUser = users.getOrPut(uuid) { DisplayUser(uuid) } + + fun remove(uuid: UUID) { + users.remove(uuid) + } + + fun get(uuid: UUID): DisplayUser? = users[uuid] + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/JavaFxPlatform.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/JavaFxPlatform.kt new file mode 100644 index 000000000..d36ad94a9 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/JavaFxPlatform.kt @@ -0,0 +1,124 @@ +package dev.slne.surf.api.paper.server.display.web + +import javafx.application.Platform +import java.io.File +import java.nio.file.Files +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Logger + +/** + * Manages the JavaFX platform lifecycle for the display web rendering system. + * + * Extracts native libraries from the shadow JAR to a temp directory + * so JavaFX can load them, then starts the platform using the native + * Glass backend with software rendering (no GPU required on servers). + * + * Safe to call multiple times — only the first call has effect. + */ +object JavaFxPlatform { + private val initialized = AtomicBoolean(false) + private val logger = Logger.getLogger("SurfDisplay-JavaFX") + private var nativeTempDir: File? = null + + fun init() { + if (!initialized.compareAndSet(false, true)) return + + extractNativeLibraries() + + System.setProperty("prism.order", "sw") + System.setProperty("prism.text", "t2k") + + try { + Platform.startup { + logger.info("JavaFX Platform started") + } + Platform.setImplicitExit(false) + } catch (_: IllegalStateException) { + logger.info("JavaFX Platform was already running") + } + } + + private fun extractNativeLibraries() { + val osName = System.getProperty("os.name").lowercase() + val isWindows = osName.contains("win") + val extension = if (isWindows) ".dll" else ".so" + val prefix = if (isWindows) "" else "lib" + + val nativeNames = listOf( + "glass", "prism_common", "prism_sw", "jfxwebkit", "jfxmedia", + "prism_d3d", "prism_es2", "javafx_font", "javafx_font_freetype", + "javafx_iio", "decora_sse", "glassgtk3" + ) + + val tempDir = Files.createTempDirectory("surfdisp-javafx").toFile() + nativeTempDir = tempDir + + val classLoader = JavaFxPlatform::class.java.classLoader + var extractedCount = 0 + + for (name in nativeNames) { + val resourceName = "$prefix$name$extension" + val stream = classLoader.getResourceAsStream(resourceName) ?: continue + val file = File(tempDir, resourceName) + try { + stream.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + extractedCount++ + } catch (e: Exception) { + logger.warning("Failed to extract native library $resourceName: ${e.message}") + } + } + + logger.info("Extracted $extractedCount JavaFX native libraries to $tempDir") + + val currentPath = System.getProperty("java.library.path", "") + val separator = File.pathSeparator + System.setProperty("java.library.path", tempDir.absolutePath + separator + currentPath) + + try { + val sysPathsField = ClassLoader::class.java.getDeclaredField("sys_paths") + sysPathsField.isAccessible = true + sysPathsField.set(null, null) + } catch (e: Exception) { + logger.fine("Could not reset ClassLoader sys_paths: ${e.message}") + } + } + + fun shutdown() { + if (initialized.get()) { + Platform.exit() + initialized.set(false) + } + nativeTempDir?.let { dir -> + try { + dir.listFiles()?.forEach { it.delete() } + dir.delete() + } catch (_: Exception) { + } + } + } + + fun runOnFxThread(block: () -> T): CompletableFuture { + val future = CompletableFuture() + if (Platform.isFxApplicationThread()) { + try { + future.complete(block()) + } catch (e: Exception) { + future.completeExceptionally(e) + } + } else { + Platform.runLater { + try { + future.complete(block()) + } catch (e: Exception) { + future.completeExceptionally(e) + } + } + } + return future + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/WebDisplay.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/WebDisplay.kt new file mode 100644 index 000000000..11b70b889 --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/WebDisplay.kt @@ -0,0 +1,89 @@ +package dev.slne.surf.api.paper.server.display.web + +import dev.slne.surf.api.paper.server.display.Display +import dev.slne.surf.api.paper.display.document.document +import dev.slne.surf.api.paper.display.render.Canvas +import org.bukkit.Bukkit +import org.bukkit.plugin.java.JavaPlugin + +/** + * A display that renders a web page (HTML/CSS/JS) via JavaFX WebView + * onto a wall of map item frames in front of the player. + * + * Extends the base [Display] system but replaces element-tree rendering + * with live WebView snapshots. Mouse events are forwarded to the WebView + * so HTML buttons, links, and JavaScript work. + * + * Usage: + * ```kotlin + * val webDisplay = WebDisplay(640, 384) + * webDisplay.display.webDisplay = webDisplay + * webDisplay.loadHtml("...") + * DisplayManager.open(player, webDisplay.display) + * webDisplay.startRendering() + * ``` + */ +class WebDisplay( + val width: Int, + val height: Int, + renderWidth: Int = width, + renderHeight: Int = height +) { + val renderer = WebRenderer(width, height, renderWidth, renderHeight) + + val display: Display + + private var updateTaskId: Int = -1 + + private var active = false + + init { + val doc = document(width, height) {} + display = Display(doc) + } + + fun loadUrl(url: String) { + renderer.loadUrl(url) + } + + fun loadHtml(html: String) { + renderer.loadHtml(html) + } + + fun startRendering() { + if (active) return + active = true + + val plugin = JavaPlugin.getProvidingPlugin(javaClass) + updateTaskId = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, Runnable { + if (!active) return@Runnable + try { + val canvas = renderer.snapshot() + updateDisplay(canvas) + } catch (_: Exception) { + } + }, 10L, 1L).taskId + } + + private fun updateDisplay(canvas: Canvas) { + display.cachedCanvas = canvas + display.update() + } + + fun onCursorMove(x: Int, y: Int) { + renderer.mouseMove(x, y) + } + + fun onClick(x: Int, y: Int, isLeftClick: Boolean) { + renderer.click(x, y, isLeftClick) + } + + fun dispose() { + active = false + if (updateTaskId != -1) { + Bukkit.getScheduler().cancelTask(updateTaskId) + updateTaskId = -1 + } + renderer.dispose() + } +} diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/WebRenderer.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/WebRenderer.kt new file mode 100644 index 000000000..77821bf3c --- /dev/null +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/WebRenderer.kt @@ -0,0 +1,218 @@ +package dev.slne.surf.api.paper.server.display.web + +import dev.slne.surf.api.paper.display.render.Canvas +import javafx.scene.web.WebView +import javafx.scene.Scene +import javafx.scene.layout.StackPane +import javafx.scene.paint.Color +import javafx.scene.image.WritableImage +import javafx.scene.input.MouseButton +import javafx.scene.input.MouseEvent +import javafx.stage.Stage +import javafx.stage.StageStyle +import javafx.event.Event +import javafx.event.EventType +import java.awt.image.BufferedImage +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Logger + +/** + * Renders a web page (HTML/CSS/JS) offscreen using JavaFX WebView + * and provides the result as a [Canvas] for the map display system. + * + * The WebView renders internally at [renderWidth]×[renderHeight] (default 1280×720), + * then the snapshot is downscaled to [width]×[height] (the map display size) for + * readable text and properly fitting content. + * + * Mouse coordinates from the display are automatically scaled to the render + * resolution before being forwarded to the WebView. + */ +class WebRenderer( + val width: Int, + val height: Int, + val renderWidth: Int = 1280, + val renderHeight: Int = 720 +) { + private val logger = Logger.getLogger("SurfDisplay-WebRenderer") + private var webView: WebView? = null + private var scene: Scene? = null + private var stage: Stage? = null + private val ready = AtomicBoolean(false) + private val disposed = AtomicBoolean(false) + + private val scaleX: Double = renderWidth.toDouble() / width.toDouble() + private val scaleY: Double = renderHeight.toDouble() / height.toDouble() + + init { + JavaFxPlatform.init() + + JavaFxPlatform.runOnFxThread { + val wv = WebView() + wv.prefWidth = renderWidth.toDouble() + wv.prefHeight = renderHeight.toDouble() + wv.minWidth = renderWidth.toDouble() + wv.minHeight = renderHeight.toDouble() + wv.maxWidth = renderWidth.toDouble() + wv.maxHeight = renderHeight.toDouble() + + val root = StackPane(wv) + val sc = Scene(root, renderWidth.toDouble(), renderHeight.toDouble()) + sc.fill = Color.WHITE + + val st = Stage(StageStyle.UTILITY) + st.scene = sc + st.width = renderWidth.toDouble() + st.height = renderHeight.toDouble() + st.x = -9999.0 + st.y = -9999.0 + st.opacity = 0.0 + st.show() + + wv.engine.loadWorker.stateProperty().addListener { _, _, newState -> + if (newState == javafx.concurrent.Worker.State.SUCCEEDED) { + ready.set(true) + logger.info("WebView page loaded successfully") + } + } + + wv.engine.loadWorker.exceptionProperty().addListener { _, _, ex -> + if (ex != null) logger.warning("WebView load error: ${ex.message}") + } + + webView = wv + scene = sc + stage = st + }.join() + } + + fun loadUrl(url: String) { + if (disposed.get()) return + ready.set(false) + JavaFxPlatform.runOnFxThread { webView?.engine?.load(url) } + } + + fun loadHtml(html: String) { + if (disposed.get()) return + ready.set(false) + JavaFxPlatform.runOnFxThread { webView?.engine?.loadContent(html, "text/html") } + } + + fun snapshot(): Canvas { + if (disposed.get()) return Canvas(width, height) + + return try { + JavaFxPlatform.runOnFxThread { + val sc = scene ?: return@runOnFxThread Canvas(width, height) + + val hiRes = WritableImage(renderWidth, renderHeight) + sc.snapshot(hiRes) + val reader = hiRes.pixelReader + + if (renderWidth == width && renderHeight == height) { + val canvas = Canvas(width, height) + for (y in 0 until height) { + for (x in 0 until width) { + canvas.pixels[y * width + x] = reader.getArgb(x, y) + } + } + canvas + } else { + val srcImage = BufferedImage(renderWidth, renderHeight, BufferedImage.TYPE_INT_ARGB) + for (y in 0 until renderHeight) { + for (x in 0 until renderWidth) { + srcImage.setRGB(x, y, reader.getArgb(x, y)) + } + } + + val scaled = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val g2d = scaled.createGraphics() + g2d.setRenderingHint( + java.awt.RenderingHints.KEY_INTERPOLATION, + java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR + ) + g2d.drawImage(srcImage, 0, 0, width, height, null) + g2d.dispose() + + val canvas = Canvas(width, height) + for (y in 0 until height) { + for (x in 0 until width) { + canvas.pixels[y * width + x] = scaled.getRGB(x, y) + } + } + canvas + } + }.join() + } catch (e: Exception) { + logger.warning("WebView snapshot failed: ${e.message}") + Canvas(width, height) + } + } + + fun mouseMove(x: Int, y: Int) { + if (disposed.get()) return + val rx = (x * scaleX).toInt().coerceIn(0, renderWidth - 1) + val ry = (y * scaleY).toInt().coerceIn(0, renderHeight - 1) + JavaFxPlatform.runOnFxThread { + val wv = webView ?: return@runOnFxThread + val event = MouseEvent( + MouseEvent.MOUSE_MOVED, + rx.toDouble(), ry.toDouble(), + rx.toDouble(), ry.toDouble(), + MouseButton.NONE, 0, + false, false, false, false, + false, false, false, + false, false, false, + null + ) + Event.fireEvent(wv, event) + } + } + + fun click(x: Int, y: Int, isLeftClick: Boolean) { + if (disposed.get()) return + val rx = (x * scaleX).toInt().coerceIn(0, renderWidth - 1) + val ry = (y * scaleY).toInt().coerceIn(0, renderHeight - 1) + JavaFxPlatform.runOnFxThread { + val wv = webView ?: return@runOnFxThread + val button = if (isLeftClick) MouseButton.PRIMARY else MouseButton.SECONDARY + fireMouseEvent(wv, MouseEvent.MOUSE_PRESSED, rx, ry, button, 1) + fireMouseEvent(wv, MouseEvent.MOUSE_RELEASED, rx, ry, button, 1) + fireMouseEvent(wv, MouseEvent.MOUSE_CLICKED, rx, ry, button, 1) + } + } + + private fun fireMouseEvent( + target: WebView, type: EventType, + x: Int, y: Int, button: MouseButton, clickCount: Int + ) { + val event = MouseEvent( + type, + x.toDouble(), y.toDouble(), x.toDouble(), y.toDouble(), + button, clickCount, + false, false, false, false, + button == MouseButton.PRIMARY, false, button == MouseButton.SECONDARY, + false, false, false, null + ) + Event.fireEvent(target, event) + } + + fun executeScript(script: String): CompletableFuture { + if (disposed.get()) return CompletableFuture.completedFuture(null) + return JavaFxPlatform.runOnFxThread { webView?.engine?.executeScript(script) } + } + + fun isReady(): Boolean = ready.get() + + fun dispose() { + if (!disposed.compareAndSet(false, true)) return + JavaFxPlatform.runOnFxThread { + stage?.hide() + stage?.close() + webView?.engine?.load(null) + webView = null + scene = null + stage = null + } + } +} 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 20775881a..351bd890c 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 @@ -699,6 +699,454 @@ public final class dev/slne/surf/api/paper/dialog/search/SearchInput { public abstract interface class dev/slne/surf/api/paper/dialog/state/DialogState { } +public final class dev/slne/surf/api/paper/display/ColorKt { + public static final fun argb (I)I + public static final fun argb (IIII)I + public static synthetic fun argb$default (IIIIILjava/lang/Object;)I + public static final fun color (I)I + public static final fun color (III)I + public static final fun color (IIII)I +} + +public abstract interface class dev/slne/surf/api/paper/display/behavior/Behavior { +} + +public final class dev/slne/surf/api/paper/display/behavior/Clickable : dev/slne/surf/api/paper/display/behavior/Behavior { + public fun ()V + public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getOnClick ()Lkotlin/jvm/functions/Function1; + public final fun getOnRightClick ()Lkotlin/jvm/functions/Function1; +} + +public final class dev/slne/surf/api/paper/display/behavior/Draggable : dev/slne/surf/api/paper/display/behavior/Behavior { + public fun ()V + public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getOnDrag ()Lkotlin/jvm/functions/Function1; + public final fun getOnDragEnd ()Lkotlin/jvm/functions/Function1; + public final fun getOnDragStart ()Lkotlin/jvm/functions/Function1; +} + +public final class dev/slne/surf/api/paper/display/behavior/ElementPhase : java/lang/Enum { + public static final field CLICK Ldev/slne/surf/api/paper/display/behavior/ElementPhase; + public static final field DEFAULT Ldev/slne/surf/api/paper/display/behavior/ElementPhase; + public static final field HOVER Ldev/slne/surf/api/paper/display/behavior/ElementPhase; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/display/behavior/ElementPhase; + public static fun values ()[Ldev/slne/surf/api/paper/display/behavior/ElementPhase; +} + +public final class dev/slne/surf/api/paper/display/behavior/Hoverable : dev/slne/surf/api/paper/display/behavior/Behavior { + public fun ()V + public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getOnEnter ()Lkotlin/jvm/functions/Function1; + public final fun getOnExit ()Lkotlin/jvm/functions/Function1; +} + +public final class dev/slne/surf/api/paper/display/behavior/InteractionContext { + public fun (Ljava/util/UUID;Ldev/slne/surf/api/paper/display/element/Element;II)V + public final fun component1 ()Ljava/util/UUID; + public final fun component2 ()Ldev/slne/surf/api/paper/display/element/Element; + public final fun component3 ()I + public final fun component4 ()I + public final fun copy (Ljava/util/UUID;Ldev/slne/surf/api/paper/display/element/Element;II)Ldev/slne/surf/api/paper/display/behavior/InteractionContext; + public static synthetic fun copy$default (Ldev/slne/surf/api/paper/display/behavior/InteractionContext;Ljava/util/UUID;Ldev/slne/surf/api/paper/display/element/Element;IIILjava/lang/Object;)Ldev/slne/surf/api/paper/display/behavior/InteractionContext; + public fun equals (Ljava/lang/Object;)Z + public final fun getElement ()Ldev/slne/surf/api/paper/display/element/Element; + public final fun getPixelX ()I + public final fun getPixelY ()I + public final fun getPlayerId ()Ljava/util/UUID; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/slne/surf/api/paper/display/behavior/Scrollable : dev/slne/surf/api/paper/display/behavior/Behavior { + public fun ()V + public fun (Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getOnScroll ()Lkotlin/jvm/functions/Function2; +} + +public final class dev/slne/surf/api/paper/display/behavior/TooltipBehavior : dev/slne/surf/api/paper/display/behavior/Behavior { + public fun (Ljava/lang/String;)V + public final fun getText ()Ljava/lang/String; +} + +public final class dev/slne/surf/api/paper/display/cursor/CursorStyle : java/lang/Enum { + public static final field CROSSHAIR Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field DEFAULT Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field GRAB Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field GRABBING Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field MOVE Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field NOT_ALLOWED Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field POINTER Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field RESIZE_HORIZONTAL Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field RESIZE_VERTICAL Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field TEXT Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static final field WAIT Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getTexturePath ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public static fun values ()[Ldev/slne/surf/api/paper/display/cursor/CursorStyle; +} + +public final class dev/slne/surf/api/paper/display/document/Document { + public fun (II)V + public final fun getHeight ()I + public final fun getRoot ()Ldev/slne/surf/api/paper/display/element/Div; + public final fun getWidth ()I + public final fun render ()Ldev/slne/surf/api/paper/display/render/Canvas; +} + +public final class dev/slne/surf/api/paper/display/document/DocumentKt { + public static final fun document (IILkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/display/document/Document; +} + +public final class dev/slne/surf/api/paper/display/element/Div : dev/slne/surf/api/paper/display/element/Element { + public fun ()V + public final fun div (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/display/element/Div; + public static synthetic fun div$default (Ldev/slne/surf/api/paper/display/element/Div;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/paper/display/element/Div; + public final fun image (Ldev/slne/surf/api/paper/display/render/Canvas;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/display/element/ImageElement; + public static synthetic fun image$default (Ldev/slne/surf/api/paper/display/element/Div;Ldev/slne/surf/api/paper/display/render/Canvas;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/paper/display/element/ImageElement; + public final fun label (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/display/element/Label; + public static synthetic fun label$default (Ldev/slne/surf/api/paper/display/element/Div;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/paper/display/element/Label; + public final fun shape (Ldev/slne/surf/api/paper/display/shape/Shape;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/display/element/ShapeElement; + public static synthetic fun shape$default (Ldev/slne/surf/api/paper/display/element/Div;Ldev/slne/surf/api/paper/display/shape/Shape;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/paper/display/element/ShapeElement; +} + +public abstract class dev/slne/surf/api/paper/display/element/Element { + public fun ()V + public final fun behavior (Ldev/slne/surf/api/paper/display/behavior/Behavior;)Ldev/slne/surf/api/paper/display/behavior/Behavior; + public final fun clickable (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun clickable$default (Ldev/slne/surf/api/paper/display/element/Element;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public final fun draggable (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun draggable$default (Ldev/slne/surf/api/paper/display/element/Element;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public final fun getBehaviors ()Ljava/util/List; + public final fun getBounds ()Ldev/slne/surf/api/paper/display/element/Rect; + public final fun getChildren ()Ljava/util/List; + public final fun getPhase ()Ldev/slne/surf/api/paper/display/behavior/ElementPhase; + public final fun getStyle ()Ldev/slne/surf/api/paper/display/style/Style; + public final fun hoverable (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun hoverable$default (Ldev/slne/surf/api/paper/display/element/Element;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public final fun onClick (Lkotlin/jvm/functions/Function1;)V + public final fun onRightClick (Lkotlin/jvm/functions/Function1;)V + public final fun scrollable (Lkotlin/jvm/functions/Function2;)V + public static synthetic fun scrollable$default (Ldev/slne/surf/api/paper/display/element/Element;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public final fun setBounds (Ldev/slne/surf/api/paper/display/element/Rect;)V + public final fun style (Lkotlin/jvm/functions/Function1;)V + public final fun tooltip (Ljava/lang/String;)V +} + +public final class dev/slne/surf/api/paper/display/element/ImageElement : dev/slne/surf/api/paper/display/element/Element { + public fun (Ldev/slne/surf/api/paper/display/render/Canvas;)V + public final fun getSource ()Ldev/slne/surf/api/paper/display/render/Canvas; +} + +public final class dev/slne/surf/api/paper/display/element/Label : dev/slne/surf/api/paper/display/element/Element { + public fun (Ljava/lang/String;)V + public final fun getText ()Ljava/lang/String; + public final fun setText (Ljava/lang/String;)V +} + +public final class dev/slne/surf/api/paper/display/element/Rect { + public fun ()V + public fun (IIII)V + public synthetic fun (IIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getHeight ()I + public final fun getWidth ()I + public final fun getX ()I + public final fun getY ()I + public final fun setHeight (I)V + public final fun setWidth (I)V + public final fun setX (I)V + public final fun setY (I)V +} + +public final class dev/slne/surf/api/paper/display/element/ShapeElement : dev/slne/surf/api/paper/display/element/Element { + public fun (Ldev/slne/surf/api/paper/display/shape/Shape;)V + public final fun getShape ()Ldev/slne/surf/api/paper/display/shape/Shape; +} + +public final class dev/slne/surf/api/paper/display/render/Canvas { + public static final field Companion Ldev/slne/surf/api/paper/display/render/Canvas$Companion; + public fun (II)V + public final fun blend (Ldev/slne/surf/api/paper/display/render/Canvas;II)V + public final fun drawRect (IIIIII)V + public static synthetic fun drawRect$default (Ldev/slne/surf/api/paper/display/render/Canvas;IIIIIIILjava/lang/Object;)V + public final fun fill (I)V + public final fun fillRect (IIIII)V + public final fun fillRectBlended (IIIII)V + public final fun getHeight ()I + public final fun getPixel (II)I + public final fun getPixels ()[I + public final fun getWidth ()I + public final fun place (Ldev/slne/surf/api/paper/display/render/Canvas;II)V + public final fun popClip ()V + public final fun pushClip (IIII)V + public final fun setPixel (III)V + public final fun setPixelUnclipped (III)V + public final fun toMapColors (II)[B +} + +public final class dev/slne/surf/api/paper/display/render/Canvas$Companion { + public final fun alphaBlend (II)I +} + +public final class dev/slne/surf/api/paper/display/render/Renderer { + public static final field INSTANCE Ldev/slne/surf/api/paper/display/render/Renderer; + public final fun render (Ldev/slne/surf/api/paper/display/element/Element;Ldev/slne/surf/api/paper/display/render/Canvas;)V +} + +public final class dev/slne/surf/api/paper/display/shape/CircleShape : dev/slne/surf/api/paper/display/shape/Shape { + public fun (IZ)V + public synthetic fun (IZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getFilled ()Z + public fun getHeight ()I + public final fun getRadius ()I + public fun getWidth ()I + public fun paint (Ldev/slne/surf/api/paper/display/render/Canvas;III)V + public fun rasterize ()Ljava/util/BitSet; +} + +public final class dev/slne/surf/api/paper/display/shape/EllipseShape : dev/slne/surf/api/paper/display/shape/Shape { + public fun (IIZ)V + public synthetic fun (IIZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getFilled ()Z + public fun getHeight ()I + public final fun getRadiusX ()I + public final fun getRadiusY ()I + public fun getWidth ()I + public fun paint (Ldev/slne/surf/api/paper/display/render/Canvas;III)V + public fun rasterize ()Ljava/util/BitSet; +} + +public final class dev/slne/surf/api/paper/display/shape/LineShape : dev/slne/surf/api/paper/display/shape/Shape { + public fun (III)V + public synthetic fun (IIIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDx ()I + public final fun getDy ()I + public fun getHeight ()I + public final fun getThickness ()I + public fun getWidth ()I + public fun paint (Ldev/slne/surf/api/paper/display/render/Canvas;III)V + public fun rasterize ()Ljava/util/BitSet; +} + +public final class dev/slne/surf/api/paper/display/shape/PolygonShape : dev/slne/surf/api/paper/display/shape/Shape { + public fun (Ljava/util/List;Z)V + public synthetic fun (Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getFilled ()Z + public fun getHeight ()I + public final fun getVertices ()Ljava/util/List; + public fun getWidth ()I + public fun paint (Ldev/slne/surf/api/paper/display/render/Canvas;III)V + public fun rasterize ()Ljava/util/BitSet; +} + +public final class dev/slne/surf/api/paper/display/shape/RectangleShape : dev/slne/surf/api/paper/display/shape/Shape { + public fun (IIZ)V + public synthetic fun (IIZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getFilled ()Z + public fun getHeight ()I + public fun getWidth ()I + public fun paint (Ldev/slne/surf/api/paper/display/render/Canvas;III)V + public fun rasterize ()Ljava/util/BitSet; +} + +public final class dev/slne/surf/api/paper/display/shape/RoundedRectangleShape : dev/slne/surf/api/paper/display/shape/Shape { + public fun (IIIZ)V + public synthetic fun (IIIZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getCornerRadius ()I + public final fun getFilled ()Z + public fun getHeight ()I + public fun getWidth ()I + public fun paint (Ldev/slne/surf/api/paper/display/render/Canvas;III)V + public fun rasterize ()Ljava/util/BitSet; +} + +public abstract interface class dev/slne/surf/api/paper/display/shape/Shape { + public static final field Companion Ldev/slne/surf/api/paper/display/shape/Shape$Companion; + public abstract fun getHeight ()I + public abstract fun getWidth ()I + public fun paint (Ldev/slne/surf/api/paper/display/render/Canvas;III)V + public abstract fun rasterize ()Ljava/util/BitSet; +} + +public final class dev/slne/surf/api/paper/display/shape/Shape$Companion { + public final fun circle (IZ)Ldev/slne/surf/api/paper/display/shape/Shape; + public static synthetic fun circle$default (Ldev/slne/surf/api/paper/display/shape/Shape$Companion;IZILjava/lang/Object;)Ldev/slne/surf/api/paper/display/shape/Shape; + public final fun ellipse (IIZ)Ldev/slne/surf/api/paper/display/shape/Shape; + public static synthetic fun ellipse$default (Ldev/slne/surf/api/paper/display/shape/Shape$Companion;IIZILjava/lang/Object;)Ldev/slne/surf/api/paper/display/shape/Shape; + public final fun line (III)Ldev/slne/surf/api/paper/display/shape/Shape; + public static synthetic fun line$default (Ldev/slne/surf/api/paper/display/shape/Shape$Companion;IIIILjava/lang/Object;)Ldev/slne/surf/api/paper/display/shape/Shape; + public final fun polygon ([Lkotlin/Pair;Z)Ldev/slne/surf/api/paper/display/shape/Shape; + public static synthetic fun polygon$default (Ldev/slne/surf/api/paper/display/shape/Shape$Companion;[Lkotlin/Pair;ZILjava/lang/Object;)Ldev/slne/surf/api/paper/display/shape/Shape; + public final fun rectangle (IIZ)Ldev/slne/surf/api/paper/display/shape/Shape; + public static synthetic fun rectangle$default (Ldev/slne/surf/api/paper/display/shape/Shape$Companion;IIZILjava/lang/Object;)Ldev/slne/surf/api/paper/display/shape/Shape; + public final fun roundedRectangle (IIIZ)Ldev/slne/surf/api/paper/display/shape/Shape; + public static synthetic fun roundedRectangle$default (Ldev/slne/surf/api/paper/display/shape/Shape$Companion;IIIZILjava/lang/Object;)Ldev/slne/surf/api/paper/display/shape/Shape; + public final fun triangle (IIZ)Ldev/slne/surf/api/paper/display/shape/Shape; + public static synthetic fun triangle$default (Ldev/slne/surf/api/paper/display/shape/Shape$Companion;IIZILjava/lang/Object;)Ldev/slne/surf/api/paper/display/shape/Shape; +} + +public final class dev/slne/surf/api/paper/display/shape/Shape$DefaultImpls { + public static fun paint (Ldev/slne/surf/api/paper/display/shape/Shape;Ldev/slne/surf/api/paper/display/render/Canvas;III)V +} + +public final class dev/slne/surf/api/paper/display/shape/TriangleShape : dev/slne/surf/api/paper/display/shape/Shape { + public fun (IIZ)V + public synthetic fun (IIZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getFilled ()Z + public fun getHeight ()I + public fun getWidth ()I + public fun paint (Ldev/slne/surf/api/paper/display/render/Canvas;III)V + public fun rasterize ()Ljava/util/BitSet; +} + +public final class dev/slne/surf/api/paper/display/style/AlignItems : java/lang/Enum { + public static final field CENTER Ldev/slne/surf/api/paper/display/style/AlignItems; + public static final field END Ldev/slne/surf/api/paper/display/style/AlignItems; + public static final field START Ldev/slne/surf/api/paper/display/style/AlignItems; + public static final field STRETCH Ldev/slne/surf/api/paper/display/style/AlignItems; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/display/style/AlignItems; + public static fun values ()[Ldev/slne/surf/api/paper/display/style/AlignItems; +} + +public final class dev/slne/surf/api/paper/display/style/Border { + public fun ()V + public fun (II)V + public synthetic fun (IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()I + public final fun copy (II)Ldev/slne/surf/api/paper/display/style/Border; + public static synthetic fun copy$default (Ldev/slne/surf/api/paper/display/style/Border;IIILjava/lang/Object;)Ldev/slne/surf/api/paper/display/style/Border; + public fun equals (Ljava/lang/Object;)Z + public final fun getColor ()I + public final fun getWidth ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/slne/surf/api/paper/display/style/FlexDirection : java/lang/Enum { + public static final field COLUMN Ldev/slne/surf/api/paper/display/style/FlexDirection; + public static final field ROW Ldev/slne/surf/api/paper/display/style/FlexDirection; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/display/style/FlexDirection; + public static fun values ()[Ldev/slne/surf/api/paper/display/style/FlexDirection; +} + +public final class dev/slne/surf/api/paper/display/style/Insets { + public static final field Companion Ldev/slne/surf/api/paper/display/style/Insets$Companion; + public fun ()V + public fun (IIII)V + public synthetic fun (IIIIILkotlin/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 copy (IIII)Ldev/slne/surf/api/paper/display/style/Insets; + public static synthetic fun copy$default (Ldev/slne/surf/api/paper/display/style/Insets;IIIIILjava/lang/Object;)Ldev/slne/surf/api/paper/display/style/Insets; + public fun equals (Ljava/lang/Object;)Z + public final fun getBottom ()I + public final fun getHorizontal ()I + public final fun getLeft ()I + public final fun getRight ()I + public final fun getTop ()I + public final fun getVertical ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/slne/surf/api/paper/display/style/Insets$Companion { + public final fun all (I)Ldev/slne/surf/api/paper/display/style/Insets; + public final fun getZERO ()Ldev/slne/surf/api/paper/display/style/Insets; + public final fun horizontal (I)Ldev/slne/surf/api/paper/display/style/Insets; + public final fun symmetric (II)Ldev/slne/surf/api/paper/display/style/Insets; + public final fun vertical (I)Ldev/slne/surf/api/paper/display/style/Insets; +} + +public final class dev/slne/surf/api/paper/display/style/JustifyContent : java/lang/Enum { + public static final field CENTER Ldev/slne/surf/api/paper/display/style/JustifyContent; + public static final field END Ldev/slne/surf/api/paper/display/style/JustifyContent; + public static final field SPACE_AROUND Ldev/slne/surf/api/paper/display/style/JustifyContent; + public static final field SPACE_BETWEEN Ldev/slne/surf/api/paper/display/style/JustifyContent; + public static final field START Ldev/slne/surf/api/paper/display/style/JustifyContent; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/display/style/JustifyContent; + public static fun values ()[Ldev/slne/surf/api/paper/display/style/JustifyContent; +} + +public final class dev/slne/surf/api/paper/display/style/Overflow : java/lang/Enum { + public static final field HIDDEN Ldev/slne/surf/api/paper/display/style/Overflow; + public static final field VISIBLE Ldev/slne/surf/api/paper/display/style/Overflow; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/display/style/Overflow; + public static fun values ()[Ldev/slne/surf/api/paper/display/style/Overflow; +} + +public final class dev/slne/surf/api/paper/display/style/Style { + public fun ()V + public final fun getAlignItems ()Ldev/slne/surf/api/paper/display/style/AlignItems; + public final fun getBackgroundColor ()Ljava/lang/Integer; + public final fun getBorder ()Ldev/slne/surf/api/paper/display/style/Border; + public final fun getBorderRadius ()I + public final fun getColor ()I + public final fun getCursor ()Ldev/slne/surf/api/paper/display/cursor/CursorStyle; + public final fun getFlexDirection ()Ldev/slne/surf/api/paper/display/style/FlexDirection; + public final fun getFontSize ()I + public final fun getGap ()I + public final fun getHeight ()Ljava/lang/Integer; + public final fun getJustifyContent ()Ldev/slne/surf/api/paper/display/style/JustifyContent; + public final fun getMargin ()Ldev/slne/surf/api/paper/display/style/Insets; + public final fun getOpacity ()F + public final fun getOverflow ()Ldev/slne/surf/api/paper/display/style/Overflow; + public final fun getPadding ()Ldev/slne/surf/api/paper/display/style/Insets; + public final fun getTextAlign ()Ldev/slne/surf/api/paper/display/style/TextAlign; + public final fun getVerticalAlign ()Ldev/slne/surf/api/paper/display/style/VerticalAlign; + public final fun getVisible ()Z + public final fun getWidth ()Ljava/lang/Integer; + public final fun setAlignItems (Ldev/slne/surf/api/paper/display/style/AlignItems;)V + public final fun setBackgroundColor (Ljava/lang/Integer;)V + public final fun setBorder (Ldev/slne/surf/api/paper/display/style/Border;)V + public final fun setBorderRadius (I)V + public final fun setColor (I)V + public final fun setCursor (Ldev/slne/surf/api/paper/display/cursor/CursorStyle;)V + public final fun setFlexDirection (Ldev/slne/surf/api/paper/display/style/FlexDirection;)V + public final fun setFontSize (I)V + public final fun setGap (I)V + public final fun setHeight (Ljava/lang/Integer;)V + public final fun setJustifyContent (Ldev/slne/surf/api/paper/display/style/JustifyContent;)V + public final fun setMargin (Ldev/slne/surf/api/paper/display/style/Insets;)V + public final fun setOpacity (F)V + public final fun setOverflow (Ldev/slne/surf/api/paper/display/style/Overflow;)V + public final fun setPadding (Ldev/slne/surf/api/paper/display/style/Insets;)V + public final fun setTextAlign (Ldev/slne/surf/api/paper/display/style/TextAlign;)V + public final fun setVerticalAlign (Ldev/slne/surf/api/paper/display/style/VerticalAlign;)V + public final fun setVisible (Z)V + public final fun setWidth (Ljava/lang/Integer;)V +} + +public final class dev/slne/surf/api/paper/display/style/TextAlign : java/lang/Enum { + public static final field CENTER Ldev/slne/surf/api/paper/display/style/TextAlign; + public static final field LEFT Ldev/slne/surf/api/paper/display/style/TextAlign; + public static final field RIGHT Ldev/slne/surf/api/paper/display/style/TextAlign; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/display/style/TextAlign; + public static fun values ()[Ldev/slne/surf/api/paper/display/style/TextAlign; +} + +public final class dev/slne/surf/api/paper/display/style/VerticalAlign : java/lang/Enum { + public static final field BOTTOM Ldev/slne/surf/api/paper/display/style/VerticalAlign; + public static final field CENTER Ldev/slne/surf/api/paper/display/style/VerticalAlign; + public static final field TOP Ldev/slne/surf/api/paper/display/style/VerticalAlign; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/slne/surf/api/paper/display/style/VerticalAlign; + public static fun values ()[Ldev/slne/surf/api/paper/display/style/VerticalAlign; +} + public final class dev/slne/surf/api/paper/event/Listener_extensionKt { public static final fun cancel (Lorg/bukkit/event/Cancellable;)V public static final fun listen (Lorg/bukkit/plugin/Plugin;Lkotlin/reflect/KClass;Lorg/bukkit/event/EventPriority;ZZLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/paper/event/SingleListener; diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/Color.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/Color.kt new file mode 100644 index 000000000..2c6013cd6 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/Color.kt @@ -0,0 +1,27 @@ +package dev.slne.surf.api.paper.display + +/** + * Packs RGBA components into a single ARGB int. + */ +fun argb(r: Int, g: Int, b: Int, a: Int = 255): Int = + (a shl 24) or (r shl 16) or (g shl 8) or b + +/** + * Converts an RGB int (0xRRGGBB) to a fully opaque ARGB int. + */ +fun argb(rgb: Int): Int = (0xFF shl 24) or (rgb and 0xFFFFFF) + +/** + * Convenience alias for [argb] from an RGB hex value. + */ +fun color(rgb: Int): Int = argb(rgb) + +/** + * Convenience alias for [argb] from individual RGB components. + */ +fun color(r: Int, g: Int, b: Int): Int = argb(r, g, b) + +/** + * Convenience alias for [argb] from individual RGBA components. + */ +fun color(r: Int, g: Int, b: Int, a: Int): Int = argb(r, g, b, a) diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Behavior.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Behavior.kt new file mode 100644 index 000000000..62bc3c7d8 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Behavior.kt @@ -0,0 +1,7 @@ +package dev.slne.surf.api.paper.display.behavior + +/** + * Marker interface for behaviors that can be attached to elements. + * Behaviors define how an element responds to user interactions. + */ +interface Behavior diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Clickable.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Clickable.kt new file mode 100644 index 000000000..1a9889b1e --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Clickable.kt @@ -0,0 +1,11 @@ +package dev.slne.surf.api.paper.display.behavior + +/** + * Makes an element respond to click interactions. + */ +class Clickable( + /** Called when the element is left-clicked. */ + val onClick: (InteractionContext) -> Unit = {}, + /** Called when the element is right-clicked. */ + val onRightClick: (InteractionContext) -> Unit = {}, +) : Behavior diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Draggable.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Draggable.kt new file mode 100644 index 000000000..fa450f128 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Draggable.kt @@ -0,0 +1,13 @@ +package dev.slne.surf.api.paper.display.behavior + +/** + * Makes an element draggable. + */ +class Draggable( + /** Called when a drag starts. */ + val onDragStart: (InteractionContext) -> Unit = {}, + /** Called during dragging with updated position. */ + val onDrag: (InteractionContext) -> Unit = {}, + /** Called when dragging ends. */ + val onDragEnd: (InteractionContext) -> Unit = {}, +) : Behavior diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/ElementPhase.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/ElementPhase.kt new file mode 100644 index 000000000..d199ab9ba --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/ElementPhase.kt @@ -0,0 +1,15 @@ +package dev.slne.surf.api.paper.display.behavior + +/** + * Tracks the current interaction phase of an element. + */ +enum class ElementPhase { + /** No interaction. */ + DEFAULT, + + /** Player is looking at this element. */ + HOVER, + + /** Player is clicking this element. */ + CLICK, +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Hoverable.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Hoverable.kt new file mode 100644 index 000000000..f9ca1d8e5 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Hoverable.kt @@ -0,0 +1,11 @@ +package dev.slne.surf.api.paper.display.behavior + +/** + * Makes an element respond to hover interactions (player looking at it). + */ +class Hoverable( + /** Called when the player starts looking at the element. */ + val onEnter: (InteractionContext) -> Unit = {}, + /** Called when the player stops looking at the element. */ + val onExit: (InteractionContext) -> Unit = {}, +) : Behavior diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/InteractionContext.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/InteractionContext.kt new file mode 100644 index 000000000..50326fe28 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/InteractionContext.kt @@ -0,0 +1,18 @@ +package dev.slne.surf.api.paper.display.behavior + +import dev.slne.surf.api.paper.display.element.Element +import java.util.UUID + +/** + * Context passed to behavior handlers during interactions. + */ +data class InteractionContext( + /** UUID of the interacting player. */ + val playerId: UUID, + /** The element being interacted with. */ + val element: Element, + /** X pixel coordinate on the display where the interaction occurred. */ + val pixelX: Int, + /** Y pixel coordinate on the display where the interaction occurred. */ + val pixelY: Int, +) diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Scrollable.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Scrollable.kt new file mode 100644 index 000000000..1160e1eee --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/Scrollable.kt @@ -0,0 +1,9 @@ +package dev.slne.surf.api.paper.display.behavior + +/** + * Makes an element respond to scroll interactions. + */ +class Scrollable( + /** Called when the player scrolls while looking at the element. Direction: positive = up, negative = down. */ + val onScroll: (context: InteractionContext, direction: Int) -> Unit = { _, _ -> }, +) : Behavior diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/TooltipBehavior.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/TooltipBehavior.kt new file mode 100644 index 000000000..4fb5206b5 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/behavior/TooltipBehavior.kt @@ -0,0 +1,9 @@ +package dev.slne.surf.api.paper.display.behavior + +/** + * Attaches a tooltip to an element that shows when hovered. + */ +class TooltipBehavior( + /** Tooltip text to display. */ + val text: String, +) : Behavior diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/cursor/CursorStyle.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/cursor/CursorStyle.kt new file mode 100644 index 000000000..586d5310b --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/cursor/CursorStyle.kt @@ -0,0 +1,48 @@ +package dev.slne.surf.api.paper.display.cursor + +/** + * Predefined cursor styles that can be applied to elements. + * Each style maps to a horse armor texture path for visual cursor display. + * + * Usage: + * ```kotlin + * div { + * style { cursor = CursorStyle.POINTER } + * clickable { ... } + * } + * ``` + */ +enum class CursorStyle(val texturePath: String) { + /** Default arrow cursor. */ + DEFAULT("cursor_default"), + + /** Pointer/hand cursor for clickable elements. */ + POINTER("cursor_pointer"), + + /** Text caret cursor for text input fields. */ + TEXT("cursor_text"), + + /** Move/drag cursor for draggable elements. */ + MOVE("cursor_move"), + + /** Grab cursor for drag-and-drop. */ + GRAB("cursor_grab"), + + /** Grabbing cursor (actively dragging). */ + GRABBING("cursor_grabbing"), + + /** Not-allowed cursor for disabled elements. */ + NOT_ALLOWED("cursor_not_allowed"), + + /** Crosshair cursor for precise selection. */ + CROSSHAIR("cursor_crosshair"), + + /** Resize horizontal cursor. */ + RESIZE_HORIZONTAL("cursor_resize_horizontal"), + + /** Resize vertical cursor. */ + RESIZE_VERTICAL("cursor_resize_vertical"), + + /** Loading/busy cursor. */ + WAIT("cursor_wait"), +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/document/Document.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/document/Document.kt new file mode 100644 index 000000000..eb6e562a7 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/document/Document.kt @@ -0,0 +1,47 @@ +package dev.slne.surf.api.paper.display.document + +import dev.slne.surf.api.paper.display.element.Div +import dev.slne.surf.api.paper.display.render.Canvas +import dev.slne.surf.api.paper.display.render.Renderer + +/** + * A document represents the root of a display's element tree. + * It has a fixed pixel size and contains a root [Div] element. + * + * Usage: + * ```kotlin + * val doc = document(384, 256) { + * style { backgroundColor = color(0x1E1E2E) } + * div { + * label("Hello World!") { + * style { color = color(0xFFFFFF) } + * } + * } + * } + * val canvas = doc.render() + * ``` + */ +class Document(val width: Int, val height: Int) { + val root = Div().apply { + style.width = width + style.height = height + } + + /** + * Renders the element tree into a [Canvas]. + */ + fun render(): Canvas { + val canvas = Canvas(width, height) + Renderer.render(root, canvas) + return canvas + } +} + +/** + * DSL entry point for creating a [Document]. + */ +fun document(width: Int, height: Int, block: Div.() -> Unit): Document { + val doc = Document(width, height) + doc.root.block() + return doc +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Div.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Div.kt new file mode 100644 index 000000000..6639b54b0 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Div.kt @@ -0,0 +1,29 @@ +package dev.slne.surf.api.paper.display.element + +import dev.slne.surf.api.paper.display.render.Canvas +import dev.slne.surf.api.paper.display.shape.Shape + +/** + * A container element (like HTML `
`) that can hold child elements. + */ +class Div : Element() { + /** Add a child div container. */ + fun div(block: Div.() -> Unit = {}): Div { + return Div().also { it.block(); children.add(it) } + } + + /** Add a text label. */ + fun label(text: String, block: Label.() -> Unit = {}): Label { + return Label(text).also { it.block(); children.add(it) } + } + + /** Add an image element from a canvas source. */ + fun image(source: Canvas, block: ImageElement.() -> Unit = {}): ImageElement { + return ImageElement(source).also { it.block(); children.add(it) } + } + + /** Add a shape element. */ + fun shape(shape: Shape, block: ShapeElement.() -> Unit = {}): ShapeElement { + return ShapeElement(shape).also { it.block(); children.add(it) } + } +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Element.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Element.kt new file mode 100644 index 000000000..c9cb0a5cc --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Element.kt @@ -0,0 +1,100 @@ +package dev.slne.surf.api.paper.display.element + +import dev.slne.surf.api.paper.display.behavior.* +import dev.slne.surf.api.paper.display.style.Style + +class Rect(var x: Int = 0, var y: Int = 0, var width: Int = 0, var height: Int = 0) + +/** + * Base class for all UI elements in the display system. + * + * Elements form a tree structure (like HTML DOM). Each element has: + * - A [style] for visual properties (CSS-like) + * - A list of [children] elements + * - A list of [behaviors] for interactivity + */ +abstract class Element { + /** CSS-like style properties for this element. */ + val style = Style() + + /** Child elements. */ + val children = mutableListOf() + + /** Attached behaviors for interactivity. */ + val behaviors = mutableListOf() + + /** Computed layout bounds (set by the renderer). */ + var bounds = Rect() + + /** Current interaction phase. */ + var phase: ElementPhase = ElementPhase.DEFAULT + internal set + + /** Configure the style using a DSL block. */ + fun style(block: Style.() -> Unit) { + style.block() + } + + // --- Behavior DSL --- + + /** Attach a behavior to this element. */ + fun behavior(behavior: T): T { + behaviors.add(behavior) + return behavior + } + + /** Make this element respond to click events. */ + fun clickable( + onClick: (InteractionContext) -> Unit = {}, + onRightClick: (InteractionContext) -> Unit = {}, + ) { + behavior(Clickable(onClick, onRightClick)) + } + + /** Make this element respond to hover events. */ + fun hoverable( + onEnter: (InteractionContext) -> Unit = {}, + onExit: (InteractionContext) -> Unit = {}, + ) { + behavior(Hoverable(onEnter, onExit)) + } + + /** Make this element draggable. */ + fun draggable( + onDragStart: (InteractionContext) -> Unit = {}, + onDrag: (InteractionContext) -> Unit = {}, + onDragEnd: (InteractionContext) -> Unit = {}, + ) { + behavior(Draggable(onDragStart, onDrag, onDragEnd)) + } + + /** Make this element respond to scroll events. */ + fun scrollable( + onScroll: (InteractionContext, Int) -> Unit = { _, _ -> }, + ) { + behavior(Scrollable(onScroll)) + } + + /** Attach a tooltip to this element. */ + fun tooltip(text: String) { + behavior(TooltipBehavior(text)) + } + + /** Shorthand: add a click handler. */ + fun onClick(handler: (InteractionContext) -> Unit) { + clickable(onClick = handler) + } + + /** Shorthand: add a right-click handler. */ + fun onRightClick(handler: (InteractionContext) -> Unit) { + clickable(onRightClick = handler) + } + + /** Find all behaviors of a specific type. */ + inline fun findBehaviors(): List = + behaviors.filterIsInstance() + + /** Check whether the element has a specific behavior type. */ + inline fun hasBehavior(): Boolean = + behaviors.any { it is T } +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/ImageElement.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/ImageElement.kt new file mode 100644 index 000000000..25ca7a10b --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/ImageElement.kt @@ -0,0 +1,8 @@ +package dev.slne.surf.api.paper.display.element + +import dev.slne.surf.api.paper.display.render.Canvas + +/** + * An element that displays an image from a [Canvas] source. + */ +class ImageElement(val source: Canvas) : Element() diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Label.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Label.kt new file mode 100644 index 000000000..a3e931a82 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Label.kt @@ -0,0 +1,10 @@ +package dev.slne.surf.api.paper.display.element + +/** + * A text label element (like HTML `` or `

`). + */ +class Label(var text: String) : Element() { + internal var wrappedLines = listOf() + internal var textWidth = 0 + internal var textHeight = 0 +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/ShapeElement.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/ShapeElement.kt new file mode 100644 index 000000000..b0353d06e --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/ShapeElement.kt @@ -0,0 +1,9 @@ +package dev.slne.surf.api.paper.display.element + +import dev.slne.surf.api.paper.display.shape.Shape + +/** + * An element that renders a geometric [Shape]. + * The shape handles its own pixel rasterization via [Shape.rasterize]. + */ +class ShapeElement(val shape: Shape) : Element() diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/render/Canvas.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/render/Canvas.kt new file mode 100644 index 000000000..1dbf954d7 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/render/Canvas.kt @@ -0,0 +1,200 @@ +package dev.slne.surf.api.paper.display.render + +import org.bukkit.map.MapPalette +import java.awt.Color + +/** + * Low-level pixel buffer. Provides only primitive drawing operations. + * Higher-level drawing (circles, lines, etc.) is handled by [dev.slne.surf.api.paper.display.shape.Shape] implementations. + */ +class Canvas(val width: Int, val height: Int) { + val pixels = IntArray(width * height) + + // --- Clip Stack (for overflow clipping) --- + private var clipX1 = 0 + private var clipY1 = 0 + private var clipX2 = width + private var clipY2 = height + private val clipStack = ArrayDeque() + + /** + * Push a clip rectangle onto the stack. The effective clip is the intersection + * of the current clip and the new rectangle. All drawing operations respect this. + */ + fun pushClip(x: Int, y: Int, w: Int, h: Int) { + clipStack.addLast(intArrayOf(clipX1, clipY1, clipX2, clipY2)) + clipX1 = maxOf(clipX1, x) + clipY1 = maxOf(clipY1, y) + clipX2 = minOf(clipX2, x + w) + clipY2 = minOf(clipY2, y + h) + } + + /** Pop the last clip rectangle, restoring the previous clip. */ + fun popClip() { + val prev = clipStack.removeLastOrNull() ?: return + clipX1 = prev[0]; clipY1 = prev[1]; clipX2 = prev[2]; clipY2 = prev[3] + } + + /** Set a single pixel. Coordinates outside the clip are silently ignored. */ + fun setPixel(x: Int, y: Int, color: Int) { + if (x in clipX1 until clipX2 && y in clipY1 until clipY2) { + pixels[y * width + x] = color + } + } + + /** Set a single pixel, ignoring the clip stack (used for cursor overlay). */ + fun setPixelUnclipped(x: Int, y: Int, color: Int) { + if (x in 0 until width && y in 0 until height) { + pixels[y * width + x] = color + } + } + + /** Get a single pixel color. Returns 0 for out-of-bounds coordinates. */ + fun getPixel(x: Int, y: Int): Int { + return if (x in 0 until width && y in 0 until height) pixels[y * width + x] else 0 + } + + /** Fill the entire canvas with a single color. */ + fun fill(color: Int) { + pixels.fill(color) + } + + /** Fill a rectangular area with a single color, respecting the clip. */ + fun fillRect(x: Int, y: Int, w: Int, h: Int, color: Int) { + val x1 = maxOf(clipX1, x) + val y1 = maxOf(clipY1, y) + val x2 = minOf(clipX2, x + w) + val y2 = minOf(clipY2, y + h) + for (py in y1 until y2) { + val rowStart = py * width + for (px in x1 until x2) { + pixels[rowStart + px] = color + } + } + } + + /** Fill a rectangular area with alpha blending, respecting the clip. */ + fun fillRectBlended(x: Int, y: Int, w: Int, h: Int, color: Int) { + val srcA = (color ushr 24) and 0xFF + if (srcA == 255) { + fillRect(x, y, w, h, color) + return + } + if (srcA == 0) return + + val x1 = maxOf(clipX1, x) + val y1 = maxOf(clipY1, y) + val x2 = minOf(clipX2, x + w) + val y2 = minOf(clipY2, y + h) + for (py in y1 until y2) { + val rowStart = py * width + for (px in x1 until x2) { + pixels[rowStart + px] = alphaBlend(color, pixels[rowStart + px]) + } + } + } + + /** Draw a rectangular outline with a given thickness. */ + fun drawRect(x: Int, y: Int, w: Int, h: Int, color: Int, thickness: Int = 1) { + fillRect(x, y, w, thickness, color) + fillRect(x, y + h - thickness, w, thickness, color) + fillRect(x, y, thickness, h, color) + fillRect(x + w - thickness, y, thickness, h, color) + } + + /** Copy another canvas onto this one at the given position (alpha-aware). */ + fun place(other: Canvas, destX: Int, destY: Int) { + for (sy in 0 until other.height) { + for (sx in 0 until other.width) { + val pixel = other.pixels[sy * other.width + sx] + if ((pixel ushr 24) > 0) { + setPixel(destX + sx, destY + sy, pixel) + } + } + } + } + + /** + * Blend another canvas onto this one using alpha compositing (Source Over). + * Used for rendering modal overlays with semi-transparent backgrounds. + */ + fun blend(other: Canvas, destX: Int, destY: Int) { + for (sy in 0 until other.height) { + for (sx in 0 until other.width) { + val srcColor = other.pixels[sy * other.width + sx] + val srcA = (srcColor ushr 24) and 0xFF + if (srcA == 0) continue + + val dx = destX + sx + val dy = destY + sy + if (dx !in 0 until width || dy !in 0 until height) continue + + if (srcA == 255) { + pixels[dy * width + dx] = srcColor + } else { + pixels[dy * width + dx] = alphaBlend(srcColor, pixels[dy * width + dx]) + } + } + } + } + + /** + * Extracts a 128x128 tile of map color data from this canvas at the given pixel offset. + * Used for converting to Minecraft map format. + */ + @Suppress("DEPRECATION") + fun toMapColors(offsetX: Int, offsetY: Int): ByteArray { + val data = ByteArray(128 * 128) + for (y in 0 until 128) { + for (x in 0 until 128) { + val px = offsetX + x + val py = offsetY + y + val argb = if (px in 0 until width && py in 0 until height) { + pixels[py * width + px] + } else { + 0 + } + val alpha = (argb ushr 24) and 0xFF + data[y * 128 + x] = if (alpha < 128) { + 0 + } else { + MapPalette.matchColor( + Color((argb shr 16) and 0xFF, (argb shr 8) and 0xFF, argb and 0xFF) + ) + } + } + } + return data + } + + companion object { + /** + * Alpha-blend source color over destination color (Source Over compositing). + */ + fun alphaBlend(src: Int, dst: Int): Int { + val srcA = (src ushr 24) and 0xFF + if (srcA == 255) return src + if (srcA == 0) return dst + + val srcR = (src shr 16) and 0xFF + val srcG = (src shr 8) and 0xFF + val srcB = src and 0xFF + + val dstA = (dst ushr 24) and 0xFF + val dstR = (dst shr 16) and 0xFF + val dstG = (dst shr 8) and 0xFF + val dstB = dst and 0xFF + + val invSrcA = 255 - srcA + val outA = srcA + (dstA * invSrcA) / 255 + if (outA == 0) return 0 + + val outR = (srcR * srcA + dstR * dstA * invSrcA / 255) / outA + val outG = (srcG * srcA + dstG * dstA * invSrcA / 255) / outA + val outB = (srcB * srcA + dstB * dstA * invSrcA / 255) / outA + + return (outA shl 24) or (outR.coerceIn(0, 255) shl 16) or + (outG.coerceIn(0, 255) shl 8) or outB.coerceIn(0, 255) + } + } +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/render/Renderer.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/render/Renderer.kt new file mode 100644 index 000000000..dfd412aa0 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/render/Renderer.kt @@ -0,0 +1,399 @@ +package dev.slne.surf.api.paper.display.render + +import dev.slne.surf.api.paper.display.element.* +import dev.slne.surf.api.paper.display.style.* + +import java.awt.Font +import java.awt.RenderingHints +import java.awt.image.BufferedImage + +/** + * Handles layout computation and painting of the element tree onto a [Canvas]. + * + * Uses a border-box model similar to CSS: + * - Element width/height includes padding and border + * - Margin is outside the element bounds + * - Children are stacked vertically (column) or horizontally (row) based on [FlexDirection] + */ +object Renderer { + + fun render(root: Element, canvas: Canvas) { + layout(root, 0, 0, canvas.width, canvas.height) + paint(root, canvas, 0, 0) + } + + // --- LAYOUT (border-box model) --- + + private fun layout(node: Element, x: Int, y: Int, availableWidth: Int, availableHeight: Int = Int.MAX_VALUE) { + if (!node.style.visible) { + node.bounds = Rect(x, y, 0, 0) + return + } + + val s = node.style + val bw = s.border?.width ?: 0 + + node.bounds.x = x + s.margin.left + node.bounds.y = y + s.margin.top + node.bounds.width = s.width ?: (availableWidth - s.margin.horizontal) + + val contentWidth = maxOf(0, node.bounds.width - s.padding.horizontal - bw * 2) + var contentHeight = 0 + + // Text content + if (node is Label && node.text.isNotEmpty()) { + node.wrappedLines = wrapText(node.text, maxOf(1, contentWidth), s.fontSize) + val (tw, th) = measureWrappedText(node.wrappedLines, s.fontSize) + node.textWidth = tw + node.textHeight = th + contentHeight += th + } + + // Image content + if (node is ImageElement) { + contentHeight += node.source.height + } + + // Shape content + if (node is ShapeElement) { + contentHeight += node.shape.height + } + + // Children layout + val childStartY = contentHeight + when (s.flexDirection) { + FlexDirection.COLUMN -> layoutColumn(node, contentWidth, s.gap, childStartY).also { contentHeight += it } + FlexDirection.ROW -> layoutRow(node, contentWidth, s.gap, childStartY).also { contentHeight += it } + } + + node.bounds.height = s.height ?: (contentHeight + s.padding.vertical + bw * 2) + } + + private fun layoutColumn(node: Element, contentWidth: Int, gap: Int, startY: Int): Int { + val s = node.style + val bw = s.border?.width ?: 0 + + var cursorY = startY + for ((index, child) in node.children.withIndex()) { + layout(child, 0, cursorY, contentWidth) + cursorY += child.style.margin.top + child.bounds.height + child.style.margin.bottom + if (index < node.children.size - 1 && gap > 0) { + cursorY += gap + } + } + val totalChildrenHeight = if (node.children.isNotEmpty()) cursorY - startY else 0 + + if (s.justifyContent != JustifyContent.START && node.children.isNotEmpty()) { + val availableContentHeight = if (s.height != null) { + maxOf(0, s.height!! - s.padding.vertical - bw * 2 - startY) + } else { + totalChildrenHeight + } + val extraSpace = maxOf(0, availableContentHeight - totalChildrenHeight) + if (extraSpace > 0) { + when (s.justifyContent) { + JustifyContent.CENTER -> { + val offset = extraSpace / 2 + for (child in node.children) { + child.bounds.y += offset + } + } + JustifyContent.END -> { + for (child in node.children) { + child.bounds.y += extraSpace + } + } + JustifyContent.SPACE_BETWEEN -> { + if (node.children.size > 1) { + val spaceBetween = extraSpace / (node.children.size - 1) + for ((i, child) in node.children.withIndex()) { + child.bounds.y += spaceBetween * i + } + } + } + JustifyContent.SPACE_AROUND -> { + val spaceAround = extraSpace / (node.children.size * 2) + for ((i, child) in node.children.withIndex()) { + child.bounds.y += spaceAround * (2 * i + 1) + } + } + else -> {} + } + } + } + + if (s.alignItems != AlignItems.START && s.alignItems != AlignItems.STRETCH && node.children.isNotEmpty()) { + for (child in node.children) { + val childWidth = child.bounds.width + val crossOffset = when (s.alignItems) { + AlignItems.CENTER -> maxOf(0, (contentWidth - childWidth) / 2) + AlignItems.END -> maxOf(0, contentWidth - childWidth) + else -> 0 + } + if (crossOffset > 0) { + child.bounds.x += crossOffset + } + } + } + + return totalChildrenHeight + } + + private fun layoutRow(node: Element, contentWidth: Int, gap: Int, startY: Int): Int { + val s = node.style + + var cursorX = 0 + var maxHeight = 0 + for ((index, child) in node.children.withIndex()) { + val intrinsicWidth = when { + child.style.width != null -> child.style.width + child is ShapeElement -> child.shape.width + child is ImageElement -> child.source.width + else -> null + } + val childAvailableWidth = intrinsicWidth ?: maxOf(1, contentWidth - cursorX) + layout(child, cursorX, startY, childAvailableWidth) + + if (child.style.width == null && child !is ShapeElement && child !is ImageElement) { + val intrinsicW = computeIntrinsicWidth(child) + if (intrinsicW < child.bounds.width) { + child.bounds.width = intrinsicW + } + } + + cursorX += child.style.margin.left + child.bounds.width + child.style.margin.right + if (index < node.children.size - 1 && gap > 0) { + cursorX += gap + } + maxHeight = maxOf(maxHeight, child.style.margin.top + child.bounds.height + child.style.margin.bottom) + } + val totalChildrenWidth = cursorX + + if (s.justifyContent != JustifyContent.START && node.children.isNotEmpty()) { + val extraSpace = maxOf(0, contentWidth - totalChildrenWidth) + if (extraSpace > 0) { + when (s.justifyContent) { + JustifyContent.CENTER -> { + val offset = extraSpace / 2 + for (child in node.children) { + child.bounds.x += offset + } + } + JustifyContent.END -> { + for (child in node.children) { + child.bounds.x += extraSpace + } + } + JustifyContent.SPACE_BETWEEN -> { + if (node.children.size > 1) { + val spaceBetween = extraSpace / (node.children.size - 1) + for ((i, child) in node.children.withIndex()) { + child.bounds.x += spaceBetween * i + } + } + } + JustifyContent.SPACE_AROUND -> { + val spaceAround = extraSpace / (node.children.size * 2) + for ((i, child) in node.children.withIndex()) { + child.bounds.x += spaceAround * (2 * i + 1) + } + } + else -> {} + } + } + } + + if (s.alignItems != AlignItems.START && s.alignItems != AlignItems.STRETCH && node.children.isNotEmpty()) { + for (child in node.children) { + val childHeight = child.bounds.height + val crossOffset = when (s.alignItems) { + AlignItems.CENTER -> maxOf(0, (maxHeight - childHeight) / 2) + AlignItems.END -> maxOf(0, maxHeight - childHeight) + else -> 0 + } + if (crossOffset > 0) { + child.bounds.y += crossOffset + } + } + } + + return maxHeight + } + + private fun computeIntrinsicWidth(node: Element): Int { + val s = node.style + val bw = s.border?.width ?: 0 + var contentWidth = 0 + + if (node is Label) { + contentWidth = maxOf(contentWidth, node.textWidth) + } + + if (node is ShapeElement) { + contentWidth = maxOf(contentWidth, node.shape.width) + } + + if (node is ImageElement) { + contentWidth = maxOf(contentWidth, node.source.width) + } + + if (s.flexDirection == FlexDirection.ROW) { + var total = 0 + for ((i, child) in node.children.withIndex()) { + total += child.style.margin.horizontal + child.bounds.width + if (i < node.children.size - 1) total += s.gap + } + contentWidth = maxOf(contentWidth, total) + } else { + for (child in node.children) { + contentWidth = maxOf(contentWidth, child.style.margin.horizontal + child.bounds.width) + } + } + + return contentWidth + s.padding.horizontal + bw * 2 + } + + // --- PAINT --- + + private fun paint(node: Element, canvas: Canvas, offsetX: Int, offsetY: Int) { + if (!node.style.visible) return + + val s = node.style + val bw = s.border?.width ?: 0 + val absX = offsetX + node.bounds.x + val absY = offsetY + node.bounds.y + + s.backgroundColor?.let { + canvas.fillRect(absX, absY, node.bounds.width, node.bounds.height, it) + } + + s.border?.let { + canvas.drawRect(absX, absY, node.bounds.width, node.bounds.height, it.color, it.width) + } + + val cx = absX + s.padding.left + bw + val cy = absY + s.padding.top + bw + val cw = maxOf(0, node.bounds.width - s.padding.horizontal - bw * 2) + + if (node is Label && node.wrappedLines.isNotEmpty()) { + val textImage = renderMultilineText(node.wrappedLines, s.fontSize, s.color) + val textX = when (s.textAlign) { + TextAlign.LEFT -> cx + TextAlign.CENTER -> cx + (cw - node.textWidth) / 2 + TextAlign.RIGHT -> cx + cw - node.textWidth + } + drawBufferedImage(textImage, canvas, textX, cy) + } + + if (node is ImageElement) { + canvas.place(node.source, cx, cy) + } + + if (node is ShapeElement) { + node.shape.paint(canvas, cx, cy, s.color) + } + + val ch = maxOf(0, node.bounds.height - s.padding.vertical - bw * 2) + canvas.pushClip(cx, cy, cw, ch) + for (child in node.children) { + paint(child, canvas, cx, cy) + } + canvas.popClip() + } + + // --- TEXT --- + + private fun wrapText(text: String, maxWidth: Int, fontSize: Int): List { + val lines = mutableListOf() + for (line in text.split("\n")) { + if (line.isEmpty()) { + lines.add("") + continue + } + val words = line.split(" ") + val current = StringBuilder() + for (word in words) { + val test = if (current.isEmpty()) word else "$current $word" + val (w, _) = measureText(test, fontSize) + if (w > maxWidth && current.isNotEmpty()) { + lines.add(current.toString()) + current.clear().append(word) + } else { + current.clear().append(test) + } + } + if (current.isNotEmpty()) lines.add(current.toString()) + } + return lines.ifEmpty { listOf("") } + } + + private fun measureText(text: String, fontSize: Int): Pair { + val font = Font(Font.SANS_SERIF, Font.PLAIN, fontSize) + val temp = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) + val g = temp.createGraphics() + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF) + g.font = font + val m = g.fontMetrics + val result = m.stringWidth(text) to m.height + g.dispose() + return result + } + + private fun measureWrappedText(lines: List, fontSize: Int): Pair { + val lineHeight = measureText("Ag", fontSize).second + var maxWidth = 0 + for (line in lines) { + if (line.isNotEmpty()) { + maxWidth = maxOf(maxWidth, measureText(line, fontSize).first) + } + } + return maxWidth to (lineHeight * lines.size) + } + + private fun renderMultilineText(lines: List, fontSize: Int, color: Int): BufferedImage { + val font = Font(Font.SANS_SERIF, Font.PLAIN, fontSize) + + val temp = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) + val tg = temp.createGraphics() + tg.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF) + tg.font = font + val lineHeight = tg.fontMetrics.height + var maxWidth = 0 + for (line in lines) { + if (line.isNotEmpty()) { + maxWidth = maxOf(maxWidth, tg.fontMetrics.stringWidth(line)) + } + } + tg.dispose() + + val w = maxOf(1, maxWidth) + val h = maxOf(1, lineHeight * lines.size) + + val img = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) + val g = img.createGraphics() + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF) + g.font = font + g.color = java.awt.Color((color shr 16) and 0xFF, (color shr 8) and 0xFF, color and 0xFF) + val ascent = g.fontMetrics.ascent + + for ((i, line) in lines.withIndex()) { + if (line.isNotEmpty()) { + g.drawString(line, 0, i * lineHeight + ascent) + } + } + g.dispose() + + return img + } + + private fun drawBufferedImage(img: BufferedImage, canvas: Canvas, destX: Int, destY: Int) { + for (y in 0 until img.height) { + for (x in 0 until img.width) { + val pixel = img.getRGB(x, y) + if ((pixel ushr 24) > 0) { + canvas.setPixel(destX + x, destY + y, pixel or (0xFF shl 24)) + } + } + } + } +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/CircleShape.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/CircleShape.kt new file mode 100644 index 000000000..f406a7bb0 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/CircleShape.kt @@ -0,0 +1,60 @@ +package dev.slne.surf.api.paper.display.shape + +import java.util.BitSet + +class CircleShape( + val radius: Int, + val filled: Boolean = true +) : Shape { + override val width = radius * 2 + 1 + override val height = radius * 2 + 1 + + private val bits: BitSet = BitSet(width * height).apply { + val cx = radius + val cy = radius + if (filled) { + for (y in 0 until height) { + for (x in 0 until width) { + val dx = x - cx + val dy = y - cy + if (dx * dx + dy * dy <= radius * radius) { + set(y * width + x) + } + } + } + } else { + var x = radius + var y = 0 + var d = 1 - radius + while (x >= y) { + setSymmetric(this, cx, cy, x, y) + y++ + if (d <= 0) { + d += 2 * y + 1 + } else { + x-- + d += 2 * y - 2 * x + 1 + } + } + } + } + + private fun setSymmetric(bits: BitSet, cx: Int, cy: Int, x: Int, y: Int) { + val w = width + fun set(px: Int, py: Int) { + if (px in 0 until w && py in 0 until height) { + bits.set(py * w + px) + } + } + set(cx + x, cy + y) + set(cx - x, cy + y) + set(cx + x, cy - y) + set(cx - x, cy - y) + set(cx + y, cy + x) + set(cx - y, cy + x) + set(cx + y, cy - x) + set(cx - y, cy - x) + } + + override fun rasterize(): BitSet = bits +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/EllipseShape.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/EllipseShape.kt new file mode 100644 index 000000000..341c7f44e --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/EllipseShape.kt @@ -0,0 +1,45 @@ +package dev.slne.surf.api.paper.display.shape + +import java.util.BitSet + +class EllipseShape( + val radiusX: Int, + val radiusY: Int, + val filled: Boolean = true +) : Shape { + override val width = radiusX * 2 + 1 + override val height = radiusY * 2 + 1 + + private val bits: BitSet = BitSet(width * height).apply { + val cx = radiusX + val cy = radiusY + val rx2 = radiusX.toLong() * radiusX + val ry2 = radiusY.toLong() * radiusY + + if (filled) { + for (y in 0 until height) { + for (x in 0 until width) { + val dx = (x - cx).toLong() + val dy = (y - cy).toLong() + if (dx * dx * ry2 + dy * dy * rx2 <= rx2 * ry2) { + set(y * width + x) + } + } + } + } else { + for (y in 0 until height) { + for (x in 0 until width) { + val dx = (x - cx).toLong() + val dy = (y - cy).toLong() + val dist = dx * dx * ry2 + dy * dy * rx2 + val threshold = rx2 * ry2 + if (dist <= threshold && dist >= threshold - (rx2 + ry2) * 2) { + set(y * width + x) + } + } + } + } + } + + override fun rasterize(): BitSet = bits +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/LineShape.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/LineShape.kt new file mode 100644 index 000000000..2f3c64410 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/LineShape.kt @@ -0,0 +1,52 @@ +package dev.slne.surf.api.paper.display.shape + +import java.util.BitSet +import kotlin.math.abs + +class LineShape( + val dx: Int, + val dy: Int, + val thickness: Int = 1 +) : Shape { + override val width = abs(dx) + thickness + override val height = abs(dy) + thickness + + private val bits: BitSet = BitSet(width * height).apply { + val x0 = if (dx >= 0) 0 else abs(dx) + val y0 = if (dy >= 0) 0 else abs(dy) + val x1 = x0 + dx + val y1 = y0 + dy + + bresenham(this, x0, y0, x1, y1, thickness) + } + + private fun bresenham(bits: BitSet, x0: Int, y0: Int, x1: Int, y1: Int, thickness: Int) { + val dx = abs(x1 - x0) + val dy = abs(y1 - y0) + val sx = if (x0 < x1) 1 else -1 + val sy = if (y0 < y1) 1 else -1 + var err = dx - dy + var x = x0 + var y = y0 + val half = thickness / 2 + + while (true) { + for (ty in -half until -half + thickness) { + for (tx in -half until -half + thickness) { + val px = x + tx + val py = y + ty + if (px in 0 until width && py in 0 until height) { + bits.set(py * width + px) + } + } + } + + if (x == x1 && y == y1) break + val e2 = 2 * err + if (e2 > -dy) { err -= dy; x += sx } + if (e2 < dx) { err += dx; y += sy } + } + } + + override fun rasterize(): BitSet = bits +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/PolygonShape.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/PolygonShape.kt new file mode 100644 index 000000000..d4b985f87 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/PolygonShape.kt @@ -0,0 +1,82 @@ +package dev.slne.surf.api.paper.display.shape + +import java.util.BitSet +import kotlin.math.abs + +class PolygonShape( + val vertices: List>, + val filled: Boolean = true +) : Shape { + override val width: Int + override val height: Int + + private val bits: BitSet + + init { + require(vertices.size >= 3) { "A polygon requires at least 3 vertices" } + + val minX = vertices.minOf { it.first } + val minY = vertices.minOf { it.second } + val maxX = vertices.maxOf { it.first } + val maxY = vertices.maxOf { it.second } + + width = maxX - minX + 1 + height = maxY - minY + 1 + + val normalized = vertices.map { (it.first - minX) to (it.second - minY) } + + bits = BitSet(width * height).apply { + if (filled) { + for (y in 0 until height) { + val intersections = mutableListOf() + val n = normalized.size + for (i in 0 until n) { + val (x0, y0) = normalized[i] + val (x1, y1) = normalized[(i + 1) % n] + if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) { + val xIntersect = x0 + (y - y0).toFloat() / (y1 - y0) * (x1 - x0) + intersections.add(xIntersect.toInt()) + } + } + intersections.sort() + for (i in 0 until intersections.size - 1 step 2) { + for (x in intersections[i]..intersections[i + 1]) { + if (x in 0 until width) { + set(y * width + x) + } + } + } + } + } else { + val n = normalized.size + for (i in 0 until n) { + val (x0, y0) = normalized[i] + val (x1, y1) = normalized[(i + 1) % n] + drawLine(this, x0, y0, x1, y1) + } + } + } + } + + private fun drawLine(bits: BitSet, x0: Int, y0: Int, x1: Int, y1: Int) { + val dx = abs(x1 - x0) + val dy = abs(y1 - y0) + val sx = if (x0 < x1) 1 else -1 + val sy = if (y0 < y1) 1 else -1 + var err = dx - dy + var x = x0 + var y = y0 + + while (true) { + if (x in 0 until width && y in 0 until height) { + bits.set(y * width + x) + } + if (x == x1 && y == y1) break + val e2 = 2 * err + if (e2 > -dy) { err -= dy; x += sx } + if (e2 < dx) { err += dx; y += sy } + } + } + + override fun rasterize(): BitSet = bits +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/RectangleShape.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/RectangleShape.kt new file mode 100644 index 000000000..41b6ccda4 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/RectangleShape.kt @@ -0,0 +1,26 @@ +package dev.slne.surf.api.paper.display.shape + +import java.util.BitSet + +class RectangleShape( + override val width: Int, + override val height: Int, + val filled: Boolean = true +) : Shape { + private val bits: BitSet = BitSet(width * height).apply { + if (filled) { + set(0, width * height) + } else { + for (x in 0 until width) { + set(x) + set((height - 1) * width + x) + } + for (y in 0 until height) { + set(y * width) + set(y * width + width - 1) + } + } + } + + override fun rasterize(): BitSet = bits +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/RoundedRectangleShape.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/RoundedRectangleShape.kt new file mode 100644 index 000000000..692f6c0c8 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/RoundedRectangleShape.kt @@ -0,0 +1,58 @@ +package dev.slne.surf.api.paper.display.shape + +import java.util.BitSet +import kotlin.math.min + +class RoundedRectangleShape( + override val width: Int, + override val height: Int, + val cornerRadius: Int, + val filled: Boolean = true +) : Shape { + private val bits: BitSet = BitSet(width * height).apply { + val r = min(cornerRadius, min(width / 2, height / 2)) + + for (y in 0 until height) { + for (x in 0 until width) { + if (isInsideRoundedRect(x, y, r)) { + if (filled) { + set(y * width + x) + } else { + if (!isInsideRoundedRect(x, y, r, inset = 1)) { + set(y * width + x) + } + } + } + } + } + } + + private fun isInsideRoundedRect(x: Int, y: Int, r: Int, inset: Int = 0): Boolean { + val w = width - inset * 2 + val h = height - inset * 2 + val px = x - inset + val py = y - inset + + if (px < 0 || py < 0 || px >= w || py >= h) return false + if (r <= 0) return true + + val effectiveR = min(r, min(w / 2, h / 2)) + + val cornerX: Int + val cornerY: Int + + when { + px < effectiveR && py < effectiveR -> { cornerX = effectiveR; cornerY = effectiveR } + px >= w - effectiveR && py < effectiveR -> { cornerX = w - effectiveR - 1; cornerY = effectiveR } + px < effectiveR && py >= h - effectiveR -> { cornerX = effectiveR; cornerY = h - effectiveR - 1 } + px >= w - effectiveR && py >= h - effectiveR -> { cornerX = w - effectiveR - 1; cornerY = h - effectiveR - 1 } + else -> return true + } + + val dx = px - cornerX + val dy = py - cornerY + return dx * dx + dy * dy <= effectiveR * effectiveR + } + + override fun rasterize(): BitSet = bits +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/Shape.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/Shape.kt new file mode 100644 index 000000000..4db932d0a --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/Shape.kt @@ -0,0 +1,36 @@ +package dev.slne.surf.api.paper.display.shape + +import dev.slne.surf.api.paper.display.render.Canvas +import java.util.BitSet + +/** + * A shape that can rasterize itself into a [BitSet] and paint its own pixels onto a [Canvas]. + */ +interface Shape { + val width: Int + val height: Int + + fun rasterize(): BitSet + + fun paint(canvas: Canvas, x: Int, y: Int, color: Int) { + val bits = rasterize() + val w = width + var i = bits.nextSetBit(0) + while (i >= 0) { + canvas.setPixel(x + i % w, y + i / w, color) + i = bits.nextSetBit(i + 1) + } + } + + companion object { + fun rectangle(w: Int, h: Int, filled: Boolean = true): Shape = RectangleShape(w, h, filled) + fun circle(radius: Int, filled: Boolean = true): Shape = CircleShape(radius, filled) + fun ellipse(radiusX: Int, radiusY: Int, filled: Boolean = true): Shape = EllipseShape(radiusX, radiusY, filled) + fun line(dx: Int, dy: Int, thickness: Int = 1): Shape = LineShape(dx, dy, thickness) + fun triangle(w: Int, h: Int, filled: Boolean = true): Shape = TriangleShape(w, h, filled) + fun roundedRectangle(w: Int, h: Int, cornerRadius: Int, filled: Boolean = true): Shape = + RoundedRectangleShape(w, h, cornerRadius, filled) + fun polygon(vararg vertices: Pair, filled: Boolean = true): Shape = + PolygonShape(vertices.toList(), filled) + } +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/TriangleShape.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/TriangleShape.kt new file mode 100644 index 000000000..9337e64a8 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/shape/TriangleShape.kt @@ -0,0 +1,58 @@ +package dev.slne.surf.api.paper.display.shape + +import java.util.BitSet + +class TriangleShape( + override val width: Int, + override val height: Int, + val filled: Boolean = true +) : Shape { + + private val bits: BitSet = BitSet(width * height).apply { + val apexX = width / 2 + val apexY = 0 + val leftX = 0 + val leftY = height - 1 + val rightX = width - 1 + val rightY = height - 1 + + if (filled) { + for (y in 0 until height) { + val progress = if (height > 1) y.toFloat() / (height - 1) else 1f + val xLeft = (apexX + (leftX - apexX) * progress).toInt() + val xRight = (apexX + (rightX - apexX) * progress).toInt() + for (x in xLeft..xRight) { + if (x in 0 until width) { + set(y * width + x) + } + } + } + } else { + drawLine(this, apexX, apexY, leftX, leftY) + drawLine(this, apexX, apexY, rightX, rightY) + drawLine(this, leftX, leftY, rightX, rightY) + } + } + + private fun drawLine(bits: BitSet, x0: Int, y0: Int, x1: Int, y1: Int) { + val dx = kotlin.math.abs(x1 - x0) + val dy = kotlin.math.abs(y1 - y0) + val sx = if (x0 < x1) 1 else -1 + val sy = if (y0 < y1) 1 else -1 + var err = dx - dy + var x = x0 + var y = y0 + + while (true) { + if (x in 0 until width && y in 0 until height) { + bits.set(y * width + x) + } + if (x == x1 && y == y1) break + val e2 = 2 * err + if (e2 > -dy) { err -= dy; x += sx } + if (e2 < dx) { err += dx; y += sy } + } + } + + override fun rasterize(): BitSet = bits +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/AlignItems.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/AlignItems.kt new file mode 100644 index 000000000..cbe59742d --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/AlignItems.kt @@ -0,0 +1,4 @@ +package dev.slne.surf.api.paper.display.style + +/** Controls how children are positioned along the cross axis. */ +enum class AlignItems { START, CENTER, END, STRETCH } diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Border.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Border.kt new file mode 100644 index 000000000..cd43faef3 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Border.kt @@ -0,0 +1,11 @@ +package dev.slne.surf.api.paper.display.style + +import dev.slne.surf.api.paper.display.argb + +/** + * Border definition with width and color. + */ +data class Border( + val width: Int = 1, + val color: Int = argb(0x000000) +) diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/FlexDirection.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/FlexDirection.kt new file mode 100644 index 000000000..fccb9660b --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/FlexDirection.kt @@ -0,0 +1,4 @@ +package dev.slne.surf.api.paper.display.style + +/** Layout direction for children within a container. */ +enum class FlexDirection { COLUMN, ROW } diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Insets.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Insets.kt new file mode 100644 index 000000000..bbf3079dc --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Insets.kt @@ -0,0 +1,22 @@ +package dev.slne.surf.api.paper.display.style + +/** + * Represents insets (padding/margin) for all four sides. + */ +data class Insets( + val top: Int = 0, + val right: Int = 0, + val bottom: Int = 0, + val left: Int = 0 +) { + val horizontal get() = left + right + val vertical get() = top + bottom + + companion object { + val ZERO = Insets() + fun all(value: Int) = Insets(value, value, value, value) + fun symmetric(vertical: Int, horizontal: Int) = Insets(vertical, horizontal, vertical, horizontal) + fun horizontal(value: Int) = Insets(0, value, 0, value) + fun vertical(value: Int) = Insets(value, 0, value, 0) + } +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/JustifyContent.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/JustifyContent.kt new file mode 100644 index 000000000..9d73b5b52 --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/JustifyContent.kt @@ -0,0 +1,4 @@ +package dev.slne.surf.api.paper.display.style + +/** Controls how children are positioned along the main axis. */ +enum class JustifyContent { START, CENTER, END, SPACE_BETWEEN, SPACE_AROUND } diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Overflow.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Overflow.kt new file mode 100644 index 000000000..9dd4b334d --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Overflow.kt @@ -0,0 +1,4 @@ +package dev.slne.surf.api.paper.display.style + +/** Controls how overflowing content is handled. */ +enum class Overflow { VISIBLE, HIDDEN } diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Style.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Style.kt new file mode 100644 index 000000000..3235ef94e --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/Style.kt @@ -0,0 +1,66 @@ +package dev.slne.surf.api.paper.display.style + +import dev.slne.surf.api.paper.display.argb +import dev.slne.surf.api.paper.display.cursor.CursorStyle + +/** + * CSS-like style properties for elements. + */ +class Style { + /** Fixed width in pixels. `null` = auto (fill available width). */ + var width: Int? = null + + /** Fixed height in pixels. `null` = auto (fit content). */ + var height: Int? = null + + /** Background color (ARGB). `null` = transparent. */ + var backgroundColor: Int? = null + + /** Foreground/text color (ARGB). */ + var color: Int = argb(0xFFFFFF) + + /** Inner spacing between border and content. */ + var padding: Insets = Insets.ZERO + + /** Outer spacing around the element. */ + var margin: Insets = Insets.ZERO + + /** Border definition. `null` = no border. */ + var border: Border? = null + + /** Horizontal text alignment. */ + var textAlign: TextAlign = TextAlign.LEFT + + /** Vertical content alignment. */ + var verticalAlign: VerticalAlign = VerticalAlign.TOP + + /** Overflow behavior. */ + var overflow: Overflow = Overflow.HIDDEN + + /** Font size for text rendering. */ + var fontSize: Int = 12 + + /** Whether this element is visible. */ + var visible: Boolean = true + + /** Layout direction for children (column = vertical, row = horizontal). */ + var flexDirection: FlexDirection = FlexDirection.COLUMN + + /** Spacing between children in pixels. */ + var gap: Int = 0 + + /** Alignment of children along the main axis. */ + var justifyContent: JustifyContent = JustifyContent.START + + /** Alignment of children along the cross axis. */ + var alignItems: AlignItems = AlignItems.START + + /** Border radius for rounded corners (0 = sharp corners). */ + var borderRadius: Int = 0 + + /** Opacity from 0.0 (invisible) to 1.0 (fully opaque). */ + var opacity: Float = 1.0f + + /** Cursor style when hovering over this element. `null` = inherit from parent. */ + var cursor: CursorStyle? = null +} diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/TextAlign.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/TextAlign.kt new file mode 100644 index 000000000..c8ccc0b9f --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/TextAlign.kt @@ -0,0 +1,4 @@ +package dev.slne.surf.api.paper.display.style + +/** Horizontal text alignment within an element. */ +enum class TextAlign { LEFT, CENTER, RIGHT } diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/VerticalAlign.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/VerticalAlign.kt new file mode 100644 index 000000000..60432041b --- /dev/null +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/style/VerticalAlign.kt @@ -0,0 +1,4 @@ +package dev.slne.surf.api.paper.display.style + +/** Vertical content alignment within an element. */ +enum class VerticalAlign { TOP, CENTER, BOTTOM }