node-based graph editor
git clone https://git.lucas.co/cce-graph.git
src/main.rs (70.1K)
1 use wayland_client::QueueHandle;
2 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
3 use cce_ui::widget::{Adapted, MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost, Event, Graph, GraphNode, MenuBar, GraphController, Dropdown, Label};
4 use image::GenericImageView;
5
6 #[derive(Debug, Clone)]
7 enum AppMessage {
8 New,
9 Open,
10 OpenRecent(std::path::PathBuf),
11 Save,
12 SaveAs,
13 SaveToPath(std::path::PathBuf),
14 Exit,
15 ToggleGrid,
16 ToggleUniformBackground,
17 SetOpacity95,
18 SetOpacity75,
19 SetOpacity50,
20 ToggleControlPanel,
21 AddNode,
22 AddImage,
23 AddImageFromPath(std::path::PathBuf),
24 }
25
26 #[derive(serde::Serialize, serde::Deserialize, Clone)]
27 struct GraphProjectImage {
28 path: String,
29 position: (f32, f32), // (column, row)
30 size: (f32, f32), // (w_cols, h_rows)
31 }
32
33 #[derive(serde::Serialize, serde::Deserialize)]
34 struct GraphProjectState {
35 name: String,
36 nodes: Vec<GraphNode>,
37 images: Vec<GraphProjectImage>,
38 show_grid: bool,
39 uniform_background: bool,
40 opacity: f32,
41 }
42
43 struct LoadedImage {
44 path: String,
45 position: (f32, f32),
46 size: (f32, f32),
47 pixels: Vec<[u8; 4]>,
48 pixel_width: u32,
49 pixel_height: u32,
50 }
51
52 struct GraphApp {
53 menu_bar: Adapted<MenuBar>,
54 dropdown_file: Adapted<Dropdown>,
55 dropdown_edit: Adapted<Dropdown>,
56 dropdown_view: Adapted<Dropdown>,
57
58 graph: Adapted<Graph>,
59 needs_rebuild: bool,
60 width: u32,
61 height: u32,
62 scale_factor: f64,
63 show_grid: bool,
64 uniform_background: bool,
65 opacity: f32,
66 ui_context: cce_ui::context::UiContext,
67 loaded_project_path: Option<std::path::PathBuf>,
68 loaded_images: Vec<LoadedImage>,
69 widgets_registered: bool,
70 message_sender: calloop::channel::Sender<AppMessage>,
71 dragging_image_idx: Option<usize>,
72 drag_image_ox: f32,
73 drag_image_oy: f32,
74 selected_image_idx: Option<usize>,
75 // Dissolved control panel (was a draggable Plate): position + drag state live here;
76 // its plate is emitted as prims and the label is a standalone walked widget.
77 panel_x: f32,
78 panel_y: f32,
79 panel_dragging: bool,
80 panel_drag_ox: f32,
81 panel_drag_oy: f32,
82 show_control_panel: bool,
83 control_panel_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
84 }
85
86 fn get_default_project_path() -> std::path::PathBuf {
87 cce_ui::config::cce_config_dir().join("cce-graph").join("default.kdl")
88 }
89
90 fn ensure_default_project_file(path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
91 if path.exists() {
92 return Ok(());
93 }
94 if let Some(parent) = path.parent() {
95 std::fs::create_dir_all(parent)?;
96 }
97 // Seeded empty: an empty KDL document parses to exactly the loader's defaults
98 // (`name "default"`, `show_grid true`, `uniform_background false`, `opacity 0.95` — see
99 // `load_project_from_kdl_path`), so the app opens on a blank canvas. The file itself still has
100 // to exist, since loading reads it directly.
101 std::fs::write(path, "")?;
102 Ok(())
103 }
104
105 fn load_project_from_kdl_path(path: &std::path::Path) -> Result<GraphProjectState, Box<dyn std::error::Error>> {
106 let content = std::fs::read_to_string(path)?;
107 let doc = content.parse::<kdl::KdlDocument>()?;
108
109 let mut name = "default".to_string();
110 let mut show_grid = true;
111 let mut uniform_background = false;
112 let mut opacity = 0.95f32;
113 let mut nodes = Vec::new();
114 let mut images = Vec::new();
115
116 for node in doc.nodes() {
117 let name_val = node.name().value();
118 match name_val {
119 "name" => {
120 if let Some(entry) = node.entries().first() {
121 if let kdl::KdlValue::String(s) = entry.value() {
122 name = s.to_string();
123 }
124 }
125 }
126 "show_grid" => {
127 if let Some(entry) = node.entries().first() {
128 if let kdl::KdlValue::Bool(b) = entry.value() {
129 show_grid = *b;
130 }
131 }
132 }
133 "uniform_background" => {
134 if let Some(entry) = node.entries().first() {
135 if let kdl::KdlValue::Bool(b) = entry.value() {
136 uniform_background = *b;
137 }
138 }
139 }
140 "opacity" => {
141 if let Some(entry) = node.entries().first() {
142 match entry.value() {
143 kdl::KdlValue::Base10Float(f) => opacity = *f as f32,
144 kdl::KdlValue::Base10(i) => opacity = *i as f32,
145 _ => {}
146 }
147 }
148 }
149 "node" => {
150 let node_name = if let Some(entry) = node.entries().first() {
151 if let kdl::KdlValue::String(s) = entry.value() {
152 s.to_string()
153 } else {
154 "".to_string()
155 }
156 } else {
157 "".to_string()
158 };
159
160 let mut position = (0.0f32, 0.0f32);
161 let mut inputs = 0;
162 let mut outputs = 0;
163 let mut geom_visible = true;
164 let mut node_type = "".to_string();
165 let mut parameters = Vec::new();
166
167 if let Some(children) = node.children() {
168 for child in children.nodes() {
169 let child_name = child.name().value();
170 match child_name {
171 "position" => {
172 let coords: Vec<f32> = child.entries().iter().filter_map(|e| {
173 match e.value() {
174 kdl::KdlValue::Base10Float(f) => Some(*f as f32),
175 kdl::KdlValue::Base10(i) => Some(*i as f32),
176 _ => None
177 }
178 }).collect();
179 if coords.len() >= 2 {
180 position = (coords[0], coords[1]);
181 }
182 }
183 "inputs" => {
184 if let Some(e) = child.entries().first() {
185 if let kdl::KdlValue::Base10(i) = e.value() {
186 inputs = *i as usize;
187 }
188 }
189 }
190 "outputs" => {
191 if let Some(e) = child.entries().first() {
192 if let kdl::KdlValue::Base10(i) = e.value() {
193 outputs = *i as usize;
194 }
195 }
196 }
197 "geom_visible" => {
198 if let Some(e) = child.entries().first() {
199 if let kdl::KdlValue::Bool(b) = e.value() {
200 geom_visible = *b;
201 }
202 }
203 }
204 "node_type" => {
205 if let Some(e) = child.entries().first() {
206 if let kdl::KdlValue::String(s) = e.value() {
207 node_type = s.to_string();
208 }
209 }
210 }
211 "parameter" => {
212 let mut param_name = "".to_string();
213 let mut param_val = "".to_string();
214 let mut param_type = "".to_string();
215 if let Some(e) = child.entries().first() {
216 if let kdl::KdlValue::String(s) = e.value() {
217 param_name = s.to_string();
218 }
219 }
220 for entry in child.entries().iter().skip(1) {
221 if let Some(prop) = entry.name() {
222 let prop_str = prop.value();
223 match prop_str {
224 "value" => {
225 if let kdl::KdlValue::String(s) = entry.value() {
226 param_val = s.to_string();
227 }
228 }
229 "type" => {
230 if let kdl::KdlValue::String(s) = entry.value() {
231 param_type = s.to_string();
232 }
233 }
234 _ => {}
235 }
236 }
237 }
238 parameters.push((param_name, param_val, param_type));
239 }
240 _ => {}
241 }
242 }
243 }
244
245 nodes.push(GraphNode {
246 id: "".to_string(),
247 name: node_name,
248 position,
249 parameters,
250 geom_visible,
251 node_type,
252 inputs,
253 outputs,
254 });
255 }
256 "image" => {
257 let img_path = if let Some(entry) = node.entries().first() {
258 if let kdl::KdlValue::String(s) = entry.value() {
259 s.to_string()
260 } else {
261 "".to_string()
262 }
263 } else {
264 "".to_string()
265 };
266
267 let mut position = (0.0f32, 0.0f32);
268 let mut size = (0.0f32, 0.0f32);
269
270 if let Some(children) = node.children() {
271 for child in children.nodes() {
272 let child_name = child.name().value();
273 match child_name {
274 "position" => {
275 let coords: Vec<f32> = child.entries().iter().filter_map(|e| {
276 match e.value() {
277 kdl::KdlValue::Base10Float(f) => Some(*f as f32),
278 kdl::KdlValue::Base10(i) => Some(*i as f32),
279 _ => None
280 }
281 }).collect();
282 if coords.len() >= 2 {
283 position = (coords[0], coords[1]);
284 }
285 }
286 "size" => {
287 let sz: Vec<f32> = child.entries().iter().filter_map(|e| {
288 match e.value() {
289 kdl::KdlValue::Base10Float(f) => Some(*f as f32),
290 kdl::KdlValue::Base10(i) => Some(*i as f32),
291 _ => None
292 }
293 }).collect();
294 if sz.len() >= 2 {
295 size = (sz[0], sz[1]);
296 }
297 }
298 _ => {}
299 }
300 }
301 }
302
303 images.push(GraphProjectImage {
304 path: img_path,
305 position,
306 size,
307 });
308 }
309 _ => {}
310 }
311 }
312
313 Ok(GraphProjectState {
314 name,
315 nodes,
316 images,
317 show_grid,
318 uniform_background,
319 opacity,
320 })
321 }
322
323 fn save_project_to_kdl_path(path: &std::path::Path, state: &GraphProjectState) -> Result<(), Box<dyn std::error::Error>> {
324 let mut kdl = String::new();
325 kdl.push_str(&format!("name {:?}\n", state.name));
326 kdl.push_str(&format!("show_grid {}\n", state.show_grid));
327 kdl.push_str(&format!("uniform_background {}\n", state.uniform_background));
328 kdl.push_str(&format!("opacity {}\n\n", state.opacity));
329
330 for node in &state.nodes {
331 kdl.push_str(&format!("node {:?} {{\n", node.name));
332 kdl.push_str(&format!(" position {} {}\n", node.position.0, node.position.1));
333 kdl.push_str(&format!(" inputs {}\n", node.inputs));
334 kdl.push_str(&format!(" outputs {}\n", node.outputs));
335 kdl.push_str(&format!(" geom_visible {}\n", node.geom_visible));
336 if !node.node_type.is_empty() {
337 kdl.push_str(&format!(" node_type {:?}\n", node.node_type));
338 }
339 for (p_name, p_val, p_type) in &node.parameters {
340 kdl.push_str(&format!(" parameter {:?} value={:?} type={:?}\n", p_name, p_val, p_type));
341 }
342 kdl.push_str("}\n\n");
343 }
344
345 for img in &state.images {
346 kdl.push_str(&format!("image {:?} {{\n", img.path));
347 kdl.push_str(&format!(" position {} {}\n", img.position.0, img.position.1));
348 kdl.push_str(&format!(" size {} {}\n", img.size.0, img.size.1));
349 kdl.push_str("}\n\n");
350 }
351
352 std::fs::write(path, kdl)?;
353 Ok(())
354 }
355
356 fn get_view_options(show_grid: bool, uniform_bg: bool, opacity: f32, show_panel: bool) -> Vec<String> {
357 vec![
358 format!("{} Show Grid", if show_grid { "✓" } else { " " }),
359 format!("{} Uniform Background", if uniform_bg { "✓" } else { " " }),
360 format!("{} Opacity: 95%", if (opacity - 0.95).abs() < 0.05 { "✓" } else { " " }),
361 format!("{} Opacity: 75%", if (opacity - 0.75).abs() < 0.05 { "✓" } else { " " }),
362 format!("{} Opacity: 50%", if (opacity - 0.50).abs() < 0.05 { "✓" } else { " " }),
363 format!("{} Control Panel", if show_panel { "✓" } else { " " }),
364 ]
365 }
366
367 fn load_image_pixels(path: &std::path::Path) -> Option<(Vec<[u8; 4]>, u32, u32)> {
368 let img = image::open(path).ok()?;
369 let max_dim = 96;
370 let (w, h) = img.dimensions();
371 let (nw, nh) = if w > h {
372 (max_dim, (h as f32 * (max_dim as f32 / w as f32)) as u32)
373 } else {
374 ((w as f32 * (max_dim as f32 / h as f32)) as u32, max_dim)
375 };
376 let img = img.resize_exact(nw, nh, image::imageops::FilterType::Triangle);
377 let rgba = img.to_rgba8();
378 let pixels = rgba.chunks_exact(4)
379 .map(|c| [c[0], c[1], c[2], c[3]])
380 .collect();
381 Some((pixels, nw, nh))
382 }
383
384 fn matches_keybind(event: &KeyEvent, keybind: &str) -> bool {
385 let kb_clean = keybind.trim().to_lowercase();
386 let parts: Vec<&str> = kb_clean.split('+').collect();
387
388 let mut has_ctrl = false;
389 let mut has_shift = false;
390 let mut main_key_str = "";
391
392 for part in &parts {
393 match *part {
394 "ctrl" => has_ctrl = true,
395 "shift" => has_shift = true,
396 "alt" | "super" => {}
397 other => main_key_str = other,
398 }
399 }
400
401 if event.ctrl != has_ctrl || event.shift != has_shift {
402 return false;
403 }
404
405 match &event.logical_key {
406 cce_ui::widget::Key::Named(nk) => {
407 let key_str = match nk {
408 cce_ui::widget::NamedKey::Backspace => "backspace",
409 cce_ui::widget::NamedKey::Tab => "tab",
410 cce_ui::widget::NamedKey::Enter => "enter",
411 cce_ui::widget::NamedKey::Space => "space",
412 cce_ui::widget::NamedKey::ArrowDown => "down",
413 cce_ui::widget::NamedKey::ArrowLeft => "left",
414 cce_ui::widget::NamedKey::ArrowRight => "right",
415 cce_ui::widget::NamedKey::ArrowUp => "up",
416 cce_ui::widget::NamedKey::End => "end",
417 cce_ui::widget::NamedKey::Home => "home",
418 cce_ui::widget::NamedKey::PageDown => "pagedown",
419 cce_ui::widget::NamedKey::PageUp => "pageup",
420 cce_ui::widget::NamedKey::Delete => "delete",
421 _ => "",
422 };
423 key_str == main_key_str
424 }
425 cce_ui::widget::Key::Character(s) => {
426 s.to_lowercase() == main_key_str
427 }
428 }
429 }
430
431 impl GraphApp {
432 /// Drain a consumed file-menu interaction into its message — shared by the
433 /// mouse path and the key path so a selection means the same thing however
434 /// it was made. Keeps the sentinel `selected = 999` protocol.
435 fn drain_file_menu(&mut self) -> Option<AppMessage> {
436 let mut msg = None;
437 if self.dropdown_file.take_change() {
438 let selected_idx = self.dropdown_file.selected;
439 if selected_idx < self.dropdown_file.options.len() {
440 let option_text = &self.dropdown_file.options[selected_idx];
441 match option_text.as_str() {
442 "New" => msg = Some(AppMessage::New),
443 "Open" | "Open..." => msg = Some(AppMessage::Open),
444 "Save" => msg = Some(AppMessage::Save),
445 "Save As" => msg = Some(AppMessage::SaveAs),
446 "Exit" => msg = Some(AppMessage::Exit),
447 "-" => {}
448 _ => {
449 let path = std::path::PathBuf::from(option_text);
450 msg = Some(AppMessage::OpenRecent(path));
451 }
452 }
453 }
454 self.dropdown_file.selected = 999;
455 }
456 msg
457 }
458
459 fn drain_edit_menu(&mut self) -> Option<AppMessage> {
460 let mut msg = None;
461 if self.dropdown_edit.take_change() {
462 match self.dropdown_edit.selected {
463 0 => msg = Some(AppMessage::AddNode),
464 1 => msg = Some(AppMessage::AddImage),
465 _ => {}
466 }
467 self.dropdown_edit.selected = 999;
468 }
469 msg
470 }
471
472 fn drain_view_menu(&mut self) -> Option<AppMessage> {
473 let mut msg = None;
474 if self.dropdown_view.take_change() {
475 match self.dropdown_view.selected {
476 0 => msg = Some(AppMessage::ToggleGrid),
477 1 => msg = Some(AppMessage::ToggleUniformBackground),
478 2 => msg = Some(AppMessage::SetOpacity95),
479 3 => msg = Some(AppMessage::SetOpacity75),
480 4 => msg = Some(AppMessage::SetOpacity50),
481 5 => msg = Some(AppMessage::ToggleControlPanel),
482 _ => {}
483 }
484 self.dropdown_view.selected = 999;
485 }
486 msg
487 }
488
489 fn delete_selected_node(&mut self) {
490 if let Some(idx) = self.graph.selected_node() {
491 let mut nodes = self.graph.get_nodes();
492 if idx < nodes.len() {
493 let deleted_node_name = nodes[idx].name.clone();
494 nodes.remove(idx);
495
496 // Clear inputs/parameters of other nodes pointing to this deleted node name
497 for node in &mut nodes {
498 for param in &mut node.parameters {
499 if param.1 == deleted_node_name {
500 param.1 = String::new();
501 }
502 }
503 }
504
505 self.graph.set_nodes(&nodes);
506 self.graph.set_selected_node(None);
507 self.needs_rebuild = true;
508 }
509 } else if let Some(idx) = self.selected_image_idx {
510 if idx < self.loaded_images.len() {
511 self.loaded_images.remove(idx);
512 self.selected_image_idx = None;
513 self.needs_rebuild = true;
514 }
515 }
516 }
517
518 fn update_view_options(&mut self) {
519 self.dropdown_view.options = get_view_options(self.show_grid, self.uniform_background, self.opacity, self.show_control_panel);
520 }
521
522 /// The dissolved control panel's rect (fixed 210x160, app-tracked position).
523 fn panel_rect(&self) -> (f32, f32, f32, f32) {
524 (self.panel_x, self.panel_y, 210.0, 160.0)
525 }
526
527 fn panel_hit(&self, px: f32, py: f32) -> bool {
528 let (x, y, w, h) = self.panel_rect();
529 px >= x && px < x + w && py >= y && py < y + h
530 }
531
532 /// Replicates the dissolved Plate's centered-first-child placement for the label
533 /// (plate padding inset, centered, 50px nominal height on first placement).
534 fn position_panel_label(&mut self) {
535 let pad = cce_ui::layout::plate_padding();
536 let (px, py, pw, ph) = self.panel_rect();
537 let left_x = px + pad;
538 let available_w = (pw - 2.0 * pad).max(1.0);
539 let start_y = py + pad;
540 let available_h = (ph - 2.0 * pad).max(1.0);
541 let center_x = left_x + available_w / 2.0;
542 let center_y = start_y + available_h / 2.0;
543
544 let (_, _, lw, lh) = self.control_panel_label.rect();
545 let use_w = if lw > 0.0 { lw.min(available_w) } else { available_w };
546 let use_h = if lh > 0.0 { lh } else { 50.0 };
547 let cx = (center_x - use_w / 2.0).clamp(left_x, (left_x + available_w - use_w).max(left_x));
548 let cy = (center_y - use_h / 2.0).clamp(start_y, (start_y + available_h - use_h).max(start_y));
549 let cw = use_w.min(px + pw - pad - cx);
550 let ch = use_h.min(py + ph - pad - cy);
551 self.control_panel_label.set_rect(cx, cy, cw, ch);
552 }
553
554 /// The dissolved Plate's visual: config plate color (else page-low, with the drag tint),
555 /// plate opacity, negative-alpha blur flag, config border and corner radius.
556 fn panel_visual(&self) -> ([f32; 4], Option<([f32; 4], f32)>, f32) {
557 let mut c = if let Some(c) = cce_ui::colors::plate_color() {
558 c
559 } else if self.panel_dragging {
560 let b = cce_ui::colors::page_low_color();
561 [(b[0] + 0.10).min(1.0), (b[1] + 0.15).min(1.0), (b[2] + 0.12).min(1.0), b[3]]
562 } else {
563 cce_ui::colors::page_low_color()
564 };
565 c[3] *= cce_ui::layout::plate_opacity();
566 if cce_ui::colors::plate_blur() {
567 c[3] = -c[3].abs();
568 }
569 let border = cce_ui::colors::plate_border_color()
570 .map(|bc| (bc, cce_ui::colors::plate_border_thickness()));
571 (c, border, cce_ui::layout::plate_corner_radius())
572 }
573
574 fn new_project(&mut self) {
575 self.graph.set_nodes(&[]);
576 self.loaded_images.clear();
577 self.loaded_project_path = None;
578 self.needs_rebuild = true;
579 }
580
581 fn save_project_to_path(&mut self, path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
582 let project_dir = path;
583 std::fs::create_dir_all(project_dir)?;
584
585 // Create assets and code subdirectories
586 std::fs::create_dir_all(project_dir.join("assets"))?;
587 std::fs::create_dir_all(project_dir.join("code"))?;
588
589 let state_file_path = project_dir.join("state.kdl");
590
591 let project_images: Vec<GraphProjectImage> = self.loaded_images.iter()
592 .map(|img| GraphProjectImage {
593 path: img.path.clone(),
594 position: img.position,
595 size: img.size,
596 })
597 .collect();
598
599 let state = GraphProjectState {
600 name: project_dir.file_name()
601 .and_then(|n| n.to_str())
602 .unwrap_or("Graph Project")
603 .to_string(),
604 nodes: self.graph.get_nodes(),
605 images: project_images,
606 show_grid: self.show_grid,
607 uniform_background: self.uniform_background,
608 opacity: self.opacity,
609 };
610
611 save_project_to_kdl_path(&state_file_path, &state)?;
612
613 // Clean up old state.json if it exists
614 let old_json_path = project_dir.join("state.json");
615 if old_json_path.exists() {
616 let _ = std::fs::remove_file(old_json_path);
617 }
618
619 self.loaded_project_path = Some(project_dir.to_path_buf());
620 self.needs_rebuild = true;
621 Ok(())
622 }
623
624 fn load_recent_files(&self) -> Vec<String> {
625 cce_ui::config::load_recent_files()
626 }
627
628 fn save_recent_files(&self, files: &[String]) {
629 cce_ui::config::save_recent_files(files)
630 }
631
632 fn add_recent_file(&mut self, file_path: &std::path::Path) {
633 if let Ok(abs_path) = std::fs::canonicalize(file_path) {
634 let abs_str = abs_path.to_string_lossy().to_string();
635 let mut recent = self.load_recent_files();
636 recent.retain(|p| p != &abs_str);
637 recent.insert(0, abs_str);
638 if recent.len() > 10 {
639 recent.truncate(10);
640 }
641 self.save_recent_files(&recent);
642 self.update_recent_files_dropdown(recent);
643 }
644 }
645
646 fn update_recent_files_dropdown(&mut self, recent: Vec<String>) {
647 let mut options = vec![
648 "New".to_string(),
649 "Open...".to_string(),
650 "Save".to_string(),
651 "Save As".to_string(),
652 ];
653 if !recent.is_empty() {
654 options.push("-".to_string());
655 options.extend(recent);
656 }
657 options.push("-".to_string());
658 options.push("Exit".to_string());
659 self.dropdown_file.options = options;
660 self.dropdown_file.selected = 999;
661 self.needs_rebuild = true;
662 }
663
664 fn load_project_from_path(&mut self, path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
665 let (state_file_path, project_dir) = if path.is_dir() {
666 let kdl_path = path.join("state.kdl");
667 if kdl_path.exists() {
668 (kdl_path, path.to_path_buf())
669 } else {
670 (path.join("state.json"), path.to_path_buf())
671 }
672 } else {
673 if path.file_name().map_or(false, |name| name == "state.json" || name == "state.kdl") {
674 (path.to_path_buf(), path.parent().unwrap_or(path).to_path_buf())
675 } else {
676 (path.to_path_buf(), path.parent().unwrap_or(path).to_path_buf())
677 }
678 };
679
680 let state: GraphProjectState = if state_file_path.extension().map_or(false, |ext| ext == "kdl") {
681 load_project_from_kdl_path(&state_file_path)?
682 } else {
683 let content = std::fs::read_to_string(&state_file_path)?;
684 serde_json::from_str(&content)?
685 };
686
687 self.graph.set_nodes(&state.nodes);
688 self.show_grid = state.show_grid;
689 self.uniform_background = state.uniform_background;
690 self.opacity = state.opacity;
691
692 // Load images
693 self.loaded_images.clear();
694 for img in state.images {
695 let image_path = if std::path::Path::new(&img.path).is_absolute() {
696 std::path::PathBuf::from(&img.path)
697 } else {
698 project_dir.join(&img.path)
699 };
700
701 if let Some((pixels, pw, ph)) = load_image_pixels(&image_path) {
702 self.loaded_images.push(LoadedImage {
703 path: img.path.clone(),
704 position: img.position,
705 size: img.size,
706 pixels,
707 pixel_width: pw,
708 pixel_height: ph,
709 });
710 } else {
711 eprintln!("Warning: Failed to load image at {:?}", image_path);
712 }
713 }
714
715 // Apply grid/background settings to self.graph
716 self.graph.set_show_network_grid(self.show_grid);
717 self.graph.set_uniform_background(self.uniform_background);
718 self.graph.set_network_opacity(self.opacity);
719
720 // Update view dropdown options
721 self.update_view_options();
722
723 self.loaded_project_path = Some(project_dir);
724 self.needs_rebuild = true;
725 Ok(())
726 }
727
728 fn hit_test_image(&self, px: f32, py: f32) -> Option<usize> {
729 let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
730 let (grid_size_x, grid_size_y) = self.graph.grid_sizes();
731 let (skipped_row_h, skipped_col_w) = self.graph.skipped_sizes();
732
733 let step_x = grid_size_x + skipped_col_w;
734 let step_y = grid_size_y + skipped_row_h;
735
736 for (i, img) in self.loaded_images.iter().enumerate().rev() {
737 let col = img.position.0;
738 let row = img.position.1;
739
740 let screen_x = grid_origin_x + col * step_x;
741 let screen_y = grid_origin_y + row * step_y;
742
743 let screen_w = img.size.0 * grid_size_x + (img.size.0 - 1.0).max(0.0) * skipped_col_w;
744 let aspect = img.pixel_height as f32 / img.pixel_width as f32;
745 let screen_h = screen_w * aspect;
746
747 if px >= screen_x && px <= screen_x + screen_w && py >= screen_y && py <= screen_y + screen_h {
748 return Some(i);
749 }
750 }
751 None
752 }
753
754 fn add_image(&mut self, src_path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
755 let image_name = src_path.file_name()
756 .map(|n| n.to_string_lossy().to_string())
757 .unwrap_or_else(|| "image.png".to_string());
758
759 let final_path = if let Some(ref project_dir) = self.loaded_project_path {
760 let dest_dir = project_dir.join("assets");
761 std::fs::create_dir_all(&dest_dir)?;
762 let dest_path = dest_dir.join(&image_name);
763 std::fs::copy(src_path, &dest_path)?;
764 format!("assets/{}", image_name)
765 } else {
766 src_path.to_string_lossy().to_string()
767 };
768
769 if let Some((pixels, pw, ph)) = load_image_pixels(src_path) {
770 let aspect = ph as f32 / pw as f32;
771 let size_w = 4.0;
772 let size_h = size_w * aspect;
773
774 let col = 2.0;
775 let row = 2.0 + self.loaded_images.len() as f32 * 5.0;
776
777 self.loaded_images.push(LoadedImage {
778 path: final_path,
779 position: (col, row),
780 size: (size_w, size_h),
781 pixels,
782 pixel_width: pw,
783 pixel_height: ph,
784 });
785 self.needs_rebuild = true;
786 }
787 Ok(())
788 }
789 }
790
791 impl Application for GraphApp {
792 type Message = AppMessage;
793
794 fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
795 Some(&self.ui_context)
796 }
797
798 fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
799 // root plate container dissolved (Phase 6m): the surface itself is the movable plate; drag
800 // anywhere a drag-blocking widget isn't.
801 self.ui_context.drag_allowed_at(px, py)
802 }
803
804 fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
805 Some(&mut self.ui_context)
806 }
807
808 fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
809 let mut graph = Graph::new();
810
811 let (show_grid, snap_enabled, uniform_background, opacity, gap_width) = load_config();
812
813 // Configure initial grid settings on the graph
814 graph.set_show_network_grid(show_grid);
815 graph.set_grid_sizes(140.0, 70.0);
816 graph.set_skipped_sizes(gap_width, gap_width);
817 graph.set_grid_origin(60.0, 60.0);
818 graph.set_grid_snap_enabled(snap_enabled);
819 graph.set_uniform_background(uniform_background);
820 graph.set_network_opacity(opacity);
821
822 // Build Menu Bar with options to toggle new features
823 // Build Menu Bar background
824 let menu_bar = MenuBar::new(0.0, 0.0, 1024.0, 42.0)
825 .with_color([0.08, 0.08, 0.12, 1.0]);
826
827 let recent = cce_ui::config::load_recent_files();
828
829 let mut file_options = vec![
830 "New".to_string(),
831 "Open...".to_string(),
832 "Save".to_string(),
833 "Save As".to_string(),
834 ];
835 if !recent.is_empty() {
836 file_options.push("-".to_string());
837 file_options.extend(recent);
838 }
839 file_options.push("-".to_string());
840 file_options.push("Exit".to_string());
841
842 let dropdown_file = Dropdown::new(file_options, 999)
843 .with_custom_display_text("File");
844
845 let dropdown_edit = Dropdown::new(
846 vec![
847 "Add Node".to_string(),
848 "Add Image".to_string(),
849 ],
850 999,
851 )
852 .with_custom_display_text("Edit");
853
854 let dropdown_view = Dropdown::new(
855 get_view_options(show_grid, uniform_background, opacity, false),
856 999,
857 )
858 .with_custom_display_text("View");
859
860
861
862 let show_control_panel = false;
863
864 let control_panel_label = Label::new("No Node Selected")
865 .with_font_size(12.0)
866 .with_color([204, 204, 221]);
867
868 let mut app = Self {
869
870 menu_bar,
871 dropdown_file,
872 dropdown_edit,
873 dropdown_view,
874 graph,
875 needs_rebuild: true,
876 width: 1024,
877 height: 768,
878 scale_factor: 1.0,
879 show_grid,
880 uniform_background,
881 opacity,
882 ui_context: cce_ui::context::UiContext::new(),
883 loaded_project_path: None,
884 loaded_images: Vec::new(),
885 widgets_registered: false,
886 message_sender: _sender.clone(),
887 dragging_image_idx: None,
888 drag_image_ox: 0.0,
889 drag_image_oy: 0.0,
890 selected_image_idx: None,
891 panel_x: 800.0,
892 panel_y: 50.0,
893 panel_dragging: false,
894 panel_drag_ox: 0.0,
895 panel_drag_oy: 0.0,
896 show_control_panel,
897 control_panel_label,
898 };
899
900
901 app.menu_bar.set_rect(0.0, 0.0, 1024.0, 42.0);
902 app.dropdown_file.set_rect(10.0, 8.0, 70.0, 26.0);
903 app.dropdown_edit.set_rect(90.0, 8.0, 70.0, 26.0);
904 app.dropdown_view.set_rect(170.0, 8.0, 70.0, 26.0);
905 app.graph.set_rect(0.0, 42.0, 1024.0, 768.0 - 42.0);
906
907 let args: Vec<String> = std::env::args().collect();
908 if args.len() > 1 {
909 let path = std::path::PathBuf::from(&args[1]);
910 if path.exists() {
911 if let Err(e) = app.load_project_from_path(&path) {
912 eprintln!("Failed to load project on startup: {:?}", e);
913 } else {
914 app.add_recent_file(&path);
915 }
916 }
917 } else {
918 let default_path = get_default_project_path();
919 let _ = ensure_default_project_file(&default_path);
920 if let Err(e) = app.load_project_from_path(&default_path) {
921 eprintln!("Failed to load default project: {:?}", e);
922 }
923 }
924
925 app
926 }
927
928 fn settings(&self) -> WindowSettings {
929 let mut title = "CCE Graph".to_string();
930 if let Some(ref path) = self.loaded_project_path {
931 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
932 title.push_str(" - ");
933 title.push_str(filename);
934 }
935 }
936 WindowSettings {
937 title,
938 app_id: "cce-graph".to_string(),
939 width: 1024,
940 height: 768,
941 fullscreen: false,
942 min_size: Some((800, 600)),
943 }
944 }
945
946 fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
947 match msg {
948 AppMessage::New => {
949 self.new_project();
950 *needs_rebuild = true;
951 self.needs_rebuild = true;
952 }
953 AppMessage::Open => {
954 let sender = self.message_sender.clone();
955 std::thread::spawn(move || {
956 if let Some(path) = cce_ui::file_dialog::pick_file("Open CCE Graph Project", &[]) {
957 let _ = sender.send(AppMessage::OpenRecent(path));
958 }
959 });
960 }
961 AppMessage::OpenRecent(path) => {
962 if let Err(e) = self.load_project_from_path(&path) {
963 eprintln!("Failed to load project: {:?}", e);
964 } else {
965 self.add_recent_file(&path);
966 }
967 *needs_rebuild = true;
968 self.needs_rebuild = true;
969 }
970 AppMessage::Save => {
971 if let Some(path) = self.loaded_project_path.clone() {
972 if let Err(e) = self.save_project_to_path(&path) {
973 eprintln!("Failed to save project: {:?}", e);
974 } else {
975 self.add_recent_file(&path);
976 }
977 *needs_rebuild = true;
978 self.needs_rebuild = true;
979 } else {
980 let sender = self.message_sender.clone();
981 std::thread::spawn(move || {
982 if let Some(path) = cce_ui::file_dialog::save_file("Save CCE Graph Project", &[]) {
983 let _ = sender.send(AppMessage::SaveToPath(path));
984 }
985 });
986 }
987 }
988 AppMessage::SaveAs => {
989 let sender = self.message_sender.clone();
990 std::thread::spawn(move || {
991 if let Some(path) = cce_ui::file_dialog::save_file("Save CCE Graph Project As", &[]) {
992 let _ = sender.send(AppMessage::SaveToPath(path));
993 }
994 });
995 }
996 AppMessage::SaveToPath(path) => {
997 if let Err(e) = self.save_project_to_path(&path) {
998 eprintln!("Failed to save project: {:?}", e);
999 } else {
1000 self.add_recent_file(&path);
1001 }
1002 *needs_rebuild = true;
1003 self.needs_rebuild = true;
1004 }
1005 AppMessage::Exit => {
1006 *exit = true;
1007 }
1008 AppMessage::ToggleGrid => {
1009 self.show_grid = !self.show_grid;
1010 self.graph.set_show_network_grid(self.show_grid);
1011 self.update_view_options();
1012 write_config_value("graph_show_grid", &self.show_grid.to_string());
1013 *needs_rebuild = true;
1014 self.needs_rebuild = true;
1015 }
1016 AppMessage::ToggleUniformBackground => {
1017 self.uniform_background = !self.uniform_background;
1018 self.graph.set_uniform_background(self.uniform_background);
1019 self.update_view_options();
1020 write_config_value("style.surface.graph.uniform_background", &self.uniform_background.to_string());
1021 *needs_rebuild = true;
1022 self.needs_rebuild = true;
1023 }
1024 AppMessage::SetOpacity95 => {
1025 self.opacity = 0.95;
1026 self.graph.set_network_opacity(0.95);
1027 self.update_view_options();
1028 write_config_value("graph_network_opacity", "0.95");
1029 *needs_rebuild = true;
1030 self.needs_rebuild = true;
1031 }
1032 AppMessage::SetOpacity75 => {
1033 self.opacity = 0.75;
1034 self.graph.set_network_opacity(0.75);
1035 self.update_view_options();
1036 write_config_value("graph_network_opacity", "0.75");
1037 *needs_rebuild = true;
1038 self.needs_rebuild = true;
1039 }
1040 AppMessage::SetOpacity50 => {
1041 self.opacity = 0.50;
1042 self.graph.set_network_opacity(0.50);
1043 self.update_view_options();
1044 write_config_value("graph_network_opacity", "0.50");
1045 *needs_rebuild = true;
1046 self.needs_rebuild = true;
1047 }
1048 AppMessage::ToggleControlPanel => {
1049 self.show_control_panel = !self.show_control_panel;
1050 self.update_view_options();
1051 *needs_rebuild = true;
1052 self.needs_rebuild = true;
1053 }
1054 AppMessage::AddNode => {
1055 let mut nodes = self.graph.get_nodes();
1056 let next_id = nodes.len() + 1;
1057 nodes.push(GraphNode {
1058 id: String::new(),
1059 name: format!("Node {}", next_id),
1060 position: (2.0 + (next_id % 3) as f32, 2.0 + (next_id / 3) as f32),
1061 parameters: vec![],
1062 geom_visible: true,
1063 node_type: String::new(),
1064 inputs: 1,
1065 outputs: 1,
1066 });
1067 self.graph.set_nodes(&nodes);
1068 *needs_rebuild = true;
1069 self.needs_rebuild = true;
1070 }
1071 AppMessage::AddImage => {
1072 let sender = self.message_sender.clone();
1073 std::thread::spawn(move || {
1074 let exts: &[&str] = &["png", "jpg", "jpeg", "gif", "bmp"];
1075 let filters = [("Images", exts)];
1076 if let Some(path) = cce_ui::file_dialog::pick_file("Select Image", &filters) {
1077 let _ = sender.send(AppMessage::AddImageFromPath(path));
1078 }
1079 });
1080 }
1081 AppMessage::AddImageFromPath(path) => {
1082 if let Err(e) = self.add_image(&path) {
1083 eprintln!("Failed to add image: {:?}", e);
1084 }
1085 *needs_rebuild = true;
1086 self.needs_rebuild = true;
1087 }
1088 }
1089 }
1090
1091 fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
1092 // Pump the widget tick walk: animating widgets (the menu dropdowns'
1093 // expand/contract) register as tick receivers and report changed
1094 // until their transition lands — without this a closing menu freezes
1095 // fully open.
1096 if self.ui_context.tick(dt) {
1097 *needs_rebuild = true;
1098 self.needs_rebuild = true;
1099 }
1100 }
1101
1102 fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
1103 // Phase 6 single paint path: setup/relayout (the old view() body), then the whole
1104 // frame — the widget tree walked into one list plus the loaded images' pixel
1105 // quads — is built here. Widget text comes from the paint walk (Graph's node names
1106 // through its per-label hatch, the control panel via the 6i container-text fix).
1107 self.ui_context.clear_popovers();
1108
1109 let is_first_layout = !self.widgets_registered;
1110 if !self.widgets_registered {
1111 // The root plate container is DISSOLVED (Phase 6m recipe): top-level widgets register
1112 // directly (parentless), the window plate is emitted below as prims, and the two
1113 // Plates keep their own children.
1114 unsafe {
1115 let self_ptr = self as *mut Self;
1116
1117 self.ui_context.register_widget(self.menu_bar.id(), (*self_ptr).menu_bar.as_ptr_mut());
1118 self.ui_context.register_widget(self.dropdown_file.base().id(), (*self_ptr).dropdown_file.as_ptr_mut());
1119 self.ui_context.register_widget(self.dropdown_edit.base().id(), (*self_ptr).dropdown_edit.as_ptr_mut());
1120 self.ui_context.register_widget(self.dropdown_view.base().id(), (*self_ptr).dropdown_view.as_ptr_mut());
1121 // Registered under the widget's OWN base id (the id-rooted router resolves
1122 // dispatch roots through the registry; the old synthetic `graph_id` key left
1123 // `graph.id()` unresolvable — a latent hole the pointer router masked).
1124 self.ui_context.register_widget(self.graph.id(), (*self_ptr).graph.as_ptr_mut());
1125 self.ui_context.register_widget(self.control_panel_label.base().id(), (*self_ptr).control_panel_label.as_ptr_mut());
1126 }
1127 self.widgets_registered = true;
1128 }
1129
1130 // Check selected node and update control panel label
1131 let selected_node_idx = self.graph.selected_node();
1132 let label_text = if let Some(idx) = selected_node_idx {
1133 let nodes = self.graph.get_nodes();
1134 if let Some(node) = nodes.get(idx) {
1135 let mut info = format!("Selected Node:\nID: {}\nName: {}\nType: {}\nInputs: {}\nOutputs: {}",
1136 node.id, node.name, node.node_type, node.inputs, node.outputs
1137 );
1138 if !node.parameters.is_empty() {
1139 info.push_str("\n\nParameters:");
1140 for (name, val, p_type) in &node.parameters {
1141 info.push_str(&format!("\n- {}: {} ({})", name, val, p_type));
1142 }
1143 }
1144 info
1145 } else {
1146 "No Object Selected".to_string()
1147 }
1148 } else if let Some(img_idx) = self.selected_image_idx {
1149 if let Some(img) = self.loaded_images.get(img_idx) {
1150 let filename = std::path::Path::new(&img.path)
1151 .file_name()
1152 .and_then(|f| f.to_str())
1153 .unwrap_or(&img.path);
1154 format!(
1155 "Selected Image:\nName: {}\nPosition: (Col {:.1}, Row {:.1})\nSize: {:.1} x {:.1} cells\nResolution: {} x {} px",
1156 filename, img.position.0, img.position.1,
1157 img.size.0, img.size.1,
1158 img.pixel_width, img.pixel_height
1159 )
1160 } else {
1161 "No Object Selected".to_string()
1162 }
1163 } else {
1164 "No Object Selected".to_string()
1165 };
1166
1167 let text_changed = {
1168 let current_text = self.control_panel_label.base().label.as_ref();
1169 current_text != Some(&label_text)
1170 };
1171 if text_changed {
1172 self.control_panel_label.set_text(&label_text);
1173 self.needs_rebuild = true;
1174 }
1175
1176 if self.dropdown_file.popover_rect().is_some() {
1177 // ui_context ONLY (the 6l pattern): the popover is drawn in the display list by
1178 // the walk; a global registration spawns a render-only xdg popup that swallows
1179 // clicks on the open menu.
1180 self.ui_context.register_popover(&mut self.dropdown_file);
1181 }
1182 if self.dropdown_edit.popover_rect().is_some() {
1183 // ui_context ONLY (the 6l pattern): the popover is drawn in the display list by
1184 // the walk; a global registration spawns a render-only xdg popup that swallows
1185 // clicks on the open menu.
1186 self.ui_context.register_popover(&mut self.dropdown_edit);
1187 }
1188 if self.dropdown_view.popover_rect().is_some() {
1189 // ui_context ONLY (the 6l pattern): the popover is drawn in the display list by
1190 // the walk; a global registration spawns a render-only xdg popup that swallows
1191 // clicks on the open menu.
1192 self.ui_context.register_popover(&mut self.dropdown_view);
1193 }
1194
1195 let size_changed = self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale;
1196 if self.needs_rebuild || size_changed || is_first_layout {
1197 self.width = size.width as u32;
1198 self.height = size.height as u32;
1199 self.scale_factor = scale;
1200
1201 // Layout MenuBar at the top
1202 self.menu_bar.set_rect(0.0, 0.0, size.width, 42.0);
1203
1204 // The File/Edit/View dropdown row, laid out directly (the transparent layout
1205 // Plate is DISSOLVED): a row at x=10/y=8 with 10px gaps, each dropdown sized
1206 // to its label via measure (the same intrinsic sizes the scene solver used).
1207 {
1208 let mut x = 10.0;
1209 let self_ptr = self as *mut Self;
1210 let dds: [&mut cce_ui::widget::Adapted<Dropdown>; 3] = unsafe {
1211 [&mut (*self_ptr).dropdown_file, &mut (*self_ptr).dropdown_edit, &mut (*self_ptr).dropdown_view]
1212 };
1213 for dd in dds {
1214 // Same sizing rule the retired scene bridge used: the dropdown's intrinsic
1215 // size (widest option x configured dropdown height).
1216 let sz = dd.intrinsic_size()
1217 .unwrap_or(cce_ui::scene::layout::Size::new(70.0, 26.0));
1218 dd.set_rect(x, 8.0, sz.width, sz.height);
1219 x += sz.width + 10.0;
1220 }
1221 }
1222
1223 // Layout Graph below MenuBar
1224 self.graph.set_rect(0.0, 42.0, size.width, size.height - 42.0);
1225
1226 // Initial control panel positioning (bounds are clamped at drag time)
1227 if size_changed || is_first_layout {
1228 self.panel_x = (size.width - 230.0).max(10.0);
1229 self.panel_y = 55.0; // Float below MenuBar
1230 }
1231 self.position_panel_label();
1232
1233 self.needs_rebuild = false;
1234
1235 self.ui_context.rebuild_spatial_grid();
1236 }
1237
1238 // 1. The dissolved root plate container's plate, then the top-level widgets walked in the
1239 // old child order (menu bar, dropdown row, graph canvas, control panel on top).
1240 let mut pc = cce_ui::scene::paint::PaintCtx::new();
1241 // The standard root plate (cce-ui PlateSpec::window).
1242 pc.root_plate(self.width as f32, self.height as f32);
1243 {
1244 // The walk takes shared borrows now — no self-alias, no pointers.
1245 let tops: [&dyn cce_ui::widget::WidgetHost; 5] = [
1246 &self.menu_bar,
1247 &self.dropdown_file,
1248 &self.dropdown_edit,
1249 &self.dropdown_view,
1250 &self.graph,
1251 ];
1252 for top in tops {
1253 cce_ui::scene::painter::paint_root_into(&self.ui_context, top, &mut pc);
1254 }
1255 }
1256
1257 // The dissolved control panel, on top: its plate as prims, then the label walked.
1258 if self.show_control_panel {
1259 use cce_ui::scene::layout::Rect;
1260 let (px, py, pw, ph) = self.panel_rect();
1261 let rect = Rect { x: px, y: py, width: pw, height: ph };
1262 let (fill, border, radius) = self.panel_visual();
1263 let radii = (radius, radius, radius, radius);
1264 if let Some((bc, thickness)) = border {
1265 pc.border(rect, radii, fill, bc, thickness);
1266 } else if fill[3].abs() > 0.001 {
1267 if radius > 0.1 {
1268 pc.rounded_rect(rect, radius, (true, true, true, true), fill);
1269 } else {
1270 pc.quad(rect, fill);
1271 }
1272 }
1273 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.control_panel_label, &mut pc);
1274 }
1275
1276 // Draw foreground images in the graph grid (above nodes, fully opaque, preserving aspect ratio)
1277 let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
1278 let (grid_size_x, grid_size_y) = self.graph.grid_sizes();
1279 let (skipped_row_h, skipped_col_w) = self.graph.skipped_sizes();
1280
1281 let step_x = grid_size_x + skipped_col_w;
1282 let step_y = grid_size_y + skipped_row_h;
1283
1284 let (graph_x, graph_y, graph_w, graph_h) = self.graph.rect();
1285 let min_x = graph_x;
1286 let min_y = graph_y;
1287 let max_x = graph_x + graph_w;
1288 let max_y = graph_y + graph_h;
1289
1290 let push_clipped = |qx: f32, qy: f32, qw: f32, qh: f32, qc: [f32; 4], q: &mut cce_ui::scene::paint::PaintCtx| {
1291 let rx1 = qx.max(min_x);
1292 let ry1 = qy.max(min_y);
1293 let rx2 = (qx + qw).min(max_x);
1294 let ry2 = (qy + qh).min(max_y);
1295 let rw = rx2 - rx1;
1296 let rh = ry2 - ry1;
1297 if rw > 0.0 && rh > 0.0 {
1298 q.quad(cce_ui::scene::layout::Rect { x: rx1, y: ry1, width: rw, height: rh }, qc);
1299 }
1300 };
1301
1302 for (i, img) in self.loaded_images.iter().enumerate() {
1303 let col = img.position.0;
1304 let row = img.position.1;
1305
1306 let screen_x = grid_origin_x + col * step_x;
1307 let screen_y = grid_origin_y + row * step_y;
1308
1309 let screen_w = img.size.0 * grid_size_x + (img.size.0 - 1.0).max(0.0) * skipped_col_w;
1310 let aspect = img.pixel_height as f32 / img.pixel_width as f32;
1311 let screen_h = screen_w * aspect;
1312
1313 let px_w = screen_w / img.pixel_width as f32;
1314 let px_h = screen_h / img.pixel_height as f32;
1315
1316 for y in 0..img.pixel_height {
1317 for x in 0..img.pixel_width {
1318 let idx = (y * img.pixel_width + x) as usize;
1319 let rgba = img.pixels[idx];
1320 let r = rgba[0] as f32 / 255.0;
1321 let g = rgba[1] as f32 / 255.0;
1322 let b = rgba[2] as f32 / 255.0;
1323 let a = rgba[3] as f32 / 255.0;
1324
1325 let px_x = screen_x + (x as f32) * px_w;
1326 let px_y = screen_y + (y as f32) * px_h;
1327
1328 push_clipped(px_x, px_y, px_w + 0.5, px_h + 0.5, [r, g, b, a], &mut pc);
1329 }
1330 }
1331
1332 if Some(i) == self.selected_image_idx {
1333 let border_thickness = 2.0;
1334 let border_color = [0.0, 0.75, 1.0, 1.0]; // Vibrant cyan selection outline
1335
1336 // Top border
1337 push_clipped(screen_x - border_thickness, screen_y - border_thickness, screen_w + 2.0 * border_thickness, border_thickness, border_color, &mut pc);
1338 // Bottom border
1339 push_clipped(screen_x - border_thickness, screen_y + screen_h, screen_w + 2.0 * border_thickness, border_thickness, border_color, &mut pc);
1340 // Left border
1341 push_clipped(screen_x - border_thickness, screen_y, border_thickness, screen_h, border_color, &mut pc);
1342 // Right border
1343 push_clipped(screen_x + screen_w, screen_y, border_thickness, screen_h, border_color, &mut pc);
1344 }
1345 }
1346
1347 // Open dropdown popovers — geometry and labels last, on top of everything, exactly
1348 // where they hit-test (the ui_context registration above is occlusion/routing only;
1349 // nothing else paints them). Labels carry bounds equal to the popover rect, which
1350 // clips them to the plate and exempts them from the dl-text occlusion clamp.
1351 {
1352 for &pop_id in &self.ui_context.active_popovers {
1353 let Some(pop_ptr) = self.ui_context.tree.get_ptr(pop_id) else { continue };
1354 let popover = unsafe { &*pop_ptr };
1355 if popover.popover_rect().is_none() {
1356 continue;
1357 }
1358 // PaintCtx is a RenderTarget: the popover draws its real prims
1359 // (the dropdown's expanded inset-plate surface) with its own
1360 // per-label bounds — no flattening collector round-trip.
1361 popover.render_popover(&mut pc);
1362 }
1363 // The lit plate and the menu font in one call — the flat
1364 // `extra_quads` look was the pre-frost menu the other apps
1365 // have moved off.
1366 cce_ui::widget::context_menu::paint_with_labels(&mut pc);
1367 }
1368
1369 Some(pc.finish())
1370 }
1371
1372 fn display_list_text(&self) -> bool {
1373 true
1374 }
1375
1376 fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1377 let mut changed = false;
1378
1379 // The global config context menu (right-click on a dropdown) gets the pointer
1380 // exclusively while open — same priority the popovers get below.
1381 if cce_ui::widget::context_menu::is_visible() {
1382 if cce_ui::widget::context_menu::cursor_moved(pos.x, pos.y) {
1383 *needs_rebuild = true;
1384 self.needs_rebuild = true;
1385 }
1386 return;
1387 }
1388
1389 let over_menu = self.dropdown_file.open || self.dropdown_edit.open || self.dropdown_view.open
1390 || self.dropdown_file.hit_test(pos.x, pos.y, &self.ui_context)
1391 || self.dropdown_edit.hit_test(pos.x, pos.y, &self.ui_context)
1392 || self.dropdown_view.hit_test(pos.x, pos.y, &self.ui_context)
1393 || self.menu_bar.hit_test(pos.x, pos.y, &self.ui_context);
1394
1395 // Routed dispatch (6bd shrink): one Event through the router per root; the panel
1396 // and loaded-image drags stay app-owned (they are not widgets).
1397 let mv = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
1398 if over_menu {
1399 let dd_roots = [self.dropdown_file.id(), self.dropdown_edit.id(), self.dropdown_view.id()];
1400 for root in dd_roots {
1401 if self.ui_context.propagate_event(&mv, root) { changed = true; }
1402 }
1403 } else {
1404 let mut handled_by_panel = false;
1405 if self.show_control_panel {
1406 if self.panel_dragging {
1407 // Dissolved Plate drag: move within the graph area's bounds.
1408 let w = self.width as f32;
1409 let h = self.height as f32;
1410 let nx = (pos.x - self.panel_drag_ox).clamp(0.0, (w - 210.0).max(0.0));
1411 let ny = (pos.y - self.panel_drag_oy).clamp(42.0, (42.0 + (h - 42.0) - 160.0).max(42.0));
1412 if (nx - self.panel_x).abs() > 0.01 || (ny - self.panel_y).abs() > 0.01 {
1413 self.panel_x = nx;
1414 self.panel_y = ny;
1415 self.position_panel_label();
1416 changed = true;
1417 }
1418 handled_by_panel = true;
1419 } else if self.panel_hit(pos.x, pos.y) {
1420 handled_by_panel = true;
1421 }
1422 }
1423
1424 if !handled_by_panel {
1425 // Otherwise feed it to Graph
1426 if let Some(img_idx) = self.dragging_image_idx {
1427 let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
1428 let (grid_size_x, grid_size_y) = self.graph.grid_sizes();
1429 let (skipped_row_h, skipped_col_w) = self.graph.skipped_sizes();
1430 let step_x = grid_size_x + skipped_col_w;
1431 let step_y = grid_size_y + skipped_row_h;
1432
1433 let nx = pos.x - self.drag_image_ox;
1434 let ny = pos.y - self.drag_image_oy;
1435
1436 let mut col = (nx - grid_origin_x) / step_x;
1437 let mut row = (ny - grid_origin_y) / step_y;
1438
1439 if self.graph.grid_snap_enabled() {
1440 col = (col * 2.0).round() / 2.0;
1441 row = (row * 2.0).round() / 2.0;
1442 }
1443
1444 if let Some(img) = self.loaded_images.get_mut(img_idx) {
1445 if (img.position.0 - col).abs() > 0.001 || (img.position.1 - row).abs() > 0.001 {
1446 img.position = (col, row);
1447 changed = true;
1448 }
1449 }
1450 } else {
1451 // The router forwards DragUpdate to a mid-drag node grab; a plain move
1452 // runs the hover recompute. Node positions change without the propagate
1453 // reporting it — rebuild every move while a drag is live.
1454 let g = self.graph.id();
1455 if self.ui_context.propagate_event(&mv, g) {
1456 changed = true;
1457 }
1458 if self.ui_context.is_dragging {
1459 changed = true;
1460 }
1461 }
1462 } else {
1463 let clear = Event::PointerMove { x: -1000.0, y: -1000.0, local_x: -1000.0, local_y: -1000.0 };
1464 let g = self.graph.id();
1465 if self.ui_context.propagate_event(&clear, g) { changed = true; }
1466 }
1467
1468 // Clear hover states if cursor moved away
1469 let dd_roots = [self.dropdown_file.id(), self.dropdown_edit.id(), self.dropdown_view.id()];
1470 for root in dd_roots {
1471 if self.ui_context.propagate_event(&mv, root) { changed = true; }
1472 }
1473 }
1474
1475 if changed {
1476 *needs_rebuild = true;
1477 self.needs_rebuild = true;
1478 }
1479 }
1480
1481 fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
1482 let mut changed = false;
1483 let mut msg_out = None;
1484
1485 // An open config context menu swallows the click (select or dismiss) before any
1486 // widget routing.
1487 if cce_ui::widget::context_menu::is_visible() {
1488 if cce_ui::widget::context_menu::mouse_input(button, state, pos.x, pos.y, Some(&mut self.ui_context)) {
1489 *needs_rebuild = true;
1490 self.needs_rebuild = true;
1491 }
1492 return None;
1493 }
1494
1495 let over_menu = self.dropdown_file.open || self.dropdown_edit.open || self.dropdown_view.open
1496 || self.dropdown_file.hit_test(pos.x, pos.y, &self.ui_context)
1497 || self.dropdown_edit.hit_test(pos.x, pos.y, &self.ui_context)
1498 || self.dropdown_view.hit_test(pos.x, pos.y, &self.ui_context)
1499 || self.menu_bar.hit_test(pos.x, pos.y, &self.ui_context);
1500
1501 let ev = Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
1502 if over_menu {
1503 let dd_file = self.dropdown_file.id();
1504 if self.ui_context.propagate_event(&ev, dd_file) {
1505 changed = true;
1506 if let Some(m) = self.drain_file_menu() {
1507 msg_out = Some(m);
1508 }
1509 }
1510 let dd_edit = self.dropdown_edit.id();
1511 if self.ui_context.propagate_event(&ev, dd_edit) {
1512 changed = true;
1513 if let Some(m) = self.drain_edit_menu() {
1514 msg_out = Some(m);
1515 }
1516 }
1517 let dd_view = self.dropdown_view.id();
1518 if self.ui_context.propagate_event(&ev, dd_view) {
1519 changed = true;
1520 if let Some(m) = self.drain_view_menu() {
1521 msg_out = Some(m);
1522 }
1523 }
1524
1525 // Outside-press dismissal. The engine already sweeps open popovers
1526 // before app dispatch (close_popovers_missed_by_press), but only for
1527 // Left — and Dropdown's own handler matches Left only too, so other
1528 // buttons would leave an open menu stranded. Run the same sweep for
1529 // those. Forcing `open = false` here instead (as this used to) skips
1530 // the contract animation entirely: both `Paint::popover` and
1531 // `draw_popover` gate on `open`, so the menu vanished in one frame
1532 // while `closing` ran on invisibly.
1533 if state == ElementState::Pressed && button != MouseButton::Left {
1534 self.ui_context.close_popovers_missed_by_press(pos.x, pos.y);
1535 }
1536 } else {
1537 let mut handled_by_panel = false;
1538 if self.show_control_panel && (self.panel_dragging || self.panel_hit(pos.x, pos.y)) {
1539 if button == MouseButton::Left {
1540 match state {
1541 ElementState::Pressed => {
1542 self.panel_dragging = true;
1543 self.panel_drag_ox = pos.x - self.panel_x;
1544 self.panel_drag_oy = pos.y - self.panel_y;
1545 changed = true;
1546 }
1547 ElementState::Released => {
1548 if self.panel_dragging {
1549 self.panel_dragging = false;
1550 changed = true;
1551 }
1552 }
1553 }
1554 }
1555 handled_by_panel = true;
1556 }
1557
1558 if !handled_by_panel {
1559 // Otherwise route to Graph
1560 if button == MouseButton::Left {
1561 if state == ElementState::Pressed {
1562 // Routed press: a node grab records the drag target; the router
1563 // synthesizes DragStart past its threshold (the old immediate
1564 // drag_begin call).
1565 let g = self.graph.id();
1566 if self.ui_context.propagate_event(&ev, g) {
1567 self.selected_image_idx = None;
1568 changed = true;
1569 } else if let Some(img_idx) = self.hit_test_image(pos.x, pos.y) {
1570 let img = &self.loaded_images[img_idx];
1571 let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
1572 let (grid_size_x, grid_size_y) = self.graph.grid_sizes();
1573 let (skipped_row_h, skipped_col_w) = self.graph.skipped_sizes();
1574 let step_x = grid_size_x + skipped_col_w;
1575 let step_y = grid_size_y + skipped_row_h;
1576
1577 let img_screen_x = grid_origin_x + img.position.0 * step_x;
1578 let img_screen_y = grid_origin_y + img.position.1 * step_y;
1579
1580 self.dragging_image_idx = Some(img_idx);
1581 self.drag_image_ox = pos.x - img_screen_x;
1582 self.drag_image_oy = pos.y - img_screen_y;
1583 self.selected_image_idx = Some(img_idx);
1584 self.graph.set_selected_node(None);
1585 changed = true;
1586 } else {
1587 self.selected_image_idx = None;
1588 self.graph.set_selected_node(None);
1589 changed = true;
1590 }
1591 } else if state == ElementState::Released {
1592 if self.dragging_image_idx.is_some() {
1593 self.dragging_image_idx = None;
1594 changed = true;
1595 } else {
1596 // The router delivers DragEnd (commit) before the release
1597 // reaches Graph; a committed drag leaves the release arm inert.
1598 let was_dragging = self.ui_context.is_dragging;
1599 let g = self.graph.id();
1600 if self.ui_context.propagate_event(&ev, g) || was_dragging {
1601 changed = true;
1602 }
1603 }
1604 }
1605 }
1606 }
1607 }
1608
1609 if changed {
1610 *needs_rebuild = true;
1611 self.needs_rebuild = true;
1612 }
1613
1614 msg_out
1615 }
1616
1617 fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
1618 let ev = Event::MouseWheel { delta: *delta, x: pos.x as f32, y: pos.y as f32, local_x: pos.x as f32, local_y: pos.y as f32 };
1619 let g = self.graph.id();
1620 if self.ui_context.propagate_event(&ev, g) {
1621 *needs_rebuild = true;
1622 self.needs_rebuild = true;
1623 }
1624 }
1625
1626 fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
1627 if event.state == ElementState::Pressed && cce_ui::widget::context_menu::is_visible() {
1628 cce_ui::widget::context_menu::hide();
1629 *needs_rebuild = true;
1630 self.needs_rebuild = true;
1631 return None;
1632 }
1633
1634 // An open menu dropdown takes the keyboard — Escape closes it, arrows
1635 // move the hover, Enter selects — routed to the widget exactly like
1636 // its mouse events above, drained through the same helpers. The
1637 // widget has handled these keys itself since cce-ui's routed events;
1638 // this app just never forwarded a key to it (the cce-files bug).
1639 if self.dropdown_file.open || self.dropdown_edit.open || self.dropdown_view.open {
1640 let kev = Event::KeyInput(event.clone());
1641 let root = if self.dropdown_file.open {
1642 self.dropdown_file.id()
1643 } else if self.dropdown_edit.open {
1644 self.dropdown_edit.id()
1645 } else {
1646 self.dropdown_view.id()
1647 };
1648 if self.ui_context.propagate_event(&kev, root) {
1649 *needs_rebuild = true;
1650 self.needs_rebuild = true;
1651 return self
1652 .drain_file_menu()
1653 .or_else(|| self.drain_edit_menu())
1654 .or_else(|| self.drain_view_menu());
1655 }
1656 }
1657
1658 if event.state == ElementState::Pressed {
1659 // input.kdl `cce-graph.delete_node`, falling back to the legacy
1660 // config.kdl graph `delete` prop.
1661 let delete_keybind = cce_ui::input::app_chord("delete_node", &cce_ui::layout::graph_node_delete());
1662 if matches_keybind(event, &delete_keybind) {
1663 self.delete_selected_node();
1664 *needs_rebuild = true;
1665 self.needs_rebuild = true;
1666 }
1667 }
1668 None
1669 }
1670 }
1671
1672 fn load_config() -> (bool, bool, bool, f32, f32) {
1673 let path = cce_ui::config::get_config_path();
1674 let content = std::fs::read_to_string(&path).unwrap_or_default();
1675 let val = cce_ui::config::parse_kdl_to_json(&content);
1676
1677 let show_grid = val.pointer("/layout/graph_show_grid").and_then(|v| v.as_bool()).unwrap_or(true);
1678 let snap_enabled = val.pointer("/layout/graph_snap_enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1679 let uniform_background = val.pointer("/style/surface/graph/uniform_background").and_then(|v| v.as_bool()).unwrap_or(false);
1680 let opacity = val.pointer("/layout/graph_network_opacity").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(0.95);
1681 let gap_width = val.pointer("/layout/graph_gap_width").and_then(|v| v.as_f64()).map(|n| n as f32).unwrap_or(35.0);
1682
1683 (show_grid, snap_enabled, uniform_background, opacity, gap_width)
1684 }
1685
1686 fn write_config_value(key: &str, value: &str) -> bool {
1687 let path = cce_ui::config::get_config_path();
1688 let path_str = path.to_string_lossy();
1689 cce_ui::config::write_config_value(&path_str, key, value, "layout")
1690 }
1691
1692 fn main() {
1693 let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
1694 let _guard = rt.enter();
1695
1696 cce_ui::engine::run::<GraphApp>();
1697 }