git.lucas.co / cce-graph
node-based graph editor
git clone https://git.lucas.co/cce-graph.git

src/main.rs (70.1K)

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