├── .github └── workflows │ └── main.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── PUBLISH.md ├── README.md ├── build.sbt ├── docs └── index.md ├── project ├── build.properties └── plugins.sbt ├── publish-doc.sh ├── publish-jar.sh ├── publish-workspace.sh └── src ├── main └── scala │ └── introprog │ ├── BlockGame.scala │ ├── Dialog.scala │ ├── IO.scala │ ├── Image.scala │ ├── PixelWindow.scala │ ├── Swing.scala │ └── examples │ ├── TestBlockGame.scala │ ├── TestIO.scala │ └── TestPixelWindow.scala └── test └── scala └── testIO.scala /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | jobs: 4 | Test: 5 | runs-on: ubuntu-20.04 6 | steps: 7 | - name: Check out repository code 8 | uses: actions/checkout@v2 9 | - name: SBT Build 10 | run: sbt test 11 | shell: bash 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | **/old 3 | **/bin 4 | 5 | # Java / Scala 6 | **/*.class 7 | .ensime 8 | .ensime_cache/ 9 | .classpath 10 | **/.metals 11 | **/.bloop 12 | **/.bsp 13 | **/metals.sbt 14 | .vscode 15 | 16 | **/*.cache-main # Eclipse 17 | **/.metadata # Eclipse 18 | **/target # sbt 19 | target 20 | *.swp # vim 21 | *~ # emacs 22 | .idea # IntelliJ 23 | 24 | # produced by TestIO 25 | highscores.ser 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `introprog-scalalib` 2 | 3 | Contributions are welcome! 4 | 5 | If you contribute to this repo you implicitly agree to the terms of the open source license in this repository. 6 | 7 | 1. Open an **issue** and start discussing your suggestion. An issue can include a proposal to e.g. fix a bug, improve documentation, include a new or enhanced feature, develop a new beginner-friendly example, add a missing test, etc. 8 | 9 | 2. **Fork** this repo and clone your fork as described [here](https://help.github.com/articles/fork-a-repo/). 10 | 11 | 3. Do your changes and additions in line with the issue discussions. Make minimal, coherent commits with good commit messages. 12 | 13 | 4. When you are ready with an enhancement that compiles and run according to expectations, create a **pull request** (PR). Make sure that you keep your fork [synced](https://help.github.com/articles/syncing-a-fork/) with this upstream repo before creating a PR. 14 | 15 | Please keep the general intention of this repo in mind as outlined in the README; in summary: *Beginner-friendly API:s usable with simple Scala* 16 | 17 | Further help on how to keep it simple: 18 | 19 | * http://www.lihaoyi.com/post/StrategicScalaStylePrincipleofLeastPower.html 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2018, Lund University 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /PUBLISH.md: -------------------------------------------------------------------------------- 1 | # Instruction for repo maintainers 2 | 3 | First two sections are preparations only done once for all or once per machine. Last comes what is done when actually publishing. 4 | 5 | ## Already done once and for all: Setup publication to Sonatype 6 | 7 | These instructions have already been followed for this repo by Bjorn Regnell who has claimed the name space se.lth.cs and the artefact id introprog: 8 | 9 | * https://www.scala-sbt.org/release/docs/Using-Sonatype.html#Sonatype+setup 10 | 11 | * Instruction videos: https://central.sonatype.org/pages/ossrh-guide.html 12 | 13 | * New project ticket (requires login to Jira): https://issues.sonatype.org/browse/OSSRH-42634?filter=-2 14 | 15 | ## Sbt config and GPG Key setup (done once per machine) 16 | 17 | Read and adapt these instructions: 18 | 19 | * https://www.scala-sbt.org/release/docs/Using-Sonatype.html 20 | * Be aware that step 1 was not used, instead the instructions from this link were used to create keys: 21 | * https://github.com/scalacenter/sbt-release-early/wiki/How-to-create-a-gpg-key 22 | 23 | * Step 2-4 from above was used. Then after key generation, step 5 should work according to "How to publish" below. See the last parts of this repo's `build.sbt` and these instructions: 24 | 25 | Issue commands below one at a time to make files in `~/.sbt/` and key pair in ascii in `~/.sbt/gpg` and publish key in `~/ci-keys` and then copy to `.sbt/gpg` tested on Ubuntu 18.04 using `gpg --version` at 2.2.4. 26 | 27 | ``` 28 | cd ~ 29 | mkdir ci-keys 30 | chmod -R go-rwx ci-keys 31 | cd ci-keys 32 | gpg --homedir . --gen-key 33 | gpg --homedir . -a --export > pubring.asc 34 | gpg --homedir . -a --export-secret-keys > secring.asc 35 | gpg --homedir . --list-key 36 | # the pub hex string e.g E7232FE8B8357EEC786315FE821738D92B63C95F 37 | gpg --homedir . --keyserver hkp://pool.sks-keyservers.net --send-keys 38 | gpg --homedir . --keyserver hkp://pgp.mit.edu --send-keys E7232FE8B8357EEC786315FE821738D92B63C95F 39 | mkdir -p ~/.sbt/gpg 40 | cd ~/.sbt/gpg 41 | cp -R ~/ci-keys/* . 42 | ``` 43 | 44 | After this you should have this these files `~/.sbt/gpg`: 45 | 46 | ``` 47 | $ cat ~/.sbt/1.0/plugins/gpg.sbt 48 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.0") 49 | 50 | $ cat ~/.sbt/sonatype_credential 51 | realm=Sonatype Nexus Repository Manager 52 | host=oss.sonatype.org 53 | user= 54 | password= 55 | 56 | $ cat ~/.sbt/1.0/sonatype.sbt 57 | credentials += Credentials(Path.userHome / ".sbt" / "sonatype_credential") 58 | 59 | $ ls ~/.sbt/gpg 60 | crls.d private-keys-v1.d pubring.kbx secring.asc 61 | openpgp-revocs.d pubring.asc trustdb.gpg 62 | 63 | ``` 64 | 65 | * See more info here: 66 | - https://github.com/sbt/sbt-pgp#configuration-signing-key 67 | - https://www.scala-sbt.org/sbt-pgp/usage.html 68 | 69 | ## How to publish 70 | 71 | 1. Build and test locally using `sbt "compile;test;doc"` 72 | 73 | 2. Bump `lazy val Version` in `build.sbt`, run `package` in sbt. Note no plus before package as from 1.2.0 we only publish for Scala 3. We also want a release on github and the course home page aligned with the release on Sonatype Central. Therefore You should also: 74 | - Don't forget to update the `doc/index.md` file with current version information and package contents etc. Read more on scaladoc here: https://docs.scala-lang.org/scala3/scaladoc.html 75 | - commit all changes and push and *then* create a github release with the packaged jar uploaded to https://github.com/lunduniversity/introprog-scalalib/releases 76 | - Publish the jar to the course home page at http://cs.lth.se/lib using `sh publish-jar.sh` 77 | - Publish updated docs to the course home page at http://cs.lth.se/api using script `sh publish-doc.sh` 78 | - Copy the introprog-scalalib/src the workspace subdir at https://github.com/lunduniversity/introprog to enable eclipse project generation with internal dependency of projects using `sh publish-workspace.sh`. Then run `sbt eclipse` IN THAT repo and `sh package.sh` to create `workspace.zip` etc. TODO: For the future it would be **nice** to have another repo introprog-workspace and factor out code to that repo and solve the problem of dependency between latex code and the workspace. 79 | - Update the link http://www.cs.lth.se/pgk/lib in typo3 so that it links to the right http://fileadmin.cs.lth.se/pgk/introprog_3-x.y.z.jar 80 | 81 | 3. In build.sbt set the key `ThisBuild / versionPolicyIntention := ` to one of `Compatibility.None`, `Compatibility.BinaryAndSourceCompatible` or `Compatibility.BinaryCompatible` depending on what is intended. Then run these checks in the sbt shell: 82 | ``` 83 | sbt> versionCheck 84 | sbt> versionPolicyCheck 85 | ``` 86 | More information here: 87 | * https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html 88 | * https://www.youtube.com/watch?v=0T3vBnYCXn4 89 | * https://www.scala-sbt.org/1.x/docs/Publishing.html#Version+scheme 90 | * https://eed3si9n.com/enforcing-semver-with-sbt-strict-update 91 | 92 | 93 | 4. In `sbt>` run `publishSigned` - a plus sign is not used since we only publish for Scala 3 from 1.2.0. 94 | 95 | Note: It is falsely said to be `sbt publish` according to https://www.scala-sbt.org/1.x/docs/Publishing.html but you need to use `sbt publishSigned` 96 | after creating a .credentials file in ~/.sbt including below where xxx and yyy is replaced with secret values that is access according to https://central.sonatype.org/publish/generate-token/ If you do just `publish` you will get an error later in the process after closing below that complains that .asc files are missing etc. 97 | 98 | Put .credentials in ~/.sbt 99 | ``` 100 | realm=Sonatype Nexus Repository Manager 101 | host=oss.sonatype.org 102 | user=xxx 103 | password=yyy 104 | ``` 105 | 106 | When I did publishSIgend last time I got these errors but the publishing went through anyway with the above .credentials in ~/.sbt: 107 | ``` 108 | sbt:introprog> publishSigned 109 | [info] Wrote /home/bjornr/git/hub/lunduniversity/introprog-scalalib/target/scala-3.3.3/introprog_3-1.4.0.pom 110 | [warn] multiple main classes detected: run 'show discoveredMainClasses' to see the list 111 | [error] gpg: Warning: not using 'E7232FE8B8357EEC786315FE821738D92B63C95F' as default key: No secret key 112 | [error] gpg: all values passed to '--default-key' ignored 113 | [error] gpg: Warning: not using 'E7232FE8B8357EEC786315FE821738D92B63C95F' as default key: No secret key 114 | [error] gpg: all values passed to '--default-key' ignored 115 | [error] gpg: Warning: not using 'E7232FE8B8357EEC786315FE821738D92B63C95F' as default key: No secret key 116 | [error] gpg: all values passed to '--default-key' ignored 117 | [error] gpg: Warning: not using 'E7232FE8B8357EEC786315FE821738D92B63C95F' as default key: No secret key 118 | [error] gpg: all values passed to '--default-key' ignored 119 | [info] published introprog_3 to https://oss.sonatype.org/service/local/staging/deploy/maven2/se/lth/cs/introprog_3/1.4.0/introprog_3-1.4.0.pom.asc 120 | [info] published introprog_3 to https://oss.sonatype.org/service/local/staging/deploy/maven2/se/lth/cs/introprog_3/1.4.0/introprog_3-1.4.0-javadoc.jar 121 | [info] published introprog_3 to https://oss.sonatype.org/service/local/staging/deploy/maven2/se/lth/cs/introprog_3/1.4.0/introprog_3-1.4.0.pom 122 | [info] published introprog_3 to https://oss.sonatype.org/service/local/staging/deploy/maven2/se/lth/cs/introprog_3/1.4.0/introprog_3-1.4.0.jar.asc 123 | [info] published introprog_3 to https://oss.sonatype.org/service/local/staging/deploy/maven2/se/lth/cs/introprog_3/1.4.0/introprog_3-1.4.0.jar 124 | [info] published introprog_3 to https://oss.sonatype.org/service/local/staging/deploy/maven2/se/lth/cs/introprog_3/1.4.0/introprog_3-1.4.0-javadoc.jar.asc 125 | [info] published introprog_3 to https://oss.sonatype.org/service/local/staging/deploy/maven2/se/lth/cs/introprog_3/1.4.0/introprog_3-1.4.0-sources.jar 126 | [info] published introprog_3 to https://oss.sonatype.org/service/local/staging/deploy/maven2/se/lth/cs/introprog_3/1.4.0/introprog_3-1.4.0-sources.jar.asc 127 | ``` 128 | 129 | OOOPS! TODO: I already had this file: `cat ~/.sbt/sonatype_credential` pulled in by `cat ~/.sbt/1.0/sonatype.sbt` so I should remove the last of them as Credentials is now included in the build.sbt as in `credentials += Credentials(Path.userHome / ".sbt" / ".credentials")` 130 | 131 | 5. After you have done `sbt publishSigned` then log into Sonatype Nexus here: (if the page does not load, clear the browser's cache by pressing Ctrl+F5) https://oss.sonatype.org/#welcome 132 | 133 | 6. Click on *Staging Repositories* in the Build Promotion list to the left. Click "Refresh" if list is empty. https://oss.sonatype.org/#stagingRepositories 134 | 135 | 7. Scroll down and select something similar to `selthcs-100X` and select the *Contents* tab and expand until leaf level of the tree where you can see the `introprog_3-x.y.z.jar` 136 | 137 | 8. Download the staged jar by clicking on it and selecting the *Artifact* tab to the right and click the Repository Path to download. Save it e.g. in `tmp`. 138 | 139 | 9. Verify that the staged jar downloaded from sonatype works by running something similar to `scala-cli repl . -S 3.4.2 --jar introprog_3-1.4.0.jar` and in REPL e.g. `val w = new introprog.PixelWindow` or `introprog.examples.TestPixelWindow.main(Array())`. The reason for this step is that there has been incidents where the uploading has failed and the jar was empty. A published jar can not be retracted even if corrupted according to Sonatype policies. 140 | 141 | 10. Click the *Close* icon with a diskette above the repository list to "close" the staging repository. No need to write anything in the "Description" field in the popup. It has happened that the Close failed - then the repo is still "Open" so try to close it again and hope it works this time... 142 | 143 | 11. Click the green arrow "Refresh" icon. Mark the Repository in the list by clicking the check-mark square to the left of th repo name similar to "selthcs-1015". After a while (typically a couple of minutes) the *Release* icon with a chain above the repository list is enabled. If it is not enabled the wait some minutes and click "Refresh" again. Click "Release" when enabled. In the dialog that appears you can keep the "Automatically Drop" checkbox checked, which means that when the repo is published on Central the staging repo is removed from the list. 144 | 145 | 12. By searching here you can see the repo in progress of being published but it takes a while before it is publicly visible on Central (typically 10-15 minutes). https://oss.sonatype.org/#nexus-search;quick~se.lth.cs 146 | 147 | 13. When visible on Central at https://repo1.maven.org/maven2/se/lth/cs/introprog_3/ verify with a simple sbt project that it works as shown in [README usage instructions for sbt](https://github.com/lunduniversity/introprog-scalalib/blob/master/README.md#using-sbt). 148 | 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # introprog-scalalib 2 | 3 | ![Build Status](https://github.com/lunduniversity/introprog-scalalib/actions/workflows/main.yml/badge.svg) 4 | 5 | [](http://search.maven.org/#search%7Cga%7C1%7Cg%3Ase.lth.cs%20a%3Aintroprog_3) [](http://search.maven.org/#search%7Cga%7C1%7Cg%3Ase.lth.cs%20a%3Aintroprog_2.13) [](http://search.maven.org/#search%7Cga%7C1%7Cg%3Ase.lth.cs%20a%3Aintroprog_2.12) 6 | 7 | This is a library with Scala utilities for Computer Science teaching. The library is maintained by Björn Regnell at Lund University, Sweden. Contributions are welcome! 8 | 9 | * The api **documentation** is available here: http://cs.lth.se/pgk/api/ 10 | 11 | * You can find code **examples** here: [src/main/scala/introprog/examples](https://github.com/lunduniversity/introprog-scalalib/tree/master/src/main/scala/introprog/examples) 12 | 13 | This repo is used in this course *(in Swedish)*: http://cs.lth.se/pgk with course material published as free open source here: https://github.com/lunduniversity/introprog 14 | 15 | 16 | ## How to use introprog-scalalib 17 | 18 | ### Getting started using scala from the command line 19 | 20 | You need to have [Scala installed](https://www.scala-lang.org/download/) using version 3.5.2 or later. 21 | 22 | You can start the Scala REPL in the current directory with `introprog` directly available to play with using this command in a terminal window: 23 | ``` 24 | scala repl . --dep se.lth.cs::introprog:1.4.0 25 | ``` 26 | 27 | You can then open a drawing window like so: 28 | ```scala 29 | scala> val w = introprog.PixelWindow() 30 | val w: introprog.PixelWindow = introprog.PixelWindow@34f60be9 31 | 32 | scala> w.drawText("Hello introprog.PixelWindow!", x = 100, y = 100) 33 | ``` 34 | 35 | If you want to use `introprog` in your program, add these magic comment lines starting with `//>` in the beginning of your Scala 3 file (update the version number after `//> using scala` to the [latest release](https://www.scala-lang.org/)): 36 | 37 | ``` 38 | //> using scala 3.5.2 39 | //> using dep se.lth.cs::introprog:1.3.1 40 | ``` 41 | 42 | You can then run your code with `scala run .` (note the ending dot, meaning "current dir") 43 | 44 | If your program looks like this: 45 | 46 | ``` 47 | //> using scala 3.5.2 48 | //> using dep se.lth.cs::introprog:1.4.0 49 | 50 | @main def run = 51 | val w = introprog.PixelWindow() 52 | w.drawText("Hello introprog.PixelWindow!", x = 100, y = 100) 53 | ``` 54 | You should see green text in a new window after executing: 55 | ``` 56 | scala-cli run . 57 | ``` 58 | See: [api documentation for PixelWindow](https://fileadmin.cs.lth.se/pgk/api/api/introprog/PixelWindow.html) for more things you can do with a PixelWindow. 59 | 60 | You can also give the `introprog` dependency directly at the command line, instead of the `using dep` directive: 61 | ``` 62 | scala-cli run . --dep se.lth.cs::introprog:1.4.0 63 | ``` 64 | 65 | ### Getting started using sbt 66 | 67 | If you use the [Scala Build Tool, version 1.6 or later](https://www.scala-sbt.org/download.html) then put this text in a file called `build.sbt` 68 | ``` 69 | scalaVersion := "3.5.2" 70 | libraryDependencies += "se.lth.cs" %% "introprog" % "1.4.0" 71 | ``` 72 | 73 | When you run `sbt` in terminal the `introprog` package is automatically downloaded and made available on your classpath. 74 | You can do things like: 75 | ``` 76 | > sbt 77 | sbt> console 78 | scala> val w = new introprog.PixelWindow() 79 | scala> w.fill(100,100,100,100,java.awt.Color.red) 80 | ``` 81 | See: [api documentation for PixelWindow](https://fileadmin.cs.lth.se/pgk/api/api/introprog/PixelWindow.html) 82 | 83 | ### Older Scala versions 84 | 85 | If you want to use Scala 2.13 with 2.13.5 or later then use these special settings in `build.sbt`, esp. note that you should use version 1.1.5 of introprog: 86 | ``` 87 | scalaVersion := "2.13.8" //2.13.5 or any later 2.13 version 88 | scalacOptions += "-Ytasty-reader" 89 | libraryDependencies += 90 | ("se.lth.cs" %% "introprog" % "1.1.5").cross(CrossVersion.for2_13Use3) 91 | ``` 92 | 93 | For Scala 2.12.x and 2.13.4 and older you need to use version 1.1.4 of introprog or older. 94 | 95 | 96 | ### Manual download 97 | 98 | Download the latest jar-file from here: 99 | * Github releases: https://github.com/lunduniversity/introprog-scalalib/releases 100 | * Scaladex: https://index.scala-lang.org/lunduniversity/introprog-scalalib 101 | * Search Maven central: https://search.maven.org/search?q=introprog 102 | * Maven central server: https://repo1.maven.org/maven2/se/lth/cs/ 103 | 104 | Put the latest introprog jar-file in your sbt project in a subfolder called `lib`. In your `build.sbt` you only need `scalaVersion := "3.0.1"` without a library dependency to introprog, as `sbt` automatically put jars in lib on your classpath. 105 | 106 | ## How to build introprog-scalalib 107 | 108 | With [`sbt`](https://www.scala-sbt.org/download.html) and [`git`](https://git-scm.com/downloads) on your path type in terminal: 109 | ``` 110 | > git clone git@github.com:lunduniversity/introprog-scalalib.git 111 | > cd introprog-scalalib 112 | > sbt package 113 | ``` 114 | 115 | ## How to build and see the doc pages using a local server 116 | 117 | Run this in linux bash terminal: 118 | ``` 119 | sbt doc && cd target/scala-3.3.3/api && python3 -m http.server 8080 120 | ``` 121 | Open Firefox and type this url in the address field: 122 | ``` 123 | http://localhost:8080/ 124 | ``` 125 | 126 | ## Intentions and philosophy behind introprog-scalalib 127 | 128 | This repo includes utilities to empower learners to advance from basic to intermediate levels of computer science by providing easy-to-use constructs for creating simple desktop apps in terminal and using simple 2D graphics. The utilities are implemented and exposed through an api that follows these guidelines: 129 | 130 | * Use as simple constructs as possible. 131 | * Follow Scala idioms with a pragmatic mix of imperative, functional and object-oriented programming. 132 | * Don't use advanced functional programming concepts and magical implicits. 133 | * Prefer a clean api with single-responsibility functions in simple modules. 134 | * Prefer immutability over mutable state, `Vector` for sequences and case classes for data. 135 | * Hide/avoid threading and complicated concurrency. 136 | * Inspiration: 137 | - Talk by Martin Odersky: [Scala the Simple Parts](https://www.youtube.com/watch?v=ecekSCX3B4Q) with slides [here](https://www.slideshare.net/Odersky/scala-the-simple-parts) 138 | - [Principle of least power](http://www.lihaoyi.com/post/StrategicScalaStylePrincipleofLeastPower.html) blog post by Li Haoyi 139 | 140 | Areas currently in scope of this library: 141 | 142 | * Simple pixel-based 2D graphics for single-threaded game programming with explicit game loop. 143 | * Simple blocking IO that hides the underlying complication of releasing resources etc. 144 | * Simple modal GUI dialogs that block while waiting for user response. 145 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | lazy val Version = "1.4.0" 2 | lazy val Name = "introprog" 3 | lazy val scala3 = "3.3.3" 4 | 5 | Global / onChangedBuildSource := ReloadOnSourceChanges 6 | 7 | // to avoid strange warnings, these lines with excludeLintKeys are needed: 8 | Global / excludeLintKeys += ThisBuild / Compile / console / fork 9 | 10 | lazy val introprog = (project in file(".")) 11 | .settings( 12 | name := Name, 13 | version := Version, 14 | scalaVersion := scala3, 15 | libraryDependencies += "org.scalameta" %% "munit" % "0.7.29" % Test, 16 | ) 17 | 18 | ThisBuild / Compile / console / fork := true 19 | 20 | //https://github.com/scalacenter/sbt-version-policy 21 | ThisBuild / versionScheme := Some("early-semver") 22 | ThisBuild / versionPolicyIntention := Compatibility.None 23 | //ThisBuild / versionPolicyIntention := Compatibility.None 24 | //ThisBuild / versionPolicyIntention := Compatibility.BinaryAndSourceCompatible 25 | //ThisBuild / versionPolicyIntention := Compatibility.BinaryCompatible 26 | //In the sbt shell check version using: 27 | //sbt> versionCheck 28 | //sbt> versionPolicyCheck 29 | //sbt> last versionPolicyFindDependencyIssues 30 | //sbt> last mimaPreviousClassfiles 31 | 32 | ThisBuild / scalacOptions ++= Seq( 33 | "-encoding", "UTF-8", 34 | "-unchecked", 35 | "-deprecation", 36 | // "-Xfuture", 37 | // "-Yno-adapted-args", 38 | // "-Ywarn-dead-code", 39 | // "-Ywarn-numeric-widen", 40 | // "-Ywarn-value-discard", 41 | // "-Ywarn-unused" 42 | ) 43 | 44 | ThisBuild / Compile / compile / javacOptions ++= Seq("-target", "1.8") // for backward compat 45 | 46 | Compile / doc / scalacOptions ++= Seq( 47 | "-groups", 48 | "-project-version", Version, 49 | "-project-footer", "Dep. of Computer Science, Lund University, Faculty of Engineering LTH", 50 | "-siteroot", ".", 51 | "-doc-root-content", "./docs/index.md", 52 | "-source-links:github://lunduniversity/introprog-scalalib/master", 53 | "-social-links:github::https://github.com/lunduniversity/introprog-scalalib" 54 | ) 55 | 56 | // Below enables publishing to central.sonatype.org 57 | // see PUBLISH.md for instructions 58 | // usage inside sbt: BUT READ PUBLISH.md FIRST - the plus is needed for cross building all versions 59 | // sbt> + publishSigned 60 | // DON'T PANIC: it takes looong time to run it 61 | 62 | ThisBuild / organization := "se.lth.cs" 63 | ThisBuild / organizationName := "LTH" 64 | ThisBuild / organizationHomepage := Some(url("http://cs.lth.se/")) 65 | 66 | ThisBuild / scmInfo := Some( 67 | ScmInfo( 68 | url("https://github.com/lunduniversity/introprog-scalalib"), 69 | "scm:git@github.com:lunduniversity/introprog-scalalib.git" 70 | ) 71 | ) 72 | ThisBuild / developers := List( 73 | Developer( 74 | id = "bjornregnell", 75 | name = "Bjorn Regnell", 76 | email = "bjorn.regnell@cs.lth.se", 77 | url = url("http://cs.lth.se/bjornregnell") 78 | ) 79 | ) 80 | 81 | ThisBuild / description := "Scala utilities for introductory Computer Science teaching." 82 | ThisBuild / licenses := List("BSD 2-Clause" -> new URL("https://opensource.org/licenses/BSD-2-Clause")) 83 | ThisBuild / homepage := Some(url("https://github.com/lunduniversity/introprog-scalalib")) 84 | 85 | // Remove all additional repository other than Maven Central from POM 86 | ThisBuild / pomIncludeRepository := { _ => false } 87 | ThisBuild / publishTo := { 88 | val nexus = "https://oss.sonatype.org/" 89 | if (isSnapshot.value) Some("snapshots" at nexus + "content/repositories/snapshots") 90 | else Some("releases" at nexus + "service/local/staging/deploy/maven2") 91 | } 92 | ThisBuild / publishMavenStyle := true 93 | 94 | publishConfiguration := publishConfiguration.value.withOverwrite(true) 95 | publishLocalConfiguration := publishLocalConfiguration.value.withOverwrite(true) 96 | //pushRemoteCacheConfiguration := pushRemoteCacheConfiguration.value.withOverwrite(true) 97 | 98 | credentials += Credentials(Path.userHome / ".sbt" / ".credentials") 99 | 100 | //https://oss.sonatype.org/#stagingRepositories 101 | //https://oss.sonatype.org/#nexus-search;quick~se.lth.cs 102 | //https://repo1.maven.org/maven2/se/lth/cs/introprog_2.12/ 103 | 104 | 105 | //https://github.com/sbt/sbt-pgp 106 | 107 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | This is the documentation of the `introprog` Scala Library with beginner-friendly utilities used in computer science teaching at Lund University. 5 | The open source code is hosted at [[https://github.com/lunduniversity/introprog-scalalib]]. 6 | 7 | ## Package contents 8 | 9 | - [[introprog.PixelWindow]] for simple, pixel-based drawing. 10 | 11 | - [[introprog.PixelWindow.Event]] for event management in a PixelWindow. 12 | 13 | - [[introprog.IO]] for file system interaction. 14 | 15 | - [[introprog.Dialog]] for user interaction with standard GUI dialogs. 16 | 17 | - [[introprog.BlockGame]] an abstract class to be inherited by games using block graphics. 18 | 19 | - [[introprog.examples]] with code examples demonstrating how to use this library. 20 | 21 | ## How to use introprog-scalalib 22 | 23 | ### Using scala-cli 24 | 25 | You need [Scala Command Line Interface](https://scala-cli.virtuslab.org/install) 26 | 27 | Add these magic comment lines starting with `//>` in the beginning of your Scala 3 file: 28 | 29 | ``` 30 | //> using scala 3 31 | //> using dep "se.lth.cs::introprog:1.4.0" 32 | ``` 33 | You can choose the latest stable Scala version, or any version from at least Scala 3.3.3. 34 | 35 | You run your code with `scala-cli run .` (note the ending dot, meaning "this dir") 36 | 37 | If your program looks like this: 38 | 39 | ``` 40 | //> using scala 3 41 | //> using dep "se.lth.cs::introprog:1.4.0" 42 | 43 | @main def MyMain = 44 | val w = introprog.PixelWindow() 45 | w.drawText("Hello introprog.PixelWindow!", x = 100, y = 100) 46 | ``` 47 | You should see green text in a new window after executing: 48 | ``` 49 | scala-cli run . 50 | ``` 51 | See: [api documentation for PixelWindow](https://fileadmin.cs.lth.se/pgk/api/api/introprog/PixelWindow.html) 52 | 53 | ### Using sbt 54 | 55 | If you have [sbt](https://www.scala-sbt.org/) installed at least version 1.10.0 then you can put this text in a file called `build.sbt` 56 | 57 | ``` 58 | scalaVersion := "3.4.2" // or any Scala version from at least 3.3.3 59 | libraryDependencies += "se.lth.cs" %% "introprog" % "1.4.0" 60 | ``` 61 | 62 | When you run `sbt` in a terminal, with the above in your `build.sbt`, the introprog lib is automatically downloaded and made available on your classpath. Then you can do things like: 63 | 64 | ``` 65 | sbt> console 66 | scala> val w = new introprog.PixelWindow() 67 | scala> w.fill(100,100,100,100,java.awt.Color.red) 68 | ``` 69 | 70 | ## Manual download 71 | 72 | You can also manually download the latest jar file from here: 73 | 74 | * Lund University: [http://www.cs.lth.se/pgk/lib](http://www.cs.lth.se/pgk/lib) 75 | 76 | * GitHub: [https://github.com/lunduniversity/introprog-scalalib/releases](https://github.com/lunduniversity/introprog-scalalib/releases) 77 | 78 | * ScalaDex: [https://index.scala-lang.org/lunduniversity/introprog-scalalib/introprog](https://index.scala-lang.org/lunduniversity/introprog-scalalib/introprog) 79 | 80 | * Maven Central: [https://repo1.maven.org/maven2/se/lth/cs/introprog_3/](https://repo1.maven.org/maven2/se/lth/cs/introprog_3/) -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.7 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // https://github.com/scalacenter/sbt-version-policy 2 | addSbtPlugin("ch.epfl.scala" % "sbt-version-policy" % "3.2.1") -------------------------------------------------------------------------------- /publish-doc.sh: -------------------------------------------------------------------------------- 1 | echo "*** Generating docs and copy api to fileadmin then zip it for local download" 2 | set -x 3 | 4 | SCALAVERSION=3.3.3 5 | sbt doc 6 | 7 | ssh $LUCATID@fileadmin.cs.lth.se rm -r pgk/api 8 | 9 | scp -r target/scala-$SCALAVERSION/api $LUCATID@fileadmin.cs.lth.se:/Websites/Fileadmin/pgk/ 10 | 11 | cd target/scala-$SCALAVERSION/ 12 | zip -rv api.zip api 13 | scp api.zip $LUCATID@fileadmin.cs.lth.se:/Websites/Fileadmin/pgk/ 14 | cd ../.. -------------------------------------------------------------------------------- /publish-jar.sh: -------------------------------------------------------------------------------- 1 | #VERSION="$(grep -m 1 -Po -e '\d+.\d+.\d+' build.sbt)" 2 | VERSION=1.4.0 3 | SCALAVERSION=3.3.3 4 | SCALACOMPAT=3 5 | 6 | JARFILE="introprog_$SCALACOMPAT-$VERSION.jar" 7 | DEST="$LUCATID@fileadmin.cs.lth.se:/Websites/Fileadmin/pgk/" 8 | 9 | sbt package 10 | echo Copying $JARFILE to $DEST 11 | scp "target/scala-$SCALAVERSION/$JARFILE" $DEST 12 | -------------------------------------------------------------------------------- /publish-workspace.sh: -------------------------------------------------------------------------------- 1 | # this assumes that you have cloned the introprog repo 2 | # next to the introprog-scalalib repo 3 | cp -R src ../introprog/workspace/introprog/. 4 | -------------------------------------------------------------------------------- /src/main/scala/introprog/BlockGame.scala: -------------------------------------------------------------------------------- 1 | package introprog 2 | 3 | import java.awt.Color 4 | 5 | /** A class for creating games with block-based graphics. 6 | * See example usage in [introprog.examples.TestBlockGame](https://github.com/lunduniversity/introprog-scalalib/blob/master/src/main/scala/introprog/examples/TestBlockGame.scala#L7) 7 | * 8 | * @constructor Create a new game. 9 | * @param title the title of the window 10 | * @param dim the (width, height) of the window in number of blocks 11 | * @param blockSize the side of each square block in pixels 12 | * @param background the color used when clearing pixels 13 | * @param framesPerSecond the desired update rate in the gameLoop 14 | * @param messageAreaHeight the height in pixels of the message area 15 | * @param messageAreaBackground the color of the message area background 16 | */ 17 | abstract class BlockGame( 18 | val title: String = "BlockGame", 19 | val dim: (Int, Int) = (50, 50), 20 | val blockSize: Int = 15, 21 | val background: Color = Color.black, 22 | var framesPerSecond: Int = 50, 23 | val messageAreaHeight: Int = 2, 24 | val messageAreaBackground: Color = Color.gray.darker.darker 25 | ): 26 | import introprog.PixelWindow 27 | 28 | /** Called when a key is pressed. Override if you want non-empty action. 29 | * @param key is a string representation of the pressed key 30 | */ 31 | def onKeyDown(key: String): Unit = () 32 | 33 | /** Called when a key is released. Override if you want non-empty action. 34 | * @param key is a string representation of the released key 35 | */ 36 | def onKeyUp(key: String): Unit = () 37 | 38 | /** Called when mouse is pressed. Override if you want non-empty action. 39 | * @param pos the mouse position in underlying `pixelWindow` coordinates 40 | */ 41 | def onMouseDown(pos: (Int, Int)): Unit = () 42 | 43 | /** Called when mouse is released. Override if you want non-empty action. 44 | * @param pos the mouse position in underlying `pixelWindow` coordinates 45 | */ 46 | def onMouseUp(pos: (Int, Int)): Unit = () 47 | 48 | /** Called when window is closed. Override if you want non-empty action. */ 49 | def onClose(): Unit = () 50 | 51 | /** Called in each `gameLoop` iteration. Override if you want non-empty action. */ 52 | def gameLoopAction(): Unit = () 53 | 54 | /** Called if no time is left in iteration to keep frame rate. 55 | * Default action is to print a warning message. 56 | */ 57 | def onFrameTimeOverrun(elapsedMillis: Long): Unit = 58 | println(s"Warning: Unable to handle $framesPerSecond fps. Loop time: $elapsedMillis ms") 59 | 60 | /** Returns the gameLoop delay in ms implied by `framesPerSecond`.*/ 61 | def gameLoopDelayMillis: Int = (1000.0 / framesPerSecond).round.toInt 62 | 63 | /** The underlying window used for drawing blocks and messages. */ 64 | protected val pixelWindow: PixelWindow = 65 | new PixelWindow( 66 | width = dim._1 * blockSize, 67 | height = (dim._2 + messageAreaHeight) * blockSize + blockSize / 2, 68 | title, 69 | background 70 | ) 71 | 72 | /** Internal buffer with block colors. */ 73 | private val blockBuffer: Array[Array[Color]] = Array.fill(dim._1, dim._2)(background) 74 | 75 | /** Internal buffer with update flags. */ 76 | private val isBufferUpdated: Array[Array[Boolean]] = Array.fill(dim._1, dim._2)(false) 77 | 78 | /** Internal buffer for post-update actions. */ 79 | private val toDoAfterBlockUpdates = collection.mutable.Buffer.empty[() => Unit] 80 | 81 | /** Max time for awaiting events from underlying window in ms. */ 82 | protected val MaxWaitForEventMillis = 1 83 | 84 | clearWindow() // erase all blocks 85 | 86 | /** The game loop that continues while not `stopWhen` is true. 87 | * It draws only updated blocks aiming at the desired frame rate. 88 | * It calls each `onXXX` method if a corresponding event is detected. 89 | * Use the call-by-name `stopWhen` to pass a condition that ends the loop if false. 90 | * See example usage in `introprog.examples.TestBlockGame`. 91 | */ 92 | protected def gameLoop(stopWhen: => Boolean): Unit = while !stopWhen do 93 | import PixelWindow.Event 94 | val t0 = System.currentTimeMillis 95 | pixelWindow.awaitEvent(MaxWaitForEventMillis.toLong) 96 | while pixelWindow.lastEventType != PixelWindow.Event.Undefined do 97 | pixelWindow.lastEventType match 98 | case Event.KeyPressed => onKeyDown(pixelWindow.lastKey) 99 | case Event.KeyReleased => onKeyUp(pixelWindow.lastKey) 100 | case Event.WindowClosed => onClose() 101 | case Event.MousePressed => onMouseDown(pixelWindow.lastMousePos) 102 | case Event.MouseReleased => onMouseUp(pixelWindow.lastMousePos) 103 | case _ => 104 | pixelWindow.awaitEvent(1) 105 | gameLoopAction() 106 | drawUpdatedBlocks() 107 | val elapsed = System.currentTimeMillis - t0 108 | if (gameLoopDelayMillis - elapsed) < MaxWaitForEventMillis then 109 | onFrameTimeOverrun(elapsed) 110 | Thread.sleep((gameLoopDelayMillis - elapsed) max 0) 111 | 112 | /** Draw updated blocks and carry out post-update actions if any. */ 113 | private def drawUpdatedBlocks(): Unit = 114 | for x <- blockBuffer.indices do 115 | for y <- blockBuffer(x).indices do 116 | if isBufferUpdated(x)(y) then 117 | val pwx = x * blockSize 118 | val pwy = y * blockSize 119 | pixelWindow.fill(pwx, pwy, blockSize, blockSize, blockBuffer(x)(y)) 120 | isBufferUpdated(x)(y) = false 121 | toDoAfterBlockUpdates.foreach(_.apply()) 122 | toDoAfterBlockUpdates.clear() 123 | 124 | /** Erase all blocks to background color. */ 125 | def clearWindow(): Unit = 126 | pixelWindow.clear() 127 | clearMessageArea() 128 | for x <- blockBuffer.indices do 129 | for y <- blockBuffer(x).indices do 130 | blockBuffer(x)(y) = background 131 | 132 | /** Paint a block in color `c` at (`x`,`y`) in block coordinates. */ 133 | def drawBlock(x: Int, y: Int, c: Color): Unit = 134 | if blockBuffer(x)(y) != c then 135 | blockBuffer(x)(y) = c 136 | isBufferUpdated(x)(y) = true 137 | 138 | /** Erase the block at (`x`,`y`) to `background` color. */ 139 | def eraseBlock(x: Int, y: Int): Unit = 140 | if blockBuffer(x)(y) != background then 141 | blockBuffer(x)(y) = background 142 | isBufferUpdated(x)(y) = true 143 | 144 | /** Write `msg` in `color` in the middle of the window. 145 | * The drawing is postponed until the end of the current game loop 146 | * iteration and thus the text drawn on top of any updated blocks. 147 | */ 148 | def drawCenteredText(msg: String, color: Color = pixelWindow.foreground, size: Int = blockSize): Unit = 149 | toDoAfterBlockUpdates.append( () => 150 | pixelWindow.drawText( 151 | msg, 152 | (pixelWindow.width / 2 - msg.length * size / 3) max size, 153 | pixelWindow.height / 2 - size, color, 154 | size 155 | ) 156 | ) 157 | 158 | /** Write `msg` in `color` in the message area at ('x','y') in block coordinates. */ 159 | def drawTextInMessageArea(msg: String, x: Int, y: Int, color: Color = pixelWindow.foreground, size: Int = blockSize): Unit = 160 | require(y < messageAreaHeight && y >= 0, s"not in message area: y = $y") 161 | require(x < dim._1 * blockSize && x >= 0, s"not in message area: x = $x") 162 | pixelWindow.drawText(msg, x * blockSize, (y + dim._2) * blockSize, color, size) 163 | 164 | /** Clear a rectangle in the message area in block coordinates. */ 165 | def clearMessageArea(x: Int = 0, y: Int = 0, width: Int = dim._1, height: Int = messageAreaHeight): Unit = 166 | require(y < messageAreaHeight && y >= 0, s"not in message area: y = $y") 167 | require(x < dim._1 * blockSize && x >= 0, s"not in message area: x = $x") 168 | pixelWindow.fill( 169 | x * blockSize, (y + dim._2) * blockSize, 170 | width * blockSize, messageAreaHeight * blockSize + blockSize / 2, 171 | messageAreaBackground 172 | ) 173 | -------------------------------------------------------------------------------- /src/main/scala/introprog/Dialog.scala: -------------------------------------------------------------------------------- 1 | package introprog 2 | 3 | /** A module with utilities for creating standard GUI dialogs. */ 4 | object Dialog: 5 | import javax.swing.{JFileChooser, JOptionPane, JColorChooser} 6 | 7 | Swing.init() // get platform-specific look and feel 8 | 9 | /** 10 | * Show a file choice dialog starting in `startDir` with confirm `button` text. 11 | * 12 | * @param button the text displayed in this file choice dialog's confirm button 13 | * @param startDir the starting directory of this file choice dialog 14 | * @return the file path entered by user upon pressing confirm button, 15 | * an empty `String` if user pressed the file choice dialog's cancel button 16 | */ 17 | def file(button: String = "Open", startDir: String = "~"): String = 18 | val fs = new JFileChooser(new java.io.File(startDir)) 19 | fs.showDialog(null, button) match 20 | case JFileChooser.APPROVE_OPTION => Option(fs.getSelectedFile.toString).getOrElse("") 21 | case _ => "" 22 | 23 | /** Show a dialog with a `message` text. */ 24 | def show(message: String): Unit = JOptionPane.showMessageDialog(null, message) 25 | 26 | /** 27 | * Show a `message` asking for input with `init` value. Return user input. 28 | * 29 | * @param message prompt text displayed for user 30 | * @param init intitial value displayed in input dialog 31 | * @return user input, or an empty string on Cancel 32 | */ 33 | def input(message: String, init: String = ""): String = 34 | Option(JOptionPane.showInputDialog(message, init)).getOrElse("") 35 | 36 | /** Show a confirmation dialog with `question` and OK and Cancel buttons. */ 37 | def isOK(question: String = "Ok?", title: String = "Confirm"): Boolean = 38 | JOptionPane.showConfirmDialog( 39 | null, question, title, JOptionPane.OK_CANCEL_OPTION 40 | ) == JOptionPane.OK_OPTION 41 | 42 | /** Show a selection dialog with `buttons`. Return a string with the chosen button text. */ 43 | def select(message: String, buttons: Seq[String], title: String = "Select"): String = 44 | scala.util.Try{ 45 | val chosenIndex = 46 | JOptionPane.showOptionDialog(null, message, title, JOptionPane.DEFAULT_OPTION, 47 | JOptionPane.QUESTION_MESSAGE, null, buttons.reverse.toArray, null) 48 | buttons(buttons.length - 1 - chosenIndex) 49 | }.getOrElse("") 50 | 51 | /** Show a color selection dialog and return the color that the user selected. */ 52 | def selectColor( 53 | message: String = "Select a color", 54 | default: java.awt.Color = java.awt.Color.red 55 | ): java.awt.Color = 56 | Option(JColorChooser.showDialog(null, message, default)).getOrElse(default) 57 | -------------------------------------------------------------------------------- /src/main/scala/introprog/IO.scala: -------------------------------------------------------------------------------- 1 | package introprog 2 | 3 | import java.io.BufferedWriter 4 | import java.io.FileWriter 5 | import java.nio.charset.Charset 6 | 7 | /** A module with input/output operations from/to the underlying file system. */ 8 | object IO: 9 | /** 10 | * Load a string from a text file called `fileName` using encoding `enc`. 11 | * 12 | * @param fileName the path of the file. 13 | * @param enc the encoding of the file. 14 | * @return the content loaded from the file. 15 | * */ 16 | def loadString(fileName: String, enc: String = "UTF-8"): String = 17 | var result: String = "" 18 | val source = scala.io.Source.fromFile(fileName, enc) 19 | try result = source.mkString finally source.close() 20 | result 21 | 22 | /** 23 | * Load string lines from a text file called `fileName` using encoding `enc`. 24 | * 25 | * @param fileName the path of the file. 26 | * @param enc the encoding of the file. 27 | * */ 28 | def loadLines(fileName: String, enc: String = "UTF-8"): Vector[String] = 29 | var result = Vector.empty[String] 30 | val source = scala.io.Source.fromFile(fileName, enc) 31 | try result = source.getLines().toVector finally source.close() 32 | result 33 | 34 | /** 35 | * Save `text` to a text file called `fileName` using encoding `enc`. 36 | * 37 | * @param text the text to be written to the file. 38 | * @param fileName the path of the file. 39 | * @param enc the encoding of the file. 40 | * */ 41 | def saveString(text: String, fileName: String, enc: String = "UTF-8"): Unit = 42 | val f = new java.io.File(fileName) 43 | val pw = new java.io.PrintWriter(f, enc) 44 | try pw.write(text) finally pw.close() 45 | 46 | /** 47 | * Save `lines` to a text file called `fileName` using encoding `enc`. 48 | * 49 | * @param lines the lines to written to the file. 50 | * @param fileName the path of the file. 51 | * @param enc the encoding of the file. 52 | * */ 53 | def saveLines(lines: Seq[String], fileName: String, enc: String = "UTF-8"): Unit = 54 | if lines.nonEmpty then saveString(lines.mkString("", "\n", "\n"), fileName, enc) 55 | 56 | /** 57 | * Appends `string` to the text file `fileName` using encoding `enc`. 58 | * 59 | * @param text the text to be appended to the file. 60 | * @param fileName the path of the file. 61 | * @param enc the encoding of the file. 62 | * */ 63 | def appendString(text: String, fileName: String, enc: String = "UTF-8"): Unit = 64 | val f = new java.io.File(fileName); 65 | require(!f.isDirectory(), "The file you're trying to write to can't be a directory.") 66 | val w = 67 | if f.exists() then 68 | new BufferedWriter(new FileWriter(fileName, Charset.forName(enc), true)) 69 | else 70 | new java.io.PrintWriter(f, enc) 71 | try w.write(text) finally w.close() 72 | 73 | /** 74 | * Appends `lines` to the text file `fileName` using encoding `enc`. 75 | * 76 | * @param lines the lines to append to the file. 77 | * @param fileName the path of the file. 78 | * @param enc the encoding of the file. 79 | * */ 80 | def appendLines(lines: Seq[String], fileName: String, enc: String = "UTF-8"): Unit = 81 | if lines.nonEmpty then appendString(lines.mkString("","\n","\n"), fileName, enc) 82 | 83 | /** 84 | * Load a serialized object from a binary file called `fileName`. 85 | * 86 | * @param fileName the path of the file. 87 | * @return the serialized object. 88 | * */ 89 | def loadObject[T](fileName: String): T = 90 | val f = new java.io.File(fileName) 91 | val ois = new java.io.ObjectInputStream(new java.io.FileInputStream(f)) 92 | try ois.readObject.asInstanceOf[T] finally ois.close() 93 | 94 | /** 95 | * Serialize `obj` to a binary file called `fileName`. 96 | * 97 | * @param obj the object to be serialized. 98 | * @param fileName the path of the file. 99 | * */ 100 | def saveObject[T](obj: T, fileName: String): Unit = 101 | val f = new java.io.File(fileName) 102 | val oos = new java.io.ObjectOutputStream(new java.io.FileOutputStream(f)) 103 | try oos.writeObject(obj) finally oos.close() 104 | 105 | /** 106 | * Test if a file with name `fileName` exists. 107 | * 108 | * @param fileName the path of the file. 109 | * @return true if the file exists else false. 110 | * */ 111 | def isExisting(fileName: String): Boolean = new java.io.File(fileName).exists 112 | 113 | /** 114 | * Create a directory with name `dir` if it does not exist. 115 | * 116 | * @param dir the path of the directory to be created. 117 | * @return true if and only if the directory was created, 118 | * along with all necessary parent directories otherwise false. 119 | * */ 120 | def createDirIfNotExist(dir: String): Boolean = new java.io.File(dir).mkdirs() 121 | 122 | /** 123 | * Gets the path of the current user's home directory. 124 | * 125 | * @return the path of the current user's home directory. 126 | * */ 127 | def userDir(): String = System.getProperty("user.home") 128 | 129 | /** 130 | * Gets the path of the current working directory. 131 | * 132 | * @return the path of the current working directory. 133 | * */ 134 | def currentDir(): String = 135 | java.nio.file.Paths.get(".").toAbsolutePath.normalize.toString 136 | 137 | /** 138 | * Gets a sequence of file names in the directory `dir`. 139 | * 140 | * @param dir the path of the directory to be listed. 141 | * @return a sequence of file names in the directory `dir 142 | * */ 143 | def list(dir: String = "."): Vector[String] = 144 | Option(new java.io.File(dir).list).map(_.toVector).getOrElse(Vector()) 145 | 146 | /** 147 | * Change name of file `from`, DANGER: silently replaces existing `to`. 148 | * 149 | * @param from the path of the file to be moved. 150 | * @param to the path the file will be moved to. 151 | * */ 152 | def move(from: String, to: String): Unit = 153 | import java.nio.file.{Files, Paths, StandardCopyOption} 154 | Files.move(Paths.get(from), Paths.get(to), StandardCopyOption.REPLACE_EXISTING) 155 | 156 | /** 157 | * Deletes `fileName`. 158 | * 159 | * @param fileName the path the file that will be deleted. 160 | * */ 161 | def delete(fileName: String): Unit = 162 | import java.nio.file.{Files, Paths} 163 | Files.delete(Paths.get(fileName)) 164 | 165 | /** 166 | * Load image from file. 167 | * 168 | * @param fileName the path to the image that will be loaded. 169 | * */ 170 | def loadImage(fileName: String): Image = 171 | loadImage(java.io.File(fileName)) 172 | 173 | /** 174 | * Load image from file. 175 | * 176 | * @param file the file that will be loaded. 177 | * */ 178 | def loadImage(file: java.io.File): Image = 179 | Image(javax.imageio.ImageIO.read(file)) 180 | 181 | /** 182 | * Save `img` to file as `JPEG`. Does not restore color of transparent pixels. 183 | * 184 | * @param image the image to save. 185 | * @param fileName the path to save the image to, `path/file.jpg` or just `path/file` 186 | * @param compression the compression factor to use `(0.0-1.0)`. 187 | * */ 188 | def saveJPEG(img: Image, fileName: String, compression: Double) : Unit = 189 | require(compression <= 1.0 && compression >= 0.0, "compression must be within 0.0 and 1.0") 190 | import javax.imageio.{stream, ImageIO, IIOImage, ImageWriteParam} 191 | import javax.imageio.plugins.jpeg.JPEGImageWriteParam 192 | import java.awt.image.BufferedImage 193 | //set compression values 194 | val jpegParams = JPEGImageWriteParam(null); 195 | jpegParams.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); 196 | jpegParams.setCompressionQuality(compression.toFloat); 197 | //create writer 198 | val writer = ImageIO.getImageWritersByFormatName("jpg").next(); 199 | // specifies where the jpg image has to be written 200 | val path = if fileName.endsWith(".jpg") then fileName else s"$fileName.jpg" 201 | writer.setOutput(new stream.FileImageOutputStream( 202 | java.io.File(path))) 203 | // writes the file with given compression level 204 | // from JPEGImageWriteParam instance 205 | writer.write( 206 | null, 207 | IIOImage( 208 | (if img.hasAlpha then img.withoutAlpha else img).underlying, //remove alpha channel 209 | null, 210 | null) 211 | ,jpegParams) //add compression details 212 | 213 | 214 | /** 215 | * Save `img` to file as `JPEG` with a compression ratio of 0.75. 216 | * Restore color of transparent pixels. 217 | * @param img the image to save. 218 | * @param fileName the path to save the image to, `path/file.jpg` or just `path/file`. 219 | * */ 220 | def saveJPEG(img: Image, fileName: String) : Unit = 221 | import javax.imageio.ImageIO 222 | import java.io.File 223 | if !ImageIO.write(img.underlying, "jpg", File(if fileName.endsWith(".jpg") then fileName else s"$fileName.jpg")) then 224 | throw java.io.IOException("no appropriate writer is found") 225 | 226 | /** 227 | * Save `img` to file as `PNG`. 228 | * 229 | * @param img the image to save. 230 | * @param fileName the path to save the image to, `path/file.png` or just `path/file`. 231 | * */ 232 | def savePNG(img: Image, fileName: String) : Unit = 233 | import javax.imageio.ImageIO 234 | import java.io.File 235 | if !ImageIO.write(img.underlying, "png", File(if fileName.endsWith(".png") then fileName else s"$fileName.png")) then 236 | throw java.io.IOException("no appropriate writer is found") 237 | 238 | 239 | 240 | 241 | -------------------------------------------------------------------------------- /src/main/scala/introprog/Image.scala: -------------------------------------------------------------------------------- 1 | package introprog 2 | 3 | /** Companion object to create Image instances. */ 4 | object Image: 5 | import java.awt.image.BufferedImage 6 | /** Create new empty Image with specified dimensions `(width, height)`*/ 7 | def ofDim(width: Int, height: Int) = 8 | Image(BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)) 9 | 10 | /** Image represents pixel arrays backed by underlying java.awtimage.BufferedImage */ 11 | class Image (val underlying: java.awt.image.BufferedImage): 12 | import java.awt.Color 13 | import java.awt.image.BufferedImage 14 | 15 | /** Get color of pixel at `(x, y)`.*/ 16 | def apply(x: Int, y: Int): Color = Color(underlying.getRGB(x, y)) 17 | 18 | /** Set color of pixel at `(x, y)`.*/ 19 | def update(x: Int, y: Int, c: Color): Unit = underlying.setRGB(x, y, c.getRGB) 20 | 21 | /** Set color of pixels by passing `f(x, y)`*/ 22 | def update(f: (Int, Int) => Color): Unit = 23 | for x <- 0 until width; y <- 0 until height do 24 | update(x, y, f(x, y)) 25 | 26 | /** Set color of pixels by passing `f(x, y)` and return self. */ 27 | def updated(f: (Int, Int) => Color): Image = 28 | for x <- 0 until width; y <- 0 until height do 29 | update(x, y, f(x, y)) 30 | this 31 | 32 | /** Extract and return image pixels. */ 33 | def toMatrix: Array[Array[Color]] = 34 | val xs: Array[Array[Color]] = Array.ofDim(width, height) 35 | for x <- 0 until width; y <- 0 until height do 36 | xs(x)(y) = apply(x, y) 37 | xs 38 | 39 | /** Copy subsection of image defined by top left corner `(x, y)` and `(width, height)`.*/ 40 | def subsection(x: Int, y: Int, width: Int, height: Int): Image = 41 | val bi = BufferedImage(width, height, underlying.getType) 42 | bi.createGraphics().drawImage(underlying, 0, 0, width, height, x, y, x + width, y + height, null) 43 | Image(bi) 44 | 45 | /** Copy image and scale to `(width, height)`.*/ 46 | def scaled(width: Int, height: Int): Image = 47 | val bi = BufferedImage(width, height, underlying.getType) 48 | bi.createGraphics().drawImage(underlying, 0, 0, width, height, null) 49 | Image(bi) 50 | 51 | /** Copy image and change image type to ARGB, including alpha channel*/ 52 | def withAlpha: Image = toImageType(BufferedImage.TYPE_INT_ARGB) 53 | 54 | /** Copy image and change image type to RGB, removing alpha channel*/ 55 | def withoutAlpha: Image = toImageType(BufferedImage.TYPE_INT_RGB) 56 | 57 | /** Copy image and change image type ex. BufferedImage.TYPE_INT_RGB*/ 58 | private def toImageType(imageType: Int): Image = 59 | val bi = BufferedImage(width, height, imageType) 60 | bi.createGraphics().drawImage(underlying, 0, 0, width, height, null) 61 | Image(bi) 62 | 63 | /** Test if alpha channel is supperted. */ 64 | val hasAlpha = underlying.getColorModel.hasAlpha 65 | 66 | /** The height of this image. */ 67 | val height = underlying.getHeight 68 | 69 | /** The width of this image. */ 70 | val width = underlying.getWidth 71 | -------------------------------------------------------------------------------- /src/main/scala/introprog/PixelWindow.scala: -------------------------------------------------------------------------------- 1 | package introprog 2 | 3 | /** A module with utilities for event handling in `PixelWindow` instances. */ 4 | object PixelWindow: 5 | /** Immediately exit running application, close all windows, kills all threads. */ 6 | def exit(): Unit = System.exit(0) 7 | 8 | /** Idle waiting for `millis` milliseconds. */ 9 | def delay(millis: Long): Unit = Thread.sleep(millis) 10 | 11 | /** A map with string representations for special key codes. */ 12 | private val keyTextLookup: Map[Int, String] = 13 | import java.awt.event.KeyEvent._ 14 | Map( 15 | VK_META -> "Meta", 16 | VK_WINDOWS -> "Meta", 17 | VK_CONTROL -> "Ctrl", 18 | VK_ALT -> "Alt", 19 | VK_ALT_GRAPH -> "Alt Gr", 20 | VK_SHIFT -> "Shift", 21 | VK_CAPS_LOCK -> "Caps Lock", 22 | VK_ENTER -> "Enter", 23 | VK_DELETE -> "Delete", 24 | VK_BACK_SPACE -> "Backspace", 25 | VK_ESCAPE -> "Esc", 26 | VK_RIGHT -> "Right", 27 | VK_LEFT -> "Left", 28 | VK_UP -> "Up", 29 | VK_DOWN -> "Down", 30 | VK_PAGE_UP -> "Page up", 31 | VK_PAGE_DOWN -> "Page down", 32 | VK_HOME -> "Home", 33 | VK_END -> "End", 34 | VK_CLEAR -> "Clear", 35 | VK_TAB -> "Tab", 36 | VK_SPACE -> " ", 37 | ) 38 | 39 | /** An object with integers representing events that can happen in a PixelWindow. */ 40 | object Event: 41 | /** An integer representing a key down event. 42 | * 43 | * This value is returned by [[introprog.PixelWindow.lastEventType]] when 44 | * the last event was that a user pressed a key on the keyboard. 45 | * You can get a text describing the key by calling [[introprog.PixelWindow.lastKey]] 46 | */ 47 | val KeyPressed = 1 48 | 49 | /** An integer representing a key up event. 50 | * 51 | * This value is returned by [[introprog.PixelWindow.lastEventType]] when 52 | * the last event was that a user released a key on the keyboard. 53 | * You can get a text describing the key by calling [[introprog.PixelWindow.lastKey]] 54 | */ 55 | val KeyReleased = 2 56 | 57 | /** An integer representing a mouse button down event. 58 | * 59 | * This value is returned by [[introprog.PixelWindow.lastEventType]] when 60 | * the last event was that a user pressed a mouse button. 61 | * You can get the mouse position by calling [[introprog.PixelWindow.lastMousePos]] 62 | */ 63 | val MousePressed = 3 64 | 65 | /** An integer representing a mouse button up event. 66 | * 67 | * This value is returned by [[introprog.PixelWindow.lastEventType]] when 68 | * the last thing that happened was that a user released a mouse button. 69 | * You can get the mouse position by calling [[introprog.PixelWindow.lastMousePos]] 70 | */ 71 | val MouseReleased = 4 72 | 73 | /** An integer representing that a window close event has happened. 74 | * 75 | * This value is returned by [[introprog.PixelWindow.lastEventType]] when 76 | * the last event was that a user has closed a window. 77 | */ 78 | val WindowClosed = 5 79 | 80 | /** An integer representing that no event is available. 81 | * 82 | * This value is returned by [[introprog.PixelWindow.lastEventType]] when 83 | * [[introprog.PixelWindow.awaitEvent]] has waited until its timeout 84 | * or if [[introprog.PixelWindow.lastEventType]] was called before awaiting any event. 85 | */ 86 | val Undefined = 0 87 | 88 | /** Returns a descriptive text for each `event`. */ 89 | def show(event: Int): String = event match 90 | case KeyPressed => "KeyPressed" 91 | case KeyReleased => "KeyReleased" 92 | case MousePressed => "MousePressed" 93 | case MouseReleased => "MouseReleased" 94 | case WindowClosed => "WindowClosed" 95 | case Undefined => "Undefined" 96 | case _ => 97 | throw new IllegalArgumentException(s"Unknown event number: $event") 98 | 99 | /** A window with a canvas for pixel-based drawing. Y-coordinates are increasing downwards. 100 | * 101 | * @constructor Create a new window for pixel-based drawing. 102 | * @param width the number of horizontal pixels 103 | * @param height number of vertical pixels 104 | * @param title the title of the window 105 | * @param background the color used when clearing pixels 106 | * @param foreground the foreground color, default color in drawing operations 107 | */ 108 | class PixelWindow( 109 | val width: Int = 800, 110 | val height: Int = 640, 111 | val title: String = "PixelWindow", 112 | val background: java.awt.Color = java.awt.Color.black, 113 | val foreground: java.awt.Color = java.awt.Color.green 114 | ) { 115 | import PixelWindow.Event 116 | 117 | private val frame = new javax.swing.JFrame(title) 118 | private val canvas = new Swing.ImagePanel(width, height, background) 119 | 120 | private val queueCapacity = 1000 121 | private val eventQueue = 122 | new java.util.concurrent.LinkedBlockingQueue[java.awt.AWTEvent](queueCapacity) 123 | 124 | @volatile private var _lastEventType = Event.Undefined 125 | 126 | /** The event type of the latest event in the event queue. 127 | * 128 | * Returns Event.Undefined if no event has occurred. See also [[introprog.PixelWindow.awaitEvent]] 129 | */ 130 | def lastEventType: Int = _lastEventType 131 | 132 | @volatile private var _lastKeyText = "" 133 | 134 | /** A string representing the last key pressed. 135 | * 136 | * Returns an empty string if no key event has occurred. 137 | */ 138 | def lastKey: String = _lastKeyText 139 | 140 | @volatile private var _lastMousePos = (-1, -1) 141 | 142 | /** A pair of integers with the coordinates of the last mouse event. 143 | * 144 | * Returns `(-1, -1)` if no mouse event has occurred. 145 | */ 146 | 147 | def lastMousePos: (Int, Int) = _lastMousePos 148 | 149 | initFrame() // initialize listeners, show frame, etc. 150 | 151 | /** Event dispatching, translating internal AWT events to exposed events. */ 152 | private def handleEvent(e: java.awt.AWTEvent): Unit = e match 153 | case me: java.awt.event.MouseEvent => 154 | _lastMousePos = (me.getX, me.getY) 155 | me.getID match 156 | case java.awt.event.MouseEvent.MOUSE_PRESSED => 157 | _lastEventType = Event.MousePressed 158 | case java.awt.event.MouseEvent.MOUSE_RELEASED => 159 | _lastEventType = Event.MouseReleased 160 | case _ => 161 | throw new IllegalArgumentException(s"Unknown MouseEvent: $e") 162 | 163 | case ke: java.awt.event.KeyEvent => 164 | if ke.getKeyChar == java.awt.event.KeyEvent.CHAR_UNDEFINED || ke.getKeyChar < ' ' then 165 | _lastKeyText = PixelWindow.keyTextLookup.getOrElse(ke.getKeyCode, java.awt.event.KeyEvent.getKeyText(ke.getKeyCode)) 166 | else _lastKeyText = ke.getKeyChar.toString 167 | 168 | ke.getID match 169 | case java.awt.event.KeyEvent.KEY_PRESSED => 170 | _lastEventType = Event.KeyPressed 171 | case java.awt.event.KeyEvent.KEY_RELEASED => 172 | _lastEventType = Event.KeyReleased 173 | case _ => 174 | throw new IllegalArgumentException(s"Unknown KeyEvent: $e") 175 | 176 | case we: java.awt.event.WindowEvent => 177 | we.getID match 178 | case java.awt.event.WindowEvent.WINDOW_CLOSING => 179 | _lastEventType = Event.WindowClosed 180 | case _ => 181 | throw new IllegalArgumentException(s"Unknown WindowEvent: $e") 182 | 183 | case _ => 184 | throw new IllegalArgumentException(s"Unknown Event: $e") 185 | 186 | /** Return `true` if `(x, y)` is inside windows borders else `false`. */ 187 | def isInside(x: Int, y: Int): Boolean = x >= 0 && x < width && y >= 0 && y < height 188 | 189 | private def requireInside(x: Int, y: Int): Unit = 190 | require(isInside(x,y), s"(x=$x, y=$y) out of window bounds (0 until $width, 0 until $height)") 191 | 192 | /** Wait for next event until `timeoutInMillis` milliseconds. 193 | * 194 | * If time is out, `lastEventType` is `Undefined`. 195 | */ 196 | def awaitEvent(timeoutInMillis: Long = 1): Unit = 197 | val e = eventQueue.poll(timeoutInMillis, java.util.concurrent.TimeUnit.MILLISECONDS) 198 | if e != null then handleEvent(e) else _lastEventType = Event.Undefined 199 | 200 | /** Draw a line from (`x1`, `y1`) to (`x2`, `y2`) using `color` and `lineWidth`. */ 201 | def line(x1: Int, y1: Int, x2: Int, y2: Int, color: java.awt.Color = foreground, lineWidth: Int = 1): Unit = 202 | canvas.withGraphics { g => 203 | import java.awt.BasicStroke 204 | val s = new BasicStroke(lineWidth.toFloat, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER) 205 | g.setStroke(s) 206 | g.setColor(color) 207 | g.drawLine(x1, y1, x2, y2) 208 | } 209 | 210 | /** Fill a rectangle with upper left corner at `(x, y)` using `color`. */ 211 | def fill(x: Int, y: Int, width: Int, height: Int, color: java.awt.Color = foreground): Unit = 212 | canvas.withGraphics { g => 213 | g.setColor(color) 214 | g.fillRect(x, y, width, height) 215 | } 216 | 217 | /** Set the color of the pixel at `(x, y)`. 218 | * 219 | * If (x, y) is outside of window bounds then an IllegalArgumentException is thrown. 220 | */ 221 | def setPixel(x: Int, y: Int, color: java.awt.Color = foreground): Unit = 222 | requireInside(x, y) 223 | canvas.withImage { img => 224 | img.setRGB(x, y, color.getRGB) 225 | } 226 | 227 | /** Clear the pixel at `(x, y)` using the `background` class parameter. 228 | * 229 | * If (x, y) is outside of window bounds then an IllegalArgumentException is thrown. 230 | */ 231 | def clearPixel(x: Int, y: Int): Unit = 232 | requireInside(x, y) 233 | canvas.withImage { img => 234 | img.setRGB(x, y, background.getRGB) 235 | } 236 | 237 | /** Return the color of the pixel at `(x, y)`. 238 | * 239 | * If (x, y) is outside of window bounds then an IllegalArgumentException is thrown. 240 | */ 241 | def getPixel(x: Int, y: Int): java.awt.Color = 242 | requireInside(x, y) 243 | Swing.await { new java.awt.Color(canvas.img.getRGB(x, y)) } 244 | 245 | 246 | /** Return image of PixelWindow. */ 247 | def getImage: Image = 248 | import java.awt.image.BufferedImage 249 | val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) 250 | Swing.await{img.getGraphics.drawImage(canvas.img, 0, 0, null)} 251 | Image(img) 252 | 253 | /** Return image of PixelWindow section defined by top left corner `(x, y)` and `(width, height)`. */ 254 | def getImage(x: Int, y: Int, width: Int, height: Int) : Image = 255 | getImage.subsection(x, y, width, height) 256 | 257 | /** Set the PixelWindow frame title. */ 258 | def setTitle(title: String): Unit = Swing { frame.setTitle(title) } 259 | 260 | /** Show the window. Has no effect if the window is already visible. */ 261 | def show(): Unit = Swing { frame.setVisible(true) } 262 | 263 | /** Hide the window. Has no effect if the window is already hidden. */ 264 | def hide(): Unit = Swing { frame.setVisible(false); frame.dispose() } 265 | 266 | /** Set window position on screen*/ 267 | def setPosition(x: Int, y: Int): Unit = frame.setBounds(x, y, width, height) 268 | 269 | /** Clear all pixels using the `background` class parameter. */ 270 | def clear(): Unit = canvas.withGraphics { g => 271 | g.setColor(background) 272 | g.fillRect(0, 0, width, height) 273 | } 274 | 275 | /** Draw `text` at `(x, y)` using `color`, `size`, `style and `fontName`. */ 276 | def drawText( 277 | text: String, 278 | x: Int, 279 | y: Int, 280 | color: java.awt.Color = foreground, 281 | size: Int = 16, 282 | style: Int = java.awt.Font.BOLD, 283 | fontName: String = java.awt.Font.MONOSPACED 284 | ) = 285 | canvas.withGraphics { g => 286 | import java.awt.RenderingHints._ 287 | // https://docs.oracle.com/javase/tutorial/2d/text/renderinghints.html 288 | g.setRenderingHint(KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_ON) 289 | //val f = g.getFont 290 | g.setFont(new java.awt.Font(fontName /*f.getName*/, style, size)) 291 | g.setColor(color) 292 | g.drawString(text, x, y + size) 293 | } 294 | 295 | 296 | /** Draw `img` at `(x, y)` scaled to `(width, height)` and rotated `(angle)` radians clockwise. 297 | * 298 | * If angle is 0 then no rotation is applied. 299 | */ 300 | def drawImage( 301 | img: Image, 302 | x: Int, 303 | y: Int, 304 | width: Int, 305 | height: Int, 306 | angle: Double = 0 307 | ): Unit = 308 | if angle == 0 then 309 | canvas.withGraphics(_.drawImage(img.underlying, x, y, width, height, null)) 310 | else 311 | val at = new java.awt.geom.AffineTransform() 312 | at.translate(x, y) 313 | at.rotate(angle, width/2, height/2) 314 | canvas.withGraphics(_.drawImage(img.scaled(width, height).underlying, at, null)) 315 | 316 | /** Draw `img` at `(x, y)` unscaled. */ 317 | def drawImage(img: Image, x: Int, y: Int): Unit = 318 | drawImage(img, x, y, img.width, img.height) 319 | 320 | /** Draw `matrix` at `(x, y)` unscaled. */ 321 | def drawMatrix(matrix: Array[Array[java.awt.Color]], x: Int, y: Int): Unit = 322 | for 323 | xx <- 0 until matrix.length 324 | yy <- 0 until matrix(xx).length 325 | do 326 | setPixel(xx+x, yy+y, matrix(xx)(yy)) 327 | 328 | 329 | /** Create the underlying window and add listeners for event management. */ 330 | private def initFrame(): Unit = Swing { 331 | Swing.init() // first time calls setPlatformSpecificLookAndFeel 332 | javax.swing.JFrame.setDefaultLookAndFeelDecorated(true) 333 | 334 | frame.setFocusTraversalKeysEnabled(false); 335 | 336 | frame.addWindowListener(new java.awt.event.WindowAdapter { 337 | override def windowClosing(e: java.awt.event.WindowEvent): Unit = { 338 | frame.setVisible(false) 339 | frame.dispose() 340 | eventQueue.offer(e) 341 | } 342 | }) 343 | 344 | frame.addKeyListener(new java.awt.event.KeyAdapter { 345 | override def keyPressed(e: java.awt.event.KeyEvent): Unit = eventQueue.offer(e) 346 | override def keyReleased(e: java.awt.event.KeyEvent): Unit = eventQueue.offer(e) 347 | }) 348 | 349 | canvas.addMouseListener(new java.awt.event.MouseAdapter { 350 | override def mousePressed(e: java.awt.event.MouseEvent): Unit = eventQueue.offer(e) 351 | override def mouseReleased(e: java.awt.event.MouseEvent): Unit = eventQueue.offer(e) 352 | }) 353 | 354 | val box = new javax.swing.Box(javax.swing.BoxLayout.Y_AXIS) 355 | box.add(javax.swing.Box.createVerticalGlue()) 356 | box.add(canvas) 357 | box.add(javax.swing.Box.createVerticalGlue()) 358 | frame.add(box) 359 | 360 | val outsideOfCanvasColorShownIfResized = java.awt.Color.BLACK.brighter.brighter 361 | frame.getContentPane().setBackground(outsideOfCanvasColorShownIfResized) 362 | frame.pack() 363 | frame.setVisible(true) 364 | } 365 | } -------------------------------------------------------------------------------- /src/main/scala/introprog/Swing.scala: -------------------------------------------------------------------------------- 1 | package introprog 2 | 3 | /** A module with Swing utilities used by [[introprog.PixelWindow]]. */ 4 | object Swing: 5 | 6 | private def runInSwingThread(callback: => Unit): Unit = 7 | javax.swing.SwingUtilities.invokeLater(() => callback) 8 | 9 | /** Run `callback` asynchronously in the Swing thread. */ 10 | def apply(callback: => Unit): Unit = runInSwingThread(callback) 11 | 12 | /** Run `callback` in the Swing thread and block until completion. */ 13 | def await[T: scala.reflect.ClassTag](callback: => T): T = 14 | val ready = new java.util.concurrent.CountDownLatch(1) 15 | val result = new Array[T](1) 16 | runInSwingThread { 17 | result(0) = callback 18 | ready.countDown 19 | } 20 | ready.await 21 | result(0) 22 | 23 | /** Return a sequence of available look and feel options. */ 24 | def installedLookAndFeels: Vector[String] = 25 | javax.swing.UIManager.getInstalledLookAndFeels.toVector.map(_.getClassName) 26 | 27 | /** Find a look and feel with a name including `partOfName`. */ 28 | def findLookAndFeel(partOfName: String): Option[String] = 29 | installedLookAndFeels.find(_.toLowerCase contains partOfName) 30 | 31 | /** Test if the current operating system name includes `partOfName`. */ 32 | def isOS(partOfName: String): Boolean = 33 | if partOfName.toLowerCase.startsWith("win") && isInProc("windows", "wsl", "microsoft") then true //WSL 34 | else scala.sys.props("os.name").toLowerCase.contains(partOfName.toLowerCase) 35 | 36 | /** Check whether `/proc/version` on this filesystem contains any of the strings in `parts`. 37 | * Can be used to detect if we are on WSL instead of "real" linux/ubuntu. 38 | */ 39 | private def isInProc(parts: String*): Boolean = 40 | util.Try(parts.map(_.toLowerCase) 41 | .exists(part => IO.loadString("/proc/version").toLowerCase.contains(part))) 42 | .getOrElse(false) 43 | 44 | private var isInit = false 45 | 46 | /** Init the Swing GUI toolkit and set platform-specific look and feel.*/ 47 | def init(): Unit = if !isInit then 48 | setPlatformSpecificLookAndFeel() 49 | isInit = true 50 | 51 | private def setPlatformSpecificLookAndFeel(): Unit = 52 | import javax.swing.UIManager.setLookAndFeel 53 | if isOS("win") then findLookAndFeel("win").foreach(setLookAndFeel) 54 | else if isOS("linux") then findLookAndFeel("gtk").foreach(setLookAndFeel) 55 | else if isOS("mac") then findLookAndFeel("apple").foreach(setLookAndFeel) 56 | else javax.swing.UIManager.setLookAndFeel( 57 | javax.swing.UIManager.getSystemLookAndFeelClassName() 58 | ) 59 | 60 | /** A Swing `JPanel` to create drawing windows for 2D graphics. */ 61 | class ImagePanel( 62 | val initWidth: Int, 63 | val initHeight: Int, 64 | val initBackground: java.awt.Color 65 | ) extends javax.swing.JPanel: 66 | val img: java.awt.image.BufferedImage = java.awt.GraphicsEnvironment 67 | .getLocalGraphicsEnvironment 68 | .getDefaultScreenDevice 69 | .getDefaultConfiguration 70 | .createCompatibleImage(initWidth, initHeight, java.awt.Transparency.OPAQUE) 71 | 72 | val g: java.awt.Graphics2D = img.createGraphics() 73 | g.setColor(initBackground) 74 | g.fillRect(0, 0, initWidth, initHeight) 75 | 76 | setBackground(initBackground) 77 | setDoubleBuffered(true) 78 | setPreferredSize(new java.awt.Dimension(initWidth, initHeight)) 79 | setMinimumSize(new java.awt.Dimension(initWidth, initHeight)) 80 | setMaximumSize(new java.awt.Dimension(initWidth, initHeight)) 81 | 82 | override def paintComponent(g: java.awt.Graphics): Unit = g.drawImage(img, 0, 0, this) 83 | 84 | override def imageUpdate(img: java.awt.Image, infoFlags: Int, x: Int, y: Int, width: Int, height: Int): Boolean = 85 | repaint() 86 | true 87 | 88 | /** Execute `action` in the Swing thread with graphics context as param. */ 89 | def withGraphics(action: java.awt.Graphics2D => Unit) = runInSwingThread { 90 | action(img.createGraphics()) 91 | repaint() 92 | } 93 | 94 | /** Execute `action` in the Swing thread with raw image as param. */ 95 | def withImage(action: java.awt.image.BufferedImage => Unit) = runInSwingThread { 96 | action(img) 97 | repaint() 98 | } 99 | -------------------------------------------------------------------------------- /src/main/scala/introprog/examples/TestBlockGame.scala: -------------------------------------------------------------------------------- 1 | package introprog.examples 2 | 3 | /** Examples of a simple BlockGame app with overridden callbacks to handle events 4 | * See the documentation of BlockGame and the source code of TestBlockGame 5 | * for inspiration on how to inherit BlockGame to create your own block game. 6 | */ 7 | object TestBlockGame: 8 | /** Create Game and start playing. */ 9 | def main(args: Array[String]): Unit = 10 | println("Press Enter to toggle random blocks. Close window to continue.") 11 | (new RandomBlocks).play() 12 | println("Opening MovingBlock. Press Ctrl+C to exit.") 13 | (new MovingBlock).start() 14 | println("MovingBlock has ended.") 15 | 16 | /** A class extending `introprog.BlockGame`, see source code. */ 17 | class RandomBlocks extends introprog.BlockGame: 18 | 19 | sealed trait State 20 | case object Starting extends State 21 | case object Playing extends State 22 | case object GameOver extends State 23 | 24 | var state: State = Starting 25 | var isDrawingRandomBlocks: Boolean = false 26 | 27 | def showEnterMessage(): Unit = 28 | drawTextInMessageArea("Press Enter to toggle random blocks.", 0,0) 29 | 30 | def showEscapeMessage(): Unit = 31 | drawTextInMessageArea("Press Esc to clear window.", 25, 0) 32 | 33 | override def onKeyDown(key: String): Unit = 34 | print(s" Key down: $key") 35 | key match 36 | case "Esc" => 37 | clearWindow() 38 | drawCenteredText("ESCAPED TO BLACK SPACE!") 39 | showEnterMessage() 40 | case "Enter" => 41 | isDrawingRandomBlocks = !isDrawingRandomBlocks 42 | showEnterMessage() 43 | showEscapeMessage() 44 | case _ => 45 | 46 | override def onKeyUp(key: String): Unit = print(s" Key up: $key") 47 | 48 | override def onMouseDown(pos: (Int, Int)): Unit = print(s" Mouse down: $pos") 49 | 50 | override def onMouseUp(pos: (Int, Int)): Unit = print(s" Mouse up: $pos") 51 | 52 | override def onClose(): Unit = 53 | print(" Window Closed.") 54 | state = GameOver 55 | override def gameLoopAction(): Unit = 56 | import scala.util.Random.nextInt 57 | def rndPos: (Int, Int) = (nextInt(dim._1), nextInt(dim._2)) 58 | def rndColor = new java.awt.Color(nextInt(256), nextInt(256), nextInt(256)) 59 | print(".") 60 | if isDrawingRandomBlocks then 61 | drawBlock(rndPos._1, rndPos._2, rndColor) 62 | 63 | def play(): Unit = 64 | state = Playing 65 | println(s"framesPerSecond == $framesPerSecond") 66 | showEnterMessage() 67 | gameLoop(stopWhen = state == GameOver) 68 | println("Goodbye!") 69 | 70 | end RandomBlocks 71 | 72 | class MovingBlock extends introprog.BlockGame( 73 | title = "MovingBlock", 74 | dim = (10,5), 75 | blockSize = 40, 76 | background = java.awt.Color.BLACK, 77 | framesPerSecond = 50, 78 | messageAreaHeight = 1, 79 | messageAreaBackground = java.awt.Color.DARK_GRAY 80 | ): 81 | 82 | var movesPerSecond: Double = 2 83 | 84 | def millisBetweenMoves: Int = (1000 / movesPerSecond).round.toInt max 1 85 | 86 | var _timestampLastMove: Long = System.currentTimeMillis 87 | 88 | def timestampLastMove = _timestampLastMove 89 | 90 | var x = 0 91 | 92 | var y = 0 93 | 94 | def move(): Unit = 95 | if x == dim._1 - 1 then 96 | x = -1 97 | y += 1 98 | end if 99 | x = x+1 100 | 101 | def erase(): Unit = drawBlock(x, y, java.awt.Color.BLACK) 102 | 103 | def draw(): Unit = drawBlock(x, y, java.awt.Color.CYAN) 104 | 105 | def update(): Unit = 106 | if System.currentTimeMillis > _timestampLastMove + millisBetweenMoves then 107 | move() 108 | _timestampLastMove = System.currentTimeMillis() 109 | 110 | var loopCounter: Int = 0 111 | 112 | override def gameLoopAction(): Unit = 113 | erase() 114 | update() 115 | draw() 116 | clearMessageArea() 117 | drawTextInMessageArea(s"Loop number: $loopCounter", 1, 0, java.awt.Color.PINK, size = 30) 118 | loopCounter += 1 119 | 120 | final def start(): Unit = 121 | pixelWindow.show() // show window again if closed and start() is called again 122 | gameLoop(stopWhen = x == dim._1 - 1 && y == dim._2 - 1) 123 | 124 | end MovingBlock 125 | -------------------------------------------------------------------------------- /src/main/scala/introprog/examples/TestIO.scala: -------------------------------------------------------------------------------- 1 | package introprog.examples 2 | 3 | /** Example of serializing objects to and from binary files on disk. */ 4 | object TestIO: 5 | import introprog.IO 6 | 7 | case class Person(name: String) 8 | 9 | def main(args: Array[String]): Unit = 10 | println("Test of IO of serializable objects to/from disk:") 11 | val highscores = Map(Person("Sandra") -> 42, Person("Björn") -> 5) 12 | 13 | // serialize to disk: 14 | IO.saveObject(highscores,"highscores.ser") 15 | 16 | // de-serialize back from disk: 17 | val highscores2 = IO.loadObject[Map[Person, Int]]("highscores.ser") 18 | 19 | val isSameContents = highscores2 == highscores 20 | val testResult = if isSameContents then "SUCCESS :)" else "FAILURE :(" 21 | assert(isSameContents, s"$highscores != $highscores2") 22 | println(s"$highscores == $highscores2\n$testResult") 23 | 24 | testImageLoadAndDraw() 25 | 26 | def testImageLoadAndDraw(): Unit = 27 | import introprog.* 28 | import java.awt.Color 29 | import java.awt.Color.* 30 | 31 | val wSize = (4*128, 3*128) 32 | val w = new PixelWindow(wSize._1, wSize._2, "DrawImage"); 33 | val w2 = new PixelWindow(wSize._1, wSize._2, "DrawMatrix") 34 | val w3 = new PixelWindow((wSize._1*1.5).toInt, (wSize._2*1.5).toInt, "SaveLoadAsJpeg") 35 | w.setPosition(0,0) 36 | w2.setPosition(wSize._1, 0) 37 | w3.setPosition(0, wSize._2+50) 38 | //draw text top right 39 | val testMatrix = Array[Array[Color]](Array[Color](blue, yellow, blue), 40 | Array[Color](yellow, yellow, yellow), 41 | Array[Color](blue, yellow, blue), 42 | Array[Color](blue, yellow, blue)) 43 | var flagPos = (0, 0) 44 | var flagSize = (4, 3) 45 | 46 | //draw small flag 47 | w.drawMatrix(testMatrix, 0, 0) 48 | for i <- 1 to 7 do 49 | // extract and save Image 50 | var img = w.getImage(flagPos._1, flagPos._2, flagSize._1, flagSize._2) 51 | IO.savePNG(img, "screenshot") 52 | //draw in other window using drawMatrix 53 | w2.drawMatrix(img.toMatrix, flagPos._1, flagPos._2) 54 | if i != 7 then 55 | //update pos and size 56 | flagPos = (flagPos._1 + flagSize._1,flagPos._2 + flagSize._2) 57 | flagSize = (flagSize._1 * 2,flagSize._2 * 2) 58 | //draw new flag from file 59 | img = IO.loadImage("screenshot.png") 60 | w.drawImage(img.scaled(img.width*2, img.height*2), flagPos._1, flagPos._2) 61 | 62 | var im = w2.getImage 63 | IO.saveJPEG(im, "screenshot.jpg", 0.2) 64 | im = IO.loadImage("screenshot.jpg") 65 | 66 | 67 | for i <- 0 to 200 do 68 | w3.clear() 69 | w3.drawImage(im, 0, 0, (im.width*0.5).toInt, (im.height*0.5).toInt, Math.toRadians(i*2)) 70 | Thread.sleep(100/6) 71 | 72 | 73 | println("Windows should be identical and display 7 flags each.") 74 | println("Press enter to quit.") 75 | val _ = scala.io.StdIn.readLine() 76 | IO.delete("screenshot.png") 77 | IO.delete("screenshot.jpg") 78 | PixelWindow.exit() 79 | 80 | // for file extension choice see: 81 | // https://stackoverflow.com/questions/10433214/file-extension-for-a-serialized-object 82 | 83 | -------------------------------------------------------------------------------- /src/main/scala/introprog/examples/TestPixelWindow.scala: -------------------------------------------------------------------------------- 1 | package introprog.examples 2 | 3 | /** Example of a simple PixelWindow app with an event loop that inspects key typing 4 | * and mouse clicking by the user. See source code for inspiration on how to use 5 | * PixelWindow for easy 2D game programming. 6 | */ 7 | object TestPixelWindow: 8 | import introprog.PixelWindow 9 | import introprog.PixelWindow.Event 10 | 11 | /** A reference to an instance of class `PixelWindow`. */ 12 | val w = new PixelWindow(400, 400, "Hello PixelWindow!") 13 | 14 | /** The color used by `square`. */ 15 | var color = java.awt.Color.red 16 | 17 | /** Draw a square with (`x`, `y`) as top left corner and size `side`. */ 18 | def square(x: Int, y: Int, side: Int): Unit = 19 | w.line(x, y, x + side, y, color) 20 | w.line(x + side, y, x + side, y + side, color) 21 | w.line(x + side, y + side, x, y + side, color) 22 | w.line(x, y + side, x, y, color) 23 | 24 | /** Draw squares and start an event loop that prints events in terminal. */ 25 | def main(args: Array[String]): Unit = 26 | println("Key and mouse events are printed. Close window to exit.") 27 | w.drawText("HELLO WORLD! 012345ÅÄÖ", 0, 0) 28 | square(200, 100, 50) 29 | w.fill(x = 50, y = 100, width = 50, height = 50, color = java.awt.Color.blue) 30 | color = java.awt.Color.orange 31 | square(50,100, 50) 32 | color = java.awt.Color.green 33 | square(150,200, 50) 34 | w.line(0,0,w.width,w.height) 35 | 36 | while w.lastEventType != Event.WindowClosed do 37 | w.awaitEvent(10) // wait for next event for max 10 milliseconds 38 | 39 | if w.lastEventType != Event.Undefined then 40 | println(s"lastEventType: ${w.lastEventType} => ${Event.show(w.lastEventType)}") 41 | 42 | w.lastEventType match 43 | case Event.KeyPressed => println("lastKey == " + w.lastKey) 44 | case Event.KeyReleased => println("lastKey == " + w.lastKey) 45 | case Event.MousePressed => println("lastMousePos == " + w.lastMousePos) 46 | case Event.MouseReleased => println("lastMousePos == " + w.lastMousePos) 47 | case Event.WindowClosed => println("Goodbye!"); System.exit(0) 48 | case _ => 49 | 50 | Thread.sleep(100) // wait for 0.1 seconds 51 | -------------------------------------------------------------------------------- /src/test/scala/testIO.scala: -------------------------------------------------------------------------------- 1 | package introprog 2 | 3 | val tmpDir = "target/tmp" 4 | def createTmp(): Boolean = IO.createDirIfNotExist(tmpDir) 5 | 6 | class TestIO extends munit.FunSuite: 7 | 8 | test("TestIO: createDirIfNotExist"): 9 | val existed = createTmp() 10 | assert(IO.isExisting(tmpDir), s"dir should exists: $tmpDir") 11 | 12 | test("TestIO: saveString, loadString, appendString, loadLines, appendLines"): 13 | createTmp() 14 | val s1 = "hello" 15 | val fn = s"$tmpDir/hello.txt" 16 | IO.saveString(s1, fileName = fn) 17 | val s2 = IO.loadString(fileName = fn) 18 | assertEquals(s1, s2, "saved string different from loaded") 19 | IO.appendString("!\n", fileName = fn ) 20 | val s3 = IO.loadString(fileName = fn) 21 | assertEquals(s3, s2 + "!\n", "saved string is missing appended '!+newline'") 22 | IO.appendLines(Seq("line2"),fileName = fn) 23 | val s4 = IO.loadLines(fileName = fn) 24 | assertEquals(s4, Vector("hello!", "line2"), s"loadLines not as expected: $s4") 25 | val s5 = IO.loadString(fileName = fn) 26 | assertEquals(s5, "hello!\nline2\n", s"loadLines not as expected: $s5") 27 | IO.appendLines(Seq(),fileName = fn) // nothing should be added, not even newline 28 | assertEquals(s5, IO.loadString(fileName = fn), s"loadLines not as expected: $s5") 29 | --------------------------------------------------------------------------------