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 0000000..4af1ff5 Binary files /dev/null and b/frontend/assets/CourierPrime-Regular.ttf differ 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; }