├── .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 | ![Sonatype Nexus (Releases)](https://img.shields.io/nexus/r/eu.joaocosta/interim_3?server=https%3A%2F%2Foss.sonatype.org) 4 | [![scaladoc](https://javadoc.io/badge2/eu.joaocosta/interim_3/javadoc.svg)](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 | ![Example of a color picker](examples/snapshot/assets/colorpicker.png) 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 | ![Color picker screenshot](assets/colorpicker.png) 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 | ![Color picker screenshot](assets/colorpicker.png) 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 | --------------------------------------------------------------------------------