` 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 }
From e5040b451acf24ff9940a5fcf4daa0b0cef592d2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 12 Apr 2026 09:54:31 +0000
Subject: [PATCH 2/4] Add Display/UI server implementation files
Create the server-side display implementation in surf-api-paper-server
with the following components:
- user/DisplayUser.kt - Player wrapper with packet sending and session tracking
- map/DisplayMap.kt - Map data container with packet creation
- frame/DisplayItemFrame.kt - Virtual item frame entity management
- cursor/Cursor.kt - Horse-based cursor rendering via equipment packets
- DisplaySession.kt - Session lifecycle (horse mount, fake camera, visual effects)
- Display.kt - Main display class with rendering, cursor, modals, interaction
- DisplayManager.kt - Active display registry for open/close/lookup
- protocol/DisplayProtocolListener.kt - PacketEvents listener for input handling
- modal/Modal.kt - Confirm/alert/success/error/warning dialog builders
- web/JavaFxPlatform.kt - JavaFX platform initialization and thread utilities
- web/WebRenderer.kt - Offscreen WebView rendering to Canvas
- web/WebDisplay.kt - WebView-backed display with async rendering
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: TheBjoRedCraft <143264463+TheBjoRedCraft@users.noreply.github.com>
---
.../surf/api/paper/server/display/Display.kt | 482 ++++++++++++++++++
.../paper/server/display/DisplayManager.kt | 31 ++
.../paper/server/display/DisplaySession.kt | 288 +++++++++++
.../api/paper/server/display/cursor/Cursor.kt | 71 +++
.../server/display/frame/DisplayItemFrame.kt | 86 ++++
.../paper/server/display/map/DisplayMap.kt | 28 +
.../api/paper/server/display/modal/Modal.kt | 203 ++++++++
.../protocol/DisplayProtocolListener.kt | 124 +++++
.../paper/server/display/user/DisplayUser.kt | 35 ++
.../server/display/web/JavaFxPlatform.kt | 115 +++++
.../paper/server/display/web/WebDisplay.kt | 72 +++
.../paper/server/display/web/WebRenderer.kt | 207 ++++++++
12 files changed, 1742 insertions(+)
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/Display.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplayManager.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplaySession.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/cursor/Cursor.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/frame/DisplayItemFrame.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/map/DisplayMap.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/modal/Modal.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/protocol/DisplayProtocolListener.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/user/DisplayUser.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/JavaFxPlatform.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/WebDisplay.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/web/WebRenderer.kt
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..e752aab6f
--- /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,482 @@
+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
+
+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/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..f7824b6ac
--- /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,31 @@
+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.*
+
+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..73212a897
--- /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,288 @@
+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
+
+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..6d66c75d3
--- /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,115 @@
+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
+
+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..7ce25b25d
--- /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,72 @@
+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
+
+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..3c5304312
--- /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,207 @@
+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
+
+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
+ }
+ }
+}
From d50cf22196c95a5d41129a06b200b03874537f6b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 12 Apr 2026 10:06:15 +0000
Subject: [PATCH 3/4] feat: register DisplayLoader in PaperInstance, add
DisplayTest command, update API dump
Agent-Logs-Url: https://github.com/SLNE-Development/surf-api/sessions/b48cae1b-adb9-4a36-a5af-f09bf8bf8fec
Co-authored-by: TheBjoRedCraft <143264463+TheBjoRedCraft@users.noreply.github.com>
---
.../build.gradle.kts | 1 +
.../test/command/SurfApiTestCommand.java | 4 +-
.../subcommands/display/DisplayTest.kt | 254 ++++++++++
.../surf/api/paper/server/PaperInstance.kt | 3 +
.../api/paper/server/display/DisplayLoader.kt | 24 +
.../surf-api-paper/api/surf-api-paper.api | 448 ++++++++++++++++++
.../surf/api/paper/display/element/Element.kt | 4 +-
7 files changed, 735 insertions(+), 3 deletions(-)
create mode 100644 surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/display/DisplayTest.kt
create mode 100644 surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/display/DisplayLoader.kt
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/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/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/element/Element.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/display/element/Element.kt
index 008b142bb..c9cb0a5cc 100644
--- 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
@@ -3,7 +3,7 @@ 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
-internal class Rect(var x: Int = 0, var y: Int = 0, var width: Int = 0, var height: Int = 0)
+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.
@@ -24,7 +24,7 @@ abstract class Element {
val behaviors = mutableListOf()
/** Computed layout bounds (set by the renderer). */
- internal var bounds = Rect()
+ var bounds = Rect()
/** Current interaction phase. */
var phase: ElementPhase = ElementPhase.DEFAULT
From 555c9859360bcede3768734b5e494678ac3e8c81 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 12 Apr 2026 10:10:08 +0000
Subject: [PATCH 4/4] docs: add KDoc to server-side display classes
Agent-Logs-Url: https://github.com/SLNE-Development/surf-api/sessions/b48cae1b-adb9-4a36-a5af-f09bf8bf8fec
Co-authored-by: TheBjoRedCraft <143264463+TheBjoRedCraft@users.noreply.github.com>
---
.../surf/api/paper/server/display/Display.kt | 19 +++++++++++++++++++
.../paper/server/display/DisplayManager.kt | 6 ++++++
.../paper/server/display/DisplaySession.kt | 9 +++++++++
.../server/display/web/JavaFxPlatform.kt | 9 +++++++++
.../paper/server/display/web/WebDisplay.kt | 17 +++++++++++++++++
.../paper/server/display/web/WebRenderer.kt | 11 +++++++++++
6 files changed, 71 insertions(+)
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
index e752aab6f..fdc8357bb 100644
--- 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
@@ -22,6 +22,25 @@ 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,
) {
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
index f7824b6ac..381b3d2da 100644
--- 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
@@ -4,6 +4,12 @@ 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()
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
index 73212a897..74e8e7503 100644
--- 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
@@ -23,6 +23,15 @@ 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,
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
index 6d66c75d3..d36ad94a9 100644
--- 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
@@ -7,6 +7,15 @@ 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")
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
index 7ce25b25d..11b70b889 100644
--- 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
@@ -6,6 +6,23 @@ 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,
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
index 3c5304312..77821bf3c 100644
--- 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
@@ -17,6 +17,17 @@ 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,