graph_rs/
graph-rs.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2025 Fundament Software SPC <https://fundament.software>
3
4use feather_ui::color::sRGB;
5use feather_ui::gen_id;
6
7use feather_ui::component::domain_line::DomainLine;
8use feather_ui::component::domain_point::DomainPoint;
9use feather_ui::component::mouse_area::MouseArea;
10use feather_ui::component::region::Region;
11use feather_ui::component::shape::{Shape, ShapeKind};
12use feather_ui::component::window::Window;
13use feather_ui::component::{ChildOf, mouse_area};
14use feather_ui::input::MouseButton;
15use feather_ui::layout::{base, fixed, leaf};
16use feather_ui::persist::FnPersist;
17use feather_ui::ultraviolet::Vec2;
18use feather_ui::{
19    AbsRect, App, CrossReferenceDomain, DRect, DataID, FILL_DRECT, Slot, SourceID, WrapEventEx, im,
20};
21use std::collections::HashSet;
22use std::f32;
23use std::sync::Arc;
24
25#[derive(PartialEq, Clone, Debug)]
26struct GraphState {
27    nodes: Vec<Vec2>,
28    edges: HashSet<(usize, usize)>,
29    offset: Vec2,
30    selected: Option<usize>,
31}
32
33struct BasicApp {}
34
35#[derive(Default, Clone, feather_macro::Area)]
36struct MinimalArea {
37    area: DRect,
38}
39
40impl base::Empty for MinimalArea {}
41impl base::ZIndex for MinimalArea {}
42impl base::Margin for MinimalArea {}
43impl base::Anchor for MinimalArea {}
44impl base::Limits for MinimalArea {}
45impl base::RLimits for MinimalArea {}
46impl fixed::Prop for MinimalArea {}
47impl fixed::Child for MinimalArea {}
48impl leaf::Prop for MinimalArea {}
49
50const NODE_RADIUS: f32 = 25.0;
51
52impl FnPersist<GraphState, im::HashMap<Arc<SourceID>, Option<Window>>> for BasicApp {
53    type Store = (GraphState, im::HashMap<Arc<SourceID>, Option<Window>>);
54
55    fn init(&self) -> Self::Store {
56        (
57            GraphState {
58                nodes: Vec::new(),
59                edges: HashSet::new(),
60                offset: Vec2::zero(),
61                selected: None,
62            },
63            im::HashMap::new(),
64        )
65    }
66    fn call(
67        &mut self,
68        mut store: Self::Store,
69        args: &GraphState,
70    ) -> (Self::Store, im::HashMap<Arc<SourceID>, Option<Window>>) {
71        if store.0 != *args {
72            let mut children: im::Vector<Option<Box<ChildOf<dyn fixed::Prop>>>> = im::Vector::new();
73            let domain: Arc<CrossReferenceDomain> = Default::default();
74
75            let mut node_ids: Vec<Arc<SourceID>> = Vec::new();
76
77            for i in 0..args.nodes.len() {
78                let node = args.nodes[i];
79                const BASE: sRGB = sRGB::new(0.2, 0.7, 0.4, 1.0);
80
81                let point = DomainPoint::new(gen_id!(), domain.clone());
82                node_ids.push(point.id.clone());
83
84                let circle = Shape::<DRect, { ShapeKind::Circle as u8 }>::new(
85                    gen_id!(),
86                    FILL_DRECT.into(),
87                    0.0,
88                    0.0,
89                    Vec2::new(0.0, 20.0),
90                    if args.selected == Some(i) {
91                        sRGB::new(0.7, 1.0, 0.8, 1.0)
92                    } else {
93                        BASE
94                    },
95                    BASE,
96                );
97
98                let bag = Region::<MinimalArea>::new(
99                    gen_id!(gen_id!(), i),
100                    MinimalArea {
101                        area: AbsRect::new(
102                            node.x - NODE_RADIUS,
103                            node.y - NODE_RADIUS,
104                            node.x + NODE_RADIUS,
105                            node.y + NODE_RADIUS,
106                        )
107                        .into(),
108                    }
109                    .into(),
110                    feather_ui::children![fixed::Prop, point, circle],
111                );
112
113                children.push_back(Some(Box::new(bag)));
114            }
115
116            let edge_id = gen_id!();
117
118            for (a, b) in &args.edges {
119                let line = DomainLine::<()> {
120                    id: edge_id
121                        .child(DataID::Int(*a as i64))
122                        .child(DataID::Int(*b as i64)),
123                    fill: sRGB::white(),
124                    domain: domain.clone(),
125                    start: node_ids[*a].clone(),
126                    end: node_ids[*b].clone(),
127                    props: ().into(),
128                };
129
130                children.push_back(Some(Box::new(line)));
131            }
132
133            let subregion = Region::new(
134                gen_id!(),
135                MinimalArea {
136                    area: AbsRect::new(
137                        args.offset.x,
138                        args.offset.y,
139                        args.offset.x + 10000.0,
140                        args.offset.y + 10000.0,
141                    )
142                    .into(),
143                }
144                .into(),
145                children,
146            );
147
148            let mousearea: MouseArea<MinimalArea> = MouseArea::new(
149                gen_id!(),
150                MinimalArea { area: FILL_DRECT },
151                Some(4.0),
152                [
153                    Some(Slot(feather_ui::APP_SOURCE_ID.into(), 0)),
154                    Some(Slot(feather_ui::APP_SOURCE_ID.into(), 0)),
155                    Some(Slot(feather_ui::APP_SOURCE_ID.into(), 0)),
156                    None,
157                    None,
158                    None,
159                ],
160            );
161
162            let region = Region::new(
163                gen_id!(),
164                MinimalArea { area: FILL_DRECT }.into(),
165                feather_ui::children![fixed::Prop, subregion, mousearea],
166            );
167
168            let window = Window::new(
169                gen_id!(),
170                feather_ui::winit::window::Window::default_attributes()
171                    .with_title(env!("CARGO_CRATE_NAME"))
172                    .with_resizable(true),
173                Box::new(region),
174            );
175
176            store.1 = im::HashMap::new();
177            store.1.insert(window.id.clone(), Some(window));
178            store.0 = args.clone();
179        }
180        let windows = store.1.clone();
181        (store, windows)
182    }
183}
184
185fn main() {
186    let handle_input = Box::new(
187        |e: mouse_area::MouseAreaEvent,
188         mut appdata: GraphState|
189         -> Result<GraphState, GraphState> {
190            match e {
191                mouse_area::MouseAreaEvent::OnClick(MouseButton::Left, pos) => {
192                    if let Some(selected) = appdata.selected {
193                        for i in 0..appdata.nodes.len() {
194                            let diff = appdata.nodes[i] - pos + appdata.offset;
195                            if diff.dot(diff) < NODE_RADIUS * NODE_RADIUS {
196                                if appdata.edges.contains(&(selected, i)) {
197                                    appdata.edges.remove(&(i, selected));
198                                    appdata.edges.remove(&(selected, i));
199                                } else {
200                                    appdata.edges.insert((selected, i));
201                                    appdata.edges.insert((i, selected));
202                                }
203                                break;
204                            }
205                        }
206
207                        appdata.selected = None;
208                    } else {
209                        // Check to see if we're anywhere near a node (yes this is inefficient but we don't care right now)
210                        for i in 0..appdata.nodes.len() {
211                            let diff = appdata.nodes[i] - pos + appdata.offset;
212                            if diff.dot(diff) < NODE_RADIUS * NODE_RADIUS {
213                                appdata.selected = Some(i);
214                                return Ok(appdata);
215                            }
216                        }
217
218                        // TODO: maybe make this require shift click
219                        appdata.nodes.push(pos - appdata.offset);
220                    }
221
222                    Ok(appdata)
223                }
224                mouse_area::MouseAreaEvent::OnDblClick(MouseButton::Left, pos) => {
225                    // TODO: winit currently doesn't capture double clicks
226                    appdata.nodes.push(pos - appdata.offset);
227                    Ok(appdata)
228                }
229                mouse_area::MouseAreaEvent::OnDrag(MouseButton::Left, diff) => {
230                    appdata.offset += diff;
231                    Ok(appdata)
232                }
233                _ => Ok(appdata),
234            }
235        }
236        .wrap(),
237    );
238
239    let (mut app, event_loop): (
240        App<GraphState, BasicApp>,
241        feather_ui::winit::event_loop::EventLoop<()>,
242    ) = App::new(
243        GraphState {
244            nodes: vec![],
245            edges: HashSet::new(),
246            offset: Vec2::new(-5000.0, -5000.0),
247            selected: None,
248        },
249        vec![handle_input],
250        BasicApp {},
251        |_| (),
252    )
253    .unwrap();
254
255    event_loop.run_app(&mut app).unwrap();
256}