1use std::collections::{BTreeMap, HashMap};
27
28pub use facett_core::{Edge, Facet, FacetCaps, Layout, Node, Scene, Theme, draw, hash_color, set_theme, theme};
29
30use serde::{Deserialize, Serialize};
31
32mod depgraph;
33pub use depgraph::{
34 DepEdge, DepGraphState, DepGraphView, DepNode, draw_arrow, Effect as DepGraphEffect, Msg as DepGraphMsg,
35};
36
37pub fn scene_from_labeled_edges<I>(rows: I) -> Scene
42where
43 I: IntoIterator<Item = (i64, i64, String, String)>,
44{
45 let mut scene = Scene::new();
46 let mut idx: HashMap<i64, usize> = HashMap::new();
47 for (s, d, sl, dl) in rows {
48 let si = *idx.entry(s).or_insert_with(|| scene.node(sl.clone(), hash_color(&sl)));
49 let di = *idx.entry(d).or_insert_with(|| scene.node(dl.clone(), hash_color(&dl)));
50 scene.edge(si, di);
51 }
52 scene
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum GraphLayout {
62 #[default]
64 Circular,
65 Force,
67}
68
69impl From<Layout> for GraphLayout {
70 fn from(l: Layout) -> Self {
71 match l {
72 Layout::Circular => GraphLayout::Circular,
73 Layout::Force => GraphLayout::Force,
74 }
75 }
76}
77
78impl From<GraphLayout> for Layout {
79 fn from(l: GraphLayout) -> Self {
80 match l {
81 GraphLayout::Circular => Layout::Circular,
82 GraphLayout::Force => Layout::Force,
83 }
84 }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
93pub struct GraphState {
94 pub layout: GraphLayout,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
102pub enum Msg {
103 SetLayout(GraphLayout),
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
110pub enum Effect {}
111
112pub struct GraphView {
115 pub scene: Scene,
118 pub empty_hint: String,
120 pub title: String,
122 state: GraphState,
124}
125
126impl GraphView {
127 pub fn new(scene: Scene) -> Self {
128 Self { scene, empty_hint: "empty".into(), title: "graph".into(), state: GraphState::default() }
129 }
130 pub fn empty_hint(mut self, hint: impl Into<String>) -> Self {
131 self.empty_hint = hint.into();
132 self
133 }
134 pub fn with_title(mut self, title: impl Into<String>) -> Self {
135 self.title = title.into();
136 self
137 }
138 pub fn with_layout(mut self, layout: Layout) -> Self {
140 self.state.layout = layout.into();
141 self
142 }
143
144 pub fn state(&self) -> &GraphState {
146 &self.state
147 }
148
149 pub fn layout(&self) -> Layout {
151 self.state.layout.into()
152 }
153
154 pub fn set_layout(&mut self, layout: Layout) {
157 let _ = self.update(Msg::SetLayout(layout.into()));
158 }
159
160 pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
163 match msg {
164 Msg::SetLayout(layout) => self.state.layout = layout,
165 }
166 Vec::new()
167 }
168
169 pub fn show(&self, ui: &mut egui::Ui) {
171 draw(ui, &self.scene, self.state.layout.into(), &self.empty_hint);
172 }
173
174 pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
179 self.show(ui);
180 #[cfg(feature = "testmatrix")]
182 facett_core::testmatrix::emit(
183 "facett-graph::GraphView::view",
184 "ui_render",
185 !self.scene.nodes.is_empty() || !self.empty_hint.is_empty(),
186 &format!("nodes={} edges={}", self.scene.nodes.len(), self.scene.edges.len()),
187 );
188 Vec::new()
189 }
190
191 pub fn label_counts(&self) -> BTreeMap<String, usize> {
193 let mut m = BTreeMap::new();
194 for n in &self.scene.nodes {
195 *m.entry(n.label.clone()).or_insert(0) += 1;
196 }
197 m
198 }
199}
200
201impl GraphView {
202 pub fn copy_text(&self) -> Option<String> {
205 if self.scene.nodes.is_empty() {
206 return None;
207 }
208 Some(self.scene.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
209 }
210}
211
212impl facett_core::clip::CopySource for GraphView {
214 fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
215 use facett_core::clip::ClipKind;
216 &[ClipKind::Text]
217 }
218 fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
219 self.copy_text().map(facett_core::clip::ClipPayload::Text)
220 }
221}
222
223impl facett_core::Elm for GraphView {
225 type Model = GraphState;
226 type Msg = Msg;
227 type Effect = Effect;
228
229 fn title(&self) -> &str {
230 &self.title
231 }
232 fn state(&self) -> &GraphState {
233 &self.state
234 }
235 fn update(&mut self, msg: Msg) -> Vec<Effect> {
236 GraphView::update(self, msg)
237 }
238 fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
239 GraphView::view(self, ui)
240 }
241}
242
243facett_core::impl_facet_via_elm!(GraphView, custom_state_json, {
250 fn state_json(&self) -> serde_json::Value {
251 serde_json::json!({
252 "nodes": self.scene.nodes.len(),
253 "edges": self.scene.edges.len(),
254 "labels": self.label_counts(),
255 })
256 }
257 fn copy(&mut self) -> Option<String> {
258 use facett_core::clip::CopySource as _;
259 self.copy_payload().map(|p| p.as_text())
260 }
261 fn caps(&self) -> FacetCaps {
264 FacetCaps::NONE.themeable().resizable().copyable()
265 }
266});
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn typed_copy_is_the_node_label_list() {
274 use facett_core::clip::{ClipKind, CopySource};
275 let scene = scene_from_labeled_edges(vec![(1, 2, "Person".into(), "Company".into())]);
276 let mut g = GraphView::new(scene);
277 let p = g.copy_payload().expect("a populated scene copies");
278 assert_eq!(p.kind(), ClipKind::Text);
279 assert!(p.as_text().contains("Person") && p.as_text().contains("Company"));
280 assert_eq!(<GraphView as Facet>::copy(&mut g), Some(p.as_text()));
281 }
282
283 #[test]
284 fn builds_scene_from_labeled_edges() {
285 let scene = scene_from_labeled_edges(vec![
286 (1, 2, "Person".into(), "Company".into()),
287 (1, 3, "Person".into(), "Address".into()),
288 ]);
289 assert_eq!(scene.nodes.len(), 3, "1, 2, 3 distinct");
290 assert_eq!(scene.edges.len(), 2);
291 assert_eq!(scene.nodes[0].label, "Person");
292 }
293
294 #[test]
296 fn state_json_preserves_the_pre_migration_keys() {
297 let scene = scene_from_labeled_edges(vec![
298 (1, 2, "Person".into(), "Company".into()),
299 (1, 3, "Person".into(), "Address".into()),
300 ]);
301 let g = GraphView::new(scene);
302 let j = Facet::state_json(&g);
303 assert_eq!(j["nodes"], 3);
305 assert_eq!(j["edges"], 2);
306 assert_eq!(j["labels"]["Person"], 1);
307 let obj = j.as_object().unwrap();
308 assert_eq!(obj.len(), 3, "exactly nodes/edges/labels, no extra keys: {:?}", obj.keys().collect::<Vec<_>>());
309 assert!(obj.contains_key("nodes") && obj.contains_key("edges") && obj.contains_key("labels"));
310 }
311
312 #[test]
314 fn harness_drives_setlayout_and_snapshots_state() {
315 use facett_core::harness;
316 let mut g = GraphView::new(scene_from_labeled_edges(vec![(1, 2, "A".into(), "B".into())]));
317 assert_eq!(g.state().layout, GraphLayout::Circular, "default layout");
318 let snap = harness::snapshot(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
320 assert_eq!(snap.layout, GraphLayout::Force, "layout is observable headlessly");
321 assert_eq!(&snap, g.state(), "the snapshot is a clone of the live state");
322 assert_eq!(g.state().layout, GraphLayout::Force, "the live view reflects the driven layout");
323 assert!(matches!(g.layout(), Layout::Force), "the accessor maps back to the core Layout");
324 }
325
326 #[test]
327 fn drive_reports_no_effects_and_state_round_trips() {
328 use facett_core::harness;
329 let mut g = GraphView::new(Scene::new());
330 let effects = harness::drive(&mut g, [Msg::SetLayout(GraphLayout::Force)]);
332 assert!(effects.is_empty(), "FC-8: graph view emits no Effects");
333 let json = serde_json::to_value(g.state()).unwrap();
335 assert_eq!(json["layout"], "force");
336 let back: GraphState = serde_json::from_value(json).unwrap();
337 assert_eq!(&back, g.state(), "serde(state) -> state round-trips");
338 }
339
340 #[test]
341 fn headless_render_draws_and_reports_state() {
342 use facett_core::harness;
343 let mut g = GraphView::new(scene_from_labeled_edges(vec![
344 (1, 2, "Person".into(), "Company".into()),
345 (2, 3, "Company".into(), "Address".into()),
346 ]))
347 .with_title("graph");
348 let r = harness::headless_render(&mut g);
349 assert_eq!(r.title, "graph");
350 assert_eq!(r.state["nodes"], 3);
351 assert_eq!(r.state["edges"], 2);
352 assert!(r.drew(), "a 3-node graph tessellates to vertices");
353 }
354}