diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..41378c9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "kiwi-java"] + path = kiwi-java + url = git@git.shadowfacts.net:shadowfacts/kiwi-java.git diff --git a/build.gradle b/build.gradle index 58d0ef1..0561ae3 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id "fabric-loom" version "0.5-SNAPSHOT" id "maven-publish" id "org.jetbrains.kotlin.jvm" version "1.4.30" + id "com.github.johnrengelman.shadow" version "4.0.4" } sourceCompatibility = JavaVersion.VERSION_1_8 @@ -18,6 +19,7 @@ repositories { maven { url = "https://mod-buildcraft.com/maven" } + jcenter() } dependencies { @@ -35,6 +37,10 @@ dependencies { modImplementation "alexiil.mc.lib:libblockattributes-all:${project.libblockattributes_version}" include "alexiil.mc.lib:libblockattributes-core:${project.libblockattributes_version}" include "alexiil.mc.lib:libblockattributes-items:${project.libblockattributes_version}" + + shadow project(":kiwi-java") + + testImplementation "org.junit.jupiter:junit-jupiter:${project.junit_version}" } processResources { diff --git a/gradle.properties b/gradle.properties index 95d3968..2d53fba 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,3 +13,5 @@ fabric_version=0.30.0+1.16 fabric_kotlin_version=1.4.30+build.2 libblockattributes_version=0.8.5 + +junit_version = 5.4.0 diff --git a/kiwi-java b/kiwi-java new file mode 160000 index 0000000..1cbaea5 --- /dev/null +++ b/kiwi-java @@ -0,0 +1 @@ +Subproject commit 1cbaea53d207f1e16c6e5ee2e6bf6e3c1440ac44 diff --git a/settings.gradle b/settings.gradle index 89fa615..c5faca1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,4 +7,6 @@ pluginManagement { } gradlePluginPortal() } -} \ No newline at end of file +} + +include("kiwi-java") diff --git a/src/main/kotlin/net/shadowfacts/cacao/CacaoScreen.kt b/src/main/kotlin/net/shadowfacts/cacao/CacaoScreen.kt new file mode 100644 index 0000000..9d8dd85 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/CacaoScreen.kt @@ -0,0 +1,100 @@ +package net.shadowfacts.cacao + +import com.mojang.blaze3d.platform.GlStateManager +import net.minecraft.client.gui.screen.Screen +import net.minecraft.client.util.math.MatrixStack +import net.minecraft.sound.SoundEvents +import net.minecraft.text.LiteralText +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.util.MouseButton +import net.shadowfacts.cacao.util.RenderHelper +import java.util.* + +/** + * This class serves as the bridge between Cacao and a Minecraft [Screen]. It renders Cacao [Window]s in Minecraft and + * sends input events from Minecraft back to Cacao objects. + * + * @author shadowfacts + */ +open class CacaoScreen: Screen(LiteralText("CacaoScreen")) { + + // _windows is the internal, mutable object, since we only want it to by mutated by the add/removeWindow methods. + private val _windows = LinkedList() + /** + * The list of windows that belong to this screen. + * This list should never be modified directly, only by using the [addWindow]/[removeWindow] methods. + */ + val windows: List = _windows + + /** + * Adds the given window to this screen's window list. + * By default, the new window is added at the tail of the window list, making it the active window. + * Only the active window will receive input events. + * + * @param window The Window to add to this screen. + * @param index The index to insert the window into the window list at. + * @return The window that was added, as a convenience. + */ + fun addWindow(window: T, index: Int = _windows.size): T { + _windows.add(index, window) + + window.screen = this + window.wasAdded() + window.resize(width, height) + + return window + } + + /** + * Removes the given window from this screen's window list. + */ + fun removeWindow(window: Window) { + _windows.remove(window) + } + + override fun init() { + super.init() + + windows.forEach { + it.resize(width, height) + } + } + + override fun render(matrixStack: MatrixStack, mouseX: Int, mouseY: Int, delta: Float) { + if (client != null) { + // workaround this.minecraft sometimes being null causing a crash + renderBackground(matrixStack) + } + + val mouse = Point(mouseX, mouseY) + windows.forEach { + it.draw(matrixStack, mouse, delta) + } + } + + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + val window = windows.lastOrNull() + val result = window?.mouseClicked(Point(mouseX, mouseY), MouseButton.fromMC(button)) + return if (result == true) { + RenderHelper.playSound(SoundEvents.UI_BUTTON_CLICK) + true + } else { + false + } + } + + override fun mouseDragged(mouseX: Double, mouseY: Double, button: Int, deltaX: Double, deltaY: Double): Boolean { + val window = windows.lastOrNull() + val startPoint = Point(mouseX, mouseY) + val delta = Point(deltaX, deltaY) + val result = window?.mouseDragged(startPoint, delta, MouseButton.fromMC(button)) + return result == true + } + + override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int): Boolean { + val window = windows.lastOrNull() + val result = window?.mouseReleased(Point(mouseX, mouseY), MouseButton.fromMC(button)) + return result == true + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/LayoutVariable.kt b/src/main/kotlin/net/shadowfacts/cacao/LayoutVariable.kt new file mode 100644 index 0000000..3161853 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/LayoutVariable.kt @@ -0,0 +1,18 @@ +package net.shadowfacts.cacao + +import net.shadowfacts.cacao.view.View +import no.birkett.kiwi.Variable + +/** + * A Kiwi variable that belongs to a Cacao view. + * This class generally isn't used directly, but via the anchor *Anchor properties on [View]. + * + * @author shadowfacts + */ +class LayoutVariable(val owner: View, val property: String): Variable("LayoutVariable") { + + override fun getName() = "$owner.$property" + + override fun toString() = "LayoutVariable(name=$name, value=$value)" + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/README.md b/src/main/kotlin/net/shadowfacts/cacao/README.md new file mode 100644 index 0000000..c873a79 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/README.md @@ -0,0 +1,33 @@ +# Cacao +Cacao is a UI framework for Fabric/Minecraft mods based on Apple's [Cocoa](https://en.wikipedia.org/wiki/Cocoa_(API) +UI toolkit. + +## Architecture +### Screen +A [CacaoScreen][] is the object that acts as the interface between Minecraft GUI code and the Cacao framework. + +The CacaoScreen draws Cacao views on screen and passes Minecraft input events to the appropriate Views. The CacaoScreen +owns a group of [Window](#window) objects which are displayed on screen, one on top of the other. + +[CacaoScreen]: https://git.shadowfacts.net/minecraft/ASMR/src/branch/master/src/main/kotlin/net/shadowfacts/cacao/CacaoScreen.kt + +### Window +A [Window][] object has a root [View Controller](#view-controller) that it displays on screen. + +The Window occupies the entire screen space and translates events from the screen to the root View Controller's View. +It owns a Solver object that manages layout constraints. The window also handles screen resizing and re-lays out the +view hierarchy. + +[Window]: https://git.shadowfacts.net/minecraft/ASMR/src/branch/master/src/main/kotlin/net/shadowfacts/cacao/Window.kt + +### View Controller +A [ViewController][] object owns a view, receives lifecycle events for it, and is generally used to control the view. + +Each View Controller has a single root [View](#view) which in turn may have subviews. + +[ViewController]: https://git.shadowfacts.net/minecraft/ASMR/src/branch/master/src/main/kotlin/net/shadowfacts/cacao/viewcontroller/ViewController.kt + +### View +A [View][] object represents a single view on screen. It handles drawing, positioning, and directly handles input. + +[View]: https://git.shadowfacts.net/minecraft/ASMR/src/branch/master/src/main/kotlin/net/shadowfacts/cacao/view/View.kt diff --git a/src/main/kotlin/net/shadowfacts/cacao/Window.kt b/src/main/kotlin/net/shadowfacts/cacao/Window.kt new file mode 100644 index 0000000..b5863df --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/Window.kt @@ -0,0 +1,214 @@ +package net.shadowfacts.cacao + +import net.minecraft.client.util.math.MatrixStack +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.util.MouseButton +import net.shadowfacts.cacao.view.View +import net.shadowfacts.cacao.viewcontroller.ViewController +import net.shadowfacts.kiwidsl.dsl +import no.birkett.kiwi.Constraint +import no.birkett.kiwi.Solver +import no.birkett.kiwi.Variable + +/** + * A Window is the object at the top of a Cacao view hierarchy. It occupies the entirety of the Minecraft screen size + * and provides the base coordinate system for its view hierarchy. + * + * The Window owns the Kiwi [Solver] object used for layout by all of its views. + * + * @author shadowfacts + * + * @param viewController The root view controller for this window. + */ +class Window( + /** + * The root view controller for this window. + */ + val viewController: ViewController +) { + + /** + * The screen that this window belongs to. + * Not initialized until this window is added to a screen, using it before that point will throw a runtime exception. + */ + lateinit var screen: CacaoScreen + + /** + * The constraint solver used by this window and all its views and subviews. + */ + var solver = Solver() + + /** + * Layout anchor for the left edge of this view in the window's coordinate system. + */ + val leftAnchor = Variable("left") + /** + * Layout anchor for the right edge of this view in the window's coordinate system. + */ + val rightAnchor = Variable("right") + /** + * Layout anchor for the top edge of this view in the window's coordinate system. + */ + val topAnchor = Variable("top") + /** + * Layout anchor for the bottom edge of this view in the window's coordinate system. + */ + val bottomAnchor = Variable("bottom") + /** + * Layout anchor for the width of this view in the window's coordinate system. + */ + val widthAnchor = Variable("width") + /** + * Layout anchor for the height of this view in the window's coordinate system. + */ + val heightAnchor = Variable("height") + /** + * Layout anchor for the center X position of this view in the window's coordinate system. + */ + val centerXAnchor = Variable("centerX") + /** + * Layout anchor for the center Y position of this view in the window's coordinate system. + */ + val centerYAnchor = Variable("centerY") + + // internal constraints that specify the window size based on the MC screen size + // stored so that they can be removed when the screen is resized + private var widthConstraint: Constraint? = null + private var heightConstraint: Constraint? = null + + private var currentDragReceiver: View? = null + + init { + createInternalConstraints() + } + + fun wasAdded() { + viewController.window = this + viewController.loadView() + + viewController.view.window = this + viewController.view.solver = solver + viewController.view.wasAdded() + viewController.createConstraints { + viewController.view.leftAnchor equalTo leftAnchor + viewController.view.rightAnchor equalTo rightAnchor + viewController.view.topAnchor equalTo topAnchor + viewController.view.bottomAnchor equalTo bottomAnchor + } + + viewController.viewDidLoad() + + layout() + } + + /** + * Creates the internal constraints used by the window. + * If overridden, the super-class method must be called. + */ + protected fun createInternalConstraints() { + solver.dsl { + leftAnchor equalTo 0 + topAnchor equalTo 0 + + rightAnchor equalTo (leftAnchor + widthAnchor) + bottomAnchor equalTo (topAnchor + heightAnchor) + centerXAnchor equalTo (leftAnchor + widthAnchor / 2) + centerYAnchor equalTo (topAnchor + heightAnchor / 2) + } + } + + /** + * Called by the window's [screen] when the Minecraft screen is resized. + * Used to update the window's width and height constraints and re-layout views. + */ + internal fun resize(width: Int, height: Int) { + if (widthConstraint != null) solver.removeConstraint(widthConstraint) + if (heightConstraint != null) solver.removeConstraint(heightConstraint) + solver.dsl { + widthConstraint = (widthAnchor equalTo width) + heightConstraint = (heightAnchor equalTo height) + } + layout() + } + + /** + * Convenience method that removes this window from its [screen]. + */ + fun removeFromScreen() { + viewController.viewWillDisappear() + screen.removeWindow(this) + viewController.viewDidDisappear() + } + + /** + * Instructs the solver to solve all of the provided constraints. + * Should be called after the view hierarchy is setup. + */ + fun layout() { + viewController.viewWillLayoutSubviews() + solver.updateVariables() + viewController.viewDidLayoutSubviews() + } + + /** + * Draws this window and all of its views. + * This method is called by [CacaoScreen] and generally shouldn't be called directly. + * + * @param mouse The point in the coordinate system of the window. + * @param delta The time elapsed since the last frame. + */ + fun draw(matrixStack: MatrixStack, mouse: Point, delta: Float) { + val mouseInView = Point(mouse.x - viewController.view.frame.left, mouse.y - viewController.view.frame.top) + viewController.view.draw(matrixStack, mouseInView, delta) + } + + /** + * Called when a mouse button is clicked and this is the active window. + * This method is called by [CacaoScreen] and generally shouldn't be called directly. + * + * @param point The point in the window of the click. + * @param mouseButton The mouse button that was used to click. + * @return Whether the mouse click was handled by a view. + */ + fun mouseClicked(point: Point, mouseButton: MouseButton): Boolean { + if (point in viewController.view.frame) { + val mouseInView = Point(point.x - viewController.view.frame.left, point.y - viewController.view.frame.top) + return viewController.view.mouseClicked(mouseInView, mouseButton) + } else { + // remove the window from the screen when the mouse clicks outside the window and this is not the primary window + if (screen.windows.size > 1) { + removeFromScreen() + } + } + return false + } + + fun mouseDragged(startPoint: Point, delta: Point, mouseButton: MouseButton): Boolean { + val currentlyDraggedView = this.currentDragReceiver + if (currentlyDraggedView != null) { + return currentlyDraggedView.mouseDragged(startPoint, delta, mouseButton) + } else if (startPoint in viewController.view.frame) { + val startInView = Point(startPoint.x - viewController.view.frame.left, startPoint.y - viewController.view.frame.top) + var prevView: View? = null + var view = viewController.view.subviewsAtPoint(startInView).maxBy(View::zIndex) + while (view != null && !view.respondsToDragging) { + prevView = view + val pointInView = viewController.view.convert(startInView, to = view) + view = view.subviewsAtPoint(pointInView).maxBy(View::zIndex) + } + this.currentDragReceiver = view ?: prevView + return this.currentDragReceiver?.mouseDragged(startPoint, delta, mouseButton) ?: false + } + return false + } + + fun mouseReleased(point: Point, mouseButton: MouseButton): Boolean { + val currentlyDraggedView = this.currentDragReceiver + if (currentlyDraggedView != null) { + this.currentDragReceiver = null + return true + } + return false + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/geometry/Axis.kt b/src/main/kotlin/net/shadowfacts/cacao/geometry/Axis.kt new file mode 100644 index 0000000..b77a31f --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/geometry/Axis.kt @@ -0,0 +1,19 @@ +package net.shadowfacts.cacao.geometry + +/** + * An axis in a 2D coordinate plane. + * + * @author shadowfacts + */ +enum class Axis { + HORIZONTAL, VERTICAL; + + /** + * Gets the axis that is perpendicular to this one. + */ + val perpendicular: Axis + get() = when (this) { + HORIZONTAL -> VERTICAL + VERTICAL -> HORIZONTAL + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/geometry/AxisPosition.kt b/src/main/kotlin/net/shadowfacts/cacao/geometry/AxisPosition.kt new file mode 100644 index 0000000..5d3a7f5 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/geometry/AxisPosition.kt @@ -0,0 +1,21 @@ +package net.shadowfacts.cacao.geometry + +/** + * A relative position on a line along an axis. + * + * @author shadowfacts + */ +enum class AxisPosition { + /** + * Top for vertical, left for horizontal. + */ + LEADING, + /** + * Center X/Y. + */ + CENTER, + /** + * Bottom for vertical, right for horizontal. + */ + TRAILING; +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/geometry/BezierCurve.kt b/src/main/kotlin/net/shadowfacts/cacao/geometry/BezierCurve.kt new file mode 100644 index 0000000..60a4453 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/geometry/BezierCurve.kt @@ -0,0 +1,49 @@ +package net.shadowfacts.cacao.geometry + +import java.lang.RuntimeException +import kotlin.math.pow + +/** + * Helper class that represents a cubic bezier curve. + * + * @author shadowfacts + */ +data class BezierCurve(private val points: Array) { + + init { + if (points.size != 4) { + throw RuntimeException("Cubic bezier curve must have exactly four points") + } + } + + fun point(time: Double): Point { + val x = coordinate(time, Axis.HORIZONTAL) + val y = coordinate(time, Axis.VERTICAL) + return Point(x, y) + } + + private fun coordinate(t: Double, axis: Axis): Double { + // B(t)=(1-t)^3*p0+3(1-t)^2*t*p1+3(1-t)*t^2*p2+t^3*p3 + val p0 = points[0][axis] + val p1 = points[1][axis] + val p2 = points[2][axis] + val p3 = points[3][axis] + return ((1 - t).pow(3) * p0) + (3 * (1 - t).pow(2) * t * p1) + (3 * (1 - t) * t.pow(2) * p2) + (t.pow(3) * p3) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BezierCurve + + if (!points.contentEquals(other.points)) return false + + return true + } + + override fun hashCode(): Int { + return points.contentHashCode() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/geometry/Point.kt b/src/main/kotlin/net/shadowfacts/cacao/geometry/Point.kt new file mode 100644 index 0000000..81406d4 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/geometry/Point.kt @@ -0,0 +1,31 @@ +package net.shadowfacts.cacao.geometry + +/** + * Helper class for defining 2D points. + * + * @author shadowfacts + */ +data class Point(val x: Double, val y: Double) { + + constructor(x: Int, y: Int): this(x.toDouble(), y.toDouble()) + + companion object { + val ORIGIN = Point(0.0, 0.0) + } + + operator fun plus(other: Point): Point { + return Point(x + other.x, y + other.y) + } + + operator fun minus(other: Point): Point { + return Point(x - other.x, y - other.y) + } + + operator fun get(axis: Axis): Double { + return when (axis) { + Axis.HORIZONTAL -> x + Axis.VERTICAL -> y + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/geometry/Rect.kt b/src/main/kotlin/net/shadowfacts/cacao/geometry/Rect.kt new file mode 100644 index 0000000..9d19d03 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/geometry/Rect.kt @@ -0,0 +1,41 @@ +package net.shadowfacts.cacao.geometry + +/** + * Helper class for defining rectangles. Provides helper values for calculating perpendicular components of a rectangle based on X/Y/W/H. + * + * @author shadowfacts + */ +data class Rect(val left: Double, val top: Double, val width: Double, val height: Double) { + + constructor(origin: Point, size: Size): this(origin.x, origin.y, size.width, size.height) + + val right: Double by lazy { + left + width + } + val bottom: Double by lazy { + top + height + } + + val midX: Double by lazy { + left + width / 2 + } + val midY: Double by lazy { + top + height / 2 + } + + val origin: Point by lazy { + Point(left, top) + } + val center: Point by lazy { + Point(midX, midY) + } + + val size: Size by lazy { + Size(width, height) + } + + operator fun contains(point: Point): Boolean { + return point.x in left..right && point.y in top..bottom + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/geometry/Size.kt b/src/main/kotlin/net/shadowfacts/cacao/geometry/Size.kt new file mode 100644 index 0000000..3ae4cc1 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/geometry/Size.kt @@ -0,0 +1,8 @@ +package net.shadowfacts.cacao.geometry + +/** + * Helper class for specifying the size of objects. + * + * @author shadowfacts + */ +data class Size(val width: Double, val height: Double) \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/Color.kt b/src/main/kotlin/net/shadowfacts/cacao/util/Color.kt new file mode 100644 index 0000000..f7775d2 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/Color.kt @@ -0,0 +1,34 @@ +package net.shadowfacts.cacao.util + +/** + * Helper class for Cacao colors. + * + * @author shadowfacts + * @param red The red component, from 0-255. + * @param green The green component, from 0-255. + * @param blue The blue component, from 0-255. + * @param alpha The alpha (i.e. transparency) component, from 0-255. (0 is transparent, 255 is opaque) + */ +data class Color(val red: Int, val green: Int, val blue: Int, val alpha: Int = 255) { + + /** + * Constructs a color from the packed RGB color. + */ + constructor(rgb: Int, alpha: Int = 255): this(rgb shr 16, (rgb shr 8) and 255, rgb and 255, alpha) + + /** + * The ARGB packed representation of this color. + */ + val argb: Int + get() = ((alpha and 255) shl 24) or ((red and 255) shl 16) or ((green and 255) shl 8) or (blue and 255) + + companion object { + val CLEAR = Color(0, alpha = 0) + val WHITE = Color(0xffffff) + val BLACK = Color(0) + val RED = Color(0xff0000) + val GREEN = Color(0x00ff00) + val BLUE = Color(0x0000ff) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/EnumHelper.kt b/src/main/kotlin/net/shadowfacts/cacao/util/EnumHelper.kt new file mode 100644 index 0000000..3ed19eb --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/EnumHelper.kt @@ -0,0 +1,20 @@ +package net.shadowfacts.cacao.util + +/** + * @author shadowfacts + */ +object EnumHelper { + + fun > next(value: E): E { + val constants = value.declaringClass.enumConstants + val index = constants.indexOf(value) + 1 + return if (index < constants.size) constants[index] else constants.first() + } + + fun > previous(value: E): E { + val constants = value.declaringClass.enumConstants + val index = constants.indexOf(value) - 1 + return if (index >= 0) constants[index] else constants.last() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/LowestCommonAncestor.kt b/src/main/kotlin/net/shadowfacts/cacao/util/LowestCommonAncestor.kt new file mode 100644 index 0000000..6ee8951 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/LowestCommonAncestor.kt @@ -0,0 +1,56 @@ +package net.shadowfacts.cacao.util + +import java.util.* +import kotlin.NoSuchElementException + +/** + * A linear time algorithm for finding the lowest common ancestor of two nodes in a graph. + * Based on https://stackoverflow.com/a/6342546/4731558 + * + * Works be finding the path from each node back to the root node. + * The LCA will then be the node after which the paths diverge. + * + * @author shadowfacts + */ +object LowestCommonAncestor { + + fun find(node1: Node, node2: Node, parent: Node.() -> Node?): Node? { + @Suppress("NAME_SHADOWING") var node1: Node? = node1 + @Suppress("NAME_SHADOWING") var node2: Node? = node2 + + val parent1 = LinkedList() + while (node1 != null) { + parent1.push(node1) + node1 = node1.parent() + } + + val parent2 = LinkedList() + while (node2 != null) { + parent2.push(node2) + node2 = node2.parent() + } + + // paths don't converge on the same root element + if (parent1.first != parent2.first) { + return null + } + + var oldNode: Node? = null + while (node1 == node2 && parent1.isNotEmpty() && parent2.isNotEmpty()) { + oldNode = node1 + node1 = parent1.popOrNull() + node2 = parent2.popOrNull() + } + return if (node1 == node2) node1!! + else oldNode!! + } + +} + +private fun LinkedList.popOrNull(): T? { + return try { + pop() + } catch (e: NoSuchElementException) { + null + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/MouseButton.kt b/src/main/kotlin/net/shadowfacts/cacao/util/MouseButton.kt new file mode 100644 index 0000000..52c54cc --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/MouseButton.kt @@ -0,0 +1,19 @@ +package net.shadowfacts.cacao.util + +/** + * @author shadowfacts + */ +enum class MouseButton { + LEFT, RIGHT, MIDDLE, UNKNOWN; + + companion object { + fun fromMC(button: Int): MouseButton { + return when (button) { + 0 -> LEFT + 1 -> RIGHT + 2 -> MIDDLE + else -> UNKNOWN + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/RenderHelper.kt b/src/main/kotlin/net/shadowfacts/cacao/util/RenderHelper.kt new file mode 100644 index 0000000..acc457b --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/RenderHelper.kt @@ -0,0 +1,130 @@ +package net.shadowfacts.cacao.util + +import com.mojang.blaze3d.platform.GlStateManager +import com.mojang.blaze3d.systems.RenderSystem +import net.minecraft.client.MinecraftClient +import net.minecraft.client.gui.DrawableHelper +import net.minecraft.client.render.* +import net.minecraft.client.sound.PositionedSoundInstance +import net.minecraft.client.util.math.MatrixStack +import net.minecraft.sound.SoundEvent +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.geometry.Rect +import net.shadowfacts.cacao.util.texture.Texture +import org.lwjgl.opengl.GL11 + +/** + * Helper methods for rendering using Minecraft's utilities from Cacao views. + * For unit testing, all drawing and OpenGL interaction can be disabled by setting the `cacao.drawing.disabled` JVM property to `true`. + * + * @author shadowfacts + */ +object RenderHelper { + + val disabled = (System.getProperty("cacao.drawing.disabled") ?: "false").toBoolean() + + // TODO: find a better place for this + fun playSound(event: SoundEvent) { + if (disabled) return + MinecraftClient.getInstance().soundManager.play(PositionedSoundInstance.master(event, 1f)) + } + + /** + * Draws a solid [rect] filled with the given [color]. + */ + fun fill(matrixStack: MatrixStack, rect: Rect, color: Color) { + if (disabled) return + DrawableHelper.fill(matrixStack, rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt(), color.argb) + } + + /** + * Binds and draws the given [texture] filling the [rect]. + */ + fun draw(rect: Rect, texture: Texture) { + if (disabled) return + color(1f, 1f, 1f, 1f) + MinecraftClient.getInstance().textureManager.bindTexture(texture.location) + draw(rect.left, rect.top, texture.u, texture.v, rect.width, rect.height, texture.width, texture.height) + } + + fun drawLine(start: Point, end: Point, z: Double, width: Float, color: Color) { + if (disabled) return + + GlStateManager.lineWidth(width) + val tessellator = Tessellator.getInstance() + val buffer = tessellator.buffer + buffer.begin(GL11.GL_LINES, VertexFormats.POSITION_COLOR) + buffer.vertex(start.x, start.y, z).color(color).next() + buffer.vertex(end.x, end.y, z).color(color).next() + tessellator.draw() + } + + /** + * Draws the bound texture with the given screen and texture position and size. + */ + fun draw(x: Double, y: Double, u: Int, v: Int, width: Double, height: Double, textureWidth: Int, textureHeight: Int) { + if (disabled) return + val uStart = u.toFloat() / textureWidth + val uEnd = (u + width).toFloat() / textureWidth + val vStart = v.toFloat() / textureHeight + val vEnd = (v + height).toFloat() / textureHeight + drawTexturedQuad(x, x + width, y, y + height, 0.0, uStart, uEnd, vStart, vEnd) + } + + // Copied from net.minecraft.client.gui.DrawableHelper + private fun drawTexturedQuad(xStart: Double, xEnd: Double, yStart: Double, yEnd: Double, z: Double, uStart: Float, uEnd: Float, vStart: Float, vEnd: Float) { + val tessellator = Tessellator.getInstance() + val buffer = tessellator.buffer + buffer.begin(GL11.GL_QUADS, VertexFormats.POSITION_TEXTURE) + buffer.vertex(xStart, yEnd, z).texture(uStart, vEnd).next() + buffer.vertex(xEnd, yEnd, z).texture(uEnd, vEnd).next() + buffer.vertex(xEnd, yStart, z).texture(uEnd, vStart).next() + buffer.vertex(xStart, yStart, z).texture(uStart, vStart).next() + tessellator.draw() + } + + /** + * @see org.lwjgl.opengl.GL11.glPushMatrix + */ + fun pushMatrix() { + if (disabled) return + RenderSystem.pushMatrix() + } + + /** + * @see org.lwjgl.opengl.GL11.glPopMatrix + */ + fun popMatrix() { + if (disabled) return + RenderSystem.popMatrix() + } + + /** + * @see org.lwjgl.opengl.GL11.glTranslated + */ + fun translate(x: Double, y: Double, z: Double = 0.0) { + if (disabled) return + RenderSystem.translated(x, y, z) + } + + /** + * @see org.lwjgl.opengl.GL11.glScaled + */ + fun scale(x: Double, y: Double, z: Double = 1.0) { + if (disabled) return + RenderSystem.scaled(x, y, z) + } + + /** + * @see org.lwjgl.opengl.GL11.glColor4f + */ + fun color(r: Float, g: Float, b: Float, alpha: Float) { + if (disabled) return + RenderSystem.color4f(r, g, b, alpha) + } + + private fun VertexConsumer.color(color: Color): VertexConsumer { + return color(color.red, color.green, color.blue, color.alpha) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/constraints.kt b/src/main/kotlin/net/shadowfacts/cacao/util/constraints.kt new file mode 100644 index 0000000..3b74884 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/constraints.kt @@ -0,0 +1,14 @@ +package net.shadowfacts.cacao.util + +import no.birkett.kiwi.Constraint +import no.birkett.kiwi.Term +import no.birkett.kiwi.Variable + +/** + * Gets all the variables used by this constraint. + * + * @author shadowfacts + */ +fun Constraint.getVariables(): List { + return expression.terms.map(Term::getVariable) +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/properties/ObservableLateInitProperty.kt b/src/main/kotlin/net/shadowfacts/cacao/util/properties/ObservableLateInitProperty.kt new file mode 100644 index 0000000..21e2176 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/properties/ObservableLateInitProperty.kt @@ -0,0 +1,24 @@ +package net.shadowfacts.cacao.util.properties + +import kotlin.reflect.KProperty + +/** + * @author shadowfacts + */ +class ObservableLateInitProperty(val observer: (T) -> Unit) { + + lateinit var storage: T + + val isInitialized: Boolean + get() = this::storage.isInitialized + + operator fun getValue(thisRef: Any, property: KProperty<*>): T { + return storage + } + + operator fun setValue(thisRef: Any, property: KProperty<*>, value: T) { + storage = value + observer(value) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/properties/ObservableLazyProperty.kt b/src/main/kotlin/net/shadowfacts/cacao/util/properties/ObservableLazyProperty.kt new file mode 100644 index 0000000..a912b59 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/properties/ObservableLazyProperty.kt @@ -0,0 +1,20 @@ +package net.shadowfacts.cacao.util.properties + +import kotlin.reflect.KProperty + +/** + * @author shadowfacts + */ +class ObservableLazyProperty(val create: () -> Value, val onCreate: () -> Unit) { + + var storage: Value? = null + + operator fun getValue(thisRef: Any, property: KProperty<*>): Value { + if (storage == null) { + storage = create() + onCreate() + } + return storage!! + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/properties/ResettableLazyProperty.kt b/src/main/kotlin/net/shadowfacts/cacao/util/properties/ResettableLazyProperty.kt new file mode 100644 index 0000000..753dab2 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/properties/ResettableLazyProperty.kt @@ -0,0 +1,24 @@ +package net.shadowfacts.cacao.util.properties + +import kotlin.reflect.KProperty + +/** + * @author shadowfacts + */ +class ResettableLazyProperty(val initializer: () -> Value) { + var value: Value? = null + + val isInitialized: Boolean + get() = value != null + + operator fun getValue(thisRef: Any, property: KProperty<*>): Value { + if (value == null) { + value = initializer() + } + return value!! + } + + fun reset() { + value = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/texture/NinePatchTexture.kt b/src/main/kotlin/net/shadowfacts/cacao/util/texture/NinePatchTexture.kt new file mode 100644 index 0000000..d922c24 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/texture/NinePatchTexture.kt @@ -0,0 +1,61 @@ +package net.shadowfacts.cacao.util.texture + +import net.minecraft.util.Identifier + +/** + * Helper class that represents a texture that can be divided into nine pieces (4 corners, 4 edges, and the center) + * and can be drawn at any size by combining and repeating those pieces. + * + * It also provides convenience [Texture] objects that represent the different patches. + * + * @author shadowfacts + * @param texture The base [Texture] object. + * @param cornerWidth The width of each corner (and therefore the width of the vertical edges). + * @param cornerHeight The height of each corner (and therefore the height of the horizontal edges.) + * @param centerWidth The width of the center patch. + * @param centerHeight The height of the center patch. + */ +data class NinePatchTexture(val texture: Texture, val cornerWidth: Int, val cornerHeight: Int, val centerWidth: Int, val centerHeight: Int) { + + companion object { + val PANEL_BG = NinePatchTexture(Texture(Identifier("textures/gui/demo_background.png"), 0, 0), 5, 5, 238, 156) + + val BUTTON_BG = NinePatchTexture(Texture(Identifier("textures/gui/widgets.png"), 0, 66), 3, 3, 194, 14) + val BUTTON_HOVERED_BG = NinePatchTexture(Texture(Identifier("textures/gui/widgets.png"), 0, 86), 3, 3, 194, 14) + val BUTTON_DISABLED_BG = NinePatchTexture(Texture(Identifier("textures/gui/widgets.png"), 0, 46), 3, 3, 194, 14) + } + + // Corners + val topLeft by lazy { + texture + } + val topRight by lazy { + Texture(texture.location, texture.u + cornerWidth + centerWidth, texture.v, texture.width, texture.height) + } + val bottomLeft by lazy { + Texture(texture.location, texture.u, texture.v + cornerHeight + centerHeight, texture.width, texture.height) + } + val bottomRight by lazy { + Texture(texture.location, topRight.u, bottomLeft.v, texture.width, texture.height) + } + + // Edges + val topMiddle by lazy { + Texture(texture.location, texture.u + cornerWidth, texture.v, texture.width, texture.height) + } + val bottomMiddle by lazy { + Texture(texture.location, topMiddle.u, bottomLeft.v, texture.width, texture.height) + } + val leftMiddle by lazy { + Texture(texture.location, texture.u, texture.v + cornerHeight, texture.width, texture.height) + } + val rightMiddle by lazy { + Texture(texture.location, topRight.u, leftMiddle.v, texture.width, texture.height) + } + + // Center + val center by lazy { + Texture(texture.location, texture.u + cornerWidth, texture.v + cornerHeight, texture.width, texture.height) + } + +} diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/texture/Texture.kt b/src/main/kotlin/net/shadowfacts/cacao/util/texture/Texture.kt new file mode 100644 index 0000000..1840fd7 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/texture/Texture.kt @@ -0,0 +1,15 @@ +package net.shadowfacts.cacao.util.texture + +import net.minecraft.util.Identifier + +/** + * A helper class that represents a texture. + * + * @author shadowfacts + * @param location The identifier representing the resource-pack location of the texture image. + * @param u The X coordinate in pixels of where the texture starts within the image. + * @param v The Y coordinate in pixels of where the texture starts within the image. + * @param width The width in pixels of the entire image. + * @param height The height in pixels of the entire image. + */ +data class Texture(val location: Identifier, val u: Int, val v: Int, val width: Int = 256, val height: Int = 256) diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/BezierCurveView.kt b/src/main/kotlin/net/shadowfacts/cacao/view/BezierCurveView.kt new file mode 100644 index 0000000..33e167d --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/BezierCurveView.kt @@ -0,0 +1,36 @@ +package net.shadowfacts.cacao.view + +import net.minecraft.client.util.math.MatrixStack +import net.shadowfacts.cacao.geometry.BezierCurve +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.util.Color +import net.shadowfacts.cacao.util.RenderHelper + +/** + * @author shadowfacts + */ +class BezierCurveView(val curve: BezierCurve): View() { + + private val points by lazy { + val step = 0.05 + var t = 0.0 + val points = mutableListOf() + while (t <= 1) { + points.add(curve.point(t)) + t += step + } + points + } + + var lineWidth = 3f + var lineColor = Color.BLACK + + override fun drawContent(matrixStack: MatrixStack, mouse: Point, delta: Float) { + RenderHelper.scale(bounds.width, bounds.height) + for ((index, point) in points.withIndex()) { + val next = points.getOrNull(index + 1) ?: break + RenderHelper.drawLine(point, next, zIndex, lineWidth, lineColor) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/DialogView.kt b/src/main/kotlin/net/shadowfacts/cacao/view/DialogView.kt new file mode 100644 index 0000000..378026e --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/DialogView.kt @@ -0,0 +1,114 @@ +package net.shadowfacts.cacao.view + +import net.shadowfacts.cacao.Window +import net.shadowfacts.cacao.geometry.Axis +import net.shadowfacts.cacao.util.Color +import net.shadowfacts.cacao.util.texture.NinePatchTexture +import net.shadowfacts.cacao.util.texture.Texture +import net.shadowfacts.cacao.view.button.Button +import net.shadowfacts.kiwidsl.dsl + +/** + * @author shadowfacts + */ +class DialogView( + val title: String, + val message: String, + val buttonTypes: Array, + val iconTexture: Texture?, + val buttonCallback: (ButtonType, Window) -> Unit +): View() { + + interface ButtonType { + val localizedName: String + } + + enum class DefaultButtonType: ButtonType { + CANCEL, CONFIRM, OK, CLOSE; + + override val localizedName: String + get() = name.toLowerCase().capitalize() // todo: actually localize me + } + + private lateinit var background: NinePatchView + private lateinit var hStack: StackView + private var iconView: TextureView? = null + private lateinit var vStack: StackView + private lateinit var messageLabel: Label + private var buttonContainer: View? = null + private var buttonStack: StackView? = null + + override fun wasAdded() { + background = addSubview(NinePatchView(NinePatchTexture.PANEL_BG).apply { zIndex = -1.0 }) + + hStack = addSubview(StackView(Axis.HORIZONTAL, StackView.Distribution.LEADING, spacing = 8.0)) + + if (iconTexture != null) { + iconView = hStack.addArrangedSubview(TextureView(iconTexture)) + } + + vStack = hStack.addArrangedSubview(StackView(Axis.VERTICAL, spacing = 4.0)) + + vStack.addArrangedSubview(Label(title, shadow = false).apply { + textColor = Color(0x404040) + }) + messageLabel = vStack.addArrangedSubview(Label(message, shadow = false).apply { + textColor = Color(0x404040) + }) + + if (buttonTypes.isNotEmpty()) { + buttonContainer = vStack.addArrangedSubview(View()) + buttonStack = buttonContainer!!.addSubview(StackView(Axis.HORIZONTAL)) + for (type in buttonTypes) { + buttonStack!!.addArrangedSubview(Button(Label(type.localizedName)).apply { + handler = { + this@DialogView.buttonCallback(type, this@DialogView.window!!) + } + }) + } + } + + super.wasAdded() + } + + override fun createInternalConstraints() { + super.createInternalConstraints() + + solver.dsl { + centerXAnchor equalTo window!!.centerXAnchor + centerYAnchor equalTo window!!.centerYAnchor + + widthAnchor greaterThanOrEqualTo 175 + + background.leftAnchor equalTo leftAnchor - 8 + background.rightAnchor equalTo rightAnchor + 8 + background.topAnchor equalTo topAnchor - 8 + background.bottomAnchor equalTo bottomAnchor + 8 + + hStack.leftAnchor equalTo leftAnchor + hStack.rightAnchor equalTo rightAnchor + hStack.topAnchor equalTo topAnchor + hStack.bottomAnchor equalTo bottomAnchor + + if (iconView != null) { + hStack.bottomAnchor greaterThanOrEqualTo iconView!!.bottomAnchor + } + hStack.bottomAnchor greaterThanOrEqualTo vStack.bottomAnchor + + if (iconView != null) { + iconView!!.widthAnchor equalTo 30 + iconView!!.heightAnchor equalTo 30 + } + + messageLabel.heightAnchor greaterThanOrEqualTo 50 + + if (buttonContainer != null) { + buttonStack!!.heightAnchor equalTo buttonContainer!!.heightAnchor + buttonStack!!.centerYAnchor equalTo buttonContainer!!.centerYAnchor + + buttonStack!!.rightAnchor equalTo buttonContainer!!.rightAnchor + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/Label.kt b/src/main/kotlin/net/shadowfacts/cacao/view/Label.kt new file mode 100644 index 0000000..9effa49 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/Label.kt @@ -0,0 +1,123 @@ +package net.shadowfacts.cacao.view + +import net.minecraft.client.MinecraftClient +import net.minecraft.client.font.TextRenderer +import net.minecraft.client.util.math.MatrixStack +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.geometry.Size +import net.shadowfacts.cacao.util.Color +import net.shadowfacts.cacao.util.RenderHelper + +/** + * A simple View that displays text. Allows for controlling the color and shadow of the text. Label cannot be used + * for multi-line text, instead use [TextView]. + * + * @author shadowfacts + * @param text The text of this label. + * @param shadow Whether the text should be rendered with a shadow or not. + * @param maxLines The maximum number of lines of text to render. + * `0` means that there is no line limit and all lines will be rendered. + * When using a non-zero [maxLines] value, the [intrinsicContentSize] of the Label still assumes all + * content on one line. So, constraints must be provided to calculate the actual width to use for line + * wrapping. + */ +class Label( + text: String, + val shadow: Boolean = true, + val maxLines: Int = 0, + val wrappingMode: WrappingMode = WrappingMode.WRAP, + val textAlignment: TextAlignment = TextAlignment.LEFT +): View() { + + companion object { + private val textRenderer: TextRenderer + get() = MinecraftClient.getInstance().textRenderer + } + + enum class WrappingMode { + WRAP, NO_WRAP + } + + enum class TextAlignment { + LEFT, CENTER, RIGHT + } + + /** + * The text of this label. Mutating this field will update the intrinsic content size and trigger a layout. + */ + var text: String = text + set(value) { + field = value + updateIntrinsicContentSize() + // todo: setNeedsLayout instead of force unwrapping window + window!!.layout() + } + private lateinit var lines: List + + var textColor = Color.WHITE + set(value) { + field = value + textColorARGB = value.argb + } + private var textColorARGB: Int = textColor.argb + + override fun wasAdded() { + super.wasAdded() + + updateIntrinsicContentSize() + } + + private fun updateIntrinsicContentSize() { + if (RenderHelper.disabled) return + + val width = textRenderer.getWidth(text) + val height = textRenderer.fontHeight + intrinsicContentSize = Size(width.toDouble(), height.toDouble()) + } + + override fun drawContent(matrixStack: MatrixStack, mouse: Point, delta: Float) { + if (!this::lines.isInitialized) { + computeLines() + } + + for (i in 0 until lines.size) { + val x = when (textAlignment) { + TextAlignment.LEFT -> 0.0 + TextAlignment.CENTER -> (bounds.width + textRenderer.getWidth(lines[i])) / 2 + TextAlignment.RIGHT -> bounds.width - textRenderer.getWidth(lines[i]) + } + val y = i * textRenderer.fontHeight + if (shadow) { + textRenderer.drawWithShadow(matrixStack, lines[i], x.toFloat(), y.toFloat(), textColorARGB) + } else { + textRenderer.draw(matrixStack, lines[i], x.toFloat(), y.toFloat(), textColorARGB) + } + } + } + + override fun didLayout() { + super.didLayout() + + computeLines() + } + + private fun computeLines() { + var lines = text.split("\n") + if (wrappingMode == WrappingMode.WRAP) { + lines = lines.flatMap { + wrapStringToWidthAsList(it, bounds.width) + } + } + if (0 < maxLines && maxLines < lines.size) { + lines = lines.dropLast(lines.size - maxLines) + } + this.lines = lines + } + + private fun wrapStringToWidthAsList(string: String, width: Double): List { + if (RenderHelper.disabled) return listOf(string) +// return textRenderer.wrapStringToWidthAsList(string, width.toInt()) + TODO() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/NinePatchView.kt b/src/main/kotlin/net/shadowfacts/cacao/view/NinePatchView.kt new file mode 100644 index 0000000..80c6b55 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/NinePatchView.kt @@ -0,0 +1,136 @@ +package net.shadowfacts.cacao.view + +import net.minecraft.client.util.math.MatrixStack +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.geometry.Rect +import net.shadowfacts.cacao.util.texture.NinePatchTexture +import net.shadowfacts.cacao.util.RenderHelper +import net.shadowfacts.cacao.util.properties.ResettableLazyProperty + +/** + * A helper class for drawing a [NinePatchTexture] in a view. + * `NinePatchView` will draw the given nine patch texture filling its bounds. + * + * This class and the region properties are left open for internal framework use, overriding them is not recommended. + * + * @author shadowfacts + * @param ninePatch The nine patch texture that this view will draw. + */ +open class NinePatchView(val ninePatch: NinePatchTexture): View() { + + // Corners + private val topLeftDelegate = ResettableLazyProperty { + Rect(0.0, 0.0, ninePatch.cornerWidth.toDouble(), ninePatch.cornerHeight.toDouble()) + } + protected open val topLeft by topLeftDelegate + + private val topRightDelegate = ResettableLazyProperty { + Rect(bounds.width - ninePatch.cornerWidth, 0.0, ninePatch.cornerWidth.toDouble(), ninePatch.cornerHeight.toDouble()) + } + protected open val topRight by topRightDelegate + + private val bottomLeftDelegate = ResettableLazyProperty { + Rect(0.0, bounds.height - ninePatch.cornerHeight, ninePatch.cornerWidth.toDouble(), ninePatch.cornerHeight.toDouble()) + } + protected open val bottomLeft by bottomLeftDelegate + + private val bottomRightDelegate = ResettableLazyProperty { + Rect(bounds.width - ninePatch.cornerWidth, bounds.height - ninePatch.cornerHeight, ninePatch.cornerWidth.toDouble(), ninePatch.cornerHeight.toDouble()) + } + protected open val bottomRight by bottomRightDelegate + + + // Edges + private val topMiddleDelegate = ResettableLazyProperty { + Rect(ninePatch.cornerWidth.toDouble(), topLeft.top, bounds.width - 2 * ninePatch.cornerWidth, ninePatch.cornerHeight.toDouble()) + } + protected open val topMiddle by topMiddleDelegate + + private val bottomMiddleDelegate = ResettableLazyProperty { + Rect(topMiddle.left, bottomLeft.top, topMiddle.width, topMiddle.height) + } + protected open val bottomMiddle by bottomMiddleDelegate + + private val leftMiddleDelegate = ResettableLazyProperty { + Rect(topLeft.left, ninePatch.cornerHeight.toDouble(), ninePatch.cornerWidth.toDouble(), bounds.height - 2 * ninePatch.cornerHeight) + } + protected open val leftMiddle by leftMiddleDelegate + + private val rightMiddleDelegate = ResettableLazyProperty { + Rect(topRight.left, leftMiddle.top, leftMiddle.width, leftMiddle.height) + } + protected open val rightMiddle by rightMiddleDelegate + + + // Center + private val centerDelegate = ResettableLazyProperty { + Rect(topLeft.right, topLeft.bottom, topMiddle.width, leftMiddle.height) + } + protected open val center by centerDelegate + + private val delegates = listOf(topLeftDelegate, topRightDelegate, bottomLeftDelegate, bottomRightDelegate, topMiddleDelegate, bottomMiddleDelegate, leftMiddleDelegate, rightMiddleDelegate, centerDelegate) + + override fun didLayout() { + super.didLayout() + + delegates.forEach(ResettableLazyProperty::reset) + } + + override fun drawContent(matrixStack: MatrixStack, mouse: Point, delta: Float) { + drawCorners() + drawEdges() + drawCenter() + } + + private fun drawCorners() { + RenderHelper.draw(topLeft, ninePatch.topLeft) + RenderHelper.draw(topRight, ninePatch.topRight) + RenderHelper.draw(bottomLeft, ninePatch.bottomLeft) + RenderHelper.draw(bottomRight, ninePatch.bottomRight) + } + + private fun drawEdges() { + // Horizontal + for (i in 0 until (topMiddle.width.toInt() / ninePatch.centerWidth)) { + RenderHelper.draw(topMiddle.left + i * ninePatch.centerWidth, topMiddle.top, ninePatch.topMiddle.u, ninePatch.topMiddle.v, ninePatch.centerWidth.toDouble(), topMiddle.height, ninePatch.texture.width, ninePatch.texture.height) + RenderHelper.draw(bottomMiddle.left + i * ninePatch.centerWidth, bottomMiddle.top, ninePatch.bottomMiddle.u, ninePatch.bottomMiddle.v, ninePatch.centerWidth.toDouble(), bottomMiddle.height, ninePatch.texture.width, ninePatch.texture.height) + } + val remWidth = topMiddle.width.toInt() % ninePatch.centerWidth + if (remWidth > 0) { + RenderHelper.draw(topMiddle.right - remWidth, topMiddle.top, ninePatch.topMiddle.u, ninePatch.topMiddle.v, remWidth.toDouble(), ninePatch.cornerHeight.toDouble(), ninePatch.texture.width, ninePatch.texture.height) + RenderHelper.draw(bottomMiddle.right - remWidth, bottomMiddle.top, ninePatch.bottomMiddle.u, ninePatch.bottomMiddle.v, remWidth.toDouble(), ninePatch.cornerHeight.toDouble(), ninePatch.texture.width, ninePatch.texture.height) + } + + // Vertical + for (i in 0 until (leftMiddle.height.toInt() / ninePatch.centerHeight)) { + RenderHelper.draw(leftMiddle.left, leftMiddle.top + i * ninePatch.centerHeight, ninePatch.leftMiddle.u, ninePatch.leftMiddle.v, ninePatch.cornerWidth.toDouble(), ninePatch.centerHeight.toDouble(), ninePatch.texture.width, ninePatch.texture.height) + RenderHelper.draw(rightMiddle.left, rightMiddle.top + i * ninePatch.centerHeight, ninePatch.rightMiddle.u, ninePatch.rightMiddle.v, ninePatch.cornerWidth.toDouble(), ninePatch.centerHeight.toDouble(), ninePatch.texture.width, ninePatch.texture.height) + } + val remHeight = leftMiddle.height.toInt() % ninePatch.centerHeight + if (remHeight > 0) { + RenderHelper.draw(leftMiddle.left, leftMiddle.bottom - remHeight, ninePatch.leftMiddle.u, ninePatch.leftMiddle.v, ninePatch.cornerWidth.toDouble(), remHeight.toDouble(), ninePatch.texture.width, ninePatch.texture.height) + RenderHelper.draw(rightMiddle.left, rightMiddle.bottom - remHeight, ninePatch.rightMiddle.u, ninePatch.rightMiddle.v, ninePatch.cornerWidth.toDouble(), remHeight.toDouble(), ninePatch.texture.width, ninePatch.texture.height) + } + } + + private fun drawCenter() { + for (i in 0 until (center.height.toInt() / ninePatch.centerHeight)) { + drawCenterRow(center.top + i * ninePatch.centerHeight.toDouble(), ninePatch.centerHeight.toDouble()) + } + val remHeight = center.height.toInt() % ninePatch.centerHeight + if (remHeight > 0) { + drawCenterRow(center.bottom - remHeight, remHeight.toDouble()) + } + } + + private fun drawCenterRow(y: Double, height: Double) { + for (i in 0 until (center.width.toInt() / ninePatch.centerWidth)) { + RenderHelper.draw(center.left + i * ninePatch.centerWidth, y, ninePatch.center.u, ninePatch.center.v, ninePatch.centerWidth.toDouble(), height, ninePatch.texture.width, ninePatch.texture.height) + } + val remWidth = center.width.toInt() % ninePatch.centerWidth + if (remWidth > 0) { + RenderHelper.draw(center.right - remWidth, y, ninePatch.center.u, ninePatch.center.v, remWidth.toDouble(), height, ninePatch.texture.width, ninePatch.texture.height) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/StackView.kt b/src/main/kotlin/net/shadowfacts/cacao/view/StackView.kt new file mode 100644 index 0000000..a24f317 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/StackView.kt @@ -0,0 +1,212 @@ +package net.shadowfacts.cacao.view + +import net.shadowfacts.kiwidsl.dsl +import net.shadowfacts.cacao.LayoutVariable +import net.shadowfacts.cacao.geometry.Axis +import net.shadowfacts.cacao.geometry.AxisPosition +import net.shadowfacts.cacao.geometry.AxisPosition.* +import no.birkett.kiwi.Constraint +import java.util.* + +/** + * A view that lays out its children in a stack along either the horizontal for vertical axes. + * This view does not have any content of its own. + * + * Only arranged subviews will be laid out in the stack mode, normal subviews must perform their own layout. + * + * @author shadowfacts + * @param axis The primary axis that this stack lays out its children along. + * @param distribution The mode by which this stack lays out its children along the axis perpendicular to the + * primary [axis]. + */ +open class StackView( + val axis: Axis, + val distribution: Distribution = Distribution.FILL, + val spacing: Double = 0.0 +): View() { + + // the internal mutable, list of arranged subviews + private val _arrangedSubviews = LinkedList() + /** + * The list of arranged subviews belonging to this stack view. + * This list should never be mutated directly, only be calling the [addArrangedSubview]/[removeArrangedSubview] + * methods. + */ + val arrangedSubviews: List = _arrangedSubviews + + private var leadingConnection: Constraint? = null + private var trailingConnection: Constraint? = null + private var arrangedSubviewConnections = mutableListOf() + + /** + * Adds an arranged subview to this view. + * Arranged subviews are laid out according to the stack. If you wish to add a subview that is laid out separately, + * use the normal [addSubview] method. + * + * @param view The view to add. + * @param index The index in this stack to add the view at. + * By default, adds the view to the end of the stack. + * @return The view that was added, as a convenience. + */ + fun addArrangedSubview(view: T, index: Int = arrangedSubviews.size): T { + addSubview(view) + _arrangedSubviews.add(index, view) + + addConstraintsForArrangedView(view, index) + + return view + } + + private fun addConstraintsForArrangedView(view: View, index: Int) { + if (index == 0) { + if (leadingConnection != null) { + solver.removeConstraint(leadingConnection) + } + solver.dsl { + leadingConnection = anchor(LEADING) equalTo anchor(LEADING, view) + } + } + if (index == arrangedSubviews.size - 1) { + if (trailingConnection != null) { + solver.removeConstraint(trailingConnection) + } + solver.dsl { + trailingConnection = anchor(TRAILING, view) equalTo anchor(TRAILING) + } + } + if (arrangedSubviews.size > 1) { + solver.dsl { + val previous = arrangedSubviews.getOrNull(index - 1) + val next = arrangedSubviews.getOrNull(index + 1) + if (next != null) { + arrangedSubviewConnections.add(index, anchor(TRAILING, view) equalTo (anchor(LEADING, next) + spacing)) + } + if (previous != null) { + arrangedSubviewConnections.add(index - 1, anchor(TRAILING, previous) equalTo (anchor(LEADING, view) - spacing)) + } + } + } + solver.dsl { + when (distribution) { + Distribution.LEADING -> + perpAnchor(LEADING) equalTo perpAnchor(LEADING, view) + Distribution.TRAILING -> + perpAnchor(TRAILING) equalTo perpAnchor(TRAILING, view) + Distribution.FILL -> { + perpAnchor(LEADING) equalTo perpAnchor(LEADING, view) + perpAnchor(TRAILING) equalTo perpAnchor(TRAILING, view) + } + Distribution.CENTER -> + perpAnchor(CENTER) equalTo perpAnchor(CENTER, view) + } + } + } + + private fun anchor(position: AxisPosition, view: View = this): LayoutVariable { + return view.getAnchor(axis, position) + } + private fun perpAnchor(position: AxisPosition, view: View = this): LayoutVariable { + return view.getAnchor(axis.perpendicular, position) + } + + /** + * Defines the modes of how content is distributed in a stack view along the perpendicular axis (i.e. the + * non-primary axis). + * + * ASCII-art examples are shown below in a stack view with the primary axis [Axis.VERTICAL]. + */ + enum class Distribution { + /** + * The leading edges of arranged subviews are pinned to the leading edge of the stack view. + * ``` + * ┌─────────────────────────────┐ + * │┌─────────────┐ │ + * ││ │ │ + * ││ │ │ + * ││ │ │ + * │└─────────────┘ │ + * │┌─────────┐ │ + * ││ │ │ + * ││ │ │ + * ││ │ │ + * │└─────────┘ │ + * │┌───────────────────────────┐│ + * ││ ││ + * ││ ││ + * ││ ││ + * │└───────────────────────────┘│ + * └─────────────────────────────┘ + * ``` + */ + LEADING, + /** + * The centers of the arranged subviews are pinned to the center of the stack view. + * ``` + * ┌─────────────────────────────┐ + * │ ┌─────────────┐ │ + * │ │ │ │ + * │ │ │ │ + * │ │ │ │ + * │ └─────────────┘ │ + * │ ┌─────────┐ │ + * │ │ │ │ + * │ │ │ │ + * │ │ │ │ + * │ └─────────┘ │ + * │┌───────────────────────────┐│ + * ││ ││ + * ││ ││ + * ││ ││ + * │└───────────────────────────┘│ + * └─────────────────────────────┘ + * ``` + */ + CENTER, + /** + * The trailing edges of arranged subviews are pinned to the leading edge of the stack view. + * ``` + * ┌─────────────────────────────┐ + * │ ┌─────────────┐│ + * │ │ ││ + * │ │ ││ + * │ │ ││ + * │ └─────────────┘│ + * │ ┌─────────┐│ + * │ │ ││ + * │ │ ││ + * │ │ ││ + * │ └─────────┘│ + * │┌───────────────────────────┐│ + * ││ ││ + * ││ ││ + * ││ ││ + * │└───────────────────────────┘│ + * └─────────────────────────────┘ + * ``` + */ + TRAILING, + /** + * The arranged subviews fill the perpendicular axis of the stack view. + * ``` + * ┌─────────────────────────────┐ + * │┌───────────────────────────┐│ + * ││ ││ + * ││ ││ + * ││ ││ + * │└───────────────────────────┘│ + * │┌───────────────────────────┐│ + * ││ ││ + * ││ ││ + * ││ ││ + * │└───────────────────────────┘│ + * │┌───────────────────────────┐│ + * ││ ││ + * ││ ││ + * ││ ││ + * │└───────────────────────────┘│ + * └─────────────────────────────┘ + * ``` + */ + FILL + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/TextureView.kt b/src/main/kotlin/net/shadowfacts/cacao/view/TextureView.kt new file mode 100644 index 0000000..e34533b --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/TextureView.kt @@ -0,0 +1,20 @@ +package net.shadowfacts.cacao.view + +import net.minecraft.client.util.math.MatrixStack +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.util.RenderHelper +import net.shadowfacts.cacao.util.texture.Texture + +/** + * A helper class for drawing a [Texture] in a view. + * `TextureView` will draw the given texture filling the bounds of the view. + * + * @author shadowfacts + */ +class TextureView(var texture: Texture): View() { + + override fun drawContent(matrixStack: MatrixStack, mouse: Point, delta: Float) { + RenderHelper.draw(bounds, texture) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/View.kt b/src/main/kotlin/net/shadowfacts/cacao/view/View.kt new file mode 100644 index 0000000..41bfaf8 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/View.kt @@ -0,0 +1,391 @@ +package net.shadowfacts.cacao.view + +import net.minecraft.client.util.math.MatrixStack +import net.shadowfacts.kiwidsl.dsl +import net.shadowfacts.cacao.LayoutVariable +import net.shadowfacts.cacao.Window +import net.shadowfacts.cacao.geometry.* +import net.shadowfacts.cacao.util.* +import net.shadowfacts.cacao.util.properties.ObservableLateInitProperty +import no.birkett.kiwi.Constraint +import no.birkett.kiwi.Solver +import java.lang.RuntimeException +import java.util.* + +/** + * The base Cacao View class. Provides layout anchors, properties, and helper methods. + * Doesn't draw anything itself (unless [backgroundColor] is specified), but may be used for encapsulation/grouping. + * + * @author shadowfacts + */ +open class View() { + + /** + * The window whose view hierarchy this view belongs to. + * Not initialized until the root view in this hierarchy has been added to a hierarchy, + * using it before that will throw a runtime exception. + */ + var window: Window? = null + + /** + * The constraint solver used by the [net.shadowfacts.cacao.Window] this view belongs to. + * Not initialized until [wasAdded] called, using it before that will throw a runtime exception. + */ + lateinit var solver: Solver + + /** + * Layout anchor for the left edge of this view in the window's coordinate system. + */ + val leftAnchor = LayoutVariable(this, "left") + /** + * Layout anchor for the right edge of this view in the window's coordinate system. + */ + val rightAnchor = LayoutVariable(this, "right") + /** + * Layout anchor for the top edge of this view in the window's coordinate system. + */ + val topAnchor = LayoutVariable(this, "top") + /** + * Layout anchor for the bottom edge of this view in the window's coordinate system. + */ + val bottomAnchor = LayoutVariable(this, "bottom") + /** + * Layout anchor for the width of this view in the window's coordinate system. + */ + val widthAnchor = LayoutVariable(this, "width") + /** + * Layout anchor for the height of this view in the window's coordinate system. + */ + val heightAnchor = LayoutVariable(this, "height") + /** + * Layout anchor for the center X position of this view in the window's coordinate system. + */ + val centerXAnchor = LayoutVariable(this, "centerX") + /** + * Layout anchor for the center Y position of this view in the window's coordinate system. + */ + val centerYAnchor = LayoutVariable(this, "centerY") + + /** + * Whether this view uses constraint-based layout. + * If `false`, the view's `frame` must be set manually and the layout anchors may not be used. + * Note: some views (such as [StackView] require arranged subviews to use constraint based layout. + * + * Default is `true`. + */ + var usesConstraintBasedLayout = true + + /** + * The rectangle for this view in the coordinate system of its superview view (or the window, if there is no superview). + * If using constraint based layout, this property is not initialized until [didLayout] called. + * Otherwise, this must be set manually. + * Setting this property updates the [bounds]. + */ + var frame: Rect by ObservableLateInitProperty { this.bounds = Rect(Point.ORIGIN, it.size) } + /** + * The rectangle for this view in its own coordinate system. + * If using constraint based layout, this property is not initialized until [didLayout] called. + * Otherwise, this will be initialized when [frame] is set. + */ + lateinit var bounds: Rect + + /** + * The position on the Z-axis of this view. + * Views are rendered from lowest Z index to highest. Clicks are handled from highest to lowest. + */ + var zIndex: Double = 0.0 + + /** + * The intrinsic size of this view's content. May be null if the view doesn't have any content or there is no + * intrinsic size. + * + * Setting this creates/updates [no.birkett.kiwi.Strength.WEAK] constraints on this view's width/height using + * the size. + */ + var intrinsicContentSize: Size? = null + set(value) { + updateIntrinsicContentSizeConstraints(intrinsicContentSize, value) + field = value + } + private var intrinsicContentSizeWidthConstraint: Constraint? = null + private var intrinsicContentSizeHeightConstraint: Constraint? = null + + /** + * The background color of this view. + */ + var backgroundColor = Color.CLEAR + + var respondsToDragging = false + + /** + * This view's parent view. If `null`, this view is a top-level view in the [Window]. + */ + var superview: View? = null + // _subviews is the internal, mutable object since we only want it to be mutated by the add/removeSubview methods + private val _subviews = LinkedList() + private var subviewsSortedByZIndex: List = listOf() + + /** + * The list of all the subviews of this view. + * This list should never by mutated directly, only by the [addSubview]/[removeSubview] methods. + */ + val subviews: List = _subviews + + constructor(frame: Rect): this() { + this.usesConstraintBasedLayout = false + this.frame = frame + } + + /** + * Helper method for retrieve the anchor for a specific position on the given axis. + */ + fun getAnchor(axis: Axis, position: AxisPosition): LayoutVariable { + return when (axis) { + Axis.HORIZONTAL -> + when (position) { + AxisPosition.LEADING -> leftAnchor + AxisPosition.CENTER -> centerXAnchor + AxisPosition.TRAILING -> rightAnchor + } + Axis.VERTICAL -> + when (position) { + AxisPosition.LEADING -> topAnchor + AxisPosition.CENTER -> centerYAnchor + AxisPosition.TRAILING -> bottomAnchor + } + } + } + + /** + * Adds the given subview as a child of this view. + * + * @param view The view to add. + * @return The view that was added, as a convenience. + */ + fun addSubview(view: T): T { + _subviews.add(view) + subviewsSortedByZIndex = subviews.sortedBy(View::zIndex) + + view.superview = this + view.solver = solver + view.window = window + + view.wasAdded() + + return view + } + + /** + * Removes the given view from this view's children and removes all constraints associated with it. + * + * @param view The view to removed as a child of this view. + * @throws RuntimeException If the given [view] is not a subview of this view. + */ + fun removeSubview(view: View) { + if (view.superview != this) { + throw RuntimeException("Cannot remove subview whose superview is not this view") + } + solver.constraints.filter { constraint -> + constraint.getVariables().any { it is LayoutVariable && it.owner == view } + }.forEach(solver::removeConstraint) + _subviews.remove(view) + subviewsSortedByZIndex = subviews.sortedBy(View::zIndex) + + view.superview = null + // todo: does this need to be reset +// view.solver = null + view.window = null + + // todo: is this necessary? +// view.wasRemoved() + } + + /** + * Removes this view from its superview, if it has one. + */ + fun removeFromSuperview() { + superview?.removeSubview(this) + } + + /** + * Finds all subviews that contain the given point. + * + * @param point The point to find subviews for, in the coordinate system of this view. + * @return All views that contain the given point. + */ + fun subviewsAtPoint(point: Point): List { + return subviews.filter { point in it.frame } + } + + /** + * Attempts to find a subview which contains the given point. + * If multiple subviews contain the given point, which one this method returns is undefined. + * [subviewsAtPoint] may be used, and the resulting List sorted by [View.zIndex]. + * + * @param point The point to find a subview for, in the coordinate system of this view. + * @return The view, if any, that contains the given point. + */ + fun subviewAtPoint(point: Point): View? { + return subviews.firstOrNull { point in it.frame } + } + + /** + * Called when this view was added to a view hierarchy. + * If overridden, the super-class method must be called. + */ + open fun wasAdded() { + createInternalConstraints() + updateIntrinsicContentSizeConstraints(null, intrinsicContentSize) + } + + /** + * Called during [wasAdded] to add any constraints to the [solver] that are internal to this view. + * If overridden, the super-class method must be called. + */ + protected open fun createInternalConstraints() { + if (!usesConstraintBasedLayout) return + + solver.dsl { + rightAnchor equalTo (leftAnchor + widthAnchor) + bottomAnchor equalTo (topAnchor + heightAnchor) + centerXAnchor equalTo (leftAnchor + widthAnchor / 2) + centerYAnchor equalTo (topAnchor + heightAnchor / 2) + } + } + + private fun updateIntrinsicContentSizeConstraints(old: Size?, new: Size?) { + if (!usesConstraintBasedLayout || !this::solver.isInitialized) return + + if (old != null) { + solver.removeConstraint(intrinsicContentSizeWidthConstraint!!) + solver.removeConstraint(intrinsicContentSizeHeightConstraint!!) + } + if (new != null) { + solver.dsl { + this@View.intrinsicContentSizeWidthConstraint = (widthAnchor.equalTo(new.width, strength = WEAK)) + this@View.intrinsicContentSizeHeightConstraint = (heightAnchor.equalTo(new.height, strength = WEAK)) + } + } + } + + /** + * Called after this view has been laid-out. + * If overridden, the super-class method must be called. + */ + open fun didLayout() { + subviews.forEach(View::didLayout) + + if (usesConstraintBasedLayout) { + val superviewLeft = superview?.leftAnchor?.value ?: 0.0 + val superviewTop = superview?.topAnchor?.value ?: 0.0 + frame = Rect(leftAnchor.value - superviewLeft, topAnchor.value - superviewTop, widthAnchor.value, heightAnchor.value) + bounds = Rect(0.0, 0.0, widthAnchor.value, heightAnchor.value) + } + } + + /** + * Called to draw this view. + * This method should not be called directly, it is called by the parent view/window. + * This method generally should not be overridden, but it is left open for internal framework use. + * Use [drawContent] to draw any custom content. + * + * @param mouse The position of the mouse in the coordinate system of this view. + * @param delta The time since the last frame. + */ + open fun draw(matrixStack: MatrixStack, mouse: Point, delta: Float) { + RenderHelper.pushMatrix() + RenderHelper.translate(frame.left, frame.top) + + RenderHelper.fill(matrixStack, bounds, backgroundColor) + + drawContent(matrixStack, mouse, delta) + + subviewsSortedByZIndex.forEach { + val mouseInView = convert(mouse, to = it) + it.draw(matrixStack, mouseInView, delta) + } + + RenderHelper.popMatrix() + } + + /** + * Called during [draw] to draw content that's part of this view. + * During this method, the OpenGL coordinate system has been translated so the origin is at the top left corner + * of this view. Be careful not to translate additionally, and not to draw outside the [bounds] of the view. + * + * @param mouse The position of the mouse in the coordinate system of this view. + * @param delta The time since the last frame. + */ + open fun drawContent(matrixStack: MatrixStack, mouse: Point, delta: Float) {} + + /** + * Called when this view is clicked. May delegate to [subviews]. + * If overridden, the super-class method does not have to be called. Intentionally not calling it may be used + * to prevent [subviews] from receiving click events. + * + * @param point The point in the coordinate system of this view that the mouse was clicked. + * @param mouseButton The mouse button used to click. + * @return Whether the mouse click was handled by this view or any subviews. + */ + open fun mouseClicked(point: Point, mouseButton: MouseButton): Boolean { + val view = subviewsAtPoint(point).maxBy(View::zIndex) + if (view != null) { + val pointInView = convert(point, to = view) + return view.mouseClicked(pointInView, mouseButton) + } + return false + } + + open fun mouseDragged(startPoint: Point, delta: Point, mouseButton: MouseButton): Boolean { + val view = subviewsAtPoint(startPoint).maxBy(View::zIndex) + if (view != null) { + val startInView = convert(startPoint, to = view) + return view.mouseDragged(startInView, delta, mouseButton) + } + return false + } + + /** + * Converts the given point in this view's coordinate system to the coordinate system of another view or the window. + * + * @param point The point to convert, in the coordinate system of this view. + * @param to The view to convert to. If `null`, it will be converted to the window's coordinate system. + * @return The point in the coordinate system of the [to] view. + */ + fun convert(point: Point, to: View?): Point { + if (to != null) { + val ancestor = LowestCommonAncestor.find(this, to, View::superview) + @Suppress("NAME_SHADOWING") var point = point + + // Convert up to the LCA + var view: View? = this + while (view != null && view != ancestor) { + point = Point(point.x + view.frame.left, point.y + view.frame.top) + view = view.superview + } + + // Convert back down to the other view + view = to + while (view != null && view != ancestor) { + point = Point(point.x - view.frame.left, point.y - view.frame.top) + view = view.superview + } + + return point + } else { + return Point(leftAnchor.value + point.x, topAnchor.value + point.y) + } + } + + /** + * Converts the given rectangle in this view's coordinate system to the coordinate system of another view or the window. + * + * @param rect The rectangle to convert, in the coordinate system of this view. + * @param to The view to convert to. If `null`, it will be converted to the window's coordinate system. + * @return The rectangle in the coordinate system of the [to] view. + */ + fun convert(rect: Rect, to: View?): Rect { + return Rect(convert(rect.origin, to), rect.size) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/button/AbstractButton.kt b/src/main/kotlin/net/shadowfacts/cacao/view/button/AbstractButton.kt new file mode 100644 index 0000000..d4431b7 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/button/AbstractButton.kt @@ -0,0 +1,117 @@ +package net.shadowfacts.cacao.view.button + +import net.minecraft.client.util.math.MatrixStack +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.util.MouseButton +import net.shadowfacts.cacao.util.texture.NinePatchTexture +import net.shadowfacts.cacao.util.RenderHelper +import net.shadowfacts.cacao.view.NinePatchView +import net.shadowfacts.cacao.view.View +import net.shadowfacts.kiwidsl.dsl + +/** + * A abstract button class. Cannot be constructed directly, used for creating button implementations with their own logic. + * Use [Button] for a generic no-frills button. + * + * @author shadowfacts + * @param Impl The type of the concrete implementation of the button. + * Used to allow the [handler] to receive the exact button type that was constructed. + * @param content The [View] that provides the content of this button. + * Will be added as a subview of the button and laid out using constraints. + * @param padding The padding between the [content] and the edges of the button. + */ +abstract class AbstractButton>(val content: View, val padding: Double = 4.0): View() { + + /** + * The function that handles when this button is clicked. + * The parameter is the type of the concrete button implementation that was used. + */ + var handler: ((Impl) -> Unit)? = null + + /** + * Whether the button is disabled. + * Disabled buttons have a different background ([disabledBackground]) and do not receive click events. + */ + var disabled = false + + /** + * The normal background view to draw behind the button content. It will be added as a subview during [wasAdded], + * so all background view properties must be specified prior to the button being added to a view hierarchy. + * + * The background will fill the entire button (going beneath the content [padding]). + * There are also [hoveredBackground] and [disabledBackground] for those states. + * If a [backgroundColor] is specified, it will be drawn behind the background View and thus not visible + * unless the background view is not fully opaque. + */ + var background: View? = NinePatchView(NinePatchTexture.BUTTON_BG) + /** + * The background to draw when the button is hovered over by the mouse. + * If `null`, the normal [background] will be used. + * @see background + */ + var hoveredBackground: View? = NinePatchView(NinePatchTexture.BUTTON_HOVERED_BG) + /** + * The background to draw when the button is [disabled]. + * If `null`, the normal [background] will be used. + * @see background + */ + var disabledBackground: View? = NinePatchView(NinePatchTexture.BUTTON_DISABLED_BG) + + override fun wasAdded() { + solver.dsl { + addSubview(content) + content.leftAnchor equalTo (leftAnchor + padding) + content.rightAnchor equalTo (rightAnchor - padding) + content.topAnchor equalTo (topAnchor + padding) + content.bottomAnchor equalTo (bottomAnchor - padding) + + listOfNotNull(background, hoveredBackground, disabledBackground).forEach { + addSubview(it) + it.leftAnchor equalTo leftAnchor + it.rightAnchor equalTo rightAnchor + it.topAnchor equalTo topAnchor + it.bottomAnchor equalTo bottomAnchor + } + } + + super.wasAdded() + } + + override fun draw(matrixStack: MatrixStack, mouse: Point, delta: Float) { + RenderHelper.pushMatrix() + RenderHelper.translate(frame.left, frame.top) + + RenderHelper.fill(matrixStack, bounds, backgroundColor) + + var currentBackground: View? = background + if (mouse in bounds) { + currentBackground = hoveredBackground ?: currentBackground + } + if (disabled) { + currentBackground = disabledBackground ?: currentBackground + } + // don't need to convert mouse to background coordinate system + // the edges are all pinned, so the coordinate space is the same + currentBackground?.draw(matrixStack, mouse, delta) + + val mouseInContent = convert(mouse, to = content) + content.draw(matrixStack, mouseInContent, delta) + + // don't draw subviews, otherwise all background views + content will get drawn + + RenderHelper.popMatrix() + } + + override fun mouseClicked(point: Point, mouseButton: MouseButton): Boolean { + if (disabled) return false + + // We can perform an unchecked cast here because we are certain that Impl will be the concrete implementation + // of AbstractButton. + // For example, an implementing class may be defined as such: `class Button: AbstractButton