diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..72ee9ae --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,19 @@ +name: CI +on: + pull_request: + push: +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + - name: Setup sbt launcher + uses: sbt/setup-sbt@v1 + - name: Build and Test + run: sbt +test diff --git a/.gitignore b/.gitignore index 8457bb2..dee274a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -publish-doc.sh -publish-jar.sh - tmp **/old **/bin @@ -10,6 +7,11 @@ tmp .ensime .ensime_cache/ .classpath +**/.metals +**/.bloop +**/.bsp +**/metals.sbt +.vscode **/*.cache-main # Eclipse **/.metadata # Eclipse @@ -18,3 +20,6 @@ target *.swp # vim *~ # emacs .idea # IntelliJ + +# produced by TestIO +highscores.ser diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6d85281..0000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -sudo: required -dist: trusty - -language: scala -scala: 2.12.6 - -git: - depth: 3 - -before_install: - - sudo apt-get update -q - -script: - - sbt compile - - sbt 'Test/compile' - - sbt doc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cff8cd3..241f547 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,8 @@ Contributions are welcome! +If you contribute to this repo you implicitly agree to the terms of the open source license in this repository. + 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. 2. **Fork** this repo and clone your fork as described [here](https://help.github.com/articles/fork-a-repo/). diff --git a/PUBLISH.md b/PUBLISH.md index f716971..802218e 100644 --- a/PUBLISH.md +++ b/PUBLISH.md @@ -1,5 +1,7 @@ # Instruction for repo maintainers +First two sections are preparations only done once for all or once per machine. Last comes what is done when actually publishing. + ## Already done once and for all: Setup publication to Sonatype 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: @@ -10,28 +12,137 @@ These instructions have already been followed for this repo by Bjorn Regnell who * New project ticket (requires login to Jira): https://issues.sonatype.org/browse/OSSRH-42634?filter=-2 +## Sbt config and GPG Key setup (done once per machine) + +Read and adapt these instructions: + +* https://www.scala-sbt.org/release/docs/Using-Sonatype.html + * Be aware that step 1 was not used, instead the instructions from this link were used to create keys: + * https://github.com/scalacenter/sbt-release-early/wiki/How-to-create-a-gpg-key + + * 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: + +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. + +``` +cd ~ +mkdir ci-keys +chmod -R go-rwx ci-keys +cd ci-keys +gpg --homedir . --gen-key +gpg --homedir . -a --export > pubring.asc +gpg --homedir . -a --export-secret-keys > secring.asc +gpg --homedir . --list-key +# the pub hex string e.g E7232FE8B8357EEC786315FE821738D92B63C95F +gpg --homedir . --keyserver hkp://pool.sks-keyservers.net --send-keys +gpg --homedir . --keyserver hkp://pgp.mit.edu --send-keys E7232FE8B8357EEC786315FE821738D92B63C95F +mkdir -p ~/.sbt/gpg +cd ~/.sbt/gpg +cp -R ~/ci-keys/* . +``` + +After this you should have this these files `~/.sbt/gpg`: + +``` +$ cat ~/.sbt/1.0/plugins/gpg.sbt +addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.0") + +$ cat ~/.sbt/sonatype_credential +realm=Sonatype Nexus Repository Manager +host=oss.sonatype.org +user= +password= + +$ cat ~/.sbt/1.0/sonatype.sbt +credentials += Credentials(Path.userHome / ".sbt" / "sonatype_credential") + +$ ls ~/.sbt/gpg +crls.d private-keys-v1.d pubring.kbx secring.asc +openpgp-revocs.d pubring.asc trustdb.gpg + +``` + +* See more info here: + - https://github.com/sbt/sbt-pgp#configuration-signing-key + - https://www.scala-sbt.org/sbt-pgp/usage.html + ## How to publish -1. Build and test locally. +1. Build and test locally using `sbt "compile;test;doc"` + +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: + - 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 + - commit all changes and push and *then* create a github release with the packaged jar uploaded to https://github.com/lunduniversity/introprog-scalalib/releases + - Publish the jar to the course home page at http://cs.lth.se/lib using `sh publish-jar.sh` + - Publish updated docs to the course home page at http://cs.lth.se/api using script `sh publish-doc.sh` + - 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. + - 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 + +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: + ``` + sbt> versionCheck + sbt> versionPolicyCheck + ``` + More information here: + * https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html + * https://www.youtube.com/watch?v=0T3vBnYCXn4 + * https://www.scala-sbt.org/1.x/docs/Publishing.html#Version+scheme + * https://eed3si9n.com/enforcing-semver-with-sbt-strict-update + + +4. In `sbt>` run `publishSigned` - a plus sign is not used since we only publish for Scala 3 from 1.2.0. + +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` +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. + +Put .credentials in ~/.sbt +``` +realm=Sonatype Nexus Repository Manager +host=oss.sonatype.org +user=xxx +password=yyy +``` + +When I did publishSIgend last time I got these errors but the publishing went through anyway with the above .credentials in ~/.sbt: +``` +sbt:introprog> publishSigned +[info] Wrote /home/bjornr/git/hub/lunduniversity/introprog-scalalib/target/scala-3.3.3/introprog_3-1.4.0.pom +[warn] multiple main classes detected: run 'show discoveredMainClasses' to see the list +[error] gpg: Warning: not using 'E7232FE8B8357EEC786315FE821738D92B63C95F' as default key: No secret key +[error] gpg: all values passed to '--default-key' ignored +[error] gpg: Warning: not using 'E7232FE8B8357EEC786315FE821738D92B63C95F' as default key: No secret key +[error] gpg: all values passed to '--default-key' ignored +[error] gpg: Warning: not using 'E7232FE8B8357EEC786315FE821738D92B63C95F' as default key: No secret key +[error] gpg: all values passed to '--default-key' ignored +[error] gpg: Warning: not using 'E7232FE8B8357EEC786315FE821738D92B63C95F' as default key: No secret key +[error] gpg: all values passed to '--default-key' ignored +[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 +[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 +[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 +[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 +[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 +[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 +[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 +[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 +``` -2. Bump version in `build.sbt`, run `sbt package`, commit and push and create a github release with the packade jar uploaded. We also want a release on github aligned with the release on Sonatype Central. +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")` -3. In `sbt` run `publishedSigned` +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 -4. Log into Sonatype Nexus here: https://oss.sonatype.org/#welcome +6. Click on *Staging Repositories* in the Build Promotion list to the left. Click "Refresh" if list is empty. https://oss.sonatype.org/#stagingRepositories -5. Click on *Staging Repositories* in the Build Promotion list to the left. https://oss.sonatype.org/#stagingRepositories +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` -6. Scroll down and select selthcs-100X and select the *Contents* tab and expand until leaf level of the tree where you can see the `introprog_2.12-x.y.z.jar` +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`. -7. Download the staged jar by clicking on it and selecting the *Artifact* tab to the right and click the *Download* button. Save it e.g. in `tmp`. +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. -8. Verify that the staged jar downloaded from sonatype works by running `scala -cp introprog-xxx.jar` and in REPL e.g. `val w = new introprog.PixelWindow`. 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. +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... -9. Click the *Close* icon with a diskett above the repository list to "close" the staging repository. +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. -10. After a while (typically a couple of minutes) the *Release* icon with a chain above the repository list is enabled. Click it when enabled. 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. +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 -11. By searching here you can see the repo in progress of being published but it takes a while before it is publically visible on Central (typically 10-15 minutes). https://oss.sonatype.org/#nexus-search;quick~se.lth.cs +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). -12. When visible on Central at https://repo1.maven.org/maven2/se/lth/cs/introprog_2.12/ 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). diff --git a/README.md b/README.md index f18075d..0e5f174 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,73 @@ # introprog-scalalib -[![Build Status](https://travis-ci.org/lunduniversity/introprog-scalalib.svg?branch=master)](https://travis-ci.org/lunduniversity/introprog-scalalib) +![Build Status](https://github.com/lunduniversity/introprog-scalalib/actions/workflows/main.yml/badge.svg) + +[](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) 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! -* The **api** documentation is available here: http://cs.lth.se/pgk/api/ +* The api **documentation** is available here: http://cs.lth.se/pgk/api/ * You can find code **examples** here: [src/main/scala/introprog/examples](https://github.com/lunduniversity/introprog-scalalib/tree/master/src/main/scala/introprog/examples) -This repo in 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 +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 ## How to use introprog-scalalib -### Using sbt -If you have the [Scala Build Tool](https://www.scala-sbt.org/download.html) then you can put this text in a file called `build.sbt` +### Getting started using scala from the command line + +You need to have [Scala installed](https://www.scala-lang.org/download/) using version 3.5.2 or later. + +You can start the Scala REPL in the current directory with `introprog` directly available to play with using this command in a terminal window: +``` +scala repl . --dep se.lth.cs::introprog:1.4.0 +``` + +You can then open a drawing window like so: +```scala +scala> val w = introprog.PixelWindow() +val w: introprog.PixelWindow = introprog.PixelWindow@34f60be9 + +scala> w.drawText("Hello introprog.PixelWindow!", x = 100, y = 100) +``` + +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/)): + +``` +//> using scala 3.5.2 +//> using dep se.lth.cs::introprog:1.3.1 +``` + +You can then run your code with `scala run .` (note the ending dot, meaning "current dir") + +If your program looks like this: + +``` +//> using scala 3.5.2 +//> using dep se.lth.cs::introprog:1.4.0 + +@main def run = + val w = introprog.PixelWindow() + w.drawText("Hello introprog.PixelWindow!", x = 100, y = 100) ``` -scalaVersion := "2.12.6" -libraryDependencies += "se.lth.cs" %% "introprog" % "1.0.0" +You should see green text in a new window after executing: +``` +scala-cli run . +``` +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. + +You can also give the `introprog` dependency directly at the command line, instead of the `using dep` directive: +``` +scala-cli run . --dep se.lth.cs::introprog:1.4.0 +``` + +### Getting started using sbt + +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` +``` +scalaVersion := "3.5.2" +libraryDependencies += "se.lth.cs" %% "introprog" % "1.4.0" ``` When you run `sbt` in terminal the `introprog` package is automatically downloaded and made available on your classpath. @@ -28,25 +78,33 @@ sbt> console scala> val w = new introprog.PixelWindow() scala> w.fill(100,100,100,100,java.awt.Color.red) ``` +See: [api documentation for PixelWindow](https://fileadmin.cs.lth.se/pgk/api/api/introprog/PixelWindow.html) -### Manual download - -Download the latest jar-file from here: https://github.com/lunduniversity/introprog-scalalib/releases +### Older Scala versions -Put the jar-file on your classpath when you run the Scala REPL, for example: +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: ``` -> scala -cp introprog_2.12-1.0.0.jar -scala> val w = new introprog.PixelWindow() -scala> w.fill(100,100,100,100,java.awt.Color.red) -scala> -``` -Put the jar-file on your classpath when you run your Scala app, for example: -``` -> scala -cp "introprog_2.12-1.0.0.jar:." Main +scalaVersion := "2.13.8" //2.13.5 or any later 2.13 version +scalacOptions += "-Ytasty-reader" +libraryDependencies += + ("se.lth.cs" %% "introprog" % "1.1.5").cross(CrossVersion.for2_13Use3) ``` -If on Windows cmd/powershell use `;` instead of `:` before the period. + +For Scala 2.12.x and 2.13.4 and older you need to use version 1.1.4 of introprog or older. + + +### Manual download + +Download the latest jar-file from here: +* Github releases: https://github.com/lunduniversity/introprog-scalalib/releases +* Scaladex: https://index.scala-lang.org/lunduniversity/introprog-scalalib +* Search Maven central: https://search.maven.org/search?q=introprog +* Maven central server: https://repo1.maven.org/maven2/se/lth/cs/ + +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. ## How to build introprog-scalalib + With [`sbt`](https://www.scala-sbt.org/download.html) and [`git`](https://git-scm.com/downloads) on your path type in terminal: ``` > git clone git@github.com:lunduniversity/introprog-scalalib.git @@ -54,13 +112,24 @@ With [`sbt`](https://www.scala-sbt.org/download.html) and [`git`](https://git-sc > sbt package ``` +## How to build and see the doc pages using a local server + +Run this in linux bash terminal: +``` +sbt doc && cd target/scala-3.3.3/api && python3 -m http.server 8080 +``` +Open Firefox and type this url in the address field: +``` +http://localhost:8080/ +``` + ## Intentions and philosophy behind introprog-scalalib 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: * Use as simple constructs as possible. * Follow Scala idioms with a pragmatic mix of imperative, functional and object-oriented programming. -* Don't use advanced functional programming concepts and magical implicit. +* Don't use advanced functional programming concepts and magical implicits. * Prefer a clean api with single-responsibility functions in simple modules. * Prefer immutability over mutable state, `Vector` for sequences and case classes for data. * Hide/avoid threading and complicated concurrency. @@ -70,6 +139,6 @@ This repo includes utilities to empower learners to advance from basic to interm Areas currently in scope of this library: -* Simple 2D graphics for single-threaded game programming with explicit game loop. -* Simple IO. -* Simple, modal GUI dialogs. +* Simple pixel-based 2D graphics for single-threaded game programming with explicit game loop. +* Simple blocking IO that hides the underlying complication of releasing resources etc. +* Simple modal GUI dialogs that block while waiting for user response. diff --git a/build.sbt b/build.sbt index 7e9b4cf..c6aae06 100644 --- a/build.sbt +++ b/build.sbt @@ -1,37 +1,63 @@ -lazy val Version = "1.0.0" +lazy val Version = "1.4.0" lazy val Name = "introprog" +lazy val scala3 = "3.3.3" -name := Name -version := Version -scalaVersion := "2.12.6" -fork in (Compile, console) := true +Global / onChangedBuildSource := ReloadOnSourceChanges -scalacOptions ++= Seq( +// to avoid strange warnings, these lines with excludeLintKeys are needed: +Global / excludeLintKeys += ThisBuild / Compile / console / fork + +lazy val introprog = (project in file(".")) + .settings( + name := Name, + version := Version, + scalaVersion := scala3, + libraryDependencies += "org.scalameta" %% "munit" % "0.7.29" % Test, + ) + +ThisBuild / Compile / console / fork := true + +//https://github.com/scalacenter/sbt-version-policy +ThisBuild / versionScheme := Some("early-semver") +ThisBuild / versionPolicyIntention := Compatibility.None +//ThisBuild / versionPolicyIntention := Compatibility.None +//ThisBuild / versionPolicyIntention := Compatibility.BinaryAndSourceCompatible +//ThisBuild / versionPolicyIntention := Compatibility.BinaryCompatible +//In the sbt shell check version using: +//sbt> versionCheck +//sbt> versionPolicyCheck +//sbt> last versionPolicyFindDependencyIssues +//sbt> last mimaPreviousClassfiles + +ThisBuild / scalacOptions ++= Seq( "-encoding", "UTF-8", "-unchecked", "-deprecation", - "-Xfuture", +// "-Xfuture", // "-Yno-adapted-args", - "-Ywarn-dead-code", - "-Ywarn-numeric-widen", +// "-Ywarn-dead-code", +// "-Ywarn-numeric-widen", // "-Ywarn-value-discard", // "-Ywarn-unused" ) -scalacOptions in (Compile, doc) ++= Seq( - "-implicits", +ThisBuild / Compile / compile / javacOptions ++= Seq("-target", "1.8") // for backward compat + +Compile / doc / scalacOptions ++= Seq( "-groups", - "-doc-title", Name, - "-doc-footer", "Dep. of Computer Science, Lund University, Faculty of Engineering LTH", - "-sourcepath", (baseDirectory in ThisBuild).value.toString, - "-doc-version", Version, - "-doc-root-content", (baseDirectory in ThisBuild).value.toString + "/src/rootdoc.txt", - "-doc-source-url", s"https://github.com/lunduniversity/introprog-scalalib/tree/master€{FILE_PATH}.scala" + "-project-version", Version, + "-project-footer", "Dep. of Computer Science, Lund University, Faculty of Engineering LTH", + "-siteroot", ".", + "-doc-root-content", "./docs/index.md", + "-source-links:github://lunduniversity/introprog-scalalib/master", + "-social-links:github::https://github.com/lunduniversity/introprog-scalalib" ) -// Below enables publishing to central.sonatype.org according to -// https://www.scala-sbt.org/release/docs/Using-Sonatype.html -// sbt> publishedSigned +// Below enables publishing to central.sonatype.org +// see PUBLISH.md for instructions +// usage inside sbt: BUT READ PUBLISH.md FIRST - the plus is needed for cross building all versions +// sbt> + publishSigned +// DON'T PANIC: it takes looong time to run it ThisBuild / organization := "se.lth.cs" ThisBuild / organizationName := "LTH" @@ -65,6 +91,16 @@ ThisBuild / publishTo := { } ThisBuild / publishMavenStyle := true +publishConfiguration := publishConfiguration.value.withOverwrite(true) +publishLocalConfiguration := publishLocalConfiguration.value.withOverwrite(true) +//pushRemoteCacheConfiguration := pushRemoteCacheConfiguration.value.withOverwrite(true) + +credentials += Credentials(Path.userHome / ".sbt" / ".credentials") + //https://oss.sonatype.org/#stagingRepositories //https://oss.sonatype.org/#nexus-search;quick~se.lth.cs //https://repo1.maven.org/maven2/se/lth/cs/introprog_2.12/ + + +//https://github.com/sbt/sbt-pgp + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f42c663 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,80 @@ +--- +--- + +This is the documentation of the `introprog` Scala Library with beginner-friendly utilities used in computer science teaching at Lund University. +The open source code is hosted at [[https://github.com/lunduniversity/introprog-scalalib]]. + +## Package contents + +- [[introprog.PixelWindow]] for simple, pixel-based drawing. + +- [[introprog.PixelWindow.Event]] for event management in a PixelWindow. + +- [[introprog.IO]] for file system interaction. + +- [[introprog.Dialog]] for user interaction with standard GUI dialogs. + +- [[introprog.BlockGame]] an abstract class to be inherited by games using block graphics. + +- [[introprog.examples]] with code examples demonstrating how to use this library. + +## How to use introprog-scalalib + +### Using scala-cli + +You need [Scala Command Line Interface](https://scala-cli.virtuslab.org/install) + +Add these magic comment lines starting with `//>` in the beginning of your Scala 3 file: + +``` +//> using scala 3 +//> using dep "se.lth.cs::introprog:1.4.0" +``` +You can choose the latest stable Scala version, or any version from at least Scala 3.3.3. + +You run your code with `scala-cli run .` (note the ending dot, meaning "this dir") + +If your program looks like this: + +``` +//> using scala 3 +//> using dep "se.lth.cs::introprog:1.4.0" + +@main def MyMain = + val w = introprog.PixelWindow() + w.drawText("Hello introprog.PixelWindow!", x = 100, y = 100) +``` +You should see green text in a new window after executing: +``` +scala-cli run . +``` +See: [api documentation for PixelWindow](https://fileadmin.cs.lth.se/pgk/api/api/introprog/PixelWindow.html) + +### Using sbt + +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` + +``` +scalaVersion := "3.4.2" // or any Scala version from at least 3.3.3 +libraryDependencies += "se.lth.cs" %% "introprog" % "1.4.0" +``` + +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: + +``` +sbt> console +scala> val w = new introprog.PixelWindow() +scala> w.fill(100,100,100,100,java.awt.Color.red) +``` + +## Manual download + +You can also manually download the latest jar file from here: + +* Lund University: [http://www.cs.lth.se/pgk/lib](http://www.cs.lth.se/pgk/lib) + +* GitHub: [https://github.com/lunduniversity/introprog-scalalib/releases](https://github.com/lunduniversity/introprog-scalalib/releases) + +* ScalaDex: [https://index.scala-lang.org/lunduniversity/introprog-scalalib/introprog](https://index.scala-lang.org/lunduniversity/introprog-scalalib/introprog) + +* Maven Central: [https://repo1.maven.org/maven2/se/lth/cs/introprog_3/](https://repo1.maven.org/maven2/se/lth/cs/introprog_3/) \ No newline at end of file diff --git a/project/build.properties b/project/build.properties index 5620cc5..e8a1e24 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.2.1 +sbt.version=1.9.7 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..b7a2210 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +// https://github.com/scalacenter/sbt-version-policy +addSbtPlugin("ch.epfl.scala" % "sbt-version-policy" % "3.2.1") \ No newline at end of file diff --git a/publish-doc.sh b/publish-doc.sh new file mode 100644 index 0000000..79245a7 --- /dev/null +++ b/publish-doc.sh @@ -0,0 +1,14 @@ +echo "*** Generating docs and copy api to fileadmin then zip it for local download" +set -x + +SCALAVERSION=3.3.3 +sbt doc + +ssh $LUCATID@fileadmin.cs.lth.se rm -r pgk/api + +scp -r target/scala-$SCALAVERSION/api $LUCATID@fileadmin.cs.lth.se:/Websites/Fileadmin/pgk/ + +cd target/scala-$SCALAVERSION/ +zip -rv api.zip api +scp api.zip $LUCATID@fileadmin.cs.lth.se:/Websites/Fileadmin/pgk/ +cd ../.. \ No newline at end of file diff --git a/publish-jar.sh b/publish-jar.sh new file mode 100644 index 0000000..8ffcd90 --- /dev/null +++ b/publish-jar.sh @@ -0,0 +1,11 @@ +#VERSION="$(grep -m 1 -Po -e '\d+.\d+.\d+' build.sbt)" +VERSION=1.4.0 +SCALAVERSION=3.3.3 +SCALACOMPAT=3 + +JARFILE="introprog_$SCALACOMPAT-$VERSION.jar" +DEST="$LUCATID@fileadmin.cs.lth.se:/Websites/Fileadmin/pgk/" + +sbt package +echo Copying $JARFILE to $DEST +scp "target/scala-$SCALAVERSION/$JARFILE" $DEST diff --git a/publish-workspace.sh b/publish-workspace.sh new file mode 100644 index 0000000..42628c2 --- /dev/null +++ b/publish-workspace.sh @@ -0,0 +1,3 @@ +# this assumes that you have cloned the introprog repo +# next to the introprog-scalalib repo +cp -R src ../introprog/workspace/introprog/. diff --git a/src/main/scala/introprog/BlockGame.scala b/src/main/scala/introprog/BlockGame.scala new file mode 100644 index 0000000..2222d9a --- /dev/null +++ b/src/main/scala/introprog/BlockGame.scala @@ -0,0 +1,172 @@ +package introprog + +import java.awt.Color + +/** A class for creating games with block-based graphics. + * See example usage in [introprog.examples.TestBlockGame](https://github.com/lunduniversity/introprog-scalalib/blob/master/src/main/scala/introprog/examples/TestBlockGame.scala#L7) + * + * @constructor Create a new game. + * @param title the title of the window + * @param dim the (width, height) of the window in number of blocks + * @param blockSize the side of each square block in pixels + * @param background the color used when clearing pixels + * @param framesPerSecond the desired update rate in the gameLoop + * @param messageAreaHeight the height in pixels of the message area + * @param messageAreaBackground the color of the message area background + */ +abstract class BlockGame( + val title: String = "BlockGame", + val dim: (Int, Int) = (50, 50), + val blockSize: Int = 15, + val background: Color = Color.black, + var framesPerSecond: Int = 50, + val messageAreaHeight: Int = 2, + val messageAreaBackground: Color = Color.gray.darker.darker +): + import introprog.PixelWindow + + /** Called when a key is pressed. Override if you want non-empty action. + * @param key is a string representation of the pressed key + */ + def onKeyDown(key: String): Unit = () + + /** Called when a key is released. Override if you want non-empty action. + * @param key is a string representation of the released key + */ + def onKeyUp(key: String): Unit = () + + /** Called when mouse is pressed. Override if you want non-empty action. + * @param pos the mouse position in underlying `pixelWindow` coordinates + */ + def onMouseDown(pos: (Int, Int)): Unit = () + + /** Called when mouse is released. Override if you want non-empty action. + * @param pos the mouse position in underlying `pixelWindow` coordinates + */ + def onMouseUp(pos: (Int, Int)): Unit = () + + /** Called when window is closed. Override if you want non-empty action. */ + def onClose(): Unit = () + + /** Called in each `gameLoop` iteration. Override if you want non-empty action. */ + def gameLoopAction(): Unit = () + + /** Called if no time is left in iteration to keep frame rate. + * Default action is to print a warning message. + */ + def onFrameTimeOverrun(elapsedMillis: Long): Unit = + println(s"Warning: Unable to handle $framesPerSecond fps. Loop time: $elapsedMillis ms") + + /** Returns the gameLoop delay in ms implied by `framesPerSecond`.*/ + def gameLoopDelayMillis: Int = (1000.0 / framesPerSecond).round.toInt + + /** The underlying window used for drawing blocks and messages. */ + protected val pixelWindow: PixelWindow = + new PixelWindow( + width = dim._1 * blockSize, + height = (dim._2 + messageAreaHeight) * blockSize + blockSize / 2, + title, + background + ) + + /** Internal buffer with block colors. */ + private val blockBuffer: Array[Array[Color]] = Array.fill(dim._1, dim._2)(background) + + /** Internal buffer with update flags. */ + private val isBufferUpdated: Array[Array[Boolean]] = Array.fill(dim._1, dim._2)(false) + + /** Internal buffer for post-update actions. */ + private val toDoAfterBlockUpdates = collection.mutable.Buffer.empty[() => Unit] + + /** Max time for awaiting events from underlying window in ms. */ + protected val MaxWaitForEventMillis = 1 + + clearWindow() // erase all blocks + + /** The game loop that continues while not `stopWhen` is true. + * It draws only updated blocks aiming at the desired frame rate. + * It calls each `onXXX` method if a corresponding event is detected. + * Use the call-by-name `stopWhen` to pass a condition that ends the loop if false. + * See example usage in `introprog.examples.TestBlockGame`. + */ + protected def gameLoop(stopWhen: => Boolean): Unit = while !stopWhen do + import PixelWindow.Event + val t0 = System.currentTimeMillis + pixelWindow.awaitEvent(MaxWaitForEventMillis.toLong) + while pixelWindow.lastEventType != PixelWindow.Event.Undefined do + pixelWindow.lastEventType match + case Event.KeyPressed => onKeyDown(pixelWindow.lastKey) + case Event.KeyReleased => onKeyUp(pixelWindow.lastKey) + case Event.WindowClosed => onClose() + case Event.MousePressed => onMouseDown(pixelWindow.lastMousePos) + case Event.MouseReleased => onMouseUp(pixelWindow.lastMousePos) + case _ => + pixelWindow.awaitEvent(1) + gameLoopAction() + drawUpdatedBlocks() + val elapsed = System.currentTimeMillis - t0 + if (gameLoopDelayMillis - elapsed) < MaxWaitForEventMillis then + onFrameTimeOverrun(elapsed) + Thread.sleep((gameLoopDelayMillis - elapsed) max 0) + + /** Draw updated blocks and carry out post-update actions if any. */ + private def drawUpdatedBlocks(): Unit = + for x <- blockBuffer.indices do + for y <- blockBuffer(x).indices do + if isBufferUpdated(x)(y) then + val pwx = x * blockSize + val pwy = y * blockSize + pixelWindow.fill(pwx, pwy, blockSize, blockSize, blockBuffer(x)(y)) + isBufferUpdated(x)(y) = false + toDoAfterBlockUpdates.foreach(_.apply()) + toDoAfterBlockUpdates.clear() + + /** Erase all blocks to background color. */ + def clearWindow(): Unit = + pixelWindow.clear() + clearMessageArea() + for x <- blockBuffer.indices do + for y <- blockBuffer(x).indices do + blockBuffer(x)(y) = background + + /** Paint a block in color `c` at (`x`,`y`) in block coordinates. */ + def drawBlock(x: Int, y: Int, c: Color): Unit = + if blockBuffer(x)(y) != c then + blockBuffer(x)(y) = c + isBufferUpdated(x)(y) = true + + /** Erase the block at (`x`,`y`) to `background` color. */ + def eraseBlock(x: Int, y: Int): Unit = + if blockBuffer(x)(y) != background then + blockBuffer(x)(y) = background + isBufferUpdated(x)(y) = true + + /** Write `msg` in `color` in the middle of the window. + * The drawing is postponed until the end of the current game loop + * iteration and thus the text drawn on top of any updated blocks. + */ + def drawCenteredText(msg: String, color: Color = pixelWindow.foreground, size: Int = blockSize): Unit = + toDoAfterBlockUpdates.append( () => + pixelWindow.drawText( + msg, + (pixelWindow.width / 2 - msg.length * size / 3) max size, + pixelWindow.height / 2 - size, color, + size + ) + ) + + /** Write `msg` in `color` in the message area at ('x','y') in block coordinates. */ + def drawTextInMessageArea(msg: String, x: Int, y: Int, color: Color = pixelWindow.foreground, size: Int = blockSize): Unit = + require(y < messageAreaHeight && y >= 0, s"not in message area: y = $y") + require(x < dim._1 * blockSize && x >= 0, s"not in message area: x = $x") + pixelWindow.drawText(msg, x * blockSize, (y + dim._2) * blockSize, color, size) + + /** Clear a rectangle in the message area in block coordinates. */ + def clearMessageArea(x: Int = 0, y: Int = 0, width: Int = dim._1, height: Int = messageAreaHeight): Unit = + require(y < messageAreaHeight && y >= 0, s"not in message area: y = $y") + require(x < dim._1 * blockSize && x >= 0, s"not in message area: x = $x") + pixelWindow.fill( + x * blockSize, (y + dim._2) * blockSize, + width * blockSize, messageAreaHeight * blockSize + blockSize / 2, + messageAreaBackground + ) diff --git a/src/main/scala/introprog/Dialog.scala b/src/main/scala/introprog/Dialog.scala index f0ca72d..b9e17c8 100644 --- a/src/main/scala/introprog/Dialog.scala +++ b/src/main/scala/introprog/Dialog.scala @@ -1,26 +1,35 @@ package introprog /** A module with utilities for creating standard GUI dialogs. */ -object Dialog { +object Dialog: import javax.swing.{JFileChooser, JOptionPane, JColorChooser} Swing.init() // get platform-specific look and feel - /** Show a file choice dialog starting in `startDir` with confirm `button` text. */ - def file(button: String = "Open", startDir: String = "~"): String = { + /** + * Show a file choice dialog starting in `startDir` with confirm `button` text. + * + * @param button the text displayed in this file choice dialog's confirm button + * @param startDir the starting directory of this file choice dialog + * @return the file path entered by user upon pressing confirm button, + * an empty `String` if user pressed the file choice dialog's cancel button + */ + def file(button: String = "Open", startDir: String = "~"): String = val fs = new JFileChooser(new java.io.File(startDir)) - fs.showDialog(null, button) match { + fs.showDialog(null, button) match case JFileChooser.APPROVE_OPTION => Option(fs.getSelectedFile.toString).getOrElse("") case _ => "" - } - } /** Show a dialog with a `message` text. */ def show(message: String): Unit = JOptionPane.showMessageDialog(null, message) - /** Show a `message` asking for input with `init` value. Return user input. + /** + * Show a `message` asking for input with `init` value. Return user input. * - * Returns empty string on Cancel. */ + * @param message prompt text displayed for user + * @param init intitial value displayed in input dialog + * @return user input, or an empty string on Cancel + */ def input(message: String, init: String = ""): String = Option(JOptionPane.showInputDialog(message, init)).getOrElse("") @@ -30,13 +39,22 @@ object Dialog { null, question, title, JOptionPane.OK_CANCEL_OPTION ) == JOptionPane.OK_OPTION - /** Show a selection dialog with `buttons`. Return a string with the chosen button text. */ + /** + * Show a selection dialog with `buttons`. Return a `String` with the chosen button text. + * + * @param message text describing the choice to be made by the user + * @param buttons the sequence of buttons to be displayed in this dialog + * @param title the title of this dialog + * @return a `String` with the chosen button text + */ def select(message: String, buttons: Seq[String], title: String = "Select"): String = scala.util.Try{ val chosenIndex = - JOptionPane.showOptionDialog(null, message, title, JOptionPane.DEFAULT_OPTION, - JOptionPane.QUESTION_MESSAGE, null, buttons.reverse.toArray, null) - buttons(buttons.length - 1 - chosenIndex) + JOptionPane.showOptionDialog( + null, message, title, JOptionPane.DEFAULT_OPTION, + JOptionPane.QUESTION_MESSAGE, null, buttons.toArray, null + ) + buttons(chosenIndex) }.getOrElse("") /** Show a color selection dialog and return the color that the user selected. */ @@ -45,4 +63,3 @@ object Dialog { default: java.awt.Color = java.awt.Color.red ): java.awt.Color = Option(JColorChooser.showDialog(null, message, default)).getOrElse(default) -} diff --git a/src/main/scala/introprog/IO.scala b/src/main/scala/introprog/IO.scala index d193c30..dd4e32a 100644 --- a/src/main/scala/introprog/IO.scala +++ b/src/main/scala/introprog/IO.scala @@ -1,74 +1,240 @@ package introprog -/** A model with input/output operations from/to the underlying file system. */ -object IO { - /** Load a string from a text file called `fileName` using encoding `enc`. */ - def loadString(fileName: String, enc: String = "UTF-8"): String = { - //This implementation risk leak open file handles: - // scala.io.Source.fromFile(fileName, enc).mkString - // Instead: +import java.io.BufferedWriter +import java.io.FileWriter +import java.nio.charset.Charset + +/** A module with input/output operations from/to the underlying file system. */ +object IO: + /** + * Load a string from a text file called `fileName` using encoding `enc`. + * + * @param fileName the path of the file. + * @param enc the encoding of the file. + * @return the content loaded from the file. + * */ + def loadString(fileName: String, enc: String = "UTF-8"): String = var result: String = "" val source = scala.io.Source.fromFile(fileName, enc) - try { result = source.mkString } finally { source.close } + try result = source.mkString finally source.close() result - } - /** Load string lines from a text file called `fileName` using encoding `enc`. */ - def loadLines(fileName: String, enc: String = "UTF-8"): Vector[String] = { - //This implementation risk leak open file handles: - // scala.io.Source.fromFile(fileName, enc).getLines.toVector - // Instead: + /** + * Load string lines from a text file called `fileName` using encoding `enc`. + * + * @param fileName the path of the file. + * @param enc the encoding of the file. + * */ + def loadLines(fileName: String, enc: String = "UTF-8"): Vector[String] = var result = Vector.empty[String] val source = scala.io.Source.fromFile(fileName, enc) - try { result = source.getLines.toVector } finally { source.close } + try result = source.getLines().toVector finally source.close() result - } - /** Save `text` to a text file called `fileName` using encoding `enc`. */ - def saveString(text: String, fileName: String, enc: String = "UTF-8"): Unit = { + /** + * Save `text` to a text file called `fileName` using encoding `enc`. + * + * @param text the text to be written to the file. + * @param fileName the path of the file. + * @param enc the encoding of the file. + * */ + def saveString(text: String, fileName: String, enc: String = "UTF-8"): Unit = val f = new java.io.File(fileName) val pw = new java.io.PrintWriter(f, enc) try pw.write(text) finally pw.close() - } - /** Save `lines` to a text file called `fileName` using encoding `enc`. */ + /** + * Save `lines` to a text file called `fileName` using encoding `enc`. + * + * @param lines the lines to written to the file. + * @param fileName the path of the file. + * @param enc the encoding of the file. + * */ def saveLines(lines: Seq[String], fileName: String, enc: String = "UTF-8"): Unit = - saveString(lines.mkString("\n"), fileName, enc) + if lines.nonEmpty then saveString(lines.mkString("", "\n", "\n"), fileName, enc) + + /** + * Appends `string` to the text file `fileName` using encoding `enc`. + * + * @param text the text to be appended to the file. + * @param fileName the path of the file. + * @param enc the encoding of the file. + * */ + def appendString(text: String, fileName: String, enc: String = "UTF-8"): Unit = + val f = new java.io.File(fileName); + require(!f.isDirectory(), "The file you're trying to write to can't be a directory.") + val w = + if f.exists() then + new BufferedWriter(new FileWriter(fileName, Charset.forName(enc), true)) + else + new java.io.PrintWriter(f, enc) + try w.write(text) finally w.close() - /** Load a serialized object from a binary file called `fileName`. */ - def loadObject[T](fileName: String): T = { + /** + * Appends `lines` to the text file `fileName` using encoding `enc`. + * + * @param lines the lines to append to the file. + * @param fileName the path of the file. + * @param enc the encoding of the file. + * */ + def appendLines(lines: Seq[String], fileName: String, enc: String = "UTF-8"): Unit = + if lines.nonEmpty then appendString(lines.mkString("","\n","\n"), fileName, enc) + + /** + * Load a serialized object from a binary file called `fileName`. + * + * @param fileName the path of the file. + * @return the serialized object. + * */ + def loadObject[T](fileName: String): T = val f = new java.io.File(fileName) val ois = new java.io.ObjectInputStream(new java.io.FileInputStream(f)) - try { ois.readObject.asInstanceOf[T] } finally ois.close() - } + try ois.readObject.asInstanceOf[T] finally ois.close() - /** Serialize `obj` to a binary file called `fileName`. */ - def saveObject[T](obj: T, fileName: String): Unit = { + /** + * Serialize `obj` to a binary file called `fileName`. + * + * @param obj the object to be serialized. + * @param fileName the path of the file. + * */ + def saveObject[T](obj: T, fileName: String): Unit = val f = new java.io.File(fileName) val oos = new java.io.ObjectOutputStream(new java.io.FileOutputStream(f)) try oos.writeObject(obj) finally oos.close() - } - /** Test if a file with name `fileName` exists. */ + /** + * Test if a file with name `fileName` exists. + * + * @param fileName the path of the file. + * @return true if the file exists else false. + * */ def isExisting(fileName: String): Boolean = new java.io.File(fileName).exists - /** Create a directory with name ´dir´ if it does not exist. */ + /** + * Create a directory with name `dir` if it does not exist. + * + * @param dir the path of the directory to be created. + * @return true if and only if the directory was created, + * along with all necessary parent directories otherwise false. + * */ def createDirIfNotExist(dir: String): Boolean = new java.io.File(dir).mkdirs() - /** Return the path name or the current user's home directory. */ - def userDir: String = System.getProperty("user.home") + /** + * Gets the path of the current user's home directory. + * + * @return the path of the current user's home directory. + * */ + def userDir(): String = System.getProperty("user.home") - /** Return the path name or the current working directory. */ - def currentDir: String = + /** + * Gets the path of the current working directory. + * + * @return the path of the current working directory. + * */ + def currentDir(): String = java.nio.file.Paths.get(".").toAbsolutePath.normalize.toString - /** Return a sequence of file names in the directory `dir`. */ + /** + * Gets a sequence of file names in the directory `dir`. + * + * @param dir the path of the directory to be listed. + * @return a sequence of file names in the directory `dir + * */ def list(dir: String = "."): Vector[String] = Option(new java.io.File(dir).list).map(_.toVector).getOrElse(Vector()) - /** Change name of file `from`, DANGER: silently replaces existing `to`. */ - def move(from: String, to: String): Unit = { + /** + * Change name of file `from`, DANGER: silently replaces existing `to`. + * + * @param from the path of the file to be moved. + * @param to the path the file will be moved to. + * */ + def move(from: String, to: String): Unit = import java.nio.file.{Files, Paths, StandardCopyOption} Files.move(Paths.get(from), Paths.get(to), StandardCopyOption.REPLACE_EXISTING) - } -} + + /** + * Deletes `fileName`. + * + * @param fileName the path the file that will be deleted. + * */ + def delete(fileName: String): Unit = + import java.nio.file.{Files, Paths} + Files.delete(Paths.get(fileName)) + + /** + * Load image from file. + * + * @param fileName the path to the image that will be loaded. + * */ + def loadImage(fileName: String): Image = + loadImage(java.io.File(fileName)) + + /** + * Load image from file. + * + * @param file the file that will be loaded. + * */ + def loadImage(file: java.io.File): Image = + Image(javax.imageio.ImageIO.read(file)) + + /** + * Save `img` to file as `JPEG`. Does not restore color of transparent pixels. + * + * @param image the image to save. + * @param fileName the path to save the image to, `path/file.jpg` or just `path/file` + * @param compression the compression factor to use `(0.0-1.0)`. + * */ + def saveJPEG(img: Image, fileName: String, compression: Double) : Unit = + require(compression <= 1.0 && compression >= 0.0, "compression must be within 0.0 and 1.0") + import javax.imageio.{stream, ImageIO, IIOImage, ImageWriteParam} + import javax.imageio.plugins.jpeg.JPEGImageWriteParam + import java.awt.image.BufferedImage + //set compression values + val jpegParams = JPEGImageWriteParam(null); + jpegParams.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + jpegParams.setCompressionQuality(compression.toFloat); + //create writer + val writer = ImageIO.getImageWritersByFormatName("jpg").next(); + // specifies where the jpg image has to be written + val path = if fileName.endsWith(".jpg") then fileName else s"$fileName.jpg" + writer.setOutput(new stream.FileImageOutputStream( + java.io.File(path))) + // writes the file with given compression level + // from JPEGImageWriteParam instance + writer.write( + null, + IIOImage( + (if img.hasAlpha then img.withoutAlpha else img).underlying, //remove alpha channel + null, + null) + ,jpegParams) //add compression details + + + /** + * Save `img` to file as `JPEG` with a compression ratio of 0.75. + * Restore color of transparent pixels. + * @param img the image to save. + * @param fileName the path to save the image to, `path/file.jpg` or just `path/file`. + * */ + def saveJPEG(img: Image, fileName: String) : Unit = + import javax.imageio.ImageIO + import java.io.File + if !ImageIO.write(img.underlying, "jpg", File(if fileName.endsWith(".jpg") then fileName else s"$fileName.jpg")) then + throw java.io.IOException("no appropriate writer is found") + + /** + * Save `img` to file as `PNG`. + * + * @param img the image to save. + * @param fileName the path to save the image to, `path/file.png` or just `path/file`. + * */ + def savePNG(img: Image, fileName: String) : Unit = + import javax.imageio.ImageIO + import java.io.File + if !ImageIO.write(img.underlying, "png", File(if fileName.endsWith(".png") then fileName else s"$fileName.png")) then + throw java.io.IOException("no appropriate writer is found") + + + + diff --git a/src/main/scala/introprog/Image.scala b/src/main/scala/introprog/Image.scala new file mode 100644 index 0000000..669de8d --- /dev/null +++ b/src/main/scala/introprog/Image.scala @@ -0,0 +1,71 @@ +package introprog + +/** Companion object to create Image instances. */ +object Image: + import java.awt.image.BufferedImage + /** Create new empty Image with specified dimensions `(width, height)`*/ + def ofDim(width: Int, height: Int) = + Image(BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)) + +/** Image represents pixel arrays backed by underlying java.awtimage.BufferedImage */ +class Image (val underlying: java.awt.image.BufferedImage): + import java.awt.Color + import java.awt.image.BufferedImage + + /** Get color of pixel at `(x, y)`.*/ + def apply(x: Int, y: Int): Color = Color(underlying.getRGB(x, y)) + + /** Set color of pixel at `(x, y)`.*/ + def update(x: Int, y: Int, c: Color): Unit = underlying.setRGB(x, y, c.getRGB) + + /** Set color of pixels by passing `f(x, y)`*/ + def update(f: (Int, Int) => Color): Unit = + for x <- 0 until width; y <- 0 until height do + update(x, y, f(x, y)) + + /** Set color of pixels by passing `f(x, y)` and return self. */ + def updated(f: (Int, Int) => Color): Image = + for x <- 0 until width; y <- 0 until height do + update(x, y, f(x, y)) + this + + /** Extract and return image pixels. */ + def toMatrix: Array[Array[Color]] = + val xs: Array[Array[Color]] = Array.ofDim(width, height) + for x <- 0 until width; y <- 0 until height do + xs(x)(y) = apply(x, y) + xs + + /** Copy subsection of image defined by top left corner `(x, y)` and `(width, height)`.*/ + def subsection(x: Int, y: Int, width: Int, height: Int): Image = + val bi = BufferedImage(width, height, underlying.getType) + bi.createGraphics().drawImage(underlying, 0, 0, width, height, x, y, x + width, y + height, null) + Image(bi) + + /** Copy image and scale to `(width, height)`.*/ + def scaled(width: Int, height: Int): Image = + val bi = BufferedImage(width, height, underlying.getType) + bi.createGraphics().drawImage(underlying, 0, 0, width, height, null) + Image(bi) + + /** Copy image and change image type to ARGB, including alpha channel*/ + def withAlpha: Image = toImageType(BufferedImage.TYPE_INT_ARGB) + + /** Copy image and change image type to RGB, removing alpha channel*/ + def withoutAlpha: Image = toImageType(BufferedImage.TYPE_INT_RGB) + + /** Copy image and change image type ex. BufferedImage.TYPE_INT_RGB*/ + private def toImageType(imageType: Int): Image = + val bi = BufferedImage(width, height, imageType) + bi.createGraphics().drawImage(underlying, 0, 0, width, height, null) + Image(bi) + + /** Test if alpha channel is supperted. */ + val hasAlpha = underlying.getColorModel.hasAlpha + + /** The height of this image. */ + val height = underlying.getHeight + + /** The width of this image. */ + val width = underlying.getWidth + \ No newline at end of file diff --git a/src/main/scala/introprog/PixelWindow.scala b/src/main/scala/introprog/PixelWindow.scala index 7c884e9..939ec06 100644 --- a/src/main/scala/introprog/PixelWindow.scala +++ b/src/main/scala/introprog/PixelWindow.scala @@ -1,15 +1,43 @@ package introprog /** A module with utilities for event handling in `PixelWindow` instances. */ -object PixelWindow { +object PixelWindow: /** Immediately exit running application, close all windows, kills all threads. */ def exit(): Unit = System.exit(0) /** Idle waiting for `millis` milliseconds. */ def delay(millis: Long): Unit = Thread.sleep(millis) + /** A map with string representations for special key codes. */ + private val keyTextLookup: Map[Int, String] = + import java.awt.event.KeyEvent._ + Map( + VK_META -> "Meta", + VK_WINDOWS -> "Meta", + VK_CONTROL -> "Ctrl", + VK_ALT -> "Alt", + VK_ALT_GRAPH -> "Alt Gr", + VK_SHIFT -> "Shift", + VK_CAPS_LOCK -> "Caps Lock", + VK_ENTER -> "Enter", + VK_DELETE -> "Delete", + VK_BACK_SPACE -> "Backspace", + VK_ESCAPE -> "Esc", + VK_RIGHT -> "Right", + VK_LEFT -> "Left", + VK_UP -> "Up", + VK_DOWN -> "Down", + VK_PAGE_UP -> "Page up", + VK_PAGE_DOWN -> "Page down", + VK_HOME -> "Home", + VK_END -> "End", + VK_CLEAR -> "Clear", + VK_TAB -> "Tab", + VK_SPACE -> " ", + ) + /** An object with integers representing events that can happen in a PixelWindow. */ - object Event { + object Event: /** An integer representing a key down event. * * This value is returned by [[introprog.PixelWindow.lastEventType]] when @@ -58,7 +86,7 @@ object PixelWindow { val Undefined = 0 /** Returns a descriptive text for each `event`. */ - def show(event: Int): String = event match { + def show(event: Int): String = event match case KeyPressed => "KeyPressed" case KeyReleased => "KeyReleased" case MousePressed => "MousePressed" @@ -67,11 +95,8 @@ object PixelWindow { case Undefined => "Undefined" case _ => throw new IllegalArgumentException(s"Unknown event number: $event") - } - } -} -/** A window with a canvas for pixel-based drawing. +/** A window with a canvas for pixel-based drawing. Y-coordinates are increasing downwards. * * @constructor Create a new window for pixel-based drawing. * @param width the number of horizontal pixels @@ -124,51 +149,53 @@ class PixelWindow( initFrame() // initialize listeners, show frame, etc. /** Event dispatching, translating internal AWT events to exposed events. */ - private def handleEvent(e: java.awt.AWTEvent): Unit = e match { + private def handleEvent(e: java.awt.AWTEvent): Unit = e match case me: java.awt.event.MouseEvent => _lastMousePos = (me.getX, me.getY) - me.getID match { + me.getID match case java.awt.event.MouseEvent.MOUSE_PRESSED => _lastEventType = Event.MousePressed case java.awt.event.MouseEvent.MOUSE_RELEASED => _lastEventType = Event.MouseReleased case _ => throw new IllegalArgumentException(s"Unknown MouseEvent: $e") - } case ke: java.awt.event.KeyEvent => - if (ke.getKeyChar == java.awt.event.KeyEvent.CHAR_UNDEFINED || ke.getKeyChar < ' ') - _lastKeyText = java.awt.event.KeyEvent.getKeyText(ke.getKeyCode) + if ke.getKeyChar == java.awt.event.KeyEvent.CHAR_UNDEFINED || ke.getKeyChar < ' ' then + _lastKeyText = PixelWindow.keyTextLookup.getOrElse(ke.getKeyCode, java.awt.event.KeyEvent.getKeyText(ke.getKeyCode)) else _lastKeyText = ke.getKeyChar.toString - ke.getID match { + ke.getID match case java.awt.event.KeyEvent.KEY_PRESSED => _lastEventType = Event.KeyPressed case java.awt.event.KeyEvent.KEY_RELEASED => _lastEventType = Event.KeyReleased - case _ => - throw new IllegalArgumentException(s"Unknown KeyEvent: $e") - } + case _ => + throw new IllegalArgumentException(s"Unknown KeyEvent: $e") case we: java.awt.event.WindowEvent => - we.getID match { + we.getID match case java.awt.event.WindowEvent.WINDOW_CLOSING => _lastEventType = Event.WindowClosed case _ => throw new IllegalArgumentException(s"Unknown WindowEvent: $e") - } - case _ => - throw new IllegalArgumentException(s"Unknown Event: $e") - } + + case _ => + throw new IllegalArgumentException(s"Unknown Event: $e") + + /** Return `true` if `(x, y)` is inside windows borders else `false`. */ + def isInside(x: Int, y: Int): Boolean = x >= 0 && x < width && y >= 0 && y < height + + private def requireInside(x: Int, y: Int): Unit = + require(isInside(x,y), s"(x=$x, y=$y) out of window bounds (0 until $width, 0 until $height)") /** Wait for next event until `timeoutInMillis` milliseconds. * * If time is out, `lastEventType` is `Undefined`. */ - def awaitEvent(timeoutInMillis: Long = 1): Unit = { + def awaitEvent(timeoutInMillis: Long = 1): Unit = val e = eventQueue.poll(timeoutInMillis, java.util.concurrent.TimeUnit.MILLISECONDS) - if (e != null) handleEvent(e) else _lastEventType = Event.Undefined - } + if e != null then handleEvent(e) else _lastEventType = Event.Undefined /** Draw a line from (`x1`, `y1`) to (`x2`, `y2`) using `color` and `lineWidth`. */ def line(x1: Int, y1: Int, x2: Int, y2: Int, color: java.awt.Color = foreground, lineWidth: Int = 1): Unit = @@ -187,22 +214,48 @@ class PixelWindow( g.fillRect(x, y, width, height) } - /** Set the color of the pixel at `(x, y)`. */ + /** Set the color of the pixel at `(x, y)`. + * + * If (x, y) is outside of window bounds then an IllegalArgumentException is thrown. + */ def setPixel(x: Int, y: Int, color: java.awt.Color = foreground): Unit = + requireInside(x, y) canvas.withImage { img => img.setRGB(x, y, color.getRGB) } - /** Clear the pixel at `(x, y)` using the `background` class parameter. */ + /** Clear the pixel at `(x, y)` using the `background` class parameter. + * + * If (x, y) is outside of window bounds then an IllegalArgumentException is thrown. + */ def clearPixel(x: Int, y: Int): Unit = + requireInside(x, y) canvas.withImage { img => img.setRGB(x, y, background.getRGB) } - /** Return the color of the pixel at `(x, y)`. */ - def getPixel(x: Int, y: Int): java.awt.Color = Swing.await { - new java.awt.Color(canvas.img.getRGB(x, y)) - } + /** Return the color of the pixel at `(x, y)`. + * + * If (x, y) is outside of window bounds then an IllegalArgumentException is thrown. + */ + def getPixel(x: Int, y: Int): java.awt.Color = + requireInside(x, y) + Swing.await { new java.awt.Color(canvas.img.getRGB(x, y)) } + + + /** Return image of PixelWindow. */ + def getImage: Image = + import java.awt.image.BufferedImage + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + Swing.await{img.getGraphics.drawImage(canvas.img, 0, 0, null)} + Image(img) + + /** Return image of PixelWindow section defined by top left corner `(x, y)` and `(width, height)`. */ + def getImage(x: Int, y: Int, width: Int, height: Int) : Image = + getImage.subsection(x, y, width, height) + + /** Set the PixelWindow frame title. */ + def setTitle(title: String): Unit = Swing { frame.setTitle(title) } /** Show the window. Has no effect if the window is already visible. */ def show(): Unit = Swing { frame.setVisible(true) } @@ -210,6 +263,9 @@ class PixelWindow( /** Hide the window. Has no effect if the window is already hidden. */ def hide(): Unit = Swing { frame.setVisible(false); frame.dispose() } + /** Set window position on screen*/ + def setPosition(x: Int, y: Int): Unit = frame.setBounds(x, y, width, height) + /** Clear all pixels using the `background` class parameter. */ def clear(): Unit = canvas.withGraphics { g => g.setColor(background) @@ -225,7 +281,7 @@ class PixelWindow( size: Int = 16, style: Int = java.awt.Font.BOLD, fontName: String = java.awt.Font.MONOSPACED - ) = { + ) = canvas.withGraphics { g => import java.awt.RenderingHints._ // https://docs.oracle.com/javase/tutorial/2d/text/renderinghints.html @@ -235,13 +291,48 @@ class PixelWindow( g.setColor(color) g.drawString(text, x, y + size) } - } + + + /** Draw `img` at `(x, y)` scaled to `(width, height)` and rotated `(angle)` radians clockwise. + * + * If angle is 0 then no rotation is applied. + */ + def drawImage( + img: Image, + x: Int, + y: Int, + width: Int, + height: Int, + angle: Double = 0 + ): Unit = + if angle == 0 then + canvas.withGraphics(_.drawImage(img.underlying, x, y, width, height, null)) + else + val at = new java.awt.geom.AffineTransform() + at.translate(x, y) + at.rotate(angle, width/2, height/2) + canvas.withGraphics(_.drawImage(img.scaled(width, height).underlying, at, null)) + + /** Draw `img` at `(x, y)` unscaled. */ + def drawImage(img: Image, x: Int, y: Int): Unit = + drawImage(img, x, y, img.width, img.height) + + /** Draw `matrix` at `(x, y)` unscaled. */ + def drawMatrix(matrix: Array[Array[java.awt.Color]], x: Int, y: Int): Unit = + for + xx <- 0 until matrix.length + yy <- 0 until matrix(xx).length + do + setPixel(xx+x, yy+y, matrix(xx)(yy)) + /** Create the underlying window and add listeners for event management. */ private def initFrame(): Unit = Swing { Swing.init() // first time calls setPlatformSpecificLookAndFeel javax.swing.JFrame.setDefaultLookAndFeelDecorated(true) + frame.setFocusTraversalKeysEnabled(false); + frame.addWindowListener(new java.awt.event.WindowAdapter { override def windowClosing(e: java.awt.event.WindowEvent): Unit = { frame.setVisible(false) @@ -271,4 +362,4 @@ class PixelWindow( frame.pack() frame.setVisible(true) } -} +} \ No newline at end of file diff --git a/src/main/scala/introprog/Swing.scala b/src/main/scala/introprog/Swing.scala index 4de91ce..c05b7ae 100644 --- a/src/main/scala/introprog/Swing.scala +++ b/src/main/scala/introprog/Swing.scala @@ -1,7 +1,7 @@ package introprog /** A module with Swing utilities used by [[introprog.PixelWindow]]. */ -object Swing { +object Swing: private def runInSwingThread(callback: => Unit): Unit = javax.swing.SwingUtilities.invokeLater(() => callback) @@ -10,7 +10,7 @@ object Swing { def apply(callback: => Unit): Unit = runInSwingThread(callback) /** Run `callback` in the Swing thread and block until completion. */ - def await[T: scala.reflect.ClassTag](callback: => T): T = { + def await[T: scala.reflect.ClassTag](callback: => T): T = val ready = new java.util.concurrent.CountDownLatch(1) val result = new Array[T](1) runInSwingThread { @@ -19,7 +19,6 @@ object Swing { } ready.await result(0) - } /** Return a sequence of available look and feel options. */ def installedLookAndFeels: Vector[String] = @@ -31,50 +30,60 @@ object Swing { /** Test if the current operating system name includes `partOfName`. */ def isOS(partOfName: String): Boolean = - scala.sys.props("os.name").toLowerCase.contains(partOfName.toLowerCase) + if partOfName.toLowerCase.startsWith("win") && isInProc("windows", "wsl", "microsoft") then true //WSL + else scala.sys.props("os.name").toLowerCase.contains(partOfName.toLowerCase) + + /** Check whether `/proc/version` on this filesystem contains any of the strings in `parts`. + * Can be used to detect if we are on WSL instead of "real" linux/ubuntu. + */ + private def isInProc(parts: String*): Boolean = + util.Try(parts.map(_.toLowerCase) + .exists(part => IO.loadString("/proc/version").toLowerCase.contains(part))) + .getOrElse(false) private var isInit = false /** Init the Swing GUI toolkit and set platform-specific look and feel.*/ - def init(): Unit = if (!isInit) { + def init(): Unit = if !isInit then setPlatformSpecificLookAndFeel() isInit = true - } - private def setPlatformSpecificLookAndFeel(): Unit = { + private def setPlatformSpecificLookAndFeel(): Unit = import javax.swing.UIManager.setLookAndFeel - if (isOS("linux")) findLookAndFeel("gtk").foreach(setLookAndFeel) - else if (isOS("win")) findLookAndFeel("win").foreach(setLookAndFeel) - else if (isOS("mac")) findLookAndFeel("apple").foreach(setLookAndFeel) + if isOS("win") then findLookAndFeel("win").foreach(setLookAndFeel) + else if isOS("linux") then findLookAndFeel("gtk").foreach(setLookAndFeel) + else if isOS("mac") then findLookAndFeel("apple").foreach(setLookAndFeel) else javax.swing.UIManager.setLookAndFeel( javax.swing.UIManager.getSystemLookAndFeelClassName() ) - } /** A Swing `JPanel` to create drawing windows for 2D graphics. */ class ImagePanel( val initWidth: Int, val initHeight: Int, val initBackground: java.awt.Color - ) extends javax.swing.JPanel { + ) extends javax.swing.JPanel: val img: java.awt.image.BufferedImage = java.awt.GraphicsEnvironment .getLocalGraphicsEnvironment .getDefaultScreenDevice .getDefaultConfiguration .createCompatibleImage(initWidth, initHeight, java.awt.Transparency.OPAQUE) + val g: java.awt.Graphics2D = img.createGraphics() + g.setColor(initBackground) + g.fillRect(0, 0, initWidth, initHeight) + setBackground(initBackground) - setDoubleBuffered(true) - setPreferredSize(new java.awt.Dimension(initWidth, initHeight)) - setMinimumSize(new java.awt.Dimension(initWidth, initHeight)) - setMaximumSize(new java.awt.Dimension(initWidth, initHeight)) + setDoubleBuffered(true) + setPreferredSize(new java.awt.Dimension(initWidth, initHeight)) + setMinimumSize(new java.awt.Dimension(initWidth, initHeight)) + setMaximumSize(new java.awt.Dimension(initWidth, initHeight)) override def paintComponent(g: java.awt.Graphics): Unit = g.drawImage(img, 0, 0, this) - override def imageUpdate(img: java.awt.Image, infoFlags: Int, x: Int, y: Int, width: Int, height: Int): Boolean = { - repaint() + override def imageUpdate(img: java.awt.Image, infoFlags: Int, x: Int, y: Int, width: Int, height: Int): Boolean = + repaint() true - } /** Execute `action` in the Swing thread with graphics context as param. */ def withGraphics(action: java.awt.Graphics2D => Unit) = runInSwingThread { @@ -87,5 +96,3 @@ object Swing { action(img) repaint() } - } -} diff --git a/src/main/scala/introprog/examples/TestBlockGame.scala b/src/main/scala/introprog/examples/TestBlockGame.scala new file mode 100644 index 0000000..5f7d810 --- /dev/null +++ b/src/main/scala/introprog/examples/TestBlockGame.scala @@ -0,0 +1,124 @@ +package introprog.examples + +/** Examples of a simple BlockGame app with overridden callbacks to handle events + * See the documentation of BlockGame and the source code of TestBlockGame + * for inspiration on how to inherit BlockGame to create your own block game. + */ +object TestBlockGame: + /** Create Game and start playing. */ + def main(args: Array[String]): Unit = + println("Press Enter to toggle random blocks. Close window to continue.") + (new RandomBlocks).play() + println("Opening MovingBlock. Press Ctrl+C to exit.") + (new MovingBlock).start() + println("MovingBlock has ended.") + + /** A class extending `introprog.BlockGame`, see source code. */ + class RandomBlocks extends introprog.BlockGame: + + sealed trait State + case object Starting extends State + case object Playing extends State + case object GameOver extends State + + var state: State = Starting + var isDrawingRandomBlocks: Boolean = false + + def showEnterMessage(): Unit = + drawTextInMessageArea("Press Enter to toggle random blocks.", 0,0) + + def showEscapeMessage(): Unit = + drawTextInMessageArea("Press Esc to clear window.", 25, 0) + + override def onKeyDown(key: String): Unit = + print(s" Key down: $key") + key match + case "Esc" => + clearWindow() + drawCenteredText("ESCAPED TO BLACK SPACE!") + showEnterMessage() + case "Enter" => + isDrawingRandomBlocks = !isDrawingRandomBlocks + showEnterMessage() + showEscapeMessage() + case _ => + + override def onKeyUp(key: String): Unit = print(s" Key up: $key") + + override def onMouseDown(pos: (Int, Int)): Unit = print(s" Mouse down: $pos") + + override def onMouseUp(pos: (Int, Int)): Unit = print(s" Mouse up: $pos") + + override def onClose(): Unit = + print(" Window Closed.") + state = GameOver + override def gameLoopAction(): Unit = + import scala.util.Random.nextInt + def rndPos: (Int, Int) = (nextInt(dim._1), nextInt(dim._2)) + def rndColor = new java.awt.Color(nextInt(256), nextInt(256), nextInt(256)) + print(".") + if isDrawingRandomBlocks then + drawBlock(rndPos._1, rndPos._2, rndColor) + + def play(): Unit = + state = Playing + println(s"framesPerSecond == $framesPerSecond") + showEnterMessage() + gameLoop(stopWhen = state == GameOver) + println("Goodbye!") + + end RandomBlocks + + class MovingBlock extends introprog.BlockGame( + title = "MovingBlock", + dim = (10,5), + blockSize = 40, + background = java.awt.Color.BLACK, + framesPerSecond = 50, + messageAreaHeight = 1, + messageAreaBackground = java.awt.Color.DARK_GRAY + ): + + var movesPerSecond: Double = 2 + + def millisBetweenMoves: Int = (1000 / movesPerSecond).round.toInt max 1 + + var _timestampLastMove: Long = System.currentTimeMillis + + def timestampLastMove = _timestampLastMove + + var x = 0 + + var y = 0 + + def move(): Unit = + if x == dim._1 - 1 then + x = -1 + y += 1 + end if + x = x+1 + + def erase(): Unit = drawBlock(x, y, java.awt.Color.BLACK) + + def draw(): Unit = drawBlock(x, y, java.awt.Color.CYAN) + + def update(): Unit = + if System.currentTimeMillis > _timestampLastMove + millisBetweenMoves then + move() + _timestampLastMove = System.currentTimeMillis() + + var loopCounter: Int = 0 + + override def gameLoopAction(): Unit = + erase() + update() + draw() + clearMessageArea() + drawTextInMessageArea(s"Loop number: $loopCounter", 1, 0, java.awt.Color.PINK, size = 30) + loopCounter += 1 + + final def start(): Unit = + pixelWindow.show() // show window again if closed and start() is called again + gameLoop(stopWhen = x == dim._1 - 1 && y == dim._2 - 1) + + end MovingBlock diff --git a/src/main/scala/introprog/examples/TestIO.scala b/src/main/scala/introprog/examples/TestIO.scala new file mode 100644 index 0000000..17c90fa --- /dev/null +++ b/src/main/scala/introprog/examples/TestIO.scala @@ -0,0 +1,82 @@ +package introprog.examples + +/** Example of serializing objects to and from binary files on disk. */ +object TestIO: + import introprog.IO + + case class Person(name: String) + + def main(args: Array[String]): Unit = + println("Test of IO of serializable objects to/from disk:") + val highscores = Map(Person("Sandra") -> 42, Person("Björn") -> 5) + + // serialize to disk: + IO.saveObject(highscores,"highscores.ser") + + // de-serialize back from disk: + val highscores2 = IO.loadObject[Map[Person, Int]]("highscores.ser") + + val isSameContents = highscores2 == highscores + val testResult = if isSameContents then "SUCCESS :)" else "FAILURE :(" + assert(isSameContents, s"$highscores != $highscores2") + println(s"$highscores == $highscores2\n$testResult") + + testImageLoadAndDraw() + + def testImageLoadAndDraw(): Unit = + import introprog.* + import java.awt.Color + import java.awt.Color.* + + val wSize = (4*128, 3*128) + val w = new PixelWindow(wSize._1, wSize._2, "DrawImage"); + val w2 = new PixelWindow(wSize._1, wSize._2, "DrawMatrix") + val w3 = new PixelWindow((wSize._1*1.5).toInt, (wSize._2*1.5).toInt, "SaveLoadAsJpeg") + w.setPosition(0,0) + w2.setPosition(wSize._1, 0) + w3.setPosition(0, wSize._2+50) + //draw text top right + val testMatrix = Array[Array[Color]](Array[Color](blue, yellow, blue), + Array[Color](yellow, yellow, yellow), + Array[Color](blue, yellow, blue), + Array[Color](blue, yellow, blue)) + var flagPos = (0, 0) + var flagSize = (4, 3) + + //draw small flag + w.drawMatrix(testMatrix, 0, 0) + for i <- 1 to 7 do + // extract and save Image + var img = w.getImage(flagPos._1, flagPos._2, flagSize._1, flagSize._2) + IO.savePNG(img, "screenshot") + //draw in other window using drawMatrix + w2.drawMatrix(img.toMatrix, flagPos._1, flagPos._2) + if i != 7 then + //update pos and size + flagPos = (flagPos._1 + flagSize._1,flagPos._2 + flagSize._2) + flagSize = (flagSize._1 * 2,flagSize._2 * 2) + //draw new flag from file + img = IO.loadImage("screenshot.png") + w.drawImage(img.scaled(img.width*2, img.height*2), flagPos._1, flagPos._2) + + var im = w2.getImage + IO.saveJPEG(im, "screenshot.jpg", 0.2) + im = IO.loadImage("screenshot.jpg") + + + for i <- 0 to 200 do + w3.clear() + w3.drawImage(im, 0, 0, (im.width*0.5).toInt, (im.height*0.5).toInt, Math.toRadians(i*2)) + Thread.sleep(100/6) + + + println("Windows should be identical and display 7 flags each.") + println("Press enter to quit.") + val _ = scala.io.StdIn.readLine() + IO.delete("screenshot.png") + IO.delete("screenshot.jpg") + PixelWindow.exit() + +// for file extension choice see: +// https://stackoverflow.com/questions/10433214/file-extension-for-a-serialized-object + diff --git a/src/main/scala/introprog/examples/TestPixelWindow.scala b/src/main/scala/introprog/examples/TestPixelWindow.scala index 9af7382..aa25f72 100644 --- a/src/main/scala/introprog/examples/TestPixelWindow.scala +++ b/src/main/scala/introprog/examples/TestPixelWindow.scala @@ -4,7 +4,7 @@ package introprog.examples * and mouse clicking by the user. See source code for inspiration on how to use * PixelWindow for easy 2D game programming. */ -object TestPixelWindow { +object TestPixelWindow: import introprog.PixelWindow import introprog.PixelWindow.Event @@ -15,15 +15,14 @@ object TestPixelWindow { var color = java.awt.Color.red /** Draw a square with (`x`, `y`) as top left corner and size `side`. */ - def square(x: Int, y: Int, side: Int): Unit = { + def square(x: Int, y: Int, side: Int): Unit = w.line(x, y, x + side, y, color) w.line(x + side, y, x + side, y + side, color) w.line(x + side, y + side, x, y + side, color) w.line(x, y + side, x, y, color) - } /** Draw squares and start an event loop that prints events in terminal. */ - def main(args: Array[String]): Unit = { + def main(args: Array[String]): Unit = println("Key and mouse events are printed. Close window to exit.") w.drawText("HELLO WORLD! 012345ÅÄÖ", 0, 0) square(200, 100, 50) @@ -34,23 +33,18 @@ object TestPixelWindow { square(150,200, 50) w.line(0,0,w.width,w.height) - while (w.lastEventType != Event.WindowClosed) { + while w.lastEventType != Event.WindowClosed do w.awaitEvent(10) // wait for next event for max 10 milliseconds - if (w.lastEventType != Event.Undefined) { + if w.lastEventType != Event.Undefined then println(s"lastEventType: ${w.lastEventType} => ${Event.show(w.lastEventType)}") - } - w.lastEventType match { + w.lastEventType match case Event.KeyPressed => println("lastKey == " + w.lastKey) case Event.KeyReleased => println("lastKey == " + w.lastKey) case Event.MousePressed => println("lastMousePos == " + w.lastMousePos) case Event.MouseReleased => println("lastMousePos == " + w.lastMousePos) case Event.WindowClosed => println("Goodbye!"); System.exit(0) case _ => - } Thread.sleep(100) // wait for 0.1 seconds - } - } -} diff --git a/src/rootdoc.txt b/src/rootdoc.txt deleted file mode 100644 index 0747fb9..0000000 --- a/src/rootdoc.txt +++ /dev/null @@ -1,32 +0,0 @@ -This is the documentation of the `introprog` Scala library with beginner-friendly utilities used in computer science teaching at Lund University. The code repository is hosted at [[https://github.com/lunduniversity/introprog-scalalib]]. - -== Package contents == - -- [[introprog.PixelWindow `introprog.PixelWindow`]] for simple, pixel-based drawing. - -- [[introprog.PixelWindow.Event `introprog.PixelWindow.Event`]] for event management in a PixelWindow. - -- [[introprog.IO `introprog.IO`]] for file system interaction. - -- [[introprog.Dialog `introprog.Dialog`]] for user interaction with standard GUI dialogs. - -- [[introprog.examples `introprog.examples`]] with code examples demonstrating how to use this library. - -== How to use this library with `sbt` == - -If you have [[https://www.scala-sbt.org/ `sbt`]] installed then you can put this text in a file called `build.sbt` - -{{{ -scalaVersion := "2.12.6" -libraryDependencies += "se.lth.cs" %% "introprog" % "1.0.0" -}}} - -When you run `sbt` in terminal the introprog lib is automatically downloaded and made available on your classpath. -You can do things like: - -{{{ -> sbt -sbt> console -scala> val w = new introprog.PixelWindow() -scala> w.fill(100,100,100,100,java.awt.Color.red) -}}} diff --git a/src/test/scala/testIO.scala b/src/test/scala/testIO.scala new file mode 100644 index 0000000..a5d3e44 --- /dev/null +++ b/src/test/scala/testIO.scala @@ -0,0 +1,28 @@ +package introprog + +val tmpDir = "target/tmp" +def createTmp(): Boolean = IO.createDirIfNotExist(tmpDir) + +class TestIO extends munit.FunSuite: + + test("TestIO: createDirIfNotExist"): + val existed = createTmp() + assert(IO.isExisting(tmpDir), s"dir should exists: $tmpDir") + + test("TestIO: saveString, loadString, appendString, loadLines, appendLines"): + createTmp() + val s1 = "hello" + val fn = s"$tmpDir/hello.txt" + IO.saveString(s1, fileName = fn) + val s2 = IO.loadString(fileName = fn) + assertEquals(s1, s2, "saved string different from loaded") + IO.appendString("!\n", fileName = fn ) + val s3 = IO.loadString(fileName = fn) + assertEquals(s3, s2 + "!\n", "saved string is missing appended '!+newline'") + IO.appendLines(Seq("line2"),fileName = fn) + val s4 = IO.loadLines(fileName = fn) + assertEquals(s4, Vector("hello!", "line2"), s"loadLines not as expected: $s4") + val s5 = IO.loadString(fileName = fn) + assertEquals(s5, "hello!\nline2\n", s"loadLines not as expected: $s5") + IO.appendLines(Seq(),fileName = fn) // nothing should be added, not even newline + assertEquals(s5, IO.loadString(fileName = fn), s"loadLines not as expected: $s5")