git.lucas.co / cce-graph
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 }