git.lucas.co / cce-data-editor
structured data editor
git clone https://git.lucas.co/cce-data-editor.git

src/main.rs (160.5K)

   1 use cce_ui::widget::Handle;
   2 use cce_ui::cosmic_text::FontSystem;
   3 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
   4 use cce_ui::widget::{
   5     MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost,
   6     TextBox, Button, Key, TreeList, TreeElement, ColorSelector, Spinbox, FontSelector, Dropdown,
   7     KeybindRecorder, MenuBar, StatusBar, Checkbox
   8 };
   9 
  10 #[derive(Debug, Clone)]
  11 enum AppMessage {
  12     Exit,
  13     OpenDocument,
  14     OpenRecent(std::path::PathBuf),
  15     SaveDocument,
  16     SaveDocumentAs,
  17     FormatJson,
  18     RefreshDocument,
  19     ApplyValue,
  20     DeleteKey,
  21     /// File-picker results, sent from the picker thread over the engine's
  22     /// message channel — the portal dialog must not block the event loop (the
  23     /// dropdown's contraction has to animate while the dialog is up).
  24     OpenPicked(Option<std::path::PathBuf>),
  25     SaveAsPicked(Option<std::path::PathBuf>),
  26 }
  27 
  28 #[derive(Debug, Clone)]
  29 enum PathToken {
  30     Key(String),
  31     Index(usize),
  32 }
  33 
  34 fn parse_path(path: &str) -> Vec<PathToken> {
  35     let mut tokens = Vec::new();
  36     for part in path.split('.') {
  37         if part.is_empty() { continue; }
  38         if let Some(bracket_idx) = part.find('[') {
  39             let name = &part[..bracket_idx];
  40             if !name.is_empty() {
  41                 tokens.push(PathToken::Key(name.to_string()));
  42             }
  43             let mut rest = &part[bracket_idx..];
  44             while let Some(start) = rest.find('[') {
  45                 if let Some(end) = rest.find(']') {
  46                     let idx_str = &rest[start + 1..end];
  47                     if let Ok(idx) = idx_str.parse::<usize>() {
  48                         tokens.push(PathToken::Index(idx));
  49                     }
  50                     rest = &rest[end + 1..];
  51                 } else {
  52                     break;
  53                 }
  54             }
  55         } else {
  56             tokens.push(PathToken::Key(part.to_string()));
  57         }
  58     }
  59     tokens
  60 }
  61 
  62 /// How often the open file is compared against its on-disk state. The runner
  63 /// wakes an idle app once a second by itself, so this costs no extra frames;
  64 /// `idle_poll_interval` below pins that cadence rather than inheriting it.
  65 const DISK_POLL_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
  66 
  67 /// KDL spans are byte offsets; the TextBox cursor/selection are char indices.
  68 /// Convert one to the other (clamping past-the-end offsets to the char count).
  69 fn byte_to_char_idx(content: &str, byte_idx: usize) -> usize {
  70     content
  71         .char_indices()
  72         .take_while(|(b, _)| *b < byte_idx)
  73         .count()
  74 }
  75 
  76 fn char_to_byte_idx(content: &str, char_idx: usize) -> usize {
  77     content
  78         .char_indices()
  79         .nth(char_idx)
  80         .map(|(b, _)| b)
  81         .unwrap_or(content.len())
  82 }
  83 
  84 /// The span of everything a node says after its name: every positional
  85 /// argument, not just the first. The joined numeric multi-arg form is one
  86 /// value — `(vec2i)100 200` flattens to the single string "100 200" — so a
  87 /// span stopping after `100` highlighted half of it. Falls back to the first
  88 /// entry (a node carrying only properties) and then to the node itself (a
  89 /// node carrying nothing, e.g. a bare section name).
  90 fn node_value_span(node: &kdl::KdlNode) -> (usize, usize) {
  91     let bounds = |e: &kdl::KdlEntry| {
  92         let sp = e.span();
  93         (sp.offset(), sp.offset() + sp.len())
  94     };
  95     let mut positional = node.entries().iter().filter(|e| e.name().is_none());
  96     if let Some(first) = positional.next() {
  97         let (start, mut end) = bounds(first);
  98         for entry in positional {
  99             end = bounds(entry).1;
 100         }
 101         return (start, end);
 102     }
 103     node.entries().first().map(bounds).unwrap_or_else(|| {
 104         let sp = node.span();
 105         (sp.offset(), sp.offset() + sp.len())
 106     })
 107 }
 108 
 109 /// The spans of the array elements one node contributes to its name's
 110 /// flattened value, in order. Mirrors `kdl_to_json`: a multi-argument node
 111 /// whose arguments are all strings (`bevel_apps "a" "b"`) is a LIST, one
 112 /// element per argument; everything else — a lone argument, the joined
 113 /// numeric multi-arg form (`(vec2i)100 200`), a node carrying properties —
 114 /// is a single element. Repeated nodes of the same name concatenate, so the
 115 /// caller walks them in document order.
 116 fn node_element_spans(node: &kdl::KdlNode) -> Vec<(usize, usize)> {
 117     let span_of = |s: &kdl::KdlEntry| {
 118         let sp = s.span();
 119         (sp.offset(), sp.offset() + sp.len())
 120     };
 121     let entries = node.entries();
 122     let has_props = entries.iter().any(|e| e.name().is_some());
 123     if !has_props
 124         && entries.len() > 1
 125         && entries
 126             .iter()
 127             .all(|e| matches!(e.value(), kdl::KdlValue::String(_) | kdl::KdlValue::RawString(_)))
 128     {
 129         return entries.iter().map(span_of).collect();
 130     }
 131     vec![node_value_span(node)]
 132 }
 133 
 134 fn find_kdl_span(content: &str, tokens: &[PathToken]) -> Option<(usize, usize)> {
 135     let doc = content.parse::<kdl::KdlDocument>().ok()?;
 136     find_kdl_span_in_doc(&doc, tokens)
 137 }
 138 
 139 fn find_kdl_span_in_doc(doc: &kdl::KdlDocument, tokens: &[PathToken]) -> Option<(usize, usize)> {
 140     if tokens.is_empty() {
 141         return None;
 142     }
 143     let next_token = &tokens[0];
 144     match next_token {
 145         PathToken::Key(target_key) => {
 146             let target_idx = if tokens.len() > 1 {
 147                 if let PathToken::Index(idx) = &tokens[1] {
 148                     Some(*idx)
 149                 } else {
 150                     None
 151                 }
 152             } else {
 153                 None
 154             };
 155 
 156             let nodes: Vec<&kdl::KdlNode> = doc.nodes().iter().filter(|n| n.name().value() == target_key).collect();
 157             if let Some(idx) = target_idx {
 158                 let node = nodes.first()?;
 159                 if let Some(children) = node.children() {
 160                     let child_node = children.nodes().get(idx)?;
 161                     if tokens.len() == 2 {
 162                         let span = child_node.span();
 163                         return Some((span.offset(), span.offset() + span.len()));
 164                     } else if tokens.len() == 3 {
 165                         if let PathToken::Key(prop_key) = &tokens[2] {
 166                             if let Some(entry) = child_node.entries().iter().find(|e| e.name().map(|id| id.value()) == Some(prop_key)) {
 167                                 let span = entry.span();
 168                                 return Some((span.offset(), span.offset() + span.len()));
 169                             }
 170                         }
 171                     }
 172                 } else if tokens.len() == 2 {
 173                     // The index addresses an element of the flattened value,
 174                     // which is not the same as the n-th node: one node can
 175                     // contribute several (a string list), and several nodes
 176                     // concatenate. Walking the elements is what finds the
 177                     // second entry of `bevel_apps "a" "b"` — indexing nodes
 178                     // found nothing past [0], and answered [0] with the whole
 179                     // line.
 180                     return nodes.iter().flat_map(|n| node_element_spans(n)).nth(idx);
 181                 } else {
 182                     // A deeper path means the element is an object, and only
 183                     // a node with properties is one — so here the index IS
 184                     // the node index.
 185                     let node = nodes.get(idx)?;
 186                     if let PathToken::Key(prop_key) = &tokens[2] {
 187                         if let Some(entry) = node.entries().iter().find(|e| e.name().map(|id| id.value()) == Some(prop_key)) {
 188                             let span = entry.span();
 189                             return Some((span.offset(), span.offset() + span.len()));
 190                         }
 191                     }
 192                 }
 193             } else {
 194                 let node = nodes.first()?;
 195                 if tokens.len() == 1 {
 196                     return Some(node_value_span(node));
 197                 } else if tokens.len() == 2 {
 198                     if let PathToken::Key(prop_key) = &tokens[1] {
 199                         if let Some(entry) = node.entries().iter().find(|e| e.name().map(|id| id.value()) == Some(prop_key)) {
 200                             let span = entry.span();
 201                             return Some((span.offset(), span.offset() + span.len()));
 202                         }
 203                     }
 204                     if let Some(children) = node.children() {
 205                         if let Some(span) = find_kdl_span_in_doc(children, &tokens[1..]) {
 206                             return Some(span);
 207                         }
 208                     }
 209                 } else if let Some(children) = node.children() {
 210                     return find_kdl_span_in_doc(children, &tokens[1..]);
 211                 }
 212             }
 213         }
 214         PathToken::Index(_) => {}
 215     }
 216     None
 217 }
 218 
 219 
 220 fn insert_value(target: &mut serde_json::Value, tokens: &[PathToken], value: serde_json::Value) {
 221     if tokens.is_empty() {
 222         *target = value;
 223         return;
 224     }
 225     
 226     match &tokens[0] {
 227         PathToken::Key(key) => {
 228             if !target.is_object() {
 229                 *target = serde_json::Value::Object(serde_json::Map::new());
 230             }
 231             let map = target.as_object_mut().unwrap();
 232             
 233             let next_default = if tokens.len() > 1 {
 234                 match &tokens[1] {
 235                     PathToken::Key(_) => serde_json::Value::Object(serde_json::Map::new()),
 236                     PathToken::Index(_) => serde_json::Value::Array(Vec::new()),
 237                 }
 238             } else {
 239                 serde_json::Value::Null
 240             };
 241             
 242             let entry = map.entry(key.clone()).or_insert(next_default);
 243             insert_value(entry, &tokens[1..], value);
 244         }
 245         PathToken::Index(idx) => {
 246             if !target.is_array() {
 247                 *target = serde_json::Value::Array(Vec::new());
 248             }
 249             let arr = target.as_array_mut().unwrap();
 250             
 251             let next_default = if tokens.len() > 1 {
 252                 match &tokens[1] {
 253                     PathToken::Key(_) => serde_json::Value::Object(serde_json::Map::new()),
 254                     PathToken::Index(_) => serde_json::Value::Array(Vec::new()),
 255                 }
 256             } else {
 257                 serde_json::Value::Null
 258             };
 259             
 260             while arr.len() <= *idx {
 261                 arr.push(next_default.clone());
 262             }
 263             insert_value(&mut arr[*idx], &tokens[1..], value);
 264         }
 265     }
 266 }
 267 
 268 fn flatten_json(value: &serde_json::Value, prefix: &str, out: &mut Vec<(String, serde_json::Value)>) {
 269     match value {
 270         serde_json::Value::Object(map) => {
 271             if map.is_empty() {
 272                 out.push((prefix.to_string(), value.clone()));
 273             } else {
 274                 for (k, v) in map {
 275                     let new_prefix = if prefix.is_empty() {
 276                         k.clone()
 277                     } else {
 278                         format!("{}.{}", prefix, k)
 279                     };
 280                     flatten_json(v, &new_prefix, out);
 281                 }
 282             }
 283         }
 284         serde_json::Value::Array(arr) => {
 285             if arr.is_empty() {
 286                 out.push((prefix.to_string(), value.clone()));
 287             } else {
 288                 for (i, v) in arr.iter().enumerate() {
 289                     let new_prefix = if prefix.is_empty() {
 290                         format!("[{}]", i)
 291                     } else {
 292                         format!("{}[{}]", prefix, i)
 293                     };
 294                     flatten_json(v, &new_prefix, out);
 295                 }
 296             }
 297         }
 298         _ => {
 299             out.push((prefix.to_string(), value.clone()));
 300         }
 301     }
 302 }
 303 
 304 fn unflatten_json(flat: &[(String, serde_json::Value)]) -> serde_json::Value {
 305     let mut root = serde_json::Value::Null;
 306     for (path, val) in flat {
 307         let tokens = parse_path(path);
 308         insert_value(&mut root, &tokens, val.clone());
 309     }
 310     root
 311 }
 312 
 313 
 314 /// App-owned two-pane horizontal split replacing the dissolved `SplitBox`
 315 /// (Phase 6aa; the 6y recipe): divider quad, hover tint, and the proportion drag —
 316 /// `SplitBox`'s two-child horizontal math verbatim. The panes (tree list, raw editor)
 317 /// are positioned directly from the pane rects and walked as separate roots.
 318 struct SplitPane {
 319     x: f32,
 320     y: f32,
 321     w: f32,
 322     h: f32,
 323     frac: f32,
 324     min_left: f32,
 325     min_right: f32,
 326     gap: f32,
 327     dragging: bool,
 328     hovered: bool,
 329 }
 330 
 331 impl SplitPane {
 332     fn new(frac: f32, min_left: f32, min_right: f32, gap: f32) -> Self {
 333         Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, frac, min_left, min_right, gap, dragging: false, hovered: false }
 334     }
 335 
 336     fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
 337         self.x = x;
 338         self.y = y;
 339         self.w = w;
 340         self.h = h;
 341     }
 342 
 343     fn left_w(&self) -> f32 {
 344         self.frac * (self.w - self.gap).max(0.0)
 345     }
 346 
 347     fn divider_rect(&self) -> (f32, f32, f32, f32) {
 348         (self.x + self.left_w(), self.y, self.gap, self.h)
 349     }
 350 
 351     fn hit_divider(&self, px: f32, py: f32) -> bool {
 352         let (sx, sy, sw, sh) = self.divider_rect();
 353         px >= sx && px <= sx + sw && py >= sy && py <= sy + sh
 354     }
 355 
 356     fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
 357         let mut handled = false;
 358         if self.dragging {
 359             let combined = (self.w - self.gap).max(0.0);
 360             if combined > 0.1 {
 361                 let new_left = (px - self.x - self.gap / 2.0)
 362                     .clamp(self.min_left, (combined - self.min_right).max(self.min_left));
 363                 let new_frac = new_left / combined;
 364                 if (new_frac - self.frac).abs() > 0.0001 {
 365                     self.frac = new_frac;
 366                     handled = true;
 367                 }
 368             }
 369         }
 370         let new_hovered = !self.dragging && self.hit_divider(px, py);
 371         if new_hovered != self.hovered {
 372             self.hovered = new_hovered;
 373             handled = true;
 374         }
 375         handled
 376     }
 377 
 378     fn press(&mut self, px: f32, py: f32) -> bool {
 379         if self.hit_divider(px, py) {
 380             self.dragging = true;
 381             return true;
 382         }
 383         false
 384     }
 385 
 386     fn release(&mut self) -> bool {
 387         std::mem::take(&mut self.dragging)
 388     }
 389 
 390     /// `SplitBox::extra_quads`: accent while dragging, tint on hover, hairline otherwise.
 391     fn divider_quad(&self) -> (f32, f32, f32, f32, [f32; 4]) {
 392         let (sx, sy, sw, sh) = self.divider_rect();
 393         if self.dragging {
 394             (sx + sw / 2.0 - 1.0, sy, 2.0, sh, [0.36, 0.56, 0.38, 0.8])
 395         } else if self.hovered {
 396             (sx + sw / 2.0 - 1.0, sy, 2.0, sh, [0.25, 0.25, 0.32, 0.6])
 397         } else {
 398             (sx + sw / 2.0 - 0.5, sy, 1.0, sh, [0.15, 0.15, 0.18, 0.4])
 399         }
 400     }
 401 }
 402 
 403 /// App shortcuts, resolved once at startup from input.kdl
 404 /// (`cce-data-editor` domain → `cce-ui` domain).
 405 struct DataEditorKeys {
 406     open_document: String,
 407     save_document: String,
 408     quit: String,
 409     format_json: String,
 410 }
 411 
 412 impl DataEditorKeys {
 413     fn load() -> Self {
 414         let get = cce_ui::input::app_chord;
 415         Self {
 416             open_document: get("open_document", "ctrl+o"),
 417             save_document: get("save_document", "ctrl+s"),
 418             quit: get("quit", "ctrl+q"),
 419             format_json: get("format_json", "ctrl+shift+f"),
 420         }
 421     }
 422 }
 423 
 424 struct DataEditorApp {
 425     keys: DataEditorKeys,
 426 
 427     // Toolbar Buttons
 428     btn_open: Handle<cce_ui::widget::Adapted<Dropdown>>,
 429 
 430     // Left Panel Form Edit
 431     flat_keys: Vec<(String, serde_json::Value)>,
 432     selected_key_idx: Option<usize>,
 433     /// A `--select <flat.path>` from the CLI, waiting for the first laid-out
 434     /// frame (the tree's viewport height) before it can select and scroll.
 435     pending_select: Option<String>,
 436     tree_list: Handle<cce_ui::widget::Adapted<TreeList>>,
 437 
 438     // Edit Value input
 439     selected_value_editor: Handle<cce_ui::widget::Adapted<TextBox>>,
 440     selected_color_editor: Handle<cce_ui::widget::Adapted<ColorSelector>>,
 441     selected_spinbox_editor: Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
 442     selected_font_editor: Handle<cce_ui::widget::Adapted<FontSelector>>,
 443     selected_choice_editor: Handle<cce_ui::widget::Adapted<Dropdown>>,
 444     /// A unit-bearing length — a `(mm)`/`(px)`/`(cm)`/`(in)`/`(pt)` annotation,
 445     /// or any string `cce_ui::units::Len` parses — edits as a two-decimal
 446     /// spinbox for the number beside a dropdown for the unit. Switching the
 447     /// unit keeps the LENGTH (converted through the process metric) and
 448     /// changes the number, so 2 mm shown in inches reads 0.08.
 449     selected_len_editor: Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
 450     selected_unit_editor: Handle<cce_ui::widget::Adapted<Dropdown>>,
 451     selected_keybind_editor: Handle<cce_ui::widget::Adapted<KeybindRecorder>>,
 452     selected_bool_editor: Handle<cce_ui::widget::Adapted<Checkbox>>,
 453     selected_button_editor: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 454     selected_bevel_editor: Handle<cce_ui::widget::Adapted<cce_ui::widget::BevelPreview>>,
 455     /// The (ramp) type's inline preview — the spec's curve; clicking opens
 456     /// cce-ramp --key on the selected key.
 457     selected_ramp_editor: Handle<cce_ui::widget::Adapted<cce_ui::widget::RampPreview>>,
 458     /// A cce-relief child spawned from the (bevel) preview: kept so a second
 459     /// click refocuses it (try_wait reaps an exited one) instead of piling
 460     /// up editors.
 461     bevel_child: Option<std::process::Child>,
 462     /// Same lifecycle for the cce-ramp child of the (ramp) preview.
 463     ramp_child: Option<std::process::Child>,
 464 
 465     // Right Panel Raw Json
 466     raw_json_editor: Handle<cce_ui::widget::Adapted<TextBox>>,
 467 
 468     // App state
 469     current_file_path: Option<std::path::PathBuf>,
 470     status_message: Option<(String, bool)>,
 471     /// Overflow rim (engine `overflow_margin`): the popover overhang past the
 472     /// window frame, quantized to 64px steps, 0 while no menu overhangs.
 473     /// Recomputed in tick from the dropdowns' FULL popover geometry (the
 474     /// animated box would resize the surface per frame).
 475     overflow_now: u32,
 476     // Disk-sync watch: the open file's (mtime, len) and a hash of its content
 477     // as last loaded/saved. tick() polls once a second; a mismatch means
 478     // another writer touched the file — auto-reload when the in-memory
 479     // document still matches `disk_hash` (no local edits to lose), else flag
 480     // `file_outdated` (toolbar + status indicator, cleared by Refresh/Save).
 481     disk_state: Option<(std::time::SystemTime, u64)>,
 482     disk_hash: u64,
 483     file_outdated: bool,
 484     /// When the disk-sync watch below may run again. A wall clock, not an
 485     /// accumulation of `tick`'s `dt`: `dt` is animation time, clamped to one
 486     /// frame after an idle sleep, so an editor sitting idle — exactly when
 487     /// another writer is the one touching the file — polled once a MINUTE.
 488     disk_poll_at: std::time::Instant,
 489     // File pickers run on a thread and report back over the engine's message
 490     // channel (OpenPicked / SaveAsPicked) so the event loop keeps animating.
 491     msg_sender: calloop::channel::Sender<AppMessage>,
 492     file_dialog_open: bool,
 493 
 494     // UI state
 495     menubar: Handle<cce_ui::widget::Adapted<MenuBar>>,
 496     statusbar: Handle<cce_ui::widget::Adapted<StatusBar>>,
 497     width: u32,
 498     height: u32,
 499     scale_factor: f64,
 500     // Shapes glyph advances (prepare_text) for the editors — load-bearing for cursor↔pixel
 501     // mapping; all rendered text is display-list prims shaped by the engine.
 502     font_system: FontSystem,
 503     needs_rebuild: bool,
 504     ui_context: cce_ui::context::UiContext,
 505     ctrl_pressed: bool,
 506     initial_focus: bool,
 507     spatial_grid_built: bool,
 508     cached_content: String,
 509     cached_flat_keys: Vec<(String, serde_json::Value)>,
 510     cached_annotations: Vec<Option<String>>,
 511     split: SplitPane,
 512 }
 513 
 514 
 515 /// The `Len` a flat value holds, if it is a unit-suffixed string (`"2mm"` —
 516 /// what `kdl_to_json` makes of `(mm)2.0`).
 517 fn len_of(val: &serde_json::Value) -> Option<cce_ui::units::Len> {
 518     val.as_str().and_then(cce_ui::units::Len::parse)
 519 }
 520 
 521 impl DataEditorApp {
 522 
 523     /// The unit the unit dropdown currently shows.
 524     fn selected_unit(&self) -> Option<cce_ui::units::Unit> {
 525         self.ui_context[self.selected_unit_editor]
 526             .options
 527             .get(self.ui_context[self.selected_unit_editor].selected)
 528             .and_then(|s| cce_ui::units::Unit::parse(s))
 529     }
 530 
 531     /// Point the length editors at `len` (the raw-reparse sync pattern).
 532     fn sync_len_editors(&mut self, len: cce_ui::units::Len) {
 533         self.ui_context[self.selected_len_editor].value = (len.value * 100.0).round() as i32;
 534         if let Some(pos) = cce_ui::units::Unit::ALL.iter().position(|u| *u == len.unit) {
 535             self.ui_context[self.selected_unit_editor].selected = pos;
 536         }
 537     }
 538 
 539     /// Write `len` into the selected key as its suffixed string; the KDL
 540     /// writer turns `"2mm"` back into `(mm)2`. Rounded to the spinbox's two
 541     /// decimals so the file and the control agree.
 542     fn commit_len(&mut self, idx: usize, len: cce_ui::units::Len) {
 543         let len = cce_ui::units::Len::new((len.value * 100.0).round() / 100.0, len.unit);
 544         self.flat_keys[idx].1 = serde_json::Value::String(len.serialize());
 545         self.sync_len_editors(len);
 546         self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
 547         self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
 548         self.ui_context[self.selected_value_editor].editing = false;
 549         self.update_raw_from_flat();
 550         self.sync_preview_selection();
 551     }
 552 
 553     fn load_recent_files(&self) -> Vec<String> {
 554         cce_ui::config::load_recent_files()
 555     }
 556 
 557     fn save_recent_files(&self, files: &[String]) {
 558         cce_ui::config::save_recent_files(files)
 559     }
 560 
 561     fn add_recent_file(&mut self, file_path: &std::path::Path) {
 562         if let Ok(abs_path) = std::fs::canonicalize(file_path) {
 563             let abs_str = abs_path.to_string_lossy().to_string();
 564             let mut recent = self.load_recent_files();
 565             recent.retain(|p| p != &abs_str);
 566             recent.insert(0, abs_str);
 567             if recent.len() > 10 {
 568                 recent.truncate(10);
 569             }
 570             self.save_recent_files(&recent);
 571             self.update_recent_files_dropdown(recent);
 572         }
 573     }
 574 
 575     fn update_recent_files_dropdown(&mut self, recent: Vec<String>) {
 576         let mut options = vec![
 577             "Save".to_string(),
 578             "Save As".to_string(),
 579             "Refresh".to_string(),
 580             "Format".to_string(),
 581             "-".to_string(),
 582             "Open...".to_string(),
 583         ];
 584         if !recent.is_empty() {
 585             options.push("-".to_string());
 586             options.extend(recent);
 587         }
 588         options.push("-".to_string());
 589         options.push("Exit".to_string());
 590         self.ui_context[self.btn_open].options = options;
 591         self.ui_context[self.btn_open].selected = 0;
 592         self.needs_rebuild = true;
 593     }
 594 
 595     fn open_file_by_path(&mut self, path: std::path::PathBuf) -> Result<(), String> {
 596         match std::fs::read_to_string(&path) {
 597             Ok(content) => {
 598                 self.ui_context[self.raw_json_editor].text = content;
 599                 self.ui_context[self.raw_json_editor].edit_buffer = self.ui_context[self.raw_json_editor].text.clone();
 600                 self.ui_context[self.raw_json_editor].cursor_idx = 0;
 601                 self.ui_context[self.raw_json_editor].select_anchor = None;
 602                 self.ui_context[self.raw_json_editor].editing = false;
 603                 
 604                 self.flat_keys.clear();
 605                 if self.ui_context[self.raw_json_editor].text.parse::<kdl::KdlDocument>().is_ok() {
 606                     let val = cce_ui::config::parse_kdl_to_json(&self.ui_context[self.raw_json_editor].text);
 607                     flatten_json(&val, "", &mut self.flat_keys);
 608                     self.status_message = Some((format!("Opened {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
 609                 } else {
 610                     let val = cce_ui::config::parse_kdl_to_json(&self.ui_context[self.raw_json_editor].text);
 611                     flatten_json(&val, "", &mut self.flat_keys);
 612                     self.status_message = Some(("Loaded, but KDL is syntactically invalid".to_string(), true));
 613                 }
 614                 
 615                 self.selected_key_idx = None;
 616                 self.ui_context[self.tree_list].scroll_box.scroll_y = 0.0;
 617                 self.current_file_path = Some(path.clone());
 618                 self.sync_preview_selection();
 619                 self.add_recent_file(&path);
 620                 self.note_disk_sync();
 621                 Ok(())
 622             }
 623             Err(e) => {
 624                 Err(format!("Error opening: {}", e))
 625             }
 626         }
 627     }
 628 
 629     fn update_raw_from_flat(&mut self) {
 630         let root = unflatten_json(&self.flat_keys);
 631         let mut anno_map = std::collections::HashMap::new();
 632         let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
 633         for (key_path, _) in &self.flat_keys {
 634             if let Some(anno) = cce_ui::config::get_kdl_type_annotation(content, key_path) {
 635                 anno_map.insert(key_path.clone(), anno);
 636             }
 637         }
 638         let pretty = cce_ui::config::json_to_kdl_string_with_annotations(&root, &anno_map);
 639         self.ui_context[self.raw_json_editor].text = pretty;
 640         self.ui_context[self.raw_json_editor].edit_buffer = self.ui_context[self.raw_json_editor].text.clone();
 641         self.ui_context[self.raw_json_editor].sync_editor_state();
 642     }
 643 
 644     fn rename_key_path(&mut self, old_path: &str, new_path: &str) {
 645         let old_prefix = format!("{}.", old_path);
 646         let new_prefix = format!("{}.", new_path);
 647 
 648         for (k, _) in &mut self.flat_keys {
 649             if k == old_path {
 650                 *k = new_path.to_string();
 651             } else if k.starts_with(&old_prefix) {
 652                 *k = k.replacen(&old_prefix, &new_prefix, 1);
 653             }
 654         }
 655         
 656         self.update_raw_from_flat();
 657         self.rebuild_tree();
 658     }
 659 
 660     /// Record that memory and disk agree right now — call immediately after
 661     /// reading the open file or writing it. Stores the file's (mtime, len) and
 662     /// the content hash the dirty check compares against.
 663     fn note_disk_sync(&mut self) {
 664         let content = if self.ui_context[self.raw_json_editor].editing {
 665             &self.ui_context[self.raw_json_editor].edit_buffer
 666         } else {
 667             &self.ui_context[self.raw_json_editor].text
 668         };
 669         self.disk_hash = hash_content(content);
 670         self.disk_state = self.current_file_path.as_ref()
 671             .and_then(|p| std::fs::metadata(p).ok())
 672             .and_then(|m| m.modified().ok().map(|t| (t, m.len())));
 673         self.file_outdated = false;
 674     }
 675 
 676     /// Focus-within for the tree pane's rim: the inline value editors float over
 677     /// tree rows and take ctx focus from the TreeList when a leaf is clicked, but
 678     /// they are visually part of the tree pane — the rim stays lit for any of
 679     /// them (and the tree's own search/rename boxes). It goes out only when focus
 680     /// genuinely leaves the pane: the raw editor, the File menu, or nothing.
 681     /// Called after every input dispatch (the only places focus moves).
 682     fn sync_tree_focus_rim(&mut self) {
 683         let ui = &self.ui_context;
 684         let outside = !ui.has_focus()
 685             || ui.is_focused_id(self.raw_json_editor.id())
 686             || ui.is_focused_id(self.btn_open.id());
 687         self.ui_context[self.tree_list].focused = !outside;
 688     }
 689 
 690     fn sync_preview_selection(&mut self) {
 691         if let Some(idx) = self.selected_key_idx {
 692             if idx < self.flat_keys.len() {
 693                 let path = &self.flat_keys[idx].0;
 694                 let tokens = parse_path(path);
 695                 let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
 696                 if let Some((start, end)) = find_kdl_span(content, &tokens) {
 697                     let (start, end) = (byte_to_char_idx(content, start), byte_to_char_idx(content, end));
 698                     self.ui_context[self.raw_json_editor].select_anchor = Some(start);
 699                     self.ui_context[self.raw_json_editor].cursor_idx = end;
 700                     self.ui_context[self.raw_json_editor].sync_editor_state();
 701                     self.ui_context[self.raw_json_editor].scroll_to_cursor();
 702                     return;
 703                 }
 704             }
 705         }
 706         self.ui_context[self.raw_json_editor].select_anchor = None;
 707         self.ui_context[self.raw_json_editor].sync_editor_state();
 708     }
 709 
 710     /// Adopt the tree's selection and seed the inline value editors from the
 711     /// selected key's value — the shared tail of every selection path that
 712     /// goes through `TreeList::select_and_show_key` (raw-pane click sync,
 713     /// `--select` deep link).
 714     fn adopt_tree_selection(&mut self) {
 715         self.selected_key_idx = self.ui_context[self.tree_list].selected_key_idx;
 716         if let Some(idx) = self.selected_key_idx {
 717             self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
 718             self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
 719             self.ui_context[self.selected_value_editor].editing = false;
 720 
 721             let val = &self.flat_keys[idx].1;
 722             if let serde_json::Value::String(s) = val {
 723                 if let Some(len) = cce_ui::units::Len::parse(s) {
 724                     self.sync_len_editors(len);
 725                 } else if let Some(c) = parse_hex_color(s) {
 726                     self.ui_context[self.selected_color_editor].color = c;
 727                 } else {
 728                     self.ui_context[self.selected_font_editor].font_family = s.clone();
 729                 }
 730             } else if let Some(num) = val.as_i64() {
 731                 self.ui_context[self.selected_spinbox_editor].value = num as i32;
 732             } else if let serde_json::Value::Bool(b) = val {
 733                 self.ui_context[self.selected_bool_editor].set_checked(*b);
 734             }
 735         }
 736         self.sync_preview_selection();
 737     }
 738 
 739     /// The (bevel) preview's click action: open the cce-relief material
 740     /// editor, or refocus the one this session already spawned. cce-relief
 741     /// saves to config.kdl itself; the disk-sync watch reloads the document
 742     /// here when it does.
 743     ///
 744     /// The tool was `cce-bevel` until it grew a shape picker covering the whole
 745     /// relief family; the `(bevel)` type annotation this hangs off is a CONFIG
 746     /// key and deliberately did not change with it.
 747     fn open_bevel_editor(&mut self) {
 748         let home = std::env::var("HOME").unwrap_or_default();
 749         if let Some(child) = self.bevel_child.as_mut() {
 750             if matches!(child.try_wait(), Ok(None)) {
 751                 // Off the UI thread: the round trip is bounded, not free.
 752                 std::thread::spawn(|| {
 753                     let _ = cce_ui::ipc::focus_window("cce-relief");
 754                 });
 755                 return;
 756             }
 757             self.bevel_child = None;
 758         }
 759         let local = format!("{home}/.local/bin/cce-relief");
 760         let cmd = if std::path::Path::new(&local).exists() { local } else { "cce-relief".to_string() };
 761         // Ask the compositor to open the editor at this control instead of
 762         // its remembered position: the pointer is on the preview right now,
 763         // so its location IS the control's location. One-shot, best-effort
 764         // (`place-next` consumed at the map; ignored off-cce) — the same
 765         // pattern as ColorSelector's picker spawn.
 766         if let Ok(reply) = cce_ui::ipc::send_command("cce", "pointer-location") {
 767             let mut px = None;
 768             let mut py = None;
 769             for tok in reply.split_whitespace() {
 770                 if let Some(v) = tok.strip_prefix("x=") {
 771                     px = v.parse::<f64>().ok();
 772                 } else if let Some(v) = tok.strip_prefix("y=") {
 773                     py = v.parse::<f64>().ok();
 774                 }
 775             }
 776             if let (Some(x), Some(y)) = (px, py) {
 777                 let _ = cce_ui::ipc::send_command("cce", &format!("place-next cce-relief {x:.0} {y:.0}"));
 778             }
 779         }
 780         let mut command = std::process::Command::new(&cmd);
 781         // A relief-valued key (the (relief) annotation, or a line_relief key
 782         // still in integer form) targets its own single value: cce-relief
 783         // --key seeds from it and Save rewrites only it. A (bevel) key keeps
 784         // editing the file's shared material.
 785         let relief_key = self.selected_key_idx.and_then(|idx| {
 786             let key = self.flat_keys[idx].0.clone();
 787             let by_anno = cce_ui::config::get_kdl_type_annotation(&self.ui_context[self.raw_json_editor].text, &key)
 788                 .as_deref()
 789                 == Some("relief");
 790             let by_name = key == "line_relief" || key.ends_with(".line_relief");
 791             (by_anno || by_name).then_some(key)
 792         });
 793         if let Some(ref key) = relief_key {
 794             command.args(["--key", key]);
 795         }
 796         // Target the file being edited: per-app configs get their own
 797         // material instead of cce-relief's default shared-config.kdl save.
 798         if let Some(ref path) = self.current_file_path {
 799             command.args(["--config", &path.to_string_lossy()]);
 800         }
 801         match command.spawn() {
 802             Ok(child) => self.bevel_child = Some(child),
 803             Err(e) => self.status_message = Some((format!("cce-relief launch failed: {e}"), true)),
 804         }
 805     }
 806 
 807     /// The (ramp) preview's click action: open cce-ramp targeted at the
 808     /// selected key, or refocus the one this session already spawned —
 809     /// `open_bevel_editor`'s lifecycle with cce-ramp's argv.
 810     fn open_ramp_editor(&mut self) {
 811         let home = std::env::var("HOME").unwrap_or_default();
 812         if let Some(child) = self.ramp_child.as_mut() {
 813             if matches!(child.try_wait(), Ok(None)) {
 814                 // Off the UI thread: the round trip is bounded, not free.
 815                 std::thread::spawn(|| {
 816                     let _ = cce_ui::ipc::focus_window("cce-ramp");
 817                 });
 818                 return;
 819             }
 820             self.ramp_child = None;
 821         }
 822         let Some(idx) = self.selected_key_idx else { return };
 823         let key = self.flat_keys[idx].0.clone();
 824         let local = format!("{home}/.local/bin/cce-ramp");
 825         let cmd = if std::path::Path::new(&local).exists() { local } else { "cce-ramp".to_string() };
 826         // Open at this control, not the remembered spot — the pointer is on
 827         // the preview right now (see open_bevel_editor).
 828         if let Ok(reply) = cce_ui::ipc::send_command("cce", "pointer-location") {
 829             let mut px = None;
 830             let mut py = None;
 831             for tok in reply.split_whitespace() {
 832                 if let Some(v) = tok.strip_prefix("x=") {
 833                     px = v.parse::<f64>().ok();
 834                 } else if let Some(v) = tok.strip_prefix("y=") {
 835                     py = v.parse::<f64>().ok();
 836                 }
 837             }
 838             if let (Some(x), Some(y)) = (px, py) {
 839                 let _ = cce_ui::ipc::send_command("cce", &format!("place-next cce-ramp {x:.0} {y:.0}"));
 840             }
 841         }
 842         let mut command = std::process::Command::new(&cmd);
 843         command.args(["--key", &key]);
 844         if let Some(ref path) = self.current_file_path {
 845             command.args(["--config", &path.to_string_lossy()]);
 846         }
 847         match command.spawn() {
 848             Ok(child) => self.ramp_child = Some(child),
 849             Err(e) => self.status_message = Some((format!("cce-ramp launch failed: {e}"), true)),
 850         }
 851     }
 852 
 853     fn rebuild_tree(&mut self) {
 854         self.ui_context[self.tree_list].selected_key_idx = self.selected_key_idx;
 855         let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
 856         
 857         let needs_anno_rebuild = content != &self.cached_content || self.flat_keys != self.cached_flat_keys;
 858         if needs_anno_rebuild {
 859             self.cached_content = content.clone();
 860             self.cached_flat_keys = self.flat_keys.clone();
 861             
 862             let keys: Vec<String> = self.flat_keys.iter().map(|(k, _)| k.clone()).collect();
 863             self.cached_annotations = cce_ui::config::get_kdl_type_annotations(content, &keys);
 864             
 865             self.ui_context[self.tree_list].annotations = self.cached_annotations.clone();
 866             self.ui_context[self.tree_list].set_flat_keys(self.flat_keys.clone());
 867         }
 868     }
 869 
 870     /// The pre-frame widget-state refresh — everything `rebuild_text_items` did EXCEPT
 871     /// building TextItems (all rendered text is display-list prims now: widget text from the
 872     /// paint walk, the toolbar file label from `display_list` directly).
 873     /// The value cell shows only the editor for the selected key's type; the rest are
 874     /// parked off the window (`set_rect(-1000, …)` above). A parked editor is also HIDDEN,
 875     /// so it is no Tab stop and not in the accessibility tree — parked alone, every one of
 876     /// them was both — and the shown one is named after the key it edits, since its label
 877     /// is the key column beside it, not one of its own.
 878     fn sync_editor_presence(&mut self) {
 879         let key = self.selected_key_idx.and_then(|i| self.flat_keys.get(i)).map(|(k, _)| k.clone());
 880         let value = key.as_ref().map(|k| format!("Value of {k}"));
 881         let unit = key.as_ref().map(|k| format!("Unit of {k}"));
 882         macro_rules! present {
 883             ($h:expr, $name:expr) => {{
 884                 let w = &mut self.ui_context[$h];
 885                 let shown = WidgetHost::rect(&*w).0 > -999.0;
 886                 WidgetHost::set_visible(w, shown);
 887                 w.set_accessible_name($name);
 888             }};
 889         }
 890         present!(self.selected_value_editor, value.as_deref());
 891         present!(self.selected_color_editor, value.as_deref());
 892         present!(self.selected_spinbox_editor, value.as_deref());
 893         present!(self.selected_font_editor, value.as_deref());
 894         present!(self.selected_choice_editor, value.as_deref());
 895         present!(self.selected_len_editor, value.as_deref());
 896         present!(self.selected_unit_editor, unit.as_deref());
 897         present!(self.selected_keybind_editor, value.as_deref());
 898         present!(self.selected_bool_editor, value.as_deref());
 899         present!(self.selected_bevel_editor, value.as_deref());
 900         present!(self.selected_ramp_editor, value.as_deref());
 901         // The test button keeps its own label ("Send Test").
 902         present!(self.selected_button_editor, None);
 903     }
 904 
 905     fn refresh_widget_text(&mut self) {
 906         self.rebuild_tree();
 907         // Glyph-advance shaping — load-bearing for cursor↔pixel mapping in the editors.
 908         self.ui_context[self.raw_json_editor].prepare_text(&mut self.font_system);
 909         self.ui_context[self.selected_value_editor].prepare_text(&mut self.font_system);
 910         self.ui_context[self.selected_keybind_editor].prepare_text(&mut self.font_system);
 911         self.ui_context[self.tree_list].prepare_text(&mut self.font_system);
 912 
 913         // Status Bar indicators
 914         if let Some((msg, is_error)) = &self.status_message {
 915             let color = if *is_error { [0.98, 0.32, 0.32, 1.0] } else { [0.25, 0.75, 0.34, 1.0] };
 916             self.ui_context[self.statusbar].set_text(msg);
 917             self.ui_context[self.statusbar].set_text_color(color);
 918         } else {
 919             let msg = format!("Keys: {} | Selected Index: {:?}", self.flat_keys.len(), self.selected_key_idx);
 920             self.ui_context[self.statusbar].set_text(&msg);
 921             self.ui_context[self.statusbar].set_text_color([0.51, 0.51, 0.54, 1.0]);
 922         }
 923         self.ui_context[self.statusbar].prepare_text(&mut self.font_system);
 924         cce_ui::widget::WidgetHost::prepare_text(&mut self.ui_context[self.menubar], &mut self.font_system);
 925     }
 926 }
 927 
 928 impl Application for DataEditorApp {
 929     type Message = AppMessage;
 930 
 931     fn overflow_margin(&self) -> u32 {
 932         self.overflow_now
 933     }
 934 
 935     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
 936         Some(&self.ui_context)
 937     }
 938 
 939     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
 940         Some(&mut self.ui_context)
 941     }
 942 
 943     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
 944         if self.split.dragging || self.split.hovered {
 945             return false;
 946         }
 947         // root plate container dissolved: the surface itself is the movable plate; drag anywhere a
 948         // drag-blocking widget isn't.
 949         self.ui_context.drag_allowed_at(px, py)
 950     }
 951 
 952     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 953         // The app keeps calloop's sender; `AppSender` converts into it.
 954         let sender: calloop::channel::Sender<Self::Message> = sender.into();
 955         println!("RUNNING DATA EDITOR DROPDOWN COLOR: {:?}", cce_ui::colors::dropdown_background_color());
 956 
 957 
 958 
 959         let mut selected_value_editor = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true);
 960         selected_value_editor.set_placeholder("value (e.g. 42, true, \"hello\")");
 961         let selected_color_editor = ColorSelector::new([255, 255, 255]);
 962         let selected_spinbox_editor = Spinbox::new(0, -1000000, 1000000, 1);
 963         let selected_font_editor = FontSelector::new(String::new());
 964         let selected_choice_editor = Dropdown::new(Vec::new(), 0);
 965         // Hundredths: the spinbox is integer-valued, `decimals` scales its
 966         // display; a wheel notch steps 0.10.
 967         let selected_len_editor = Spinbox::new(0, -100_000_000, 100_000_000, 10).with_decimals(2);
 968         let selected_unit_editor = Dropdown::new(
 969             cce_ui::units::Unit::ALL.iter().map(|u| u.suffix().to_string()).collect(),
 970             1,
 971         );
 972         let selected_keybind_editor = KeybindRecorder::new(String::new());
 973         let selected_bool_editor = Checkbox::new();
 974         let selected_button_editor = Button::new(0.0, 0.0, 125.0, cce_ui::layout::button_height()).with_label("Send Test");
 975         let selected_bevel_editor = cce_ui::widget::BevelPreview::new();
 976         let selected_ramp_editor = cce_ui::widget::RampPreview::new();
 977 
 978         let mut raw_json_editor = TextBox::new(String::new())
 979             .with_multiline(true)
 980             .with_draw_bg_border(true)
 981             .with_max_width(None);
 982         raw_json_editor.font_family = "monospace".to_string();
 983         raw_json_editor.font_size = 13.0;
 984         raw_json_editor.set_accessible_name(Some("KDL source"));
 985 
 986         // Auto-load argument path if passed. `--select <flat.path>` deep-links
 987         // to a key (or section prefix) once the first frame has laid out.
 988         let args: Vec<String> = std::env::args().collect();
 989         let mut current_file_path = None;
 990         let mut flat_keys = Vec::new();
 991         let mut pending_select = None;
 992         let mut file_arg = None;
 993         let mut i = 1;
 994         while i < args.len() {
 995             if args[i] == "--select" {
 996                 if i + 1 < args.len() {
 997                     pending_select = Some(args[i + 1].clone());
 998                     i += 1;
 999                 }
1000             } else if file_arg.is_none() {
1001                 file_arg = Some(args[i].clone());
1002             }
1003             i += 1;
1004         }
1005         if let Some(arg) = file_arg {
1006             let path = std::path::PathBuf::from(&arg);
1007             if path.exists() {
1008                 if let Ok(content) = std::fs::read_to_string(&path) {
1009                     raw_json_editor.text = content;
1010                     raw_json_editor.edit_buffer = raw_json_editor.text.clone();
1011                     current_file_path = Some(path);
1012                     let val = cce_ui::config::parse_kdl_to_json(&raw_json_editor.text);
1013                     flatten_json(&val, "", &mut flat_keys);
1014                 }
1015             }
1016         }
1017 
1018         // Load recent files list
1019         let mut recent = cce_ui::config::load_recent_files();
1020 
1021         if let Some(ref path) = current_file_path {
1022             if let Ok(abs_path) = std::fs::canonicalize(path) {
1023                 let abs_str = abs_path.to_string_lossy().to_string();
1024                 recent.retain(|p| p != &abs_str);
1025                 recent.insert(0, abs_str);
1026                 if recent.len() > 10 {
1027                     recent.truncate(10);
1028                 }
1029                 cce_ui::config::save_recent_files(&recent);
1030             }
1031         }
1032 
1033         let mut dropdown_options = vec![
1034             "Save".to_string(),
1035             "Save As".to_string(),
1036             "Refresh".to_string(),
1037             "Format".to_string(),
1038             "-".to_string(),
1039             "Open...".to_string(),
1040         ];
1041         if !recent.is_empty() {
1042             dropdown_options.push("-".to_string());
1043             dropdown_options.extend(recent);
1044         }
1045         dropdown_options.push("-".to_string());
1046         dropdown_options.push("Exit".to_string());
1047         let mut btn_open = Dropdown::new(dropdown_options, 0).with_custom_display_text("File");
1048         btn_open.set_rect(10.0, 8.0, 70.0, cce_ui::layout::dropdown_height());
1049         btn_open.set_accessible_name(Some("File"));
1050 
1051         let mut tree_list = TreeList::new();
1052         tree_list.set_accessible_name(Some("Keys"));
1053         let keys_to_anno: Vec<String> = flat_keys.iter().map(|(k, _)| k.clone()).collect();
1054         let cached_annotations = cce_ui::config::get_kdl_type_annotations(&raw_json_editor.text, &keys_to_anno);
1055         tree_list.annotations = cached_annotations.clone();
1056         tree_list.set_flat_keys(flat_keys.clone());
1057 
1058         let cached_content = raw_json_editor.text.clone();
1059         let cached_flat_keys = flat_keys.clone();
1060 
1061         // Disk-sync baseline for the startup file (later loads/saves go
1062         // through note_disk_sync).
1063         let disk_hash = hash_content(&raw_json_editor.text);
1064         let disk_state = current_file_path.as_ref()
1065             .and_then(|p| std::fs::metadata(p).ok())
1066             .and_then(|m| m.modified().ok().map(|t| (t, m.len())));
1067 
1068         // Recessed: no bar background, the window root plate shows through and is shaded to
1069         // read as carved into it. Supersedes the old opaque .with_color([0.08,0.08,0.12,1]).
1070         let menubar = MenuBar::new(0.0, 0.0, 800.0, 42.0).with_recess(true);
1071         let statusbar = StatusBar::new()
1072             .with_recess(true)
1073             .with_text_offset_x(15.0);
1074 
1075             let split = SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::root_plate_gap());
1076 
1077             // The context owns the widgets; the app keeps their handles.
1078             let mut ui_context = cce_ui::context::UiContext::new();
1079             Self {
1080                 keys: DataEditorKeys::load(),
1081                 menubar: ui_context.insert(menubar),
1082                 statusbar: ui_context.insert(statusbar),
1083                 btn_open: ui_context.insert(btn_open),
1084                 flat_keys,
1085                 selected_key_idx: None,
1086                 pending_select,
1087                 tree_list: ui_context.insert(tree_list),
1088                 selected_value_editor: ui_context.insert(selected_value_editor),
1089                 selected_color_editor: ui_context.insert(selected_color_editor),
1090                 selected_spinbox_editor: ui_context.insert(selected_spinbox_editor),
1091                 selected_font_editor: ui_context.insert(selected_font_editor),
1092                 selected_choice_editor: ui_context.insert(selected_choice_editor),
1093                 selected_len_editor: ui_context.insert(selected_len_editor),
1094                 selected_unit_editor: ui_context.insert(selected_unit_editor),
1095                 selected_keybind_editor: ui_context.insert(selected_keybind_editor),
1096                 selected_bool_editor: ui_context.insert(selected_bool_editor),
1097                 selected_button_editor: ui_context.insert(selected_button_editor),
1098                 selected_bevel_editor: ui_context.insert(selected_bevel_editor),
1099                 selected_ramp_editor: ui_context.insert(selected_ramp_editor),
1100                 bevel_child: None,
1101                 ramp_child: None,
1102                 raw_json_editor: ui_context.insert(raw_json_editor),
1103                 current_file_path,
1104                 status_message: None,
1105                 overflow_now: 0,
1106                 disk_state,
1107                 disk_hash,
1108                 file_outdated: false,
1109                 disk_poll_at: std::time::Instant::now(),
1110                 msg_sender: sender,
1111                 file_dialog_open: false,
1112                 width: 800,
1113                 height: 600,
1114                 scale_factor: 1.0,
1115                 font_system: cce_ui::create_font_system(),
1116                 needs_rebuild: true,
1117                 ui_context,
1118                 ctrl_pressed: false,
1119                 initial_focus: true,
1120                 spatial_grid_built: false,
1121                 cached_content,
1122                 cached_flat_keys,
1123                 cached_annotations,
1124                 split,
1125             }
1126     }
1127 
1128     fn settings(&self) -> WindowSettings {
1129         WindowSettings {
1130             title: "Clear KDL Data Editor".to_string(),
1131             app_id: "cce-data-editor".to_string(),
1132             width: 800,
1133             height: 600,
1134             fullscreen: false,
1135             min_size: Some((600, 450)),
1136         }
1137     }
1138 
1139     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
1140         match msg {
1141             AppMessage::Exit => {
1142                 *exit = true;
1143             }
1144             AppMessage::OpenDocument => {
1145                 // The portal dialog runs on a thread; the result arrives as
1146                 // OpenPicked. Blocking here would freeze the loop with the
1147                 // dropdown still expanded — its contraction animates while the
1148                 // dialog is up.
1149                 if self.file_dialog_open {
1150                     return;
1151                 }
1152                 self.file_dialog_open = true;
1153                 let sender = self.msg_sender.clone();
1154                 std::thread::spawn(move || {
1155                     let picked = cce_ui::file_dialog::pick_file(
1156                         "Open KDL Document",
1157                         &[("KDL Documents", &["kdl"]), ("All Files", &["*"])],
1158                     );
1159                     let _ = sender.send(AppMessage::OpenPicked(picked));
1160                 });
1161             }
1162             AppMessage::OpenPicked(picked) => {
1163                 self.file_dialog_open = false;
1164                 if let Some(path) = picked {
1165                     if let Err(e) = self.open_file_by_path(path) {
1166                         self.status_message = Some((e, true));
1167                     }
1168                 }
1169                 *needs_rebuild = true;
1170                 self.needs_rebuild = true;
1171             }
1172             AppMessage::OpenRecent(path) => {
1173                 println!("[DEBUG] update: AppMessage::OpenRecent received, path = {:?}", path);
1174                 if let Err(e) = self.open_file_by_path(path) {
1175                     self.status_message = Some((e, true));
1176                 }
1177                 *needs_rebuild = true;
1178                 self.needs_rebuild = true;
1179             }
1180             AppMessage::SaveDocument => {
1181                 let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
1182                 if let Err(e) = content.parse::<kdl::KdlDocument>() {
1183                     self.status_message = Some((format!("Cannot save: invalid KDL ({})", e), true));
1184                     *needs_rebuild = true;
1185                     self.needs_rebuild = true;
1186                     return;
1187                 }
1188                 
1189                 if let Some(path) = self.current_file_path.clone() {
1190                     match std::fs::write(&path, content) {
1191                         Ok(_) => {
1192                             if self.ui_context[self.raw_json_editor].editing {
1193                                 self.ui_context[self.raw_json_editor].text = self.ui_context[self.raw_json_editor].edit_buffer.clone();
1194                             }
1195                             self.status_message = Some((format!("Saved to {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
1196                             self.add_recent_file(&path);
1197                             self.note_disk_sync();
1198                         }
1199                         Err(e) => {
1200                             self.status_message = Some((format!("Save failed: {}", e), true));
1201                         }
1202                     }
1203                     *needs_rebuild = true;
1204                     self.needs_rebuild = true;
1205                 } else {
1206                     self.update(AppMessage::SaveDocumentAs, needs_rebuild, exit);
1207                 }
1208             }
1209             AppMessage::SaveDocumentAs => {
1210                 let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
1211                 if let Err(e) = content.parse::<kdl::KdlDocument>() {
1212                     self.status_message = Some((format!("Cannot save: invalid KDL ({})", e), true));
1213                     *needs_rebuild = true;
1214                     self.needs_rebuild = true;
1215                     return;
1216                 }
1217                 // Same threaded-picker shape as OpenDocument; the write happens
1218                 // in SaveAsPicked (content re-read and re-validated there —
1219                 // the document can change while the dialog is up).
1220                 if self.file_dialog_open {
1221                     return;
1222                 }
1223                 self.file_dialog_open = true;
1224                 let sender = self.msg_sender.clone();
1225                 std::thread::spawn(move || {
1226                     let picked = cce_ui::file_dialog::save_file(
1227                         "Save KDL Document",
1228                         &[("KDL Documents", &["kdl"]), ("All Files", &["*"])],
1229                     );
1230                     let _ = sender.send(AppMessage::SaveAsPicked(picked));
1231                 });
1232             }
1233             AppMessage::SaveAsPicked(picked) => {
1234                 self.file_dialog_open = false;
1235                 let Some(path) = picked else {
1236                     return;
1237                 };
1238                 let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
1239                 if let Err(e) = content.parse::<kdl::KdlDocument>() {
1240                     self.status_message = Some((format!("Cannot save: invalid KDL ({})", e), true));
1241                     *needs_rebuild = true;
1242                     self.needs_rebuild = true;
1243                     return;
1244                 }
1245                 match std::fs::write(&path, content) {
1246                     Ok(_) => {
1247                         if self.ui_context[self.raw_json_editor].editing {
1248                             self.ui_context[self.raw_json_editor].text = self.ui_context[self.raw_json_editor].edit_buffer.clone();
1249                         }
1250                         self.current_file_path = Some(path.clone());
1251                         self.status_message = Some((format!("Saved to {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
1252                         self.add_recent_file(&path);
1253                         self.note_disk_sync();
1254                     }
1255                     Err(e) => {
1256                         self.status_message = Some((format!("Save failed: {}", e), true));
1257                     }
1258                 }
1259                 *needs_rebuild = true;
1260                 self.needs_rebuild = true;
1261             }
1262             AppMessage::FormatJson => {
1263                 let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
1264                 match content.parse::<kdl::KdlDocument>() {
1265                     Ok(doc) => {
1266                         let pretty = doc.to_string();
1267                         self.ui_context[self.raw_json_editor].text = pretty;
1268                         self.ui_context[self.raw_json_editor].edit_buffer = self.ui_context[self.raw_json_editor].text.clone();
1269                         self.ui_context[self.raw_json_editor].editing = false;
1270                         self.ui_context[self.raw_json_editor].sync_editor_state();
1271                         
1272                         let val = cce_ui::config::parse_kdl_to_json(&self.ui_context[self.raw_json_editor].text);
1273                         self.flat_keys.clear();
1274                         flatten_json(&val, "", &mut self.flat_keys);
1275                         self.status_message = Some(("Formatted successfully".to_string(), false));
1276                         self.sync_preview_selection();
1277                     }
1278                     Err(e) => {
1279                         self.status_message = Some((format!("KDL error: {}", e), true));
1280                     }
1281                 }
1282                 *needs_rebuild = true;
1283                 self.needs_rebuild = true;
1284             }
1285             AppMessage::RefreshDocument => {
1286                 if let Some(path) = &self.current_file_path {
1287                     match std::fs::read_to_string(path) {
1288                         Ok(content) => {
1289                             // Selection survives the reload by key name (the
1290                             // raw-reparse convention): captured before the
1291                             // rebuild, re-resolved after.
1292                             let selected_key = self.selected_key_idx
1293                                 .and_then(|idx| self.flat_keys.get(idx).map(|(k, _)| k.clone()));
1294 
1295                             self.ui_context[self.raw_json_editor].text = content;
1296                             self.ui_context[self.raw_json_editor].edit_buffer = self.ui_context[self.raw_json_editor].text.clone();
1297                             self.ui_context[self.raw_json_editor].cursor_idx = 0;
1298                             self.ui_context[self.raw_json_editor].select_anchor = None;
1299                             self.ui_context[self.raw_json_editor].editing = false;
1300 
1301                             self.flat_keys.clear();
1302                             if self.ui_context[self.raw_json_editor].text.parse::<kdl::KdlDocument>().is_ok() {
1303                                 let val = cce_ui::config::parse_kdl_to_json(&self.ui_context[self.raw_json_editor].text);
1304                                 flatten_json(&val, "", &mut self.flat_keys);
1305                                 self.status_message = Some(("Refreshed from disk".to_string(), false));
1306                             } else {
1307                                 let val = cce_ui::config::parse_kdl_to_json(&self.ui_context[self.raw_json_editor].text);
1308                                 flatten_json(&val, "", &mut self.flat_keys);
1309                                 self.status_message = Some(("Refreshed, but KDL is syntactically invalid".to_string(), true));
1310                             }
1311 
1312                             self.selected_key_idx = selected_key
1313                                 .as_deref()
1314                                 .and_then(|k| self.flat_keys.iter().position(|(key, _)| key == k));
1315                             if let Some(idx) = self.selected_key_idx {
1316                                 self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1317                                 self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1318                                 self.ui_context[self.selected_value_editor].editing = false;
1319 
1320                                 // Sync controls (the raw-reparse pattern).
1321                                 let val = &self.flat_keys[idx].1;
1322                                 if let serde_json::Value::String(s) = val {
1323                                     if let Some(len) = cce_ui::units::Len::parse(s) {
1324                                         self.sync_len_editors(len);
1325                                     } else if let Some(c) = parse_hex_color(s) {
1326                                         self.ui_context[self.selected_color_editor].color = c;
1327                                     } else {
1328                                         self.ui_context[self.selected_font_editor].font_family = s.clone();
1329                                     }
1330                                 } else if let Some(num) = val.as_i64() {
1331                                     self.ui_context[self.selected_spinbox_editor].value = num as i32;
1332                                 } else if let serde_json::Value::Bool(b) = val {
1333                                     self.ui_context[self.selected_bool_editor].set_checked(*b);
1334                                 }
1335                             } else {
1336                                 self.ui_context[self.selected_value_editor].text.clear();
1337                                 self.ui_context[self.selected_value_editor].edit_buffer.clear();
1338                                 self.ui_context[self.selected_value_editor].editing = false;
1339                             }
1340                             self.sync_preview_selection();
1341                             self.note_disk_sync();
1342                         }
1343                         Err(e) => {
1344                             self.status_message = Some((format!("Refresh failed: {}", e), true));
1345                         }
1346                     }
1347                 } else {
1348                     self.status_message = Some(("No file is currently open".to_string(), true));
1349                 }
1350                 *needs_rebuild = true;
1351                 self.needs_rebuild = true;
1352             }
1353 
1354             AppMessage::ApplyValue => {
1355                 if let Some(idx) = self.selected_key_idx {
1356                     let val_str = if self.ui_context[self.selected_value_editor].editing { &self.ui_context[self.selected_value_editor].edit_buffer } else { &self.ui_context[self.selected_value_editor].text };
1357                     let val_trimmed = val_str.trim();
1358                     match serde_json::from_str::<serde_json::Value>(val_trimmed) {
1359                         Ok(mut parsed) => {
1360                             if let Some(num_f) = parsed.as_f64() {
1361                                 if let Some(annotation) = cce_ui::config::get_kdl_type_annotation(&self.ui_context[self.raw_json_editor].text, &self.flat_keys[idx].0) {
1362                                     if annotation.starts_with("f64:") {
1363                                         let range_str = annotation.trim_start_matches("f64:");
1364                                         if let Some(dash_idx) = range_str.find('-') {
1365                                             let min_str = &range_str[..dash_idx].trim();
1366                                             let max_str = &range_str[dash_idx + 1..].trim();
1367                                             if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
1368                                                 let clamped = num_f.clamp(min_f, max_f);
1369                                                 if let Some(num) = serde_json::Number::from_f64(clamped) {
1370                                                     parsed = serde_json::Value::Number(num);
1371                                                 }
1372                                             }
1373                                         }
1374                                     }
1375                                 }
1376                             }
1377                             self.flat_keys[idx].1 = parsed;
1378                             self.update_raw_from_flat();
1379                             self.status_message = Some(("Value applied".to_string(), false));
1380                             self.sync_preview_selection();
1381 
1382                             // Sync controls:
1383                             let val = &self.flat_keys[idx].1;
1384                             if let serde_json::Value::String(s) = val {
1385                                 if let Some(len) = cce_ui::units::Len::parse(s) {
1386                                     self.sync_len_editors(len);
1387                                 } else if let Some(c) = parse_hex_color(s) {
1388                                     self.ui_context[self.selected_color_editor].color = c;
1389                                 } else {
1390                                     self.ui_context[self.selected_font_editor].font_family = s.clone();
1391                                 }
1392                             } else if let Some(num) = val.as_i64() {
1393                                 self.ui_context[self.selected_spinbox_editor].value = num as i32;
1394                             }
1395                         }
1396                         Err(e) => {
1397                             if !val_trimmed.starts_with('"') && !val_trimmed.starts_with('[') && !val_trimmed.starts_with('{') {
1398                                 let parsed = serde_json::Value::String(val_trimmed.to_string());
1399                                 self.flat_keys[idx].1 = parsed;
1400                                 self.update_raw_from_flat();
1401                                 
1402                                 self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1403                                 self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1404                                 self.ui_context[self.selected_value_editor].editing = false;
1405                                 
1406                                 // Sync controls:
1407                                 let val = &self.flat_keys[idx].1;
1408                                 if let serde_json::Value::String(s) = val {
1409                                     if let Some(len) = cce_ui::units::Len::parse(s) {
1410                                         self.sync_len_editors(len);
1411                                     } else if let Some(c) = parse_hex_color(s) {
1412                                         self.ui_context[self.selected_color_editor].color = c;
1413                                     } else {
1414                                         self.ui_context[self.selected_font_editor].font_family = s.clone();
1415                                     }
1416                                 } else if let Some(num) = val.as_i64() {
1417                                     self.ui_context[self.selected_spinbox_editor].value = num as i32;
1418                                 }
1419 
1420                                 self.status_message = Some(("Value applied as string".to_string(), false));
1421                                 self.sync_preview_selection();
1422                             } else {
1423                                 self.status_message = Some((format!("Parse Error: must be valid JSON value ({})", e), true));
1424                             }
1425                         }
1426                     }
1427                 }
1428                 *needs_rebuild = true;
1429                 self.needs_rebuild = true;
1430             }
1431             AppMessage::DeleteKey => {
1432                 if let Some(idx) = self.selected_key_idx {
1433                     let name = self.flat_keys[idx].0.clone();
1434                     self.flat_keys.remove(idx);
1435                     self.selected_key_idx = None;
1436                     self.ui_context[self.selected_value_editor].text.clear();
1437                     self.ui_context[self.selected_value_editor].edit_buffer.clear();
1438                     self.ui_context[self.selected_value_editor].editing = false;
1439                     self.update_raw_from_flat();
1440                     self.sync_preview_selection();
1441                     self.status_message = Some((format!("Deleted key: {}", name), false));
1442                 }
1443                 *needs_rebuild = true;
1444                 self.needs_rebuild = true;
1445             }
1446         }
1447     }
1448 
1449     /// The disk-sync watch in `tick` is work the runner cannot see — nothing
1450     /// redraws until the file actually changes — so say how often the loop
1451     /// must come back rather than relying on its default idle cap.
1452     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
1453         Some(DISK_POLL_EVERY)
1454     }
1455 
1456     fn tick(&mut self, _dt: f32, needs_rebuild: &mut bool) {
1457         if self.ui_context.tick(_dt) {
1458             *needs_rebuild = true;
1459             self.needs_rebuild = true;
1460         }
1461         // Overflow rim for overhanging menus: sized from the FULL popover
1462         // geometry (stable per open, unlike the animated box) whenever a
1463         // dropdown is open, so the menu extends past the window edge instead
1464         // of clipping at the buffer. Quantized so it can't flap.
1465         {
1466             let mut need = 0.0f32;
1467             let (fw, fh) = (self.width as f32, self.height as f32);
1468             for (open, geom) in [
1469                 (self.ui_context[self.btn_open].popover_rect().is_some(), self.ui_context[self.btn_open].get_popover_geom()),
1470                 (self.ui_context[self.selected_choice_editor].popover_rect().is_some(), self.ui_context[self.selected_choice_editor].get_popover_geom()),
1471                 (self.ui_context[self.selected_unit_editor].popover_rect().is_some(), self.ui_context[self.selected_unit_editor].get_popover_geom()),
1472             ] {
1473                 if !open {
1474                     continue;
1475                 }
1476                 // Right/bottom rim only — a leading overhang would shift the
1477                 // surface origin (see Application::overflow_margin).
1478                 let (px, py, pw, ph) = geom;
1479                 need = need.max(px + pw - fw).max(py + ph - fh);
1480             }
1481             let quantized = if need > 0.0 {
1482                 (((need / 64.0).ceil() * 64.0) as u32).min(512)
1483             } else {
1484                 0
1485             };
1486             if quantized != self.overflow_now {
1487                 self.overflow_now = quantized;
1488                 *needs_rebuild = true;
1489                 self.needs_rebuild = true;
1490             }
1491         }
1492         // One-shot `--select` deep link. Deferred to here (not `new`) because
1493         // scroll_to_selected_key needs the tree's solved viewport height, which
1494         // exists only after the first frame has laid out.
1495         if self.pending_select.is_some() && self.ui_context[self.tree_list].scroll_box.viewport_h > 0.0 {
1496             let sel = self.pending_select.take().unwrap();
1497             // Exact key first; else treat it as a section prefix and land on
1498             // the section's first key ("style.status" → its first child).
1499             let key_path = if self.flat_keys.iter().any(|(k, _)| k == &sel) {
1500                 Some(sel.clone())
1501             } else {
1502                 let prefix = format!("{}.", sel);
1503                 self.flat_keys.iter().map(|(k, _)| k).find(|k| k.starts_with(&prefix)).cloned()
1504             };
1505             match key_path {
1506                 Some(kp) if self.ui_context[self.tree_list].select_and_show_key(&kp) => {
1507                     self.adopt_tree_selection();
1508                     *needs_rebuild = true;
1509                     self.needs_rebuild = true;
1510                 }
1511                 _ => {
1512                     self.status_message = Some((format!("--select: key '{}' not found", sel), true));
1513                     *needs_rebuild = true;
1514                     self.needs_rebuild = true;
1515                 }
1516             }
1517         }
1518         // Disk-sync watch: once a second, compare the open file's (mtime, len)
1519         // against the recorded loaded/saved state. Another writer touched it:
1520         // reload in place when the in-memory document is clean, else raise the
1521         // outdated indicator and leave the local edits alone.
1522         let now = std::time::Instant::now();
1523         if now >= self.disk_poll_at {
1524             self.disk_poll_at = now + DISK_POLL_EVERY;
1525             if let (Some(path), Some(recorded)) = (self.current_file_path.clone(), self.disk_state) {
1526                 let on_disk = std::fs::metadata(&path)
1527                     .ok()
1528                     .and_then(|m| m.modified().ok().map(|t| (t, m.len())));
1529                 if let Some(on_disk) = on_disk {
1530                     if on_disk != recorded && !self.file_outdated {
1531                         let content = if self.ui_context[self.raw_json_editor].editing {
1532                             &self.ui_context[self.raw_json_editor].edit_buffer
1533                         } else {
1534                             &self.ui_context[self.raw_json_editor].text
1535                         };
1536                         if hash_content(content) == self.disk_hash {
1537                             let mut exit = false;
1538                             self.update(AppMessage::RefreshDocument, needs_rebuild, &mut exit);
1539                             self.status_message = Some(("Reloaded: file changed on disk".to_string(), false));
1540                         } else {
1541                             self.file_outdated = true;
1542                             self.status_message = Some((
1543                                 "File changed on disk — unsaved edits here; Refresh discards them, Save overwrites the disk version".to_string(),
1544                                 true,
1545                             ));
1546                         }
1547                         *needs_rebuild = true;
1548                         self.needs_rebuild = true;
1549                     }
1550                 }
1551             }
1552         }
1553         if let Some((old_path, new_path)) = self.ui_context[self.tree_list].take_rename_request() {
1554             self.rename_key_path(&old_path, &new_path);
1555             *needs_rebuild = true;
1556             self.needs_rebuild = true;
1557         }
1558         if let Some(new_key) = self.ui_context[self.tree_list].take_new_key_path_request() {
1559             let trimmed = new_key.trim().to_string();
1560             if trimmed.is_empty() {
1561                 self.status_message = Some(("Key path cannot be empty".to_string(), true));
1562             } else if self.flat_keys.iter().any(|(k, _)| k == &trimmed) {
1563                 self.status_message = Some(("Key path already exists".to_string(), true));
1564             } else {
1565                 self.flat_keys.push((trimmed.clone(), serde_json::Value::String(String::new())));
1566                 self.update_raw_from_flat();
1567                 if let Some(idx) = self.flat_keys.iter().position(|(k, _)| k == &trimmed) {
1568                     self.selected_key_idx = Some(idx);
1569                     self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1570                     self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1571                     self.ui_context[self.selected_value_editor].editing = false;
1572                     self.sync_preview_selection();
1573                 }
1574                 self.status_message = Some((format!("Added key: {}", trimmed), false));
1575             }
1576             *needs_rebuild = true;
1577             self.needs_rebuild = true;
1578         }
1579         if self.ui_context.lend_h(self.selected_color_editor, |w, ctx| w.tick(_dt, ctx)).unwrap_or(false) {
1580             *needs_rebuild = true;
1581             self.needs_rebuild = true;
1582         }
1583         if self.ui_context.lend_h(self.selected_font_editor, |w, ctx| w.tick(_dt, ctx)).unwrap_or(false) {
1584             *needs_rebuild = true;
1585             self.needs_rebuild = true;
1586         }
1587         if self.ui_context.lend_h(self.selected_choice_editor, |w, ctx| w.tick(_dt, ctx)).unwrap_or(false) {
1588             *needs_rebuild = true;
1589             self.needs_rebuild = true;
1590         }
1591         if self.ui_context.lend_h(self.selected_unit_editor, |w, ctx| w.tick(_dt, ctx)).unwrap_or(false) {
1592             *needs_rebuild = true;
1593             self.needs_rebuild = true;
1594         }
1595 
1596         if self.ui_context[self.selected_value_editor].take_change() {
1597             let mut exit = false;
1598             self.update(AppMessage::ApplyValue, needs_rebuild, &mut exit);
1599         }
1600         if self.ui_context[self.selected_bool_editor].take_change() {
1601             if let Some(idx) = self.selected_key_idx {
1602                 let new_val = serde_json::Value::Bool(self.ui_context[self.selected_bool_editor].checked());
1603                 self.flat_keys[idx].1 = new_val;
1604                 self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1605                 self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1606                 self.ui_context[self.selected_value_editor].editing = false;
1607                 self.update_raw_from_flat();
1608                 self.sync_preview_selection();
1609                 *needs_rebuild = true;
1610                 self.needs_rebuild = true;
1611             }
1612         }
1613         if self.ui_context[self.selected_color_editor].take_change() {
1614             if let Some(idx) = self.selected_key_idx {
1615                 let hex_str = self.ui_context[self.selected_color_editor].get_value_string().unwrap_or_else(|| format_hex_color(self.ui_context[self.selected_color_editor].color));
1616                 let new_val = serde_json::Value::String(hex_str.clone());
1617                 self.flat_keys[idx].1 = new_val;
1618                 self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1619                 self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1620                 self.ui_context[self.selected_value_editor].editing = false;
1621                 self.update_raw_from_flat();
1622                 self.sync_preview_selection();
1623                 *needs_rebuild = true;
1624                 self.needs_rebuild = true;
1625             }
1626         }
1627         if self.ui_context[self.selected_spinbox_editor].take_change() {
1628             if let Some(idx) = self.selected_key_idx {
1629                 let new_val = serde_json::Value::Number(self.ui_context[self.selected_spinbox_editor].value.into());
1630                 self.flat_keys[idx].1 = new_val;
1631                 self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1632                 self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1633                 self.ui_context[self.selected_value_editor].editing = false;
1634                 self.update_raw_from_flat();
1635                 self.sync_preview_selection();
1636                 *needs_rebuild = true;
1637                 self.needs_rebuild = true;
1638             }
1639         }
1640         if self.ui_context[self.selected_len_editor].take_change() {
1641             if let Some(idx) = self.selected_key_idx {
1642                 if let Some(unit) = self.selected_unit() {
1643                     let len = cce_ui::units::Len::new(self.ui_context[self.selected_len_editor].value as f32 / 100.0, unit);
1644                     self.commit_len(idx, len);
1645                     *needs_rebuild = true;
1646                     self.needs_rebuild = true;
1647                 }
1648             }
1649         }
1650         if self.ui_context[self.selected_unit_editor].take_change() {
1651             if let Some(idx) = self.selected_key_idx {
1652                 if let (Some(cur), Some(unit)) = (len_of(&self.flat_keys[idx].1), self.selected_unit()) {
1653                     // The same length in the new unit, through the display
1654                     // metric — on an assumed metric that is the CSS 96 ppi
1655                     // guess, which is all a conversion can honestly be there.
1656                     let len = cur.convert(unit, &cce_ui::units::metric());
1657                     self.commit_len(idx, len);
1658                     *needs_rebuild = true;
1659                     self.needs_rebuild = true;
1660                 }
1661             }
1662         }
1663         if self.ui_context[self.selected_font_editor].take_change() {
1664             if let Some(idx) = self.selected_key_idx {
1665                 let new_val = serde_json::Value::String(self.ui_context[self.selected_font_editor].font_family.clone());
1666                 self.flat_keys[idx].1 = new_val;
1667                 self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1668                 self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1669                 self.ui_context[self.selected_value_editor].editing = false;
1670                 self.update_raw_from_flat();
1671                 self.sync_preview_selection();
1672                 *needs_rebuild = true;
1673                 self.needs_rebuild = true;
1674             }
1675         }
1676         if self.ui_context[self.selected_choice_editor].take_change() {
1677             if let Some(idx) = self.selected_key_idx {
1678                 let selected_opt = self.ui_context[self.selected_choice_editor].options[self.ui_context[self.selected_choice_editor].selected].clone();
1679                 let new_val = match &self.flat_keys[idx].1 {
1680                     serde_json::Value::Bool(_) => {
1681                         serde_json::Value::Bool(selected_opt.parse::<bool>().unwrap_or(false))
1682                     }
1683                     serde_json::Value::Number(_) => {
1684                         if let Ok(i) = selected_opt.parse::<i64>() {
1685                             serde_json::Value::Number(i.into())
1686                         } else if let Ok(f) = selected_opt.parse::<f64>() {
1687                             serde_json::Value::Number(serde_json::Number::from_f64(f).unwrap())
1688                         } else {
1689                             serde_json::Value::String(selected_opt)
1690                         }
1691                     }
1692                     _ => serde_json::Value::String(selected_opt),
1693                 };
1694                 self.flat_keys[idx].1 = new_val;
1695                 self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1696                 self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1697                 self.ui_context[self.selected_value_editor].editing = false;
1698                 self.update_raw_from_flat();
1699                 self.sync_preview_selection();
1700                 *needs_rebuild = true;
1701                 self.needs_rebuild = true;
1702             }
1703         }
1704         if self.ui_context.lend_h(self.selected_keybind_editor, |w, ctx| w.tick(_dt, ctx)).unwrap_or(false) {
1705             *needs_rebuild = true;
1706             self.needs_rebuild = true;
1707         }
1708         if self.ui_context[self.selected_keybind_editor].take_change() {
1709             if let Some(idx) = self.selected_key_idx {
1710                 let new_val = serde_json::Value::String(self.ui_context[self.selected_keybind_editor].value.clone());
1711                 self.flat_keys[idx].1 = new_val;
1712                 self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1713                 self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1714                 self.ui_context[self.selected_value_editor].editing = false;
1715                 self.update_raw_from_flat();
1716                 self.sync_preview_selection();
1717                 *needs_rebuild = true;
1718                 self.needs_rebuild = true;
1719             }
1720         }
1721         if self.ui_context[self.raw_json_editor].take_change() {
1722             let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
1723             if content.parse::<kdl::KdlDocument>().is_ok() {
1724                 let val = cce_ui::config::parse_kdl_to_json(content);
1725                 let mut new_flat = Vec::new();
1726                 flatten_json(&val, "", &mut new_flat);
1727                 
1728                 let selected_key = self.selected_key_idx.and_then(|idx| self.flat_keys.get(idx).map(|(k, _)| k.clone()));
1729                 self.flat_keys = new_flat;
1730                 
1731                 if let Some(k) = selected_key {
1732                     self.selected_key_idx = self.flat_keys.iter().position(|(key, _)| key == &k);
1733                     if let Some(idx) = self.selected_key_idx {
1734                         self.ui_context[self.selected_value_editor].text = serde_json::to_string(&self.flat_keys[idx].1).unwrap_or_default();
1735                         if !self.ui_context[self.selected_value_editor].editing {
1736                             self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
1737                         }
1738                         
1739                         // Sync controls:
1740                         let val = &self.flat_keys[idx].1;
1741                         if let serde_json::Value::String(s) = val {
1742                             if let Some(len) = cce_ui::units::Len::parse(s) {
1743                                 self.sync_len_editors(len);
1744                             } else if let Some(c) = parse_hex_color(s) {
1745                                 self.ui_context[self.selected_color_editor].color = c;
1746                             } else {
1747                                 self.ui_context[self.selected_font_editor].font_family = s.clone();
1748                             }
1749                         } else if let Some(num) = val.as_i64() {
1750                             self.ui_context[self.selected_spinbox_editor].value = num as i32;
1751                         } else if let serde_json::Value::Bool(b) = val {
1752                             self.ui_context[self.selected_bool_editor].set_checked(*b);
1753                         }
1754                     } else {
1755                         self.ui_context[self.selected_value_editor].text.clear();
1756                         self.ui_context[self.selected_value_editor].edit_buffer.clear();
1757                         self.ui_context[self.selected_value_editor].editing = false;
1758                     }
1759                 }
1760                 if !self.ui_context[self.raw_json_editor].editing {
1761                     self.sync_preview_selection();
1762                 }
1763                 self.status_message = None;
1764             } else {
1765                 self.status_message = Some(("KDL syntax error in text editor".to_string(), true));
1766             }
1767             *needs_rebuild = true;
1768             self.needs_rebuild = true;
1769         }
1770     }
1771 
1772     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
1773         // Phase 6 single paint path: setup/relayout (the old view() body), then the whole
1774         // frame — the root plate container tree walked into one list plus the toolbar file label — is
1775         // built here. Widget text comes from the paint walk; TreeList (a legacy subtree
1776         // painter) serves its rows' text through the walk's bounded-getter emission.
1777         if !self.spatial_grid_built {
1778             self.spatial_grid_built = true;
1779             self.ui_context.rebuild_spatial_grid();
1780         }
1781 
1782         if self.initial_focus {
1783             self.initial_focus = false;
1784             self.ui_context.set_focused_id(self.raw_json_editor.id());
1785             WidgetHost::focus(&mut self.ui_context[self.raw_json_editor]);
1786             self.needs_rebuild = true;
1787         }
1788         
1789         // Overflow-margin mode: `size` is the SURFACE (frame + right/bottom
1790         // rim sized to an overhanging popover). Frame coords == surface
1791         // coords; the only obligation is laying out against the frame.
1792         let ov = self.overflow_now as f32;
1793         let frame_w = (size.width as f32 - ov).max(1.0);
1794         let frame_h = (size.height as f32 - ov).max(1.0);
1795         let size_changed = self.width != frame_w as u32 || self.height != frame_h as u32 || self.scale_factor != scale;
1796         if self.needs_rebuild || size_changed {
1797             self.width = frame_w as u32;
1798             self.height = frame_h as u32;
1799             self.scale_factor = scale;
1800             cce_ui::scale::set_scale_factor(scale as f32);
1801             
1802             // Layout via the scene solver (Phase 6ac): the frame is a stretched column
1803             // [menubar (fixed 42, padded 10/8, holding the File-menu leaf), content row
1804             // (grow, padded 10, gap = the divider width, panes growing by the SplitPane
1805             // fractions), statusbar (fixed 30)]. Solves to the exact 6aa hand rects; the
1806             // SplitPane keeps the divider drag/hover state and its rect derives from the
1807             // solved panes. The inline value editors stay hand-positioned below — they
1808             // float over tree rows, not a static tree.
1809             {
1810                 use cce_ui::scene::arena::Arena;
1811                 use cce_ui::scene::layout::{
1812                     compute_layout, CrossAlign, Edges, LayoutBox, Length, Size as LSize, Style,
1813                 };
1814                 let mut arena: Arena<LayoutBox> = Arena::new();
1815                 let root = arena.insert(LayoutBox::container(
1816                     Style::column().cross_align(CrossAlign::Stretch),
1817                 ));
1818                 // The window-edge inset (the root plate's roll plus one
1819                 // padding); the pane gap is the SplitPane's, seeded from
1820                 // root_plate_gap.
1821                 let plate_pad = cce_ui::layout::root_plate_inset();
1822                 // The File dropdown nests into the window's top-left corner:
1823                 // equal gap to the left and top edges, so its corner_frame
1824                 // adjustment (below) rounds it concentric with the plate.
1825                 let menu_gap = 8.0;
1826                 let menu_h = cce_ui::layout::dropdown_height();
1827                 let top_bar = arena.insert(LayoutBox::container({
1828                     let mut s = Style::row().height(Length::Fixed(42.0));
1829                     s.padding = Edges { left: menu_gap, right: plate_pad, top: menu_gap, bottom: 42.0 - menu_gap - menu_h };
1830                     s
1831                 }));
1832                 let menu_leaf = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(70.0, menu_h)));
1833                 let content = arena.insert(LayoutBox::container({
1834                     let mut s = Style::row()
1835                         .grow(1.0)
1836                         .padding(plate_pad)
1837                         .gap(self.split.gap)
1838                         .cross_align(CrossAlign::Stretch);
1839                     s.min_height = 120.0;
1840                     s
1841                 }));
1842                 let tree_pane = arena.insert(LayoutBox::container(Style::column().grow(self.split.frac)));
1843                 let editor_pane = arena.insert(LayoutBox::container(Style::column().grow(1.0 - self.split.frac)));
1844                 let status = arena.insert(LayoutBox::container(
1845                     Style::column().height(Length::Fixed(30.0)),
1846                 ));
1847                 arena.append_child(root, top_bar);
1848                 arena.append_child(top_bar, menu_leaf);
1849                 arena.append_child(root, content);
1850                 arena.append_child(content, tree_pane);
1851                 arena.append_child(content, editor_pane);
1852                 arena.append_child(root, status);
1853                 compute_layout(&mut arena, root, LSize::new(self.width as f32, self.height as f32));
1854 
1855                 let tb = arena.value(top_bar).unwrap().rect;
1856                 self.ui_context[self.menubar].set_rect(tb.x, tb.y, tb.width, tb.height);
1857                 let mb = arena.value(menu_leaf).unwrap().rect;
1858                 self.ui_context[self.btn_open].set_rect(mb.x, mb.y, mb.width, mb.height);
1859                 // Concentric with the window plate: equal left/top gaps make
1860                 // the corner_frame adjustment round the dropdown's top-left
1861                 // corner to (plate radius - gap), following the window curve.
1862                 self.ui_context[self.btn_open].set_corner_frame(Some((
1863                     (0.0, 0.0, self.width as f32, self.height as f32),
1864                     // The concentric nesting follows the SILHOUETTE arc the
1865                     // plate now wears (cce-ui RFC 7b), not the un-spanned
1866                     // nominal radius.
1867                     cce_ui::layout::window_silhouette_radius(),
1868                     (true, true, true, true),
1869                 )));
1870                 let tr = arena.value(tree_pane).unwrap().rect;
1871                 self.ui_context[self.tree_list].set_rect(tr.x, tr.y, tr.width, tr.height);
1872                 let er = arena.value(editor_pane).unwrap().rect;
1873                 self.ui_context[self.raw_json_editor].set_rect(er.x, er.y, er.width, er.height);
1874                 let st = arena.value(status).unwrap().rect;
1875                 self.ui_context[self.statusbar].set_rect(st.x, st.y, st.width, st.height);
1876                 // The divider's hit/drag frame spans both panes (same fractions, same math).
1877                 self.split.set_rect(tr.x, tr.y, (er.x + er.width) - tr.x, tr.height);
1878             }
1879 
1880             // Position the selected value editor inline inside the list if visible
1881             if let Some(selected_idx) = self.selected_key_idx {
1882                 if let Some((row_x, row_y, _row_w, row_h)) = self.ui_context[self.tree_list].get_row_rect(selected_idx) {
1883                     // Each editor at its own toolkit control height, centred
1884                     // on the tree row.
1885                     let at = |h: f32| row_y + (row_h - h) / 2.0;
1886                     let btn_h = cce_ui::layout::button_height();
1887                     let tb_h = cce_ui::layout::textbox_height();
1888                     let sb_h = cce_ui::layout::spinbox_height();
1889                     let dd_h = cce_ui::layout::dropdown_height();
1890                     let tg_h = cce_ui::layout::toggle_height();
1891                     let cs_h = cce_ui::layout::color_selector_height();
1892                     let fs_h = cce_ui::layout::font_selector_height();
1893                     let val = &self.flat_keys[selected_idx].1;
1894                     let key_name = &self.flat_keys[selected_idx].0;
1895                     let is_font_type = key_name == "font" || key_name.ends_with("_font") || key_name.ends_with(".font");
1896                     
1897                     let mut is_menu_type = false;
1898                     let mut menu_options = Vec::new();
1899                     let mut is_button_type = false;
1900                     let mut is_bevel_type = false;
1901                     let mut is_relief_type = false;
1902                     let mut is_ramp_type = false;
1903                     let mut annotation_str = None;
1904                     if let Some(annotation) = cce_ui::config::get_kdl_type_annotation(&self.ui_context[self.raw_json_editor].text, key_name) {
1905                         annotation_str = Some(annotation.clone());
1906                         if annotation.starts_with("menu:") {
1907                             is_menu_type = true;
1908                             let opts_str = annotation.trim_start_matches("menu:");
1909                             menu_options = opts_str.split(',').map(|s| s.trim().to_string()).collect();
1910                         } else if annotation == "button" || annotation.starts_with("button:") {
1911                             is_button_type = true;
1912                         } else if annotation == "bevel" {
1913                             is_bevel_type = true;
1914                         } else if annotation == "relief" {
1915                             is_relief_type = true;
1916                         } else if annotation == "ramp" {
1917                             is_ramp_type = true;
1918                         }
1919                     }
1920                     // Ramp-spec strings under ramp-named keys get the ramp
1921                     // treatment before the annotation exists (overview_ramp
1922                     // shipped unannotated) — but only when the value actually
1923                     // parses as a spec, so an unrelated *_ramp key stays a
1924                     // plain string row.
1925                     if !is_ramp_type
1926                         && (key_name == "ramp" || key_name.ends_with("_ramp") || key_name.ends_with(".ramp"))
1927                     {
1928                         if let serde_json::Value::String(st) = val {
1929                             if cce_ui::widget::parse_ramp_spec(st).is_some() {
1930                                 is_ramp_type = true;
1931                             }
1932                         }
1933                     }
1934                     // line_relief keys get the relief treatment even in
1935                     // integer form (the (relief) annotation only appears once
1936                     // a material was saved): the preview is the affordance
1937                     // that opens cce-relief --key on them — same key-name
1938                     // heuristic convention as fonts and keybinds.
1939                     if !is_relief_type
1940                         && !is_bevel_type
1941                         && (key_name == "line_relief" || key_name.ends_with(".line_relief"))
1942                     {
1943                         is_relief_type = true;
1944                     }
1945                     // A length: the annotation is a unit, or the value is a
1946                     // unit-suffixed string (the two are the same thing seen
1947                     // from the KDL and the JSON side).
1948                     let is_len_type = annotation_str.as_deref().and_then(cce_ui::units::Unit::parse).is_some()
1949                         || len_of(val).is_some();
1950                     // Parked unless the bevel/ramp/length branch below places
1951                     // it (the other branches never show them, so one shared
1952                     // park suffices).
1953                     self.ui_context[self.selected_bevel_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1954                     self.ui_context[self.selected_ramp_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1955                     self.ui_context[self.selected_len_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1956                     self.ui_context[self.selected_unit_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1957 
1958                     if is_len_type {
1959                         // Number and unit side by side in the value cell. An
1960                         // annotated bare number (`width=(mm)2` reads as the
1961                         // string "2mm" already, so `len_of` covers both).
1962                         if let Some(len) = len_of(val) {
1963                             self.sync_len_editors(len);
1964                         }
1965                         self.ui_context[self.selected_len_editor].set_rect(row_x + 245.0, at(sb_h), 70.0, sb_h);
1966                         self.ui_context[self.selected_unit_editor].set_rect(row_x + 319.0, at(dd_h), 54.0, dd_h);
1967                         self.ui_context[self.selected_choice_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1968                         self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1969                         self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1970                         self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1971                         self.ui_context[self.selected_keybind_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1972                         self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1973                         self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1974                         self.ui_context[self.selected_button_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1975                     } else if is_bevel_type || is_relief_type {
1976                         // The (bevel)/(relief) preview: a mini lit
1977                         // cross-section of the knob triple; clicking it opens
1978                         // cce-relief (targeted at the KEY for a relief value).
1979                         if is_relief_type {
1980                             let knobs = if let serde_json::Value::String(st) = val {
1981                                 cce_ui::relief_spec::ReliefSpec::parse(st).and_then(|s| s.knobs)
1982                             } else {
1983                                 None
1984                             };
1985                             let (a, b, c) = knobs.unwrap_or((0.5, 0.5, 0.5));
1986                             self.ui_context[self.selected_bevel_editor].set_knobs_str(&format!("{a:.3},{b:.3},{c:.3}"));
1987                         } else if let serde_json::Value::String(st) = val {
1988                             self.ui_context[self.selected_bevel_editor].set_knobs_str(st);
1989                         }
1990                         self.ui_context[self.selected_bevel_editor].set_rect(row_x + 245.0, row_y + 1.0, 125.0, 26.0);
1991                         self.ui_context[self.selected_choice_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1992                         self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1993                         self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1994                         self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1995                         self.ui_context[self.selected_keybind_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1996                         self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1997                         self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1998                         self.ui_context[self.selected_button_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
1999                     } else if is_ramp_type {
2000                         // The (ramp) preview: the spec's curve as a polyline;
2001                         // clicking it opens cce-ramp --key on this key.
2002                         if let serde_json::Value::String(st) = val {
2003                             self.ui_context[self.selected_ramp_editor].set_spec_str(st);
2004                         }
2005                         self.ui_context[self.selected_ramp_editor].set_rect(row_x + 245.0, row_y + 1.0, 125.0, 26.0);
2006                         self.ui_context[self.selected_choice_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2007                         self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2008                         self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2009                         self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2010                         self.ui_context[self.selected_keybind_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2011                         self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2012                         self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2013                         self.ui_context[self.selected_button_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2014                     } else if is_menu_type {
2015                         self.ui_context[self.selected_choice_editor].options = menu_options.clone();
2016                         let val_str = match val {
2017                             serde_json::Value::String(st) => st.clone(),
2018                             serde_json::Value::Bool(b) => b.to_string(),
2019                             serde_json::Value::Number(n) => n.to_string(),
2020                             _ => String::new(),
2021                         };
2022                         if let Some(pos) = menu_options.iter().position(|o| o == &val_str) {
2023                             self.ui_context[self.selected_choice_editor].selected = pos;
2024                         } else {
2025                             self.ui_context[self.selected_choice_editor].selected = 0;
2026                         }
2027                         self.ui_context[self.selected_choice_editor].set_rect(row_x + 245.0, at(dd_h), 125.0, dd_h);
2028                         self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2029                         self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2030                         self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2031                         self.ui_context[self.selected_keybind_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2032                         self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2033                         self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2034                         self.ui_context[self.selected_button_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2035                     } else if is_button_type {
2036                         let val_str = match val {
2037                             serde_json::Value::String(st) => st.clone(),
2038                             _ => "Trigger".to_string(),
2039                         };
2040                         self.ui_context[self.selected_button_editor].base_mut().label = Some(val_str);
2041                         self.ui_context[self.selected_button_editor].set_rect(row_x + 245.0, at(btn_h), 125.0, btn_h);
2042                         self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2043                         self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2044                         self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2045                         self.ui_context[self.selected_keybind_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2046                         self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2047                         self.ui_context[self.selected_choice_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2048                         self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2049                     } else {
2050                         self.ui_context[self.selected_button_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2051                         self.ui_context[self.selected_choice_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2052                         let is_keybind_type = key_name == "key" || key_name == "keybind" || key_name == "shortcut" || key_name == "delete" || key_name.ends_with("_key") || key_name.ends_with(".key") || key_name.ends_with(".keybind") || key_name.ends_with("_delete") || key_name.ends_with(".delete") || key_name == "brightness_up" || key_name == "brightness_down" || key_name.ends_with(".brightness_up") || key_name.ends_with(".brightness_down") || annotation_str.as_deref() == Some("keybind");
2053                         if is_keybind_type {
2054                             let val_str = match val {
2055                                 serde_json::Value::String(st) => st.clone(),
2056                                 _ => String::new(),
2057                             };
2058                             self.ui_context[self.selected_keybind_editor].value = val_str;
2059                             self.ui_context[self.selected_keybind_editor].set_rect(row_x + 245.0, at(tb_h), 125.0, tb_h);
2060                             self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2061                             self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2062                             self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2063                             self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2064                             self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2065                         } else {
2066                             self.ui_context[self.selected_keybind_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2067                             if let serde_json::Value::String(s) = val {
2068                                 self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2069                                 if s.starts_with('#') {
2070                                     self.ui_context[self.selected_color_editor].set_rect(row_x + 245.0, at(cs_h), 125.0, cs_h);
2071                                     self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2072                                     self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2073                                     self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2074                                 } else if is_font_type {
2075                                     self.ui_context[self.selected_font_editor].set_rect(row_x + 245.0, at(fs_h), 125.0, fs_h);
2076                                     self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2077                                     self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2078                                     self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2079                                 } else {
2080                                     self.ui_context[self.selected_value_editor].set_rect(row_x + 245.0, at(tb_h), 125.0, tb_h);
2081                                     self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2082                                     self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2083                                     self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2084                                 }
2085                             } else if val.is_i64() {
2086                                 self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2087                                 self.ui_context[self.selected_spinbox_editor].set_rect(row_x + 245.0, at(sb_h), 125.0, sb_h);
2088                                 self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2089                                 self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2090                                 self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2091                             } else if val.is_boolean() {
2092                                 self.ui_context[self.selected_bool_editor].set_rect(row_x + 245.0, at(tg_h), tg_h, tg_h);
2093                                 self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2094                                 self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2095                                 self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2096                                 self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2097                             } else {
2098                                 self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2099                                 self.ui_context[self.selected_value_editor].set_rect(row_x + 245.0, at(tb_h), 125.0, tb_h);
2100                                 self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2101                                 self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2102                                 self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2103                             }
2104                         }
2105                     }
2106                 } else {
2107                     self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2108                     self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2109                     self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2110                     self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2111                     self.ui_context[self.selected_choice_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2112                     self.ui_context[self.selected_keybind_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2113                     self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2114                     self.ui_context[self.selected_button_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2115                     self.ui_context[self.selected_bevel_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2116                     self.ui_context[self.selected_ramp_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2117                     self.ui_context[self.selected_len_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2118                     self.ui_context[self.selected_unit_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2119                 }
2120             } else {
2121                 self.ui_context[self.selected_value_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2122                 self.ui_context[self.selected_color_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2123                 self.ui_context[self.selected_spinbox_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2124                 self.ui_context[self.selected_font_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2125                 self.ui_context[self.selected_choice_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2126                 self.ui_context[self.selected_keybind_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2127                 self.ui_context[self.selected_bool_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2128                 self.ui_context[self.selected_len_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2129                 self.ui_context[self.selected_unit_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2130                 self.ui_context[self.selected_button_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2131                 self.ui_context[self.selected_bevel_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2132                 self.ui_context[self.selected_ramp_editor].set_rect(-1000.0, -1000.0, 1.0, 1.0);
2133             }
2134             
2135 
2136             
2137             self.sync_editor_presence();
2138             self.refresh_widget_text();
2139             self.ui_context.rebuild_spatial_grid();
2140             self.needs_rebuild = false;
2141         }
2142 
2143         // Popover registration — ui_context ONLY (drives the engine's dl-text occlusion
2144         // clamp). The popovers and the context menu draw into this display list below;
2145         // the global registry fed the engine's render-only xdg popup, no longer used.
2146         self.ui_context.clear_popovers();
2147         if self.ui_context[self.selected_choice_editor].popover_rect().is_some() {
2148             self.ui_context.register_popover_id(self.selected_choice_editor.id());
2149         }
2150         if self.ui_context[self.selected_unit_editor].popover_rect().is_some() {
2151             self.ui_context.register_popover_id(self.selected_unit_editor.id());
2152         }
2153         if self.ui_context[self.btn_open].popover_rect().is_some() {
2154             self.ui_context.register_popover_id(self.btn_open.id());
2155         }
2156 
2157         let mut pc = cce_ui::scene::paint::PaintCtx::new();
2158 
2159         // The standard root plate (cce-ui `PlateSpec::window`): one glass slab,
2160         // the fill plus a rolled, lit perimeter. The menubar and statusbar then
2161         // sink into this surface as steps (see their with_recess), so the whole
2162         // window reads as a single piece with varying depth rather than stacked
2163         // opaque bars. A square-cornered config (silhouette radius 0) gets the
2164         // flat fill the plate would degenerate to anyway.
2165         {
2166             use cce_ui::scene::layout::Rect;
2167             let (w, h) = (self.width as f32, self.height as f32);
2168             if cce_ui::layout::window_silhouette_radius() > 0.1 {
2169                 pc.root_plate(w, h);
2170             } else {
2171                 let plate_color = cce_ui::color::page_low_color();
2172                 if plate_color[3] > 0.001 {
2173                     pc.quad(Rect { x: 0.0, y: 0.0, width: w, height: h }, plate_color);
2174                 }
2175             }
2176         }
2177 
2178         // Top-level widgets walked in the old child order (menubar, statusbar, top bar, then
2179         // the dissolved splitter's slot: its divider quad and the two panes walked as
2180         // separate roots), and the inline editors LAST.
2181         {
2182             // The walk takes shared borrows now — no self-alias, no pointers.
2183             let tops: [&dyn cce_ui::widget::WidgetHost; 3] =
2184                 [&self.ui_context[self.menubar], &self.ui_context[self.statusbar], &self.ui_context[self.btn_open]];
2185             for top in tops {
2186                 cce_ui::scene::painter::paint_root_into(&self.ui_context, top, &mut pc);
2187             }
2188             {
2189                 use cce_ui::scene::layout::Rect;
2190                 let (dx, dy, dw, dh, dc) = self.split.divider_quad();
2191                 pc.quad(Rect { x: dx, y: dy, width: dw, height: dh }, dc);
2192             }
2193             {
2194                 let panes: [&dyn cce_ui::widget::WidgetHost; 2] = [&self.ui_context[self.tree_list], &self.ui_context[self.raw_json_editor]];
2195                 for pane in panes {
2196                     cce_ui::scene::painter::paint_root_into(&self.ui_context, pane, &mut pc);
2197                 }
2198             }
2199             // The inline value editors float OVER the tree's rows, so they have to be
2200             // painted after it: TreeList fills a background quad for every visible row,
2201             // including the selected one it leaves a value-cell hole in. Painted before the
2202             // tree, an editor's box, border and caret all landed under that quad — only its
2203             // text survived, because the engine draws every label after all geometry. Hence
2204             // "the value control has no caret".
2205             let editors: [&dyn cce_ui::widget::WidgetHost; 12] = [
2206                 &self.ui_context[self.selected_len_editor],
2207                 &self.ui_context[self.selected_unit_editor],
2208                 &self.ui_context[self.selected_value_editor],
2209                 &self.ui_context[self.selected_color_editor],
2210                 &self.ui_context[self.selected_spinbox_editor],
2211                 &self.ui_context[self.selected_font_editor],
2212                 &self.ui_context[self.selected_choice_editor],
2213                 &self.ui_context[self.selected_keybind_editor],
2214                 &self.ui_context[self.selected_bool_editor],
2215                 &self.ui_context[self.selected_button_editor],
2216                 &self.ui_context[self.selected_bevel_editor],
2217                 &self.ui_context[self.selected_ramp_editor],
2218             ];
2219             for editor in editors {
2220                 cce_ui::scene::painter::paint_root_into(&self.ui_context, editor, &mut pc);
2221             }
2222         }
2223 
2224         // Toolbar file label — app chrome, not owned by any widget. Outdated
2225         // (changed on disk under local edits) renders in the highlight accent.
2226         let file_name_str = match &self.current_file_path {
2227             Some(path) => path.display().to_string(),
2228             None => "Untitled".to_string(),
2229         };
2230         let (file_label, file_label_color) = if self.file_outdated {
2231             let hc = cce_ui::color::highlight_primary_color();
2232             (
2233                 format!("File: {} — changed on disk", file_name_str),
2234                 [(hc[0] * 255.0) as u8, (hc[1] * 255.0) as u8, (hc[2] * 255.0) as u8],
2235             )
2236         } else {
2237             (format!("File: {}", file_name_str), [0xdd, 0xdd, 0xe2])
2238         };
2239         pc.text_with(
2240             file_label,
2241             self.ui_context[self.raw_json_editor].rect().0 + 10.0,
2242             15.0,
2243             12.0,
2244             file_label_color,
2245             Some(cce_ui::layout::menubar_font()),
2246             None,
2247         );
2248 
2249         // Popovers + the global context menu — geometry and labels last, on top of
2250         // everything, exactly where they hit-test (the engine xdg popup is gone). Labels
2251         // carry bounds equal to their overlay rect: clips them to the plate and exempts
2252         // them from the dl-text occlusion clamp (the is-overlay-text convention).
2253         {
2254             for &pop_id in &self.ui_context.active_popovers {
2255                 let Some(popover) = self.ui_context.get_widget(pop_id) else { continue };
2256                 if popover.popover_rect().is_none() {
2257                     continue;
2258                 }
2259                 // PaintCtx is a RenderTarget: the popover draws its REAL prims
2260                 // (the dropdown's expanded inset-plate surface) with its own
2261                 // per-label bounds — no flattening collector round-trip.
2262                 popover.render_popover(&mut pc);
2263             }
2264             // The lit plate and the menu font in one call — the flat
2265             // `extra_quads` look was the pre-frost menu the other apps
2266             // have moved off.
2267             cce_ui::widget::context_menu::paint_with_labels(&mut pc);
2268         }
2269 
2270         Some(pc.finish())
2271     }
2272 
2273     fn display_list_text(&self) -> bool {
2274         true
2275     }
2276 
2277     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
2278         let mut changed = false;
2279         let px = pos.x as f32;
2280         let py = pos.y as f32;
2281 
2282         if self.split.cursor_moved(px, py) {
2283             changed = true;
2284         }
2285 
2286         if cce_ui::widget::context_menu::is_visible() {
2287             if cce_ui::widget::context_menu::cursor_moved(px, py) {
2288                 changed = true;
2289             }
2290         } else {
2291             // Routed dispatch (Phase 6ac): one PointerMove through the router per root
2292             // (hover bookkeeping, Enter/Leave synthesis, drag forwarding).
2293             let ev = cce_ui::widget::Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
2294             if self.ui_context.propagate_event(&ev, self.btn_open.id()) { changed = true; }
2295             if self.ui_context.propagate_event(&ev, self.selected_value_editor.id()) { changed = true; }
2296             if self.ui_context.propagate_event(&ev, self.selected_color_editor.id()) { changed = true; }
2297             if self.ui_context.propagate_event(&ev, self.selected_spinbox_editor.id()) { changed = true; }
2298             if self.ui_context.propagate_event(&ev, self.selected_font_editor.id()) { changed = true; }
2299             if self.ui_context.propagate_event(&ev, self.selected_choice_editor.id()) { changed = true; }
2300             if self.ui_context.propagate_event(&ev, self.selected_len_editor.id()) { changed = true; }
2301             if self.ui_context.propagate_event(&ev, self.selected_unit_editor.id()) { changed = true; }
2302             if self.ui_context.propagate_event(&ev, self.selected_keybind_editor.id()) { changed = true; }
2303             if self.ui_context.propagate_event(&ev, self.selected_bool_editor.id()) { changed = true; }
2304             if self.ui_context.propagate_event(&ev, self.selected_button_editor.id()) { changed = true; }
2305             if self.ui_context.propagate_event(&ev, self.selected_bevel_editor.id()) { changed = true; }
2306             if self.ui_context.propagate_event(&ev, self.selected_ramp_editor.id()) { changed = true; }
2307             if self.ui_context.propagate_event(&ev, self.raw_json_editor.id()) { changed = true; }
2308 
2309             if self.ui_context.propagate_event(&ev, self.tree_list.id()) { changed = true; }
2310         }
2311 
2312         if changed {
2313             *needs_rebuild = true;
2314             self.needs_rebuild = true;
2315         }
2316     }
2317 
2318     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
2319         println!("[DEBUG] handle_mouse_input: button={:?}, state={:?}, pos=({:.1}, {:.1})", button, state, pos.x, pos.y);
2320         let mut changed = false;
2321         let mut msg_out = None;
2322         let px = pos.x as f32;
2323         let py = pos.y as f32;
2324 
2325         // Routed dispatch (Phase 6ac): one MouseButton event through the router per
2326         // root — presses are hit-gated per widget, releases delivered everywhere, drag
2327         // targets recorded; the interleaved take_* plumbing below is unchanged.
2328         let mouse_ev = cce_ui::widget::Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
2329 
2330         // The dissolved splitter's divider: a left press grabs it (stealing keyboard
2331         // focus like the legacy ctx.set_focused_ptr / clear_focus pair did), a release
2332         // ends the drag.
2333         if button == MouseButton::Left {
2334             if state == ElementState::Pressed {
2335                 if self.split.press(px, py) {
2336                     self.ui_context.clear_focus();
2337                     self.sync_tree_focus_rim();
2338                     *needs_rebuild = true;
2339                     self.needs_rebuild = true;
2340                     return None;
2341                 }
2342             } else if self.split.release() {
2343                 *needs_rebuild = true;
2344                 self.needs_rebuild = true;
2345                 return None;
2346             }
2347         }
2348 
2349         if cce_ui::widget::context_menu::is_visible() {
2350             if cce_ui::widget::context_menu::mouse_input(button, state, px, py, Some(&mut self.ui_context)) {
2351                 changed = true;
2352             }
2353             if let Some(_deleted_path) = self.ui_context[self.tree_list].take_deleted_key_path() {
2354                 if let Some(idx) = self.ui_context[self.tree_list].selected_key_idx {
2355                     self.selected_key_idx = Some(idx);
2356                 }
2357                 msg_out = Some(AppMessage::DeleteKey);
2358             }
2359             if changed || msg_out.is_some() {
2360                 *needs_rebuild = true;
2361                 self.needs_rebuild = true;
2362             }
2363             if msg_out.is_some() {
2364                 return msg_out;
2365             }
2366             return None;
2367         }
2368 
2369         if self.ui_context.propagate_event(&mouse_ev, self.btn_open.id()) {
2370             changed = true;
2371             if self.ui_context[self.btn_open].take_change() {
2372                 let selected_idx = self.ui_context[self.btn_open].selected;
2373                 if selected_idx < self.ui_context[self.btn_open].options.len() {
2374                     let option_text = &self.ui_context[self.btn_open].options[selected_idx];
2375                     match option_text.as_str() {
2376                         "Save" => {
2377                             println!("[DEBUG] File menu selected 'Save', Dispatching SaveDocument");
2378                             msg_out = Some(AppMessage::SaveDocument);
2379                         }
2380                         "Save As" => {
2381                             println!("[DEBUG] File menu selected 'Save As', Dispatching SaveDocumentAs");
2382                             msg_out = Some(AppMessage::SaveDocumentAs);
2383                         }
2384                         "Refresh" => {
2385                             println!("[DEBUG] File menu selected 'Refresh', Dispatching RefreshDocument");
2386                             msg_out = Some(AppMessage::RefreshDocument);
2387                         }
2388                         "Format" => {
2389                             println!("[DEBUG] File menu selected 'Format', Dispatching FormatJson");
2390                             msg_out = Some(AppMessage::FormatJson);
2391                         }
2392                         "Exit" => {
2393                             println!("[DEBUG] File menu selected 'Exit', Dispatching Exit");
2394                             msg_out = Some(AppMessage::Exit);
2395                         }
2396                         "Open..." | "Other" => {
2397                             println!("[DEBUG] File menu selected 'Open...', Dispatching OpenDocument");
2398                             msg_out = Some(AppMessage::OpenDocument);
2399                         }
2400                         _ => {
2401                             let path = std::path::PathBuf::from(option_text);
2402                             println!("[DEBUG] File menu selected recent file: {:?}", path);
2403                             msg_out = Some(AppMessage::OpenRecent(path));
2404                         }
2405                     }
2406                 }
2407             }
2408         }
2409         let mut editor_handled = false;
2410         if self.ui_context.propagate_event(&mouse_ev, self.selected_value_editor.id()) {
2411             changed = true;
2412             editor_handled = true;
2413         }
2414         if self.ui_context.propagate_event(&mouse_ev, self.selected_color_editor.id()) {
2415             changed = true;
2416             editor_handled = true;
2417         }
2418         if self.ui_context.propagate_event(&mouse_ev, self.selected_spinbox_editor.id()) {
2419             changed = true;
2420             editor_handled = true;
2421         }
2422         if self.ui_context.propagate_event(&mouse_ev, self.selected_font_editor.id()) {
2423             changed = true;
2424             editor_handled = true;
2425         }
2426         if self.ui_context.propagate_event(&mouse_ev, self.selected_choice_editor.id()) {
2427             changed = true;
2428             editor_handled = true;
2429         }
2430         if self.ui_context.propagate_event(&mouse_ev, self.selected_len_editor.id()) {
2431             changed = true;
2432             editor_handled = true;
2433         }
2434         if self.ui_context.propagate_event(&mouse_ev, self.selected_unit_editor.id()) {
2435             changed = true;
2436             editor_handled = true;
2437         }
2438         if self.ui_context.propagate_event(&mouse_ev, self.selected_keybind_editor.id()) {
2439             changed = true;
2440             editor_handled = true;
2441         }
2442         if self.ui_context.propagate_event(&mouse_ev, self.selected_bool_editor.id()) {
2443             changed = true;
2444             editor_handled = true;
2445         }
2446         if self.ui_context.propagate_event(&mouse_ev, self.selected_bevel_editor.id()) {
2447             changed = true;
2448             editor_handled = true;
2449             if state == ElementState::Pressed && self.ui_context[self.selected_bevel_editor].take_click() {
2450                 self.open_bevel_editor();
2451             }
2452         }
2453         if self.ui_context.propagate_event(&mouse_ev, self.selected_ramp_editor.id()) {
2454             changed = true;
2455             editor_handled = true;
2456             if state == ElementState::Pressed && self.ui_context[self.selected_ramp_editor].take_click() {
2457                 self.open_ramp_editor();
2458             }
2459         }
2460         if self.ui_context.propagate_event(&mouse_ev, self.selected_button_editor.id()) {
2461             changed = true;
2462             editor_handled = true;
2463             if state == ElementState::Released && self.ui_context[self.selected_button_editor].take_click() {
2464                 if let Some(idx) = self.selected_key_idx {
2465                     let key_name = &self.flat_keys[idx].0;
2466                     if let Some(annotation) = cce_ui::config::get_kdl_type_annotation(&self.ui_context[self.raw_json_editor].text, key_name) {
2467                         if annotation.starts_with("button:") {
2468                             let cmd = annotation.trim_start_matches("button:");
2469                             println!("[DEBUG] Visual button clicked, running command: {}", cmd);
2470                             std::process::Command::new("sh")
2471                                 .args(["-c", cmd])
2472                                 .spawn()
2473                                 .ok();
2474                         } else if key_name == "notifications.test_notification" {
2475                             println!("[DEBUG] Test notification button clicked, sending D-Bus notification");
2476                             std::process::Command::new("busctl")
2477                                 .args([
2478                                     "--user",
2479                                     "call",
2480                                     "org.freedesktop.Notifications",
2481                                     "/org/freedesktop/Notifications",
2482                                     "org.freedesktop.Notifications",
2483                                     "Notify",
2484                                     "susssasa{sv}i",
2485                                     "cce-client",
2486                                     "0",
2487                                     "",
2488                                     "CCE Test Notification",
2489                                     "This is a test notification from the Data Editor.",
2490                                     "0",
2491                                     "0",
2492                                     "-1",
2493                                 ])
2494                                 .spawn()
2495                                 .ok();
2496                         }
2497                     }
2498                 }
2499             }
2500         }
2501         if self.ui_context.propagate_event(&mouse_ev, self.raw_json_editor.id()) {
2502             changed = true;
2503             editor_handled = true;
2504 
2505             if button == MouseButton::Left && state == ElementState::Pressed {
2506                 // TextBox handles the click without claiming ctx focus; claim it
2507                 // here so the tree gets FocusOut and its focus rim clears.
2508                 self.ui_context.set_focused_id(self.raw_json_editor.id());
2509                 let content = if self.ui_context[self.raw_json_editor].editing { &self.ui_context[self.raw_json_editor].edit_buffer } else { &self.ui_context[self.raw_json_editor].text };
2510                 let cursor_offset = char_to_byte_idx(content, self.ui_context[self.raw_json_editor].cursor_idx);
2511                 
2512                 let mut best_key = None;
2513                 let mut best_span_len = usize::MAX;
2514                 
2515                 for (flat_key, _) in &self.flat_keys {
2516                     let tokens = parse_path(flat_key);
2517                     if let Some((start, end)) = find_kdl_span(content, &tokens) {
2518                         if cursor_offset >= start && cursor_offset <= end {
2519                             let span_len = end - start;
2520                             if span_len < best_span_len {
2521                                 best_span_len = span_len;
2522                                 best_key = Some(flat_key.clone());
2523                             }
2524                         }
2525                     }
2526                 }
2527                 
2528                 if let Some(key_path) = best_key {
2529                     if self.ui_context[self.tree_list].select_and_show_key(&key_path) {
2530                         self.adopt_tree_selection();
2531                         changed = true;
2532                     }
2533                 }
2534             }
2535         }
2536 
2537         if !editor_handled && self.ui_context.propagate_event(&mouse_ev, self.tree_list.id()) {
2538             changed = true;
2539             if let Some((old_path, new_path)) = self.ui_context[self.tree_list].take_rename_request() {
2540                 self.rename_key_path(&old_path, &new_path);
2541             }
2542             if let Some(clicked_item) = self.ui_context[self.tree_list].take_clicked_item() {
2543                 match clicked_item {
2544                     TreeElement::Section { .. } => {
2545                         changed = true;
2546                     }
2547                     TreeElement::Leaf { original_idx, .. } => {
2548                         self.selected_key_idx = Some(original_idx);
2549                         
2550                         let value_str = serde_json::to_string(&self.flat_keys[original_idx].1).unwrap_or_default();
2551                         self.ui_context[self.selected_value_editor].text = value_str;
2552                         self.ui_context[self.selected_value_editor].edit_buffer = self.ui_context[self.selected_value_editor].text.clone();
2553                         self.ui_context[self.selected_value_editor].editing = false;
2554                         
2555                         // Sync controls (owned copies: the length sync below
2556                         // borrows self mutably while these are still in use).
2557                         let val = self.flat_keys[original_idx].1.clone();
2558                         let val = &val;
2559                         let key_name = self.flat_keys[original_idx].0.clone();
2560                         let key_name = &key_name;
2561                         let is_font_type = key_name == "font" || key_name.ends_with("_font") || key_name.ends_with(".font");
2562                         
2563                         let mut is_menu_type = false;
2564                         let mut menu_options = Vec::new();
2565                         let mut annotation_str = None;
2566                         if let Some(annotation) = cce_ui::config::get_kdl_type_annotation(&self.ui_context[self.raw_json_editor].text, key_name) {
2567                             annotation_str = Some(annotation.clone());
2568                             if annotation.starts_with("menu:") {
2569                                 is_menu_type = true;
2570                                 let opts_str = annotation.trim_start_matches("menu:");
2571                                 menu_options = opts_str.split(',').map(|s| s.trim().to_string()).collect();
2572                             }
2573                         }
2574 
2575                         if is_menu_type {
2576                             self.ui_context[self.selected_choice_editor].options = menu_options.clone();
2577                             let val_str = match val {
2578                                 serde_json::Value::String(st) => st.clone(),
2579                                 serde_json::Value::Bool(b) => b.to_string(),
2580                                 serde_json::Value::Number(n) => n.to_string(),
2581                                 _ => String::new(),
2582                             };
2583                             if let Some(pos) = menu_options.iter().position(|o| o == &val_str) {
2584                                 self.ui_context[self.selected_choice_editor].selected = pos;
2585                             } else {
2586                                 self.ui_context[self.selected_choice_editor].selected = 0;
2587                             }
2588                         } else if let serde_json::Value::String(s) = val {
2589                             if let Some(len) = cce_ui::units::Len::parse(s) {
2590                                 self.sync_len_editors(len);
2591                             } else if let Some(rgba) = parse_hex_color_rgba(s) {
2592                                 self.ui_context[self.selected_color_editor].color = [rgba[0], rgba[1], rgba[2]];
2593                                 self.ui_context[self.selected_color_editor].alpha = rgba[3];
2594                                 let clean_s = s.trim_matches(|c| c == '"' || c == '\'' || c == ' ').trim_start_matches('#');
2595                                 self.ui_context[self.selected_color_editor].with_alpha = clean_s.len() == 8;
2596                             } else {
2597                                 self.ui_context[self.selected_font_editor].font_family = s.clone();
2598                             }
2599                         } else if let Some(num) = val.as_i64() {
2600                             self.ui_context[self.selected_spinbox_editor].value = num as i32;
2601                         } else if let serde_json::Value::Bool(b) = val {
2602                             self.ui_context[self.selected_bool_editor].set_checked(*b);
2603                         }
2604                         
2605                         let is_keybind_type = key_name == "key" || key_name == "keybind" || key_name == "shortcut" || key_name == "delete" || key_name.ends_with("_key") || key_name.ends_with(".key") || key_name.ends_with(".keybind") || key_name.ends_with("_delete") || key_name.ends_with(".delete") || key_name == "brightness_up" || key_name == "brightness_down" || key_name.ends_with(".brightness_up") || key_name.ends_with(".brightness_down") || annotation_str.as_deref() == Some("keybind");
2606 
2607                         let is_len_type = annotation_str.as_deref().and_then(cce_ui::units::Unit::parse).is_some()
2608                             || len_of(val).is_some();
2609                         if is_len_type {
2610                             self.ui_context.set_focused_id(self.selected_len_editor.id());
2611                         } else if is_menu_type {
2612                             self.ui_context.set_focused_id(self.selected_choice_editor.id());
2613                         } else if is_keybind_type {
2614                             if let serde_json::Value::String(s) = val {
2615                                 self.ui_context[self.selected_keybind_editor].value = s.clone();
2616                             }
2617                             self.ui_context.set_focused_id(self.selected_keybind_editor.id());
2618                         } else if let serde_json::Value::String(s) = val {
2619                             if s.starts_with('#') {
2620                                 self.ui_context.set_focused_id(self.selected_color_editor.id());
2621                             } else if is_font_type {
2622                                 self.ui_context.set_focused_id(self.selected_font_editor.id());
2623                             } else {
2624                                 self.ui_context.set_focused_id(self.selected_value_editor.id());
2625                                 WidgetHost::focus(&mut self.ui_context[self.selected_value_editor]);
2626                             }
2627                         } else if val.is_i64() {
2628                             self.ui_context.set_focused_id(self.selected_spinbox_editor.id());
2629                         } else if val.is_boolean() {
2630                             self.ui_context.set_focused_id(self.selected_bool_editor.id());
2631                         } else {
2632                             self.ui_context.set_focused_id(self.selected_value_editor.id());
2633                             WidgetHost::focus(&mut self.ui_context[self.selected_value_editor]);
2634                         }
2635                         self.sync_preview_selection();
2636                         changed = true;
2637                     }
2638                 }
2639             }
2640         } else if !editor_handled && button == MouseButton::Left && state == ElementState::Pressed {
2641             let (rx, ry, rw, rh) = self.ui_context[self.raw_json_editor].rect();
2642             let in_raw = px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
2643             
2644             if !in_raw {
2645                 self.ui_context.unfocus_id(self.raw_json_editor.id());
2646                 self.ui_context.unfocus_id(self.selected_value_editor.id());
2647                 self.ui_context.unfocus_id(self.selected_color_editor.id());
2648                 self.ui_context.unfocus_id(self.selected_spinbox_editor.id());
2649                 self.ui_context.unfocus_id(self.selected_font_editor.id());
2650                 self.ui_context.unfocus_id(self.selected_choice_editor.id());
2651                 self.ui_context.unfocus_id(self.selected_len_editor.id());
2652                 self.ui_context.unfocus_id(self.selected_unit_editor.id());
2653                 self.ui_context.unfocus_id(self.selected_keybind_editor.id());
2654                 self.ui_context.unfocus_id(self.tree_list.id());
2655                 // The manual unfocus sweep above leaves ctx.focused_widget stale;
2656                 // clear it so focus-derived state (the tree rim) sees reality.
2657                 self.ui_context.clear_focus();
2658                 changed = true;
2659             }
2660         }
2661 
2662         self.sync_tree_focus_rim();
2663         if changed {
2664             *needs_rebuild = true;
2665             self.needs_rebuild = true;
2666         }
2667 
2668         msg_out
2669     }
2670 
2671     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
2672         let px = pos.x as f32;
2673         let py = pos.y as f32;
2674         // Routed dispatch (Phase 6ac): hit-scoped per root, like the legacy direct calls.
2675         let wheel_ev = cce_ui::widget::Event::MouseWheel { delta: delta.clone(), x: px, y: py, local_x: px, local_y: py };
2676         if self.ui_context.propagate_event(&wheel_ev, self.tree_list.id()) {
2677             *needs_rebuild = true;
2678             self.needs_rebuild = true;
2679         }
2680         if self.ui_context.propagate_event(&wheel_ev, self.selected_color_editor.id()) {
2681             *needs_rebuild = true;
2682             self.needs_rebuild = true;
2683         }
2684         if self.ui_context.propagate_event(&wheel_ev, self.selected_spinbox_editor.id()) {
2685             *needs_rebuild = true;
2686             self.needs_rebuild = true;
2687         }
2688         if self.ui_context.propagate_event(&wheel_ev, self.selected_font_editor.id()) {
2689             *needs_rebuild = true;
2690             self.needs_rebuild = true;
2691         }
2692         if self.ui_context.propagate_event(&wheel_ev, self.selected_choice_editor.id()) {
2693             *needs_rebuild = true;
2694             self.needs_rebuild = true;
2695         }
2696         if self.ui_context.propagate_event(&wheel_ev, self.raw_json_editor.id()) {
2697             *needs_rebuild = true;
2698             self.needs_rebuild = true;
2699         }
2700     }
2701 
2702     /// Tab walks the plates and wells (cce-ui's navigation in plate terms).
2703     fn plate_navigation(&self) -> bool {
2704         true
2705     }
2706 
2707     /// The first app to publish its accessibility tree to screen readers (AT-SPI): a tree
2708     /// list, text fields, a menubar and plate navigation already on — cce-ui's
2709     /// rfc-accessibility-locale, phase 2.
2710     fn publishes_accessibility(&self) -> bool {
2711         true
2712     }
2713 
2714     /// The geometry is cached until the next rebuild — a moved focus ring needs one.
2715     fn focus_stepped(&mut self) {
2716         self.needs_rebuild = true;
2717     }
2718 
2719     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
2720         self.ctrl_pressed = event.ctrl;
2721         self.ui_context.ctrl_pressed = event.ctrl;
2722         self.ui_context.shift_pressed = event.shift;
2723 
2724         if event.state == ElementState::Pressed && cce_ui::widget::context_menu::is_visible() {
2725             cce_ui::widget::context_menu::hide();
2726             *needs_rebuild = true;
2727             self.needs_rebuild = true;
2728             return None;
2729         }
2730 
2731         if event.state == ElementState::Pressed && self.status_message.is_some() {
2732             self.status_message = None;
2733             *needs_rebuild = true;
2734             self.needs_rebuild = true;
2735         }
2736 
2737         let mut handled = false;
2738         let mut msg_out = None;
2739 
2740         if event.state == ElementState::Pressed {
2741             let open_search_shortcut = cce_ui::color::tree_open_search_key();
2742             if match_key_shortcut(event, &open_search_shortcut) {
2743                 let has_ctrl = open_search_shortcut.to_lowercase().contains("ctrl") || open_search_shortcut.to_lowercase().contains("control");
2744                 if has_ctrl || self.ui_context.focused_widget.is_none() {
2745                     self.ui_context.lend_h(self.tree_list, |w, ctx| w.focus_search(ctx));
2746                     handled = true;
2747                 }
2748             }
2749         }
2750 
2751         // Keyboard Shortcuts (input.kdl `cce-data-editor` domain); the
2752         // font-size chords stay hardcoded (+/= don't round-trip chords).
2753         if !handled && event.state == ElementState::Pressed {
2754             let m = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
2755             if m(&self.keys.open_document) {
2756                 msg_out = Some(AppMessage::OpenDocument);
2757                 handled = true;
2758             } else if m(&self.keys.save_document) {
2759                 msg_out = Some(AppMessage::SaveDocument);
2760                 handled = true;
2761             } else if m(&self.keys.quit) {
2762                 msg_out = Some(AppMessage::Exit);
2763                 handled = true;
2764             } else if m(&self.keys.format_json) {
2765                 msg_out = Some(AppMessage::FormatJson);
2766                 handled = true;
2767             }
2768         }
2769         if !handled && event.ctrl && event.state == ElementState::Pressed {
2770             if let Key::Character(ref ch) = event.logical_key {
2771                 match ch.to_lowercase().as_str() {
2772                     "=" | "+" => {
2773                         self.ui_context[self.raw_json_editor].font_size = (self.ui_context[self.raw_json_editor].font_size + 1.0).min(72.0);
2774                         self.ui_context[self.selected_value_editor].font_size = (self.ui_context[self.selected_value_editor].font_size + 1.0).min(72.0);
2775                         *needs_rebuild = true;
2776                         self.needs_rebuild = true;
2777                         handled = true;
2778                     }
2779                     "-" | "_" => {
2780                         self.ui_context[self.raw_json_editor].font_size = (self.ui_context[self.raw_json_editor].font_size - 1.0).max(6.0);
2781                         self.ui_context[self.selected_value_editor].font_size = (self.ui_context[self.selected_value_editor].font_size - 1.0).max(6.0);
2782                         *needs_rebuild = true;
2783                         self.needs_rebuild = true;
2784                         handled = true;
2785                     }
2786                     _ => {}
2787                 }
2788             }
2789         }
2790 
2791         // Routed dispatch (Phase 6ac). The router delivers KeyInput to the ctx-focused
2792         // widget FIRST on every call, so the legacy anything-goes chain would deliver a
2793         // typed key to the focused widget once per call site — the chain short-circuits
2794         // on first handled now (at most one widget is focused/editing at a time; the
2795         // legacy non-else chain relied on exactly that). Plumbing that used to key off
2796         // WHICH call returned true is gated on widget state instead: Enter applies the
2797         // value when the value editor was editing when the key arrived, no matter which
2798         // call site's propagate consumed it.
2799         let value_was_editing = self.ui_context[self.selected_value_editor].editing;
2800         if !handled {
2801             let key_ev = cce_ui::widget::Event::KeyInput(event.clone());
2802             let roots: [cce_ui::widget::WidgetId; 12] = [
2803                 self.tree_list.id(),
2804                 self.btn_open.id(),
2805                 self.raw_json_editor.id(),
2806                 self.selected_value_editor.id(),
2807                 self.selected_color_editor.id(),
2808                 self.selected_spinbox_editor.id(),
2809                 self.selected_font_editor.id(),
2810                 self.selected_choice_editor.id(),
2811                 self.selected_len_editor.id(),
2812                 self.selected_unit_editor.id(),
2813                 self.selected_keybind_editor.id(),
2814                 self.selected_bool_editor.id(),
2815             ];
2816             for root in roots {
2817                 if self.ui_context.propagate_event(&key_ev, root) {
2818                     handled = true;
2819                     break;
2820                 }
2821             }
2822             if handled {
2823                 if self.ui_context[self.btn_open].take_change() {
2824                     let selected_idx = self.ui_context[self.btn_open].selected;
2825                     if selected_idx < self.ui_context[self.btn_open].options.len() {
2826                         let option_text = &self.ui_context[self.btn_open].options[selected_idx];
2827                         match option_text.as_str() {
2828                             "Save" => msg_out = Some(AppMessage::SaveDocument),
2829                             "Save As" => msg_out = Some(AppMessage::SaveDocumentAs),
2830                             "Refresh" => msg_out = Some(AppMessage::RefreshDocument),
2831                             "Format" => msg_out = Some(AppMessage::FormatJson),
2832                             "Exit" => msg_out = Some(AppMessage::Exit),
2833                             "Open..." | "Other" => msg_out = Some(AppMessage::OpenDocument),
2834                             _ => {
2835                                 msg_out = Some(AppMessage::OpenRecent(std::path::PathBuf::from(option_text)));
2836                             }
2837                         }
2838                     }
2839                 }
2840                 if value_was_editing
2841                     && event.state == ElementState::Pressed
2842                     && event.logical_key == Key::Named(cce_ui::widget::NamedKey::Enter)
2843                 {
2844                     msg_out = Some(AppMessage::ApplyValue);
2845                 }
2846             }
2847         }
2848 
2849         self.sync_tree_focus_rim();
2850         if handled {
2851             *needs_rebuild = true;
2852             self.needs_rebuild = true;
2853         }
2854 
2855         msg_out
2856     }
2857 
2858     /// Clicks outside our own surface never arrive as pointer events — the
2859     /// compositor's keyboard-leave is the only "user went elsewhere" signal.
2860     /// Dismiss any open dropdown menu on it (FocusOut is the widget's own
2861     /// close-me event, so this rides the normal animated-close path).
2862     fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
2863         if focused {
2864             return;
2865         }
2866         for (open, id) in [
2867             (self.ui_context[self.btn_open].popover_rect().is_some(), self.btn_open.id()),
2868             (self.ui_context[self.selected_choice_editor].popover_rect().is_some(), self.selected_choice_editor.id()),
2869             (self.ui_context[self.selected_unit_editor].popover_rect().is_some(), self.selected_unit_editor.id()),
2870         ] {
2871             if open {
2872                 self.ui_context.propagate_event(&cce_ui::widget::Event::FocusOut, id);
2873                 *needs_rebuild = true;
2874                 self.needs_rebuild = true;
2875             }
2876         }
2877     }
2878 }
2879 
2880 fn parse_hex_color_rgba(s: &str) -> Option<[u8; 4]> {
2881     cce_ui::color::parse_hex_bytes(s)
2882 }
2883 
2884 fn parse_hex_color(s: &str) -> Option<[u8; 3]> {
2885     parse_hex_color_rgba(s).map(|rgba| [rgba[0], rgba[1], rgba[2]])
2886 }
2887 
2888 fn match_key_shortcut(event: &KeyEvent, shortcut_str: &str) -> bool {
2889     let shortcut_lower = shortcut_str.to_lowercase();
2890     let parts: Vec<&str> = shortcut_lower.split('+').collect();
2891     
2892     let mut req_ctrl = false;
2893     let mut req_shift = false;
2894     let mut req_key = "";
2895     
2896     for part in parts {
2897         match part {
2898             "ctrl" | "control" => req_ctrl = true,
2899             "shift" => req_shift = true,
2900             "super" | "win" | "logo" | "alt" | "meta" => {}
2901             k => req_key = k,
2902         }
2903     }
2904     
2905     if event.ctrl != req_ctrl { return false; }
2906     if event.shift != req_shift { return false; }
2907     
2908     if let Key::Character(ref ch) = event.logical_key {
2909         let ch_lower = ch.to_lowercase();
2910         if req_key.len() == 1 {
2911             return ch_lower == req_key;
2912         } else {
2913             let mapped_key = match req_key {
2914                 "slash" => "/",
2915                 "enter" => "enter",
2916                 "escape" => "escape",
2917                 "space" => " ",
2918                 k => k,
2919             };
2920             return ch_lower == mapped_key;
2921         }
2922     } else if let Key::Named(named) = event.logical_key {
2923         let named_str = match named {
2924             cce_ui::widget::NamedKey::Enter => "enter",
2925             cce_ui::widget::NamedKey::Escape => "escape",
2926             cce_ui::widget::NamedKey::Space => "space",
2927             _ => "",
2928         };
2929         return named_str == req_key;
2930     }
2931     false
2932 }
2933 
2934 fn format_hex_color(color: [u8; 3]) -> String {
2935     format!("#{:02x}{:02x}{:02x}", color[0], color[1], color[2])
2936 }
2937 
2938 fn hash_content(s: &str) -> u64 {
2939     use std::hash::{Hash, Hasher};
2940     let mut h = std::collections::hash_map::DefaultHasher::new();
2941     s.hash(&mut h);
2942     h.finish()
2943 }
2944 
2945 fn main() {
2946     let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
2947     let _guard = rt.enter();
2948     
2949     cce_ui::engine::run::<DataEditorApp>();
2950 }
2951 
2952 #[cfg(test)]
2953 mod tests {
2954     use super::*;
2955 
2956     /// One of every KDL shape the real configs are built from, so the tests
2957     /// below exercise them without reading the machine's own `config.kdl`.
2958     /// Reading the live file made the suite's result depend on whose machine
2959     /// it ran on — and it did fail, for a year-old span bug nobody had hit
2960     /// until a second `bevel_apps` entry appeared there (see
2961     /// `span_of_an_indexed_element`). Anything the real config grows that
2962     /// this does not cover belongs here, as a shape rather than a copy.
2963     // `r##`, not `r#`: the colour literals below contain `"#`.
2964     const FIXTURE: &str = r##"
2965 bell "dialog"
2966 duration (i64)6
2967 fonts {
2968     monospace "Berkeley Mono"
2969     serif "Circe Slab C"
2970 }
2971 input {
2972     inertial inertial_pointer=(bool)true pointer_friction=(i64)96
2973 }
2974 layout {
2975     bar_height (i64)24
2976     list_bg_color (rgba)"#08080c4c"
2977     status_bar light_source="top-left" window="top-left"
2978 }
2979 output {
2980     eDP-1 scale=(f64)2.0
2981 }
2982 style {
2983     control corner_radius=(i64)8 {
2984         button background=(rgba)"#00000000" font="Berkeley Mono 14" padding=(i64)9
2985         relief edge_knobs=(bevel)"0.500,0.500,0.500" width=(f64)9.3
2986         tree border_color=(rgb)"#767686" opacity=(f64)0.8 open_search=(keybind)"ctrl+f"
2987         window {
2988             gutter (mm)2.0
2989         }
2990     }
2991     status {
2992         background_color (rgba)"#12141c00"
2993     }
2994 }
2995 window_manager {
2996     // A comment between nodes: spans are byte offsets, so anything that
2997     // shifts them belongs in here too.
2998     touchpad_view_apps "Houdini FX"
2999     center_on_spawn (bool)true
3000     corner_shape (f64)4.5
3001     light_source_position (radian)2.356194490192345
3002     on_app_exit ("menu:focus_previous,overview,none")"focus_previous"
3003     bevel_apps "claude-desktop" "com.anthropic.Claude"
3004     rounded_apps "claude-desktop" "com.anthropic.Claude" "*chrome*"
3005 }
3006 "##;
3007 
3008     /// Only the editor for the selected key's type reaches a screen reader, named after the
3009     /// key it edits; the rest are parked and hidden. Until 2026-10-08 a parked editor was
3010     /// only moved off the window, which the accessibility tree took as on screen, so every
3011     /// one of them was in it, unnamed.
3012     #[test]
3013     fn only_the_selected_keys_editor_is_in_the_accessibility_tree() {
3014         use cce_ui::engine::Application;
3015         let (tx, _rx) = calloop::channel::channel();
3016         let mut app = DataEditorApp::create(tx.into());
3017         app.ui_context[app.raw_json_editor].text = FIXTURE.to_string();
3018         let val = cce_ui::config::parse_kdl_to_json(FIXTURE);
3019         app.flat_keys.clear();
3020         flatten_json(&val, "", &mut app.flat_keys);
3021         let keys = app.flat_keys.clone();
3022         app.ui_context[app.tree_list].set_flat_keys(keys);
3023         let named = |app: &mut DataEditorApp| -> Vec<String> {
3024             app.needs_rebuild = true;
3025             let _ = app.display_list(cce_ui::engine::LogicalSize { width: 1200.0, height: 800.0 }, 1.0);
3026             let tree = cce_ui::a11y::app_tree(app, 1.0);
3027             tree.nodes.iter().filter_map(|(_, n)| n.label().map(str::to_string)).collect()
3028         };
3029 
3030         let names = named(&mut app);
3031         assert!(["File", "Keys", "KDL source"].iter().all(|n| names.iter().any(|m| m == n)), "{names:?}");
3032         assert!(!names.iter().any(|n| n.starts_with("Value of")), "no key selected, no editor: {names:?}");
3033 
3034         app.selected_key_idx = app.flat_keys.iter().position(|(k, _)| k == "duration");
3035         let names = named(&mut app);
3036         let editors: Vec<&String> = names.iter().filter(|n| n.starts_with("Value of") || n.starts_with("Unit of")).collect();
3037         assert_eq!(editors, ["Value of duration"], "{names:?}");
3038         assert!(!names.iter().any(|n| n == "Send Test"), "the parked test button is not in it: {names:?}");
3039     }
3040 
3041     #[test]
3042     fn test_kdl_roundtrip() {
3043         let content = FIXTURE.to_string();
3044         let val = cce_ui::config::parse_kdl_to_json(&content);
3045         let mut flat = Vec::new();
3046         flatten_json(&val, "", &mut flat);
3047         for (k, v) in &flat {
3048             println!("FLAT KEY: {:?} = {:?}", k, v);
3049         }
3050         
3051         let mut found = false;
3052         for (k, v) in &mut flat {
3053             if k == "style.status.background_color" {
3054                 *v = serde_json::Value::String("#151520e6".to_string());
3055                 found = true;
3056             }
3057         }
3058         assert!(found, "Should find style.status.background_color!");
3059 
3060         let root = unflatten_json(&flat);
3061         let new_kdl = cce_ui::config::json_to_kdl_string(&root);
3062         println!("Generated KDL:\n{}", new_kdl);
3063         match new_kdl.parse::<kdl::KdlDocument>() {
3064             Ok(_) => println!("Parsed OK!"),
3065             Err(e) => panic!("Failed to parse generated KDL: {}", e),
3066         }
3067     }
3068 
3069     /// What `find_kdl_span` must return for an indexed path, pinned on a
3070     /// fixture rather than the user's config: the earlier version indexed
3071     /// NODES, so a string list answered `[0]` with its whole line and `[1]`
3072     /// with nothing at all — the tree could not highlight the second app in
3073     /// `bevel_apps`, and a click anywhere on that line selected `[0]`.
3074     #[test]
3075     fn span_of_an_indexed_element() {
3076         let content = "\
3077 window_manager {
3078     bevel_apps \"claude-desktop\" \"com.anthropic.Claude\"
3079     bevel_apps \"latecomer\"
3080     gap 12
3081 }
3082 ";
3083         let slice = |path: &str| -> Option<&str> {
3084             let (start, end) = find_kdl_span(content, &parse_path(path))?;
3085             Some(content[start..end].trim())
3086         };
3087         // Each argument of a string list is its own element...
3088         assert_eq!(slice("window_manager.bevel_apps[0]"), Some("\"claude-desktop\""));
3089         assert_eq!(slice("window_manager.bevel_apps[1]"), Some("\"com.anthropic.Claude\""));
3090         // ...and a repeated node continues the same index space, because
3091         // kdl_to_json concatenates the two lists.
3092         assert_eq!(slice("window_manager.bevel_apps[2]"), Some("\"latecomer\""));
3093         assert_eq!(slice("window_manager.bevel_apps[3]"), None);
3094         // Plain keys are untouched by the change.
3095         assert_eq!(slice("window_manager.gap"), Some("12"));
3096     }
3097 
3098     /// A joined multi-argument value is one value, so its span is all of it.
3099     /// Stopping at the first argument highlighted `100` out of `100 200` —
3100     /// and, for a repeated node, told the raw pane the wrong extent for the
3101     /// element the tree had selected.
3102     #[test]
3103     fn span_of_a_joined_multi_arg() {
3104         let content = "\
3105 style {
3106     window {
3107         position_default (vec2i)100 200
3108     }
3109 }
3110 anchor (vec2i)1 2
3111 anchor (vec2i)3 4
3112 ";
3113         let slice = |path: &str| -> Option<&str> {
3114             let (start, end) = find_kdl_span(content, &parse_path(path))?;
3115             Some(content[start..end].trim())
3116         };
3117         assert_eq!(slice("style.window.position_default"), Some("(vec2i)100 200"));
3118         // Repeated: each node is one element, and each element is all of it.
3119         assert_eq!(slice("anchor[0]"), Some("(vec2i)1 2"));
3120         assert_eq!(slice("anchor[1]"), Some("(vec2i)3 4"));
3121     }
3122 
3123     /// The other index shape: `key_bindings` children, where the index does
3124     /// address the n-th node and a trailing key addresses one of its props.
3125     #[test]
3126     fn span_of_a_key_binding() {
3127         let content = "\
3128 key_bindings {
3129     bind key=\"q\" action=\"close\"
3130     bind key=\"t\" action=\"new_tab\"
3131 }
3132 ";
3133         let span = |path: &str| -> Option<&str> {
3134             let (start, end) = find_kdl_span(content, &parse_path(path))?;
3135             Some(content[start..end].trim())
3136         };
3137         assert_eq!(span("key_bindings[1].action"), Some("action=\"new_tab\""));
3138         assert_eq!(span("key_bindings[0].key"), Some("key=\"q\""));
3139     }
3140 
3141     /// Every key the editor lists must be findable in the text it came from
3142     /// — that is what the tree/raw two-way selection rides on — so walk the
3143     /// whole fixture rather than spot-checking it.
3144     #[test]
3145     fn test_kdl_span_lookup() {
3146         let content = FIXTURE;
3147         let val = cce_ui::config::parse_kdl_to_json(content);
3148         let mut flat = Vec::new();
3149         flatten_json(&val, "", &mut flat);
3150         assert!(!flat.is_empty(), "the fixture should flatten to something");
3151 
3152         for (k, _) in &flat {
3153             let tokens = parse_path(k);
3154             let span = find_kdl_span(content, &tokens);
3155             let Some((start, end)) = span else {
3156                 panic!("Should find span for path: {} with tokens: {:?}", k, tokens);
3157             };
3158             // A span the editor can actually select with: in bounds, not
3159             // empty, and on character boundaries (it is handed to
3160             // byte_to_char_idx).
3161             assert!(start < end, "empty span for {}: {:?}", k, span);
3162             assert!(end <= content.len(), "span past the end for {}: {:?}", k, span);
3163             assert!(
3164                 content.is_char_boundary(start) && content.is_char_boundary(end),
3165                 "span splits a character for {}: {:?}",
3166                 k,
3167                 span
3168             );
3169         }
3170     }
3171 }