Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mouse area layout #64

Merged
merged 2 commits into from
Nov 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 25 additions & 27 deletions core/src/main/scala/eu/joaocosta/interim/InputState.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,8 @@ import scala.annotation.tailrec
/** Input State to be used by the components. */
sealed trait InputState:

/** Mouse X position, from the left */
def mouseX: Int

/** Mouse Y position, from the top */
def mouseY: Int

/** @param mouseDown whether the mouse is pressed */
def mouseDown: Boolean
/** Current Mouse state */
def mouseInput: InputState.MouseInput

/** String generated from the keyboard inputs since the last frame. Usually this will be a single character.
* A `\u0008` character is interpreted as a backspace.
Expand Down Expand Up @@ -45,63 +39,67 @@ sealed trait InputState:
def clip(area: Rect): InputState

object InputState:
/** Creates a new Input State

/** Creates a new InputState.
*
* @param mouseX mouse X position, from the left
* @param mouseY mouse Y position, from the top
* @param mouseDown whether the body is pressed
* @param mousePressed whether the mouse is pressed
* @param keyboardInput
* String generated from the keyboard inputs since the last frame. Usually this will be a single character.
* A `\u0008` character is interpreted as a backspace.
*/
def apply(mouseX: Int, mouseY: Int, mouseDown: Boolean, keyboardInput: String): InputState =
InputState.Current(mouseX, mouseY, mouseDown, keyboardInput: String)
InputState.Current(InputState.MouseInput(mouseX, mouseY, mouseDown), keyboardInput)

/** Mouse position and button state.
*
* @param x mouse X position, from the left
* @param y mouse Y position, from the top
* @param isPressed whether the mouse is pressed
*/
final case class MouseInput(x: Int, y: Int, isPressed: Boolean)

/** Input state at the current point in time
*
* @param mouseX mouse X position, from the left
* @param mouseY mouse Y position, from the top
* @param mouseDown whether the body is pressed
* @param mouseInput the current mouse state
* @param keyboardInput
* String generated from the keyboard inputs since the last frame. Usually this will be a single character.
* A `\u0008` character is interpreted as a backspace.
*/
final case class Current(mouseX: Int, mouseY: Int, mouseDown: Boolean, keyboardInput: String) extends InputState:
final case class Current(mouseInput: InputState.MouseInput, keyboardInput: String) extends InputState:

def clip(area: Rect): InputState.Current =
if (area.isMouseOver(using this)) this
else this.copy(mouseX = Int.MinValue, mouseY = Int.MinValue)
else this.copy(mouseInput = mouseInput.copy(x = Int.MinValue, y = Int.MinValue))

/** Input state at the current point in time and in the previous frame
*
* @param previousMouseX previous mouse X position, from the left
* @param previousMouseY previous mouse Y position, from the top
* @param mouseX mouse X position, from the left
* @param mouseY mouse Y position, from the top
* @param mouseDown whether the body is pressed
* @param mouseDown whether the mouse is pressed
* @param keyboardInput
* String generated from the keyboard inputs since the last frame. Usually this will be a single character.
* A `\u0008` character is interpreted as a backspace.
*/
final case class Historical(
previousMouseX: Int,
previousMouseY: Int,
mouseX: Int,
mouseY: Int,
mouseDown: Boolean,
previousMouseInput: MouseInput,
mouseInput: MouseInput,
keyboardInput: String
) extends InputState:

/** How much the mouse moved in the X axis */
lazy val deltaX: Int =
if (previousMouseX == Int.MinValue || mouseX == Int.MinValue) 0
else mouseX - previousMouseX
if (previousMouseInput.x == Int.MinValue || mouseInput.x == Int.MinValue) 0
else mouseInput.x - previousMouseInput.x

/** How much the mouse moved in the Y axis */
lazy val deltaY: Int =
if (previousMouseY == Int.MinValue || mouseY == Int.MinValue) 0
else mouseY - previousMouseY
if (previousMouseInput.y == Int.MinValue || mouseInput.y == Int.MinValue) 0
else mouseInput.y - previousMouseInput.y

def clip(area: Rect): InputState.Historical =
if (area.isMouseOver(using this)) this
else this.copy(mouseX = Int.MinValue, mouseY = Int.MinValue)
else this.copy(mouseInput = mouseInput.copy(x = Int.MinValue, y = Int.MinValue))
4 changes: 2 additions & 2 deletions core/src/main/scala/eu/joaocosta/interim/InterIm.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ object InterIm extends api.Primitives with api.Layouts with api.Components with
uiContext.currentZ = 0
uiContext.hotItem = None
val historicalInputState = uiContext.pushInputState(inputState)
if (inputState.mouseDown) uiContext.selectedItem = None
if (inputState.mouseInput.isPressed) uiContext.selectedItem = None
// run
val res = run(using historicalInputState, uiContext)
// finish
if (!historicalInputState.mouseDown) uiContext.activeItem = None
if (!historicalInputState.mouseInput.isPressed) uiContext.activeItem = None
// return
(uiContext.getOrderedOps(), res)
2 changes: 1 addition & 1 deletion core/src/main/scala/eu/joaocosta/interim/Rect.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final case class Rect(x: Int, y: Int, w: Int, h: Int):
/** Checks if the mouse is over this area.
*/
def isMouseOver(using inputState: InputState): Boolean =
!(inputState.mouseX < x || inputState.mouseY < y || inputState.mouseX >= x + w || inputState.mouseY >= y + h)
!(inputState.mouseInput.x < x || inputState.mouseInput.y < y || inputState.mouseInput.x >= x + w || inputState.mouseInput.y >= y + h)

