Skip to content

Commit

Permalink
Merge pull request #109 from JD557/one-frame-delay
Browse files Browse the repository at this point in the history
Add scratch state to UI context
  • Loading branch information
JD557 committed Apr 13, 2024
2 parents fd679de + 9432955 commit 5c68f17
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 75 deletions.
9 changes: 5 additions & 4 deletions core/shared/src/main/scala/eu/joaocosta/interim/InterIm.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package eu.joaocosta.interim

import eu.joaocosta.interim.TextLayout.*
import eu.joaocosta.interim.TextLayout._

/** Object with all the DSL operations.
*
Expand All @@ -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)
96 changes: 74 additions & 22 deletions core/shared/src/main/scala/eu/joaocosta/interim/UiContext.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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).
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 36 additions & 17 deletions core/shared/src/test/scala/eu/joaocosta/interim/UiContextSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down

0 comments on commit 5c68f17

Please sign in to comment.