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 }