Skip to content

Commit 225c1c4

Browse files
committed
Add Sugiyama hierarchical auto-layout behind layout feature
Integrate rust-sugiyama as an optional dependency gated behind features = ["layout"]. Provides sugiyama_layout() for computing hierarchical positions from node-level edges, and sugiyama_layout_from_cache() for resolving pin-based edges via GeometryCache. Includes Direction enum (TopToBottom, LeftToRight), NodePosition struct, and #[non_exhaustive] on both Direction and SugiyamaConfig for future extensibility. 20 unit tests cover core layout, cache integration, disconnected graphs, and cyclic input. Adds a demo example with 8-node DAG, horizontal layout, and scramble button.
1 parent 9385eb5 commit 225c1c4

8 files changed

Lines changed: 931 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ include = [
2323

2424
[dependencies]
2525
slint = { git = "https://github.com/slint-ui/slint", default-features = false, features = ["std", "compat-1-2"] }
26+
rust-sugiyama = { version = "0.4", optional = true }
27+
28+
[features]
29+
layout = ["rust-sugiyama"]
2630

2731
[dev-dependencies]
2832
i-slint-backend-testing = { git = "https://github.com/slint-ui/slint" }
@@ -38,6 +42,7 @@ members = [
3842
"examples/animated-links",
3943
"examples/pin-compatibility",
4044
"examples/zoom-stress-test",
45+
"examples/sugiyama",
4146
]
4247

4348
[workspace.dependencies]

examples/sugiyama/Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[package]
2+
name = "sugiyama"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[dependencies]
8+
slint = { workspace = true }
9+
slint-node-editor = { workspace = true, features = ["layout"] }
10+
11+
[build-dependencies]
12+
slint-build = { workspace = true }

examples/sugiyama/build.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
fn main() {
2+
let mut library_paths = std::collections::HashMap::new();
3+
library_paths.insert("slint-node-editor".into(), "../../".into());
4+
5+
let config = slint_build::CompilerConfiguration::default()
6+
.with_library_paths(library_paths);
7+
slint_build::compile_with_config("ui/sugiyama.slint", config).unwrap();
8+
}

examples/sugiyama/src/main.rs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use std::cell::Cell;
2+
use std::collections::HashMap;
3+
use std::rc::Rc;
4+
5+
use slint::{Color, Model, ModelRc, SharedString, VecModel};
6+
use slint_node_editor::{sugiyama_layout, Direction, NodeEditorController, SugiyamaConfig};
7+
8+
slint::include_modules!();
9+
10+
/// Deterministic LCG — produces the same sequence on first click after each
11+
/// restart, but varies across subsequent clicks within a session.
12+
fn random_f32() -> f32 {
13+
thread_local! { static SEED: Cell<u64> = Cell::new(12345); }
14+
SEED.with(|s| {
15+
let v = s.get().wrapping_mul(6364136223846793005).wrapping_add(1);
16+
s.set(v);
17+
((v >> 33) as f32) / (u32::MAX as f32)
18+
})
19+
}
20+
21+
/// Build an index from node_id → model row for O(1) lookups.
22+
fn build_node_index(nodes: &VecModel<NodeData>) -> HashMap<i32, usize> {
23+
(0..nodes.row_count())
24+
.filter_map(|i| nodes.row_data(i).map(|n| (n.id, i)))
25+
.collect()
26+
}
27+
28+
fn main() {
29+
let window = MainWindow::new().unwrap();
30+
let ctrl = NodeEditorController::new();
31+
let w = window.as_weak();
32+
33+
// Create a DAG with 8 nodes:
34+
//
35+
// 1 ──► 2 ──► 4 ──► 7
36+
// │ │ │
37+
// ▼ ▼ ▼
38+
// 3 ──► 5 ──► 6 ──► 8
39+
//
40+
let nodes = Rc::new(VecModel::from(vec![
41+
NodeData { id: 1, title: SharedString::from("Input"), x: 50.0, y: 50.0 },
42+
NodeData { id: 2, title: SharedString::from("Parse"), x: 50.0, y: 120.0 },
43+
NodeData { id: 3, title: SharedString::from("Validate"), x: 50.0, y: 190.0 },
44+
NodeData { id: 4, title: SharedString::from("Transform"), x: 50.0, y: 260.0 },
45+
NodeData { id: 5, title: SharedString::from("Filter"), x: 50.0, y: 330.0 },
46+
NodeData { id: 6, title: SharedString::from("Merge"), x: 50.0, y: 400.0 },
47+
NodeData { id: 7, title: SharedString::from("Format"), x: 50.0, y: 470.0 },
48+
NodeData { id: 8, title: SharedString::from("Output"), x: 50.0, y: 540.0 },
49+
]));
50+
window.set_nodes(ModelRc::from(nodes.clone()));
51+
52+
// DAG edges as (source_node_id, target_node_id) — single source of truth
53+
// Pin encoding: input = id*2, output = id*2+1
54+
let dag_edges: Vec<(i32, i32)> = vec![
55+
(1, 2), (1, 3),
56+
(2, 4), (2, 5),
57+
(3, 5),
58+
(4, 6), (4, 7),
59+
(5, 6),
60+
(6, 8), (7, 8),
61+
];
62+
63+
// Derive LinkData from dag_edges so they can't drift out of sync
64+
let link_color = Color::from_argb_u8(255, 100, 180, 255);
65+
let link_data: Vec<LinkData> = dag_edges
66+
.iter()
67+
.enumerate()
68+
.map(|(i, &(src, dst))| LinkData {
69+
id: (i + 1) as i32,
70+
start_pin_id: src * 2 + 1, // output pin of source
71+
end_pin_id: dst * 2, // input pin of target
72+
color: link_color,
73+
line_width: 2.0,
74+
})
75+
.collect();
76+
window.set_links(ModelRc::from(Rc::new(VecModel::from(link_data))));
77+
78+
// Layout button callback
79+
window.on_layout_requested({
80+
let nodes = nodes.clone();
81+
let dag_edges = dag_edges.clone();
82+
move || {
83+
let node_sizes: Vec<(i32, (f64, f64))> = (0..nodes.row_count())
84+
.filter_map(|i| nodes.row_data(i))
85+
.map(|n| (n.id, (120.0, 60.0)))
86+
.collect();
87+
88+
let mut config = SugiyamaConfig::default();
89+
config.vertex_spacing = 60.0;
90+
config.direction = Direction::LeftToRight;
91+
92+
let positions = sugiyama_layout(&dag_edges, &node_sizes, &config);
93+
94+
let index = build_node_index(&nodes);
95+
let offset_x = 80.0_f32;
96+
let offset_y = 100.0_f32;
97+
for pos in &positions {
98+
if let Some(&row) = index.get(&pos.id) {
99+
if let Some(mut node) = nodes.row_data(row) {
100+
node.x = pos.x as f32 + offset_x;
101+
node.y = pos.y as f32 + offset_y;
102+
nodes.set_row_data(row, node);
103+
}
104+
}
105+
}
106+
}
107+
});
108+
109+
// Scramble button callback
110+
window.on_scramble_requested({
111+
let nodes = nodes.clone();
112+
move || {
113+
for i in 0..nodes.row_count() {
114+
if let Some(mut node) = nodes.row_data(i) {
115+
node.x = 50.0 + random_f32() * 800.0;
116+
node.y = 50.0 + random_f32() * 500.0;
117+
nodes.set_row_data(i, node);
118+
}
119+
}
120+
}
121+
});
122+
123+
// Core callbacks
124+
window.on_compute_link_path(ctrl.compute_link_path_callback());
125+
window.on_node_drag_started(ctrl.node_drag_started_callback());
126+
127+
window.on_node_rect_changed({
128+
let ctrl = ctrl.clone();
129+
move |id, x, y, width, h| {
130+
ctrl.handle_node_rect(id, x, y, width, h);
131+
}
132+
});
133+
134+
window.on_pin_position_changed({
135+
let ctrl = ctrl.clone();
136+
move |pid, nid, ptype, x, y| {
137+
ctrl.handle_pin_position(pid, nid, ptype, x, y);
138+
}
139+
});
140+
141+
window.on_request_grid_update({
142+
let ctrl = ctrl.clone();
143+
let w = w.clone();
144+
move || {
145+
if let Some(w) = w.upgrade() {
146+
w.set_grid_commands(ctrl.generate_initial_grid(w.get_width_(), w.get_height_()));
147+
}
148+
}
149+
});
150+
151+
window.on_update_viewport({
152+
let ctrl = ctrl.clone();
153+
let w = w.clone();
154+
move |z, pan_x, pan_y| {
155+
if let Some(w) = w.upgrade() {
156+
ctrl.set_viewport(z, pan_x, pan_y);
157+
w.set_grid_commands(ctrl.generate_grid(w.get_width_(), w.get_height_(), pan_x, pan_y));
158+
}
159+
}
160+
});
161+
162+
window.on_node_drag_ended({
163+
let ctrl = ctrl.clone();
164+
let nodes = nodes.clone();
165+
move |delta_x, delta_y| {
166+
let node_id = ctrl.dragged_node_id();
167+
for i in 0..nodes.row_count() {
168+
if let Some(mut node) = nodes.row_data(i) {
169+
if node.id == node_id {
170+
node.x += delta_x;
171+
node.y += delta_y;
172+
nodes.set_row_data(i, node);
173+
break;
174+
}
175+
}
176+
}
177+
}
178+
});
179+
180+
window.invoke_request_grid_update();
181+
window.run().unwrap();
182+
}

0 commit comments

Comments
 (0)