├── .git-blame-ignore-revs
├── .github
└── workflows
│ └── compile_test.yml
├── .gitignore
├── .scalafix.conf
├── .scalafmt.conf
├── LICENSE
├── README.md
├── build.sbt
├── contributors.sbt
├── core
└── shared
│ └── src
│ ├── main
│ └── scala
│ │ └── eu
│ │ └── joaocosta
│ │ └── interim
│ │ ├── Alignment.scala
│ │ ├── Color.scala
│ │ ├── Component.scala
│ │ ├── Font.scala
│ │ ├── InputState.scala
│ │ ├── InterIm.scala
│ │ ├── ItemId.scala
│ │ ├── LayoutAllocator.scala
│ │ ├── Panel.scala
│ │ ├── PanelState.scala
│ │ ├── Rect.scala
│ │ ├── Ref.scala
│ │ ├── RenderOp.scala
│ │ ├── TextLayout.scala
│ │ ├── UiContext.scala
│ │ ├── api
│ │ ├── Components.scala
│ │ ├── Constants.scala
│ │ ├── Layouts.scala
│ │ ├── Panels.scala
│ │ └── Primitives.scala
│ │ ├── layouts
│ │ ├── DynamicColumnAllocator.scala
│ │ ├── DynamicRowAllocator.scala
│ │ ├── StaticColumnAllocator.scala
│ │ └── StaticRowAllocator.scala
│ │ └── skins
│ │ ├── ButtonSkin.scala
│ │ ├── CheckboxSkin.scala
│ │ ├── ColorScheme.scala
│ │ ├── DefaultSkin.scala
│ │ ├── HandleSkin.scala
│ │ ├── SelectSkin.scala
│ │ ├── SliderSkin.scala
│ │ ├── TextInputSkin.scala
│ │ └── WindowSkin.scala
│ └── test
│ └── scala
│ └── eu
│ └── joaocosta
│ └── interim
│ ├── InputStateSpec.scala
│ ├── RectSpec.scala
│ ├── RefSpec.scala
│ ├── UiContextSpec.scala
│ └── api
│ └── LayoutsSpec.scala
├── docs
├── _docs
│ ├── advanced-usage.md
│ ├── getting-started.md
│ ├── index.html
│ ├── overview.md
│ └── tutorial.md
└── sidebar.yml
├── examples
├── README.md
├── release
│ ├── 1-introduction.md
│ ├── 2-explicit-layout.md
│ ├── 3-implicit-layout.md
│ ├── 4-windows.md
│ ├── 5-refs.md
│ ├── 6-colorpicker.md
│ ├── assets
│ │ ├── colorpicker.png
│ │ └── gloop.bmp
│ └── example-minart-backend.scala
└── snapshot
│ ├── 1-introduction.md
│ ├── 2-explicit-layout.md
│ ├── 3-implicit-layout.md
│ ├── 4-windows.md
│ ├── 5-refs.md
│ ├── 6-colorpicker.md
│ ├── assets
│ ├── colorpicker.png
│ └── gloop.bmp
│ └── example-minart-backend.scala
├── project
├── build.properties
└── plugins.sbt
└── version.sbt
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Scala Steward: Reformat with scalafmt 3.7.12
2 | fb28cb61e3b756de7125c1af8e43770cb7cd006f
3 |
4 | # Scala Steward: Reformat with scalafmt 3.8.0
5 | 668c493ccc4b9673ed8f821176887284469b6264
6 |
7 | # Scala Steward: Reformat with scalafmt 3.8.4
8 | 6bc4920f71407b36edec65017fa623736c4f6115
9 |
10 | # Scala Steward: Reformat with scalafmt 3.9.7
11 | b508ee878ab79d3b64de42e367e2567a20758982
12 |
--------------------------------------------------------------------------------
/.github/workflows/compile_test.yml:
--------------------------------------------------------------------------------
1 | name: Compile and Test
2 |
3 | on: [push, pull_request]
4 |
5 | env:
6 | CI: true # disables SBT super shell which has problems with CI environments
7 |
8 | jobs:
9 | compile-lib:
10 | name: Compile library and test
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | fail-fast: true
15 | matrix:
16 | scala: ["3"]
17 | platform: [JVM, JS, Native]
18 |
19 | env:
20 | PROJECT: core${{ matrix.platform }}
21 |
22 | steps:
23 | - uses: actions/checkout@v2
24 | - name: Set up JDK (8)
25 | uses: actions/setup-java@v4
26 | with:
27 | distribution: temurin
28 | java-version: 8
29 | cache: sbt
30 | - name: Setup SBT
31 | uses: sbt/setup-sbt@v1
32 | - name: Compile (Scala ${{ matrix.scala }} - ${{ matrix.platform }})
33 | run: sbt -J-Xmx3G -Dsbt.color=always $PROJECT/compile
34 | - name: Install scala-native libraries
35 | if: matrix.platform == 'native'
36 | run: sudo apt-get update && sudo apt-get -y install libunwind-dev libre2-dev
37 | - name: Compile tests (Scala ${{ matrix.scala }} - ${{ matrix.platform }})
38 | run: sbt -J-Xmx3G -Dsbt.color=always $PROJECT/test:compile
39 | - name: Run tests (Scala ${{ matrix.scala }} - ${{ matrix.platform }})
40 | run: sbt -J-Xmx3G -Dsbt.color=always $PROJECT/test
41 | compile-examples:
42 | name: Compile examples
43 | runs-on: ubuntu-latest
44 |
45 | strategy:
46 | fail-fast: true
47 |
48 | steps:
49 | - uses: actions/checkout@v2
50 | - name: Set up JDK (17)
51 | uses: actions/setup-java@v4
52 | with:
53 | distribution: temurin
54 | java-version: 17
55 | cache: sbt
56 | - name: Setup SBT
57 | uses: sbt/setup-sbt@v1
58 | - name: Install Scala-CLI
59 | run: |
60 | curl -fL https://github.com/Virtuslab/scala-cli/releases/latest/download/scala-cli-x86_64-pc-linux.gz | gzip -d > scala-cli
61 | chmod +x scala-cli
62 | sudo mv scala-cli /usr/local/bin/scala-cli
63 | - name: Compile examples (Release)
64 | run: |
65 | cd examples/release
66 | ls | grep .scala | xargs scala-cli compile --server=false
67 | ls | grep .md | xargs scala-cli compile --server=false example-minart-backend.scala
68 | - name: Publish Local
69 | run: sbt -J-Xmx3G -Dsbt.color=always publishLocal
70 | - name: Compile examples (Snapshot)
71 | run: |
72 | cd examples/snapshot
73 | ls | grep .scala | xargs scala-cli compile --server=false
74 | ls | grep .md | xargs scala-cli compile --server=false example-minart-backend.scala
75 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.class
2 | *.log
3 | *.hnir
4 | *.DS_Store
5 | *.dll
6 |
7 | # sbt specific
8 | .sbtopts
9 | .cache
10 | .history
11 | .lib/
12 | dist/*
13 | target/
14 | lib_managed/
15 | src_managed/
16 | project/boot/
17 | project/plugins/project/
18 |
19 | # Scala-IDE specific
20 | .scala_dependencies
21 | .worksheet
22 | .bloop
23 | .bsp
24 |
25 | # Metals specific
26 | metals.sbt
27 | .metals/
28 |
29 | # Scala-CLI
30 | .scala-build/
31 |
32 | # Windows specific
33 | null/
34 |
--------------------------------------------------------------------------------
/.scalafix.conf:
--------------------------------------------------------------------------------
1 | rules = [OrganizeImports]
2 |
3 | OrganizeImports {
4 | blankLines = Auto
5 | coalesceToWildcardImportThreshold = null
6 | expandRelative = true
7 | groupExplicitlyImportedImplicitsSeparately = false
8 | groupedImports = AggressiveMerge
9 | groups = ["re:javax?\\.", "scala.", "*", "eu.joaocosta."]
10 | importSelectorsOrder = Ascii
11 | importsOrder = Ascii
12 | removeUnused = false # requires "-Ywarn-unused"
13 | }
14 |
--------------------------------------------------------------------------------
/.scalafmt.conf:
--------------------------------------------------------------------------------
1 | version = 3.9.7
2 |
3 | runner.dialect = "scala3"
4 | align.preset = more
5 | docstrings.wrap = no
6 | maxColumn = 120
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2023 João Costa
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # InterIm
2 |
3 | 
4 | [](https://javadoc.io/doc/eu.joaocosta/interim_3)
5 |
6 | InterIm is an [Immediate mode GUI](https://en.wikipedia.org/wiki/Immediate_mode_GUI) library in pure Scala (JVM/JS/Native).
7 |
8 | It provides methods to build an interface and return a sequence of simple render operations (render rectangles and render text).
9 |
10 | The library does not perform any rendering. The resulting output must be interpreted by a rendering backend.
11 | While this might sound like a limitation, it actually allows for an easy integration with other libraries.
12 |
13 | To know more about the library and how to get started check the [examples](https://github.com/JD557/interim/tree/master/examples).
14 |
15 | **NOTE:** This library is still in heavy development. Expect big breaking changes in future versions.
16 |
17 | ## Features
18 |
19 | 
20 |
21 | [Online Demo](https://joaocosta.eu/Demos/InterIm/)
22 |
23 | ### Primitives and Components
24 |
25 | - Rectangles
26 | - Text
27 | - Buttons
28 | - Checkboxes
29 | - Radio buttons
30 | - Select boxes
31 | - Sliders
32 | - Text input
33 | - Movable/Closable windows
34 |
35 | ### Layouts
36 |
37 | - Grid based
38 | - Row based (equally sized or dynamically sized)
39 | - Column based (equally sized or dynamically sized)
40 |
41 | ### Skins
42 |
43 | - Configurable skins for all components
44 | - Light and dark mode
45 |
46 | ## Acknowledgments
47 |
48 | This project was heavily inspired by [Jari Komppa's Immediate Mode GUI tutorial](https://solhsa.com/imgui/) and [microui](https://github.com/rxi/microui).
49 |
--------------------------------------------------------------------------------
/build.sbt:
--------------------------------------------------------------------------------
1 | import ReleaseTransformations._
2 |
3 | ThisBuild / organization := "eu.joaocosta"
4 | ThisBuild / publishTo := sonatypePublishToBundle.value
5 | ThisBuild / scalaVersion := "3.3.6"
6 | ThisBuild / licenses := Seq("MIT License" -> url("http://opensource.org/licenses/MIT"))
7 | ThisBuild / homepage := Some(url("https://github.com/JD557/interim"))
8 | ThisBuild / scmInfo := Some(
9 | ScmInfo(
10 | url("https://github.com/JD557/interim"),
11 | "scm:git@github.com:JD557/interim.git"
12 | )
13 | )
14 | ThisBuild / versionScheme := Some("semver-spec")
15 | ThisBuild / autoAPIMappings := true
16 | ThisBuild / scalacOptions ++= Seq(
17 | "-deprecation",
18 | "-feature",
19 | "-language:higherKinds",
20 | "-unchecked"
21 | )
22 | ThisBuild / scalafmtOnCompile := true
23 | ThisBuild / semanticdbEnabled := true
24 | ThisBuild / semanticdbVersion := scalafixSemanticdb.revision
25 | ThisBuild / scalafixOnCompile := true
26 |
27 | // Don't publish the root project
28 | publish / skip := true
29 | publish := (())
30 | publishLocal := (())
31 | publishArtifact := false
32 | publishTo := None
33 |
34 | val siteSettings = Seq(
35 | Compile / doc / scalacOptions ++= (
36 | if (scalaBinaryVersion.value.startsWith("3"))
37 | Seq("-siteroot", "docs")
38 | else Seq()
39 | )
40 | )
41 |
42 | lazy val core =
43 | crossProject(JVMPlatform, JSPlatform, NativePlatform)
44 | .in(file("core"))
45 | .settings(
46 | name := "interim",
47 | libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" % Test,
48 | Compile / doc / scalacOptions ++=
49 | Seq(
50 | "-project",
51 | "InterIm",
52 | "-project-version",
53 | version.value,
54 | "-social-links:github::https://github.com/JD557/interim",
55 | "-siteroot",
56 | "docs"
57 | )
58 | )
59 |
60 | releaseCrossBuild := true
61 | releaseTagComment := s"Release ${(ThisBuild / version).value}"
62 | releaseCommitMessage := s"Set version to ${(ThisBuild / version).value}"
63 |
64 | releaseProcess := Seq[ReleaseStep](
65 | checkSnapshotDependencies,
66 | inquireVersions,
67 | runClean,
68 | runTest,
69 | setReleaseVersion,
70 | commitReleaseVersion,
71 | tagRelease,
72 | releaseStepCommandAndRemaining("publishSigned"),
73 | releaseStepCommand("sonatypeBundleRelease"),
74 | setNextVersion,
75 | commitNextVersion,
76 | pushChanges
77 | )
78 |
--------------------------------------------------------------------------------
/contributors.sbt:
--------------------------------------------------------------------------------
1 | ThisBuild / pomExtra := {
2 |
3 |
4 | JD557
5 | João Costa
6 |
7 | developer
8 |
9 |
10 |
11 | }
12 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/Alignment.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | enum HorizontalAlignment:
4 | case Left, Center, Right
5 |
6 | enum VerticalAlignment:
7 | case Top, Center, Bottom
8 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/Color.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | final case class Color(r: Int, g: Int, b: Int, a: Int = 255)
4 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/Component.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | /** A Component is something that can be shown in the UI.
4 | *
5 | * It uses the current input state and UI context to draw itself and returns the current value.
6 | */
7 | type Component[+T] = (inputState: InputState.Historical, uiContext: UiContext) ?=> T
8 |
9 | /** A Component that returns a value.
10 | */
11 | trait ComponentWithValue[T]:
12 | def render(area: Rect, value: Ref[T]): Component[Unit]
13 |
14 | def applyRef(area: Rect, value: Ref[T]): Component[T] =
15 | render(area, value)
16 | value.get
17 |
18 | def applyValue(area: Rect, value: T): Component[T] =
19 | apply(area, Ref(value))
20 |
21 | inline def apply(area: Rect, value: T | Ref[T]): Component[T] = inline value match
22 | case x: T => applyValue(area, x)
23 | case x: Ref[T] => applyRef(area, x)
24 |
25 | /** A Component that returns a value.
26 | *
27 | * The area can be computed dynamically based on a layout allocator.
28 | */
29 | trait DynamicComponentWithValue[T] extends ComponentWithValue[T]:
30 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect
31 |
32 | def render(value: Ref[T])(using allocator: LayoutAllocator.AreaAllocator): Component[Unit] =
33 | render(allocateArea, value)
34 |
35 | inline def apply(value: T | Ref[T])(using allocator: LayoutAllocator.AreaAllocator): Component[T] =
36 | apply(allocateArea, value)
37 |
38 | /** A Component that computes its value based on a body.
39 | */
40 | trait ComponentWithBody[I, F[_]]:
41 | def render[T](area: Rect, body: I => T): Component[F[T]]
42 |
43 | def apply[T](area: Rect)(body: I => T): Component[F[T]] = render(area, body)
44 |
45 | def apply[T](area: Rect)(body: => T)(using ev: I =:= Unit): Component[F[T]] = render(area, _ => body)
46 |
47 | /** A Component that computes its value based on a body.
48 | *
49 | * The area can be computed dynamically based on a layout allocator.
50 | */
51 | trait DynamicComponentWithBody[I, F[_]] extends ComponentWithBody[I, F]:
52 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect
53 |
54 | def render[T](body: I => T)(using allocator: LayoutAllocator.AreaAllocator): Component[Unit] =
55 | render(allocateArea, body)
56 |
57 | def apply[T](body: I => T)(using allocator: LayoutAllocator.AreaAllocator): Component[F[T]] =
58 | render(allocateArea, body)
59 |
60 | def apply[T](body: => T)(using allocator: LayoutAllocator.AreaAllocator, ev: I =:= Unit): Component[F[T]] =
61 | render(allocateArea, _ => body)
62 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/Font.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | /** A description of a font.
4 | *
5 | * @param name font name
6 | * @param fontSize font height in pixels
7 | * @param charWidth the width of each character in pixels
8 | * @param lineHeight the line height in pixels
9 | */
10 | final case class Font(name: String, fontSize: Int, charWidth: Char => Int, lineHeight: Int)
11 |
12 | object Font:
13 | /** A description of a font.
14 | *
15 | * All chars are assumed to be square.
16 | * @param name font name
17 | * @param fontSize font width and height in pixels
18 | */
19 | def apply(name: String, fontSize: Int): Font = Font(name, fontSize, _ => fontSize, (fontSize * 1.3).toInt)
20 |
21 | /** A description of a font.
22 | *
23 | * All chars are assumed to have the same width.
24 | * @param name font name
25 | * @param fontSize font height in pixels
26 | * @param charWidth character width in pixels
27 | */
28 | def apply(name: String, fontSize: Int, charWidth: Int): Font =
29 | Font(name, fontSize, _ => charWidth, (fontSize * 1.3).toInt)
30 |
31 | /** The default font */
32 | val default = Font("default", 8)
33 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/InputState.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import scala.annotation.tailrec
4 |
5 | /** Input State to be used by the components. */
6 | sealed trait InputState:
7 |
8 | /** Current Mouse state */
9 | def mouseInput: InputState.MouseInput
10 |
11 | /** String generated from the keyboard inputs since the last frame. Usually this will be a single character.
12 | * A `\u0008` character is interpreted as a backspace.
13 | */
14 | def keyboardInput: String
15 |
16 | /** Appends the current keyboard input to an already existing string.
17 | * A `\u0008` character in the input is interpreted as a backspace.
18 | */
19 | def appendKeyboardInput(str: String): String =
20 | if (keyboardInput.isEmpty) str
21 | else
22 | val fullString = str + keyboardInput
23 | val processedString =
24 | if (fullString.size >= 2)
25 | fullString.iterator
26 | .sliding(2)
27 | .flatMap {
28 | case Seq(_, '\u0008') => ""
29 | case Seq(x, _) => x.toString
30 | case seq => seq.mkString
31 | }
32 | .mkString + fullString.lastOption.mkString
33 | else fullString
34 | processedString
35 | .filterNot(Character.isISOControl)
36 | .mkString
37 |
38 | /** Clips the mouse position to a rectagle. If the mouse is outside of the region, the position is set to None */
39 | def clip(area: Rect): InputState
40 |
41 | object InputState:
42 |
43 | /** Creates a new InputState.
44 | *
45 | * @param mousePosition optional mouse (x, y) position, from the top-left
46 | * @param mousePressed whether the mouse is pressed
47 | * @param keyboardInput
48 | * String generated from the keyboard inputs since the last frame. Usually this will be a single character.
49 | * A `\u0008` character is interpreted as a backspace.
50 | */
51 | def apply(mousePosition: Option[(Int, Int)], mouseDown: Boolean, keyboardInput: String): InputState =
52 | InputState.Current(InputState.MouseInput(mousePosition, mouseDown), keyboardInput)
53 |
54 | /** Creates a new InputState.
55 | *
56 | * @param mouseX mouse X position, from the left
57 | * @param mouseY mouse Y position, from the top
58 | * @param mousePressed whether the mouse is pressed
59 | * @param keyboardInput
60 | * String generated from the keyboard inputs since the last frame. Usually this will be a single character.
61 | * A `\u0008` character is interpreted as a backspace.
62 | */
63 | def apply(mouseX: Int, mouseY: Int, mouseDown: Boolean, keyboardInput: String): InputState =
64 | InputState.Current(InputState.MouseInput(Some((mouseX, mouseY)), mouseDown), keyboardInput)
65 |
66 | /** Creates a new InputState with an unknown mouse position.
67 | *
68 | * @param mousePressed whether the mouse is pressed
69 | * @param keyboardInput
70 | * String generated from the keyboard inputs since the last frame. Usually this will be a single character.
71 | * A `\u0008` character is interpreted as a backspace.
72 | */
73 | def apply(mouseDown: Boolean, keyboardInput: String): InputState =
74 | InputState.Current(InputState.MouseInput(None, mouseDown), keyboardInput)
75 |
76 | /** Mouse position and button state.
77 | *
78 | * @param position mouse position in a (x, y) tuple. None if the mouse is off-screen.
79 | * @param isPressed whether the mouse is pressed
80 | */
81 | final case class MouseInput(position: Option[(Int, Int)], isPressed: Boolean):
82 | def x = position.map(_._1)
83 | def y = position.map(_._2)
84 |
85 | object MouseInput:
86 | /** Mouse position and button state.
87 | *
88 | * @param x mouse position from the left side
89 | * @param y mouse position from the top
90 | * @param isPressed whether the mouse is pressed
91 | */
92 | def apply(x: Int, y: Int, isPressed: Boolean): MouseInput =
93 | MouseInput(position = Some((x, y)), isPressed = isPressed)
94 |
95 | /** Input state at the current point in time
96 | *
97 | * @param mouseInput the current mouse state
98 | * @param keyboardInput
99 | * String generated from the keyboard inputs since the last frame. Usually this will be a single character.
100 | * A `\u0008` character is interpreted as a backspace.
101 | */
102 | final case class Current(mouseInput: InputState.MouseInput, keyboardInput: String) extends InputState:
103 |
104 | def clip(area: Rect): InputState.Current =
105 | if (area.isMouseOver(using this)) this
106 | else this.copy(mouseInput = mouseInput.copy(position = None))
107 |
108 | /** Input state at the current point in time and in the previous frame
109 | *
110 | * @param previousMouseX previous mouse X position, from the left
111 | * @param previousMouseY previous mouse Y position, from the top
112 | * @param mouseX mouse X position, from the left
113 | * @param mouseY mouse Y position, from the top
114 | * @param mouseDown whether the mouse is pressed
115 | * @param keyboardInput
116 | * String generated from the keyboard inputs since the last frame. Usually this will be a single character.
117 | * A `\u0008` character is interpreted as a backspace.
118 | */
119 | final case class Historical(
120 | previousMouseInput: MouseInput,
121 | mouseInput: MouseInput,
122 | keyboardInput: String
123 | ) extends InputState:
124 |
125 | /** If true, then the mouse was released on this frame, performing a click */
126 | lazy val mouseClicked: Boolean = mouseInput.isPressed == false && previousMouseInput.isPressed == true
127 |
128 | /** How much the mouse moved in the X axis */
129 | lazy val deltaX: Int =
130 | mouseInput.x.zip(previousMouseInput.x).fold(0)((curr, prev) => curr - prev)
131 |
132 | /** How much the mouse moved in the Y axis */
133 | lazy val deltaY: Int =
134 | mouseInput.y.zip(previousMouseInput.y).fold(0)((curr, prev) => curr - prev)
135 |
136 | def clip(area: Rect): InputState.Historical =
137 | if (area.isMouseOver(using this)) this
138 | else this.copy(mouseInput = mouseInput.copy(position = None))
139 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/InterIm.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import eu.joaocosta.interim.TextLayout._
4 |
5 | /** Object with all the DSL operations.
6 | *
7 | * Most applications will only need to import `eu.joaocosta.interim._` and `eu.joaocosta.interim.InterIm._`.
8 | * However, since some of the methods in the DSL can conflict with other variable names, it can be desirable to not
9 | * import the DSL and explicitly use the InterIm prefix (e.g. `IterIm.text` instead of `text`)
10 | */
11 | object InterIm extends api.Primitives with api.Layouts with api.Components with api.Constants with api.Panels:
12 |
13 | /** Wraps the UI interactions. All API calls should happen inside the body (run parameter).
14 | *
15 | * This method takes an input state and a UI context and mutates the UI context accordingly.
16 | * This should be called on every frame.
17 | *
18 | * The method returns a list of operations to render and the result of the body.
19 | */
20 | def ui[T](inputState: InputState, uiContext: UiContext)(
21 | run: (historicalInputState: InputState.Historical, uiContext: UiContext) ?=> T
22 | ): (List[RenderOp], T) =
23 | // prepare
24 | uiContext.commit()
25 | uiContext.ops.clear()
26 | uiContext.currentZ = 0
27 | uiContext.scratchItemState.hotItem = None
28 | val historicalInputState = uiContext.pushInputState(inputState)
29 | if (inputState.mouseInput.isPressed) uiContext.scratchItemState.selectedItem = None
30 | // run
31 | val res = run(using historicalInputState, uiContext)
32 | // finish
33 | if (!historicalInputState.mouseInput.isPressed) uiContext.scratchItemState.activeItem = None
34 | // return
35 | (uiContext.getOrderedOps(), res)
36 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/ItemId.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import scala.annotation.targetName
4 |
5 | /** Identifier of an item. Should be unique for each item.
6 | *
7 | * Can be either a Int, a String, or a sequence of that (which is especially useful for composite components).
8 | */
9 | type ItemId = (Int | String) | List[(Int | String)]
10 |
11 | /** Helper method to convert an ItemId into a List
12 | */
13 | extension (itemId: ItemId)
14 | def toIdList: List[(Int | String)] =
15 | itemId match {
16 | case int: Int => List(int)
17 | case str: String => List(str)
18 | case list: List[(Int | String)] => list
19 | }
20 |
21 | /** Operator to add a child to an item id. Useful for composite components.
22 | */
23 | extension (parentId: ItemId)
24 | @targetName("addChild")
25 | def |>(childId: ItemId): ItemId =
26 | parentId.toIdList ++ childId.toIdList
27 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/LayoutAllocator.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import eu.joaocosta.interim._
4 |
5 | /** A layout allocator is a side-effectful function that tries to allocate some space inside of an area.
6 | *
7 | * Note that calls to this function are side effectful, as each call reserves an area.
8 | */
9 | sealed trait LayoutAllocator:
10 | def area: Rect
11 |
12 | object LayoutAllocator:
13 | /** Allocator that allows one to allocate space based on a required area.
14 | */
15 | trait AreaAllocator extends LayoutAllocator:
16 | def allocate(width: Int, height: Int): Rect
17 | def fill(): Rect = allocate(Int.MaxValue, Int.MaxValue)
18 | def allocate(text: String, font: Font, paddingW: Int = 0, paddingH: Int = 0): Rect =
19 | val textArea =
20 | TextLayout.computeArea(area.resize(-2 * paddingW, -2 * paddingH), text, font)
21 | allocate(textArea.w + 2 * paddingW, textArea.h + 2 * paddingH)
22 |
23 | /** Allocator that allows one to request new cells.
24 | *
25 | * The preallocated cells can also be accessed as an `IndexedSeq`
26 | */
27 | trait CellAllocator extends LayoutAllocator with IndexedSeq[Rect]:
28 | lazy val cells: IndexedSeq[Rect]
29 | protected lazy val cellsIterator = cells.iterator
30 |
31 | def apply(i: Int): Rect = cells(i)
32 | val length = cells.length
33 |
34 | def nextCell(): Rect =
35 | if (!cellsIterator.hasNext) area.copy(w = 0, h = 0)
36 | else cellsIterator.next()
37 |
38 | trait RowAllocator extends LayoutAllocator:
39 | def nextRow(height: Int): Rect
40 | def allocate(width: Int, height: Int): Rect = nextRow(height)
41 |
42 | trait ColumnAllocator extends LayoutAllocator:
43 | def nextColumn(width: Int): Rect
44 | def allocate(width: Int, height: Int): Rect = nextColumn(width)
45 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/Panel.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | /*
4 | * Panels are a mix of a component and a layout. They perform rendering operations, but also provide a draw area.
5 | */
6 | trait Panel[I, F[_]]:
7 | def render[T](area: Ref[PanelState[Rect]], body: I => T): Component[F[T]]
8 |
9 | def apply[T](area: Ref[PanelState[Rect]])(body: I => T): Component[F[T]] =
10 | render(area, body)
11 |
12 | def apply[T](area: PanelState[Rect])(body: I => T): Component[F[T]] =
13 | render(Ref(area), body)
14 |
15 | def apply[T](area: Rect)(body: I => T): Component[F[T]] =
16 | render(Ref(PanelState.open(area)), body)
17 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/PanelState.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | /** State of a panel that can either be open or closed.
4 | * Can also carry a value.
5 | */
6 | final case class PanelState[T](isOpen: Boolean, value: T):
7 | def isClosed: Boolean = !isOpen
8 | def open: PanelState[T] = copy(isOpen = true)
9 | def close: PanelState[T] = copy(isOpen = false)
10 |
11 | object PanelState:
12 | def open[T](value: T): PanelState[T] = PanelState(true, value)
13 | def closed[T](value: T): PanelState[T] = PanelState(false, value)
14 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/Rect.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import scala.annotation.targetName
4 |
5 | /** Rectangle abstraction, which represents an area with positive width and height.
6 | *
7 | * (x, y) is the top left coordinate.
8 | *
9 | * Alternatively, (x1, y1) is the top left coordinate and (x2, y2) is the bottom right one.
10 | */
11 | final case class Rect(x: Int, y: Int, w: Int, h: Int):
12 | def x1 = x
13 | def y1 = y
14 | def x2 = x + w
15 | def y2 = y + h
16 | def centerX = x + w / 2
17 | def centerY = y + h / 2
18 |
19 | /** Returns true if the rectangle has no area
20 | */
21 | def isEmpty: Boolean = w <= 0 || h <= 0
22 |
23 | /** Checks if the mouse is over this area.
24 | */
25 | def isMouseOver(using inputState: InputState): Boolean =
26 | inputState.mouseInput.position.exists((mouseX, mouseY) =>
27 | !(mouseX < x || mouseY < y || mouseX >= x + w || mouseY >= y + h)
28 | )
29 |
30 | /** Translates the area to another position.
31 | */
32 | def move(dx: Int, dy: Int): Rect =
33 | copy(x = x + dx, y = y + dy)
34 |
35 | /** Resizes this rectangle by increasing the width and height.
36 | */
37 | def resize(dw: Int, dh: Int): Rect =
38 | copy(w = w + dw, h = h + dh)
39 |
40 | /** Centers this rectangle at the defined position. */
41 | def centerAt(x: Int, y: Int): Rect =
42 | copy(x = x - w / 2, y = y - h / 2)
43 |
44 | /** Shrinks this area by removing `size` pixels from each side.
45 | */
46 | def shrink(size: Int): Rect =
47 | Rect(x + size, y + size, w - size * 2, h - size * 2)
48 |
49 | /** Grows this area by removing `size` pixels from each side.
50 | */
51 | def grow(size: Int): Rect =
52 | Rect(x - size, y - size, w + size * 2, h + size * 2)
53 |
54 | /** Swaps the width and height of this area.
55 | */
56 | def transpose: Rect =
57 | copy(w = h, h = w)
58 |
59 | /** Merges this rectangle with another one.
60 | *
61 | * Gaps between the rectangles will also be considered as part of the final area.
62 | */
63 | @targetName("merge")
64 | def ++(that: Rect): Rect =
65 | val minX = math.min(this.x1, that.x1)
66 | val maxX = math.max(this.x2, that.x2)
67 | val minY = math.min(this.y1, that.y1)
68 | val maxY = math.max(this.y2, that.y2)
69 | Rect(x = minX, y = minY, w = maxX - minX, h = maxY - minY)
70 |
71 | /** Intersects this rectangle with another one.
72 | */
73 | @targetName("intersect")
74 | def &(that: Rect): Rect =
75 | val maxX1 = math.max(this.x1, that.x1)
76 | val maxY1 = math.max(this.y1, that.y1)
77 | val minX2 = math.min(this.x2, that.x2)
78 | val minY2 = math.min(this.y2, that.y2)
79 | Rect(x = maxX1, y = maxY1, w = minX2 - maxX1, h = minY2 - maxY1)
80 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/Ref.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import scala.annotation.targetName
4 | import scala.deriving.Mirror
5 |
6 | /** A mutable reference to a variable.
7 | *
8 | * When a function receives a Ref as an argument, it will probably mutate it.
9 | */
10 | final case class Ref[T](private var value: T):
11 | /** Returns the value of this Ref.
12 | */
13 | def get: T = value
14 |
15 | /** Assigns a value to this Ref.
16 | */
17 | @targetName("set")
18 | def :=(newValue: T): this.type =
19 | value = newValue
20 | this
21 |
22 | /** Modifies the value of this Ref.
23 | * Shorthand for `ref := f(ref.value)`
24 | */
25 | def modify(f: T => T): this.type =
26 | value = f(value)
27 | this
28 |
29 | /** Modifies the value of this Ref if the condition is true.
30 | * Shorthand for `if (cond) ref := f(ref.value) else ref`
31 | */
32 | def modifyIf(cond: Boolean)(f: T => T): this.type =
33 | if (cond) value = f(value)
34 | this
35 |
36 | object Ref:
37 |
38 | /** Creates a Ref that can be used inside a block and returns that value.
39 | *
40 | * Useful to set temporary mutable variables.
41 | */
42 | def withRef[T](initialValue: T)(block: Ref[T] => Unit): T =
43 | val ref = Ref(initialValue)
44 | block(ref)
45 | ref.value
46 |
47 | /** Destructures an object into a tuple of Refs that can be used inside the block.
48 | * In the end, a new object is returned with the updated values
49 | *
50 | * Useful to set temporary mutable variables.
51 | */
52 | def withRefs[T <: Product](initialValue: T)(using
53 | mirror: Mirror.ProductOf[T]
54 | )(
55 | block: Tuple.Map[mirror.MirroredElemTypes, Ref] => Unit
56 | ): T =
57 | val tuple: mirror.MirroredElemTypes = Tuple.fromProductTyped(initialValue)
58 | val refTuple: Tuple.Map[tuple.type, Ref] = tuple.map([T] => (x: T) => Ref(x))
59 | block(refTuple.asInstanceOf)
60 | type UnRef[T] = T match { case Ref[a] => a }
61 | val updatedTuple: mirror.MirroredElemTypes =
62 | refTuple.map([T] => (x: T) => x.asInstanceOf[Ref[_]].value.asInstanceOf[UnRef[T]]).asInstanceOf
63 | mirror.fromTuple(updatedTuple)
64 |
65 | /** Wraps this value into a Ref and passes it to a block, returning the final value of the ref.
66 | *
67 | * Useful to set temporary mutable variables.
68 | */
69 | extension [T](x: T) def asRef(block: Ref[T] => Unit): T = Ref.withRef(x)(block)
70 |
71 | /** Destructures this value into multiple Refs and passes it to a block, returning the final value of the ref.
72 | *
73 | * Useful to set temporary mutable variables.
74 | */
75 | extension [T <: Product](x: T)
76 | def asRefs(using mirror: Mirror.ProductOf[T])(block: Tuple.Map[mirror.MirroredElemTypes, Ref] => Unit): T =
77 | Ref.withRefs(x)(block)
78 |
79 | /** Destructures a Ref into multiple Refs and passes it to a block, returning the updated Ref.
80 | *
81 | * Useful to work with large state objects.
82 | *
83 | * Equivalent to `x.modify(_.asRefs(block))`
84 | */
85 | extension [T <: Product](x: Ref[T])
86 | def modifyRefs(using mirror: Mirror.ProductOf[T])(block: Tuple.Map[mirror.MirroredElemTypes, Ref] => Unit): Ref[T] =
87 | x.modify(_.asRefs(block))
88 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/RenderOp.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | /** Render operation that needs to be handled by the render backend.
4 | *
5 | * There are 3 types of operation:
6 | * - DrawRect, to draw a rectangle;
7 | * - DrawText, to draw text in an area, with a font size in px and a defined alignment;
8 | * - Custom, for advanced use cases not supported by interim out of the box.
9 | *
10 | * Every operation has an area and a color, so that a naive implementation can just draw a box if they don't support
11 | * some operations.
12 | *
13 | * For DrawText, the backend is expected to layout the text. However, there's a `asDrawChars` method that applies
14 | * some naive layout logic and returns simpler [[RenderOp.DrawChar]] operations.
15 | */
16 | sealed trait RenderOp {
17 | def area: Rect
18 | def color: Color
19 |
20 | def clip(rect: Rect): RenderOp
21 | }
22 |
23 | object RenderOp:
24 |
25 | /** Operation to draw a rectangle on the screen.
26 | *
27 | * @param area area to render
28 | * @param color color of the rectangle
29 | */
30 | final case class DrawRect(area: Rect, color: Color) extends RenderOp:
31 | def clip(rect: Rect): DrawRect = copy(area = area & rect)
32 |
33 | /** Operation to draw text on the screen.
34 | *
35 | * @param area area to render, text outside this area should not be shown
36 | * @param color text color
37 | * @param text string to render
38 | * @param font font size and style
39 | * @param textArea area where the text should be layed out
40 | * @param horizontalAlignment how the text should be layed out horizontally
41 | * @param verticalAlignment how the text should be layed out vertically
42 | */
43 | final case class DrawText(
44 | area: Rect,
45 | color: Color,
46 | text: String,
47 | font: Font,
48 | textArea: Rect,
49 | horizontalAlignment: HorizontalAlignment,
50 | verticalAlignment: VerticalAlignment
51 | ) extends RenderOp:
52 | def clip(rect: Rect): DrawText = copy(area = area & rect)
53 |
54 | /** Converts a DrawText operation into a sequence of simpler DrawChar operations. */
55 | def asDrawChars: List[DrawChar] = TextLayout.asDrawChars(this)
56 |
57 | /** Operation to draw a custom element on the screen
58 | *
59 | * @param area area to render
60 | * @param color fallback color
61 | * @param data domain specific data to use when rendering this element
62 | */
63 | final case class Custom[T](area: Rect, color: Color, data: T) extends RenderOp:
64 | def clip(rect: Rect): Custom[T] = copy(area = area & rect)
65 |
66 | /** Operation to draw a single character.
67 | * Note that this is not part of the RenderOp enum. InterIm components will never generate this operation.
68 | *
69 | * The only way to get it is to call `DrawText#asDrawChars`.
70 | */
71 | final case class DrawChar(area: Rect, color: Color, char: Char)
72 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/TextLayout.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import scala.annotation.tailrec
4 |
5 | object TextLayout:
6 |
7 | private def cumulativeSum(xs: Iterable[Int]): Iterable[Int] =
8 | if (xs.isEmpty) xs
9 | else xs.tail.scanLeft(xs.head)(_ + _)
10 |
11 | private def cumulativeSum[A](xs: Iterable[A])(f: A => Int): Iterable[(A, Int)] =
12 | xs.zip(cumulativeSum(xs.map(f)))
13 |
14 | private def getNextLine(str: String, lineSize: Int, charWidth: Char => Int): (String, String) =
15 | def textSize(str: String): Int = str.foldLeft(0)(_ + charWidth(_))
16 | if (str.isEmpty) ("", "")
17 | else
18 | val (nextFullLine, remainingLines) = str.span(_ != '\n')
19 | // If the line fits, simply return the line
20 | if (textSize(nextFullLine) <= lineSize) (nextFullLine, remainingLines.drop(1))
21 | else
22 | val words = nextFullLine.split(" ")
23 | val firstWord = words.headOption.getOrElse("")
24 | // If the first word is too big, it needs to be broken
25 | if (textSize(firstWord) > lineSize)
26 | val (firstPart, secondPart) = cumulativeSum(firstWord)(charWidth).span(_._2 <= lineSize)
27 | (firstPart.map(_._1).mkString(""), secondPart.map(_._1).mkString("") ++ remainingLines)
28 | else // Otherwise, pick as many words as fit
29 | val (selectedWords, remainingWords) =
30 | cumulativeSum(words)(charWidth(' ') + textSize(_)).span(_._2 <= lineSize)
31 | (selectedWords.map(_._1).mkString(" "), remainingWords.map(_._1).mkString(" ") ++ remainingLines)
32 |
33 | private def alignH(
34 | chars: List[RenderOp.DrawChar],
35 | areaWidth: Int,
36 | alignment: HorizontalAlignment
37 | ): List[RenderOp.DrawChar] =
38 | val minX = chars.map(_.area.x).minOption.getOrElse(0)
39 | val maxX = chars.map(c => c.area.x + c.area.w).maxOption.getOrElse(0)
40 | val deltaX = alignment.ordinal * (areaWidth - (maxX - minX)) / 2
41 | chars.map(c => c.copy(area = c.area.copy(x = c.area.x + deltaX)))
42 |
43 | private def alignV(
44 | chars: List[RenderOp.DrawChar],
45 | areaHeight: Int,
46 | alignment: VerticalAlignment
47 | ): List[RenderOp.DrawChar] =
48 | val minY = chars.map(_.area.y).minOption.getOrElse(0)
49 | val maxY = chars.map(c => c.area.y + c.area.h).maxOption.getOrElse(0)
50 | val deltaY = alignment.ordinal * (areaHeight - (maxY - minY)) / 2
51 | chars.map(c => c.copy(area = c.area.copy(y = c.area.y + deltaY)))
52 |
53 | private[interim] def asDrawChars(
54 | textOp: RenderOp.DrawText
55 | ): List[RenderOp.DrawChar] =
56 | @tailrec
57 | def layout(
58 | remaining: String,
59 | dy: Int,
60 | textAcc: List[RenderOp.DrawChar]
61 | ): List[RenderOp.DrawChar] =
62 | remaining match
63 | case "" =>
64 | alignV(
65 | textAcc,
66 | textOp.textArea.h,
67 | textOp.verticalAlignment
68 | )
69 | case str =>
70 | if (dy + textOp.font.fontSize > textOp.textArea.h) layout("", dy, textAcc) // Can't fit this line, end here
71 | else
72 | val (thisLine, nextLine) = getNextLine(str, textOp.textArea.w, textOp.font.charWidth)
73 | val ops = cumulativeSum(thisLine)(textOp.font.charWidth).map { case (char, dx) =>
74 | val width = textOp.font.charWidth(char)
75 | val charArea = Rect(
76 | x = textOp.textArea.x + dx - width,
77 | y = textOp.textArea.y + dy,
78 | w = width,
79 | h = textOp.font.fontSize
80 | )
81 | RenderOp.DrawChar(charArea, textOp.color, char)
82 | }.toList
83 | if (ops.isEmpty && nextLine == remaining) layout("", dy, textAcc) // Can't fit a single character, end here
84 | else
85 | layout(
86 | nextLine,
87 | dy + textOp.font.lineHeight,
88 | alignH(ops, textOp.textArea.w, textOp.horizontalAlignment) ++ textAcc
89 | )
90 | layout(textOp.text, 0, Nil).filter(char => (char.area & textOp.area) == char.area)
91 |
92 | /** Computes the area that some text will occupy
93 | *
94 | * @param boundingArea area where the text can be inserted
95 | * @param text string of text
96 | * @param font font to use
97 | */
98 | def computeArea(
99 | boundingArea: Rect,
100 | text: String,
101 | font: Font
102 | ): Rect =
103 | @tailrec
104 | def layout(
105 | remaining: String,
106 | dy: Int,
107 | areaAcc: Rect
108 | ): Rect =
109 | remaining match
110 | case "" => areaAcc
111 | case str =>
112 | if (dy + font.fontSize > boundingArea.h) layout("", dy, areaAcc) // Can't fit this line, end here
113 | else
114 | val (thisLine, nextLine) = getNextLine(str, boundingArea.w, font.charWidth)
115 | val charAreas = cumulativeSum(thisLine)(font.charWidth).map { case (char, dx) =>
116 | val width = font.charWidth(char)
117 | val charArea = Rect(
118 | x = boundingArea.x + dx - width,
119 | y = boundingArea.y + dy,
120 | w = width,
121 | h = font.fontSize
122 | )
123 | charArea
124 | }.toList
125 | if (charAreas.isEmpty && nextLine == remaining)
126 | layout("", dy, areaAcc) // Can't fit a single character, end here
127 | else
128 | layout(nextLine, dy + font.lineHeight, charAreas.fold(areaAcc)(_ ++ _))
129 | layout(text, 0, boundingArea.copy(w = 0, h = 0)) & boundingArea
130 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/UiContext.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import scala.collection.mutable
4 |
5 | /** Internal state of the UI.
6 | *
7 | * This object keeps the mutable state of the UI and should not be manipulated manually.
8 | *
9 | * Instead, it should be created with `new UiContext()` at the start of the application and passed on every frame to
10 | * [[eu.joaocosta.interim.InterIm.ui]].
11 | */
12 | final class UiContext private (
13 | private[interim] var currentZ: Int,
14 | private[interim] var previousInputState: Option[InputState],
15 | private[interim] var currentItemState: UiContext.ItemState,
16 | private[interim] var scratchItemState: UiContext.ItemState,
17 | private[interim] val ops: mutable.TreeMap[Int, mutable.Queue[RenderOp]]
18 | ):
19 |
20 | private def getItemStatus(id: ItemId)(using inputState: InputState): UiContext.ItemStatus =
21 | currentItemState.getItemStatus(id)
22 |
23 | private def getScratchItemStatus(id: ItemId)(using inputState: InputState): UiContext.ItemStatus =
24 | scratchItemState.getItemStatus(id)
25 |
26 | private def registerItem(id: ItemId, area: Rect, passive: Boolean)(using
27 | inputState: InputState
28 | ): UiContext.ItemStatus =
29 | if (area.isMouseOver && scratchItemState.hotItem.forall((hotZ, _) => hotZ <= currentZ))
30 | scratchItemState.hotItem = Some(currentZ -> id)
31 | if (inputState.mouseInput.isPressed)
32 | if (passive && currentItemState.activeItem == None)
33 | scratchItemState.activeItem = None
34 | scratchItemState.selectedItem = None
35 | else if (!passive && currentItemState.activeItem.forall(_ == id))
36 | scratchItemState.activeItem = Some(id)
37 | scratchItemState.selectedItem = Some(id)
38 | getItemStatus(id)
39 |
40 | private[interim] def getOrderedOps(): List[RenderOp] =
41 | ops.values.toList.flatten
42 |
43 | private[interim] def pushInputState(inputState: InputState): InputState.Historical =
44 | val history = InputState.Historical(
45 | previousMouseInput = previousInputState
46 | .map(_.mouseInput)
47 | .getOrElse(InputState.MouseInput(None, false)),
48 | mouseInput = inputState.mouseInput,
49 | keyboardInput = inputState.keyboardInput
50 | )
51 | previousInputState = Some(inputState)
52 | history
53 |
54 | private[interim] def commit(): this.type =
55 | currentItemState = scratchItemState.clone()
56 | this
57 |
58 | def this() = this(0, None, UiContext.ItemState(), UiContext.ItemState(), new mutable.TreeMap())
59 |
60 | override def clone(): UiContext =
61 | new UiContext(
62 | currentZ,
63 | previousInputState,
64 | currentItemState.clone(),
65 | scratchItemState.clone(),
66 | ops.clone().mapValuesInPlace((_, v) => v.clone())
67 | )
68 |
69 | def fork(): UiContext =
70 | new UiContext(currentZ, previousInputState, currentItemState, scratchItemState, new mutable.TreeMap())
71 |
72 | def ++=(that: UiContext): this.type =
73 | // previousInputState stays the same
74 | this.scratchItemState = that.scratchItemState.clone()
75 | that.ops.foreach: (z, ops) =>
76 | if (this.ops.contains(z)) this.ops(z) ++= that.ops(z)
77 | else this.ops(z) = that.ops(z)
78 | this
79 |
80 | def pushRenderOp(op: RenderOp): this.type =
81 | if (!this.ops.contains(currentZ)) this.ops(currentZ) = new mutable.Queue()
82 | this.ops(currentZ).addOne(op)
83 | this
84 |
85 | object UiContext:
86 | private[interim] class ItemState(
87 | var hotItem: Option[(Int, ItemId)] = None, // Item being hovered by the mouse
88 | var activeItem: Option[ItemId] = None, // Item being clicked by the mouse
89 | var selectedItem: Option[ItemId] = None // Last item clicked
90 | ):
91 | def getItemStatus(id: ItemId)(using inputState: InputState): UiContext.ItemStatus =
92 | val hot = hotItem.map(_._2) == Some(id)
93 | val active = activeItem == Some(id)
94 | val selected = selectedItem == Some(id)
95 | val clicked = hot && active && inputState.mouseInput.isPressed == false
96 | UiContext.ItemStatus(hot, active, selected, clicked)
97 |
98 | override def clone(): ItemState = new ItemState(hotItem, activeItem, selectedItem)
99 |
100 | /** Status of an item.
101 | *
102 | * @param hot if the mouse is on top of the item
103 | * @param active if the mouse clicked the item (and is still pressed down).
104 | * This value stays true for one extra frame, so that it's
105 | * possible to trigger an action on mouse up (see `clicked`).
106 | * @param selected if this was the last element clicked
107 | * @param clicked if the mouse clicked this element and was just released.
108 | */
109 | final case class ItemStatus(hot: Boolean, active: Boolean, selected: Boolean, clicked: Boolean)
110 |
111 | /** Registers an item on the UI state, taking a certain area.
112 | *
113 | * Components register themselves on every frame to update and check their status.
114 | *
115 | * This is only required when creating new components. If you are using the premade components
116 | * you do not need to call this.
117 | *
118 | * Also of note is that this method returns the status from computed in the previous iteration,
119 | * as that's the only consistent information.
120 | * If you need the status as it's being computed, check [[getScratchItemStatus]].
121 | *
122 | * @param id Item ID to register
123 | * @param area the area of this component
124 | * @param passive passive items are items such as windows that are never marked as active,
125 | * they are just registered to block components under them.
126 | * @return the item status of the registered component.
127 | */
128 | def registerItem(id: ItemId, area: Rect, passive: Boolean = false)(using
129 | uiContext: UiContext,
130 | inputState: InputState
131 | ): UiContext.ItemStatus =
132 | uiContext.registerItem(id, area, passive)
133 |
134 | /** Checks the status of a component from the previous UI computation without registering it.
135 | *
136 | * This method can be used if one needs to check the status of an item in the previous iteration
137 | * without registering it.
138 | * @param id Item ID to register
139 | * @return the item status of the component.
140 | */
141 | def getItemStatus(id: ItemId)(using
142 | uiContext: UiContext,
143 | inputState: InputState
144 | ): UiContext.ItemStatus =
145 | uiContext.getItemStatus(id)
146 |
147 | /** Checks the status of a component at the current point in the UI computation without registering it.
148 | *
149 | * This method can be used if one needs to check the status of an item in the middle of the current iteration.
150 | *
151 | * This is can return inconsistent state, and is only recommended for unit tests or debugging.
152 | * @param id Item ID to register
153 | * @return the item status of the component.
154 | */
155 | def getScratchItemStatus(id: ItemId)(using
156 | uiContext: UiContext,
157 | inputState: InputState
158 | ): UiContext.ItemStatus =
159 | uiContext.getScratchItemStatus(id)
160 |
161 | /** Applies the operations in a code block at a specified z-index
162 | * (higher z-indices show on front of lower z-indices).
163 | */
164 | def withZIndex[T](zIndex: Int)(body: (UiContext) ?=> T)(using uiContext: UiContext): T =
165 | val oldZ = uiContext.currentZ
166 | uiContext.currentZ = zIndex
167 | val res = body(using uiContext)
168 | uiContext.currentZ = oldZ
169 | res
170 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/api/Components.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.api
2 |
3 | import eu.joaocosta.interim._
4 | import eu.joaocosta.interim.skins._
5 |
6 | /** Object containing the default components.
7 | *
8 | * By convention, all components are functions in the form `def component(id, ...params, skin)(area, value): Value`.
9 | *
10 | * The area parameter can be ommited if there's an area allocator in scope.
11 | */
12 | object Components extends Components
13 |
14 | trait Components:
15 |
16 | /** Button component. Returns true if it's being clicked, false otherwise.
17 | *
18 | * @param label text label to show on this button
19 | */
20 | final def button(
21 | id: ItemId,
22 | label: String,
23 | skin: ButtonSkin = ButtonSkin.default()
24 | ): DynamicComponentWithBody[Unit, Option] =
25 | new DynamicComponentWithBody[Unit, Option]:
26 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
27 | skin.allocateArea(allocator, label)
28 |
29 | def render[T](area: Rect, body: Unit => T): Component[Option[T]] =
30 | val buttonArea = skin.buttonArea(area)
31 | val itemStatus = UiContext.registerItem(id, buttonArea)
32 | skin.renderButton(area, label, itemStatus)
33 | Option.when(itemStatus.clicked)(body(()))
34 |
35 | /** Checkbox component. Returns true if it's enabled, false otherwise.
36 | */
37 | final def checkbox(
38 | id: ItemId,
39 | skin: CheckboxSkin = CheckboxSkin.default()
40 | ): DynamicComponentWithValue[Boolean] =
41 | new DynamicComponentWithValue[Boolean]:
42 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
43 | skin.allocateArea(allocator)
44 |
45 | def render(area: Rect, value: Ref[Boolean]): Component[Unit] =
46 | val checkboxArea = skin.checkboxArea(area)
47 | val itemStatus = UiContext.registerItem(id, checkboxArea)
48 | skin.renderCheckbox(area, value.get, itemStatus)
49 | value.modifyIf(itemStatus.clicked)(!_)
50 |
51 | /** Radio button component. Returns value currently selected.
52 | *
53 | * @param buttonValue the value of this button (value that this button returns when selected)
54 | * @param label text label to show on this button
55 | */
56 | final def radioButton[T](
57 | id: ItemId,
58 | area: Rect,
59 | buttonValue: T,
60 | label: String,
61 | skin: ButtonSkin = ButtonSkin.default()
62 | ): DynamicComponentWithValue[T] =
63 | new DynamicComponentWithValue[T]:
64 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
65 | skin.allocateArea(allocator, label)
66 |
67 | def render(area: Rect, value: Ref[T]): Component[Unit] =
68 | val buttonArea = skin.buttonArea(area)
69 | val itemStatus = UiContext.registerItem(id, buttonArea)
70 | if (itemStatus.clicked) value := buttonValue
71 | if (value.get == buttonValue) skin.renderButton(area, label, itemStatus.copy(hot = true, active = true))
72 | else skin.renderButton(area, label, itemStatus)
73 |
74 | /** Select box component. Returns the index value currently selected inside a PanelState.
75 | *
76 | * It also allows one to define a default label if the value is invalid.
77 | * This can be particularly to keep track if a user has selected no value, by setting the
78 | * initial value to an invalid sentinel value (e.g. -1).
79 | *
80 | * @param labels text labels for each value
81 | * @param defaultLabel label to print if the value doesn't match any of the labels.
82 | */
83 | final def select(
84 | id: ItemId,
85 | labels: Vector[String],
86 | defaultLabel: String = "",
87 | skin: SelectSkin = SelectSkin.default()
88 | ): DynamicComponentWithValue[PanelState[Int]] =
89 | new DynamicComponentWithValue[PanelState[Int]]:
90 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
91 | skin.allocateArea(allocator, labels)
92 |
93 | def render(area: Rect, value: Ref[PanelState[Int]]): Component[Unit] =
94 | val selectBoxArea = skin.selectBoxArea(area)
95 | val itemStatus = UiContext.registerItem(id, area)
96 | value.modifyIf(itemStatus.selected)(_.open)
97 | skin.renderSelectBox(area, value.get.value, labels, defaultLabel, itemStatus)
98 | if (value.get.isOpen)
99 | value.modifyIf(!itemStatus.selected)(_.close)
100 | Primitives.onTop:
101 | labels.zipWithIndex
102 | .foreach: (label, idx) =>
103 | val selectOptionArea = skin.selectOptionArea(area, idx)
104 | val optionStatus = UiContext.registerItem(id |> idx, selectOptionArea)
105 | skin.renderSelectOption(area, idx, labels, optionStatus)
106 | if (optionStatus.active) value := PanelState.closed(idx)
107 |
108 | /** Slider component. Returns the current position of the slider, between min and max.
109 | *
110 | * @param min minimum value for this slider
111 | * @param max maximum value fr this slider
112 | */
113 | final def slider(
114 | id: ItemId,
115 | min: Int,
116 | max: Int,
117 | skin: SliderSkin = SliderSkin.default()
118 | ): DynamicComponentWithValue[Int] =
119 | new DynamicComponentWithValue[Int]:
120 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
121 | skin.allocateArea(allocator)
122 |
123 | def render(area: Rect, value: Ref[Int]): Component[Unit] =
124 | val sliderArea = skin.sliderArea(area)
125 | val steps = max - min + 1
126 | val itemStatus = UiContext.registerItem(id, sliderArea)
127 | val clampedValue = math.max(min, math.min(value.get, max))
128 | skin.renderSlider(area, min, clampedValue, max, itemStatus)
129 | if (itemStatus.active)
130 | summon[InputState].mouseInput.position.foreach: (mouseX, mouseY) =>
131 | val intPosition =
132 | if (area.w > area.h) steps * (mouseX - sliderArea.x) / sliderArea.w
133 | else steps * (mouseY - sliderArea.y) / sliderArea.h
134 | value := math.max(min, math.min(min + intPosition, max))
135 |
136 | /** Text input component. Returns the current string inputed.
137 | */
138 | final def textInput(
139 | id: ItemId,
140 | skin: TextInputSkin = TextInputSkin.default()
141 | ): DynamicComponentWithValue[String] =
142 | new DynamicComponentWithValue[String]:
143 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
144 | skin.allocateArea(allocator)
145 |
146 | def render(area: Rect, value: Ref[String]): Component[Unit] =
147 | val textInputArea = skin.textInputArea(area)
148 | val itemStatus = UiContext.registerItem(id, textInputArea)
149 | skin.renderTextInput(area, value.get, itemStatus)
150 | value.modifyIf(itemStatus.selected)(summon[InputState].appendKeyboardInput)
151 |
152 | /** Draggable handle. Returns the moved area.
153 | *
154 | * Instead of using this component directly, it can be easier to use [[eu.joaocosta.interim.api.Panels.window]]
155 | * with movable = true.
156 | */
157 | final def moveHandle(id: ItemId, skin: HandleSkin = HandleSkin.default()): DynamicComponentWithValue[Rect] =
158 | new DynamicComponentWithValue[Rect]:
159 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
160 | skin.allocateArea(allocator)
161 |
162 | def render(area: Rect, value: Ref[Rect]): Component[Unit] =
163 | val handleArea = skin.moveHandleArea(area)
164 | val itemStatus = UiContext.registerItem(id, handleArea)
165 | val deltaX = summon[InputState.Historical].deltaX
166 | val deltaY = summon[InputState.Historical].deltaY
167 | skin.renderMoveHandle(area, itemStatus)
168 | value.modifyIf(itemStatus.active)(_.move(deltaX, deltaY))
169 |
170 | /** Draggable handle. Returns the resized area.
171 | *
172 | * Instead of using this component directly, it can be easier to use [[eu.joaocosta.interim.api.Panels.window]]
173 | * with movable = true.
174 | */
175 | final def resizeHandle(id: ItemId, skin: HandleSkin = HandleSkin.default()): DynamicComponentWithValue[Rect] =
176 | new DynamicComponentWithValue[Rect]:
177 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
178 | skin.allocateArea(allocator)
179 |
180 | def render(area: Rect, value: Ref[Rect]): Component[Unit] =
181 | val handleArea = skin.resizeHandleArea(area)
182 | val itemStatus = UiContext.registerItem(id, handleArea)
183 | val deltaX = summon[InputState.Historical].deltaX
184 | val deltaY = summon[InputState.Historical].deltaY
185 | skin.renderResizeHandle(area, itemStatus)
186 | value.modifyIf(itemStatus.active)(_.resize(deltaX, deltaY))
187 |
188 | /** Close handle. Closes the panel when clicked.
189 | *
190 | * Instead of using this component directly, it can be easier to use [[eu.joaocosta.interim.api.Panels.window]]
191 | * with closable = true.
192 | */
193 | final def closeHandle[T](
194 | id: ItemId,
195 | skin: HandleSkin = HandleSkin.default()
196 | ): DynamicComponentWithValue[PanelState[T]] =
197 | new DynamicComponentWithValue[PanelState[T]]:
198 | def allocateArea(using allocator: LayoutAllocator.AreaAllocator): Rect =
199 | skin.allocateArea(allocator)
200 |
201 | def render(area: Rect, value: Ref[PanelState[T]]): Component[Unit] =
202 | val handleArea = skin.closeHandleArea(area)
203 | val itemStatus = UiContext.registerItem(id, handleArea)
204 | skin.renderCloseHandle(area, itemStatus)
205 | value.modifyIf(itemStatus.clicked)(_.close)
206 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/api/Constants.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.api
2 |
3 | import eu.joaocosta.interim.{HorizontalAlignment, VerticalAlignment}
4 |
5 | /** Object containing some aliases for some constants.
6 | */
7 | object Constants extends Constants
8 |
9 | trait Constants:
10 | final val alignLeft: HorizontalAlignment.Left.type = HorizontalAlignment.Left
11 | final val centerHorizontally: HorizontalAlignment.Center.type = HorizontalAlignment.Center
12 | final val alignRight: HorizontalAlignment.Right.type = HorizontalAlignment.Right
13 |
14 | final val alignTop: VerticalAlignment.Top.type = VerticalAlignment.Top
15 | final val centerVertically: VerticalAlignment.Center.type = VerticalAlignment.Center
16 | final val alignBottom: VerticalAlignment.Bottom.type = VerticalAlignment.Bottom
17 |
18 | final val maxSize: Int.MaxValue.type = Int.MaxValue
19 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/api/Layouts.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.api
2 |
3 | import eu.joaocosta.interim._
4 | import eu.joaocosta.interim.layouts._
5 |
6 | /** Objects containing all default layouts.
7 | *
8 | * By convention, all layouts are of the form `def layout(area, params...)(body): Value`.
9 | */
10 | object Layouts extends Layouts
11 |
12 | trait Layouts:
13 |
14 | /** Clipped region.
15 | *
16 | * The body will be rendered only inside the clipped area. Input outside the area will also be ignored.
17 | *
18 | * Note that the clip will only be applied to elements in the current Z-index.
19 | */
20 | final def clip[T](area: Rect)(
21 | body: (InputState, UiContext) ?=> T
22 | )(using inputState: InputState, uiContext: UiContext): T =
23 | val newUiContext = uiContext.fork()
24 | val newInputState = inputState.clip(area)
25 | val result = body(using newInputState, newUiContext)
26 | newUiContext.ops.get(newUiContext.currentZ).foreach(_.mapInPlace(_.clip(area)).filterInPlace(!_.area.isEmpty))
27 | uiContext ++= newUiContext
28 | result
29 |
30 | /** Lays out the components in a grid where all elements have the same size, separated by a padding.
31 | *
32 | * The body receives a `cell: Vector[Vector[Rect]]`, where `cell(y)(x)` is the rect of the y-th row and x-th
33 | * column.
34 | */
35 | final def grid[T](area: Rect, numRows: Int, numColumns: Int, padding: Int)(
36 | body: IndexedSeq[IndexedSeq[Rect]] => T
37 | ): T =
38 | body(
39 | rows(area, numRows, padding)(rowAlloc ?=> rowAlloc.map(subArea => columns(subArea, numColumns, padding)(summon)))
40 | )
41 |
42 | /** Lays out the components in a sequence of rows where all elements have the same size, separated by a padding.
43 | *
44 | * The body receives a `row: Vector[Rect]`, where `row(y)` is the rect of the y-th row.
45 | */
46 | final def rows[T](
47 | area: Rect,
48 | numRows: Int,
49 | padding: Int,
50 | alignment: VerticalAlignment.Top.type | VerticalAlignment.Bottom.type = VerticalAlignment.Top
51 | )(body: StaticRowAllocator ?=> T): T =
52 | val allocator = new StaticRowAllocator(area, padding, numRows, alignment)
53 | body(using allocator)
54 |
55 | /** Lays out the components in a sequence of columns where all elements have the same size, separated by a padding.
56 | *
57 | * The body receives a `column: Vector[Rect]`, where `column(y)` is the rect of the x-th column.
58 | */
59 | final def columns[T](
60 | area: Rect,
61 | numColumns: Int,
62 | padding: Int,
63 | alignment: HorizontalAlignment.Left.type | HorizontalAlignment.Right.type = HorizontalAlignment.Left
64 | )(body: StaticColumnAllocator ?=> T): T =
65 | val allocator = new StaticColumnAllocator(area, padding, numColumns, alignment)
66 | body(using allocator)
67 |
68 | /** Lays out the components in a sequence of rows of different sizes, separated by a padding.
69 | *
70 | * The body receives a `nextRow: Int => Rect`, where `nextRow(height)` is the rect of the next row, with the
71 | * specified height (if possible). If the size is negative, the row will start from the bottom.
72 | */
73 | final def dynamicRows[T](
74 | area: Rect,
75 | padding: Int,
76 | alignment: VerticalAlignment.Top.type | VerticalAlignment.Bottom.type = VerticalAlignment.Top
77 | )(body: DynamicRowAllocator ?=> T): T =
78 | val allocator = new DynamicRowAllocator(area, padding, alignment)
79 | body(using allocator)
80 |
81 | /** Lays out the components in a sequence of columns of different sizes, separated by a padding.
82 | *
83 | * The body receives a `nextColumn: Int => Rect`, where `nextColumn(width)` is the rect of the next column, with the
84 | * specified width (if possible). . If the size is negative, the row will start from the right.
85 | */
86 | final def dynamicColumns[T](
87 | area: Rect,
88 | padding: Int,
89 | alignment: HorizontalAlignment.Left.type | HorizontalAlignment.Right.type = HorizontalAlignment.Left
90 | )(body: DynamicColumnAllocator ?=> T): T =
91 | val allocator = new DynamicColumnAllocator(area, padding, alignment)
92 | body(using allocator)
93 |
94 | /** Handle mouse events inside a specified area.
95 | *
96 | * The body receives an optional MouseInput with the coordinates adjusted to be relative
97 | * to the enclosing area.
98 | * If the mouse is outside of the area, the body receives None.
99 | */
100 | final def mouseArea[T](area: Rect)(body: Option[InputState.MouseInput] => T)(using inputState: InputState): T =
101 | body(
102 | Option.when(area.isMouseOver)(
103 | inputState.mouseInput.copy(position = inputState.mouseInput.position.map((x, y) => (x - area.x, y - area.y)))
104 | )
105 | )
106 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/api/Panels.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.api
2 |
3 | import eu.joaocosta.interim._
4 | import eu.joaocosta.interim.skins._
5 |
6 | /** Objects containing all default panels.
7 | *
8 | * Panels are a mix of a component and a layout. They perform rendering operations, but also provide a draw area.
9 | *
10 | * By convention, all panels are of the form `def panel(id, area, params..., skin)(body): (Option[Value], PanelState[Rect])`.
11 | * The returned value is the value returned by the body. Panels also return a rect, which is the area
12 | * the panel must be called with in the next frame (e.g. for movable panels).
13 | *
14 | * As such, panels should be called like:
15 | *
16 | * ```
17 | * val (value, nextRect) = panel(id, params..., skins...)(panelRect){area => ...}
18 | * panelRect = nextRect
19 | * ```
20 | */
21 | object Panels extends Panels
22 |
23 | trait Panels:
24 |
25 | /** Window with a title.
26 | *
27 | * @param title of this window
28 | * @param closable if true, the window will include a closable handle in the title bar
29 | * @param movable if true, the window will include a move handle in the title bar
30 | * @param resizable if true, the window will include a resize handle in the bottom corner
31 | */
32 | final def window(
33 | id: ItemId,
34 | title: String,
35 | closable: Boolean = false,
36 | movable: Boolean = false,
37 | resizable: Boolean = false,
38 | skin: WindowSkin = WindowSkin.default(),
39 | handleSkin: HandleSkin = HandleSkin.default()
40 | ): Panel[Rect, [T] =>> (Option[T], PanelState[Rect])] =
41 | new Panel[Rect, [T] =>> (Option[T], PanelState[Rect])]:
42 | def render[T](area: Ref[PanelState[Rect]], body: Rect => T): Component[(Option[T], PanelState[Rect])] =
43 | if (area.get.isOpen)
44 | def windowArea = area.get.value
45 | UiContext.registerItem(id, windowArea, passive = true)
46 | skin.renderWindow(windowArea, title)
47 | val res = body(skin.panelArea(windowArea))
48 | if (closable)
49 | Components
50 | .closeHandle(
51 | id |> "internal_close_handle",
52 | handleSkin
53 | )(skin.titleTextArea(windowArea), area)
54 | if (resizable)
55 | val newArea = Components
56 | .resizeHandle(
57 | id |> "internal_resize_handle",
58 | handleSkin
59 | )(skin.resizeArea(windowArea), windowArea)
60 | area.modify(_.copy(value = skin.ensureMinimumArea(newArea)))
61 | if (movable)
62 | val newArea = Components
63 | .moveHandle(
64 | id |> "internal_move_handle",
65 | handleSkin
66 | )(skin.titleTextArea(windowArea), windowArea)
67 | area.modify(_.copy(value = newArea))
68 | (Option.when(area.get.isOpen)(res), area.get)
69 | else (None, area.get)
70 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/api/Primitives.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.api
2 |
3 | import eu.joaocosta.interim._
4 |
5 | /** Object containing the default primitives.
6 | *
7 | * By convention, all components are functions in the form `def primitive(area, color, params...): Unit`.
8 | *
9 | * The area parameter can be either a `Rect` or a `LayoutAllocator`.
10 | */
11 | object Primitives extends Primitives
12 |
13 | trait Primitives:
14 |
15 | /** Draws a rectangle filling the specified area with a color.
16 | */
17 | final def rectangle(area: Rect | LayoutAllocator.CellAllocator, color: Color)(using uiContext: UiContext): Unit =
18 | val reservedArea = area match {
19 | case rect: Rect => rect
20 | case alloc: LayoutAllocator.CellAllocator => alloc.nextCell()
21 | }
22 | uiContext.pushRenderOp(RenderOp.DrawRect(reservedArea, color))
23 |
24 | /** Draws the outline a rectangle inside the specified area with a color.
25 | */
26 | final def rectangleOutline(area: Rect | LayoutAllocator.CellAllocator, color: Color, strokeSize: Int)(using
27 | uiContext: UiContext
28 | ): Unit =
29 | val reservedArea = area match {
30 | case rect: Rect => rect
31 | case alloc: LayoutAllocator.CellAllocator => alloc.nextCell()
32 | }
33 | val top = reservedArea.copy(h = strokeSize)
34 | val bottom = top.move(dx = 0, dy = reservedArea.h - strokeSize)
35 | val left = reservedArea.copy(w = strokeSize)
36 | val right = left.move(dx = reservedArea.w - strokeSize, dy = 0)
37 | rectangle(top, color)
38 | rectangle(bottom, color)
39 | rectangle(left, color)
40 | rectangle(right, color)
41 |
42 | /** Draws a block of text in the specified area with a color.
43 | *
44 | * @param text text to write
45 | * @param font font definition
46 | * @param horizontalAlignment how the text should be aligned horizontally
47 | * @param verticalAlignment how the text should be aligned vertically
48 | */
49 | final def text(
50 | area: Rect | LayoutAllocator.AreaAllocator,
51 | color: Color,
52 | message: String,
53 | font: Font = Font.default,
54 | horizontalAlignment: HorizontalAlignment = HorizontalAlignment.Left,
55 | verticalAlignment: VerticalAlignment = VerticalAlignment.Top
56 | )(using
57 | uiContext: UiContext
58 | ): Unit =
59 | if (message.nonEmpty)
60 | val reservedArea = area match {
61 | case rect: Rect => rect
62 | case alloc: LayoutAllocator.AreaAllocator => alloc.allocate(message, font)
63 | }
64 | uiContext.pushRenderOp(
65 | RenderOp.DrawText(reservedArea, color, message, font, reservedArea, horizontalAlignment, verticalAlignment)
66 | )
67 |
68 | /** Advanced operation to add a custom primitive to the list of render operations.
69 | *
70 | * Supports an arbitrary data value. It's up to the backend to interpret it as it sees fit.
71 | * If the backend does not know how to interpret it, it can just render a colored rect.
72 | *
73 | * @param data custom value to be interpreted by the backend.
74 | */
75 | final def custom[T](area: Rect, color: Color, data: T)(using uiContext: UiContext): Unit =
76 | uiContext.pushRenderOp(RenderOp.Custom(area, color, data))
77 |
78 | /** Applies the operations in a code block at the next z-index. */
79 | def onTop[T](body: (UiContext) ?=> T)(using uiContext: UiContext): T =
80 | UiContext.withZIndex(uiContext.currentZ + 1)(body)
81 |
82 | /** Applies the operations in a code block at the previous z-index. */
83 | def onBottom[T](body: (UiContext) ?=> T)(using uiContext: UiContext): T =
84 | UiContext.withZIndex(uiContext.currentZ - 1)(body)
85 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/layouts/DynamicColumnAllocator.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.layouts
2 |
3 | import eu.joaocosta.interim._
4 |
5 | final class DynamicColumnAllocator(
6 | val area: Rect,
7 | padding: Int,
8 | alignment: HorizontalAlignment.Left.type | HorizontalAlignment.Right.type
9 | ) extends LayoutAllocator.ColumnAllocator
10 | with LayoutAllocator.AreaAllocator
11 | with (Int => Rect):
12 | private var currentX = area.x
13 | private var currentW = area.w
14 | private val dirMod = if (alignment == HorizontalAlignment.Left) 1 else -1
15 |
16 | def nextColumn(width: Int): Rect =
17 | val absWidth = math.abs(width).toInt
18 | if (absWidth == 0 || currentW <= 0) // Empty
19 | area.copy(x = currentX, w = 0)
20 | else if (absWidth >= currentW) // Fill remaining area
21 | val areaX = currentX
22 | val areaW = currentW
23 | currentX = area.w
24 | currentW = 0
25 | area.copy(x = areaX, w = areaW)
26 | else if (dirMod * width >= 0) // Fill from the left
27 | val areaX = currentX
28 | currentX += absWidth + padding
29 | currentW -= absWidth + padding
30 | area.copy(x = areaX, w = absWidth)
31 | else // Fill from the right
32 | val areaX = currentX + currentW - absWidth
33 | currentW -= absWidth + padding
34 | area.copy(x = areaX, w = absWidth)
35 |
36 | def apply(height: Int) = nextColumn(height)
37 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/layouts/DynamicRowAllocator.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.layouts
2 |
3 | import eu.joaocosta.interim._
4 |
5 | final class DynamicRowAllocator(
6 | val area: Rect,
7 | padding: Int,
8 | alignment: VerticalAlignment.Top.type | VerticalAlignment.Bottom.type
9 | ) extends LayoutAllocator.RowAllocator
10 | with LayoutAllocator.AreaAllocator
11 | with (Int => Rect):
12 | private var currentY = area.y
13 | private var currentH = area.h
14 | private val dirMod = if (alignment == VerticalAlignment.Top) 1 else -1
15 |
16 | def nextRow(height: Int) =
17 | val absHeight = math.abs(height).toInt
18 | if (absHeight == 0 || currentH <= 0) // Empty
19 | area.copy(y = currentY, h = 0)
20 | else if (absHeight >= currentH) // Fill remaining area
21 | val areaY = currentY
22 | val areaH = currentH
23 | currentY = area.h
24 | currentH = 0
25 | area.copy(y = areaY, h = areaH)
26 | else if (dirMod * height >= 0) // Fill from the top
27 | val areaY = currentY
28 | currentY += absHeight + padding
29 | currentH -= absHeight + padding
30 | area.copy(y = areaY, h = absHeight)
31 | else // Fill from the bottom
32 | val areaY = currentY + currentH - absHeight
33 | currentH -= absHeight + padding
34 | area.copy(y = areaY, h = absHeight)
35 |
36 | def apply(height: Int) = nextRow(height)
37 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/layouts/StaticColumnAllocator.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.layouts
2 |
3 | import eu.joaocosta.interim._
4 |
5 | final class StaticColumnAllocator(
6 | val area: Rect,
7 | padding: Int,
8 | numColumns: Int,
9 | alignment: HorizontalAlignment.Left.type | HorizontalAlignment.Right.type
10 | ) extends LayoutAllocator.ColumnAllocator
11 | with LayoutAllocator.AreaAllocator
12 | with LayoutAllocator.CellAllocator:
13 | lazy val cells: IndexedSeq[Rect] =
14 | if (numColumns == 0) Vector.empty
15 | else
16 | val columnSize = (area.w - (numColumns - 1) * padding) / numColumns.toDouble
17 | val intColumnSize = columnSize.toInt
18 | val baseCells = for
19 | column <- (0 until numColumns)
20 | dx = (column * (columnSize + padding)).toInt
21 | yield Rect(area.x + dx, area.y, intColumnSize, area.h)
22 | if (alignment == HorizontalAlignment.Left) baseCells
23 | else baseCells.reverse
24 |
25 | def nextColumn(): Rect = nextCell()
26 |
27 | def nextColumn(width: Int): Rect =
28 | if (!cellsIterator.hasNext) area.copy(w = 0, h = 0)
29 | else
30 | var acc = cellsIterator.next()
31 | while (acc.w < width && cellsIterator.hasNext)
32 | acc = acc ++ cellsIterator.next()
33 | acc
34 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/layouts/StaticRowAllocator.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.layouts
2 |
3 | import eu.joaocosta.interim._
4 |
5 | final class StaticRowAllocator(
6 | val area: Rect,
7 | padding: Int,
8 | numRows: Int,
9 | alignment: VerticalAlignment.Top.type | VerticalAlignment.Bottom.type
10 | ) extends LayoutAllocator.RowAllocator
11 | with LayoutAllocator.AreaAllocator
12 | with LayoutAllocator.CellAllocator:
13 | lazy val cells: IndexedSeq[Rect] =
14 | if (numRows == 0) Vector.empty
15 | else
16 | val rowSize = (area.h - (numRows - 1) * padding) / numRows.toDouble
17 | val intRowSize = rowSize.toInt
18 | val baseCells = for
19 | row <- (0 until numRows)
20 | dy = (row * (rowSize + padding)).toInt
21 | yield Rect(area.x, area.y + dy, area.w, intRowSize)
22 | if (alignment == VerticalAlignment.Top) baseCells
23 | else baseCells.reverse
24 |
25 | def nextRow(): Rect = nextCell()
26 |
27 | def nextRow(height: Int): Rect =
28 | if (!cellsIterator.hasNext) area.copy(w = 0, h = 0)
29 | else
30 | var acc = cellsIterator.next()
31 | while (acc.h < height && cellsIterator.hasNext)
32 | acc = acc ++ cellsIterator.next()
33 | acc
34 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/ButtonSkin.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | import eu.joaocosta.interim.TextLayout._
4 | import eu.joaocosta.interim._
5 | import eu.joaocosta.interim.api.Primitives._
6 |
7 | trait ButtonSkin:
8 | def allocateArea(allocator: LayoutAllocator.AreaAllocator, label: String): Rect
9 | def buttonArea(area: Rect): Rect
10 | def renderButton(area: Rect, label: String, itemStatus: UiContext.ItemStatus)(using
11 | uiContext: UiContext
12 | ): Unit
13 |
14 | object ButtonSkin extends DefaultSkin:
15 |
16 | final case class Default(
17 | buttonHeight: Int,
18 | font: Font,
19 | colorScheme: ColorScheme
20 | ) extends ButtonSkin:
21 |
22 | def allocateArea(allocator: LayoutAllocator.AreaAllocator, label: String): Rect =
23 | allocator.allocate(label, font, paddingH = buttonHeight / 2)
24 |
25 | def buttonArea(area: Rect): Rect =
26 | area.copy(w = area.w, h = area.h - buttonHeight)
27 |
28 | def renderButton(area: Rect, label: String, itemStatus: UiContext.ItemStatus)(using
29 | uiContext: UiContext
30 | ): Unit =
31 | val buttonArea = this.buttonArea(area)
32 | val clickedArea = buttonArea.move(dx = 0, dy = buttonHeight)
33 | itemStatus match
34 | case UiContext.ItemStatus(false, false, _, _) =>
35 | rectangle(area.copy(y = buttonArea.y2, h = buttonHeight), colorScheme.primaryShadow)
36 | rectangle(buttonArea, colorScheme.primary)
37 | case UiContext.ItemStatus(true, false, _, _) =>
38 | rectangle(area.copy(y = buttonArea.y2, h = buttonHeight), colorScheme.primary)
39 | rectangle(buttonArea, colorScheme.primaryHighlight)
40 | case UiContext.ItemStatus(false, true, _, _) =>
41 | rectangle(area.copy(y = buttonArea.y2, h = buttonHeight), colorScheme.primary)
42 | rectangle(buttonArea, colorScheme.primaryHighlight)
43 | case UiContext.ItemStatus(true, true, _, _) =>
44 | rectangle(clickedArea, colorScheme.primaryHighlight)
45 | itemStatus match
46 | case UiContext.ItemStatus(true, true, _, _) =>
47 | text(clickedArea, colorScheme.text, label, font, HorizontalAlignment.Center, VerticalAlignment.Center)
48 | case _ =>
49 | text(buttonArea, colorScheme.text, label, font, HorizontalAlignment.Center, VerticalAlignment.Center)
50 |
51 | val lightDefault: Default = Default(
52 | buttonHeight = 3,
53 | font = Font.default,
54 | colorScheme = ColorScheme.lightScheme
55 | )
56 |
57 | val darkDefault: Default = Default(
58 | buttonHeight = 3,
59 | font = Font.default,
60 | colorScheme = ColorScheme.darkScheme
61 | )
62 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/CheckboxSkin.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | import eu.joaocosta.interim._
4 | import eu.joaocosta.interim.api.Primitives._
5 |
6 | trait CheckboxSkin:
7 | def allocateArea(allocator: LayoutAllocator.AreaAllocator): Rect
8 | def checkboxArea(area: Rect): Rect
9 | def renderCheckbox(area: Rect, value: Boolean, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit
10 |
11 | object CheckboxSkin extends DefaultSkin:
12 |
13 | final case class Default(
14 | padding: Int,
15 | colorScheme: ColorScheme
16 | ) extends CheckboxSkin:
17 |
18 | def allocateArea(allocator: LayoutAllocator.AreaAllocator): Rect =
19 | allocator.allocate(Font.default.fontSize, Font.default.fontSize)
20 |
21 | def checkboxArea(area: Rect): Rect =
22 | val smallSide = math.min(area.w, area.h)
23 | area.copy(w = smallSide, h = smallSide)
24 |
25 | def renderCheckbox(area: Rect, value: Boolean, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit =
26 | val checkboxArea = this.checkboxArea(area)
27 | itemStatus match
28 | case UiContext.ItemStatus(false, false, _, _) =>
29 | rectangle(checkboxArea, colorScheme.secondary)
30 | case UiContext.ItemStatus(true, false, _, _) =>
31 | rectangle(checkboxArea, colorScheme.secondaryHighlight)
32 | case UiContext.ItemStatus(_, true, _, _) =>
33 | rectangle(checkboxArea, colorScheme.primaryHighlight)
34 | if (value) rectangle(checkboxArea.shrink(padding), colorScheme.icon)
35 |
36 | val lightDefault: Default = Default(
37 | padding = 2,
38 | ColorScheme.lightScheme
39 | )
40 |
41 | val darkDefault: Default = Default(
42 | padding = 2,
43 | ColorScheme.darkScheme
44 | )
45 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/ColorScheme.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | import eu.joaocosta.interim.Color
4 |
5 | final case class ColorScheme(
6 | background: Color, // white / black
7 | text: Color, // black / white
8 | icon: Color, // black / lightGray
9 | iconHighlight: Color, // pureGray / white
10 | primary: Color, // lightPrimary / darkPrimary
11 | primaryShadow: Color, // lightPrimaryShadow / darkPrimaryShadow
12 | primaryHighlight: Color, // lightPrimaryHighlight / darkPrimaryHighlight
13 | secondary: Color, // lightGray / darkGray
14 | secondaryHighlight: Color, // pureGray / pureGray
15 | borderColor: Color // pureGray / pureGray
16 | )
17 |
18 | /** Internal default color scheme used by InterIm's default skins */
19 | object ColorScheme:
20 | val white = Color(246, 247, 251)
21 | val lightGray = Color(177, 186, 177)
22 | val pureGray = Color(127, 127, 127)
23 | val darkGray = Color(77, 77, 77)
24 | val black = Color(23, 21, 23)
25 |
26 | val lightPrimary = Color(9, 211, 222)
27 | val lightPrimaryShadow = Color(15, 172, 186)
28 | val lightPrimaryHighlight = Color(0, 247, 255)
29 |
30 | val darkPrimary = Color(97, 31, 125)
31 | val darkPrimaryShadow = Color(67, 24, 92)
32 | val darkPrimaryHighlight = Color(130, 38, 158)
33 |
34 | val lightScheme = ColorScheme(
35 | background = white,
36 | text = black,
37 | icon = black,
38 | iconHighlight = pureGray,
39 | primary = lightPrimary,
40 | primaryShadow = lightPrimaryShadow,
41 | primaryHighlight = lightPrimaryHighlight,
42 | secondary = lightGray,
43 | secondaryHighlight = pureGray,
44 | borderColor = pureGray
45 | )
46 | val darkScheme = ColorScheme(
47 | background = black,
48 | text = white,
49 | icon = lightGray,
50 | iconHighlight = white,
51 | primary = darkPrimary,
52 | primaryShadow = darkPrimaryShadow,
53 | primaryHighlight = darkPrimaryHighlight,
54 | secondary = darkGray,
55 | secondaryHighlight = pureGray,
56 | borderColor = pureGray
57 | )
58 |
59 | private var darkMode = false
60 |
61 | /** Forces default skins to use the dark mode */
62 | def useDarkMode() = darkMode = true
63 |
64 | /** Forces default skins to use the light mode */
65 | def useLightMode() = darkMode = false
66 |
67 | /** Checks if dark mode is enabed for default skins */
68 | def darkModeEnabled() = darkMode
69 |
70 | /** Checks if light mode is enabed for default skins */
71 | def lightModeEnabled() = !darkMode
72 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/DefaultSkin.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | /** Default Skin companion object. Includes both a light and dark mode.
4 | */
5 | trait DefaultSkin:
6 | type Default
7 |
8 | def default(): Default =
9 | if (ColorScheme.lightModeEnabled()) lightDefault
10 | else darkDefault
11 |
12 | def lightDefault: Default
13 | def darkDefault: Default
14 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/HandleSkin.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | import eu.joaocosta.interim._
4 | import eu.joaocosta.interim.api.Primitives._
5 |
6 | trait HandleSkin:
7 | def allocateArea(allocator: LayoutAllocator.AreaAllocator): Rect
8 |
9 | def moveHandleArea(area: Rect): Rect
10 | def closeHandleArea(area: Rect): Rect
11 | def resizeHandleArea(area: Rect): Rect
12 |
13 | def renderMoveHandle(area: Rect, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit
14 | def renderCloseHandle(area: Rect, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit
15 | def renderResizeHandle(area: Rect, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit
16 |
17 | object HandleSkin extends DefaultSkin:
18 |
19 | final case class Default(colorScheme: ColorScheme) extends HandleSkin:
20 |
21 | def allocateArea(allocator: LayoutAllocator.AreaAllocator): Rect =
22 | allocator.allocate(Font.default.fontSize, Font.default.fontSize)
23 |
24 | def moveHandleArea(area: Rect): Rect =
25 | val smallSide = math.min(area.w, area.h)
26 | area.copy(w = smallSide, h = smallSide)
27 |
28 | def renderMoveHandle(area: Rect, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit =
29 | val handleArea = this.moveHandleArea(area)
30 | val color = itemStatus match
31 | case UiContext.ItemStatus(false, false, _, _) => colorScheme.icon
32 | case UiContext.ItemStatus(true, false, _, _) => colorScheme.iconHighlight
33 | case UiContext.ItemStatus(_, true, _, _) => colorScheme.primaryHighlight
34 | val lineHeight = handleArea.h / 3
35 | rectangle(handleArea.copy(h = lineHeight), color)
36 | rectangle(handleArea.copy(y = handleArea.y + 2 * lineHeight, h = lineHeight), color)
37 |
38 | def closeHandleArea(area: Rect): Rect =
39 | val smallSide = math.min(area.w, area.h)
40 | area.copy(x = area.x + area.w - smallSide, w = smallSide, h = smallSide)
41 |
42 | def renderCloseHandle(area: Rect, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit =
43 | val handleArea = this.closeHandleArea(area)
44 | val color = itemStatus match
45 | case UiContext.ItemStatus(false, false, _, _) => colorScheme.icon
46 | case UiContext.ItemStatus(true, false, _, _) => colorScheme.iconHighlight
47 | case UiContext.ItemStatus(_, true, _, _) => colorScheme.primaryHighlight
48 | rectangle(handleArea, color)
49 |
50 | def resizeHandleArea(area: Rect): Rect =
51 | val smallSide = math.min(area.w, area.h)
52 | area.copy(x = area.x2 - smallSide, w = smallSide, h = smallSide)
53 |
54 | def renderResizeHandle(area: Rect, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit =
55 | val handleArea = this.resizeHandleArea(area)
56 | val color = itemStatus match
57 | case UiContext.ItemStatus(false, false, _, _) => colorScheme.icon
58 | case UiContext.ItemStatus(true, false, _, _) => colorScheme.iconHighlight
59 | case UiContext.ItemStatus(_, true, _, _) => colorScheme.primaryHighlight
60 | val lineSize = handleArea.h / 3
61 | rectangle(handleArea.move(dx = handleArea.w - lineSize, dy = 0).copy(w = lineSize), color)
62 | rectangle(handleArea.move(dx = 0, dy = handleArea.h - lineSize).copy(h = lineSize), color)
63 |
64 | val lightDefault: Default = Default(ColorScheme.lightScheme)
65 |
66 | val darkDefault: Default = Default(ColorScheme.darkScheme)
67 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/SelectSkin.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | import eu.joaocosta.interim._
4 | import eu.joaocosta.interim.api.Primitives._
5 |
6 | trait SelectSkin:
7 | def allocateArea(allocator: LayoutAllocator.AreaAllocator, labels: Vector[String]): Rect
8 |
9 | def selectBoxArea(area: Rect): Rect
10 | def renderSelectBox(
11 | area: Rect,
12 | value: Int,
13 | labels: Vector[String],
14 | defaultLabel: String,
15 | itemStatus: UiContext.ItemStatus
16 | )(using
17 | uiContext: UiContext
18 | ): Unit
19 |
20 | def selectOptionArea(area: Rect, value: Int): Rect
21 | def renderSelectOption(area: Rect, value: Int, labels: Vector[String], itemStatus: UiContext.ItemStatus)(using
22 | uiContext: UiContext
23 | ): Unit
24 |
25 | object SelectSkin extends DefaultSkin:
26 |
27 | final case class Default(
28 | padding: Int,
29 | font: Font,
30 | colorScheme: ColorScheme
31 | ) extends SelectSkin:
32 |
33 | def allocateArea(allocator: LayoutAllocator.AreaAllocator, labels: Vector[String]): Rect =
34 | val largestLabel = labels.maxByOption(_.size).getOrElse("")
35 | allocator.allocate(largestLabel, font, paddingW = padding, paddingH = padding)
36 |
37 | // Select box
38 | def selectBoxArea(area: Rect): Rect =
39 | area
40 |
41 | def renderSelectBox(
42 | area: Rect,
43 | value: Int,
44 | labels: Vector[String],
45 | defaultLabel: String,
46 | itemStatus: UiContext.ItemStatus
47 | )(using
48 | uiContext: UiContext
49 | ): Unit =
50 | val selectBoxArea = this.selectBoxArea(area)
51 | val selectedLabel = labels.applyOrElse(value, _ => defaultLabel)
52 | itemStatus match
53 | case UiContext.ItemStatus(_, _, true, _) | UiContext.ItemStatus(_, true, _, _) =>
54 | rectangle(selectBoxArea, colorScheme.primaryHighlight)
55 | case UiContext.ItemStatus(true, _, _, _) =>
56 | rectangle(selectBoxArea, colorScheme.secondaryHighlight)
57 | case UiContext.ItemStatus(_, _, _, _) =>
58 | rectangle(selectBoxArea, colorScheme.secondary)
59 | text(
60 | selectBoxArea.shrink(padding),
61 | colorScheme.text,
62 | selectedLabel,
63 | font,
64 | HorizontalAlignment.Left,
65 | VerticalAlignment.Center
66 | )
67 |
68 | // Select option
69 | def selectOptionArea(area: Rect, value: Int): Rect =
70 | area.copy(y = area.y + area.h * (value + 1))
71 |
72 | def renderSelectOption(area: Rect, value: Int, labels: Vector[String], itemStatus: UiContext.ItemStatus)(using
73 | uiContext: UiContext
74 | ): Unit =
75 | val selectOptionArea = this.selectOptionArea(area, value)
76 | val optionLabel = labels.applyOrElse(value, _ => "")
77 | itemStatus match
78 | case UiContext.ItemStatus(_, _, true, _) | UiContext.ItemStatus(_, true, _, _) =>
79 | rectangle(selectOptionArea, colorScheme.primaryHighlight)
80 | case UiContext.ItemStatus(true, _, _, _) =>
81 | rectangle(selectOptionArea, colorScheme.secondaryHighlight)
82 | case _ =>
83 | rectangle(selectOptionArea, colorScheme.secondary)
84 | text(
85 | selectOptionArea.shrink(padding),
86 | colorScheme.text,
87 | optionLabel,
88 | font,
89 | HorizontalAlignment.Left,
90 | VerticalAlignment.Center
91 | )
92 |
93 | val lightDefault: Default = Default(
94 | padding = 2,
95 | font = Font.default,
96 | colorScheme = ColorScheme.lightScheme
97 | )
98 |
99 | val darkDefault: Default = Default(
100 | padding = 2,
101 | font = Font.default,
102 | colorScheme = ColorScheme.darkScheme
103 | )
104 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/SliderSkin.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | import eu.joaocosta.interim._
4 | import eu.joaocosta.interim.api.Primitives._
5 |
6 | trait SliderSkin:
7 | def allocateArea(allocator: LayoutAllocator.AreaAllocator): Rect
8 | def sliderArea(area: Rect): Rect
9 | def renderSlider(area: Rect, min: Int, value: Int, max: Int, itemStatus: UiContext.ItemStatus)(using
10 | uiContext: UiContext
11 | ): Unit
12 |
13 | object SliderSkin extends DefaultSkin:
14 |
15 | final case class Default(
16 | padding: Int,
17 | minSliderSize: Int,
18 | colorScheme: ColorScheme
19 | ) extends SliderSkin:
20 |
21 | def allocateArea(allocator: LayoutAllocator.AreaAllocator): Rect =
22 | allocator.allocate(Font.default.fontSize + 2 * padding, Font.default.fontSize + 2 * padding)
23 |
24 | def sliderArea(area: Rect): Rect = area.shrink(padding)
25 |
26 | def renderSlider(area: Rect, min: Int, value: Int, max: Int, itemStatus: UiContext.ItemStatus)(using
27 | uiContext: UiContext
28 | ): Unit =
29 | val sliderArea = this.sliderArea(area)
30 | val delta = value - min
31 | val steps = (max - min) + 1
32 | val sliderRect =
33 | if (area.w > area.h)
34 | val sliderSize = math.max(minSliderSize, sliderArea.w / steps)
35 | val maxX =
36 | (steps + 1) * sliderArea.w / steps - sliderSize // Correction for when the slider hits the min size
37 | val deltaX = delta * maxX / steps
38 | Rect(0, 0, sliderSize, sliderArea.h)
39 | .centerAt(0, sliderArea.centerY)
40 | .copy(x = sliderArea.x + deltaX)
41 | else
42 | val sliderSize = math.max(minSliderSize, sliderArea.h / steps)
43 | val maxY =
44 | (steps + 1) * sliderArea.h / steps - sliderSize // Correction for when the slider hits the min size
45 | val deltaY = delta * maxY / steps
46 | Rect(0, 0, sliderArea.w, sliderSize)
47 | .centerAt(sliderArea.centerX, 0)
48 | .copy(y = sliderArea.y + deltaY)
49 | rectangle(area, colorScheme.secondary)
50 | itemStatus match
51 | case UiContext.ItemStatus(false, false, _, _) =>
52 | rectangle(sliderRect, colorScheme.primaryShadow)
53 | case UiContext.ItemStatus(true, false, _, _) =>
54 | rectangle(sliderRect, colorScheme.primary)
55 | case _ =>
56 | rectangle(sliderRect, colorScheme.primaryHighlight)
57 |
58 | val lightDefault: Default = Default(
59 | padding = 1,
60 | minSliderSize = 8,
61 | ColorScheme.lightScheme
62 | )
63 |
64 | val darkDefault: Default = Default(
65 | padding = 1,
66 | minSliderSize = 8,
67 | ColorScheme.darkScheme
68 | )
69 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/TextInputSkin.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | import eu.joaocosta.interim._
4 | import eu.joaocosta.interim.api.Primitives._
5 |
6 | trait TextInputSkin:
7 | def allocateArea(allocator: LayoutAllocator.AreaAllocator): Rect
8 | def textInputArea(area: Rect): Rect
9 | def renderTextInput(area: Rect, value: String, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit
10 |
11 | object TextInputSkin extends DefaultSkin:
12 |
13 | final case class Default(
14 | border: Int,
15 | activeBorder: Int,
16 | font: Font,
17 | colorScheme: ColorScheme
18 | ) extends TextInputSkin:
19 |
20 | def allocateArea(allocator: LayoutAllocator.AreaAllocator): Rect =
21 | val maxBorder = math.max(border, activeBorder) + 2
22 | allocator.allocate(2 * maxBorder + 8 * font.fontSize, 2 * maxBorder + font.fontSize)
23 |
24 | def textInputArea(area: Rect): Rect = area
25 |
26 | def renderTextInput(area: Rect, value: String, itemStatus: UiContext.ItemStatus)(using uiContext: UiContext): Unit =
27 | val textInputArea = this.textInputArea(area)
28 | itemStatus match
29 | case UiContext.ItemStatus(_, _, true, _) | UiContext.ItemStatus(_, true, _, _) =>
30 | rectangle(textInputArea, colorScheme.background)
31 | rectangleOutline(area, colorScheme.primaryHighlight, activeBorder)
32 | case UiContext.ItemStatus(true, _, _, _) =>
33 | rectangle(textInputArea, colorScheme.secondary)
34 | rectangleOutline(area, colorScheme.primary, border)
35 | case _ =>
36 | rectangle(textInputArea, colorScheme.secondary)
37 | rectangleOutline(area, colorScheme.borderColor, border)
38 | text(
39 | textInputArea.shrink(activeBorder),
40 | colorScheme.text,
41 | value,
42 | font,
43 | HorizontalAlignment.Left,
44 | VerticalAlignment.Center
45 | )
46 |
47 | val lightDefault: Default = Default(
48 | border = 1,
49 | activeBorder = 2,
50 | font = Font.default,
51 | colorScheme = ColorScheme.lightScheme
52 | )
53 |
54 | val darkDefault: Default = Default(
55 | border = 1,
56 | activeBorder = 2,
57 | font = Font.default,
58 | colorScheme = ColorScheme.darkScheme
59 | )
60 |
--------------------------------------------------------------------------------
/core/shared/src/main/scala/eu/joaocosta/interim/skins/WindowSkin.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.skins
2 |
3 | import eu.joaocosta.interim.TextLayout._
4 | import eu.joaocosta.interim._
5 | import eu.joaocosta.interim.api.Components._
6 | import eu.joaocosta.interim.api.Primitives._
7 |
8 | trait WindowSkin:
9 | def titleArea(area: Rect): Rect
10 | def titleTextArea(area: Rect): Rect
11 | def panelArea(area: Rect): Rect
12 | def resizeArea(area: Rect): Rect
13 | def ensureMinimumArea(area: Rect): Rect
14 | def renderWindow(area: Rect, title: String)(using uiContext: UiContext): Unit
15 |
16 | object WindowSkin extends DefaultSkin:
17 |
18 | final case class Default(
19 | font: Font,
20 | border: Int,
21 | colorScheme: ColorScheme
22 | ) extends WindowSkin:
23 |
24 | def titleArea(area: Rect): Rect =
25 | area.copy(h = font.fontSize * 2)
26 |
27 | def titleTextArea(area: Rect): Rect =
28 | area.copy(h = font.fontSize * 2).shrink(font.fontSize / 2)
29 |
30 | def panelArea(area: Rect): Rect =
31 | area.copy(y = area.y + font.fontSize * 2, h = area.h - font.fontSize * 2)
32 |
33 | def resizeArea(area: Rect): Rect =
34 | area.copy(
35 | x = area.x2 - font.fontSize,
36 | y = area.y2 - font.fontSize,
37 | w = font.fontSize,
38 | h = font.fontSize
39 | )
40 |
41 | def ensureMinimumArea(area: Rect): Rect =
42 | area.copy(w = math.max(font.fontSize * 8, area.w), h = math.max(font.fontSize * 8, area.h))
43 |
44 | def renderWindow(area: Rect, title: String)(using uiContext: UiContext): Unit =
45 | val titleArea = this.titleArea(area)
46 | val panelArea = this.panelArea(area)
47 | rectangle(titleArea, colorScheme.secondary)
48 | text(titleArea, colorScheme.text, title, font, HorizontalAlignment.Center, VerticalAlignment.Center)
49 | rectangle(panelArea, colorScheme.background)
50 | rectangleOutline(area, colorScheme.borderColor, border)
51 |
52 | val lightDefault: Default = Default(
53 | font = Font.default,
54 | border = 1,
55 | colorScheme = ColorScheme.lightScheme
56 | )
57 |
58 | val darkDefault: Default = Default(
59 | font = Font.default,
60 | border = 1,
61 | colorScheme = ColorScheme.darkScheme
62 | )
63 |
--------------------------------------------------------------------------------
/core/shared/src/test/scala/eu/joaocosta/interim/InputStateSpec.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | import scala.annotation.tailrec
4 |
5 | class InputStateSpec extends munit.FunSuite:
6 |
7 | test("appendKeyboardInput should preserve the original string"):
8 | val result = InputState(0, 0, false, "").appendKeyboardInput("test")
9 | assertEquals(result, "test")
10 |
11 | test("appendKeyboardInput should append both strings"):
12 | val result = InputState(0, 0, false, " foo").appendKeyboardInput("test")
13 | assertEquals(result, "test foo")
14 |
15 | test("appendKeyboardInput should delete characters from the keyboard string"):
16 | val result = InputState(0, 0, false, " f\u0008oo").appendKeyboardInput("test")
17 | assertEquals(result, "test oo")
18 |
19 | test("appendKeyboardInput should delete characters from the original string"):
20 | val result = InputState(0, 0, false, "\u0008oo").appendKeyboardInput("test")
21 | assertEquals(result, "tesoo")
22 |
23 | test("appendKeyboardInput should handle overdeletion"):
24 | val result = InputState(0, 0, false, "\u0008oo").appendKeyboardInput("test")
25 | assertEquals(result, "tesoo")
26 |
--------------------------------------------------------------------------------
/core/shared/src/test/scala/eu/joaocosta/interim/RectSpec.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | class RectSpec extends munit.FunSuite:
4 |
5 | test("compute helper positions"):
6 | val rect = Rect(10, 15, 10, 20)
7 | assertEquals(rect.x1, 10)
8 | assertEquals(rect.x2, 20)
9 | assertEquals(rect.y1, 15)
10 | assertEquals(rect.y2, 35)
11 | assertEquals(rect.centerX, 15)
12 | assertEquals(rect.centerY, 25)
13 |
14 | test("isMouseOver detects collisions with the mouse"):
15 | val rect = Rect(10, 10, 10, 10)
16 | assertEquals(rect.isMouseOver(using InputState(0, 0, false, "")), false)
17 | assertEquals(rect.isMouseOver(using InputState(15, 15, false, "")), true)
18 | assertEquals(rect.isMouseOver(using InputState(30, 30, false, "")), false)
19 |
20 | test("shrink and grow the rectangle"):
21 | val rect = Rect(10, 10, 10, 10)
22 | assertEquals(rect.shrink(2), Rect(12, 12, 6, 6))
23 | assertEquals(rect.grow(2), Rect(8, 8, 14, 14))
24 |
25 | test("merge expands two rects when there's a gap"):
26 | val rect1 = Rect(10, 10, 10, 10)
27 | val rect2 = Rect(30, 10, 10, 10)
28 | assertEquals(rect1 ++ rect2, Rect(10, 10, 30, 10))
29 | assertEquals(rect2 ++ rect1, Rect(10, 10, 30, 10))
30 |
31 | test("merge expands two rects when they intersect"):
32 | val rect1 = Rect(10, 10, 10, 10)
33 | val rect2 = Rect(15, 10, 10, 10)
34 | assertEquals(rect1 ++ rect2, Rect(10, 10, 15, 10))
35 | assertEquals(rect2 ++ rect1, Rect(10, 10, 15, 10))
36 |
37 | test("intersect returns an empty rect when there's a gap"):
38 | val rect1 = Rect(10, 10, 10, 10)
39 | val rect2 = Rect(30, 10, 10, 10)
40 | assertEquals((rect1 & rect2).isEmpty, true)
41 | assertEquals((rect2 & rect1).isEmpty, true)
42 |
43 | test("intersect shrinks two rects when they intersect"):
44 | val rect1 = Rect(10, 10, 10, 10)
45 | val rect2 = Rect(15, 10, 10, 10)
46 | assertEquals(rect1 & rect2, Rect(15, 10, 5, 10))
47 | assertEquals(rect2 & rect1, Rect(15, 10, 5, 10))
48 |
--------------------------------------------------------------------------------
/core/shared/src/test/scala/eu/joaocosta/interim/RefSpec.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | class RefSpec extends munit.FunSuite:
4 |
5 | test("A Ref value can be correctly set and retrieved with := and get"):
6 | val x = Ref(1)
7 | assertEquals(x.get, 1)
8 | x := 2
9 | assertEquals(x.get, 2)
10 |
11 | test("Ref values can be modified with modify"):
12 | val x = Ref(1)
13 |
14 | assertEquals(x.modify(_ + 1).get, 2)
15 | assertEquals(x.get, 2)
16 |
17 | test("Ref values can be modified with modifyIf"):
18 | val x = Ref(1)
19 |
20 | assertEquals(x.modifyIf(false)(_ + 1).get, 1)
21 | assertEquals(x.get, 1)
22 | assertEquals(x.modifyIf(true)(_ + 1).get, 2)
23 | assertEquals(x.get, 2)
24 |
25 | test("withRef allows to use a temporary Ref value"):
26 | val result = Ref.withRef(0): ref =>
27 | ref.modify(_ + 2)
28 | assertEquals(result, 2)
29 |
30 | test("withRefs allows to build a case class from temporary Ref values"):
31 | case class Foo(x: Int, y: String)
32 | val result = Ref.withRefs(Foo(1, "asd")): (x, y) =>
33 | x := 2
34 | y := "dsa"
35 | assertEquals(result, Foo(2, "dsa"))
36 |
37 | test("asRef allows to use a temporary Ref value"):
38 | val result = 0.asRef: ref =>
39 | ref.modify(_ + 2)
40 | assertEquals(result, 2)
41 |
42 | test("asRefs allows to build a case class from temporary Ref values"):
43 | case class Foo(x: Int, y: String)
44 | val result = Foo(1, "asd").asRefs: (x, y) =>
45 | x := 2
46 | y := "dsa"
47 | assertEquals(result, Foo(2, "dsa"))
48 |
49 | test("modifyRefs allows to modify a case class Ref from temporary Ref values"):
50 | case class Foo(x: Int, y: String)
51 | val ref = Ref(Foo(1, "asd"))
52 | ref.modifyRefs: (x, y) =>
53 | x := 2
54 | y := "dsa"
55 | assertEquals(ref.get, Foo(2, "dsa"))
56 |
--------------------------------------------------------------------------------
/core/shared/src/test/scala/eu/joaocosta/interim/UiContextSpec.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim
2 |
3 | class UiContextSpec extends munit.FunSuite:
4 |
5 | test("registerItem should not mark an item not under the cursor"):
6 | given uiContext: UiContext = new UiContext()
7 | given inputState: InputState = InputState(0, 0, false, "")
8 |
9 | UiContext.registerItem(1, Rect(1, 1, 10, 10))
10 | assertEquals(uiContext.scratchItemState.hotItem, None)
11 | assertEquals(uiContext.scratchItemState.activeItem, None)
12 | assertEquals(uiContext.scratchItemState.selectedItem, None)
13 |
14 | val itemStatus = UiContext.getScratchItemStatus(1)
15 | assertEquals(itemStatus.hot, false)
16 | assertEquals(itemStatus.active, false)
17 | assertEquals(itemStatus.selected, false)
18 | assertEquals(itemStatus.clicked, false)
19 |
20 | test("registerItem should mark an item under the cursor as hot"):
21 | given uiContext: UiContext = new UiContext()
22 | given inputState: InputState = InputState(5, 5, false, "")
23 |
24 | UiContext.registerItem(1, Rect(1, 1, 10, 10))
25 | assertEquals(uiContext.scratchItemState.hotItem, Some(0 -> 1))
26 | assertEquals(uiContext.scratchItemState.activeItem, None)
27 | assertEquals(uiContext.scratchItemState.selectedItem, None)
28 |
29 | val itemStatus = UiContext.getScratchItemStatus(1)
30 | assertEquals(itemStatus.hot, true)
31 | assertEquals(itemStatus.active, false)
32 | assertEquals(itemStatus.selected, false)
33 | assertEquals(itemStatus.clicked, false)
34 |
35 | test("registerItem should mark a clicked item as active and focused"):
36 | given uiContext: UiContext = new UiContext()
37 | given inputState: InputState = InputState(5, 5, true, "")
38 |
39 | UiContext.registerItem(1, Rect(1, 1, 10, 10))
40 | assertEquals(uiContext.scratchItemState.hotItem, Some(0 -> 1))
41 | assertEquals(uiContext.scratchItemState.activeItem, Some(1))
42 | assertEquals(uiContext.scratchItemState.selectedItem, Some(1))
43 |
44 | val itemStatus = UiContext.getScratchItemStatus(1)
45 | assertEquals(itemStatus.hot, true)
46 | assertEquals(itemStatus.active, true)
47 | assertEquals(itemStatus.selected, true)
48 | assertEquals(itemStatus.clicked, false)
49 |
50 | test("registerItem should mark a clicked item as clicked once the mouse is released"):
51 | val uiContext: UiContext = new UiContext()
52 | val inputState1: InputState = InputState(5, 5, true, "")
53 | UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState1)
54 | uiContext.commit()
55 |
56 | val inputState2: InputState = InputState(5, 5, false, "")
57 | UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState2)
58 | uiContext.commit()
59 |
60 | val itemStatus = UiContext.getItemStatus(1)(using uiContext, inputState2)
61 | assertEquals(itemStatus.hot, true)
62 | assertEquals(itemStatus.active, true)
63 | assertEquals(itemStatus.selected, true)
64 | assertEquals(itemStatus.clicked, true)
65 |
66 | test("registerItem should not override an active item with another one"):
67 | val uiContext = new UiContext()
68 | val inputState1 = InputState(5, 5, true, "")
69 | UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState1)
70 | uiContext.commit()
71 |
72 | val inputState2 = InputState(20, 20, true, "")
73 | UiContext.registerItem(1, Rect(1, 1, 10, 10))(using uiContext, inputState2)
74 | UiContext.registerItem(2, Rect(15, 15, 10, 10))(using uiContext, inputState2)
75 | assertEquals(uiContext.scratchItemState.hotItem, Some(0 -> 2))
76 | assertEquals(uiContext.scratchItemState.activeItem, Some(1))
77 | assertEquals(uiContext.scratchItemState.selectedItem, Some(1))
78 | uiContext.commit()
79 |
80 | val itemStatus = UiContext.getItemStatus(2)(using uiContext, inputState2)
81 | assertEquals(itemStatus.hot, true)
82 | assertEquals(itemStatus.active, false)
83 | assertEquals(itemStatus.selected, false)
84 | assertEquals(itemStatus.clicked, false)
85 |
86 | test("fork should create a new UiContext with no ops, and merge them back with ++="):
87 | val uiContext: UiContext = new UiContext()
88 | api.Primitives.rectangle(Rect(0, 0, 1, 1), Color(0, 0, 0))(using uiContext)
89 | assertEquals(uiContext.getOrderedOps(), List(RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(0, 0, 0))))
90 | val forked = uiContext.fork()
91 | assertEquals(forked.getOrderedOps(), Nil)
92 | api.Primitives.rectangle(Rect(0, 0, 1, 1), Color(1, 2, 3))(using forked)
93 | assertEquals(uiContext.getOrderedOps(), List(RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(0, 0, 0))))
94 | assertEquals(forked.getOrderedOps(), List(RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(1, 2, 3))))
95 | uiContext ++= forked
96 | assertEquals(
97 | uiContext.getOrderedOps(),
98 | List(
99 | RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(0, 0, 0)),
100 | RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(1, 2, 3))
101 | )
102 | )
103 |
104 | test("operations with a higher z-index should be returned last"):
105 | given uiContext: UiContext = new UiContext()
106 | UiContext.withZIndex(1):
107 | api.Primitives.rectangle(Rect(0, 0, 1, 1), Color(3, 3, 3))
108 | api.Primitives.rectangle(Rect(0, 0, 1, 1), Color(4, 4, 4))
109 | UiContext.withZIndex(-1):
110 | api.Primitives.rectangle(Rect(0, 0, 1, 1), Color(0, 0, 0))
111 | api.Primitives.rectangle(Rect(0, 0, 1, 1), Color(1, 1, 1))
112 | api.Primitives.rectangle(Rect(0, 0, 1, 1), Color(2, 2, 2))
113 |
114 | assertEquals(
115 | uiContext.getOrderedOps(),
116 | List(
117 | RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(0, 0, 0)),
118 | RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(1, 1, 1)),
119 | RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(2, 2, 2)),
120 | RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(3, 3, 3)),
121 | RenderOp.DrawRect(Rect(0, 0, 1, 1), Color(4, 4, 4))
122 | )
123 | )
124 |
125 | test("pushInputState should update the historical state"):
126 | val uiContext: UiContext = new UiContext()
127 | val inputState1 = uiContext.pushInputState(InputState(5, 5, false, ""))
128 | assertEquals(inputState1.deltaX, 0)
129 | assertEquals(inputState1.deltaY, 0)
130 | val inputState2 = uiContext.pushInputState(InputState(6, 7, false, ""))
131 | assertEquals(inputState2.deltaX, 1)
132 | assertEquals(inputState2.deltaY, 2)
133 | val inputState3 = uiContext.pushInputState(InputState(false, ""))
134 | assertEquals(inputState3.deltaX, 0)
135 | assertEquals(inputState3.deltaY, 0)
136 |
--------------------------------------------------------------------------------
/core/shared/src/test/scala/eu/joaocosta/interim/api/LayoutsSpec.scala:
--------------------------------------------------------------------------------
1 | package eu.joaocosta.interim.api
2 |
3 | import eu.joaocosta.interim.{Color, InputState, Rect, RenderOp, UiContext}
4 |
5 | class LayoutsSpec extends munit.FunSuite:
6 |
7 | test("clip correctly clips render ops"):
8 | given uiContext: UiContext = new UiContext()
9 | given inputState: InputState = InputState(0, 0, false, "")
10 | Layouts.clip(Rect(10, 10, 10, 10)):
11 | Primitives.rectangle(Rect(0, 0, 15, 15), Color(0, 0, 0))
12 | assertEquals(uiContext.getOrderedOps(), List(RenderOp.DrawRect(Rect(10, 10, 5, 5), Color(0, 0, 0))))
13 |
14 | test("clip ignores input outside the clip area"):
15 | given uiContext: UiContext = new UiContext()
16 | given inputState: InputState = InputState(5, 5, false, "")
17 | Layouts.clip(Rect(10, 10, 10, 10)):
18 | UiContext.registerItem(1, Rect(0, 0, 15, 15))
19 | val itemStatus = UiContext.getScratchItemStatus(1)
20 | assertEquals(itemStatus.hot, false)
21 |
22 | test("clip considers input inside the clip area"):
23 | given uiContext: UiContext = new UiContext()
24 | given inputState: InputState = InputState(12, 12, false, "")
25 | Layouts.clip(Rect(10, 10, 10, 10)):
26 | UiContext.registerItem(1, Rect(0, 0, 15, 15))
27 | val itemStatus = UiContext.getScratchItemStatus(1)
28 | assertEquals(itemStatus.hot, true)
29 |
30 | test("grid correctly lays out elements in a grid"):
31 | val areas = Layouts.grid(Rect(10, 10, 100, 100), numRows = 3, numColumns = 2, padding = 8)(identity)
32 | val expected =
33 | Vector(
34 | Vector(Rect(10, 10, 46, 28), Rect(64, 10, 46, 28)),
35 | Vector(Rect(10, 46, 46, 28), Rect(64, 46, 46, 28)),
36 | Vector(Rect(10, 82, 46, 28), Rect(64, 82, 46, 28))
37 | )
38 | assertEquals(areas, expected)
39 |
40 | test("clip correctly does not clip elements with a different z-index"):
41 | given uiContext: UiContext = new UiContext()
42 | given inputState: InputState = InputState(0, 0, false, "")
43 | Layouts.clip(Rect(10, 10, 10, 10)):
44 | Primitives.onTop:
45 | Primitives.rectangle(Rect(0, 0, 15, 15), Color(0, 0, 0))
46 | assertEquals(uiContext.getOrderedOps(), List(RenderOp.DrawRect(Rect(0, 0, 15, 15), Color(0, 0, 0))))
47 |
48 | test("grid returns nothing for an empty grid"):
49 | val areas = Layouts.grid(Rect(10, 10, 100, 100), numRows = 0, numColumns = 0, padding = 8)(identity)
50 | val expected = Vector.empty
51 | assertEquals(areas, expected)
52 |
53 | test("rows correctly lays out elements in rows"):
54 | val areas = Layouts.rows(Rect(10, 10, 100, 100), numRows = 3, padding = 8)(alloc ?=> alloc.toVector)
55 | val expected =
56 | Vector(Rect(10, 10, 100, 28), Rect(10, 46, 100, 28), Rect(10, 82, 100, 28))
57 | assertEquals(areas, expected)
58 |
59 | test("rows returns nothing for 0 rows"):
60 | val areas = Layouts.rows(Rect(10, 10, 100, 100), numRows = 0, padding = 8)(alloc ?=> alloc.toVector)
61 | val expected = Vector.empty
62 | assertEquals(areas, expected)
63 |
64 | test("columns correctly lays out elements in columns"):
65 | val areas = Layouts.columns(Rect(10, 10, 100, 100), numColumns = 3, padding = 8)(alloc ?=> alloc.toVector)
66 | val expected =
67 | Vector(Rect(10, 10, 28, 100), Rect(46, 10, 28, 100), Rect(82, 10, 28, 100))
68 | assertEquals(areas, expected)
69 |
70 | test("columns returns nothing for 0 columns"):
71 | val areas = Layouts.columns(Rect(10, 10, 100, 100), numColumns = 0, padding = 8)(alloc ?=> alloc.toVector)
72 | val expected = Vector.empty
73 | assertEquals(areas, expected)
74 |
75 | test("dynamicRows correctly lays out elements in rows"):
76 | val areas = Layouts.dynamicRows(Rect(10, 10, 100, 100), padding = 8) { nextRow ?=>
77 | Vector(nextRow(16), nextRow(-32), nextRow(Int.MaxValue))
78 | }
79 | val expected =
80 | Vector(Rect(10, 10, 100, 16), Rect(10, 78, 100, 32), Rect(10, 34, 100, 36))
81 | assertEquals(areas, expected)
82 |
83 | test("dynamicColumns correctly lays out elements in columns"):
84 | val areas = Layouts.dynamicColumns(Rect(10, 10, 100, 100), padding = 8) { nextColumn ?=>
85 | Vector(nextColumn(16), nextColumn(-32), nextColumn(Int.MaxValue))
86 | }
87 | val expected =
88 | Vector(Rect(10, 10, 16, 100), Rect(78, 10, 32, 100), Rect(34, 10, 36, 100))
89 | assertEquals(areas, expected)
90 |
91 | test("mouseArea passes an updated mouse input"):
92 | val inputState = InputState(10, 10, false, "")
93 | val inArea = Layouts.mouseArea(Rect(5, 5, 10, 10))(identity)(using inputState)
94 | assertEquals(inArea, Some(InputState.MouseInput(5, 5, false)))
95 | val outsideArea = Layouts.mouseArea(Rect(5, 5, 1, 1))(identity)(using inputState)
96 | assertEquals(outsideArea, None)
97 |
--------------------------------------------------------------------------------
/docs/_docs/advanced-usage.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Advanced Usage
3 | ---
4 |
5 | # Advanced Usage
6 |
7 | ## Custom Backends
8 |
9 | InterIm applications will usually be function of the type
10 | `(InputState, UiContext) => (List[RenderOp], Unit)` or `(InputState, UiContext, ApplicationState) => (List[RenderOp], ApplicationState)` (depending on how pure the application is).
11 |
12 | For simplicity, let's assume a mutable app in this example.
13 |
14 | ```scala
15 | def application(inputState: InputState, uiState: UiState) = ??? // Our application code
16 | ```
17 |
18 | The first thing that the backend needs to do is to create an `UiContext`. This is a class that will keep the internal mutable state of the UI.
19 |
20 | ```scala
21 | val uiContext = new UiContext()
22 | ```
23 |
24 | Then, in the render loop, the backend needs to:
25 | - Build the input state
26 | - Call the application code
27 | - Render the operations
28 |
29 | ```scala
30 | while (true) // Hypothetical render loop
31 | val inputState: InputState =
32 | InputState(
33 | mouseX = ???,
34 | mouseY = ???,
35 | mouseDown = ???,
36 | keyboardInput = ???
37 | )
38 | val (renderOps, _) = application(inputState, uiContext)
39 | renderOps.foreach {
40 | case op: RenderOp.DrawRect => ??? // Draw Rectangle
41 | case op: RenderOp.DrawText => ??? // Draw Text
42 | case op: RenderOp.Custom => ??? // Custom logic
43 | }
44 | ```
45 |
46 | And that's it!
47 |
48 | ## Custom operations
49 |
50 | You might have noticed the `RenderOp.Custom` in the example above.
51 |
52 | This operation is designed so that you can extend InterIm with your own operations (e.g. draw a circle). The default InterIm components will never use this operation.
53 |
54 | Custom operations are defined as:
55 | ```scala
56 | final case class Custom[T](area: Rect, color: Color, data: T) extends RenderOp
57 | ```
58 |
59 | Notice the `area` and the `color`. This allows you to simply draw a colored rectangle when you receive a custom operation that your backend is not able to interpret.
60 |
--------------------------------------------------------------------------------
/docs/_docs/getting-started.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Getting Started
3 | ---
4 |
5 | # Getting Started
6 |
7 | To include InterIm, simply add the `interim` library to your project:
8 |
9 | ```scala
10 | // JVM Only
11 | libraryDependencies += "eu.joaocosta" %% "interim" % "{{ projectVersion }}"
12 | // For JVM/JS/Native cross-compilation
13 | libraryDependencies += "eu.joaocosta" %%% "interim" % "{{ projectVersion }}"
14 | ```
15 |
16 | ## Tutorial
17 |
18 | The easiest way to start using the library is to follow the tutorials in the [`examples`](https://github.com/JD557/minart/tree/master/examples) directory.
19 |
20 | The examples in [`examples/release`](https://github.com/JD557/minart/tree/master/examples/release) target the latest released version,
21 | while the examples in [`examples/snapshot`](https://github.com/JD557/minart/tree/master/examples/snapshot) target the code in the repository.
22 |
23 | All the examples are `.md` files that can be executed via [scala-cli](https://scala-cli.virtuslab.org/).
24 |
25 | ## Example backend
26 |
27 | Since InterIm doesn't come with any graphical backend, it's quite useless to create apps with just
28 | InterIm.
29 |
30 | However, on the examples folder you will find a simple backend powered by [Minart](https://github.com/jd557/minart)
31 | in `example-minart-backend.scala`.
32 | While this backend is quite limited, it is powerful enough for most small projects.
33 |
--------------------------------------------------------------------------------
/docs/_docs/index.html:
--------------------------------------------------------------------------------
1 | ---
2 | title: InterIm
3 | ---
4 |
5 |
InterIm
6 |
7 | What is InterIm?
8 |
9 | IterIm is a minimal Scala library for immediate mode GUIs.
10 |
11 |
12 |
13 | The library itself doesn't perform any rendering or IO by itself, but it provides a simple interface so that it can be
14 | easilly integrated on custom backends: The backend sends InterIm the current input and gets back a list of colored
15 | rectangles and text regions to render.
16 |
17 |
18 |
19 | The lack of a backend also ensures that the library can be used in JVM, Scala.js and Scala Native applications.
20 |
21 |
22 | Development status
23 |
24 | InterIm is still in a 0.x version. Quoting the semver specification:
25 |
26 | Major version zero (0.y.z) is for initial development. Anything MAY change at any time. The public API SHOULD NOT be considered stable.
27 |
28 |
29 | As such, while it's OK to use InterIm for small demos, it's not recommended to use it for commercial projects.
30 |
31 |
--------------------------------------------------------------------------------
/docs/_docs/overview.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Feature Overview
3 | ---
4 |
5 | # Feature Overview
6 |
7 | ## Cross-compilation
8 |
9 | InterIm can target **JVM**, **JS** and **Native**.
10 |
11 | ## Simple integration with custom backends
12 |
13 | InterIm is designed to be simple to integrate with other graphical applications, such as
14 | Minart demos or Indigo games.
15 |
16 | ## Supported features
17 |
18 | ### Primitives and Components
19 |
20 | - Rectangles
21 | - Text
22 | - Buttons
23 | - Checkboxes
24 | - Radio buttons
25 | - Select boxes
26 | - Sliders
27 | - Text input
28 | - Movable/Closable windows
29 |
30 | ### Layouts
31 |
32 | - Grid based
33 | - Row based (equally sized or dynamically sized)
34 | - Column based (equally sized or dynamically sized)
35 |
36 | ### Skins
37 |
38 | - Configurable skins for all components
39 | - Light and dark mode
40 |
--------------------------------------------------------------------------------
/docs/_docs/tutorial.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Tutorial
3 | ---
4 |
5 | # Tutorial
6 |
7 | Here you'll find multiple examples that also work as a tutorial to get started with InterIm. It is recommended to follow them in order.
8 |
9 | If you would like to run some of them, all the examples are available in [the examples directory](https://github.com/JD557/interim/tree/master/examples).
10 | Just follow the instructions to run them with [Scala CLI](https://scala-cli.virtuslab.org/).
11 |
--------------------------------------------------------------------------------
/docs/sidebar.yml:
--------------------------------------------------------------------------------
1 | index: index.html
2 | subsection:
3 | - title: Feature Overview
4 | page: overview.md
5 | - title: Getting Started
6 | page: getting-started.md
7 | - title: Advanced Usage
8 | page: advanced-usage.md
9 | - title: Tutorial
10 | directory: tutorial
11 | index: tutorial.md
12 | subsection:
13 | - title: "1. Introduction"
14 | page: ../../examples/release/1-introduction.md
15 | - title: "2. Explicit Layout"
16 | page: ../../examples/release/2-explicit-layout.md
17 | - title: "3. Implicit Layout"
18 | page: ../../examples/release/3-implicit-layout.md
19 | - title: "4. Windows"
20 | page: ../../examples/release/4-windows.md
21 | - title: "5. Refs"
22 | page: ../../examples/release/5-refs.md
23 | - title: "6. Color Picker"
24 | page: ../../examples/release/6-colorpicker.md
25 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # InterIm Examples
2 |
3 | Here you'll find multiple examples that also work as a tutorial to get started with InterIm.
4 |
5 | Each example is prefixed by a number, which indicate the recommended order for each lesson.
6 |
7 | For instance, to run the first example, just run `scala-cli 01-intro.md example-minart-backend.scala`.
8 |
9 | The examples in `examples/release` target the latest released version, while the examples in `examples/snapshot` target
10 | the code in the repository. If you are starting out, it is recommended that you look at the examples in the `release`
11 | directory.
12 |
--------------------------------------------------------------------------------
/examples/release/1-introduction.md:
--------------------------------------------------------------------------------
1 | # 1. Introduction
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 1-intro.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Introductory notes
16 |
17 | InterIm doesn't come with a graphics backend, so all examples will use an example backend powered by
18 | [Minart](https://github.com/jd557/minart). However, you can easily use your own backend.
19 |
20 | Also, all examples will use the ["8x8 Gloop"](https://www.gridsagegames.com/rexpaint/resources.html#Fonts) font
21 | by [Polyducks](https://twitter.com/PolyDucks).
22 |
23 | Finally, due to the imperative nature of Immediate Mode UIs, the examples will be extremely imperative,
24 | with lots of mutation, as that's the idiomatic way to write such UIs.
25 | However, InterIm also allows you to write code with almost no mutability (at the expense of verbosity).
26 |
27 | ## A simple counter application
28 |
29 | Let's start with a simple counter application with:
30 | - A counter showing a number
31 | - A button that increments the counter
32 | - A button that decrements the counter
33 |
34 | First, let's start by setting our state:
35 |
36 | ```scala
37 | var counter = 0
38 | ```
39 |
40 | We also need to create a `UiContext`. This is the object InterIm uses to keep it's mutable internal state
41 | between each call.
42 |
43 | ```scala
44 | import eu.joaocosta.interim.*
45 |
46 | val uiContext = new UiContext()
47 | ```
48 |
49 | Now, let's write our interface. We are going to need the following components:
50 | - `text`, to draw the counter value
51 | - `button`, to increase and decrease the value
52 |
53 | ```scala
54 | def application(inputState: InputState) =
55 | import eu.joaocosta.interim.InterIm.*
56 | ui(inputState, uiContext):
57 | button(id = "minus", label = "-")(area = Rect(x = 10, y = 10, w = 30, h = 30)):
58 | counter = counter - 1
59 | text(
60 | area = Rect(x = 40, y = 10, w = 30, h = 30),
61 | color = Color(0, 0, 0),
62 | message = counter.toString,
63 | font = Font.default,
64 | horizontalAlignment = centerHorizontally,
65 | verticalAlignment = centerVertically
66 | )
67 | button(id = "plus", label = "+")(area = Rect(x = 70, y = 10, w = 30, h = 30)):
68 | counter = counter + 1
69 | ```
70 |
71 | Let's go line by line:
72 |
73 | First, our application will need to somehow receive input (e.g. mouse clicks), so we need to provide the `InputState`
74 | from our backend.
75 |
76 | Then, we import `import eu.joaocosta.interim.InterIm.*`. This enables the InterIm DSL, which give us access to our
77 | component functions.
78 |
79 | Next, we start our UI with `ui(inputState, uiContext)`. All DSL operation must happen inside this block which,
80 | in the end, returns the sequence of render operations that must be executed by the backend.
81 |
82 | Now, to the button logic:
83 | 1. Interactive components like buttons require a unique ID, which is the first parameter;
84 | 2. We also add a label, which is the text that will be shown on the button;
85 | 3. Then we need to specify an area where the button will be drawn;
86 | 4. Finally, `button` returns `true` when the button is pressed, so we use that to decrement our counter.
87 |
88 | For the text block we don't need an id, as it's just a rendering primitive with no interaction, so we just need to
89 | give it the string we want to show and the style details.
90 |
91 | Finally, we add another button that increments the counter. Note that this one uses a different id!
92 |
93 | ## Integrating with the backend
94 |
95 | Now that our application is defined, we can call it from our backend:
96 |
97 | In pseudo code, this looks like the following:
98 |
99 | ```
100 | val uiContext = new UiContext()
101 |
102 | def application(inputState: InputState) = ??? // Our application code
103 |
104 | while(true) {
105 | val input: InputState = backend.grabInput() // Grab input from the backend
106 | val (renderOps, _) = application(input) // Generate render ops
107 | backend.render(renderOps) // Send render operations to the backend
108 | }
109 |
110 | ```
111 |
112 | In this examples, we'll simply call this basic `MinartBackend` with:
113 |
114 | ```scala
115 | MinartBackend.run(application)
116 | ```
117 |
118 | ## A note on state and mutability
119 |
120 | You might have noticed that our application returns two parameters, and we are ignoring the second one.
121 | Indeed, the `ui` function (and other InterIm operations) return the last value of the body.
122 | This makes it possible to write applications without using mutable variables.
123 |
124 | For example we could rewrite our application as:
125 |
126 | ```scala
127 | def immutableApp(inputState: InputState, counter: Int): (List[RenderOp], Int) =
128 | import eu.joaocosta.interim.InterIm.*
129 | ui(inputState, uiContext):
130 | val (decrementCounter, _, incrementCounter) = (
131 | button(id = "minus", label = "-")(area = Rect(x = 10, y = 10, w = 30, h = 30))(true).getOrElse(false),
132 | text(
133 | area = Rect(x = 40, y = 10, w = 30, h = 30),
134 | color = Color(0, 0, 0),
135 | message = counter.toString,
136 | font = Font.default,
137 | horizontalAlignment = centerHorizontally,
138 | verticalAlignment = centerVertically
139 | ),
140 | button(id = "plus", label = "+")(area = Rect(x = 70, y = 10, w = 30, h = 30))(true).getOrElse(false)
141 | )
142 | if (decrementCounter && !incrementCounter) counter - 1
143 | else if (!decrementCounter && incrementCounter) counter + 1
144 | else counter
145 | ```
146 |
147 | Unfortunately, as it might be visible from the example, when multiple components update the same state, some
148 | boilerplate is required to unify the state changes.
149 |
150 | One possible solution to this is to use local mutability:
151 |
152 | ```scala
153 | def localMutableApp(inputState: InputState, counter: Int): (List[RenderOp], Int) =
154 | import eu.joaocosta.interim.InterIm.*
155 | var _counter = counter
156 | ui(inputState, uiContext):
157 | button(id = "minus", label = "-")(area = Rect(x = 10, y = 10, w = 30, h = 30)):
158 | _counter = counter - 1
159 | text(
160 | area = Rect(x = 40, y = 10, w = 30, h = 30),
161 | color = Color(0, 0, 0),
162 | message = counter.toString,
163 | font = Font.default,
164 | horizontalAlignment = centerHorizontally,
165 | verticalAlignment = centerVertically
166 | )
167 | button(id = "plus", label = "+")(area = Rect(x = 70, y = 10, w = 30, h = 30)):
168 | _counter = counter + 1
169 | _counter
170 | ```
171 |
172 | InterIm also provides some tools to make local mutability easier and safer. Those are introduced in later examples.
173 |
--------------------------------------------------------------------------------
/examples/release/2-explicit-layout.md:
--------------------------------------------------------------------------------
1 | # 2. Explicit Layout
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 2-explicit-layout.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Component layout in InterIm applications
16 |
17 | In InterIm, every component receives a `Rect` with the area where it can be rendered, so its size must be known up front.
18 |
19 | This is very different from other systems like HTML, where elements can infer their size from their contents.
20 |
21 | This is a typical problem of immediate mode GUIs. While there are some techniques to address that, InterIm
22 | currently opts for the simpler option.
23 |
24 | However, explicit does not mean manual! InterIm comes with multiple helpers to automatically generate areas according
25 | to a specified layout:
26 | - `grid`: a grid layout with n*m equally sized cells
27 | - `rows`: a row layout with n equally sized rows
28 | - `columns`: a column layout with n equally sized columns
29 | - `dynamicRows`: a row layout with rows of different sizes
30 | - `dynamicColumns`: a column layout with columns of different sizes
31 |
32 | ## Using layouts in the counter application
33 |
34 | Previously, in out counter application, we had to manually set the areas for all components.
35 | This can be quite a chore, especially since changing one area might force us to manually change them all!
36 |
37 | Everything was layed out in 3 equally sized columns, so let's use the `columns` layout.
38 |
39 | This layout returns a `IndexedSeq[Rect]`, with the 3 areas we want to use.
40 |
41 | Our application now looks like:
42 |
43 | ```scala
44 | import eu.joaocosta.interim.*
45 |
46 | val uiContext = new UiContext()
47 | var counter = 0
48 |
49 | def application(inputState: InputState) =
50 | import eu.joaocosta.interim.InterIm.*
51 | ui(inputState, uiContext):
52 | columns(area = Rect(x = 10, y = 10, w = 110, h = 30), numColumns = 3, padding = 10): column ?=>
53 | button(id = "minus", label = "-")(column(0)):
54 | counter = counter - 1
55 | text(
56 | area = column(1),
57 | color = Color(0, 0, 0),
58 | message = counter.toString,
59 | font = Font.default,
60 | horizontalAlignment = centerHorizontally,
61 | verticalAlignment = centerVertically
62 | )
63 | button(id = "plus", label = "+")(column(2)):
64 | counter = counter + 1
65 | ```
66 |
67 | Now let's run it:
68 |
69 | ```scala
70 | MinartBackend.run(application)
71 | ```
72 |
73 | ## Note about dynamic layouts
74 |
75 | While `rows`, `columns` and `cells` provide a `IndexedSeq[Rect]` (or a `IndexedSeq[IndexedSeq[Rect]]`) to our body, the dynamic
76 | versions work a bit differently.
77 |
78 | In those cases, a `Int => Rect` function is provided where, given a desired size, a `Rect` is returned.
79 | If the size is positive, the `Rect` will be allocated from the top/left, while negative sizes will allocate a `Rect`
80 | from the bottom/right.
81 |
82 | For example, this is how our application would look like with a dynamic layout:
83 |
84 | ```scala
85 | def dynamicApp(inputState: InputState) =
86 | import eu.joaocosta.interim.InterIm.*
87 | ui(inputState, uiContext):
88 | dynamicColumns(area = Rect(x = 10, y = 10, w = 110, h = 30), padding = 10): column ?=>
89 | button(id = "minus", label = "-")(area = column(30)): // 30px from the left
90 | counter = counter - 1
91 | button(id = "plus", label = "+")(area = column(-30)): // 30px from the right
92 | counter = counter + 1
93 | text(
94 | area = column(maxSize), // Fill the remaining area
95 | color = Color(0, 0, 0),
96 | message = counter.toString,
97 | font = Font.default,
98 | horizontalAlignment = centerHorizontally,
99 | verticalAlignment = centerVertically
100 | )
101 | ```
102 |
--------------------------------------------------------------------------------
/examples/release/3-implicit-layout.md:
--------------------------------------------------------------------------------
1 | # 3. Explicit Layout
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 3-layout.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Implicit layouts
16 |
17 | In the previous example, you might have noticed something odd: helpers like `columns` use a context function instead of a
18 | regular function.
19 |
20 | Indeed, that's because those functions introduce an implicit `LayoutAllocator`.
21 | When there's no area defined, components will use the allocator from the current context to pick an area implicitly.
22 |
23 | Right now, however, while primitives (such as `rectangle` and `text`) can use an allocator, that must be passed explicitly
24 | (also in the `area` parameter).
25 |
26 | ## Using implicit layouts in the counter application
27 |
28 | Here's the previous example but with implicit layouts:
29 |
30 | ```scala
31 | import eu.joaocosta.interim.*
32 |
33 | val uiContext = new UiContext()
34 | var counter = 0
35 |
36 | def application(inputState: InputState) =
37 | import eu.joaocosta.interim.InterIm.*
38 | ui(inputState, uiContext):
39 | columns(area = Rect(x = 10, y = 10, w = 110, h = 30), numColumns = 3, padding = 10):
40 | button(id = "minus", label = "-"):
41 | counter = counter - 1
42 | text(
43 | area = summon, // we can easily get the allocator with `summon`
44 | color = Color(0, 0, 0),
45 | message = counter.toString,
46 | font = Font.default,
47 | horizontalAlignment = centerHorizontally,
48 | verticalAlignment = centerVertically
49 | )
50 | button(id = "plus", label = "+"):
51 | counter = counter + 1
52 | ```
53 |
54 | Now let's run it:
55 |
56 | ```scala
57 | MinartBackend.run(application)
58 | ```
59 |
--------------------------------------------------------------------------------
/examples/release/4-windows.md:
--------------------------------------------------------------------------------
1 | # 4. Windows
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 4-windows.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Floating Windows in InterIm
16 |
17 | An advanced use case for InterIm is to draw components on top of already existing applications (e.g. to create a debug
18 | menu).
19 |
20 | For this, it's helpful to have a floating window abstraction, and that's exactly what `window` is!
21 |
22 | A window is a special component that:
23 | - Passes an area to a function, which is the window drawable region
24 | - Returns it's value (optional) and a `PanelState[Rect]`. That `PanelState[Rect]` is the one that needs to be passed as the window area.
25 |
26 | This might sound a little convoluted, but this is what allows windows to be dragged and closed.
27 |
28 | ## Using window in the counter application
29 |
30 | Let's go straight to the example, as things are not as confusing as they sound.
31 |
32 | ```scala
33 | import eu.joaocosta.interim.*
34 |
35 | val uiContext = new UiContext()
36 |
37 | var windowArea = PanelState.open(Rect(x = 10, y = 10, w = 110, h = 50))
38 | var counter = 0
39 |
40 | def application(inputState: InputState) =
41 | import eu.joaocosta.interim.InterIm.*
42 | ui(inputState, uiContext):
43 | windowArea = window(id = "window", title = "My Counter", movable = true, closable = false)(area = windowArea) { area =>
44 | columns(area = area.shrink(5), numColumns = 3, padding = 10) {
45 | button(id = "minus", label = "-"):
46 | counter = counter - 1
47 | text(
48 | area = summon,
49 | color = Color(0, 0, 0),
50 | message = counter.toString,
51 | font = Font.default,
52 | horizontalAlignment = centerHorizontally,
53 | verticalAlignment = centerVertically
54 | )
55 | button(id = "plus", label = "+"):
56 | counter = counter + 1
57 | }
58 | }._2 // We don't care about the value, just the rect
59 | ```
60 |
61 | We now have a window that we can drag across the screen! Pick it up with the top left icon and try it.
62 |
63 | Note how we just pass the area to our layout, so everything just moves with our window.
64 | In this example we use `area.shrink(5)` to reduce our area by 5px on all sides, which gives the contents a nice padding.
65 |
66 | Let's run it:
67 |
68 | ```scala
69 | MinartBackend.run(application)
70 | ```
71 |
--------------------------------------------------------------------------------
/examples/release/5-refs.md:
--------------------------------------------------------------------------------
1 | # 5. Mutable References
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 5-refs.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Mutable references
16 |
17 | In some situations, even a mutable style can be quite verbose.
18 |
19 | In the previous window above, we only care about the window area, so we are dropping one of the values with `._2`, but if we
20 | wanted to keep the value we would need to create a temporary variable and then mutate our state with the result.
21 |
22 | Usually, immediate mode UIs solve this by using out parameters, and this is exactly what InterIm provides with the `Ref`
23 | abstraction.
24 |
25 | A `Ref` is simply a wrapper for a mutable value (`final case class Ref[T](var value: T)`). That way, components can
26 | handle the mutation themselves.
27 |
28 | The example above could also be written as:
29 |
30 | ```scala
31 | import eu.joaocosta.interim.*
32 |
33 | val uiContext = new UiContext()
34 |
35 | val windowArea = Ref(PanelState.open(Rect(x = 10, y = 10, w = 110, h = 50))) // Now a val instead of a var
36 | var counter = 0
37 |
38 | def application(inputState: InputState) =
39 | import eu.joaocosta.interim.InterIm.*
40 | ui(inputState, uiContext):
41 | // window takes area as a ref, so will mutate the window area variable
42 | window(id = "window", title = "My Counter", movable = true)(area = windowArea): area =>
43 | columns(area = area.shrink(5), numColumns = 3, padding = 10):
44 | button(id = "minus", label = "-"):
45 | counter = counter - 1
46 | text(
47 | area = summon,
48 | color = Color(0, 0, 0),
49 | message = counter.toString,
50 | font = Font.default,
51 | horizontalAlignment = centerHorizontally,
52 | verticalAlignment = centerVertically
53 | )
54 | button(id = "plus", label = "+"):
55 | counter = counter + 1
56 | ```
57 |
58 | Be aware that, while the code is more concise, coding with out parameters can lead to confusing code where it's hard
59 | to find out where a value is being mutated. It's up to you to decide when to use `Ref`s and when to use plain values.
60 |
61 | ## Scoped mutable references
62 |
63 | Having global mutable variables is usually not a good choice, and they are even worse if functions can change the values
64 | of their arguments at will.
65 |
66 | To avoid this, there are a few helpful methods (`Ref.withRef`/`Ref.withRefs` and the corresponding `asRef`/`asRefs`
67 | extension methods) that let us write applications that can only mutate the state inside the UI code.
68 |
69 | In this example we will demonstrate how `asRefs` works. This extension method decomposes a case class into a tuple
70 | of `Ref`s that can be used inside the block. At the end of the block, a new object is created with the new values.
71 |
72 | ```scala reset
73 | import eu.joaocosta.interim.*
74 |
75 | val uiContext = new UiContext()
76 |
77 | case class AppState(counter: Int = 0, windowArea: PanelState[Rect] = PanelState.open(Rect(x = 10, y = 10, w = 110, h = 50)))
78 | val initialState = AppState()
79 |
80 | def applicationRef(inputState: InputState, appState: AppState) =
81 | import eu.joaocosta.interim.InterIm.*
82 | ui(inputState, uiContext):
83 | appState.asRefs: (counter, windowArea) =>
84 | window(id = "window", title = "My Counter", movable = true)(area = windowArea): area =>
85 | columns(area = area.shrink(5), numColumns = 3, padding = 10):
86 | button(id = "minus", label = "-"):
87 | counter := counter.get - 1 // Counter is a Ref, so we need to use :=
88 | text(
89 | area = summon,
90 | color = Color(0, 0, 0),
91 | message = counter.get.toString, // Counter is a Ref, so we need to use .get
92 | font = Font.default,
93 | horizontalAlignment = centerHorizontally,
94 | verticalAlignment = centerVertically
95 | )
96 | button(id = "plus", label = "+"):
97 | counter := counter.get + 1 // Counter is a Ref, so we need to use :=
98 | ```
99 |
100 | Then we can run our app:
101 |
102 | ```scala
103 | MinartBackend.run[AppState](initialState)(applicationRef)
104 | ```
105 |
--------------------------------------------------------------------------------
/examples/release/6-colorpicker.md:
--------------------------------------------------------------------------------
1 | # 6. Color Picker
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 6-colorpicker.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## An advanced color picker example
16 |
17 | In this last example you can see a complex InterIm application, with various types of components and features.
18 |
19 | This example contains:
20 | - Colored rectangles
21 | - Text
22 | - Buttons
23 | - Sliders
24 | - Checkboxes
25 | - Text inputs
26 | - Elements that appear/disappear conditionally (search result slider)
27 | - Light/Dark mode
28 | - Fixed and dynamic layouts, nested
29 | - Movable and static windows
30 | - Mutable References
31 | - Components with different z-indexes (using `onTop` and `onBottom`)
32 |
33 | 
34 |
35 | This one is more of a show off of what you can do.
36 |
37 | ```scala
38 | import eu.joaocosta.interim.*
39 |
40 | val uiContext = new UiContext()
41 |
42 | case class AppState(
43 | colorPickerArea: PanelState[Rect] = PanelState.open(Rect(x = 10, y = 10, w = 190, h = 180)),
44 | colorSearchArea: PanelState[Rect] = PanelState.open(Rect(x = 300, y = 10, w = 210, h = 210)),
45 | colorRange: PanelState[Int] = PanelState(false, 0),
46 | resultDelta: Int = 0,
47 | color: Color = Color(0, 0, 0),
48 | query: String = ""
49 | )
50 | val initialState = AppState()
51 |
52 | val htmlColors = List(
53 | "White" -> Color(255, 255, 255),
54 | "Silver" -> Color(192, 192, 192),
55 | "Gray" -> Color(128, 128, 128),
56 | "Black" -> Color(0, 0, 0),
57 | "Red" -> Color(255, 0, 0),
58 | "Maroon" -> Color(128, 0, 0),
59 | "Yellow" -> Color(255, 255, 0),
60 | "Olive" -> Color(128, 128, 0),
61 | "Lime" -> Color(0, 255, 0),
62 | "Green" -> Color(0, 128, 0),
63 | "Aqua" -> Color(0, 255, 255),
64 | "Teal" -> Color(0, 128, 128),
65 | "Blue" -> Color(0, 0, 255),
66 | "Navy" -> Color(0, 0, 128),
67 | "Fuchsia" -> Color(255, 0, 255),
68 | "Purple" -> Color(128, 0, 128)
69 | )
70 |
71 | def textColor =
72 | if (skins.ColorScheme.darkModeEnabled()) skins.ColorScheme.white
73 | else skins.ColorScheme.black
74 |
75 | def application(inputState: InputState, appState: AppState) =
76 | import eu.joaocosta.interim.InterIm.*
77 |
78 | ui(inputState, uiContext):
79 | appState.asRefs: (colorPickerArea, colorSearchArea, colorRange, resultDelta, color, query) =>
80 | onTop:
81 | window(id = "color picker", title = "Color Picker", closable = true, movable = true, resizable = true)(area = colorPickerArea): area =>
82 | rows(area = area.shrink(5), numRows = 6, padding = 10):
83 | rectangle(summon, color.get)
84 | select(id = "range", Vector("0-255","0-100", "0x00-0xff"))(colorRange).value match
85 | case 0 =>
86 | val colorStr = f"R:${color.get.r}%03d G:${color.get.g}%03d B:${color.get.b}%03d"
87 | text(summon, textColor, colorStr, Font.default, alignLeft, centerVertically)
88 | case 1 =>
89 | val colorStr = f"R:${color.get.r * 100 / 255}%03d G:${color.get.g * 100 / 255}%03d B:${color.get.b * 100 / 255}%03d"
90 | text(summon, textColor, colorStr, Font.default, alignLeft, centerVertically)
91 | case 2 =>
92 | val colorStr = f"R:0x${color.get.r}%02x G:0x${color.get.g}%02x B:0x${color.get.b}%02x"
93 | text(summon, textColor, colorStr, Font.default, alignLeft, centerVertically)
94 | val r = slider("red slider", min = 0, max = 255)(color.get.r)
95 | val g = slider("green slider", min = 0, max = 255)(color.get.g)
96 | val b = slider("blue slider", min = 0, max = 255)(color.get.b)
97 | color := Color(r, g, b)
98 |
99 | window(id = "color search", title = "Color Search", closable = false, movable = true)(area = colorSearchArea): area =>
100 | dynamicRows(area = area.shrink(5), padding = 10): rowAlloc ?=>
101 | val oldQuery = query.get
102 | textInput("query")(query)
103 | if (query.get != oldQuery) resultDelta := 0
104 | val results = htmlColors.filter(_._1.toLowerCase.startsWith(query.get.toLowerCase))
105 | val resultsArea = rowAlloc.fill()
106 | val buttonSize = 32
107 | dynamicColumns(area = resultsArea, padding = 10, alignRight): newColumn ?=>
108 | val resultsHeight = results.size * buttonSize
109 | if (resultsHeight > resultsArea.h)
110 | slider("result scroller", min = 0, max = resultsHeight - resultsArea.h)(resultDelta)
111 | val clipArea = newColumn.fill()
112 | clip(area = clipArea):
113 | rows(area = clipArea.copy(y = clipArea.y - resultDelta.get, h = resultsHeight), numRows = results.size, padding = 10): rows ?=>
114 | results.zip(rows).foreach:
115 | case ((colorName, colorValue), row) =>
116 | button(s"$colorName button", colorName)(row):
117 | colorPickerArea.modify(_.open)
118 | color := colorValue
119 |
120 | onBottom:
121 | window(id = "settings", title = "Settings", movable = false)(area = Rect(10, 430, 250, 40)): area =>
122 | dynamicColumns(area = area.shrink(5), padding = 10, alignRight): colAlloc ?=>
123 | if (checkbox(id = "dark mode")(skins.ColorScheme.darkModeEnabled()))
124 | skins.ColorScheme.useDarkMode()
125 | else skins.ColorScheme.useLightMode()
126 | text(colAlloc.fill(), textColor, "Dark Mode", Font.default, alignRight)
127 | ```
128 |
129 | Let's run it:
130 |
131 | ```scala
132 | MinartBackend.run(initialState)(application)
133 | ```
134 |
--------------------------------------------------------------------------------
/examples/release/assets/colorpicker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JD557/interim/3fa0f05df6ec8a400aab9f4c313a42ea7e0563c5/examples/release/assets/colorpicker.png
--------------------------------------------------------------------------------
/examples/release/assets/gloop.bmp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JD557/interim/3fa0f05df6ec8a400aab9f4c313a42ea7e0563c5/examples/release/assets/gloop.bmp
--------------------------------------------------------------------------------
/examples/release/example-minart-backend.scala:
--------------------------------------------------------------------------------
1 | //> using scala "3.3.6"
2 | //> using dep "eu.joaocosta::minart::0.6.3"
3 | //> using dep "eu.joaocosta::interim::0.2.0"
4 |
5 | /** This file contains a simple graphical backend written in Minart.
6 | *
7 | * This code supports the bare minimum for the examples.
8 | */
9 | import scala.concurrent.Future
10 |
11 | import eu.joaocosta.interim.*
12 | import eu.joaocosta.minart.backend.defaults.given
13 | import eu.joaocosta.minart.graphics.image.*
14 | import eu.joaocosta.minart.graphics.{Color => MinartColor, *}
15 | import eu.joaocosta.minart.input.*
16 | import eu.joaocosta.minart.runtime.*
17 |
18 | object MinartBackend:
19 |
20 | trait MinartFont:
21 | def charWidth(char: Char): Int
22 | def coloredChar(char: Char, color: MinartColor): SurfaceView
23 |
24 | case class BitmapFont(file: String, width: Int, height: Int, fontFirstChar: Char = '\u0000') extends MinartFont:
25 | private val spriteSheet = SpriteSheet(Image.loadBmpImage(Resource(file)).get, width, height)
26 | def charWidth(char: Char): Int = width
27 | def coloredChar(char: Char, color: MinartColor): SurfaceView =
28 | spriteSheet.getSprite(char.toInt - fontFirstChar.toInt).map {
29 | case MinartColor(255, 255, 255) => color
30 | case c => MinartColor(255, 0, 255)
31 | }
32 |
33 | case class BitmapFontPack(fonts: List[BitmapFont]):
34 | val sortedFonts = fonts.sortBy(_.height)
35 | def withSize(fontSize: Int): MinartFont =
36 | val baseFont = sortedFonts.filter(_.height <= fontSize).lastOption.getOrElse(sortedFonts.head)
37 | if (baseFont.height == fontSize) baseFont
38 | else
39 | val scale = fontSize / baseFont.height.toDouble
40 | new MinartFont:
41 | def charWidth(char: Char): Int = (baseFont.width * scale).toInt
42 | def coloredChar(char: Char, color: MinartColor): SurfaceView = baseFont.coloredChar(char, color).scale(scale)
43 |
44 | // Gloop font by Polyducks: https://twitter.com/PolyDucks
45 | private val gloop = BitmapFontPack(List(BitmapFont("assets/gloop.bmp", 8, 8)))
46 |
47 | private def processKeyboard(keyboardInput: KeyboardInput): String =
48 | import KeyboardInput.Key._
49 | keyboardInput.events
50 | .collect { case KeyboardInput.Event.Pressed(key) => key }
51 | .flatMap {
52 | case Enter => ""
53 | case x =>
54 | x.baseChar
55 | .map(char =>
56 | if (keyboardInput.keysDown(Shift)) char.toUpper.toString
57 | else char.toString
58 | )
59 | .getOrElse("")
60 | }
61 | .mkString
62 |
63 | private def getInputState(canvas: Canvas): InputState = InputState(
64 | canvas.getPointerInput().position.map(pos => (pos.x, pos.y)),
65 | canvas.getPointerInput().isPressed,
66 | processKeyboard(canvas.getKeyboardInput())
67 | )
68 |
69 | private def renderUi(canvas: Canvas, renderOps: List[RenderOp]): Unit =
70 | renderOps.foreach {
71 | case RenderOp.DrawRect(Rect(x, y, w, h), color) =>
72 | canvas.fillRegion(x, y, w, h, MinartColor(color.r, color.g, color.b))
73 | case op: RenderOp.DrawText =>
74 | val font = gloop.withSize(op.font.fontSize)
75 | op.asDrawChars.foreach { case RenderOp.DrawChar(Rect(x, y, _, _), color, char) =>
76 | val charSprite = font.coloredChar(char, MinartColor(color.r, color.g, color.b))
77 | canvas
78 | .blit(charSprite, BlendMode.ColorMask(MinartColor(255, 0, 255)))(x, y)
79 | }
80 | case RenderOp.Custom(Rect(x, y, w, h), color, surface: Surface) =>
81 | canvas.blit(surface)(x, y, 0, 0, w, h)
82 | case RenderOp.Custom(Rect(x, y, w, h), color, data) =>
83 | canvas.fillRegion(x, y, w, h, MinartColor(color.r, color.g, color.b))
84 | }
85 |
86 | private val canvasSettings =
87 | Canvas.Settings(width = 640, height = 480, title = "Immediate GUI", clearColor = MinartColor(80, 110, 120))
88 |
89 | // Example of a loop with global mutable state
90 | def run(body: InputState => (List[RenderOp], _)): Future[Unit] =
91 | AppLoop
92 | .statelessRenderLoop { (canvas: Canvas) =>
93 | val inputState = getInputState(canvas)
94 | canvas.clear()
95 | val ops = body(inputState)._1
96 | renderUi(canvas, ops)
97 | canvas.redraw()
98 | }
99 | .configure(
100 | canvasSettings,
101 | LoopFrequency.hz60
102 | )
103 | .run()
104 |
105 | // Example of a loop with immutable state
106 | def run[S](initialState: S)(body: (InputState, S) => (List[RenderOp], S)): Future[S] =
107 | AppLoop
108 | .statefulRenderLoop { (state: S) => (canvas: Canvas) =>
109 | val inputState = getInputState(canvas)
110 | canvas.clear()
111 | val (ops, newState) = body(inputState, state)
112 | renderUi(canvas, ops)
113 | canvas.redraw()
114 | newState
115 | }
116 | .configure(
117 | canvasSettings,
118 | LoopFrequency.hz60,
119 | initialState
120 | )
121 | .run()
122 |
--------------------------------------------------------------------------------
/examples/snapshot/1-introduction.md:
--------------------------------------------------------------------------------
1 | # 1. Introduction
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 1-intro.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Introductory notes
16 |
17 | InterIm doesn't come with a graphics backend, so all examples will use an example backend powered by
18 | [Minart](https://github.com/jd557/minart). However, you can easily use your own backend.
19 |
20 | Also, all examples will use the ["8x8 Gloop"](https://www.gridsagegames.com/rexpaint/resources.html#Fonts) font
21 | by [Polyducks](https://twitter.com/PolyDucks).
22 |
23 | Finally, due to the imperative nature of Immediate Mode UIs, the examples will be extremely imperative,
24 | with lots of mutation, as that's the idiomatic way to write such UIs.
25 | However, InterIm also allows you to write code with almost no mutability (at the expense of verbosity).
26 |
27 | ## A simple counter application
28 |
29 | Let's start with a simple counter application with:
30 | - A counter showing a number
31 | - A button that increments the counter
32 | - A button that decrements the counter
33 |
34 | First, let's start by setting our state:
35 |
36 | ```scala
37 | var counter = 0
38 | ```
39 |
40 | We also need to create a `UiContext`. This is the object InterIm uses to keep it's mutable internal state
41 | between each call.
42 |
43 | ```scala
44 | import eu.joaocosta.interim.*
45 |
46 | val uiContext = new UiContext()
47 | ```
48 |
49 | Now, let's write our interface. We are going to need the following components:
50 | - `text`, to draw the counter value
51 | - `button`, to increase and decrease the value
52 |
53 | ```scala
54 | def application(inputState: InputState) =
55 | import eu.joaocosta.interim.InterIm.*
56 | ui(inputState, uiContext):
57 | button(id = "minus", label = "-")(area = Rect(x = 10, y = 10, w = 30, h = 30)):
58 | counter = counter - 1
59 | text(
60 | area = Rect(x = 40, y = 10, w = 30, h = 30),
61 | color = Color(0, 0, 0),
62 | message = counter.toString,
63 | font = Font.default,
64 | horizontalAlignment = centerHorizontally,
65 | verticalAlignment = centerVertically
66 | )
67 | button(id = "plus", label = "+")(area = Rect(x = 70, y = 10, w = 30, h = 30)):
68 | counter = counter + 1
69 | ```
70 |
71 | Let's go line by line:
72 |
73 | First, our application will need to somehow receive input (e.g. mouse clicks), so we need to provide the `InputState`
74 | from our backend.
75 |
76 | Then, we import `import eu.joaocosta.interim.InterIm.*`. This enables the InterIm DSL, which give us access to our
77 | component functions.
78 |
79 | Next, we start our UI with `ui(inputState, uiContext)`. All DSL operation must happen inside this block which,
80 | in the end, returns the sequence of render operations that must be executed by the backend.
81 |
82 | Now, to the button logic:
83 | 1. Interactive components like buttons require a unique ID, which is the first parameter;
84 | 2. We also add a label, which is the text that will be shown on the button;
85 | 3. Then we need to specify an area where the button will be drawn;
86 | 4. Finally, `button` returns `true` when the button is pressed, so we use that to decrement our counter.
87 |
88 | For the text block we don't need an id, as it's just a rendering primitive with no interaction, so we just need to
89 | give it the string we want to show and the style details.
90 |
91 | Finally, we add another button that increments the counter. Note that this one uses a different id!
92 |
93 | ## Integrating with the backend
94 |
95 | Now that our application is defined, we can call it from our backend:
96 |
97 | In pseudo code, this looks like the following:
98 |
99 | ```
100 | val uiContext = new UiContext()
101 |
102 | def application(inputState: InputState) = ??? // Our application code
103 |
104 | while(true) {
105 | val input: InputState = backend.grabInput() // Grab input from the backend
106 | val (renderOps, _) = application(input) // Generate render ops
107 | backend.render(renderOps) // Send render operations to the backend
108 | }
109 |
110 | ```
111 |
112 | In this examples, we'll simply call this basic `MinartBackend` with:
113 |
114 | ```scala
115 | MinartBackend.run(application)
116 | ```
117 |
118 | ## A note on state and mutability
119 |
120 | You might have noticed that our application returns two parameters, and we are ignoring the second one.
121 | Indeed, the `ui` function (and other InterIm operations) return the last value of the body.
122 | This makes it possible to write applications without using mutable variables.
123 |
124 | For example we could rewrite our application as:
125 |
126 | ```scala
127 | def immutableApp(inputState: InputState, counter: Int): (List[RenderOp], Int) =
128 | import eu.joaocosta.interim.InterIm.*
129 | ui(inputState, uiContext):
130 | val (decrementCounter, _, incrementCounter) = (
131 | button(id = "minus", label = "-")(area = Rect(x = 10, y = 10, w = 30, h = 30))(true).getOrElse(false),
132 | text(
133 | area = Rect(x = 40, y = 10, w = 30, h = 30),
134 | color = Color(0, 0, 0),
135 | message = counter.toString,
136 | font = Font.default,
137 | horizontalAlignment = centerHorizontally,
138 | verticalAlignment = centerVertically
139 | ),
140 | button(id = "plus", label = "+")(area = Rect(x = 70, y = 10, w = 30, h = 30))(true).getOrElse(false)
141 | )
142 | if (decrementCounter && !incrementCounter) counter - 1
143 | else if (!decrementCounter && incrementCounter) counter + 1
144 | else counter
145 | ```
146 |
147 | Unfortunately, as it might be visible from the example, when multiple components update the same state, some
148 | boilerplate is required to unify the state changes.
149 |
150 | One possible solution to this is to use local mutability:
151 |
152 | ```scala
153 | def localMutableApp(inputState: InputState, counter: Int): (List[RenderOp], Int) =
154 | import eu.joaocosta.interim.InterIm.*
155 | var _counter = counter
156 | ui(inputState, uiContext):
157 | button(id = "minus", label = "-")(area = Rect(x = 10, y = 10, w = 30, h = 30)):
158 | _counter = counter - 1
159 | text(
160 | area = Rect(x = 40, y = 10, w = 30, h = 30),
161 | color = Color(0, 0, 0),
162 | message = counter.toString,
163 | font = Font.default,
164 | horizontalAlignment = centerHorizontally,
165 | verticalAlignment = centerVertically
166 | )
167 | button(id = "plus", label = "+")(area = Rect(x = 70, y = 10, w = 30, h = 30)):
168 | _counter = counter + 1
169 | _counter
170 | ```
171 |
172 | InterIm also provides some tools to make local mutability easier and safer. Those are introduced in later examples.
173 |
--------------------------------------------------------------------------------
/examples/snapshot/2-explicit-layout.md:
--------------------------------------------------------------------------------
1 | # 2. Explicit Layout
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 2-explicit-layout.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Component layout in InterIm applications
16 |
17 | In InterIm, every component receives a `Rect` with the area where it can be rendered, so its size must be known up front.
18 |
19 | This is very different from other systems like HTML, where elements can infer their size from their contents.
20 |
21 | This is a typical problem of immediate mode GUIs. While there are some techniques to address that, InterIm
22 | currently opts for the simpler option.
23 |
24 | However, explicit does not mean manual! InterIm comes with multiple helpers to automatically generate areas according
25 | to a specified layout:
26 | - `grid`: a grid layout with n*m equally sized cells
27 | - `rows`: a row layout with n equally sized rows
28 | - `columns`: a column layout with n equally sized columns
29 | - `dynamicRows`: a row layout with rows of different sizes
30 | - `dynamicColumns`: a column layout with columns of different sizes
31 |
32 | ## Using layouts in the counter application
33 |
34 | Previously, in out counter application, we had to manually set the areas for all components.
35 | This can be quite a chore, especially since changing one area might force us to manually change them all!
36 |
37 | Everything was layed out in 3 equally sized columns, so let's use the `columns` layout.
38 |
39 | This layout returns a `IndexedSeq[Rect]`, with the 3 areas we want to use.
40 |
41 | Our application now looks like:
42 |
43 | ```scala
44 | import eu.joaocosta.interim.*
45 |
46 | val uiContext = new UiContext()
47 | var counter = 0
48 |
49 | def application(inputState: InputState) =
50 | import eu.joaocosta.interim.InterIm.*
51 | ui(inputState, uiContext):
52 | columns(area = Rect(x = 10, y = 10, w = 110, h = 30), numColumns = 3, padding = 10): column ?=>
53 | button(id = "minus", label = "-")(column(0)):
54 | counter = counter - 1
55 | text(
56 | area = column(1),
57 | color = Color(0, 0, 0),
58 | message = counter.toString,
59 | font = Font.default,
60 | horizontalAlignment = centerHorizontally,
61 | verticalAlignment = centerVertically
62 | )
63 | button(id = "plus", label = "+")(column(2)):
64 | counter = counter + 1
65 | ```
66 |
67 | Now let's run it:
68 |
69 | ```scala
70 | MinartBackend.run(application)
71 | ```
72 |
73 | ## Note about dynamic layouts
74 |
75 | While `rows`, `columns` and `cells` provide a `IndexedSeq[Rect]` (or a `IndexedSeq[IndexedSeq[Rect]]`) to our body, the dynamic
76 | versions work a bit differently.
77 |
78 | In those cases, a `Int => Rect` function is provided where, given a desired size, a `Rect` is returned.
79 | If the size is positive, the `Rect` will be allocated from the top/left, while negative sizes will allocate a `Rect`
80 | from the bottom/right.
81 |
82 | For example, this is how our application would look like with a dynamic layout:
83 |
84 | ```scala
85 | def dynamicApp(inputState: InputState) =
86 | import eu.joaocosta.interim.InterIm.*
87 | ui(inputState, uiContext):
88 | dynamicColumns(area = Rect(x = 10, y = 10, w = 110, h = 30), padding = 10): column ?=>
89 | button(id = "minus", label = "-")(area = column(30)): // 30px from the left
90 | counter = counter - 1
91 | button(id = "plus", label = "+")(area = column(-30)): // 30px from the right
92 | counter = counter + 1
93 | text(
94 | area = column(maxSize), // Fill the remaining area
95 | color = Color(0, 0, 0),
96 | message = counter.toString,
97 | font = Font.default,
98 | horizontalAlignment = centerHorizontally,
99 | verticalAlignment = centerVertically
100 | )
101 | ```
102 |
--------------------------------------------------------------------------------
/examples/snapshot/3-implicit-layout.md:
--------------------------------------------------------------------------------
1 | # 3. Explicit Layout
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 3-layout.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Implicit layouts
16 |
17 | In the previous example, you might have noticed something odd: helpers like `columns` use a context function instead of a
18 | regular function.
19 |
20 | Indeed, that's because those functions introduce an implicit `LayoutAllocator`.
21 | When there's no area defined, components will use the allocator from the current context to pick an area implicitly.
22 |
23 | Right now, however, while primitives (such as `rectangle` and `text`) can use an allocator, that must be passed explicitly
24 | (also in the `area` parameter).
25 |
26 | ## Using implicit layouts in the counter application
27 |
28 | Here's the previous example but with implicit layouts:
29 |
30 | ```scala
31 | import eu.joaocosta.interim.*
32 |
33 | val uiContext = new UiContext()
34 | var counter = 0
35 |
36 | def application(inputState: InputState) =
37 | import eu.joaocosta.interim.InterIm.*
38 | ui(inputState, uiContext):
39 | columns(area = Rect(x = 10, y = 10, w = 110, h = 30), numColumns = 3, padding = 10):
40 | button(id = "minus", label = "-"):
41 | counter = counter - 1
42 | text(
43 | area = summon, // we can easily get the allocator with `summon`
44 | color = Color(0, 0, 0),
45 | message = counter.toString,
46 | font = Font.default,
47 | horizontalAlignment = centerHorizontally,
48 | verticalAlignment = centerVertically
49 | )
50 | button(id = "plus", label = "+"):
51 | counter = counter + 1
52 | ```
53 |
54 | Now let's run it:
55 |
56 | ```scala
57 | MinartBackend.run(application)
58 | ```
59 |
--------------------------------------------------------------------------------
/examples/snapshot/4-windows.md:
--------------------------------------------------------------------------------
1 | # 4. Windows
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 4-windows.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Floating Windows in InterIm
16 |
17 | An advanced use case for InterIm is to draw components on top of already existing applications (e.g. to create a debug
18 | menu).
19 |
20 | For this, it's helpful to have a floating window abstraction, and that's exactly what `window` is!
21 |
22 | A window is a special component that:
23 | - Passes an area to a function, which is the window drawable region
24 | - Returns it's value (optional) and a `PanelState[Rect]`. That `PanelState[Rect]` is the one that needs to be passed as the window area.
25 |
26 | This might sound a little convoluted, but this is what allows windows to be dragged and closed.
27 |
28 | ## Using window in the counter application
29 |
30 | Let's go straight to the example, as things are not as confusing as they sound.
31 |
32 | ```scala
33 | import eu.joaocosta.interim.*
34 |
35 | val uiContext = new UiContext()
36 |
37 | var windowArea = PanelState.open(Rect(x = 10, y = 10, w = 110, h = 50))
38 | var counter = 0
39 |
40 | def application(inputState: InputState) =
41 | import eu.joaocosta.interim.InterIm.*
42 | ui(inputState, uiContext):
43 | windowArea = window(id = "window", title = "My Counter", movable = true, closable = false)(area = windowArea) { area =>
44 | columns(area = area.shrink(5), numColumns = 3, padding = 10) {
45 | button(id = "minus", label = "-"):
46 | counter = counter - 1
47 | text(
48 | area = summon,
49 | color = Color(0, 0, 0),
50 | message = counter.toString,
51 | font = Font.default,
52 | horizontalAlignment = centerHorizontally,
53 | verticalAlignment = centerVertically
54 | )
55 | button(id = "plus", label = "+"):
56 | counter = counter + 1
57 | }
58 | }._2 // We don't care about the value, just the rect
59 | ```
60 |
61 | We now have a window that we can drag across the screen! Pick it up with the top left icon and try it.
62 |
63 | Note how we just pass the area to our layout, so everything just moves with our window.
64 | In this example we use `area.shrink(5)` to reduce our area by 5px on all sides, which gives the contents a nice padding.
65 |
66 | Let's run it:
67 |
68 | ```scala
69 | MinartBackend.run(application)
70 | ```
71 |
--------------------------------------------------------------------------------
/examples/snapshot/5-refs.md:
--------------------------------------------------------------------------------
1 | # 5. Mutable References
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 5-refs.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## Mutable references
16 |
17 | In some situations, even a mutable style can be quite verbose.
18 |
19 | In the previous window above, we only care about the window area, so we are dropping one of the values with `._2`, but if we
20 | wanted to keep the value we would need to create a temporary variable and then mutate our state with the result.
21 |
22 | Usually, immediate mode UIs solve this by using out parameters, and this is exactly what InterIm provides with the `Ref`
23 | abstraction.
24 |
25 | A `Ref` is simply a wrapper for a mutable value (`final case class Ref[T](var value: T)`). That way, components can
26 | handle the mutation themselves.
27 |
28 | The example above could also be written as:
29 |
30 | ```scala
31 | import eu.joaocosta.interim.*
32 |
33 | val uiContext = new UiContext()
34 |
35 | val windowArea = Ref(PanelState.open(Rect(x = 10, y = 10, w = 110, h = 50))) // Now a val instead of a var
36 | var counter = 0
37 |
38 | def application(inputState: InputState) =
39 | import eu.joaocosta.interim.InterIm.*
40 | ui(inputState, uiContext):
41 | // window takes area as a ref, so will mutate the window area variable
42 | window(id = "window", title = "My Counter", movable = true)(area = windowArea): area =>
43 | columns(area = area.shrink(5), numColumns = 3, padding = 10):
44 | button(id = "minus", label = "-"):
45 | counter = counter - 1
46 | text(
47 | area = summon,
48 | color = Color(0, 0, 0),
49 | message = counter.toString,
50 | font = Font.default,
51 | horizontalAlignment = centerHorizontally,
52 | verticalAlignment = centerVertically
53 | )
54 | button(id = "plus", label = "+"):
55 | counter = counter + 1
56 | ```
57 |
58 | Be aware that, while the code is more concise, coding with out parameters can lead to confusing code where it's hard
59 | to find out where a value is being mutated. It's up to you to decide when to use `Ref`s and when to use plain values.
60 |
61 | ## Scoped mutable references
62 |
63 | Having global mutable variables is usually not a good choice, and they are even worse if functions can change the values
64 | of their arguments at will.
65 |
66 | To avoid this, there are a few helpful methods (`Ref.withRef`/`Ref.withRefs` and the corresponding `asRef`/`asRefs`
67 | extension methods) that let us write applications that can only mutate the state inside the UI code.
68 |
69 | In this example we will demonstrate how `asRefs` works. This extension method decomposes a case class into a tuple
70 | of `Ref`s that can be used inside the block. At the end of the block, a new object is created with the new values.
71 |
72 | ```scala reset
73 | import eu.joaocosta.interim.*
74 |
75 | val uiContext = new UiContext()
76 |
77 | case class AppState(counter: Int = 0, windowArea: PanelState[Rect] = PanelState.open(Rect(x = 10, y = 10, w = 110, h = 50)))
78 | val initialState = AppState()
79 |
80 | def applicationRef(inputState: InputState, appState: AppState) =
81 | import eu.joaocosta.interim.InterIm.*
82 | ui(inputState, uiContext):
83 | appState.asRefs: (counter, windowArea) =>
84 | window(id = "window", title = "My Counter", movable = true)(area = windowArea): area =>
85 | columns(area = area.shrink(5), numColumns = 3, padding = 10):
86 | button(id = "minus", label = "-"):
87 | counter := counter.get - 1 // Counter is a Ref, so we need to use :=
88 | text(
89 | area = summon,
90 | color = Color(0, 0, 0),
91 | message = counter.get.toString, // Counter is a Ref, so we need to use .get
92 | font = Font.default,
93 | horizontalAlignment = centerHorizontally,
94 | verticalAlignment = centerVertically
95 | )
96 | button(id = "plus", label = "+"):
97 | counter := counter.get + 1 // Counter is a Ref, so we need to use :=
98 | ```
99 |
100 | Then we can run our app:
101 |
102 | ```scala
103 | MinartBackend.run[AppState](initialState)(applicationRef)
104 | ```
105 |
--------------------------------------------------------------------------------
/examples/snapshot/6-colorpicker.md:
--------------------------------------------------------------------------------
1 | # 6. Color Picker
2 |
3 | Welcome to the InterIm tutorial!
4 |
5 | ## Running the examples
6 |
7 | You can run the code in this file (and other tutorials) with:
8 |
9 | ```bash
10 | scala-cli 6-colorpicker.md example-minart-backend.scala
11 | ```
12 |
13 | Other examples can be run in a similar fashion
14 |
15 | ## An advanced color picker example
16 |
17 | In this last example you can see a complex InterIm application, with various types of components and features.
18 |
19 | This example contains:
20 | - Colored rectangles
21 | - Text
22 | - Buttons
23 | - Sliders
24 | - Checkboxes
25 | - Text inputs
26 | - Elements that appear/disappear conditionally (search result slider)
27 | - Light/Dark mode
28 | - Fixed and dynamic layouts, nested
29 | - Movable and static windows
30 | - Mutable References
31 | - Components with different z-indexes (using `onTop` and `onBottom`)
32 |
33 | 
34 |
35 | This one is more of a show off of what you can do.
36 |
37 | ```scala
38 | import eu.joaocosta.interim.*
39 |
40 | val uiContext = new UiContext()
41 |
42 | case class AppState(
43 | colorPickerArea: PanelState[Rect] = PanelState.open(Rect(x = 10, y = 10, w = 190, h = 180)),
44 | colorSearchArea: PanelState[Rect] = PanelState.open(Rect(x = 300, y = 10, w = 210, h = 210)),
45 | colorRange: PanelState[Int] = PanelState(false, 0),
46 | resultDelta: Int = 0,
47 | color: Color = Color(0, 0, 0),
48 | query: String = ""
49 | )
50 | val initialState = AppState()
51 |
52 | val htmlColors = List(
53 | "White" -> Color(255, 255, 255),
54 | "Silver" -> Color(192, 192, 192),
55 | "Gray" -> Color(128, 128, 128),
56 | "Black" -> Color(0, 0, 0),
57 | "Red" -> Color(255, 0, 0),
58 | "Maroon" -> Color(128, 0, 0),
59 | "Yellow" -> Color(255, 255, 0),
60 | "Olive" -> Color(128, 128, 0),
61 | "Lime" -> Color(0, 255, 0),
62 | "Green" -> Color(0, 128, 0),
63 | "Aqua" -> Color(0, 255, 255),
64 | "Teal" -> Color(0, 128, 128),
65 | "Blue" -> Color(0, 0, 255),
66 | "Navy" -> Color(0, 0, 128),
67 | "Fuchsia" -> Color(255, 0, 255),
68 | "Purple" -> Color(128, 0, 128)
69 | )
70 |
71 | def textColor =
72 | if (skins.ColorScheme.darkModeEnabled()) skins.ColorScheme.white
73 | else skins.ColorScheme.black
74 |
75 | def application(inputState: InputState, appState: AppState) =
76 | import eu.joaocosta.interim.InterIm.*
77 |
78 | ui(inputState, uiContext):
79 | appState.asRefs: (colorPickerArea, colorSearchArea, colorRange, resultDelta, color, query) =>
80 | onTop:
81 | window(id = "color picker", title = "Color Picker", closable = true, movable = true, resizable = true)(area = colorPickerArea): area =>
82 | rows(area = area.shrink(5), numRows = 6, padding = 10):
83 | rectangle(summon, color.get)
84 | select(id = "range", Vector("0-255","0-100", "0x00-0xff"))(colorRange).value match
85 | case 0 =>
86 | val colorStr = f"R:${color.get.r}%03d G:${color.get.g}%03d B:${color.get.b}%03d"
87 | text(summon, textColor, colorStr, Font.default, alignLeft, centerVertically)
88 | case 1 =>
89 | val colorStr = f"R:${color.get.r * 100 / 255}%03d G:${color.get.g * 100 / 255}%03d B:${color.get.b * 100 / 255}%03d"
90 | text(summon, textColor, colorStr, Font.default, alignLeft, centerVertically)
91 | case 2 =>
92 | val colorStr = f"R:0x${color.get.r}%02x G:0x${color.get.g}%02x B:0x${color.get.b}%02x"
93 | text(summon, textColor, colorStr, Font.default, alignLeft, centerVertically)
94 | val r = slider("red slider", min = 0, max = 255)(color.get.r)
95 | val g = slider("green slider", min = 0, max = 255)(color.get.g)
96 | val b = slider("blue slider", min = 0, max = 255)(color.get.b)
97 | color := Color(r, g, b)
98 |
99 | window(id = "color search", title = "Color Search", closable = false, movable = true)(area = colorSearchArea): area =>
100 | dynamicRows(area = area.shrink(5), padding = 10): rowAlloc ?=>
101 | val oldQuery = query.get
102 | textInput("query")(query)
103 | if (query.get != oldQuery) resultDelta := 0
104 | val results = htmlColors.filter(_._1.toLowerCase.startsWith(query.get.toLowerCase))
105 | val resultsArea = rowAlloc.fill()
106 | val buttonSize = 32
107 | dynamicColumns(area = resultsArea, padding = 10, alignRight): newColumn ?=>
108 | val resultsHeight = results.size * buttonSize
109 | if (resultsHeight > resultsArea.h)
110 | slider("result scroller", min = 0, max = resultsHeight - resultsArea.h)(resultDelta)
111 | val clipArea = newColumn.fill()
112 | clip(area = clipArea):
113 | rows(area = clipArea.copy(y = clipArea.y - resultDelta.get, h = resultsHeight), numRows = results.size, padding = 10): rows ?=>
114 | results.zip(rows).foreach:
115 | case ((colorName, colorValue), row) =>
116 | button(s"$colorName button", colorName)(row):
117 | colorPickerArea.modify(_.open)
118 | color := colorValue
119 |
120 | onBottom:
121 | window(id = "settings", title = "Settings", movable = false)(area = Rect(10, 430, 250, 40)): area =>
122 | dynamicColumns(area = area.shrink(5), padding = 10, alignRight): colAlloc ?=>
123 | if (checkbox(id = "dark mode")(skins.ColorScheme.darkModeEnabled()))
124 | skins.ColorScheme.useDarkMode()
125 | else skins.ColorScheme.useLightMode()
126 | text(colAlloc.fill(), textColor, "Dark Mode", Font.default, alignRight)
127 | ```
128 |
129 | Let's run it:
130 |
131 | ```scala
132 | MinartBackend.run(initialState)(application)
133 | ```
134 |
--------------------------------------------------------------------------------
/examples/snapshot/assets/colorpicker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JD557/interim/3fa0f05df6ec8a400aab9f4c313a42ea7e0563c5/examples/snapshot/assets/colorpicker.png
--------------------------------------------------------------------------------
/examples/snapshot/assets/gloop.bmp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JD557/interim/3fa0f05df6ec8a400aab9f4c313a42ea7e0563c5/examples/snapshot/assets/gloop.bmp
--------------------------------------------------------------------------------
/examples/snapshot/example-minart-backend.scala:
--------------------------------------------------------------------------------
1 | //> using scala "3.3.6"
2 | //> using dep "eu.joaocosta::minart::0.6.3"
3 | //> using dep "eu.joaocosta::interim::0.2.1-SNAPSHOT"
4 |
5 | /** This file contains a simple graphical backend written in Minart.
6 | *
7 | * This code supports the bare minimum for the examples.
8 | */
9 | import scala.concurrent.Future
10 |
11 | import eu.joaocosta.interim.*
12 | import eu.joaocosta.minart.backend.defaults.given
13 | import eu.joaocosta.minart.graphics.image.*
14 | import eu.joaocosta.minart.graphics.{Color => MinartColor, *}
15 | import eu.joaocosta.minart.input.*
16 | import eu.joaocosta.minart.runtime.*
17 |
18 | object MinartBackend:
19 |
20 | trait MinartFont:
21 | def charWidth(char: Char): Int
22 | def coloredChar(char: Char, color: MinartColor): SurfaceView
23 |
24 | case class BitmapFont(file: String, width: Int, height: Int, fontFirstChar: Char = '\u0000') extends MinartFont:
25 | private val spriteSheet = SpriteSheet(Image.loadBmpImage(Resource(file)).get, width, height)
26 | def charWidth(char: Char): Int = width
27 | def coloredChar(char: Char, color: MinartColor): SurfaceView =
28 | spriteSheet.getSprite(char.toInt - fontFirstChar.toInt).map {
29 | case MinartColor(255, 255, 255) => color
30 | case c => MinartColor(255, 0, 255)
31 | }
32 |
33 | case class BitmapFontPack(fonts: List[BitmapFont]):
34 | val sortedFonts = fonts.sortBy(_.height)
35 | def withSize(fontSize: Int): MinartFont =
36 | val baseFont = sortedFonts.filter(_.height <= fontSize).lastOption.getOrElse(sortedFonts.head)
37 | if (baseFont.height == fontSize) baseFont
38 | else
39 | val scale = fontSize / baseFont.height.toDouble
40 | new MinartFont:
41 | def charWidth(char: Char): Int = (baseFont.width * scale).toInt
42 | def coloredChar(char: Char, color: MinartColor): SurfaceView = baseFont.coloredChar(char, color).scale(scale)
43 |
44 | // Gloop font by Polyducks: https://twitter.com/PolyDucks
45 | private val gloop = BitmapFontPack(List(BitmapFont("assets/gloop.bmp", 8, 8)))
46 |
47 | private def processKeyboard(keyboardInput: KeyboardInput): String =
48 | import KeyboardInput.Key._
49 | keyboardInput.events
50 | .collect { case KeyboardInput.Event.Pressed(key) => key }
51 | .flatMap {
52 | case Enter => ""
53 | case x =>
54 | x.baseChar
55 | .map(char =>
56 | if (keyboardInput.keysDown(Shift)) char.toUpper.toString
57 | else char.toString
58 | )
59 | .getOrElse("")
60 | }
61 | .mkString
62 |
63 | private def getInputState(canvas: Canvas): InputState = InputState(
64 | canvas.getPointerInput().position.map(pos => (pos.x, pos.y)),
65 | canvas.getPointerInput().isPressed,
66 | processKeyboard(canvas.getKeyboardInput())
67 | )
68 |
69 | private def renderUi(canvas: Canvas, renderOps: List[RenderOp]): Unit =
70 | renderOps.foreach {
71 | case RenderOp.DrawRect(Rect(x, y, w, h), color) =>
72 | canvas.fillRegion(x, y, w, h, MinartColor(color.r, color.g, color.b))
73 | case op: RenderOp.DrawText =>
74 | val font = gloop.withSize(op.font.fontSize)
75 | op.asDrawChars.foreach { case RenderOp.DrawChar(Rect(x, y, _, _), color, char) =>
76 | val charSprite = font.coloredChar(char, MinartColor(color.r, color.g, color.b))
77 | canvas
78 | .blit(charSprite, BlendMode.ColorMask(MinartColor(255, 0, 255)))(x, y)
79 | }
80 | case RenderOp.Custom(Rect(x, y, w, h), color, surface: Surface) =>
81 | canvas.blit(surface)(x, y, 0, 0, w, h)
82 | case RenderOp.Custom(Rect(x, y, w, h), color, data) =>
83 | canvas.fillRegion(x, y, w, h, MinartColor(color.r, color.g, color.b))
84 | }
85 |
86 | private val canvasSettings =
87 | Canvas.Settings(width = 640, height = 480, title = "Immediate GUI", clearColor = MinartColor(80, 110, 120))
88 |
89 | // Example of a loop with global mutable state
90 | def run(body: InputState => (List[RenderOp], _)): Future[Unit] =
91 | AppLoop
92 | .statelessRenderLoop { (canvas: Canvas) =>
93 | val inputState = getInputState(canvas)
94 | canvas.clear()
95 | val ops = body(inputState)._1
96 | renderUi(canvas, ops)
97 | canvas.redraw()
98 | }
99 | .configure(
100 | canvasSettings,
101 | LoopFrequency.hz60
102 | )
103 | .run()
104 |
105 | // Example of a loop with immutable state
106 | def run[S](initialState: S)(body: (InputState, S) => (List[RenderOp], S)): Future[S] =
107 | AppLoop
108 | .statefulRenderLoop { (state: S) => (canvas: Canvas) =>
109 | val inputState = getInputState(canvas)
110 | canvas.clear()
111 | val (ops, newState) = body(inputState, state)
112 | renderUi(canvas, ops)
113 | canvas.redraw()
114 | newState
115 | }
116 | .configure(
117 | canvasSettings,
118 | LoopFrequency.hz60,
119 | initialState
120 | )
121 | .run()
122 |
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version=1.11.0
2 |
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.3")
2 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1")
3 | addSbtPlugin("com.github.sbt" % "sbt-release" % "1.4.0")
4 | addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2")
5 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")
6 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0")
7 | addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.7")
8 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4")
9 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2")
10 |
--------------------------------------------------------------------------------
/version.sbt:
--------------------------------------------------------------------------------
1 | ThisBuild / version := "0.2.1-SNAPSHOT"
2 |
--------------------------------------------------------------------------------