Working application

This commit is contained in:
elvis
2025-10-17 21:42:41 +02:00
parent 3472a31584
commit 617af61d7c
35 changed files with 8725 additions and 0 deletions

18
.rustfmt.toml Normal file
View File

@ -0,0 +1,18 @@
edition = "2024"
unstable_features = true
max_width = 80
match_arm_blocks = false
match_arm_leading_pipes = "Always"
match_block_trailing_comma = true
imports_granularity = "Module"
newline_style = "Unix"
overflow_delimited_expr = true
enum_discrim_align_threshold = 20
group_imports = "StdExternalCrate"
struct_field_align_threshold = 5
trailing_semicolon = true
type_punctuation_density = "Wide"
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true

6
Cargo.toml Normal file
View File

@ -0,0 +1,6 @@
[workspace]
resolver = "2"
members = [ "egui_node_graph2", "reaction_systems_gui", ]
# [reaction_systems_gui.profile.release]
# opt-level = 2 # fast and small wasm

View File

@ -1,2 +1,3 @@
# ReactionSystemsGUI
Repository for the GUI of [ReactionSystem](https://tautocrono.it/elvis/ReactionSystems).

View File

@ -0,0 +1,21 @@
[package]
name = "egui_node_graph2"
description = "A helper library to create interactive node graphs using egui"
homepage = "https://github.com/trevyn/egui_node_graph2"
repository = "https://github.com/trevyn/egui_node_graph2"
license = "MIT"
version = "0.7.0"
keywords = ["egui_node_graph", "ui", "egui", "graph", "node"]
edition = "2021"
readme = "../README.md"
workspace = ".."
[features]
persistence = ["serde", "slotmap/serde", "smallvec/serde", "egui/persistence"]
[dependencies]
egui = "0.32"
slotmap = { version = "1.0" }
smallvec = { version = "1.10.0" }
serde = { version = "1.0", optional = true, features = ["derive"] }
thiserror = "1.0"

View File

@ -0,0 +1,94 @@
use egui::Color32;
/// Converts a hex string with a leading '#' into a egui::Color32.
/// - The first three channels are interpreted as R, G, B.
/// - The fourth channel, if present, is used as the alpha value.
/// - Both upper and lowercase characters can be used for the hex values.
///
/// *Adapted from: https://docs.rs/raster/0.1.0/src/raster/lib.rs.html#425-725.
/// Credit goes to original authors.*
pub fn color_from_hex(hex: &str) -> Result<Color32, String> {
// Convert a hex string to decimal. Eg. "00" -> 0. "FF" -> 255.
fn _hex_dec(hex_string: &str) -> Result<u8, String> {
match u8::from_str_radix(hex_string, 16) {
| Ok(o) => Ok(o),
| Err(e) => Err(format!("Error parsing hex: {}", e)),
}
}
if hex.len() == 9 && hex.starts_with('#') {
// #FFFFFFFF (Red Green Blue Alpha)
return Ok(Color32::from_rgba_premultiplied(
_hex_dec(&hex[1..3])?,
_hex_dec(&hex[3..5])?,
_hex_dec(&hex[5..7])?,
_hex_dec(&hex[7..9])?,
));
} else if hex.len() == 7 && hex.starts_with('#') {
// #FFFFFF (Red Green Blue)
return Ok(Color32::from_rgb(
_hex_dec(&hex[1..3])?,
_hex_dec(&hex[3..5])?,
_hex_dec(&hex[5..7])?,
));
}
Err(format!(
"Error parsing hex: {}. Example of valid formats: #FFFFFF or #ffffffff",
hex
))
}
/// Converts a Color32 into its canonical hexadecimal representation.
/// - The color string will be preceded by '#'.
/// - If the alpha channel is completely opaque, it will be ommitted.
/// - Characters from 'a' to 'f' will be written in lowercase.
#[allow(dead_code)]
pub fn color_to_hex(color: Color32) -> String {
if color.a() < 255 {
format!(
"#{:02x?}{:02x?}{:02x?}{:02x?}",
color.r(),
color.g(),
color.b(),
color.a()
)
} else {
format!("#{:02x?}{:02x?}{:02x?}", color.r(), color.g(), color.b())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
pub fn test_color_from_and_to_hex() {
assert_eq!(
color_from_hex("#00ff00").unwrap(),
Color32::from_rgb(0, 255, 0)
);
assert_eq!(
color_from_hex("#5577AA").unwrap(),
Color32::from_rgb(85, 119, 170)
);
assert_eq!(
color_from_hex("#E2e2e277").unwrap(),
Color32::from_rgba_premultiplied(226, 226, 226, 119)
);
assert!(color_from_hex("abcdefgh").is_err());
assert_eq!(
color_to_hex(Color32::from_rgb(0, 255, 0)),
"#00ff00".to_string()
);
assert_eq!(
color_to_hex(Color32::from_rgb(85, 119, 170)),
"#5577aa".to_string()
);
assert_eq!(
color_to_hex(Color32::from_rgba_premultiplied(226, 226, 226, 119)),
"#e2e2e277".to_string()
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
use super::*;
#[derive(Debug, thiserror::Error)]
pub enum EguiGraphError {
#[error("Node {0:?} has no parameter named {1}")]
NoParameterNamed(NodeId, String),
#[error("Parameter {0:?} was not found in the graph.")]
InvalidParameterId(AnyParameterId),
}

View File

@ -0,0 +1,99 @@
use std::num::NonZeroU32;
#[cfg(feature = "persistence")]
use serde::{Deserialize, Serialize};
use super::*;
/// A node inside the [`Graph`]. Nodes have input and output parameters, stored
/// as ids. They also contain a custom `NodeData` struct with whatever data the
/// user wants to store per-node.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct Node<NodeData> {
pub id: NodeId,
pub label: String,
pub inputs: Vec<(String, InputId)>,
pub outputs: Vec<(String, OutputId)>,
pub user_data: NodeData,
}
/// The three kinds of input params. These describe how the graph must behave
/// with respect to inline widgets and connections for this parameter.
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub enum InputParamKind {
/// No constant value can be set. Only incoming connections can produce it
ConnectionOnly,
/// Only a constant value can be set. No incoming connections accepted.
ConstantOnly,
/// Both incoming connections and constants are accepted. Connections take
/// precedence over the constant values.
ConnectionOrConstant,
}
#[cfg(feature = "persistence")]
fn shown_inline_default() -> bool {
true
}
/// An input parameter. Input parameters are inside a node, and represent data
/// that this node receives. Unlike their [`OutputParam`] counterparts, input
/// parameters also display an inline widget which allows setting its "value".
/// The `DataType` generic parameter is used to restrict the range of input
/// connections for this parameter, and the `ValueType` is use to represent the
/// data for the inline widget (i.e. constant) value.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct InputParam<DataType, ValueType> {
pub id: InputId,
/// The data type of this node. Used to determine incoming connections.
/// This should always match the type of the InputParamValue, but the
/// property is not actually enforced.
pub typ: DataType,
/// The constant value stored in this parameter.
pub value: ValueType,
/// The input kind. See [`InputParamKind`]
pub kind: InputParamKind,
/// Back-reference to the node containing this parameter.
pub node: NodeId,
/// How many connections can be made with this input. `None` means no
/// limit.
pub max_connections: Option<NonZeroU32>,
/// When true, the node is shown inline inside the node graph.
#[cfg_attr(
feature = "persistence",
serde(default = "shown_inline_default")
)]
pub shown_inline: bool,
}
/// An output parameter. Output parameters are inside a node, and represent the
/// data that the node produces. Output parameters can be linked to the input
/// parameters of other nodes. Unlike an [`InputParam`], output parameters
/// cannot have a constant inline value.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct OutputParam<DataType> {
pub id: OutputId,
/// Back-reference to the node containing this parameter.
pub node: NodeId,
pub typ: DataType,
}
/// The graph, containing nodes, input parameters and output parameters. Because
/// graphs are full of self-referential structures, this type uses the `slotmap`
/// crate to represent all the inner references in the data.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct Graph<NodeData, DataType, ValueType> {
/// The [`Node`]s of the graph
pub nodes: SlotMap<NodeId, Node<NodeData>>,
/// The [`InputParam`]s of the graph
pub inputs: SlotMap<InputId, InputParam<DataType, ValueType>>,
/// The [`OutputParam`]s of the graph
pub outputs: SlotMap<OutputId, OutputParam<DataType>>,
// Connects the input of a node, to the output of its predecessor that
// produces it
pub connections: SecondaryMap<InputId, Vec<OutputId>>,
}

