diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..3a1a008 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,11 @@ +name: Build +on: [push] +jobs: + Test: + runs-on: ubuntu-20.04 + steps: + - name: Check out repository code + uses: actions/checkout@v2 + - name: SBT Build + run: sbt test + shell: bash diff --git a/.gitignore b/.gitignore index 087f83b..dee274a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,9 @@ tmp .classpath **/.metals **/.bloop - +**/.bsp +**/metals.sbt +.vscode **/*.cache-main # Eclipse **/.metadata # Eclipse diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 581a413..0000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -sudo: required -dist: trusty - -language: scala -scala: 2.12.8 - -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 d4c7f97..802218e 100644 --- a/PUBLISH.md +++ b/PUBLISH.md @@ -68,32 +68,81 @@ openpgp-revocs.d pubring.asc trustdb.gpg ## How to publish -1. Build and test locally. +1. Build and test locally using `sbt "compile;test;doc"` -2. Bump version in `build.sbt`, run `sbt package`. 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 `rootdoc.txt` file with current version information and package contents etc.: https://github.com/lunduniversity/introprog-scalalib/blob/master/src/rootdoc.txt +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_2.12-x.y.z.jar + - 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 `sbt` run `publishSigned` +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. 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 -5. Click on *Staging Repositories* in the Build Promotion list to the left. Click "Refresh" if list is empty. https://oss.sonatype.org/#stagingRepositories +4. In `sbt>` run `publishSigned` - a plus sign is not used since we only publish for Scala 3 from 1.2.0. -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` +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. -7. 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`. +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 +``` + +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")` + +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 + +6. Click on *Staging Repositories* in the Build Promotion list to the left. Click "Refresh" if list is empty. 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` + +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`. + +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_2.12-x.y.z.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. 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... +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 publicly 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 7dc3c75..0e5f174 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 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! @@ -12,43 +14,97 @@ This repo is used in this course *(in Swedish)*: http://cs.lth.se/pgk with cours ## 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: ``` -scalaVersion := "2.12.10" -libraryDependencies += "se.lth.cs" %% "introprog" % "1.1.4" +scala repl . --dep 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. -You can do things like: +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) ``` -> sbt -sbt> console -scala> val w = new introprog.PixelWindow() -scala> w.fill(100,100,100,100,java.awt.Color.red) + +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 ``` -### Manual download +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) +``` +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 +``` -Download the latest jar-file from here: https://github.com/lunduniversity/introprog-scalalib/releases +### Getting started using sbt -Or from Maven central: https://search.maven.org/search?q=a:introprog_2.12 +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" +``` -Put the jar-file on your classpath when you run the Scala REPL, for example: +When you run `sbt` in terminal the `introprog` package is automatically downloaded and made available on your classpath. +You can do things like: ``` -> scala -cp introprog_2.12-1.1.4.jar +> sbt +sbt> console 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: +See: [api documentation for PixelWindow](https://fileadmin.cs.lth.se/pgk/api/api/introprog/PixelWindow.html) + +### Older Scala versions + +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.1.4.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 @@ -56,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. @@ -74,4 +141,4 @@ Areas currently in scope of this library: * 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 blocks while waiting for user response. +* Simple modal GUI dialogs that block while waiting for user response. diff --git a/build.sbt b/build.sbt index 7f7128d..c6aae06 100644 --- a/build.sbt +++ b/build.sbt @@ -1,40 +1,62 @@ -lazy val Version = "1.1.4" +lazy val Version = "1.4.0" lazy val Name = "introprog" +lazy val scala3 = "3.3.3" -name := Name -version := Version -scalaVersion := "2.12.10" -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" ) -javacOptions in (Compile, compile) ++= Seq("-target", "1.8") +ThisBuild / Compile / compile / javacOptions ++= Seq("-target", "1.8") // for backward compat -scalacOptions in (Compile, doc) ++= Seq( - "-implicits", +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 // see PUBLISH.md for instructions -// usage inside sbt: -// sbt> publishSigned +// 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" @@ -69,10 +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/ -usePgpKeyHex("E7232FE8B8357EEC786315FE821738D92B63C95F") -//https://github.com/sbt/sbt-pgp#configuration-signing-key \ No newline at end of file +//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 0cd8b07..e8a1e24 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.2.3 +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 index cf20081..79245a7 100644 --- a/publish-doc.sh +++ b/publish-doc.sh @@ -1,3 +1,14 @@ +echo "*** Generating docs and copy api to fileadmin then zip it for local download" +set -x + +SCALAVERSION=3.3.3 sbt doc -echo "*** scp docs to web.cs.lth.se" -scp -r target/scala-2.12/api $LUCATID@web.cs.lth.se:/Websites/Fileadmin/pgk/ + +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 index f4b66eb..8ffcd90 100644 --- a/publish-jar.sh +++ b/publish-jar.sh @@ -1,6 +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 -VERSION="$(grep -m 1 -Po -e '\d+.\d+.\d+' build.sbt)" -JARFILE="introprog_2.12-$VERSION.jar" -DEST="$LUCATID@web.cs.lth.se:/Websites/Fileadmin/pgk/" echo Copying $JARFILE to $DEST -scp "target/scala-2.12/$JARFILE" $DEST +scp "target/scala-$SCALAVERSION/$JARFILE" $DEST diff --git a/src/main/scala/introprog/BlockGame.scala b/src/main/scala/introprog/BlockGame.scala index 763262d..2222d9a 100644 --- a/src/main/scala/introprog/BlockGame.scala +++ b/src/main/scala/introprog/BlockGame.scala @@ -2,7 +2,9 @@ package introprog import java.awt.Color -/** A class for creating games with block-based graphics. +/** 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 @@ -20,7 +22,7 @@ abstract class BlockGame( 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. @@ -84,79 +86,66 @@ abstract class BlockGame( /** 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) { + 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) { - pixelWindow.lastEventType match { + 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) { + 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) { - for (y <- blockBuffer(x).indices) { - if (isBufferUpdated(x)(y)) { + 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 + def clearWindow(): Unit = + pixelWindow.clear() clearMessageArea() - for (x <- blockBuffer.indices) { - for (y <- blockBuffer(x).indices) { + 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) { + 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) { + 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 = { + def drawCenteredText(msg: String, color: Color = pixelWindow.foreground, size: Int = blockSize): Unit = toDoAfterBlockUpdates.append( () => pixelWindow.drawText( msg, @@ -165,17 +154,15 @@ abstract class BlockGame( 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 = { + 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 = { + 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( @@ -183,5 +170,3 @@ abstract class BlockGame( 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 a82dd30..dd4e32a 100644 --- a/src/main/scala/introprog/IO.scala +++ b/src/main/scala/introprog/IO.scala @@ -1,68 +1,240 @@ package introprog +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`. */ - def loadString(fileName: String, enc: String = "UTF-8"): String = { +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() result - } - /** Load string lines from a text file called `fileName` using encoding `enc`. */ - def loadLines(fileName: String, enc: String = "UTF-8"): Vector[String] = { + /** + * 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() - } - /** 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 of the current user's home directory. */ + /** + * 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 of the current working directory. */ + /** + * 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 09989d6..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,57 +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 = + 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 = @@ -193,36 +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 = { + 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 = { + 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)`. + /** 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 = { + 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) } @@ -230,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) @@ -245,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 @@ -255,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) @@ -291,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 2c02f0a..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,32 +30,39 @@ 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 @@ -68,17 +74,16 @@ object Swing { 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 { @@ -91,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 index b5c7001..5f7d810 100644 --- a/src/main/scala/introprog/examples/TestBlockGame.scala +++ b/src/main/scala/introprog/examples/TestBlockGame.scala @@ -1,15 +1,20 @@ package introprog.examples -/** Example of a simple BlockGame app with overridden callbacks to handle events +/** 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 { +object TestBlockGame: /** Create Game and start playing. */ - def main(args: Array[String]): Unit = (new RandomBlocks).play() + 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 { + class RandomBlocks extends introprog.BlockGame: sealed trait State case object Starting extends State @@ -23,21 +28,20 @@ object TestBlockGame { drawTextInMessageArea("Press Enter to toggle random blocks.", 0,0) def showEscapeMessage(): Unit = - drawTextInMessageArea("Press Escape to clear window.", 25, 0) + drawTextInMessageArea("Press Esc to clear window.", 25, 0) - override def onKeyDown(key: String): Unit = { + override def onKeyDown(key: String): Unit = print(s" Key down: $key") - key match { - case "Escape" => + 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") @@ -45,26 +49,76 @@ object TestBlockGame { override def onMouseUp(pos: (Int, Int)): Unit = print(s" Mouse up: $pos") - override def onClose(): Unit = { + override def onClose(): Unit = print(" Window Closed.") state = GameOver - } - override def gameLoopAction(): Unit = { + 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) { + if isDrawingRandomBlocks then drawBlock(rndPos._1, rndPos._2, rndColor) - } - } - def play(): Unit = { + 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 index b9b7dc8..17c90fa 100644 --- a/src/main/scala/introprog/examples/TestIO.scala +++ b/src/main/scala/introprog/examples/TestIO.scala @@ -1,12 +1,12 @@ package introprog.examples /** Example of serializing objects to and from binary files on disk. */ -object TestIO { +object TestIO: import introprog.IO case class Person(name: String) - def main(args: Array[String]): Unit = { + 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) @@ -17,12 +17,66 @@ object TestIO { val highscores2 = IO.loadObject[Map[Person, Int]]("highscores.ser") val isSameContents = highscores2 == highscores - val testResult = if (isSameContents) "SUCCESS :)" else "FAILURE :(" + 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 2c23d54..0000000 --- a/src/rootdoc.txt +++ /dev/null @@ -1,34 +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.BlockGame `introprog.BlockGame`]] an abstract class to be inherited by games using block graphics. - -- [[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.10" -libraryDependencies += "se.lth.cs" %% "introprog" % "1.1.4" -}}} - -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")