ASMR/src/main/kotlin/net/shadowfacts/cacao/view/View.kt

379 lines
12 KiB
Kotlin

package net.shadowfacts.cacao.view
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
/**
* 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<View>()
private var subviewsSortedByZIndex: List<View> = 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<View> = _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 <T: View> 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<View> {
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(mouse: Point, delta: Float) {
RenderHelper.pushMatrix()
RenderHelper.translate(frame.left, frame.top)
RenderHelper.fill(bounds, backgroundColor)
drawContent(mouse, delta)
subviewsSortedByZIndex.forEach {
val mouseInView = convert(mouse, to = it)
it.draw(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(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
}
/**
* 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)
}
}