View File

@ -0,0 +1,337 @@
use std::num::NonZeroU32;
use super::*;
impl<NodeData, DataType, ValueType> Graph<NodeData, DataType, ValueType> {
pub fn new() -> Self {
Self {
nodes: SlotMap::default(),
inputs: SlotMap::default(),
outputs: SlotMap::default(),
connections: SecondaryMap::default(),
}
}
pub fn add_node(
&mut self,
label: String,
user_data: NodeData,
f: impl FnOnce(&mut Graph<NodeData, DataType, ValueType>, NodeId),
) -> NodeId {
let node_id = self.nodes.insert_with_key(|node_id| {
Node {
id: node_id,
label,
// These get filled in later by the user function
inputs: Vec::default(),
outputs: Vec::default(),
user_data,
}
});
f(self, node_id);
node_id
}
#[allow(clippy::too_many_arguments)]
pub fn add_wide_input_param(
&mut self,
node_id: NodeId,
name: String,
typ: DataType,
value: ValueType,
kind: InputParamKind,
max_connections: Option<NonZeroU32>,
shown_inline: bool,
) -> InputId {
let input_id = self.inputs.insert_with_key(|input_id| InputParam {
id: input_id,
typ,
value,
kind,
node: node_id,
max_connections,
shown_inline,
});
self.nodes[node_id].inputs.push((name, input_id));
input_id
}
pub fn add_input_param(
&mut self,
node_id: NodeId,
name: String,
typ: DataType,
value: ValueType,
kind: InputParamKind,
shown_inline: bool,
) -> InputId {
self.add_wide_input_param(
node_id,
name,
typ,
value,
kind,
NonZeroU32::new(1),
shown_inline,
)
}
pub fn remove_input_param(&mut self, param: InputId) {
let node = self[param].node;
self[node].inputs.retain(|(_, id)| *id != param);
self.inputs.remove(param);
self.connections.retain(|i, _| i != param);
}
pub fn remove_output_param(&mut self, param: OutputId) {
let node = self[param].node;
self[node].outputs.retain(|(_, id)| *id != param);
self.outputs.remove(param);
for (_, conns) in &mut self.connections {
conns.retain(|o| *o != param);
}
}
pub fn add_output_param(
&mut self,
node_id: NodeId,
name: String,
typ: DataType,
) -> OutputId {
let output_id = self.outputs.insert_with_key(|output_id| OutputParam {
id: output_id,
node: node_id,
typ,
});
self.nodes[node_id].outputs.push((name, output_id));
output_id
}
/// Removes a node from the graph with given `node_id`. This also removes
/// any incoming or outgoing connections from that node
///
/// This function returns the list of connections that has been removed
/// after deleting this node as input-output pairs. Note that one of the two
/// ids in the pair (the one on `node_id`'s end) will be invalid after
/// calling this function.
pub fn remove_node(
&mut self,
node_id: NodeId,
) -> (Node<NodeData>, Vec<(InputId, OutputId)>) {
let mut disconnect_events = vec![];
for (i, conns) in &mut self.connections {
conns.retain(|o| {
if self.outputs[*o].node == node_id
|| self.inputs[i].node == node_id
{
disconnect_events.push((i, *o));
false
} else {
true
}
});
}
// NOTE: Collect is needed because we can't borrow the input ids while
// we remove them inside the loop.
for input in self[node_id].input_ids().collect::<SVec<_>>() {
self.inputs.remove(input);
}
for output in self[node_id].output_ids().collect::<SVec<_>>() {
self.outputs.remove(output);
}
let removed_node =
self.nodes.remove(node_id).expect("Node should exist");
(removed_node, disconnect_events)
}
pub fn remove_connection(
&mut self,
input_id: InputId,
output_id: OutputId,
) -> bool {
self.connections
.get_mut(input_id)
.map(|conns| {
let old_size = conns.len();
conns.retain(|id| id != &output_id);
// connection removed if `conn` size changes
old_size != conns.len()
})
.unwrap_or(false)
}
pub fn iter_nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
self.nodes.iter().map(|(id, _)| id)
}
pub fn add_connection(
&mut self,
output: OutputId,
input: InputId,
pos: usize,
) {
if !self.connections.contains_key(input) {
self.connections.insert(input, Vec::default());
}
let max_connections = self
.get_input(input)
.max_connections
.map(NonZeroU32::get)
.unwrap_or(u32::MAX) as usize;
let already_in = self.connections[input].contains(&output);
// connecting twice to the same port is a no-op
// even for wide ports.
if already_in {
return;
}
if self.connections[input].len() == max_connections {
// if full, replace the connected output
self.connections[input][pos] = output;
} else {
// otherwise, insert at a selected position
self.connections[input].insert(pos, output);
}
}
pub fn iter_connection_groups(
&self,
) -> impl Iterator<Item = (InputId, Vec<OutputId>)> + '_ {
self.connections.iter().map(|(i, conns)| (i, conns.clone()))
}
pub fn iter_connections(
&self,
) -> impl Iterator<Item = (InputId, OutputId)> + '_ {
self.iter_connection_groups()
.flat_map(|(i, conns)| conns.into_iter().map(move |o| (i, o)))
}
pub fn connections(&self, input: InputId) -> Vec<OutputId> {
self.connections.get(input).cloned().unwrap_or_default()
}
pub fn connection(&self, input: InputId) -> Option<OutputId> {
let is_limit_1 =
self.get_input(input).max_connections == NonZeroU32::new(1);
let connections = self.connections(input);
if is_limit_1 && connections.len() == 1 {
connections.into_iter().next()
} else {
None
}
}
pub fn any_param_type(
&self,
param: AnyParameterId,
) -> Result<&DataType, EguiGraphError> {
match param {
| AnyParameterId::Input(input) =>
self.inputs.get(input).map(|x| &x.typ),
| AnyParameterId::Output(output) =>
self.outputs.get(output).map(|x| &x.typ),
}
.ok_or(EguiGraphError::InvalidParameterId(param))
}
pub fn try_get_input(
&self,
input: InputId,
) -> Option<&InputParam<DataType, ValueType>> {
self.inputs.get(input)
}
pub fn get_input(
&self,
input: InputId,
) -> &InputParam<DataType, ValueType> {
&self.inputs[input]
}
pub fn try_get_output(
&self,
output: OutputId,
) -> Option<&OutputParam<DataType>> {
self.outputs.get(output)
}
pub fn get_output(&self, output: OutputId) -> &OutputParam<DataType> {
&self.outputs[output]
}
}
impl<NodeData, DataType, ValueType> Default
for Graph<NodeData, DataType, ValueType>
{
fn default() -> Self {
Self::new()
}
}
impl<NodeData> Node<NodeData> {
pub fn inputs<'a, DataType, DataValue>(
&'a self,
graph: &'a Graph<NodeData, DataType, DataValue>,
) -> impl Iterator<Item = &'a InputParam<DataType, DataValue>> + 'a {
self.input_ids().map(|id| graph.get_input(id))
}
pub fn outputs<'a, DataType, DataValue>(
&'a self,
graph: &'a Graph<NodeData, DataType, DataValue>,
) -> impl Iterator<Item = &'a OutputParam<DataType>> + 'a {
self.output_ids().map(|id| graph.get_output(id))
}
pub fn input_ids(&self) -> impl Iterator<Item = InputId> + '_ {
self.inputs.iter().map(|(_name, id)| *id)
}
pub fn output_ids(&self) -> impl Iterator<Item = OutputId> + '_ {
self.outputs.iter().map(|(_name, id)| *id)
}
pub fn get_input(&self, name: &str) -> Result<InputId, EguiGraphError> {
self.inputs
.iter()
.find(|(param_name, _id)| param_name == name)
.map(|x| x.1)
.ok_or_else(|| {
EguiGraphError::NoParameterNamed(self.id, name.into())
})
}
pub fn get_output(&self, name: &str) -> Result<OutputId, EguiGraphError> {
self.outputs
.iter()
.find(|(param_name, _id)| param_name == name)
.map(|x| x.1)
.ok_or_else(|| {
EguiGraphError::NoParameterNamed(self.id, name.into())
})
}
}
impl<DataType, ValueType> InputParam<DataType, ValueType> {
pub fn value(&self) -> &ValueType {
&self.value
}
pub fn kind(&self) -> InputParamKind {
self.kind
}
pub fn node(&self) -> NodeId {
self.node
}
}

