From 94329553f94f942008783b015b7630013c717b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Sat, 13 Apr 2024 15:36:33 +0100 Subject: [PATCH] Add scratch state to UI context --- .../scala/eu/joaocosta/interim/InterIm.scala | 9 +- .../eu/joaocosta/interim/UiContext.scala | 96 ++++++++++++++----- .../eu/joaocosta/interim/api/Components.scala | 17 ++-- .../joaocosta/interim/skins/SelectSkin.scala | 35 ++++--- .../eu/joaocosta/interim/UiContextSpec.scala | 53 ++++++---- .../joaocosta/interim/api/LayoutsSpec.scala | 12 +-- 6 files changed, 147 insertions(+), 75 deletions(-) diff --git a/core/shared/src/main/scala/eu/joaocosta/interim/InterIm.scala b/core/shared/src/main/scala/eu/joaocosta/interim/InterIm.scala index fefbb6a..f3247be 100644 --- a/core/shared/src/main/scala/eu/joaocosta/interim/InterIm.scala +++ b/core/shared/src/main/scala/eu/joaocosta/interim/InterIm.scala @@ -1,6 +1,6 @@ package eu.joaocosta.interim -import eu.joaocosta.interim.TextLayout.* +import eu.joaocosta.interim.TextLayout._ /** Object with all the DSL operations. * @@ -21,14 +21,15 @@ object InterIm extends api.Primitives with api.Layouts with api.Components with run: (historicalInputState: InputState.Historical, uiContext: UiContext) ?=> T ): (List[RenderOp], T) = // prepare + uiContext.commit() uiContext.ops.clear() uiContext.currentZ = 0 - uiContext.hotItem = None + uiContext.scratchItemState.hotItem = None val historicalInputState = uiContext.pushInputState(inputState) - if (inputState.mouseInput.isPressed) uiContext.selectedItem = None + if (inputState.mouseInput.isPressed) uiContext.scratchItemState.selectedItem = None // run val res = run(using historicalInputState, uiContext) // finish - if (!historicalInputState.mouseInput.isPressed) uiContext.activeItem = None + if (!historicalInputState.mouseInput.isPressed) uiContext.scratchItemState.activeItem = None // return (uiContext.getOrderedOps(), res) diff --git a/core/shared/src/main/scala/eu/joaocosta/interim/UiContext.scala b/core/shared/src/main/scala/eu/joaocosta/interim/UiContext.scala index a356070..5660f58 100644 --- a/core/shared/src/main/scala/eu/joaocosta/interim/UiContext.scala +++ b/core/shared/src/main/scala/eu/joaocosta/interim/UiContext.scala @@ -12,25 +12,30 @@ import scala.collection.mutable final class UiContext private ( private[interim] var currentZ: Int, private[interim] var previousInputState: Option[InputState], - private[interim] var hotItem: Option[(Int, ItemId)], // Item being hovered by the mouse - private[interim] var activeItem: Option[ItemId], // Item being clicked by the mouse - private[interim] var selectedItem: Option[ItemId], // Last item clicked + private[interim] var currentItemState: UiContext.ItemState, + private[interim] var scratchItemState: UiContext.ItemState, private[interim] val ops: mutable.TreeMap[Int, mutable.Queue[RenderOp]] ): + private def getItemStatus(id: ItemId)(using inputState: InputState): UiContext.ItemStatus = + currentItemState.getItemStatus(id) + + private def getScratchItemStatus(id: ItemId)(using inputState: InputState): UiContext.ItemStatus = + scratchItemState.getItemStatus(id) + private def registerItem(id: ItemId, area: Rect, passive: Boolean)(using inputState: InputState ): UiContext.ItemStatus = - if (area.isMouseOver && hotItem.forall((hotZ, _) => hotZ <= currentZ)) - hotItem = Some(currentZ -> id) - if (!passive && (activeItem == None || activeItem == Some(id)) && inputState.mouseInput.isPressed) - activeItem = Some(id) - selectedItem = Some(id) - val hot = hotItem.map(_._2) == Some(id) - val active = activeItem == Some(id) - val selected = selectedItem == Some(id) - val clicked = hot && active && inputState.mouseInput.isPressed == false - UiContext.ItemStatus(hot, active, selected, clicked) + if (area.isMouseOver && scratchItemState.hotItem.forall((hotZ, _) => hotZ <= currentZ)) + scratchItemState.hotItem = Some(currentZ -> id) + if (inputState.mouseInput.isPressed) + if (passive && currentItemState.activeItem == None) + scratchItemState.activeItem = None + scratchItemState.selectedItem = None + else if (!passive && currentItemState.activeItem.forall(_ == id)) + scratchItemState.activeItem = Some(id) + scratchItemState.selectedItem = Some(id) + getItemStatus(id) private[interim] def getOrderedOps(): List[RenderOp] = ops.values.toList.flatten @@ -46,26 +51,27 @@ final class UiContext private ( previousInputState = Some(inputState) history - def this() = this(0, None, None, None, None, new mutable.TreeMap()) + private[interim] def commit(): this.type = + currentItemState = scratchItemState.clone() + this + + def this() = this(0, None, UiContext.ItemState(), UiContext.ItemState(), new mutable.TreeMap()) override def clone(): UiContext = new UiContext( currentZ, previousInputState, - hotItem, - activeItem, - selectedItem, + currentItemState.clone(), + scratchItemState.clone(), ops.clone().mapValuesInPlace((_, v) => v.clone()) ) def fork(): UiContext = - new UiContext(currentZ, previousInputState, hotItem, activeItem, selectedItem, new mutable.TreeMap()) + new UiContext(currentZ, previousInputState, currentItemState, scratchItemState, new mutable.TreeMap()) def ++=(that: UiContext): this.type = // previousInputState stays the same - this.hotItem = that.hotItem - this.activeItem = that.activeItem - this.selectedItem = that.selectedItem + this.scratchItemState = that.scratchItemState.clone() that.ops.foreach: (z, ops) => if (this.ops.contains(z)) this.ops(z) ++= that.ops(z) else this.ops(z) = that.ops(z) @@ -77,6 +83,20 @@ final class UiContext private ( this object UiContext: + private[interim] class ItemState( + var hotItem: Option[(Int, ItemId)] = None, // Item being hovered by the mouse + var activeItem: Option[ItemId] = None, // Item being clicked by the mouse + var selectedItem: Option[ItemId] = None // Last item clicked + ): + def getItemStatus(id: ItemId)(using inputState: InputState): UiContext.ItemStatus = + val hot = hotItem.map(_._2) == Some(id) + val active = activeItem == Some(id) + val selected = selectedItem == Some(id) + val clicked = hot && active && inputState.mouseInput.isPressed == false + UiContext.ItemStatus(hot, active, selected, clicked) + + override def clone(): ItemState = new ItemState(hotItem, activeItem, selectedItem) + /** Status of an item. * * @param hot if the mouse is on top of the item @@ -92,7 +112,12 @@ object UiContext: * * Components register themselves on every frame to update and check their status. * - * Note that this is only required when creating new components. + * This is only required when creating new components. If you are using the premade components + * you do not need to call this. + * + * Also of note is that this method returns the status from computed in the previous iteration, + * as that's the only consistent information. + * If you need the status as it's being computed, check [[getScratchItemStatus]]. * * @param id Item ID to register * @param area the area of this component @@ -106,6 +131,33 @@ object UiContext: ): UiContext.ItemStatus = uiContext.registerItem(id, area, passive) + /** Checks the status of a component from the previous UI computation without registering it. + * + * This method can be used if one needs to check the status of an item in the previous iteration + * without registering it. + * @param id Item ID to register + * @return the item status of the component. + */ + def getItemStatus(id: ItemId)(using + uiContext: UiContext, + inputState: InputState + ): UiContext.ItemStatus = + uiContext.getItemStatus(id) + + /** Checks the status of a component at the current point in the UI computation without registering it. + * + * This method can be used if one needs to check the status of an item in the middle of the current iteration. + * + * This is can return inconsistent state, and is only recommended for unit tests or debugging. + * @param id Item ID to register + * @return the item status of the component. + */ + def getScratchItemStatus(id: ItemId)(using + uiContext: UiContext, + inputState: InputState + ): UiContext.ItemStatus = + uiContext.getScratchItemStatus(id) + /** Applies the operations in a code block at a specified z-index * (higher z-indices show on front of lower z-indices). */ diff --git a/core/shared/src/main/scala/eu/joaocosta/interim/api/Components.scala b/core/shared/src/main/scala/eu/joaocosta/interim/api/Components.scala index 3616b53..7fbf29c 100644 --- a/core/shared/src/main/scala/eu/joaocosta/interim/api/Components.scala +++ b/core/shared/src/main/scala/eu/joaocosta/interim/api/Components.scala @@ -1,7 +1,7 @@ package eu.joaocosta.interim.api -import eu.joaocosta.interim.* -import eu.joaocosta.interim.skins.* +import eu.joaocosta.interim._ +import eu.joaocosta.interim.skins._ /** Object containing the default components. * @@ -102,12 +102,13 @@ trait Components: if (value.get.isOpen) value.modifyIf(!itemStatus.selected)(_.close) val selectableLabels = labels.drop(if (undefinedFirstValue) 1 else 0) - selectableLabels.zipWithIndex - .foreach: (label, idx) => - val selectOptionArea = skin.selectOptionArea(area, idx) - val optionStatus = UiContext.registerItem(id |> idx, selectOptionArea) - skin.renderSelectOption(area, idx, selectableLabels, optionStatus) - if (optionStatus.active) value := PanelState.closed(if (undefinedFirstValue) idx + 1 else idx) + Primitives.onTop: + selectableLabels.zipWithIndex + .foreach: (label, idx) => + val selectOptionArea = skin.selectOptionArea(area, idx) + val optionStatus = UiContext.registerItem(id |> idx, selectOptionArea) + skin.renderSelectOption(area, idx, selectableLabels, optionStatus) + if (optionStatus.active) value := PanelState.closed(if (undefinedFirstValue) idx + 1 else idx) /** Slider component. Returns the current position of the slider, between min and max. * diff --git a/core/shared/src/main/scala/eu/joaocosta/interim/skins/SelectSkin.scala b/core/shared/src/main/scala/eu/joaocosta/interim/skins/SelectSkin.scala index d992fdb..3aac2c8 100644 --- a/core/shared/src/main/scala/eu/joaocosta/interim/skins/SelectSkin.scala +++ b/core/shared/src/main/scala/eu/joaocosta/interim/skins/SelectSkin.scala @@ -1,7 +1,7 @@ package eu.joaocosta.interim.skins -import eu.joaocosta.interim.* -import eu.joaocosta.interim.api.Primitives.* +import eu.joaocosta.interim._ +import eu.joaocosta.interim.api.Primitives._ trait SelectSkin: def selectBoxArea(area: Rect): Rect @@ -56,22 +56,21 @@ object SelectSkin extends DefaultSkin: ): Unit = val selectOptionArea = this.selectOptionArea(area, value) val optionLabel = labels.applyOrElse(value, _ => "") - onTop: - itemStatus match - case UiContext.ItemStatus(_, _, true, _) | UiContext.ItemStatus(_, true, _, _) => - rectangle(selectOptionArea, colorScheme.primaryHighlight) - case UiContext.ItemStatus(true, _, _, _) => - rectangle(selectOptionArea, colorScheme.secondaryHighlight) - case _ => - rectangle(selectOptionArea, colorScheme.secondary) - text( - selectOptionArea.shrink(padding), - colorScheme.text, - optionLabel, - font, - TextLayout.HorizontalAlignment.Left, - TextLayout.VerticalAlignment.Center - ) + itemStatus match + case UiContext.ItemStatus(_, _, true, _) | UiContext.ItemStatus(_, true, _, _) => + rectangle(selectOptionArea, colorScheme.primaryHighlight) + case UiContext.ItemStatus(true, _, _, _) => + rectangle(selectOptionArea, colorScheme.secondaryHighlight) + case _ => + rectangle(selectOptionArea, colorScheme.secondary) + text( + selectOptionArea.shrink(padding), + colorScheme.text, + optionLabel, + font, + TextLayout.HorizontalAlignment.Left, + TextLayout.VerticalAlignment.Center + ) val lightDefault: Default = Default( padding = 2, diff --git a/core/shared/src/test/scala/eu/joaocosta/interim/UiContextSpec.scala b/core/shared/src/test/scala/eu/joaocosta/interim/UiContextSpec.scala index 3fffa4c..08c7b21 100644 --- a/core/shared/src/test/scala/eu/joaocosta/interim/UiContextSpec.scala +++ b/core/shared/src/test/scala/eu/joaocosta/interim/UiContextSpec.scala @@ -5,45 +5,59 @@ class UiContextSpec extends munit.FunSuite: test("registerItem should not mark an item not under the cursor"): given uiContext: UiContext = new UiContext() given inputState: InputState = InputState(0, 0, false, "") - val itemStatus = UiContext.registerItem(1, Rect(1, 1, 10, 10)) + + UiContext.registerItem(1, Rect(1, 1, 10, 10)) + assertEquals(uiContext.scratchItemState.hotItem, None) + assertEquals(uiContext.scratchItemState.activeItem, None) + assertEquals(uiContext.scratchItemState.selectedItem, None) + + val itemStatus = UiContext.getScratchItemStatus(1) assertEquals(itemStatus.hot, false) assertEquals(itemStatus.active, false) assertEquals(itemStatus.selected, false) assertEquals(itemStatus.clicked, false) - assertEquals(uiContext.hotItem, None) - assertEquals(uiContext.activeItem, None) - assertEquals(uiContext.selectedItem, None) test("registerItem should mark an item under the cursor as hot"): given uiContext: UiContext = new UiContext() given inputState: InputState = InputState(5, 5, false, "") - val itemStatus = UiContext.registerItem(1, Rect(1, 1, 10, 10)) + + UiContext.registerItem(1, Rect(1, 1, 10, 10)) + assertEquals(uiContext.scratchItemState.hotItem, Some(0 -> 1)) + assertEquals(uiContext.scratchItemState.activeItem, None) + assertEquals(uiContext.scratchItemState.selectedItem, None) + + val itemStatus = UiContext.getScratchItemStatus(1) assertEquals(itemStatus.hot, true) assertEquals(itemStatus.active, false) assertEquals(itemStatus.selected, false) assertEquals(itemStatus.clicked, false) - assertEquals(uiContext.hotItem, Some(0 -> 1)) - assertEquals(uiContext.activeItem, None) - assertEquals(uiContext.selectedItem, None) test("registerItem should mark a clicked item as active and focused"): given uiContext: UiContext = new UiContext() given inputState: InputState = InputState(5, 5, true, "") - val itemStatus = UiContext.registerItem(1, Rect(1, 1, 10, 10)) + + UiContext.registerItem(1, Rect(1, 1, 10, 10)) + assertEquals(uiContext.scratchItemState.hotItem, Some(0 -> 1)) + assertEquals(uiContext.scratchItemState.activeItem, Some(1)) + assertEquals(uiContext.scratchItemState.selectedItem, Some(1)) + + val itemStatus = UiContext.getScratchItemStatus(1) assertEquals(itemStatus.hot, true) assertEquals(itemStatus.active, true) assertEquals(itemStatus.selected, true) assertEquals(itemStatus.clicked, false) - assertEquals(uiContext.hotItem, Some(0 -> 1)) - assertEquals(uiContext.activeItem, Some(1)) - assertEquals(uiContext.selectedItem, Some(1)) test("registerItem should mark a clicked item as clicked once the mouse is released"): val uiContext: UiContext = new UiContext() val inputState1: InputState = InputState(5, 5, true, "") UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState1) + uiContext.commit() + val inputState2: InputState = InputState(5, 5, false, "") - val itemStatus = UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState2) + UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState2) + uiContext.commit() + + val itemStatus = UiContext.getItemStatus(1)(using uiContext, inputState2) assertEquals(itemStatus.hot, true) assertEquals(itemStatus.active, true) assertEquals(itemStatus.selected, true) @@ -53,16 +67,21 @@ class UiContextSpec extends munit.FunSuite: val uiContext = new UiContext() val inputState1 = InputState(5, 5, true, "") UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState1) + uiContext.commit() + val inputState2 = InputState(20, 20, true, "") UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState2) - val itemStatus = UiContext.registerItem(2, Rect(15, 15, 10, 10))(using uiContext, inputState2) + UiContext.registerItem(2, Rect(15, 15, 10, 10))(using uiContext, inputState2) + assertEquals(uiContext.scratchItemState.hotItem, Some(0 -> 2)) + assertEquals(uiContext.scratchItemState.activeItem, Some(1)) + assertEquals(uiContext.scratchItemState.selectedItem, Some(1)) + uiContext.commit() + + val itemStatus = UiContext.getItemStatus(2)(using uiContext, inputState2) assertEquals(itemStatus.hot, true) assertEquals(itemStatus.active, false) assertEquals(itemStatus.selected, false) assertEquals(itemStatus.clicked, false) - assertEquals(uiContext.hotItem, Some(0 -> 2)) - assertEquals(uiContext.activeItem, Some(1)) - assertEquals(uiContext.selectedItem, Some(1)) test("fork should create a new UiContext with no ops, and merge them back with ++="): val uiContext: UiContext = new UiContext() diff --git a/core/shared/src/test/scala/eu/joaocosta/interim/api/LayoutsSpec.scala b/core/shared/src/test/scala/eu/joaocosta/interim/api/LayoutsSpec.scala index f8e7512..bedbc8c 100644 --- a/core/shared/src/test/scala/eu/joaocosta/interim/api/LayoutsSpec.scala +++ b/core/shared/src/test/scala/eu/joaocosta/interim/api/LayoutsSpec.scala @@ -14,17 +14,17 @@ class LayoutsSpec extends munit.FunSuite: test("clip ignores input outside the clip area"): given uiContext: UiContext = new UiContext() given inputState: InputState = InputState(5, 5, false, "") - val itemStatus = - Layouts.clip(Rect(10, 10, 10, 10)): - UiContext.registerItem(1, Rect(0, 0, 15, 15)) + Layouts.clip(Rect(10, 10, 10, 10)): + UiContext.registerItem(1, Rect(0, 0, 15, 15)) + val itemStatus = UiContext.getScratchItemStatus(1) assertEquals(itemStatus.hot, false) test("clip considers input inside the clip area"): given uiContext: UiContext = new UiContext() given inputState: InputState = InputState(12, 12, false, "") - val itemStatus = - Layouts.clip(Rect(10, 10, 10, 10)): - UiContext.registerItem(1, Rect(0, 0, 15, 15)) + Layouts.clip(Rect(10, 10, 10, 10)): + UiContext.registerItem(1, Rect(0, 0, 15, 15)) + val itemStatus = UiContext.getScratchItemStatus(1) assertEquals(itemStatus.hot, true) test("grid correctly lays out elements in a grid"):