├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── check.sh ├── eterm.png ├── eterm ├── Cargo.toml ├── examples │ ├── game_server.rs │ └── print.rs └── src │ ├── client.rs │ ├── lib.rs │ ├── net_shape.rs │ └── server.rs └── eterm_viewer ├── Cargo.toml └── src └── main.rs /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: CI 4 | 5 | env: 6 | # This is required to enable the web_sys clipboard API which egui_web uses 7 | # https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Clipboard.html 8 | # https://rustwasm.github.io/docs/wasm-bindgen/web-sys/unstable-apis.html 9 | RUSTFLAGS: --cfg=web_sys_unstable_apis 10 | 11 | jobs: 12 | check_: 13 | name: cargo check 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions-rs/toolchain@v1 18 | with: 19 | profile: minimal 20 | toolchain: 1.56.1 21 | override: true 22 | - uses: actions-rs/cargo@v1 23 | with: 24 | command: check 25 | args: --all-targets --all-features 26 | 27 | test: 28 | name: cargo test 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions-rs/toolchain@v1 33 | with: 34 | profile: minimal 35 | toolchain: 1.56.1 36 | override: true 37 | - run: sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libspeechd-dev libxkbcommon-dev libssl-dev 38 | - uses: actions-rs/cargo@v1 39 | with: 40 | command: test 41 | args: --all-targets --all-features 42 | 43 | fmt: 44 | name: cargo fmt 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v2 48 | - uses: actions-rs/toolchain@v1 49 | with: 50 | profile: minimal 51 | toolchain: 1.56.1 52 | override: true 53 | - run: rustup component add rustfmt 54 | - uses: actions-rs/cargo@v1 55 | with: 56 | command: fmt 57 | args: --all -- --check 58 | 59 | clippy: 60 | name: cargo clippy 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v2 64 | - uses: actions-rs/toolchain@v1 65 | with: 66 | profile: minimal 67 | toolchain: 1.56.1 68 | override: true 69 | - run: rustup component add clippy 70 | - uses: actions-rs/cargo@v1 71 | with: 72 | command: clippy 73 | args: --workspace --all-targets --all-features -- -D warnings -W clippy::all 74 | 75 | doc: 76 | name: cargo doc 77 | runs-on: ubuntu-latest 78 | steps: 79 | - uses: actions/checkout@v2 80 | - uses: actions-rs/toolchain@v1 81 | with: 82 | profile: minimal 83 | toolchain: 1.56.1 84 | override: true 85 | - run: cargo doc -p eterm --lib --no-deps --all-features 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "eterm", 5 | "eterm_viewer", 6 | ] 7 | 8 | [patch.crates-io] 9 | # egui = { path = "../egui/egui" } 10 | # egui_demo_lib = { path = "../egui/egui_demo_lib" } 11 | # egui_glium = { path = "../egui/egui_glium" } 12 | # egui = { git = "https://github.com/emilk/egui", branch = "master" } 13 | # egui_demo_lib = { git = "https://github.com/emilk/egui", branch = "master" } 14 | # egui_glium = { git = "https://github.com/emilk/egui", branch = "master" } 15 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2021 Emil Ernerfeldt 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eterm: a visual terminal for [egui](https://github.com/emilk/egui/) 2 | 3 | [![Latest version](https://img.shields.io/crates/v/eterm.svg)](https://crates.io/crates/eterm) 4 | [![Documentation](https://docs.rs/eterm/badge.svg)](https://docs.rs/eterm) 5 | [![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance/) 6 | [![Build Status](https://github.com/emilk/eterm/workflows/CI/badge.svg)](https://github.com/emilk/eterm/actions?workflow=CI) 7 | ![MIT](https://img.shields.io/badge/license-MIT-blue.svg) 8 | ![Apache](https://img.shields.io/badge/license-Apache-blue.svg) 9 | 10 | If you have a service written in rust (running on the cloud, or even locally) that you need to inspect, `eterm` might be for you. 11 | 12 | By adding a few lines of rust to your service you can connect to it over TCP and get a visual terminal, allowing you to inspect apsects of the running process and/or configure it. 13 | 14 | **NOTE**: This is work-in-progress! 15 | 16 | ## How do I use `eterm`? 17 | On the service you want to inspect you add the following: 18 | 19 | 20 | ``` rust 21 | let mut eterm_server = eterm::Server::new("0.0.0.0:8505")?; 22 | 23 | … 24 | 25 | eterm_server 26 | .show(|egui_ctx: &egui::CtxRef, client_id: eterm::ClientId| { 27 | egui::CentralPanel::default().show(egui_ctx, |ui| { 28 | ui.label("Some important stats"); 29 | ui.checkbox(&mut some_option, "Option enabled"); 30 | }); 31 | }); 32 | ``` 33 | 34 | This will listen for TCP connections on port `8505`. You connect to it using `eterm_viewer --url 127.0.0.1:8505`. 35 | 36 | ## How does it work? 37 | The `eterm_viewer` captures mouse and keyboard input and send it to the server. The servers runs the gui code and collects what to draw and sends it back to the viewer, which displays it. 38 | 39 | What is sent is not a picture of the rendered gui, but basic shapes such as rectangles, lines and text. This keeps the bandwidth use reasonably low, even though eterm sends the entire screen each frame (no delta-encoding!). 40 | 41 | To save bandwidth, frames are only sent when there is change on screen. 42 | 43 | ## Testing 44 | ``` sh 45 | cargo run --release --example game_server & 46 | cargo run --release -p eterm_viewer -- --url 127.0.0.1:8505 47 | ``` 48 | 49 | ## Limitations and future work 50 | There is no authentication and no encryption. 51 | 52 | The implementation is pretty basic so far, and is probably wasting a bit of CPU. 53 | 54 | eterm uses no delta-encoding, so with visually intense scenes it can use a lot of bandwidth (> 1MB/s). 55 | 56 | It would be nice to port the viewer to `eframe` so we can compile it for the web. Requires a Rust TCP library that works with web-sockets. 57 | 58 | ## Screenshot 59 | 60 | 61 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | script_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 3 | cd "$script_path" 4 | set -eux 5 | 6 | # Checks all tests, lints etc. 7 | # Basically does what the CI does. 8 | 9 | cargo check --workspace --all-targets --all-features 10 | cargo test --workspace --all-targets --all-features 11 | cargo clippy --workspace --all-targets --all-features -- -D warnings -W clippy::all 12 | cargo fmt --all -- --check 13 | 14 | cargo doc -p eterm --lib --no-deps --all-features 15 | -------------------------------------------------------------------------------- /eterm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emilk/eterm/1d0612c7f348e89bf6f1ebcb3778b6bb807b231c/eterm.png -------------------------------------------------------------------------------- /eterm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "eterm" 3 | version = "0.0.1" 4 | edition = "2021" 5 | rust-version = "1.56" 6 | authors = ["Emil Ernerfeldt "] 7 | description = "Visual terminal for egui" 8 | homepage = "https://github.com/emilk/eterm" 9 | license = "MIT OR Apache-2.0" 10 | readme = "../README.md" 11 | repository = "https://github.com/emilk/egui" 12 | categories = ["gui"] 13 | keywords = ["gui", "egui", "terminal", "thin client"] 14 | include = [ 15 | "../LICENSE-APACHE", 16 | "../LICENSE-MIT", 17 | "**/*.rs", 18 | "Cargo.toml", 19 | ] 20 | 21 | [package.metadata.docs.rs] 22 | all-features = true 23 | 24 | [lib] 25 | 26 | [dependencies] 27 | anyhow = "1.0.43" 28 | bincode = "1.3" 29 | egui = { version = "0.16.0", features = ["serialize"] } 30 | itertools = "0.10" 31 | parking_lot = "0.11.2" 32 | serde = { version = "1", features = ["derive"] } 33 | tracing = "0.1" 34 | zstd = "0.9" 35 | 36 | [dev-dependencies] 37 | chrono = "0.4" 38 | egui_demo_lib = { version = "0.16.0", features = ["serialize"] } 39 | tracing-subscriber = "0.3" 40 | -------------------------------------------------------------------------------- /eterm/examples/game_server.rs: -------------------------------------------------------------------------------- 1 | //! Example for something spinning fast (~60 Hz) and server 2 | //! a eterm at the same time: 3 | 4 | fn main() { 5 | // Log to stdout (if you run with `RUST_LOG=debug`). 6 | tracing_subscriber::fmt::init(); 7 | 8 | let mut eterm_server = eterm::Server::new("0.0.0.0:8505").unwrap(); 9 | eterm_server.set_minimum_update_interval(1.0); 10 | 11 | let mut demo_windows = egui_demo_lib::DemoWindows::default(); 12 | 13 | loop { 14 | eterm_server 15 | .show(|egui_ctx: &egui::CtxRef, _client_id: eterm::ClientId| { 16 | egui::TopBottomPanel::bottom("game_server_info").show(egui_ctx, |ui| { 17 | ui.horizontal(|ui| { 18 | ui.label("Server time:"); 19 | ui_clock(ui); 20 | }); 21 | }); 22 | demo_windows.ui(egui_ctx); 23 | }) 24 | .unwrap(); 25 | 26 | std::thread::sleep(std::time::Duration::from_secs_f32(1.0 / 60.0)); 27 | } 28 | } 29 | 30 | fn ui_clock(ui: &mut egui::Ui) { 31 | let seconds_since_midnight = seconds_since_midnight(); 32 | 33 | ui.monospace(format!( 34 | "{:02}:{:02}:{:02}", 35 | (seconds_since_midnight % (24.0 * 60.0 * 60.0) / 3600.0).floor(), 36 | (seconds_since_midnight % (60.0 * 60.0) / 60.0).floor(), 37 | (seconds_since_midnight % 60.0).floor(), 38 | )); 39 | } 40 | 41 | fn seconds_since_midnight() -> f64 { 42 | use chrono::Timelike; 43 | let time = chrono::Local::now().time(); 44 | time.num_seconds_from_midnight() as f64 + 1e-9 * (time.nanosecond() as f64) 45 | } 46 | -------------------------------------------------------------------------------- /eterm/examples/print.rs: -------------------------------------------------------------------------------- 1 | //! Print info to help guide what encoding to use for the network. 2 | use egui::epaint; 3 | 4 | /// `anti_alias=false` gives us around 23% savings in final bandwidth 5 | fn example_output(anti_alias: bool) -> (egui::Output, Vec) { 6 | let mut ctx = egui::CtxRef::default(); 7 | ctx.memory().options.tessellation_options.anti_alias = anti_alias; 8 | 9 | let raw_input = egui::RawInput::default(); 10 | let mut demo_windows = egui_demo_lib::DemoWindows::default(); 11 | let (output, shapes) = ctx.run(raw_input, |ctx| demo_windows.ui(ctx)); 12 | let clipped_meshes = ctx.tessellate(shapes); 13 | (output, clipped_meshes) 14 | } 15 | 16 | fn example_shapes() -> (egui::Output, Vec) { 17 | let mut ctx = egui::CtxRef::default(); 18 | let raw_input = egui::RawInput::default(); 19 | let mut demo_windows = egui_demo_lib::DemoWindows::default(); 20 | ctx.run(raw_input, |ctx| demo_windows.ui(ctx)) 21 | } 22 | 23 | fn bincode(data: &S) -> Vec { 24 | use bincode::Options as _; 25 | bincode::options().serialize(data).unwrap() 26 | } 27 | 28 | fn zstd(data: &[u8], level: i32) -> Vec { 29 | zstd::encode_all(std::io::Cursor::new(data), level).unwrap() 30 | } 31 | 32 | fn zstd_kb(data: &[u8], level: i32) -> f32 { 33 | zstd(data, level).len() as f32 * 1e-3 34 | } 35 | 36 | // ---------------------------------------------------------------------------- 37 | 38 | fn print_encodings(data: &S) { 39 | let encoded = bincode(data); 40 | println!("bincode: {:>6.2} kB", encoded.len() as f32 * 1e-3); 41 | println!("zstd-0: {:>6.2} kB", zstd_kb(&encoded, 0)); 42 | println!("zstd-5: {:>6.2} kB", zstd_kb(&encoded, 5)); 43 | // println!("zstd-15: {:>6.2} kB", zstd_kb(&encoded, 15)); 44 | // println!("zstd-21: {:>6.2} kB (too slow)", zstd_kb(&encoded, 21)); // way too slow 45 | } 46 | 47 | fn print_compressions(clipped_meshes: &[egui::ClippedMesh]) { 48 | let mut num_vertices = 0; 49 | let mut num_indices = 0; 50 | let mut bytes_vertices = 0; 51 | let mut bytes_indices = 0; 52 | for egui::ClippedMesh(_rect, mesh) in clipped_meshes { 53 | num_vertices += mesh.vertices.len(); 54 | num_indices += mesh.indices.len(); 55 | bytes_vertices += mesh.vertices.len() * std::mem::size_of_val(&mesh.vertices[0]); 56 | bytes_indices += mesh.indices.len() * std::mem::size_of_val(&mesh.indices[0]); 57 | } 58 | let mesh_bytes = bytes_indices + bytes_vertices; 59 | println!( 60 | "vertices: {:>5} {:>6.2} kb", 61 | num_vertices, 62 | bytes_vertices as f32 * 1e-3 63 | ); 64 | println!( 65 | "indices: {:>5} {:>6.2} kb", 66 | num_indices, 67 | bytes_indices as f32 * 1e-3 68 | ); 69 | println!(); 70 | 71 | let net_meshes: Vec<_> = clipped_meshes 72 | .iter() 73 | .map(|egui::ClippedMesh(rect, mesh)| (*rect, eterm::net_shape::NetMesh::from(mesh))) 74 | .collect(); 75 | 76 | let mut quantized_meshes = net_meshes.clone(); 77 | for (_, mesh) in &mut quantized_meshes { 78 | for pos in &mut mesh.pos { 79 | pos.x = quantize(pos.x); 80 | pos.y = quantize(pos.y); 81 | } 82 | } 83 | 84 | println!("raw: {:>6.2} kB", mesh_bytes as f32 * 1e-3); 85 | println!(); 86 | print_encodings(&clipped_meshes); 87 | println!(); 88 | println!("Flattened mesh:"); 89 | print_encodings(&net_meshes); 90 | println!(); 91 | println!("Quantized positions:"); 92 | print_encodings(&quantized_meshes); 93 | 94 | // Other things I've tried: delta-encoded positions (5-10% worse). 95 | } 96 | 97 | fn main() { 98 | println!("FontDefinitions:"); 99 | let font_definitions = egui::FontDefinitions::default(); 100 | print_encodings(&font_definitions); 101 | println!(); 102 | 103 | let (_, clipped_meshes) = example_output(true); 104 | println!("Antialiasing ON:"); 105 | print_compressions(&clipped_meshes); 106 | println!(); 107 | 108 | let (_, clipped_meshes) = example_output(false); 109 | println!("Antialiasing OFF:"); 110 | print_compressions(&clipped_meshes); 111 | println!(); 112 | 113 | let (_, shapes) = example_shapes(); 114 | let net_shapes = eterm::net_shape::to_clipped_net_shapes(shapes); 115 | println!("Shapes:"); 116 | print_encodings(&net_shapes); 117 | println!(); 118 | } 119 | 120 | fn quantize(f: f32) -> f32 { 121 | // TODO: should be based on pixels_to_point 122 | 123 | // let precision = 2.0; // 15% wins 124 | let precision = 8.0; // 12% wins 125 | 126 | (f * precision).round() / precision 127 | } 128 | -------------------------------------------------------------------------------- /eterm/src/client.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{ 2 | atomic::{AtomicBool, Ordering::SeqCst}, 3 | mpsc::{self}, 4 | Arc, 5 | }; 6 | 7 | use egui::{text::Fonts, util::History, RawInput}; 8 | use parking_lot::Mutex; 9 | 10 | use crate::{ClientToServerMessage, EguiFrame, ServerToClientMessage, TcpEndpoint}; 11 | 12 | pub struct Client { 13 | addr: String, 14 | connected: Arc, 15 | alive: Arc, 16 | outgoing_msg_tx: mpsc::Sender, 17 | incoming_msg_rx: mpsc::Receiver, 18 | 19 | font_definitions: egui::FontDefinitions, 20 | fonts: Option, 21 | latest_frame: Option, 22 | 23 | bandwidth_history: Arc>>, 24 | frame_size_history: Arc>>, 25 | latency_history: History, 26 | frame_history: History<()>, 27 | } 28 | 29 | impl Drop for Client { 30 | fn drop(&mut self) { 31 | self.alive.store(false, SeqCst); 32 | } 33 | } 34 | 35 | impl Client { 36 | /// Connects to the given eterm server. 37 | /// 38 | /// ``` no_run 39 | /// eterm::Client::new("127.0.0.1:8580".to_owned()); 40 | /// ``` 41 | pub fn new(addr: String) -> Self { 42 | let alive = Arc::new(AtomicBool::new(true)); 43 | let connected = Arc::new(AtomicBool::new(false)); 44 | let mut bandwidth_history = Arc::new(Mutex::new(History::new(0..200, 2.0))); 45 | let mut frame_size_history = Arc::new(Mutex::new(History::new(1..100, 0.5))); 46 | 47 | let (outgoing_msg_tx, mut outgoing_msg_rx) = mpsc::channel(); 48 | let (mut incoming_msg_tx, incoming_msg_rx) = mpsc::channel(); 49 | 50 | let client = Self { 51 | addr: addr.clone(), 52 | connected: connected.clone(), 53 | alive: alive.clone(), 54 | outgoing_msg_tx, 55 | incoming_msg_rx, 56 | font_definitions: Default::default(), 57 | fonts: None, 58 | latest_frame: Default::default(), 59 | bandwidth_history: bandwidth_history.clone(), 60 | frame_size_history: frame_size_history.clone(), 61 | latency_history: History::new(1..100, 1.0), 62 | frame_history: History::new(2..100, 1.0), 63 | }; 64 | 65 | std::thread::spawn(move || { 66 | tracing::info!("Connecting to {}…", addr); 67 | while alive.load(SeqCst) { 68 | match std::net::TcpStream::connect(&addr) { 69 | Ok(tcp_stream) => { 70 | tracing::info!("Connected!"); 71 | connected.store(true, SeqCst); 72 | if let Err(err) = run( 73 | tcp_stream, 74 | &mut outgoing_msg_rx, 75 | &mut incoming_msg_tx, 76 | &mut bandwidth_history, 77 | &mut frame_size_history, 78 | ) { 79 | tracing::info!( 80 | "Connection lost: {}", 81 | crate::error_display_chain(err.as_ref()) 82 | ); 83 | } else { 84 | tracing::info!("Connection closed.",); 85 | } 86 | connected.store(false, SeqCst); 87 | } 88 | Err(err) => { 89 | tracing::debug!("Failed to connect to {}: {}", addr, err); 90 | std::thread::sleep(std::time::Duration::from_secs(1)); 91 | } 92 | } 93 | } 94 | }); 95 | 96 | client 97 | } 98 | 99 | /// The address we are connected to or trying to connect to. 100 | pub fn addr(&self) -> &str { 101 | &self.addr 102 | } 103 | 104 | /// Are we currently connect to the server? 105 | pub fn is_connected(&self) -> bool { 106 | self.connected.load(SeqCst) 107 | } 108 | 109 | pub fn send_input(&self, raw_input: RawInput) { 110 | self.outgoing_msg_tx 111 | .send(ClientToServerMessage::Input { 112 | raw_input, 113 | client_time: now(), 114 | }) 115 | .ok(); 116 | } 117 | 118 | /// Estimated bandwidth use (downstream). 119 | pub fn bytes_per_second(&self) -> f32 { 120 | self.bandwidth_history.lock().bandwidth().unwrap_or(0.0) 121 | } 122 | 123 | /// Estimated size of one frame packet 124 | pub fn average_frame_packet_size(&self) -> Option { 125 | self.frame_size_history.lock().average() 126 | } 127 | 128 | /// Smoothed round-trip-time estimate in seconds. 129 | pub fn latency(&self) -> Option { 130 | self.latency_history.average() 131 | } 132 | 133 | /// Smoothed estimate of the adaptive frames per second. 134 | pub fn adaptive_fps(&self) -> Option { 135 | self.frame_history.rate() 136 | } 137 | 138 | /// Retrieved new events, and gives back what to do. 139 | /// 140 | /// Return `None` when there is nothing new. 141 | pub fn update(&mut self, pixels_per_point: f32) -> Option { 142 | if self.fonts.is_none() { 143 | self.fonts = Some(Fonts::new(pixels_per_point, self.font_definitions.clone())); 144 | } 145 | let fonts = self.fonts.as_mut().unwrap(); 146 | if pixels_per_point != fonts.pixels_per_point() { 147 | *fonts = Fonts::new(pixels_per_point, self.font_definitions.clone()); 148 | } 149 | 150 | while let Ok(msg) = self.incoming_msg_rx.try_recv() { 151 | match msg { 152 | ServerToClientMessage::Fonts { font_definitions } => { 153 | self.font_definitions = font_definitions; 154 | *fonts = Fonts::new(pixels_per_point, self.font_definitions.clone()); 155 | } 156 | ServerToClientMessage::Frame { 157 | frame_index, 158 | output, 159 | clipped_net_shapes, 160 | client_time, 161 | } => { 162 | let clipped_shapes = 163 | crate::net_shape::from_clipped_net_shapes(fonts, clipped_net_shapes); 164 | let tesselator_options = 165 | egui::epaint::tessellator::TessellationOptions::from_pixels_per_point( 166 | pixels_per_point, 167 | ); 168 | let tex_size = fonts.font_image().size(); 169 | let clipped_meshes = egui::epaint::tessellator::tessellate_shapes( 170 | clipped_shapes, 171 | tesselator_options, 172 | tex_size, 173 | ); 174 | 175 | let latest_frame = self.latest_frame.get_or_insert_with(EguiFrame::default); 176 | latest_frame.frame_index = frame_index; 177 | latest_frame.output.append(output); 178 | latest_frame.clipped_meshes = clipped_meshes; 179 | 180 | if let Some(client_time) = client_time { 181 | let rtt = (now() - client_time) as f32; 182 | self.latency_history.add(now(), rtt); 183 | } 184 | 185 | self.frame_history.add(now(), ()); 186 | } 187 | } 188 | } 189 | 190 | fonts.end_frame(); // make sure to evict galley cache 191 | 192 | self.bandwidth_history.lock().flush(now()); 193 | self.frame_size_history.lock().flush(now()); 194 | self.latency_history.flush(now()); 195 | self.frame_history.flush(now()); 196 | 197 | self.latest_frame.take() 198 | } 199 | 200 | pub fn font_image(&self) -> Arc { 201 | self.fonts 202 | .as_ref() 203 | .expect("Call update() first") 204 | .font_image() 205 | } 206 | } 207 | 208 | fn run( 209 | tcp_stream: std::net::TcpStream, 210 | outgoing_msg_rx: &mut mpsc::Receiver, 211 | incoming_msg_tx: &mut mpsc::Sender, 212 | bandwidth_history: &mut Arc>>, 213 | frame_size_history: &mut Arc>>, 214 | ) -> anyhow::Result<()> { 215 | use anyhow::Context as _; 216 | 217 | tcp_stream 218 | .set_nonblocking(true) 219 | .context("TCP set_nonblocking")?; 220 | 221 | let mut tcp_endpoint = TcpEndpoint { tcp_stream }; 222 | 223 | loop { 224 | loop { 225 | match outgoing_msg_rx.try_recv() { 226 | Ok(message) => { 227 | tcp_endpoint.send_message(&message)?; 228 | } 229 | Err(mpsc::TryRecvError::Empty) => break, 230 | Err(mpsc::TryRecvError::Disconnected) => { 231 | return Ok(()); 232 | } 233 | } 234 | } 235 | 236 | while let Some(packet) = tcp_endpoint.try_receive_packet().context("receive")? { 237 | bandwidth_history.lock().add(now(), packet.len() as f32); 238 | let message = crate::decode_message(&packet).context("decode")?; 239 | if let ServerToClientMessage::Frame { .. } = &message { 240 | frame_size_history.lock().add(now(), packet.len() as f32); 241 | } 242 | incoming_msg_tx.send(message)?; 243 | } 244 | 245 | std::thread::sleep(std::time::Duration::from_millis(5)); 246 | } 247 | } 248 | 249 | fn now() -> f64 { 250 | std::time::UNIX_EPOCH.elapsed().unwrap().as_secs_f64() 251 | } 252 | -------------------------------------------------------------------------------- /eterm/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! * Client: the think client that has a screen, a keyboard etc. 2 | //! * Server: what runs the egui code. 3 | 4 | #![forbid(unsafe_code)] 5 | #![warn( 6 | clippy::all, 7 | clippy::await_holding_lock, 8 | clippy::char_lit_as_u8, 9 | clippy::checked_conversions, 10 | clippy::dbg_macro, 11 | clippy::debug_assert_with_mut_call, 12 | clippy::doc_markdown, 13 | clippy::empty_enum, 14 | clippy::enum_glob_use, 15 | clippy::exit, 16 | clippy::expl_impl_clone_on_copy, 17 | clippy::explicit_deref_methods, 18 | clippy::explicit_into_iter_loop, 19 | clippy::fallible_impl_from, 20 | clippy::filter_map_next, 21 | clippy::float_cmp_const, 22 | clippy::fn_params_excessive_bools, 23 | clippy::if_let_mutex, 24 | clippy::imprecise_flops, 25 | clippy::inefficient_to_string, 26 | clippy::invalid_upcast_comparisons, 27 | clippy::large_types_passed_by_value, 28 | clippy::let_unit_value, 29 | clippy::linkedlist, 30 | clippy::lossy_float_literal, 31 | clippy::macro_use_imports, 32 | clippy::manual_ok_or, 33 | clippy::map_err_ignore, 34 | clippy::map_flatten, 35 | clippy::match_on_vec_items, 36 | clippy::match_same_arms, 37 | clippy::match_wildcard_for_single_variants, 38 | clippy::mem_forget, 39 | clippy::mismatched_target_os, 40 | clippy::missing_errors_doc, 41 | clippy::missing_safety_doc, 42 | clippy::mut_mut, 43 | clippy::mutex_integer, 44 | clippy::needless_borrow, 45 | clippy::needless_continue, 46 | clippy::needless_pass_by_value, 47 | clippy::option_option, 48 | clippy::path_buf_push_overwrite, 49 | clippy::ptr_as_ptr, 50 | clippy::ref_option_ref, 51 | clippy::rest_pat_in_fully_bound_structs, 52 | clippy::same_functions_in_if_condition, 53 | clippy::string_add_assign, 54 | clippy::string_add, 55 | clippy::string_lit_as_bytes, 56 | clippy::string_to_string, 57 | clippy::todo, 58 | clippy::trait_duplication_in_bounds, 59 | clippy::unimplemented, 60 | clippy::unnested_or_patterns, 61 | clippy::unused_self, 62 | clippy::useless_transmute, 63 | clippy::verbose_file_reads, 64 | clippy::zero_sized_map_values, 65 | future_incompatible, 66 | nonstandard_style, 67 | rust_2018_idioms, 68 | rustdoc::missing_crate_level_docs 69 | )] 70 | #![allow(clippy::float_cmp)] 71 | #![allow(clippy::manual_range_contains)] 72 | 73 | mod client; 74 | pub mod net_shape; 75 | mod server; 76 | 77 | pub use client::Client; 78 | pub use server::{ClientId, Server}; 79 | 80 | use std::sync::Arc; 81 | 82 | /// All TCP packets are prefixed with this. 83 | /// 84 | /// b"eterm", major, minor, patch 85 | pub(crate) const PROTOCOL_HEADER: [u8; 8] = [b'e', b't', b'e', b'r', b'm', 0, 0, 1]; 86 | 87 | #[test] 88 | fn test_version() { 89 | let [_, _, _, _, _, major, minor, patch] = PROTOCOL_HEADER; 90 | assert_eq!( 91 | env!("CARGO_PKG_VERSION"), 92 | format!("{}.{}.{}", major, minor, patch), 93 | "You must update PROTOCOL_HEADER when you publish a new eterm", 94 | ); 95 | } 96 | 97 | pub type Packet = Arc<[u8]>; 98 | 99 | #[derive(Default)] 100 | pub struct EguiFrame { 101 | pub frame_index: u64, 102 | pub output: egui::Output, 103 | pub clipped_meshes: Vec, 104 | } 105 | 106 | #[derive(serde::Serialize, serde::Deserialize)] 107 | pub enum ClientToServerMessage { 108 | Input { 109 | raw_input: egui::RawInput, 110 | /// Seconds since epoch. Used to measure latency. 111 | client_time: f64, 112 | }, 113 | Goodbye, 114 | } 115 | 116 | #[derive(serde::Serialize, serde::Deserialize)] 117 | pub enum ServerToClientMessage { 118 | /// Sent first to all clients so they know how to paint 119 | /// the [`crate::net_shape::NetShape`]:s. 120 | Fonts { 121 | font_definitions: egui::FontDefinitions, 122 | }, 123 | 124 | /// What to paint to screen. 125 | Frame { 126 | frame_index: u64, 127 | output: egui::Output, 128 | clipped_net_shapes: Vec, 129 | /// If this frame is a response to a `ClientToServerMessage::Input`. 130 | /// Used to measure latency. 131 | client_time: Option, 132 | }, 133 | } 134 | 135 | fn encode_message(message: &M) -> anyhow::Result { 136 | use anyhow::Context as _; 137 | use bincode::Options as _; 138 | 139 | let bincoded = bincode::options().serialize(message).context("bincode")?; 140 | 141 | const ZSTD_LEVEL: i32 = 5; 142 | let compressed = 143 | zstd::encode_all(std::io::Cursor::new(&bincoded), ZSTD_LEVEL).context("zstd")?; 144 | 145 | Ok(compressed.into()) 146 | } 147 | 148 | fn decode_message(packet: &[u8]) -> anyhow::Result { 149 | use anyhow::Context as _; 150 | use bincode::Options as _; 151 | 152 | let bincoded = zstd::decode_all(packet).context("zstd")?; 153 | 154 | let message = bincode::options() 155 | .deserialize(&bincoded) 156 | .context("bincode")?; 157 | 158 | Ok(message) 159 | } 160 | 161 | /// Show full cause chain in a single line 162 | pub(crate) fn error_display_chain(error: &dyn std::error::Error) -> String { 163 | let mut s = error.to_string(); 164 | if let Some(source) = error.source() { 165 | s.push_str(" -> "); 166 | s.push_str(&error_display_chain(source)); 167 | } 168 | s 169 | } 170 | 171 | // ---------------------------------------------------------------------------- 172 | 173 | /// Wrapper around a non-blocking [`std::net::TcpStream`]. 174 | pub(crate) struct TcpEndpoint { 175 | tcp_stream: std::net::TcpStream, 176 | } 177 | 178 | impl TcpEndpoint { 179 | /// returns immediately if there is nothing to read 180 | fn try_receive_packet(&mut self) -> anyhow::Result> { 181 | use std::io::Read as _; 182 | 183 | // All messages are length-prefixed by PROTOCOL_HEADER and u32 (LE). 184 | let mut header = [0_u8; 12]; 185 | match self.tcp_stream.peek(&mut header) { 186 | Ok(12) => {} 187 | Ok(_) => { 188 | return Ok(None); 189 | } 190 | Err(err) => { 191 | if err.kind() == std::io::ErrorKind::WouldBlock { 192 | return Ok(None); 193 | } else { 194 | return Err(err.into()); 195 | } 196 | } 197 | } 198 | 199 | let protocol = &header[..PROTOCOL_HEADER.len()]; 200 | let length = &header[PROTOCOL_HEADER.len()..]; 201 | let length = u32::from_le_bytes([length[0], length[1], length[2], length[3]]) as usize; 202 | 203 | if protocol[0..5] != PROTOCOL_HEADER[0..5] { 204 | anyhow::bail!("The other side is not eterm"); 205 | } 206 | 207 | if protocol != PROTOCOL_HEADER { 208 | anyhow::bail!( 209 | "This side uses eterm {}.{}.{}, the other side is on {}.{}.{}", 210 | PROTOCOL_HEADER[5], 211 | PROTOCOL_HEADER[6], 212 | PROTOCOL_HEADER[7], 213 | protocol[5], 214 | protocol[6], 215 | protocol[7], 216 | ); 217 | } 218 | 219 | if length > 32_000_000 { 220 | anyhow::bail!("Refusing packet of {:.1} MB", length as f32 * 1e-6); 221 | } 222 | 223 | // See if we have the whole packet yet: 224 | let mut length_and_packet = vec![0_u8; header.len() + length]; 225 | match self.tcp_stream.peek(&mut length_and_packet) { 226 | Ok(bytes_read) => { 227 | if bytes_read != length_and_packet.len() { 228 | return Ok(None); // not yet! 229 | } 230 | } 231 | Err(err) => { 232 | if err.kind() == std::io::ErrorKind::WouldBlock { 233 | return Ok(None); 234 | } else { 235 | return Err(err.into()); 236 | } 237 | } 238 | } 239 | 240 | // consume the bytes: 241 | self.tcp_stream.read_exact(&mut length_and_packet)?; 242 | 243 | let packet = &length_and_packet[header.len()..]; 244 | 245 | Ok(Some(packet.into())) 246 | } 247 | 248 | /// returns immediately if there is nothing to read 249 | fn try_receive_message(&mut self) -> anyhow::Result> { 250 | use anyhow::Context as _; 251 | match self.try_receive_packet().context("receive")? { 252 | Some(packet) => { 253 | let message = crate::decode_message(&packet).context("decode")?; 254 | Ok(Some(message)) 255 | } 256 | None => Ok(None), 257 | } 258 | } 259 | 260 | fn send_packet(&mut self, packet: &[u8]) -> anyhow::Result<()> { 261 | let length = packet.len() as u32; 262 | let length = length.to_le_bytes(); 263 | self.write_all_with_retry(&PROTOCOL_HEADER)?; 264 | self.write_all_with_retry(&length)?; 265 | self.write_all_with_retry(packet)?; 266 | Ok(()) 267 | } 268 | 269 | fn write_all_with_retry(&mut self, chunk: &[u8]) -> anyhow::Result<()> { 270 | use std::io::Write as _; 271 | loop { 272 | match self.tcp_stream.write_all(chunk) { 273 | Ok(()) => { 274 | return Ok(()); 275 | } 276 | Err(err) => { 277 | if err.kind() == std::io::ErrorKind::WouldBlock { 278 | // doesn't seem to help 279 | std::thread::sleep(std::time::Duration::from_millis(5)); 280 | } else { 281 | anyhow::bail!("{:?}", err); 282 | } 283 | } 284 | } 285 | } 286 | } 287 | 288 | fn send_message(&mut self, message: &M) -> anyhow::Result<()> { 289 | self.send_packet(&encode_message(message)?) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /eterm/src/net_shape.rs: -------------------------------------------------------------------------------- 1 | use egui::epaint::{self, Color32, Pos2, Rect, Stroke, TextureId}; 2 | 3 | /// Like [`epaint::Mesh`], but optimized for transport over a network. 4 | #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] 5 | pub struct NetMesh { 6 | pub texture_id: TextureId, 7 | pub indices: Vec, 8 | pub pos: Vec, 9 | pub uv: Vec, 10 | pub color: Vec, 11 | } 12 | 13 | impl From<&epaint::Mesh> for NetMesh { 14 | fn from(mesh: &epaint::Mesh) -> Self { 15 | Self { 16 | texture_id: mesh.texture_id, 17 | indices: mesh.indices.clone(), 18 | pos: mesh.vertices.iter().map(|v| v.pos).collect(), 19 | uv: mesh.vertices.iter().map(|v| v.uv).collect(), 20 | color: mesh.vertices.iter().map(|v| v.color).collect(), 21 | } 22 | } 23 | } 24 | 25 | impl From<&NetMesh> for epaint::Mesh { 26 | fn from(mesh: &NetMesh) -> epaint::Mesh { 27 | epaint::Mesh { 28 | texture_id: mesh.texture_id, 29 | indices: mesh.indices.clone(), 30 | vertices: itertools::izip!(&mesh.pos, &mesh.uv, &mesh.color) 31 | .map(|(&pos, &uv, &color)| epaint::Vertex { pos, uv, color }) 32 | .collect(), 33 | } 34 | } 35 | } 36 | 37 | // ---------------------------------------------------------------------------- 38 | 39 | /// Like [`epaint::Shape`], but optimized for transport over a network. 40 | #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] 41 | pub enum NetShape { 42 | Circle(epaint::CircleShape), 43 | LineSegment { points: [Pos2; 2], stroke: Stroke }, 44 | Path(epaint::PathShape), 45 | Rect(epaint::RectShape), 46 | Text(NetTextShape), 47 | Mesh(NetMesh), 48 | } 49 | 50 | /// How to draw some text on screen. 51 | #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] 52 | pub struct NetTextShape { 53 | pub pos: Pos2, 54 | pub job: epaint::text::LayoutJob, 55 | pub underline: Stroke, 56 | pub override_text_color: Option, 57 | pub angle: f32, 58 | } 59 | 60 | #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] 61 | pub struct ClippedNetShape(Rect, NetShape); 62 | 63 | pub fn to_clipped_net_shapes(in_shapes: Vec) -> Vec { 64 | let mut net_shapes = vec![]; 65 | for epaint::ClippedShape(clip_rect, shape) in in_shapes { 66 | to_net_shapes(clip_rect, shape, &mut net_shapes) 67 | } 68 | net_shapes 69 | } 70 | 71 | fn to_net_shapes( 72 | clip_rect: Rect, 73 | in_shape: epaint::Shape, 74 | out_net_shapes: &mut Vec, 75 | ) { 76 | if !clip_rect.is_positive() { 77 | return; 78 | } 79 | 80 | match in_shape { 81 | epaint::Shape::Noop => {} 82 | epaint::Shape::Vec(shapes) => { 83 | for shape in shapes { 84 | to_net_shapes(clip_rect, shape, out_net_shapes); 85 | } 86 | } 87 | epaint::Shape::Circle(circle_shape) => { 88 | if circle_shape.radius > 0.0 89 | && clip_rect 90 | .expand(circle_shape.radius + circle_shape.stroke.width) 91 | .contains(circle_shape.center) 92 | { 93 | out_net_shapes.push(ClippedNetShape(clip_rect, NetShape::Circle(circle_shape))); 94 | } 95 | } 96 | epaint::Shape::LineSegment { points, stroke } => { 97 | if !stroke.is_empty() 98 | && clip_rect 99 | .intersects(Rect::from_two_pos(points[0], points[1]).expand(stroke.width)) 100 | { 101 | out_net_shapes.push(ClippedNetShape( 102 | clip_rect, 103 | NetShape::LineSegment { points, stroke }, 104 | )); 105 | } 106 | } 107 | epaint::Shape::Path(path_shape) => { 108 | if path_shape.points.len() >= 2 && clip_rect.intersects(path_shape.bounding_rect()) { 109 | out_net_shapes.push(ClippedNetShape(clip_rect, NetShape::Path(path_shape))); 110 | } 111 | } 112 | epaint::Shape::Rect(rect_shape) => { 113 | if clip_rect.intersects(rect_shape.bounding_rect()) && !rect_shape.rect.is_negative() { 114 | out_net_shapes.push(ClippedNetShape(clip_rect, NetShape::Rect(rect_shape))); 115 | } 116 | } 117 | epaint::Shape::Text(text_shape) => { 118 | if clip_rect.intersects(text_shape.bounding_rect()) && !text_shape.galley.is_empty() { 119 | out_net_shapes.push(ClippedNetShape( 120 | clip_rect, 121 | NetShape::Text(NetTextShape { 122 | pos: text_shape.pos, 123 | job: (*text_shape.galley.job).clone(), 124 | underline: text_shape.underline, 125 | override_text_color: text_shape.override_text_color, 126 | angle: text_shape.angle, 127 | }), 128 | )); 129 | } 130 | } 131 | epaint::Shape::Mesh(mesh) => { 132 | if clip_rect.intersects(mesh.calc_bounds()) { 133 | out_net_shapes.push(ClippedNetShape( 134 | clip_rect, 135 | NetShape::Mesh(NetMesh::from(&mesh)), 136 | )); 137 | } 138 | } 139 | } 140 | } 141 | 142 | pub fn from_clipped_net_shapes( 143 | fonts: &epaint::text::Fonts, 144 | in_shapes: Vec, 145 | ) -> Vec { 146 | in_shapes 147 | .into_iter() 148 | .map(|ClippedNetShape(clip_rect, net_shape)| { 149 | epaint::ClippedShape(clip_rect, to_epaint_shape(fonts, net_shape)) 150 | }) 151 | .collect() 152 | } 153 | 154 | fn to_epaint_shape(fonts: &epaint::text::Fonts, net_shape: NetShape) -> epaint::Shape { 155 | match net_shape { 156 | NetShape::Circle(circle_shape) => epaint::Shape::Circle(circle_shape), 157 | NetShape::LineSegment { points, stroke } => epaint::Shape::LineSegment { points, stroke }, 158 | NetShape::Path(path_shape) => epaint::Shape::Path(path_shape), 159 | NetShape::Rect(rect_shape) => epaint::Shape::Rect(rect_shape), 160 | NetShape::Text(text_shape) => { 161 | let galley = fonts.layout_job(text_shape.job); 162 | epaint::Shape::Text(epaint::TextShape { 163 | pos: text_shape.pos, 164 | galley, 165 | underline: text_shape.underline, 166 | override_text_color: text_shape.override_text_color, 167 | angle: text_shape.angle, 168 | }) 169 | } 170 | NetShape::Mesh(net_mesh) => epaint::Shape::Mesh(epaint::Mesh::from(&net_mesh)), 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /eterm/src/server.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | net::{SocketAddr, TcpListener}, 4 | }; 5 | 6 | use anyhow::Context as _; 7 | use egui::RawInput; 8 | 9 | use crate::{net_shape::ClippedNetShape, ClientToServerMessage}; 10 | 11 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 12 | pub struct ClientId(u64); 13 | 14 | pub struct Server { 15 | next_client_id: u64, 16 | tcp_listener: TcpListener, 17 | clients: HashMap, 18 | minimum_update_interval: f32, 19 | } 20 | 21 | impl Server { 22 | /// Start listening for connections on this addr (e.g. "0.0.0.0:8585") 23 | /// 24 | /// # Errors 25 | /// Can fail if the port is already taken. 26 | pub fn new(bind_addr: &str) -> anyhow::Result { 27 | let tcp_listener = TcpListener::bind(bind_addr).context("binding server TCP socket")?; 28 | tcp_listener 29 | .set_nonblocking(true) 30 | .context("TCP set_nonblocking")?; 31 | 32 | Ok(Self { 33 | next_client_id: 0, 34 | tcp_listener, 35 | clients: Default::default(), 36 | minimum_update_interval: 1.0, 37 | }) 38 | } 39 | 40 | /// Send a new frame to each client at least this often. 41 | /// Default: one second. 42 | pub fn set_minimum_update_interval(&mut self, seconds: f32) { 43 | self.minimum_update_interval = seconds; 44 | } 45 | 46 | /// Call frequently (e.g. 60 times per second) with the ui you'd like to show to clients. 47 | /// 48 | /// # Errors 49 | /// Underlying TCP errors. 50 | pub fn show(&mut self, mut do_ui: impl FnMut(&egui::CtxRef, ClientId)) -> anyhow::Result<()> { 51 | self.show_dyn(&mut do_ui) 52 | } 53 | 54 | fn show_dyn(&mut self, do_ui: &mut dyn FnMut(&egui::CtxRef, ClientId)) -> anyhow::Result<()> { 55 | self.accept_new_clients()?; 56 | self.try_receive(); 57 | 58 | for client in self.clients.values_mut() { 59 | client.show(do_ui, self.minimum_update_interval); 60 | } 61 | Ok(()) 62 | } 63 | 64 | /// non-blocking 65 | fn accept_new_clients(&mut self) -> anyhow::Result<()> { 66 | loop { 67 | match self.tcp_listener.accept() { 68 | Ok((tcp_stream, client_addr)) => { 69 | tcp_stream 70 | .set_nonblocking(true) 71 | .context("stream.set_nonblocking")?; 72 | let tcp_endpoint = crate::TcpEndpoint { tcp_stream }; 73 | 74 | // reuse existing client - especially the egui context 75 | // which contains things like window positons: 76 | let clients = &mut self.clients; 77 | let next_client_id = &mut self.next_client_id; 78 | let client = clients.entry(client_addr).or_insert_with(|| { 79 | let client_id = ClientId(*next_client_id); 80 | *next_client_id += 1; 81 | 82 | Client { 83 | client_id, 84 | addr: client_addr, 85 | tcp_endpoint: None, 86 | start_time: std::time::Instant::now(), 87 | frame_index: 0, 88 | egui_ctx: Default::default(), 89 | input: None, 90 | client_time: None, 91 | last_update: None, 92 | last_visuals: Default::default(), 93 | } 94 | }); 95 | 96 | client.tcp_endpoint = Some(tcp_endpoint); 97 | 98 | // TODO: send egui::FontDefinitions to client 99 | 100 | tracing::info!("{} connected", client.info()); 101 | } 102 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { 103 | break; // No (more) new clients 104 | } 105 | Err(err) => { 106 | anyhow::bail!("eterm server TCP error: {:?}", err); 107 | } 108 | } 109 | } 110 | Ok(()) 111 | } 112 | 113 | /// non-blocking 114 | fn try_receive(&mut self) { 115 | for client in self.clients.values_mut() { 116 | client.try_receive(); 117 | } 118 | } 119 | } 120 | 121 | // ---------------------------------------------------------------------------- 122 | 123 | struct Client { 124 | client_id: ClientId, 125 | addr: SocketAddr, 126 | tcp_endpoint: Option, 127 | start_time: std::time::Instant, 128 | frame_index: u64, 129 | egui_ctx: egui::CtxRef, 130 | /// Set when there is something to do. Cleared after painting. 131 | input: Option, 132 | /// The client time of the last input we got from them. 133 | client_time: Option, 134 | last_update: Option, 135 | last_visuals: Vec, 136 | } 137 | 138 | impl Client { 139 | fn disconnect(&mut self) { 140 | self.tcp_endpoint = None; 141 | self.last_visuals = Default::default(); 142 | } 143 | 144 | fn show( 145 | &mut self, 146 | do_ui: &mut dyn FnMut(&egui::CtxRef, ClientId), 147 | minimum_update_interval: f32, 148 | ) { 149 | if self.tcp_endpoint.is_none() { 150 | return; 151 | } 152 | 153 | let client_time = self.client_time.take(); 154 | 155 | let mut input = match self.input.take() { 156 | Some(input) => input, 157 | None => { 158 | let time_since_last_update = 159 | self.last_update.map_or(f32::INFINITY, |last_update| { 160 | last_update.elapsed().as_secs_f32() 161 | }); 162 | if time_since_last_update > minimum_update_interval { 163 | Default::default() 164 | } else { 165 | return; 166 | } 167 | } 168 | }; 169 | 170 | self.last_update = Some(std::time::Instant::now()); 171 | 172 | // Ignore client time: 173 | input.time = Some(self.start_time.elapsed().as_secs_f64()); 174 | 175 | let (mut output, clipped_shapes) = self 176 | .egui_ctx 177 | .run(input, |egui_ctx| do_ui(egui_ctx, self.client_id)); 178 | 179 | let clipped_net_shapes = crate::net_shape::to_clipped_net_shapes(clipped_shapes); 180 | 181 | let needs_repaint = output.needs_repaint; 182 | output.needs_repaint = false; // so we can compare below 183 | 184 | if output == Default::default() && clipped_net_shapes == self.last_visuals { 185 | // No change - save bandwidth and send nothing 186 | } else { 187 | let frame_index = self.frame_index; 188 | self.frame_index += 1; 189 | 190 | let message = crate::ServerToClientMessage::Frame { 191 | frame_index, 192 | output, 193 | clipped_net_shapes: clipped_net_shapes.clone(), 194 | client_time, 195 | }; 196 | 197 | self.last_visuals = clipped_net_shapes; 198 | self.send_message(&message); 199 | } 200 | 201 | if needs_repaint { 202 | // eprintln!("frame {} painted, needs_repaint", frame_index); 203 | // Reschedule asap (don't wait for client) to request it. 204 | self.input = Some(Default::default()); 205 | } else { 206 | // eprintln!("frame {} painted", frame_index); 207 | } 208 | } 209 | 210 | fn info(&self) -> String { 211 | format!("Client {} ({})", self.client_id.0, self.addr) 212 | } 213 | 214 | fn send_message(&mut self, message: &impl serde::Serialize) { 215 | if let Some(tcp_endpoint) = &mut self.tcp_endpoint { 216 | match tcp_endpoint.send_message(&message) { 217 | Ok(()) => {} 218 | Err(err) => { 219 | tracing::error!( 220 | "Failed to send to client {:?} {}: {:?}. Disconnecting.", 221 | self.client_id, 222 | self.addr, 223 | crate::error_display_chain(err.as_ref()) 224 | ); 225 | self.disconnect(); 226 | } 227 | } 228 | } 229 | } 230 | 231 | /// non-blocking 232 | fn try_receive(&mut self) { 233 | loop { 234 | let tcp_endpoint = match &mut self.tcp_endpoint { 235 | Some(tcp_endpoint) => tcp_endpoint, 236 | None => return, 237 | }; 238 | 239 | let message = match tcp_endpoint.try_receive_message() { 240 | Ok(None) => { 241 | return; 242 | } 243 | Ok(Some(message)) => message, 244 | Err(err) => { 245 | tracing::error!( 246 | "Failed to read from client {}: {:?}. Disconnecting.", 247 | self.info(), 248 | crate::error_display_chain(err.as_ref()) 249 | ); 250 | self.disconnect(); 251 | return; 252 | } 253 | }; 254 | 255 | match message { 256 | ClientToServerMessage::Input { 257 | raw_input, 258 | client_time, 259 | } => { 260 | // eprintln!("Received new input"); 261 | self.input(raw_input); 262 | self.client_time = Some(client_time); 263 | // keep polling for more messages 264 | } 265 | ClientToServerMessage::Goodbye => { 266 | self.disconnect(); 267 | return; 268 | } 269 | } 270 | } 271 | } 272 | 273 | fn input(&mut self, new_input: RawInput) { 274 | match &mut self.input { 275 | None => { 276 | self.input = Some(new_input); 277 | } 278 | Some(existing_input) => { 279 | existing_input.append(new_input); 280 | } 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /eterm_viewer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "eterm_viewer" 3 | version = "0.0.1" 4 | edition = "2021" 5 | rust-version = "1.56" 6 | authors = ["Emil Ernerfeldt "] 7 | description = "Remove viewer for eterm, a visual terminal for egui" 8 | homepage = "https://github.com/emilk/eterm" 9 | license = "MIT OR Apache-2.0" 10 | readme = "../README.md" 11 | repository = "https://github.com/emilk/egui" 12 | categories = ["gui"] 13 | keywords = ["gui", "egui", "terminal", "thin client", "eterm"] 14 | include = [ 15 | "../LICENSE-APACHE", 16 | "../LICENSE-MIT", 17 | "**/*.rs", 18 | "Cargo.toml", 19 | ] 20 | 21 | [package.metadata.docs.rs] 22 | all-features = true 23 | 24 | [dependencies] 25 | argh = "0.1.6" 26 | egui = "0.16.0" 27 | egui_glium = "0.16.0" 28 | eterm = { version = "0.0.1", path = "../eterm" } 29 | glium = "0.31" 30 | tracing = "0.1" 31 | tracing-subscriber = "0.3" 32 | -------------------------------------------------------------------------------- /eterm_viewer/src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![warn( 3 | clippy::all, 4 | clippy::await_holding_lock, 5 | clippy::char_lit_as_u8, 6 | clippy::checked_conversions, 7 | clippy::dbg_macro, 8 | clippy::debug_assert_with_mut_call, 9 | clippy::doc_markdown, 10 | clippy::empty_enum, 11 | clippy::enum_glob_use, 12 | clippy::exit, 13 | clippy::expl_impl_clone_on_copy, 14 | clippy::explicit_deref_methods, 15 | clippy::explicit_into_iter_loop, 16 | clippy::fallible_impl_from, 17 | clippy::filter_map_next, 18 | clippy::float_cmp_const, 19 | clippy::fn_params_excessive_bools, 20 | clippy::if_let_mutex, 21 | clippy::imprecise_flops, 22 | clippy::inefficient_to_string, 23 | clippy::invalid_upcast_comparisons, 24 | clippy::large_types_passed_by_value, 25 | clippy::let_unit_value, 26 | clippy::linkedlist, 27 | clippy::lossy_float_literal, 28 | clippy::macro_use_imports, 29 | clippy::manual_ok_or, 30 | clippy::map_err_ignore, 31 | clippy::map_flatten, 32 | clippy::match_on_vec_items, 33 | clippy::match_same_arms, 34 | clippy::match_wildcard_for_single_variants, 35 | clippy::mem_forget, 36 | clippy::mismatched_target_os, 37 | clippy::missing_errors_doc, 38 | clippy::missing_safety_doc, 39 | clippy::mut_mut, 40 | clippy::mutex_integer, 41 | clippy::needless_borrow, 42 | clippy::needless_continue, 43 | clippy::needless_pass_by_value, 44 | clippy::option_option, 45 | clippy::path_buf_push_overwrite, 46 | clippy::ptr_as_ptr, 47 | clippy::ref_option_ref, 48 | clippy::rest_pat_in_fully_bound_structs, 49 | clippy::same_functions_in_if_condition, 50 | clippy::string_add_assign, 51 | clippy::string_add, 52 | clippy::string_lit_as_bytes, 53 | clippy::string_to_string, 54 | clippy::todo, 55 | clippy::trait_duplication_in_bounds, 56 | clippy::unimplemented, 57 | clippy::unnested_or_patterns, 58 | clippy::unused_self, 59 | clippy::useless_transmute, 60 | clippy::verbose_file_reads, 61 | clippy::zero_sized_map_values, 62 | future_incompatible, 63 | missing_crate_level_docs, 64 | nonstandard_style, 65 | rust_2018_idioms 66 | )] 67 | #![allow(clippy::float_cmp)] 68 | #![allow(clippy::manual_range_contains)] 69 | 70 | use eterm::EguiFrame; 71 | use glium::glutin; 72 | 73 | /// We reserve this much space for eterm to show some stats. 74 | /// The rest is used for the view of the remove server. 75 | const TOP_BAR_HEIGHT: f32 = 24.0; 76 | 77 | /// Repaint every so often to check connection status etc. 78 | const MIN_REPAINT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); 79 | 80 | /// eterm viewer viewer. 81 | /// 82 | /// Connects to an eterm server somewhere. 83 | #[derive(argh::FromArgs)] 84 | struct Arguments { 85 | /// which server to connect to, e.g. `127.0.0.1:8505`. 86 | #[argh(option)] 87 | url: String, 88 | } 89 | 90 | fn main() { 91 | // Log to stdout (if you run with `RUST_LOG=debug`). 92 | tracing_subscriber::fmt::init(); 93 | 94 | let opt: Arguments = argh::from_env(); 95 | let mut client = eterm::Client::new(opt.url); 96 | 97 | let event_loop = glutin::event_loop::EventLoop::with_user_event(); 98 | let display = create_display(&event_loop); 99 | 100 | let mut egui_glium = egui_glium::EguiGlium::new(&display); 101 | 102 | let mut last_sent_input = None; 103 | 104 | let mut latest_eterm_meshes = Default::default(); 105 | 106 | let mut needs_repaint = true; 107 | let mut last_repaint = std::time::Instant::now(); 108 | 109 | event_loop.run(move |event, _, control_flow| { 110 | let mut redraw = || { 111 | let raw_input = egui_glium 112 | .egui_winit 113 | .take_egui_input(display.gl_window().window()); 114 | 115 | let mut sent_input = raw_input.clone(); 116 | sent_input.time = None; // server knows the time 117 | if let Some(screen_rect) = &mut sent_input.screen_rect { 118 | screen_rect.min.y += TOP_BAR_HEIGHT; 119 | screen_rect.max.y = screen_rect.max.y.max(screen_rect.min.y); 120 | } 121 | 122 | if last_sent_input.as_ref() != Some(&sent_input) { 123 | client.send_input(sent_input.clone()); 124 | last_sent_input = Some(sent_input); 125 | needs_repaint = true; 126 | } 127 | 128 | let pixels_per_point = egui_glium.egui_winit.pixels_per_point(); 129 | if let Some(frame) = client.update(pixels_per_point) { 130 | // We got something new from the server! 131 | let EguiFrame { 132 | frame_index: _, 133 | output, 134 | clipped_meshes, 135 | } = frame; 136 | 137 | egui_glium.egui_winit.handle_output( 138 | display.gl_window().window(), 139 | &egui_glium.egui_ctx, 140 | output, 141 | ); 142 | 143 | latest_eterm_meshes = clipped_meshes; 144 | needs_repaint = true; 145 | } 146 | 147 | if needs_repaint || last_repaint.elapsed() > MIN_REPAINT_INTERVAL { 148 | needs_repaint = false; 149 | last_repaint = std::time::Instant::now(); 150 | 151 | // paint the eterm viewer ui: 152 | let (egui_output, clipped_shapes) = egui_glium 153 | .egui_ctx 154 | .run(raw_input, |egui_ctx| client_gui(egui_ctx, &client)); 155 | 156 | needs_repaint |= egui_output.needs_repaint; 157 | egui_glium.egui_winit.handle_output( 158 | display.gl_window().window(), 159 | &egui_glium.egui_ctx, 160 | egui_output, 161 | ); 162 | 163 | use glium::Surface as _; 164 | let mut target = display.draw(); 165 | 166 | let cc = egui::Rgba::from_rgb(0.1, 0.3, 0.2); 167 | target.clear_color(cc[0], cc[1], cc[2], cc[3]); 168 | 169 | egui_glium.painter.paint_meshes( 170 | &display, 171 | &mut target, 172 | pixels_per_point, 173 | latest_eterm_meshes.clone(), 174 | &client.font_image(), 175 | ); 176 | 177 | egui_glium.paint(&display, &mut target, clipped_shapes); 178 | 179 | target.finish().unwrap(); 180 | } 181 | 182 | std::thread::sleep(std::time::Duration::from_millis(10)); 183 | 184 | display.gl_window().window().request_redraw(); 185 | *control_flow = glutin::event_loop::ControlFlow::Wait; 186 | }; 187 | 188 | match event { 189 | // Platform-dependent event handlers to workaround a winit bug 190 | // See: https://github.com/rust-windowing/winit/issues/987 191 | // See: https://github.com/rust-windowing/winit/issues/1619 192 | glutin::event::Event::RedrawEventsCleared if cfg!(windows) => redraw(), 193 | glutin::event::Event::RedrawRequested(_) if !cfg!(windows) => redraw(), 194 | 195 | glutin::event::Event::WindowEvent { event, .. } => { 196 | use glutin::event::WindowEvent; 197 | if matches!(event, WindowEvent::CloseRequested | WindowEvent::Destroyed) { 198 | *control_flow = glium::glutin::event_loop::ControlFlow::Exit; 199 | } 200 | 201 | egui_glium.on_event(&event); 202 | 203 | display.gl_window().window().request_redraw(); 204 | } 205 | 206 | _ => (), 207 | } 208 | }); 209 | } 210 | 211 | fn create_display(event_loop: &glutin::event_loop::EventLoop<()>) -> glium::Display { 212 | let window_builder = glutin::window::WindowBuilder::new() 213 | .with_resizable(true) 214 | .with_inner_size(glutin::dpi::LogicalSize { 215 | width: 800.0, 216 | height: 600.0, 217 | }) 218 | .with_title("eterm viewer"); 219 | 220 | let context_builder = glutin::ContextBuilder::new() 221 | .with_depth_buffer(0) 222 | .with_double_buffer(Some(true)) 223 | .with_srgb(true) 224 | .with_stencil_buffer(0) 225 | .with_vsync(true); 226 | 227 | glium::Display::new(window_builder, context_builder, event_loop).unwrap() 228 | } 229 | 230 | fn client_gui(ctx: &egui::CtxRef, client: &eterm::Client) { 231 | // Chose a theme that sets us apart from the server: 232 | let mut visuals = ctx.style().visuals.clone(); 233 | let panel_background = if visuals.dark_mode { 234 | egui::Color32::from_rgb(55, 0, 105) 235 | } else { 236 | egui::Color32::from_rgb(255, 240, 0) 237 | }; 238 | visuals.widgets.noninteractive.bg_fill = panel_background; 239 | ctx.set_visuals(visuals); 240 | 241 | let height = TOP_BAR_HEIGHT - 4.0; // add some breathing room 242 | 243 | egui::TopBottomPanel::top("eterm_viewer_panel") 244 | .height_range(height..=height) 245 | .show(ctx, |ui| { 246 | ui.horizontal(|ui| { 247 | client_info_bar(ui, client); 248 | }); 249 | }); 250 | } 251 | 252 | fn client_info_bar(ui: &mut egui::Ui, client: &eterm::Client) { 253 | if client.is_connected() { 254 | ui.label(format!("Connected to {}", client.addr(),)); 255 | ui.separator(); 256 | ui.label(format!("{:.2} MB/s", client.bytes_per_second() * 1e-6)); 257 | ui.separator(); 258 | ui.label(format!( 259 | "{:5.1} kB / frame", 260 | client.average_frame_packet_size().unwrap_or(0.0) * 1e-3 261 | )); 262 | ui.separator(); 263 | ui.label("adaptive FPS:"); 264 | let fps = client.adaptive_fps().unwrap_or(0.0); 265 | ui.add_sized( 266 | [16.0, ui.available_height()], 267 | egui::Label::new(format!("{:.0}", fps)), 268 | ); 269 | ui.separator(); 270 | match client.latency() { 271 | Some(latency) => ui.label(format!("latency: {:.0} ms", latency * 1e3)), 272 | None => ui.label("latency: "), 273 | }; 274 | } else { 275 | ui.label(format!("Connecting to {}…", client.addr())); 276 | } 277 | } 278 | --------------------------------------------------------------------------------