View File

@ -0,0 +1,42 @@
slotmap::new_key_type! { pub struct NodeId; }
slotmap::new_key_type! { pub struct InputId; }
slotmap::new_key_type! { pub struct OutputId; }
#[cfg_attr(
feature = "persistence",
derive(serde::Serialize, serde::Deserialize)
)]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum AnyParameterId {
Input(InputId),
Output(OutputId),
}
impl AnyParameterId {
pub fn assume_input(&self) -> InputId {
match self {
| AnyParameterId::Input(input) => *input,
| AnyParameterId::Output(output) =>
panic!("{:?} is not an InputId", output),
}
}
pub fn assume_output(&self) -> OutputId {
match self {
| AnyParameterId::Output(output) => *output,
| AnyParameterId::Input(input) =>
panic!("{:?} is not an OutputId", input),
}
}
}
impl From<OutputId> for AnyParameterId {
fn from(output: OutputId) -> Self {
Self::Output(output)
}
}
impl From<InputId> for AnyParameterId {
fn from(input: InputId) -> Self {
Self::Input(input)
}
}

View File

@ -0,0 +1,35 @@
use super::*;
macro_rules! impl_index_traits {
($id_type:ty, $output_type:ty, $arena:ident) => {
impl<A, B, C> std::ops::Index<$id_type> for Graph<A, B, C> {
type Output = $output_type;
fn index(&self, index: $id_type) -> &Self::Output {
self.$arena.get(index).unwrap_or_else(|| {
panic!(
"{} index error for {:?}. Has the value been deleted?",
stringify!($id_type),
index
)
})
}
}
impl<A, B, C> std::ops::IndexMut<$id_type> for Graph<A, B, C> {
fn index_mut(&mut self, index: $id_type) -> &mut Self::Output {
self.$arena.get_mut(index).unwrap_or_else(|| {
panic!(
"{} index error for {:?}. Has the value been deleted?",
stringify!($id_type),
index
)
})
}
}
};
}
impl_index_traits!(NodeId, Node<A>, nodes);
impl_index_traits!(InputId, InputParam<B, C>, inputs);
impl_index_traits!(OutputId, OutputParam<B>, outputs);

View File

@ -0,0 +1,47 @@
#![forbid(unsafe_code)]
use slotmap::{SecondaryMap, SlotMap};
pub type SVec<T> = smallvec::SmallVec<[T; 4]>;
/// Contains the main definitions for the node graph model.
pub mod graph;
pub use graph::*;
/// Type declarations for the different id types (node, input, output)
pub mod id_type;
pub use id_type::*;
/// Implements the index trait for the Graph type, allowing indexing by all
/// three id types
pub mod index_impls;
/// Implementing the main methods for the `Graph`
pub mod graph_impls;
/// Custom error types, crate-wide
pub mod error;
pub use error::*;
/// The main struct in the library, contains all the necessary state to draw the
/// UI graph
pub mod ui_state;
pub use ui_state::*;
/// The node finder is a tiny widget allowing to create new node types
pub mod node_finder;
pub use node_finder::*;
/// The inner details of the egui implementation. Most egui code lives here.
pub mod editor_ui;
pub use editor_ui::*;
/// Several traits that must be implemented by the user to customize the
/// behavior of this library.
pub mod traits;
pub use traits::*;
mod utils;
mod color_hex_utils;
mod scale;

View File

