From b285e2364efd4591a5e54dff0fd2e36d0e4c1726 Mon Sep 17 00:00:00 2001 From: Yehowshua Immanuel Date: Sun, 26 Jan 2025 16:12:06 -0500 Subject: [PATCH] term now supported and subcriptions now properly lifted --- Makefile | 3 +- README.md | 27 +- backend/Cargo.lock | 356 ++++++++++++++++++-- backend/Cargo.toml | 6 +- backend/EncodeDecodeTop.elm | 150 +++++++++ backend/src/landing.rs | 29 +- backend/src/main.rs | 104 +++++- backend/src/term.rs | 341 +++++++++++++++++++ backend/src/terminal_size.rs | 55 +++ backend/types.elm | 0 frontend/assets/CourierPrime-Regular.ttf | Bin 0 -> 68304 bytes frontend/elm.json | 1 + frontend/index.html | 8 + frontend/src/Body.elm | 74 ++++- frontend/src/EncodeDecode.elm | 161 +++++++++ frontend/src/Header.elm | 2 + frontend/src/Main.elm | 16 +- frontend/src/Page/About.elm | 4 +- frontend/src/Page/Contact.elm | 4 +- frontend/src/Page/Landing.elm | 79 ++--- frontend/src/Page/NotFound.elm | 4 +- frontend/src/Page/Products.elm | 4 +- frontend/src/Page/Resources.elm | 4 +- frontend/src/Page/Term.elm | 407 +++++++++++++++++++++++ frontend/src/ports.websocket.js | 14 +- 25 files changed, 1700 insertions(+), 153 deletions(-) create mode 100644 backend/EncodeDecodeTop.elm create mode 100644 backend/src/term.rs create mode 100644 backend/src/terminal_size.rs create mode 100644 backend/types.elm create mode 100644 frontend/assets/CourierPrime-Regular.ttf create mode 100644 frontend/src/EncodeDecode.elm create mode 100644 frontend/src/Page/Term.elm diff --git a/Makefile b/Makefile index ae86c3e..3ae287b 100644 --- a/Makefile +++ b/Makefile @@ -29,10 +29,11 @@ $(PUBLIC_DIR)/index.html: $(FRONTEND_DIR)/index.html $(PUBLIC_DIR) .PHONY: frontend backend frontend: $(PUBLIC_DIR)/index.html $(PUBLIC_DIR) + mkdir -p $(PUBLIC_DIR)/assets make -C $(FRONTEND_DIR) cp $(FRONTEND_DIR)/elm.min.js $(PUBLIC_DIR)/ cp $(FRONTEND_DIR)/src/ports.websocket.js $(PUBLIC_DIR)/ - mkdir -p $(PUBLIC_DIR)/assets + cp $(FRONTEND_DIR)/assets/CourierPrime-Regular.ttf $(PUBLIC_DIR)/assets/ backend: $(CARGO_BUILD) --manifest-path=$(BACKEND_DIR)/Cargo.toml diff --git a/README.md b/README.md index a0b949e..1755060 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # About -Example demonstrating how one might architect a single page application -Elm app. +A tool that lets you remotely try new exotic hardware such as Tenstorrent Wormhole or SiFive P550 Dev board from the comfort of your browser. # Building @@ -13,15 +12,19 @@ Now open `http://127.0.0.1:8080` in your browser. # TODO - - [x] Add Makefile - - [ ] `EventHandlers.onMessage` should inject successfully decoded message into - Msg type directly... - - [ ] Run `uglify` twice as per [this link](https://github.com/rtfeldman/elm-spa-example/tree/master?tab=readme-ov-file#production-build) - - [ ] Frontend should initiate with TimeRequest - - [ ] JSONify backend code for all send/receive - - [ ] Backend should only communicate over websocket + - [ ] Binding to `0.0.0.0` should not be default behavior. + - [ ] Rename `term.rs` to `terminal.rs` unless there is a good reason not to. + - [ ] Move to `actix-ws` + - [ ] Use [`spawn_local`][discord_chat] instead of `addr.do_send` + - [ ] Re-factor if and else branches of `term::msg_handler` + - [ ] Stopping the websocket should terminate terminal. The ergonomic way + to do this is to have each widget provision its own stop handler. + - [ ] Reduce warnings, especially from unused imports. + - [ ] Implement logic for `CloseTerm` UpMsg for terminal + - [ ] Implement terminal + - [ ] Start by simply adding new term page - [ ] Close websocket after 15s of no response(from frontend) to websocket pings - [ ] Implement dark mode - - [ ] Add GPLV3 License - - [ ] Add `make release` target that is nix ready... - - [ ] Add `default.nix` + + +[discord_chat]: https://discord.com/channels/771444961383153695/771447545154371646/1327308910654259291 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9c845a6..8e81f4e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -113,7 +113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn", + "syn 2.0.93", ] [[package]] @@ -248,7 +248,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn", + "syn 2.0.93", ] [[package]] @@ -259,7 +259,7 @@ checksum = "b6ac1e58cded18cb28ddc17143c4dea5345b3ad575e14f32f66e4054a56eb271" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", ] [[package]] @@ -299,6 +299,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.24.2" +source = "git+https://github.com/alacritty/alacritty?rev=53395536aa4ebebcbc0431e7336c2a6857efcff5#53395536aa4ebebcbc0431e7336c2a6857efcff5" +dependencies = [ + "base64", + "bitflags", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -329,6 +352,12 @@ dependencies = [ "libc", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -343,11 +372,15 @@ dependencies = [ "actix-files", "actix-web", "actix-web-actors", + "alacritty_terminal", "chrono", + "elm_rs", "env_logger", "log", + "once_cell", "serde", "serde_json", + "tokio", ] [[package]] @@ -362,7 +395,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -376,6 +409,9 @@ name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] [[package]] name = "block-buffer" @@ -462,7 +498,16 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -531,6 +576,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cursor-icon" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" + [[package]] name = "deranged" version = "0.3.11" @@ -550,7 +601,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.93", ] [[package]] @@ -571,7 +622,28 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", +] + +[[package]] +name = "elm_rs" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "043b06877e194324cabd54e3f0eef99b0131d80b6b6073cb22697bed97862aa3" +dependencies = [ + "elm_rs_derive", +] + +[[package]] +name = "elm_rs_derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3e657caae2215ca5380ae58302ec2ff1b6c87a55aa9137444972cd9b3540bf" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -602,6 +674,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "flate2" version = "1.0.35" @@ -633,6 +721,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-sink" version = "0.3.31" @@ -709,12 +803,27 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hermit-abi" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "http" version = "0.2.12" @@ -888,7 +997,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", ] [[package]] @@ -976,6 +1085,12 @@ version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.7.4" @@ -1058,6 +1173,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "miow" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359f76430b20a79f9e20e115b3428614e654f04fab314482fc0fda0ebd3c6044" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1108,7 +1232,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1135,12 +1259,38 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "polling" +version = "3.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1263,6 +1413,31 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" +dependencies = [ + "bitflags", + "errno", + "itoa", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix-openpty" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25c3aad9fc1424eb82c88087789a7d938e1829724f3e4043163baf0d13cfc12" +dependencies = [ + "errno", + "libc", + "rustix", +] + [[package]] name = "ryu" version = "1.0.18" @@ -1298,7 +1473,7 @@ checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", ] [[package]] @@ -1342,6 +1517,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -1382,6 +1567,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.93" @@ -1401,7 +1597,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", ] [[package]] @@ -1456,9 +1652,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.42.0" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" dependencies = [ "backtrace", "bytes", @@ -1522,6 +1718,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "url" version = "2.5.4" @@ -1545,6 +1747,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "v_htmlescape" version = "0.15.8" @@ -1557,6 +1765,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0b683b20ef64071ff03745b14391751f6beab06a54347885459b77a3f2caa5" +dependencies = [ + "bitflags", + "cursor-icon", + "log", + "serde", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1584,7 +1816,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.93", "wasm-bindgen-shared", ] @@ -1606,7 +1838,7 @@ checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1632,7 +1864,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", ] [[package]] @@ -1641,7 +1882,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1650,7 +1891,22 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -1659,28 +1915,46 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1693,24 +1967,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1749,7 +2047,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", "synstructure", ] @@ -1771,7 +2069,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", ] [[package]] @@ -1791,7 +2089,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", "synstructure", ] @@ -1814,7 +2112,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.93", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5ff9bae..dede24d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -8,8 +8,12 @@ actix-web = "4.0" actix-files = "0.6" actix-web-actors = "4.0" actix = "0.13" +alacritty_terminal = { git = "https://github.com/alacritty/alacritty", rev = "53395536aa4ebebcbc0431e7336c2a6857efcff5" } chrono = "0.4" # For timestamp generation env_logger = "0.10" # For logging +once_cell = "1.20.2" +log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -log = "0.4" +tokio = "1.43.0" +elm_rs = "0.2.2" diff --git a/backend/EncodeDecodeTop.elm b/backend/EncodeDecodeTop.elm new file mode 100644 index 0000000..83329ed --- /dev/null +++ b/backend/EncodeDecodeTop.elm @@ -0,0 +1,150 @@ + +-- generated by elm_rs + + +module EncodeDecodeTop exposing (..) + +import Dict exposing (Dict) +import Http +import Json.Decode +import Json.Encode +import Url.Builder + + +resultEncoder : (e -> Json.Encode.Value) -> (t -> Json.Encode.Value) -> (Result e t -> Json.Encode.Value) +resultEncoder errEncoder okEncoder enum = + case enum of + Ok inner -> + Json.Encode.object [ ( "Ok", okEncoder inner ) ] + Err inner -> + Json.Encode.object [ ( "Err", errEncoder inner ) ] + + +resultDecoder : Json.Decode.Decoder e -> Json.Decode.Decoder t -> Json.Decode.Decoder (Result e t) +resultDecoder errDecoder okDecoder = + Json.Decode.oneOf + [ Json.Decode.map Ok (Json.Decode.field "Ok" okDecoder) + , Json.Decode.map Err (Json.Decode.field "Err" errDecoder) + ] + + +type DownMsg + = Landing (LandingDownMsg) + | Term (TermDownMsg) + + +downMsgEncoder : DownMsg -> Json.Encode.Value +downMsgEncoder enum = + case enum of + Landing inner -> + Json.Encode.object [ ( "Landing", landingDownMsgEncoder inner ) ] + Term inner -> + Json.Encode.object [ ( "Term", termDownMsgEncoder inner ) ] + +type TermDownMsg + = FullTermUpdate (TerminalScreen) + | BackendTermStartFailure (String) + | TermNotStarted + + +termDownMsgEncoder : TermDownMsg -> Json.Encode.Value +termDownMsgEncoder enum = + case enum of + FullTermUpdate inner -> + Json.Encode.object [ ( "FullTermUpdate", terminalScreenEncoder inner ) ] + BackendTermStartFailure inner -> + Json.Encode.object [ ( "BackendTermStartFailure", Json.Encode.string inner ) ] + TermNotStarted -> + Json.Encode.string "TermNotStarted" + +type alias TerminalScreen = + { cols : Int + , rows : Int + , content : String + } + + +terminalScreenEncoder : TerminalScreen -> Json.Encode.Value +terminalScreenEncoder struct = + Json.Encode.object + [ ( "cols", (Json.Encode.int) struct.cols ) + , ( "rows", (Json.Encode.int) struct.rows ) + , ( "content", (Json.Encode.string) struct.content ) + ] + + +type LandingDownMsg + = Greeting (String) + | TimeUpdate (String) + + +landingDownMsgEncoder : LandingDownMsg -> Json.Encode.Value +landingDownMsgEncoder enum = + case enum of + Greeting inner -> + Json.Encode.object [ ( "Greeting", Json.Encode.string inner ) ] + TimeUpdate inner -> + Json.Encode.object [ ( "TimeUpdate", Json.Encode.string inner ) ] + +type UpMsg + = Landing (LandingUpMsg) + | Term (TermUpMsg) + + +upMsgDecoder : Json.Decode.Decoder UpMsg +upMsgDecoder = + Json.Decode.oneOf + [ Json.Decode.map Landing (Json.Decode.field "Landing" (landingUpMsgDecoder)) + , Json.Decode.map Term (Json.Decode.field "Term" (termUpMsgDecoder)) + ] + +type TermUpMsg + = RequestFullTermState + | CloseTerm + | RequestIncrementalTermStateUpdate + | SendCharacter (String) + + +termUpMsgDecoder : Json.Decode.Decoder TermUpMsg +termUpMsgDecoder = + Json.Decode.oneOf + [ Json.Decode.string + |> Json.Decode.andThen + (\x -> + case x of + "RequestFullTermState" -> + Json.Decode.succeed RequestFullTermState + unexpected -> + Json.Decode.fail <| "Unexpected variant " ++ unexpected + ) + , Json.Decode.string + |> Json.Decode.andThen + (\x -> + case x of + "CloseTerm" -> + Json.Decode.succeed CloseTerm + unexpected -> + Json.Decode.fail <| "Unexpected variant " ++ unexpected + ) + , Json.Decode.string + |> Json.Decode.andThen + (\x -> + case x of + "RequestIncrementalTermStateUpdate" -> + Json.Decode.succeed RequestIncrementalTermStateUpdate + unexpected -> + Json.Decode.fail <| "Unexpected variant " ++ unexpected + ) + , Json.Decode.map SendCharacter (Json.Decode.field "SendCharacter" (Json.Decode.string)) + ] + +type LandingUpMsg + = RequestGreet (String) + + +landingUpMsgDecoder : Json.Decode.Decoder LandingUpMsg +landingUpMsgDecoder = + Json.Decode.oneOf + [ Json.Decode.map RequestGreet (Json.Decode.field "RequestGreet" (Json.Decode.string)) + ] + diff --git a/backend/src/landing.rs b/backend/src/landing.rs index 273f3c1..7e0db6e 100644 --- a/backend/src/landing.rs +++ b/backend/src/landing.rs @@ -1,27 +1,36 @@ use actix_web_actors::ws::WebsocketContext; +use elm_rs::{Elm, ElmEncode, ElmDecode}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Debug)] -pub enum DownMsg { +type BackendType = crate::TryItBackend; + +#[derive(Serialize, Debug, Elm, ElmDecode)] +pub enum LandingDownMsg { Greeting(String), TimeUpdate(String), } -#[derive(Deserialize, Debug)] -pub enum UpMsg { +#[derive(Deserialize, Debug, Elm, ElmEncode)] +pub enum LandingUpMsg { RequestGreet(String), } +pub fn cleanup( + actor_instance: &mut BackendType, + ctx: &mut WebsocketContext, +) { +} + pub fn msg_handler( - msg: UpMsg, - ws: &mut crate::MyWebSocket, - ctx: &mut WebsocketContext, + msg: LandingUpMsg, + actor_instance: &mut crate::TryItBackend, + ctx: &mut WebsocketContext, ) { match msg { - UpMsg::RequestGreet(name) => { + LandingUpMsg::RequestGreet(name) => { let greeting = format!("Hello, {}!", name); - let response = crate::DownMsg::Landing(DownMsg::Greeting(greeting)); - ws.send_down_msg(response, ctx); + let response = crate::DownMsg::LandingDownMsg(LandingDownMsg::Greeting(greeting)); + actor_instance.send_down_msg(response, ctx); } } } diff --git a/backend/src/main.rs b/backend/src/main.rs index e58723c..95d3794 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,43 +1,89 @@ +use actix_web::middleware::Compress; use actix::prelude::*; use actix_files::Files; -use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer, Responder}; +use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer, middleware}; use actix_web_actors::ws; use chrono::Local; use log::info; use serde::{Deserialize, Serialize}; -use std::{fs, time::Duration}; +use std::{fs, os::unix::fs::PermissionsExt, time::Duration}; + + +use elm_rs::{Elm, ElmEncode, ElmDecode}; +use std::time::Instant; mod landing; +mod term; +mod terminal_size; -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, Message, Elm, ElmDecode)] +#[rtype(result = "()")] pub enum DownMsg { - Landing(landing::DownMsg), + LandingDownMsg(landing::LandingDownMsg), + TermDownMsg(term::TermDownMsg), } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Elm, ElmEncode)] enum UpMsg { - Landing(landing::UpMsg), + LandingUpMsg(landing::LandingUpMsg), + TermUpMsg(term::TermUpMsg), } -pub struct MyWebSocket; +pub struct TryItBackend { + addr: Option>, + graceful_stop: bool +} -impl Actor for MyWebSocket { +fn do_elm_gen() { + let mut encode_decode_top = vec![]; + elm_rs::export!("EncodeDecode", &mut encode_decode_top, { + encoders: [UpMsg, + term::TermUpMsg, + landing::LandingUpMsg], + decoders: [DownMsg, + term::TermDownMsg, term::TerminalScreen, term::Cursor, term::Line, + landing::LandingDownMsg] + }).unwrap(); + let output = String::from_utf8(encode_decode_top).unwrap(); + fs::write("../frontend/src/EncodeDecode.elm", output).unwrap(); +} + +impl Actor for TryItBackend { type Context = ws::WebsocketContext; fn started(&mut self, ctx: &mut Self::Context) { info!("WebSocket actor started"); + let addr = ctx.address(); + self.addr = Some(addr); ctx.run_interval(Duration::from_secs(1), |_, ctx| { let current_time = Local::now().format("%H:%M:%S").to_string(); - let message = DownMsg::Landing(landing::DownMsg::TimeUpdate(current_time)); + let message = DownMsg::LandingDownMsg(landing::LandingDownMsg::TimeUpdate(current_time)); if let Ok(json_message) = serde_json::to_string(&message) { ctx.text(json_message); } }); + + } + + fn stopped(&mut self, ctx: &mut Self::Context) { + if !self.graceful_stop { + landing::cleanup(self, ctx); + term::cleanup(self, ctx); + } + } + +} + +impl actix::Handler for TryItBackend { + type Result = (); + + fn handle(&mut self, msg: DownMsg, ctx: &mut ws::WebsocketContext) { + self.send_down_msg(msg, ctx); } } -impl StreamHandler> for MyWebSocket { +impl StreamHandler> for TryItBackend { fn handle( &mut self, msg: Result, @@ -47,20 +93,28 @@ impl StreamHandler> for MyWebSocket { Ok(ws::Message::Text(text)) => { if let Ok(up_msg) = serde_json::from_str::(&text) { match up_msg { - UpMsg::Landing(msg) => - landing::msg_handler(msg, self, ctx), + UpMsg::LandingUpMsg(msg) => landing::msg_handler(msg, self, ctx), + UpMsg::TermUpMsg(msg) => term::msg_handler(msg, self, ctx), } } } Ok(ws::Message::Ping(msg)) => { ctx.pong(&msg); } + Ok(ws::Message::Close(reason)) => { + log::info!("WebSocket connection closed: {:?}", reason); + + // Call `term::cleanup` to remove the associated terminal instance + term::cleanup(self, ctx); + self.graceful_stop = true; + } _ => {} } } } -impl MyWebSocket { +impl TryItBackend { + fn send_down_msg(&self, down_msg: DownMsg, ctx: &mut ws::WebsocketContext) { if let Ok(serialized_msg) = serde_json::to_string(&down_msg) { ctx.text(serialized_msg); @@ -71,14 +125,26 @@ impl MyWebSocket { } async fn websocket_handler(req: HttpRequest, stream: web::Payload) -> Result { - ws::start(MyWebSocket {}, &req, stream) + let try_it_backend = TryItBackend { addr: None, graceful_stop: false }; + ws::start(try_it_backend, &req, stream) } #[actix_web::main] async fn main() -> std::io::Result<()> { env_logger::init(); - let address = "127.0.0.1"; + // Check if an environment variable `GENERATE_ELM` is set + if std::env::var("GENERATE_ELM").is_ok() { + do_elm_gen(); + return Ok(()); + } + + let address = if std::env::var("DEBUG").is_ok() { + "0.0.0.0" + } else { + "127.0.0.1" + }; + let port = std::env::var("EXAMPLE_ELM_APP_PORT") .unwrap_or("8080".to_string()) .parse() @@ -88,9 +154,15 @@ async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() + .wrap(Compress::default()) + .wrap(middleware::DefaultHeaders::new() + .add(("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")) + .add(("Pragma", "no-cache")) + .add(("Expires", "0")) + ) .route("/ws/", web::get().to(websocket_handler)) // WebSocket endpoint .service(Files::new("/assets", "./public/assets")) - .service(Files::new("/", "./public").index_file("index.html")) // Serve frontend + .service(Files::new("/", "./public").index_file("index.html")) .default_service(web::route().to(|| async { let index_html = fs::read_to_string("./public/index.html") .unwrap_or_else(|_| "404 Not Found".to_string()); diff --git a/backend/src/term.rs b/backend/src/term.rs new file mode 100644 index 0000000..5d1dd2c --- /dev/null +++ b/backend/src/term.rs @@ -0,0 +1,341 @@ +use actix::Addr; +use actix_web_actors::ws::WebsocketContext; +use serde::{Deserialize, Serialize}; + +use crate::terminal_size; +use alacritty_terminal::event::Notify; +use alacritty_terminal::event::{Event, EventListener}; +use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg, Notifier}; +use alacritty_terminal::sync::FairMutex; +use alacritty_terminal::term::cell::Cell; +use alacritty_terminal::term::{self, Term as Terminal}; +use alacritty_terminal::{tty, Grid}; +use actix::AsyncContext; + +use elm_rs::{Elm, ElmEncode, ElmDecode}; + +// use tokio::sync::mpsc; + +use std::collections::HashMap; +use std::fmt::format; +use std::sync::LazyLock; +use std::sync::{Arc, RwLock}; +use std::thread; + +type BackendType = crate::TryItBackend; +const max_terminals: usize = 9; + +static TERM_SESSION_BY_ACTOR: LazyLock< + RwLock, Arc>>>, +> = LazyLock::new(|| RwLock::new(HashMap::new())); + +#[derive(Deserialize, Debug, PartialEq, Elm, ElmEncode)] +#[serde(crate = "serde")] +pub enum TermUpMsg { + RequestFullTermState, + CloseTerm, + RequestIncrementalTermStateUpdate, + SendCharacter(String), +} + +#[derive(Serialize, Debug, Elm, ElmDecode)] +pub enum TermDownMsg { + TermUpdate(TerminalScreen), + BackendTermStartFailure(String), + TermNotStarted, +} + +#[derive(Serialize, Debug, Elm, ElmDecode, PartialEq, Clone)] +pub struct Line { + pub line: i32, + pub content: String +} + +#[derive(Serialize, Debug, Elm, ElmDecode)] +pub struct Cursor { + pub row: i32, + pub col: usize, +} + +#[derive(Serialize, Debug, Elm, ElmDecode)] +pub struct TerminalScreen { + pub cols: usize, + pub rows: usize, + pub damaged_lines: Vec, + // (line, col) + pub cursor_loc: Cursor +} + +struct TerminalSession { + term: Arc>>, + old_content: String, + // (line, col) + old_cursor_pos: (i32, usize), + /// Use to write to the terminal from the outside world. + tx: Notifier, + cols: u16, + rows: u16, +} + +#[derive(Clone)] +pub struct EventProxy{ + channel: std::sync::mpsc::Sender, +} + +impl EventListener for EventProxy { + fn send_event(&self, event: Event) { + let _ = self.channel.send(event.clone()); + } +} + +pub fn cleanup( + actor_instance: &mut BackendType, + ctx: &mut WebsocketContext, +) { + if let Some(actor_id) = actor_instance.addr.clone() { + if term_session_already_registered_in_hashmap(&actor_id) { + let mut sessions = TERM_SESSION_BY_ACTOR.write().unwrap(); + if let Some(some_session) = sessions.get(&actor_id) { + some_session.read().unwrap().tx.0.send(Msg::Shutdown).unwrap(); + log::info!("Closed pty."); + } + + sessions.remove(&actor_id); + log::info!("Cleaned up terminal session for actor {:?}", actor_id); + } + } +} + +pub fn msg_handler( + msg: TermUpMsg, + actor_instance: &mut BackendType, + ctx: &mut WebsocketContext, +) { + // only proceed if msg not CloseTerm + if !(msg == TermUpMsg::CloseTerm) { + if let Some(actor_id) = actor_instance.addr.clone() { + if term_session_already_registered_in_hashmap(&actor_id) { + let mut term_session = TERM_SESSION_BY_ACTOR.write().unwrap(); + if let Some(term_session) = term_session.get_mut(&actor_id) { + + match msg { + TermUpMsg::RequestFullTermState => { + send_terminal_update_if_changed( + &actor_id, + term_session + ); + } + TermUpMsg::SendCharacter(c) => { + { + let term_session = term_session.write().unwrap(); + term_session.tx.notify(c.to_string().into_bytes()); + let mut term = term_session.term.lock(); + term.scroll_display(alacritty_terminal::grid::Scroll::Bottom); + } + send_terminal_update_if_changed( + &actor_id, + term_session + ); + } + _ => {} + } + } + } + else if get_session_count() >= max_terminals { + backend_term_start_error( + actor_instance, + ctx, + format!("Maximum number of {} open terminal sessions reached. Somebody must close at least one!", max_terminals).to_string(), + ); + + } + else { + let (rows, cols) = (24, 80); + + let id = get_session_count() as u64; + + let pty_config = tty::Options { + shell: Some(tty::Shell::new( + "/usr/bin/env".to_string(), + vec!["bash".to_string()], + )), + ..tty::Options::default() + }; + let config = term::Config::default(); + let terminal_size = terminal_size::TerminalSize::new(rows, cols); + let pty = match tty::new(&pty_config, terminal_size.into(), id) { + Ok(pty) => pty, + Err(msg) => { + backend_term_start_error( + actor_instance, + ctx, + format!("Failed to create pty: {}", msg), + ); + return; + } + }; + let (event_sender, event_receiver) = std::sync::mpsc::channel(); + let event_proxy = EventProxy { + channel: event_sender, + }; + let term = Terminal::new::( + config, + &terminal_size.into(), + event_proxy.clone(), + ); + let term = Arc::new(FairMutex::new(term)); + let pty_event_loop = + match EventLoop::new(term.clone(), event_proxy, pty, false, false) { + Ok(loop_instance) => loop_instance, + Err(err) => { + backend_term_start_error( + actor_instance, + ctx, + format!("EventLoop::new failed: {:?}", err), + ); + return; + } + }; + let notifier = Notifier(pty_event_loop.channel()); + let term_clone = term.clone(); + pty_event_loop.spawn(); + + let actor_id_thread_clone = actor_id.clone(); + let term_thread_clone = term.clone(); + let addr_thread_clone = ctx.address().clone(); + ctx.spawn(actix::fut::wrap_future(async move { + // Start a new thread + thread::spawn(move || { + loop { + if let Ok(event) = event_receiver.recv() { + let mut sessions = TERM_SESSION_BY_ACTOR.write().unwrap(); + match event { + Event::Exit => { + sessions.remove(&actor_id_thread_clone); + break; + } + _ => { + if let Some(term_session) = sessions.get_mut(&actor_id_thread_clone) { + send_terminal_update_if_changed( + &addr_thread_clone, + term_session + ); + } + + } + + } + }; + } + }); + })); + + let terminal_session = TerminalSession { + term: term.clone(), + old_content: String::new(), + old_cursor_pos: (0, 0), + tx: notifier, + cols: cols, + rows: rows, + }; + TERM_SESSION_BY_ACTOR + .write() + .unwrap() + .insert(actor_id, Arc::new(RwLock::new(terminal_session))); + } + } + } +} + +fn backend_term_start_error( + actor_instance: &mut BackendType, + ctx: &mut WebsocketContext, + msg: String, +) { + log::error!("{}", msg); + let down_msg = crate::DownMsg::TermDownMsg(TermDownMsg::BackendTermStartFailure(msg)); + actor_instance.send_down_msg(down_msg, ctx); +} +fn get_session_count() -> usize { + let sessions_map = TERM_SESSION_BY_ACTOR.read().unwrap(); + sessions_map.len() +} +fn term_session_already_registered_in_hashmap(actor_id: &Addr) -> bool { + let sessions_map = TERM_SESSION_BY_ACTOR.read().unwrap(); + sessions_map.contains_key(&actor_id) +} + +fn full_term_state(terminal_session: &TerminalSession) -> (String, alacritty_terminal::index::Point) { + let (rows, cols) = (terminal_session.rows, terminal_session.cols); + let term = terminal_session.term.lock(); + let grid = term.grid().clone(); + let cursor = grid.cursor.point.clone(); + + return (term_grid_to_string(&grid, rows, cols), cursor); +} + +fn term_grid_to_string(grid: &Grid, rows: u16, cols: u16) -> String { + let mut term_content = String::with_capacity((rows * cols) as usize); + + // Populate string from grid + for indexed in grid.display_iter() { + let x = indexed.point.column.0 as usize; + let y = indexed.point.line.0 as usize; + if y < rows as usize && x < cols as usize { + term_content.push(indexed.c); + } + } + return term_content; +} + +fn split_term_content_by_line(term_content: &str, cols: u16) -> Vec { + let mut term_content_by_line: Vec = vec![]; + let term_content_chars: Vec = term_content.chars().collect(); + for (index, chunk) in term_content_chars.chunks(cols as usize).enumerate() { + let content = chunk.iter().collect::(); + let line = Line { line: index as i32, content: content }; + term_content_by_line.push(line); + + } + return term_content_by_line; +} + + +fn send_terminal_update_if_changed( + actor_instance: &Addr, + term_session: &mut Arc> +) { + let mut term_session = term_session.write().unwrap(); + + let (new_content, new_cursor) = full_term_state(&*term_session); + let new_term_content_by_line = + split_term_content_by_line(&new_content, term_session.cols); + + let term_content_len_has_changed = term_session.old_content.len() != new_content.len(); + let damaged_lines = + if term_content_len_has_changed { + new_term_content_by_line + } else { + let old_term_content_by_line = + split_term_content_by_line( + &term_session.old_content, + term_session.cols); + new_term_content_by_line + .iter() + .zip(old_term_content_by_line.iter()) + .filter(|(a, b)| a != b) + .map(|(a, b)| (a.clone())) // Extract values from references + .collect() + }; + let down_msg = crate::DownMsg::TermDownMsg(TermDownMsg::TermUpdate(TerminalScreen { + rows: term_session.rows as usize, + cols: term_session.cols as usize, + damaged_lines: damaged_lines, + cursor_loc: Cursor { + row: new_cursor.line.0, + col: new_cursor.column.0, + } + })); + actor_instance.do_send(down_msg); + term_session.old_content = new_content; +} diff --git a/backend/src/terminal_size.rs b/backend/src/terminal_size.rs new file mode 100644 index 0000000..aa1db97 --- /dev/null +++ b/backend/src/terminal_size.rs @@ -0,0 +1,55 @@ +use alacritty_terminal::event::WindowSize; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line}; + +#[derive(Clone, Copy, Debug)] +pub struct TerminalSize { + pub cell_width: u16, + pub cell_height: u16, + pub num_cols: u16, + pub num_lines: u16, +} + +impl TerminalSize { + pub fn new(rows: u16, cols: u16) -> Self { + Self { + cell_width: 1, + cell_height: 1, + num_cols: cols, + num_lines: rows, + } + } +} + +impl Dimensions for TerminalSize { + fn total_lines(&self) -> usize { + self.screen_lines() + } + + fn screen_lines(&self) -> usize { + self.num_lines as usize + } + + fn columns(&self) -> usize { + self.num_cols as usize + } + + fn last_column(&self) -> Column { + Column(self.num_cols as usize - 1) + } + + fn bottommost_line(&self) -> Line { + Line(self.num_lines as i32 - 1) + } +} + +impl From for WindowSize { + fn from(size: TerminalSize) -> Self { + Self { + num_lines: size.num_lines, + num_cols: size.num_cols, + cell_width: size.cell_width, + cell_height: size.cell_height, + } + } +} diff --git a/backend/types.elm b/backend/types.elm new file mode 100644 index 0000000..e69de29 diff --git a/frontend/assets/CourierPrime-Regular.ttf b/frontend/assets/CourierPrime-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4af1ff54c5e2f54238a0e2013cb9a37e3130e4ca GIT binary patch literal 68304 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 Z=czZ#XSL_ndX=wQX5;$?^ZRsr{|5yaS;qhX literal 0 HcmV?d00001 diff --git a/frontend/elm.json b/frontend/elm.json index 9104ca4..7b9c147 100644 --- a/frontend/elm.json +++ b/frontend/elm.json @@ -11,6 +11,7 @@ "elm/html": "1.0.0", "elm/json": "1.1.3", "elm/url": "1.0.0", + "elm-community/list-extra": "8.7.0", "kageurufu/elm-websockets": "1.0.1", "mdgriffith/elm-ui": "1.1.8" }, diff --git a/frontend/index.html b/frontend/index.html index 2149339..1824f2c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,14 @@ Elm UI Website +
diff --git a/frontend/src/Body.elm b/frontend/src/Body.elm index 3cdc0f3..de8c1d1 100644 --- a/frontend/src/Body.elm +++ b/frontend/src/Body.elm @@ -2,11 +2,12 @@ module Body exposing (Msg(..), Model(..), init, update, view, handleRoute, subsc import Element -import Page.About -import Page.Contact import Page.Landing import Page.Products import Page.Resources +import Page.About +import Page.Contact +import Page.Term import Page.NotFound type Msg @@ -15,6 +16,7 @@ type Msg | MsgResources Page.Resources.Msg | MsgAbout Page.About.Msg | MsgContact Page.Contact.Msg + | MsgTerm Page.Term.Msg | MsgNotFound Page.NotFound.Msg type Model @@ -23,10 +25,15 @@ type Model | ModelResources Page.Resources.Model | ModelAbout Page.About.Model | ModelContact Page.Contact.Model + | ModelTerm Page.Term.Model | ModelNotFound Page.NotFound.Model -init : () -> Model -init flags = ModelLanding (Page.Landing.init flags) +init : () -> (Model, Cmd Msg) +init flags = + let + (landingModel, landingCmd) = Page.Landing.init () + in + (ModelLanding landingModel, Cmd.map MsgLanding landingCmd) subscriptions : Model -> Sub Msg subscriptions model = @@ -36,22 +43,53 @@ subscriptions model = ModelResources m -> Page.Resources.subscriptions m |> Sub.map MsgResources ModelAbout m -> Page.About.subscriptions m |> Sub.map MsgAbout ModelContact m -> Page.Contact.subscriptions m |> Sub.map MsgContact + ModelTerm m -> Page.Term.subscriptions m |> Sub.map MsgTerm ModelNotFound m -> Page.NotFound.subscriptions m |> Sub.map MsgNotFound -handleRoute : String -> Model +handleRoute : String -> (Model, Cmd Msg) handleRoute path = - let - page = - case path of - "/" -> ModelLanding <| Page.Landing.init () - "/Products" -> ModelProducts <| Page.Products.init () - "/Resources" -> ModelResources <| Page.Resources.init () - "/About" -> ModelAbout <| Page.About.init () - "/Contact" -> ModelContact <| Page.Contact.init () - _ -> ModelNotFound <| Page.NotFound.init () - in - page + case path of + "/" -> + let + (landingModel, landingCmd) = Page.Landing.init () + in + (ModelLanding landingModel, Cmd.map MsgLanding landingCmd) + "/Products" -> + let + (productsModel, productsCmd) = Page.Products.init () + in + (ModelProducts productsModel, Cmd.map MsgProducts productsCmd) + + "/Resources" -> + let + (resourcesModel, resourcesCmd) = Page.Resources.init () + in + (ModelResources resourcesModel, Cmd.map MsgResources resourcesCmd) + + "/About" -> + let + (aboutModel, aboutCmd) = Page.About.init () + in + (ModelAbout aboutModel, Cmd.map MsgAbout aboutCmd) + + "/Contact" -> + let + (contactModel, contactCmd) = Page.Contact.init () + in + (ModelContact contactModel, Cmd.map MsgContact contactCmd) + + "/Term" -> + let + (termModel, termCmd) = Page.Term.init () + in + (ModelTerm termModel, Cmd.map MsgTerm termCmd) + + _ -> + let + (notFoundModel, notFoundCmd) = Page.NotFound.init () + in + (ModelNotFound notFoundModel, Cmd.map MsgNotFound notFoundCmd) update : Msg -> Model -> (Model, Cmd Msg) update bodyMsg bodyModel = let @@ -77,6 +115,9 @@ update bodyMsg bodyModel = (MsgContact msg, ModelContact m) -> updatePage msg m Page.Contact.update MsgContact ModelContact + (MsgTerm msg, ModelTerm m) -> + updatePage msg m Page.Term.update MsgTerm ModelTerm + _ -> (bodyModel, Cmd.none) @@ -89,6 +130,7 @@ view model = ModelResources m -> Page.Resources.view m |> Element.map MsgResources ModelAbout m -> Page.About.view m |> Element.map MsgAbout ModelContact m -> Page.Contact.view m |> Element.map MsgContact + ModelTerm m -> Page.Term.view m |> Element.map MsgTerm ModelNotFound m -> Page.NotFound.view m |> Element.map MsgNotFound in Element.el [Element.centerY ,Element.centerX] content diff --git a/frontend/src/EncodeDecode.elm b/frontend/src/EncodeDecode.elm new file mode 100644 index 0000000..f398955 --- /dev/null +++ b/frontend/src/EncodeDecode.elm @@ -0,0 +1,161 @@ + +-- generated by elm_rs + + +module EncodeDecode exposing (..) + +import Dict exposing (Dict) +-- import Http +import Json.Decode +import Json.Encode +import Url.Builder + + +resultEncoder : (e -> Json.Encode.Value) -> (t -> Json.Encode.Value) -> (Result e t -> Json.Encode.Value) +resultEncoder errEncoder okEncoder enum = + case enum of + Ok inner -> + Json.Encode.object [ ( "Ok", okEncoder inner ) ] + Err inner -> + Json.Encode.object [ ( "Err", errEncoder inner ) ] + + +resultDecoder : Json.Decode.Decoder e -> Json.Decode.Decoder t -> Json.Decode.Decoder (Result e t) +resultDecoder errDecoder okDecoder = + Json.Decode.oneOf + [ Json.Decode.map Ok (Json.Decode.field "Ok" okDecoder) + , Json.Decode.map Err (Json.Decode.field "Err" errDecoder) + ] + + +type UpMsg + = LandingUpMsg (LandingUpMsg) + | TermUpMsg (TermUpMsg) + + +upMsgEncoder : UpMsg -> Json.Encode.Value +upMsgEncoder enum = + case enum of + LandingUpMsg inner -> + Json.Encode.object [ ( "LandingUpMsg", landingUpMsgEncoder inner ) ] + TermUpMsg inner -> + Json.Encode.object [ ( "TermUpMsg", termUpMsgEncoder inner ) ] + +type TermUpMsg + = RequestFullTermState + | CloseTerm + | RequestIncrementalTermStateUpdate + | SendCharacter (String) + + +termUpMsgEncoder : TermUpMsg -> Json.Encode.Value +termUpMsgEncoder enum = + case enum of + RequestFullTermState -> + Json.Encode.string "RequestFullTermState" + CloseTerm -> + Json.Encode.string "CloseTerm" + RequestIncrementalTermStateUpdate -> + Json.Encode.string "RequestIncrementalTermStateUpdate" + SendCharacter inner -> + Json.Encode.object [ ( "SendCharacter", Json.Encode.string inner ) ] + +type LandingUpMsg + = RequestGreet (String) + + +landingUpMsgEncoder : LandingUpMsg -> Json.Encode.Value +landingUpMsgEncoder enum = + case enum of + RequestGreet inner -> + Json.Encode.object [ ( "RequestGreet", Json.Encode.string inner ) ] + +type DownMsg + = LandingDownMsg (LandingDownMsg) + | TermDownMsg (TermDownMsg) + + +downMsgDecoder : Json.Decode.Decoder DownMsg +downMsgDecoder = + Json.Decode.oneOf + [ Json.Decode.map LandingDownMsg (Json.Decode.field "LandingDownMsg" (landingDownMsgDecoder)) + , Json.Decode.map TermDownMsg (Json.Decode.field "TermDownMsg" (termDownMsgDecoder)) + ] + +type TermDownMsg + = TermUpdate (TerminalScreen) + | BackendTermStartFailure (String) + | TermNotStarted + + +termDownMsgDecoder : Json.Decode.Decoder TermDownMsg +termDownMsgDecoder = + Json.Decode.oneOf + [ Json.Decode.map TermUpdate (Json.Decode.field "TermUpdate" (terminalScreenDecoder)) + , Json.Decode.map BackendTermStartFailure (Json.Decode.field "BackendTermStartFailure" (Json.Decode.string)) + , Json.Decode.string + |> Json.Decode.andThen + (\x -> + case x of + "TermNotStarted" -> + Json.Decode.succeed TermNotStarted + unexpected -> + Json.Decode.fail <| "Unexpected variant " ++ unexpected + ) + ] + +type alias TerminalScreen = + { cols : Int + , rows : Int + , damagedLines : List (Line) + , cursorLoc : Cursor + } + + +terminalScreenDecoder : Json.Decode.Decoder TerminalScreen +terminalScreenDecoder = + Json.Decode.succeed TerminalScreen + |> Json.Decode.andThen (\x -> Json.Decode.map x (Json.Decode.field "cols" (Json.Decode.int))) + |> Json.Decode.andThen (\x -> Json.Decode.map x (Json.Decode.field "rows" (Json.Decode.int))) + |> Json.Decode.andThen (\x -> Json.Decode.map x (Json.Decode.field "damaged_lines" (Json.Decode.list (lineDecoder)))) + |> Json.Decode.andThen (\x -> Json.Decode.map x (Json.Decode.field "cursor_loc" (cursorDecoder))) + + +type alias Cursor = + { row : Int + , col : Int + } + + +cursorDecoder : Json.Decode.Decoder Cursor +cursorDecoder = + Json.Decode.succeed Cursor + |> Json.Decode.andThen (\x -> Json.Decode.map x (Json.Decode.field "row" (Json.Decode.int))) + |> Json.Decode.andThen (\x -> Json.Decode.map x (Json.Decode.field "col" (Json.Decode.int))) + + +type alias Line = + { line : Int + , content : String + } + + +lineDecoder : Json.Decode.Decoder Line +lineDecoder = + Json.Decode.succeed Line + |> Json.Decode.andThen (\x -> Json.Decode.map x (Json.Decode.field "line" (Json.Decode.int))) + |> Json.Decode.andThen (\x -> Json.Decode.map x (Json.Decode.field "content" (Json.Decode.string))) + + +type LandingDownMsg + = Greeting (String) + | TimeUpdate (String) + + +landingDownMsgDecoder : Json.Decode.Decoder LandingDownMsg +landingDownMsgDecoder = + Json.Decode.oneOf + [ Json.Decode.map Greeting (Json.Decode.field "Greeting" (Json.Decode.string)) + , Json.Decode.map TimeUpdate (Json.Decode.field "TimeUpdate" (Json.Decode.string)) + ] + diff --git a/frontend/src/Header.elm b/frontend/src/Header.elm index 82e2e72..9a40426 100644 --- a/frontend/src/Header.elm +++ b/frontend/src/Header.elm @@ -47,6 +47,7 @@ view model = resources = headerButton "Resources" about = headerButton "About" contact = headerButton "Contact" + term = headerButton "Term" in Element.row [Element.width Element.fill, Element.spacing 15, @@ -57,4 +58,5 @@ view model = , resources , about , contact + , term ] diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 38e8b90..0ef5eae 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -10,6 +10,7 @@ import Browser.Navigation import Browser exposing (UrlRequest) import Html exposing (header) import Ports +import EncodeDecode -- internal imports import Body @@ -30,16 +31,20 @@ type alias Model = init : () -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) init flags url key = let - page = Body.init flags + (page, cmd) = Body.handleRoute url.path header = Header.init flags model = { key = key , url = url - , page = Body.handleRoute url.path + , page = page , header = header } in - (model, Ports.socketOpen) + ( model + , Cmd.batch + [ cmd |> Cmd.map Body + , Ports.socketOpen + ] ) update : Msg -> Model -> (Model, Cmd Msg) @@ -52,9 +57,10 @@ update msg model = ( {model | page = newPage}, cmd |> Cmd.map Body ) UrlChanged url -> let - newModel = {model | page = Body.handleRoute url.path, url = url} + (page, cmd) = Body.handleRoute url.path + newModel = {model | page = page, url = url} in - ( newModel, Cmd.none ) + ( newModel, cmd |> Cmd.map Body ) UrlRequest (Browser.Internal url) -> ( model, Browser.Navigation.pushUrl model.key (Url.toString url) ) _ -> (model, Cmd.none) diff --git a/frontend/src/Page/About.elm b/frontend/src/Page/About.elm index 8df3cbc..f2d87f6 100644 --- a/frontend/src/Page/About.elm +++ b/frontend/src/Page/About.elm @@ -11,8 +11,8 @@ import Element exposing (Element) type alias Model = {} type alias Msg = {} -init : () -> Model -init flags = {} +init : () -> (Model, Cmd Msg) +init flags = ({}, Cmd.none) subscriptions : Model -> Sub Msg subscriptions _ = Sub.none diff --git a/frontend/src/Page/Contact.elm b/frontend/src/Page/Contact.elm index 32fc41d..2caffbe 100644 --- a/frontend/src/Page/Contact.elm +++ b/frontend/src/Page/Contact.elm @@ -11,8 +11,8 @@ import Element exposing (Element) type alias Model = {} type alias Msg = {} -init : () -> Model -init flags = {} +init : () -> (Model, Cmd Msg) +init flags = ({}, Cmd.none) subscriptions : Model -> Sub Msg subscriptions _ = Sub.none diff --git a/frontend/src/Page/Landing.elm b/frontend/src/Page/Landing.elm index 8c039c0..e7cda21 100644 --- a/frontend/src/Page/Landing.elm +++ b/frontend/src/Page/Landing.elm @@ -15,57 +15,29 @@ import Html.Attributes exposing (placeholder) import Element.Input import Element.Background +import EncodeDecode + type alias Model = { time : String, greetWidgetText : String, greeting : String } -type DownMsgLanding - = Greeting String - | TimeUpdate String - -decodeDownMsgLanding : Decode.Decoder DownMsgLanding -decodeDownMsgLanding = - let - t = Decode.map Greeting (Decode.field "Greeting" Decode.string) - in - Decode.oneOf - [ Decode.map Greeting (Decode.field "Greeting" Decode.string) - , Decode.map TimeUpdate (Decode.field "TimeUpdate" Decode.string) - ] - -decodeDownMsg : Decode.Decoder Msg -decodeDownMsg = - let - decoder = Decode.field "Landing" decodeDownMsgLanding - in - Decode.map DownMsg decoder - -type UpMsgLanding = RequestGreet String - -encodeUpMsgLanding : UpMsgLanding -> Value -encodeUpMsgLanding msg = - case msg of - RequestGreet name -> - Encode.object - [ ( "Landing", Encode.object - [ ( "RequestGreet", Encode.string name ) ] - ) - ] type Msg - = DownMsg DownMsgLanding - | UpMsg UpMsgLanding + = DownMsg EncodeDecode.DownMsg + | UpMsg EncodeDecode.LandingUpMsg | DecodeError Decode.Error | GreetWidgetText String | NoOp -init : () -> Model -init flags = { - time = "time not yet set", - greetWidgetText = "", - greeting = "" - } +init : () -> (Model, Cmd Msg) +init flags = ( + { time = "time not yet set" + , greetWidgetText = "" + , greeting = "" + }, + Cmd.none + ) subscriptions : Model -> Sub Msg subscriptions model = @@ -75,8 +47,8 @@ subscriptions model = (\_ -> NoOp) (\_ -> NoOp) (\message -> - case Decode.decodeString decodeDownMsg message.data of - Ok msg -> msg + case Decode.decodeString EncodeDecode.downMsgDecoder message.data of + Ok msg -> DownMsg msg Err err -> DecodeError err ) (\_ -> NoOp) @@ -85,12 +57,15 @@ subscriptions model = update : Msg -> Model -> (Model, Cmd Msg) update msg model = case msg of - DownMsg (TimeUpdate time) -> ( {model | time = time}, Cmd.none ) - DownMsg (Greeting greeting) -> ( {model | greeting = greeting}, Cmd.none) - UpMsg upMsgLanding -> ( - model, - upMsgLanding |> encodeUpMsgLanding |> Ports.socketSend - ) + DownMsg (EncodeDecode.LandingDownMsg (EncodeDecode.TimeUpdate time)) -> + ( {model | time = time}, Cmd.none ) + DownMsg (EncodeDecode.LandingDownMsg (EncodeDecode.Greeting greeting)) -> + ( {model | greeting = greeting}, Cmd.none) + UpMsg upMsg -> + let cmd = Ports.socketSend <| EncodeDecode.upMsgEncoder <| EncodeDecode.LandingUpMsg upMsg + in + (model, cmd) + GreetWidgetText text -> ( {model | greetWidgetText = text}, Cmd.none ) _ -> (model, Cmd.none) @@ -106,16 +81,16 @@ greetWidget model = , Element.width (Element.fill |> Element.maximum 100) , Element.height (Element.fill |> Element.maximum 50) ] - { onPress = Just <| UpMsg <| RequestGreet model.greetWidgetText + { onPress = Just <| UpMsg <| EncodeDecode.RequestGreet model.greetWidgetText , label = Element.text "Greet" } placeholder = case model.greetWidgetText of "" -> Just <| Element.Input.placeholder [] <| Element.text "Type Your Name" _ -> Just <| Element.Input.placeholder [] <| Element.text "" textInput = - Element.Input.text + Element.Input.text [ Element.width (Element.fill |> Element.maximum 400) - , Element.height Element.fill + , Element.height Element.fill ] { onChange = GreetWidgetText , text = model.greetWidgetText @@ -125,7 +100,7 @@ greetWidget model = nameInput = Element.row [] [ textInput , myButton] in Element.column [] - [ nameInput + [ nameInput , Element.text model.greeting ] diff --git a/frontend/src/Page/NotFound.elm b/frontend/src/Page/NotFound.elm index c68f107..9f4b61f 100644 --- a/frontend/src/Page/NotFound.elm +++ b/frontend/src/Page/NotFound.elm @@ -11,8 +11,8 @@ import Element exposing (Element) type alias Model = {} type alias Msg = {} -init : () -> Model -init flags = {} +init : () -> (Model, Cmd Msg) +init flags = ({}, Cmd.none) subscriptions : Model -> Sub Msg subscriptions _ = Sub.none diff --git a/frontend/src/Page/Products.elm b/frontend/src/Page/Products.elm index d7ebdc1..715e882 100644 --- a/frontend/src/Page/Products.elm +++ b/frontend/src/Page/Products.elm @@ -11,8 +11,8 @@ import Element exposing (Element) type alias Model = {} type alias Msg = {} -init : () -> Model -init flags = {} +init : () -> (Model, Cmd Msg) +init flags = ({}, Cmd.none) subscriptions : Model -> Sub Msg subscriptions _ = Sub.none diff --git a/frontend/src/Page/Resources.elm b/frontend/src/Page/Resources.elm index 93e8a48..d55bdb9 100644 --- a/frontend/src/Page/Resources.elm +++ b/frontend/src/Page/Resources.elm @@ -11,8 +11,8 @@ import Element exposing (Element) type alias Model = {} type alias Msg = {} -init : () -> Model -init flags = {} +init : () -> (Model, Cmd Msg) +init flags = ({}, Cmd.none) subscriptions : Model -> Sub Msg subscriptions _ = Sub.none diff --git a/frontend/src/Page/Term.elm b/frontend/src/Page/Term.elm new file mode 100644 index 0000000..50acce9 --- /dev/null +++ b/frontend/src/Page/Term.elm @@ -0,0 +1,407 @@ +module Page.Term exposing + ( Model + , Msg + , view + , init + , update + , subscriptions + ) +import Element exposing (Element) +import Websockets +import Ports +import Json.Decode as Decode +import Json.Encode as Encode exposing (Value) +import Html.Attributes exposing (placeholder) +import Browser.Events exposing (onKeyDown, onKeyUp) + +import EncodeDecode + +import List.Extra +import Element.Font +import Element.Input +import Element.Background +import Debug +import Element.Border +import String exposing (pad) + +type alias RawKey = String + +type Key + = Character RawKey Char + | Control RawKey String + | Invalid + +prependKey : Key -> List Key -> List Key +prependKey key keys = + case key of + Invalid -> + keys + _ -> + if List.any (sameRawKey key) keys then + keys + else + key :: keys + +removeKey : Key -> List Key -> List Key +removeKey key keys = + List.filter (not << sameRawKey key) keys + +sameRawKey : Key -> Key -> Bool +sameRawKey key1 key2 = + case (key1, key2) of + (Character raw1 _, Character raw2 _) -> + raw1 == raw2 + + (Control raw1 _, Control raw2 _) -> + raw1 == raw2 + + _ -> + False + + +keyDecoder : Decode.Decoder Key +keyDecoder = + Decode.map2 + toKey + (Decode.field "key" Decode.string) + (Decode.field "code" Decode.string) + +toKey : String -> String -> Key +toKey key code = + if key == "Tab" then + Invalid + else + case String.uncons key of + Just (char, "") -> + Character code char + + _ -> + Control code key + +isLowercaseAlpha : Char -> Bool +isLowercaseAlpha c = + charIsBetweenInclusive c 'a' 'z' + + +processForCtrlChar : Char -> Char +processForCtrlChar c = + if isLowercaseAlpha c then + Char.fromCode (Char.toCode c - Char.toCode 'a' + 1) + else if charIsBetweenInclusive c '[' '_' then + Char.fromCode (Char.toCode c - Char.toCode '[') + else + c + + +charIsBetweenInclusive : Char -> Char -> Char -> Bool +charIsBetweenInclusive c loChar hiChar = + let + cCode = Char.toCode c + loCode = Char.toCode loChar + hiCode = Char.toCode hiChar + in + cCode >= loCode && cCode <= hiCode + +controlInKeyList : List Key -> Bool +controlInKeyList list = + case list of + [] -> False + x :: xs -> + case x of + (Control _ "Control") -> True + _ -> controlInKeyList xs + + +keyToString : List Key -> Maybe String +keyToString keyList = + case keyList of + (Character _ c) :: _ -> + if (controlInKeyList keyList) then + Just <| String.fromChar <| processForCtrlChar c + else + Just <| String.fromChar c + (Control _ "Enter") :: _ -> Just "\r" + (Control _ "Escape") :: _ -> Just "\u{001B}" + (Control _ "Backspace") :: _ -> Just "\u{0008}" + (Control _ "ArrowUp") :: _ -> Just "\u{001B}[A" + (Control _ "ArrowDown") :: _ -> Just "\u{001B}[B" + (Control _ "ArrowLeft") :: _ -> Just "\u{001B}[D" + (Control _ "ArrowRight") :: _ -> Just "\u{001B}[C" + + _ -> Nothing + + +init : () -> (Model, Cmd Msg) +init flags = + let + -- Initial model with default terminal screen + initialLine = + { line = 0 + , content = "Spinning up container..." + } + initialCursor = + { row = 0 + , col = 0 + } + initialModel = + { termState = + { cols = String.length initialLine.content + , rows = 1 + , lines = [initialLine] + , cursorLoc = initialCursor + } + , heldKeys = [] + , err = Nothing + } + + upMsg = EncodeDecode.TermUpMsg + <| EncodeDecode.RequestFullTermState + cmd = + Ports.socketSend <| EncodeDecode.upMsgEncoder upMsg + in + (initialModel, cmd) + +subscriptions : Model -> Sub Msg +subscriptions model = + let + ws = Ports.socketOnEvent + (Websockets.EventHandlers + (SocketOpened) + (\_ -> NoOp) + (\_ -> NoOp) + (\message -> + case Decode.decodeString EncodeDecode.downMsgDecoder message.data of + Ok msg -> DownMsg msg + Err err -> DecodeError err + ) + (\_ -> NoOp) + ) + keyDownSubscription = onKeyDown (Decode.map KeyDown keyDecoder) + keyUpSubscription = onKeyUp (Decode.map KeyUp keyDecoder) + in + Sub.batch [ ws + , keyDownSubscription + , keyUpSubscription + ] + +makeGridWithNewlines : String -> Int -> String +makeGridWithNewlines string cols = + let + rows = + string + |> String.toList + |> List.indexedMap Tuple.pair + |> groupByRow cols + |> List.map (List.map Tuple.second) -- Extract characters + |> List.map String.fromList -- Convert rows to strings + in + String.join "\n" rows + + +groupByRow : Int -> List (Int, Char) -> List (List (Int, Char)) +groupByRow cols list = + case list of + [] -> + [] + + _ -> + let + (row, rest) = + List.Extra.splitAt cols list + in + row :: groupByRow cols rest + +type alias TermState = + { cols : Int + , rows : Int + , lines : List (EncodeDecode.Line) + , cursorLoc : EncodeDecode.Cursor + } + +type alias Model = { + termState : TermState, + heldKeys : List Key, + err : Maybe String + } + +type Msg + = DownMsg EncodeDecode.DownMsg + | DecodeError Decode.Error + | GreetWidgetText String + | SocketOpened Websockets.WebsocketOpened + | KeyDown Key + | KeyUp Key + | NoOp + +updateTermState : EncodeDecode.TerminalScreen -> TermState -> TermState +updateTermState termScreenUpdate currTermState = + let + fullUpdate = termScreenUpdate.rows == (List.length termScreenUpdate.damagedLines) + newLines = case fullUpdate of + True -> termScreenUpdate.damagedLines + False -> updateLines currTermState.lines termScreenUpdate.damagedLines + in + { cols = termScreenUpdate.cols + , rows = termScreenUpdate.rows + , lines = newLines + , cursorLoc = termScreenUpdate.cursorLoc + } + +updateLines : List EncodeDecode.Line -> List EncodeDecode.Line -> List EncodeDecode.Line +updateLines currLines damagedLines = + List.map + (\oldLine -> + case List.filter (\damaged -> damaged.line == oldLine.line) damagedLines of + [] -> + oldLine -- No replacement, keep the old line + + damaged :: _ -> + damaged -- Replace with the first matching damaged line + ) + currLines + +update : Msg -> Model -> (Model, Cmd Msg) +update msg model = + case msg of + DownMsg (EncodeDecode.TermDownMsg (EncodeDecode.TermUpdate terminalScreen)) -> + ({ model | termState = updateTermState terminalScreen model.termState }, Cmd.none) + DownMsg (EncodeDecode.TermDownMsg (EncodeDecode.BackendTermStartFailure err_msg)) -> + ({ model | err = Just err_msg }, Cmd.none) + SocketOpened _ -> + let + upMsg = EncodeDecode.TermUpMsg + <| EncodeDecode.RequestFullTermState + cmd = + Ports.socketSend <| EncodeDecode.upMsgEncoder upMsg + in + (model, cmd) + KeyDown key -> + let + newHeldKeys = prependKey key model.heldKeys + processedChar = keyToString newHeldKeys + cmd = case processedChar of + Just c -> + let + upMsg = + EncodeDecode.TermUpMsg <| + EncodeDecode.SendCharacter <| + c + in + Ports.socketSend <| EncodeDecode.upMsgEncoder upMsg + Nothing -> Cmd.none + in + ({model | heldKeys = newHeldKeys}, cmd) + KeyUp key -> + ({model | heldKeys = removeKey key model.heldKeys}, Cmd.none) + _ -> (model, Cmd.none) + +termFont = Element.Font.family + [ Element.Font.typeface "Courier Prime" + , Element.Font.monospace + ] + +lightOrangeBackground = Element.Background.color (Element.rgb255 255 87 51) +lightGreenBackground = Element.Background.color (Element.rgb255 175 225 175) + +view : Model -> Element Msg +view model = + let + termContentString = case model.err of + Nothing -> String.join "\n" (List.map .content model.termState.lines) + Just err -> err + useNotes = + Element.column + -- light orange background + -- rounded corners + [ lightGreenBackground + , Element.padding 10 + , Element.spacing 5 + , Element.width (Element.fill) + , Element.Border.rounded 10 + , Element.Font.color (Element.rgb255 255 255 255) + ] + [ + Element.paragraph [] [Element.text <| "Written in Elm. Works well in Chrome. Your mileage with other browsers may vary!"], + Element.paragraph [] [Element.text <| "Container is nix based, so install programs with `nix-shell -p` vim htop for example."], + Element.paragraph [] [Element.text <| "Cursor and line higlighting not yet implmented.\n"], + Element.paragraph [] [Element.text <| "Container stats: 4 cores, 6gb memory"] + ] + cursorString = + let + cursorRow = model.termState.cursorLoc.row + cursorCol = model.termState.cursorLoc.col + + paddingLeft = String.repeat cursorCol " " + cursorChar = "█" -- Cursor box character + paddingRight = String.repeat (model.termState.cols - cursorCol - 1) " " + activeRow = paddingLeft ++ cursorChar ++ paddingRight + + standardRow = (String.repeat model.termState.cols " ") + + paddingAbove = String.repeat cursorRow standardRow + paddingBelow = String.repeat (model.termState.rows - cursorRow - 1) standardRow + + stringBeforeNewlines = paddingAbove ++ activeRow ++ paddingBelow + in + case model.err of + Nothing -> + makeGridWithNewlines stringBeforeNewlines model.termState.cols + Just _ -> "" + cursorLayer = + Element.el + [ termFont + , Element.width (Element.fill |> Element.maximum 770 |> Element.minimum 770) + , Element.height (Element.fill |> Element.maximum 390 |> Element.minimum 390) + , Element.Font.color (Element.rgba255 0 0 0 0.3) -- White cursor + ] + (cursorString |> Element.text) + + + mainTermLayer = + Element.el + [ termFont + , Element.width (Element.fill |> Element.maximum 770 |> Element.minimum 770) + , Element.height (Element.fill |> Element.maximum 390 |> Element.minimum 390) + ] + (termContentString |> Element.text) + keyDebugger = + Element.el + [ lightOrangeBackground + , Element.Border.rounded 10 + , Element.width (Element.fill) + , Element.Font.color (Element.rgb255 255 255 255) + , Element.padding 10 + ] + (Element.text <| ("Debug keys: " ++ keysToText model.heldKeys)) + in + Element.column + [ Element.spacing 20 + , Element.alignTop + , Element.alignLeft + , Element.Font.size 16 + ] + [ useNotes + , Element.el + [Element.inFront cursorLayer] + (mainTermLayer) + , keyDebugger + ] + +keysToText : List Key -> String +keysToText keys = + keys + |> List.map keyToStringDebug + |> String.join "" + +keyToStringDebug : Key -> String +keyToStringDebug key = + case key of + Character _ c -> + if c == ' ' then + "⊔" + else + String.fromChar c + + Control _ str -> + "<" ++ str ++ ">" + _ -> "" diff --git a/frontend/src/ports.websocket.js b/frontend/src/ports.websocket.js index f652ad3..eb4999b 100644 --- a/frontend/src/ports.websocket.js +++ b/frontend/src/ports.websocket.js @@ -42,7 +42,19 @@ function initSockets(app) { var name_2 = command.name, data = command.data; var socket = sockets.get(name_2); if (socket) { - socket.ws.send(typeof data === "object" ? JSON.stringify(data) : data); + if (socket.ws.readyState === WebSocket.OPEN) { + socket.ws.send(typeof data === "object" ? JSON.stringify(data) : data); + } else { + console.warn(`Cannot send message. WebSocket "${name_2}" is not open. Current state: ${socket.ws.readyState}`); + app.ports.webSocketEvent.send({ + type: "error", + name: name_2, + meta: socket.meta, + error: `WebSocket is not open. Current state: ${socket.ws.readyState}` + }); + } + } else { + console.warn(`WebSocket "${name_2}" does not exist.`); } break; }