diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/component/Component.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/component/Component.scala new file mode 100644 index 000000000..3c0baeefc --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/component/Component.scala @@ -0,0 +1,56 @@ +package indigoextras.ui.component + +import indigo.* +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +/** A typeclass that confirms that some type `A` can be used as a `Component` provides the necessary operations for that + * type to act as a component. + */ +trait Component[A, ReferenceData]: + + /** The position and size of the component + */ + def bounds(reference: ReferenceData, model: A): Bounds + + /** Update this componenets model. + */ + def updateModel( + context: UIContext[ReferenceData], + model: A + ): GlobalEvent => Outcome[A] + + /** Produce a renderable output for this component, based on the component's model. + */ + def present( + context: UIContext[ReferenceData], + model: A + ): Outcome[Layer] + + /** Used internally to instruct the component that the layout has changed in some way, and that it should + * reflow/refresh it's contents - whatever that means in the context of this component type. + */ + def refresh(reference: ReferenceData, model: A, parentDimensions: Dimensions): A + +object Component: + + given [ReferenceData]: Component[Unit, ReferenceData] = + new Component[Unit, ReferenceData]: + def bounds(reference: ReferenceData, model: Unit): Bounds = + Bounds.zero + + def updateModel( + context: UIContext[ReferenceData], + model: Unit + ): GlobalEvent => Outcome[Unit] = + _ => Outcome(model) + + def present( + context: UIContext[ReferenceData], + model: Unit + ): Outcome[Layer] = + Outcome(Layer.empty) + + def refresh(reference: ReferenceData, model: Unit, parentDimensions: Dimensions): Unit = + () diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Button.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Button.scala new file mode 100644 index 000000000..3a22faec7 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Button.scala @@ -0,0 +1,516 @@ +package indigoextras.ui.components + +import indigo.* +import indigo.syntax.* +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +import datatypes.BoundsType + +/** The Button `Component` allows you to create buttons for your UI. Buttons also support drag options, and can be used + * for making things like resizing controls. + */ +final case class Button[ReferenceData]( + bounds: Bounds, + state: ButtonState, + up: (Coords, Bounds, ReferenceData) => Outcome[Layer], + over: Option[(Coords, Bounds, ReferenceData) => Outcome[Layer]], + down: Option[(Coords, Bounds, ReferenceData) => Outcome[Layer]], + click: ReferenceData => Batch[GlobalEvent], + press: ReferenceData => Batch[GlobalEvent], + release: ReferenceData => Batch[GlobalEvent], + drag: (ReferenceData, DragData) => Batch[GlobalEvent], + boundsType: BoundsType[ReferenceData, Unit], + isDown: Boolean, + dragOptions: DragOptions, + dragStart: Option[DragData] +): + val isDragged: Boolean = dragStart.isDefined + + def presentUp( + up: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Button[ReferenceData] = + this.copy(up = up) + + def presentOver( + over: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Button[ReferenceData] = + this.copy(over = Option(over)) + + def presentDown( + down: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Button[ReferenceData] = + this.copy(down = Option(down)) + + def onClick(events: ReferenceData => Batch[GlobalEvent]): Button[ReferenceData] = + this.copy(click = events) + def onClick(events: Batch[GlobalEvent]): Button[ReferenceData] = + onClick(_ => events) + def onClick(events: GlobalEvent*): Button[ReferenceData] = + onClick(Batch.fromSeq(events)) + + def onPress(events: ReferenceData => Batch[GlobalEvent]): Button[ReferenceData] = + this.copy(press = events) + def onPress(events: Batch[GlobalEvent]): Button[ReferenceData] = + onPress(_ => events) + def onPress(events: GlobalEvent*): Button[ReferenceData] = + onPress(Batch.fromSeq(events)) + + def onRelease(events: ReferenceData => Batch[GlobalEvent]): Button[ReferenceData] = + this.copy(release = events) + def onRelease(events: Batch[GlobalEvent]): Button[ReferenceData] = + onRelease(_ => events) + def onRelease(events: GlobalEvent*): Button[ReferenceData] = + onRelease(Batch.fromSeq(events)) + + def onDrag( + events: (ReferenceData, DragData) => Batch[GlobalEvent] + ): Button[ReferenceData] = + this.copy(drag = events) + def onDrag(events: Batch[GlobalEvent]): Button[ReferenceData] = + onDrag((_, _) => events) + def onDrag(events: GlobalEvent*): Button[ReferenceData] = + onDrag(Batch.fromSeq(events)) + + def withDragOptions(value: DragOptions): Button[ReferenceData] = + this.copy(dragOptions = value) + def makeDraggable: Button[ReferenceData] = + withDragOptions(dragOptions.withMode(DragMode.Drag)) + def reportDrag: Button[ReferenceData] = + withDragOptions(dragOptions.withMode(DragMode.ReportDrag)) + def notDraggable: Button[ReferenceData] = + withDragOptions(dragOptions.withMode(DragMode.None)) + + def withDragConstrain(value: DragConstrain): Button[ReferenceData] = + this.copy(dragOptions = dragOptions.withConstraints(value)) + def constrainDragTo(bounds: Bounds): Button[ReferenceData] = + withDragConstrain(DragConstrain.To(bounds)) + def constrainDragVertically: Button[ReferenceData] = + withDragConstrain(DragConstrain.Vertical) + def constrainDragVertically(from: Int, to: Int, x: Int): Button[ReferenceData] = + withDragConstrain(DragConstrain.vertical(from, to, x)) + def constrainDragHorizontally: Button[ReferenceData] = + withDragConstrain(DragConstrain.Horizontal) + def constrainDragHorizontally(from: Int, to: Int, y: Int): Button[ReferenceData] = + withDragConstrain(DragConstrain.horizontal(from, to, y)) + + def withDragArea(value: DragArea): Button[ReferenceData] = + this.copy(dragOptions = dragOptions.withArea(value)) + def noDragArea: Button[ReferenceData] = + withDragArea(DragArea.None) + def fixedDragArea(bounds: Bounds): Button[ReferenceData] = + withDragArea(DragArea.Fixed(bounds)) + def inheritDragArea: Button[ReferenceData] = + withDragArea(DragArea.Inherit) + + def withBoundsType(value: BoundsType[ReferenceData, Unit]): Button[ReferenceData] = + this.copy(boundsType = value) + + def toHitArea: HitArea[ReferenceData] = + HitArea( + bounds, + state, + click, + press, + release, + drag, + boundsType, + isDown, + dragOptions, + dragStart + ) + +object Button: + + /** Minimal button constructor with custom rendering function + */ + def apply[ReferenceData](boundsType: BoundsType[ReferenceData, Unit])( + present: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Button[ReferenceData] = + Button( + Bounds.zero, + ButtonState.Up, + present, + None, + None, + _ => Batch.empty, + _ => Batch.empty, + _ => Batch.empty, + (_, _) => Batch.empty, + boundsType, + isDown = false, + dragOptions = DragOptions.default, + dragStart = None + ) + + /** Minimal button constructor with custom rendering function + */ + def apply[ReferenceData](bounds: Bounds)( + present: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Button[ReferenceData] = + Button( + bounds, + ButtonState.Up, + present, + None, + None, + _ => Batch.empty, + _ => Batch.empty, + _ => Batch.empty, + (_, _) => Batch.empty, + datatypes.BoundsType.Fixed(bounds), + isDown = false, + dragOptions = DragOptions.default, + dragStart = None + ) + + /** Minimal button constructor with custom rendering function and dynamic sizing + */ + def apply[ReferenceData](calculateBounds: ReferenceData => Bounds)( + present: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Button[ReferenceData] = + Button( + Bounds.zero, + ButtonState.Up, + present, + None, + None, + _ => Batch.empty, + _ => Batch.empty, + _ => Batch.empty, + (_, _) => Batch.empty, + datatypes.BoundsType.Calculated(calculateBounds), + isDown = false, + dragOptions = DragOptions.default, + dragStart = None + ) + + given [ReferenceData]: Component[Button[ReferenceData], ReferenceData] with + def bounds(reference: ReferenceData, model: Button[ReferenceData]): Bounds = + model.bounds + + def updateModel( + context: UIContext[ReferenceData], + model: Button[ReferenceData] + ): GlobalEvent => Outcome[Button[ReferenceData]] = + case FrameTick => + val newBounds = + model.boundsType match + case datatypes.BoundsType.Fixed(bounds) => + bounds + + case datatypes.BoundsType.Calculated(calculate) => + calculate(context.reference, ()) + + case _ => + model.bounds + + def decideState: ButtonState = + if model.isDown then ButtonState.Down + else if newBounds + .moveBy(context.bounds.coords + context.additionalOffset) + .contains(context.pointerCoords) + then + if context.frame.input.pointers.isLeftDown then ButtonState.Down + else ButtonState.Over + else ButtonState.Up + + Outcome( + model.copy( + state = + if context.isActive || model.isDragged then decideState + else ButtonState.Up, + bounds = newBounds + ) + ) + + case _: PointerEvent.Click + if context.isActive && model.bounds + .moveBy(context.bounds.coords + context.additionalOffset) + .contains(context.pointerCoords) => + Outcome(model.copy(state = ButtonState.Up, isDown = false, dragStart = None)) + .addGlobalEvents(model.click(context.reference)) + + case _: PointerEvent.Down + if context.isActive && model.bounds + .moveBy(context.bounds.coords + context.additionalOffset) + .contains(context.pointerCoords) => + Outcome(model.copy(state = ButtonState.Down, isDown = true, dragStart = None)) + .addGlobalEvents(model.press(context.reference)) + + case _: PointerEvent.Up + if context.isActive && model.bounds + .moveBy(context.bounds.coords + context.additionalOffset) + .contains(context.pointerCoords) => + Outcome(model.copy(state = ButtonState.Up, isDown = false, dragStart = None)) + .addGlobalEvents(model.release(context.reference)) + + case _: PointerEvent.Up => + // Released Outside. + Outcome(model.copy(state = ButtonState.Up, isDown = false, dragStart = None)) + + case _: PointerEvent.Move + if (context.isActive || model.isDragged) && model.isDown && model.dragOptions.isDraggable => + val dragToCoords = + model.dragOptions.constrainCoords(context.pointerCoords, context.bounds) + + def makeDragData = + DragData( + start = dragToCoords, + position = dragToCoords, + offset = dragToCoords - context.bounds.coords, + delta = Coords.zero + ) + + val newDragStart = + if model.dragStart.isEmpty then makeDragData + else model.dragStart.getOrElse(makeDragData) + + val updatedDragData = + newDragStart.copy( + position = dragToCoords, + delta = dragToCoords - newDragStart.start + ) + + Outcome( + model.copy(dragStart = Option(updatedDragData)) + ).addGlobalEvents( + model.drag(context.reference, updatedDragData) + ) + + case _ => + Outcome(model) + + def present( + context: UIContext[ReferenceData], + model: Button[ReferenceData] + ): Outcome[Layer] = + val b = + if model.isDragged && model.dragOptions.followPointer then + val dragCoords = + model.dragOptions.constrainCoords(context.pointerCoords, context.bounds) + + model.bounds.moveBy( + model.dragStart.map(dd => dragCoords - dd.start).getOrElse(Coords.zero) + ) + else model.bounds + + model.state match + case ButtonState.Up => + model + .up(context.bounds.coords, b, context.reference) + + case ButtonState.Over => + model.over + .getOrElse(model.up)(context.bounds.coords, b, context.reference) + + case ButtonState.Down => + model.down + .getOrElse(model.up)(context.bounds.coords, b, context.reference) + + def refresh( + reference: ReferenceData, + model: Button[ReferenceData], + parentDimensions: Dimensions + ): Button[ReferenceData] = + model.boundsType match + case datatypes.BoundsType.Fixed(bounds) => + model.copy( + bounds = bounds + ) + + case datatypes.BoundsType.Calculated(calculate) => + model + + case datatypes.BoundsType.FillWidth(height, padding) => + model.copy( + bounds = Bounds( + parentDimensions.width - padding.left - padding.right, + height + ) + ) + + case datatypes.BoundsType.FillHeight(width, padding) => + model.copy( + bounds = Bounds( + width, + parentDimensions.height - padding.top - padding.bottom + ) + ) + + case datatypes.BoundsType.Fill(padding) => + model.copy( + bounds = Bounds( + parentDimensions.width - padding.left - padding.right, + parentDimensions.height - padding.top - padding.bottom + ) + ) + +enum ButtonState derives CanEqual: + case Up, Over, Down + + def isUp: Boolean = + this match + case Up => true + case _ => false + + def is: Boolean = + this match + case Over => true + case _ => false + + def isDown: Boolean = + this match + case Down => true + case _ => false + +final case class DragOptions(mode: DragMode, contraints: DragConstrain, area: DragArea): + def withConstraints(constrain: DragConstrain): DragOptions = + this.copy(contraints = constrain) + + def isDraggable: Boolean = + mode.isDraggable + + def followPointer: Boolean = + mode.followPointer + + def withArea(value: DragArea): DragOptions = + this.copy(area = value) + def noDragArea: DragOptions = + withArea(DragArea.None) + def fixedDragArea(bounds: Bounds): DragOptions = + this.copy(area = DragArea.Fixed(bounds)) + def inheritDragArea: DragOptions = + this.copy(area = DragArea.Inherit) + + def withMode(mode: DragMode): DragOptions = + this.copy(mode = mode) + + def constrainCoords(pointerCoords: Coords, parentBounds: Bounds): Coords = + val areaConstrained = + area match + case DragArea.None => + pointerCoords + + case DragArea.Fixed(relativeBounds) => + val bounds = + relativeBounds.moveTo(parentBounds.topLeft) + + Coords( + if pointerCoords.x < bounds.left then bounds.left + else if pointerCoords.x > bounds.right then bounds.right + else pointerCoords.x, + if pointerCoords.y < bounds.top then bounds.top + else if pointerCoords.y > bounds.bottom then bounds.bottom + else pointerCoords.y + ) + + case DragArea.Inherit => + Coords( + if pointerCoords.x < parentBounds.left then parentBounds.left + else if pointerCoords.x > parentBounds.right then parentBounds.right + else pointerCoords.x, + if pointerCoords.y < parentBounds.top then parentBounds.top + else if pointerCoords.y > parentBounds.bottom - 1 then parentBounds.bottom - 1 + else pointerCoords.y + ) + + contraints match + case DragConstrain.To(bounds) => + Coords( + if areaConstrained.x < bounds.left then bounds.left + else if areaConstrained.x > bounds.right then bounds.right + else areaConstrained.x, + if areaConstrained.y < bounds.top then bounds.top + else if areaConstrained.y > bounds.bottom - 1 then bounds.bottom - 1 + else areaConstrained.y + ) + + case DragConstrain.Horizontal => + Coords(areaConstrained.x, 0) + + case DragConstrain.Vertical => + Coords(0, areaConstrained.y) + + case DragConstrain.None => + areaConstrained + +object DragOptions: + + val default: DragOptions = + DragOptions(DragMode.None, DragConstrain.None, DragArea.None) + +/** Describes the drag behaviour of the component + */ +enum DragMode derives CanEqual: + + /** Cannot be dragged + */ + case None + + /** The drag movement is tracked and reported (via events), but the component is rendered as normal, i.e. does not + * move. + */ + case ReportDrag + + /** The component follows the pointer movement as well as emitting relevant events. + */ + case Drag + + def isDraggable: Boolean = + this match + case None => false + case ReportDrag => true + case Drag => true + + def followPointer: Boolean = + this match + case None => false + case ReportDrag => false + case Drag => true + +/** Data about the ongoing drag operation, all positions are in screen space. + * + * @param start + * The position of the pointer when the drag started + * @param position + * The current position of the pointer + * @param offset + * The start position relative to the component + * @param delta + * The change in position since the drag started + */ +final case class DragData( + start: Coords, + position: Coords, + offset: Coords, + delta: Coords +) + +enum DragConstrain derives CanEqual: + case None + case Horizontal + case Vertical + case To(bounds: Bounds) + +object DragConstrain: + + def none: DragConstrain = + DragConstrain.None + + def to(bounds: Bounds): DragConstrain = + DragConstrain.To(bounds) + + def vertical(from: Int, to: Int, x: Int): DragConstrain = + DragConstrain.To(Bounds(x, from, 0, to)) + + def horizontal(from: Int, to: Int, y: Int): DragConstrain = + DragConstrain.To(Bounds(from, y, to, 0)) + +enum DragArea derives CanEqual: + case None + case Fixed(bounds: Bounds) + case Inherit diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ComponentGroup.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ComponentGroup.scala new file mode 100644 index 000000000..6493959e4 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ComponentGroup.scala @@ -0,0 +1,350 @@ +package indigoextras.ui.components + +import indigo.* +import indigoextras.ui.component.* +import indigoextras.ui.components.datatypes.Anchor +import indigoextras.ui.components.datatypes.BoundsMode +import indigoextras.ui.components.datatypes.ComponentEntry +import indigoextras.ui.components.datatypes.ComponentId +import indigoextras.ui.components.datatypes.ComponentLayout +import indigoextras.ui.components.datatypes.ContainerLikeFunctions +import indigoextras.ui.components.datatypes.FitMode +import indigoextras.ui.components.datatypes.Overflow +import indigoextras.ui.components.datatypes.Padding +import indigoextras.ui.datatypes.* + +/** Describes a fixed arrangement of components, manages their layout, which may include anchored components. + */ +final case class ComponentGroup[ReferenceData] private[components] ( + boundsMode: BoundsMode, + layout: ComponentLayout, + components: Batch[ComponentEntry[?, ReferenceData]], + background: Bounds => Layer, + // Internal + dimensions: Dimensions, // The actual cached dimensions of the group + contentBounds: Bounds, // The calculated and cached bounds of the content + dirty: Boolean // Whether the groups content needs to be refreshed, and it's bounds recalculated +): + + private def addSingle[A](entry: A)(using + c: Component[A, ReferenceData] + ): ComponentGroup[ReferenceData] = + this.copy( + components = components :+ ComponentEntry(ComponentId.None, Coords.zero, entry, c, None), + dirty = true + ) + + def add[A](entry: A)(using Component[A, ReferenceData]): ComponentGroup[ReferenceData] = + addSingle(entry) + + def addOptional[A](entry: Option[A])(using + Component[A, ReferenceData] + ): ComponentGroup[ReferenceData] = + entry match + case None => this + case Some(a) => addSingle(a) + + def addConditional[A](condition: Boolean)(entry: A)(using + Component[A, ReferenceData] + ): ComponentGroup[ReferenceData] = + if condition then addSingle(entry) else this + + def add[A](entries: Batch[A])(using + c: Component[A, ReferenceData] + ): ComponentGroup[ReferenceData] = + entries.foldLeft(this.copy(dirty = true)) { case (acc, next) => acc.addSingle(next) } + def add[A](entries: A*)(using c: Component[A, ReferenceData]): ComponentGroup[ReferenceData] = + add(Batch.fromSeq(entries)) + + private def _anchor[A](entry: A, anchor: Anchor)(using + c: Component[A, ReferenceData] + ): ComponentGroup[ReferenceData] = + this.copy( + components = components :+ ComponentEntry(ComponentId.None, Coords.zero, entry, c, Option(anchor)), + dirty = true + ) + + def anchor[A](entry: A, anchor: Anchor)(using + c: Component[A, ReferenceData] + ): ComponentGroup[ReferenceData] = + _anchor(entry, anchor) + + def anchorOptional[A](entry: Option[A], anchor: Anchor)(using + c: Component[A, ReferenceData] + ): ComponentGroup[ReferenceData] = + entry match + case None => this + case Some(a) => _anchor(a, anchor) + + def anchorConditional[A](condition: Boolean)(entry: A, anchor: Anchor)(using + c: Component[A, ReferenceData] + ): ComponentGroup[ReferenceData] = + if condition then _anchor(entry, anchor) else this + + def withDimensions(value: Dimensions): ComponentGroup[ReferenceData] = + this.copy(dimensions = value, dirty = true) + + def withBoundsMode(value: BoundsMode): ComponentGroup[ReferenceData] = + this.copy(boundsMode = value, dirty = true) + + def withLayout(value: ComponentLayout): ComponentGroup[ReferenceData] = + this.copy(layout = value, dirty = true) + + def withBackground(present: Bounds => Layer): ComponentGroup[ReferenceData] = + this.copy(background = present, dirty = true) + +object ComponentGroup: + + def apply[ReferenceData](): ComponentGroup[ReferenceData] = + ComponentGroup( + indigoextras.ui.components.datatypes.BoundsMode.default, + ComponentLayout.Horizontal(Padding.zero, Overflow.Wrap), + Batch.empty, + _ => Layer.empty, + Dimensions.zero, + Bounds.zero, + dirty = true + ) + + def apply[ReferenceData](boundsMode: BoundsMode): ComponentGroup[ReferenceData] = + ComponentGroup( + boundsMode, + ComponentLayout.Horizontal(Padding.zero, Overflow.Wrap), + Batch.empty, + _ => Layer.empty, + Dimensions.zero, + Bounds.zero, + dirty = true + ) + + def apply[ReferenceData](dimensions: Dimensions): ComponentGroup[ReferenceData] = + ComponentGroup( + indigoextras.ui.components.datatypes.BoundsMode.fixed(dimensions), + ComponentLayout.Horizontal(Padding.zero, Overflow.Wrap), + Batch.empty, + _ => Layer.empty, + dimensions, + Bounds.zero, + dirty = true + ) + + def apply[ReferenceData](width: Int, height: Int): ComponentGroup[ReferenceData] = + ComponentGroup(Dimensions(width, height)) + + given [ReferenceData]: Component[ComponentGroup[ReferenceData], ReferenceData] with + + def bounds(reference: ReferenceData, model: ComponentGroup[ReferenceData]): Bounds = + Bounds(model.dimensions) + + def updateModel( + context: UIContext[ReferenceData], + model: ComponentGroup[ReferenceData] + ): GlobalEvent => Outcome[ComponentGroup[ReferenceData]] = + case FrameTick => + // Sub-groups will naturally refresh themselves as needed + updateComponents(context, model)(FrameTick).map { updated => + if model.dirty then refresh(context.reference, updated, context.bounds.dimensions) + else updated + } + + case e => + updateComponents(context, model)(e) + + private def updateComponents[StartupData, ContextData]( + context: UIContext[ReferenceData], + model: ComponentGroup[ReferenceData] + ): GlobalEvent => Outcome[ComponentGroup[ReferenceData]] = + e => + model.components + .map { c => + c.component + .updateModel( + context + .copy(bounds = Bounds(context.bounds.moveBy(c.offset).coords, model.dimensions)), + c.model + )(e) + .map { updated => + c.copy(model = updated) + } + } + .sequence + .map { updatedComponents => + model.copy( + components = updatedComponents + ) + } + + def present( + context: UIContext[ReferenceData], + model: ComponentGroup[ReferenceData] + ): Outcome[Layer] = + ContainerLikeFunctions.present(context, model.dimensions, model.components).map { components => + val background = model.background(Bounds(context.bounds.coords, model.dimensions)) + Layer.Stack(background, components) + } + + def refresh( + reference: ReferenceData, + model: ComponentGroup[ReferenceData], + parentDimensions: Dimensions + ): ComponentGroup[ReferenceData] = + + // First, calculate the bounds without content + val boundsWithoutContent = + model.boundsMode match + + // Available + + case BoundsMode(FitMode.Available, FitMode.Available) => + parentDimensions + + case BoundsMode(FitMode.Available, FitMode.Content) => + parentDimensions.withHeight(0) + + case BoundsMode(FitMode.Available, FitMode.Fixed(height)) => + parentDimensions.withHeight(height) + + case BoundsMode(FitMode.Available, FitMode.Relative(amountH)) => + parentDimensions.withHeight((parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Available, FitMode.Offset(amount)) => + parentDimensions.withHeight(parentDimensions.height + amount) + + // Content + + case BoundsMode(FitMode.Content, FitMode.Available) => + Dimensions(0, parentDimensions.height) + + case BoundsMode(FitMode.Content, FitMode.Content) => + Dimensions.zero + + case BoundsMode(FitMode.Content, FitMode.Fixed(height)) => + Dimensions(0, height) + + case BoundsMode(FitMode.Content, FitMode.Relative(amountH)) => + Dimensions(0, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Content, FitMode.Offset(amount)) => + Dimensions(0, parentDimensions.height + amount) + + // Fixed + + case BoundsMode(FitMode.Fixed(width), FitMode.Available) => + Dimensions(width, parentDimensions.height) + + case BoundsMode(FitMode.Fixed(width), FitMode.Content) => + Dimensions(width, 0) + + case BoundsMode(FitMode.Fixed(width), FitMode.Fixed(height)) => + Dimensions(width, height) + + case BoundsMode(FitMode.Fixed(width), FitMode.Relative(amountH)) => + Dimensions(width, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Fixed(width), FitMode.Offset(amount)) => + Dimensions(width, parentDimensions.height + amount) + + // Relative + + case BoundsMode(FitMode.Relative(amountW), FitMode.Available) => + Dimensions((parentDimensions.width * amountW).toInt, parentDimensions.height) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Content) => + Dimensions((parentDimensions.width * amountW).toInt, 0) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Fixed(height)) => + Dimensions((parentDimensions.width * amountW).toInt, height) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Relative(amountH)) => + Dimensions( + (parentDimensions.width * amountW).toInt, + (parentDimensions.height * amountH).toInt + ) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Offset(amount)) => + Dimensions((parentDimensions.width * amountW).toInt, parentDimensions.height + amount) + + // Offset + + case BoundsMode(FitMode.Offset(amount), FitMode.Available) => + parentDimensions.withWidth(parentDimensions.width + amount) + + case BoundsMode(FitMode.Offset(amount), FitMode.Content) => + Dimensions(parentDimensions.width + amount, 0) + + case BoundsMode(FitMode.Offset(amount), FitMode.Fixed(height)) => + Dimensions(parentDimensions.width + amount, height) + + case BoundsMode(FitMode.Offset(amount), FitMode.Relative(amountH)) => + Dimensions(parentDimensions.width + amount, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Offset(w), FitMode.Offset(h)) => + parentDimensions + Dimensions(w, h) + + // Next, loop over all the children, calling refresh on each one, and supplying the best guess for the bounds + val updatedComponents = + model.components.map { c => + val refreshed = c.component.refresh(reference, c.model, boundsWithoutContent) + c.copy(model = refreshed) + } + + // Now we need to set the offset of each child, based on the layout + val withOffsets = + updatedComponents.foldLeft(Batch.empty[ComponentEntry[?, ReferenceData]]) { (acc, next) => + next.anchor match + case None => + val nextOffset = + ContainerLikeFunctions.calculateNextOffset[ReferenceData]( + boundsWithoutContent, + model.layout + )(reference, acc) + + acc :+ next.copy(offset = nextOffset) + + case _ => + acc :+ next + } + + // Now we can calculate the content bounds + val contentBounds: Bounds = + withOffsets.foldLeft(Bounds.zero) { (acc, c) => + val bounds = c.component.bounds(reference, c.model).moveTo(c.offset) + acc.expandToInclude(bounds) + } + + // We can now calculate the boundsWithoutContent updating in the FitMode.Content cases and leaving as-is in others + val updatedBounds = + model.boundsMode match + case BoundsMode(FitMode.Content, FitMode.Content) => + contentBounds.dimensions + + case BoundsMode(FitMode.Content, _) => + boundsWithoutContent.withWidth(contentBounds.width) + + case BoundsMode(_, FitMode.Content) => + boundsWithoutContent.withHeight(contentBounds.height) + + case _ => + boundsWithoutContent + + // Finally, we can apply the anchors to the components that are not set to Anchor.None based on the updatedBounds + val withAnchors = + withOffsets.map { c => + c.anchor match + case None => + c + + case Some(a) => + val componentBounds = c.component.bounds(reference, c.model) + val offset = a.calculatePosition(updatedBounds, componentBounds.dimensions) + + c.copy(offset = offset) + } + + // Return the updated model with the new bounds and content bounds and dirty flag reset + model.copy( + dirty = false, + contentBounds = contentBounds, + dimensions = updatedBounds, + components = withAnchors + ) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ComponentList.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ComponentList.scala new file mode 100644 index 000000000..0f799bf2f --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ComponentList.scala @@ -0,0 +1,231 @@ +package indigoextras.ui.components + +import indigo.* +import indigoextras.ui.component.* +import indigoextras.ui.components.datatypes.Anchor +import indigoextras.ui.components.datatypes.ComponentEntry +import indigoextras.ui.components.datatypes.ComponentId +import indigoextras.ui.components.datatypes.ComponentLayout +import indigoextras.ui.components.datatypes.ContainerLikeFunctions +import indigoextras.ui.components.datatypes.Padding +import indigoextras.ui.datatypes.* + +/** Describes a dynamic list of components, and their realtive layout. + */ +final case class ComponentList[ReferenceData] private[components] ( + content: ReferenceData => Batch[ComponentEntry[?, ReferenceData]], + stateMap: Map[ComponentId, Any], + layout: ComponentLayout, + dimensions: Dimensions, + background: Bounds => Layer +): + + private def addSingle[A](entry: ReferenceData => (ComponentId, A))(using + c: Component[A, ReferenceData] + ): ComponentList[ReferenceData] = + val f = + (r: ReferenceData) => + content(r) :+ { + val (id, a) = entry(r) + ComponentEntry(id, Coords.zero, a, c, None) + } + + this.copy( + content = f + ) + + def addOne[A](entry: ReferenceData => (ComponentId, A))(using + c: Component[A, ReferenceData] + ): ComponentList[ReferenceData] = + addSingle(entry) + + def addOne[A](entry: (ComponentId, A))(using + c: Component[A, ReferenceData] + ): ComponentList[ReferenceData] = + addSingle(_ => entry) + + def add[A](entries: Batch[ReferenceData => (ComponentId, A)])(using + c: Component[A, ReferenceData] + ): ComponentList[ReferenceData] = + entries.foldLeft(this) { case (acc, next) => acc.addSingle(next) } + + def add[A](entries: (ReferenceData => (ComponentId, A))*)(using + c: Component[A, ReferenceData] + ): ComponentList[ReferenceData] = + Batch.fromSeq(entries).foldLeft(this) { case (acc, next) => acc.addSingle(next) } + + def add[A](entries: ReferenceData => Batch[(ComponentId, A)])(using + c: Component[A, ReferenceData] + ): ComponentList[ReferenceData] = + this.copy( + content = + (r: ReferenceData) => content(r) ++ entries(r).map(v => ComponentEntry(v._1, Coords.zero, v._2, c, None)) + ) + + def withDimensions(value: Dimensions): ComponentList[ReferenceData] = + this.copy(dimensions = value) + + def withLayout(value: ComponentLayout): ComponentList[ReferenceData] = + this.copy(layout = value) + + def resizeTo(size: Dimensions): ComponentList[ReferenceData] = + withDimensions(size) + def resizeTo(x: Int, y: Int): ComponentList[ReferenceData] = + resizeTo(Dimensions(x, y)) + def resizeBy(amount: Dimensions): ComponentList[ReferenceData] = + withDimensions(dimensions + amount) + def resizeBy(x: Int, y: Int): ComponentList[ReferenceData] = + resizeBy(Dimensions(x, y)) + + def withBackground(present: Bounds => Layer): ComponentList[ReferenceData] = + this.copy(background = present) + +object ComponentList: + + def apply[ReferenceData, A]( + dimensions: Dimensions + )(contents: ReferenceData => Batch[(ComponentId, A)])(using + c: Component[A, ReferenceData] + ): ComponentList[ReferenceData] = + val f: ReferenceData => Batch[ComponentEntry[A, ReferenceData]] = + r => contents(r).map(v => ComponentEntry(v._1, Coords.zero, v._2, c, None)) + + ComponentList( + f, + Map.empty, + ComponentLayout.Vertical(Padding.zero), + dimensions, + _ => Layer.empty + ) + + def apply[ReferenceData, A]( + dimensions: Dimensions + )(contents: (ComponentId, A)*)(using + c: Component[A, ReferenceData] + ): ComponentList[ReferenceData] = + val f: ReferenceData => Batch[ComponentEntry[A, ReferenceData]] = + _ => Batch.fromSeq(contents).map(v => ComponentEntry(v._1, Coords.zero, v._2, c, None)) + + ComponentList( + f, + Map.empty, + ComponentLayout.Vertical(Padding.zero), + dimensions, + _ => Layer.empty + ) + + given [ReferenceData]: Component[ComponentList[ReferenceData], ReferenceData] with + + def bounds( + context: ReferenceData, + model: ComponentList[ReferenceData] + ): Bounds = + Bounds(model.dimensions) + + def updateModel( + context: UIContext[ReferenceData], + model: ComponentList[ReferenceData] + ): GlobalEvent => Outcome[ComponentList[ReferenceData]] = + case e => + // What we're doing here it updating the stateMap, not the content function. + // However, to do that properly, we need to reflow the content too, to make sure things + // like pointer clicks are still in the right place. + val nextOffset = + ContainerLikeFunctions + .calculateNextOffset[ReferenceData](model.dimensions, model.layout) + + val entries = + model.content(context.reference) + + val nextStateMap = + entries + .foldLeft(Outcome(Batch.empty[ComponentEntry[?, ReferenceData]])) { (accum, entry) => + accum.flatMap { acc => + val offset = nextOffset(context.reference, acc) + + val updated = + model.stateMap.get(entry.id) match + case None => + // No entry, so we make one based on the component's default state + entry.component + .updateModel( + context.copy(bounds = context.bounds.moveBy(offset)), + entry.model + )(e) + .map(m => entry.copy(offset = offset, model = m)) + + case Some(savedState) => + // We have an entry, so we update it + entry.component + .updateModel( + context.copy(bounds = context.bounds.moveBy(offset)), + savedState.asInstanceOf[entry.Out] + )(e) + .map(m => entry.copy(offset = offset, model = m)) + + updated.map(u => acc :+ u) + } + } + .map(_.map(e => e.id -> e.model).toMap) + + nextStateMap.map { newStateMap => + model.copy(stateMap = newStateMap) + } + + def present( + context: UIContext[ReferenceData], + model: ComponentList[ReferenceData] + ): Outcome[Layer] = + // Pull the state out of the stateMap and present it + val entries = + model + .content(context.reference) + .map { entry => + model.stateMap.get(entry.id) match + case None => + // No entry, so we use the default. + entry + + case Some(savedState) => + // We have an entry, so overwrite the model with it. + entry.copy(model = savedState.asInstanceOf[entry.Out]) + } + + ContainerLikeFunctions + .present( + context, + model.dimensions, + contentReflow(context.reference, model.dimensions, model.layout, entries) + ) + .map { components => + val background = model.background(Bounds(context.bounds.coords, model.dimensions)) + Layer.Stack(background, components) + } + + // ComponentList's have a fixed size, so we don't need to do anything here, + // and since this component's size doesn't change, nor do we need to + // propagate further. + def refresh( + reference: ReferenceData, + model: ComponentList[ReferenceData], + parentDimensions: Dimensions + ): ComponentList[ReferenceData] = + model + + private def contentReflow( + reference: ReferenceData, + dimensions: Dimensions, + layout: ComponentLayout, + entries: Batch[ComponentEntry[?, ReferenceData]] + ): Batch[ComponentEntry[?, ReferenceData]] = + val nextOffset = + ContainerLikeFunctions + .calculateNextOffset[ReferenceData](dimensions, layout) + + entries.foldLeft(Batch.empty[ComponentEntry[?, ReferenceData]]) { (acc, entry) => + val reflowed = entry.copy( + offset = nextOffset(reference, acc) + ) + + acc :+ reflowed + } diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/HitArea.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/HitArea.scala new file mode 100644 index 000000000..393487eac --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/HitArea.scala @@ -0,0 +1,228 @@ +package indigoextras.ui.components + +import indigo.* +import indigo.syntax.* +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +import datatypes.BoundsType + +/** The HitArea `Component` allows you to create invisible buttons for your UI. + * + * Functionally, a hit area is identical to a button that does not render anything. In fact a HitArea is isomorphic to + * a Button that renders nothing, and its component instance is mostly implemented by delegating to the button + * instance. + * + * All that said... for debug purposes, you can set a fill or stroke color to see the hit area. + */ +final case class HitArea[ReferenceData]( + bounds: Bounds, + state: ButtonState, + click: ReferenceData => Batch[GlobalEvent], + press: ReferenceData => Batch[GlobalEvent], + release: ReferenceData => Batch[GlobalEvent], + drag: (ReferenceData, DragData) => Batch[GlobalEvent], + boundsType: BoundsType[ReferenceData, Unit], + isDown: Boolean, + dragOptions: DragOptions, + dragStart: Option[DragData], + fill: Option[RGBA] = None, + stroke: Option[Stroke] = None +): + val isDragged: Boolean = dragStart.isDefined + + def onClick(events: ReferenceData => Batch[GlobalEvent]): HitArea[ReferenceData] = + this.copy(click = events) + def onClick(events: Batch[GlobalEvent]): HitArea[ReferenceData] = + onClick(_ => events) + def onClick(events: GlobalEvent*): HitArea[ReferenceData] = + onClick(Batch.fromSeq(events)) + + def onPress(events: ReferenceData => Batch[GlobalEvent]): HitArea[ReferenceData] = + this.copy(press = events) + def onPress(events: Batch[GlobalEvent]): HitArea[ReferenceData] = + onPress(_ => events) + def onPress(events: GlobalEvent*): HitArea[ReferenceData] = + onPress(Batch.fromSeq(events)) + + def onRelease(events: ReferenceData => Batch[GlobalEvent]): HitArea[ReferenceData] = + this.copy(release = events) + def onRelease(events: Batch[GlobalEvent]): HitArea[ReferenceData] = + onRelease(_ => events) + def onRelease(events: GlobalEvent*): HitArea[ReferenceData] = + onRelease(Batch.fromSeq(events)) + + def onDrag( + events: (ReferenceData, DragData) => Batch[GlobalEvent] + ): HitArea[ReferenceData] = + this.copy(drag = events) + def onDrag(events: Batch[GlobalEvent]): HitArea[ReferenceData] = + onDrag((_, _) => events) + def onDrag(events: GlobalEvent*): HitArea[ReferenceData] = + onDrag(Batch.fromSeq(events)) + + def withDragOptions(value: DragOptions): HitArea[ReferenceData] = + this.copy(dragOptions = value) + def makeDraggable: HitArea[ReferenceData] = + withDragOptions(dragOptions.withMode(DragMode.Drag)) + def reportDrag: HitArea[ReferenceData] = + withDragOptions(dragOptions.withMode(DragMode.ReportDrag)) + def notDraggable: HitArea[ReferenceData] = + withDragOptions(dragOptions.withMode(DragMode.None)) + + def withDragConstrain(value: DragConstrain): HitArea[ReferenceData] = + this.copy(dragOptions = dragOptions.withConstraints(value)) + def constrainDragTo(bounds: Bounds): HitArea[ReferenceData] = + withDragConstrain(DragConstrain.To(bounds)) + def constrainDragVertically: HitArea[ReferenceData] = + withDragConstrain(DragConstrain.Vertical) + def constrainDragVertically(from: Int, to: Int, x: Int): HitArea[ReferenceData] = + withDragConstrain(DragConstrain.vertical(from, to, x)) + def constrainDragHorizontally: HitArea[ReferenceData] = + withDragConstrain(DragConstrain.Horizontal) + def constrainDragHorizontally(from: Int, to: Int, y: Int): HitArea[ReferenceData] = + withDragConstrain(DragConstrain.horizontal(from, to, y)) + + def withDragArea(value: DragArea): HitArea[ReferenceData] = + this.copy(dragOptions = dragOptions.withArea(value)) + def noDragArea: HitArea[ReferenceData] = + withDragArea(DragArea.None) + def fixedDragArea(bounds: Bounds): HitArea[ReferenceData] = + withDragArea(DragArea.Fixed(bounds)) + def inheritDragArea: HitArea[ReferenceData] = + withDragArea(DragArea.Inherit) + + def withBoundsType(value: BoundsType[ReferenceData, Unit]): HitArea[ReferenceData] = + this.copy(boundsType = value) + + def withFill(value: RGBA): HitArea[ReferenceData] = + this.copy(fill = Option(value)) + def clearFill: HitArea[ReferenceData] = + this.copy(fill = None) + + def withStroke(value: Stroke): HitArea[ReferenceData] = + this.copy(stroke = Option(value)) + def clearStroke: HitArea[ReferenceData] = + this.copy(stroke = None) + + def toButton: Button[ReferenceData] = + Button( + bounds, + state, + (_, _, _) => Outcome(Layer.empty), + None, + None, + click, + press, + release, + drag, + boundsType, + isDown, + dragOptions, + dragStart + ) + +object HitArea: + + /** Minimal hitarea constructor with no events. + */ + def apply[ReferenceData](boundsType: BoundsType[ReferenceData, Unit]): HitArea[ReferenceData] = + HitArea( + Bounds.zero, + ButtonState.Up, + _ => Batch.empty, + _ => Batch.empty, + _ => Batch.empty, + (_, _) => Batch.empty, + boundsType, + isDown = false, + dragOptions = DragOptions.default, + dragStart = None, + fill = None, + stroke = None + ) + + /** Minimal hitarea constructor with no events. + */ + def apply[ReferenceData](bounds: Bounds): HitArea[ReferenceData] = + HitArea( + bounds, + ButtonState.Up, + _ => Batch.empty, + _ => Batch.empty, + _ => Batch.empty, + (_, _) => Batch.empty, + datatypes.BoundsType.Fixed(bounds), + isDown = false, + dragOptions = DragOptions.default, + dragStart = None, + fill = None, + stroke = None + ) + + given [ReferenceData](using btn: Component[Button[ReferenceData], ReferenceData]): Component[ + HitArea[ReferenceData], + ReferenceData + ] with + def bounds(reference: ReferenceData, model: HitArea[ReferenceData]): Bounds = + btn.bounds(reference, model.toButton) + + def updateModel( + context: UIContext[ReferenceData], + model: HitArea[ReferenceData] + ): GlobalEvent => Outcome[HitArea[ReferenceData]] = + e => + val f = model.fill + val s = model.stroke + btn.updateModel(context, model.toButton)(e).map(_.toHitArea.copy(fill = f, stroke = s)) + + def present( + context: UIContext[ReferenceData], + model: HitArea[ReferenceData] + ): Outcome[Layer] = + (model.fill, model.stroke) match + case (Some(fill), Some(stroke)) => + Outcome( + Layer( + Shape.Box( + model.bounds.unsafeToRectangle.moveTo(context.bounds.coords.unsafeToPoint), + Fill.Color(fill), + stroke + ) + ) + ) + + case (Some(fill), None) => + Outcome( + Layer( + Shape.Box( + model.bounds.unsafeToRectangle.moveTo(context.bounds.coords.unsafeToPoint), + Fill.Color(fill) + ) + ) + ) + + case (None, Some(stroke)) => + Outcome( + Layer( + Shape.Box( + model.bounds.unsafeToRectangle.moveTo(context.bounds.coords.unsafeToPoint), + Fill.None, + stroke + ) + ) + ) + + case (None, None) => + Outcome(Layer.empty) + + def refresh( + reference: ReferenceData, + model: HitArea[ReferenceData], + parentDimensions: Dimensions + ): HitArea[ReferenceData] = + val f = model.fill + val s = model.stroke + btn.refresh(reference, model.toButton, parentDimensions).toHitArea.copy(fill = f, stroke = s) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Input.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Input.scala new file mode 100644 index 000000000..ecba536c4 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Input.scala @@ -0,0 +1,260 @@ +package indigoextras.ui.components + +import indigo.* +import indigo.syntax.* +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +import scala.annotation.tailrec + +/** Input components allow the user to input text information. + */ +final case class Input( + text: String, + dimensions: Dimensions, + render: (Coords, Bounds, Input, Seconds) => Outcome[Layer], + change: String => Batch[GlobalEvent], + // + characterLimit: Int, + cursor: Cursor, + hasFocus: Boolean, + onFocus: () => Batch[GlobalEvent], + onLoseFocus: () => Batch[GlobalEvent] +): + lazy val length: Int = text.length + + def withText(value: String): Input = + this.copy(text = value) + + def withDimensions(value: Dimensions): Input = + this.copy(dimensions = value) + + def withWidth(value: Int): Input = + this.copy(dimensions = dimensions.withWidth(value)) + + def onChange(events: String => Batch[GlobalEvent]): Input = + this.copy(change = events) + def onChange(events: Batch[GlobalEvent]): Input = + onChange(_ => events) + def onChange(events: GlobalEvent*): Input = + onChange(Batch.fromSeq(events)) + + def noCursorBlink: Input = + this.copy(cursor = cursor.noCursorBlink) + def withCursorBlinkRate(interval: Seconds): Input = + this.copy(cursor = cursor.withCursorBlinkRate(interval)) + + def giveFocus: Outcome[Input] = + Outcome( + this.copy(hasFocus = true), + onFocus() + ) + + def loseFocus: Outcome[Input] = + Outcome( + this.copy(hasFocus = false), + onLoseFocus() + ) + + def withCharacterLimit(limit: Int): Input = + this.copy(characterLimit = limit) + + def withLastCursorMove(value: Seconds): Input = + this.copy(cursor = cursor.withLastCursorMove(value)) + + def cursorLeft: Input = + this.copy(cursor = cursor.cursorLeft) + + def cursorRight: Input = + this.copy(cursor = cursor.cursorRight(length)) + + def cursorHome: Input = + this.copy(cursor = cursor.cursorHome) + + def moveCursorTo(newCursorPosition: Int): Input = + this.copy(cursor = cursor.moveCursorTo(newCursorPosition, length)) + + def cursorEnd: Input = + this.copy(cursor = cursor.cursorEnd(length)) + + def delete: Input = + if cursor.position == length then this + else + val splitString = text.splitAt(cursor.position) + copy(text = splitString._1 + splitString._2.substring(1)) + + def backspace: Input = + val splitString = text.splitAt(cursor.position) + + this.copy( + text = splitString._1.take(splitString._1.length - 1) + splitString._2, + cursor = cursor.moveTo( + if cursor.position > 0 then cursor.position - 1 else cursor.position + ) + ) + + def addCharacter(char: Char): Input = + addCharacterText(char.toString()) + + def addCharacterText(textToInsert: String): Input = { + @tailrec + def rec(remaining: List[Char], textHead: String, textTail: String, position: Int): Input = + remaining match + case Nil => + this.copy( + text = textHead + textTail, + cursor = cursor.moveTo(position) + ) + + case _ if (textHead + textTail).length >= characterLimit => + rec(Nil, textHead, textTail, position) + + case c :: cs if c != '\n' => + rec(cs, textHead + c.toString(), textTail, position + 1) + + case _ :: cs => + rec(cs, textHead, textTail, position) + + val splitString = text.splitAt(cursor.position) + + rec(textToInsert.toCharArray().toList, splitString._1, splitString._2, cursor.position) + } + + def withFocusActions(actions: GlobalEvent*): Input = + withFocusActions(Batch.fromSeq(actions)) + def withFocusActions(actions: => Batch[GlobalEvent]): Input = + this.copy(onFocus = () => actions) + + def withLoseFocusActions(actions: GlobalEvent*): Input = + withLoseFocusActions(Batch.fromSeq(actions)) + def withLoseFocusActions(actions: => Batch[GlobalEvent]): Input = + this.copy(onLoseFocus = () => actions) + +object Input: + + /** Minimal input constructor with custom rendering function + */ + def apply(dimensions: Dimensions)( + present: (Coords, Bounds, Input, Seconds) => Outcome[Layer] + ): Input = + Input( + "", + dimensions, + present, + _ => Batch.empty, + // + characterLimit = dimensions.width, + cursor = Cursor.default, + hasFocus = false, + () => Batch.empty, + () => Batch.empty + ) + + given [ReferenceData]: Component[Input, ReferenceData] with + def bounds(reference: ReferenceData, model: Input): Bounds = + Bounds(model.dimensions).resizeBy(2, 2) + + def updateModel( + context: UIContext[ReferenceData], + model: Input + ): GlobalEvent => Outcome[Input] = + case _: PointerEvent.Click + if context.isActive && Bounds(model.dimensions) + .resizeBy(2, 2) + .moveBy(context.bounds.coords) + .contains(context.pointerCoords) => + model + .moveCursorTo(context.pointerCoords.x - context.bounds.coords.x - 1) + .giveFocus + + case _: PointerEvent.Click => + model.loseFocus + + case KeyboardEvent.KeyUp(Key.BACKSPACE) if model.hasFocus => + val next = model.backspace.withLastCursorMove(context.frame.time.running) + Outcome(next, model.change(next.text)) + + case KeyboardEvent.KeyUp(Key.DELETE) if model.hasFocus => + val next = model.delete.withLastCursorMove(context.frame.time.running) + Outcome(next, model.change(next.text)) + + case KeyboardEvent.KeyUp(Key.ARROW_LEFT) if model.hasFocus => + Outcome(model.cursorLeft.withLastCursorMove(context.frame.time.running)) + + case KeyboardEvent.KeyUp(Key.ARROW_RIGHT) if model.hasFocus => + Outcome(model.cursorRight.withLastCursorMove(context.frame.time.running)) + + case KeyboardEvent.KeyUp(Key.HOME) if model.hasFocus => + Outcome(model.cursorHome.withLastCursorMove(context.frame.time.running)) + + case KeyboardEvent.KeyUp(Key.END) if model.hasFocus => + Outcome(model.cursorEnd.withLastCursorMove(context.frame.time.running)) + + case KeyboardEvent.KeyUp(Key.ENTER) if model.hasFocus => + // Enter key is ignored. Single line input fields. + Outcome(model.withLastCursorMove(context.frame.time.running)) + + case KeyboardEvent.KeyUp(key) if model.hasFocus && key.isPrintable => + val next = model.addCharacterText(key.key).withLastCursorMove(context.frame.time.running) + Outcome(next, model.change(next.text)) + + case FrameTick if !context.isActive => + model.loseFocus + + case _ => + Outcome(model) + + def present( + context: UIContext[ReferenceData], + model: Input + ): Outcome[Layer] = + model.render( + context.bounds.coords, + Bounds(model.dimensions), + model, + context.frame.time.running + ) + + def refresh(reference: ReferenceData, model: Input, parentDimensions: Dimensions): Input = + model + +final case class Cursor( + position: Int, + blinkRate: Option[Seconds], + lastModified: Seconds +): + + def moveTo(position: Int): Cursor = + this.copy(position = position) + + def noCursorBlink: Cursor = + this.copy(blinkRate = None) + def withCursorBlinkRate(interval: Seconds): Cursor = + this.copy(blinkRate = Some(interval)) + + def withLastCursorMove(value: Seconds): Cursor = + this.copy(lastModified = value) + + def cursorLeft: Cursor = + this.copy(position = if (position - 1 >= 0) position - 1 else position) + + def cursorRight(maxLength: Int): Cursor = + this.copy(position = if (position + 1 <= maxLength) position + 1 else maxLength) + + def cursorHome: Cursor = + this.copy(position = 0) + + def moveCursorTo(newCursorPosition: Int, maxLength: Int): Cursor = + if newCursorPosition >= 0 && newCursorPosition <= maxLength then this.copy(position = newCursorPosition) + else if newCursorPosition < 0 then this.copy(position = 0) + else this.copy(position = Math.max(0, maxLength)) + + def cursorEnd(maxLength: Int): Cursor = + this.copy(position = maxLength) + +object Cursor: + val default: Cursor = + Cursor(0, Option(Seconds(0.5)), Seconds.zero) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Label.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Label.scala new file mode 100644 index 000000000..952aa7a89 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Label.scala @@ -0,0 +1,54 @@ +package indigoextras.ui.components + +import indigo.* +import indigo.syntax.* +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +/** Labels are a simple `Component` that render text. + */ +final case class Label[ReferenceData]( + text: ReferenceData => String, + render: (Coords, String, Dimensions) => Outcome[Layer], + calculateBounds: (ReferenceData, String) => Bounds +): + def withText(value: String): Label[ReferenceData] = + this.copy(text = _ => value) + def withText(f: ReferenceData => String): Label[ReferenceData] = + this.copy(text = f) + +object Label: + + /** Minimal label constructor with custom rendering function + */ + def apply[ReferenceData](text: String, calculateBounds: (ReferenceData, String) => Bounds)( + present: (Coords, String, Dimensions) => Outcome[Layer] + ): Label[ReferenceData] = + Label(_ => text, present, calculateBounds) + + given [ReferenceData]: Component[Label[ReferenceData], ReferenceData] with + def bounds(reference: ReferenceData, model: Label[ReferenceData]): Bounds = + model.calculateBounds(reference, model.text(reference)) + + def updateModel( + context: UIContext[ReferenceData], + model: Label[ReferenceData] + ): GlobalEvent => Outcome[Label[ReferenceData]] = + _ => Outcome(model) + + def present( + context: UIContext[ReferenceData], + model: Label[ReferenceData] + ): Outcome[Layer] = + val t = model.text(context.reference) + model.render(context.bounds.coords, t, model.calculateBounds(context.reference, t).dimensions) + + def refresh( + reference: ReferenceData, + model: Label[ReferenceData], + parentDimensions: Dimensions + ): Label[ReferenceData] = + model diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/MaskedPane.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/MaskedPane.scala new file mode 100644 index 000000000..b8a0cdc09 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/MaskedPane.scala @@ -0,0 +1,308 @@ +package indigoextras.ui.components + +import indigo.* +import indigoextras.ui.component.* +import indigoextras.ui.components.datatypes.Anchor +import indigoextras.ui.components.datatypes.BoundsMode +import indigoextras.ui.components.datatypes.ComponentEntry +import indigoextras.ui.components.datatypes.ComponentId +import indigoextras.ui.components.datatypes.ContainerLikeFunctions +import indigoextras.ui.components.datatypes.FitMode +import indigoextras.ui.datatypes.* +import indigoextras.ui.shaders.LayerMask + +/** Describes a fixed arrangement of components, manages their layout, which may include anchored components, and masks + * the content outside of the pane. Like a ScrollPane without the scrolling! + */ +final case class MaskedPane[A, ReferenceData] private[components] ( + bindingKey: BindingKey, + boundsMode: BoundsMode, + dimensions: Dimensions, // The actual cached dimensions of the scroll pane + contentBounds: Bounds, // The calculated and cached bounds of the content + // Components + content: ComponentEntry[A, ReferenceData] +): + + def withContent[B](component: B)(using + c: Component[B, ReferenceData] + ): MaskedPane[B, ReferenceData] = + this.copy( + content = MaskedPane.makeComponentEntry(component) + ) + + def withDimensions(value: Dimensions): MaskedPane[A, ReferenceData] = + this.copy(dimensions = value) + + def withBoundsMode(value: BoundsMode): MaskedPane[A, ReferenceData] = + this.copy(boundsMode = value) + +object MaskedPane: + + private def makeComponentEntry[A, ReferenceData]( + content: A + )(using c: Component[A, ReferenceData]): ComponentEntry[A, ReferenceData] = + ComponentEntry( + ComponentId("scroll pane component"), + Coords.zero, + content, + c, + None + ) + + def apply[A, ReferenceData]( + bindingKey: BindingKey, + content: A + )(using c: Component[A, ReferenceData]): MaskedPane[A, ReferenceData] = + MaskedPane( + bindingKey, + indigoextras.ui.components.datatypes.BoundsMode.default, + Dimensions.zero, + Bounds.zero, + MaskedPane.makeComponentEntry(content) + ) + + def apply[A, ReferenceData]( + bindingKey: BindingKey, + boundsMode: BoundsMode, + content: A + )(using + c: Component[A, ReferenceData] + ): MaskedPane[A, ReferenceData] = + MaskedPane( + bindingKey, + boundsMode, + Dimensions.zero, + Bounds.zero, + MaskedPane.makeComponentEntry(content) + ) + + def apply[A, ReferenceData]( + bindingKey: BindingKey, + dimensions: Dimensions, + content: A + )(using + c: Component[A, ReferenceData] + ): MaskedPane[A, ReferenceData] = + MaskedPane( + bindingKey, + indigoextras.ui.components.datatypes.BoundsMode.fixed(dimensions), + dimensions, + Bounds.zero, + MaskedPane.makeComponentEntry(content) + ) + + def apply[A, ReferenceData]( + bindingKey: BindingKey, + width: Int, + height: Int, + content: A + )(using + c: Component[A, ReferenceData] + ): MaskedPane[A, ReferenceData] = + MaskedPane( + bindingKey, + Dimensions(width, height), + content + ) + + given [A, ReferenceData]: Component[MaskedPane[A, ReferenceData], ReferenceData] with + + def bounds(reference: ReferenceData, model: MaskedPane[A, ReferenceData]): Bounds = + Bounds(model.dimensions) + + def updateModel( + context: UIContext[ReferenceData], + model: MaskedPane[A, ReferenceData] + ): GlobalEvent => Outcome[MaskedPane[A, ReferenceData]] = + case FrameTick => + // Sub-groups will naturally refresh themselves as needed + updateComponents(context, model)(FrameTick).map { updated => + refresh(context.reference, updated, context.bounds.dimensions) + } + + case e => + updateComponents(context, model)(e) + + private def updateComponents[StartupData, ContextData]( + context: UIContext[ReferenceData], + model: MaskedPane[A, ReferenceData] + ): GlobalEvent => Outcome[MaskedPane[A, ReferenceData]] = + case e => + val ctx = context.copy(bounds = Bounds(context.bounds.coords, model.dimensions)) + + model.content.component + .updateModel(ctx, model.content.model)(e) + .flatMap { updatedContent => + Outcome( + model.copy( + content = model.content.copy(model = updatedContent) + ) + ) + } + + def present( + context: UIContext[ReferenceData], + model: MaskedPane[A, ReferenceData] + ): Outcome[Layer] = + val adjustBounds = Bounds(context.bounds.coords, model.dimensions) + val ctx = context.copy(bounds = adjustBounds) + + val content = + ContainerLikeFunctions + .present( + ctx, + model.dimensions, + Batch(model.content) + ) + + val layers: Outcome[Layer.Stack] = + content.map(c => Layer.Stack(c)) + + layers + .map { stack => + val masked = + stack.toBatch.map { + _.withBlendMaterial( + LayerMask( + Bounds( + ctx.bounds.coords, + model.dimensions + ).toScreenSpace(ctx.snapGrid * ctx.magnification) + ) + ) + } + + stack.copy(layers = masked) + } + + def refresh( + reference: ReferenceData, + model: MaskedPane[A, ReferenceData], + parentDimensions: Dimensions + ): MaskedPane[A, ReferenceData] = + // Note: This is note _quite_ the same process as found in ComponentGroup + + // First, calculate the bounds without content + val boundsWithoutContent = + model.boundsMode match + + // Available + + case BoundsMode(FitMode.Available, FitMode.Available) => + parentDimensions + + case BoundsMode(FitMode.Available, FitMode.Content) => + parentDimensions.withHeight(0) + + case BoundsMode(FitMode.Available, FitMode.Fixed(height)) => + parentDimensions.withHeight(height) + + case BoundsMode(FitMode.Available, FitMode.Relative(amountH)) => + parentDimensions.withHeight((parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Available, FitMode.Offset(amount)) => + parentDimensions.withHeight(parentDimensions.height + amount) + + // Content + + case BoundsMode(FitMode.Content, FitMode.Available) => + Dimensions(0, parentDimensions.height) + + case BoundsMode(FitMode.Content, FitMode.Content) => + Dimensions.zero + + case BoundsMode(FitMode.Content, FitMode.Fixed(height)) => + Dimensions(0, height) + + case BoundsMode(FitMode.Content, FitMode.Relative(amountH)) => + Dimensions(0, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Content, FitMode.Offset(amount)) => + Dimensions(0, parentDimensions.height + amount) + + // Fixed + + case BoundsMode(FitMode.Fixed(width), FitMode.Available) => + Dimensions(width, parentDimensions.height) + + case BoundsMode(FitMode.Fixed(width), FitMode.Content) => + Dimensions(width, 0) + + case BoundsMode(FitMode.Fixed(width), FitMode.Fixed(height)) => + Dimensions(width, height) + + case BoundsMode(FitMode.Fixed(width), FitMode.Relative(amountH)) => + Dimensions(width, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Fixed(width), FitMode.Offset(amount)) => + Dimensions(width, parentDimensions.height + amount) + + // Relative + + case BoundsMode(FitMode.Relative(amountW), FitMode.Available) => + Dimensions((parentDimensions.width * amountW).toInt, parentDimensions.height) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Content) => + Dimensions((parentDimensions.width * amountW).toInt, 0) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Fixed(height)) => + Dimensions((parentDimensions.width * amountW).toInt, height) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Relative(amountH)) => + Dimensions( + (parentDimensions.width * amountW).toInt, + (parentDimensions.height * amountH).toInt + ) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Offset(amount)) => + Dimensions((parentDimensions.width * amountW).toInt, parentDimensions.height + amount) + + // Offset + + case BoundsMode(FitMode.Offset(amount), FitMode.Available) => + parentDimensions.withWidth(parentDimensions.width + amount) + + case BoundsMode(FitMode.Offset(amount), FitMode.Content) => + Dimensions(parentDimensions.width + amount, 0) + + case BoundsMode(FitMode.Offset(amount), FitMode.Fixed(height)) => + Dimensions(parentDimensions.width + amount, height) + + case BoundsMode(FitMode.Offset(amount), FitMode.Relative(amountH)) => + Dimensions(parentDimensions.width + amount, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Offset(w), FitMode.Offset(h)) => + parentDimensions + Dimensions(w, h) + + // Next, call refresh on the component, and supplying the best guess for the bounds + val updatedComponent = + model.content.copy( + model = model.content.component + .refresh(reference, model.content.model, boundsWithoutContent) + ) + + // Now we can calculate the content bounds + val contentBounds: Bounds = + model.content.component.bounds(reference, updatedComponent.model) + + // We can now calculate the boundsWithoutContent updating in the FitMode.Content cases and leaving as-is in others + val updatedBounds = + model.boundsMode match + case BoundsMode(FitMode.Content, FitMode.Content) => + contentBounds.dimensions + + case BoundsMode(FitMode.Content, _) => + boundsWithoutContent.withWidth(contentBounds.width) + + case BoundsMode(_, FitMode.Content) => + boundsWithoutContent.withHeight(contentBounds.height) + + case _ => + boundsWithoutContent + + // Return the updated model with the new bounds and content bounds and dirty flag reset + model.copy( + contentBounds = contentBounds, + dimensions = updatedBounds, + content = updatedComponent + ) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ScrollPane.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ScrollPane.scala new file mode 100644 index 000000000..ea2640119 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/ScrollPane.scala @@ -0,0 +1,462 @@ +package indigoextras.ui.components + +import indigo.* +import indigoextras.ui.component.* +import indigoextras.ui.components.datatypes.Anchor +import indigoextras.ui.components.datatypes.BoundsMode +import indigoextras.ui.components.datatypes.ComponentEntry +import indigoextras.ui.components.datatypes.ComponentId +import indigoextras.ui.components.datatypes.ContainerLikeFunctions +import indigoextras.ui.components.datatypes.FitMode +import indigoextras.ui.components.datatypes.ScrollMode +import indigoextras.ui.components.datatypes.ScrollOptions +import indigoextras.ui.datatypes.* +import indigoextras.ui.shaders.LayerMask + +/** Describes a fixed arrangement of components, manages their layout, which may include anchored components, and masks + * the content outside of the pane, providing a vertical scroll bar to make it visible. + */ +final case class ScrollPane[A, ReferenceData] private[components] ( + bindingKey: BindingKey, + boundsMode: BoundsMode, + dimensions: Dimensions, // The actual cached dimensions of the scroll pane + contentBounds: Bounds, // The calculated and cached bounds of the content + scrollAmount: Double, + // Components + content: ComponentEntry[A, ReferenceData], + scrollBar: Button[Unit], + scrollBarBackground: Bounds => Layer, + scrollOptions: ScrollOptions +): + + def withContent[B](component: B)(using + c: Component[B, ReferenceData] + ): ScrollPane[B, ReferenceData] = + this.copy( + content = ScrollPane.makeComponentEntry(component) + ) + + def withDimensions(value: Dimensions): ScrollPane[A, ReferenceData] = + this.copy(dimensions = value) + + def withBoundsMode(value: BoundsMode): ScrollPane[A, ReferenceData] = + this.copy(boundsMode = value) + + def withScrollBar(value: Button[Unit]): ScrollPane[A, ReferenceData] = + this.copy(scrollBar = value) + + def withScrollBackground(value: Bounds => Layer): ScrollPane[A, ReferenceData] = + this.copy(scrollBarBackground = value) + + def withScrollOptions(value: ScrollOptions): ScrollPane[A, ReferenceData] = + this.copy(scrollOptions = value) + + def enableScrolling: ScrollPane[A, ReferenceData] = + this.copy(scrollOptions = scrollOptions.withScrollMode(ScrollMode.Vertical)) + + def disableScrolling: ScrollPane[A, ReferenceData] = + this.copy(scrollOptions = scrollOptions.withScrollMode(ScrollMode.None)) + + def withMaxScrollSpeed(value: Int): ScrollPane[A, ReferenceData] = + this.copy(scrollOptions = scrollOptions.withMaxScrollSpeed(value)) + + def withMinScrollSpeed(value: Int): ScrollPane[A, ReferenceData] = + this.copy(scrollOptions = scrollOptions.withMinScrollSpeed(value)) + + def withScrollMode(value: ScrollMode): ScrollPane[A, ReferenceData] = + this.copy(scrollOptions = scrollOptions.withScrollMode(value)) + +object ScrollPane: + + private def makeComponentEntry[A, ReferenceData]( + content: A + )(using c: Component[A, ReferenceData]): ComponentEntry[A, ReferenceData] = + ComponentEntry( + ComponentId("scroll pane component"), + Coords.zero, + content, + c, + None + ) + + private val scrollDragEvent: BindingKey => (Unit, DragData) => Batch[GlobalEvent] = + key => (unit, dragData) => Batch(ScrollPaneEvent.Scroll(key, dragData.position.y)) + + private val setupScrollButton: (BindingKey, Button[Unit]) => Button[Unit] = + (key, button) => + button.reportDrag + .fixedDragArea(Bounds.zero) + .constrainDragVertically + .onDrag(scrollDragEvent(key)) + + def apply[A, ReferenceData]( + bindingKey: BindingKey, + content: A, + scrollBar: Button[Unit] + )(using c: Component[A, ReferenceData]): ScrollPane[A, ReferenceData] = + ScrollPane( + bindingKey, + indigoextras.ui.components.datatypes.BoundsMode.default, + Dimensions.zero, + Bounds.zero, + 0.0, + ScrollPane.makeComponentEntry(content), + setupScrollButton(bindingKey, scrollBar), + _ => Layer.empty, + indigoextras.ui.components.datatypes.ScrollOptions.default + ) + + def apply[A, ReferenceData]( + bindingKey: BindingKey, + boundsMode: BoundsMode, + content: A, + scrollBar: Button[Unit] + )(using + c: Component[A, ReferenceData] + ): ScrollPane[A, ReferenceData] = + ScrollPane( + bindingKey, + boundsMode, + Dimensions.zero, + Bounds.zero, + 0.0, + ScrollPane.makeComponentEntry(content), + setupScrollButton(bindingKey, scrollBar), + _ => Layer.empty, + indigoextras.ui.components.datatypes.ScrollOptions.default + ) + + def apply[A, ReferenceData]( + bindingKey: BindingKey, + dimensions: Dimensions, + content: A, + scrollBar: Button[Unit] + )(using + c: Component[A, ReferenceData] + ): ScrollPane[A, ReferenceData] = + ScrollPane( + bindingKey, + indigoextras.ui.components.datatypes.BoundsMode.fixed(dimensions), + dimensions, + Bounds.zero, + 0.0, + ScrollPane.makeComponentEntry(content), + setupScrollButton(bindingKey, scrollBar), + _ => Layer.empty, + indigoextras.ui.components.datatypes.ScrollOptions.default + ) + + def apply[A, ReferenceData]( + bindingKey: BindingKey, + width: Int, + height: Int, + content: A, + scrollBar: Button[Unit] + )(using + c: Component[A, ReferenceData] + ): ScrollPane[A, ReferenceData] = + ScrollPane( + bindingKey, + Dimensions(width, height), + content, + setupScrollButton(bindingKey, scrollBar) + ) + + given [A, ReferenceData]: Component[ScrollPane[A, ReferenceData], ReferenceData] with + + def bounds(reference: ReferenceData, model: ScrollPane[A, ReferenceData]): Bounds = + Bounds(model.dimensions) + + def updateModel( + context: UIContext[ReferenceData], + model: ScrollPane[A, ReferenceData] + ): GlobalEvent => Outcome[ScrollPane[A, ReferenceData]] = + case FrameTick => + // Sub-groups will naturally refresh themselves as needed + updateComponents(context, model)(FrameTick).map { updated => + refresh(context.reference, updated, context.bounds.dimensions) + } + + case ScrollPaneEvent.Scroll(bindingKey, yPos) if bindingKey == model.bindingKey => + val bounds = Bounds(context.bounds.coords, model.dimensions) + val newAmount = (yPos + 1 - bounds.y).toDouble / bounds.height.toDouble + Outcome(model.copy(scrollAmount = newAmount)) + + case e => + updateComponents(context, model)(e) + + private def updateComponents[StartupData, ContextData]( + context: UIContext[ReferenceData], + model: ScrollPane[A, ReferenceData] + ): GlobalEvent => Outcome[ScrollPane[A, ReferenceData]] = + case MouseEvent.Wheel(pos, deltaY) + if model.scrollOptions.isEnabled && Bounds(context.bounds.coords, model.dimensions) + .contains(context.pointerCoords) => + val scrollBy = + val speed = + if model.dimensions.height > 0 then model.dimensions.height / 10 else 1 + val clamped = + Math.min( + model.scrollOptions.maxScrollSpeed, + Math.max(model.scrollOptions.minScrollSpeed, speed) + ) + val coords = + if deltaY < 0 then -clamped else clamped + + coords.toDouble / model.dimensions.height.toDouble + + Outcome( + model.copy( + scrollAmount = Math.min(1.0d, Math.max(0.0d, model.scrollAmount + scrollBy)) + ) + ) + + case e => + val scrollingActive = + model.scrollOptions.isEnabled && model.contentBounds.height > model.dimensions.height + val ctx = context.copy(bounds = Bounds(context.bounds.coords, model.dimensions)) + + def updateScrollBar: Outcome[Button[Unit]] = + val c: Component[Button[Unit], Unit] = summon[Component[Button[Unit], Unit]] + val unitContext: UIContext[Unit] = ctx.copy(reference = ()) + + c.updateModel( + unitContext + .moveBoundsBy( + Coords( + model.dimensions.width - model.scrollBar.bounds.width, + 0 + ) + ) + .withAdditionalOffset( + Coords( + 0, + ((model.dimensions.height - 1).toDouble * model.scrollAmount).toInt + ) + ), + model.scrollBar + )(e) + + for { + updatedContent <- model.content.component.updateModel(ctx, model.content.model)(e) + updatedScrollBar <- if scrollingActive then updateScrollBar else Outcome(model.scrollBar) + } yield model.copy( + content = model.content.copy(model = updatedContent), + scrollBar = updatedScrollBar + ) + + def present( + context: UIContext[ReferenceData], + model: ScrollPane[A, ReferenceData] + ): Outcome[Layer] = + val scrollingActive = + model.scrollOptions.isEnabled && model.contentBounds.height > model.dimensions.height + val adjustBounds = Bounds(context.bounds.coords, model.dimensions) + val ctx = context.copy(bounds = adjustBounds) + val scrollOffset: Coords = + if scrollingActive then + Coords( + 0, + ((model.dimensions.height.toDouble - model.contentBounds.height.toDouble) * model.scrollAmount).toInt + ) + else Coords.zero + + val content = + ContainerLikeFunctions + .present( + ctx.moveBoundsBy(scrollOffset), + model.dimensions, + Batch(model.content) + ) + + val layers: Outcome[Layer.Stack] = + if scrollingActive then + val c: Component[Button[Unit], Unit] = summon[Component[Button[Unit], Unit]] + val unitContext: UIContext[Unit] = ctx.copy(reference = ()) + val scrollbar = + c.present( + unitContext.moveBoundsBy( + Coords( + model.dimensions.width - model.scrollBar.bounds.width, + ((model.dimensions.height - 1).toDouble * model.scrollAmount).toInt + ) + ), + model.scrollBar + ) + val scrollBg = + model.scrollBarBackground( + adjustBounds + .moveBy(model.dimensions.width - model.scrollBar.bounds.width, 0) + .resize(model.scrollBar.bounds.width, adjustBounds.height) + ) + + (content, scrollbar) + .map2 { (c, sb) => + Layer.Stack( + c, + scrollBg, + sb + ) + } + else content.map(c => Layer.Stack(c)) + + layers + .map { stack => + val masked = + stack.toBatch.map { + _.withBlendMaterial( + LayerMask( + Bounds( + ctx.bounds.coords, + model.dimensions + ).toScreenSpace(ctx.snapGrid * ctx.magnification) + ) + ) + } + + stack.copy(layers = masked) + } + + def refresh( + reference: ReferenceData, + model: ScrollPane[A, ReferenceData], + parentDimensions: Dimensions + ): ScrollPane[A, ReferenceData] = + // Note: This is note _quite_ the same process as found in ComponentGroup + + // First, calculate the bounds without content + val boundsWithoutContent = + model.boundsMode match + + // Available + + case BoundsMode(FitMode.Available, FitMode.Available) => + parentDimensions + + case BoundsMode(FitMode.Available, FitMode.Content) => + parentDimensions.withHeight(0) + + case BoundsMode(FitMode.Available, FitMode.Fixed(height)) => + parentDimensions.withHeight(height) + + case BoundsMode(FitMode.Available, FitMode.Relative(amountH)) => + parentDimensions.withHeight((parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Available, FitMode.Offset(amount)) => + parentDimensions.withHeight(parentDimensions.height + amount) + + // Content + + case BoundsMode(FitMode.Content, FitMode.Available) => + Dimensions(0, parentDimensions.height) + + case BoundsMode(FitMode.Content, FitMode.Content) => + Dimensions.zero + + case BoundsMode(FitMode.Content, FitMode.Fixed(height)) => + Dimensions(0, height) + + case BoundsMode(FitMode.Content, FitMode.Relative(amountH)) => + Dimensions(0, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Content, FitMode.Offset(amount)) => + Dimensions(0, parentDimensions.height + amount) + + // Fixed + + case BoundsMode(FitMode.Fixed(width), FitMode.Available) => + Dimensions(width, parentDimensions.height) + + case BoundsMode(FitMode.Fixed(width), FitMode.Content) => + Dimensions(width, 0) + + case BoundsMode(FitMode.Fixed(width), FitMode.Fixed(height)) => + Dimensions(width, height) + + case BoundsMode(FitMode.Fixed(width), FitMode.Relative(amountH)) => + Dimensions(width, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Fixed(width), FitMode.Offset(amount)) => + Dimensions(width, parentDimensions.height + amount) + + // Relative + + case BoundsMode(FitMode.Relative(amountW), FitMode.Available) => + Dimensions((parentDimensions.width * amountW).toInt, parentDimensions.height) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Content) => + Dimensions((parentDimensions.width * amountW).toInt, 0) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Fixed(height)) => + Dimensions((parentDimensions.width * amountW).toInt, height) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Relative(amountH)) => + Dimensions( + (parentDimensions.width * amountW).toInt, + (parentDimensions.height * amountH).toInt + ) + + case BoundsMode(FitMode.Relative(amountW), FitMode.Offset(amount)) => + Dimensions((parentDimensions.width * amountW).toInt, parentDimensions.height + amount) + + // Offset + + case BoundsMode(FitMode.Offset(amount), FitMode.Available) => + parentDimensions.withWidth(parentDimensions.width + amount) + + case BoundsMode(FitMode.Offset(amount), FitMode.Content) => + Dimensions(parentDimensions.width + amount, 0) + + case BoundsMode(FitMode.Offset(amount), FitMode.Fixed(height)) => + Dimensions(parentDimensions.width + amount, height) + + case BoundsMode(FitMode.Offset(amount), FitMode.Relative(amountH)) => + Dimensions(parentDimensions.width + amount, (parentDimensions.height * amountH).toInt) + + case BoundsMode(FitMode.Offset(w), FitMode.Offset(h)) => + parentDimensions + Dimensions(w, h) + + // Next, call refresh on the component, and supplying the best guess for the bounds + val updatedComponent = + model.content.copy( + model = model.content.component + .refresh(reference, model.content.model, boundsWithoutContent) + ) + + // Now we can calculate the content bounds + val contentBounds: Bounds = + model.content.component.bounds(reference, updatedComponent.model) + + // We can now calculate the boundsWithoutContent updating in the FitMode.Content cases and leaving as-is in others + val updatedBounds = + model.boundsMode match + case BoundsMode(FitMode.Content, FitMode.Content) => + contentBounds.dimensions + + case BoundsMode(FitMode.Content, _) => + boundsWithoutContent.withWidth(contentBounds.width) + + case BoundsMode(_, FitMode.Content) => + boundsWithoutContent.withHeight(contentBounds.height) + + case _ => + boundsWithoutContent + + // Return the updated model with the new bounds and content bounds and dirty flag reset + model.copy( + contentBounds = contentBounds, + dimensions = updatedBounds, + content = updatedComponent, + scrollBar = model.scrollBar + .fixedDragArea( + Bounds( + updatedBounds.width - model.scrollBar.bounds.width, + 0, + 1, + updatedBounds.height - 1 + ) + ) + ) + +enum ScrollPaneEvent extends GlobalEvent: + case Scroll(bindingKey: BindingKey, amount: Int) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Switch.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Switch.scala new file mode 100644 index 000000000..96b79506b --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/Switch.scala @@ -0,0 +1,212 @@ +package indigoextras.ui.components + +import indigo.* +import indigo.syntax.* +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +import datatypes.BoundsType +import datatypes.SwitchState + +/** The Switch `Component` allows you to create a two state (on / off) button for your UI. These can be used for simple + * checkboxes, toggles, and switches, but also with more coordination, compound components like radio button groups. + */ +final case class Switch[ReferenceData]( + bounds: Bounds, + state: SwitchState, + on: (Coords, Bounds, ReferenceData) => Outcome[Layer], + off: (Coords, Bounds, ReferenceData) => Outcome[Layer], + switch: (SwitchState, ReferenceData) => Batch[GlobalEvent], + boundsType: BoundsType[ReferenceData, Unit], + isDown: Boolean, + autoToggle: (SwitchState, ReferenceData) => Option[SwitchState] +): + def withSwitchState(value: SwitchState): Switch[ReferenceData] = + this.copy(state = value) + def switchOn: Switch[ReferenceData] = + withSwitchState(SwitchState.On) + def switchOff: Switch[ReferenceData] = + withSwitchState(SwitchState.Off) + + def presentOn( + on: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Switch[ReferenceData] = + this.copy(on = on) + + def presentOff( + off: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Switch[ReferenceData] = + this.copy(off = off) + + def onSwitch(events: (SwitchState, ReferenceData) => Batch[GlobalEvent]): Switch[ReferenceData] = + this.copy(switch = events) + def onSwitch(events: SwitchState => Batch[GlobalEvent]): Switch[ReferenceData] = + onSwitch((ss, _) => events(ss)) + + def withBoundsType(value: BoundsType[ReferenceData, Unit]): Switch[ReferenceData] = + this.copy(boundsType = value) + + /** Decide the state of the switch based on the current state and the reference data. Returns an optional value, if + * `None` is returned the switch will not change state. + */ + def withAutoToggle( + f: (SwitchState, ReferenceData) => Option[SwitchState] + ): Switch[ReferenceData] = + this.copy(autoToggle = f) + +object Switch: + + /** Minimal button constructor with custom rendering function + */ + def apply[ReferenceData, A](boundsType: BoundsType[ReferenceData, Unit])( + on: (Coords, Bounds, ReferenceData) => Outcome[Layer], + off: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Switch[ReferenceData] = + Switch( + Bounds.zero, + SwitchState.Off, + on, + off, + (_, _) => Batch.empty, + boundsType, + false, + (_, _) => None + ) + + /** Minimal button constructor with custom rendering function + */ + def apply[ReferenceData](bounds: Bounds)( + on: (Coords, Bounds, ReferenceData) => Outcome[Layer], + off: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Switch[ReferenceData] = + Switch( + bounds, + SwitchState.Off, + on, + off, + (_, _) => Batch.empty, + datatypes.BoundsType.Fixed(bounds), + false, + (_, _) => None + ) + + /** Minimal button constructor with custom rendering function and dynamic sizing + */ + def apply[ReferenceData](calculateBounds: ReferenceData => Bounds)( + on: (Coords, Bounds, ReferenceData) => Outcome[Layer], + off: (Coords, Bounds, ReferenceData) => Outcome[Layer] + ): Switch[ReferenceData] = + Switch( + Bounds.zero, + SwitchState.Off, + on, + off, + (_, _) => Batch.empty, + datatypes.BoundsType.Calculated(calculateBounds), + false, + (_, _) => None + ) + + given [ReferenceData]: Component[Switch[ReferenceData], ReferenceData] with + def bounds(reference: ReferenceData, model: Switch[ReferenceData]): Bounds = + model.bounds + + def updateModel( + context: UIContext[ReferenceData], + model: Switch[ReferenceData] + ): GlobalEvent => Outcome[Switch[ReferenceData]] = + case FrameTick => + val newBounds = + model.boundsType match + case datatypes.BoundsType.Fixed(bounds) => + bounds + + case datatypes.BoundsType.Calculated(calculate) => + calculate(context.reference, ()) + + case _ => + model.bounds + + val nextState = + model.autoToggle(model.state, context.reference).getOrElse(model.state) + + Outcome( + model.copy( + bounds = newBounds, + state = nextState + ) + ) + + case _: PointerEvent.Down + if context.isActive && model.bounds + .moveBy(context.bounds.coords + context.additionalOffset) + .contains(context.pointerCoords) => + Outcome(model.copy(isDown = true)) + + case _: PointerEvent.Up + if context.isActive && model.isDown && model.bounds + .moveBy(context.bounds.coords + context.additionalOffset) + .contains(context.pointerCoords) => + val next = model.state.toggle + Outcome(model.copy(state = next, isDown = false)) + .addGlobalEvents(model.switch(next, context.reference)) + + case _: PointerEvent.Up => + // Released Outside. + Outcome(model.copy(isDown = false)) + + case _ => + Outcome(model) + + def present( + context: UIContext[ReferenceData], + model: Switch[ReferenceData] + ): Outcome[Layer] = + model.state match + case SwitchState.On => + model.on(context.bounds.coords, model.bounds, context.reference) + + case SwitchState.Off => + model.off(context.bounds.coords, model.bounds, context.reference) + + def refresh( + reference: ReferenceData, + model: Switch[ReferenceData], + parentDimensions: Dimensions + ): Switch[ReferenceData] = + model.boundsType match + case datatypes.BoundsType.Fixed(bounds) => + model.copy( + bounds = bounds + ) + + case datatypes.BoundsType.Calculated(calculate) => + model + + case datatypes.BoundsType.FillWidth(height, padding) => + model.copy( + bounds = Bounds( + parentDimensions.width - padding.left - padding.right, + height + ) + ) + + case datatypes.BoundsType.FillHeight(width, padding) => + model.copy( + bounds = Bounds( + width, + parentDimensions.height - padding.top - padding.bottom + ) + ) + + case datatypes.BoundsType.Fill(padding) => + model.copy( + bounds = Bounds( + parentDimensions.width - padding.left - padding.right, + parentDimensions.height - padding.top - padding.bottom + ) + ) + diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/TextArea.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/TextArea.scala new file mode 100644 index 000000000..950f76837 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/TextArea.scala @@ -0,0 +1,74 @@ +package indigoextras.ui.components + +import indigo.* +import indigo.syntax.* +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +import scala.annotation.targetName + +/** TextAreas are a simple `StatelessComponent` that render text. + */ +final case class TextArea[ReferenceData]( + text: ReferenceData => List[String], + render: (Coords, List[String], Dimensions) => Outcome[Layer], + calculateBounds: (ReferenceData, List[String]) => Bounds +): + def withText(value: String): TextArea[ReferenceData] = + this.copy(text = _ => value.split("\n").toList) + def withText(f: ReferenceData => String): TextArea[ReferenceData] = + this.copy(text = (r: ReferenceData) => f(r).split("\n").toList) + +object TextArea: + + def apply[ReferenceData](text: String, calculateBounds: (ReferenceData, List[String]) => Bounds)( + present: (Coords, List[String], Dimensions) => Outcome[Layer] + ): TextArea[ReferenceData] = + TextArea( + (_: ReferenceData) => text.split("\n").toList, + present, + calculateBounds + ) + + @targetName("TextAreaRefToString") + def apply[ReferenceData]( + text: ReferenceData => String, + calculateBounds: (ReferenceData, List[String]) => Bounds + )( + present: (Coords, List[String], Dimensions) => Outcome[Layer] + ): TextArea[ReferenceData] = + TextArea( + (r: ReferenceData) => text(r).split("\n").toList, + present, + calculateBounds + ) + + given [ReferenceData]: Component[TextArea[ReferenceData], ReferenceData] with + def bounds(reference: ReferenceData, model: TextArea[ReferenceData]): Bounds = + model.calculateBounds(reference, model.text(reference)) + + def updateModel( + context: UIContext[ReferenceData], + model: TextArea[ReferenceData] + ): GlobalEvent => Outcome[TextArea[ReferenceData]] = + _ => Outcome(model) + + def present( + context: UIContext[ReferenceData], + model: TextArea[ReferenceData] + ): Outcome[Layer] = + model.render( + context.bounds.coords, + model.text(context.reference), + bounds(context.reference, model).dimensions + ) + + def refresh( + reference: ReferenceData, + model: TextArea[ReferenceData], + parentDimensions: Dimensions + ): TextArea[ReferenceData] = + model diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Anchor.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Anchor.scala new file mode 100644 index 000000000..b67615f4a --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Anchor.scala @@ -0,0 +1,74 @@ +package indigoextras.ui.components.datatypes + +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions + +final case class Anchor(location: AnchorLocation, padding: Padding): + + def withPadding(padding: Padding): Anchor = + this.copy(padding = padding) + + def withLocation(location: AnchorLocation): Anchor = + this.copy(location = location) + + def calculatePosition(area: Dimensions, component: Dimensions): Coords = + Anchor.calculatePosition(this, area, component) + +object Anchor: + + val TopLeft: Anchor = Anchor(AnchorLocation.TopLeft, Padding.zero) + val TopCenter: Anchor = Anchor(AnchorLocation.TopCenter, Padding.zero) + val TopRight: Anchor = Anchor(AnchorLocation.TopRight, Padding.zero) + val CenterLeft: Anchor = Anchor(AnchorLocation.CenterLeft, Padding.zero) + val Center: Anchor = Anchor(AnchorLocation.Center, Padding.zero) + val CenterRight: Anchor = Anchor(AnchorLocation.CenterRight, Padding.zero) + val BottomLeft: Anchor = Anchor(AnchorLocation.BottomLeft, Padding.zero) + val BottomCenter: Anchor = Anchor(AnchorLocation.BottomCenter, Padding.zero) + val BottomRight: Anchor = Anchor(AnchorLocation.BottomRight, Padding.zero) + + def calculatePosition(anchor: Anchor, area: Dimensions, component: Dimensions): Coords = + anchor.location match + case AnchorLocation.TopLeft => + Coords(anchor.padding.left, anchor.padding.top) + + case AnchorLocation.TopCenter => + Coords((area.width - component.width) / 2, 0) + + Coords(0, anchor.padding.top) + + case AnchorLocation.TopRight => + Coords(area.width - component.width, 0) + + Coords(-anchor.padding.right, anchor.padding.top) + + case AnchorLocation.CenterLeft => + Coords(0, (area.height - component.height) / 2) + + Coords(anchor.padding.left, 0) + + case AnchorLocation.Center => + Coords((area.width - component.width) / 2, (area.height - component.height) / 2) + + case AnchorLocation.CenterRight => + Coords(area.width - component.width, (area.height - component.height) / 2) + + Coords(-anchor.padding.right, 0) + + case AnchorLocation.BottomLeft => + Coords(0, area.height - component.height) + + Coords(anchor.padding.left, -anchor.padding.bottom) + + case AnchorLocation.BottomCenter => + Coords((area.width - component.width) / 2, area.height - component.height) + + Coords(0, -anchor.padding.bottom) + + case AnchorLocation.BottomRight => + Coords(area.width - component.width, area.height - component.height) + + Coords(-anchor.padding.right, -anchor.padding.bottom) + +enum AnchorLocation derives CanEqual: + case TopLeft + case TopCenter + case TopRight + case CenterLeft + case Center + case CenterRight + case BottomLeft + case BottomCenter + case BottomRight diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/BoundsMode.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/BoundsMode.scala new file mode 100644 index 000000000..710e62439 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/BoundsMode.scala @@ -0,0 +1,56 @@ +package indigoextras.ui.components.datatypes + +import indigoextras.ui.datatypes.Dimensions + +/** Describes how a ComponentGroup responds to changes in its parents bounds. + */ +final case class BoundsMode(width: FitMode, height: FitMode) + +object BoundsMode: + + val default: BoundsMode = + BoundsMode(FitMode.Available, FitMode.Content) + + def fixed(dimensions: Dimensions): BoundsMode = + BoundsMode( + FitMode.Fixed(dimensions.width), + FitMode.Fixed(dimensions.height) + ) + + def fixed(width: Int, height: Int): BoundsMode = + fixed(Dimensions(width, height)) + + def available: BoundsMode = + BoundsMode(FitMode.Available, FitMode.Available) + def inherit: BoundsMode = + available + + def availableWidth: BoundsMode = + BoundsMode(FitMode.Available, FitMode.Content) + + def offset(offsetWidth: Int, offsetHeight: Int): BoundsMode = + BoundsMode(FitMode.Offset(offsetWidth), FitMode.Offset(offsetHeight)) + + def offsetWidth(offsetWidth: Int): BoundsMode = + BoundsMode(FitMode.Offset(offsetWidth), FitMode.Content) + + def fit: BoundsMode = + BoundsMode(FitMode.Content, FitMode.Content) + + def halfHorizontal: BoundsMode = + BoundsMode( + FitMode.Relative(0.5), + FitMode.Available + ) + + def halfVertical: BoundsMode = + BoundsMode( + FitMode.Available, + FitMode.Relative(0.5) + ) + + def relative(relativeWidth: Double, relativeHeight: Double): BoundsMode = + BoundsMode( + FitMode.Relative(relativeWidth), + FitMode.Relative(relativeHeight) + ) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/BoundsType.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/BoundsType.scala new file mode 100644 index 000000000..fcc995753 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/BoundsType.scala @@ -0,0 +1,34 @@ +package indigoextras.ui.components.datatypes + +import indigoextras.ui.components.datatypes.Padding +import indigoextras.ui.datatypes.Bounds + +/** Describes how a component should be sized within its parent. + */ +enum BoundsType[ReferenceData, A]: + case Fixed(bounds: Bounds) + case Calculated(calculate: (ReferenceData, A) => Bounds) + case FillWidth(height: Int, padding: Padding) + case FillHeight(width: Int, padding: Padding) + case Fill(padding: Padding) + +object BoundsType: + + def fixed[ReferenceData](bounds: Bounds): BoundsType[ReferenceData, Unit] = + BoundsType.Fixed(bounds) + def fixed[ReferenceData](width: Int, height: Int): BoundsType[ReferenceData, Unit] = + BoundsType.Fixed(Bounds(width, height)) + + def fillWidth[ReferenceData](height: Int, padding: Padding): BoundsType[ReferenceData, Unit] = + BoundsType.FillWidth(height, padding) + def fillHeight[ReferenceData](width: Int, padding: Padding): BoundsType[ReferenceData, Unit] = + BoundsType.FillHeight(width, padding) + def fill[ReferenceData](padding: Padding): BoundsType[ReferenceData, Unit] = + BoundsType.Fill(padding) + + def calculated[ReferenceData, A](f: (ReferenceData, A) => Bounds): BoundsType[ReferenceData, A] = + BoundsType.Calculated(f) + + object Calculated: + def apply[ReferenceData](f: ReferenceData => Bounds): BoundsType[ReferenceData, Unit] = + BoundsType.Calculated((ref, _) => f(ref)) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentEntry.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentEntry.scala new file mode 100644 index 000000000..190c8f479 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentEntry.scala @@ -0,0 +1,16 @@ +package indigoextras.ui.components.datatypes + +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Coords + +/** `ComponentEntry` s record a components model, position, and relevant component typeclass instance for use inside a + * `ComponentGroup`. + */ +final case class ComponentEntry[A, ReferenceData]( + id: ComponentId, + offset: Coords, + model: A, + component: Component[A, ReferenceData], + anchor: Option[Anchor] +): + type Out = A diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentId.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentId.scala new file mode 100644 index 000000000..788bdde66 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentId.scala @@ -0,0 +1,10 @@ +package indigoextras.ui.components.datatypes + +opaque type ComponentId = String + +object ComponentId: + + def apply(value: String): ComponentId = value + def None: ComponentId = "" + + extension (c: ComponentId) def value: String = c diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentLayout.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentLayout.scala new file mode 100644 index 000000000..1686914a0 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ComponentLayout.scala @@ -0,0 +1,39 @@ +package indigoextras.ui.components.datatypes + +/** `ComponentLayout` instructs a `ComponentGroup` how it should layout the components it contains. They are always + * placed one after another, optionally with some padding unless the layout type is `None`. + */ +enum ComponentLayout: + case Horizontal(padding: Padding, overflow: Overflow) + case Vertical(padding: Padding) + + def withPadding(value: Padding): ComponentLayout = + this match + case Horizontal(_, overflow) => Horizontal(value, overflow) + case Vertical(_) => Vertical(value) + + def givePadding: Padding = + this match + case Horizontal(padding, _) => padding + case Vertical(padding) => padding + + def topPadding: Int = givePadding.top + def rightPadding: Int = givePadding.right + def bottomPadding: Int = givePadding.bottom + def leftPadding: Int = givePadding.left + +object ComponentLayout: + + object Horizontal: + def apply(): Horizontal = + Horizontal(Padding.zero, Overflow.Hidden) + def apply(padding: Padding): Horizontal = + Horizontal(padding, Overflow.Hidden) + def apply(overflow: Overflow): Horizontal = + Horizontal(Padding.zero, overflow) + + extension (h: Horizontal) def withOverflow(value: Overflow): Horizontal = h.copy(overflow = value) + + object Vertical: + def apply(): Vertical = + Vertical(Padding.zero) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ContainerLikeFunctions.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ContainerLikeFunctions.scala new file mode 100644 index 000000000..eb1f716f7 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ContainerLikeFunctions.scala @@ -0,0 +1,66 @@ +package indigoextras.ui.components.datatypes + +import indigo.* +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +object ContainerLikeFunctions: + + extension (b: Bounds) + def withPadding(p: Padding): Bounds = + b.moveBy(p.left, p.top).resize(b.width + p.right, b.height + p.bottom) + + def calculateNextOffset[ReferenceData](containerDimensions: Dimensions, layout: ComponentLayout)( + reference: ReferenceData, + components: Batch[ComponentEntry[?, ReferenceData]] + ): Coords = + layout match + case ComponentLayout.Horizontal(padding, Overflow.Hidden) => + components + .takeRight(1) + .headOption + .map(c => c.offset + Coords(c.component.bounds(reference, c.model).withPadding(padding).right, 0)) + .getOrElse(Coords(padding.left, padding.top)) + + case ComponentLayout.Horizontal(padding, Overflow.Wrap) => + val maxY = components + .map(c => c.offset.y + c.component.bounds(reference, c.model).withPadding(padding).height) + .sortWith(_ > _) + .headOption + .getOrElse(0) + + components + .takeRight(1) + .headOption + .map { c => + val padded = c.component.bounds(reference, c.model).withPadding(padding) + val maybeOffset = c.offset + Coords(padded.right, 0) + + if padded.moveBy(maybeOffset).right < containerDimensions.width then maybeOffset + else Coords(padding.left, maxY) + } + .getOrElse(Coords(padding.left, padding.top)) + + case ComponentLayout.Vertical(padding) => + components + .takeRight(1) + .headOption + .map(c => c.offset + Coords(0, c.component.bounds(reference, c.model).withPadding(padding).bottom)) + .getOrElse(Coords(padding.left, padding.top)) + + def present[ReferenceData]( + context: UIContext[ReferenceData], + dimensions: Dimensions, + components: Batch[ComponentEntry[?, ReferenceData]] + ): Outcome[Layer] = + components + .map { c => + c.component.present( + context.copy(bounds = Bounds(context.bounds.moveBy(c.offset).coords, dimensions)), + c.model + ) + } + .sequence + .map(_.foldLeft(Layer.Stack.empty)(_ :+ _)) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/FitMode.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/FitMode.scala new file mode 100644 index 000000000..6fecbbd15 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/FitMode.scala @@ -0,0 +1,27 @@ +package indigoextras.ui.components.datatypes + +/** Fit mode describes how dynamic bounds decide to expand and shink based on their contents or the available space, in + * one dimension, i.e. width or height. This allows us to say "fill the available width but shrink to fit the contents + * vertically." + */ +enum FitMode derives CanEqual: + + /** Fills the available space in one dimension, this is like BoundsType.Inherit in only one dimension. + */ + case Available + + /** Fills the available space in one dimension, plus an offset amount, which can be negative. + */ + case Offset(offset: Int) + + /** Fits the size of the group's contents in one dimension. + */ + case Content + + /** Fixes the size in one dimension. + */ + case Fixed(units: Int) + + /** Fills the available space in one dimension, but only up to a certain percentage of the available space. + */ + case Relative(amount: Double) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Overflow.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Overflow.scala new file mode 100644 index 000000000..c92b4f94c --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Overflow.scala @@ -0,0 +1,7 @@ +package indigoextras.ui.components.datatypes + +/** Overflow describes what to do in the event that a component's layout position is beyond the bounds of the + * `ComponentGroup`. + */ +enum Overflow derives CanEqual: + case Hidden, Wrap diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Padding.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Padding.scala new file mode 100644 index 000000000..a3c0f6cdf --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/Padding.scala @@ -0,0 +1,29 @@ +package indigoextras.ui.components.datatypes + +/** Describes the padding between components. + */ +final case class Padding(top: Int, right: Int, bottom: Int, left: Int): + def withTop(amount: Int): Padding = this.copy(top = amount) + def withRight(amount: Int): Padding = this.copy(right = amount) + def withBottom(amount: Int): Padding = this.copy(bottom = amount) + def withLeft(amount: Int): Padding = this.copy(left = amount) + def withHorizontal(amount: Int): Padding = this.copy(right = amount, left = amount) + def withVertical(amount: Int): Padding = this.copy(top = amount, bottom = amount) + +object Padding: + def apply(amount: Int): Padding = + Padding(amount, amount, amount, amount) + def apply(topAndBottom: Int, leftAndRight: Int): Padding = + Padding(topAndBottom, leftAndRight, topAndBottom, leftAndRight) + def apply(top: Int, leftAndRight: Int, bottom: Int): Padding = + Padding(top, leftAndRight, bottom, leftAndRight) + + val zero: Padding = Padding(0) + val one: Padding = Padding(1) + + def top(amount: Int): Padding = Padding(amount, 0, 0, 0) + def right(amount: Int): Padding = Padding(0, amount, 0, 0) + def bottom(amount: Int): Padding = Padding(0, 0, amount, 0) + def left(amount: Int): Padding = Padding(0, 0, 0, amount) + def horizontal(amount: Int): Padding = Padding(0, amount, 0, amount) + def verticl(amount: Int): Padding = Padding(amount, 0, amount, 0) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ScrollOptions.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ScrollOptions.scala new file mode 100644 index 000000000..1dfd74aba --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/ScrollOptions.scala @@ -0,0 +1,29 @@ +package indigoextras.ui.components.datatypes + +final case class ScrollOptions( + scrollMode: ScrollMode, + minScrollSpeed: Int, + maxScrollSpeed: Int +): + + def withScrollMode(value: ScrollMode): ScrollOptions = + copy(scrollMode = value) + + def withMaxScrollSpeed(value: Int): ScrollOptions = + copy(maxScrollSpeed = value) + + def withMinScrollSpeed(value: Int): ScrollOptions = + copy(minScrollSpeed = value) + + def isEnabled: Boolean = + scrollMode != ScrollMode.None + + def isDisabled: Boolean = + scrollMode == ScrollMode.None + +object ScrollOptions: + val default: ScrollOptions = ScrollOptions(ScrollMode.Vertical, 1, 10) + +enum ScrollMode derives CanEqual: + case None + case Vertical diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/SwitchState.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/SwitchState.scala new file mode 100644 index 000000000..8d7b6c926 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/components/datatypes/SwitchState.scala @@ -0,0 +1,20 @@ +package indigoextras.ui.components.datatypes + +enum SwitchState derives CanEqual: + case On, Off + + def toggle: SwitchState = + this match + case On => Off + case Off => On + + def toBoolean: Boolean = + this match + case On => true + case Off => false + + def isOn: Boolean = + this == On + + def isOff: Boolean = + this == Off \ No newline at end of file diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Bounds.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Bounds.scala new file mode 100644 index 000000000..826d14589 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Bounds.scala @@ -0,0 +1,97 @@ +package indigoextras.ui.datatypes + +import indigo.* + +/** Represents a rectangle on the ui grid, rather than a rectangle on the screen. + */ +opaque type Bounds = Rectangle + +object Bounds: + + inline def apply(r: Rectangle): Bounds = r + inline def apply(x: Int, y: Int, width: Int, height: Int): Bounds = Rectangle(x, y, width, height) + inline def apply(width: Int, height: Int): Bounds = Rectangle(0, 0, width, height) + inline def apply(dimensions: Dimensions): Bounds = Rectangle(dimensions.toSize) + inline def apply(coords: Coords, dimensions: Dimensions): Bounds = + Rectangle(coords.toPoint, dimensions.toSize) + + val zero: Bounds = Bounds(0, 0, 0, 0) + + extension (r: Bounds) + inline def unsafeToRectangle: Rectangle = r + inline def coords: Coords = Coords(r.position) + inline def dimensions: Dimensions = Dimensions(r.size) + inline def toScreenSpace(charSize: Size): Rectangle = + Rectangle(r.position * charSize.toPoint, r.size * charSize) + + inline def x: Int = r.x + inline def y: Int = r.y + inline def width: Int = r.width + inline def height: Int = r.height + + inline def left: Int = if width >= 0 then x else x + width + inline def right: Int = if width >= 0 then x + width else x + inline def top: Int = if height >= 0 then y else y + height + inline def bottom: Int = if height >= 0 then y + height else y + + inline def horizontalCenter: Int = x + (width / 2) + inline def verticalCenter: Int = y + (height / 2) + + inline def topLeft: Coords = Coords(left, top) + inline def topRight: Coords = Coords(right, top) + inline def bottomRight: Coords = Coords(right, bottom) + inline def bottomLeft: Coords = Coords(left, bottom) + inline def center: Coords = Coords(horizontalCenter, verticalCenter) + inline def halfSize: Dimensions = (dimensions / 2).abs + + def +(other: Bounds): Bounds = + Bounds(x + other.x, y + other.y, width + other.width, height + other.height) + def -(other: Bounds): Bounds = + Bounds(x - other.x, y - other.y, width - other.width, height - other.height) + def *(other: Bounds): Bounds = + Bounds(x * other.x, y * other.y, width * other.width, height * other.height) + def /(other: Bounds): Bounds = + Bounds(x / other.x, y / other.y, width / other.width, height / other.height) + + def contains(coords: Coords): Boolean = + r.contains(coords.unsafeToPoint) + def contains(x: Int, y: Int): Boolean = + contains(Coords(x, y)) + + def moveBy(coords: Coords): Bounds = + r.moveBy(coords.toPoint) + def moveBy(x: Int, y: Int): Bounds = + moveBy(Point(x, y)) + + def moveTo(coords: Coords): Bounds = + r.moveTo(coords.toPoint) + def moveTo(x: Int, y: Int): Bounds = + moveTo(Point(x, y)) + + def resize(newSize: Dimensions): Bounds = + r.resize(newSize.toSize) + def resize(x: Int, y: Int): Bounds = + resize(Size(x, y)) + + def resizeBy(amount: Dimensions): Bounds = + r.resizeBy(amount.toSize) + def resizeBy(x: Int, y: Int): Bounds = + resizeBy(Size(x, y)) + + def withPosition(coords: Coords): Bounds = + moveTo(coords.toPoint) + def withPosition(x: Int, y: Int): Bounds = + moveTo(Point(x, y)) + + def withDimensions(newSize: Dimensions): Bounds = + resize(newSize) + def withDimensions(x: Int, y: Int): Bounds = + resize(Size(x, y)) + + def withWidth(newWidth: Int): Bounds = + resize(Size(newWidth, height)) + def withHeight(newHeight: Int): Bounds = + resize(Size(width, newHeight)) + + def expandToInclude(other: Bounds): Bounds = + Bounds(Rectangle.expandToInclude(r, other)) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Coords.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Coords.scala new file mode 100644 index 000000000..381763679 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Coords.scala @@ -0,0 +1,38 @@ +package indigoextras.ui.datatypes + +import indigo.* + +/** Represents a position on the ui grid, rather than a position on the screen. + */ +opaque type Coords = Point + +object Coords: + + inline def apply(value: Int): Coords = Point(value) + inline def apply(x: Int, y: Int): Coords = Point(x, y) + inline def apply(point: Point): Coords = point + + def fromScreenSpace(pt: Point, charSize: Size): Coords = + Coords(pt / charSize.toPoint) + + val zero: Coords = Coords(0, 0) + + extension (c: Coords) + private[datatypes] inline def toPoint: Point = c + inline def unsafeToPoint: Point = c + inline def toDimensions: Dimensions = Dimensions(c.toSize) + inline def toScreenSpace(charSize: Size): Point = c * charSize.toPoint + + inline def x: Int = c.x + inline def y: Int = c.y + + inline def +(other: Coords): Coords = c + other + inline def +(i: Int): Coords = c + i + inline def -(other: Coords): Coords = c - other + inline def -(i: Int): Coords = c - i + inline def *(other: Coords): Coords = c * other + inline def *(i: Int): Coords = c * i + inline def /(other: Coords): Coords = c / other + inline def /(i: Int): Coords = c / i + + inline def abs: Coords = c.abs diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Dimensions.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Dimensions.scala new file mode 100644 index 000000000..1ce2d7453 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/Dimensions.scala @@ -0,0 +1,44 @@ +package indigoextras.ui.datatypes + +import indigo.* + +/** Represents a size on the ui grid, rather than a position on the screen. + */ +opaque type Dimensions = Size + +object Dimensions: + + inline def apply(value: Int): Dimensions = Size(value) + inline def apply(width: Int, height: Int): Dimensions = Size(width, height) + inline def apply(size: Size): Dimensions = size + + val zero: Dimensions = Dimensions(0, 0) + + extension (d: Dimensions) + private[datatypes] inline def toSize: Size = d + inline def unsafeToSize: Size = d + inline def toCoords: Coords = Coords(d.toPoint) + inline def toScreenSpace(charSize: Size): Size = d * charSize + + inline def width: Int = d.width + inline def height: Int = d.height + + inline def +(other: Dimensions): Dimensions = d + other + inline def +(i: Int): Dimensions = d + i + inline def -(other: Dimensions): Dimensions = d - other + inline def -(i: Int): Dimensions = d - i + inline def *(other: Dimensions): Dimensions = d * other + inline def *(i: Int): Dimensions = d * i + inline def /(other: Dimensions): Dimensions = d / other + inline def /(i: Int): Dimensions = d / i + + inline def min(other: Dimensions): Dimensions = d.min(other) + inline def min(value: Int): Dimensions = d.min(value) + + inline def max(other: Dimensions): Dimensions = d.max(other) + inline def max(value: Int): Dimensions = d.max(value) + + inline def abs: Dimensions = d.abs + + def withWidth(value: Int): Dimensions = Dimensions(value, d.height) + def withHeight(value: Int): Dimensions = Dimensions(d.width, value) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/UIContext.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/UIContext.scala new file mode 100644 index 000000000..145d48f9e --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/datatypes/UIContext.scala @@ -0,0 +1,110 @@ +package indigoextras.ui.datatypes + +import indigo.* +import indigo.scenes.SceneContext + +final case class UIContext[ReferenceData]( + // Specific to UIContext + bounds: Bounds, + snapGrid: Size, + pointerCoords: Coords, + state: UIState, + magnification: Int, + additionalOffset: Coords, + // The following are all the same as in SubSystemContext + reference: ReferenceData, + frame: Context.Frame, + services: Context.Services +): + lazy val screenSpaceBounds: Rectangle = + bounds.toScreenSpace(snapGrid) + + def moveBoundsBy(offset: Coords): UIContext[ReferenceData] = + this.copy(bounds = bounds.moveBy(offset)) + + val isActive: Boolean = + state == UIState.Active + + def unitReference: UIContext[Unit] = + this.copy(reference = ()) + + def withAdditionalOffset(offset: Coords): UIContext[ReferenceData] = + this.copy(additionalOffset = offset) + +object UIContext: + + def apply[ReferenceData]( + subSystemContext: SubSystemContext[ReferenceData], + snapGrid: Size, + magnification: Int + ): UIContext[ReferenceData] = + val pointerCoords = Coords(subSystemContext.frame.input.pointers.position / snapGrid.toPoint) + UIContext( + Bounds.zero, + snapGrid, + pointerCoords, + UIState.Active, + magnification, + Coords.zero, + subSystemContext.reference, + subSystemContext.frame, + subSystemContext.services + ) + + def apply[ReferenceData]( + subSystemContext: SubSystemContext[ReferenceData], + snapGrid: Size, + magnification: Int, + additionalOffset: Coords + ): UIContext[ReferenceData] = + val pointerCoords = Coords(subSystemContext.frame.input.pointers.position / snapGrid.toPoint) + UIContext( + Bounds.zero, + snapGrid, + pointerCoords, + UIState.Active, + magnification, + additionalOffset, + subSystemContext.reference, + subSystemContext.frame, + subSystemContext.services + ) + + def fromContext[ReferenceData]( + ctx: Context[?], + reference: ReferenceData + ): UIContext[ReferenceData] = + UIContext( + Bounds.zero, + Size(1), + Coords.zero, + UIState.Active, + 1, + Coords.zero, + reference, + ctx.frame, + ctx.services + ) + + def fromSceneContext[ReferenceData]( + ctx: SceneContext[?], + reference: ReferenceData + ): UIContext[ReferenceData] = + fromContext(ctx.toFrameContext, reference) + + def fromSubSystemContext[ReferenceData]( + ctx: SubSystemContext[?], + reference: ReferenceData + ): UIContext[ReferenceData] = + fromContext(ctx.toContext, reference) + +enum UIState derives CanEqual: + case Active, InActive + + def isActive: Boolean = + this match + case UIState.Active => true + case UIState.InActive => false + + def isInActive: Boolean = + !isActive diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/package.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/package.scala new file mode 100644 index 000000000..059ebd34a --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/package.scala @@ -0,0 +1,123 @@ +package indigoextras + +package object ui { + + // Component + + type Component[A, ReferenceData] = indigoextras.ui.component.Component[A, ReferenceData] + val Component: indigoextras.ui.component.Component.type = indigoextras.ui.component.Component + + // Components + + type Button[ReferenceData] = indigoextras.ui.components.Button[ReferenceData] + val Button: indigoextras.ui.components.Button.type = indigoextras.ui.components.Button + + type ComponentGroup[ReferenceData] = indigoextras.ui.components.ComponentGroup[ReferenceData] + val ComponentGroup: indigoextras.ui.components.ComponentGroup.type = indigoextras.ui.components.ComponentGroup + + type ComponentList[ReferenceData] = indigoextras.ui.components.ComponentList[ReferenceData] + val ComponentList: indigoextras.ui.components.ComponentList.type = indigoextras.ui.components.ComponentList + + type HitArea[ReferenceData] = indigoextras.ui.components.HitArea[ReferenceData] + val HitArea: indigoextras.ui.components.HitArea.type = indigoextras.ui.components.HitArea + + type Input = indigoextras.ui.components.Input + val Input: indigoextras.ui.components.Input.type = indigoextras.ui.components.Input + + type Label[ReferenceData] = indigoextras.ui.components.Label[ReferenceData] + val Label: indigoextras.ui.components.Label.type = indigoextras.ui.components.Label + + type MaskedPane[A, ReferenceData] = indigoextras.ui.components.MaskedPane[A, ReferenceData] + val MaskedPane: indigoextras.ui.components.MaskedPane.type = indigoextras.ui.components.MaskedPane + + type ScrollPane[A, ReferenceData] = indigoextras.ui.components.ScrollPane[A, ReferenceData] + val ScrollPane: indigoextras.ui.components.ScrollPane.type = indigoextras.ui.components.ScrollPane + + type Switch[ReferenceData] = indigoextras.ui.components.Switch[ReferenceData] + val Switch: indigoextras.ui.components.Switch.type = indigoextras.ui.components.Switch + + type TextArea[ReferenceData] = indigoextras.ui.components.TextArea[ReferenceData] + val TextArea: indigoextras.ui.components.TextArea.type = indigoextras.ui.components.TextArea + + // Component datatypes + + type Anchor = indigoextras.ui.components.datatypes.Anchor + val Anchor: indigoextras.ui.components.datatypes.Anchor.type = indigoextras.ui.components.datatypes.Anchor + + type BoundsMode = indigoextras.ui.components.datatypes.BoundsMode + val BoundsMode: indigoextras.ui.components.datatypes.BoundsMode.type = indigoextras.ui.components.datatypes.BoundsMode + + type BoundsType[ReferenceData, A] = indigoextras.ui.components.datatypes.BoundsType[ReferenceData, A] + val BoundsType: indigoextras.ui.components.datatypes.BoundsType.type = indigoextras.ui.components.datatypes.BoundsType + + type ComponentEntry[A, ReferenceData] = indigoextras.ui.components.datatypes.ComponentEntry[A, ReferenceData] + val ComponentEntry: indigoextras.ui.components.datatypes.ComponentEntry.type = + indigoextras.ui.components.datatypes.ComponentEntry + + type ComponentId = indigoextras.ui.components.datatypes.ComponentId + val ComponentId: indigoextras.ui.components.datatypes.ComponentId.type = + indigoextras.ui.components.datatypes.ComponentId + + type ComponentLayout = indigoextras.ui.components.datatypes.ComponentLayout + val ComponentLayout: indigoextras.ui.components.datatypes.ComponentLayout.type = + indigoextras.ui.components.datatypes.ComponentLayout + + type FitMode = indigoextras.ui.components.datatypes.FitMode + val FitMode: indigoextras.ui.components.datatypes.FitMode.type = indigoextras.ui.components.datatypes.FitMode + + type Overflow = indigoextras.ui.components.datatypes.Overflow + val Overflow: indigoextras.ui.components.datatypes.Overflow.type = indigoextras.ui.components.datatypes.Overflow + + type Padding = indigoextras.ui.components.datatypes.Padding + val Padding: indigoextras.ui.components.datatypes.Padding.type = indigoextras.ui.components.datatypes.Padding + + type ScrollOptions = indigoextras.ui.components.datatypes.ScrollOptions + val ScrollOptions: indigoextras.ui.components.datatypes.ScrollOptions.type = + indigoextras.ui.components.datatypes.ScrollOptions + + type SwitchState = indigoextras.ui.components.datatypes.SwitchState + val SwitchState: indigoextras.ui.components.datatypes.SwitchState.type = indigoextras.ui.components.datatypes.SwitchState + + // Datatypes + + type Bounds = indigoextras.ui.datatypes.Bounds + val Bounds: indigoextras.ui.datatypes.Bounds.type = indigoextras.ui.datatypes.Bounds + + type Coords = indigoextras.ui.datatypes.Coords + val Coords: indigoextras.ui.datatypes.Coords.type = indigoextras.ui.datatypes.Coords + + type Dimensions = indigoextras.ui.datatypes.Dimensions + val Dimensions: indigoextras.ui.datatypes.Dimensions.type = indigoextras.ui.datatypes.Dimensions + + type UIContext[ReferenceData] = indigoextras.ui.datatypes.UIContext[ReferenceData] + val UIContext: indigoextras.ui.datatypes.UIContext.type = indigoextras.ui.datatypes.UIContext + + // Shaders + + type LayerMask = indigoextras.ui.shaders.LayerMask + val LayerMask: indigoextras.ui.shaders.LayerMask.type = indigoextras.ui.shaders.LayerMask + + // Window + + type Window[A, ReferenceData] = indigoextras.ui.window.Window[A, ReferenceData] + val Window: indigoextras.ui.window.Window.type = indigoextras.ui.window.Window + + type WindowContext = indigoextras.ui.window.WindowContext + val WindowContext: indigoextras.ui.window.WindowContext.type = indigoextras.ui.window.WindowContext + + type WindowEvent = indigoextras.ui.window.WindowEvent + val WindowEvent: indigoextras.ui.window.WindowEvent.type = indigoextras.ui.window.WindowEvent + + type WindowId = indigoextras.ui.window.WindowId + val WindowId: indigoextras.ui.window.WindowId.type = indigoextras.ui.window.WindowId + + type WindowManager[StartUpData, Model, RefData] = indigoextras.ui.window.WindowManager[StartUpData, Model, RefData] + val WindowManager: indigoextras.ui.window.WindowManager.type = indigoextras.ui.window.WindowManager + + type WindowMode = indigoextras.ui.window.WindowMode + val WindowMode: indigoextras.ui.window.WindowMode.type = indigoextras.ui.window.WindowMode + + type Space = indigoextras.ui.window.Space + val Space: indigoextras.ui.window.Space.type = indigoextras.ui.window.Space + +} diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/shaders/LayerMask.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/shaders/LayerMask.scala new file mode 100644 index 000000000..e2b78fe92 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/shaders/LayerMask.scala @@ -0,0 +1,64 @@ +package indigoextras.ui.shaders + +import indigo.* +import indigo.shared.shader.ShaderPrimitive +import indigo.shared.shader.Uniform +import indigo.shared.shader.UniformBlock +import indigo.shared.shader.UniformBlockName +import ultraviolet.syntax.* + +import scala.annotation.nowarn + +final case class LayerMask(mask: Rectangle) extends BlendMaterial: + lazy val toShaderData: ShaderData = + ShaderData( + LayerMask.shader.id, + Batch( + UniformBlock( + UniformBlockName("MaskBounds"), + Batch( + Uniform("MASK_BOUNDS") -> ShaderPrimitive.vec4.fromRectangle(mask) + ) + ) + ) + ) + +object LayerMask: + val shader: UltravioletShader = + UltravioletShader.blendFragment( + ShaderId("[indigo]-ui-masked-layer"), + BlendShader.fragment( + fragment, + Env.ref + ) + ) + + final case class Env( + MASK_BOUNDS: vec4 + ) extends BlendFragmentEnvReference + + object Env: + val ref = + Env( + vec4(1.0f) + ) + + final case class MaskBounds( + MASK_BOUNDS: vec4 + ) + + @nowarn("msg=unused") + inline def fragment = + Shader[Env] { env => + + ubo[MaskBounds] + + def fragment(color: vec4): vec4 = + val x = env.MASK_BOUNDS.x / env.SIZE.x + val y = env.MASK_BOUNDS.y / env.SIZE.y + val w = env.MASK_BOUNDS.z / env.SIZE.x + val h = env.MASK_BOUNDS.w / env.SIZE.y + + if env.UV.x > x && env.UV.x < x + w && env.UV.y > y && env.UV.y < y + h then env.SRC + else vec4(0.0f) + } diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/syntax.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/syntax.scala new file mode 100644 index 000000000..b0c495fa1 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/syntax.scala @@ -0,0 +1,24 @@ +package indigoextras.ui + +import indigo.GlobalEvent +import indigo.Layer +import indigo.Outcome + +object syntax: + + extension [A, ReferenceData](component: A)(using c: Component[A, ReferenceData]) + def update[StartupData, ContextData]( + context: UIContext[ReferenceData] + ): GlobalEvent => Outcome[A] = + c.updateModel(context, component) + + def present[StartupData, ContextData]( + context: UIContext[ReferenceData] + ): Outcome[Layer] = + c.present(context, component) + + def refresh( + reference: ReferenceData, + parentDimensions: Dimensions + ): A = + c.refresh(reference, component, parentDimensions) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/Space.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/Space.scala new file mode 100644 index 000000000..eeb12646f --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/Space.scala @@ -0,0 +1,5 @@ +package indigoextras.ui.window + +enum Space derives CanEqual: + case Screen + case Window \ No newline at end of file diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/Window.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/Window.scala new file mode 100644 index 000000000..766f44589 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/Window.scala @@ -0,0 +1,163 @@ +package indigoextras.ui.window + +import indigo.* +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +final case class Window[A, ReferenceData]( + id: WindowId, + snapGrid: Size, + bounds: Bounds, + content: A, + component: Component[A, ReferenceData], + hasFocus: Boolean, + minSize: Dimensions, + maxSize: Option[Dimensions], + state: WindowState, + background: WindowContext => Outcome[Layer], + mode: WindowMode +): + + def withId(value: WindowId): Window[A, ReferenceData] = + this.copy(id = value) + + def withBounds(value: Bounds): Window[A, ReferenceData] = + this.copy(bounds = value) + + def withPosition(value: Coords): Window[A, ReferenceData] = + withBounds(bounds.moveTo(value)) + def moveTo(position: Coords): Window[A, ReferenceData] = + withPosition(position) + def moveTo(x: Int, y: Int): Window[A, ReferenceData] = + moveTo(Coords(x, y)) + def moveBy(amount: Coords): Window[A, ReferenceData] = + withPosition(bounds.coords + amount) + def moveBy(x: Int, y: Int): Window[A, ReferenceData] = + moveBy(Coords(x, y)) + + def withDimensions(value: Dimensions): Window[A, ReferenceData] = + val d = value.max(minSize) + withBounds(bounds.withDimensions(maxSize.fold(d)(_.min(d)))) + def resizeTo(size: Dimensions): Window[A, ReferenceData] = + withDimensions(size) + def resizeTo(x: Int, y: Int): Window[A, ReferenceData] = + resizeTo(Dimensions(x, y)) + def resizeBy(amount: Dimensions): Window[A, ReferenceData] = + withDimensions(bounds.dimensions + amount) + def resizeBy(x: Int, y: Int): Window[A, ReferenceData] = + resizeBy(Dimensions(x, y)) + + def withModel(value: A): Window[A, ReferenceData] = + this.copy(content = value) + + def withFocus(value: Boolean): Window[A, ReferenceData] = + this.copy(hasFocus = value) + def focus: Window[A, ReferenceData] = + withFocus(true) + def blur: Window[A, ReferenceData] = + withFocus(false) + + def withMinSize(min: Dimensions): Window[A, ReferenceData] = + this.copy(minSize = min) + def withMinSize(width: Int, height: Int): Window[A, ReferenceData] = + this.copy(minSize = Dimensions(width, height)) + + def withMaxSize(max: Dimensions): Window[A, ReferenceData] = + this.copy(maxSize = Option(max)) + def withMaxSize(width: Int, height: Int): Window[A, ReferenceData] = + this.copy(maxSize = Option(Dimensions(width, height))) + def noMaxSize: Window[A, ReferenceData] = + this.copy(maxSize = None) + + def withState(value: WindowState): Window[A, ReferenceData] = + this.copy(state = value) + def open: Window[A, ReferenceData] = + withState(WindowState.Open) + def close: Window[A, ReferenceData] = + withState(WindowState.Closed) + + def isOpen: Boolean = + state == WindowState.Open + def isClosed: Boolean = + state == WindowState.Closed + + def refresh(reference: ReferenceData): Window[A, ReferenceData] = + this.copy(content = + component.refresh( + reference, + content, + bounds.dimensions + ) + ) + + def withBackground(present: WindowContext => Outcome[Layer]): Window[A, ReferenceData] = + this.copy(background = present) + + def withWindowMode(value: WindowMode): Window[A, ReferenceData] = + this.copy(mode = value) + def modal: Window[A, ReferenceData] = + withWindowMode(WindowMode.Modal) + def standard: Window[A, ReferenceData] = + withWindowMode(WindowMode.Standard) + +object Window: + + def apply[A, ReferenceData]( + id: WindowId, + snapGrid: Size, + minSize: Dimensions, + content: A + )(using c: Component[A, ReferenceData]): Window[A, ReferenceData] = + Window( + id, + snapGrid, + Bounds(Coords.zero, minSize), + content, + c, + false, + minSize, + None, + WindowState.Closed, + _ => Outcome(Layer.empty), + WindowMode.Standard + ) + + def apply[A, ReferenceData]( + id: WindowId, + snapGrid: Size, + minSize: Dimensions, + content: A + )( + background: WindowContext => Outcome[Layer] + )(using c: Component[A, ReferenceData]): Window[A, ReferenceData] = + Window( + id, + snapGrid, + Bounds(Coords.zero, minSize), + content, + c, + false, + minSize, + None, + WindowState.Closed, + background, + WindowMode.Standard + ) + + def updateModel[A, ReferenceData]( + context: UIContext[ReferenceData], + window: Window[A, ReferenceData] + ): GlobalEvent => Outcome[Window[A, ReferenceData]] = + case e => + val minBounds = window.bounds.withDimensions(window.bounds.dimensions.max(window.minSize)) + + window.component + .updateModel( + context + .copy(bounds = minBounds), + window.content + )(e) + .map(m => window.withModel(m).withBounds(minBounds)) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowContext.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowContext.scala new file mode 100644 index 000000000..2d927c0ad --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowContext.scala @@ -0,0 +1,20 @@ +package indigoextras.ui.window + +import indigoextras.ui.datatypes.Bounds + +final case class WindowContext( + bounds: Bounds, + hasFocus: Boolean, + pointerIsOver: Boolean, + magnification: Int +) + +object WindowContext: + + def from(model: Window[?, ?], viewModel: WindowViewModel[?]): WindowContext = + WindowContext( + model.bounds, + model.hasFocus, + viewModel.pointerIsOver, + viewModel.magnification + ) \ No newline at end of file diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowEvent.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowEvent.scala new file mode 100644 index 000000000..bbf9d4d46 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowEvent.scala @@ -0,0 +1,79 @@ +package indigoextras.ui.window + +import indigo.* +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions + +enum WindowEvent extends GlobalEvent derives CanEqual: + + // Events sent to the game + + /** Informs the game when the pointer moves into a window's bounds */ + case PointerOver(id: WindowId) + + /** Informs the game when the pointer moves out of a window's bounds */ + case PointerOut(id: WindowId) + + /** Informs the game when a window has resized */ + case Resized(id: WindowId) + + /** Informs the game when a window has opened */ + case Opened(id: WindowId) + + /** Informs the game when a window has closed */ + case Closed(id: WindowId) + + // User sent events + + /** Tells a window to open */ + case Open(id: WindowId) + + /** Tells a window to open at a specific location */ + case OpenAt(id: WindowId, coords: Coords) + + /** Tells a window to close */ + case Close(id: WindowId) + + /** Tells a window to toggle between open and closed. */ + case Toggle(id: WindowId) + + /** Brings a window into focus */ + case Focus(id: WindowId) + + /** Focuses the top window at the given location */ + case GiveFocusAt(coords: Coords) + + /** Moves a window to the location given */ + case Move(id: WindowId, position: Coords, space: Space) + + /** Resizes a window to a given size */ + case Resize(id: WindowId, dimensions: Dimensions, space: Space) + + /** Changes the bounds of a window */ + case Transform(id: WindowId, bounds: Bounds, space: Space) + + /** Changes the magnification of all windows */ + case ChangeMagnification(newMagnification: Int) + + /** Tells a window request its content to refresh */ + case Refresh(id: WindowId) + + def windowId: Option[WindowId] = + this match + case PointerOver(id) => Some(id) + case PointerOut(id) => Some(id) + case Resized(id) => Some(id) + case Opened(id) => Some(id) + case Closed(id) => Some(id) + case Open(id) => Some(id) + case OpenAt(id, _) => Some(id) + case Close(id) => Some(id) + case Toggle(id) => Some(id) + case Move(id, _, _) => Some(id) + case Resize(id, _, _) => Some(id) + case Transform(id, _, _) => Some(id) + case Refresh(id) => Some(id) + case Focus(id) => Some(id) + case GiveFocusAt(_) => None + case ChangeMagnification(_) => None diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowId.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowId.scala new file mode 100644 index 000000000..651d2d8b4 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowId.scala @@ -0,0 +1,9 @@ +package indigoextras.ui.window + +opaque type WindowId = String + +object WindowId: + def apply(id: String): WindowId = id + extension (id: WindowId) def toString: String = id + + given CanEqual[WindowId, WindowId] = CanEqual.derived \ No newline at end of file diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowInternalEvent.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowInternalEvent.scala new file mode 100644 index 000000000..048b9e3ba --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowInternalEvent.scala @@ -0,0 +1,7 @@ +package indigoextras.ui.window + +import indigo.* + +/** Internal events */ +enum WindowInternalEvent extends GlobalEvent: + case Redraw diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManager.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManager.scala new file mode 100644 index 000000000..01641a230 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManager.scala @@ -0,0 +1,379 @@ +package indigoextras.ui.window + +import indigo.* +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.UIContext +import indigoextras.ui.datatypes.UIState + +final case class WindowManager[StartUpData, Model, RefData]( + id: SubSystemId, + initialMagnification: Int, + snapGrid: Size, + extractReference: Model => RefData, + startUpData: StartUpData, + layerKey: Option[BindingKey], + windows: Batch[Window[?, RefData]] +) extends SubSystem[Model]: + type EventType = GlobalEvent + type ReferenceData = RefData + type SubSystemModel = ModelHolder[ReferenceData] + + def eventFilter: GlobalEvent => Option[GlobalEvent] = + e => Some(e) + + def reference(model: Model): ReferenceData = + extractReference(model) + + def initialModel: Outcome[ModelHolder[ReferenceData]] = + Outcome( + ModelHolder.initial(windows, initialMagnification) + ) + + def update( + context: SubSystemContext[ReferenceData], + model: ModelHolder[ReferenceData] + ): GlobalEvent => Outcome[ModelHolder[ReferenceData]] = + e => + for { + updatedModel <- WindowManager.updateModel[ReferenceData]( + UIContext(context, snapGrid, model.viewModel.magnification), + model.model + )(e) + + updatedViewModel <- + WindowManager.updateViewModel[ReferenceData]( + UIContext(context, snapGrid, model.viewModel.magnification), + updatedModel, + model.viewModel + )(e) + } yield ModelHolder(updatedModel, updatedViewModel) + + def present( + context: SubSystemContext[ReferenceData], + model: ModelHolder[ReferenceData] + ): Outcome[SceneUpdateFragment] = + WindowManager.present( + layerKey, + UIContext(context, snapGrid, model.viewModel.magnification), + model.model, + model.viewModel + ) + + /** Registers a window with the WindowManager. All Window's must be registered before the scene starts. + */ + def register( + windowModels: Window[?, ReferenceData]* + ): WindowManager[StartUpData, Model, ReferenceData] = + register(Batch.fromSeq(windowModels)) + def register( + windowModels: Batch[Window[?, ReferenceData]] + ): WindowManager[StartUpData, Model, ReferenceData] = + this.copy(windows = windows ++ windowModels) + + /** Sets which windows are initially open. Once the scene is running, opening and closing is managed by the + * WindowManagerModel via events. + */ + def open(ids: WindowId*): WindowManager[StartUpData, Model, ReferenceData] = + open(Batch.fromSeq(ids)) + def open(ids: Batch[WindowId]): WindowManager[StartUpData, Model, ReferenceData] = + this.copy(windows = windows.map(w => if ids.exists(_ == w.id) then w.open else w)) + + /** Sets which window is initially focused. Once the scene is running, focusing is managed by the WindowManagerModel + * via events. + */ + def focus(id: WindowId): WindowManager[StartUpData, Model, ReferenceData] = + val reordered = + windows.find(_.id == id) match + case None => + windows + + case Some(w) => + windows.filterNot(_.id == w.id).map(_.blur) :+ w.focus + + this.copy(windows = reordered) + + def withStartupData[A](newStartupData: A): WindowManager[A, Model, ReferenceData] = + WindowManager( + id, + initialMagnification, + snapGrid, + extractReference, + newStartupData, + layerKey, + windows + ) + + /** Allows you to set the layer key that the WindowManager will use to present the windows. + */ + def withLayerKey(newLayerKey: BindingKey): WindowManager[StartUpData, Model, ReferenceData] = + this.copy(layerKey = Option(newLayerKey)) + +object WindowManager: + + /** Creates a WindowManager instance with no snap grid, that respects the magnification specified. + */ + def apply[Model](id: SubSystemId): WindowManager[Unit, Model, Unit] = + WindowManager(id, 1, Size(1), _ => (), (), None, Batch.empty) + + /** Creates a WindowManager instance with no snap grid, that respects the magnification specified. + */ + def apply[Model]( + id: SubSystemId, + magnification: Int + ): WindowManager[Unit, Model, Unit] = + WindowManager(id, magnification, Size(1), _ => (), (), None, Batch.empty) + + /** Creates a WindowManager instance with no snap grid, that respects the magnification specified. + */ + def apply[Model]( + id: SubSystemId, + magnification: Int, + snapGrid: Size + ): WindowManager[Unit, Model, Unit] = + WindowManager(id, magnification, Size(1), _ => (), (), None, Batch.empty) + + def apply[Model, ReferenceData]( + id: SubSystemId, + magnification: Int, + snapGrid: Size, + extractReference: Model => ReferenceData + ): WindowManager[Unit, Model, ReferenceData] = + WindowManager(id, magnification, snapGrid, extractReference, (), None, Batch.empty) + + def apply[StartUpData, Model, ReferenceData]( + id: SubSystemId, + magnification: Int, + snapGrid: Size, + extractReference: Model => ReferenceData, + startUpData: StartUpData + ): WindowManager[StartUpData, Model, ReferenceData] = + WindowManager(id, magnification, snapGrid, extractReference, startUpData, None, Batch.empty) + + def apply[StartUpData, Model, ReferenceData]( + id: SubSystemId, + magnification: Int, + snapGrid: Size, + extractReference: Model => ReferenceData, + startUpData: StartUpData, + layerKey: BindingKey + ): WindowManager[StartUpData, Model, ReferenceData] = + WindowManager( + id, + magnification, + snapGrid, + extractReference, + startUpData, + Option(layerKey), + Batch.empty + ) + + private def modalWindowOpen[ReferenceData]( + model: WindowManagerModel[ReferenceData] + ): Option[WindowId] = + model.windows.find(w => w.isOpen && w.mode == WindowMode.Modal).map(_.id) + + private[window] def updateModel[ReferenceData]( + context: UIContext[ReferenceData], + model: WindowManagerModel[ReferenceData] + ): GlobalEvent => Outcome[WindowManagerModel[ReferenceData]] = + case e: WindowEvent => + modalWindowOpen(model) match + case None => + handleWindowEvents(context, model)(e) + + case modelId => + if modelId == e.windowId then handleWindowEvents(context, model)(e) + else Outcome(model) + + case e: PointerEvent.Click => + updateWindows(context, model, modalWindowOpen(model))(e) + .addGlobalEvents(WindowEvent.GiveFocusAt(context.pointerCoords)) + + case FrameTick => + modalWindowOpen(model) match + case None => + updateWindows(context, model, None)(FrameTick) + + case _id @ Some(id) => + updateWindows(context, model, _id)(FrameTick).map(_.focusOn(id)) + + case e => + updateWindows(context, model, modalWindowOpen(model))(e) + + private def updateWindows[ReferenceData]( + context: UIContext[ReferenceData], + model: WindowManagerModel[ReferenceData], + modalWindow: Option[WindowId] + ): GlobalEvent => Outcome[WindowManagerModel[ReferenceData]] = + e => + val windowUnderPointer = model.windowAt(context.pointerCoords) + + model.windows + .map { w => + Window.updateModel( + context.copy(state = modalWindow match + case Some(id) if id == w.id => + UIState.Active + + case Some(_) => + UIState.InActive + + case None => + if w.hasFocus || windowUnderPointer.exists(_ == w.id) then UIState.Active + else UIState.InActive + ), + w + )(e) + } + .sequence + .map(m => model.copy(windows = m)) + + private def handleWindowEvents[ReferenceData]( + context: UIContext[ReferenceData], + model: WindowManagerModel[ReferenceData] + ): WindowEvent => Outcome[WindowManagerModel[ReferenceData]] = + case WindowEvent.Refresh(id) => + model.refresh(id, context.reference) + + case WindowEvent.Focus(id) => + Outcome(model.focusOn(id)) + + case WindowEvent.GiveFocusAt(position) => + Outcome(model.focusAt(position)) + .addGlobalEvents(WindowInternalEvent.Redraw) + + case WindowEvent.Open(id) => + model.open(id).addGlobalEvents(WindowEvent.Focus(id)) + + case WindowEvent.OpenAt(id, coords) => + model + .open(id) + .map(_.moveTo(id, coords, Space.Screen)) + .addGlobalEvents(WindowEvent.Focus(id)) + + case WindowEvent.Close(id) => + model.close(id) + + case WindowEvent.Toggle(id) => + model.toggle(id) + + case WindowEvent.Move(id, coords, space) => + Outcome(model.moveTo(id, coords, space)) + + case WindowEvent.Resize(id, dimensions, space) => + model.resizeTo(id, dimensions, space).refresh(id, context.reference) + + case WindowEvent.Transform(id, bounds, space) => + model.transformTo(id, bounds, space).refresh(id, context.reference) + + case WindowEvent.Opened(_) => + Outcome(model) + + case WindowEvent.Closed(_) => + Outcome(model) + + case WindowEvent.Resized(_) => + Outcome(model) + + case WindowEvent.PointerOver(_) => + Outcome(model) + + case WindowEvent.PointerOut(_) => + Outcome(model) + + case WindowEvent.ChangeMagnification(_) => + Outcome(model) + + private[window] def updateViewModel[ReferenceData]( + context: UIContext[ReferenceData], + model: WindowManagerModel[ReferenceData], + viewModel: WindowManagerViewModel[ReferenceData] + ): GlobalEvent => Outcome[WindowManagerViewModel[ReferenceData]] = + case WindowEvent.ChangeMagnification(next) => + Outcome(viewModel.changeMagnification(next)) + + case e => + val windowUnderPointer = model.windowAt(context.pointerCoords) + + val updated = + val prunedVM = viewModel.prune(model) + model.windows.flatMap { m => + if m.isClosed then Batch.empty + else + prunedVM.windows.find(_.id == m.id) match + case None => + Batch(Outcome(WindowViewModel.initial(m.id, viewModel.magnification))) + + case Some(vm) => + Batch( + vm.update( + context.copy(state = + if m.hasFocus || windowUnderPointer.exists(_ == m.id) then UIState.Active + else UIState.InActive + ), + m, + e + ) + ) + } + + updated.sequence.map(vm => viewModel.copy(windows = vm)) + + private[window] def present[ReferenceData]( + layerKey: Option[BindingKey], + context: UIContext[ReferenceData], + model: WindowManagerModel[ReferenceData], + viewModel: WindowManagerViewModel[ReferenceData] + ): Outcome[SceneUpdateFragment] = + val windowUnderPointer = model.windowAt(context.pointerCoords) + + val windowLayers: Outcome[Batch[Layer]] = + model.windows + .filter(_.isOpen) + .flatMap { m => + viewModel.windows.find(_.id == m.id) match + case None => + // Shouldn't get here. + Batch.empty + + case Some(vm) => + Batch( + WindowView + .present( + context.copy(state = + if m.hasFocus || windowUnderPointer.exists(_ == m.id) then UIState.Active + else UIState.InActive + ), + m, + vm + ) + ) + } + .sequence + + windowLayers.map { layers => + layerKey match + case None => + SceneUpdateFragment( + LayerEntry(Layer.Stack(layers)) + ) + + case Some(key) => + SceneUpdateFragment( + LayerEntry(key -> Layer.Stack(layers)) + ) + } + +final case class ModelHolder[ReferenceData]( + model: WindowManagerModel[ReferenceData], + viewModel: WindowManagerViewModel[ReferenceData] +) +object ModelHolder: + def initial[ReferenceData]( + windows: Batch[Window[?, ReferenceData]], + magnification: Int + ): ModelHolder[ReferenceData] = + ModelHolder( + WindowManagerModel.initial.register(windows), + WindowManagerViewModel.initial(magnification) + ) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManagerModel.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManagerModel.scala new file mode 100644 index 000000000..f67d5cdfc --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManagerModel.scala @@ -0,0 +1,145 @@ +package indigoextras.ui.window + +import indigo.* +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +final case class WindowManagerModel[ReferenceData](windows: Batch[Window[?, ReferenceData]]): + def register(windowModels: Window[?, ReferenceData]*): WindowManagerModel[ReferenceData] = + register(Batch.fromSeq(windowModels)) + def register( + windowModels: Batch[Window[?, ReferenceData]] + ): WindowManagerModel[ReferenceData] = + this.copy(windows = windows ++ windowModels) + + def open(ids: WindowId*): Outcome[WindowManagerModel[ReferenceData]] = + open(Batch.fromSeq(ids)) + + def open(ids: Batch[WindowId]): Outcome[WindowManagerModel[ReferenceData]] = + Outcome( + this.copy(windows = windows.map(w => if ids.exists(_ == w.id) then w.open else w)), + ids.filter(id => windows.exists(_.id == id)).map(WindowEvent.Opened.apply) + ) + + def close(id: WindowId): Outcome[WindowManagerModel[ReferenceData]] = + Outcome( + this.copy(windows = windows.map(w => if w.id == id then w.close else w)), + Batch(WindowEvent.Closed(id)) + ) + + def toggle(id: WindowId): Outcome[WindowManagerModel[ReferenceData]] = + windows.find(_.id == id).map(_.isOpen) match + case None => + Outcome(this) + + case Some(isOpen) => + Outcome( + this.copy( + windows = windows.map { w => + if w.id == id then if isOpen then w.close else w.open + else w + } + ), + Batch(if isOpen then WindowEvent.Closed(id) else WindowEvent.Opened(id)) + ) + + def focusAt(coords: Coords): WindowManagerModel[ReferenceData] = + val reordered = + windows.reverse.find(w => w.isOpen && w.bounds.contains(coords)) match + case None => + windows.map(_.blur) + + case Some(w) => + windows.filterNot(_.id == w.id).map(_.blur) :+ w.focus + + this.copy(windows = reordered) + + def focusOn(id: WindowId): WindowManagerModel[ReferenceData] = + val reordered = + windows.find(_.id == id) match + case None => + windows + + case Some(w) => + windows.filterNot(_.id == w.id).map(_.blur) :+ w.focus + + this.copy(windows = reordered) + + def windowAt(coords: Coords): Option[WindowId] = + windows.reverse.find(_.bounds.contains(coords)).map(_.id) + + def moveTo( + id: WindowId, + position: Coords, + space: Space + ): WindowManagerModel[ReferenceData] = + this.copy( + windows = windows.map { w => + if w.id == id then + space match + case Space.Screen => + w.moveTo(position) + + case Space.Window => + // The coords are relative to the window, so we need to adjust them to screen coords. + w.moveTo(position + w.bounds.coords) + else w + } + ) + + def resizeTo( + id: WindowId, + dimensions: Dimensions, + space: Space + ): WindowManagerModel[ReferenceData] = + this.copy( + windows = windows.map { w => + if w.id == id then + space match + case Space.Screen => + // The dimensions are relative to the screen, so we need to adjust them to window dimensions. + w.resizeTo(dimensions - w.bounds.coords.toDimensions) + + case Space.Window => + w.resizeTo(dimensions) + else w + } + ) + + def transformTo( + id: WindowId, + bounds: Bounds, + space: Space + ): WindowManagerModel[ReferenceData] = + this.copy( + windows = windows.map { w => + // Note: We do _not_ use .withBounds here because that won't do the min size checks. + if w.id == id then + space match + case Space.Screen => + // See above (moveTo / resizeTo) for the reasoning behind these adjustments. + w.moveTo(bounds.coords).resizeTo(bounds.dimensions - w.bounds.coords.toDimensions) + + case Space.Window => + // See above (moveTo / resizeTo) for the reasoning behind these adjustments. + w.moveTo(bounds.coords + w.bounds.coords).resizeTo(bounds.dimensions) + else w + } + ) + + def refresh(id: WindowId, reference: ReferenceData): Outcome[WindowManagerModel[ReferenceData]] = + Outcome( + this.copy(windows = windows.map(w => if w.id == id then w.refresh(reference) else w)) + ) + + def update( + context: UIContext[ReferenceData], + event: GlobalEvent + ): Outcome[WindowManagerModel[ReferenceData]] = + WindowManager.updateModel(context, this)(event) + +object WindowManagerModel: + def initial[ReferenceData]: WindowManagerModel[ReferenceData] = + WindowManagerModel(Batch.empty) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManagerViewModel.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManagerViewModel.scala new file mode 100644 index 000000000..55f2a6b02 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowManagerViewModel.scala @@ -0,0 +1,34 @@ +package indigoextras.ui.window + +import indigo.* +import indigoextras.ui.datatypes.UIContext + +final case class WindowManagerViewModel[ReferenceData]( + windows: Batch[WindowViewModel[ReferenceData]], + magnification: Int +): + def prune(model: WindowManagerModel[ReferenceData]): WindowManagerViewModel[ReferenceData] = + this.copy(windows = windows.filter(w => model.windows.exists(_.id == w.id))) + + def update( + context: UIContext[ReferenceData], + model: WindowManagerModel[ReferenceData], + event: GlobalEvent + ): Outcome[WindowManagerViewModel[ReferenceData]] = + WindowManager.updateViewModel(context, model, this)(event) + + def pointerIsOverAnyWindow: Boolean = + windows.exists(_.pointerIsOver) + + def pointerIsOver: Batch[WindowId] = + windows.collect { case wvm if wvm.pointerIsOver => wvm.id } + + def changeMagnification(next: Int): WindowManagerViewModel[ReferenceData] = + this.copy( + windows = windows.map(_.copy(magnification = next)), + magnification = next + ) + +object WindowManagerViewModel: + def initial[A, ReferenceData](magnification: Int): WindowManagerViewModel[ReferenceData] = + WindowManagerViewModel(Batch.empty, magnification) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowMode.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowMode.scala new file mode 100644 index 000000000..3e8d2d672 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowMode.scala @@ -0,0 +1,4 @@ +package indigoextras.ui.window + +enum WindowMode derives CanEqual: + case Standard, Modal diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowState.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowState.scala new file mode 100644 index 000000000..72164cdbf --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowState.scala @@ -0,0 +1,4 @@ +package indigoextras.ui.window + +enum WindowState derives CanEqual: + case Open, Closed diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowView.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowView.scala new file mode 100644 index 000000000..34aa72008 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowView.scala @@ -0,0 +1,31 @@ +package indigoextras.ui.window + +import indigo.* +import indigoextras.ui.datatypes.UIContext + +object WindowView: + + def present[A, ReferenceData]( + context: UIContext[ReferenceData], + model: Window[A, ReferenceData], + viewModel: WindowViewModel[ReferenceData] + ): Outcome[Layer] = + model.component + .present( + context.copy(bounds = model.bounds), + model.content + ) + .flatMap { + case l: Layer.Content => + model.background(WindowContext.from(model, viewModel)).map { windowChrome => + Layer.Stack( + windowChrome, + l + ) + } + + case l: Layer.Stack => + model.background(WindowContext.from(model, viewModel)).map { windowChrome => + l.prepend(windowChrome) + } + } diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowViewModel.scala b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowViewModel.scala new file mode 100644 index 000000000..f1fb8d832 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/ui/window/WindowViewModel.scala @@ -0,0 +1,67 @@ +package indigoextras.ui.window + +import indigo.* +import indigo.syntax.* +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.UIContext + +final case class WindowViewModel[ReferenceData]( + id: WindowId, + modelHashCode: Int, + pointerIsOver: Boolean, + magnification: Int +): + + def update[A]( + context: UIContext[ReferenceData], + model: Window[A, ReferenceData], + event: GlobalEvent + ): Outcome[WindowViewModel[ReferenceData]] = + WindowViewModel.updateViewModel(context, model, this)(event) + +object WindowViewModel: + + def initial[ReferenceData](id: WindowId, magnification: Int): WindowViewModel[ReferenceData] = + WindowViewModel( + id, + 0, + false, + magnification + ) + + def updateViewModel[A, ReferenceData]( + context: UIContext[ReferenceData], + model: Window[A, ReferenceData], + viewModel: WindowViewModel[ReferenceData] + ): GlobalEvent => Outcome[WindowViewModel[ReferenceData]] = + case FrameTick if model.bounds.hashCode() != viewModel.modelHashCode => + Outcome(redraw(model, viewModel)) + + case WindowInternalEvent.Redraw => + Outcome(redraw(model, viewModel)) + + case PointerEvent.PointerMove(pt) + if viewModel.pointerIsOver && !model.bounds + .toScreenSpace(context.snapGrid) + .contains(pt) => + Outcome(viewModel.copy(pointerIsOver = false)) + .addGlobalEvents(WindowEvent.PointerOut(model.id)) + + case PointerEvent.PointerMove(pt) + if !viewModel.pointerIsOver && model.bounds + .toScreenSpace(context.snapGrid) + .contains(pt) => + Outcome(viewModel.copy(pointerIsOver = true)) + .addGlobalEvents(WindowEvent.PointerOver(model.id)) + + case _ => + Outcome(viewModel) + + private def redraw[A, ReferenceData]( + // context: UIContext[ReferenceData], + model: Window[A, ReferenceData], + viewModel: WindowViewModel[ReferenceData] + ): WindowViewModel[ReferenceData] = + viewModel.copy( + modelHashCode = model.bounds.hashCode() + ) diff --git a/indigo/indigo-extras/src/test/scala/indigoextras/ui/Helper.scala b/indigo/indigo-extras/src/test/scala/indigoextras/ui/Helper.scala new file mode 100644 index 000000000..3ff8efede --- /dev/null +++ b/indigo/indigo-extras/src/test/scala/indigoextras/ui/Helper.scala @@ -0,0 +1,27 @@ +package indigoextras.ui + +import indigo.shared.Outcome +import indigo.shared.events.GlobalEvent +import indigo.shared.scenegraph.Layer +import indigoextras.ui.component.Component +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +object Helper: + + extension [A, ReferenceData](component: A)(using c: Component[A, ReferenceData]) + def update[StartupData, ContextData]( + context: UIContext[ReferenceData] + ): GlobalEvent => Outcome[A] = + c.updateModel(context, component) + + def present[StartupData, ContextData]( + context: UIContext[ReferenceData] + ): Outcome[Layer] = + c.present(context, component) + + def refresh( + reference: ReferenceData, + parentDimensions: Dimensions + ): A = + c.refresh(reference, component, parentDimensions) diff --git a/indigo/indigo-extras/src/test/scala/indigoextras/ui/components/ComponentGroupTests.scala b/indigo/indigo-extras/src/test/scala/indigoextras/ui/components/ComponentGroupTests.scala new file mode 100644 index 000000000..88cde6ada --- /dev/null +++ b/indigo/indigo-extras/src/test/scala/indigoextras/ui/components/ComponentGroupTests.scala @@ -0,0 +1,387 @@ +package indigoextras.ui.components + +import indigo.* +import indigoextras.ui.component.* +import indigoextras.ui.components.datatypes.* +import indigoextras.ui.datatypes.Bounds +import indigoextras.ui.datatypes.Coords +import indigoextras.ui.datatypes.Dimensions +import indigoextras.ui.datatypes.UIContext + +class ComponentGroupTests extends munit.FunSuite: + + given Component[String, Unit] with + def bounds(reference: Unit, model: String): Bounds = + Bounds(0, 0, model.length, 1) + + def updateModel( + context: UIContext[Unit], + model: String + ): GlobalEvent => Outcome[String] = + _ => Outcome(model) + + def present( + context: UIContext[Unit], + model: String + ): Outcome[Layer] = + Outcome(Layer.empty) + + def refresh(reference: Unit, model: String, parentDimensions: Dimensions): String = + model + + def cascade(model: String, parentDimensions: Bounds): String = + model + + test("ComponentGroup.calculateContentBounds should return the correct bounds (Vertical)") { + val group: ComponentGroup[Unit] = + ComponentGroup() + .withLayout( + ComponentLayout.Vertical(Padding.zero.withBottom(2)) + ) + .withBoundsMode(indigoextras.ui.components.datatypes.BoundsMode.fixed(100, 100)) + .add("abc", "def") + + val instance = + summon[Component[ComponentGroup[Unit], Unit]] + + // This normally happens as part of the update process + val processed = instance.refresh((), group, Dimensions(100, 100)) + + val actual = + processed.contentBounds + + val expected = + Bounds(0, 0, 3, 4) + + assertEquals(actual, expected) + } + + test("ComponentGroup.calculateContentBounds should return the correct bounds (Horizontal)") { + val group: ComponentGroup[Unit] = + ComponentGroup() + .withLayout( + ComponentLayout.Horizontal(Padding.zero.withRight(2)) + ) + .withBoundsMode(indigoextras.ui.components.datatypes.BoundsMode.fixed(100, 100)) + .add("abc", "def") + + val instance = + summon[Component[ComponentGroup[Unit], Unit]] + + // This normally happens as part of the update process + val processed = instance.refresh((), group, Dimensions(100, 100)) + + val actual = + processed.contentBounds + + val expected = + Bounds(0, 0, 8, 1) + + assertEquals(actual, expected) + } + + // Write a test for ComponentGroup.calculateCascadeBounds + test("ComponentGroup.calculateCascadeBounds should return the correct bounds") { + val group: ComponentGroup[Unit] = + ComponentGroup() + .withLayout( + ComponentLayout.Vertical(Padding.zero.withBottom(2)) + ) + .withBoundsMode(indigoextras.ui.components.datatypes.BoundsMode.fixed(100, 100)) + .add("abc", "def") + + val instance = + summon[Component[ComponentGroup[Unit], Unit]] + + // This normally happens as part of the update process + val processed = + instance.refresh((), group, Dimensions(100, 100)) + + val actualFixed = + processed.dimensions + + assertEquals(actualFixed, Dimensions(100, 100)) + + val actualDefault = + val c = summon[Component[ComponentGroup[Unit], Unit]] + c.refresh( + (), + group.withBoundsMode(indigoextras.ui.components.datatypes.BoundsMode.default), + Dimensions(100, 100) + ).dimensions + + assertEquals(actualDefault, Dimensions(100, 4)) + } + + test("refresh should re-apply the layout to all existing components") { + val group: ComponentGroup[Unit] = + ComponentGroup() + .withLayout( + ComponentLayout.Horizontal(Padding(5), Overflow.Wrap) + ) + .withBoundsMode(indigoextras.ui.components.datatypes.BoundsMode.fixed(100, 100)) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(5, 5), + Coords(18, 5) // It's like this: 5 |3| 5.5 |3| 5 + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - vertical, padding 0") { + val group = ComponentGroup(10, 5) + .withLayout(ComponentLayout.Vertical(Padding(0))) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(0, 0), + Coords(0, 1) + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - vertical, padding 5") { + val group = ComponentGroup(10, 5) + .withLayout(ComponentLayout.Vertical(Padding(5))) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(5, 5), + Coords(5, 5 + 1 + 5 + 5) + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - vertical, padding top=5") { + val group = ComponentGroup(10, 5) + .withLayout( + ComponentLayout.Vertical(Padding(5, 0, 0, 0)) + ) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(0, 5), + Coords(0, 5 + 1 + 5) + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - horizontal, padding 0, hidden") { + val group = ComponentGroup(5, 5) + .withLayout( + ComponentLayout.Horizontal(Padding(0), Overflow.Hidden) + ) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(0, 0), + Coords(3, 0) + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - horizontal, padding 5, hidden") { + val group = ComponentGroup(5, 5) + .withLayout( + ComponentLayout.Horizontal(Padding(5), Overflow.Hidden) + ) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(5, 5), + Coords(5 + 3 + 5 + 5, 5) + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - horizontal, padding left=5, hidden") { + val group = ComponentGroup(5, 5) + .withLayout( + ComponentLayout.Horizontal(Padding(0, 0, 0, 5), Overflow.Hidden) + ) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(5, 0), + Coords(5 + 3 + 5, 0) + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - horizontal, padding 0, wrap") { + val group = ComponentGroup(5, 5) + .withLayout( + ComponentLayout.Horizontal(Padding(0), Overflow.Wrap) + ) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(0, 0), + Coords(0, 1) + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - horizontal, padding 5, wrap") { + val group = ComponentGroup(5, 5) + .withLayout( + ComponentLayout.Horizontal(Padding(5), Overflow.Wrap) + ) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(5, 5), + Coords(5, 5 + 1 + 5) + ) + + assertEquals(actual, expected) + } + + test("Calculate the next offset - horizontal, padding left=5 top=2, wrap") { + val group = ComponentGroup(3, 5) + .withLayout( + ComponentLayout.Horizontal(Padding(2, 0, 0, 5), Overflow.Wrap) + ) + .add("abc", "def") + + val actual = + summon[Component[ComponentGroup[Unit], Unit]] + .refresh((), group, Dimensions(3, 5)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(5, 2), + Coords(5, 2 + 1) + ) + + assertEquals(actual, expected) + } + + test("Refresh should snap to width of parent and height of contents by default.") { + val c = + summon[Component[ComponentGroup[Unit], Unit]] + + val group = + ComponentGroup() + .withLayout( + ComponentLayout.Vertical(Padding.zero.withBottom(10)) + ) + .add("abc", "def") + + val updated: ComponentGroup[Unit] = + c.refresh((), group, Dimensions(100, 100)) + + assertEquals(updated.contentBounds, Bounds(0, 0, 3, 12)) + assertEquals(updated.dimensions, Dimensions(100, 12)) + } + + test("Calculate the next offset for nested components".only) { + val c = summon[Component[ComponentGroup[Unit], Unit]] + + val group = ComponentGroup() + .withLayout(ComponentLayout.Vertical()) + .add( + ComponentGroup() + .withLayout(ComponentLayout.Horizontal(Overflow.Wrap)) + .add("abc") + ) + .add("abc") + + val actual = + c.refresh((), group, Dimensions(100, 100)) + .components + .toList + .map(_.offset) + + val expected = + List( + Coords(0, 0), + Coords(0, 1) + ) + + assertEquals(actual, expected) + } diff --git a/indigo/indigo-extras/src/test/scala/indigoextras/ui/components/datatypes/ContainerLikeFunctionsTests.scala b/indigo/indigo-extras/src/test/scala/indigoextras/ui/components/datatypes/ContainerLikeFunctionsTests.scala new file mode 100644 index 000000000..63ee7717a --- /dev/null +++ b/indigo/indigo-extras/src/test/scala/indigoextras/ui/components/datatypes/ContainerLikeFunctionsTests.scala @@ -0,0 +1,79 @@ +package indigoextras.ui.components.datatypes + +import indigo.* +import indigoextras.ui.components.* +import indigoextras.ui.datatypes.* + +class ContainerLikeFunctionsTests extends munit.FunSuite: + + import indigoextras.ui.Helper.* + + val present: (Coords, String, Dimensions) => Outcome[Layer] = + (c, s, d) => Outcome(Layer.empty) + + test("calculateNextOffset labels") { + + val group: ComponentGroup[Unit] = + ComponentGroup() + .withLayout( + ComponentLayout.Vertical(Padding.zero) + ) + .add( + Label[Unit]("label 1", (_, s) => Bounds(0, 0, s.length, 1))(present), + Label[Unit]("label 2", (_, s) => Bounds(0, 0, s.length, 1))(present), + Label[Unit]("label 3", (_, s) => Bounds(0, 0, s.length, 1))(present) + ) + + val updated: ComponentGroup[Unit] = + group.refresh((), Dimensions(100, 100)) + + val actual = + ContainerLikeFunctions.calculateNextOffset[Unit]( + Dimensions(20, 20), + updated.layout + )((), updated.components) + + val expected = + Coords(0, 3) + + assertEquals(actual, expected) + } + + test("calculateNextOffset group of labels") { + + val group: ComponentGroup[Unit] = + ComponentGroup() + .withLayout( + ComponentLayout.Vertical() + ) + .add( + ComponentGroup() + .withLayout( + ComponentLayout.Vertical() + ) + .add( + Label[Unit]("label 1", (_, s) => Bounds(0, 0, s.length, 1))(present), + Label[Unit]("label 2", (_, s) => Bounds(0, 0, s.length, 1))(present), + Label[Unit]("label 3", (_, s) => Bounds(0, 0, s.length, 1))(present) + ) + ) + + val parentDimensions = Dimensions(100, 100) + + val updated: ComponentGroup[Unit] = + group.refresh((), parentDimensions) + + assertEquals(updated.contentBounds, Bounds(0, 0, 100, 3)) + assertEquals(updated.dimensions, Dimensions(100, 3)) + + val actual = + ContainerLikeFunctions.calculateNextOffset[Unit]( + Dimensions(100, 0), // The layout is dynamic and horizontal, so we'll only know the width + updated.layout + )((), updated.components) + + val expected = + Coords(0, 3) + + assertEquals(actual, expected) + } diff --git a/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxGame.scala b/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxGame.scala index 2cbc453f9..3445c8f76 100644 --- a/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxGame.scala +++ b/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxGame.scala @@ -1,36 +1,6 @@ package com.example.sandbox -import com.example.sandbox.scenes.Archetype -import com.example.sandbox.scenes.BoundingCircleScene -import com.example.sandbox.scenes.BoundsScene -import com.example.sandbox.scenes.BoxesScene -import com.example.sandbox.scenes.CameraScene -import com.example.sandbox.scenes.CameraWithCloneTilesScene -import com.example.sandbox.scenes.CaptureScreenScene -import com.example.sandbox.scenes.CaptureScreenScene.CaptureScreenSceneViewModel -import com.example.sandbox.scenes.ClipScene -import com.example.sandbox.scenes.ConfettiScene -import com.example.sandbox.scenes.CratesScene -import com.example.sandbox.scenes.LegacyEffectsScene -import com.example.sandbox.scenes.LightsScene -import com.example.sandbox.scenes.LineReflectionScene -import com.example.sandbox.scenes.ManyEventHandlers -import com.example.sandbox.scenes.MutantsScene -import com.example.sandbox.scenes.NineSliceScene -import com.example.sandbox.scenes.OriginalScene -import com.example.sandbox.scenes.PathFindingScene -import com.example.sandbox.scenes.PointersScene -import com.example.sandbox.scenes.RefractionScene -import com.example.sandbox.scenes.Shaders -import com.example.sandbox.scenes.ShapesScene -import com.example.sandbox.scenes.TextBoxScene -import com.example.sandbox.scenes.TextScene -import com.example.sandbox.scenes.TextureTileScene -import com.example.sandbox.scenes.TimelineScene -import com.example.sandbox.scenes.UVShaders -import com.example.sandbox.scenes.UiScene -import com.example.sandbox.scenes.UiSceneViewModel -import com.example.sandbox.scenes.UltravioletScene +import com.example.sandbox.scenes.* import example.TestFont import indigo.* import indigo.json.Json @@ -53,7 +23,7 @@ object SandboxGame extends IndigoGame[SandboxBootData, SandboxStartupData, Sandb val viewportHeight: Int = gameHeight * magnificationLevel // 256 def initialScene(bootData: SandboxBootData): Option[SceneName] = - Some(NineSliceScene.name) + Some(ComponentUIScene.name) def scenes(bootData: SandboxBootData): NonEmptyList[Scene[SandboxStartupData, SandboxGameModel, SandboxViewModel]] = NonEmptyList( @@ -82,7 +52,8 @@ object SandboxGame extends IndigoGame[SandboxBootData, SandboxStartupData, Sandb CameraWithCloneTilesScene, PathFindingScene, CaptureScreenScene, - NineSliceScene + NineSliceScene, + ComponentUIScene ) val eventFilters: EventFilters = EventFilters.Permissive @@ -191,7 +162,7 @@ object SandboxGame extends IndigoGame[SandboxBootData, SandboxStartupData, Sandb InputField("multi\nline", assets).withKey(BindingKey("multi")).makeMultiLine.moveTo(5, 5), true, UiSceneViewModel.initial, - CaptureScreenSceneViewModel(None, None, Point.zero) + CaptureScreenScene.ViewModel(None, None, Point.zero) ) ) } @@ -290,7 +261,7 @@ final case class SandboxViewModel( multi: InputField, useLightingLayer: Boolean, uiScene: UiSceneViewModel, - captureScreenScene: CaptureScreenSceneViewModel + captureScreenScene: CaptureScreenScene.ViewModel ) final case class Log(message: String) extends GlobalEvent diff --git a/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxModel.scala b/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxModel.scala index 66f91a40c..2820d92dc 100644 --- a/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxModel.scala +++ b/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxModel.scala @@ -1,9 +1,12 @@ package com.example.sandbox +import com.example.sandbox.scenes.ChangeValue import com.example.sandbox.scenes.ConfettiModel import com.example.sandbox.scenes.PathFindingModel import com.example.sandbox.scenes.PointersModel import indigo.* +import indigo.syntax.* +import indigoextras.ui.* import indigoextras.ui.simple.InputFieldChange object SandboxModel { @@ -18,9 +21,183 @@ object SandboxModel { ConfettiModel.empty, PointersModel.empty, PathFindingModel.empty, - Radians.zero + Radians.zero, + 0, + components ) + def components: ComponentGroup[Int] = + ComponentGroup(BoundsMode.fixed(200, 300)) + .add( + ComponentList(Dimensions(200, 40)) { (_: Int) => + (1 to 3).toBatch.map { i => + ComponentId("lbl" + i) -> Label[Int]( + "Custom rendered label " + i, + (_, label) => Bounds(0, 0, 150, 10) + ) { case (offset, label, dimensions) => + Outcome( + Layer( + TextBox(label) + .withColor(RGBA.Red) + .moveTo(offset.unsafeToPoint) + .withSize(dimensions.unsafeToSize) + ) + ) + } + } + } + ) + .add( + Label[Int]( + "Another label", + (_, label) => Bounds(0, 0, 150, 10) + ) { case (offset, label, dimensions) => + Outcome( + Layer( + TextBox(label) + .withColor(RGBA.White) + .moveTo(offset.unsafeToPoint) + .withSize(dimensions.unsafeToSize) + ) + ) + } + ) + .add( + Switch[Int, Int](BoundsType.fixed(40, 40))( + (coords, bounds, _) => + Outcome( + Layer( + Shape + .Box( + bounds.unsafeToRectangle, + Fill.Color(RGBA.Green.mix(RGBA.Black)), + Stroke(1, RGBA.Green) + ) + .moveTo(coords.unsafeToPoint) + ) + ), + (coords, bounds, _) => + Outcome( + Layer( + Shape + .Box( + bounds.unsafeToRectangle, + Fill.Color(RGBA.Red.mix(RGBA.Black)), + Stroke(1, RGBA.Red) + ) + .moveTo(coords.unsafeToPoint) + ) + ) + ) + .onSwitch(value => Batch(Log("Switched to: " + value))) + .switchOn + ) + .add( + Button[Int](Bounds(32, 32)) { (coords, bounds, _) => + Outcome( + Layer( + Shape + .Box( + bounds.unsafeToRectangle, + Fill.Color(RGBA.Magenta.mix(RGBA.Black)), + Stroke(1, RGBA.Magenta) + ) + .moveTo(coords.unsafeToPoint) + ) + ) + } + .presentDown { (coords, bounds, _) => + Outcome( + Layer( + Shape + .Box( + bounds.unsafeToRectangle, + Fill.Color(RGBA.Cyan.mix(RGBA.Black)), + Stroke(1, RGBA.Cyan) + ) + .moveTo(coords.unsafeToPoint) + ) + ) + } + .presentOver((coords, bounds, _) => + Outcome( + Layer( + Shape + .Box( + bounds.unsafeToRectangle, + Fill.Color(RGBA.Yellow.mix(RGBA.Black)), + Stroke(1, RGBA.Yellow) + ) + .moveTo(coords.unsafeToPoint) + ) + ) + ) + .onClick(Log("Button clicked")) + .onPress(Log("Button pressed")) + .onRelease(Log("Button released")) + ) + .add( + ComponentList(Dimensions(200, 150)) { (_: Int) => + (1 to 3).toBatch.map { i => + ComponentId("radio-" + i) -> + ComponentGroup(BoundsMode.fixed(200, 30)) + .withLayout(ComponentLayout.Horizontal(Padding.right(10))) + .add( + Switch[Int, Int](BoundsType.fixed(20, 20))( + (coords, bounds, _) => + Outcome( + Layer( + Shape + .Circle( + bounds.unsafeToRectangle.toIncircle, + Fill.Color(RGBA.Green.mix(RGBA.Black)), + Stroke(1, RGBA.Green) + ) + .moveTo(coords.unsafeToPoint + Point(10)) + ) + ), + (coords, bounds, _) => + Outcome( + Layer( + Shape + .Circle( + bounds.unsafeToRectangle.toIncircle, + Fill.Color(RGBA.Red.mix(RGBA.Black)), + Stroke(1, RGBA.Red) + ) + .moveTo(coords.unsafeToPoint + Point(10)) + ) + ) + ) + .onSwitch { value => + Batch( + Log("Selected: " + i), + ChangeValue(i) + ) + } + .withAutoToggle { (_, ref) => + if ref == i then Option(SwitchState.On) else Option(SwitchState.Off) + } + ) + .add( + Label[Int]( + "Radio " + i, + (_, label) => Bounds(0, 0, 150, 10) + ) { case (offset, label, dimensions) => + Outcome( + Layer( + TextBox(label) + .withColor(RGBA.Red) + .moveTo(offset.unsafeToPoint) + .withSize(dimensions.unsafeToSize) + ) + ) + } + ) + } + } + ) + def updateModel(state: SandboxGameModel): GlobalEvent => Outcome[SandboxGameModel] = { case rd @ RendererDetails(_, _, _) => println(rd) @@ -141,7 +318,9 @@ final case class SandboxGameModel( confetti: ConfettiModel, pointers: PointersModel, pathfinding: PathFindingModel, - rotation: Radians + rotation: Radians, + num: Int, + components: ComponentGroup[Int] ) final case class DudeModel(dude: Dude, walkDirection: DudeDirection) { diff --git a/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/CaptureScreenScene.scala b/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/CaptureScreenScene.scala index 68ac30f4c..4fdb02569 100644 --- a/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/CaptureScreenScene.scala +++ b/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/CaptureScreenScene.scala @@ -26,7 +26,7 @@ import org.w3c.dom.css.Rect object CaptureScreenScene extends Scene[SandboxStartupData, SandboxGameModel, SandboxViewModel]: type SceneModel = SandboxGameModel - type SceneViewModel = CaptureScreenSceneViewModel + type SceneViewModel = ViewModel val uiKey = BindingKey("ui") val defaultKey = BindingKey("default") @@ -39,7 +39,7 @@ object CaptureScreenScene extends Scene[SandboxStartupData, SandboxGameModel, Sa def modelLens: Lens[SandboxGameModel, SandboxGameModel] = Lens.keepOriginal - def viewModelLens: Lens[SandboxViewModel, CaptureScreenSceneViewModel] = + def viewModelLens: Lens[SandboxViewModel, ViewModel] = Lens( _.captureScreenScene, (m, vm) => m.copy(captureScreenScene = vm) @@ -59,8 +59,8 @@ object CaptureScreenScene extends Scene[SandboxStartupData, SandboxGameModel, Sa def updateViewModel( context: SceneContext[SandboxStartupData], model: SandboxGameModel, - viewModel: CaptureScreenSceneViewModel - ): GlobalEvent => Outcome[CaptureScreenSceneViewModel] = { + viewModel: ViewModel + ): GlobalEvent => Outcome[ViewModel] = { case MouseEvent.Click(x, y) if x >= 250 && x <= 266 && y >= 165 && y <= 181 => val screenshots: Set[AssetType] = // Capture 2 screenshots, 1 of the full screen and the other of the clipping rectangle @@ -102,7 +102,7 @@ object CaptureScreenScene extends Scene[SandboxStartupData, SandboxGameModel, Sa def present( context: SceneContext[SandboxStartupData], model: SandboxGameModel, - viewModel: CaptureScreenSceneViewModel + viewModel: ViewModel ): Outcome[SceneUpdateFragment] = val screenshotScale = 0.3 val viewPort = context.startUpData.gameViewport.size / SandboxGame.magnificationLevel @@ -150,7 +150,7 @@ object CaptureScreenScene extends Scene[SandboxStartupData, SandboxGameModel, Sa ) ) - def gameLayer(currentState: SandboxGameModel, viewModel: CaptureScreenSceneViewModel): Batch[SceneNode] = + def gameLayer(currentState: SandboxGameModel, viewModel: ViewModel): Batch[SceneNode] = Batch( currentState.dude.walkDirection match { case d @ DudeLeft => @@ -192,7 +192,7 @@ object CaptureScreenScene extends Scene[SandboxStartupData, SandboxGameModel, Sa CloneBatch(dudeCloneId, CloneBatchData(16, 64, Radians.zero, -1.0, 1.0)) ) - final case class CaptureScreenSceneViewModel( + final case class ViewModel( screenshot1: Option[AssetName], screenshot2: Option[AssetName], offset: Point diff --git a/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/ComponentUIScene.scala b/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/ComponentUIScene.scala new file mode 100644 index 000000000..0f9baf7dc --- /dev/null +++ b/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/ComponentUIScene.scala @@ -0,0 +1,62 @@ +package com.example.sandbox.scenes + +import com.example.sandbox.SandboxGameModel +import com.example.sandbox.SandboxStartupData +import com.example.sandbox.SandboxViewModel +import indigo.* +import indigo.scenes.* +import indigo.shared.subsystems.SubSystemContext.* +import indigoextras.ui.* +import indigoextras.ui.syntax.* + +object ComponentUIScene extends Scene[SandboxStartupData, SandboxGameModel, SandboxViewModel]: + + type SceneModel = SandboxGameModel + type SceneViewModel = SandboxViewModel + + val name: SceneName = + SceneName("ComponentUI scene") + + val modelLens: Lens[SandboxGameModel, SandboxGameModel] = + Lens.keepLatest + + val viewModelLens: Lens[SandboxViewModel, SandboxViewModel] = + Lens.keepLatest + + val eventFilters: EventFilters = + EventFilters.Permissive + + val subSystems: Set[SubSystem[SandboxGameModel]] = + Set() + + def updateModel( + context: SceneContext[SandboxStartupData], + model: SandboxGameModel + ): GlobalEvent => Outcome[SandboxGameModel] = + case ChangeValue(value) => + Outcome(model.copy(num = value)) + + case e => + val ctx = + UIContext(context.toFrameContext.forSubSystems.copy(reference = model.num), Size(1), 1) + summon[Component[ComponentGroup[Int], Int]].updateModel(ctx, model.components)(e).map { cl => + model.copy(components = cl) + } + + def updateViewModel( + context: SceneContext[SandboxStartupData], + model: SandboxGameModel, + viewModel: SandboxViewModel + ): GlobalEvent => Outcome[SandboxViewModel] = + _ => Outcome(viewModel) + + def present( + context: SceneContext[SandboxStartupData], + model: SandboxGameModel, + viewModel: SandboxViewModel + ): Outcome[SceneUpdateFragment] = + model.components + .present(UIContext(context.toFrameContext.forSubSystems.copy(reference = 0), Size(1), 1)) + .map(l => SceneUpdateFragment(l)) + +final case class ChangeValue(value: Int) extends GlobalEvent