@ -0,0 +1,190 @@
use std::collections::BTreeMap;
use std::marker::PhantomData;
use egui::*;
use crate::color_hex_utils::*;
use crate::{CategoryTrait, NodeTemplateIter, NodeTemplateTrait};
#[derive(Clone)]
#[cfg_attr(
feature = "persistence",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct NodeFinder<NodeTemplate> {
pub query: String,
/// Reset every frame. When set, the node finder will be moved at that
/// position
pub position: Option<Pos2>,
pub just_spawned: bool,
_phantom: PhantomData<NodeTemplate>,
}
impl<NodeTemplate, NodeData, UserState, CategoryType> NodeFinder<NodeTemplate>
where
NodeTemplate: NodeTemplateTrait<
NodeData = NodeData,
UserState = UserState,
CategoryType = CategoryType,
>,
CategoryType: CategoryTrait,
{
pub fn new_at(pos: Pos2) -> Self {
NodeFinder {
query: "".into(),
position: Some(pos),
just_spawned: true,
_phantom: Default::default(),
}
}
/// Shows the node selector panel with a search bar. Returns whether a node
/// archetype was selected and, in that case, the finder should be hidden on
/// the next frame.
pub fn show(
&mut self,
ui: &mut Ui,
all_kinds: impl NodeTemplateIter<Item = NodeTemplate>,
user_state: &mut UserState,
) -> Option<NodeTemplate> {
let background_color;
let text_color;
if ui.visuals().dark_mode {
background_color = color_from_hex("#3f3f3f").unwrap();
text_color = color_from_hex("#fefefe").unwrap();
} else {
background_color = color_from_hex("#fefefe").unwrap();
text_color = color_from_hex("#3f3f3f").unwrap();
}
ui.visuals_mut().widgets.noninteractive.fg_stroke =
Stroke::new(2.0, text_color);
let frame = Frame::dark_canvas(ui.style())
.fill(background_color)
.inner_margin(vec2(5.0, 5.0));
// The archetype that will be returned.
let mut submitted_archetype = None;
frame.show(ui, |ui| {
ui.vertical(|ui| {
let resp = ui.text_edit_singleline(&mut self.query);
if self.just_spawned {
resp.request_focus();
self.just_spawned = false;
}
let update_open = resp.changed();
let mut query_submit = resp.lost_focus()
&& ui.input(|i| i.key_pressed(Key::Enter));
let max_height =
ui.input(|i| f32::max(i.screen_rect.height() * 0.5, 200.));
let scroll_area_width = resp.rect.width();
let all_kinds = all_kinds.all_kinds();
let mut categories: BTreeMap<String, Vec<&NodeTemplate>> =
Default::default();
let mut orphan_kinds = Vec::new();
for kind in &all_kinds {
let kind_categories =
kind.node_finder_categories(user_state);
if kind_categories.is_empty() {
orphan_kinds.push(kind);
} else {
for category in kind_categories {
categories
.entry(category.name())
.or_default()
.push(kind);
}
}
}
Frame::default().inner_margin(vec2(10.0, 10.0)).show(
ui,
|ui| {
ScrollArea::vertical()
.max_height(max_height)
.auto_shrink([true, false])
.show(ui, |ui| {
ui.set_width(scroll_area_width);
for (category, kinds) in categories {
let filtered_kinds: Vec<_> = kinds
.into_iter()
.map(|kind| {
let kind_name = kind
.node_finder_label(user_state)
.to_string();
(kind, kind_name)
})
.filter(|(_kind, kind_name)| {
kind_name.to_lowercase().contains(
self.query
.to_lowercase()
.as_str(),
)
})
.collect();
if !filtered_kinds.is_empty() {
let default_open =
!self.query.is_empty();
CollapsingHeader::new(&category)
.default_open(default_open)
.open(
update_open
.then_some(default_open),
)
.show(ui, |ui| {
for (kind, kind_name) in
filtered_kinds
{
if ui
.selectable_label(
false, kind_name,
)
.clicked()
{
submitted_archetype =
Some(kind.clone());
} else if query_submit {
submitted_archetype =
Some(kind.clone());
query_submit = false;
}
}
});
}
}
for kind in orphan_kinds {
let kind_name = kind
.node_finder_label(user_state)
.to_string();
if ui
.selectable_label(false, kind_name)
.clicked()
{
submitted_archetype =
Some(kind.clone());
} else if query_submit {
submitted_archetype =
Some(kind.clone());
query_submit = false;
}
}
});
},
);
});
});
submitted_archetype
}
}

View File

@ -0,0 +1,128 @@
use egui::epaint::{CornerRadiusF32, Shadow};
use egui::style::WidgetVisuals;
use egui::{CornerRadius, Margin, Stroke, Style, Vec2};
// Copied from https://github.com/gzp-crey/shine
pub trait Scale {
fn scale(&mut self, amount: f32);
fn scaled(&self, amount: f32) -> Self
where
Self: Clone,
{
let mut scaled = self.clone();
scaled.scale(amount);
scaled
}
}
impl Scale for Vec2 {
fn scale(&mut self, amount: f32) {
self.x *= amount;
self.y *= amount;
}
}
impl Scale for Margin {
fn scale(&mut self, amount: f32) {
self.left = (self.leftf() * amount).round() as _;
self.right = (self.rightf() * amount).round() as _;
self.top = (self.topf() * amount).round() as _;
self.bottom = (self.bottomf() * amount).round() as _;
}
}
impl Scale for CornerRadiusF32 {
fn scale(&mut self, amount: f32) {
self.ne *= amount;
self.nw *= amount;
self.se *= amount;
self.sw *= amount;
}
}
impl Scale for CornerRadius {
fn scale(&mut self, amount: f32) {
self.nw = (self.nw as f32 * amount) as u8;
self.ne = (self.ne as f32 * amount) as u8;
self.sw = (self.sw as f32 * amount) as u8;
self.se = (self.se as f32 * amount) as u8;
}
}
impl Scale for Stroke {
fn scale(&mut self, amount: f32) {
self.width *= amount;
}
}
impl Scale for Shadow {
fn scale(&mut self, amount: f32) {
self.spread =
((self.spread as f32) * amount.clamp(0.4, 1.)).round() as _;
}
}
impl Scale for WidgetVisuals {
fn scale(&mut self, amount: f32) {
self.bg_stroke.scale(amount);
self.fg_stroke.scale(amount);
self.corner_radius.scale(amount);
self.expansion *= amount.clamp(0.4, 1.);
}
}
impl Scale for Style {
fn scale(&mut self, amount: f32) {
if let Some(ov_font_id) = &mut self.override_font_id {
ov_font_id.size *= amount;
}
for text_style in self.text_styles.values_mut() {
text_style.size *= amount;
}
self.spacing.item_spacing.scale(amount);
self.spacing.window_margin.scale(amount);
self.spacing.button_padding.scale(amount);
self.spacing.menu_margin *= amount;
self.spacing.indent *= amount;
self.spacing.interact_size.scale(amount);
self.spacing.slider_width *= amount;
self.spacing.slider_rail_height *= amount;
self.spacing.combo_width *= amount;
self.spacing.text_edit_width *= amount;
self.spacing.icon_width *= amount;
self.spacing.icon_width_inner *= amount;
self.spacing.icon_spacing *= amount;
self.spacing.default_area_size *= amount;
self.spacing.tooltip_width *= amount;
self.spacing.menu_width *= amount;
self.spacing.menu_spacing *= amount;
self.spacing.combo_height *= amount;
self.spacing.scroll.bar_width *= amount;
self.spacing.scroll.floating_allocated_width *= amount;
self.spacing.scroll.floating_width *= amount;
self.interaction.resize_grab_radius_side *= amount;
self.interaction.resize_grab_radius_corner *= amount;
self.visuals.widgets.noninteractive.scale(amount);
self.visuals.widgets.inactive.scale(amount);
self.visuals.widgets.hovered.scale(amount);
self.visuals.widgets.active.scale(amount);
self.visuals.widgets.open.scale(amount);
self.visuals.selection.stroke.scale(amount);
self.visuals.menu_corner_radius *= amount;
self.visuals.resize_corner_size *= amount;
self.visuals.text_cursor.stroke.width *= amount;
self.visuals.clip_rect_margin *= amount;
self.visuals.window_corner_radius.scale(amount);
self.visuals.window_shadow.scale(amount);
self.visuals.popup_shadow.scale(amount);
}
}

View File

