├── .github └── workflows │ └── scala.yml ├── .gitignore ├── .scalafmt.conf ├── Readme.md ├── bin ├── build-exports ├── publish-local └── publish-maven-central-signed ├── build.sbt ├── docs ├── chakra.md ├── images │ ├── chakra │ │ ├── alerts.png │ │ ├── box.png │ │ ├── button.png │ │ ├── ccs.png │ │ ├── editable-editing.png │ │ ├── editable.png │ │ ├── forms.png │ │ ├── hstack.png │ │ ├── icons.png │ │ ├── images.png │ │ ├── menu.png │ │ ├── simplegrid.png │ │ ├── table.png │ │ ├── tabs.png │ │ └── text.png │ ├── csv-editor-change.png │ ├── csv-editor.png │ ├── hello-world-terminated.png │ ├── hello-world.png │ ├── mathjax │ │ ├── mathjax.png │ │ └── mathjaxbig.png │ ├── nivo │ │ ├── responsivebar.png │ │ └── responsiveline.png │ ├── postit.png │ ├── spark │ │ ├── spark-notebook.png │ │ └── sparkbasics.png │ ├── terminal21-architecture.png │ ├── text-editor.png │ └── tutorial │ │ ├── progress.png │ │ └── read-value.png ├── mathjax.md ├── nivo.md ├── quick.md ├── run-on-server.md ├── spark.md ├── std.md └── tutorial.md ├── end-to-end-tests └── src │ ├── main │ └── scala │ │ └── tests │ │ ├── ChakraComponents.scala │ │ ├── LoginPage.scala │ │ ├── MathJaxComponents.scala │ │ ├── NivoComponents.scala │ │ ├── RunAll.scala │ │ ├── StdComponents.scala │ │ ├── chakra │ │ ├── Buttons.scala │ │ ├── ChakraModel.scala │ │ ├── Common.scala │ │ ├── DataDisplay.scala │ │ ├── Disclosure.scala │ │ ├── Editables.scala │ │ ├── Etc.scala │ │ ├── Feedback.scala │ │ ├── Forms.scala │ │ ├── Grids.scala │ │ ├── MediaAndIcons.scala │ │ ├── Navigation.scala │ │ ├── Overlay.scala │ │ ├── Stacks.scala │ │ └── Typography.scala │ │ └── nivo │ │ ├── ResponsiveBarChart.scala │ │ ├── ResponsiveLineChart.scala │ │ └── common.scala │ └── test │ └── scala │ └── tests │ ├── LoggedInTest.scala │ └── LoginPageTest.scala ├── example-scripts ├── .scalafmt.conf ├── bouncing-ball.sc ├── budget.sc ├── csv-editor.sc ├── csv-viewer.sc ├── hello-world.sc ├── mathjax.sc ├── mvc-click-form.sc ├── mvc-user-form.sc ├── nivo-bar-chart.sc ├── nivo-line-chart.sc ├── postit.sc ├── progress.sc ├── project.scala ├── server.sc └── textedit.sc ├── example-spark ├── etc │ └── logback.xml ├── model │ └── Person.scala ├── project.scala └── spark-notebook.sc ├── project ├── build.properties └── plugins.sbt ├── publish.sbt ├── terminal21-code-generation └── src │ └── main │ └── scala │ ├── functions │ └── tastyextractor │ │ ├── BetterErrors.scala │ │ ├── StructureExtractor.scala │ │ └── model │ │ ├── EImport.scala │ │ ├── EMethod.scala │ │ ├── EPackage.scala │ │ ├── EParam.scala │ │ └── EType.scala │ └── org │ └── terminal21 │ └── codegen │ ├── Code.scala │ └── PropertiesExtensionGenerator.scala ├── terminal21-mathjax └── src │ └── main │ └── scala │ └── org │ └── terminal21 │ └── client │ └── components │ └── mathjax │ ├── MathJax.scala │ └── MathJaxLib.scala ├── terminal21-nivo └── src │ └── main │ └── scala │ └── org │ └── terminal21 │ └── client │ └── components │ ├── NivoLib.scala │ └── nivo │ ├── Axis.scala │ ├── BarDatum.scala │ ├── Defs.scala │ ├── Effect.scala │ ├── Fill.scala │ ├── Legend.scala │ ├── Margin.scala │ ├── NivoElement.scala │ ├── Scale.scala │ └── Serie.scala ├── terminal21-server-app └── src │ ├── main │ ├── resources │ │ └── logback.xml │ └── scala │ │ └── org │ │ └── terminal21 │ │ ├── server │ │ ├── Dependencies.scala │ │ └── Terminal21Server.scala │ │ └── serverapp │ │ ├── ServerSideApp.scala │ │ ├── ServerSideSessions.scala │ │ └── bundled │ │ ├── AppManager.scala │ │ ├── DefaultApps.scala │ │ ├── ServerStatusApp.scala │ │ └── SettingsApp.scala │ └── test │ └── scala │ └── org │ └── terminal21 │ └── serverapp │ ├── ServerSideSessionsTest.scala │ └── bundled │ ├── AppManagerPageTest.scala │ ├── ServerStatusPageTest.scala │ └── SettingsPageTest.scala ├── terminal21-server-client-common └── src │ ├── main │ └── scala │ │ └── org │ │ └── terminal21 │ │ ├── client │ │ └── components │ │ │ └── AnyElement.scala │ │ ├── collections │ │ ├── ProducerConsumerCollections.scala │ │ ├── SEList.scala │ │ └── TypedMap.scala │ │ ├── config │ │ └── Config.scala │ │ ├── model │ │ ├── ClientToServer.scala │ │ ├── CommandEvent.scala │ │ ├── Session.scala │ │ └── SessionOptions.scala │ │ ├── utils │ │ └── ErrorLogger.scala │ │ └── ws │ │ ├── AbstractWsListener.scala │ │ ├── ClientWsListener.scala │ │ ├── ReliableClientWsListener.scala │ │ ├── ReliableServerWsListener.scala │ │ ├── ServerValue.scala │ │ ├── ServerWsListener.scala │ │ └── Transformer.scala │ └── test │ └── scala │ └── org │ └── terminal21 │ ├── collections │ ├── ProducerConsumerCollectionsTest.scala │ ├── SEListTest.scala │ └── TypedMapTest.scala │ ├── model │ └── CommonModelBuilders.scala │ └── ws │ └── ReliableWsListenerTest.scala ├── terminal21-server └── src │ ├── main │ ├── resources │ │ └── META-INF │ │ │ └── helidon │ │ │ └── serial-config.properties │ └── scala │ │ └── org │ │ └── terminal21 │ │ └── server │ │ ├── Routes.scala │ │ ├── ServerBeans.scala │ │ ├── json │ │ ├── WsRequest.scala │ │ └── WsResponse.scala │ │ ├── model │ │ └── SessionState.scala │ │ ├── service │ │ ├── CommandWebSocket.scala │ │ └── ServerSessionsService.scala │ │ ├── ui │ │ ├── SessionsWebSocket.scala │ │ └── WsSessionOps.scala │ │ └── utils │ │ ├── Environment.scala │ │ └── NotificationRegistry.scala │ └── test │ └── scala │ └── org │ └── terminal21 │ └── server │ ├── service │ └── ServerSessionsServiceTest.scala │ └── utils │ └── NotificationRegistryTest.scala ├── terminal21-spark └── src │ ├── main │ └── scala │ │ └── org │ │ └── terminal21 │ │ └── sparklib │ │ ├── Cached.scala │ │ ├── DataframeExtensions.scala │ │ ├── SparkSessionExt.scala │ │ ├── SparkSessions.scala │ │ ├── calculations │ │ ├── ReadWriter.scala │ │ └── SparkCalculation.scala │ │ └── util │ │ └── Environment.scala │ └── test │ ├── resources │ └── logback-test.xml │ └── scala │ └── org │ └── terminal21 │ └── sparklib │ ├── AbstractSparkSuite.scala │ ├── SparkSessionExtTest.scala │ ├── SparkSessionsTest.scala │ ├── endtoend │ ├── SparkBasics.scala │ └── model │ │ └── CodeFile.scala │ └── testmodel │ └── Person.scala ├── terminal21-ui-std-exports └── src │ ├── main │ └── scala │ │ └── org │ │ └── terminal21 │ │ └── ui │ │ └── std │ │ ├── ServerJson.scala │ │ └── SessionsService.scala │ └── test │ └── scala │ └── org │ └── terminal21 │ └── ui │ └── std │ └── StdExportsBuilders.scala └── terminal21-ui-std └── src ├── main └── scala │ └── org │ └── terminal21 │ └── client │ ├── ClientEventsWsListener.scala │ ├── ConnectedSession.scala │ ├── Controller.scala │ ├── Globals.scala │ ├── Sessions.scala │ ├── collections │ └── EventIterator.scala │ ├── components │ ├── ComponentLib.scala │ ├── EventHandler.scala │ ├── Keys.scala │ ├── UiComponent.scala │ ├── UiElement.scala │ ├── chakra │ │ ├── ChakraElement.scala │ │ ├── QuickFormControl.scala │ │ ├── QuickTable.scala │ │ └── QuickTabs.scala │ ├── frontend │ │ └── FrontEndElement.scala │ └── std │ │ ├── StdElement.scala │ │ └── StdHttp.scala │ └── json │ └── UiElementEncoding.scala └── test └── scala └── org └── terminal21 └── client ├── ConnectedSessionMock.scala ├── ConnectedSessionTest.scala ├── ControllerTest.scala ├── collections └── EventIteratorTest.scala ├── components └── UiElementTest.scala └── json └── UiElementEncodingTest.scala /.github/workflows/scala.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Scala CI 7 | 8 | on: 9 | push: 10 | branches: [ "main" ] 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up JDK 21 25 | uses: actions/setup-java@v3 26 | with: 27 | java-version: '21' 28 | distribution: 'temurin' 29 | cache: 'sbt' 30 | - name: Run tests 31 | run: sbt compile test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Add any directories, files, or patterns you don't want to be tracked by version control 2 | 3 | .bsp 4 | project/project 5 | project/target 6 | target 7 | .history 8 | dist 9 | .idea 10 | *.iml 11 | .idea_modules 12 | .classpath 13 | RUNNING_PID 14 | .settings 15 | .project 16 | out 17 | src_generated 18 | .attach* 19 | *.so 20 | .metals 21 | .bloop 22 | project/metals.sbt 23 | .vscode 24 | *.dmp 25 | javacore.* 26 | generated 27 | .scala-build 28 | 29 | functions-remote-generated 30 | terminal21-server/src/main/resources/web -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.7.10 2 | runner.dialect = scala3 3 | align.openParenCallSite = false 4 | align.openParenDefnSite = false 5 | maxColumn = 160 6 | 7 | align.preset = more 8 | align.multiline = true 9 | -------------------------------------------------------------------------------- /bin/build-exports: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sbt clean terminal21-server-client-common/publishLocal terminal21-ui-std-exports/publishLocal compile 3 | -------------------------------------------------------------------------------- /bin/publish-local: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | cd ../terminal21-ui 4 | bin/build-and-copy-to-restapi 5 | 6 | cd ../terminal21-restapi 7 | sbt clean terminal21-server-client-common/publishLocal terminal21-ui-std-exports/publishLocal compile publishLocal 8 | -------------------------------------------------------------------------------- /bin/publish-maven-central-signed: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | cd ../terminal21-ui 4 | bin/build-and-copy-to-restapi 5 | 6 | cd ../terminal21-restapi 7 | sbt clean terminal21-server-client-common/publishLocal terminal21-ui-std-exports/publishLocal compile publishSigned 8 | 9 | -------------------------------------------------------------------------------- /docs/images/chakra/alerts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/alerts.png -------------------------------------------------------------------------------- /docs/images/chakra/box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/box.png -------------------------------------------------------------------------------- /docs/images/chakra/button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/button.png -------------------------------------------------------------------------------- /docs/images/chakra/ccs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/ccs.png -------------------------------------------------------------------------------- /docs/images/chakra/editable-editing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/editable-editing.png -------------------------------------------------------------------------------- /docs/images/chakra/editable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/editable.png -------------------------------------------------------------------------------- /docs/images/chakra/forms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/forms.png -------------------------------------------------------------------------------- /docs/images/chakra/hstack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/hstack.png -------------------------------------------------------------------------------- /docs/images/chakra/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/icons.png -------------------------------------------------------------------------------- /docs/images/chakra/images.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/images.png -------------------------------------------------------------------------------- /docs/images/chakra/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/menu.png -------------------------------------------------------------------------------- /docs/images/chakra/simplegrid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/simplegrid.png -------------------------------------------------------------------------------- /docs/images/chakra/table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/table.png -------------------------------------------------------------------------------- /docs/images/chakra/tabs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/tabs.png -------------------------------------------------------------------------------- /docs/images/chakra/text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/chakra/text.png -------------------------------------------------------------------------------- /docs/images/csv-editor-change.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/csv-editor-change.png -------------------------------------------------------------------------------- /docs/images/csv-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/csv-editor.png -------------------------------------------------------------------------------- /docs/images/hello-world-terminated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/hello-world-terminated.png -------------------------------------------------------------------------------- /docs/images/hello-world.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/hello-world.png -------------------------------------------------------------------------------- /docs/images/mathjax/mathjax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/mathjax/mathjax.png -------------------------------------------------------------------------------- /docs/images/mathjax/mathjaxbig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/mathjax/mathjaxbig.png -------------------------------------------------------------------------------- /docs/images/nivo/responsivebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/nivo/responsivebar.png -------------------------------------------------------------------------------- /docs/images/nivo/responsiveline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/nivo/responsiveline.png -------------------------------------------------------------------------------- /docs/images/postit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/postit.png -------------------------------------------------------------------------------- /docs/images/spark/spark-notebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/spark/spark-notebook.png -------------------------------------------------------------------------------- /docs/images/spark/sparkbasics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/spark/sparkbasics.png -------------------------------------------------------------------------------- /docs/images/terminal21-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/terminal21-architecture.png -------------------------------------------------------------------------------- /docs/images/text-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/text-editor.png -------------------------------------------------------------------------------- /docs/images/tutorial/progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/tutorial/progress.png -------------------------------------------------------------------------------- /docs/images/tutorial/read-value.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kostaskougios/terminal21-restapi/0afa5fe63f1ad97bb9a94f638e98ee8777449c18/docs/images/tutorial/read-value.png -------------------------------------------------------------------------------- /docs/mathjax.md: -------------------------------------------------------------------------------- 1 | # mathjax 2 | ![MathJax](images/mathjax/mathjaxbig.png) 3 | 4 | [mathjax](https://docs.mathjax.org/en/latest/), "MathJax is an open-source JavaScript display engine for LaTeX, MathML, and AsciiMath notation ". 5 | 6 | terminal21 supports asciimath notation for mathjax version 3. 7 | 8 | Dependency: `io.github.kostaskougios::terminal21-mathjax:$VERSION` 9 | 10 | ### MathJax 11 | 12 | Example: [mathjax.sc](../example-scripts/mathjax.sc) [MathJax](../end-to-end-tests/src/main/scala/tests/MathJaxComponents.scala) 13 | 14 | ```scala 15 | MathJax( 16 | expression = """Everyone knows this one : \(ax^2 + bx + c = 0\)""" 17 | ) 18 | ``` 19 | ![MathJax](images/mathjax/mathjax.png) 20 | 21 | see [Writing Mathematics for MathJax](https://docs.mathjax.org/en/latest/basic/mathematics.html) -------------------------------------------------------------------------------- /docs/nivo.md: -------------------------------------------------------------------------------- 1 | # Nivo 2 | 3 | [Nivo](https://nivo.rocks/), "nivo provides a rich set of dataviz components, built on top of D3 and React". 4 | 5 | terminal21 supports a few of these, but please add a comment [here](https://github.com/kostaskougios/terminal21-restapi/discussions/3) if you 6 | would like support for a particular component. 7 | 8 | Dependency: `io.github.kostaskougios::terminal21-nivo:$VERSION` 9 | 10 | ### ResponsiveLine 11 | 12 | Code: [ResponsiveLine](../end-to-end-tests/src/main/scala/tests/nivo/ResponsiveLineChart.scala) 13 | 14 | ![RL](images/nivo/responsiveline.png) 15 | 16 | ### ResponsiveBar 17 | 18 | Code: [ResponsiveBar](../end-to-end-tests/src/main/scala/tests/nivo/ResponsiveBarChart.scala) 19 | 20 | ![RB](images/nivo/responsivebar.png) 21 | 22 | -------------------------------------------------------------------------------- /docs/quick.md: -------------------------------------------------------------------------------- 1 | # Quick classes 2 | 3 | There are some UI components, like tables, that require a lot of elements: TableContainer, TBody, Tr, Th etc. `Quick*` classes 4 | simplify creation of these components. 5 | 6 | ## QuickTable 7 | 8 | This class helps create tables quickly. 9 | 10 | ```scala 11 | val conversionTable = QuickTable().headers("To convert", "into", "multiply by") 12 | .caption("Imperial to metric conversion factors") 13 | val tableRows:Seq[Seq[String]] = Seq( 14 | Seq("inches","millimetres (mm)","25.4"), 15 | ... 16 | ) 17 | conversionTable.rows(tableRows) 18 | ``` 19 | 20 | ## QuickTabs 21 | 22 | This class simplifies the creation of tabs. 23 | 24 | ```scala 25 | 26 | QuickTabs() 27 | .withTabs("Tab 1", "Tab 2") 28 | .withTabPanels( 29 | Paragraph(text="Tab 1 content"), 30 | Paragraph(text="Tab 2 content") 31 | ) 32 | 33 | ``` 34 | 35 | ## QuickFormControl 36 | 37 | Simplifies creating forms. 38 | 39 | ```scala 40 | QuickFormControl() 41 | .withLabel("Email address") 42 | .withHelperText("We'll never share your email.") 43 | .withInputGroup( 44 | InputLeftAddon().withChildren(EmailIcon()), 45 | emailInput, 46 | InputRightAddon().withChildren(CheckCircleIcon()) 47 | ) 48 | ``` -------------------------------------------------------------------------------- /docs/run-on-server.md: -------------------------------------------------------------------------------- 1 | # Running applications on the server 2 | 3 | To create an app that runs on the server, implement the `ServerSideApp` trait (from `io.github.kostaskougios::terminal21-server-app`) and then pass your implementation to the `start()` method of the server: 4 | 5 | ```scala 6 | class MyServerApp extends ServerSideApp: 7 | override def name = "My Server App" 8 | 9 | override def description = "Some app that I want to be available when I start the server" 10 | 11 | override def createSession(serverSideSessions: ServerSideSessions, dependencies: Dependencies): Unit = 12 | serverSideSessions 13 | .withNewSession("my-server-app-session", name) 14 | .connect: session => 15 | given ConnectedSession = session 16 | ... your app code ... 17 | ``` 18 | 19 | See for example the [terminal21 settings app](../terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/SettingsApp.scala). 20 | 21 | Now make sure your app is included in the server's classpath and then pass it as an argument to `start()`, i.e. with this `scala-cli` script: 22 | 23 | ```scala 24 | //> ... 25 | //> using dep MY_APP_DEP 26 | 27 | import org.terminal21.server.Terminal21Server 28 | 29 | Terminal21Server.start(apps = Seq(new MyServerApp)) 30 | ``` 31 | 32 | Now start the server and the app should be available in the app list of terminal21. 33 | -------------------------------------------------------------------------------- /docs/spark.md: -------------------------------------------------------------------------------- 1 | # Spark 2 | 3 | Dependency: `io.github.kostaskougios::terminal21-spark:$VERSION` 4 | 5 | Terminal 21 spark integration allows using datasets and dataframes inside terminal21 scripts/code. 6 | It also provides caching of datasets in order for scripts to be used as notebooks. The caching 7 | has also a UI component to allow invalidating the cache and re-evaluating the datasets. 8 | 9 | To give it a go, please checkout this repo and try the examples. Only requirement to do this is that you have `scala-cli` installed: 10 | 11 | ```shell 12 | git clone https://github.com/kostaskougios/terminal21-restapi.git 13 | cd terminal21-restapi/example-scripts 14 | 15 | # start the server 16 | ./server.sc 17 | # ... it will download dependencies & jdk and start the server. 18 | 19 | # Now lets run a spark notebook 20 | 21 | cd terminal21-restapi/example-spark 22 | ./spark-notebook.sc 23 | ``` 24 | 25 | Leave `spark-notebook.sc` running and edit it with your preferred editor. When you save your changes, it will automatically be rerun and 26 | the changes will be reflected in the UI. 27 | 28 | ## Using terminal21 as notebook with scala-cli 29 | 30 | See [spark-notebook.sc](../example-spark/spark-notebook.sc). 31 | On top of the file, `scala-cli` is configured to run with the `--restart` option. This will terminate and restart the script 32 | whenever a change in the file is detected. Edit the script with your favorite IDE and when saving it, it will automatically 33 | re-run. If you want to re-evaluate the datasets, click "Recalculate" on the UI components. 34 | 35 | ![SparkNotebook](images/spark/spark-notebook.png) 36 | 37 | ## Using terminal21 as notebook within an ide 38 | 39 | Create a scala project (i.e. using sbt), add the terminal21 dependencies and run the terminal21 server. Create your notebook code, i.e. 40 | see [SparkBasics](../terminal21-spark/src/test/scala/org/terminal21/sparklib/endtoend/SparkBasics.scala). Run it. Let it run while 41 | you interact with the UI. Change the code and rerun it. Click "Recalculate" if you want the datasets to be re-evaluated. 42 | 43 | ![SparkBasics](images/spark/sparkbasics.png) 44 | -------------------------------------------------------------------------------- /docs/std.md: -------------------------------------------------------------------------------- 1 | # Std 2 | 3 | These are standard html elements but please prefer the more flexible chakra component if it exists. 4 | 5 | [Example](../end-to-end-tests/src/main/scala/tests/StdComponents.scala) 6 | 7 | Dependency: `io.github.kostaskougios::terminal21-ui-std:$VERSION` 8 | 9 | ### Paragraph, NewLine, Span, Em 10 | 11 | ```scala 12 | Paragraph(text = "Hello World!").withChildren( 13 | NewLine(), 14 | Span(text = "Some more text"), 15 | Em(text = " emphasized!"), 16 | NewLine(), 17 | Span(text = "And the last line") 18 | ) 19 | ``` 20 | ### Header 21 | 22 | ```scala 23 | Header1(text = "Welcome to the std components demo/test") 24 | ``` 25 | 26 | ### Input 27 | 28 | ```scala 29 | val input = Input(defaultValue = "Please enter your name") 30 | val output = Paragraph(text = "This will reflect what you type in the input") 31 | input.onChange: newValue => 32 | output.withText(newValue).renderChanges() 33 | ``` 34 | 35 | ### Cookies 36 | 37 | Set a cookie: 38 | 39 | ```scala 40 | Cookie(name = "cookie-name", value = "cookie value") 41 | ``` 42 | 43 | Read a cookie: 44 | 45 | ```scala 46 | val cookieReader = CookieReader(key = "cookie-reader", name = "cookie-name") 47 | val cookieValue = events.changedValue(cookieReader) 48 | ``` -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/ChakraComponents.scala: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import org.terminal21.client.* 4 | import org.terminal21.client.components.chakra.* 5 | import org.terminal21.client.components.std.Paragraph 6 | import tests.chakra.* 7 | 8 | @main def chakraComponents(): Unit = 9 | def loop(): Unit = 10 | println("Starting new session") 11 | Sessions 12 | .withNewSession("chakra-components", "Chakra Components") 13 | .connect: session => 14 | given ConnectedSession = session 15 | 16 | def components(m: ChakraModel, events: Events): MV[ChakraModel] = 17 | // react tests reset the session to clear state 18 | val krButton = Button("reset", text = "Reset state") 19 | 20 | val bcs = Buttons.components(m, events) 21 | val elements = Overlay.components(events) ++ Forms.components( 22 | m, 23 | events 24 | ) ++ Editables.components( 25 | events 26 | ) ++ Stacks.components ++ Grids.components ++ bcs.view ++ Etc.components ++ MediaAndIcons.components ++ DataDisplay.components ++ Typography.components ++ Feedback.components ++ Disclosure.components ++ 27 | Navigation.components(events) ++ Seq( 28 | krButton 29 | ) 30 | 31 | val modifiedModel = bcs.model 32 | val model = modifiedModel.copy( 33 | rerun = events.isClicked(krButton) 34 | ) 35 | MV( 36 | model, 37 | elements, 38 | model.rerun || model.terminate 39 | ) 40 | 41 | Controller(components).render(ChakraModel()).iterator.lastOption.map(_.model) match 42 | case Some(m) if m.rerun => 43 | Controller.noModel(Seq(Paragraph(text = "chakra-session-reset"))).render(()) 44 | Thread.sleep(500) 45 | loop() 46 | case _ => 47 | 48 | loop() 49 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/LoginPage.scala: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import org.terminal21.client.components.* 4 | import org.terminal21.client.components.chakra.* 5 | import org.terminal21.client.components.std.{NewLine, Paragraph} 6 | import org.terminal21.client.* 7 | 8 | @main def loginFormApp(): Unit = 9 | Sessions 10 | .withNewSession("login-form", "Login Form") 11 | .connect: session => 12 | given ConnectedSession = session 13 | val confirmed = for 14 | login <- new LoginPage().run() 15 | isYes <- new LoggedIn(login).run() 16 | yield isYes 17 | 18 | if confirmed.getOrElse(false) then println("User confirmed the details") else println("Not confirmed") 19 | 20 | case class LoginForm(email: String = "my@email.com", pwd: String = "mysecret", submitted: Boolean = false, submittedInvalidEmail: Boolean = false): 21 | def isValidEmail: Boolean = email.contains("@") 22 | 23 | /** The login form. Displays an email and password input and a submit button. When run() it will fill in the Login(email,pwd) model. 24 | */ 25 | class LoginPage(using session: ConnectedSession): 26 | private val initialModel = LoginForm() 27 | val okIcon = CheckCircleIcon(color = Some("green")) 28 | val notOkIcon = WarningTwoIcon(color = Some("red")) 29 | val emailInput = Input(key = "email", `type` = "email", defaultValue = initialModel.email) 30 | 31 | val submitButton = Button(key = "submit", text = "Submit") 32 | 33 | val passwordInput = Input(key = "password", `type` = "password", defaultValue = initialModel.pwd) 34 | 35 | val errorsBox = Box() 36 | val errorMsgInvalidEmail = Paragraph(text = "Invalid Email", style = Map("color" -> "red")) 37 | 38 | def run(): Option[LoginForm] = 39 | controller 40 | .render(initialModel) 41 | .iterator 42 | .map(_.model) 43 | .tapEach: form => 44 | println(form) 45 | .dropWhile(!_.submitted) 46 | .nextOption() 47 | 48 | def components(form: LoginForm, events: Events): MV[LoginForm] = 49 | println(events.event) 50 | val isValidEmail = form.isValidEmail 51 | val newForm = form.copy( 52 | email = events.changedValue(emailInput, form.email), 53 | pwd = events.changedValue(passwordInput, form.pwd), 54 | submitted = events.isClicked(submitButton) && isValidEmail, 55 | submittedInvalidEmail = events.isClicked(submitButton) && !isValidEmail 56 | ) 57 | val view = Seq( 58 | QuickFormControl() 59 | .withLabel("Email address") 60 | .withHelperText("We'll never share your email.") 61 | .withInputGroup( 62 | InputLeftAddon().withChildren(EmailIcon()), 63 | emailInput, 64 | InputRightAddon().withChildren(if newForm.isValidEmail then okIcon else notOkIcon) 65 | ), 66 | QuickFormControl() 67 | .withLabel("Password") 68 | .withHelperText("Don't share with anyone") 69 | .withInputGroup( 70 | InputLeftAddon().withChildren(ViewOffIcon()), 71 | passwordInput 72 | ), 73 | submitButton, 74 | errorsBox.withChildren(if newForm.submittedInvalidEmail then errorMsgInvalidEmail else errorsBox) 75 | ) 76 | MV( 77 | newForm, 78 | view 79 | ) 80 | 81 | def controller: Controller[LoginForm] = Controller(components) 82 | 83 | class LoggedIn(login: LoginForm)(using session: ConnectedSession): 84 | val yesButton = Button(key = "yes-button", text = "Yes") 85 | 86 | val noButton = Button(key = "no-button", text = "No") 87 | 88 | val emailDetails = Text(text = s"email : ${login.email}") 89 | val passwordDetails = Text(text = s"password : ${login.pwd}") 90 | 91 | def run(): Option[Boolean] = 92 | controller.render(false).iterator.lastOption.map(_.model) 93 | 94 | def components(isYes: Boolean, events: Events): MV[Boolean] = 95 | val view = Seq( 96 | Paragraph().withChildren( 97 | Text(text = "Are your details correct?"), 98 | NewLine(), 99 | emailDetails, 100 | NewLine(), 101 | passwordDetails 102 | ), 103 | HStack().withChildren(yesButton, noButton) 104 | ) 105 | MV( 106 | events.isClicked(yesButton), 107 | view, 108 | events.isClicked(yesButton) || events.isClicked(noButton) 109 | ) 110 | 111 | /** @return 112 | * A controller with a boolean value, true if user clicked "Yes", false for "No" 113 | */ 114 | def controller: Controller[Boolean] = Controller(components) 115 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/MathJaxComponents.scala: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import org.terminal21.client.* 4 | import org.terminal21.client.components.* 5 | import org.terminal21.client.components.chakra.* 6 | import org.terminal21.client.components.mathjax.* 7 | 8 | @main def mathJaxComponents(): Unit = 9 | Sessions 10 | .withNewSession("mathjax-components", "MathJax Components") 11 | .andLibraries(MathJaxLib) 12 | .connect: session => 13 | given ConnectedSession = session 14 | 15 | val components = Seq( 16 | HStack().withChildren( 17 | Text(text = "Lets write some math expressions that will wow everybody!"), 18 | MathJax(expression = """\[\sum_{n = 200}^{1000}\left(\frac{20\sqrt{n}}{n}\right)\]""") 19 | ), 20 | MathJax(expression = """Everyone knows this one : \(ax^2 + bx + c = 0\). But how about this? \(\sum_{i=1}^n i^3 = ((n(n+1))/2)^2 \)"""), 21 | MathJax( 22 | expression = """Does it align correctly? \(ax^2 + bx + c = 0\) It does provided CHTML renderer is used.""", 23 | style = Map("backgroundColor" -> "gray") 24 | ) 25 | ) 26 | Controller.noModel(components).render() 27 | session.leaveSessionOpenAfterExiting() 28 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/NivoComponents.scala: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import org.terminal21.client.* 4 | import org.terminal21.client.components.* 5 | import tests.nivo.{ResponsiveBarChart, ResponsiveLineChart} 6 | 7 | @main def nivoComponents(): Unit = 8 | Sessions 9 | .withNewSession("nivo-components", "Nivo Components") 10 | .andLibraries(NivoLib) 11 | .connect: session => 12 | given ConnectedSession = session 13 | 14 | val components = ResponsiveBarChart() ++ ResponsiveLineChart() 15 | Controller.noModel(components).render() 16 | session.leaveSessionOpenAfterExiting() 17 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/RunAll.scala: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import functions.fibers.Fiber 4 | import org.terminal21.client.* 5 | 6 | @main def runAll(): Unit = 7 | Seq( 8 | submit: 9 | chakraComponents() 10 | , 11 | submit: 12 | stdComponents() 13 | , 14 | submit: 15 | loginFormApp() 16 | , 17 | submit: 18 | mathJaxComponents() 19 | , 20 | submit: 21 | nivoComponents() 22 | ).foreach(_.get()) 23 | 24 | private def submit(f: => Unit): Fiber[Unit] = 25 | fiberExecutor.submit: 26 | try f 27 | catch case t: Throwable => t.printStackTrace() 28 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/StdComponents.scala: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import org.terminal21.client.* 4 | import org.terminal21.client.components.* 5 | import org.terminal21.client.components.std.* 6 | 7 | @main def stdComponents(): Unit = 8 | Sessions 9 | .withNewSession("std-components", "Std Components") 10 | .connect: session => 11 | given ConnectedSession = session 12 | 13 | def components(events: Events) = 14 | val input = Input(key = "name", defaultValue = "Please enter your name") 15 | val cookieReader = CookieReader(key = "cookie-reader", name = "std-components-test-cookie") 16 | 17 | val outputMsg = events.changedValue(input, "This will reflect what you type in the input") 18 | val output = Paragraph(text = outputMsg) 19 | 20 | val cookieMsg = events.changedValue(cookieReader).map(newValue => s"Cookie value $newValue").getOrElse("This will display the value of the cookie") 21 | val cookieValue = Paragraph(text = cookieMsg) 22 | 23 | Seq( 24 | Header1(text = "header1 test"), 25 | Header2(text = "header2 test"), 26 | Header3(text = "header3 test"), 27 | Header4(text = "header4 test"), 28 | Header5(text = "header5 test"), 29 | Header6(text = "header6 test"), 30 | Paragraph(text = "Hello World!").withChildren( 31 | NewLine(), 32 | Span(text = "Some more text"), 33 | Em(text = " emphasized!"), 34 | NewLine(), 35 | Span(text = "And the last line") 36 | ), 37 | Paragraph(text = "A Form").withChildren(input), 38 | output, 39 | Cookie(name = "std-components-test-cookie", value = "test-cookie-value"), 40 | cookieReader, 41 | cookieValue 42 | ) 43 | 44 | Controller.noModel(components).render().iterator.lastOption 45 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Buttons.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.* 4 | import org.terminal21.client.components.chakra.* 5 | import org.terminal21.client.* 6 | import tests.chakra.Common.* 7 | 8 | import java.util.concurrent.CountDownLatch 9 | 10 | object Buttons: 11 | def components(m: ChakraModel, events: Events): MV[ChakraModel] = 12 | val box1 = commonBox(text = "Buttons") 13 | val exitButton = Button(key = "exit-button", text = "Click to exit program", colorScheme = Some("red")) 14 | val model = m.copy( 15 | terminate = events.isClicked(exitButton) 16 | ) 17 | MV( 18 | model, 19 | Seq( 20 | box1, 21 | exitButton 22 | ) 23 | ) 24 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/ChakraModel.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | case class ChakraModel( 4 | rerun: Boolean = false, 5 | email: String = "the-test-email@email.com", 6 | terminate: Boolean = false 7 | ) 8 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Common.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.chakra.Box 4 | 5 | object Common: 6 | def commonBox(text: String) = Box(text = text, bg = "green", p = 4, color = "black") 7 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/DataDisplay.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.* 5 | import org.terminal21.client.components.std.NewLine 6 | import tests.chakra.Common.* 7 | 8 | object DataDisplay: 9 | def components: Seq[UiElement] = 10 | def headAndFoot = Tr().withChildren( 11 | Th(text = "To convert"), 12 | Th(text = "into"), 13 | Th(text = "multiply by", isNumeric = true) 14 | ) 15 | val quickTable1 = QuickTable() 16 | .withHeaders("id", "name") 17 | .caption("Quick Table Caption") 18 | .withRows( 19 | Seq( 20 | Seq(1, "Kostas"), 21 | Seq(2, "Andreas") 22 | ) 23 | ) 24 | Seq( 25 | commonBox(text = "Badges"), 26 | HStack().withChildren( 27 | Badge(text = "badge 1", size = "sm"), 28 | Badge(text = "badge 2", size = "md", colorScheme = Some("red")), 29 | Badge(text = "badge 3", size = "lg", colorScheme = Some("green")), 30 | Badge(text = "badge 4", variant = Some("outline"), colorScheme = Some("tomato")), 31 | Badge(text = "badge 4").withChildren( 32 | Button("test", text = "test") 33 | ) 34 | ), 35 | commonBox(text = "Quick Tables"), 36 | quickTable1, 37 | commonBox(text = "Tables"), 38 | TableContainer().withChildren( 39 | Table(variant = "striped", colorScheme = Some("teal"), size = "lg").withChildren( 40 | TableCaption(text = "Imperial to metric conversion factors (table-caption-0001)"), 41 | Thead().withChildren( 42 | headAndFoot 43 | ), 44 | Tbody().withChildren( 45 | Tr().withChildren( 46 | Td(text = "inches"), 47 | Td(text = "millimetres (mm)"), 48 | Td(text = "25.4", isNumeric = true) 49 | ), 50 | Tr().withChildren( 51 | Td(text = "feet"), 52 | Td(text = "centimetres (cm)"), 53 | Td(text = "30.48", isNumeric = true) 54 | ), 55 | Tr().withChildren( 56 | Td(text = "yards"), 57 | Td(text = "metres (m)"), 58 | Td(text = "0.91444", isNumeric = true) 59 | ), 60 | Tr().withChildren( 61 | Td(text = "td0001"), 62 | Td(text = "td0002"), 63 | Td(text = "td0003", isNumeric = true) 64 | ) 65 | ), 66 | Tfoot().withChildren( 67 | headAndFoot 68 | ) 69 | ) 70 | ), 71 | VStack().withChildren( 72 | Code(text = """ 73 | |code-0001 74 | |""".stripMargin), 75 | Code(colorScheme = Some("red")).withChildren( 76 | Text(text = "val a=1"), 77 | NewLine(), 78 | Text(text = "println(a)") 79 | ) 80 | ), 81 | UnorderedList().withChildren( 82 | ListItem(text = "unordered-list-list-item1"), 83 | ListItem(text = "unordered-list-list-item2") 84 | ), 85 | OrderedList().withChildren( 86 | ListItem(text = "Ordered-list-list-item1"), 87 | ListItem(text = "Ordered-list-list-item2") 88 | ) 89 | ) 90 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Disclosure.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.* 5 | import org.terminal21.client.components.std.Paragraph 6 | import tests.chakra.Common.commonBox 7 | 8 | object Disclosure: 9 | def components: Seq[UiElement] = 10 | Seq( 11 | commonBox(text = "Tabs"), 12 | Tabs().withChildren( 13 | TabList().withChildren( 14 | Tab(text = "tab-one").withSelected(Map("color" -> "white", "bg" -> "blue.500")), 15 | Tab(text = "tab-two").withSelected(Map("color" -> "white", "bg" -> "green.400")), 16 | Tab(text = "tab-three") 17 | ), 18 | TabPanels().withChildren( 19 | TabPanel().withChildren( 20 | Paragraph(text = "tab-1-content") 21 | ), 22 | TabPanel().withChildren( 23 | Paragraph(text = "tab-2-content") 24 | ), 25 | TabPanel().withChildren( 26 | Paragraph(text = "tab-3-content") 27 | ) 28 | ) 29 | ) 30 | ) 31 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Editables.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.* 5 | import org.terminal21.client.* 6 | import tests.chakra.Common.* 7 | 8 | object Editables: 9 | def components(events: Events): Seq[UiElement] = 10 | val editable1 = Editable(key = "editable1", defaultValue = "Please type here") 11 | .withChildren( 12 | EditablePreview(), 13 | EditableInput() 14 | ) 15 | 16 | val editable2 = Editable(key = "editable2", defaultValue = "For longer maybe-editable texts\nUse an EditableTextarea\nIt uses a textarea control.") 17 | .withChildren( 18 | EditablePreview(), 19 | EditableTextarea() 20 | ) 21 | 22 | val statusMsg = (events.changedValue(editable1).map(newValue => s"editable1 newValue = $newValue") ++ events 23 | .changedValue(editable2) 24 | .map(newValue => s"editable2 newValue = $newValue")).headOption.getOrElse("This will reflect any changes in the form.") 25 | 26 | val status = Box(text = statusMsg) 27 | 28 | Seq( 29 | commonBox(text = "Editables"), 30 | SimpleGrid(columns = 2).withChildren( 31 | Box(text = "Editable"), 32 | editable1, 33 | Box(text = "Editable with text area"), 34 | editable2 35 | ), 36 | status 37 | ) 38 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Etc.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.* 5 | import tests.chakra.Common.* 6 | 7 | object Etc: 8 | def components: Seq[UiElement] = 9 | Seq( 10 | commonBox(text = "Center"), 11 | Center(text = "Center demo, not styled"), 12 | Center(text = "Center demo center-demo-0001", bg = Some("tomato"), color = Some("white"), w = Some("100px"), h = Some("100px")), 13 | commonBox(text = "Circle"), 14 | Circle(text = "Circle demo, not styled"), 15 | Circle(text = "Circle demo circle-demo-0001", bg = Some("tomato"), color = Some("white"), w = Some("100px"), h = Some("100px")), 16 | commonBox(text = "Square"), 17 | Square(text = "Square demo, not styled"), 18 | Square(text = "Square demo square-demo-0001", bg = Some("tomato"), color = Some("white"), w = Some("100px"), h = Some("100px")) 19 | ) 20 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Feedback.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.* 5 | import tests.chakra.Common.commonBox 6 | 7 | object Feedback: 8 | def components: Seq[UiElement] = 9 | Seq( 10 | commonBox(text = "Alerts"), 11 | VStack().withChildren( 12 | Alert(status = "error").withChildren(AlertIcon(), AlertTitle(text = "Alert:error"), AlertDescription(text = "alert-error-text-01")), 13 | Alert(status = "success").withChildren(AlertIcon(), AlertTitle(text = "Alert:success"), AlertDescription(text = "alert-success-text-01")), 14 | Alert(status = "warning").withChildren(AlertIcon(), AlertTitle(text = "Alert:warning"), AlertDescription(text = "alert-warning-text-01")), 15 | Alert(status = "info").withChildren(AlertIcon(), AlertTitle(text = "Alert:info"), AlertDescription(text = "alert-info-text-01")) 16 | ), 17 | commonBox(text = "Progress"), 18 | Progress(value = 10), 19 | Progress(value = 20, hasStripe = Some(true)), 20 | Progress(value = 30, isIndeterminate = Some(true)), 21 | Tooltip(label = "A help message").withContent(Text(text = "hover me!")) 22 | ) 23 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Grids.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.{Box, SimpleGrid} 5 | import tests.chakra.Common.* 6 | 7 | object Grids: 8 | def components: Seq[UiElement] = 9 | val box1 = commonBox(text = "Simple grid") 10 | Seq( 11 | box1, 12 | SimpleGrid(spacing = Some("8px"), columns = 4).withChildren( 13 | Box(text = "One", bg = "yellow", color = "black"), 14 | Box(text = "Two", bg = "tomato", color = "black"), 15 | Box(text = "Three", bg = "blue", color = "black") 16 | ) 17 | ) 18 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/MediaAndIcons.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.* 5 | import tests.chakra.Common.commonBox 6 | 7 | object MediaAndIcons: 8 | def components: Seq[UiElement] = 9 | Seq( 10 | commonBox(text = "Icons"), 11 | HStack().withChildren( 12 | InfoIcon(color = Some("tomato")), 13 | MoonIcon(color = Some("green")), 14 | AddIcon(), 15 | ArrowBackIcon(), 16 | ArrowDownIcon(), 17 | ArrowForwardIcon(), 18 | ArrowLeftIcon(), 19 | ArrowRightIcon(), 20 | ArrowUpIcon(), 21 | ArrowUpDownIcon(), 22 | AtSignIcon(), 23 | AttachmentIcon(), 24 | BellIcon(), 25 | CalendarIcon(), 26 | ChatIcon(), 27 | CheckIcon(), 28 | CheckCircleIcon(), 29 | ChevronDownIcon(), 30 | ChevronLeftIcon(), 31 | ChevronRightIcon(), 32 | ChevronUpIcon(), 33 | CloseIcon(), 34 | CopyIcon(), 35 | DeleteIcon(), 36 | DownloadIcon(), 37 | DragHandleIcon(), 38 | EditIcon(), 39 | EmailIcon(), 40 | ExternalLinkIcon(), 41 | HamburgerIcon(), 42 | InfoIcon(), 43 | InfoOutlineIcon(), 44 | LinkIcon(), 45 | LockIcon(), 46 | MinusIcon(), 47 | MoonIcon(), 48 | NotAllowedIcon(), 49 | PhoneIcon(), 50 | PlusSquareIcon(), 51 | QuestionIcon(), 52 | QuestionOutlineIcon(), 53 | RepeatIcon(), 54 | RepeatClockIcon(), 55 | SearchIcon(), 56 | Search2Icon(), 57 | SettingsIcon(), 58 | SmallAddIcon(), 59 | SmallCloseIcon(), 60 | SpinnerIcon(), 61 | StarIcon(), 62 | SunIcon(), 63 | TimeIcon(), 64 | TriangleDownIcon(), 65 | TriangleUpIcon(), 66 | UnlockIcon(), 67 | UpDownIcon(), 68 | ViewIcon(), 69 | ViewOffIcon(), 70 | WarningIcon(), 71 | WarningTwoIcon() 72 | ), 73 | commonBox(text = "Images"), 74 | HStack().withChildren( 75 | Image( 76 | src = "https://bit.ly/dan-abramov", 77 | alt = "Dan Abramov", 78 | boxSize = Some("150px") 79 | ), 80 | Image( 81 | src = "https://bit.ly/dan-abramov", 82 | alt = "Dan Abramov", 83 | boxSize = Some("150px"), 84 | borderRadius = Some("full") 85 | ), 86 | Image( 87 | src = "/web/images/logo1.png", 88 | alt = "logo no 1", 89 | boxSize = Some("150px"), 90 | borderRadius = Some("full") 91 | ) 92 | ) 93 | ) 94 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Navigation.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.* 4 | import org.terminal21.client.components.UiElement 5 | import org.terminal21.client.components.chakra.* 6 | import org.terminal21.client.components.std.Paragraph 7 | import tests.chakra.Common.commonBox 8 | 9 | object Navigation: 10 | def components(events: Events): Seq[UiElement] = 11 | val bcLinkHome = BreadcrumbLink("breadcrumb-home", text = "breadcrumb-home") 12 | val bcLink1 = BreadcrumbLink("breadcrumb-link1", text = "breadcrumb1") 13 | val bcCurrent = BreadcrumbItem(isCurrentPage = Some(true)) 14 | val bcLink2 = BreadcrumbLink("breadcrumb-link2", text = "breadcrumb2") 15 | val link = Link(key = "google-link", text = "link-external-google", href = "https://www.google.com/", isExternal = Some(true)) 16 | 17 | val bcStatus = 18 | ( 19 | events.ifClicked(bcLinkHome, "breadcrumb-click: breadcrumb-home").toSeq ++ 20 | events.ifClicked(bcLink1, "breadcrumb-click: breadcrumb-link1") ++ 21 | events.ifClicked(bcLink2, "breadcrumb-click: breadcrumb-link2") 22 | ).headOption.getOrElse("no-breadcrumb-clicked") 23 | 24 | val clickedBreadcrumb = Paragraph(text = bcStatus) 25 | val clickedLink = Paragraph(text = if events.isClicked(link) then "link-clicked" else "no-link-clicked") 26 | 27 | Seq( 28 | commonBox(text = "Breadcrumbs"), 29 | Breadcrumb().withChildren( 30 | BreadcrumbItem().withChildren(bcLinkHome), 31 | BreadcrumbItem().withChildren(bcLink1), 32 | bcCurrent.withChildren(bcLink2) 33 | ), 34 | clickedBreadcrumb, 35 | commonBox(text = "Link"), 36 | link, 37 | clickedLink 38 | ) 39 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Overlay.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.* 4 | import org.terminal21.client.components.UiElement 5 | import org.terminal21.client.components.chakra.* 6 | import tests.chakra.Common.commonBox 7 | 8 | object Overlay: 9 | def components(events: Events): Seq[UiElement] = 10 | val mi1 = MenuItem(key = "download-menu", text = "Download menu-download") 11 | val mi2 = MenuItem(key = "copy-menu", text = "Copy") 12 | val mi3 = MenuItem(key = "paste-menu", text = "Paste") 13 | val mi4 = MenuItem(key = "exit-menu", text = "Exit") 14 | 15 | val box1Msg = 16 | if events.isClicked(mi1) then "'Download' clicked" 17 | else if events.isClicked(mi2) then "'Copy' clicked" 18 | else if events.isClicked(mi3) then "'Paste' clicked" 19 | else if events.isClicked(mi4) then "'Exit' clicked" 20 | else "Clicks will be reported here." 21 | 22 | val box1 = Box(text = box1Msg) 23 | 24 | Seq( 25 | commonBox(text = "Menus box0001"), 26 | HStack().withChildren( 27 | Menu(key = "menu1").withChildren( 28 | MenuButton(text = "Actions menu0001", size = Some("sm"), colorScheme = Some("teal")).withChildren( 29 | ChevronDownIcon() 30 | ), 31 | MenuList().withChildren( 32 | mi1, 33 | mi2, 34 | mi3, 35 | MenuDivider(), 36 | mi4 37 | ) 38 | ), 39 | box1 40 | ) 41 | ) 42 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Stacks.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.{Box, HStack, VStack} 5 | import tests.chakra.Common.* 6 | 7 | object Stacks: 8 | def components: Seq[UiElement] = 9 | Seq( 10 | commonBox(text = "VStack"), 11 | VStack(spacing = Some("24px"), align = Some("stretch")).withChildren( 12 | Box(text = "1", bg = "green", p = 2, color = "black"), 13 | Box(text = "2", bg = "red", p = 2, color = "black"), 14 | Box(text = "3", bg = "blue", p = 2, color = "black") 15 | ), 16 | commonBox(text = "HStack"), 17 | HStack(spacing = Some("24px")).withChildren( 18 | Box(text = "1", bg = "green", p = 2, color = "black"), 19 | Box(text = "2", bg = "red", p = 2, color = "black"), 20 | Box(text = "3", bg = "blue", p = 2, color = "black") 21 | ) 22 | ) 23 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/chakra/Typography.scala: -------------------------------------------------------------------------------- 1 | package tests.chakra 2 | 3 | import org.terminal21.client.components.UiElement 4 | import org.terminal21.client.components.chakra.* 5 | 6 | object Typography: 7 | def components: Seq[UiElement] = 8 | Seq( 9 | Text(text = "typography-text-0001", color = Some("tomato")) 10 | ) 11 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/nivo/ResponsiveBarChart.scala: -------------------------------------------------------------------------------- 1 | package tests.nivo 2 | 3 | import org.terminal21.client.components.nivo.* 4 | import tests.chakra.Common.commonBox 5 | 6 | import scala.util.Random 7 | 8 | object ResponsiveBarChart: 9 | def apply() = Seq( 10 | commonBox("ResponsiveBar"), 11 | ResponsiveBar( 12 | data = Seq( 13 | dataFor("AD"), 14 | dataFor("AE"), 15 | dataFor("GB"), 16 | dataFor("GR"), 17 | dataFor("IT"), 18 | dataFor("FR"), 19 | dataFor("GE"), 20 | dataFor("US") 21 | ), 22 | keys = Seq("hot dog", "burger", "sandwich", "kebab", "fries", "donut"), 23 | indexBy = "country", 24 | padding = 0.3, 25 | defs = Seq( 26 | Defs("dots", "patternDots", "inherit", "#38bcb2", size = Some(4), padding = Some(1), stagger = Some(true)), 27 | Defs("lines", "patternLines", "inherit", "#eed312", rotation = Some(-45), lineWidth = Some(6), spacing = Some(10)) 28 | ), 29 | fill = Seq(Fill("dots", Match("fries")), Fill("lines", Match("sandwich"))), 30 | axisLeft = Some(Axis(legend = "food", legendOffset = -40)), 31 | axisBottom = Some(Axis(legend = "country", legendOffset = 32)), 32 | valueScale = Scale(`type` = "linear"), 33 | indexScale = Scale(`type` = "band", round = Some(true)), 34 | legends = Seq( 35 | Legend( 36 | dataFrom = "keys", 37 | translateX = 120, 38 | itemsSpacing = 2, 39 | itemWidth = 100, 40 | itemHeight = 20, 41 | symbolSize = 20, 42 | symbolShape = "square" 43 | ) 44 | ) 45 | ) 46 | ) 47 | 48 | def dataFor(country: String) = 49 | Seq( 50 | BarDatum("country", country), 51 | BarDatum("hot dog", rnd), 52 | BarDatum("hot dogColor", "hsl(202, 70%, 50%)"), 53 | BarDatum("burger", rnd), 54 | BarDatum("burgerColor", "hsl(106, 70%, 50%)"), 55 | BarDatum("sandwich", rnd), 56 | BarDatum("sandwichColor", "hsl(115, 70%, 50%)"), 57 | BarDatum("kebab", rnd), 58 | BarDatum("kebabColor", "hsl(113, 70%, 50%)"), 59 | BarDatum("fries", rnd), 60 | BarDatum("friesColor", "hsl(209, 70%, 50%)"), 61 | BarDatum("donut", rnd), 62 | BarDatum("donutColor", "hsl(47, 70%, 50%)") 63 | ) 64 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/nivo/ResponsiveLineChart.scala: -------------------------------------------------------------------------------- 1 | package tests.nivo 2 | 3 | import org.terminal21.client.* 4 | import org.terminal21.client.components.* 5 | import org.terminal21.client.components.nivo.* 6 | import tests.chakra.Common 7 | import tests.chakra.Common.commonBox 8 | 9 | import scala.collection.immutable.Seq 10 | 11 | object ResponsiveLineChart: 12 | def apply() = Seq( 13 | commonBox("ResponsiveLine"), 14 | ResponsiveLine( 15 | data = Seq( 16 | dataFor("Japan"), 17 | dataFor("France"), 18 | dataFor("Greece"), 19 | dataFor("UK"), 20 | dataFor("Germany") 21 | ), 22 | yScale = Scale(stacked = Some(true)), 23 | axisBottom = Some(Axis(legend = "transportation", legendOffset = 36)), 24 | axisLeft = Some(Axis(legend = "count", legendOffset = -40)), 25 | legends = Seq( 26 | Legend() 27 | ) 28 | ) 29 | ) 30 | 31 | def dataFor(country: String) = 32 | Serie( 33 | country, 34 | data = Seq( 35 | Datum("plane", rnd), 36 | Datum("helicopter", rnd), 37 | Datum("boat", rnd), 38 | Datum("car", rnd), 39 | Datum("submarine", rnd) 40 | ) 41 | ) 42 | -------------------------------------------------------------------------------- /end-to-end-tests/src/main/scala/tests/nivo/common.scala: -------------------------------------------------------------------------------- 1 | package tests.nivo 2 | 3 | import scala.util.Random 4 | 5 | def rnd = Random.nextInt(500) + 50 6 | -------------------------------------------------------------------------------- /end-to-end-tests/src/test/scala/tests/LoggedInTest.scala: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.scalatest.matchers.should.Matchers.* 5 | import org.terminal21.client.* 6 | import org.terminal21.model.CommandEvent 7 | 8 | class LoggedInTest extends AnyFunSuiteLike: 9 | class App: 10 | val login = LoginForm() 11 | given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock 12 | val form = new LoggedIn(login) 13 | def allComponents = form.components(false, Events.Empty).view.flatMap(_.flat) 14 | 15 | test("renders email details"): 16 | new App: 17 | allComponents should contain(form.emailDetails) 18 | 19 | test("renders password details"): 20 | new App: 21 | allComponents should contain(form.passwordDetails) 22 | 23 | test("yes clicked"): 24 | new App: 25 | val eventsIt = form.controller.render(false).iterator 26 | session.fireEvents(CommandEvent.onClick(form.yesButton), CommandEvent.sessionClosed) 27 | eventsIt.lastOption.map(_.model) should be(Some(true)) 28 | 29 | test("no clicked"): 30 | new App: 31 | val eventsIt = form.controller.render(false).iterator 32 | session.fireEvents(CommandEvent.onClick(form.noButton), CommandEvent.sessionClosed) 33 | eventsIt.lastOption.map(_.model) should be(Some(false)) 34 | -------------------------------------------------------------------------------- /end-to-end-tests/src/test/scala/tests/LoginPageTest.scala: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.scalatest.matchers.should.Matchers.* 5 | import org.terminal21.client.components.* 6 | import org.terminal21.client.* 7 | import org.terminal21.model.CommandEvent 8 | 9 | class LoginPageTest extends AnyFunSuiteLike: 10 | 11 | class App: 12 | given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock 13 | val login = LoginForm() 14 | val page = new LoginPage 15 | def allComponents: Seq[UiElement] = page.components(login, Events.Empty).view.flatMap(_.flat) 16 | 17 | test("renders email input"): 18 | new App: 19 | allComponents should contain(page.emailInput) 20 | 21 | test("renders password input"): 22 | new App: 23 | allComponents should contain(page.passwordInput) 24 | 25 | test("renders submit button"): 26 | new App: 27 | allComponents should contain(page.submitButton) 28 | 29 | test("user submits validated data"): 30 | new App: 31 | val eventsIt = page.controller.render(login).iterator // get the iterator before we fire the events, otherwise the iterator will be empty 32 | session.fireEvents( 33 | CommandEvent.onChange(page.emailInput, "an@email.com"), 34 | CommandEvent.onChange(page.passwordInput, "secret"), 35 | CommandEvent.onClick(page.submitButton), 36 | CommandEvent.sessionClosed // every test should close the session so that the iterator doesn't block if converted to a list. 37 | ) 38 | 39 | eventsIt.lastOption.map(_.model) should be(Some(LoginForm("an@email.com", "secret", true))) 40 | -------------------------------------------------------------------------------- /example-scripts/.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.7.15" 2 | runner.dialect = scala3 3 | maxColumn = 160 4 | -------------------------------------------------------------------------------- /example-scripts/bouncing-ball.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | // ------------------------------------------------------------------------------ 4 | // A bouncing ball similar to the C64 basic program. Can we make a bouncing 5 | // ball with the same simplicity as that program? 6 | // ------------------------------------------------------------------------------ 7 | 8 | // always import these 9 | import org.terminal21.client.* 10 | import org.terminal21.client.components.* 11 | import org.terminal21.model.* 12 | // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components 13 | // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala 14 | import org.terminal21.client.components.chakra.* 15 | 16 | Sessions 17 | .withNewSession("bouncing-ball", "C64 bouncing ball") 18 | .connect: session => 19 | given ConnectedSession = session 20 | 21 | // We'll do this with an MVC approach. This is our Model: 22 | case class Ball(x: Int, y: Int, dx: Int, dy: Int): 23 | def nextPosition: Ball = 24 | val newDx = if x < 0 || x > 600 then -dx else dx 25 | val newDy = if y < 0 || y > 500 then -dy else dy 26 | Ball(x + newDx, y + newDy, newDx, newDy) 27 | 28 | // In order to update the ball's position, we will be sending approx 60 Ticker events per second to our controller. 29 | case object Ticker extends ClientEvent 30 | 31 | val initialModel = Ball(50, 50, 8, 8) 32 | 33 | println( 34 | "Files under ~/.terminal21/web will be served under /web . Please place a ball.png file under ~/.terminal21/web/images on the box where the server runs." 35 | ) 36 | 37 | // This is our controller implementation. It takes the model (ball) and events (in this case just the Ticker which we can otherwise ignore) 38 | // and results in the next frame's state. 39 | def components(ball: Ball, events: Events): MV[Ball] = 40 | val b = ball.nextPosition 41 | MV( 42 | b, 43 | Image(src = "/web/images/ball.png").withStyle("position" -> "fixed", "left" -> (b.x + "px"), "top" -> (b.y + "px")) 44 | ) 45 | // We'll be sending a Ticker 60 times per second 46 | fiberExecutor.submit: 47 | while !session.isClosed do 48 | session.fireEvent(Ticker) 49 | Thread.sleep(1000 / 60) 50 | 51 | // We are ready to create a controller instance with our components function. 52 | Controller(components) 53 | .render(initialModel) // and render it with our initial model (it will call the components function and render any resulting UI) 54 | .run() // and run this until the user closes the session 55 | -------------------------------------------------------------------------------- /example-scripts/budget.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli 2 | 3 | //> using file project.scala 4 | import org.terminal21.client.components.chakra.QuickTable 5 | import java.time.LocalDate 6 | 7 | import org.terminal21.client.* 8 | import org.terminal21.client.components.* 9 | import org.terminal21.client.components.std.* 10 | 11 | Sessions 12 | .withNewSession("budget", "Personal Budget Calculator") 13 | .connect: session => 14 | given ConnectedSession = session 15 | 16 | println(new BudgetPage(BudgetForm()).run) 17 | 18 | case class BudgetForm( 19 | startDate: LocalDate = LocalDate.of(2024, 4, 1), 20 | initialBudget: Int = 1000, 21 | percentIncreasePerYear: Float = 4f / 100f, 22 | available: Float = 100000 23 | ) 24 | 25 | class BudgetPage(initialForm: BudgetForm)(using ConnectedSession): 26 | def run: Option[BudgetForm] = 27 | controller.render(initialForm).run() 28 | 29 | def components(form: BudgetForm, events: Events): MV[BudgetForm] = 30 | 31 | case class Row(date: LocalDate, budget: Float, total: Float, available: Float) 32 | val rows = (1 to 30 * 12) 33 | .foldLeft((Seq.empty[Row], form.initialBudget)): 34 | case ((rows, lastBudget), month) => 35 | val date = form.startDate.plusMonths(month) 36 | val budget = if month % 12 == 0 then (lastBudget + lastBudget * form.percentIncreasePerYear).toInt else lastBudget 37 | val total = rows.map(_.budget).sum + budget 38 | ( 39 | rows :+ Row(date, budget, total, Math.max(0, form.available - total)), 40 | budget 41 | ) 42 | ._1 43 | val table = QuickTable().withHeaders("Date", "Budget", "Total", "Available").withRows(rows.map(r => Seq(r.date, r.budget, r.total, r.available))) 44 | 45 | MV(form, table) 46 | 47 | def controller: Controller[BudgetForm] = Controller(components) 48 | -------------------------------------------------------------------------------- /example-scripts/csv-viewer.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | // ------------------------------------------------------------------------------ 4 | // A csv file viewer 5 | // Run with: ./csv-viewer.sc -- csv-file 6 | // ------------------------------------------------------------------------------ 7 | 8 | import org.terminal21.client.* 9 | import org.terminal21.client.components.* 10 | // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components 11 | // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala 12 | import org.terminal21.client.components.chakra.* 13 | 14 | import java.io.File 15 | import org.apache.commons.io.FileUtils 16 | 17 | if args.length != 1 then 18 | throw new IllegalArgumentException( 19 | "Expecting 1 argument, the name of the csv file to edit" 20 | ) 21 | 22 | val fileName = args(0) 23 | val file = new File(fileName) 24 | val contents = FileUtils.readFileToString(file, "UTF-8") 25 | 26 | val csv = contents.split("\n").map(_.split(",")) 27 | 28 | Sessions 29 | .withNewSession(s"csv-viewer-$fileName", s"CsvView: $fileName") 30 | .connect: session => 31 | given ConnectedSession = session 32 | 33 | Controller 34 | .noModel( 35 | TableContainer() // We could use the QuickTable component here, but lets do it a bit more low level with the Chakra components 36 | .withChildren( 37 | Table(variant = "striped", colorScheme = Some("teal"), size = "mg") 38 | .withChildren( 39 | TableCaption(text = "Csv file contents"), 40 | Tbody( 41 | children = csv.map: row => 42 | Tr( 43 | children = row.map: column => 44 | Td(text = column) 45 | ) 46 | ) 47 | ) 48 | ) 49 | ) 50 | .render() // we don't have to process any events here, just let the user view the csv file. 51 | println(s"Now open ${session.uiUrl} to view the UI.") 52 | // since this is a read-only UI, we can exit the app but leave the session open on the UI for the user to examine the data. 53 | session.leaveSessionOpenAfterExiting() 54 | -------------------------------------------------------------------------------- /example-scripts/hello-world.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | // ------------------------------------------------------------------------------ 3 | // Hello world with terminal21. 4 | // Run with ./hello-world.sc 5 | // ------------------------------------------------------------------------------ 6 | 7 | import org.terminal21.client.* 8 | import org.terminal21.client.components.* 9 | // std components like Paragraph, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala 10 | import org.terminal21.client.components.std.* 11 | 12 | Sessions 13 | .withNewSession("hello-world", "Hello World Example") 14 | .connect: session => 15 | given ConnectedSession = session 16 | 17 | Controller.noModel(Paragraph(text = "Hello World!")).render() 18 | // since this is a read-only UI, we can exit the app but leave the session open for the user to examine the page. 19 | session.leaveSessionOpenAfterExiting() 20 | -------------------------------------------------------------------------------- /example-scripts/mathjax.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | // ------------------------------------------------------------------------------ 4 | // Render some maths on screen for demo purposes. 5 | // Run with ./mathjax.sc 6 | // ------------------------------------------------------------------------------ 7 | 8 | import org.terminal21.client.* 9 | import org.terminal21.client.components.* 10 | import org.terminal21.client.components.mathjax.* 11 | 12 | Sessions 13 | .withNewSession("mathjax", "MathJax Example") 14 | .andLibraries(MathJaxLib /* note we need to register the MathJaxLib in order to use it */ ) 15 | .connect: session => 16 | given ConnectedSession = session 17 | Controller 18 | .noModel( 19 | Seq( 20 | MathJax( 21 | expression = """When \(a \ne 0\), there are two solutions to \(ax^2 + bx + c = 0\) and they are $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$""" 22 | ), 23 | MathJax( 24 | expression = """ 25 | |when \(a \ne 0\), there are two solutions to \(x = {-b \pm \sqrt{b^2-4ac} \over 2a}.\) 26 | |Aenean vel velit a lacus lacinia pulvinar. Morbi eget ex et tellus aliquam molestie sit amet eu diam. 27 | |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas tellus enim, tempor non efficitur et, rutrum efficitur metus. 28 | |Nulla scelerisque, mauris sit amet accumsan iaculis, elit ipsum suscipit lorem, sed fermentum nunc purus non tellus. 29 | |Aenean congue accumsan tempor. \(x = {-b \pm \sqrt{b^2-4ac} \over 2a}.\) maecenas vitae commodo tortor. Aliquam erat volutpat. Etiam laoreet malesuada elit sed vestibulum. 30 | |Etiam consequat congue fermentum. Vivamus dapibus scelerisque ipsum eu tempus. Integer non pulvinar nisi. 31 | |Morbi ultrices sem quis nisl convallis, ac cursus nunc condimentum. Orci varius natoque penatibus et magnis dis parturient montes, 32 | |nascetur ridiculus mus. 33 | |""".stripMargin 34 | ) 35 | ) 36 | ) 37 | .render() 38 | // since this is a read-only UI, we can exit the app but leave the session open on the UI for the user to examine the data. 39 | session.leaveSessionOpenAfterExiting() 40 | -------------------------------------------------------------------------------- /example-scripts/mvc-click-form.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | // ------------------------------------------------------------------------------ 4 | // MVC demo that handles a button click 5 | // Run with ./mvc-click-form.sc 6 | // ------------------------------------------------------------------------------ 7 | 8 | import org.terminal21.client.* 9 | import org.terminal21.client.components.* 10 | import org.terminal21.client.components.std.* 11 | import org.terminal21.client.components.chakra.* 12 | import org.terminal21.model.SessionOptions 13 | 14 | Sessions 15 | .withNewSession("mvc-click-form", "MVC form with a button") 16 | .connect: session => 17 | given ConnectedSession = session 18 | new ClickPage(ClickForm(false)).run() match 19 | case None => // the user closed the app 20 | case Some(model) => println(s"model = $model") 21 | 22 | Thread.sleep(1000) // wait a bit so that the user can see the change in the UI 23 | 24 | /** Our model 25 | * 26 | * @param clicked 27 | * will be set to true when the button is clicked 28 | */ 29 | case class ClickForm(clicked: Boolean) 30 | 31 | /** One nice way to structure the code (that simplifies testing too) is to create a class for every page in the user interface. In this instance, we create a 32 | * page for the click form to be displayed. All components are in `components` method. The controller is in the `controller` method and we can run to get the 33 | * result in the `run` method. We can use these methods in unit tests to test what is rendered and how events are processed respectively. 34 | */ 35 | class ClickPage(initialForm: ClickForm)(using ConnectedSession): 36 | def run(): Option[ClickForm] = controller.render(initialForm).run() 37 | 38 | def components(form: ClickForm, events: Events): MV[ClickForm] = 39 | val button = Button(key = "click-me", text = "Please click me") 40 | val updatedForm = form.copy( 41 | clicked = events.isClicked(button) 42 | ) 43 | val msg = Paragraph(text = if updatedForm.clicked then "Button clicked!" else "Waiting for user to click the button") 44 | 45 | MV( 46 | updatedForm, 47 | Seq(msg, button), 48 | terminate = updatedForm.clicked // terminate the event iteration 49 | ) 50 | 51 | def controller: Controller[ClickForm] = Controller(components) 52 | -------------------------------------------------------------------------------- /example-scripts/mvc-user-form.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | import org.terminal21.client.* 4 | import org.terminal21.client.components.* 5 | import org.terminal21.client.components.std.Paragraph 6 | import org.terminal21.client.components.chakra.* 7 | 8 | // ------------------------------------------------------------------------------ 9 | // MVC demo with an email form 10 | // Run with ./mvc-user-form.sc 11 | // ------------------------------------------------------------------------------ 12 | 13 | Sessions 14 | .withNewSession("mvc-user-form", "MVC example with a user form") 15 | .connect: session => 16 | given ConnectedSession = session 17 | new UserPage(UserForm("my@email.com", false)).run match 18 | case Some(submittedUser) => 19 | println(s"Submitted: $submittedUser") 20 | case None => 21 | println("User closed session without submitting the form") 22 | 23 | /** Our model for the form */ 24 | case class UserForm( 25 | email: String, // the email 26 | submitted: Boolean // true if user clicks the submit button, false otherwise 27 | ) 28 | 29 | /** One nice way to structure the code (that simplifies testing too) is to create a class for every page in the user interface. In this instance, we create a 30 | * page for the user form to be displayed. All components are in `components` method. The controller is in the `controller` method and we can run to get the 31 | * result in the `run` method. We can use these methods in unit tests to test what is rendered and how events are processed respectively. 32 | */ 33 | class UserPage(initialForm: UserForm)(using ConnectedSession): 34 | 35 | /** Runs the form and returns the results 36 | * @return 37 | * if None, the user didn't submit the form (i.e. closed the session), if Some(userForm) the user submitted the form. 38 | */ 39 | def run: Option[UserForm] = 40 | controller.render(initialForm).run().filter(_.submitted) 41 | 42 | /** @return 43 | * all the components that should be rendered for the page 44 | */ 45 | def components(form: UserForm, events: Events): MV[UserForm] = 46 | val emailInput = Input(key = "email", `type` = "email", defaultValue = initialForm.email) 47 | val submitButton = Button(key = "submit", text = "Submit") 48 | 49 | val updatedForm = form.copy( 50 | email = events.changedValue(emailInput, form.email), 51 | submitted = events.isClicked(submitButton) 52 | ) 53 | 54 | val output = Paragraph(text = if events.isChangedValue(emailInput) then s"Email changed: ${updatedForm.email}" else "Please modify the email.") 55 | 56 | MV( 57 | updatedForm, 58 | Seq( 59 | QuickFormControl() 60 | .withLabel("Email address") 61 | .withInputGroup( 62 | InputLeftAddon().withChildren(EmailIcon()), 63 | emailInput 64 | ) 65 | .withHelperText("We'll never share your email."), 66 | submitButton, 67 | output 68 | ), 69 | terminate = updatedForm.submitted // terminate the form when the submit button is clicked 70 | ) 71 | 72 | def controller: Controller[UserForm] = Controller(components) 73 | -------------------------------------------------------------------------------- /example-scripts/nivo-bar-chart.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | // ------------------------------------------------------------------------------ 4 | // Nivo bar chart demo, animated ! 5 | // Run with ./nivo-bar-chart.sc 6 | // ------------------------------------------------------------------------------ 7 | 8 | import org.terminal21.client.* 9 | import org.terminal21.client.fiberExecutor 10 | import org.terminal21.client.components.* 11 | import org.terminal21.client.components.std.* 12 | import org.terminal21.client.components.nivo.* 13 | 14 | import scala.util.Random 15 | import NivoBarChart.* 16 | import org.terminal21.model.ClientEvent 17 | 18 | Sessions 19 | .withNewSession("nivo-bar-chart", "Nivo Bar Chart") 20 | .andLibraries(NivoLib /* note we need to register the NivoLib in order to use it */ ) 21 | .connect: session => 22 | given ConnectedSession = session 23 | 24 | def components(events: Events): Seq[UiElement] = 25 | val data = createRandomData 26 | val chart = ResponsiveBar( 27 | data = data, 28 | keys = Seq("hot dog", "burger", "sandwich", "kebab", "fries", "donut"), 29 | indexBy = "country", 30 | padding = 0.3, 31 | defs = Seq( 32 | Defs("dots", "patternDots", "inherit", "#38bcb2", size = Some(4), padding = Some(1), stagger = Some(true)), 33 | Defs("lines", "patternLines", "inherit", "#eed312", rotation = Some(-45), lineWidth = Some(6), spacing = Some(10)) 34 | ), 35 | fill = Seq(Fill("dots", Match("fries")), Fill("lines", Match("sandwich"))), 36 | axisLeft = Some(Axis(legend = "food", legendOffset = -40)), 37 | axisBottom = Some(Axis(legend = "country", legendOffset = 32)), 38 | valueScale = Scale(`type` = "linear"), 39 | indexScale = Scale(`type` = "band", round = Some(true)), 40 | legends = Seq( 41 | Legend( 42 | dataFrom = "keys", 43 | translateX = 120, 44 | itemsSpacing = 2, 45 | itemWidth = 100, 46 | itemHeight = 20, 47 | symbolSize = 20, 48 | symbolShape = "square" 49 | ) 50 | ) 51 | ) 52 | Seq( 53 | Paragraph(text = "Various foods.", style = Map("margin" -> 20)), 54 | chart 55 | ) 56 | 57 | // we'll send new data to our controller every 2 seconds via a custom event 58 | case object Ticker extends ClientEvent 59 | fiberExecutor.submit: 60 | while !session.isClosed do 61 | Thread.sleep(2000) 62 | session.fireEvent(Ticker) 63 | 64 | Controller 65 | .noModel(components) 66 | .render() 67 | .run() 68 | 69 | object NivoBarChart: 70 | def createRandomData: Seq[Seq[BarDatum]] = 71 | Seq( 72 | dataFor("AD"), 73 | dataFor("AE"), 74 | dataFor("GB"), 75 | dataFor("GR"), 76 | dataFor("IT"), 77 | dataFor("FR"), 78 | dataFor("GE"), 79 | dataFor("US") 80 | ) 81 | 82 | def dataFor(country: String) = 83 | Seq( 84 | BarDatum("country", country), 85 | BarDatum("hot dog", rnd), 86 | BarDatum("hot dogColor", "hsl(202, 70%, 50%)"), 87 | BarDatum("burger", rnd), 88 | BarDatum("burgerColor", "hsl(106, 70%, 50%)"), 89 | BarDatum("sandwich", rnd), 90 | BarDatum("sandwichColor", "hsl(115, 70%, 50%)"), 91 | BarDatum("kebab", rnd), 92 | BarDatum("kebabColor", "hsl(113, 70%, 50%)"), 93 | BarDatum("fries", rnd), 94 | BarDatum("friesColor", "hsl(209, 70%, 50%)"), 95 | BarDatum("donut", rnd), 96 | BarDatum("donutColor", "hsl(47, 70%, 50%)") 97 | ) 98 | 99 | def rnd = Random.nextInt(500) + 50 100 | -------------------------------------------------------------------------------- /example-scripts/nivo-line-chart.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | // ------------------------------------------------------------------------------ 4 | // Nivo line chart demo, animated ! 5 | // Run with ./nivo-line-chart.sc 6 | // ------------------------------------------------------------------------------ 7 | 8 | import org.terminal21.client.* 9 | import org.terminal21.client.fiberExecutor 10 | import org.terminal21.client.components.* 11 | import org.terminal21.client.components.std.* 12 | import org.terminal21.client.components.nivo.* 13 | 14 | import scala.util.Random 15 | import NivoLineChart.* 16 | import org.terminal21.model.ClientEvent 17 | 18 | Sessions 19 | .withNewSession("nivo-line-chart", "Nivo Line Chart") 20 | .andLibraries(NivoLib /* note we need to register the NivoLib in order to use it */ ) 21 | .connect: session => 22 | given ConnectedSession = session 23 | 24 | def components(events: Events): Seq[UiElement] = 25 | val chart = ResponsiveLine( 26 | data = createRandomData, 27 | yScale = Scale(stacked = Some(true)), 28 | axisBottom = Some(Axis(legend = "transportation", legendOffset = 36)), 29 | axisLeft = Some(Axis(legend = "count", legendOffset = -40)), 30 | legends = Seq(Legend()) 31 | ) 32 | Seq( 33 | Paragraph(text = "Means of transportation for various countries", style = Map("margin" -> 20)), 34 | chart 35 | ) 36 | // we'll send new data to our controller every 2 seconds via a custom event 37 | case object Ticker extends ClientEvent 38 | fiberExecutor.submit: 39 | while !session.isClosed do 40 | Thread.sleep(2000) 41 | session.fireEvent(Ticker) 42 | 43 | Controller 44 | .noModel(components) 45 | .render() 46 | .run() 47 | 48 | object NivoLineChart: 49 | def createRandomData: Seq[Serie] = 50 | Seq( 51 | dataFor("Japan"), 52 | dataFor("France"), 53 | dataFor("Greece"), 54 | dataFor("UK"), 55 | dataFor("Germany") 56 | ) 57 | def dataFor(country: String): Serie = 58 | Serie( 59 | country, 60 | data = Seq( 61 | Datum("plane", rnd), // rnd = random int, see below 62 | Datum("helicopter", rnd), 63 | Datum("boat", rnd), 64 | Datum("car", rnd), 65 | Datum("submarine", rnd) 66 | ) 67 | ) 68 | 69 | def rnd = Random.nextInt(500) + 50 70 | -------------------------------------------------------------------------------- /example-scripts/postit.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | // ------------------------------------------------------------------------------ 3 | // A note poster, where anyone can write a note 4 | // Run with ./postit.sc 5 | // ------------------------------------------------------------------------------ 6 | 7 | import org.terminal21.client.* 8 | import org.terminal21.client.components.* 9 | // std components like Paragraph, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala 10 | import org.terminal21.client.components.std.* 11 | // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components 12 | // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala 13 | import org.terminal21.client.components.chakra.* 14 | 15 | Sessions 16 | .withNewSession("postit", "Post-It") 17 | .connect: session => 18 | given ConnectedSession = session 19 | println(s"Now open ${session.uiUrl} to view the UI.") 20 | new PostItPage().run() 21 | 22 | class PostItPage(using ConnectedSession): 23 | case class PostIt(message: String = "", messages: List[String] = Nil) 24 | def run(): Unit = controller.render(PostIt()).iterator.lastOption 25 | 26 | def components(model: PostIt, events: Events): MV[PostIt] = 27 | val editor = Textarea("postit-message", placeholder = "Please post your note by clicking here and editing the content") 28 | val addButton = Button("postit", text = "Post It.") 29 | val clearButton = Button("clear-it", text = "Clear board.") 30 | 31 | val updatedMessages = if events.isClicked(clearButton) then Nil else model.messages ++ events.ifClicked(addButton, model.message) 32 | val updatedModel = model.copy( 33 | message = events.changedValue(editor, model.message), 34 | messages = updatedMessages 35 | ) 36 | 37 | val messagesVStack = VStack( 38 | "the-board", 39 | align = Some("stretch"), 40 | children = updatedMessages.map: msg => 41 | HStack() 42 | .withSpacing("8px") 43 | .withChildren( 44 | Image( 45 | src = "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Apple_Notes_icon.svg/2048px-Apple_Notes_icon.svg.png", 46 | boxSize = Some("32px") 47 | ), 48 | Box(text = msg) 49 | ) 50 | ) 51 | MV( 52 | updatedModel, 53 | Seq( 54 | Paragraph(text = "Please type your note below and click 'Post It' to post it so that everyone can view it."), 55 | InputGroup().withChildren( 56 | InputLeftAddon().withChildren(EditIcon()), 57 | editor 58 | ), 59 | HStack() 60 | .withSpacing("8px") 61 | .withChildren( 62 | addButton, 63 | clearButton 64 | ), 65 | messagesVStack 66 | ) 67 | ) 68 | 69 | def controller = Controller(components) 70 | -------------------------------------------------------------------------------- /example-scripts/progress.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | // ------------------------------------------------------------------------------ 4 | // Universe creation progress bar demo 5 | // Run with ./progress.sc 6 | // ------------------------------------------------------------------------------ 7 | 8 | import org.terminal21.client.{*, given} 9 | import org.terminal21.client.components.* 10 | import org.terminal21.client.components.std.* 11 | import org.terminal21.client.components.chakra.* 12 | import org.terminal21.model.{ClientEvent, SessionOptions} 13 | 14 | Sessions 15 | .withNewSession("universe-generation", "Universe Generation Progress") 16 | .connect: session => 17 | given ConnectedSession = session 18 | 19 | def components(model: Int, events: Events): MV[Int] = 20 | val status = 21 | if model < 10 then "Generating universe ..." 22 | else if model < 30 then "Creating atoms" 23 | else if model < 50 then "Big bang!" 24 | else if model < 80 then "Inflating" 25 | else "Life evolution" 26 | 27 | val msg = Paragraph(text = status) 28 | val progress = Progress(value = model) 29 | 30 | MV( 31 | model + 1, 32 | Seq(msg, progress) 33 | ) 34 | 35 | // send a ticker to update the progress bar 36 | object Ticker extends ClientEvent 37 | fiberExecutor.submit: 38 | for _ <- 1 to 100 do 39 | Thread.sleep(200) 40 | session.fireEvent(Ticker) 41 | 42 | Controller(components) 43 | .render(1) // render takes the initial model value, in this case our model is the progress as an Int between 0 and 100. We start with 1 and increment it in the components function 44 | .iterator 45 | .takeWhile(_.model < 100) // terminate when model == 100 46 | .foreach(_ => ()) // and run it 47 | // clear UI 48 | session.render(Seq(Paragraph(text = "Universe ready!"))) 49 | session.leaveSessionOpenAfterExiting() 50 | -------------------------------------------------------------------------------- /example-scripts/project.scala: -------------------------------------------------------------------------------- 1 | //> using jvm "21" 2 | //> using scala 3 3 | 4 | //> using dep io.github.kostaskougios::terminal21-ui-std:0.30 5 | //> using dep io.github.kostaskougios::terminal21-nivo:0.30 6 | //> using dep io.github.kostaskougios::terminal21-mathjax:0.30 7 | 8 | //> using dep commons-io:commons-io:2.16.0 9 | 10 | //> using javaOpt -Xmx128m 11 | -------------------------------------------------------------------------------- /example-scripts/server.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli 2 | 3 | //> using jvm "21" 4 | //> using scala 3 5 | //> using javaOpt -Xmx128m 6 | //> using dep io.github.kostaskougios::terminal21-server-app:0.30 7 | 8 | import org.terminal21.server.Terminal21Server 9 | 10 | Terminal21Server.start() 11 | -------------------------------------------------------------------------------- /example-scripts/textedit.sc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S scala-cli project.scala 2 | 3 | // ------------------------------------------------------------------------------ 4 | // A text file editor for small files. 5 | // run with ./textedit.sc -- text-file 6 | // ------------------------------------------------------------------------------ 7 | 8 | import org.apache.commons.io.FileUtils 9 | import java.io.File 10 | 11 | import org.terminal21.client.* 12 | import org.terminal21.client.components.* 13 | // std components like Paragraph, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala 14 | import org.terminal21.client.components.std.* 15 | // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components 16 | // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala 17 | import org.terminal21.client.components.chakra.* 18 | 19 | if args.length != 1 then 20 | throw new IllegalArgumentException( 21 | "Expecting 1 argument, the name of the file to edit" 22 | ) 23 | 24 | val fileName = args(0) 25 | val file = new File(fileName) 26 | val contents = 27 | if file.exists() then FileUtils.readFileToString(file, "UTF-8") else "" 28 | 29 | def saveFile(content: String): Unit = 30 | println(s"Saving file $fileName") 31 | FileUtils.writeStringToFile(file, content, "UTF-8") 32 | 33 | Sessions 34 | .withNewSession(s"textedit-$fileName", s"Edit: $fileName") 35 | .connect: session => 36 | given ConnectedSession = session 37 | 38 | // the model for our editor form 39 | case class Edit(content: String, savedContent: String, save: Boolean) 40 | // the main editor area. 41 | def components(edit: Edit, events: Events): MV[Edit] = 42 | val editorTextArea = Textarea("editor", defaultValue = edit.content) 43 | val saveMenu = MenuItem("save-menu", text = "Save") 44 | val exitMenu = MenuItem("exit-menu", text = "Exit") 45 | val isSave = events.isClicked(saveMenu) 46 | val updatedContent = events.changedValue(editorTextArea, edit.content) 47 | val updatedEdit = edit.copy( 48 | content = updatedContent, 49 | save = isSave, 50 | savedContent = if isSave then updatedContent else edit.savedContent 51 | ) 52 | val modified = Badge(colorScheme = Some("red"), text = if updatedEdit.content != updatedEdit.savedContent then "*" else "") 53 | val status = Badge(text = if updatedEdit.save then "Saved" else "") 54 | 55 | val view = Seq( 56 | HStack().withChildren( 57 | Menu().withChildren( 58 | MenuButton("file-menu", text = "File").withChildren(ChevronDownIcon()), 59 | MenuList().withChildren( 60 | saveMenu, 61 | exitMenu 62 | ) 63 | ), 64 | status, 65 | modified 66 | ), 67 | FormControl().withChildren( 68 | FormLabel(text = "Editor"), 69 | InputGroup().withChildren( 70 | InputLeftAddon().withChildren(EditIcon()), 71 | editorTextArea 72 | ) 73 | ) 74 | ) 75 | 76 | MV(updatedEdit, view, terminate = events.isClicked(exitMenu)) 77 | 78 | println(s"Now open ${session.uiUrl} to view the UI") 79 | Controller(components) 80 | .render(Edit(contents, contents, false)) 81 | .iterator 82 | .tapEach: mv => 83 | if mv.model.save then saveFile(mv.model.content) 84 | .foreach(_ => ()) 85 | session.render(Seq(Paragraph(text = "Terminated"))) 86 | -------------------------------------------------------------------------------- /example-spark/etc/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /example-spark/model/Person.scala: -------------------------------------------------------------------------------- 1 | case class Person(id: Int, name: String, age: Int) 2 | 3 | -------------------------------------------------------------------------------- /example-spark/project.scala: -------------------------------------------------------------------------------- 1 | //> using jvm "21" 2 | //> using scala 3 3 | 4 | // these java params are needed for spark to work with jdk 21 5 | //> using javaOpt --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens java.base/sun.util.calendar=ALL-UNNAMED --add-opens java.base/sun.security.action=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED 6 | 7 | // configure logback 8 | //> using javaOpt -Dlogback.configurationFile=file:etc/logback.xml 9 | 10 | // terminal21 dependencies 11 | //> using dep io.github.kostaskougios::terminal21-ui-std:0.30 12 | //> using dep io.github.kostaskougios::terminal21-spark:0.30 13 | //> using dep io.github.kostaskougios::terminal21-nivo:0.30 14 | //> using dep io.github.kostaskougios::terminal21-mathjax:0.30 15 | 16 | //> using dep ch.qos.logback:logback-classic:1.4.14 17 | 18 | //> using file model 19 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.9 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") 2 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") 3 | 4 | addSbtPlugin("io.github.kostaskougios" % "functions-remote-sbt-plugin" % "0.51") 5 | 6 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.10.0") 7 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1") 8 | -------------------------------------------------------------------------------- /publish.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / publishMavenStyle := true 2 | ThisBuild / Test / publishArtifact := false 3 | ThisBuild / pomIncludeRepository := { _ => false } 4 | ThisBuild / licenses := Seq("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")) 5 | ThisBuild / homepage := Some(url("https://github.com/kostaskougios/terminal21-restapi")) 6 | ThisBuild / scmInfo := Some( 7 | ScmInfo( 8 | url("https://github.com/kostaskougios/terminal21-restapi"), 9 | "scm:https://github.com/kostaskougios/terminal21-restapi.git" 10 | ) 11 | ) 12 | ThisBuild / developers := List( 13 | Developer(id = "kostaskougios", name = "Kostas Kougios", email = "kostas.kougios@googlemail.com", url = url("https://github.com/kostaskougios")) 14 | ) 15 | ThisBuild / versionScheme := Some("early-semver") 16 | 17 | ThisBuild / publishTo := { 18 | val nexus = "https://s01.oss.sonatype.org/" 19 | if (isSnapshot.value) Some("snapshots" at nexus + "content/repositories/snapshots") 20 | else Some("releases" at nexus + "service/local/staging/deploy/maven2") 21 | } 22 | 23 | // disable publishing the root 24 | publish := {} 25 | publishLocal := {} 26 | publishArtifact := false 27 | -------------------------------------------------------------------------------- /terminal21-code-generation/src/main/scala/functions/tastyextractor/BetterErrors.scala: -------------------------------------------------------------------------------- 1 | package functions.tastyextractor 2 | 3 | object BetterErrors: 4 | def betterError[R](name: String)(f: => R): R = 5 | try f 6 | catch case t: Throwable => throw new IllegalStateException(name, t) 7 | -------------------------------------------------------------------------------- /terminal21-code-generation/src/main/scala/functions/tastyextractor/model/EImport.scala: -------------------------------------------------------------------------------- 1 | package functions.tastyextractor.model 2 | 3 | case class EImport(fullName: String) 4 | -------------------------------------------------------------------------------- /terminal21-code-generation/src/main/scala/functions/tastyextractor/model/EMethod.scala: -------------------------------------------------------------------------------- 1 | package functions.tastyextractor.model 2 | 3 | case class EMethod(name: String, paramss: List[List[EParam]], returnType: EType, scalaDocs: Option[String]) 4 | -------------------------------------------------------------------------------- /terminal21-code-generation/src/main/scala/functions/tastyextractor/model/EPackage.scala: -------------------------------------------------------------------------------- 1 | package functions.tastyextractor.model 2 | 3 | case class EPackage(name: String, imports: Seq[EImport], types: Seq[EType]): 4 | def toPath: String = name.replace('.', '/') 5 | -------------------------------------------------------------------------------- /terminal21-code-generation/src/main/scala/functions/tastyextractor/model/EParam.scala: -------------------------------------------------------------------------------- 1 | package functions.tastyextractor.model 2 | 3 | case class EParam(name: String, `type`: EType, code: String) 4 | -------------------------------------------------------------------------------- /terminal21-code-generation/src/main/scala/functions/tastyextractor/model/EType.scala: -------------------------------------------------------------------------------- 1 | package functions.tastyextractor.model 2 | 3 | import org.apache.commons.lang3.StringUtils 4 | 5 | case class EType(name: String, code: String, typeArgs: Seq[EType], vals: List[EParam], scalaDocs: Option[String], methods: Seq[EMethod]): 6 | def isUnit: Boolean = code == "scala.Unit" 7 | 8 | def simplifiedCode: String = if typeArgs.isEmpty then name else s"$name[${typeArgs.map(_.simplifiedCode).mkString(", ")}]" 9 | 10 | object EType: 11 | def code(name: String, code: String) = EType(name, code, Nil, Nil, None, Nil) 12 | -------------------------------------------------------------------------------- /terminal21-code-generation/src/main/scala/org/terminal21/codegen/Code.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.codegen 2 | 3 | import org.apache.commons.io.FileUtils 4 | 5 | import java.io.File 6 | 7 | case class Code(file: String, code: String): 8 | def writeTo(srcRootFolder: String): Unit = 9 | val f = new File(srcRootFolder, file) 10 | FileUtils.writeStringToFile(f, code, "UTF-8") 11 | -------------------------------------------------------------------------------- /terminal21-code-generation/src/main/scala/org/terminal21/codegen/PropertiesExtensionGenerator.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.codegen 2 | 3 | import functions.tastyextractor.StructureExtractor 4 | import functions.tastyextractor.model.EType 5 | import org.terminal21.codegen.PropertiesExtensionGenerator.{extract, generate} 6 | 7 | import java.io.File 8 | object PropertiesExtensionGenerator: 9 | private val e = StructureExtractor() 10 | 11 | def extract(tastys: List[String]): Code = 12 | val mainCp = detectClasspath(new File("../terminal21-ui-std")) 13 | val packages = e.fromFiles(tastys, List(mainCp.getAbsolutePath)) 14 | val ext = packages.map: p => 15 | val extCode = p.types 16 | .filterNot(_.name.contains("$")) 17 | .filterNot(_.vals.isEmpty) 18 | .map: t => 19 | createExtension(t) 20 | 21 | extCode.mkString("\n") 22 | 23 | val p = packages.head 24 | Code( 25 | s"${p.name.replace('.', '/')}/extensions.scala", 26 | s""" 27 | |package ${p.name} 28 | | 29 | |// GENERATED WITH PropertiesExtensionGenerator, DON'T EDIT 30 | | 31 | |${p.imports.map(_.fullName).mkString("import ", "\nimport ", "")} 32 | |${ext.mkString("\n")} 33 | |""".stripMargin 34 | ) 35 | 36 | def fix(n: String) = n match 37 | case "type" => "`type`" 38 | case _ => n 39 | 40 | def createExtension(t: EType): String = 41 | val skipVals = Set("children", "rendered", "style") 42 | val methods = t.vals 43 | .filterNot(v => skipVals(v.name)) 44 | .map: vl => 45 | s"def with${vl.name.capitalize}(v: ${vl.`type`.simplifiedCode}) = copy(${fix(vl.name)} = v)" 46 | 47 | s""" 48 | |extension (e: ${t.name}) 49 | | ${methods.mkString("\n ")} 50 | |""".stripMargin 51 | 52 | def detectClasspath(moduleDir: File) = 53 | val targetDir = new File(moduleDir, "target") 54 | val scala3Dir = targetDir.listFiles().find(_.getName.startsWith("scala-3")).get 55 | val classesDir = new File(scala3Dir, "classes") 56 | classesDir 57 | 58 | def generate(moduleDir: File, pckg: String): Unit = 59 | println(s"Generating for $pckg") 60 | val classesRootDir = detectClasspath(moduleDir) 61 | val classesDir = new File(classesRootDir, pckg.replace('.', '/')) 62 | val code = extract(classesDir.listFiles().filter(_.getName.endsWith(".tasty")).filterNot(_.getName.contains("$")).map(_.getAbsolutePath).toList) 63 | code.writeTo(s"${moduleDir.getAbsolutePath}/src/main/ui-generated") 64 | 65 | @main def propertiesExtensionGeneratorApp(): Unit = 66 | generate(new File("../terminal21-ui-std"), "org.terminal21.client.components.std") 67 | generate(new File("../terminal21-ui-std"), "org.terminal21.client.components.chakra") 68 | // generate(new File("../terminal21-nivo"), "org.terminal21.client.components.nivo") 69 | -------------------------------------------------------------------------------- /terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJax.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.mathjax 2 | 3 | import org.terminal21.client.components.UiElement.HasStyle 4 | import org.terminal21.client.components.{Keys, UiElement} 5 | import org.terminal21.collections.TypedMap 6 | 7 | sealed trait MathJaxElement extends UiElement 8 | 9 | /** see https://asciimath.org/ and https://github.com/fast-reflexes/better-react-mathjax 10 | */ 11 | case class MathJax( 12 | key: String = Keys.nextKey, 13 | // expression should be like """ text \( asciimath \) text""", i.e. """When \(a \ne 0\), there are two solutions to \(ax^2 + bx + c = 0\)""" 14 | expression: String = """fill in the expression as per https://asciimath.org/""", 15 | style: Map[String, Any] = Map.empty, // Note: some of the styles are ignored by mathjax lib 16 | dataStore: TypedMap = TypedMap.Empty 17 | ) extends MathJaxElement 18 | with HasStyle: 19 | type This = MathJax 20 | override def withStyle(v: Map[String, Any]): MathJax = copy(style = v) 21 | def withKey(k: String) = copy(key = k) 22 | def withExpression(e: String) = copy(expression = e) 23 | override def withDataStore(ds: TypedMap): MathJax = copy(dataStore = ds) 24 | -------------------------------------------------------------------------------- /terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJaxLib.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.mathjax 2 | 3 | import io.circe.generic.auto.* 4 | import io.circe.syntax.* 5 | import io.circe.* 6 | import org.terminal21.client.components.{ComponentLib, UiElement} 7 | 8 | object MathJaxLib extends ComponentLib: 9 | import org.terminal21.client.json.StdElementEncoding.given 10 | override def toJson(using Encoder[UiElement]): PartialFunction[UiElement, Json] = 11 | case n: MathJaxElement => n.asJson.mapObject(o => o.add("type", "MathJax".asJson)) 12 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/NivoLib.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components 2 | 3 | import io.circe.* 4 | import io.circe.generic.auto.* 5 | import io.circe.syntax.* 6 | import org.terminal21.client.components.nivo.NEJson 7 | 8 | object NivoLib extends ComponentLib: 9 | import org.terminal21.client.json.StdElementEncoding.given 10 | override def toJson(using Encoder[UiElement]): PartialFunction[UiElement, Json] = 11 | case n: NEJson => n.asJson.mapObject(o => o.add("type", "Nivo".asJson)) 12 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/Axis.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | case class Axis( 4 | tickSize: Int = 5, 5 | tickPadding: Int = 5, 6 | tickRotation: Int = 0, 7 | legend: String = "CHANGEME axis.legend", 8 | legendOffset: Int = 0, 9 | legendPosition: String = "middle" 10 | ) 11 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/BarDatum.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | import io.circe.{Encoder, Json} 4 | 5 | case class BarDatum( 6 | name: String, 7 | value: Any // String, Int, Float etc, see the encoder below for supported types 8 | ) 9 | 10 | object BarDatum: 11 | given Encoder[Seq[BarDatum]] = s => 12 | val vs = s.map: bd => 13 | ( 14 | bd.name, 15 | bd.value match 16 | case s: String => Json.fromString(s) 17 | case i: Int => Json.fromInt(i) 18 | case f: Float => Json.fromFloat(f).get 19 | case d: Double => Json.fromDouble(d).get 20 | case b: Boolean => Json.fromBoolean(b) 21 | case _ => throw new IllegalArgumentException(s"type $bd not supported, either use one of the supported ones or open a bug request") 22 | ) 23 | Json.obj(vs: _*) 24 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/Defs.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | case class Defs( 4 | id: String = "dots", 5 | `type`: String = "patternDots", 6 | background: String = "inherit", 7 | color: String = "#38bcb2", 8 | size: Option[Int] = None, 9 | padding: Option[Float] = None, 10 | stagger: Option[Boolean] = None, 11 | rotation: Option[Int] = None, 12 | lineWidth: Option[Int] = None, 13 | spacing: Option[Int] = None 14 | ) 15 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/Effect.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | case class Effect( 4 | on: String = "hover", 5 | style: Map[String, Any] = Map( 6 | "itemBackground" -> "rgba(0, 0, 0, .03)", 7 | "itemOpacity" -> 1 8 | ) 9 | ) 10 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/Fill.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | case class Fill( 4 | id: String, 5 | `match`: Match 6 | ) 7 | 8 | case class Match(id: String) 9 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/Legend.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | case class Legend( 4 | dataFrom: String = "keys", 5 | anchor: String = "bottom-right", 6 | direction: String = "column", 7 | justify: Boolean = false, 8 | translateX: Int = 100, 9 | translateY: Int = 0, 10 | itemsSpacing: Int = 0, 11 | itemDirection: String = "left-to-right", 12 | itemWidth: Int = 80, 13 | itemHeight: Int = 20, 14 | itemOpacity: Float = 0.75, 15 | symbolSize: Int = 12, 16 | symbolShape: String = "circle", 17 | symbolBorderColor: String = "rgba(0, 0, 0, .5)", 18 | effects: Seq[Effect] = Seq(Effect()) 19 | ) 20 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/Margin.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | case class Margin(top: Int = 50, right: Int = 50, bottom: Int = 50, left: Int = 50) 4 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/NivoElement.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | import org.terminal21.client.components.UiElement.HasStyle 4 | import org.terminal21.client.components.{Keys, UiElement} 5 | import org.terminal21.collections.TypedMap 6 | 7 | sealed trait NEJson extends UiElement 8 | sealed trait NivoElement extends NEJson with HasStyle 9 | 10 | /** https://nivo.rocks/line/ 11 | */ 12 | case class ResponsiveLine( 13 | key: String = Keys.nextKey, 14 | // to give width and height, we wrap the component in a wrapper element. Height must be provided 15 | // for nivo components to be visible 16 | style: Map[String, Any] = Map("height" -> "400px"), 17 | data: Seq[Serie] = Nil, 18 | margin: Margin = Margin(right = 110), 19 | xScale: Scale = Scale.Point, 20 | yScale: Scale = Scale(), 21 | yFormat: String = " >-.2f", 22 | axisTop: Option[Axis] = None, 23 | axisRight: Option[Axis] = None, 24 | axisBottom: Option[Axis] = Some(Axis(legend = "y", legendOffset = 36)), 25 | axisLeft: Option[Axis] = Some(Axis(legend = "x", legendOffset = -40)), 26 | pointSize: Int = 10, 27 | pointColor: Map[String, String] = Map("theme" -> "background"), 28 | pointBorderWidth: Int = 2, 29 | pointBorderColor: Map[String, String] = Map("from" -> "serieColor"), 30 | pointLabelYOffset: Int = -12, 31 | useMesh: Boolean = true, 32 | legends: Seq[Legend] = Nil, 33 | dataStore: TypedMap = TypedMap.Empty 34 | ) extends NivoElement: 35 | type This = ResponsiveLine 36 | override def withStyle(v: Map[String, Any]): ResponsiveLine = copy(style = v) 37 | def withKey(v: String) = copy(key = v) 38 | def withData(data: Seq[Serie]) = copy(data = data) 39 | override def withDataStore(ds: TypedMap) = copy(dataStore = ds) 40 | 41 | /** https://nivo.rocks/bar/ 42 | */ 43 | case class ResponsiveBar( 44 | key: String = Keys.nextKey, 45 | // to give width and height, we wrap the component in a wrapper element. Height must be provided 46 | // for nivo components to be visible 47 | style: Map[String, Any] = Map("height" -> "400px"), 48 | data: Seq[Seq[BarDatum]] = Nil, 49 | keys: Seq[String] = Nil, 50 | indexBy: String = "", 51 | margin: Margin = Margin(right = 110), 52 | padding: Float = 0, 53 | valueScale: Scale = Scale(), 54 | indexScale: Scale = Scale(), 55 | colors: Map[String, String] = Map("scheme" -> "nivo"), 56 | defs: Seq[Defs] = Nil, 57 | fill: Seq[Fill] = Nil, 58 | axisTop: Option[Axis] = None, 59 | axisRight: Option[Axis] = None, 60 | axisBottom: Option[Axis] = Some(Axis(legend = "y", legendOffset = 36)), 61 | axisLeft: Option[Axis] = Some(Axis(legend = "x", legendOffset = -40)), 62 | legends: Seq[Legend] = Nil, 63 | ariaLabel: String = "Chart Label", 64 | dataStore: TypedMap = TypedMap.Empty 65 | ) extends NivoElement: 66 | type This = ResponsiveBar 67 | override def withStyle(v: Map[String, Any]): ResponsiveBar = copy(style = v) 68 | def withKey(v: String) = copy(key = v) 69 | def withData(data: Seq[Seq[BarDatum]]) = copy(data = data) 70 | override def withDataStore(ds: TypedMap) = copy(dataStore = ds) 71 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/Scale.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | case class Scale( 4 | `type`: String = "linear", 5 | min: Option[String] = Some("auto"), 6 | max: Option[String] = Some("auto"), 7 | stacked: Option[Boolean] = Some(false), 8 | reverse: Option[Boolean] = Some(false), 9 | round: Option[Boolean] = None 10 | ) 11 | 12 | object Scale: 13 | val Point = Scale(`type` = "point", None, None, None, None) 14 | -------------------------------------------------------------------------------- /terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/Serie.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.nivo 2 | 3 | import io.circe.{Encoder, Json} 4 | 5 | case class Serie( 6 | id: String, 7 | color: String = "hsl(88, 70%, 50%)", 8 | data: Seq[Datum] = Nil 9 | ) 10 | 11 | case class Datum( 12 | x: String | Int | Float, 13 | y: String | Int | Float 14 | ) 15 | 16 | object Datum: 17 | private def toJson(name: String, x: String | Int | Float): (String, Json) = x match 18 | case s: String => (name, Json.fromString(s)) 19 | case i: Int => (name, Json.fromInt(i)) 20 | case f: Float => (name, Json.fromFloat(f).get) 21 | 22 | given Encoder[Datum] = 23 | case Datum(x, y) => 24 | Json.obj(toJson("x", x), toJson("y", y)) 25 | -------------------------------------------------------------------------------- /terminal21-server-app/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /terminal21-server-app/src/main/scala/org/terminal21/server/Dependencies.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server 2 | 3 | import functions.fibers.FiberExecutor 4 | import org.terminal21.serverapp.{ServerSideApp, ServerSideSessionsBeans} 5 | import org.terminal21.serverapp.bundled.AppManagerBeans 6 | 7 | class Dependencies(val fiberExecutor: FiberExecutor, val apps: Seq[ServerSideApp]) extends ServerBeans with ServerSideSessionsBeans with AppManagerBeans: 8 | override def dependencies: Dependencies = this 9 | -------------------------------------------------------------------------------- /terminal21-server-app/src/main/scala/org/terminal21/server/Terminal21Server.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server 2 | 3 | import functions.fibers.FiberExecutor 4 | import io.helidon.logging.common.LogConfig 5 | import io.helidon.webserver.WebServer 6 | import io.helidon.webserver.http.HttpRouting 7 | import org.slf4j.LoggerFactory 8 | import org.terminal21.config.Config 9 | import org.terminal21.serverapp.ServerSideApp 10 | import org.terminal21.serverapp.bundled.DefaultApps 11 | 12 | import java.net.InetAddress 13 | 14 | object Terminal21Server: 15 | private val logger = LoggerFactory.getLogger(getClass) 16 | def start(port: Option[Int] = None, apps: Seq[ServerSideApp] = Nil, defaultApps: Seq[ServerSideApp] = DefaultApps.All): Unit = 17 | FiberExecutor.withFiberExecutor: executor => 18 | val portV = port.getOrElse(Config.Default.port) 19 | val dependencies = new Dependencies(executor, apps ++ defaultApps) 20 | val routesBuilder = HttpRouting.builder() 21 | Routes.register(dependencies, routesBuilder) 22 | Routes.static(routesBuilder) 23 | 24 | LogConfig.configureRuntime() 25 | val server = WebServer.builder 26 | .port(portV) 27 | .routing(routesBuilder) 28 | .addRouting(Routes.ws(dependencies)) 29 | .build 30 | .start 31 | 32 | dependencies.appManager.start() 33 | if !server.isRunning then throw new IllegalStateException("Server failed to start") 34 | try 35 | logger.info(s"Terminal 21 Server started. Please open http://localhost:$portV/ui for the user interface") 36 | val hostname = InetAddress.getLocalHost.getHostName 37 | logger.info(s""" 38 | |Files under ~/.terminal21/web will be served under /web 39 | |Clients should set env variables: 40 | |TERMINAL21_HOST = $hostname 41 | |TERMINAL21_PORT = $portV 42 | | 43 | |if unset, they will point to localhost:8080 44 | |""".stripMargin) 45 | while true do 46 | Thread.sleep(86400 * 1000) 47 | logger.info("One more day passed...") 48 | finally 49 | logger.info("Server stopping....") 50 | server.stop() 51 | 52 | @main def terminal21ServerMain(): Unit = Terminal21Server.start() 53 | -------------------------------------------------------------------------------- /terminal21-server-app/src/main/scala/org/terminal21/serverapp/ServerSideApp.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.serverapp 2 | 3 | import org.terminal21.server.Dependencies 4 | 5 | trait ServerSideApp: 6 | def name: String 7 | def description: String 8 | def createSession(serverSideSessions: ServerSideSessions, dependencies: Dependencies): Unit 9 | -------------------------------------------------------------------------------- /terminal21-server-app/src/main/scala/org/terminal21/serverapp/ServerSideSessions.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.serverapp 2 | 3 | import functions.fibers.FiberExecutor 4 | import org.terminal21.client.ConnectedSession 5 | import org.terminal21.client.components.ComponentLib 6 | import org.terminal21.client.json.{StdElementEncoding, UiElementEncoding} 7 | import org.terminal21.config.Config 8 | import org.terminal21.model.SessionOptions 9 | import org.terminal21.server.service.ServerSessionsService 10 | 11 | import java.util.concurrent.atomic.AtomicBoolean 12 | 13 | class ServerSideSessions(sessionsService: ServerSessionsService, executor: FiberExecutor): 14 | case class ServerSideSessionBuilder( 15 | id: String, 16 | name: String, 17 | componentLibs: Seq[ComponentLib] = Seq(StdElementEncoding), 18 | sessionOptions: SessionOptions = SessionOptions.Defaults 19 | ): 20 | def andLibraries(libraries: ComponentLib*): ServerSideSessionBuilder = copy(componentLibs = componentLibs ++ libraries) 21 | def andOptions(sessionOptions: SessionOptions) = copy(sessionOptions = sessionOptions) 22 | 23 | def connect[R](f: ConnectedSession => R): R = 24 | val config = Config.Default 25 | val serverUrl = s"http://${config.host}:${config.port}" 26 | 27 | val session = sessionsService.createSession(id, name, sessionOptions) 28 | val encoding = new UiElementEncoding(Seq(StdElementEncoding) ++ componentLibs) 29 | val isStopped = new AtomicBoolean(false) 30 | 31 | def terminate(): Unit = 32 | isStopped.set(true) 33 | 34 | val connectedSession = ConnectedSession(session, encoding, serverUrl, sessionsService, terminate) 35 | sessionsService.notifyMeOnSessionEvents(session): event => 36 | executor.submit: 37 | connectedSession.fireEvent(event) 38 | true 39 | try 40 | f(connectedSession) 41 | finally 42 | if !isStopped.get() && !connectedSession.isLeaveSessionOpen then sessionsService.terminateSession(session) 43 | 44 | def withNewSession(id: String, name: String): ServerSideSessionBuilder = ServerSideSessionBuilder(id, name) 45 | 46 | trait ServerSideSessionsBeans: 47 | def sessionsService: ServerSessionsService 48 | def fiberExecutor: FiberExecutor 49 | lazy val serverSideSessions = new ServerSideSessions(sessionsService, fiberExecutor) 50 | -------------------------------------------------------------------------------- /terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/AppManager.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.serverapp.bundled 2 | 3 | import functions.fibers.FiberExecutor 4 | import org.terminal21.client.* 5 | import org.terminal21.client.components.* 6 | import org.terminal21.client.components.chakra.* 7 | import org.terminal21.client.components.std.{Header1, Paragraph, Span} 8 | import org.terminal21.model.SessionOptions 9 | import org.terminal21.server.Dependencies 10 | import org.terminal21.serverapp.{ServerSideApp, ServerSideSessions} 11 | 12 | class AppManager(serverSideSessions: ServerSideSessions, fiberExecutor: FiberExecutor, apps: Seq[ServerSideApp], dependencies: Dependencies): 13 | def start(): Unit = 14 | fiberExecutor.submit: 15 | serverSideSessions 16 | .withNewSession("app-manager", "Terminal 21") 17 | .andOptions(SessionOptions(alwaysOpen = true)) 18 | .connect: session => 19 | given ConnectedSession = session 20 | new AppManagerPage(apps, startApp).run() 21 | 22 | private def startApp(app: ServerSideApp): Unit = 23 | fiberExecutor.submit: 24 | app.createSession(serverSideSessions, dependencies) 25 | 26 | class AppManagerPage(apps: Seq[ServerSideApp], startApp: ServerSideApp => Unit)(using session: ConnectedSession): 27 | case class ManagerModel(startApp: Option[ServerSideApp] = None) 28 | 29 | def run(): Unit = 30 | eventsIterator.foreach(_ => ()) 31 | 32 | private case class TableView(clicked: Option[ServerSideApp], columns: Seq[UiElement]) 33 | private def appRows(events: Events): Seq[TableView] = apps.map: app => 34 | val link = Link(s"app-${app.name}", text = app.name) 35 | TableView( 36 | if events.isClicked(link) then Some(app) else None, 37 | Seq( 38 | link, 39 | Text(text = app.description) 40 | ) 41 | ) 42 | 43 | def components(model: ManagerModel, events: Events): MV[ManagerModel] = 44 | val appsMv = appRows(events) 45 | val appsTable = QuickTable( 46 | key = "apps-table", 47 | caption = Some("Apps installed on the server, click one to run it."), 48 | rows = appsMv.map(tv => tv.columns) 49 | ).withHeaders("App Name", "Description") 50 | val startApp = appsMv.map(_.clicked).find(_.nonEmpty).flatten 51 | MV( 52 | model.copy(startApp = startApp), 53 | Seq( 54 | Header1(text = "Terminal 21 Manager"), 55 | Paragraph(text = "Here you can run all the installed apps on the server."), 56 | appsTable, 57 | Paragraph().withChildren( 58 | Span(text = "Have a question? Please ask at "), 59 | Link( 60 | key = "discussion-board-link", 61 | text = "terminal21's discussion board ", 62 | href = "https://github.com/kostaskougios/terminal21-restapi/discussions", 63 | color = Some("teal.500"), 64 | isExternal = Some(true) 65 | ).withChildren(ExternalLinkIcon(mx = Some("2px"))) 66 | ) 67 | ) 68 | ) 69 | 70 | def controller: Controller[ManagerModel] = 71 | Controller(components) 72 | 73 | def eventsIterator: Iterator[ManagerModel] = 74 | controller 75 | .render(ManagerModel()) 76 | .iterator 77 | .map(_.model) 78 | .tapEach: m => 79 | for app <- m.startApp do startApp(app) 80 | 81 | trait AppManagerBeans: 82 | def serverSideSessions: ServerSideSessions 83 | def fiberExecutor: FiberExecutor 84 | def apps: Seq[ServerSideApp] 85 | def dependencies: Dependencies 86 | lazy val appManager = new AppManager(serverSideSessions, fiberExecutor, apps, dependencies) 87 | -------------------------------------------------------------------------------- /terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/DefaultApps.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.serverapp.bundled 2 | 3 | object DefaultApps: 4 | val All = Seq( 5 | new ServerStatusApp, 6 | new SettingsApp 7 | ) 8 | -------------------------------------------------------------------------------- /terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/SettingsApp.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.serverapp.bundled 2 | 3 | import org.terminal21.client.components.* 4 | import org.terminal21.client.components.frontend.ThemeToggle 5 | import org.terminal21.client.* 6 | import org.terminal21.server.Dependencies 7 | import org.terminal21.serverapp.{ServerSideApp, ServerSideSessions} 8 | 9 | class SettingsApp extends ServerSideApp: 10 | override def name = "Settings" 11 | 12 | override def description = "Terminal21 Settings" 13 | 14 | override def createSession(serverSideSessions: ServerSideSessions, dependencies: Dependencies): Unit = 15 | serverSideSessions 16 | .withNewSession("frontend-settings", "Settings") 17 | .connect: session => 18 | given ConnectedSession = session 19 | new SettingsPage().run() 20 | 21 | class SettingsPage(using session: ConnectedSession): 22 | val themeToggle = ThemeToggle() 23 | def run() = 24 | controller.render(()).iterator.lastOption 25 | 26 | def components = Seq(themeToggle) 27 | 28 | def controller = Controller.noModel(components) 29 | -------------------------------------------------------------------------------- /terminal21-server-app/src/test/scala/org/terminal21/serverapp/ServerSideSessionsTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.serverapp 2 | 3 | import functions.fibers.FiberExecutor 4 | import org.mockito.Mockito 5 | import org.mockito.Mockito.{verify, when} 6 | import org.scalatest.BeforeAndAfterAll 7 | import org.scalatest.funsuite.AnyFunSuiteLike 8 | import org.scalatestplus.mockito.MockitoSugar.* 9 | import org.terminal21.model.{CommonModelBuilders, SessionOptions} 10 | import org.terminal21.model.CommonModelBuilders.session 11 | import org.terminal21.server.service.ServerSessionsService 12 | 13 | class ServerSideSessionsTest extends AnyFunSuiteLike with BeforeAndAfterAll: 14 | val executor = FiberExecutor() 15 | override protected def afterAll(): Unit = executor.shutdown() 16 | 17 | test("creates session"): 18 | new App: 19 | val s = session() 20 | when(sessionsService.createSession(s.id, s.name, SessionOptions.Defaults)).thenReturn(s) 21 | serverSideSessions 22 | .withNewSession(s.id, s.name) 23 | .connect: session => 24 | session.leaveSessionOpenAfterExiting() 25 | 26 | verify(sessionsService).createSession(s.id, s.name, SessionOptions.Defaults) 27 | 28 | test("terminates session before exiting"): 29 | new App: 30 | val s = session() 31 | when(sessionsService.createSession(s.id, s.name, SessionOptions.Defaults)).thenReturn(s) 32 | serverSideSessions 33 | .withNewSession(s.id, s.name) 34 | .connect: _ => 35 | () 36 | 37 | verify(sessionsService).terminateSession(s) 38 | 39 | test("registers to receive events"): 40 | new App: 41 | val s = session() 42 | when(sessionsService.createSession(s.id, s.name, SessionOptions.Defaults)).thenReturn(s) 43 | serverSideSessions 44 | .withNewSession(s.id, s.name) 45 | .connect: _ => 46 | () 47 | 48 | verify(sessionsService).notifyMeOnSessionEvents(s) 49 | 50 | class App: 51 | val sessionsService = mock[ServerSessionsService] 52 | val serverSideSessions = new ServerSideSessions(sessionsService, executor) 53 | -------------------------------------------------------------------------------- /terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/AppManagerPageTest.scala: -------------------------------------------------------------------------------- 1 | //package org.terminal21.serverapp.bundled 2 | // 3 | //import org.mockito.Mockito 4 | //import org.mockito.Mockito.when 5 | //import org.scalatest.funsuite.AnyFunSuiteLike 6 | //import org.scalatestplus.mockito.MockitoSugar.mock 7 | //import org.terminal21.client.components.* 8 | //import org.terminal21.client.components.chakra.{Link, Text} 9 | //import org.terminal21.client.{ConnectedSession, ConnectedSessionMock} 10 | //import org.terminal21.serverapp.ServerSideApp 11 | //import org.scalatest.matchers.should.Matchers.* 12 | //import org.terminal21.model.CommandEvent 13 | // 14 | //class AppManagerPageTest extends AnyFunSuiteLike: 15 | // def mockApp(name: String, description: String) = 16 | // val app = mock[ServerSideApp] 17 | // when(app.name).thenReturn(name) 18 | // when(app.description).thenReturn(description) 19 | // app 20 | // 21 | // class App(apps: ServerSideApp*): 22 | // given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock 23 | // var startedApp: Option[ServerSideApp] = None 24 | // val page = new AppManagerPage(apps, app => startedApp = Some(app)) 25 | // val model = page.ManagerModel() 26 | // def allComponents = page.components.flatMap(_.flat) 27 | // 28 | // test("renders app links"): 29 | // new App(mockApp("app1", "the-app1-desc")): 30 | // allComponents 31 | // .collect: 32 | // case l: Link if l.text == "app1" => l 33 | // .size should be(1) 34 | // 35 | // test("renders app description"): 36 | // new App(mockApp("app1", "the-app1-desc")): 37 | // allComponents 38 | // .collect: 39 | // case t: Text if t.text == "the-app1-desc" => t 40 | // .size should be(1) 41 | // 42 | // test("renders the discussions link"): 43 | // new App(): 44 | // allComponents 45 | // .collect: 46 | // case l: Link if l.href == "https://github.com/kostaskougios/terminal21-restapi/discussions" => l 47 | // .size should be(1) 48 | // 49 | // test("starts app when app link is clicked"): 50 | // val app = mockApp("app1", "the-app1-desc") 51 | // new App(app): 52 | // val eventsIt = page.eventsIterator 53 | // session.fireEvents(CommandEvent.onClick(page.appRows.head.head), CommandEvent.sessionClosed) 54 | // eventsIt.toList 55 | // startedApp should be(Some(app)) 56 | // 57 | // test("resets startApp state on other events"): 58 | // val app = mockApp("app1", "the-app1-desc") 59 | // new App(app): 60 | // val other = allComponents.find(_.key == "discussion-board-link").get 61 | // val eventsIt = page.controller.render().handledEventsIterator 62 | // session.fireEvents(CommandEvent.onClick(page.appRows.head.head), CommandEvent.onClick(other), CommandEvent.sessionClosed) 63 | // eventsIt.toList.map(_.model).last.startApp should be(None) 64 | -------------------------------------------------------------------------------- /terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/ServerStatusPageTest.scala: -------------------------------------------------------------------------------- 1 | //package org.terminal21.serverapp.bundled 2 | // 3 | //import org.mockito.Mockito.when 4 | //import org.scalatest.funsuite.AnyFunSuiteLike 5 | //import org.scalatest.matchers.should.Matchers.* 6 | //import org.scalatestplus.mockito.MockitoSugar.mock 7 | //import org.terminal21.client.components.chakra.{Button, CheckIcon, NotAllowedIcon} 8 | //import org.terminal21.client.{ConnectedSession, ConnectedSessionMock, given} 9 | //import org.terminal21.model.CommonModelBuilders.session 10 | //import org.terminal21.model.{CommandEvent, CommonModelBuilders, Session} 11 | //import org.terminal21.server.service.ServerSessionsService 12 | //import org.terminal21.serverapp.ServerSideSessions 13 | // 14 | //class ServerStatusPageTest extends AnyFunSuiteLike: 15 | // class App: 16 | // given connectedSession: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock 17 | // val sessionsService = mock[ServerSessionsService] 18 | // val serverSideSessions = mock[ServerSideSessions] 19 | // val session1 = session(id = "session1") 20 | // when(sessionsService.allSessions).thenReturn(Seq(session1)) 21 | // val page = new ServerStatusPage(serverSideSessions, sessionsService) 22 | // 23 | // test("Close button for a session"): 24 | // new App: 25 | // page.sessionsTable.flat 26 | // .collectFirst: 27 | // case b: Button if b.text == "Close" => b 28 | // .isEmpty should be(false) 29 | // 30 | // test("View state button for a session"): 31 | // new App: 32 | // page.sessionsTable.flat 33 | // .collectFirst: 34 | // case b: Button if b.text == "View State" => b 35 | // .isEmpty should be(false) 36 | // 37 | // test("When session is open, a CheckIcon is displayed"): 38 | // new App: 39 | // page.sessionsTable.flat 40 | // .collectFirst: 41 | // case i: CheckIcon => i 42 | // .isEmpty should be(false) 43 | // 44 | // test("When session is closed, a NotAllowedIcon is displayed"): 45 | // new App: 46 | // import page.given 47 | // val table = page.sessionsTable 48 | // val m = page.initModel.copy(sessions = Seq(session(isOpen = false))) 49 | // table 50 | // .fireModelChangeRender(m) 51 | // .flat 52 | // .collectFirst: 53 | // case i: NotAllowedIcon => i 54 | // .isEmpty should be(false) 55 | // 56 | // test("sessions are rendered when Ticker event is fired"): 57 | // new App: 58 | // val it = page.controller.render().handledEventsIterator 59 | // private val sessions2 = Seq(session(id = "s2", name = "session 2")) 60 | // private val sessions3 = Seq(session(id = "s3", name = "session 3")) 61 | // connectedSession.fireEvents( 62 | // page.Ticker(sessions2), 63 | // page.Ticker(sessions3), 64 | // CommandEvent.sessionClosed 65 | // ) 66 | // val handledEvents = it.toList 67 | // handledEvents.head.model.sessions should be(Seq(session(id = "session1"))) 68 | // handledEvents(1).model.sessions should be(sessions2) 69 | // handledEvents(2).model.sessions should be(sessions3) 70 | -------------------------------------------------------------------------------- /terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/SettingsPageTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.serverapp.bundled 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.scalatest.matchers.should.Matchers.* 5 | import org.terminal21.client.{*, given} 6 | 7 | class SettingsPageTest extends AnyFunSuiteLike: 8 | class App: 9 | given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock 10 | val page = new SettingsPage 11 | 12 | test("Should render the ThemeToggle component"): 13 | new App: 14 | page.components should contain(page.themeToggle) 15 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/client/components/AnyElement.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components 2 | 3 | /** Base trait for any renderable element that has a key 4 | */ 5 | trait AnyElement: 6 | def key: String 7 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/collections/ProducerConsumerCollections.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.collections 2 | 3 | import java.util.concurrent.atomic.AtomicBoolean 4 | import java.util.concurrent.{LinkedBlockingQueue, TimeUnit} 5 | import scala.annotation.tailrec 6 | 7 | class LazyBlockingIterator[A](q: LinkedBlockingQueue[A]) extends Iterator[A]: 8 | private val open = new AtomicBoolean(true) 9 | override def hasNext: Boolean = true 10 | @tailrec override final def next(): A = 11 | q.poll(10, TimeUnit.MILLISECONDS) match 12 | case null => 13 | if !open.get() then throw new NoSuchElementException() 14 | next() 15 | case e => e 16 | 17 | def close(): Unit = 18 | open.set(false) 19 | 20 | object ProducerConsumerCollections: 21 | def lazyIterator[A](initialSize: Int = 64): (LazyBlockingIterator[A], A => Unit) = 22 | val queue = new LinkedBlockingQueue[A](initialSize) 23 | val it = new LazyBlockingIterator[A](queue) 24 | (it, a => queue.put(a)) 25 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/collections/SEList.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.collections 2 | 3 | import java.util.concurrent.CountDownLatch 4 | 5 | class SEList[A]: 6 | @volatile private var currentNode: NormalNode[A] = NormalNode(None, EndNode) 7 | 8 | private val atLeastOneIterator = new CountDownLatch(1) 9 | 10 | /** @return 11 | * A new iterator that only reads elements that are added before the iterator is created. 12 | */ 13 | def iterator: SEBlockingIterator[A] = 14 | atLeastOneIterator.countDown() 15 | new SEBlockingIterator(currentNode) 16 | 17 | def waitUntilAtLeast1IteratorWasCreated(): Unit = atLeastOneIterator.await() 18 | 19 | /** Add a poison pill to terminate all iterators. 20 | */ 21 | def poisonPill(): Unit = 22 | synchronized: 23 | currentNode.valueAndNext = (None, PoisonPillNode) 24 | currentNode.latch.countDown() 25 | 26 | /** Adds an item that will be visible to all iterators that were created before this item was added. 27 | * @param item 28 | * the item 29 | */ 30 | def add(item: A): Unit = 31 | val cn = synchronized: 32 | val cn = currentNode 33 | if cn.valueAndNext._2 == PoisonPillNode then throw new IllegalStateException("Can't add items when the list has been poisoned.") 34 | val n = NormalNode(None, currentNode.valueAndNext._2) 35 | currentNode.valueAndNext = (Some(item), n) 36 | currentNode = n 37 | cn 38 | cn.latch.countDown() 39 | 40 | class SEBlockingIterator[A](@volatile var currentNode: NormalNode[A]) extends Iterator[A]: 41 | /** @return 42 | * true if hasNext & next() will return immediately with the next value. This won't block. 43 | */ 44 | def isNextAvailable: Boolean = currentNode.hasValue 45 | 46 | /** @return 47 | * true if there is a next() but blocks otherwise till next() becomes available or we are at the end of the iterator. 48 | */ 49 | override def hasNext: Boolean = 50 | currentNode.waitValue() 51 | val v = currentNode.valueAndNext._2 52 | if v == PoisonPillNode then false else true 53 | 54 | /** @return 55 | * the next element or blocks until the next element becomes available 56 | */ 57 | override def next(): A = 58 | if hasNext then 59 | val v = currentNode.value 60 | currentNode = currentNode.next 61 | v 62 | else throw new NoSuchElementException("next() called but there is no next element. The SEList has been poisoned and we reached the PoisonPill") 63 | 64 | sealed trait Node[+A] 65 | case object EndNode extends Node[Nothing] 66 | case object PoisonPillNode extends Node[Nothing] 67 | 68 | case class NormalNode[A](@volatile var valueAndNext: (Option[A], Node[A])) extends Node[A]: 69 | val latch = new CountDownLatch(1) 70 | def waitValue(): Unit = latch.await() 71 | 72 | def hasValue: Boolean = valueAndNext._1.nonEmpty 73 | def value: A = valueAndNext._1.get 74 | def next: NormalNode[A] = valueAndNext._2 match 75 | case nn: NormalNode[A] @unchecked => nn 76 | case _ => throw new NoSuchElementException("next should be called only if hasValue is true") 77 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/collections/TypedMap.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.collections 2 | 3 | import scala.reflect.{ClassTag, classTag} 4 | 5 | type TMMap = Map[TypedMapKey[_], Any] 6 | 7 | class TypedMap(protected val m: TMMap): 8 | def +[A](kv: (TypedMapKey[A], A)): TypedMap = new TypedMap(m + kv) 9 | def apply[A](k: TypedMapKey[A]): A = m(k).asInstanceOf[A] 10 | def get[A](k: TypedMapKey[A]): Option[A] = m.get(k).asInstanceOf[Option[A]] 11 | def getOrElse[A](k: TypedMapKey[A], default: => A) = m.getOrElse(k, default).asInstanceOf[A] 12 | def keys: Iterable[TypedMapKey[_]] = m.keys 13 | 14 | override def hashCode() = m.hashCode() 15 | override def equals(obj: Any) = obj match 16 | case tm: TypedMap => m == tm.m 17 | case _ => false 18 | 19 | def contains[A](k: TypedMapKey[A]) = m.contains(k) 20 | override def toString = s"TypedMap(${m.keys.mkString(", ")})" 21 | 22 | object TypedMap: 23 | val Empty = new TypedMap(Map.empty) 24 | def apply(kv: (TypedMapKey[_], Any)*) = 25 | val m = Map(kv*) 26 | new TypedMap(m) 27 | 28 | trait TypedMapKey[A: ClassTag]: 29 | type Of = A 30 | 31 | override def toString = s"${getClass.getSimpleName}[${classTag[A].runtimeClass.getSimpleName}]" 32 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/config/Config.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.config 2 | 3 | case class Config(host: String, port: Int) 4 | 5 | object Config: 6 | private def host = sys.env.getOrElse("TERMINAL21_HOST", "localhost") 7 | private def port = sys.env.getOrElse("TERMINAL21_PORT", "8080").toInt 8 | 9 | val Default = Config(host, port) 10 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/model/ClientToServer.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.model 2 | 3 | sealed trait ClientToServer 4 | 5 | case class SubscribeTo(session: Session) extends ClientToServer 6 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/model/CommandEvent.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.model 2 | 3 | import io.circe.* 4 | import io.circe.generic.auto.* 5 | import io.circe.syntax.* 6 | import org.terminal21.client.components.AnyElement 7 | 8 | /** These are the events as they arrive from the server 9 | */ 10 | sealed trait CommandEvent: 11 | def key: String 12 | def isSessionClosed: Boolean 13 | 14 | object CommandEvent: 15 | def onClick(receivedBy: AnyElement): OnClick = OnClick(receivedBy.key) 16 | def onChange(receivedBy: AnyElement, value: String): OnChange = OnChange(receivedBy.key, value) 17 | def onChange(receivedBy: AnyElement, value: Boolean): OnChange = OnChange(receivedBy.key, value.toString) 18 | def sessionClosed: SessionClosed = SessionClosed("-") 19 | 20 | given Encoder[CommandEvent] = 21 | case c: OnClick => c.asJson.mapObject(_.add("type", "OnClick".asJson)) 22 | case c: OnChange => c.asJson.mapObject(_.add("type", "OnChange".asJson)) 23 | case sc: SessionClosed => sc.asJson.mapObject(_.add("type", "SessionClosed".asJson)) 24 | case x => throw new IllegalStateException(s"$x should never be send as json") 25 | 26 | given Decoder[CommandEvent] = o => 27 | o.get[String]("type") match 28 | case Right("OnClick") => o.as[OnClick] 29 | case Right("OnChange") => o.as[OnChange] 30 | case Right("SessionClosed") => o.as[SessionClosed] 31 | case x => throw new IllegalStateException(s"got unexpected $x") 32 | 33 | case class OnClick(key: String) extends CommandEvent: 34 | override def isSessionClosed: Boolean = false 35 | 36 | case class OnChange(key: String, value: String) extends CommandEvent: 37 | override def isSessionClosed: Boolean = false 38 | 39 | case class SessionClosed(key: String) extends CommandEvent: 40 | override def isSessionClosed: Boolean = true 41 | 42 | /** Extend this to send your own messages 43 | */ 44 | trait ClientEvent extends CommandEvent: 45 | override def key = "client-event" 46 | override def isSessionClosed: Boolean = false 47 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/model/Session.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.model 2 | 3 | case class Session(id: String, name: String, secret: String, isOpen: Boolean, options: SessionOptions): 4 | def hideSecret: Session = copy(secret = "***") 5 | def close: Session = copy(isOpen = false) 6 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/model/SessionOptions.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.model 2 | 3 | case class SessionOptions(closeTabWhenTerminated: Boolean = true, alwaysOpen: Boolean = false) 4 | 5 | object SessionOptions: 6 | val Defaults = SessionOptions() 7 | val LeaveOpenWhenTerminated = SessionOptions(closeTabWhenTerminated = false) 8 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/utils/ErrorLogger.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.utils 2 | 3 | import org.slf4j.Logger 4 | 5 | import java.io.UncheckedIOException 6 | import scala.annotation.tailrec 7 | 8 | class ErrorLogger(logger: Logger): 9 | def logErrors(f: => Unit): Unit = 10 | try f 11 | catch 12 | case s: UncheckedIOException if s.getCause.getMessage == "Socket closed" => 13 | logger.info("Socket closed") 14 | throw s 15 | case t: Throwable => 16 | logger.error("an error occurred", t) 17 | throw t 18 | 19 | @tailrec final def tryForeverLogErrors[R](f: => R): R = 20 | try f 21 | catch 22 | case t: Throwable => 23 | logger.error("An error occurred but will retry forever until there is no error", t) 24 | tryForeverLogErrors(f) 25 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/ws/AbstractWsListener.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ws 2 | 3 | import io.helidon.websocket.WsListener 4 | import org.slf4j.LoggerFactory 5 | import org.terminal21.utils.ErrorLogger 6 | 7 | import java.io.UncheckedIOException 8 | 9 | abstract class AbstractWsListener extends WsListener: 10 | protected val logger = LoggerFactory.getLogger(getClass) 11 | protected val errorLogger = new ErrorLogger(logger) 12 | 13 | protected def tryOnSocketClosedIgnore(f: => Unit): Unit = 14 | try f 15 | catch case _: UncheckedIOException => () // nop 16 | 17 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/ws/ClientWsListener.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ws 2 | 3 | import io.helidon.common.buffers.BufferData 4 | import org.terminal21.collections.LazyBlockingIterator 5 | 6 | import scala.util.Using.Releasable 7 | 8 | case class ClientWsListener[R, S]( 9 | listener: ReliableClientWsListener, 10 | dataIterator: LazyBlockingIterator[BufferData], 11 | receivedIterator: Iterator[R], 12 | send: S => Unit 13 | ): 14 | def close(): Unit = 15 | dataIterator.close() 16 | listener.close() 17 | 18 | def transform[NR, NS](receiveTransformer: R => NR, sendTransformer: NS => S): ClientWsListener[NR, NS] = 19 | ClientWsListener(listener, dataIterator, receivedIterator.map(receiveTransformer), b => send(sendTransformer(b))) 20 | 21 | object ClientWsListener: 22 | given Releasable[ClientWsListener[_, _]] = _.close() 23 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/ws/ReliableServerWsListener.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ws 2 | 3 | import functions.fibers.FiberExecutor 4 | import io.helidon.common.buffers.BufferData 5 | import io.helidon.websocket.WsSession 6 | import org.terminal21.collections.{LazyBlockingIterator, ProducerConsumerCollections} 7 | 8 | import scala.collection.concurrent.TrieMap 9 | 10 | abstract class ReliableServerWsListener(fiberExecutor: FiberExecutor) extends AbstractWsListener: 11 | private val perClientIdWsSession = TrieMap.empty[String, WsSession] 12 | 13 | protected def receive(id: String, data: BufferData): Unit 14 | 15 | def hasClientId(id: String): Boolean = perClientIdWsSession.contains(id) 16 | 17 | def send(id: String, data: BufferData): Unit = 18 | val wsSession = perClientIdWsSession.getOrElse(id, throw new IllegalArgumentException(s"No client with id = $id has a session")) 19 | wsSession.send(data, true) 20 | 21 | override def onMessage(wsSession: WsSession, data: BufferData, last: Boolean): Unit = 22 | fiberExecutor.submit: 23 | errorLogger.logErrors: 24 | if data.available() > 0 then 25 | val len = data.read() 26 | val strDat = new Array[Byte](len) 27 | data.read(strDat) 28 | val id = new String(strDat, "UTF-8") 29 | perClientIdWsSession.put(id, wsSession) 30 | if data.available() > 0 then receive(id, data) 31 | else logger.warn(s"Received empty message for $wsSession") 32 | 33 | override def onClose(wsSession: WsSession, status: Int, reason: String): Unit = 34 | logger.info(s"Server session $wsSession closed with status $status and reason $reason") 35 | 36 | override def onError(wsSession: WsSession, t: Throwable): Unit = 37 | logger.error(s"Server session $wsSession had an error", t) 38 | 39 | def close(): Unit = 40 | perClientIdWsSession.clear() 41 | 42 | object ReliableServerWsListener: 43 | def server(fiberExecutor: FiberExecutor): ServerWsListener[ServerValue[BufferData], ServerValue[BufferData]] = 44 | val (it, producer) = ProducerConsumerCollections.lazyIterator[ServerValue[BufferData]]() 45 | val listener = new ReliableServerWsListener(fiberExecutor): 46 | override protected def receive(id: String, data: BufferData): Unit = producer(ServerValue(id, data)) 47 | 48 | ServerWsListener(listener, it, it, sv => listener.send(sv.id, sv.value)) 49 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/ws/ServerValue.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ws 2 | 3 | case class ServerValue[A](id: String, value: A) 4 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/ws/ServerWsListener.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ws 2 | 3 | import io.helidon.common.buffers.BufferData 4 | import org.terminal21.collections.LazyBlockingIterator 5 | 6 | import scala.util.Using.Releasable 7 | 8 | case class ServerWsListener[R, S]( 9 | listener: ReliableServerWsListener, 10 | dataIterator: LazyBlockingIterator[ServerValue[BufferData]], 11 | receivedIterator: Iterator[R], 12 | send: S => Unit 13 | ): 14 | def close(): Unit = 15 | listener.close() 16 | dataIterator.close() 17 | 18 | object ServerWsListener: 19 | extension (l: ServerWsListener[ServerValue[BufferData], ServerValue[BufferData]]) 20 | def transform[NR, NS]( 21 | receiveTransformer: ServerValue[BufferData] => NR, 22 | sendTransformer: NS => ServerValue[BufferData] 23 | ): ServerWsListener[NR, NS] = 24 | ServerWsListener( 25 | l.listener, 26 | l.dataIterator, 27 | l.receivedIterator.map(receiveTransformer), 28 | sv => l.send(sendTransformer(sv)) 29 | ) 30 | 31 | def transformValue[NR, NS]( 32 | receiveTransformer: BufferData => NR, 33 | sendTransformer: NS => BufferData 34 | ): ServerWsListener[ServerValue[NR], ServerValue[NS]] = 35 | ServerWsListener( 36 | l.listener, 37 | l.dataIterator, 38 | l.receivedIterator.map(sv => sv.copy(value = receiveTransformer(sv.value))), 39 | sv => l.send(sv.copy(value = sendTransformer(sv.value))) 40 | ) 41 | 42 | given Releasable[ServerWsListener[_, _]] = _.close() 43 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/main/scala/org/terminal21/ws/Transformer.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ws 2 | 3 | import io.helidon.common.buffers.BufferData 4 | 5 | trait Transformer[A, B]: 6 | def map(a: A): B 7 | 8 | trait BufferDataTransformer[A] extends Transformer[BufferData, A] 9 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/test/scala/org/terminal21/collections/ProducerConsumerCollectionsTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.collections 2 | 3 | import functions.fibers.FiberExecutor 4 | import org.scalatest.funsuite.AnyFunSuiteLike 5 | import org.scalatest.matchers.should.Matchers.* 6 | 7 | class ProducerConsumerCollectionsTest extends AnyFunSuiteLike: 8 | val executor = FiberExecutor() 9 | test("producing/consuming"): 10 | val (it, producer) = ProducerConsumerCollections.lazyIterator[Int]() 11 | producer(2) 12 | it.take(1).toList should be(Seq(2)) 13 | 14 | test("consuming blocks until an item is available"): 15 | val (it, producer) = ProducerConsumerCollections.lazyIterator[Int]() 16 | executor.submit: 17 | Thread.sleep(5) 18 | producer(10) 19 | 20 | it.take(1).toList should be(Seq(10)) 21 | 22 | test("closing will throw an exception for consumers"): 23 | val (it, producer) = ProducerConsumerCollections.lazyIterator[Int]() 24 | val f = executor.submit: 25 | an[NoSuchElementException] should be thrownBy it.toList 26 | 27 | producer(5) 28 | it.close() 29 | f.await() 30 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/test/scala/org/terminal21/collections/SEListTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.collections 2 | 3 | import functions.fibers.FiberExecutor 4 | import org.scalatest.funsuite.AnyFunSuiteLike 5 | import org.scalatest.matchers.should.Matchers.* 6 | 7 | class SEListTest extends AnyFunSuiteLike: 8 | val executor = FiberExecutor() 9 | 10 | test("when empty, it.hasNext should wait"): 11 | val l = SEList[Int]() 12 | val it = l.iterator 13 | l.poisonPill() 14 | it.toList should be(Nil) 15 | 16 | test("when empty with 2 iterators"): 17 | val l = SEList[Int]() 18 | val it1 = l.iterator 19 | val it2 = l.iterator 20 | l.poisonPill() 21 | it1.toList should be(Nil) 22 | it2.toList should be(Nil) 23 | 24 | test("with 1 item"): 25 | val l = SEList[Int]() 26 | val it = l.iterator 27 | l.add(1) 28 | l.poisonPill() 29 | it.toList should be(List(1)) 30 | 31 | test("with 2 items"): 32 | val l = SEList[Int]() 33 | val it = l.iterator 34 | l.add(1) 35 | l.add(2) 36 | l.poisonPill() 37 | it.toList should be(List(1, 2)) 38 | 39 | test("with 2 items and 2 iterators"): 40 | val l = SEList[Int]() 41 | val it1 = l.iterator 42 | val it2 = l.iterator 43 | l.add(1) 44 | l.add(2) 45 | l.poisonPill() 46 | it1.toList should be(List(1, 2)) 47 | it2.toList should be(List(1, 2)) 48 | 49 | test("iterator after added items"): 50 | val l = SEList[Int]() 51 | l.add(1) 52 | val it = l.iterator 53 | l.add(2) 54 | l.poisonPill() 55 | it.toList should be(List(2)) 56 | 57 | test("hasNext & next()"): 58 | val l = SEList[Int]() 59 | val it = l.iterator 60 | l.add(1) 61 | l.add(2) 62 | l.poisonPill() 63 | it.hasNext should be(true) 64 | it.next() should be(1) 65 | it.hasNext should be(true) 66 | it.next() should be(2) 67 | it.hasNext should be(false) 68 | an[NoSuchElementException] should be thrownBy (it.next()) 69 | 70 | test("it.isNextAvailable"): 71 | val l = SEList[Int]() 72 | val it = l.iterator 73 | it.isNextAvailable should be(false) 74 | l.add(1) 75 | it.isNextAvailable should be(true) 76 | 77 | test("multiple iterators and multi threading"): 78 | val l = SEList[Int]() 79 | val iterators = for _ <- 1 to 1000 yield 80 | val it = l.iterator 81 | executor.submit: 82 | it.toList 83 | 84 | for i <- 1 to 100 do 85 | Thread.sleep(1) 86 | l.add(i) 87 | 88 | l.poisonPill() 89 | 90 | val expected = (1 to 100).toList 91 | for f <- iterators do f.get() should be(expected) 92 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/test/scala/org/terminal21/collections/TypedMapTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.collections 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.scalatest.matchers.should.Matchers.* 5 | 6 | class TypedMapTest extends AnyFunSuiteLike: 7 | object IntKey extends TypedMapKey[Int] 8 | object StringKey extends TypedMapKey[String] 9 | 10 | test("apply"): 11 | val m = TypedMap.Empty + (IntKey -> 5) + (StringKey -> "x") 12 | m(IntKey) should be(5) 13 | m(StringKey) should be("x") 14 | 15 | test("construct"): 16 | val m = TypedMap(IntKey -> 5, StringKey -> "x") 17 | m(IntKey) should be(5) 18 | m(StringKey) should be("x") 19 | 20 | test("keys"): 21 | val m = TypedMap(IntKey -> 5, StringKey -> "x") 22 | m.keys.toSet should be(Set(IntKey, StringKey)) 23 | 24 | test("get"): 25 | val m = TypedMap.Empty + (IntKey -> 5) + (StringKey -> "x") 26 | m.get(IntKey) should be(Some(5)) 27 | m.get(StringKey) should be(Some("x")) 28 | 29 | test("getOrElse when key not available"): 30 | TypedMap.Empty.getOrElse(IntKey, 2) should be(2) 31 | 32 | test("getOrElse when key available"): 33 | (TypedMap.Empty + (IntKey -> 5)).getOrElse(IntKey, 2) should be(5) 34 | 35 | test("contains key positive"): 36 | (TypedMap.Empty + (IntKey -> 5)).contains(IntKey) should be(true) 37 | 38 | test("contains key negative"): 39 | TypedMap.Empty.contains(IntKey) should be(false) 40 | 41 | test("get key negative"): 42 | TypedMap.Empty.get(IntKey) should be(None) 43 | 44 | test("equals positive"): 45 | val m1 = TypedMap.Empty + (IntKey -> 5) 46 | val m2 = TypedMap.Empty + (IntKey -> 5) 47 | m1 should be(m2) 48 | 49 | test("equals negative"): 50 | val m1 = TypedMap.Empty + (IntKey -> 5) 51 | val m2 = TypedMap.Empty + (IntKey -> 6) 52 | m1 should not be m2 53 | 54 | test("hashCode positive"): 55 | val m1 = TypedMap.Empty + (IntKey -> 5) 56 | val m2 = TypedMap.Empty + (IntKey -> 5) 57 | m1.hashCode should be(m2.hashCode) 58 | 59 | test("hashCode negative"): 60 | val m1 = TypedMap.Empty + (IntKey -> 5) 61 | val m2 = TypedMap.Empty + (IntKey -> 6) 62 | m1.hashCode should not be m2.hashCode 63 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/test/scala/org/terminal21/model/CommonModelBuilders.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.model 2 | 3 | object CommonModelBuilders: 4 | def session( 5 | id: String = "session-id", 6 | name: String = "session-name", 7 | secret: String = "session-secret", 8 | isOpen: Boolean = true, 9 | sessionOptions: SessionOptions = SessionOptions.Defaults 10 | ) = 11 | Session(id, name, secret, isOpen, sessionOptions) 12 | -------------------------------------------------------------------------------- /terminal21-server-client-common/src/test/scala/org/terminal21/ws/ReliableWsListenerTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ws 2 | 3 | import functions.fibers.FiberExecutor 4 | import io.helidon.common.buffers.BufferData 5 | import io.helidon.webclient.websocket.WsClient 6 | import io.helidon.webserver.WebServer 7 | import io.helidon.webserver.websocket.WsRouting 8 | import org.scalatest.funsuite.AnyFunSuiteLike 9 | import org.scalatest.matchers.should.Matchers.* 10 | 11 | import java.net.URI 12 | import scala.util.Using 13 | 14 | class ReliableWsListenerTest extends AnyFunSuiteLike: 15 | 16 | def buffDataToString(buf: BufferData) = new String(buf.readBytes(), "UTF-8") 17 | def stringToBuffData(s: String) = BufferData.create(s.getBytes("UTF-8")) 18 | 19 | def withServer[R](executor: FiberExecutor)(f: (WebServer, ServerWsListener[ServerValue[String], ServerValue[String]]) => R): R = 20 | Using.resource(ReliableServerWsListener.server(executor)): serverWsListener => 21 | val wsB = WsRouting.builder().endpoint("/ws-test", serverWsListener.listener) 22 | val server = WebServer.builder 23 | .port(0) 24 | .addRouting(wsB) 25 | .build 26 | .start 27 | val stringListener = serverWsListener.transformValue(buffDataToString, stringToBuffData) 28 | try 29 | f(server, stringListener) 30 | finally server.stop() 31 | 32 | def withClient[R](id: String, serverPort: Int, executor: FiberExecutor)(f: ClientWsListener[String, String] => R): R = 33 | val wsClient = newWsClient(serverPort) 34 | createClient(id, wsClient, executor)(f) 35 | 36 | def newWsClient(serverPort: Int) = 37 | val uri = URI.create(s"ws://localhost:$serverPort") 38 | WsClient 39 | .builder() 40 | .baseUri(uri) 41 | .build() 42 | 43 | def createClient[R](id: String, wsClient: WsClient, executor: FiberExecutor)(f: ClientWsListener[String, String] => R) = 44 | Using.resource(ReliableClientWsListener.client(id, wsClient, "/ws-test", executor)): clientWsListener => 45 | f(stringClient(clientWsListener)) 46 | 47 | def stringClient(clientWsListener: ClientWsListener[BufferData, BufferData]) = 48 | clientWsListener.transform(buffDataToString, stringToBuffData) 49 | 50 | def runServerClient[R](clientId: String)(test: (ServerWsListener[ServerValue[String], ServerValue[String]], ClientWsListener[String, String]) => R): R = 51 | FiberExecutor.withFiberExecutor: executor => 52 | withServer(executor): (server, serverWsListener) => 53 | withClient(clientId, server.port, executor)(clientWsListener => test(serverWsListener, clientWsListener)) 54 | 55 | test("client sends server a msg"): 56 | runServerClient("client-1"): (serverWsListener, clientWsListener) => 57 | clientWsListener.send("Hello") 58 | serverWsListener.receivedIterator 59 | .take(1) 60 | .toList should be(Seq(ServerValue("client-1", "Hello"))) 61 | 62 | test("multiple clients / server messages"): 63 | FiberExecutor.withFiberExecutor: executor => 64 | withServer(executor): (server, serverWsListener) => 65 | val wsClient = newWsClient(server.port) 66 | val fibers = for i <- 1 to 40 yield executor.submit: 67 | createClient(s"client-$i", wsClient, executor): client => 68 | for j <- 1 to 100 do 69 | client.send(s"hello-$i-$j") 70 | client.receivedIterator.next() should be(s"got hello-$i-$j") 71 | 72 | val serverFiber = executor.submit: 73 | for sv <- serverWsListener.receivedIterator do serverWsListener.send(ServerValue(sv.id, s"got ${sv.value}")) 74 | 75 | // make sure no exceptions 76 | try for f <- fibers yield f.get() 77 | finally serverFiber.interrupt() 78 | 79 | test("server sends client a msg"): 80 | runServerClient("client-1"): (serverWsListener, clientWsListener) => 81 | while !serverWsListener.listener.hasClientId("client-1") do Thread.sleep(5) 82 | serverWsListener.send(ServerValue("client-1", "Hello")) 83 | clientWsListener.receivedIterator 84 | .take(1) 85 | .toList should be(Seq("Hello")) 86 | -------------------------------------------------------------------------------- /terminal21-server/src/main/resources/META-INF/helidon/serial-config.properties: -------------------------------------------------------------------------------- 1 | pattern=javax.management.**;java.lang.**;java.rmi.**;javax.security.auth.Subject -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/Routes.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server 2 | 3 | import io.helidon.webserver.http.HttpRouting 4 | import io.helidon.webserver.staticcontent.StaticContentService 5 | import io.helidon.webserver.websocket.WsRouting 6 | import org.slf4j.LoggerFactory 7 | import org.terminal21.server.utils.Environment 8 | import org.terminal21.ui.std.SessionsServiceReceiverFactory 9 | 10 | import java.io.File 11 | import java.nio.file.Path 12 | 13 | object Routes: 14 | private val logger = LoggerFactory.getLogger(getClass) 15 | def register(dependencies: ServerBeans, rb: HttpRouting.Builder): Unit = 16 | import dependencies.* 17 | SessionsServiceReceiverFactory.newJsonSessionsServiceHelidonRoutes(sessionsService).routes(rb) 18 | 19 | def static(rb: HttpRouting.Builder): Unit = 20 | val staticContent = StaticContentService 21 | .builder("web") 22 | .welcomeFileName("index.html") 23 | .build 24 | val webFolder = new File(Environment.UserHome, ".terminal21/web") 25 | if !webFolder.exists() then 26 | logger.info(s"Creating $webFolder where static files can be placed.") 27 | webFolder.mkdirs() 28 | 29 | val publicContent = StaticContentService 30 | .builder(Path.of(Environment.UserHome, ".terminal21", "web")) 31 | .welcomeFileName("index.html") 32 | .build 33 | 34 | rb.register("/ui", staticContent) 35 | rb.register("/web", publicContent) 36 | 37 | def ws(dependencies: ServerBeans): WsRouting.Builder = 38 | val b = WsRouting.builder 39 | b.endpoint("/ui/sessions", dependencies.sessionsWebSocket) 40 | .endpoint("/api/command-ws", dependencies.commandWebSocket.commandWebSocketListener.listener) 41 | b 42 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/ServerBeans.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server 2 | 3 | import functions.fibers.FiberExecutor 4 | import org.terminal21.server.service.{CommandWebSocketBeans, ServerSessionsServiceBeans} 5 | import org.terminal21.server.ui.SessionsWebSocketBeans 6 | 7 | trait ServerBeans extends ServerSessionsServiceBeans with SessionsWebSocketBeans with CommandWebSocketBeans 8 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/json/WsRequest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.json 2 | 3 | import io.circe.* 4 | import io.circe.generic.auto.* 5 | import io.circe.parser.* 6 | 7 | case class WsRequest(operation: String, body: Option[Body]) 8 | 9 | sealed trait Body 10 | 11 | case class SessionFullRefresh(sessionId: String) extends Body 12 | 13 | sealed trait UiEvent extends Body: 14 | def sessionId: String 15 | 16 | case class OnClick(sessionId: String, key: String) extends UiEvent 17 | case class OnChange(sessionId: String, key: String, value: String) extends UiEvent 18 | 19 | case class CloseSession(id: String) extends Body 20 | case class RemoveSession(id: String) extends Body 21 | 22 | object WsRequest: 23 | val decoder = decode[WsRequest] 24 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/json/WsResponse.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.json 2 | 3 | import org.terminal21.model.Session 4 | import org.terminal21.ui.std.ServerJson 5 | 6 | sealed trait WsResponse 7 | 8 | case class SessionsWsResponse(sessions: Seq[Session]) extends WsResponse 9 | 10 | case class StateWsResponse(session: Session, sessionState: ServerJson) extends WsResponse 11 | 12 | case class StateChangeWsResponse(session: Session, sessionStateChange: ServerJson) extends WsResponse 13 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/model/SessionState.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.model 2 | 3 | import org.terminal21.model.CommandEvent 4 | import org.terminal21.server.utils.NotificationRegistry 5 | import org.terminal21.ui.std.ServerJson 6 | 7 | case class SessionState( 8 | serverJson: ServerJson, 9 | eventsNotificationRegistry: NotificationRegistry[CommandEvent] 10 | ): 11 | def withNewState(newJson: ServerJson): SessionState = copy(serverJson = newJson) 12 | def close: SessionState = copy(eventsNotificationRegistry = new NotificationRegistry[CommandEvent]) 13 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/service/CommandWebSocket.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.service 2 | 3 | import functions.fibers.FiberExecutor 4 | import io.circe.* 5 | import io.circe.generic.auto.* 6 | import io.circe.parser.* 7 | import io.circe.syntax.* 8 | import io.helidon.common.buffers.BufferData 9 | import org.slf4j.LoggerFactory 10 | import org.terminal21.model.{ClientToServer, CommandEvent, SubscribeTo} 11 | import org.terminal21.ws.{ReliableServerWsListener, ServerValue} 12 | 13 | class CommandWebSocket(executor: FiberExecutor, sessionsService: ServerSessionsService): 14 | private val logger = LoggerFactory.getLogger(getClass.getName) 15 | 16 | private def decoder(buf: BufferData): ClientToServer = { 17 | val json = new String(buf.readBytes(), "UTF-8") 18 | decode[ClientToServer](json) match 19 | case Right(msg) => msg 20 | case Left(e) => throw new IllegalStateException(s"Invalid json : json = $json error = $e") 21 | } 22 | 23 | private def encoder(event: CommandEvent): BufferData = 24 | val j = event.asJson.noSpaces 25 | BufferData.create(j.getBytes("UTF-8")) 26 | 27 | val commandWebSocketListener = ReliableServerWsListener 28 | .server(executor) 29 | .transformValue( 30 | decoder, 31 | encoder 32 | ) 33 | 34 | start() 35 | 36 | def start(): Unit = 37 | executor.submit: 38 | val send = commandWebSocketListener.send 39 | for sv <- commandWebSocketListener.receivedIterator do 40 | sv.value match 41 | case SubscribeTo(session) => 42 | logger.info(s"Command subscribes to events of session ${session.id}") 43 | sessionsService.notifyMeOnSessionEvents(session): event => 44 | val e = ServerValue(sv.id, event) 45 | send(e) 46 | true 47 | 48 | trait CommandWebSocketBeans: 49 | def sessionsService: ServerSessionsService 50 | def fiberExecutor: FiberExecutor 51 | lazy val commandWebSocket = new CommandWebSocket(fiberExecutor, sessionsService) 52 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/service/ServerSessionsService.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.service 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.terminal21.model.* 5 | import org.terminal21.server.json.UiEvent 6 | import org.terminal21.server.model.SessionState 7 | import org.terminal21.server.utils.{ListenerFunction, NotificationRegistry} 8 | import org.terminal21.ui.std.{ServerJson, SessionsService} 9 | 10 | import java.util.UUID 11 | 12 | class ServerSessionsService extends SessionsService: 13 | private val logger = LoggerFactory.getLogger(getClass) 14 | 15 | private val sessions = collection.concurrent.TrieMap.empty[Session, SessionState] 16 | 17 | private val sessionChangeNotificationRegistry = new NotificationRegistry[Seq[Session]] 18 | private val sessionStateChangeNotificationRegistry = new NotificationRegistry[(Session, SessionState, Option[ServerJson])] 19 | 20 | def sessionById(sessionId: String): Session = 21 | sessions.keys.find(_.id == sessionId).getOrElse(throw new IllegalArgumentException(s"Invalid session id = $sessionId")) 22 | 23 | def sessionStateOf(session: Session): SessionState = sessions(session) 24 | 25 | def notifyMeWhenSessionsChange(listener: ListenerFunction[Seq[Session]]): Unit = 26 | sessionChangeNotificationRegistry.addAndNotify(allSessions)(listener) 27 | 28 | def removeSession(session: Session): Unit = 29 | sessions -= session 30 | sessionChangeNotificationRegistry.notifyAll(allSessions) 31 | 32 | override def terminateSession(session: Session): Unit = 33 | val state = sessions.getOrElse(session, throw new IllegalArgumentException(s"Session ${session.id} doesn't exist")) 34 | if session.options.alwaysOpen then throw new IllegalArgumentException("Can't terminate a session that should be always open") 35 | state.eventsNotificationRegistry.notifyAll(SessionClosed("-")) 36 | sessions -= session 37 | sessions += session.close -> state.close 38 | sessionChangeNotificationRegistry.notifyAll(allSessions) 39 | if (session.options.closeTabWhenTerminated) removeSession(session.close) 40 | 41 | def terminateAndRemove(session: Session): Unit = 42 | terminateSession(session) 43 | removeSession(session.close) 44 | 45 | override def createSession(id: String, name: String, sessionOptions: SessionOptions): Session = 46 | val s = Session(id, name, UUID.randomUUID().toString, true, sessionOptions) 47 | logger.info(s"Creating session $s") 48 | sessions.keys.toList.foreach(s => if s.id == id then sessions.remove(s)) 49 | val state = SessionState(ServerJson.Empty, new NotificationRegistry) 50 | sessions += s -> state 51 | sessionChangeNotificationRegistry.notifyAll(allSessions) 52 | s 53 | 54 | def allSessions: Seq[Session] = sessions.keySet.toList 55 | 56 | def notifyMeWhenSessionChanges(f: ListenerFunction[(Session, SessionState, Option[ServerJson])]): Unit = 57 | sessionStateChangeNotificationRegistry.add(f) 58 | for (session, state) <- sessions do f(session, state, None) 59 | 60 | override def setSessionJsonState(session: Session, newStateJson: ServerJson): Unit = 61 | val oldV = sessions(session) 62 | val newV = oldV.withNewState(newStateJson) 63 | sessions += session -> newV 64 | sessionStateChangeNotificationRegistry.notifyAll((session, newV, None)) 65 | logger.debug(s"Session $session new state $newStateJson") 66 | 67 | override def changeSessionJsonState(session: Session, change: ServerJson): Unit = 68 | ??? 69 | // val oldV = sessions(session) 70 | // val newV = oldV.withNewState(oldV.serverJson.include(change)) 71 | // sessions += session -> newV 72 | // sessionStateChangeNotificationRegistry.notifyAll((session, newV, Some(change))) 73 | // logger.debug(s"Session $session change $change") 74 | 75 | def triggerUiEvent(event: UiEvent): Unit = 76 | val e = event match 77 | case org.terminal21.server.json.OnClick(_, key) => OnClick(key) 78 | case org.terminal21.server.json.OnChange(_, key, value) => OnChange(key, value) 79 | 80 | val session = sessionById(event.sessionId) 81 | val state = sessions(session) 82 | state.eventsNotificationRegistry.notifyAll(e) 83 | 84 | def notifyMeOnSessionEvents(session: Session)(listener: ListenerFunction[CommandEvent]): Unit = 85 | val state = sessions(session) 86 | state.eventsNotificationRegistry.add(listener) 87 | 88 | trait ServerSessionsServiceBeans: 89 | lazy val sessionsService: ServerSessionsService = new ServerSessionsService 90 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/ui/WsSessionOps.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.ui 2 | 3 | import org.slf4j.LoggerFactory 4 | 5 | import java.io.UncheckedIOException 6 | 7 | object WsSessionOps: 8 | private val logger = LoggerFactory.getLogger(getClass.getName) 9 | 10 | def returnTrueIfSessionIsNotClosed(f: => Unit): Boolean = 11 | try 12 | f 13 | true 14 | catch 15 | case s: UncheckedIOException if s.getCause.getMessage == "Socket closed" => 16 | logger.info("Socket closed") 17 | false 18 | // ignore 19 | case t: Throwable => 20 | logger.error("An error occurred", t) 21 | false 22 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/utils/Environment.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.utils 2 | 3 | object Environment: 4 | val UserHome = sys.props("user.home") 5 | -------------------------------------------------------------------------------- /terminal21-server/src/main/scala/org/terminal21/server/utils/NotificationRegistry.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.utils 2 | 3 | import org.slf4j.LoggerFactory 4 | 5 | import scala.util.Try 6 | 7 | // make sure this doesn't throw any exceptions 8 | type ListenerFunction[A] = A => Boolean 9 | 10 | class NotificationRegistry[A]: 11 | private val logger = LoggerFactory.getLogger(getClass) 12 | private var ns = List.empty[ListenerFunction[A]] 13 | 14 | def add(listener: ListenerFunction[A]): Unit = 15 | synchronized: 16 | ns = listener :: ns 17 | 18 | def addAndNotify(a: A)(listener: ListenerFunction[A]): Unit = 19 | if listener(a) then add(listener) 20 | 21 | def notifyAll(a: A): Int = 22 | synchronized: 23 | ns = ns.filter: f => 24 | Try(f(a)) 25 | .recover: e => 26 | logger.error("an error occurred during a notification", e) 27 | false 28 | .get 29 | ns.size 30 | -------------------------------------------------------------------------------- /terminal21-server/src/test/scala/org/terminal21/server/utils/NotificationRegistryTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.server.utils 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.scalatest.matchers.should.Matchers.* 5 | 6 | class NotificationRegistryTest extends AnyFunSuiteLike: 7 | test("notifies"): 8 | var c = 0 9 | val n = new NotificationRegistry[Int] 10 | n.addAndNotify(5): i => 11 | i should be(5) 12 | c += 1 13 | true 14 | 15 | c should be(1) 16 | 17 | n.addAndNotify(5): i => 18 | i should be(5) 19 | c += 10 20 | true 21 | c should be(11) 22 | n.notifyAll(5) should be(2) 23 | c should be(22) 24 | 25 | test("removes notifier"): 26 | val n = new NotificationRegistry[Int] 27 | n.addAndNotify(1): _ => 28 | false 29 | 30 | n.notifyAll(5) should be(0) 31 | -------------------------------------------------------------------------------- /terminal21-spark/src/main/scala/org/terminal21/sparklib/Cached.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib 2 | 3 | import org.apache.commons.io.FileUtils 4 | import org.apache.spark.sql.SparkSession 5 | import org.terminal21.client.* 6 | import org.terminal21.client.components.UiElement 7 | import org.terminal21.client.components.UiElement.HasStyle 8 | import org.terminal21.sparklib.calculations.SparkCalculation.TriggerRedraw 9 | import org.terminal21.sparklib.calculations.{ReadWriter, SparkCalculation} 10 | import org.terminal21.sparklib.util.Environment 11 | 12 | import java.io.File 13 | 14 | class Cached[OUT: ReadWriter](val name: String, outF: => OUT)(using spark: SparkSession): 15 | private val rw = implicitly[ReadWriter[OUT]] 16 | private val rootFolder = s"${Environment.tmpDirectory}/spark-calculations" 17 | private val targetDir = s"$rootFolder/$name" 18 | 19 | def isCached: Boolean = new File(targetDir).exists() 20 | 21 | def cachePath: String = targetDir 22 | 23 | private def cache[A](reader: => A, writer: => A): A = 24 | if isCached then reader 25 | else writer 26 | 27 | def invalidateCache(): Unit = 28 | FileUtils.deleteDirectory(new File(targetDir)) 29 | out = None 30 | 31 | private def calculateOnce: OUT = 32 | cache( 33 | rw.read(spark, targetDir), { 34 | val ds = outF 35 | rw.write(targetDir, ds) 36 | ds 37 | } 38 | ) 39 | 40 | @volatile private var out = Option.empty[OUT] 41 | private def startCalc(session: ConnectedSession): Unit = 42 | fiberExecutor.submit: 43 | out = Some(calculateOnce) 44 | session.fireEvent(TriggerRedraw) 45 | 46 | def get: Option[OUT] = out 47 | 48 | def visualize(dataUi: UiElement & HasStyle)( 49 | toUi: OUT => UiElement & HasStyle 50 | )(using 51 | SparkSession 52 | )(using session: ConnectedSession, events: Events) = 53 | val sc = new SparkCalculation[OUT](s"spark-calc-$name", dataUi, toUi, this) 54 | 55 | if events.isClicked(sc.recalc) then 56 | invalidateCache() 57 | startCalc(session) 58 | else if events.isInitialRender then startCalc(session) 59 | sc 60 | 61 | object Cached: 62 | def apply[OUT: ReadWriter](name: String)(outF: => OUT)(using spark: SparkSession): Cached[OUT] = new Cached(name, outF) 63 | -------------------------------------------------------------------------------- /terminal21-spark/src/main/scala/org/terminal21/sparklib/DataframeExtensions.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib 2 | 3 | import org.apache.spark.sql.{DataFrame, Row} 4 | 5 | extension (rows: Seq[Row]) 6 | def toUiTable: Seq[Seq[String]] = rows.map: row => 7 | row.toSeq.map(_.toString) 8 | -------------------------------------------------------------------------------- /terminal21-spark/src/main/scala/org/terminal21/sparklib/SparkSessionExt.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib 2 | 3 | import org.apache.spark.sql.{DataFrame, Dataset, Encoder, SparkSession} 4 | 5 | import scala.reflect.ClassTag 6 | 7 | class SparkSessionExt(spark: SparkSession): 8 | import spark.implicits.* 9 | 10 | def schemaOf[P: Encoder] = summon[Encoder[P]].schema 11 | 12 | def toDF[P: ClassTag: Encoder](s: Seq[P], numSlices: Int = spark.sparkContext.defaultParallelism): DataFrame = 13 | spark.sparkContext.parallelize(s, numSlices).toDF() 14 | 15 | def toDS[P: ClassTag: Encoder](s: Seq[P], numSlices: Int = spark.sparkContext.defaultParallelism): Dataset[P] = 16 | spark.sparkContext.parallelize(s, numSlices).toDS() 17 | -------------------------------------------------------------------------------- /terminal21-spark/src/main/scala/org/terminal21/sparklib/SparkSessions.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib 2 | 3 | import org.apache.spark.sql.SparkSession 4 | 5 | object SparkSessions: 6 | def newSparkSession( 7 | appName: String = "spark-app", 8 | master: String = "local[*]", 9 | bindAddress: String = "localhost", 10 | sparkUiEnabled: Boolean = false 11 | ): SparkSession = 12 | SparkSession 13 | .builder() 14 | .appName(appName) 15 | .master(master) 16 | .config("spark.driver.bindAddress", bindAddress) 17 | .config("spark.ui.enabled", sparkUiEnabled) 18 | .getOrCreate() 19 | -------------------------------------------------------------------------------- /terminal21-spark/src/main/scala/org/terminal21/sparklib/calculations/ReadWriter.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib.calculations 2 | 3 | import org.apache.spark.sql.{DataFrame, Dataset, Encoder, SparkSession} 4 | 5 | import scala.annotation.implicitNotFound 6 | 7 | @implicitNotFound("Unable to find ReadWriter for type ${A}. Dataset of case classes and Dataframes are supported.") 8 | trait ReadWriter[A]: 9 | def read(spark: SparkSession, file: String): A 10 | def write(file: String, ds: A): Unit 11 | 12 | object ReadWriter: 13 | given datasetReadWriter[A](using Encoder[A]): ReadWriter[Dataset[A]] = new ReadWriter[Dataset[A]]: 14 | override def read(spark: SparkSession, file: String) = spark.read.parquet(file).as[A] 15 | override def write(file: String, ds: Dataset[A]): Unit = ds.write.parquet(file) 16 | 17 | given dataframeReadWriter: ReadWriter[DataFrame] = new ReadWriter[DataFrame]: 18 | override def read(spark: SparkSession, file: String) = spark.read.parquet(file) 19 | override def write(file: String, ds: DataFrame): Unit = ds.write.parquet(file) 20 | -------------------------------------------------------------------------------- /terminal21-spark/src/main/scala/org/terminal21/sparklib/calculations/SparkCalculation.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib.calculations 2 | 3 | import org.apache.spark.sql.SparkSession 4 | import org.terminal21.client.components.* 5 | import org.terminal21.client.components.UiElement.HasStyle 6 | import org.terminal21.client.components.chakra.* 7 | import org.terminal21.client.* 8 | import org.terminal21.collections.TypedMap 9 | import org.terminal21.model.ClientEvent 10 | import org.terminal21.sparklib.Cached 11 | 12 | /** A UI component that takes a spark calculation (i.e. a spark query) that results in a Dataset. It caches the results by storing them as parquet into the tmp 13 | * folder/spark-calculations/$name. Next time the calculation runs it reads the cache if available. A button should allow the user to clear the cache and rerun 14 | * the spark calculations in case the data changed. 15 | * 16 | * Because the cache is stored in the disk, it is available even if the jvm running the code restarts. This allows the user to run and rerun their code without 17 | * having to rerun the spark calculation. 18 | */ 19 | case class SparkCalculation[OUT: ReadWriter]( 20 | key: String, 21 | dataUi: UiElement with HasStyle, 22 | toUi: OUT => UiElement & HasStyle, 23 | cached: Cached[OUT], 24 | dataStore: TypedMap = TypedMap.Empty 25 | )(using 26 | spark: SparkSession, 27 | session: ConnectedSession, 28 | events: Events 29 | ) extends UiComponent: 30 | def name = cached.name 31 | override type This = SparkCalculation[OUT] 32 | override def withKey(key: String): This = copy(key = key) 33 | override def withDataStore(ds: TypedMap): This = copy(dataStore = ds) 34 | 35 | val recalc = Button(s"recalc-button-$name", text = "Recalculate", size = Some("sm"), leftIcon = Some(RepeatIcon())) 36 | 37 | override def rendered: Seq[UiElement] = 38 | val header = Box( 39 | s"recalc-box-$name", 40 | bg = "green", 41 | p = 4, 42 | children = Seq( 43 | HStack().withChildren( 44 | Text(text = name), 45 | if events.isClicked(recalc) then Badge(text = "Recalculating...") 46 | else if events.isInitialRender then Badge(text = "Initializing...") 47 | else recalc 48 | ) 49 | ) 50 | ) 51 | val ui = cached.get 52 | .map: ds => 53 | toUi(ds) 54 | .getOrElse(dataUi) 55 | 56 | Seq(header, ui) 57 | 58 | object SparkCalculation: 59 | object TriggerRedraw extends ClientEvent 60 | -------------------------------------------------------------------------------- /terminal21-spark/src/main/scala/org/terminal21/sparklib/util/Environment.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib.util 2 | 3 | import org.apache.commons.lang3.StringUtils 4 | 5 | object Environment: 6 | val tmpDirectory = 7 | val t = System.getProperty("java.io.tmpdir") 8 | if (t.endsWith("/")) StringUtils.substringBeforeLast(t, "/") else t 9 | -------------------------------------------------------------------------------- /terminal21-spark/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /terminal21-spark/src/test/scala/org/terminal21/sparklib/AbstractSparkSuite.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.scalatest.matchers.should.Matchers 5 | import org.terminal21.sparklib.util.Environment 6 | 7 | import java.util.UUID 8 | 9 | class AbstractSparkSuite extends AnyFunSuiteLike with Matchers: 10 | protected def randomString: String = UUID.randomUUID().toString 11 | protected def randomTmpFilename: String = s"${Environment.tmpDirectory}/AbstractSparkSuite-" + UUID.randomUUID().toString 12 | -------------------------------------------------------------------------------- /terminal21-spark/src/test/scala/org/terminal21/sparklib/SparkSessionExtTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib 2 | 3 | import org.terminal21.sparklib.testmodel.Person 4 | 5 | import scala.util.Using 6 | 7 | class SparkSessionExtTest extends AbstractSparkSuite: 8 | val people = for (i <- 1 to 10) yield Person(i.toString, s"text for row $i") 9 | 10 | test("schemaOf"): 11 | Using.resource(SparkSessions.newSparkSession()): spark => 12 | val sp = new SparkSessionExt(spark) 13 | import scala3encoders.given 14 | import spark.implicits.* 15 | val schema = sp.schemaOf[Person] 16 | schema.toList.size should be(2) 17 | 18 | test("toDF"): 19 | Using.resource(SparkSessions.newSparkSession()): spark => 20 | val sp = new SparkSessionExt(spark) 21 | import scala3encoders.given 22 | import spark.implicits.* 23 | val df = sp.toDF(people) 24 | df.as[Person].collect() should be(people.toArray) 25 | 26 | test("toDS"): 27 | Using.resource(SparkSessions.newSparkSession()): spark => 28 | val sp = new SparkSessionExt(spark) 29 | import scala3encoders.given 30 | import spark.implicits.* 31 | val ds = sp.toDS(people) 32 | ds.collect() should be(people.toArray) 33 | -------------------------------------------------------------------------------- /terminal21-spark/src/test/scala/org/terminal21/sparklib/SparkSessionsTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib 2 | 3 | import org.terminal21.sparklib.testmodel.Person 4 | 5 | import scala.util.Using 6 | 7 | class SparkSessionsTest extends AbstractSparkSuite: 8 | val people = for (i <- 1 to 10) yield Person(i.toString, s"text for row $i") 9 | 10 | test("creates/destroys session"): 11 | Using.resource(SparkSessions.newSparkSession()): spark => 12 | () 13 | 14 | test("Can convert to Dataframe"): 15 | Using.resource(SparkSessions.newSparkSession()): spark => 16 | import scala3encoders.given 17 | import spark.implicits.* 18 | val df = spark.sparkContext.parallelize(people, 16).toDF() 19 | df.as[Person].collect() should be(people.toArray) 20 | 21 | test("Can convert to Dataset"): 22 | Using.resource(SparkSessions.newSparkSession()): spark => 23 | import scala3encoders.given 24 | import spark.implicits.* 25 | val ds = spark.sparkContext.parallelize(people, 16).toDS() 26 | ds.collect() should be(people.toArray) 27 | 28 | test("Can write parquet"): 29 | Using.resource(SparkSessions.newSparkSession()): spark => 30 | import scala3encoders.given 31 | import spark.implicits.* 32 | val ds = spark.sparkContext.parallelize(people, 16).toDS() 33 | val f = randomTmpFilename 34 | ds.write.parquet(f) 35 | val rds = spark.read.parquet(f).as[Person] 36 | rds.collect() should be(rds.collect()) 37 | 38 | test("Can write csv"): 39 | Using.resource(SparkSessions.newSparkSession()): spark => 40 | import scala3encoders.given 41 | import spark.implicits.* 42 | val ds = spark.sparkContext.parallelize(people, 16).toDS() 43 | val f = randomTmpFilename 44 | ds.write.option("header", true).csv(f) 45 | val rds = spark.read.option("header", true).csv(f).as[Person] 46 | rds.collect() should be(rds.collect()) 47 | 48 | test("Can write json"): 49 | Using.resource(SparkSessions.newSparkSession()): spark => 50 | import scala3encoders.given 51 | import spark.implicits.* 52 | val ds = spark.sparkContext.parallelize(people, 16).toDS() 53 | val f = randomTmpFilename 54 | ds.write.json(f) 55 | val rds = spark.read.json(f).as[Person] 56 | rds.collect() should be(rds.collect()) 57 | 58 | test("Can write orc"): 59 | Using.resource(SparkSessions.newSparkSession()): spark => 60 | import scala3encoders.given 61 | import spark.implicits.* 62 | val ds = spark.sparkContext.parallelize(people, 16).toDS() 63 | val f = randomTmpFilename 64 | ds.write.orc(f) 65 | val rds = spark.read.orc(f).as[Person] 66 | rds.collect() should be(rds.collect()) 67 | 68 | test("Can mount as tmp table"): 69 | Using.resource(SparkSessions.newSparkSession()): spark => 70 | import scala3encoders.given 71 | import spark.implicits.* 72 | val ds = spark.sparkContext.parallelize(people, 16).toDS() 73 | ds.createOrReplaceTempView("people") 74 | spark.sql("select * from people").count() should be(10) 75 | -------------------------------------------------------------------------------- /terminal21-spark/src/test/scala/org/terminal21/sparklib/endtoend/SparkBasics.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib.endtoend 2 | 3 | import org.apache.commons.lang3.StringUtils 4 | import org.apache.spark.sql.{Dataset, SparkSession} 5 | import org.terminal21.client.components.* 6 | import org.terminal21.client.components.chakra.* 7 | import org.terminal21.client.components.nivo.* 8 | import org.terminal21.client.* 9 | import org.terminal21.sparklib.* 10 | import org.terminal21.sparklib.endtoend.model.CodeFile 11 | import org.terminal21.sparklib.endtoend.model.CodeFile.scanSourceFiles 12 | 13 | import scala.util.Using 14 | 15 | @main def sparkBasics(): Unit = 16 | Using.resource(SparkSessions.newSparkSession()): spark => 17 | Sessions 18 | .withNewSession("spark-basics", "Spark Basics") 19 | .andLibraries(NivoLib) 20 | .connect: session => 21 | given ConnectedSession = session 22 | given SparkSession = spark 23 | import scala3encoders.given 24 | import spark.implicits.* 25 | 26 | val sourceFileCached = Cached("Code files"): 27 | sourceFiles().limit(3) 28 | val sortedSourceFilesDS = Cached("Sorted files"): 29 | sortedSourceFiles(sourceFiles()).limit(3) 30 | val sortedSourceFilesDFCached = Cached("Sorted files DF"): 31 | sourceFiles() 32 | .sort($"createdDate".asc, $"numOfWords".asc) 33 | .toDF() 34 | .limit(4) 35 | 36 | val sourceFilesSortedByNumOfLinesCached = Cached("Biggest Code Files"): 37 | sourceFiles() 38 | .sort($"numOfLines".desc) 39 | 40 | println(s"Cached dir: ${sourceFileCached.cachePath}") 41 | def components(events: Events) = 42 | given Events = events 43 | 44 | val headers = Seq("id", "name", "path", "numOfLines", "numOfWords", "createdDate", "timestamp") 45 | val sortedFilesTable = QuickTable().withHeaders(headers: _*).caption("Files sorted by createdDate and numOfWords") 46 | val codeFilesTable = QuickTable().withHeaders(headers: _*).caption("Unsorted files") 47 | 48 | val sortedCalc = sortedSourceFilesDS.visualize(sortedFilesTable): results => 49 | val tableRows = results.collect().map(_.toData).toList 50 | sortedFilesTable.withRows(tableRows) 51 | 52 | val codeFilesCalculation = sourceFileCached.visualize(codeFilesTable): results => 53 | val dt = results.collect().toList 54 | codeFilesTable.withRows(dt.map(_.toData)) 55 | 56 | val sortedFilesTableDF = QuickTable().withHeaders(headers: _*).caption("Files sorted by createdDate and numOfWords ASC and as DF") 57 | val sortedCalcAsDF = sortedSourceFilesDFCached 58 | .visualize(sortedFilesTableDF): results => 59 | val tableRows = results.collect().toList 60 | sortedFilesTableDF.withRows(tableRows.toUiTable) 61 | 62 | val chart = ResponsiveLine( 63 | data = Seq( 64 | Serie( 65 | "Scala", 66 | data = Nil 67 | ) 68 | ), 69 | axisBottom = Some(Axis(legend = "Class", legendOffset = 36)), 70 | axisLeft = Some(Axis(legend = "Number of Lines", legendOffset = -40)), 71 | legends = Seq(Legend()) 72 | ) 73 | 74 | val sourceFileChart = sourceFilesSortedByNumOfLinesCached 75 | .visualize(chart): results => 76 | val data = results.take(10).map(cf => Datum(StringUtils.substringBeforeLast(cf.name, ".scala"), cf.numOfLines)).toList 77 | chart.withData(Seq(Serie("Scala", data = data))) 78 | Seq( 79 | codeFilesCalculation, 80 | sortedCalc, 81 | sortedCalcAsDF, 82 | sourceFileChart 83 | ) 84 | 85 | Controller 86 | .noModel(components) 87 | .render() 88 | .run() 89 | 90 | def sourceFiles()(using spark: SparkSession) = 91 | import scala3encoders.given 92 | import spark.implicits.* 93 | scanSourceFiles.toDS.map: cf => 94 | cf.copy(timestamp = System.currentTimeMillis()) 95 | 96 | def sortedSourceFiles(sourceFiles: Dataset[CodeFile])(using spark: SparkSession) = 97 | import spark.implicits.* 98 | sourceFiles.sort($"createdDate".desc, $"numOfWords".desc) 99 | -------------------------------------------------------------------------------- /terminal21-spark/src/test/scala/org/terminal21/sparklib/endtoend/model/CodeFile.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib.endtoend.model 2 | 3 | import org.apache.commons.io.FileUtils 4 | 5 | import java.io.File 6 | import java.nio.file.Files 7 | import java.time.{Instant, LocalDate, ZoneId} 8 | 9 | case class CodeFile(id: Int, name: String, path: String, numOfLines: Int, numOfWords: Int, createdDate: LocalDate, timestamp: Long): 10 | def toColumnNames: Seq[String] = productElementNames.toList 11 | def toData: Seq[String] = productIterator.map(_.toString).toList 12 | 13 | object CodeFile: 14 | import scala.jdk.CollectionConverters.* 15 | def scanSourceFiles: Seq[CodeFile] = 16 | val availableFiles = FileUtils.listFiles(new File(".."), Array("scala"), true).asScala.filterNot(_.getPath.contains("/.scala-build/")).toList 17 | availableFiles.zipWithIndex.map: (f, i) => 18 | val code = Files.readString(f.toPath) 19 | CodeFile( 20 | i, 21 | f.getName, 22 | f.getPath, 23 | code.split("\n").length, 24 | code.split(" ").length, 25 | LocalDate.ofInstant(Instant.ofEpochMilli(f.lastModified()), ZoneId.systemDefault()), 26 | System.currentTimeMillis() 27 | ) 28 | -------------------------------------------------------------------------------- /terminal21-spark/src/test/scala/org/terminal21/sparklib/testmodel/Person.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.sparklib.testmodel 2 | 3 | case class Person(id: String, name: String) 4 | -------------------------------------------------------------------------------- /terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/ServerJson.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ui.std 2 | 3 | import io.circe.Json 4 | import org.slf4j.LoggerFactory 5 | 6 | case class ServerJson( 7 | elements: Seq[Json] 8 | ) 9 | 10 | object ServerJson: 11 | val Empty = ServerJson(Nil) 12 | -------------------------------------------------------------------------------- /terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/SessionsService.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ui.std 2 | 3 | import org.terminal21.model.{Session, SessionOptions} 4 | 5 | /** //> exported 6 | */ 7 | trait SessionsService: 8 | def createSession(id: String, name: String, sessionOptions: SessionOptions): Session 9 | def terminateSession(session: Session): Unit 10 | 11 | def setSessionJsonState(session: Session, state: ServerJson): Unit 12 | def changeSessionJsonState(session: Session, state: ServerJson): Unit 13 | -------------------------------------------------------------------------------- /terminal21-ui-std-exports/src/test/scala/org/terminal21/ui/std/StdExportsBuilders.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.ui.std 2 | 3 | import io.circe.Json 4 | 5 | object StdExportsBuilders: 6 | def serverJson( 7 | elements: Seq[Json] = Nil 8 | ) = ServerJson(elements) 9 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/ClientEventsWsListener.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client 2 | 3 | import functions.fibers.FiberExecutor 4 | import io.circe.* 5 | import io.circe.generic.auto.* 6 | import io.circe.parser.* 7 | import io.circe.syntax.* 8 | import io.helidon.common.buffers.BufferData 9 | import io.helidon.webclient.websocket.WsClient 10 | import org.slf4j.LoggerFactory 11 | import org.terminal21.model.{ClientToServer, CommandEvent, Session, SubscribeTo} 12 | import org.terminal21.ws.ReliableClientWsListener 13 | 14 | import java.util.UUID 15 | 16 | class ClientEventsWsListener(wsClient: WsClient, session: ConnectedSession, executor: FiberExecutor): 17 | private val logger = LoggerFactory.getLogger(getClass) 18 | private val id = UUID.randomUUID().toString 19 | private val eventsListener = ReliableClientWsListener.client(id, wsClient, "/api/command-ws", executor, pingEveryMs = 500).transform(decoder, encoder) 20 | 21 | private def decoder(buf: BufferData): Either[Error, CommandEvent] = decode[CommandEvent](new String(buf.readBytes(), "UTF-8")) 22 | private def encoder(cts: ClientToServer): BufferData = 23 | val j = cts.asJson.noSpaces 24 | BufferData.create(j.getBytes("UTF-8")) 25 | 26 | def start(): Unit = 27 | executor.submit: 28 | val send = eventsListener.send 29 | val it = eventsListener.receivedIterator 30 | send(SubscribeTo(session.session)) 31 | for msg <- it do 32 | msg match 33 | case Left(e) => 34 | logger.error(s"An invalid json was received as an event. error = $e") 35 | case Right(event) => 36 | executor.submit: 37 | try session.fireEvent(event) 38 | catch case t: Throwable => logger.error("An error occurred while an event was fired", t) 39 | 40 | def close(): Unit = 41 | eventsListener.close() 42 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/Globals.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client 2 | 3 | import functions.fibers.FiberExecutor 4 | 5 | given FiberExecutor = FiberExecutor() 6 | val fiberExecutor = implicitly[FiberExecutor] 7 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/Sessions.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client 2 | 3 | import functions.fibers.FiberExecutor 4 | import functions.helidon.transport.HelidonTransport 5 | import io.helidon.webclient.api.WebClient 6 | import io.helidon.webclient.websocket.WsClient 7 | import org.terminal21.client.components.ComponentLib 8 | import org.terminal21.client.json.{StdElementEncoding, UiElementEncoding} 9 | import org.terminal21.config.Config 10 | import org.terminal21.model.SessionOptions 11 | import org.terminal21.ui.std.SessionsServiceCallerFactory 12 | 13 | import java.util.concurrent.atomic.AtomicBoolean 14 | 15 | object Sessions: 16 | case class SessionBuilder( 17 | id: String, 18 | name: String, 19 | componentLibs: Seq[ComponentLib] = Seq(StdElementEncoding), 20 | sessionOptions: SessionOptions = SessionOptions.Defaults 21 | ): 22 | def andLibraries(libraries: ComponentLib*): SessionBuilder = copy(componentLibs = componentLibs ++ libraries) 23 | def andOptions(sessionOptions: SessionOptions) = copy(sessionOptions = sessionOptions) 24 | 25 | def connect[R](f: ConnectedSession => R): R = 26 | val config = Config.Default 27 | val serverUrl = s"http://${config.host}:${config.port}" 28 | val client = WebClient.builder 29 | .baseUri(serverUrl) 30 | .build 31 | val transport = new HelidonTransport(client) 32 | val sessionsService = SessionsServiceCallerFactory.newHelidonJsonSessionsService(transport) 33 | val session = sessionsService.createSession(id, name, sessionOptions) 34 | val wsClient = WsClient.builder 35 | .baseUri(s"ws://${config.host}:${config.port}") 36 | .build 37 | 38 | val isStopped = new AtomicBoolean(false) 39 | 40 | def terminate(): Unit = 41 | isStopped.set(true) 42 | 43 | val encoding = new UiElementEncoding(Seq(StdElementEncoding) ++ componentLibs) 44 | val connectedSession = ConnectedSession(session, encoding, serverUrl, sessionsService, terminate) 45 | FiberExecutor.withFiberExecutor: executor => 46 | val listener = new ClientEventsWsListener(wsClient, connectedSession, executor) 47 | listener.start() 48 | 49 | try f(connectedSession) 50 | finally 51 | if !isStopped.get() && !connectedSession.isLeaveSessionOpen then sessionsService.terminateSession(session) 52 | listener.close() 53 | 54 | def withNewSession(id: String, name: String): SessionBuilder = SessionBuilder(id, name) 55 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/collections/EventIterator.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.collections 2 | 3 | import org.terminal21.client.ConnectedSession 4 | 5 | import scala.collection.AbstractIterator 6 | 7 | class EventIterator[A](it: Iterator[A]) extends AbstractIterator[A]: 8 | override def hasNext: Boolean = it.hasNext 9 | override def next(): A = it.next() 10 | 11 | def lastOption: Option[A] = 12 | var last = Option.empty[A] 13 | while hasNext do last = Some(next()) 14 | last 15 | 16 | def lastOptionOrNoneIfSessionClosed(using session: ConnectedSession) = 17 | val v = lastOption 18 | if session.isClosed then None else v 19 | 20 | object EventIterator: 21 | def apply[A](items: A*): EventIterator[A] = new EventIterator(Iterator(items*)) 22 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/ComponentLib.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components 2 | 3 | import io.circe.{Encoder, Json} 4 | 5 | trait ComponentLib: 6 | def toJson(using Encoder[UiElement]): PartialFunction[UiElement, Json] 7 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/EventHandler.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components 2 | 3 | trait EventHandler 4 | 5 | object OnClickEventHandler: 6 | trait CanHandleOnClickEvent: 7 | this: UiElement => 8 | if key.isEmpty then throw new IllegalStateException(s"clickables must have a stable key. Error occurred on $this") 9 | 10 | object OnChangeEventHandler: 11 | trait CanHandleOnChangeEvent: 12 | this: UiElement => 13 | if key.isEmpty then throw new IllegalStateException(s"changeable must have a stable key. Error occurred on $this") 14 | 15 | object OnChangeBooleanEventHandler: 16 | trait CanHandleOnChangeEvent: 17 | this: UiElement => 18 | if key.isEmpty then throw new IllegalStateException(s"changeable must have a stable key. Error occurred on $this") 19 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/Keys.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components 2 | 3 | import org.terminal21.client.components.UiElement.HasChildren 4 | 5 | import java.util.concurrent.atomic.AtomicInteger 6 | 7 | object Keys: 8 | def nextKey: String = "" 9 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiComponent.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components 2 | 3 | /** A UiComponent is a UI element that is composed of a seq of other ui elements 4 | */ 5 | trait UiComponent extends UiElement: 6 | // Note: impl as a lazy val to avoid UiElements getting a random key and try to fix the 7 | // keys of any sub-elements the component has. 8 | def rendered: Seq[UiElement] 9 | override def flat = Seq(this) ++ rendered.flatMap(_.flat) 10 | 11 | protected def subKey(suffix: String): String = key + "-" + suffix 12 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiElement.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components 2 | 3 | import org.terminal21.client.components.UiElement.HasChildren 4 | import org.terminal21.client.components.chakra.Box 5 | import org.terminal21.collections.{TypedMap, TypedMapKey} 6 | 7 | abstract class UiElement extends AnyElement: 8 | type This <: UiElement 9 | 10 | def key: String 11 | def withKey(key: String): This 12 | def findKey(key: String): UiElement = flat.find(_.key == key).get 13 | 14 | def dataStore: TypedMap 15 | def withDataStore(ds: TypedMap): This 16 | def store[V](key: TypedMapKey[V], value: V): This = withDataStore(dataStore + (key -> value)) 17 | def storedValue[V](key: TypedMapKey[V]): V = dataStore(key) 18 | 19 | /** @return 20 | * this element along all it's children flattened 21 | */ 22 | def flat: Seq[UiElement] = Seq(this) 23 | 24 | def substituteComponents: UiElement = 25 | this match 26 | case c: UiComponent => Box(key = c.key, text = "", children = c.rendered.map(_.substituteComponents), dataStore = c.dataStore) 27 | case ch: HasChildren => ch.withChildren(ch.children.map(_.substituteComponents)*) 28 | case _ => this 29 | 30 | def toSimpleString: String = s"${getClass.getSimpleName}($key)" 31 | 32 | object UiElement: 33 | trait HasChildren: 34 | this: UiElement => 35 | def children: Seq[UiElement] 36 | override def flat: Seq[UiElement] = Seq(this) ++ children.flatMap(_.flat) 37 | def withChildren(cn: UiElement*): This 38 | def noChildren: This = withChildren() 39 | def addChildren(cn: UiElement*): This = withChildren(children ++ cn: _*) 40 | 41 | trait HasStyle: 42 | this: UiElement => 43 | def style: Map[String, Any] 44 | def withStyle(v: Map[String, Any]): This 45 | def withStyle(vs: (String, Any)*): This = withStyle(vs.toMap) 46 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickFormControl.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.chakra 2 | 3 | import org.terminal21.client.components.UiElement.HasStyle 4 | import org.terminal21.client.components.{Keys, UiComponent, UiElement} 5 | import org.terminal21.collections.TypedMap 6 | 7 | case class QuickFormControl( 8 | key: String = Keys.nextKey, 9 | style: Map[String, Any] = Map.empty, 10 | label: Option[String] = None, 11 | inputGroup: Seq[UiElement] = Nil, 12 | helperText: Option[String] = None, 13 | dataStore: TypedMap = TypedMap.Empty 14 | ) extends UiComponent 15 | with HasStyle: 16 | type This = QuickFormControl 17 | lazy val rendered: Seq[UiElement] = 18 | val ch: Seq[UiElement] = 19 | label.map(l => FormLabel(key = subKey("label"), text = l)).toSeq ++ 20 | Seq(InputGroup(key = subKey("ig")).withChildren(inputGroup*)) ++ 21 | helperText.map(h => FormHelperText(key = subKey("helper"), text = h)) 22 | Seq(FormControl(key = subKey("fc"), style = style).withChildren(ch: _*)) 23 | 24 | def withLabel(label: String): QuickFormControl = copy(label = Some(label)) 25 | def withInputGroup(ig: UiElement*): QuickFormControl = copy(inputGroup = ig) 26 | def withHelperText(text: String): QuickFormControl = copy(helperText = Some(text)) 27 | 28 | override def withStyle(v: Map[String, Any]): QuickFormControl = copy(style = v) 29 | override def withKey(key: String): QuickFormControl = copy(key = key) 30 | override def withDataStore(ds: TypedMap) = copy(dataStore = ds) 31 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTable.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.chakra 2 | 3 | import org.terminal21.client.components.UiElement.HasStyle 4 | import org.terminal21.client.components.{Keys, UiComponent, UiElement} 5 | import org.terminal21.collections.TypedMap 6 | 7 | case class QuickTable( 8 | key: String = Keys.nextKey, 9 | variant: String = "striped", 10 | colorScheme: String = "teal", 11 | size: String = "mg", 12 | style: Map[String, Any] = Map.empty, 13 | caption: Option[String] = None, 14 | headers: Seq[Any] = Nil, 15 | rows: Seq[Seq[Any]] = Nil, 16 | dataStore: TypedMap = TypedMap.Empty 17 | ) extends UiComponent 18 | with HasStyle: 19 | type This = QuickTable 20 | def withKey(v: String) = copy(key = v) 21 | def withVariant(v: String) = copy(variant = v) 22 | def withColorScheme(v: String) = copy(colorScheme = v) 23 | def withSize(v: String) = copy(size = v) 24 | def withCaption(v: Option[String]) = copy(caption = v) 25 | def withCaption(v: String) = copy(caption = Some(v)) 26 | 27 | override lazy val rendered: Seq[UiElement] = 28 | val head = Thead( 29 | key = subKey("thead"), 30 | children = Seq( 31 | Tr( 32 | key = subKey("thead-tr"), 33 | children = headers.zipWithIndex.map: (h, i) => 34 | Th( 35 | key = subKey(s"thead-tr-th-$i"), 36 | children = Seq( 37 | h match 38 | case u: UiElement => u 39 | case c => Text(text = c.toString) 40 | ) 41 | ) 42 | ) 43 | ) 44 | ) 45 | val body = Tbody( 46 | key = subKey("tb"), 47 | children = rows.zipWithIndex.map: (row, i) => 48 | Tr( 49 | key = subKey(s"tb-tr-$i"), 50 | children = row.zipWithIndex.map: (c, i) => 51 | Td( 52 | key = subKey(s"tb-th-$i"), 53 | children = Seq( 54 | c match 55 | case u: UiElement => u 56 | case c => Text(text = c.toString) 57 | ) 58 | ) 59 | ) 60 | ) 61 | val table = Table( 62 | key = subKey("t"), 63 | variant = variant, 64 | colorScheme = Some(colorScheme), 65 | size = size, 66 | children = caption.map(text => TableCaption(text = text)).toSeq ++ Seq(head, body) 67 | ) 68 | val tableContainer = TableContainer(key = subKey("tc"), style = style, children = Seq(table)) 69 | Seq(tableContainer) 70 | 71 | def withHeaders(headers: String*): QuickTable = copy(headers = headers.map(h => Text(text = h))) 72 | def withHeadersElements(headers: UiElement*): QuickTable = copy(headers = headers) 73 | 74 | /** @param data 75 | * A mix of plain types or UiElement. If it is a UiElement, it will be rendered otherwise if it is anything else the `.toString` method will be used to 76 | * render it. 77 | * @return 78 | * QuickTable 79 | */ 80 | def withRows(data: Seq[Seq[Any]]): QuickTable = copy(rows = data) 81 | def withRowsElements(data: Seq[Seq[UiElement]]): QuickTable = copy(rows = data) 82 | 83 | def caption(text: String): QuickTable = copy(caption = Some(text)) 84 | override def withStyle(v: Map[String, Any]): QuickTable = copy(style = v) 85 | override def withDataStore(ds: TypedMap) = copy(dataStore = ds) 86 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTabs.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.chakra 2 | 3 | import org.terminal21.client.components.UiElement.HasStyle 4 | import org.terminal21.client.components.{Keys, UiComponent, UiElement} 5 | import org.terminal21.collections.TypedMap 6 | 7 | case class QuickTabs( 8 | key: String = Keys.nextKey, 9 | style: Map[String, Any] = Map.empty, 10 | tabs: Seq[String | Seq[UiElement]] = Nil, 11 | tabPanels: Seq[Seq[UiElement]] = Nil, 12 | dataStore: TypedMap = TypedMap.Empty 13 | ) extends UiComponent 14 | with HasStyle: 15 | type This = QuickTabs 16 | 17 | def withTabs(tabs: String | Seq[UiElement]*): QuickTabs = copy(tabs = tabs) 18 | def withTabPanels(tabPanels: Seq[UiElement]*): QuickTabs = copy(tabPanels = tabPanels) 19 | def withTabPanelsSimple(tabPanels: UiElement*): QuickTabs = copy(tabPanels = tabPanels.map(e => Seq(e))) 20 | 21 | override lazy val rendered = 22 | Seq( 23 | Tabs(key = subKey("tabs"), style = style).withChildren( 24 | TabList( 25 | key = subKey("tab-list"), 26 | children = tabs.zipWithIndex.map: 27 | case (name: String, idx) => Tab(key = s"$key-tab-$idx", text = name) 28 | case (elements: Seq[UiElement], idx) => Tab(key = s"$key-tab-$idx", children = elements) 29 | ), 30 | TabPanels( 31 | key = subKey("panels"), 32 | children = tabPanels.zipWithIndex.map: (elements, idx) => 33 | TabPanel(key = s"$key-panel-$idx", children = elements) 34 | ) 35 | ) 36 | ) 37 | 38 | override def withStyle(v: Map[String, Any]): QuickTabs = copy(style = v) 39 | override def withKey(key: String): QuickTabs = copy(key = key) 40 | override def withDataStore(ds: TypedMap) = copy(dataStore = ds) 41 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/frontend/FrontEndElement.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.frontend 2 | 3 | import org.terminal21.client.components.{Keys, UiElement} 4 | import org.terminal21.collections.TypedMap 5 | 6 | sealed trait FrontEndElement extends UiElement 7 | 8 | case class ThemeToggle(key: String = Keys.nextKey, dataStore: TypedMap = TypedMap.Empty) extends FrontEndElement: 9 | override type This = ThemeToggle 10 | override def withKey(key: String): ThemeToggle = copy(key = key) 11 | override def withDataStore(ds: TypedMap) = copy(dataStore = ds) 12 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/components/std/StdHttp.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components.std 2 | 3 | import org.terminal21.client.components.OnChangeEventHandler.CanHandleOnChangeEvent 4 | import org.terminal21.client.components.{Keys, OnChangeEventHandler, UiElement} 5 | import org.terminal21.collections.TypedMap 6 | 7 | /** Elements mapping to Http functionality 8 | */ 9 | sealed trait StdHttp extends UiElement: 10 | /** Each requestId will be processed only once per browser. 11 | * 12 | * I.e. lets say we have the Cookie(). If we add a cookie, we send it to the UI which in turn checks if it already set the cookie via the requestId. If it 13 | * did, it skips it, if it didn't it sets the cookie. 14 | * 15 | * @return 16 | * Should always be TransientRequest.newRequestId() 17 | */ 18 | def requestId: String 19 | 20 | /** On the browser, https://github.com/js-cookie/js-cookie is used. 21 | * 22 | * Set a cookie on the browser. 23 | */ 24 | case class Cookie( 25 | key: String = Keys.nextKey, 26 | name: String = "cookie.name", 27 | value: String = "cookie.value", 28 | path: Option[String] = None, 29 | expireDays: Option[Int] = None, 30 | requestId: String = "cookie-set-req", 31 | dataStore: TypedMap = TypedMap.Empty 32 | ) extends StdHttp: 33 | override type This = Cookie 34 | override def withKey(key: String): Cookie = copy(key = key) 35 | override def withDataStore(ds: TypedMap) = copy(dataStore = ds) 36 | 37 | /** Read a cookie value. The value, when read from the ui, it will reflect in `value` assuming the UI had the time to send the value back. Also the onChange 38 | * handler will be called once with the value. 39 | */ 40 | case class CookieReader( 41 | key: String = Keys.nextKey, 42 | name: String = "cookie.name", 43 | requestId: String = "cookie-read-req", 44 | dataStore: TypedMap = TypedMap.Empty 45 | ) extends StdHttp 46 | with CanHandleOnChangeEvent: 47 | type This = CookieReader 48 | override def withDataStore(ds: TypedMap): CookieReader = copy(dataStore = ds) 49 | override def withKey(key: String): CookieReader = copy(key = key) 50 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/main/scala/org/terminal21/client/json/UiElementEncoding.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.json 2 | 3 | import io.circe.* 4 | import io.circe.generic.auto.* 5 | import io.circe.syntax.* 6 | import org.terminal21.client.components.chakra.{Box, CEJson, ChakraElement} 7 | import org.terminal21.client.components.frontend.FrontEndElement 8 | import org.terminal21.client.components.std.{StdEJson, StdElement, StdHttp} 9 | import org.terminal21.client.components.{ComponentLib, UiComponent, UiElement} 10 | import org.terminal21.collections.TypedMap 11 | 12 | class UiElementEncoding(libs: Seq[ComponentLib]): 13 | given uiElementEncoder: Encoder[UiElement] = 14 | a => 15 | val cl = 16 | libs 17 | .find(_.toJson.isDefinedAt(a)) 18 | .getOrElse(throw new IllegalStateException(s"Unknown ui element, did you forget to register a Lib when creating a session? Component: $a")) 19 | cl.toJson(a) 20 | 21 | object StdElementEncoding extends ComponentLib: 22 | given Encoder[Map[String, Any]] = m => 23 | val vs = m.toSeq.map: (k, v) => 24 | ( 25 | k, 26 | v match 27 | case s: String => Json.fromString(s) 28 | case i: Int => Json.fromInt(i) 29 | case f: Float => Json.fromFloat(f).get 30 | case d: Double => Json.fromDouble(d).get 31 | case _ => throw new IllegalArgumentException(s"type $v not supported, either use one of the supported ones or open a bug request") 32 | ) 33 | Json.obj(vs: _*) 34 | 35 | given Encoder[TypedMap] = _ => Json.Null 36 | 37 | override def toJson(using Encoder[UiElement]): PartialFunction[UiElement, Json] = 38 | case std: StdEJson => std.asJson.mapObject(o => o.add("type", "Std".asJson)) 39 | case c: CEJson => c.asJson.mapObject(o => o.add("type", "Chakra".asJson)) 40 | case std: StdHttp => std.asJson.mapObject(o => o.add("type", "Std".asJson)) 41 | case fe: FrontEndElement => fe.asJson.mapObject(o => o.add("type", "FrontEnd".asJson)) 42 | case _: UiComponent => 43 | throw new IllegalStateException("substitute all components before serializing") 44 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionMock.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client 2 | 3 | import org.mockito.Mockito.mock 4 | import org.terminal21.client.json.{StdElementEncoding, UiElementEncoding} 5 | import org.terminal21.model.CommonModelBuilders.session 6 | import org.terminal21.ui.std.SessionsService 7 | 8 | object ConnectedSessionMock: 9 | val encoding = new UiElementEncoding(Seq(StdElementEncoding)) 10 | val encoder = ConnectedSessionMock.encoding.uiElementEncoder 11 | 12 | def newConnectedSessionAndSessionServiceMock: (SessionsService, ConnectedSession) = 13 | val sessionsService = mock(classOf[SessionsService]) 14 | (sessionsService, new ConnectedSession(session(), encoding, "test", sessionsService, () => ())) 15 | 16 | def newConnectedSessionMock: ConnectedSession = newConnectedSessionAndSessionServiceMock._2 17 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client 2 | 3 | import org.mockito.Mockito 4 | import org.mockito.Mockito.verify 5 | import org.scalatest.funsuite.AnyFunSuiteLike 6 | import org.scalatest.matchers.should.Matchers.* 7 | import org.terminal21.client.ConnectedSessionMock.encoder 8 | import org.terminal21.client.components.chakra.Editable 9 | import org.terminal21.client.components.std.{Paragraph, Span} 10 | import org.terminal21.model.OnChange 11 | import org.terminal21.ui.std.ServerJson 12 | 13 | class ConnectedSessionTest extends AnyFunSuiteLike: 14 | 15 | test("event iterator"): 16 | given connectedSession: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock 17 | val editable = Editable(key = "ed") 18 | val it = connectedSession.eventIterator 19 | val event1 = OnChange(editable.key, "v1") 20 | val event2 = OnChange(editable.key, "v2") 21 | connectedSession.fireEvent(event1) 22 | connectedSession.fireEvent(event2) 23 | connectedSession.clear() 24 | it.toList should be( 25 | List( 26 | event1, 27 | event2 28 | ) 29 | ) 30 | 31 | test("to server json"): 32 | val (sessionService, connectedSession) = ConnectedSessionMock.newConnectedSessionAndSessionServiceMock 33 | val span1 = Span("sk", text = "span1") 34 | val p1 = Paragraph("pk", text = "p1").withChildren(span1) 35 | connectedSession.render(Seq(p1.withChildren(span1))) 36 | verify(sessionService).setSessionJsonState( 37 | connectedSession.session, 38 | ServerJson( 39 | Seq(encoder(p1).deepDropNullValues) 40 | ) 41 | ) 42 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/test/scala/org/terminal21/client/collections/EventIteratorTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.collections 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.scalatest.matchers.should.Matchers.* 5 | import org.terminal21.client.ConnectedSessionMock 6 | import org.terminal21.model.{CommandEvent, SessionClosed} 7 | 8 | class EventIteratorTest extends AnyFunSuiteLike: 9 | test("works as normal iterator"): 10 | EventIterator(1, 2, 3).toList should be(List(1, 2, 3)) 11 | 12 | test("works as normal iterator when empty"): 13 | EventIterator().toList should be(Nil) 14 | 15 | test("lastOption when available"): 16 | EventIterator(1, 2, 3).lastOption should be(Some(3)) 17 | 18 | test("lastOption when not available"): 19 | EventIterator().lastOption should be(None) 20 | 21 | test("lastOptionOrNoneIfSessionClosed when session open"): 22 | val session = ConnectedSessionMock.newConnectedSessionMock 23 | EventIterator(1, 2).lastOptionOrNoneIfSessionClosed(using session) should be(Some(2)) 24 | 25 | test("lastOptionOrNoneIfSessionClosed when session closed"): 26 | val session = ConnectedSessionMock.newConnectedSessionMock 27 | session.fireEvent(CommandEvent.sessionClosed) 28 | EventIterator(1, 2).lastOptionOrNoneIfSessionClosed(using session) should be(None) 29 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/test/scala/org/terminal21/client/components/UiElementTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.components 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.scalatest.matchers.should.Matchers.* 5 | import org.terminal21.client.components.chakra.{Box, QuickTable, Text} 6 | import org.terminal21.client.components.std.Paragraph 7 | 8 | class UiElementTest extends AnyFunSuiteLike: 9 | test("flat"): 10 | val box = Box(key = "k1").withChildren(Text(key = "k2"), Text(key = "k3")) 11 | box.flat should be( 12 | Seq(box, Text(key = "k2"), Text(key = "k3")) 13 | ) 14 | test("findKey"): 15 | Box(key = "k1").withChildren(Text(key = "k2"), Text(key = "k3")).findKey("k3") should be(Text(key = "k3")) 16 | 17 | test("substituteComponents when not component"): 18 | val e = Text() 19 | e.substituteComponents should be(e) 20 | 21 | test("substituteComponents when component"): 22 | val e = QuickTable(key = "k1") 23 | e.substituteComponents should be(Box("k1", children = e.rendered)) 24 | 25 | test("substituteComponents when children are component"): 26 | val t = QuickTable(key = "k1") 27 | val e = Paragraph(key = "p1").withChildren(t) 28 | e.substituteComponents should be(Paragraph(key = "p1").withChildren(Box("k1", children = t.rendered))) 29 | -------------------------------------------------------------------------------- /terminal21-ui-std/src/test/scala/org/terminal21/client/json/UiElementEncodingTest.scala: -------------------------------------------------------------------------------- 1 | package org.terminal21.client.json 2 | 3 | import org.scalatest.funsuite.AnyFunSuiteLike 4 | import org.terminal21.client.components.chakra.Button 5 | import org.scalatest.matchers.should.Matchers.* 6 | 7 | class UiElementEncodingTest extends AnyFunSuiteLike: 8 | val encoding = new UiElementEncoding(Seq(StdElementEncoding)) 9 | test("dataStore"): 10 | val b = Button(key = "b") 11 | val j = encoding.uiElementEncoder(b).deepDropNullValues 12 | j.hcursor.downField("Button").downField("dataStore").failed should be(true) 13 | --------------------------------------------------------------------------------