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