@ -0,0 +1,290 @@
use super::*;
/// This trait must be implemented by the `ValueType` generic parameter of the
/// [`Graph`]. The trait allows drawing custom inline widgets for the different
/// types of the node graph.
///
/// The [`Default`] trait bound is required to circumvent borrow checker issues
/// using `std::mem::take` Otherwise, it would be impossible to pass the
/// `node_data` parameter during `value_widget`. The default value is never
/// used, so the implementation is not important, but it should be reasonably
/// cheap to construct.
pub trait WidgetValueTrait: Default {
type Response;
type UserState;
type NodeData;
/// This method will be called for each input parameter with a widget with
/// an disconnected input only. To display UI for connected inputs use
/// [`WidgetValueTrait::value_widget_connected`]. The return value is a
/// vector of custom response objects which can be used to implement
/// handling of side effects. If unsure, the response Vec can be empty.
fn value_widget(
&mut self,
param_name: &str,
node_id: NodeId,
ui: &mut egui::Ui,
user_state: &mut Self::UserState,
node_data: &Self::NodeData,
) -> Vec<Self::Response>;
/// This method will be called for each input parameter with a widget with a
/// connected input only. To display UI for diconnected inputs use
/// [`WidgetValueTrait::value_widget`]. The return value is a vector of
/// custom response objects which can be used to implement handling of
/// side effects. If unsure, the response Vec can be empty.
///
/// Shows the input name label by default.
fn value_widget_connected(
&mut self,
param_name: &str,
_node_id: NodeId,
ui: &mut egui::Ui,
_user_state: &mut Self::UserState,
_node_data: &Self::NodeData,
) -> Vec<Self::Response> {
ui.label(param_name);
Default::default()
}
}
/// This trait must be implemented by the `DataType` generic parameter of the
/// [`Graph`]. This trait tells the library how to visually expose data types
/// to the user.
pub trait DataTypeTrait<UserState>: PartialEq + Eq {
/// The associated port color of this datatype
fn data_type_color(&self, user_state: &mut UserState) -> egui::Color32;
/// The name of this datatype. Return type is specified as Cow<str> because
/// some implementations will need to allocate a new string to provide an
/// answer while others won't.
///
/// ## Example (borrowed value)
/// Use this when you can get the name of the datatype from its fields or as
/// a &'static str. Prefer this method when possible.
/// ```ignore
/// pub struct DataType { name: String }
///
/// impl DataTypeTrait<()> for DataType {
/// fn name(&self) -> std::borrow::Cow<str> {
/// Cow::Borrowed(&self.name)
/// }
/// }
/// ```
///
/// ## Example (owned value)
/// Use this when you can't derive the name of the datatype from its fields.
/// ```ignore
/// pub struct DataType { some_tag: i32 }
///
/// impl DataTypeTrait<()> for DataType {
/// fn name(&self) -> std::borrow::Cow<str> {
/// Cow::Owned(format!("Super amazing type #{}", self.some_tag))
/// }
/// }
/// ```
fn name(&self) -> std::borrow::Cow<'_, str>;
}
/// This trait must be implemented for the `NodeData` generic parameter of the
/// [`Graph`]. This trait allows customizing some aspects of the node drawing.
pub trait NodeDataTrait
where
Self: Sized,
{
/// Must be set to the custom user `NodeResponse` type
type Response;
/// Must be set to the custom user `UserState` type
type UserState;
/// Must be set to the custom user `DataType` type
type DataType;
/// Must be set to the custom user `ValueType` type
type ValueType;
/// Additional UI elements to draw in the nodes, after the parameters.
fn bottom_ui(
&self,
ui: &mut egui::Ui,
node_id: NodeId,
graph: &Graph<Self, Self::DataType, Self::ValueType>,
user_state: &mut Self::UserState,
) -> Vec<NodeResponse<Self::Response, Self>>
where
Self::Response: UserResponseTrait;
/// UI to draw on the top bar of the node.
fn top_bar_ui(
&self,
_ui: &mut egui::Ui,
_node_id: NodeId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
) -> Vec<NodeResponse<Self::Response, Self>>
where
Self::Response: UserResponseTrait,
{
Default::default()
}
/// UI to draw for each output
///
/// Defaults to showing param_name as a simple label.
fn output_ui(
&self,
ui: &mut egui::Ui,
_node_id: NodeId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
param_name: &str,
) -> Vec<NodeResponse<Self::Response, Self>>
where
Self::Response: UserResponseTrait,
{
ui.label(param_name);
Default::default()
}
/// Set background color on titlebar
/// If the return value is None, the default color is set.
fn titlebar_color(
&self,
_ui: &egui::Ui,
_node_id: NodeId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
) -> Option<egui::Color32> {
None
}
/// Separator to put between elements in the node.
///
/// Invoked between inputs, outputs and bottom UI. Useful for
/// complicated UIs that start to lose structure without explicit
/// separators. The `param_id` argument is the id of input or output
/// *preceeding* the separator.
///
/// Default implementation does nothing.
fn separator(
&self,
_ui: &mut egui::Ui,
_node_id: NodeId,
_param_id: AnyParameterId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
) {
}
fn can_delete(
&self,
_node_id: NodeId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
) -> bool {
true
}
}
/// This trait can be implemented by any user type. The trait tells the library
/// how to enumerate the node templates it will present to the user as part of
/// the node finder.
pub trait NodeTemplateIter {
type Item;
fn all_kinds(&self) -> Vec<Self::Item>;
}
/// Describes a category of nodes.
///
/// Used by [`NodeTemplateTrait::node_finder_categories`] to categorize nodes
/// templates into groups.
///
/// If all nodes in a program are known beforehand, it's usefult to define
/// an enum containing all categories and implement [`CategoryTrait`] for it.
/// This will make it impossible to accidentally create a new category by
/// mis-typing an existing one, like in the case of using string types.
pub trait CategoryTrait {
/// Name of the category.
fn name(&self) -> String;
}
impl CategoryTrait for () {
fn name(&self) -> String {
String::new()
}
}
impl CategoryTrait for &str {
fn name(&self) -> String {
self.to_string()
}
}
impl CategoryTrait for String {
fn name(&self) -> String {
self.clone()
}
}
/// This trait must be implemented by the `NodeTemplate` generic parameter of
/// the [`GraphEditorState`]. It allows the customization of node templates. A
/// node template is what describes what kinds of nodes can be added to the
/// graph, what is their name, and what are their input / output parameters.
pub trait NodeTemplateTrait: Clone {
/// Must be set to the custom user `NodeData` type
type NodeData;
/// Must be set to the custom user `DataType` type
type DataType;
/// Must be set to the custom user `ValueType` type
type ValueType;
/// Must be set to the custom user `UserState` type
type UserState;
/// Must be a type that implements the [`CategoryTrait`] trait.
///
/// `&'static str` is a good default if you intend to simply type out
/// the categories of your node. Use `()` if you don't need categories
/// at all.
type CategoryType;
/// Returns a descriptive name for the node kind, used in the node finder.
///
/// The return type is Cow<str> to allow returning owned or borrowed values
/// more flexibly. Refer to the documentation for `DataTypeTrait::name` for
/// more information
fn node_finder_label(
&self,
user_state: &mut Self::UserState,
) -> std::borrow::Cow<'_, str>;
/// Vec of categories to which the node belongs.
///
/// It's often useful to organize similar nodes into categories, which will
/// then be used by the node finder to show a more manageable UI, especially
/// if the node template are numerous.
fn node_finder_categories(
&self,
_user_state: &mut Self::UserState,
) -> Vec<Self::CategoryType> {
Vec::default()
}
/// Returns a descriptive name for the node kind, used in the graph.
fn node_graph_label(&self, user_state: &mut Self::UserState) -> String;
/// Returns the user data for this node kind.
fn user_data(&self, user_state: &mut Self::UserState) -> Self::NodeData;
/// This function is run when this node kind gets added to the graph. The
/// node will be empty by default, and this function can be used to fill its
/// parameters.
fn build_node(
&self,
graph: &mut Graph<Self::NodeData, Self::DataType, Self::ValueType>,
user_state: &mut Self::UserState,
node_id: NodeId,
);
}
/// The custom user response types when drawing nodes in the graph must
/// implement this trait.
pub trait UserResponseTrait: Clone + std::fmt::Debug {}

