graph_rs/
graph-rs.rs

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