├── Trunk.toml ├── .gitignore ├── assets ├── Logo.png ├── favicon.ico ├── icon-256.png ├── Icon.afdesign ├── Logo.afdesign ├── ShareTech.ttf ├── icon-1024.png ├── ShareTechMono.ttf ├── favicon-16x16.png ├── favicon-32x32.png ├── UI-Description.png ├── apple-touch-icon.png ├── icon_ios_touch_192.png ├── maskable_icon_x512.png ├── UI-Description.afdesign ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── sw.js ├── manifest.json ├── Icon.svg ├── Logo.svg └── OFL.txt ├── src ├── app │ ├── graphics │ │ ├── mod.rs │ │ └── pan_zoom_container.rs │ ├── graph │ │ ├── mutex_node.rs │ │ ├── activity_node.rs │ │ ├── connection.rs │ │ └── mod.rs │ └── mod.rs ├── lib.rs └── main.rs ├── shell.nix ├── rust-toolchain ├── check.sh ├── .cargo └── config.toml ├── .vscode └── launch.json ├── Cargo.toml ├── .github └── workflows │ ├── pages.yml │ └── rust.yml ├── README.md └── index.html /Trunk.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /dist 3 | -------------------------------------------------------------------------------- /assets/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/Logo.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/icon-256.png -------------------------------------------------------------------------------- /src/app/graphics/mod.rs: -------------------------------------------------------------------------------- 1 | pub use pan_zoom_container::*; 2 | 3 | mod pan_zoom_container; 4 | -------------------------------------------------------------------------------- /assets/Icon.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/Icon.afdesign -------------------------------------------------------------------------------- /assets/Logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/Logo.afdesign -------------------------------------------------------------------------------- /assets/ShareTech.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/ShareTech.ttf -------------------------------------------------------------------------------- /assets/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/icon-1024.png -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, rust_2018_idioms)] 2 | 3 | mod app; 4 | pub use app::App; 5 | -------------------------------------------------------------------------------- /assets/ShareTechMono.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/ShareTechMono.ttf -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/favicon-32x32.png -------------------------------------------------------------------------------- /assets/UI-Description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/UI-Description.png -------------------------------------------------------------------------------- /assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/icon_ios_touch_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/icon_ios_touch_192.png -------------------------------------------------------------------------------- /assets/maskable_icon_x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/maskable_icon_x512.png -------------------------------------------------------------------------------- /assets/UI-Description.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/UI-Description.afdesign -------------------------------------------------------------------------------- /assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyCraftix/tsyncs/HEAD/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | pkgs.mkShell rec { 3 | nativeBuildInputs = with pkgs; [ 4 | rustup 5 | cargo 6 | rustc 7 | trunk 8 | pkg-config 9 | wayland 10 | libxkbcommon 11 | xorg.libX11 12 | xorg.libXcursor 13 | xorg.libXrandr 14 | xorg.libXi 15 | libglvnd 16 | libsForQt5.kdialog 17 | ]; 18 | LD_LIBRARY_PATH="${pkgs.libglvnd}/lib:${pkgs.libxkbcommon}/lib:${pkgs.xorg.libX11}/lib"; 19 | } 20 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | # If you see this, run "rustup self update" to get rustup 1.23 or newer. 2 | 3 | # NOTE: above comment is for older `rustup` (before TOML support was added), 4 | # which will treat the first line as the toolchain name, and therefore show it 5 | # to the user in the error, instead of "error: invalid channel name '[toolchain]'". 6 | 7 | [toolchain] 8 | channel = "1.75.0" 9 | components = [ "rustfmt", "clippy" ] 10 | targets = [ "wasm32-unknown-unknown" ] 11 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This scripts runs various CI-like checks in a convenient way. 3 | set -eux 4 | 5 | cargo check --quiet --workspace --all-targets 6 | cargo check --quiet --workspace --all-features --lib --target wasm32-unknown-unknown 7 | cargo fmt --all -- --check 8 | cargo clippy --quiet --workspace --all-targets --all-features -- -D warnings -W clippy::all 9 | cargo test --quiet --workspace --all-targets --all-features 10 | cargo test --quiet --workspace --doc 11 | trunk build 12 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # clipboard api is still unstable, so web-sys requires the below flag to be passed for copy (ctrl + c) to work 2 | # https://rustwasm.github.io/docs/wasm-bindgen/web-sys/unstable-apis.html 3 | # check status at https://developer.mozilla.org/en-US/docs/Web/API/Clipboard#browser_compatibility 4 | # we don't use `[build]` because of rust analyzer's build cache invalidation https://github.com/emilk/eframe_template/issues/93 5 | [target.wasm32-unknown-unknown] 6 | rustflags = ["--cfg=web_sys_unstable_apis"] -------------------------------------------------------------------------------- /assets/sw.js: -------------------------------------------------------------------------------- 1 | var cacheName = 'tsyncs'; 2 | var filesToCache = [ 3 | './', 4 | './index.html', 5 | './tsyncs.js', 6 | './tsyncs_bg.wasm', 7 | ]; 8 | 9 | /* Start the service worker and cache all of the app's content */ 10 | self.addEventListener('install', function (e) { 11 | e.waitUntil( 12 | caches.open(cacheName).then(function (cache) { 13 | return cache.addAll(filesToCache); 14 | }) 15 | ); 16 | }); 17 | 18 | /* Serve cached content when offline */ 19 | self.addEventListener('fetch', function (e) { 20 | e.respondWith( 21 | caches.match(e.request).then(function (response) { 22 | return response || fetch(e.request); 23 | }) 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsyncs", 3 | "short_name": "tsyncs", 4 | "icons": [ 5 | { 6 | "src": "./icon-256.png", 7 | "sizes": "256x256", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./maskable_icon_x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png", 14 | "purpose": "any maskable" 15 | }, 16 | { 17 | "src": "./icon-1024.png", 18 | "sizes": "1024x1024", 19 | "type": "image/png" 20 | } 21 | ], 22 | "lang": "en-US", 23 | "id": "/index.html", 24 | "start_url": "./index.html", 25 | "display": "standalone", 26 | "background_color": "white", 27 | "theme_color": "white" 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Verwendet IntelliSense zum Ermitteln möglicher Attribute. 3 | // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. 4 | // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'tsyncs'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=tsyncs", 15 | "--package=tsyncs" 16 | ], 17 | "filter": { 18 | "name": "tsyncs", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "type": "lldb", 27 | "request": "launch", 28 | "name": "Debug unit tests in executable 'tsyncs'", 29 | "cargo": { 30 | "args": [ 31 | "test", 32 | "--no-run", 33 | "--bin=tsyncs", 34 | "--package=tsyncs" 35 | ], 36 | "filter": { 37 | "name": "tsyncs", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [], 42 | "cwd": "${workspaceFolder}" 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, rust_2018_idioms)] 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 3 | 4 | // When compiling natively: 5 | #[cfg(not(target_arch = "wasm32"))] 6 | fn main() -> eframe::Result<()> { 7 | env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). 8 | 9 | let native_options = eframe::NativeOptions { 10 | viewport: egui::ViewportBuilder::default() 11 | .with_inner_size([1200.0, 600.0]) 12 | .with_min_inner_size([1200.0, 600.0]) 13 | .with_icon( 14 | // NOE: Adding an icon is optional 15 | eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..]) 16 | .unwrap(), 17 | ), 18 | ..Default::default() 19 | }; 20 | eframe::run_native( 21 | "tsyncs", 22 | native_options, 23 | Box::new(|cc| Box::new(tsyncs::App::new(cc))), 24 | ) 25 | } 26 | 27 | // When compiling to web using trunk: 28 | #[cfg(target_arch = "wasm32")] 29 | fn main() { 30 | // Redirect `log` message to `console.log` and friends: 31 | eframe::WebLogger::init(log::LevelFilter::Debug).ok(); 32 | 33 | let web_options = eframe::WebOptions::default(); 34 | 35 | wasm_bindgen_futures::spawn_local(async { 36 | eframe::WebRunner::new() 37 | .start( 38 | "the_canvas_id", // hardcode it 39 | web_options, 40 | Box::new(|cc| Box::new(tsyncs::App::new(cc))), 41 | ) 42 | .await 43 | .expect("failed to start eframe"); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /assets/Icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tsyncs" 3 | version = "0.1.0" 4 | edition = "2021" 5 | rust-version = "1.72" 6 | 7 | 8 | [dependencies] 9 | egui = "0.27.2" 10 | eframe = { version = "0.27.2", default-features = false, features = [ 11 | #"default", # Use the default set of features. 12 | "glow", # Use the glow rendering backend. Alternative: "wgpu". 13 | "persistence", # Enable restoring app state when restarting the app. 14 | ] } 15 | 16 | egui_extras = { version = "0.27.2", features = ["default", "all_loaders", "image", "svg"] } 17 | image = {version = "0.24", default-features = false, features = ["png"]} 18 | 19 | log = "0.4" 20 | 21 | # You only need serde if you want app persistence: 22 | serde = { version = "1", features = ["derive"] } 23 | indexmap = { version = "2.2.6", features = ["serde"] } 24 | rand = "0.8.5" 25 | rfd = "0.14.1" 26 | serde_json = "1.0.115" 27 | random_word = { version = "0.4.3", features = ["en"] } 28 | 29 | # native: 30 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 31 | futures = "0.3.30" 32 | env_logger = "0.10" 33 | dirs = "5.0.1" 34 | 35 | # web: 36 | [target.'cfg(target_arch = "wasm32")'.dependencies] 37 | wasm-bindgen-futures = "0.4" 38 | getrandom = { version = "0.2.12", features = ["js"] } # enable js feature 39 | 40 | 41 | [profile.release] 42 | opt-level = 2 # fast and small wasm 43 | 44 | # Optimize all dependencies even in debug builds: 45 | [profile.dev.package."*"] 46 | opt-level = 2 47 | 48 | 49 | [patch.crates-io] 50 | # If you want to use the bleeding edge version of egui and eframe: 51 | # egui = { git = "https://github.com/emilk/egui", branch = "master" } 52 | # eframe = { git = "https://github.com/emilk/egui", branch = "master" } 53 | 54 | # If you fork https://github.com/emilk/egui you can test with: 55 | # egui = { path = "../egui/crates/egui" } 56 | # eframe = { path = "../egui/crates/eframe" } 57 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Github Pages 2 | 3 | # By default, runs if you push to master. keeps your deployed app in sync with master branch. 4 | on: 5 | push: 6 | branches: 7 | - master 8 | # to only run when you do a new github release, comment out above part and uncomment the below trigger. 9 | # on: 10 | # release: 11 | # types: 12 | # - published 13 | 14 | permissions: 15 | contents: write # for committing to gh-pages branch. 16 | 17 | jobs: 18 | build-github-pages: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 # repo checkout 22 | - uses: actions-rs/toolchain@v1 # get rust toolchain for wasm 23 | with: 24 | profile: minimal 25 | toolchain: stable 26 | target: wasm32-unknown-unknown 27 | override: true 28 | - name: Rust Cache # cache the rust build artefacts 29 | uses: Swatinem/rust-cache@v1 30 | - name: Download and install Trunk binary 31 | run: wget -qO- https://github.com/thedodd/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf- 32 | - name: Build # build 33 | # Environment $public_url resolves to the github project page. 34 | # If using a user/organization page, remove the `${{ github.event.repository.name }}` part. 35 | # using --public-url something will allow trunk to modify all the href paths like from favicon.ico to repo_name/favicon.ico . 36 | # this is necessary for github pages where the site is deployed to username.github.io/repo_name and all files must be requested 37 | # relatively as repository_name/favicon.ico. if we skip public-url option, the href paths will instead request username.github.io/favicon.ico which 38 | # will obviously return error 404 not found. 39 | run: ./trunk build --release --public-url $public_url 40 | env: 41 | #public_url: "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}" 42 | public_url: "https://tsyncs.de" 43 | - name: Deploy 44 | uses: JamesIves/github-pages-deploy-action@v4 45 | with: 46 | folder: dist 47 | # this option will not maintain any history of your previous pages deployment 48 | # set to false if you want all page build to be committed to your gh-pages branch history 49 | single-commit: true 50 | -------------------------------------------------------------------------------- /src/app/graph/mutex_node.rs: -------------------------------------------------------------------------------- 1 | #[derive(Default, serde::Deserialize, serde::Serialize)] 2 | pub struct MutexNode { 3 | pub pos: egui::Pos2, 4 | pub value: u32, 5 | 6 | #[serde(skip)] 7 | response_outer_id: Option, 8 | #[serde(skip)] 9 | response_value_id: Option, 10 | } 11 | 12 | impl Clone for MutexNode { 13 | fn clone(&self) -> Self { 14 | Self { 15 | pos: self.pos, 16 | value: self.value, 17 | response_outer_id: None, 18 | response_value_id: None, 19 | } 20 | } 21 | } 22 | 23 | impl MutexNode { 24 | pub fn new(pos: egui::Pos2) -> Self { 25 | Self { 26 | pos, 27 | ..Default::default() 28 | } 29 | } 30 | 31 | pub fn interact(&mut self, ui: &egui::Ui) -> Option { 32 | if let (Some(Some(response_outer)), Some(Some(response_value))) = ( 33 | self.response_outer_id 34 | .map(|response_outer_id| ui.ctx().read_response(response_outer_id)), 35 | self.response_value_id 36 | .map(|response_value_id| ui.ctx().read_response(response_value_id)), 37 | ) { 38 | if !ui.ctx().input(|i| i.pointer.secondary_down()) 39 | && (response_outer.dragged() || response_outer.drag_stopped()) 40 | { 41 | self.pos += response_outer.drag_delta(); 42 | response_value.surrender_focus(); 43 | } 44 | 45 | Some(response_outer | response_value) 46 | } else { 47 | None 48 | } 49 | } 50 | 51 | pub fn draw(&mut self, ui: &mut egui::Ui, container_transform: egui::emath::TSTransform) { 52 | let style = ui.ctx().style().visuals.widgets.inactive; 53 | 54 | let mut ui = ui.child_ui(ui.max_rect(), *ui.layout()); 55 | //ui.set_enabled(!ui.ctx().input(|i| i.pointer.secondary_down())); 56 | 57 | let outer_rect = egui::Rect::from_center_size(self.pos, egui::Vec2::splat(30.)); 58 | 59 | let mut stroke = style.fg_stroke; 60 | if self.value != 0 { 61 | stroke.color = egui::Color32::GREEN; 62 | stroke.width = 1.5; 63 | } 64 | ui.painter().rect_filled(outer_rect, 0., style.bg_fill); 65 | ui.painter().rect_stroke(outer_rect, 0., stroke); 66 | let response_outer = ui.allocate_rect(outer_rect, egui::Sense::click_and_drag()); 67 | self.response_outer_id = Some(response_outer.id); 68 | 69 | let response_value = ui.put( 70 | egui::Rect::from_center_size(self.pos, egui::Vec2::splat(15.)), 71 | egui::DragValue::new(&mut self.value) 72 | .update_while_editing(false) 73 | .speed(container_transform.scaling * 0.05), 74 | ); 75 | self.response_value_id = Some(response_value.id); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | 7 | name: CI 8 | 9 | env: 10 | # This is required to enable the web_sys clipboard API which egui_web uses 11 | # https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Clipboard.html 12 | # https://rustwasm.github.io/docs/wasm-bindgen/web-sys/unstable-apis.html 13 | RUSTFLAGS: --cfg=web_sys_unstable_apis 14 | 15 | jobs: 16 | check: 17 | name: Check 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions-rs/toolchain@v1 22 | with: 23 | profile: minimal 24 | toolchain: stable 25 | override: true 26 | - uses: actions-rs/cargo@v1 27 | with: 28 | command: check 29 | args: --all-features 30 | 31 | check_wasm: 32 | name: Check wasm32 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v2 36 | - uses: actions-rs/toolchain@v1 37 | with: 38 | profile: minimal 39 | toolchain: stable 40 | target: wasm32-unknown-unknown 41 | override: true 42 | - uses: actions-rs/cargo@v1 43 | with: 44 | command: check 45 | args: --all-features --lib --target wasm32-unknown-unknown 46 | 47 | test: 48 | name: Test Suite 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v2 52 | - uses: actions-rs/toolchain@v1 53 | with: 54 | profile: minimal 55 | toolchain: stable 56 | override: true 57 | - run: sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev 58 | - uses: actions-rs/cargo@v1 59 | with: 60 | command: test 61 | args: --lib 62 | 63 | fmt: 64 | name: Rustfmt 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v2 68 | - uses: actions-rs/toolchain@v1 69 | with: 70 | profile: minimal 71 | toolchain: stable 72 | override: true 73 | components: rustfmt 74 | - uses: actions-rs/cargo@v1 75 | with: 76 | command: fmt 77 | args: --all -- --check 78 | 79 | clippy: 80 | name: Clippy 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v2 84 | - uses: actions-rs/toolchain@v1 85 | with: 86 | profile: minimal 87 | toolchain: stable 88 | override: true 89 | components: clippy 90 | - uses: actions-rs/cargo@v1 91 | with: 92 | command: clippy 93 | args: -- -D warnings 94 | 95 | trunk: 96 | name: trunk 97 | runs-on: ubuntu-latest 98 | steps: 99 | - uses: actions/checkout@v2 100 | - uses: actions-rs/toolchain@v1 101 | with: 102 | profile: minimal 103 | toolchain: 1.72.0 104 | target: wasm32-unknown-unknown 105 | override: true 106 | - name: Download and install Trunk binary 107 | run: wget -qO- https://github.com/thedodd/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf- 108 | - name: Build 109 | run: ./trunk build 110 | -------------------------------------------------------------------------------- /assets/Logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2012 The Share Tech Project Authors (post@carrois.com), with Reserved Font Name 'Share’. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | https://openfontlicense.org 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | tsyncs logo 2 | 3 | # Task Synchronization Simulator 4 | A simple tool to simulate the execution of interdependent tasks. 5 | Try it out at [tsyncs.de](https://tsyncs.de/). 6 | 7 | ## Tutorial 8 | tsyncs provides a convenient way to load and visualize task graphs. 9 | At the top is the menu bar. 10 | Here you can import and export task charts, save and load your current session, and start a new session. 11 | You can also edit the diagram and change the view. 12 | 13 | ![UserInterface](assets/UI-Description.png) 14 | 15 | ### File Menu 16 | To load a graph, select `File -> Import Graph...` and select a CSV file containing the task graph. 17 | You can also export the current graph as a CSV file by choosing `File -> Export Graph...`. 18 | You can also save the current session by selecting `File -> Save Graph...` and load a saved session by selecting `File -> Load Graph`. 19 | 20 | To start a new empty session select `File -> New Graph`. 21 | 22 | ### Editing the Graph 23 | The task graph is displayed in the center of the screen. 24 | You can zoom in and out using `CTRL` `mouse wheel` and pan by dragging the mouse. 25 | You can move nodes by dragging them. 26 | 27 | The tasks and mutexes are connected by arrows, which represent the dependencies between the tasks. 28 | A running task is highlighted by a green border, a waiting task by a red border. 29 | 30 | The circle in the top right corner of a task shows the remaining duration above the full duration of the task. 31 | The task priority is shown in the bottom right corner. 32 | 33 | Mutexes simply show their current count. 34 | 35 | All these values can be edited by dragging, or by clicking on them. 36 | 37 | #### Adding Tasks and Mutexes 38 | You can add a new task by right clicking on an empty area of the canvas. 39 | Using right click, you can connect existing nodes, or create new nodes that are immediately connected. 40 | Connections can be deleted the same way they are created. 41 | 42 | #### Deleting a Task or Mutex 43 | To delete a task or mutex, click on `Edit -> Delete mode` to activate the delete mode. 44 | Now you can click on any node to delete it. 45 | Exist delete mode using right click, or by clicking the warning at the top of the screen. 46 | 47 | ### Simulation Settings 48 | At the bottom you will find the simulation settings. 49 | 50 | The slider on the left changes the simulation speed, and on the very right is a pause button ⏸. 51 | When the simulation is paused, you can use the `Single Step` button to manually advance the simulation by one tick. 52 | This button will schedule a new tick, which will then be executed at the configured simulation speed. 53 | You can see and edited the amount of scheduled ticks left of the `Single Step` button. 54 | 55 | ### File Format 56 | You can export and import graphs to and from CSV files. 57 | There are two types of entries in the CSV file `Task` and `Mutex`. 58 | Task entries take the following format: 59 | ```csv 60 | Task; Position X; Position Y; ID; Task-Name; Activity-Name; Priority; Duration; Remaining Duration; [Semicolon seperated list of Connected Mutex IDs] 61 | ``` 62 | 63 | Mutex entries take the following format: 64 | ```csv 65 | Mutex; Position X; Position Y; ID; Mutex Value; [Semicolon seperated list of Connected Task IDs] 66 | ``` 67 | 68 | #### Example CSV file 69 | ```csv 70 | Type;Position X;Position Y;ID;Parameters... 71 | #Task;Position X;Position Y;ID;Task Name;Activity Name;Priority;Duration;Remaining Duration;[Semicolon seperated list of Connected Mutex IDs] 72 | Task;300;100;0;Task 2;Activity 2;0;3;0;0;2 73 | Task;150;250;1;Task 1;Activity 1;0;3;0;1;4 74 | Task;150;400;2;Task 5;Activity 5b;0;1;0;7;8 75 | Task;450;400;3;Task 5;Activity 5a;0;2;0;9 76 | Task;450;250;4;Task 3;Activity 3;0;2;0;2;5 77 | Task;600;100;5;Task 4;Activity 4;0;3;0;2;3 78 | Task;750;250;6;Task 6;Activity 6;0;3;0;6 79 | #Mutex;Position X;Position Y;ID;Mutex Value;[Semicolon seperated list of Connected Task IDs] 80 | Mutex;225;175;1;0;0 81 | Mutex;300;380;9;0;2 82 | Mutex;150;325;7;0;1 83 | Mutex;300;250;4;0;4 84 | Mutex;600;325;6;0;3 85 | Mutex;450;100;0;0;5 86 | Mutex;450;150;2;1;4;5;0 87 | Mutex;300;420;8;1;3 88 | Mutex;600;250;5;0;6 89 | Mutex;675;175;3;0;6 90 | ``` 91 | ## Building the Project 92 | To build the project, you need to have rust installed. 93 | You can install rust by following the instructions on the [official rust website](https://rustup.rs/). 94 | 95 | ### Native Build 96 | ```pwsh 97 | cargo run --release 98 | ``` 99 | This will build the project and start the application. 100 | 101 | ### Web Assembly Build 102 | To run the project for the web, you need to have `trunk` installed. 103 | You can install trunk by running the following command: 104 | ```pwsh 105 | cargo install trunk 106 | ``` 107 | 108 | Then start a local webserver using: 109 | ```pwsh 110 | trunk serve --release 111 | ``` 112 | Once the build is complete, you can open the URL shown by trunk in your browser. 113 | If you see only a blank or gray screen, try force-reloading the page. 114 | This can be done by pressing `CTRL` `F5` in most browsers. 115 | -------------------------------------------------------------------------------- /src/app/graphics/pan_zoom_container.rs: -------------------------------------------------------------------------------- 1 | use egui::{emath::TSTransform, Id, LayerId, Pos2, Vec2}; 2 | 3 | #[derive(Clone, PartialEq)] 4 | #[must_use = "You should call .show()"] 5 | pub struct PanZoomContainer { 6 | id_source: Id, 7 | min_size: Vec2, 8 | } 9 | 10 | #[allow(dead_code)] 11 | impl PanZoomContainer { 12 | /// create a new [`PanZoomContainer`] 13 | pub fn new() -> Self { 14 | Self { 15 | id_source: Id::NULL, 16 | min_size: Vec2::INFINITY, 17 | } 18 | } 19 | 20 | /// specify an id source, default is `Id::NULL` 21 | /// the final id will become `ui.id().with(id_source)` 22 | /// ids must be unique, see [`Id`] 23 | pub fn id_source(mut self, id_source: impl Into) -> Self { 24 | self.id_source = id_source.into(); 25 | self 26 | } 27 | 28 | /// specify the minimum size 29 | /// this will be capped at `ui.available_size_before_wrap()` 30 | /// default is `Vec2::INFINITY`, so effectively the cap 31 | /// the actual size may be bigger e.g. in a justified layout, see [`egui::Ui::allocate_space()`] 32 | pub fn min_size(mut self, min_size: Vec2) -> Self { 33 | self.min_size = min_size; 34 | self 35 | } 36 | } 37 | 38 | impl PanZoomContainer { 39 | pub fn show( 40 | self, 41 | ui: &mut egui::Ui, 42 | add_contents: impl FnOnce(&mut egui::Ui, &mut TSTransform, &egui::Response) -> R, 43 | ) -> egui::InnerResponse { 44 | let id = ui.id().with(self.id_source); 45 | 46 | // allocate space and check for interactions 47 | let available_size = ui.available_size_before_wrap(); 48 | let (_, rect) = ui.allocate_space(available_size.min(self.min_size)); 49 | let response = ui.interact(rect, id, egui::Sense::click_and_drag()); 50 | 51 | // update zomm and pan 52 | let mut state = PanZoomContainerState::load(ui.ctx(), id); 53 | state.handle_zoom_pan(&response); 54 | 55 | // draw on a transformed layer inside a child ui, decoupled from the surrounding ui 56 | // this seems to be the cleanest way to get this to work 57 | let mut ui = ui.child_ui(ui.max_rect(), *ui.layout()); 58 | let inner_response = ui 59 | .with_layer_id(LayerId::new(egui::Order::Middle, id), |ui| { 60 | ui.set_clip_rect(state.transform.inverse() * rect); 61 | ui.ctx().set_transform_layer(ui.layer_id(), state.transform); 62 | add_contents(ui, &mut state.transform, &response) 63 | }) 64 | .inner; 65 | 66 | state.store(ui.ctx(), id); 67 | 68 | egui::InnerResponse { 69 | inner: inner_response, 70 | response, 71 | } 72 | } 73 | } 74 | 75 | #[derive(Default, Clone, serde::Serialize, serde::Deserialize)] 76 | struct PanZoomContainerState { 77 | transform: TSTransform, 78 | last_center: Pos2, 79 | } 80 | 81 | impl PanZoomContainerState { 82 | fn load(context: &egui::Context, id: Id) -> Self { 83 | context 84 | .data_mut(|data| data.get_persisted(id)) 85 | .unwrap_or_default() 86 | } 87 | 88 | fn store(&self, context: &egui::Context, id: Id) { 89 | context.data_mut(|data| { 90 | data.insert_persisted(id, self.clone()); 91 | }); 92 | } 93 | 94 | fn handle_zoom_pan(&mut self, response: &egui::Response) { 95 | let mouse_position = response.ctx.input(|i| i.pointer.latest_pos()); 96 | if mouse_position.map_or(true, |pos| response.interact_rect.contains(pos)) { 97 | // zoom 98 | let zoom_delta = response.ctx.input(|i| i.zoom_delta()); 99 | if zoom_delta != 1. { 100 | let screen_space_zoom_anchor_position = response 101 | .ctx 102 | .input(|i| i.pointer.latest_pos()) 103 | .unwrap_or_default(); 104 | 105 | let transformed_space_zoom_anchor_position = 106 | self.transform.inverse() * screen_space_zoom_anchor_position; 107 | self.transform.scaling *= zoom_delta; 108 | let new_screen_space_zoom_anchor_position = 109 | self.transform * transformed_space_zoom_anchor_position; 110 | 111 | self.transform.translation += 112 | screen_space_zoom_anchor_position - new_screen_space_zoom_anchor_position; 113 | } 114 | 115 | // scroll 116 | let scroll_delta = response.ctx.input(|i| i.smooth_scroll_delta); 117 | self.transform.translation += scroll_delta; 118 | } 119 | 120 | // pan 121 | if !response.ctx.input(|i| i.pointer.secondary_down()) { 122 | self.transform.translation += response.drag_delta(); 123 | } 124 | 125 | // anchor the content in the center 126 | let center = response.rect.center(); 127 | self.transform.translation += center - self.last_center; 128 | self.last_center = center; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | tsyncs 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 137 | 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /src/app/graph/activity_node.rs: -------------------------------------------------------------------------------- 1 | use std::f32::consts::PI; 2 | 3 | #[derive(Default, serde::Deserialize, serde::Serialize)] 4 | pub struct ActivityNode { 5 | pub pos: egui::Pos2, 6 | pub task_name: String, 7 | pub activity_name: String, 8 | pub priority: u32, 9 | pub duration: u32, 10 | pub remaining_duration: u32, 11 | 12 | #[serde(skip)] 13 | response_outer_id: Option, 14 | #[serde(skip)] 15 | response_circle_id: Option, 16 | #[serde(skip)] 17 | response_task_name_id: Option, 18 | #[serde(skip)] 19 | response_activity_name_id: Option, 20 | } 21 | 22 | impl Clone for ActivityNode { 23 | fn clone(&self) -> Self { 24 | Self { 25 | pos: self.pos, 26 | task_name: self.task_name.clone(), 27 | activity_name: self.activity_name.clone(), 28 | priority: self.priority, 29 | duration: self.duration, 30 | remaining_duration: self.remaining_duration, 31 | response_outer_id: None, 32 | response_circle_id: None, 33 | response_task_name_id: None, 34 | response_activity_name_id: None, 35 | } 36 | } 37 | } 38 | 39 | impl ActivityNode { 40 | pub fn new(pos: egui::Pos2) -> Self { 41 | Self { 42 | pos, 43 | ..Default::default() 44 | } 45 | } 46 | 47 | pub fn interact(&mut self, ui: &egui::Ui) -> Option { 48 | if let ( 49 | Some(Some(response_outer)), 50 | Some(Some(response_circle)), 51 | Some(Some(response_task_name)), 52 | Some(Some(response_activity_name)), 53 | ) = ( 54 | self.response_outer_id 55 | .map(|response_outer_id| ui.ctx().read_response(response_outer_id)), 56 | self.response_circle_id 57 | .map(|response_circle_id| ui.ctx().read_response(response_circle_id)), 58 | self.response_task_name_id 59 | .map(|response_task_name_id| ui.ctx().read_response(response_task_name_id)), 60 | self.response_activity_name_id 61 | .map(|response_activity_name_id| ui.ctx().read_response(response_activity_name_id)), 62 | ) { 63 | let response_union = response_outer 64 | | response_circle 65 | | response_task_name.clone() 66 | | response_activity_name.clone(); 67 | if !ui.ctx().input(|i| i.pointer.secondary_down()) 68 | && (response_union.dragged() || response_union.drag_stopped()) 69 | { 70 | self.pos += response_union.drag_delta(); 71 | response_task_name.surrender_focus(); 72 | response_activity_name.surrender_focus(); 73 | } 74 | Some(response_union) 75 | } else { 76 | None 77 | } 78 | } 79 | 80 | pub fn draw( 81 | &mut self, 82 | ui: &mut egui::Ui, 83 | container_transform: egui::emath::TSTransform, 84 | tick_progress: f32, 85 | ) { 86 | const MAX_THREE_DIGIT_NUMBER: u32 = 999; 87 | let style = ui.style().visuals.widgets.inactive; 88 | 89 | let outline_stoke = match self.remaining_duration { 90 | 0 => egui::Stroke::new(2., egui::Color32::RED), 91 | _ => egui::Stroke::new(2.5, egui::Color32::GREEN), 92 | }; 93 | 94 | let text_field_width = 100.; 95 | 96 | let task_name_height = 20.; 97 | let activity_name_height = 18.; 98 | let textinput_height = 15.; 99 | 100 | let task_name_font = egui::FontId::proportional(18.); 101 | let activity_name_font = egui::FontId::proportional(15.5); 102 | 103 | let outer_padding = egui::vec2(6., 4.); 104 | let outer_size_without_padding = 105 | egui::vec2(text_field_width, task_name_height + activity_name_height); 106 | let outer_size = outer_size_without_padding + 2. * outer_padding; 107 | let outer_rect = egui::Rect::from_center_size(self.pos, outer_size); 108 | let outer_rounding = 10.; 109 | let priority_rect = egui::Rect::from_two_pos( 110 | outer_rect.right_bottom(), 111 | outer_rect.right_bottom() - egui::vec2(textinput_height * 2., textinput_height * 1.29), 112 | ); 113 | 114 | let circle_position = egui::pos2(priority_rect.center_top().x, outer_rect.right_top().y); 115 | let circle_radius = outer_size.y / 2.5; 116 | let circle_hitbox = 117 | egui::Rect::from_center_size(circle_position, egui::Vec2::splat(2. * circle_radius)); 118 | 119 | let priority_rounding = egui::Rounding { 120 | nw: outer_rounding, 121 | ne: 0., 122 | sw: 0., 123 | se: outer_rounding, 124 | }; 125 | 126 | // for debugging 127 | let frame = false; 128 | ui.child_ui(ui.max_rect(), *ui.layout()); 129 | 130 | ui.painter() 131 | .rect_filled(outer_rect, outer_rounding, style.bg_fill); 132 | 133 | if self.remaining_duration > 0 { 134 | let mut progress_rect = outer_rect; 135 | progress_rect.set_width( 136 | (1. - (self.remaining_duration as f32 - tick_progress) 137 | / (self.duration as f32 - 0.5)) 138 | * outer_rect.width(), 139 | ); 140 | let height = outer_rect.height() - outer_rounding 141 | + ((progress_rect.width() / outer_rounding / 2.).min(1.) * PI / 2.).sin() 142 | * outer_rounding; 143 | progress_rect.set_top(outer_rect.top() + (outer_rect.height() - height) / 2.); 144 | progress_rect.set_bottom(outer_rect.bottom() - (outer_rect.height() - height) / 2.); 145 | progress_rect.set_left(outer_rect.left()); 146 | ui.painter().rect_filled( 147 | progress_rect, 148 | outer_rounding, 149 | egui::Color32::from_rgba_unmultiplied(0, 255, 0, 5), 150 | ); 151 | } 152 | 153 | let response_outer = ui.allocate_rect(outer_rect, egui::Sense::click_and_drag()); 154 | self.response_outer_id = Some(response_outer.id); 155 | 156 | let response_circle = ui.allocate_rect(circle_hitbox, egui::Sense::click_and_drag()); 157 | self.response_circle_id = Some(response_circle.id); 158 | 159 | let response_task_name = ui.put( 160 | egui::Rect::from_center_size( 161 | self.pos 162 | - egui::vec2( 163 | (circle_radius + outer_padding.x / 2.) / 2., 164 | (outer_size_without_padding.y - task_name_height) / 2., 165 | ), 166 | egui::vec2( 167 | text_field_width - circle_radius - outer_padding.x / 2., 168 | task_name_height, 169 | ), 170 | ), 171 | egui::TextEdit::singleline(&mut self.task_name) 172 | .margin(egui::Margin::ZERO) 173 | .frame(frame) 174 | .vertical_align(egui::Align::Center) 175 | .font(task_name_font), 176 | ); 177 | self.response_task_name_id = Some(response_task_name.id); 178 | 179 | let response_activity_name = ui.put( 180 | egui::Rect::from_center_size( 181 | self.pos 182 | + egui::vec2( 183 | 0., 184 | (outer_size_without_padding.y - activity_name_height) / 2., 185 | ), 186 | egui::vec2(text_field_width, activity_name_height), 187 | ), 188 | egui::TextEdit::singleline(&mut self.activity_name) 189 | .margin(egui::Margin::ZERO) 190 | .frame(frame) 191 | .vertical_align(egui::Align::Center) 192 | .font(activity_name_font), 193 | ); 194 | self.response_activity_name_id = Some(response_activity_name.id); 195 | 196 | ui.painter() 197 | .rect_filled(priority_rect, priority_rounding, style.bg_fill); 198 | ui.painter() 199 | .rect_stroke(priority_rect, priority_rounding, style.fg_stroke); 200 | ui.painter() 201 | .rect_stroke(outer_rect, outer_rounding, outline_stoke); 202 | ui.painter() 203 | .circle_filled(circle_position, circle_radius, style.bg_fill); 204 | ui.painter() 205 | .circle_stroke(circle_position, circle_radius, style.fg_stroke); 206 | 207 | // Priority 208 | let speed = container_transform.scaling * 0.05; 209 | ui.put( 210 | egui::Rect::from_center_size( 211 | priority_rect.center(), 212 | egui::Vec2::splat(textinput_height), 213 | ), 214 | egui::DragValue::new(&mut self.priority) 215 | .update_while_editing(false) 216 | .speed(speed) 217 | .clamp_range(0..=MAX_THREE_DIGIT_NUMBER), 218 | ); 219 | 220 | // Line between remaining duration and duration 221 | ui.painter().line_segment( 222 | [ 223 | circle_position + egui::vec2(-circle_radius * 0.7, 0.), 224 | circle_position + egui::vec2(circle_radius * 0.7, 0.), 225 | ], 226 | egui::Stroke::new(0.5, egui::Color32::GRAY), 227 | ); 228 | 229 | // Duration 230 | ui.put( 231 | egui::Rect::from_center_size( 232 | circle_position + egui::vec2(0., 0.53 * textinput_height), 233 | egui::Vec2::splat(textinput_height), 234 | ), 235 | egui::DragValue::new(&mut self.duration) 236 | .update_while_editing(false) 237 | .speed(speed) 238 | .clamp_range(1..=MAX_THREE_DIGIT_NUMBER), 239 | ); 240 | 241 | // Remaining Duration 242 | ui.put( 243 | egui::Rect::from_center_size( 244 | circle_position + egui::vec2(0., -0.53 * textinput_height), 245 | egui::Vec2::splat(textinput_height), 246 | ), 247 | egui::DragValue::new(&mut self.remaining_duration) 248 | .update_while_editing(false) 249 | .speed(speed) 250 | .clamp_range(0..=MAX_THREE_DIGIT_NUMBER), 251 | ); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/app/graph/connection.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] 2 | pub enum Direction { 3 | MutexToActivity, 4 | ActivityToMutex, 5 | TwoWay, 6 | } 7 | 8 | #[derive(Clone, Copy, serde::Serialize, serde::Deserialize)] 9 | enum MutexToActivityState { 10 | Uncharged, 11 | Charging, 12 | Charged, 13 | Forwarding, 14 | Uncharging, 15 | } 16 | 17 | #[derive(Clone, Copy, serde::Serialize, serde::Deserialize)] 18 | enum ActivityToMutexState { 19 | Uncharged, 20 | Charging, 21 | Forwarding, 22 | } 23 | 24 | pub enum Color { 25 | Default, 26 | Active, 27 | } 28 | impl From for Vec { 29 | fn from(color: Color) -> Vec { 30 | match color { 31 | Color::Default => vec![ 32 | egui::Color32::GRAY, 33 | egui::Color32::GRAY, 34 | egui::Color32::GRAY, 35 | egui::Color32::GRAY, 36 | egui::Color32::GRAY, 37 | egui::Color32::DARK_GRAY, 38 | ], 39 | Color::Active => vec![ 40 | egui::Color32::DARK_GREEN, 41 | egui::Color32::DARK_GREEN, 42 | egui::Color32::DARK_GREEN, 43 | egui::Color32::DARK_GREEN, 44 | egui::Color32::DARK_GREEN, 45 | egui::Color32::DARK_GREEN, 46 | egui::Color32::GREEN, 47 | egui::Color32::GREEN, 48 | egui::Color32::GREEN, 49 | egui::Color32::GREEN, 50 | egui::Color32::GREEN, 51 | egui::Color32::GREEN, 52 | ], 53 | } 54 | } 55 | } 56 | 57 | #[derive(Clone, serde::Serialize, serde::Deserialize)] 58 | pub struct Connection { 59 | direction: Direction, 60 | 61 | activity_to_mutex_state: ActivityToMutexState, 62 | mutex_to_activity_state: MutexToActivityState, 63 | } 64 | 65 | impl Connection { 66 | pub fn new(direction: Direction) -> Self { 67 | Self { 68 | direction, 69 | activity_to_mutex_state: ActivityToMutexState::Uncharged, 70 | mutex_to_activity_state: MutexToActivityState::Uncharged, 71 | } 72 | } 73 | 74 | pub fn get_direction(&self) -> Direction { 75 | self.direction 76 | } 77 | 78 | pub fn set_direction(&mut self, direction: Direction) { 79 | self.direction = direction; 80 | // reset states in case of reconnect 81 | match self.direction { 82 | Direction::MutexToActivity => { 83 | self.activity_to_mutex_state = ActivityToMutexState::Uncharged 84 | } 85 | Direction::ActivityToMutex => { 86 | self.mutex_to_activity_state = MutexToActivityState::Uncharged 87 | } 88 | Direction::TwoWay => {} 89 | } 90 | } 91 | 92 | pub fn tick(&mut self, activity_node: &super::ActivityNode, mutex_node: &super::MutexNode) { 93 | self.tick_mutex_to_activity(activity_node, mutex_node); 94 | self.tick_activity_to_mutex(activity_node); 95 | } 96 | 97 | pub fn tick_mutex_to_activity( 98 | &mut self, 99 | activity_node: &super::ActivityNode, 100 | mutex_node: &super::MutexNode, 101 | ) { 102 | if self.direction == Direction::MutexToActivity || self.direction == Direction::TwoWay { 103 | self.mutex_to_activity_state = match ( 104 | &self.mutex_to_activity_state, 105 | mutex_node.value, 106 | activity_node.duration == activity_node.remaining_duration, 107 | ) { 108 | (MutexToActivityState::Charging, 0, true) => MutexToActivityState::Forwarding, 109 | (MutexToActivityState::Charged, 0, true) => MutexToActivityState::Forwarding, 110 | 111 | (MutexToActivityState::Charging, 0, _) => MutexToActivityState::Uncharging, 112 | (MutexToActivityState::Charged, 0, _) => MutexToActivityState::Uncharging, 113 | (_, 0, _) => MutexToActivityState::Uncharged, 114 | 115 | (MutexToActivityState::Charging, _, _) => MutexToActivityState::Charged, 116 | (MutexToActivityState::Charged, _, _) => MutexToActivityState::Charged, 117 | _ => MutexToActivityState::Charging, 118 | }; 119 | } 120 | } 121 | 122 | pub fn tick_activity_to_mutex(&mut self, activity_node: &super::ActivityNode) { 123 | if self.direction == Direction::ActivityToMutex || self.direction == Direction::TwoWay { 124 | self.activity_to_mutex_state = match ( 125 | &self.activity_to_mutex_state, 126 | activity_node.remaining_duration, 127 | ) { 128 | (_, 1) => ActivityToMutexState::Charging, 129 | (ActivityToMutexState::Uncharged, _) => ActivityToMutexState::Uncharged, 130 | (ActivityToMutexState::Charging, _) => ActivityToMutexState::Forwarding, 131 | (ActivityToMutexState::Forwarding, _) => ActivityToMutexState::Uncharged, 132 | } 133 | } 134 | } 135 | 136 | pub fn draw( 137 | &mut self, 138 | ui: &egui::Ui, 139 | activity_node: &super::ActivityNode, 140 | mutex_node: &super::MutexNode, 141 | tick_progress: f32, 142 | ) { 143 | let (activity_to_mutex_progress, activity_to_mutex_color_1, activity_to_mutex_color_2) = 144 | match self.activity_to_mutex_state { 145 | ActivityToMutexState::Uncharged => (0., Color::Default, Color::Default), 146 | ActivityToMutexState::Charging => ( 147 | (tick_progress - 0.5) * 2. * 2. - 1., 148 | Color::Active, 149 | Color::Default, 150 | ), 151 | ActivityToMutexState::Forwarding => { 152 | (tick_progress * 2. * 2. - 1., Color::Default, Color::Active) 153 | } 154 | }; 155 | let (mutex_to_activity_progress, mutex_to_activity_color_1, mutex_to_activity_color_2) = 156 | match self.mutex_to_activity_state { 157 | MutexToActivityState::Uncharged => (0., Color::Default, Color::Default), 158 | MutexToActivityState::Charging => { 159 | (tick_progress * 2. * 2. - 1., Color::Active, Color::Default) 160 | } 161 | MutexToActivityState::Charged => (0., Color::Active, Color::Active), 162 | MutexToActivityState::Forwarding => ( 163 | ((tick_progress - 0.5) * 2. * 2.) - 1., 164 | Color::Default, 165 | Color::Active, 166 | ), 167 | MutexToActivityState::Uncharging => ( 168 | 1. - (tick_progress - 0.5) * 2. * 2., 169 | Color::Active, 170 | Color::Default, 171 | ), 172 | }; 173 | 174 | match self.direction { 175 | Direction::ActivityToMutex => { 176 | Self::draw_arrow( 177 | ui, 178 | activity_node.pos, 179 | mutex_node.pos, 180 | activity_to_mutex_color_1, 181 | activity_to_mutex_color_2, 182 | activity_to_mutex_progress, 183 | ); 184 | } 185 | Direction::MutexToActivity => { 186 | Self::draw_arrow( 187 | ui, 188 | mutex_node.pos, 189 | activity_node.pos, 190 | mutex_to_activity_color_1, 191 | mutex_to_activity_color_2, 192 | mutex_to_activity_progress, 193 | ); 194 | } 195 | Direction::TwoWay => { 196 | let offset = (activity_node.pos - mutex_node.pos).normalized().rot90() * 6.; 197 | Self::draw_arrow( 198 | ui, 199 | activity_node.pos + offset, 200 | mutex_node.pos + offset, 201 | activity_to_mutex_color_1, 202 | activity_to_mutex_color_2, 203 | activity_to_mutex_progress, 204 | ); 205 | Self::draw_arrow( 206 | ui, 207 | mutex_node.pos - offset, 208 | activity_node.pos - offset, 209 | mutex_to_activity_color_1, 210 | mutex_to_activity_color_2, 211 | mutex_to_activity_progress, 212 | ); 213 | } 214 | } 215 | } 216 | 217 | pub fn draw_arrow( 218 | ui: &egui::Ui, 219 | from_point: egui::Pos2, 220 | to_point: egui::Pos2, 221 | color_1: Color, 222 | color_2: Color, 223 | color_progress: f32, 224 | ) { 225 | let color_1: Vec = color_1.into(); 226 | let color_2: Vec = color_2.into(); 227 | 228 | const WIDTH: f32 = 7.; 229 | const ARROW_SPACING: f32 = 3.; 230 | const ARROW_DEPTH: f32 = 3.; 231 | const SCROLL_SPEED_IN_POINTS_PER_SECOND: f32 = 4.; 232 | 233 | ui.ctx().request_repaint(); // keep redrawing to show the arrow scroll animation 234 | let time_offset = ui.input(|i| i.time) as f32 * SCROLL_SPEED_IN_POINTS_PER_SECOND 235 | % (ARROW_SPACING * color_1.len().max(color_2.len()) as f32); 236 | let color_offset = -(time_offset / ARROW_SPACING) as i32; 237 | 238 | let from_to_vector = to_point - from_point; 239 | let from_to_unit_vector = from_to_vector.normalized(); 240 | let line_center_point = 241 | from_point + 0.5 * from_to_vector + (time_offset % ARROW_SPACING) * from_to_unit_vector; 242 | let from_to_vector_length = from_to_vector.length(); 243 | let half_arrow_count = (from_to_vector_length / 2. / ARROW_SPACING) as i32; 244 | 245 | let arrow_tip_to_arrow_top_right = 246 | -ARROW_DEPTH * from_to_unit_vector + from_to_unit_vector.rot90() * (WIDTH / 2.); 247 | let arrow_tip_to_arrow_top_left = 248 | arrow_tip_to_arrow_top_right - from_to_unit_vector.rot90() * WIDTH; 249 | 250 | for i in ((-half_arrow_count + 1)..=half_arrow_count).rev() { 251 | let arrow_tip = line_center_point + i as f32 * ARROW_SPACING * from_to_unit_vector; 252 | let arrow_top_left = arrow_tip + arrow_tip_to_arrow_top_left; 253 | let arrow_top_right = arrow_tip + arrow_tip_to_arrow_top_right; 254 | let arrow_bottom_left = arrow_top_left - from_to_unit_vector * ARROW_SPACING; 255 | let arrow_bottom_right = arrow_top_right - from_to_unit_vector * ARROW_SPACING; 256 | 257 | let progress = (arrow_tip - from_point).length() / from_to_vector_length; 258 | 259 | let colors = match progress { 260 | p if p < color_progress => &color_1, 261 | _ => &color_2, 262 | }; 263 | 264 | ui.painter().add(egui::Shape::convex_polygon( 265 | vec![ 266 | arrow_bottom_left, 267 | arrow_top_left, 268 | arrow_tip, 269 | arrow_top_right, 270 | arrow_bottom_right, 271 | ], 272 | colors[(i + color_offset).rem_euclid(colors.len() as i32) as usize], 273 | egui::Stroke::NONE, 274 | )); 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::mpsc::{channel, Receiver, Sender}; 2 | 3 | use egui::{Layout, Pos2}; 4 | 5 | use self::graph::Graph; 6 | use std::future; 7 | 8 | mod graph; 9 | mod graphics; 10 | 11 | #[derive(serde::Deserialize, serde::Serialize)] 12 | #[serde(default)] 13 | pub struct App { 14 | active_graph: Graph, 15 | stored_graphs: Vec, 16 | scaling_in_percent: f32, 17 | 18 | show_about_dialog: bool, 19 | show_simulation_controls: bool, 20 | pin_menu_bar: bool, 21 | 22 | #[serde(skip)] 23 | text_channel: (Sender, Receiver), 24 | #[serde(skip)] 25 | file_buffer: String, 26 | #[serde(skip)] 27 | import_state: ImportState, 28 | 29 | #[serde(skip)] 30 | seconds_until_hiding_menu_bar: f32, 31 | } 32 | 33 | #[derive(PartialEq)] 34 | enum ImportState { 35 | Free, 36 | Csv, 37 | } 38 | 39 | impl Default for App { 40 | fn default() -> Self { 41 | let mut a2 = graph::ActivityNode::new(egui::pos2(300., 100.)); 42 | a2.task_name = "Task 2".into(); 43 | a2.activity_name = "Activity 2".into(); 44 | a2.duration = 3; 45 | 46 | let mut a1 = graph::ActivityNode::new(egui::pos2(150., 250.)); 47 | a1.task_name = "Task 1".into(); 48 | a1.activity_name = "Activity 1".into(); 49 | a1.duration = 3; 50 | 51 | let mut a5b = graph::ActivityNode::new(egui::pos2(150., 400.)); 52 | a5b.task_name = "Task 5".into(); 53 | a5b.activity_name = "Activity 5b".into(); 54 | a5b.duration = 1; 55 | 56 | let mut a5a = graph::ActivityNode::new(egui::pos2(450., 400.)); 57 | a5a.task_name = "Task 5".into(); 58 | a5a.activity_name = "Activity 5a".into(); 59 | a5a.duration = 2; 60 | 61 | let mut a3 = graph::ActivityNode::new(egui::pos2(450., 250.)); 62 | a3.task_name = "Task 3".into(); 63 | a3.activity_name = "Activity 3".into(); 64 | a3.duration = 2; 65 | 66 | let mut a4 = graph::ActivityNode::new(egui::pos2(600., 100.)); 67 | a4.task_name = "Task 4".into(); 68 | a4.activity_name = "Activity 4".into(); 69 | a4.duration = 3; 70 | 71 | let mut a6 = graph::ActivityNode::new(egui::pos2(750., 250.)); 72 | a6.task_name = "Task 6".into(); 73 | a6.activity_name = "Activity 6".into(); 74 | a6.duration = 3; 75 | 76 | let m24 = graph::MutexNode::new((a2.pos + a4.pos.to_vec2()) / 2.); 77 | let m12 = graph::MutexNode::new((a1.pos + a2.pos.to_vec2()) / 2.); 78 | let mut m234 = graph::MutexNode::new((a2.pos + a3.pos.to_vec2() + a4.pos.to_vec2()) / 3.); 79 | m234.value = 1; 80 | let m46 = graph::MutexNode::new((a4.pos + a6.pos.to_vec2()) / 2.); 81 | let m13 = graph::MutexNode::new((a1.pos + a3.pos.to_vec2()) / 2.); 82 | let m36 = graph::MutexNode::new((a3.pos + a6.pos.to_vec2()) / 2.); 83 | let m65a = graph::MutexNode::new((a6.pos + a5a.pos.to_vec2()) / 2.); 84 | let mut m5b1 = graph::MutexNode::new((a5b.pos + a1.pos.to_vec2()) / 2.); 85 | m5b1.value = 1; 86 | let mut m5b5a = 87 | graph::MutexNode::new((a5b.pos + a5a.pos.to_vec2()) / 2. + egui::vec2(0., 20.)); 88 | m5b5a.value = 1; 89 | let m5a5b = graph::MutexNode::new((a5b.pos + a5a.pos.to_vec2()) / 2. - egui::vec2(0., 20.)); 90 | 91 | let mut graph = Graph::default(); 92 | let a2 = graph.add_activity_node(a2); 93 | let a1 = graph.add_activity_node(a1); 94 | let a5b = graph.add_activity_node(a5b); 95 | let a5a = graph.add_activity_node(a5a); 96 | let a3 = graph.add_activity_node(a3); 97 | let a4 = graph.add_activity_node(a4); 98 | let a6 = graph.add_activity_node(a6); 99 | 100 | let m24 = graph.add_mutex_node(m24); 101 | let m12 = graph.add_mutex_node(m12); 102 | let m234 = graph.add_mutex_node(m234); 103 | let m46 = graph.add_mutex_node(m46); 104 | let m13 = graph.add_mutex_node(m13); 105 | let m36 = graph.add_mutex_node(m36); 106 | let m65a = graph.add_mutex_node(m65a); 107 | let m5b1 = graph.add_mutex_node(m5b1); 108 | let m5b5a = graph.add_mutex_node(m5b5a); 109 | let m5a5b = graph.add_mutex_node(m5a5b); 110 | 111 | graph.connect(a2, m24, graph::connection::Direction::ActivityToMutex, true); 112 | graph.connect(a4, m24, graph::connection::Direction::MutexToActivity, true); 113 | 114 | graph.connect(a1, m12, graph::connection::Direction::ActivityToMutex, true); 115 | graph.connect(a2, m12, graph::connection::Direction::MutexToActivity, true); 116 | 117 | graph.connect(a2, m234, graph::connection::Direction::TwoWay, true); 118 | graph.connect(a3, m234, graph::connection::Direction::TwoWay, true); 119 | graph.connect(a4, m234, graph::connection::Direction::TwoWay, true); 120 | 121 | graph.connect(a4, m46, graph::connection::Direction::ActivityToMutex, true); 122 | graph.connect(a6, m46, graph::connection::Direction::MutexToActivity, true); 123 | 124 | graph.connect(a1, m13, graph::connection::Direction::ActivityToMutex, true); 125 | graph.connect(a3, m13, graph::connection::Direction::MutexToActivity, true); 126 | 127 | graph.connect(a3, m36, graph::connection::Direction::ActivityToMutex, true); 128 | graph.connect(a6, m36, graph::connection::Direction::MutexToActivity, true); 129 | 130 | graph.connect( 131 | a6, 132 | m65a, 133 | graph::connection::Direction::ActivityToMutex, 134 | true, 135 | ); 136 | graph.connect( 137 | a5a, 138 | m65a, 139 | graph::connection::Direction::MutexToActivity, 140 | true, 141 | ); 142 | 143 | graph.connect( 144 | a5b, 145 | m5b1, 146 | graph::connection::Direction::ActivityToMutex, 147 | true, 148 | ); 149 | graph.connect( 150 | a1, 151 | m5b1, 152 | graph::connection::Direction::MutexToActivity, 153 | true, 154 | ); 155 | 156 | graph.connect( 157 | a5b, 158 | m5b5a, 159 | graph::connection::Direction::ActivityToMutex, 160 | true, 161 | ); 162 | graph.connect( 163 | a5a, 164 | m5b5a, 165 | graph::connection::Direction::MutexToActivity, 166 | true, 167 | ); 168 | 169 | graph.connect( 170 | a5a, 171 | m5a5b, 172 | graph::connection::Direction::ActivityToMutex, 173 | true, 174 | ); 175 | graph.connect( 176 | a5b, 177 | m5a5b, 178 | graph::connection::Direction::MutexToActivity, 179 | true, 180 | ); 181 | 182 | graph.name = "Example Graph".to_string(); 183 | graph.toggle_play_pause(); 184 | 185 | Self { 186 | stored_graphs: vec![graph.clone()], 187 | active_graph: graph, 188 | show_about_dialog: true, 189 | show_simulation_controls: true, 190 | pin_menu_bar: true, 191 | scaling_in_percent: 100., 192 | text_channel: channel(), 193 | file_buffer: Default::default(), 194 | import_state: ImportState::Free, 195 | seconds_until_hiding_menu_bar: 0., 196 | } 197 | } 198 | } 199 | 200 | impl App { 201 | pub fn new(creation_context: &eframe::CreationContext<'_>) -> Self { 202 | creation_context.egui_ctx.set_visuals(egui::Visuals::dark()); 203 | setup_custom_fonts(&creation_context.egui_ctx); 204 | 205 | // load previous app state, if it exists 206 | // create default otherwise 207 | let app: Self = match creation_context.storage { 208 | Some(storage) => eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(), 209 | None => Default::default(), 210 | }; 211 | 212 | egui_extras::install_image_loaders(&creation_context.egui_ctx); 213 | 214 | creation_context 215 | .egui_ctx 216 | .set_zoom_factor(1.5 * app.scaling_in_percent / 100.); 217 | 218 | app 219 | } 220 | } 221 | 222 | fn setup_custom_fonts(ctx: &egui::Context) { 223 | // Start with the default fonts (we will be adding to them rather than replacing them). 224 | let mut fonts = egui::FontDefinitions::default(); 225 | 226 | // Install my own font (maybe supporting non-latin characters). 227 | // .ttf and .otf files supported. 228 | fonts.font_data.insert( 229 | "sharetech".to_owned(), 230 | egui::FontData::from_static(include_bytes!("../../assets/ShareTech.ttf")), 231 | ); 232 | 233 | fonts.font_data.insert( 234 | "sharetechmono".to_owned(), 235 | egui::FontData::from_static(include_bytes!("../../assets/ShareTechMono.ttf")), 236 | ); 237 | 238 | // Put my font first (highest priority) for proportional text: 239 | fonts 240 | .families 241 | .entry(egui::FontFamily::Proportional) 242 | .or_default() 243 | .insert(0, "sharetech".to_owned()); 244 | 245 | // Put my font as last fallback for monospace: 246 | fonts 247 | .families 248 | .entry(egui::FontFamily::Monospace) 249 | .or_default() 250 | .push("sharetechmono".to_owned()); 251 | 252 | // Tell egui to use these fonts: 253 | ctx.set_fonts(fonts); 254 | } 255 | 256 | impl eframe::App for App { 257 | fn save(&mut self, storage: &mut dyn eframe::Storage) { 258 | // save app state 259 | eframe::set_value(storage, eframe::APP_KEY, self); 260 | } 261 | 262 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 263 | const LOGO_IMAGESORUCE: egui::ImageSource<'static> = 264 | egui::include_image!("../../assets/Logo.png"); 265 | if let Ok(text) = self.text_channel.1.try_recv() { 266 | self.file_buffer = text; 267 | } 268 | 269 | if !self.file_buffer.is_empty() && self.import_state != ImportState::Free { 270 | if self.import_state == ImportState::Csv { 271 | match Graph::from_csv(&self.file_buffer) { 272 | Ok(graph) => { 273 | self.active_graph = graph; 274 | } 275 | Err(e) => { 276 | rfd::MessageDialog::new() 277 | .set_title("Parser Error") 278 | .set_description(format!("Failed to import graph: {}", e)) 279 | .set_level(rfd::MessageLevel::Error) 280 | .show(); 281 | } 282 | } 283 | } 284 | self.file_buffer.clear(); 285 | } 286 | 287 | if self.pin_menu_bar || ctx.pointer_interact_pos().map_or(false, |pos| pos.y < 25.) { 288 | self.seconds_until_hiding_menu_bar = 2.; 289 | } else if self.seconds_until_hiding_menu_bar > 0. { 290 | self.seconds_until_hiding_menu_bar -= ctx.input(|i| i.unstable_dt); 291 | } 292 | let show_menu_bar = self.pin_menu_bar || self.seconds_until_hiding_menu_bar > 0.; 293 | egui::TopBottomPanel::top("top_panel") 294 | .min_height(0.) 295 | .show_animated(ctx, show_menu_bar, |ui| { 296 | egui::menu::bar(ui, |ui| { 297 | egui::menu::menu_button(ui, "File", |ui| { 298 | if ui.button("📄 New Graph").clicked() { 299 | ui.close_menu(); 300 | self.active_graph = Graph::default(); 301 | } 302 | 303 | ui.separator(); 304 | 305 | if ui.button("💾 Save Graph").clicked() { 306 | self.stored_graphs 307 | .append(&mut vec![self.active_graph.clone()]); 308 | } 309 | 310 | ui.menu_button("📂 Load Graph", |ui| { 311 | egui::scroll_area::ScrollArea::vertical().show(ui, |ui| { 312 | if self.stored_graphs.is_empty() { 313 | ui.label("nothing to load"); 314 | return; 315 | } 316 | 317 | ui.spacing_mut().item_spacing.x = 3.; 318 | let mut graph_to_delete = None; 319 | for i in (0..self.stored_graphs.len()).rev() { 320 | ui.horizontal(|ui| { 321 | ui.add( 322 | egui::TextEdit::singleline( 323 | &mut self.stored_graphs[i].name, 324 | ) 325 | .desired_width(100.), 326 | ); 327 | if ui.button("🗑").clicked() { 328 | graph_to_delete = Some(i); 329 | } 330 | if ui.button("⬆").clicked() 331 | && i + 1 < self.stored_graphs.len() 332 | { 333 | self.stored_graphs.swap(i, i + 1); 334 | } 335 | if ui.button("⬇").clicked() && i > 0 { 336 | self.stored_graphs.swap(i - 1, i); 337 | } 338 | if ui.button("➡").clicked() { 339 | self.active_graph = self.stored_graphs[i].clone(); 340 | ui.close_menu(); 341 | } 342 | }); 343 | } 344 | if let Some(i) = graph_to_delete { 345 | self.stored_graphs.remove(i); 346 | } 347 | }); 348 | }); 349 | 350 | ui.separator(); 351 | 352 | if ui.button("⬅ Export Graph").clicked() { 353 | ui.close_menu(); 354 | let task = rfd::AsyncFileDialog::new() 355 | .add_filter("Comma Seperated Values", &["csv"]) 356 | .add_filter("All Files", &["*"]) 357 | .set_file_name(format!("{}.csv", self.active_graph.name)) 358 | .save_file(); 359 | let contents = self.active_graph.to_csv(); 360 | execute(async move { 361 | let file = task.await; 362 | if let Some(file) = file { 363 | println!("{}", file.file_name()); 364 | _ = file.write(contents.as_bytes()).await; 365 | } 366 | }); 367 | } 368 | 369 | if ui.button("➡ Import Graph").clicked() { 370 | ui.close_menu(); 371 | let sender = self.text_channel.0.clone(); 372 | let task = rfd::AsyncFileDialog::new() 373 | .add_filter("Comma Seperated Values", &["csv"]) 374 | .add_filter("All Files", &["*"]) 375 | .pick_file(); 376 | execute(async move { 377 | let file = task.await; 378 | if let Some(file) = file { 379 | let text = file.read().await; 380 | let _ = sender.send(String::from_utf8_lossy(&text).to_string()); 381 | } 382 | }); 383 | self.import_state = ImportState::Csv; 384 | } 385 | }); 386 | egui::menu::menu_button(ui, "Edit", |ui| { 387 | if ui.button("🗑 Delete Mode").clicked() { 388 | ui.close_menu(); 389 | self.active_graph.editing_mode = graph::EditingMode::Delete; 390 | } 391 | }); 392 | egui::menu::menu_button(ui, "View", |ui| { 393 | if ui.button("[ ] Autofit Graph").clicked() { 394 | self.active_graph.queue_autofit(); 395 | ui.close_menu(); 396 | } 397 | ui.separator(); 398 | ui.checkbox(&mut self.pin_menu_bar, " Pin Menu Bar"); 399 | ui.checkbox(&mut self.show_simulation_controls, " Simulation Controls"); 400 | ui.checkbox(&mut self.show_about_dialog, " ℹ About"); 401 | }); 402 | ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { 403 | let previous_scaling = self.scaling_in_percent; 404 | if ui.button("+").clicked() { 405 | self.scaling_in_percent += 10.; 406 | if self.scaling_in_percent > 300. { 407 | self.scaling_in_percent = 300.; 408 | } 409 | } 410 | let response = ui.add( 411 | egui::DragValue::new(&mut self.scaling_in_percent) 412 | .fixed_decimals(0) 413 | .clamp_range(50.0..=300.0) 414 | .suffix("%".to_owned()) 415 | .update_while_editing(false), 416 | ); 417 | if response.double_clicked() { 418 | self.scaling_in_percent = 100.; 419 | response.surrender_focus(); 420 | }; 421 | if ui.button("-").clicked() { 422 | self.scaling_in_percent -= 10.; 423 | if self.scaling_in_percent < 50. { 424 | self.scaling_in_percent = 50.; 425 | } 426 | }; 427 | 428 | if self.scaling_in_percent != previous_scaling { 429 | ui.ctx() 430 | .set_zoom_factor(1.5 * self.scaling_in_percent / 100.); 431 | } 432 | 433 | if ui 434 | .label( 435 | egui::RichText::new(match self.active_graph.editing_mode { 436 | graph::EditingMode::None => "", 437 | graph::EditingMode::Delete => { 438 | "Delete Mode active! Click here to exit." 439 | } 440 | }) 441 | .color(egui::Color32::YELLOW), 442 | ) 443 | .clicked() 444 | { 445 | self.active_graph.editing_mode = graph::EditingMode::None; 446 | } 447 | ui.centered_and_justified(|ui| { 448 | ui.add( 449 | egui::TextEdit::singleline(&mut self.active_graph.name) 450 | .horizontal_align(egui::Align::Center) 451 | .frame(false), 452 | ); 453 | }); 454 | }); 455 | }); 456 | }); 457 | 458 | egui::TopBottomPanel::bottom("bottom_panel") 459 | .min_height(25.) 460 | .show_animated(ctx, self.show_simulation_controls, |ui| { 461 | ui.horizontal_centered(|ui| { 462 | ui.style_mut().spacing.slider_width = 175.; 463 | let response = ui.add( 464 | egui::widgets::Slider::new( 465 | &mut self.active_graph.ticks_per_second, 466 | 0.1..=50.0, 467 | ) 468 | .text("ticks per second") 469 | .logarithmic(true) 470 | .max_decimals(2), 471 | ); 472 | if response.double_clicked() { 473 | self.active_graph.ticks_per_second = 1.0; 474 | response.surrender_focus(); 475 | }; 476 | 477 | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 478 | if ui 479 | .add( 480 | egui::Button::new( 481 | match self.active_graph.is_running() { 482 | true => "⏸", 483 | false => "▶", 484 | } 485 | .to_string(), 486 | ) 487 | .min_size(egui::vec2(25., 0.)), 488 | ) 489 | .clicked() 490 | { 491 | self.active_graph.toggle_play_pause(); 492 | }; 493 | if !self.active_graph.is_running() { 494 | let mut remaining_ticks = 495 | self.active_graph.get_remaining_ticks_to_run(); 496 | let range = match remaining_ticks { 497 | 0 => 0..=1000, 498 | _ => 1..=1000, 499 | }; 500 | if ui.button("Single Step").clicked() { 501 | self.active_graph.queue_tick(); 502 | } 503 | ui.separator(); 504 | ui.label("ticks remaining"); 505 | if ui 506 | .add( 507 | egui::DragValue::new(&mut remaining_ticks) 508 | .update_while_editing(false) 509 | .speed(0.1) 510 | .clamp_range(range) 511 | .max_decimals(0), 512 | ) 513 | .changed() 514 | { 515 | self.active_graph 516 | .set_remaining_ticks_to_run(remaining_ticks); 517 | }; 518 | } 519 | }); 520 | }); 521 | }); 522 | 523 | egui::SidePanel::left("about_panel") 524 | .resizable(true) 525 | .default_width(350.) 526 | .min_width(200.) 527 | .max_width(500.) 528 | .show_animated(ctx, self.show_about_dialog, |ui| { 529 | ui.spacing_mut().item_spacing.x = 0.; 530 | ui.spacing_mut().item_spacing.y = 10.; 531 | 532 | // pinned close button 533 | let right_top = ui.available_rect_before_wrap().right_top(); 534 | let rect = egui::Rect::from_points(&[right_top, right_top + egui::vec2(-20., 20.)]); 535 | ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, "❌", egui::FontId::proportional(15.), egui::Color32::GRAY); 536 | if ui.input(|i| i.pointer.primary_clicked()) && ui.rect_contains_pointer(rect) { 537 | self.show_about_dialog = false; 538 | } 539 | 540 | egui::scroll_area::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { 541 | egui::warn_if_debug_build(ui); 542 | ui.vertical_centered(|ui|ui.add(egui::Image::new(LOGO_IMAGESORUCE).tint(egui::Color32::LIGHT_GRAY).max_height(125.))); 543 | ui.heading("Task Synchronization Simulator"); 544 | 545 | ui.label("A simple tool for simulating the execution of interdependent tasks."); 546 | 547 | ui.label("Tasks block until all inputs are > 0, then all inputs are decremented and the task starts running. When a task finishes, all outputs are incremented. Using these simple rules, it is possible to model complex systems, including synchronization mechanisms like semaphores and mutexes."); 548 | 549 | ui.horizontal_wrapped(|ui| { 550 | ui.label("View on "); 551 | ui.add(egui::Hyperlink::from_label_and_url( 552 | "GitHub", 553 | "https://github.com/CrazyCraftix/tsyncs", 554 | ).open_in_new_tab(true)); 555 | ui.label(" for more information and documentation."); 556 | }); 557 | 558 | #[cfg(not(target_arch = "wasm32"))] 559 | ui.horizontal_wrapped(|ui| { 560 | ui.label("Also, try the "); 561 | ui.add(egui::Hyperlink::from_label_and_url("web version", "https://tsyncs.de").open_in_new_tab(true)); 562 | ui.label("!"); 563 | }); 564 | 565 | ui.separator(); 566 | 567 | ui.horizontal_wrapped(|ui| { 568 | ui.label("This project was created as part of the course 'Echtzeitsysteme' at the "); 569 | ui.add(egui::Hyperlink::from_label_and_url("DHBW Stuttgart", "https://www.dhbw-stuttgart.de/").open_in_new_tab(true)); 570 | ui.label("."); 571 | }); 572 | 573 | ui.horizontal_wrapped(|ui| { 574 | ui.label("Made with ♥ by "); 575 | ui.add(egui::Hyperlink::from_label_and_url("Nicolai Bergmann", "https://github.com/CrazyCraftix").open_in_new_tab(true)); 576 | ui.label(" and "); 577 | ui.add(egui::Hyperlink::from_label_and_url("Mark Orlando Zeller", "https://the-maze.net").open_in_new_tab(true)); 578 | ui.label(".\nPowered by "); 579 | ui.add(egui::Hyperlink::from_label_and_url("egui", "https://github.com/emilk/egui").open_in_new_tab(true)); 580 | ui.label(" and "); 581 | ui.add(egui::Hyperlink::from_label_and_url( 582 | "eframe", 583 | "https://github.com/emilk/egui/tree/master/crates/eframe", 584 | ).open_in_new_tab(true)); 585 | ui.label("."); 586 | }); 587 | }); 588 | }); 589 | 590 | // main panel 591 | egui::CentralPanel::default().show(ctx, |ui| { 592 | ui.centered_and_justified(|ui| { 593 | graphics::PanZoomContainer::new().show( 594 | ui, 595 | |ui, container_transform, container_response| { 596 | let image = egui::Image::new(LOGO_IMAGESORUCE); 597 | let image_size = egui::vec2(120., 60.); 598 | image 599 | .shrink_to_fit() 600 | .tint(egui::Color32::DARK_GRAY) 601 | .paint_at( 602 | ui, 603 | egui::Rect::from_center_size( 604 | //container_transform.inverse() 605 | container_transform.inverse() 606 | * Pos2::new( 607 | container_response.rect.right() - image_size.x * 0.7, 608 | container_response.rect.bottom() - image_size.y * 0.5, 609 | ), 610 | image_size / container_transform.scaling, 611 | ), 612 | ); 613 | 614 | self.active_graph.tick(ui); 615 | // skip first frame because interaction results don't exist yet 616 | if ui.ctx().frame_nr() != 0 { 617 | self.active_graph 618 | .interact(ui, container_transform, container_response); 619 | } 620 | self.active_graph.draw(ui, *container_transform); 621 | }, 622 | ); 623 | }); 624 | }); 625 | } 626 | } 627 | 628 | #[cfg(not(target_arch = "wasm32"))] 629 | fn execute + Send + 'static>(f: F) { 630 | std::thread::spawn(move || futures::executor::block_on(f)); 631 | } 632 | 633 | #[cfg(target_arch = "wasm32")] 634 | fn execute + 'static>(f: F) { 635 | wasm_bindgen_futures::spawn_local(f); 636 | } 637 | -------------------------------------------------------------------------------- /src/app/graph/mod.rs: -------------------------------------------------------------------------------- 1 | mod activity_node; 2 | pub mod connection; 3 | mod mutex_node; 4 | 5 | pub use activity_node::ActivityNode; 6 | use egui::{emath::TSTransform, Pos2}; 7 | pub use mutex_node::MutexNode; 8 | use rand::{thread_rng, Rng as _, SeedableRng as _}; 9 | use random_word::Lang; 10 | 11 | use self::connection::Direction; 12 | 13 | #[derive( 14 | PartialOrd, Ord, Default, Hash, Clone, Copy, Eq, PartialEq, serde::Serialize, serde::Deserialize, 15 | )] 16 | pub struct ActivityNodeId(usize); 17 | impl std::ops::Deref for ActivityNodeId { 18 | type Target = usize; 19 | fn deref(&self) -> &Self::Target { 20 | &self.0 21 | } 22 | } 23 | impl std::ops::DerefMut for ActivityNodeId { 24 | fn deref_mut(&mut self) -> &mut Self::Target { 25 | &mut self.0 26 | } 27 | } 28 | 29 | #[derive( 30 | PartialOrd, Ord, Default, Hash, Clone, Copy, Eq, PartialEq, serde::Serialize, serde::Deserialize, 31 | )] 32 | pub struct MutexNodeId(usize); 33 | impl std::ops::Deref for MutexNodeId { 34 | type Target = usize; 35 | fn deref(&self) -> &Self::Target { 36 | &self.0 37 | } 38 | } 39 | impl std::ops::DerefMut for MutexNodeId { 40 | fn deref_mut(&mut self) -> &mut Self::Target { 41 | &mut self.0 42 | } 43 | } 44 | 45 | #[derive(PartialEq, Eq)] 46 | pub enum EditingMode { 47 | None, 48 | Delete, 49 | } 50 | 51 | impl Default for EditingMode { 52 | fn default() -> Self { 53 | Self::None 54 | } 55 | } 56 | 57 | #[derive(Clone, Copy)] 58 | enum AnyNode { 59 | Activity(ActivityNodeId), 60 | Mutex(MutexNodeId), 61 | } 62 | 63 | #[derive(serde::Deserialize, serde::Serialize)] 64 | pub struct Graph { 65 | pub name: String, 66 | activity_nodes: indexmap::IndexMap, 67 | mutex_nodes: std::collections::HashMap, 68 | 69 | connections: std::collections::HashMap< 70 | ActivityNodeId, 71 | std::collections::HashMap, 72 | >, 73 | 74 | next_activity_id: ActivityNodeId, 75 | next_mutex_id: MutexNodeId, 76 | 77 | tick_progress: f32, 78 | 79 | pub ticks_per_second: f32, 80 | 81 | remaining_ticks_to_run: i32, 82 | 83 | #[serde(skip)] 84 | currently_connecting_from: Option, 85 | 86 | #[serde(skip)] 87 | pub editing_mode: EditingMode, 88 | 89 | #[serde(skip)] 90 | autofit_rect: Option, 91 | } 92 | 93 | impl Clone for Graph { 94 | fn clone(&self) -> Self { 95 | Self { 96 | name: self.name.clone(), 97 | activity_nodes: self.activity_nodes.clone(), 98 | mutex_nodes: self.mutex_nodes.clone(), 99 | connections: self.connections.clone(), 100 | next_activity_id: self.next_activity_id, 101 | next_mutex_id: self.next_mutex_id, 102 | tick_progress: self.tick_progress, 103 | ticks_per_second: self.ticks_per_second, 104 | ..Default::default() 105 | } 106 | } 107 | } 108 | 109 | impl Default for Graph { 110 | fn default() -> Self { 111 | Self { 112 | name: "Unnamed Graph".into(), 113 | activity_nodes: indexmap::IndexMap::new(), 114 | mutex_nodes: std::collections::HashMap::new(), 115 | connections: std::collections::HashMap::new(), 116 | next_activity_id: ActivityNodeId(0), 117 | next_mutex_id: MutexNodeId(0), 118 | tick_progress: 0., 119 | ticks_per_second: 1., 120 | remaining_ticks_to_run: 0, 121 | currently_connecting_from: None, 122 | editing_mode: EditingMode::None, 123 | autofit_rect: Some(egui::Rect::NAN), 124 | } 125 | } 126 | } 127 | 128 | // import/export 129 | impl Graph { 130 | pub fn from_csv(text: &str) -> Result { 131 | const SEPERATOR: char = ';'; 132 | let mut graph = Graph::default(); 133 | 134 | for (line_number, line) in text.lines().enumerate() { 135 | let line_number = line_number + 1; // enumerate starts at 0 136 | 137 | // split returns at least 1 empty string -> subsequent values[0] are fine 138 | let values = line.split(SEPERATOR).map(|s| s.trim()).collect::>(); 139 | 140 | // match first value to determine type of line 141 | match values[0].to_lowercase().as_str() { 142 | "task" if values.len() >= 9 => { 143 | let mut activity_node = ActivityNode::new(egui::Pos2 { 144 | x: values[1].parse::().map_err(|_| { 145 | format!("Error while parsing Position X in line: {}", line_number) 146 | })?, 147 | y: values[2].parse::().map_err(|_| { 148 | format!("Error while parsing Position Y in line: {}", line_number) 149 | })?, 150 | }); 151 | let activity_id = 152 | ActivityNodeId(values[3].parse::().map_err(|_| { 153 | format!("Error while parsing ID in line: {}", line_number) 154 | })?); 155 | activity_node.task_name = values[4].to_string(); 156 | activity_node.activity_name = values[5].to_string(); 157 | activity_node.priority = values[6].parse::().map_err(|_| { 158 | format!("Error while parsing Priority in line: {}", line_number) 159 | })?; 160 | activity_node.duration = values[7].parse::().map_err(|_| { 161 | format!("Error while parsing Duration in line: {}", line_number) 162 | })?; 163 | activity_node.remaining_duration = values[8].parse::().map_err(|_| { 164 | format!( 165 | "Error while parsing Remaining Duration in line: {}", 166 | line_number 167 | ) 168 | })?; 169 | graph.add_activiy_node_with_id(activity_node, activity_id); 170 | 171 | values[9..] 172 | .iter() 173 | .filter(|x| !x.is_empty()) 174 | .find_map(|x| match x.parse::() { 175 | Ok(mutex_id) => { 176 | graph.connect( 177 | activity_id, 178 | MutexNodeId(mutex_id), 179 | Direction::ActivityToMutex, 180 | false, 181 | ); 182 | None 183 | } 184 | Err(_) => Some(Err(format!( 185 | "Error while parsing Activity Connection in line: {}", 186 | line_number 187 | ))), 188 | }) 189 | .unwrap_or(Ok(()))?; 190 | } 191 | 192 | "mutex" if values.len() >= 5 => { 193 | let mut mutex_node = MutexNode::new(egui::Pos2 { 194 | x: values[1].parse::().map_err(|_| { 195 | format!("Error while parsing Position X in line: {}", line_number) 196 | })?, 197 | y: values[2].parse::().map_err(|_| { 198 | format!("Error while parsing Position Y in line: {}", line_number) 199 | })?, 200 | }); 201 | let mutex_id = 202 | MutexNodeId(values[3].parse::().map_err(|_| { 203 | format!("Error while parsing ID in line: {}", line_number) 204 | })?); 205 | mutex_node.value = values[4].parse::().map_err(|_| { 206 | format!("Error while parsing Mutex Value in line: {}", line_number) 207 | })?; 208 | graph.add_mutex_node_with_id(mutex_node, mutex_id); 209 | 210 | values[5..] 211 | .iter() 212 | .filter(|x| !x.is_empty()) 213 | .find_map(|x| match x.parse::() { 214 | Ok(activity_id) => { 215 | graph.connect( 216 | ActivityNodeId(activity_id), 217 | mutex_id, 218 | Direction::MutexToActivity, 219 | false, 220 | ); 221 | None 222 | } 223 | Err(_) => Some(Err(format!( 224 | "Error while parsing Activity Connection in line: {}", 225 | line_number 226 | ))), 227 | }) 228 | .unwrap_or(Ok(()))?; 229 | } 230 | _ => {} // skip line 231 | } 232 | } 233 | graph.update_connection_states(); 234 | Ok(graph) 235 | } 236 | 237 | pub fn to_csv(&self) -> String { 238 | use std::collections::HashMap; 239 | let seperator = ";"; 240 | 241 | let mut connection_activity_to_mutex: HashMap> = HashMap::new(); 242 | let mut connection_mutex_to_activity: HashMap> = HashMap::new(); 243 | 244 | for (activity_id, activity_connections) in &self.connections { 245 | for (mutex_id, connection) in activity_connections { 246 | match connection.get_direction() { 247 | Direction::ActivityToMutex => { 248 | connection_activity_to_mutex 249 | .entry(activity_id.0) 250 | .or_default() 251 | .push(mutex_id.0); 252 | } 253 | Direction::MutexToActivity => { 254 | connection_mutex_to_activity 255 | .entry(mutex_id.0) 256 | .or_default() 257 | .push(activity_id.0); 258 | } 259 | Direction::TwoWay => { 260 | connection_activity_to_mutex 261 | .entry(activity_id.0) 262 | .or_default() 263 | .push(mutex_id.0); 264 | connection_mutex_to_activity 265 | .entry(mutex_id.0) 266 | .or_default() 267 | .push(activity_id.0); 268 | } 269 | } 270 | } 271 | } 272 | 273 | let mut csv = String::new(); 274 | csv.push_str("Type;Position X;Position Y;ID;Parameters...\n"); 275 | 276 | // add tasks 277 | csv.push_str("#Task;Position X;Position Y;ID;Task Name;Activity Name;Priority;Duration;Remaining Duration;[Semicolon seperated list of connected Mutex IDs]\n"); 278 | for (activity_id, activity_node) in &self.activity_nodes { 279 | csv.push_str(&format!( 280 | "Task{seperator}{}{seperator}{}{seperator}{}{seperator}{}{seperator}{}{seperator}{}{seperator}{}{seperator}{}{seperator}{}\n", 281 | activity_node.pos.x.round() as i64, 282 | activity_node.pos.y.round() as i64, 283 | activity_id.0, 284 | activity_node.task_name, 285 | activity_node.activity_name, 286 | activity_node.priority, 287 | activity_node.duration, 288 | activity_node.remaining_duration, 289 | connection_activity_to_mutex 290 | .get(&activity_id.0) 291 | .map(|x| x.iter().map(|x| x.to_string()).collect::>()) 292 | .unwrap_or_default() 293 | .join(seperator) 294 | )); 295 | } 296 | 297 | // add mutexes 298 | csv.push_str("#Mutex;Position X;Position Y;ID;Mutex Value;[Semicolon seperated list of connected Mutex IDs]\n"); 299 | for (mutex_id, mutex_node) in &self.mutex_nodes { 300 | csv.push_str(&format!( 301 | "Mutex{seperator}{}{seperator}{}{seperator}{}{seperator}{}{seperator}{}\n", 302 | mutex_node.pos.x.round() as i64, 303 | mutex_node.pos.y.round() as i64, 304 | mutex_id.0, 305 | mutex_node.value, 306 | connection_mutex_to_activity 307 | .get(&mutex_id.0) 308 | .map(|x| x.iter().map(|x| x.to_string()).collect::>()) 309 | .unwrap_or_default() 310 | .join(seperator) 311 | )); 312 | } 313 | 314 | csv 315 | } 316 | } 317 | 318 | // structure 319 | impl Graph { 320 | pub fn add_activity_node(&mut self, activity_node: ActivityNode) -> ActivityNodeId { 321 | self.add_activiy_node_with_id(activity_node, self.next_activity_id) 322 | } 323 | pub fn add_activiy_node_with_id( 324 | &mut self, 325 | activity_node: ActivityNode, 326 | id: ActivityNodeId, 327 | ) -> ActivityNodeId { 328 | self.activity_nodes.insert(id, activity_node); 329 | *self.next_activity_id = usize::max(*self.next_mutex_id, *id + 1); 330 | id 331 | } 332 | 333 | pub fn add_mutex_node(&mut self, mutex_node: MutexNode) -> MutexNodeId { 334 | self.add_mutex_node_with_id(mutex_node, self.next_mutex_id) 335 | } 336 | pub fn add_mutex_node_with_id( 337 | &mut self, 338 | mutex_node: MutexNode, 339 | id: MutexNodeId, 340 | ) -> MutexNodeId { 341 | self.mutex_nodes.insert(id, mutex_node); 342 | *self.next_mutex_id = usize::max(*self.next_mutex_id, *id + 1); 343 | id 344 | } 345 | 346 | pub fn connect( 347 | &mut self, 348 | activity_id: ActivityNodeId, 349 | mutex_id: MutexNodeId, 350 | direction: Direction, 351 | update_connections: bool, 352 | ) { 353 | let mut activity_connections = self.connections.remove(&activity_id).unwrap_or_default(); 354 | let connection = match activity_connections.remove(&mutex_id) { 355 | Some(mut previous_connection) if previous_connection.get_direction() != direction => { 356 | let previous_direction = previous_connection.get_direction(); 357 | previous_connection.set_direction(Direction::TwoWay); 358 | 359 | if update_connections { 360 | if let (Some(activity_node), Some(mutex_node)) = ( 361 | self.activity_nodes.get(&activity_id), 362 | self.mutex_nodes.get(&mutex_id), 363 | ) { 364 | match previous_direction { 365 | Direction::MutexToActivity => { 366 | previous_connection.tick_activity_to_mutex(activity_node); 367 | } 368 | Direction::ActivityToMutex => { 369 | previous_connection 370 | .tick_mutex_to_activity(activity_node, mutex_node); 371 | } 372 | Direction::TwoWay => {} 373 | } 374 | } 375 | } 376 | 377 | previous_connection 378 | } 379 | Some(previous_connection) => previous_connection, 380 | None => { 381 | let mut connection = connection::Connection::new(direction); 382 | if update_connections { 383 | if let (Some(activity_node), Some(mutex_node)) = ( 384 | self.activity_nodes.get(&activity_id), 385 | self.mutex_nodes.get(&mutex_id), 386 | ) { 387 | connection.tick(activity_node, mutex_node); 388 | } 389 | } 390 | connection 391 | } 392 | }; 393 | activity_connections.insert(mutex_id, connection); 394 | self.connections.insert(activity_id, activity_connections); 395 | } 396 | 397 | pub fn disconnect( 398 | &mut self, 399 | activity_id: ActivityNodeId, 400 | mutex_id: MutexNodeId, 401 | direction: Direction, 402 | ) { 403 | if let Some(mut activity_connections) = self.connections.remove(&activity_id) { 404 | if let Some(mut connection) = activity_connections.remove(&mutex_id) { 405 | if let Some(new_direction) = match (connection.get_direction(), direction) { 406 | (Direction::MutexToActivity, Direction::ActivityToMutex) => { 407 | Some(Direction::MutexToActivity) 408 | } 409 | (Direction::ActivityToMutex, Direction::MutexToActivity) => { 410 | Some(Direction::ActivityToMutex) 411 | } 412 | (Direction::TwoWay, Direction::MutexToActivity) => { 413 | Some(Direction::ActivityToMutex) 414 | } 415 | (Direction::TwoWay, Direction::ActivityToMutex) => { 416 | Some(Direction::MutexToActivity) 417 | } 418 | _ => None, 419 | } { 420 | connection.set_direction(new_direction); 421 | activity_connections.insert(mutex_id, connection); 422 | } 423 | }; 424 | self.connections.insert(activity_id, activity_connections); 425 | } 426 | } 427 | 428 | pub fn update_connection_states(&mut self) { 429 | self.do_per_connection(|connection, activity_node, mutex_node| { 430 | connection.tick(activity_node, mutex_node); 431 | }); 432 | } 433 | 434 | pub fn is_connected( 435 | &self, 436 | activity_id: ActivityNodeId, 437 | mutex_id: MutexNodeId, 438 | direction: Direction, 439 | ) -> bool { 440 | self.connections 441 | .get(&activity_id) 442 | .and_then(|activity_connections| activity_connections.get(&mutex_id)) 443 | .map(|connection| { 444 | connection.get_direction() == direction 445 | || connection.get_direction() == Direction::TwoWay 446 | }) 447 | .unwrap_or(false) 448 | } 449 | 450 | pub fn toggle_connection( 451 | &mut self, 452 | activity_id: ActivityNodeId, 453 | mutex_id: MutexNodeId, 454 | direction: Direction, 455 | ) { 456 | if self.is_connected(activity_id, mutex_id, direction) { 457 | self.disconnect(activity_id, mutex_id, direction); 458 | } else { 459 | self.connect(activity_id, mutex_id, direction, true); 460 | } 461 | } 462 | 463 | fn do_per_connection(&mut self, mut action: F) 464 | where 465 | F: FnMut(&mut connection::Connection, &mut ActivityNode, &mut MutexNode), 466 | { 467 | self.connections 468 | .iter_mut() 469 | .for_each(|(activity_id, activity_connections)| { 470 | if let Some(activity_node) = self.activity_nodes.get_mut(activity_id) { 471 | activity_connections 472 | .iter_mut() 473 | .for_each(|(mutex_id, connection)| { 474 | if let Some(mutex_node) = self.mutex_nodes.get_mut(mutex_id) { 475 | action(connection, activity_node, mutex_node); 476 | } 477 | }); 478 | } 479 | }); 480 | } 481 | 482 | fn new_random_activity(pos: Pos2) -> ActivityNode { 483 | let mut activity_node = ActivityNode::new(pos); 484 | activity_node.activity_name = "Activity".to_string(); 485 | activity_node.task_name = random_word::gen_len(thread_rng().gen_range(4..=8), Lang::En) 486 | .map_or("Task".to_string(), |s| { 487 | // capitalize first character of the word 488 | let mut chars = s.chars(); 489 | match chars.next() { 490 | None => String::new(), 491 | Some(first_char) => { 492 | first_char.to_uppercase().collect::() + chars.as_str() 493 | } 494 | } 495 | }); 496 | activity_node.duration = 1; 497 | activity_node.priority = 0; 498 | activity_node 499 | } 500 | } 501 | 502 | // simulation 503 | impl Graph { 504 | pub fn tick(&mut self, ui: &egui::Ui) { 505 | if self.remaining_ticks_to_run != 0 { 506 | let mut previous_tick_progress = self.tick_progress; 507 | self.tick_progress += ui.ctx().input(|i| i.stable_dt) * self.ticks_per_second; 508 | ui.ctx().request_repaint(); // keep the simulation running 509 | loop { 510 | if previous_tick_progress < 0.5 && self.tick_progress >= 0.5 { 511 | self.tick_a(); 512 | self.do_per_connection(|c, a, m| { 513 | c.tick(a, m); 514 | }); 515 | } 516 | if self.tick_progress >= 1. { 517 | self.tick_b(); 518 | self.do_per_connection(|c, a, m| { 519 | c.tick(a, m); 520 | }); 521 | 522 | self.tick_progress -= 1.; 523 | if self.remaining_ticks_to_run > 0 { 524 | self.remaining_ticks_to_run -= 1; 525 | if self.remaining_ticks_to_run == 0 { 526 | self.tick_progress = 0.; 527 | } 528 | } 529 | 530 | // make sure tick_a() is called 531 | previous_tick_progress = 0.; 532 | } else { 533 | break; 534 | } 535 | } 536 | } 537 | } 538 | 539 | fn tick_a(&mut self) { 540 | let base_seed = rand::random::(); 541 | self.activity_nodes 542 | .sort_by(|&id_1, activity_node_1, &id_2, activity_node_2| { 543 | match activity_node_1.priority.cmp(&activity_node_2.priority) { 544 | // randomize if priority is the same 545 | std::cmp::Ordering::Equal => { 546 | let random_1 = 547 | rand::rngs::StdRng::seed_from_u64(base_seed.wrapping_add(*id_1 as u64)) 548 | .gen::(); 549 | let random_2 = 550 | rand::rngs::StdRng::seed_from_u64(base_seed.wrapping_add(*id_2 as u64)) 551 | .gen::(); 552 | random_1.cmp(&random_2) 553 | } 554 | ordering => ordering, 555 | } 556 | }); 557 | self.activity_nodes 558 | .iter_mut() 559 | .rev() 560 | .for_each(|(activity_id, activity_node)| { 561 | if activity_node.remaining_duration > 0 { 562 | return; 563 | } 564 | 565 | let activity_connections = self.connections.get(activity_id); 566 | 567 | // check if prerequisites are met 568 | let prerequisites_missing = 569 | activity_connections.map_or(false, |activity_connections| { 570 | activity_connections 571 | .iter() 572 | .filter(|(_, connection)| { 573 | connection.get_direction() != Direction::ActivityToMutex 574 | }) 575 | .filter_map(|(mutex_id, _)| self.mutex_nodes.get(mutex_id)) 576 | .any(|mutex_node| mutex_node.value == 0) 577 | }); 578 | if prerequisites_missing { 579 | return; 580 | } 581 | 582 | // start the node 583 | activity_node.remaining_duration = activity_node.duration; 584 | 585 | // decrement prerequisites 586 | if let Some(activity_connections) = activity_connections { 587 | activity_connections 588 | .iter() 589 | .for_each(|(mutex_id, connection)| { 590 | if connection.get_direction() != Direction::ActivityToMutex { 591 | if let Some(mutex_node) = self.mutex_nodes.get_mut(mutex_id) { 592 | mutex_node.value -= 1; 593 | } 594 | } 595 | }); 596 | } 597 | }); 598 | 599 | // return to predictable order for drawing the ui 600 | self.activity_nodes.sort_unstable_keys(); 601 | } 602 | 603 | fn tick_b(&mut self) { 604 | for (activity_id, activity_node) in &mut self.activity_nodes { 605 | if activity_node.remaining_duration == 0 { 606 | continue; 607 | } 608 | activity_node.remaining_duration -= 1; 609 | 610 | if activity_node.remaining_duration == 0 { 611 | if let Some(activity_connections) = self.connections.get(activity_id) { 612 | // increment all outputs 613 | activity_connections 614 | .iter() 615 | .for_each(|(mutex_id, connection)| { 616 | if connection.get_direction() != Direction::MutexToActivity { 617 | if let Some(mutex_node) = self.mutex_nodes.get_mut(mutex_id) { 618 | mutex_node.value += 1; 619 | } 620 | } 621 | }) 622 | } 623 | } 624 | } 625 | } 626 | 627 | pub fn is_running(&self) -> bool { 628 | self.remaining_ticks_to_run < 0 629 | } 630 | 631 | pub fn toggle_play_pause(&mut self) { 632 | self.remaining_ticks_to_run = match self.remaining_ticks_to_run { 633 | -1 => 1, 634 | _ => -1, 635 | } 636 | } 637 | 638 | pub fn queue_tick(&mut self) { 639 | if self.remaining_ticks_to_run >= 0 { 640 | self.set_remaining_ticks_to_run(self.remaining_ticks_to_run + 1); 641 | } 642 | } 643 | 644 | pub fn set_remaining_ticks_to_run(&mut self, ticks: i32) { 645 | self.remaining_ticks_to_run = ticks; 646 | } 647 | 648 | pub fn get_remaining_ticks_to_run(&self) -> i32 { 649 | self.remaining_ticks_to_run 650 | } 651 | } 652 | 653 | // ux 654 | impl Graph { 655 | pub fn queue_autofit(&mut self) { 656 | self.autofit_rect = Some(egui::Rect::NAN); 657 | } 658 | 659 | pub fn interact( 660 | &mut self, 661 | ui: &mut egui::Ui, 662 | container_transform: &mut egui::emath::TSTransform, 663 | container_response: &egui::Response, 664 | ) { 665 | // autofit 666 | if container_response.triple_clicked() && self.editing_mode != EditingMode::Delete { 667 | self.queue_autofit(); 668 | } 669 | 670 | if let Some(last_autofit_frame_rect) = self.autofit_rect { 671 | let untransformed_viewport_rect = container_response.rect; 672 | if last_autofit_frame_rect == untransformed_viewport_rect 673 | && untransformed_viewport_rect.width() >= 150. 674 | && untransformed_viewport_rect.height() >= 150. 675 | { 676 | self.autofit_rect = None; 677 | } else if untransformed_viewport_rect.is_positive() { 678 | self.autofit_rect = Some(untransformed_viewport_rect); 679 | let mut bounding_rect = egui::Rect::NOTHING; 680 | self.activity_nodes.iter().for_each(|(_, node)| { 681 | let rect = egui::Rect::from_center_size(node.pos, egui::vec2(150., 100.)); 682 | bounding_rect = bounding_rect.union(rect); 683 | }); 684 | self.mutex_nodes.iter().for_each(|(_, node)| { 685 | let rect = egui::Rect::from_center_size(node.pos, egui::vec2(50., 50.)); 686 | bounding_rect = bounding_rect.union(rect); 687 | }); 688 | 689 | if bounding_rect.is_positive() { 690 | let scale_x = untransformed_viewport_rect.width() / bounding_rect.width(); 691 | let scale_y = untransformed_viewport_rect.height() / bounding_rect.height(); 692 | container_transform.scaling = scale_x.min(scale_y).min(1.8); 693 | container_transform.translation = egui::Vec2::ZERO; 694 | container_transform.translation = 695 | untransformed_viewport_rect.center().to_vec2() 696 | - (*container_transform * bounding_rect.center()).to_vec2(); 697 | } else { 698 | *container_transform = TSTransform::default(); 699 | } 700 | } 701 | } 702 | 703 | // node interactions 704 | let mut node_left_clicked = None; 705 | let mut node_right_clicked = None; 706 | self.activity_nodes.iter_mut().for_each(|(id, node)| { 707 | if let Some(response) = node.interact(ui) { 708 | if response.clicked() { 709 | node_left_clicked = Some(AnyNode::Activity(*id)); 710 | } 711 | if response.secondary_clicked() { 712 | node_right_clicked = Some(AnyNode::Activity(*id)); 713 | } 714 | } 715 | }); 716 | self.mutex_nodes.iter_mut().for_each(|(id, node)| { 717 | if let Some(response) = node.interact(ui) { 718 | if response.clicked() { 719 | node_left_clicked = Some(AnyNode::Mutex(*id)); 720 | } 721 | if response.secondary_clicked() { 722 | node_right_clicked = Some(AnyNode::Mutex(*id)); 723 | } 724 | } 725 | }); 726 | if self.currently_connecting_from.is_none() { 727 | self.currently_connecting_from = node_right_clicked; 728 | node_right_clicked = None; 729 | } 730 | 731 | match self.editing_mode { 732 | EditingMode::Delete => { 733 | if container_response.secondary_clicked() { 734 | self.editing_mode = EditingMode::None; 735 | return; 736 | } 737 | if let Some(AnyNode::Activity(id)) = node_left_clicked { 738 | self.activity_nodes.swap_remove(&id); 739 | self.connections.remove(&id); 740 | } 741 | if let Some(AnyNode::Mutex(id)) = node_left_clicked { 742 | self.mutex_nodes.remove(&id); 743 | self.connections.iter_mut().for_each(|(_, connections)| { 744 | connections.remove(&id); 745 | }); 746 | } 747 | } 748 | EditingMode::None => { 749 | // click existing node 750 | if let Some(new_from_node) = match ( 751 | self.currently_connecting_from, 752 | node_left_clicked.or(node_right_clicked), 753 | ) { 754 | ( 755 | Some(AnyNode::Activity(from_activity_id)), 756 | Some(AnyNode::Mutex(to_mutex_id)), 757 | ) => { 758 | self.toggle_connection( 759 | from_activity_id, 760 | to_mutex_id, 761 | Direction::ActivityToMutex, 762 | ); 763 | Some(AnyNode::Mutex(to_mutex_id)) 764 | } 765 | ( 766 | Some(AnyNode::Mutex(from_mutex_id)), 767 | Some(AnyNode::Activity(to_activity_id)), 768 | ) => { 769 | self.toggle_connection( 770 | to_activity_id, 771 | from_mutex_id, 772 | Direction::MutexToActivity, 773 | ); 774 | Some(AnyNode::Activity(to_activity_id)) 775 | } 776 | ( 777 | Some(AnyNode::Activity(from_activity_id)), 778 | Some(AnyNode::Activity(to_activity_id)), 779 | ) => { 780 | if let (Some(from_activity), Some(to_activity)) = ( 781 | self.activity_nodes.get(&from_activity_id), 782 | self.activity_nodes.get(&to_activity_id), 783 | ) { 784 | let mutex_pos = from_activity.pos / 2. + to_activity.pos.to_vec2() / 2.; 785 | let mutex_id = self.add_mutex_node(MutexNode::new(mutex_pos)); 786 | self.connect( 787 | from_activity_id, 788 | mutex_id, 789 | Direction::ActivityToMutex, 790 | true, 791 | ); 792 | self.connect( 793 | to_activity_id, 794 | mutex_id, 795 | Direction::MutexToActivity, 796 | true, 797 | ); 798 | } 799 | Some(AnyNode::Activity(to_activity_id)) 800 | } 801 | (Some(AnyNode::Mutex(from_mutex_id)), Some(AnyNode::Mutex(to_mutex_id))) => { 802 | if let (Some(from_mutex), Some(to_mutex)) = ( 803 | self.mutex_nodes.get(&from_mutex_id), 804 | self.mutex_nodes.get(&to_mutex_id), 805 | ) { 806 | let activity_pos = from_mutex.pos / 2. + to_mutex.pos.to_vec2() / 2.; 807 | let activity_id = 808 | self.add_activity_node(Graph::new_random_activity(activity_pos)); 809 | self.connect( 810 | activity_id, 811 | from_mutex_id, 812 | Direction::MutexToActivity, 813 | true, 814 | ); 815 | self.connect( 816 | activity_id, 817 | to_mutex_id, 818 | Direction::ActivityToMutex, 819 | true, 820 | ); 821 | } 822 | Some(AnyNode::Mutex(to_mutex_id)) 823 | } 824 | _ => None, 825 | } { 826 | self.currently_connecting_from = match node_left_clicked.is_some() { 827 | true => None, 828 | false => Some(new_from_node), 829 | }; 830 | } 831 | 832 | // right click empty space (create nodes) 833 | if container_response.secondary_clicked() { 834 | if let Some(pos) = container_response.interact_pointer_pos() { 835 | let pos = container_transform.inverse() * pos; 836 | match self.currently_connecting_from { 837 | Some(AnyNode::Mutex(mutex_id)) => { 838 | let activity_id = 839 | self.add_activity_node(Graph::new_random_activity(pos)); 840 | self.connect( 841 | activity_id, 842 | mutex_id, 843 | Direction::MutexToActivity, 844 | true, 845 | ); 846 | self.currently_connecting_from = 847 | Some(AnyNode::Activity(activity_id)); 848 | } 849 | Some(AnyNode::Activity(activity_id)) => { 850 | let mutex_id = self.add_mutex_node(MutexNode::new(pos)); 851 | self.connect( 852 | activity_id, 853 | mutex_id, 854 | Direction::ActivityToMutex, 855 | true, 856 | ); 857 | self.currently_connecting_from = Some(AnyNode::Mutex(mutex_id)); 858 | } 859 | None => { 860 | self.add_activity_node(Graph::new_random_activity(pos)); 861 | } 862 | } 863 | } 864 | } 865 | 866 | // left click empty space (click away) 867 | if container_response.clicked() { 868 | self.currently_connecting_from = None; 869 | } 870 | 871 | // draw connection preview 872 | if let Some(pointer_pos) = ui.input(|i| i.pointer.latest_pos()) { 873 | match self.currently_connecting_from { 874 | Some(AnyNode::Mutex(id)) => { 875 | if let Some(node) = self.mutex_nodes.get(&id) { 876 | connection::Connection::draw_arrow( 877 | ui, 878 | node.pos, 879 | container_transform.inverse() * pointer_pos, 880 | connection::Color::Default, 881 | connection::Color::Default, 882 | 0., 883 | ); 884 | } 885 | } 886 | Some(AnyNode::Activity(id)) => { 887 | if let Some(node) = self.activity_nodes.get(&id) { 888 | connection::Connection::draw_arrow( 889 | ui, 890 | node.pos, 891 | container_transform.inverse() * pointer_pos, 892 | connection::Color::Default, 893 | connection::Color::Default, 894 | 0., 895 | ); 896 | } 897 | } 898 | None => (), 899 | }; 900 | } 901 | } 902 | } 903 | } 904 | 905 | pub fn draw(&mut self, ui: &mut egui::Ui, container_transform: egui::emath::TSTransform) { 906 | ui.style_mut().spacing.interact_size = egui::Vec2::ZERO; 907 | ui.style_mut().spacing.button_padding = egui::Vec2::ZERO; 908 | ui.style_mut().interaction.multi_widget_text_select = false; 909 | 910 | // draw 911 | let tick_progress = self.tick_progress; 912 | self.do_per_connection(|c, a, m| c.draw(ui, a, m, tick_progress)); 913 | self.mutex_nodes 914 | .iter_mut() 915 | .for_each(|n| n.1.draw(ui, container_transform)); 916 | self.activity_nodes 917 | .iter_mut() 918 | .for_each(|(_, activity_node)| { 919 | activity_node.draw(ui, container_transform, tick_progress) 920 | }); 921 | } 922 | } 923 | --------------------------------------------------------------------------------