View File

@ -0,0 +1,144 @@
use std::marker::PhantomData;
use std::sync::Arc;
use egui::{Rect, Style, Ui, Vec2};
#[cfg(feature = "persistence")]
use serde::{Deserialize, Serialize};
use super::*;
use crate::scale::Scale;
const MIN_ZOOM: f32 = 0.2;
const MAX_ZOOM: f32 = 2.0;
#[derive(Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct GraphEditorState<
NodeData,
DataType,
ValueType,
NodeTemplate,
UserState,
> {
pub graph: Graph<NodeData, DataType, ValueType>,
/// Nodes are drawn in this order. Draw order is important because nodes
/// that are drawn last are on top.
pub node_order: Vec<NodeId>,
/// An ongoing connection interaction: The mouse has dragged away from a
/// port and the user is holding the click
pub connection_in_progress: Option<(NodeId, AnyParameterId)>,
/// The currently selected node. Some interface actions depend on the
/// currently selected node.
pub selected_nodes: Vec<NodeId>,
/// The mouse drag start position for an ongoing box selection.
pub ongoing_box_selection: Option<egui::Pos2>,
/// The position of each node.
pub node_positions: SecondaryMap<NodeId, egui::Pos2>,
/// The node finder is used to create new nodes.
pub node_finder: Option<NodeFinder<NodeTemplate>>,
/// The panning of the graph viewport.
pub pan_zoom: PanZoom,
pub _user_state: PhantomData<fn() -> UserState>,
}
impl<NodeData, DataType, ValueType, NodeKind, UserState>
GraphEditorState<NodeData, DataType, ValueType, NodeKind, UserState>
{
pub fn new(default_zoom: f32) -> Self {
Self {
pan_zoom: PanZoom::new(default_zoom),
..Default::default()
}
}
}
impl<NodeData, DataType, ValueType, NodeKind, UserState> Default
for GraphEditorState<NodeData, DataType, ValueType, NodeKind, UserState>
{
fn default() -> Self {
Self {
graph: Default::default(),
node_order: Default::default(),
connection_in_progress: Default::default(),
selected_nodes: Default::default(),
ongoing_box_selection: Default::default(),
node_positions: Default::default(),
node_finder: Default::default(),
pan_zoom: Default::default(),
_user_state: Default::default(),
}
}
}
#[cfg(feature = "persistence")]
fn _default_clip_rect() -> Rect {
Rect::NOTHING
}
#[derive(Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct PanZoom {
pub pan: Vec2,
pub zoom: f32,
#[cfg_attr(
feature = "persistence",
serde(skip, default = "_default_clip_rect")
)]
pub clip_rect: Rect,
#[cfg_attr(feature = "persistence", serde(skip, default))]
pub zoomed_style: Arc<Style>,
#[cfg_attr(feature = "persistence", serde(skip, default))]
pub started: bool,
}
impl Default for PanZoom {
fn default() -> Self {
PanZoom {
pan: Vec2::ZERO,
zoom: 1.0,
clip_rect: Rect::NOTHING,
zoomed_style: Default::default(),
started: false,
}
}
}
impl PanZoom {
pub fn new(zoom: f32) -> PanZoom {
let style: Style = Default::default();
PanZoom {
pan: Vec2::ZERO,
zoom,
clip_rect: Rect::NOTHING,
zoomed_style: Arc::new(style.scaled(1.0)),
started: false,
}
}
pub fn zoom(
&mut self,
clip_rect: Rect,
style: &Arc<Style>,
zoom_delta: f32,
) {
self.clip_rect = clip_rect;
let new_zoom = (self.zoom * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM);
self.zoomed_style = Arc::new(style.scaled(new_zoom));
self.zoom = new_zoom;
}
}
pub fn show_zoomed<R, F>(
default_style: Arc<Style>,
zoomed_style: Arc<Style>,
ui: &mut Ui,
add_content: F,
) -> R
where
F: FnOnce(&mut Ui) -> R,
{
*ui.style_mut() = (*zoomed_style).clone();
let response = add_content(ui);
*ui.style_mut() = (*default_style).clone();
response
}

View File

@ -0,0 +1,15 @@
pub trait ColorUtils {
/// Multiplies the color rgb values by `factor`, keeping alpha untouched.
fn lighten(&self, factor: f32) -> Self;
}
impl ColorUtils for egui::Color32 {
fn lighten(&self, factor: f32) -> Self {
egui::Color32::from_rgba_premultiplied(
(self.r() as f32 * factor) as u8,
(self.g() as f32 * factor) as u8,
(self.b() as f32 * factor) as u8,
self.a(),
)
}
}

View File

@ -0,0 +1,39 @@
[package]
name = "reaction_systems_gui"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
log = "*"
eframe = "0.32"
anyhow = "1"
serde = { version = "1", optional = true }
colored = "*"
lalrpop-util = "*"
petgraph = ">=0.8"
egui_node_graph2 = { path = "../egui_node_graph2" }
petgraph-graphml = "*"
getrandom = { version = "0.3" }
layout-rs = "0.1"
rfd = "*"
ron = "*"
# rsprocess = { version = "*", git = "https://tautocrono.it/elvis/ReactionSystems.git" }
rsprocess = { version = "*", path = "../../ReactionSystems/rsprocess/" }
assert = { version = "*", path = "../../ReactionSystems/assert/" }
execution = { version = "*", path = "../../ReactionSystems/execution/" }
bisimilarity = { version = "*", path = "../../ReactionSystems/bisimilarity/" }
grammar_separated = { version = "*", path = "../../ReactionSystems/grammar_separated/" }
[target.'cfg(all(target_arch = "wasm32", not(target_os = "emscripten")))'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "*"
getrandom = { version = "0.3", features = ["wasm_js"]}
[features]
default = []
persistence = ["serde", "egui_node_graph2/persistence", "eframe/persistence"]

View File

@ -0,0 +1,75 @@
#!/bin/bash
set -eu
script_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
cd "$script_path"
OPEN=false
FAST=false
while test $# -gt 0; do
case "$1" in
-h|--help)
echo "build_web.sh [--fast] [--open]"
echo " --fast: skip optimization step"
echo " --open: open the result in a browser"
exit 0
;;
--fast)
shift
FAST=true
;;
--open)
shift
OPEN=true
;;
*)
break
;;
esac
done
# ./setup_web.sh # <- call this first!
CRATE_NAME="reaction_systems_gui"
CRATE_NAME_SNAKE_CASE="${CRATE_NAME//-/_}" # for those who name crates with-kebab-case
# This is required to enable the web_sys clipboard API which egui_web uses
# https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Clipboard.html
# https://rustwasm.github.io/docs/wasm-bindgen/web-sys/unstable-apis.html
export RUSTFLAGS='--cfg=web_sys_unstable_apis --cfg getrandom_backend="wasm_js"'
# Clear output from old stuff:
rm -f "docs/${CRATE_NAME_SNAKE_CASE}_bg.wasm"
echo "Building rust…"
BUILD=release
cargo build -p "${CRATE_NAME}" --release --all-features --lib --target wasm32-unknown-unknown
# Get the output directory (in the workspace it is in another location)
TARGET=$(cargo metadata --format-version=1 | jq --raw-output .target_directory)
echo "Generating JS bindings for wasm…"
TARGET_NAME="${CRATE_NAME_SNAKE_CASE}.wasm"
wasm-bindgen "${TARGET}/wasm32-unknown-unknown/${BUILD}/${TARGET_NAME}" \
--out-dir docs --no-modules --no-typescript
if [[ "${FAST}" == false ]]; then
echo "Optimizing wasm…"
# to get wasm-opt: apt/brew/dnf install binaryen
wasm-opt "docs/${CRATE_NAME}_bg.wasm" -O2 --fast-math -o "docs/${CRATE_NAME}_bg.wasm" # add -g to get debug symbols
fi
echo "Finished: docs/${CRATE_NAME_SNAKE_CASE}.wasm"
if [[ "${OPEN}" == true ]]; then
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Linux, ex: Fedora
xdg-open http://localhost:8080/index.html
elif [[ "$OSTYPE" == "msys" ]]; then
# Windows
start http://localhost:8080/index.html
else
# Darwin/MacOS, or something else
open http://localhost:8080/index.html
fi
fi

