diff --git a/src/main/kotlin/net/shadowfacts/cacao/CacaoScreen.kt b/src/main/kotlin/net/shadowfacts/cacao/CacaoScreen.kt index 5b95805..3d712da 100644 --- a/src/main/kotlin/net/shadowfacts/cacao/CacaoScreen.kt +++ b/src/main/kotlin/net/shadowfacts/cacao/CacaoScreen.kt @@ -6,9 +6,11 @@ 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.KeyModifiers import net.shadowfacts.cacao.util.MouseButton import net.shadowfacts.cacao.util.RenderHelper import net.shadowfacts.cacao.window.Window +import org.lwjgl.glfw.GLFW import java.util.* /** @@ -55,6 +57,7 @@ open class CacaoScreen: Screen(LiteralText("CacaoScreen")), AbstractCacaoScreen */ override fun removeWindow(window: Window) { _windows.remove(window) + // todo: VC callbacks } override fun init() { @@ -65,6 +68,17 @@ open class CacaoScreen: Screen(LiteralText("CacaoScreen")), AbstractCacaoScreen } } + override fun onClose() { + super.onClose() + + windows.forEach { + // todo: VC callbacks + + // resign the current first responder (if any) + it.firstResponder = null + } + } + override fun render(matrixStack: MatrixStack, mouseX: Int, mouseY: Int, delta: Float) { if (client != null) { // workaround this.minecraft sometimes being null causing a crash @@ -102,4 +116,35 @@ open class CacaoScreen: Screen(LiteralText("CacaoScreen")), AbstractCacaoScreen return result == true } + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + val modifiersSet by lazy { KeyModifiers(modifiers) } + if (findResponder { it.keyPressed(keyCode, modifiersSet) }) { + return true + } + return super.keyPressed(keyCode, scanCode, modifiers) + } + + override fun keyReleased(i: Int, j: Int, k: Int): Boolean { + return super.keyReleased(i, j, k) + } + + override fun charTyped(char: Char, modifiers: Int): Boolean { + val modifiersSet by lazy { KeyModifiers(modifiers) } + if (findResponder { it.charTyped(char, modifiersSet) }) { + return true + } + return super.charTyped(char, modifiers) + } + + private fun findResponder(fn: (Responder) -> Boolean): Boolean { + var responder = windows.lastOrNull()?.firstResponder + while (responder != null) { + if (fn(responder)) { + return true + } + responder = responder.nextResponder + } + return false + } + } \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/Responder.kt b/src/main/kotlin/net/shadowfacts/cacao/Responder.kt new file mode 100644 index 0000000..272b04f --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/Responder.kt @@ -0,0 +1,44 @@ +package net.shadowfacts.cacao + +import net.shadowfacts.cacao.util.KeyModifiers +import net.shadowfacts.cacao.window.Window + +/** + * @author shadowfacts + */ +interface Responder { + + val window: Window? + + val isFirstResponder: Boolean + get() = window?.firstResponder === this + + val nextResponder: Responder? + + fun becomeFirstResponder() { + if (window == null) { + throw RuntimeException("Cannot become first responder while not in Window") + } + window!!.firstResponder = this + } + + fun didBecomeFirstResponder() {} + + fun resignFirstResponder() { + if (window == null) { + throw RuntimeException("Cannot resign first responder while not in Window") + } + window!!.firstResponder = null + } + + fun didResignFirstResponder() {} + + fun charTyped(char: Char, modifiers: KeyModifiers): Boolean { + return false + } + + fun keyPressed(keyCode: Int, modifiers: KeyModifiers): Boolean { + return false + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/shadowfacts/cacao/util/KeyModifiers.kt b/src/main/kotlin/net/shadowfacts/cacao/util/KeyModifiers.kt new file mode 100644 index 0000000..013dcb3 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/util/KeyModifiers.kt @@ -0,0 +1,32 @@ +package net.shadowfacts.cacao.util + +import org.lwjgl.glfw.GLFW + +/** + * @author shadowfacts + */ +class KeyModifiers(val value: Int) { + + val shift: Boolean + get() = this[GLFW.GLFW_MOD_SHIFT] + + val control: Boolean + get() = this[GLFW.GLFW_MOD_CONTROL] + + val alt: Boolean + get() = this[GLFW.GLFW_MOD_ALT] + + val command: Boolean + get() = this[GLFW.GLFW_MOD_SUPER] + + val capsLock: Boolean + get() = this[GLFW.GLFW_MOD_CAPS_LOCK] + + val numLock: Boolean + get() = this[GLFW.GLFW_MOD_NUM_LOCK] + + private operator fun get(mod: Int): Boolean { + return (value and mod) == mod + } + +} \ 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 index 6c08c7e..fbed536 100644 --- a/src/main/kotlin/net/shadowfacts/cacao/view/View.kt +++ b/src/main/kotlin/net/shadowfacts/cacao/view/View.kt @@ -3,6 +3,7 @@ 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.Responder import net.shadowfacts.cacao.window.Window import net.shadowfacts.cacao.geometry.* import net.shadowfacts.cacao.util.* @@ -19,14 +20,18 @@ import kotlin.collections.HashSet * * @author shadowfacts */ -open class View() { +open class View(): Responder { /** * 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 + override var window: Window? = null + + override val nextResponder: Responder? + // todo: should the view controller be a Responder? + get() = superview private val solverDelegate = ObservableLateInitProperty { for (v in subviews) { @@ -354,12 +359,25 @@ open class View() { * @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).maxByOrNull(View::zIndex) + val (inside, outside) = subviews.partition { point in it.frame } + val view = inside.maxByOrNull(View::zIndex) + var result = false if (view != null) { val pointInView = convert(point, to = view) - return view.mouseClicked(pointInView, mouseButton) + result = view.mouseClicked(pointInView, mouseButton) + } + for (v in outside) { + val pointInV = convert(point, to = v) + v.mouseClickedOutside(pointInV, mouseButton) + } + return result + } + + open fun mouseClickedOutside(point: Point, mouseButton: MouseButton) { + for (view in subviews) { + val pointInView = convert(point, to = view) + view.mouseClickedOutside(pointInView, mouseButton) } - return false } open fun mouseDragged(startPoint: Point, delta: Point, mouseButton: MouseButton): Boolean { diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/textfield/AbstractTextField.kt b/src/main/kotlin/net/shadowfacts/cacao/view/textfield/AbstractTextField.kt new file mode 100644 index 0000000..aca771c --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/textfield/AbstractTextField.kt @@ -0,0 +1,128 @@ +package net.shadowfacts.cacao.view.textfield + +import net.minecraft.client.MinecraftClient +import net.minecraft.client.gui.widget.TextFieldWidget +import net.minecraft.client.util.math.MatrixStack +import net.minecraft.text.LiteralText +import net.shadowfacts.cacao.geometry.Point +import net.shadowfacts.cacao.util.Color +import net.shadowfacts.cacao.util.KeyModifiers +import net.shadowfacts.cacao.util.MouseButton +import net.shadowfacts.cacao.util.RenderHelper +import net.shadowfacts.cacao.view.View + +/** + * @author shadowfacts + */ +abstract class AbstractTextField>( + initialText: String +): View() { + + var handler: ((Impl) -> Unit)? = null + var disabled = false + + val focused: Boolean + get() = isFirstResponder + + var text: String + get() = minecraftWidget.text + set(value) { + minecraftWidget.text = value + } + + private lateinit var originInWindow: Point + private var minecraftWidget = ProxyWidget() + + init { + minecraftWidget.text = initialText + minecraftWidget.setTextPredicate { this.validate(it) } + } + + abstract fun validate(proposedText: String): Boolean + + override fun didLayout() { + super.didLayout() + + originInWindow = convert(bounds.origin, to = null) + + // offset View dimensions by 1 on each side because TextFieldWidget draws the border outside its dimensions + minecraftWidget.x = originInWindow.x.toInt() + 1 + minecraftWidget.y = originInWindow.y.toInt() + 1 + minecraftWidget.width = bounds.width.toInt() - 2 + minecraftWidget.height = bounds.height.toInt() - 2 + + // after dimensions change call setText on the widget to make sure its internal scroll position is up-to-date + minecraftWidget.text = text + } + + override fun drawContent(matrixStack: MatrixStack, mouse: Point, delta: Float) { + RenderHelper.pushMatrix() + RenderHelper.translate(-originInWindow.x, -originInWindow.y) + + val mouseXInWindow = (mouse.x + originInWindow.x).toInt() + val mouseYInWindow = (mouse.y + originInWindow.y).toInt() + minecraftWidget.render(matrixStack, mouseXInWindow, mouseYInWindow, delta) + + RenderHelper.popMatrix() + } + + override fun mouseClicked(point: Point, mouseButton: MouseButton): Boolean { + if (!disabled) { + if (focused) { + val mouseXInWindow = (point.x + originInWindow.x) + val mouseYInWindow = (point.y + originInWindow.y) + minecraftWidget.mouseClicked(mouseXInWindow, mouseYInWindow, mouseButton.ordinal) + } else { + becomeFirstResponder() + } + } + // don't play sound when interacting with text field + return false + } + + override fun mouseClickedOutside(point: Point, mouseButton: MouseButton) { + if (focused) { + resignFirstResponder() + } + } + + override fun didBecomeFirstResponder() { + super.didBecomeFirstResponder() + minecraftWidget.setSelected(true) + } + + override fun didResignFirstResponder() { + super.didResignFirstResponder() + minecraftWidget.setSelected(false) + } + + override fun charTyped(char: Char, modifiers: KeyModifiers): Boolean { + val oldText = text + val result = minecraftWidget.charTyped(char, modifiers.value) + if (text != oldText) { + @Suppress("UNCHECKED_CAST") + handler?.invoke(this as Impl) + } + return result + } + + override fun keyPressed(keyCode: Int, modifiers: KeyModifiers): Boolean { + val oldText = text + // scanCode isn't used by TextFieldWidget, hopefully this doesn't break :/ + val result = minecraftWidget.keyPressed(keyCode, -1, modifiers.value) + if (text != oldText) { + @Suppress("UNCHECKED_CAST") + handler?.invoke(this as Impl) + } + return result + } + + // todo: label for the TextFieldWidget? + class ProxyWidget: TextFieldWidget(MinecraftClient.getInstance().textRenderer, 0, 0, 0, 0, LiteralText("")) { + // AbstractButtonWidget.height is protected + fun setHeight(height: Int) { + this.height = height + } + } + +} diff --git a/src/main/kotlin/net/shadowfacts/cacao/view/textfield/TextField.kt b/src/main/kotlin/net/shadowfacts/cacao/view/textfield/TextField.kt new file mode 100644 index 0000000..ef99ed8 --- /dev/null +++ b/src/main/kotlin/net/shadowfacts/cacao/view/textfield/TextField.kt @@ -0,0 +1,14 @@ +package net.shadowfacts.cacao.view.textfield + +/** + * @author shadowfacts + */ +class TextField(initialText: String): AbstractTextField(initialText) { + constructor(initialText: String, handler: (TextField) -> Unit): this(initialText) { + this.handler = handler + } + + override fun validate(proposedText: String): Boolean { + return true + } +} diff --git a/src/main/kotlin/net/shadowfacts/cacao/window/Window.kt b/src/main/kotlin/net/shadowfacts/cacao/window/Window.kt index 290f9db..d0650bb 100644 --- a/src/main/kotlin/net/shadowfacts/cacao/window/Window.kt +++ b/src/main/kotlin/net/shadowfacts/cacao/window/Window.kt @@ -3,6 +3,7 @@ package net.shadowfacts.cacao.window import net.minecraft.client.util.math.MatrixStack import net.shadowfacts.cacao.AbstractCacaoScreen import net.shadowfacts.cacao.CacaoScreen +import net.shadowfacts.cacao.Responder import net.shadowfacts.cacao.geometry.Point import net.shadowfacts.cacao.util.MouseButton import net.shadowfacts.cacao.view.View @@ -73,6 +74,13 @@ open class Window( */ val centerYAnchor = Variable("centerY") + var firstResponder: Responder? = null + set(value) { + field?.didResignFirstResponder() + field = value + field?.didBecomeFirstResponder() + } + // 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 diff --git a/src/main/kotlin/net/shadowfacts/phycon/screen/TestCacaoScreen.kt b/src/main/kotlin/net/shadowfacts/phycon/screen/TestCacaoScreen.kt index 32e1a01..7baeca1 100644 --- a/src/main/kotlin/net/shadowfacts/phycon/screen/TestCacaoScreen.kt +++ b/src/main/kotlin/net/shadowfacts/phycon/screen/TestCacaoScreen.kt @@ -13,6 +13,7 @@ import net.shadowfacts.cacao.util.texture.NinePatchTexture import net.shadowfacts.cacao.util.texture.Texture import net.shadowfacts.cacao.view.* import net.shadowfacts.cacao.view.button.Button +import net.shadowfacts.cacao.view.textfield.TextField import net.shadowfacts.cacao.viewcontroller.TabViewController import net.shadowfacts.cacao.viewcontroller.ViewController import net.shadowfacts.kiwidsl.dsl @@ -23,70 +24,78 @@ import net.shadowfacts.kiwidsl.dsl class TestCacaoScreen: CacaoScreen() { init { -// val viewController = object: ViewController() { -// override fun loadView() { -// view = View() -// } -// -// override fun viewDidLoad() { -// super.viewDidLoad() -// -// val stack = view.addSubview(StackView(Axis.VERTICAL, StackView.Distribution.CENTER, spacing = 4.0)).apply { -// backgroundColor = Color.WHITE -// } -// val birch = stack.addArrangedSubview(TextureView(Texture(Identifier("textures/block/birch_log_top.png"), 0, 0, 16, 16))).apply { -// intrinsicContentSize = Size(50.0, 50.0) -// } -// val ninePatch = stack.addArrangedSubview(NinePatchView(NinePatchTexture.PANEL_BG)).apply { -// intrinsicContentSize = Size(75.0, 100.0) -// } -// val red = stack.addArrangedSubview(View()).apply { -// intrinsicContentSize = Size(50.0, 50.0) -// backgroundColor = Color.RED -// } -// -// val label = Label(LiteralText("Test"), wrappingMode = Label.WrappingMode.NO_WRAP).apply { -//// textColor = Color.BLACK -// } -//// stack.addArrangedSubview(label) -// val button = red.addSubview(Button(label)) -// -// view.solver.dsl { -// stack.topAnchor equalTo 0 -// stack.centerXAnchor equalTo window!!.centerXAnchor -// stack.widthAnchor equalTo 100 -// -// -// button.centerXAnchor equalTo red.centerXAnchor -// button.centerYAnchor equalTo red.centerYAnchor -//// label.heightAnchor equalTo 9 -// button.heightAnchor equalTo 20 -// } -// -// } -// } -// addWindow(Window(viewController)) - val viewController = object: ViewController() { + override fun loadView() { + view = View() + } + override fun viewDidLoad() { super.viewDidLoad() - val tabs = arrayOf( - Tab(Label("A"), AViewController()), - Tab(Label("B"), BViewController()), - ) - val tabVC = TabViewController(tabs) - embedChild(tabVC, pinEdges = false) + val stack = view.addSubview(StackView(Axis.VERTICAL, StackView.Distribution.CENTER, spacing = 4.0)).apply { + backgroundColor = Color.WHITE + } + val birch = stack.addArrangedSubview(TextureView(Texture(Identifier("textures/block/birch_log_top.png"), 0, 0, 16, 16))).apply { + intrinsicContentSize = Size(50.0, 50.0) + } + val ninePatch = stack.addArrangedSubview(NinePatchView(NinePatchTexture.PANEL_BG)).apply { + intrinsicContentSize = Size(75.0, 100.0) + } + val red = stack.addArrangedSubview(View()).apply { + intrinsicContentSize = Size(50.0, 50.0) + backgroundColor = Color.RED + } + + val label = Label(LiteralText("Test"), wrappingMode = Label.WrappingMode.NO_WRAP).apply { +// textColor = Color.BLACK + } +// stack.addArrangedSubview(label) + val button = red.addSubview(Button(label)) + + val field = TextField("Test") { + println("new value: ${it.text}") + } + stack.addArrangedSubview(field) view.solver.dsl { - tabVC.view.centerXAnchor equalTo view.centerXAnchor - tabVC.view.centerYAnchor equalTo view.centerYAnchor - tabVC.view.widthAnchor equalTo 200 - tabVC.view.heightAnchor equalTo 150 + stack.topAnchor equalTo 0 + stack.centerXAnchor equalTo window!!.centerXAnchor + stack.widthAnchor equalTo 100 + + + button.centerXAnchor equalTo red.centerXAnchor + button.centerYAnchor equalTo red.centerYAnchor +// label.heightAnchor equalTo 9 + button.heightAnchor equalTo 20 + + field.widthAnchor equalTo stack.widthAnchor + field.heightAnchor equalTo 20 } + } } addWindow(Window(viewController)) + +// val viewController = object: ViewController() { +// override fun viewDidLoad() { +// super.viewDidLoad() +// +// val tabs = arrayOf( +// Tab(Label("A"), AViewController()), +// Tab(Label("B"), BViewController()), +// ) +// val tabVC = TabViewController(tabs) +// embedChild(tabVC, pinEdges = false) +// +// view.solver.dsl { +// tabVC.view.centerXAnchor equalTo view.centerXAnchor +// tabVC.view.centerYAnchor equalTo view.centerYAnchor +// tabVC.view.widthAnchor equalTo 200 +// tabVC.view.heightAnchor equalTo 150 +// } +// } +// } +// addWindow(Window(viewController)) } data class Tab(