diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..006c8da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: Cargo Build & Test + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + build_and_test: + name: latest + runs-on: ubuntu-latest + strategy: + matrix: + toolchain: + - stable + - beta + - nightly + steps: + - uses: actions/checkout@v4 + - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} + - run: cargo build --verbose + - run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..999382e --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Exclude VSCode folder +.vscode + +# Exclude build directory +build/ + +dashboard.html + +generated_is.json + +# Python virtual environment. +.env/ + +gui.bin + +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ed63636 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "egui-gdl" +version = "0.1.0" +edition = "2021" +rust-version = "1.65" +description = "Draw graphs with egui." +license = "MIT OR Apache-2.0" + +[dependencies] +egui = "0.23" +eframe = { version = "0.23", default-features = false, features = [ + "default_fonts", # Embed the default egui fonts. + "glow", # Use the glow rendering backend. Alternative: "wgpu". + "persistence", # Enable restoring app state when restarting the app. +] } + +# You only need serde if you want app persistence: +serde = { version = "1", features = ["derive"] } +layout-rs = "0.1.1" +petgraph = { version = "0.6.3", features = ["serde-1"] } +rand = "0.8.5" +tracing = "0.1.37" + +# native: +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +instant = "0.1" + +# web: +[target.'cfg(target_arch = "wasm32")'.dependencies] +instant = { version = "0.1", features = [ "wasm-bindgen", "inaccurate" ] } diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..141b5fa --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2024 NEVERHACK + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..611a29c --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# EGUI Graph Drawer Library + +Draw graphs with egui, highly extensible. Supports images, animations, interactivity... + +![](./etc/hello-world.gif) + +## Examples + +- [Hello-world](./examples/hellow-world): + +![](./etc/hello-world.png) + +## License + +Licensed under either of + + * Apache License, Version 2.0 + ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) + * MIT license + ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. + +## Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in the work by you, as defined in the Apache-2.0 license, shall be +dual licensed as above, without any additional terms or conditions. diff --git a/etc/hello-world.gif b/etc/hello-world.gif new file mode 100644 index 0000000..5f34f87 Binary files /dev/null and b/etc/hello-world.gif differ diff --git a/etc/hello-world.png b/etc/hello-world.png new file mode 100644 index 0000000..99b4d47 Binary files /dev/null and b/etc/hello-world.png differ diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml new file mode 100644 index 0000000..6685687 --- /dev/null +++ b/examples/hello-world/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "hello-world" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +egui = "0.23" +eframe = { version = "0.23", default-features = false, features = [ + "default_fonts", # Embed the default egui fonts. + "glow", # Use the glow rendering backend. Alternative: "wgpu". + "persistence", # Enable restoring app state when restarting the app. +] } +petgraph = { version = "0.6.3", features = ["serde-1"] } +egui-gdl = { path = "../.." } diff --git a/examples/hello-world/src/main.rs b/examples/hello-world/src/main.rs new file mode 100644 index 0000000..94c6666 --- /dev/null +++ b/examples/hello-world/src/main.rs @@ -0,0 +1,69 @@ +use eframe::egui; +use egui::*; +use egui_gdl::*; + +pub type GraphType = petgraph::Graph; + +struct MyApp { + pub graph: Graph<(), String, ()>, +} + +impl Default for MyApp { + fn default() -> Self { + let mut initial_graph = GraphType::new(); + let pg = initial_graph.add_node("petgraph".to_string()); + let fb = initial_graph.add_node("fixedbitset".to_string()); + let qc = initial_graph.add_node("quickcheck".to_string()); + let rand = initial_graph.add_node("rand".to_string()); + let libc = initial_graph.add_node("libc".to_string()); + initial_graph.extend_with_edges([(pg, fb), (pg, qc), (qc, rand), (rand, libc), (qc, libc)]); + + let mut graph = Graph::new() + .set_node_size(vec2(75.0, 75.0)) + .set_node_margin(vec2(0.0, 20.0)) + .show_node( + |graph: &mut petgraph::Graph, index, ui: &mut Ui, zoom_factor| { + let name = graph.node_weight(index).unwrap(); + + ui.painter().rect_filled( + ui.available_rect_before_wrap(), + 100. * zoom_factor, + Color32::WHITE, + ); + + ui.add_space(75.0 * zoom_factor); + + ui.centered_and_justified(|ui| { + ui.label(name); + }); + }, + ); + graph.update_graph(initial_graph); + + Self { graph } + } +} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.heading("Hello world graphs!!!"); + self.graph.show(ui, None); + }); + } +} + +fn main() { + let options = eframe::NativeOptions { + ..Default::default() + }; + eframe::run_native( + "Hello world", + options, + Box::new(|_| { + // This gives us image support: + Box::::default() + }), + ) + .unwrap(); +} diff --git a/src/graph_elements.rs b/src/graph_elements.rs new file mode 100644 index 0000000..1625ec5 --- /dev/null +++ b/src/graph_elements.rs @@ -0,0 +1,395 @@ +//! # Graph elements + +use std::{collections::HashMap, f32::INFINITY}; + +use egui::{Pos2, Rect, Vec2}; +use instant::Instant; +use layout::{ + adt::dag::NodeHandle, + core::{base::Orientation, format::RenderBackend, geometry::Point, style::StyleAttr}, + std_shapes::shapes::{Arrow, Element, ShapeKind}, + topo::layout::VisualGraph, +}; +use petgraph::{stable_graph::IndexType, EdgeType, Graph}; +use std::fmt::Debug; + +/// Configuration of a drawable graph. +#[derive(Clone)] +pub struct DrawableGraphConfig { + /// Max size of a node (represents its shape). + pub node_box_size: Vec2, + /// Max size of an edge (represents its shape). + pub edge_box_size: Vec2, + /// Default text size. + pub text_font_size: f32, + /// Sampling for bezier curves. + /// Number of sampling to apply to draw a bezier curve. Higher it is, smoother it will be rendered. + pub bezier_curve_sampling: usize, +} + +impl Default for DrawableGraphConfig { + fn default() -> Self { + Self { + node_box_size: Vec2::new(150.0, 40.0), + edge_box_size: Vec2::new(150.0, 40.0), + text_font_size: 11.0, + bezier_curve_sampling: 20, // NOTE: higher sampling render better graph lines, but impact performance + } + } +} + +/// Helper struct that'll contain a petgraph graph. +/// TODO: maybe store a reference, or don't store it +/// TODO: maybe only apply graph addition here also? (to not update the whole graph all the time) +pub struct DrawableGraph { + /// Rectangles to draw to represents nodes. + pub nodes: HashMap, + /// Representing nodes links/graph edges. + pub edges: HashMap, + /// Collection of the lines created by the edges. + pub lines: HashMap, + /// Inner petgraph graph. + pub inner: Option>, + /// The bounding box of the drawable graph (graph coordinates, not screen coordinates). + pub bounding_box: Rect, + /// Configuration of the drawable graph. + pub config: DrawableGraphConfig, +} + +impl DrawableGraph { + /// Create a new drawable graph. + pub fn new() -> Self { + Self { + nodes: HashMap::new(), + edges: HashMap::new(), + lines: HashMap::new(), + inner: None, + bounding_box: Rect::NOTHING, + config: Default::default(), + } + } + + /// Set a new inner graph. + pub fn set_graph(&mut self, graph: Graph) { + self.inner = Some(graph); + } + + /// Clear all drawable edges, nodes and links. + /// + /// NOTE: this keep the allocated memory, just clear elements and set len to 0. + pub fn clear_drawable_graph(&mut self) { + self.edges.clear(); + self.nodes.clear(); + self.lines.clear(); + } + + /// Update layout (to call if the inner graph changed). + pub fn update(&mut self) { + self.clear_drawable_graph(); + + let Some(inner) = &mut self.inner else { return }; + + // NOTE: if the graph is empty, there is nothing to do + if inner.node_count() == 0 { + return; + } + + // Reset graph size + self.bounding_box.max = Pos2::new(-INFINITY, -INFINITY); + self.bounding_box.min = Pos2::new(INFINITY, INFINITY); + + // Reset nodes and edges + // Since we clear (= we keep allocations) this just make sure we have enough space (otherwise do new allocation) + self.nodes.reserve(inner.node_count()); + self.edges.reserve(inner.edge_count()); + + // Convert the petgraph into a `layout::VisualGraph` + let mut layouter = VisualGraph::new(Orientation::LeftToRight); + let mut nodes = Vec::::with_capacity(inner.node_indices().count()); + + // Convert nodes + for i in inner.node_indices() { + // We store the node index inside rounded + // FIXME: this is a hack, either make a PR to layout or fork it? + let mut style = StyleAttr::simple(); + style.rounded = i.index(); + + let shape = ShapeKind::new_box(""); + let size = Point::new( + self.config.node_box_size.x.into(), + self.config.node_box_size.y.into(), + ); + let element = Element::create(shape, style, Orientation::LeftToRight, size); + let handle = layouter.add_node(element); + nodes.push(handle); + } + + // Convert edges + for i in inner.edge_indices() { + if let Some(endpoints) = inner.edge_endpoints(i) { + let mut arrow = Arrow::simple(&i.index().to_string()); + + // We store the node index inside font_size + // FIXME: this is a hack, either make a PR to layout or fork it? + arrow.look.font_size = i.index(); + + // NOTE: this is a fix to make the graph left to right in all cases + let (start, end) = if endpoints.0.index() > endpoints.1.index() { + (nodes[endpoints.1.index()], nodes[endpoints.0.index()]) + } else { + (nodes[endpoints.0.index()], nodes[endpoints.1.index()]) + }; + + layouter.add_edge(arrow, start, end); + } + } + + self.config.bezier_curve_sampling = + Self::optimize_bezier_curve_sampling(inner.edge_count()); + + // Compute the graph layout + // NOTE: "disable_opt" will improve performances a lot on huge graphs + // It doesn't impact that much the rendered graph + let disable_opt = inner.node_count() > 50 && inner.edge_count() > 50; + + tracing::debug!("Layout starting..."); + let started = Instant::now(); + + layouter.do_it(false, disable_opt, false, self); + + tracing::debug!("Layout finished in {:.2?}", started.elapsed()); + } + + /// Scale the bezier curve sampling value regarding the number of edges. + /// NOTE: higher sampling render better graph lines, but impact performance + fn optimize_bezier_curve_sampling(edges_count: usize) -> usize { + if edges_count < 50 { + 1000 + } else if edges_count < 200 { + 100 + } else { + 20 + } + } + + /// Check if position is in current bounding box, if not change the bounding box to include it. + fn update_bound_pos(&mut self, position: &Pos2) { + self.bounding_box.min = position.min(self.bounding_box.min); + self.bounding_box.max = position.max(self.bounding_box.max); + } + + /// Check if rect is in current bounding box, if not change the bounding box to include it. + fn update_bound_rect(&mut self, rect: &Rect) { + if rect.min.x < self.bounding_box.min.x { + self.bounding_box.min.x = rect.min.x; + }; + if rect.min.y < self.bounding_box.min.y { + self.bounding_box.min.y = rect.min.y; + }; + if rect.max.x > self.bounding_box.max.x { + self.bounding_box.max.x = rect.max.x; + }; + if rect.max.y > self.bounding_box.max.y { + self.bounding_box.max.y = rect.max.y; + }; + } +} + +// Used to convert a layout::VisualGraph into a drawable graph (layout will do the placement). +// FIXME: everything here is very hacky... +impl RenderBackend for DrawableGraph { + fn draw_rect( + &mut self, + xy: Point, + size: Point, + look: &StyleAttr, + _clip: Option, + ) { + let rect = Rect::from_min_max( + Pos2 { + x: xy.x as f32, + y: xy.y as f32, + }, + Pos2 { + x: xy.add(size).x as f32, + y: xy.add(size).y as f32, + }, + ); + + // Compute the bounding box of the graph + self.update_bound_rect(&rect); + + // We stored the node index inside rounded + // FIXME: this is a hack, either make a PR to layout or fork it? + self.nodes.insert(look.rounded, DrawableNode::new(rect)); + } + + fn draw_line(&mut self, _: Point, _: Point, _look: &StyleAttr) {} + + fn draw_circle(&mut self, _: Point, _: Point, _look: &StyleAttr) {} + + fn draw_text(&mut self, xy: Point, text: &str, _look: &StyleAttr) { + // If there is a text and that is parsable to and index, then it's an edge text location + // FIXME: this is a hack, either make a PR to layout or fork it? + if let Ok(index) = text.parse::() { + self.edges.insert( + index, + DrawableEdge::new(Pos2::new(xy.x as f32, xy.y as f32)), + ); + } + } + + fn draw_arrow( + &mut self, + path: &[(Point, Point)], + _: bool, + _: (bool, bool), + style: &StyleAttr, + _: &str, + ) { + let links = path + .to_vec() + .iter() + .map(|(first, second)| { + let first = Pos2::new(first.x as f32, first.y as f32); + let second = Pos2::new(second.x as f32, second.y as f32); + + // Compute the bounding box of the graph + self.update_bound_pos(&first); + self.update_bound_pos(&second); + + (first, second) + }) + .collect(); + + // We retrieve the id of the link from the font_size + // FIXME: this is a hack, either make a PR to layout or fork it? + self.lines.insert( + style.font_size, + DrawableEdgesLines::new(links, self.config.bezier_curve_sampling), + ); + } + + // Here we don't use layout clip functionality/styling, so let's just ignore it (= do nothing when drawing) + // We don't miss anything here since the construction of `layout::VisualGraph` doesn't create any clip rect + fn create_clip(&mut self, _: Point, _: Point, _: usize) -> layout::core::format::ClipHandle { + 0 + } +} + +impl Default for DrawableGraph { + fn default() -> Self { + Self::new() + } +} + +/// Node of a visual graph (drawable). +#[derive(Clone)] +pub struct DrawableNode { + /// Struct rect for the position of the rectangle. + pub rect: Rect, +} + +impl DrawableNode { + /// Helper to create a new node. + pub fn new(rect: Rect) -> Self { + Self { rect } + } +} + +/// Edge of a visual graph (drawable). +#[derive(Clone)] +pub struct DrawableEdge { + /// Content position of the edge (e.g. where to place text on a arrow). + pub content_position: Pos2, +} + +impl DrawableEdge { + /// Helper to create a new arrow. + pub fn new(content_position: Pos2) -> Self { + Self { content_position } + } +} + +/// Store a collection of drawable edges links. +/// Can contain all the lines/points of all of the edges of the graph. +pub struct DrawableEdgesLines { + /// Set of self.points that define the arrow (bezier curves). + points: Vec<(Pos2, Pos2)>, + /// List of line from a sampled bezier curve. + sampled_lines: Vec, + /// Sampling for the bezier curves. + bezier_curves_sampling: usize, +} + +impl DrawableEdgesLines { + /// Helper to create a new arrow. + pub fn new(points: Vec<(Pos2, Pos2)>, bezier_curves_sampling: usize) -> Self { + let mut this = Self { + points, + sampled_lines: Vec::new(), + bezier_curves_sampling, + }; + this.compute_bezier_curve(); + this + } + + /// Update the drawable edge. + #[allow(dead_code)] // This function will be used in a cache system + pub fn update(&mut self, points: Vec<(Pos2, Pos2)>) { + self.points = points; + self.compute_bezier_curve(); + } + + /// Get the sampled lines from this drawable edge. + pub fn sampled_lines(&self) -> &[Pos2] { + &self.sampled_lines + } + + /// Convert a set of lines to smooth bezier curves. + /// + /// + fn compute_bezier_curve(&mut self) { + if self.points.is_empty() { + return; + } + + let len = self.points.len() - 2; + + self.sampled_lines.clear(); + self.sampled_lines + .reserve(len * self.bezier_curves_sampling); + + for i in 0..=len { + let p0 = if i == 0 { + self.points[i].0 + } else { + self.points[i].1 + }; + let p1 = if i == 0 { + self.points[i].1 + } else { + Pos2::new( + self.points[i].0.x + ((self.points[i].1.x - self.points[i].0.x) * 2.0), + self.points[i].0.y + ((self.points[i].1.y - self.points[i].0.y) * 2.0), + ) + }; + let p2 = self.points[i + 1].0; + let p3 = self.points[i + 1].1; + + for step in 0..=self.bezier_curves_sampling { + let t = step as f32 / self.bezier_curves_sampling as f32; + let x = (1.0 - t).powi(3) * p0.x + + 3.0 * t * (1.0 - t).powi(2) * p1.x + + 3.0 * t.powi(2) * (1.0 - t) * p2.x + + t.powi(3) * p3.x; + let y = (1.0 - t).powi(3) * p0.y + + 3.0 * t * (1.0 - t).powi(2) * p1.y + + 3.0 * t.powi(2) * (1.0 - t) * p2.y + + t.powi(3) * p3.y; + + self.sampled_lines.push(Pos2::new(x, y)); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8c34797 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,731 @@ +//! # Graph UI +//! +//! This allow to draw in egui a petgraph using a node formatter function (returning a String). +//! +//! One actual drawback of the implementation is the performances (we could optimize with a cache and a quadtree coordinate system) +//! and the fact that after the "layouting" we don't have the whole node but just a formatted text (i.e. in case of a network graph, +//! we cannot know if the not correspond to a compromised machine and do extra animation). A solution is to add an id in the layout node text +//! and make a function using that id to associate a rect position and text to a specific node (kind of dirty hack...). +//! +//! Main things to do: +//! - Optimization (cache + quadtree to know which element to draw if collision with the screen area), +//! - Node association to layout graph (either layout fork or id in text solution). + +use eframe::egui; + +use egui::epaint::PathShape; +use egui::{Color32, Context, Id, Pos2, Rect, Response, Sense, Stroke, Ui}; +use egui::{Layout, Vec2}; +use petgraph::stable_graph::{DefaultIx, EdgeIndex, IndexType, NodeIndex}; +use petgraph::{Directed, EdgeType}; +use rand::Rng; +use std::fmt::Debug; +use std::marker::PhantomData; + +pub mod graph_elements; +pub mod transform; + +use transform::TransformState; + +use self::graph_elements::{DrawableGraph, DrawableGraphConfig}; + +/// Zoom step to apply. +const ZOOM_STEP: f32 = 0.002; + +/// Box margin used when auto resizing the view. +const BOX_MARGIN: f32 = 20.0; + +/// Possible closures to call on node drawing. +/// Will be called for each node. +#[allow(clippy::type_complexity)] +enum ShowNode { + /// See `Graph::show_node`. + Simple(Box, NodeIndex, &mut Ui, f32)>), + /// See `Graph::show_node_with_state`. + WithState(Box, NodeIndex, &mut Ui, f32, &mut S)>), + /// See `Graph::show_node_interactive`. + Interactive(Box, NodeIndex, &mut Ui, f32, Response)>), + /// See `Graph::show_node_interactive_with_state`. + InteractiveWithState( + Box, NodeIndex, &mut Ui, f32, &mut S, Response)>, + ), +} + +/// Possible closures to call on edge drawing. +/// Will be called for each edge. +#[allow(clippy::type_complexity)] +enum ShowEdge { + /// See `Graph::show_edge`. + Simple(Box, EdgeIndex, &mut Ui, f32)>), + /// See `Graph::show_edge_with_state`. + WithState(Box, EdgeIndex, &mut Ui, f32, &mut S)>), + /// See `Graph::show_edge_interactive`. + Interactive(Box, EdgeIndex, &mut Ui, f32, Response)>), + /// See `Graph::show_edge_interactive_with_state`. + InteractiveWithState( + Box, EdgeIndex, &mut Ui, f32, &mut S, Response)>, + ), +} + +/// Store a drawable representation of a graph, after a layout pass (positioning was done). +/// +/// TODO: we should add a "hook" system after layout to keep the node. To do that we know that a node text center = rect center, +/// so if we store an id in the text, we can retrieve the associated text and rect of the associated node. +#[allow(clippy::type_complexity)] +pub struct Graph { + /// Inner drawable graph. + inner: DrawableGraph, + /// Closure to know what to draw to represents a node. + show_node_closure: Option>, + /// Closure to know what to draw to represents an edge. + show_edge_closure: Option>, + /// Closure to know how to draw to represents a link. + show_link_closure: + Option, &Context, EdgeIndex, f32) -> Stroke>>, + /// Id of the graph. + id: Id, + /// Graph state (phantom data). + phantom: PhantomData, + /// Has the graph changed or not. + graph_need_update: bool, + /// Maximum bound of the graph size in `x`. + /// If the limit is hit, only the right size of the graph will be shown on `auto_resize`. + /// The visible graph will have a virtual bounding box in `x` min of `bounding_box.x - window_size`. + /// So in `x` only values in `[bounding_box.max.x - window_size;bounding_box.max.x]` will be shown. + maximum_bound_x: Option, + /// Maximum bound of the graph size in `y`. + /// If the limit is hit, only the right size of the graph will be shown on `auto_resize`. + /// The visible graph will have a virtual bounding box in `y` min of `bounding_box.y - window_size`. + /// So in `y` only values in `[bounding_box.max.y - window_size;bounding_box.max.y]` will be shown. + maximum_bound_y: Option, + /// Margin to use when `auto_resize` (i.e. to not have extremities nodes overlapping with graph view limits). + node_margin: Vec2, +} + +impl Graph { + /// Create a new graph (egi drawable) from a petgraph graph. + pub fn new() -> Self { + Self { + inner: DrawableGraph::new(), + show_node_closure: None, + show_edge_closure: None, + show_link_closure: None, + id: Id::new(rand::thread_rng().gen::()), + phantom: PhantomData, + graph_need_update: true, + maximum_bound_x: None, + maximum_bound_y: None, + node_margin: Vec2::new(BOX_MARGIN, BOX_MARGIN), + } + } + + /// Simply show a node without state or [`egui::Response`]. + /// + /// Helper function for underlying `ShowNode::Simple`. + pub fn show_node(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, NodeIndex, &mut Ui, f32) + 'static, + { + self.show_node_closure = Some(ShowNode::Simple(Box::new(closure))); + self + } + + /// Show a node regarding a provided closure, providing a state (e.g. app state). + /// If used, the state have to be providing when showing the graph (otherwise nothing will be shown). + /// + /// Helper function for underlying `ShowNode::WithState`. + pub fn show_node_with_state(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, NodeIndex, &mut Ui, f32, &mut S) + 'static, + { + self.show_node_closure = Some(ShowNode::WithState(Box::new(closure))); + self + } + + /// Show a node regarding a provided closure, providing an [`egui::Response`] used to check if the node is clicked + /// or anything else. + /// + /// NOTE: Using this will disable drag/move tracking for graph movement/position reset (for all nodes). + /// + /// Helper function for underlying `ShowNode::Interactive`. + pub fn show_node_interactive(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, NodeIndex, &mut Ui, f32, Response) + 'static, + { + self.show_node_closure = Some(ShowNode::Interactive(Box::new(closure))); + self + } + + /// Show a node regarding a provided closure, providing a state (e.g. app state) and an [`egui::Response`] used to + /// check if the node is clicked or anything else. + /// + /// NOTE: Using this will disable drag/move tracking for graph movement/position reset (for all nodes). + /// + /// Helper function for underlying `ShowNode::InteractiveWithState`. + pub fn show_node_interactive_with_state(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, NodeIndex, &mut Ui, f32, &mut S, Response) + + 'static, + { + self.show_node_closure = Some(ShowNode::InteractiveWithState(Box::new(closure))); + self + } + + /// Helper function for underlying `ShowEdge::Simple`. + pub fn show_edge(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, EdgeIndex, &mut Ui, f32) + 'static, + { + self.show_edge_closure = Some(ShowEdge::Simple(Box::new(closure))); + self + } + + /// Helper function for underlying `ShowEdge::WithState`. + pub fn show_edge_with_state(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, EdgeIndex, &mut Ui, f32, &mut S) + 'static, + { + self.show_edge_closure = Some(ShowEdge::WithState(Box::new(closure))); + self + } + + /// Helper function for underlying `ShowEdge::Interactive`. + pub fn show_edge_interactive(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, EdgeIndex, &mut Ui, f32, Response) + 'static, + { + self.show_edge_closure = Some(ShowEdge::Interactive(Box::new(closure))); + self + } + + /// Helper function for underlying `ShowEdge::InteractiveWithState`. + pub fn show_edge_interactive_with_state(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, EdgeIndex, &mut Ui, f32, &mut S, Response) + + 'static, + { + self.show_edge_closure = Some(ShowEdge::InteractiveWithState(Box::new(closure))); + self + } + + /// Set a closure to be called when drawing a link. + /// This can be used to define links style. + pub fn show_link(mut self, closure: F) -> Self + where + F: Fn(&mut petgraph::Graph, &Context, EdgeIndex, f32) -> Stroke + 'static, + { + self.show_link_closure = Some(Box::new(closure)); + self + } + + /// Max size of a node (represents its shape). + pub fn set_node_size(mut self, node_size: Vec2) -> Self { + self.inner.config.node_box_size = node_size; + self + } + + /// Max size of an edge (represents its shape). + pub fn set_edge_box_size(mut self, edge_box_size: Vec2) -> Self { + self.inner.config.edge_box_size = edge_box_size; + self + } + + /// Default text size. + pub fn set_text_font_size(mut self, text_font_size: f32) -> Self { + self.inner.config.text_font_size = text_font_size; + self + } + + /// Sampling for bezier curves. + /// Number of sampling to apply to draw a bezier curve. Higher it is, smoother it will be rendered. + pub fn set_bezier_curve_sampling(mut self, bezier_curve_sampling: usize) -> Self { + self.inner.config.bezier_curve_sampling = bezier_curve_sampling; + self + } + + /// Maximum bound of the graph size in `x`. + /// If the limit is hit, only the right size of the graph will be shown on `auto_resize`. + /// The visible graph will have a virtual bounding box in `x` min of `bounding_box.x - window_size`. + /// So in `x` only values in `[bounding_box.max.x - window_size;bounding_box.max.x]` will be shown. + pub fn set_maximum_bound_x(mut self, maximum_bound_x: f32) -> Self { + self.maximum_bound_x = Some(maximum_bound_x); + self + } + + /// Maximum bound of the graph size in `y`. + /// If the limit is hit, only the right size of the graph will be shown on `auto_resize`. + /// The visible graph will have a virtual bounding box in `y` min of `bounding_box.y - window_size`. + /// So in `y` only values in `[bounding_box.max.y - window_size;bounding_box.max.y]` will be shown. + pub fn set_maximum_bound_y(mut self, maximum_bound_y: f32) -> Self { + self.maximum_bound_y = Some(maximum_bound_y); + self + } + + /// Margin to use when `auto_resize` (i.e. to not have extremities nodes overlapping with graph view limits). + pub fn set_node_margin(mut self, node_margin: Vec2) -> Self { + self.node_margin = node_margin; + self + } + + /// Set the graph to use (initial graph). + pub fn set_graph(mut self, graph: petgraph::Graph) -> Self { + self.inner.set_graph(graph); + self.graph_need_update = true; + self + } + + /// Set a new drawable `GraphUi` from a `petgraph` (keeping all ids and transformation). + /// Helpful to keep IDs between replacement (e.g. keep same zoom/movement). + /// + /// # Arguments + /// + /// `graph` - The petgraph to convert. + pub fn update_graph(&mut self, graph: petgraph::Graph) { + self.inner.set_graph(graph); + self.graph_need_update = true; + } + + /// Get inner graph. + pub fn graph(&self) -> Option<&petgraph::Graph> { + self.inner.inner.as_ref() + } + + /// Get inner graph mutably. + pub fn graph_mut(&mut self) -> Option<&mut petgraph::Graph> { + self.inner.inner.as_mut() + } + + /// Show/paint the graph with transformations. + /// Also wait and apply user interactions (drag, zoom...). + /// + /// **Warning: calling this function will convert and compute the graph layout (if needed, i.e. if graph changed), so this is compute intensive.** + pub fn show(&mut self, ui: &mut Ui, state: Option<&mut S>) { + // If the graph has changed since last show, we need to update it + if self.graph_need_update { + self.inner.update(); + self.graph_need_update = false; + } + + // If there is nothing to draw, just skip everything + if self.inner.nodes.is_empty() && self.inner.edges.is_empty() { + return; + } + + // Create a new transform or load the old one + let mut transform = TransformState::load_from_ui_memory(ui, self.id.with("zoom")) + .unwrap_or_else(|| TransformState::new(self.id.with("zoom"), ui)); + + // Upload in transform the rect drawable + transform.draw_rect = ui.available_rect_before_wrap(); + + // Recovers the interactions + let response = ui.interact(transform.draw_rect, self.id, Sense::click_and_drag()); + + // Paint the graph with zoom, drag + transform.show(ui, |ui| { + self.draw(ui, &transform, state); + }); + + // Handle drag. + if response.dragged() { + transform.user_modified = true; + transform.drag(response.drag_delta()); + } + + // Handle zoom + if let Some(pos) = ui.ctx().pointer_latest_pos() { + let zoom = ui.input(|i| i.scroll_delta.y); + if zoom != 0. && transform.draw_rect.contains(pos) { + transform.user_modified = true; + let zoom = (zoom * ZOOM_STEP).exp(); + transform.zoom(pos, zoom); + } + } + + // Handle reset double click + if response.double_clicked() || !transform.user_modified { + self.auto_resize(ui, &mut transform); + } + + // Save the transform into egui memory + transform.store_in_ui_memory(ui); + } + + /// Draw the graph. + /// + /// Maybe we could optimize this function since we'll iter on all element of the graph and draw them. + /// We doesn't cache anything so we recalcultate all at each call. + /// + /// **WARNING: this is not optimized at all** + /// + /// TODO: this could be optimized by storing Shapes and reuse them if the user didn't zoom/drag. + fn draw(&mut self, ui: &mut Ui, transform: &TransformState, mut state: Option<&mut S>) { + let Some(mut graph) = self.inner.inner.take() else { + return; + }; + + // Render everything + // NOTE: order is very important here, it's the same as the draw order + self.draw_lines(ui, transform, &mut graph); + self.draw_edges(ui, transform, &mut state, &mut graph); + self.draw_nodes(ui, transform, &mut state, &mut graph); + + self.inner.inner = Some(graph); + } + + /// Draw lines. + fn draw_lines( + &mut self, + ui: &mut Ui, + transform: &TransformState, + graph: &mut petgraph::Graph, + ) { + for (index, edge) in &self.inner.lines { + // Arrow default color + let color = Color32::WHITE; + + // Line stroke + let connection_stroke = if let Some(connection_stroke) = &mut self.show_link_closure { + (connection_stroke)( + graph, + ui.ctx(), + EdgeIndex::new(*index), + transform.zoom_factor, + ) + } else { + Stroke { + width: 1.0 * transform.zoom_factor, + color, + } + }; + + // Show edges link bezier curves + let bezier = PathShape::line( + edge.sampled_lines() + .iter() + .map(|position| transform.graph_pos_to_screen_pos(*position)) + .collect(), + connection_stroke, + ); + + ui.painter().add(bezier); + } + } + + /// Draw edges. + fn draw_edges( + &mut self, + ui: &mut Ui, + transform: &TransformState, + state: &mut Option<&mut S>, + graph: &mut petgraph::Graph, + ) { + if let Some(show_edge) = &self.show_edge_closure { + for (index, edge) in &self.inner.edges { + let rect = Rect::from_center_size( + transform.graph_pos_to_screen_pos(edge.content_position), + Vec2::new( + self.inner.config.edge_box_size.x * transform.zoom_factor, + self.inner.config.edge_box_size.y * transform.zoom_factor, + ), + ); + + // Here we draw the edge content (can be any widget) + let mut child_ui = ui.child_ui(rect, Layout::top_down(egui::Align::Center)); + + // Regarding show edge value (which closure parameter to provide), call underlying closure + Self::show_transformed(&mut child_ui, transform, &self.inner.config, |ui| { + match show_edge { + ShowEdge::Simple(show_edge) => { + // Call show edge closure made by the user (telling us what to draw in place of the edge) + (show_edge)(graph, EdgeIndex::new(*index), ui, transform.zoom_factor); + } + ShowEdge::WithState(show_edge) => { + if let Some(state) = state { + // Call show edge closure made by the user (telling us what to draw in place of the edge) + (show_edge)( + graph, + EdgeIndex::new(*index), + ui, + transform.zoom_factor, + state, + ); + } + } + ShowEdge::Interactive(show_edge) => { + // Recovers the interactions + // NOTE: here it's important to have an id PER edge + // NOTE: this make the node region loosing drag and double click capability (graph reposition/move) + let response = ui.interact( + rect, + self.id.with("edges").with(index), + Sense::click(), + ); + + // Call show edge closure made by the user (telling us what to draw in place of the edge) + (show_edge)( + graph, + EdgeIndex::new(*index), + ui, + transform.zoom_factor, + response, + ); + } + ShowEdge::InteractiveWithState(show_edge) => { + if let Some(state) = state { + // Recovers the interactions + // NOTE: here it's important to have an id PER edge + // NOTE: this make the node region loosing drag and double click capability (graph reposition/move) + let response = ui.interact( + rect, + self.id.with("edges").with(index), + Sense::click(), + ); + + // Call show edge closure made by the user (telling us what to draw in place of the edge) + (show_edge)( + graph, + EdgeIndex::new(*index), + ui, + transform.zoom_factor, + state, + response, + ); + } + } + } + }); + } + } + } + + /// Draw nodes. + fn draw_nodes( + &mut self, + ui: &mut Ui, + transform: &TransformState, + state: &mut Option<&mut S>, + graph: &mut petgraph::Graph, + ) { + if let Some(show_node) = &self.show_node_closure { + for (index, node) in &self.inner.nodes { + let rect = Rect { + min: transform.graph_pos_to_screen_pos(node.rect.min), + max: transform.graph_pos_to_screen_pos(node.rect.max), + }; + + // Here we draw the node content (can be any widget) + let mut child_ui = ui.child_ui(rect, Layout::top_down(egui::Align::Center)); + + // Regarding show node value (which closure parameter to provide), call underlying closure + Self::show_transformed(&mut child_ui, transform, &self.inner.config, |ui| { + match show_node { + ShowNode::Simple(show_node) => { + // Call show node closure made by the user (telling us what to draw in place of the node) + (show_node)(graph, NodeIndex::new(*index), ui, transform.zoom_factor); + } + ShowNode::WithState(show_node) => { + if let Some(state) = state { + // Call show node closure made by the user (telling us what to draw in place of the node) + (show_node)( + graph, + NodeIndex::new(*index), + ui, + transform.zoom_factor, + state, + ); + } + } + ShowNode::Interactive(show_node) => { + // Recovers the interactions + // NOTE: here it's important to have an id PER node + // NOTE: this make the node region loosing drag and double click capability (graph reposition/move) + let response = ui.interact( + rect, + self.id.with("nodes").with(index), + Sense::click(), + ); + + // Call show node closure made by the user (telling us what to draw in place of the node) + (show_node)( + graph, + NodeIndex::new(*index), + ui, + transform.zoom_factor, + response, + ); + } + ShowNode::InteractiveWithState(show_node) => { + if let Some(state) = state { + // Recovers the interactions + // NOTE: here it's important to have an id PER node + // NOTE: this make the node region loosing drag and double click capability (graph reposition/move) + let response = ui.interact( + rect, + self.id.with("nodes").with(index), + Sense::click(), + ); + + // Call show node closure made by the user (telling us what to draw in place of the node) + (show_node)( + graph, + NodeIndex::new(*index), + ui, + transform.zoom_factor, + state, + response, + ); + } + } + } + }); + } + } + } + + /// Show a transformed collection of widgets. + /// + /// This will hook the font system to force the right perspective (using the `TransformState`). + fn show_transformed( + ui: &mut Ui, + transform: &TransformState, + config: &DrawableGraphConfig, + mut show: F, + ) where + F: FnMut(&mut Ui), + { + // We compute the font size here, applying the zoom factor + let font_size = config.text_font_size * transform.zoom_factor; + + // If the font_size is too small, we stop drawing the node content + if font_size <= 1.0 { + return; + } + + // This is a hack (cf https://github.com/emilk/egui/issues/1811#issuecomment-1260473122) + // Here we just force all text font to scale regarding our zoom factor + let old_style = ui.style().clone(); + + ui.style_mut() + .text_styles + .iter_mut() + .for_each(|(_, current_font)| current_font.size = font_size); + ui.style_mut().spacing.interact_size = Vec2::ZERO; + ui.style_mut().spacing.item_spacing *= transform.zoom_factor; + ui.style_mut().spacing.window_margin.bottom *= transform.zoom_factor; + ui.style_mut().spacing.window_margin.top *= transform.zoom_factor; + ui.style_mut().spacing.window_margin.right *= transform.zoom_factor; + ui.style_mut().spacing.window_margin.left *= transform.zoom_factor; + ui.style_mut().spacing.button_padding *= transform.zoom_factor; + ui.style_mut().spacing.menu_margin.bottom *= transform.zoom_factor; + ui.style_mut().spacing.menu_margin.top *= transform.zoom_factor; + ui.style_mut().spacing.menu_margin.right *= transform.zoom_factor; + ui.style_mut().spacing.menu_margin.left *= transform.zoom_factor; + ui.style_mut().spacing.indent *= transform.zoom_factor; + ui.style_mut().spacing.interact_size *= transform.zoom_factor; + ui.style_mut().spacing.slider_width *= transform.zoom_factor; + ui.style_mut().spacing.combo_width *= transform.zoom_factor; + ui.style_mut().spacing.text_edit_width *= transform.zoom_factor; + ui.style_mut().spacing.icon_width *= transform.zoom_factor; + ui.style_mut().spacing.icon_width_inner *= transform.zoom_factor; + ui.style_mut().spacing.icon_spacing *= transform.zoom_factor; + ui.style_mut().spacing.tooltip_width *= transform.zoom_factor; + ui.style_mut().spacing.combo_height *= transform.zoom_factor; + ui.style_mut().spacing.scroll_bar_width *= transform.zoom_factor; + ui.style_mut().spacing.scroll_handle_min_length *= transform.zoom_factor; + ui.style_mut().spacing.scroll_bar_inner_margin *= transform.zoom_factor; + ui.style_mut().spacing.scroll_bar_outer_margin *= transform.zoom_factor; + + // Here we call the closure + (show)(ui); + + // And then we reset everything + ui.set_style(old_style); + } + + /// Automatically resize the position and zoom of the graph to fit it into the screen. + /// + /// This use """complex""" maths to compute the adequate zoom and position offset to use + /// to fit the whole graph into the screen. + fn auto_resize(&mut self, ui: &mut Ui, transform: &mut TransformState) { + // Reset user interaction variable + transform.user_modified = false; + + // If there is nothing to draw, size_min and/or size_max will be NaN (not valid) + // In this case we do nothing + if !self.inner.bounding_box.min.is_finite() || !self.inner.bounding_box.max.is_finite() { + return; + } + + let mut bounding_box = self.inner.bounding_box; + bounding_box.max.x += self.node_margin.x; + bounding_box.max.y += self.node_margin.y; + bounding_box.min.x -= self.node_margin.x; + bounding_box.min.y -= self.node_margin.y; + + // If maximum bound, the new min bound is max.x - maximum_bound_x + if let Some(x_size) = self.maximum_bound_x { + if bounding_box.max.x > x_size { + bounding_box.min.x = bounding_box.max.x - x_size; + } + } + + // If maximum bound, the new min bound is max.y - maximum_bound_y + if let Some(y_size) = self.maximum_bound_y { + if bounding_box.max.y > y_size { + bounding_box.min.y = bounding_box.max.y - y_size; + } + } + + // Available space on the current screen (size) + let screen_size = Vec2::new( + ui.available_size_before_wrap().x, + ui.available_size_before_wrap().y, + ); + + // Zoom according to the screen and the size of the graph + transform.zoom_factor = if ((bounding_box.max.x - bounding_box.min.x) / screen_size.x) + > ((bounding_box.max.y - bounding_box.min.y) / screen_size.y) + { + // Resize X + screen_size.x / (bounding_box.max.x - bounding_box.min.x) + } else { + // Resize Y + screen_size.y / (bounding_box.max.y - bounding_box.min.y) + }; + + // Reset position offset + transform.position_offset = Vec2::ZERO; + + // Middle of the graph in the screen, in function of the drag + let middle = Pos2::new( + (transform.graph_pos_to_screen_pos(bounding_box.min).x + + transform.graph_pos_to_screen_pos(bounding_box.max).x) + / 2.0, + (transform.graph_pos_to_screen_pos(bounding_box.min).y + + transform.graph_pos_to_screen_pos(bounding_box.max).y) + / 2.0, + ); + + // Offset of the tab in the ui + let offset_screen = Pos2::new(ui.next_widget_position().x, ui.next_widget_position().y); + + // Distance of the middle of the graph and the middle of the screen + offset + let mut dist = Pos2::new( + (ui.available_size_before_wrap().x / 2.0) - middle.x + offset_screen.x, + (ui.available_size_before_wrap().y / 2.0) - middle.y + offset_screen.y, + ); + + // Movement of the graph divider by the zoom + dist.x /= transform.zoom_factor; + dist.y /= transform.zoom_factor; + + transform.position_offset = dist.to_vec2(); + } +} + +impl Default for Graph { + fn default() -> Self { + Self::new() + } +} diff --git a/src/transform.rs b/src/transform.rs new file mode 100644 index 0000000..8ac7b43 --- /dev/null +++ b/src/transform.rs @@ -0,0 +1,125 @@ +//! # Graph transform + +use egui::{pos2, vec2, Id, Pos2, Rect, Style, Ui, Vec2}; +use std::sync::Arc; + +/// This should be cheap to clone. +/// One each frame draw this will clone the struct, update it and restore it. +#[derive(Clone)] +pub struct TransformState { + /// Id of the current zoom object used to store and load data from the global app state. + pub id: Id, + /// Offset of the position (`screen_x = real_x + position_offset.x`). + pub position_offset: Vec2, + /// Ratio of `screen.?? / max_size.??` used to transform everything that is drawn with an offset of `real_position * zoom`. + pub zoom_factor: f32, + /// This is the rect used to clip the view only on a part of the graph. Anything that isn't in this rect will not be draw. + pub draw_rect: Rect, + /// Default style to use. + pub default_style: Arc