10
reaction_systems_gui/check.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash
# This scripts runs various CI-like checks in a convenient way.
set -eux
cargo check --workspace --all-targets
cargo check --workspace --all-features --lib --target wasm32-unknown-unknown
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings -W clippy::all
cargo test --workspace --all-targets --all-features
cargo test --workspace --doc

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!-- Disable zooming: -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<head>
<title>eframe template</title>
<style>
html {
touch-action: manipulation;
}
/* dark mode preference support */
body {
background: #909090;
}
@media (prefers-color-scheme: dark) {
body {
background: #404040;
}
}
html, body {
overflow: hidden;
margin: 0 !important;
padding: 0 !important;
width: 100%;
height: 100%;
}
#rscanvas {
width: 100%;
height: 100%;
margin-right: auto;
margin-left: auto;
display: block;
position: absolute;
top: 0%;
left: 50%;
transform: translate(-50%, 0%);
}
.loading {
margin-right: auto;
margin-left: auto;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
font-family: Ubuntu-Light, Helvetica, sans-serif;
}
/* ---------------------------------------------- */
/* Loading animation from https://loading.io/css/ */
.lds-dual-ring {
display: inline-block;
width: 24px;
height: 24px;
}
.lds-dual-ring:after {
content: " ";
display: block;
width: 24px;
height: 24px;
margin: 0px;
border-radius: 50%;
border: 3px solid #fff;
border-color: #fff transparent #fff transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<link rel="manifest" href="./manifest.json">
<script>
// register ServiceWorker
window.onload = () => {
'use strict';
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./sw.js');
}
}
</script>
</head>
<body>
<!-- The WASM code will resize this canvas to cover the entire screen -->
<canvas id="rscanvas"></canvas>
<div class="loading" id="loading">
Loading…&nbsp;&nbsp;
<div class="lds-dual-ring"></div>
</div>
<script>
// The `--no-modules`-generated JS from `wasm-bindgen` attempts to use
// `WebAssembly.instantiateStreaming` to instantiate the wasm module,
// but this doesn't work with `file://` urls. This example is frequently
// viewed by simply opening `index.html` in a browser (with a `file://`
// url), so it would fail if we were to call this function!
//
// Work around this for now by deleting the function to ensure that the
// `no_modules.js` script doesn't have access to it. You won't need this
// hack when deploying over HTTP.
delete WebAssembly.instantiateStreaming;
</script>
<!-- This is the JS generated by the `wasm-bindgen` CLI tool -->
<script src="reaction_systems_gui.js"></script>
<script>
console.debug("Loading wasm…");
// We'll defer our execution until the wasm is ready to go.
// Here we tell bindgen the path to the wasm file so it can start
// initialization and return to us a promise when it's done.
wasm_bindgen("./reaction_systems_gui_bg.wasm")
.then(on_wasm_loaded)
.catch(console.error);
function on_wasm_loaded() {
console.log("loaded wasm, starting egui app…");
// // This call installs a bunch of callbacks and then returns:
// wasm_bindgen.start("the_canvas_id");
let rscanvas = document.getElementById("rscanvas");
let handle = new wasm_bindgen.WebHandle();
function check_for_panic() {
if (handle.has_panicked()) {
console.error("The egui app has crashed");
// The demo app already logs the panic message and callstack, but you
// can access them like this if you want to show them in the html:
// console.error(`${handle.panic_message()}`);
// console.error(`${handle.panic_callstack()}`);
// document.getElementById("the_canvas_id").remove();
// document.getElementById("center_text").innerHTML = `
// <p>
// The egui app has crashed.
// </p>
// <p style="font-size:10px" align="left">
// ${handle.panic_message()}
// </p>
// <p style="font-size:14px">
// See the console for details.
// </p>
// <p style="font-size:14px">
// Reload the page to try again.
// </p>`;
} else {
let delay_ms = 1000;
setTimeout(check_for_panic, delay_ms);
}
}
check_for_panic();
handle.start(rscanvas).then(on_app_started).catch(on_error);
console.log("egui app started.");
document.getElementById("loading").remove();
}
function on_app_started(handle) {
// Call `handle.destroy()` to stop. Uncomment to quick result:
// setTimeout(() => { handle.destroy(); handle.free()) }, 2000)
console.debug("App started.");
// document.getElementById("center_text").innerHTML = '';
// Make sure the canvas is focused so it can receive keyboard events right away:
rscanvas.focus();
}
function on_error(error) {
console.error("Failed to start: " + error);
rscanvas.remove();
// document.getElementById("center_text").innerHTML = `
// <p>
// An error occurred during loading:
// </p>
// <p style="font-family:Courier New">
// ${error}
// </p>
// <p style="font-size:14px">
// Make sure you use a modern browser with WebGL and WASM enabled.
// </p>`;
}
</script>
</body>
</html>

View File

