├── .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 | 
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 | 
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 | 
15 |
16 | ### ResponsiveBar
17 |
18 | Code: [ResponsiveBar](../end-to-end-tests/src/main/scala/tests/nivo/ResponsiveBarChart.scala)
19 |
20 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------