/** Translates the area to another position.
*/
Expand Down
11 changes: 5 additions & 6 deletions core/src/main/scala/eu/joaocosta/interim/UiContext.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ final class UiContext private (
): UiContext.ItemStatus =
if (area.isMouseOver && hotItem.forall((hotZ, _) => hotZ <= currentZ))
hotItem = Some(currentZ -> id)
if (!passive && (activeItem == None || activeItem == Some(id)) && inputState.mouseDown)
if (!passive && (activeItem == None || activeItem == Some(id)) && inputState.mouseInput.isPressed)
activeItem = Some(id)
selectedItem = Some(id)
UiContext.ItemStatus(hotItem.map(_._2) == Some(id), activeItem == Some(id), selectedItem == Some(id))
Expand All @@ -33,11 +33,10 @@ final class UiContext private (

private[interim] def pushInputState(inputState: InputState): InputState.Historical =
val history = InputState.Historical(
previousMouseX = previousInputState.map(_.mouseX).getOrElse(Int.MinValue),
previousMouseY = previousInputState.map(_.mouseY).getOrElse(Int.MinValue),
mouseX = inputState.mouseX,
mouseY = inputState.mouseY,
mouseDown = inputState.mouseDown,
previousMouseInput = previousInputState
.map(_.mouseInput)
.getOrElse(InputState.MouseInput(Int.MinValue, Int.MinValue, false)),
mouseInput = inputState.mouseInput,
keyboardInput = inputState.keyboardInput
)
previousInputState = Some(inputState)
Expand Down
10 changes: 5 additions & 5 deletions core/src/main/scala/eu/joaocosta/interim/api/Components.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ trait Components:
val buttonArea = skin.buttonArea(area)
val itemStatus = UiContext.registerItem(id, buttonArea)
skin.renderButton(area, label, itemStatus)
itemStatus.hot && itemStatus.active && summon[InputState].mouseDown == false
itemStatus.hot && itemStatus.active && summon[InputState].mouseInput.isPressed == false

/** Checkbox component. Returns true if it's enabled, false otherwise.
*/
Expand All @@ -46,7 +46,7 @@ trait Components:
val checkboxArea = skin.checkboxArea(area)
val itemStatus = UiContext.registerItem(id, checkboxArea)
skin.renderCheckbox(area, value.get, itemStatus)
if (itemStatus.hot && itemStatus.active && summon[InputState].mouseDown == false) value.modify(!_)
if (itemStatus.hot && itemStatus.active && summon[InputState].mouseInput.isPressed == false) value.modify(!_)
value.get

/** Radio button component. Returns value currently selected.
Expand All @@ -65,7 +65,7 @@ trait Components:
def applyRef(value: Ref[T]): Component[T] =
val buttonArea = skin.buttonArea(area)
val itemStatus = UiContext.registerItem(id, buttonArea)
if (itemStatus.hot && itemStatus.active && summon[InputState].mouseDown == false)
if (itemStatus.hot && itemStatus.active && summon[InputState].mouseInput.isPressed == false)
value := buttonValue
if (value.get == buttonValue) skin.renderButton(area, label, itemStatus.copy(hot = true, active = true))
else skin.renderButton(area, label, itemStatus)
Expand Down Expand Up @@ -118,11 +118,11 @@ trait Components:
skin.renderSlider(area, min, clampedValue, max, itemStatus)
if (itemStatus.active)
if (area.w > area.h)
val mousePos = summon[InputState].mouseX - sliderArea.x - sliderSize / 2
val mousePos = summon[InputState].mouseInput.x - sliderArea.x - sliderSize / 2
val maxPos = sliderArea.w - sliderSize
value := math.max(min, math.min(min + (mousePos * range) / maxPos, max))
else
val mousePos = summon[InputState].mouseY - sliderArea.y - sliderSize / 2
val mousePos = summon[InputState].mouseInput.y - sliderArea.y - sliderSize / 2
val maxPos = sliderArea.h - sliderSize
value := math.max(min, math.min((mousePos * range) / maxPos, max))
value.get
Expand Down
13 changes: 13 additions & 0 deletions core/src/main/scala/eu/joaocosta/interim/api/Layouts.scala
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,16 @@ trait Layouts:
currentW -= absWidth + padding
area.copy(x = areaX, w = absWidth)
body(generateRect)

/** Handle mouse events inside a specified area.
*
* The body receives an optional MouseInput with the coordinates adjusted to be relative
* to the enclosing area.
* If the mouse is outside of the area, the body receives None.
*/
final def mouseArea[T](area: Rect)(body: Option[InputState.MouseInput] => T)(using inputState: InputState): T =
body(
Option.when(area.isMouseOver)(
inputState.mouseInput.copy(x = inputState.mouseInput.x - area.x, y = inputState.mouseInput.y - area.y)
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,10 @@ class LayoutsSpec extends munit.FunSuite:
val expected =
Vector(Rect(10, 10, 16, 100), Rect(78, 10, 32, 100), Rect(34, 10, 36, 100))
assertEquals(areas, expected)

test("mouseArea passes un updated mouse input"):
val inputState = InputState(10, 10, false, "")
val inArea = Layouts.mouseArea(Rect(5, 5, 10, 10))(identity)(using inputState)
assertEquals(inArea, Some(InputState.MouseInput(5, 5, false)))
val outsideArea = Layouts.mouseArea(Rect(5, 5, 1, 1))(identity)(using inputState)
assertEquals(outsideArea, None)
Loading