@ -0,0 +1,14 @@
{
"name": "Egui Template PWA",
"short_name": "egui-template-pwa",
"icons": [{
"src": "./icon-256.png",
"sizes": "256x256",
"type": "image/png"
}],
"lang": "en-US",
"start_url": "./index.html",
"display": "standalone",
"background_color": "white",
"theme_color": "white"
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,25 @@
var cacheName = 'egui-template-pwa';
var filesToCache = [
'./',
'./index.html',
'./reaction_systems_gui.js',
'./reaction_systems_gui_bg.wasm',
];
/* Start the service worker and cache all of the app's content */
self.addEventListener('install', function (e) {
e.waitUntil(
caches.open(cacheName).then(function (cache) {
return cache.addAll(filesToCache);
})
);
});
/* Serve cached content when offline */
self.addEventListener('fetch', function (e) {
e.respondWith(
caches.match(e.request).then(function (response) {
return response || fetch(e.request);
})
);
});

View File

@ -0,0 +1,10 @@
#!/bin/bash
set -eu
# Pre-requisites:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli
cargo update -p wasm-bindgen
# For local tests with `./start_server`:
cargo install basic-http-server

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,163 @@
use std::fmt::Display;
use eframe::egui::{self, Color32, TextFormat, TextStyle};
use egui::text::LayoutJob;
use grammar_separated::user_error::{UserError, UserErrorTypes};
use lalrpop_util::ParseError;
fn create_error<T>(
input_str: &str,
l: usize,
t: T,
r: usize,
expected: Option<Vec<String>>,
error: Option<UserErrorTypes>,
ctx: &eframe::egui::Context,
) -> LayoutJob
where
T: Display,
{
let style = ctx.style();
let monospace_text = TextStyle::Monospace.resolve(&style);
let monospace = TextFormat {
font_id: monospace_text.clone(),
..Default::default()
};
let monospace_red = TextFormat {
font_id: monospace_text.clone(),
color: Color32::RED,
..Default::default()
};
let monospace_blue = TextFormat {
font_id: monospace_text.clone(),
color: Color32::BLUE,
..Default::default()
};
let monospace_green = TextFormat {
font_id: monospace_text,
color: Color32::GREEN,
..Default::default()
};
let mut err = LayoutJob::default();
if let Some(error) = error {
err.append(&format!("{error} "), 0., Default::default());
err.append(&format!("\"{t}\""), 0., monospace_red.clone());
err.append(
&format!(" between positions {l} and {r}."),
0.,
Default::default(),
);
} else {
err.append("Unrecognized token ", 0., Default::default());
err.append(&format!("\"{t}\""), 0., monospace_red.clone());
err.append(
&format!(" between positions {l} and {r}."),
0.,
Default::default(),
);
}
{
if let Some(expected) = expected {
// Temporary debug.
err.append("\nExpected: ", 0., Default::default());
let mut it = expected.iter().peekable();
while let Some(s) = it.next() {
err.append("(", 0., monospace.clone());
err.append(s, 0., monospace_green.clone());
err.append(")", 0., monospace.clone());
if it.peek().is_some() {
err.append(", ", 0., monospace.clone());
}
}
}
}
let right_new_line = input_str[l..]
.find("\n")
.map(|pos| pos + l)
.unwrap_or(input_str.len());
let left_new_line = input_str[..r]
.rfind("\n")
.map(|pos| pos + 1)
.unwrap_or_default();
let line_number = input_str[..l].match_indices('\n').count() + 1;
let pre = format!("{line_number} |");
let line_pos_l = l - left_new_line;
let line_pos_r = r - left_new_line;
err.append(
&format!(
"\nLine {} position {} to {}:\n",
line_number, line_pos_l, line_pos_r,
),
0.,
Default::default(),
);
err.append(&pre, 0., monospace_blue.clone());
err.append(&input_str[left_new_line..l], 0., monospace_green);
err.append(&input_str[l..r], 0., monospace_red.clone());
err.append(&input_str[r..right_new_line], 0., monospace.clone());
err.append("\n", 0., monospace.clone());
err.append(&" ".repeat(pre.len() - 1), 0., monospace.clone());
err.append("|", 0., monospace_blue);
err.append(&" ".repeat(l - left_new_line), 0., monospace.clone());
err.append("^", 0., monospace_red.clone());
if r - l > 1 {
err.append(&" ".repeat(r - l - 2), 0., monospace);
err.append("^", 0., monospace_red);
}
err
}
pub fn reformat_error<T>(
e: ParseError<usize, T, UserError>,
input_str: &str,
ctx: &eframe::egui::Context,
) -> LayoutJob
where
T: Display,
{
let mut job = LayoutJob::default();
match e {
| ParseError::ExtraToken { token: (l, t, r) } => job.append(
&format!(
"Unexpected extra token \"{t}\" between positions {l} \
and {r}."
),
0.,
Default::default(),
),
| ParseError::UnrecognizedEof {
location: _,
expected: _,
} => job.append(
"End of file encountered while parsing.",
0.,
Default::default(),
),
| ParseError::InvalidToken { location } => job.append(
&format!("Invalid token at position {location}."),
0.,
Default::default(),
),
| ParseError::UnrecognizedToken {
token: (l, t, r),
expected,
} => job = create_error(input_str, l, t, r, Some(expected), None, ctx),
| ParseError::User {
error:
UserError {
token: (l, t, r),
error,
},
} => job = create_error(input_str, l, t, r, None, Some(error), ctx),
};
job
}

View File

@ -0,0 +1,18 @@
#![forbid(unsafe_code)]
#![warn(clippy::all, rust_2018_idioms)]
// Forbid warnings in release builds
#![cfg_attr(not(debug_assertions), deny(warnings))]
mod app;
mod app_logic;
mod helper;
pub use app::AppHandle;
// If compiling for web
#[cfg(target_arch = "wasm32")]
mod web;
// Export endpoints for wasm
#[cfg(target_arch = "wasm32")]
pub use web::*;

View File

@ -0,0 +1,29 @@
#![cfg_attr(not(debug_assertions), deny(warnings))]
#![warn(clippy::all, rust_2018_idioms)]
// hide console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use reaction_systems_gui::AppHandle;
// When compiling natively:
#[cfg(not(target_arch = "wasm32"))]
fn main() {
use eframe::egui::Visuals;
eframe::run_native(
"Reaction Systems",
eframe::NativeOptions::default(),
Box::new(|cc| {
cc.egui_ctx.set_visuals(Visuals::dark());
#[cfg(feature = "persistence")]
{
Ok(Box::new(AppHandle::new(cc)))
}
#[cfg(not(feature = "persistence"))]
{
Ok(Box::<AppHandle>::default())
}
}),
)
.expect("Failed to run native example");
}

View File

@ -0,0 +1,77 @@
#![allow(clippy::mem_forget)] // False positives from #[wasm_bindgen] macro
use eframe::wasm_bindgen::prelude::*;
use eframe::wasm_bindgen::{self};
use crate::app::NodeGraphExample;
#[derive(Clone)]
#[wasm_bindgen()]
pub struct WebHandle {
runner: eframe::WebRunner,
}
#[wasm_bindgen()]
impl WebHandle {
/// Installs a panic hook, then returns.
#[allow(clippy::new_without_default, clippy::allow_attributes)]
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
// Redirect [`log`] message to `console.log` and friends:
let log_level = if cfg!(debug_assertions) {
log::LevelFilter::Trace
} else {
log::LevelFilter::Debug
};
eframe::WebLogger::init(log_level).ok();
Self {
runner: eframe::WebRunner::new(),
}
}
/// Call this once from JavaScript to start the app.
#[wasm_bindgen]
pub async fn start(
&self,
canvas: eframe::web_sys::HtmlCanvasElement,
) -> Result<(), wasm_bindgen::JsValue> {
self.runner
.start(
canvas,
eframe::WebOptions::default(),
Box::new(|_cc| {
#[cfg(feature = "persistence")]
{
Ok(Box::new(NodeGraphExample::new(_cc)))
}
#[cfg(not(feature = "persistence"))]
{
Ok(Box::<NodeGraphExample>::default())
}
}),
)
.await
}
#[wasm_bindgen]
pub fn destroy(&self) {
self.runner.destroy();
}
/// The JavaScript can check whether or not the app has crashed:
#[wasm_bindgen]
pub fn has_panicked(&self) -> bool {
self.runner.has_panicked()
}
#[wasm_bindgen]
pub fn panic_message(&self) -> Option<String> {
self.runner.panic_summary().map(|s| s.message())
}
#[wasm_bindgen]
pub fn panic_callstack(&self) -> Option<String> {
self.runner.panic_summary().map(|s| s.callstack())
}
}

View File

@ -0,0 +1,12 @@
#!/bin/bash
set -eu
# Starts a local web-server that serves the contents of the `doc/` folder,
# which is the folder to where the web version is compiled.
# cargo install basic-http-server
echo "open http://localhost:8080"
(cd docs && basic-http-server --addr 127.0.0.1:8080 .)
# (cd docs && python3 -m http.server 8080 --bind 127.0.0.1)