GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/container/treelist.rs (83.5K)
1 use crate::widget::*;
2 use crate::l10n::tr;
3 use crate::widget::container::scroll_box::ScrollBox;
4 use crate::widget::display::TextLabel;
5 use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
6 use crate::scene::layout::Rect;
7 use crate::scene::paint::PaintCtx;
8 use std::collections::HashSet;
9
10 /// The add-key button's label, which also sizes it.
11 const ADD_KEY_LABEL: &str = "+ Add Key";
12
13 #[derive(Debug, Clone, PartialEq)]
14 pub enum TreeElement {
15 Section {
16 path: String,
17 name: String,
18 indent: usize,
19 collapsed: bool,
20 },
21 Leaf {
22 path: String,
23 name: String,
24 indent: usize,
25 val: serde_json::Value,
26 original_idx: usize,
27 }
28 }
29
30 #[derive(Debug, Clone)]
31 enum PathToken {
32 Key(String),
33 Index(usize),
34 }
35
36 fn parse_path(path: &str) -> Vec<PathToken> {
37 let mut tokens = Vec::new();
38 for part in path.split('.') {
39 if part.is_empty() { continue; }
40 if let Some(bracket_idx) = part.find('[') {
41 let name = &part[..bracket_idx];
42 if !name.is_empty() {
43 tokens.push(PathToken::Key(name.to_string()));
44 }
45 let mut rest = &part[bracket_idx..];
46 while let Some(start) = rest.find('[') {
47 if let Some(end) = rest.find(']') {
48 let idx_str = &rest[start + 1..end];
49 if let Ok(idx) = idx_str.parse::<usize>() {
50 tokens.push(PathToken::Index(idx));
51 }
52 rest = &rest[end + 1..];
53 } else {
54 break;
55 }
56 }
57 } else {
58 tokens.push(PathToken::Key(part.to_string()));
59 }
60 }
61 tokens
62 }
63
64 fn matches_query(key_path: &str, val: &serde_json::Value, annotation: Option<&str>, query: &str) -> bool {
65 if query.is_empty() {
66 return true;
67 }
68 let query_lower = query.to_lowercase();
69 if key_path.to_lowercase().contains(&query_lower) {
70 return true;
71 }
72 let val_str = match val {
73 serde_json::Value::String(s) => s.clone(),
74 serde_json::Value::Bool(b) => b.to_string(),
75 serde_json::Value::Number(n) => n.to_string(),
76 other => serde_json::to_string(other).unwrap_or_default(),
77 };
78 if val_str.to_lowercase().contains(&query_lower) {
79 return true;
80 }
81 if let Some(anno) = annotation {
82 if anno.to_lowercase().contains(&query_lower) {
83 return true;
84 }
85 }
86 false
87 }
88
89 fn build_tree(
90 flat_keys: &[(String, serde_json::Value)],
91 annotations: &[Option<String>],
92 collapsed_sections: &HashSet<String>,
93 query: &str,
94 ) -> Vec<TreeElement> {
95 let mut items = Vec::new();
96 let mut seen_prefixes = HashSet::new();
97
98 let mut matching_indices = HashSet::new();
99 for (idx, (key_path, val)) in flat_keys.iter().enumerate() {
100 let annotation = annotations.get(idx).and_then(|opt| opt.as_deref());
101 if query.is_empty() || matches_query(key_path, val, annotation, query) {
102 matching_indices.insert(idx);
103 }
104 }
105
106 for (original_idx, (key_path, val)) in flat_keys.iter().enumerate() {
107 if !matching_indices.contains(&original_idx) {
108 continue;
109 }
110 let tokens = parse_path(key_path);
111 let mut current_prefix = String::new();
112 let mut is_hidden = false;
113
114 for i in 0..tokens.len() {
115 let token = &tokens[i];
116 let part_name = match token {
117 PathToken::Key(k) => {
118 if current_prefix.is_empty() {
119 current_prefix = k.clone();
120 } else {
121 current_prefix = format!("{}.{}", current_prefix, k);
122 }
123 k.clone()
124 }
125 PathToken::Index(idx) => {
126 let s = format!("[{}]", idx);
127 current_prefix = format!("{}{}", current_prefix, s);
128 s
129 }
130 };
131
132 let is_last = i == tokens.len() - 1;
133
134 if is_hidden {
135 continue;
136 }
137
138 if is_last {
139 items.push(TreeElement::Leaf {
140 path: key_path.clone(),
141 name: part_name,
142 indent: i,
143 val: val.clone(),
144 original_idx,
145 });
146 } else {
147 if !seen_prefixes.contains(¤t_prefix) {
148 seen_prefixes.insert(current_prefix.clone());
149 let collapsed = collapsed_sections.contains(¤t_prefix);
150 items.push(TreeElement::Section {
151 path: current_prefix.clone(),
152 name: part_name,
153 indent: i,
154 collapsed,
155 });
156 }
157 if collapsed_sections.contains(¤t_prefix) {
158 is_hidden = true;
159 }
160 }
161 }
162 }
163 items
164 }
165
166 /// The fields' places (`TreeList::field_rects`).
167 struct FieldRects {
168 search_box: Rect,
169 add_key_btn: Rect,
170 popover_box: Rect,
171 }
172
173 #[derive(Debug)]
174 pub struct TreeList {
175 pub base: Widget,
176 pub scroll_box: ScrollBox,
177 /// The search field: an [`Embedded`] child, the context's once the tree is (see
178 /// `register_embedded_children`).
179 pub search_box: Embedded<Adapted<TextBox>>,
180 /// What the search field held when the rows were last built (`rebuild_tree` has no
181 /// context to read the field through).
182 query: String,
183 pub add_key_btn: Embedded<Adapted<Button>>,
184 pub add_key_popover_open: bool,
185 pub add_key_popover_box: Embedded<Adapted<TextBox>>,
186 pub new_key_path_request: Option<String>,
187 pub flat_keys: Vec<(String, serde_json::Value)>,
188 pub annotations: Vec<Option<String>>,
189 pub collapsed_sections: HashSet<String>,
190 pub items: Vec<TreeElement>,
191 pub selected_key_idx: Option<usize>,
192 pub hovered_row_idx: Option<usize>,
193 pub item_height: f32,
194 pub clicked_item: Option<TreeElement>,
195 pub right_clicked_section: Option<String>,
196 pub last_scroll_y: f32,
197 /// Lights the recess rim (`paint` has no UiContext, so this mirrors focus).
198 /// Driven by FocusIn/FocusOut by default; an app whose inline editors float
199 /// over the tree (cce-data-editor) overrides it per input event with its own
200 /// focus-within computation — those editors take ctx focus away from the
201 /// tree while still being, visually, part of the tree pane.
202 pub focused: bool,
203 pub deleted_key_path: Option<String>,
204 /// The inline rename editor: in the context only while a rename is under way.
205 pub edit_box: Embedded<Adapted<TextBox>>,
206 pub editing_key_idx: Option<usize>,
207 pub double_click_timer: Option<(web_time::Instant, usize)>,
208 pub rename_request: Option<(String, String)>,
209 }
210
211 impl TreeList {
212 pub fn new() -> Adapted<TreeList> {
213 let mut scroll_box = ScrollBox::new();
214 scroll_box.show_background = false;
215 // The DE's scrollbar: down the tree's centre line, behind its plate
216 // until a scroll raises it (`paint`).
217 scroll_box.sink_behind = true;
218 Adapted::new(TreeList {
219 base: Widget::new(),
220 scroll_box,
221 search_box: Embedded::new(TextBox::new(String::new()).with_search().with_update_on_type(true)),
222 query: String::new(),
223 add_key_btn: Embedded::new(Button::new(0.0, 0.0, 80.0, 26.0).with_label(ADD_KEY_LABEL)),
224 add_key_popover_open: false,
225 add_key_popover_box: Embedded::new(TextBox::new(String::new()).with_placeholder("new.key.path").with_multiline(false)),
226 new_key_path_request: None,
227 flat_keys: Vec::new(),
228 annotations: Vec::new(),
229 collapsed_sections: HashSet::new(),
230 items: Vec::new(),
231 selected_key_idx: None,
232 hovered_row_idx: None,
233 item_height: 28.0,
234 clicked_item: None,
235 right_clicked_section: None,
236 last_scroll_y: 0.0,
237 focused: false,
238 deleted_key_path: None,
239 edit_box: Embedded::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true)),
240 editing_key_idx: None,
241 double_click_timer: None,
242 rename_request: None,
243 })
244 }
245
246 pub fn take_new_key_path_request(&mut self) -> Option<String> {
247 self.new_key_path_request.take()
248 }
249
250 pub fn popover_rect_geom(&self) -> (f32, f32, f32, f32) {
251 let b = self.field_rects().add_key_btn;
252 let (bx, by, bw, bh) = (b.x, b.y, b.width, b.height);
253 let popover_w = 220.0;
254 let popover_h = 36.0;
255 let popover_x = bx + bw - popover_w;
256 let popover_y = by + bh + 4.0;
257 (popover_x, popover_y, popover_w, popover_h)
258 }
259
260 /// Where the search field, the add-key button and the add-key popover's box stand, from
261 /// the tree's content rect: the field across the top less the button at its right end,
262 /// the popover's box inside the popover under the button.
263 fn field_rects(&self) -> FieldRects {
264 let (x, y, w) = (self.base.x, self.base.y, self.base.w);
265 let search_margin_x = 8.0;
266 let search_margin_y = 6.0;
267 let search_h = 26.0;
268 let (btn_family, btn_size) = crate::layout::parse_font_string(&crate::layout::button_font());
269 let label_w = crate::widget::display::measure_text_width(ADD_KEY_LABEL, &btn_family, btn_size.unwrap_or(12.0));
270 let button_width = label_w + 2.0 * crate::layout::button_padding();
271 let button_x = x + w - search_margin_x - button_width;
272 let add_key_btn = Rect { x: button_x, y: y + search_margin_y, width: button_width, height: search_h };
273 let popover_w = 220.0;
274 let popover_h = 36.0;
275 let (px, py) = (button_x + button_width - popover_w, add_key_btn.y + search_h + 4.0);
276 FieldRects {
277 search_box: Rect { x: x + search_margin_x, y: y + search_margin_y, width: w - 2.0 * search_margin_x - button_width - 6.0, height: search_h },
278 add_key_btn,
279 popover_box: Rect { x: px + 8.0, y: py + 5.0, width: popover_w - 16.0, height: popover_h - 10.0 },
280 }
281 }
282
283 /// Place the fields the tree still holds by value (it is not in a context).
284 fn place_held_fields(&mut self) {
285 let r = self.field_rects();
286 for (field, rect) in [(&mut self.search_box, r.search_box), (&mut self.add_key_popover_box, r.popover_box)] {
287 if let Some(f) = field.here_mut() {
288 f.set_rect(rect.x, rect.y, rect.width, rect.height);
289 }
290 }
291 if let Some(b) = self.add_key_btn.here_mut() {
292 b.set_rect(r.add_key_btn.x, r.add_key_btn.y, r.add_key_btn.width, r.add_key_btn.height);
293 }
294 }
295
296 /// The field children, in the legacy children() order: those held here (`held`) or
297 /// those in `ui` (not `held`).
298 fn paint_fields(&self, ui: &UiContext, pc: &mut PaintCtx, held: bool) {
299 macro_rules! field {
300 ($f:expr) => {
301 if $f.is_attached() != held {
302 $f.get(ui).paint_self(ui, pc);
303 }
304 };
305 }
306 field!(self.search_box);
307 field!(self.add_key_btn);
308 if self.add_key_popover_open {
309 field!(self.add_key_popover_box);
310 }
311 if self.editing_key_idx.is_some() {
312 field!(self.edit_box);
313 }
314 }
315
316 pub fn focus_search(&mut self, ctx: &mut UiContext) {
317 self.search_box.attach(ctx);
318 ctx.set_focused_id(self.search_box.id());
319 self.search_box.get_mut(ctx).focus();
320 }
321
322 pub fn set_flat_keys(&mut self, flat_keys: Vec<(String, serde_json::Value)>) {
323 self.flat_keys = flat_keys;
324 self.rebuild_tree();
325 }
326
327 pub fn rebuild_tree(&mut self) {
328 self.items = build_tree(&self.flat_keys, &self.annotations, &self.collapsed_sections, &self.query);
329 let content_h = self.items.len() as f32 * self.item_height;
330 let h = self.base.h;
331 let search_margin_y = 6.0;
332 let search_h = 26.0;
333 let offset_y = search_h + 2.0 * search_margin_y;
334 let header_h = 26.0;
335 self.scroll_box.update_bounds(content_h, self.scroll_box.viewport_y, h - offset_y - header_h);
336 }
337
338 /// The on-screen rect of a leaf row, or `None` unless the row is FULLY
339 /// visible. Deliberately full-containment, unlike the draw-side
340 /// virtualization (which returns partial rows to be drawn cut by the
341 /// clip): this positions a floating overlay (cce-data-editor's inline
342 /// value editors) that draws OVER the well unclipped, and an editor
343 /// hanging half off the list edge is worse than one that waits for its
344 /// row to scroll fully into view.
345 pub fn get_row_rect(&self, original_idx: usize) -> Option<(f32, f32, f32, f32)> {
346 let list_top = self.scroll_box.viewport_y;
347 let list_bottom = self.scroll_box.viewport_y + self.scroll_box.viewport_h;
348
349 let row_idx = self.items.iter().position(|item| {
350 match item {
351 TreeElement::Leaf { original_idx: idx, .. } => *idx == original_idx,
352 _ => false,
353 }
354 })?;
355
356 let row_y = list_top + row_idx as f32 * self.item_height - self.scroll_box.scroll_y;
357 if row_y >= list_top && row_y + self.item_height <= list_bottom {
358 Some((self.scroll_box.base.x, row_y, self.scroll_box.base.w, self.item_height))
359 } else {
360 None
361 }
362 }
363
364 pub fn take_clicked_item(&mut self) -> Option<TreeElement> {
365 self.clicked_item.take()
366 }
367
368 pub fn take_deleted_key_path(&mut self) -> Option<String> {
369 self.deleted_key_path.take()
370 }
371
372 pub fn take_rename_request(&mut self) -> Option<(String, String)> {
373 self.rename_request.take()
374 }
375
376
377 pub fn scroll_to_selected_key(&mut self) {
378 if let Some(selected_idx) = self.selected_key_idx {
379 let visible_row_idx = self.items.iter().position(|item| {
380 if let TreeElement::Leaf { original_idx, .. } = item {
381 *original_idx == selected_idx
382 } else {
383 false
384 }
385 });
386 if let Some(row_idx) = visible_row_idx {
387 let row_top = row_idx as f32 * self.item_height;
388 let row_bottom = row_top + self.item_height;
389 let viewport_h = self.scroll_box.viewport_h;
390
391 if row_top < self.scroll_box.scroll_y {
392 self.scroll_box.scroll_y = row_top;
393 } else if row_bottom > self.scroll_box.scroll_y + viewport_h {
394 self.scroll_box.scroll_y = (row_bottom - viewport_h).max(0.0);
395 }
396
397 let max_scroll = (self.scroll_box.content_h - viewport_h).max(0.0);
398 self.scroll_box.scroll_y = self.scroll_box.scroll_y.clamp(0.0, max_scroll);
399 }
400 }
401 }
402
403 pub fn select_and_show_key(&mut self, key_path: &str) -> bool {
404 let found_idx = self.flat_keys.iter().position(|(k, _)| k == key_path);
405 if let Some(idx) = found_idx {
406 self.selected_key_idx = Some(idx);
407
408 let parts: Vec<&str> = key_path.split('.').collect();
409 let mut current = String::new();
410 let mut expanded_any = false;
411 for i in 0..parts.len() - 1 {
412 if !current.is_empty() {
413 current.push('.');
414 }
415 current.push_str(parts[i]);
416 if self.collapsed_sections.contains(¤t) {
417 self.collapsed_sections.remove(¤t);
418 expanded_any = true;
419 }
420 }
421 if expanded_any {
422 self.rebuild_tree();
423 }
424
425 self.scroll_to_selected_key();
426 true
427 } else {
428 false
429 }
430 }
431 }
432
433 impl TreeList {
434 fn tree_radius(&self) -> f32 {
435 crate::layout::tree_corner_radius()
436 }
437
438 fn tree_corners(&self) -> (bool, bool, bool, bool) {
439 (true, true, true, true)
440 }
441
442 fn tree_border(&self) -> Option<([f32; 4], f32)> {
443 if self.scroll_box.show_border {
444 Some((crate::color::tree_border_color(), 1.0))
445 } else {
446 None
447 }
448 }
449
450 fn mouse_body(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ui: &mut UiContext, host_id: WidgetId) -> bool {
451 let _ = host_id;
452 if self.editing_key_idx.is_some() {
453 if button == MouseButton::Left && state == ElementState::Pressed {
454 let (ex, ey, ew, eh) = self.edit_box.get(ui).rect();
455 if px >= ex && px <= ex + ew && py >= ey && py <= ey + eh {
456 if self.edit_box.lend(ui, |w, ui| w.mouse_input(button, state, px, py, ui)) == Some(true) {
457 return true;
458 }
459 } else {
460 ui.clear_focus();
461 return true;
462 }
463 }
464 return false;
465 }
466
467 let mut changed = false;
468
469 if self.add_key_popover_open {
470 let (px_rect, py_rect, pw, ph) = self.popover_rect_geom();
471 if button == MouseButton::Left && state == ElementState::Pressed {
472 if px < px_rect || px > px_rect + pw || py < py_rect || py > py_rect + ph {
473 self.add_key_popover_open = false;
474 ui.clear_focus();
475 changed = true;
476 } else {
477 if self.add_key_popover_box.lend(ui, |w, ui| w.mouse_input(button, state, px, py, ui)) == Some(true) {
478 ui.set_focused_id(self.add_key_popover_box.id());
479 changed = true;
480 }
481 }
482 }
483 let (px_rect, py_rect, pw, ph) = self.popover_rect_geom();
484 if px >= px_rect && px <= px_rect + pw && py >= py_rect && py <= py_rect + ph {
485 return true;
486 }
487 }
488
489 if self.scroll_box.mouse_input(button, state, px, py, ui) {
490 changed = true;
491 }
492 if self.search_box.lend(ui, |w, ui| w.mouse_input(button, state, px, py, ui)) == Some(true) {
493 ui.set_focused_id(self.search_box.id());
494 changed = true;
495 }
496 if let Some(Some(clicked)) = self.add_key_btn.lend(ui, |w, ui| w.mouse_input(button, state, px, py, ui).then(|| w.take_click())) {
497 if clicked {
498 self.add_key_popover_open = !self.add_key_popover_open;
499 if self.add_key_popover_open {
500 let b = self.add_key_popover_box.get_mut(ui);
501 WidgetHost::set_visible(b, true);
502 b.text.clear();
503 b.edit_buffer.clear();
504 b.cursor_idx = 0;
505 b.select_anchor = None;
506 b.all_selected = false;
507 b.editing = true;
508 ui.set_focused_id(self.add_key_popover_box.id());
509 self.add_key_popover_box.get_mut(ui).focus();
510 } else {
511 ui.clear_focus();
512 }
513 }
514 changed = true;
515 }
516
517 let list_left = self.scroll_box.base.x;
518 let list_width = self.scroll_box.base.w;
519 let list_top = self.scroll_box.viewport_y;
520 let list_bottom = self.scroll_box.viewport_y + self.scroll_box.viewport_h;
521
522 // The tree receives every press UNGATED (see gates_presses) for its
523 // dismiss/commit semantics, which skips the router's popover-coverage
524 // check — honor it here for row interactions, or a click on a menu
525 // floating over the tree (the File dropdown) also selects the row
526 // beneath it. The dismiss paths above deliberately stay: a covered
527 // press IS an outside press for the rename editor and add-key popover.
528 let covered = ui.is_coordinate_covered(host_id, px, py);
529
530 if button == MouseButton::Left && state == ElementState::Pressed && !covered {
531 let on_scrollbar = self.scroll_box.hit_test_scrollbar(px, py) || self.scroll_box.scrollbar_dragging;
532 if !on_scrollbar && px >= list_left && px <= list_left + list_width && py >= list_top && py <= list_bottom {
533 // Focus claimed from inside this event (`UiContext::claim_focus`): a FocusIn
534 // through the registry would re-enter this widget while it holds `&mut self`.
535 ui.claim_focus(host_id);
536 self.focused = true;
537 let relative_y = py - list_top + self.scroll_box.scroll_y;
538 let row_idx = (relative_y / self.item_height) as usize;
539 if row_idx < self.items.len() {
540 let item = self.items[row_idx].clone();
541
542 let mut is_double = false;
543 let now = web_time::Instant::now();
544 if let Some((prev_time, prev_row)) = self.double_click_timer {
545 if prev_row == row_idx && now.duration_since(prev_time).as_millis() < 300 {
546 is_double = true;
547 }
548 }
549 self.double_click_timer = Some((now, row_idx));
550
551 if is_double {
552 let (_path_to_edit, relative_name) = match &item {
553 TreeElement::Section { path, name, .. } => {
554 if self.collapsed_sections.contains(path) {
555 self.collapsed_sections.remove(path);
556 } else {
557 self.collapsed_sections.insert(path.clone());
558 }
559 self.rebuild_tree();
560 (path.clone(), name.clone())
561 }
562 TreeElement::Leaf { path, name, .. } => (path.clone(), name.clone()),
563 };
564 self.editing_key_idx = Some(row_idx);
565 // A fresh editor for this rename, the context's while it is up
566 // (linked under the tree) and given back when it commits (`tick_ctx`).
567 self.edit_box.detach(ui);
568 let mut eb = TextBox::new(relative_name).with_multiline(false).with_draw_bg_border(true);
569 eb.editing = true;
570 eb.cursor_idx = eb.text.chars().count();
571 eb.select_anchor = Some(0);
572 self.edit_box = Embedded::new(eb);
573 self.edit_box.attach(ui);
574
575 let eb_id = self.edit_box.id();
576 ui.link_ids(host_id, eb_id);
577 ui.set_focused_id(eb_id);
578 return true;
579 }
580
581 match item {
582 TreeElement::Section { ref path, .. } => {
583 if self.collapsed_sections.contains(path) {
584 self.collapsed_sections.remove(path);
585 } else {
586 self.collapsed_sections.insert(path.clone());
587 }
588 self.rebuild_tree();
589 self.clicked_item = Some(TreeElement::Section {
590 path: path.clone(),
591 name: String::new(),
592 indent: 0,
593 collapsed: self.collapsed_sections.contains(path),
594 });
595 changed = true;
596 }
597 TreeElement::Leaf { original_idx, ref path, ref name, indent, ref val } => {
598 self.selected_key_idx = Some(original_idx);
599 self.clicked_item = Some(TreeElement::Leaf {
600 path: path.clone(),
601 name: name.clone(),
602 indent,
603 val: val.clone(),
604 original_idx,
605 });
606 changed = true;
607 }
608 }
609 }
610 }
611 }
612
613 if button == MouseButton::Right && state == ElementState::Pressed && !covered
614 && px >= list_left && px <= list_left + list_width && py >= list_top && py <= list_bottom {
615 // Focus claimed from inside this event (`UiContext::claim_focus`): a FocusIn
616 // through the registry would re-enter this widget while it holds `&mut self`.
617 ui.claim_focus(host_id);
618 self.focused = true;
619 let relative_y = py - list_top + self.scroll_box.scroll_y;
620 let row_idx = (relative_y / self.item_height) as usize;
621 if row_idx < self.items.len() {
622 let item = self.items[row_idx].clone();
623 match item {
624 TreeElement::Section { ref path, collapsed, .. } => {
625 self.right_clicked_section = Some(path.clone());
626
627 use crate::widget::ContextAction as CA;
628 let rows = vec![
629 (path.clone(), None),
630 if collapsed { (tr("tree-expand"), Some(CA::ExpandNode)) } else { (tr("tree-collapse"), Some(CA::CollapseNode)) },
631 (tr("tree-expand-all"), Some(CA::ExpandAll)),
632 (tr("tree-collapse-all"), Some(CA::CollapseAll)),
633 ];
634
635 let scroll_offset = crate::widget::hover_animation::get_scroll_offset();
636 ui.show_context_menu_rows(px, py - scroll_offset, rows, 1, host_id);
637 changed = true;
638 }
639 TreeElement::Leaf { original_idx, ref path, ref name, indent, ref val } => {
640 self.selected_key_idx = Some(original_idx);
641 self.clicked_item = Some(TreeElement::Leaf {
642 path: path.clone(),
643 name: name.clone(),
644 indent,
645 val: val.clone(),
646 original_idx,
647 });
648
649 use crate::widget::ContextAction as CA;
650 let rows = vec![
651 (path.clone(), None),
652 (tr("tree-copy-key"), Some(CA::CopyKey)),
653 (tr("tree-copy-value"), Some(CA::CopyValue)),
654 (tr("tree-delete"), Some(CA::DeleteKey)),
655 ];
656 let scroll_offset = crate::widget::hover_animation::get_scroll_offset();
657 ui.show_context_menu_rows(px, py - scroll_offset, rows, 1, host_id);
658 changed = true;
659 }
660 }
661 }
662 }
663 changed
664
665 }
666
667 fn move_body(&mut self, px: f32, py: f32, ui: &mut UiContext) -> bool {
668 let mut changed = self.scroll_box.on_cursor_moved(px, py, ui);
669 if self.search_box.lend(ui, |w, ui| w.on_cursor_moved(px, py, ui)) == Some(true) {
670 changed = true;
671 }
672 if self.add_key_btn.lend(ui, |w, ui| w.on_cursor_moved(px, py, ui)) == Some(true) {
673 changed = true;
674 }
675 if self.add_key_popover_open
676 && self.add_key_popover_box.lend(ui, |w, ui| w.on_cursor_moved(px, py, ui)) == Some(true) {
677 changed = true;
678 }
679
680 let list_left = self.scroll_box.base.x;
681 let list_width = self.scroll_box.base.w;
682 let list_top = self.scroll_box.viewport_y;
683 let list_bottom = self.scroll_box.viewport_y + self.scroll_box.viewport_h;
684
685 let old_hovered = self.hovered_row_idx;
686 self.hovered_row_idx = None;
687
688 let on_scrollbar = self.scroll_box.hit_test_scrollbar(px, py) || self.scroll_box.scrollbar_dragging;
689 if !on_scrollbar && px >= list_left && px <= list_left + list_width && py >= list_top && py <= list_bottom {
690 let relative_y = py - list_top + self.scroll_box.scroll_y;
691 let row_idx = (relative_y / self.item_height) as usize;
692 if row_idx < self.items.len() {
693 self.hovered_row_idx = Some(row_idx);
694 }
695 }
696
697 if old_hovered != self.hovered_row_idx {
698 changed = true;
699 }
700 changed
701
702 }
703
704 fn key_body(&mut self, event: &KeyEvent, ui: &mut UiContext) -> bool {
705 if self.editing_key_idx.is_some()
706 && self.edit_box.lend(ui, |w, ui| w.keyboard_input(event, ui)) == Some(true) {
707 return true;
708 }
709 if self.add_key_popover_open
710 && self.add_key_popover_box.lend(ui, |w, ui| w.keyboard_input(event, ui)) == Some(true) {
711 return true;
712 }
713 if self.search_box.lend(ui, |w, ui| w.keyboard_input(event, ui)) == Some(true) {
714 return true;
715 }
716 false
717
718 }
719 }
720
721 impl Layout for TreeList {
722 /// The legacy `set_rect` body: cache the CONTENT rect on the internal base and
723 /// arrange the field widgets (search box, add-key button, popover box, scroll box)
724 /// in it. The content rect, not `rect_assigned`'s block: the adapter's block holds
725 /// the detached label strip above the content, and `paint` draws the well at the
726 /// content rect — fields placed from the block sat one strip above the well, the
727 /// search box straddling its top edge over the label and the header row's text
728 /// clipped away outside its bounds (the gallery's labelled tree).
729 fn arrange_children(&mut self, rect: Rect) {
730 let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
731 self.base.x = x;
732 self.base.y = y;
733 self.base.w = w;
734 self.base.h = h;
735
736 // The fields are placed here while the tree holds them; once they are the context's,
737 // `register_embedded_children` places them (it has the context, `set_rect` does not).
738 self.place_held_fields();
739
740 let search_margin_y = 6.0;
741 let search_h = 26.0;
742 let offset_y = search_h + 2.0 * search_margin_y;
743 let header_h = 26.0;
744 self.scroll_box.set_rect(x, y + offset_y + header_h, w, h - offset_y - header_h);
745
746 let content_h = self.items.len() as f32 * self.item_height;
747 self.scroll_box.update_bounds(content_h, y + offset_y + header_h, h - offset_y - header_h);
748 self.last_scroll_y = self.scroll_box.scroll_y;
749 }
750
751 /// Keep the field widgets registered/linked under the adapter every tick (the legacy
752 /// `set_parent` side effect; also heals the inline rename editor's registry entry).
753 fn register_embedded_children(&mut self, host_id: WidgetId, ctx: &mut UiContext) {
754 // In the context but deliberately NOT tree-linked (6bd): the tree is a SELF-ROUTING
755 // composite — mouse_body/move_body/key_body forward to every field widget
756 // internally, so the router's children-first descent double-delivered AND starved
757 // the tree-level logic (the recorded 6as latents: the hit add-key button consumed
758 // the press before mouse_body's take_click toggle ran, so the popover never
759 // opened, and the wheel died the same way). Being the context's keeps the ids
760 // resolvable for focus, coverage, the spatial grid and the accessibility tree.
761 // (The rename editor is linked while it is up; see `mouse_body`.)
762 let _ = host_id;
763 self.search_box.attach(ctx);
764 self.add_key_btn.attach(ctx);
765 self.add_key_popover_box.attach(ctx);
766 let r = self.field_rects();
767 self.search_box.get_mut(ctx).set_rect(r.search_box.x, r.search_box.y, r.search_box.width, r.search_box.height);
768 self.add_key_btn.get_mut(ctx).set_rect(r.add_key_btn.x, r.add_key_btn.y, r.add_key_btn.width, r.add_key_btn.height);
769 self.add_key_popover_box.get_mut(ctx).set_rect(r.popover_box.x, r.popover_box.y, r.popover_box.width, r.popover_box.height);
770 // The popover's box is shown only while the popover is: hidden, it is no Tab stop
771 // and no field a screen reader finds, where it stood in both, drawn or not.
772 let open = self.add_key_popover_open;
773 let b = self.add_key_popover_box.get_mut(ctx);
774 if WidgetHost::visible(b) != open {
775 WidgetHost::set_visible(b, open);
776 }
777 }
778
779 fn release_embedded_children(&mut self, ctx: &mut UiContext) {
780 self.search_box.detach(ctx);
781 self.add_key_btn.detach(ctx);
782 self.add_key_popover_box.detach(ctx);
783 self.edit_box.detach(ctx);
784 }
785 }
786
787 impl Paint for TreeList {
788 fn color(&self) -> [f32; 4] {
789 [0.0, 0.0, 0.0, 0.0]
790 }
791
792 fn widget_font(&self) -> Option<String> {
793 Some(crate::layout::tree_font())
794 }
795
796 // The field widgets are the context's but not tree-linked (self-routing, see
797 // register_embedded_children); their pixels come from `paint_ui`'s child pass — the
798 // walk must not descend either.
799 fn paints_own_subtree(&self) -> bool {
800 true
801 }
802
803 /// Shape the fields the tree still holds; once they are the context's, the runner
804 /// shapes them with every other widget it has.
805 fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
806 if let Some(f) = self.search_box.here_mut() {
807 f.prepare_text(fs);
808 }
809 if let Some(b) = self.add_key_btn.here_mut() {
810 b.prepare_text(fs);
811 }
812 if self.add_key_popover_open {
813 if let Some(f) = self.add_key_popover_box.here_mut() {
814 f.prepare_text(fs);
815 }
816 }
817 if self.editing_key_idx.is_some() {
818 if let Some(f) = self.edit_box.here_mut() {
819 f.prepare_text(fs);
820 }
821 }
822
823 }
824
825 /// The whole tree — container border/background, search/header chrome, virtualized
826 /// rows (backgrounds, separators, color previews, button pills), the scrollbar, the
827 /// row/header labels, and the field children (search box, add-key button, the add-key
828 /// popover box while open, the inline rename editor while editing). Ported verbatim
829 /// from the legacy `all_rounded_quads` rounded branch + `subtree_fonted_labels`;
830 /// children paint through their own adapters (dummy ctx — none of their paint reads it).
831 fn paint(&self, rect: Rect, pc: &mut PaintCtx) {
832 let (r1, r2, r3, r4) = self.tree_corners();
833 let mut quads: Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> = Vec::new();
834 let radius = self.tree_radius();
835 let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
836
837 let opacity = crate::layout::tree_opacity();
838 let apply_opacity = |mut c: [f32; 4]| -> [f32; 4] {
839 c[3] *= opacity;
840 c
841 };
842
843 // 0. The scrollbar's idle copy, BEFORE the plate: sunk behind it, it
844 // shows dimly through the translucent fill and takes no press. The
845 // fore copy fades in over the rows (below) while a scroll holds it.
846 self.scroll_box.paint_scrollbar_pills(pc, 1.0);
847
848 // 1. Draw container border and background. A true fill + border ring
849 // (not the legacy full-rect border punched out by the background quad,
850 // which read as a whole-pane border_color wash once the background
851 // went transparent).
852 if let Some((border_color, thickness)) = self.tree_border() {
853 let rr = |on: bool| if on { radius } else { 0.0 };
854 pc.border(
855 Rect { x, y, width: w, height: h },
856 (rr(r1), rr(r2), rr(r3), rr(r4)),
857 apply_opacity(crate::color::tree_background_color()),
858 apply_opacity(border_color),
859 thickness,
860 );
861 } else {
862 quads.push((x, y, w, h, radius, apply_opacity(crate::color::tree_background_color()), (r1, r2, r3, r4)));
863 }
864
865 let search_margin_y = 6.0;
866 let search_h = 26.0;
867 let offset_y = search_h + 2.0 * search_margin_y;
868
869 // Draw Header border (no background fill — the header sits directly on
870 // the pane surface, like the rows).
871 let header_h = 26.0;
872 let header_border_color = [0.18, 0.18, 0.22, 1.0];
873
874 let list_left = self.scroll_box.base.x;
875 let list_width = self.scroll_box.base.w;
876
877 // Separator line below header
878 quads.push((list_left + 1.0, y + offset_y + header_h - 1.0, list_width - 2.0, 1.0, 0.0, apply_opacity(header_border_color), (false, false, false, false)));
879
880 // Vertical separators inside header
881 quads.push((list_left + 180.0, y + offset_y + 1.0, 1.0, header_h - 2.0, 0.0, apply_opacity(header_border_color), (false, false, false, false)));
882 quads.push((list_left + 235.0, y + offset_y + 1.0, 1.0, header_h - 2.0, 0.0, apply_opacity(header_border_color), (false, false, false, false)));
883
884
885 // 2. Draw items (row backgrounds, separator lines, color previews)
886 let list_left = self.scroll_box.base.x;
887 let list_width = self.scroll_box.base.w;
888 let list_top = self.scroll_box.viewport_y;
889 let list_bottom = self.scroll_box.viewport_y + self.scroll_box.viewport_h;
890
891 for (i, item) in self.items.iter().enumerate() {
892 let row_y = list_top + i as f32 * self.item_height - self.scroll_box.scroll_y;
893 if row_y + self.item_height < list_top || row_y > list_bottom {
894 continue;
895 }
896
897 let draw_y = row_y.max(list_top);
898 let draw_bottom = (row_y + self.item_height).min(list_bottom);
899 let draw_h = draw_bottom - draw_y;
900 if draw_h <= 0.0 { continue; }
901
902 let bg_color = match item {
903 TreeElement::Section { .. } => {
904 if Some(i) == self.hovered_row_idx {
905 crate::color::tree_section_bg_hover_color()
906 } else {
907 crate::color::tree_section_bg_color()
908 }
909 }
910 TreeElement::Leaf { original_idx, .. } => {
911 if Some(*original_idx) == self.selected_key_idx {
912 crate::color::tree_leaf_bg_selected_color()
913 } else if Some(i) == self.hovered_row_idx {
914 crate::color::tree_leaf_bg_hover_color()
915 } else if i % 2 == 0 {
916 crate::color::tree_leaf_bg_even_color()
917 } else {
918 crate::color::tree_leaf_bg_odd_color()
919 }
920 }
921 };
922
923 let row_r1 = false;
924 let row_r2 = false;
925 let mut row_r3 = false;
926 let mut row_r4 = false;
927 let row_radius = radius - 1.0;
928
929 if draw_bottom >= list_bottom - radius {
930 row_r3 = r3;
931 row_r4 = r4;
932 }
933
934 quads.push((list_left + 1.0, draw_y, list_width - 2.0, draw_h, row_radius, apply_opacity(bg_color), (row_r1, row_r2, row_r3, row_r4)));
935
936 if let TreeElement::Leaf { ref val, original_idx, .. } = item {
937 let separator_color = crate::color::tree_separator_color();
938 quads.push((list_left + 180.0, draw_y, 1.0, draw_h, 0.0, apply_opacity(separator_color), (false, false, false, false)));
939 quads.push((list_left + 235.0, draw_y, 1.0, draw_h, 0.0, apply_opacity(separator_color), (false, false, false, false)));
940
941 let mut is_button = false;
942 if let Some(Some(ref anno)) = self.annotations.get(*original_idx) {
943 if anno == "button" || anno.starts_with("button:") {
944 is_button = true;
945 }
946 }
947
948 if Some(*original_idx) != self.selected_key_idx {
949 if is_button {
950 let btn_x = list_left + 245.0;
951 let btn_y = row_y + 1.0;
952 let btn_bottom = (row_y + 27.0).min(list_bottom);
953 let btn_draw_y = btn_y.max(list_top);
954 let btn_draw_h = btn_bottom - btn_draw_y;
955 if btn_draw_h > 0.0 {
956 let btn_bg = [0.10, 0.29, 0.33, 0.65]; // theme button color
957 quads.push((btn_x, btn_draw_y, 125.0, btn_draw_h, 4.0, apply_opacity(btn_bg), (true, true, true, true)));
958 }
959 } else if let serde_json::Value::String(s) = val {
960 if s.starts_with('#') {
961 if let Some(rgba) = parse_hex_f32(s) {
962 let preview_x = list_left + 245.0;
963 let preview_y = row_y + 4.0;
964 let preview_bottom = (row_y + 20.0).min(list_bottom);
965 let preview_draw_y = preview_y.max(list_top);
966 let preview_draw_h = preview_bottom - preview_draw_y;
967 if preview_draw_h > 0.0 {
968 // Checkerboard pattern
969 let grid_size = 8.0;
970 quads.push((preview_x, preview_draw_y, 16.0, preview_draw_h, 0.0, [1.0, 1.0, 1.0, 1.0], (false, false, false, false)));
971 let cols = (16.0f32 / grid_size).ceil() as i32;
972 let rows = (preview_draw_h / grid_size).ceil() as i32;
973 for r in 0..rows {
974 for c in 0..cols {
975 if (r + c) % 2 == 1 {
976 let qx = preview_x + c as f32 * grid_size;
977 let qy = preview_draw_y + r as f32 * grid_size;
978 let qw = grid_size.min(preview_x + 16.0 - qx);
979 let qh = grid_size.min(preview_draw_y + preview_draw_h - qy);
980 if qw > 0.0 && qh > 0.0 {
981 quads.push((qx, qy, qw, qh, 0.0, [0.8, 0.8, 0.8, 1.0], (false, false, false, false)));
982 }
983 }
984 }
985 }
986 quads.push((preview_x, preview_draw_y, 16.0, preview_draw_h, 0.0, rgba, (false, false, false, false)));
987 }
988 }
989 }
990 }
991 }
992 }
993
994 if row_y + self.item_height <= list_bottom {
995 let mut sep_r3 = false;
996 let mut sep_r4 = false;
997 if row_y + self.item_height >= list_bottom - radius {
998 sep_r3 = r3;
999 sep_r4 = r4;
1000 }
1001 quads.push((list_left + 1.0, row_y + self.item_height - 1.0, list_width - 2.0, 1.0, row_radius, apply_opacity([0.13, 0.13, 0.17, 1.0]), (false, false, sep_r3, sep_r4)));
1002 }
1003 }
1004
1005 for (qx, qy, qw, qh, qr, qc, qcorners) in quads {
1006 if qr > 0.1 {
1007 pc.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, qcorners, qc);
1008 } else {
1009 pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1010 }
1011 }
1012
1013 // Recessed well like a text box or list: the tree floor sits below the
1014 // pane surface, its wall carved over the bg and row quads above. Focus
1015 // lights the rim in the highlight accent instead of washing the tree
1016 // (the retired legacy_focus_highlight overlay).
1017 if crate::layout::control_relief() {
1018 let depth = crate::layout::bevel_width().min(h * 0.2);
1019 let (well, radii) = crate::layout::carve_inside(Rect { x, y, width: w, height: h }, (radius, radius, radius, radius), depth);
1020 if self.focused {
1021 let hc = crate::color::highlight_primary_color();
1022 pc.recess_tinted(well, radii, depth, [hc[0], hc[1], hc[2]]);
1023 } else {
1024 pc.recess(well, radii, depth);
1025 }
1026 }
1027
1028 // The scrollbar's fore copy, over the rows and the well's wall, at
1029 // the activity's fade.
1030 self.scroll_box.paint_scrollbar_pills(pc, self.scroll_box.scrollbar_fade());
1031
1032 // Row/header labels with the legacy header/list viewport bounds.
1033 let font = Some(crate::layout::tree_font());
1034 let (x, y, w, _h) = (rect.x, rect.y, rect.width, rect.height);
1035 let search_margin_y = 6.0;
1036 let search_h = 26.0;
1037 let offset_y = search_h + 2.0 * search_margin_y;
1038 let header_h = 26.0;
1039 // Labels and chevrons paint over the recessed well's wall (the recess
1040 // is shading-only and already drawn), so cut them at the wall's inner
1041 // edge — content slides under the bevel instead of sitting on it. The
1042 // top stays at the viewport: the wall there is behind the search/header
1043 // strip, outside the scroll area.
1044 let wall = if crate::layout::control_relief() {
1045 crate::layout::bevel_width().min(rect.height * 0.2)
1046 } else {
1047 0.0
1048 };
1049 let list_bounds = Some([
1050 self.scroll_box.base.x + wall,
1051 self.scroll_box.viewport_y,
1052 self.scroll_box.base.x + self.scroll_box.base.w - wall,
1053 self.scroll_box.viewport_y + self.scroll_box.viewport_h - wall,
1054 ]);
1055 let header_bounds = Some([x, y + offset_y, x + w, y + offset_y + header_h]);
1056 for (idx, (l, col_max_x)) in self.own_labels().into_iter().enumerate() {
1057 let mut b = if idx < 3 { header_bounds } else { list_bounds };
1058 if let (Some(bb), Some(mx)) = (b.as_mut(), col_max_x) {
1059 bb[2] = bb[2].min(mx);
1060 }
1061 pc.text_with(l.text, l.x, l.y, l.font_size, l.color, font.clone(), b);
1062 }
1063
1064 // Section chevrons: image icons in the slot own_labels leaves open,
1065 // clipped like the row text — to the list viewport shrunk by the
1066 // well wall, so arrows are cut off by the bevel, never drawn on it.
1067 {
1068 let (_, tree_font_size) = crate::layout::tree_font_parsed();
1069 let list_left = self.scroll_box.base.x;
1070 let list_top = self.scroll_box.viewport_y;
1071 let list_bottom = list_top + self.scroll_box.viewport_h;
1072 let viewport = Rect {
1073 x: list_left + wall,
1074 y: list_top,
1075 width: self.scroll_box.base.w - 2.0 * wall,
1076 height: self.scroll_box.viewport_h - wall,
1077 };
1078 pc.clip(viewport, |pc| {
1079 for (i, item) in self.items.iter().enumerate() {
1080 let row_y = list_top + i as f32 * self.item_height - self.scroll_box.scroll_y;
1081 if row_y + self.item_height < list_top || row_y > list_bottom {
1082 continue;
1083 }
1084 if let TreeElement::Section { indent, collapsed, .. } = item {
1085 if self.editing_key_idx == Some(i) {
1086 continue;
1087 }
1088 if let Some((id, _, _)) = Self::chevron_icon(*collapsed) {
1089 let s = tree_font_size;
1090 pc.image(id, Rect {
1091 x: list_left + 8.0 + *indent as f32 * 12.0,
1092 y: row_y + 7.0,
1093 width: s,
1094 height: s,
1095 }, 1.0);
1096 }
1097 }
1098 }
1099 });
1100 }
1101
1102 // Field children the tree still holds (no context: `paint_ui` paints the context's).
1103 let dummy = UiContext::new();
1104 self.paint_fields(&dummy, pc, true);
1105 }
1106
1107 /// The tree, then its fields wherever they are — the context's through `ui`.
1108 fn paint_ui(&self, ui: &UiContext, rect: Rect, pc: &mut PaintCtx) {
1109 self.paint(rect, pc);
1110 self.paint_fields(ui, pc, false);
1111 }
1112
1113 fn popover(&self, _rect: Rect) -> Option<(f32, f32, f32, f32)> {
1114 if self.add_key_popover_open {
1115 Some(self.popover_rect_geom())
1116 } else {
1117 None
1118 }
1119
1120 }
1121
1122 fn draw_popover(&self, _rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
1123 if !self.add_key_popover_open { return; }
1124
1125 let (rx, ry, rw, rh) = self.popover_rect_geom();
1126
1127 // 1. Soft layered drop shadows
1128 pc.rect([0.02, 0.02, 0.05, 0.15], rx + 1.0, ry + 1.0, rw, rh);
1129 pc.rect([0.02, 0.02, 0.05, 0.08], rx + 3.0, ry + 3.0, rw, rh);
1130 pc.rect([0.02, 0.02, 0.05, 0.04], rx + 5.0, ry + 5.0, rw, rh);
1131
1132 let theme = crate::color::active_theme();
1133
1134 // 2. High-contrast premium outer border
1135 pc.rect(theme.surface_border, rx, ry, rw, rh);
1136
1137 // 3. Frosted glass background
1138 pc.rect(theme.surface_bg, rx + 1.0, ry + 1.0, rw - 2.0, rh - 2.0); // bg
1139
1140 }
1141 }
1142
1143 impl Input for TreeList {
1144 fn focus_role(&self) -> crate::widget::FocusRole {
1145 crate::widget::FocusRole::Well
1146 }
1147 fn blocks_root_plate_drag(&self) -> bool {
1148 true
1149 }
1150
1151 /// The tree must see every press: outside presses dismiss the add-key popover and
1152 /// commit/cancel the inline rename editor (the legacy ungated `mouse_input` contract).
1153 fn gates_presses(&self) -> bool {
1154 false
1155 }
1156
1157 fn wants_tick(&self) -> bool {
1158 true
1159 }
1160
1161 fn draggable(&self, _rect: Rect) -> bool {
1162 self.scroll_box.draggable()
1163 }
1164
1165 fn is_dragging(&self) -> bool {
1166 self.scroll_box.is_dragging()
1167 }
1168
1169 fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
1170 self.scroll_box.drag_begin(px, py);
1171 }
1172
1173 fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
1174 self.scroll_box.drag_update(px, py)
1175 }
1176
1177 fn drag_end(&mut self) {
1178 self.scroll_box.drag_end();
1179 }
1180
1181 /// The legacy `tick` body: advances the field widgets, drains the add-key popover and
1182 /// search box, positions/commits the inline rename editor (re-targeting focus to the
1183 /// adapter on commit), and runs the scrollbar activity fade.
1184 fn tick_ctx(&mut self, dt: f32, ectx: &mut EventCtx) -> bool {
1185 let host_id = ectx.id;
1186 let Some(ui) = ectx.ui.as_deref_mut() else {
1187 return false;
1188 };
1189 let mut changed = false;
1190 if self.search_box.lend(ui, |w, ui| w.tick(dt, ui)) == Some(true) {
1191 changed = true;
1192 }
1193 if self.add_key_btn.lend(ui, |w, ui| w.tick(dt, ui)) == Some(true) {
1194 changed = true;
1195 }
1196 if self.add_key_popover_open {
1197 if self.add_key_popover_box.lend(ui, |w, ui| w.tick(dt, ui)) == Some(true) {
1198 changed = true;
1199 }
1200 if !self.add_key_popover_box.get(ui).editing {
1201 let path = self.add_key_popover_box.get(ui).text.trim().to_string();
1202 if !path.is_empty() {
1203 self.new_key_path_request = Some(path);
1204 }
1205 self.add_key_popover_open = false;
1206 ui.clear_focus();
1207 changed = true;
1208 }
1209 }
1210 if self.search_box.get_mut(ui).take_change() {
1211 self.query = self.search_box.get(ui).text.clone();
1212 self.rebuild_tree();
1213 changed = true;
1214 }
1215
1216 if self.editing_key_idx.is_some() {
1217 if self.edit_box.lend(ui, |w, ui| w.tick(dt, ui)) == Some(true) {
1218 changed = true;
1219 }
1220 if let Some(row_idx) = self.editing_key_idx {
1221 if row_idx < self.items.len() {
1222 let list_left = self.scroll_box.base.x;
1223 let list_top = self.scroll_box.viewport_y;
1224 let row_y = list_top + row_idx as f32 * self.item_height - self.scroll_box.scroll_y;
1225 let box_x = list_left + 5.0;
1226 let box_y = row_y + 2.0;
1227 self.edit_box.get_mut(ui).set_rect(box_x, box_y, 170.0, 24.0);
1228 }
1229 }
1230 if !self.edit_box.get(ui).editing {
1231 let row_idx = self.editing_key_idx.unwrap();
1232 if row_idx < self.items.len() {
1233 let (old_path, relative_name) = match &self.items[row_idx] {
1234 TreeElement::Section { path, name, .. } => (path.clone(), name.clone()),
1235 TreeElement::Leaf { path, name, .. } => (path.clone(), name.clone()),
1236 };
1237 let new_name = self.edit_box.get(ui).text.trim().to_string();
1238 if !new_name.is_empty() && new_name != relative_name {
1239 let new_path = if let Some(pos) = old_path.rfind('.') {
1240 format!("{}.{}", &old_path[..pos], new_name)
1241 } else {
1242 new_name
1243 };
1244 self.rename_request = Some((old_path, new_path));
1245 }
1246 }
1247 self.editing_key_idx = None;
1248 // Undo the register+link done when editing began (see `mouse_body`). The paint
1249 // (`if self.editing_key_idx.is_some()`) and the `set_rect` beside it are both
1250 // gated on editing, but the tree link was not — so leaving it attached parked a
1251 // 170x24 child at the last-edited row's screen coordinates that kept its stale
1252 // rect, kept hit-testing (visible, gates_presses default true) and swallowed the
1253 // press before `mouse_body` ever ran: an invisible dead zone that ate row clicks
1254 // and silently re-entered editing on an unpainted box. Each rename also minted a
1255 // fresh TextBox id into the same field, so the child list grew monotonically.
1256 let eb_id = self.edit_box.id();
1257 ui.unlink_child(host_id, eb_id);
1258 self.edit_box.detach(ui);
1259 ui.claim_focus(host_id);
1260 self.focused = true;
1261 changed = true;
1262 }
1263 }
1264
1265 // The list's own glide/coast (wheel notches and trackpad flicks land
1266 // in the ScrollBox; only its tick moves the drawn offset).
1267 if self.scroll_box.tick(dt, ui) {
1268 changed = true;
1269 }
1270 // A scroll the tree made itself (following the selection) raises the
1271 // bar as a wheel would.
1272 if (self.scroll_box.scroll_y - self.last_scroll_y).abs() > 0.01 {
1273 self.last_scroll_y = self.scroll_box.scroll_y;
1274 self.scroll_box.notify_scrolled();
1275 changed = true;
1276 }
1277 changed
1278
1279 }
1280
1281 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
1282 let host_id = ectx.id;
1283 match event {
1284 Event::MouseButton { button, state, x, y, .. } => {
1285 let (button, state, px, py) = (*button, *state, *x, *y);
1286 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
1287 self.mouse_body(button, state, px, py, ui, host_id)
1288 }
1289 Event::PointerMove { x, y, .. } => {
1290 let (px, py) = (*x, *y);
1291 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
1292 self.move_body(px, py, ui)
1293 }
1294 Event::MouseWheel { delta, x, y, .. } => {
1295 let (delta, px, py) = (*delta, *x, *y);
1296 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
1297 self.scroll_box.mouse_wheel(&delta, px, py, ui)
1298 }
1299 Event::KeyInput(ev) => {
1300 let ev = ev.clone();
1301 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
1302 self.key_body(&ev, ui)
1303 }
1304 Event::FocusIn => {
1305 self.focused = true;
1306 false
1307 }
1308 Event::FocusOut => {
1309 self.focused = false;
1310 self.add_key_popover_open = false;
1311 match ectx.ui.as_deref_mut() {
1312 Some(ui) => self.add_key_popover_box.get_mut(ui).unfocus(),
1313 None => {
1314 if let Some(b) = self.add_key_popover_box.here_mut() {
1315 b.unfocus();
1316 }
1317 }
1318 }
1319 false
1320 }
1321 _ => false,
1322 }
1323 }
1324
1325 fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
1326 use crate::widget::ContextAction as CA;
1327 match action {
1328 CA::CopyKey => self.copy_key(),
1329 CA::CopyValue => self.copy_value(),
1330 CA::DeleteKey => self.delete_key(),
1331 CA::ExpandNode => self.expand_node(),
1332 CA::CollapseNode => self.collapse_node(),
1333 CA::ExpandAll => self.expand_all_nodes(),
1334 CA::CollapseAll => self.collapse_all_nodes(),
1335 _ => return false,
1336 }
1337 true
1338 }
1339 }
1340
1341 impl TreeList {
1342 // The tree context-menu actions, dispatched by the global context menu through
1343 // `Input::context_action`.
1344 fn copy_key(&self) {
1345 if let Some(idx) = self.selected_key_idx {
1346 if idx < self.flat_keys.len() {
1347 let key_path = &self.flat_keys[idx].0;
1348 clipboard::copy_to_clipboard(key_path);
1349 }
1350 }
1351 }
1352
1353 fn copy_value(&self) {
1354 if let Some(idx) = self.selected_key_idx {
1355 if idx < self.flat_keys.len() {
1356 let val = &self.flat_keys[idx].1;
1357 let val_str = match val {
1358 serde_json::Value::String(s) => s.clone(),
1359 serde_json::Value::Bool(b) => b.to_string(),
1360 serde_json::Value::Number(n) => n.to_string(),
1361 other => serde_json::to_string(other).unwrap_or_default(),
1362 };
1363 clipboard::copy_to_clipboard(&val_str);
1364 }
1365 }
1366 }
1367
1368 fn delete_key(&mut self) {
1369 if let Some(idx) = self.selected_key_idx {
1370 if idx < self.flat_keys.len() {
1371 let key_path = self.flat_keys[idx].0.clone();
1372 self.deleted_key_path = Some(key_path);
1373 }
1374 }
1375 }
1376
1377 fn expand_node(&mut self) {
1378 if let Some(path) = self.right_clicked_section.clone() {
1379 self.collapsed_sections.remove(&path);
1380 self.rebuild_tree();
1381 self.clicked_item = Some(TreeElement::Section {
1382 path,
1383 name: String::new(),
1384 indent: 0,
1385 collapsed: false,
1386 });
1387 }
1388 self.right_clicked_section = None;
1389 }
1390
1391 fn collapse_node(&mut self) {
1392 if let Some(path) = self.right_clicked_section.clone() {
1393 self.collapsed_sections.insert(path.clone());
1394 self.rebuild_tree();
1395 self.clicked_item = Some(TreeElement::Section {
1396 path,
1397 name: String::new(),
1398 indent: 0,
1399 collapsed: true,
1400 });
1401 }
1402 self.right_clicked_section = None;
1403 }
1404
1405 fn expand_all_nodes(&mut self) {
1406 self.collapsed_sections.clear();
1407 self.rebuild_tree();
1408 if let Some(path) = self.right_clicked_section.clone() {
1409 self.clicked_item = Some(TreeElement::Section {
1410 path,
1411 name: String::new(),
1412 indent: 0,
1413 collapsed: false,
1414 });
1415 } else {
1416 self.clicked_item = Some(TreeElement::Section {
1417 path: String::new(),
1418 name: String::new(),
1419 indent: 0,
1420 collapsed: false,
1421 });
1422 }
1423 self.right_clicked_section = None;
1424 }
1425
1426 fn collapse_all_nodes(&mut self) {
1427 self.collapsed_sections = self.get_all_section_paths();
1428 self.rebuild_tree();
1429 if let Some(path) = self.right_clicked_section.clone() {
1430 self.clicked_item = Some(TreeElement::Section {
1431 path,
1432 name: String::new(),
1433 indent: 0,
1434 collapsed: true,
1435 });
1436 } else {
1437 self.clicked_item = Some(TreeElement::Section {
1438 path: String::new(),
1439 name: String::new(),
1440 indent: 0,
1441 collapsed: true,
1442 });
1443 }
1444 self.right_clicked_section = None;
1445 }
1446
1447 }
1448
1449 impl TreeList {
1450 fn get_all_section_paths(&self) -> HashSet<String> {
1451 let mut sections = HashSet::new();
1452 for (key_path, _) in &self.flat_keys {
1453 let tokens = parse_path(key_path);
1454 let mut current_prefix = String::new();
1455 for i in 0..(tokens.len().saturating_sub(1)) {
1456 let token = &tokens[i];
1457 match token {
1458 PathToken::Key(k) => {
1459 if current_prefix.is_empty() {
1460 current_prefix = k.clone();
1461 } else {
1462 current_prefix = format!("{}.{}", current_prefix, k);
1463 }
1464 }
1465 PathToken::Index(idx) => {
1466 let s = format!("[{}]", idx);
1467 current_prefix = format!("{}{}", current_prefix, s);
1468 }
1469 };
1470 sections.insert(current_prefix.clone());
1471 }
1472 }
1473 sections
1474 }
1475 }
1476
1477 fn parse_hex_f32(s: &str) -> Option<[f32; 4]> {
1478 crate::color::parse_hex_rgba_linear(s)
1479 }
1480
1481 unsafe impl Send for TreeList {}
1482 unsafe impl Sync for TreeList {}
1483
1484 impl TreeList {
1485 /// Row/header labels plus each label's column clip: the x where its
1486 /// column ends (None = only the shared list/header bounds apply). Key and
1487 /// Type cells clip at their separators so text can't bleed into the next
1488 /// column; sections span the whole row.
1489 /// The section-row chevron (cce-icons), cached per size by `upload_icon`;
1490 /// `None` when the icon set is missing (the slot is left empty).
1491 fn chevron_icon(collapsed: bool) -> Option<(u32, u32, u32)> {
1492 crate::upload_icon(if collapsed { "chevron-right" } else { "chevron-down" }, 32)
1493 }
1494
1495 pub(crate) fn own_labels(&self) -> Vec<(TextLabel, Option<f32>)> {
1496 let f32_to_rgb = |c: [f32; 4]| -> [u8; 3] {
1497 [
1498 (crate::color::linear_to_srgb(c[0]) * 255.0).round() as u8,
1499 (crate::color::linear_to_srgb(c[1]) * 255.0).round() as u8,
1500 (crate::color::linear_to_srgb(c[2]) * 255.0).round() as u8,
1501 ]
1502 };
1503
1504 let (_, tree_font_size) = crate::layout::tree_font_parsed();
1505 let header_font_size = (tree_font_size - 1.0).max(8.0);
1506
1507 let mut labels = Vec::new();
1508 let list_left = self.scroll_box.base.x;
1509 let list_top = self.scroll_box.viewport_y;
1510 let list_bottom = self.scroll_box.viewport_y + self.scroll_box.viewport_h;
1511
1512 let search_margin_y = 6.0;
1513 let search_h = 26.0;
1514 let offset_y = search_h + 2.0 * search_margin_y;
1515
1516 labels.push((TextLabel {
1517 text: "Key".to_string(),
1518 x: list_left + 8.0,
1519 y: self.base.y + offset_y + 6.0,
1520 font_size: header_font_size,
1521 color: [200, 200, 210],
1522 }, None));
1523 labels.push((TextLabel {
1524 text: "Type".to_string(),
1525 x: list_left + 180.0 + 8.0,
1526 y: self.base.y + offset_y + 6.0,
1527 font_size: header_font_size,
1528 color: [200, 200, 210],
1529 }, None));
1530 labels.push((TextLabel {
1531 text: "Value".to_string(),
1532 x: list_left + 235.0 + 8.0,
1533 y: self.base.y + offset_y + 6.0,
1534 font_size: header_font_size,
1535 color: [200, 200, 210],
1536 }, None));
1537
1538 for (i, item) in self.items.iter().enumerate() {
1539 let row_y = list_top + i as f32 * self.item_height - self.scroll_box.scroll_y;
1540 if row_y + self.item_height < list_top || row_y > list_bottom {
1541 continue;
1542 }
1543
1544 match item {
1545 TreeElement::Section { name, indent, .. } => {
1546 if self.editing_key_idx != Some(i) {
1547 let sx = list_left + 8.0 + *indent as f32 * 12.0;
1548 // The chevron glyph (cce-icons) stands in the slot
1549 // this leaves open — paint() draws it. A machine
1550 // without the icon set shows the slot empty: no
1551 // symbol is drawn as a character.
1552 let (text, tx) = (name.clone(), sx + tree_font_size + 6.0);
1553 labels.push((TextLabel {
1554 text,
1555 x: tx,
1556 y: row_y + 6.0,
1557 font_size: tree_font_size,
1558 color: f32_to_rgb(crate::color::tree_section_text_color()),
1559 }, None));
1560 }
1561 }
1562 TreeElement::Leaf { name, indent, val, original_idx, .. } => {
1563 // A unit-suffixed string (`"2mm"`, what `(mm)2.0` reads
1564 // as) shows as the length it is — `2 mm` — not a quoted
1565 // string; its unit is its type below.
1566 let len = val.as_str().and_then(crate::units::Len::parse);
1567 let val_str = match len {
1568 Some(l) => format!("{} {}", crate::units::fmt_num(l.value), l.unit.suffix()),
1569 None => serde_json::to_string(val).unwrap_or_default(),
1570 };
1571 // Generous shaping cap only — the column bounds clip the
1572 // visible text at the list edge. (char-based: the old
1573 // byte slice could panic on multibyte text.)
1574 let display_val = if val_str.chars().count() > 120 {
1575 let cut: String = val_str.chars().take(117).collect();
1576 format!("{}...", cut)
1577 } else {
1578 val_str
1579 };
1580
1581 let color = if Some(*original_idx) == self.selected_key_idx {
1582 f32_to_rgb(crate::color::tree_leaf_text_selected_color())
1583 } else {
1584 f32_to_rgb(crate::color::tree_leaf_text_color())
1585 };
1586
1587 if self.editing_key_idx != Some(i) {
1588 labels.push((TextLabel {
1589 text: name.clone(),
1590 x: list_left + 8.0 + *indent as f32 * 12.0,
1591 y: row_y + 6.0,
1592 font_size: tree_font_size,
1593 color,
1594 }, Some(list_left + 178.0)));
1595 }
1596
1597 let val_ty = match val {
1598 serde_json::Value::Bool(_) => Some("bool"),
1599 serde_json::Value::Number(num) => {
1600 if num.is_f64() {
1601 Some("f64")
1602 } else {
1603 Some("i64")
1604 }
1605 }
1606 serde_json::Value::String(s) => {
1607 if s.starts_with('#') {
1608 let s_clean = s.trim_start_matches('#');
1609 if s_clean.len() == 8 {
1610 Some("rgba")
1611 } else {
1612 Some("rgb")
1613 }
1614 } else if name == "key" || name == "keybind" || name == "shortcut" || name == "open_search" || name == "close_search" || name == "delete" || name.ends_with("_key") || name.ends_with(".key") || name.ends_with(".keybind") || name.ends_with(".shortcut") || name.ends_with(".open_search") || name.ends_with(".close_search") || name.ends_with("_delete") || name.ends_with(".delete") {
1615 Some("keybind")
1616 } else if name == "font" || name.ends_with("_font") || name.ends_with(".font") {
1617 Some("font")
1618 } else { len.map(|l| l.unit.suffix()) }
1619 }
1620 _ => None,
1621 };
1622 let mut display_ty = val_ty.map(|s| s.to_string());
1623 if let Some(Some(ref anno)) = self.annotations.get(*original_idx) {
1624 if anno.starts_with("menu:") {
1625 display_ty = Some("menu".to_string());
1626 } else if anno == "button" || anno.starts_with("button:") {
1627 display_ty = Some("button".to_string());
1628 } else {
1629 display_ty = Some(anno.clone());
1630 }
1631 } else if display_ty.is_none() {
1632 if let serde_json::Value::String(_) = val {
1633 display_ty = Some("string".to_string());
1634 }
1635 }
1636
1637 if let Some(ty) = display_ty {
1638 let ty_text = format!("({})", ty);
1639 labels.push((TextLabel {
1640 text: ty_text,
1641 x: list_left + 190.0,
1642 y: row_y + 6.0,
1643 font_size: tree_font_size,
1644 color: f32_to_rgb(crate::color::tree_type_text_color()),
1645 }, Some(list_left + 233.0)));
1646 }
1647
1648 if Some(*original_idx) != self.selected_key_idx {
1649 let mut is_button = false;
1650 if let Some(Some(ref anno)) = self.annotations.get(*original_idx) {
1651 if anno == "button" || anno.starts_with("button:") {
1652 is_button = true;
1653 }
1654 }
1655
1656 let is_color = if let serde_json::Value::String(s) = val {
1657 s.starts_with('#')
1658 } else {
1659 false
1660 };
1661
1662 let label_x = if is_color {
1663 list_left + 267.0
1664 } else {
1665 list_left + 245.0
1666 };
1667
1668 if is_button {
1669 labels.push((TextLabel {
1670 text: display_val,
1671 x: list_left + 245.0 + 8.0,
1672 y: row_y + 6.0,
1673 font_size: tree_font_size,
1674 color: [240, 240, 245],
1675 }, None));
1676 } else {
1677 labels.push((TextLabel {
1678 text: display_val,
1679 x: label_x,
1680 y: row_y + 6.0,
1681 font_size: tree_font_size,
1682 color: f32_to_rgb(crate::color::tree_value_text_color()),
1683 }, None));
1684 }
1685 }
1686 }
1687 }
1688 }
1689 labels
1690 }
1691
1692 }
1693
1694 #[cfg(test)]
1695 mod tests {
1696 use super::*;
1697 use crate::context::UiContext;
1698
1699 /// A labelled tree's fields sit in its content rect, below the label strip — where
1700 /// `paint` draws the well — not at the top of the block the adapter is given.
1701 #[test]
1702 fn a_labelled_trees_fields_are_in_its_content_rect() {
1703 let mut tree_list = TreeList::new().with_label("TreeList");
1704 let strip = tree_list.label_strip();
1705 assert!(strip > 0.0, "a detached label has a strip");
1706 tree_list.set_rect(10.0, 52.0, 380.0, 200.0 + strip);
1707 let content_y = 52.0 + strip;
1708 let (_, sy, _, sh) = tree_list.search_box.here().unwrap().rect();
1709 assert_eq!(sy, content_y + 6.0, "the search box is inside the well, one margin down");
1710 let (_, by, _, _) = tree_list.add_key_btn.here().unwrap().rect();
1711 assert_eq!(by, sy, "the add-key button shares the search row");
1712 let header_y = content_y + sh + 12.0;
1713 assert_eq!(tree_list.scroll_box.base.y, header_y + 26.0, "the rows start under the header");
1714 let labels = tree_list.own_labels();
1715 let key = labels.iter().find(|(l, _)| l.text == "Key").expect("a Key header");
1716 assert_eq!(key.0.y, header_y + 6.0, "the header text is in the header band");
1717 assert_eq!(tree_list.scroll_box.base.y + tree_list.scroll_box.base.h, 52.0 + strip + 200.0, "the rows end at the block's bottom");
1718
1719 // In a context the fields are its entries, placed in the same places by the
1720 // embedded hook.
1721 let held = (tree_list.search_box.here().unwrap().rect(), tree_list.add_key_btn.here().unwrap().rect());
1722 let mut ctx = UiContext::new();
1723 let h = ctx.insert(tree_list);
1724 ctx.lend_h(h, |t, ctx| t.attach_embedded(ctx));
1725 let t = &ctx[h];
1726 assert!(t.search_box.is_attached() && t.add_key_btn.is_attached());
1727 assert_eq!((t.search_box.get(&ctx).rect(), t.add_key_btn.get(&ctx).rect()), held);
1728 }
1729
1730 /// The add-key popover's box is in the window only while the popover is open: closed,
1731 /// it is no Tab stop and no field a screen reader finds; the add-key button opens it,
1732 /// and focuses it.
1733 #[test]
1734 fn the_add_key_box_is_there_only_while_its_popover_is() {
1735 let mut ctx = UiContext::new();
1736 let tl = ctx.insert(TreeList::new());
1737 WidgetHost::set_rect(&mut ctx[tl], 0.0, 0.0, 400.0, 300.0);
1738 ctx.tick(0.016);
1739 let box_id = ctx[tl].add_key_popover_box.id();
1740 let in_tree = |ctx: &UiContext| crate::a11y::tree_update(ctx, "", 1.0).nodes.iter().any(|(n, _)| *n == crate::a11y::node_id(box_id));
1741 assert!(!ctx.get_widget(box_id).unwrap().visible(), "closed, the box is hidden");
1742 assert!(!in_tree(&ctx), "and not in the accessibility tree");
1743 for _ in 0..4 {
1744 ctx.focus_step(false);
1745 assert!(!ctx.is_focused_id(box_id), "nor a Tab stop");
1746 }
1747
1748 let b = ctx[tl].field_rects().add_key_btn;
1749 let (x, y) = (b.x + b.width / 2.0, b.y + b.height / 2.0);
1750 let at = |state| Event::MouseButton { button: MouseButton::Left, state, x, y, local_x: x, local_y: y };
1751 ctx.propagate_event(&at(ElementState::Pressed), tl.id());
1752 ctx.propagate_event(&at(ElementState::Released), tl.id());
1753 assert!(ctx[tl].add_key_popover_open, "the button opens the popover");
1754 assert!(ctx.get_widget(box_id).unwrap().visible() && ctx.is_focused_id(box_id), "its box shown and focused");
1755 assert!(in_tree(&ctx));
1756 }
1757
1758 #[test]
1759 fn test_treelist_blocks_window_drag() {
1760 // root plate container is DELETED: dissolved windows ask `drag_allowed_at` instead — same
1761 // walk, minus the registered-movable-root plate container requirement.
1762 let mut ctx = UiContext::new();
1763 let tree_list = ctx.insert(TreeList::new());
1764 ctx[tree_list].set_rect(10.0, 52.0, 380.0, 500.0);
1765
1766 ctx.tick(0.016);
1767 ctx.clear_dirty();
1768
1769 assert!(!ctx.drag_allowed_at(100.0, 200.0), "clicking the TreeList must block the window drag");
1770 assert!(ctx.drag_allowed_at(600.0, 300.0), "empty surface stays draggable");
1771 }
1772
1773 #[test]
1774 fn test_exact_app_layout_blocks_drag() {
1775 // The data-editor shape: a parentless tree registered directly (dissolved root).
1776 let mut ctx = UiContext::new();
1777 let tree_list = ctx.insert(TreeList::new());
1778
1779 ctx.rebuild_spatial_grid();
1780
1781 let list_top = 52.0;
1782 let list_bottom = 600.0 - 180.0;
1783 ctx[tree_list].set_rect(10.0, list_top, 380.0, list_bottom - list_top);
1784 ctx.rebuild_spatial_grid();
1785
1786 assert!(!ctx.drag_allowed_at(100.0, 200.0), "clicking the TreeList under the app layout must block the drag");
1787 }
1788
1789 #[test]
1790 fn test_treelist_separators() {
1791 let mut tree_list = TreeList::new();
1792 tree_list.set_rect(10.0, 52.0, 380.0, 500.0);
1793 tree_list.set_flat_keys(vec![
1794 ("style.data.tree.corner_radius".to_string(), serde_json::Value::Number(serde_json::Number::from(8)))
1795 ]);
1796
1797 println!("Flat keys size: {}", tree_list.flat_keys.len());
1798 println!("Items count: {}", tree_list.items.len());
1799 for (i, item) in tree_list.items.iter().enumerate() {
1800 println!("Item {}: {:?}", i, item);
1801 }
1802 println!("scroll_box base.x: {}", tree_list.scroll_box.base.x);
1803 println!("scroll_box viewport_y: {}", tree_list.scroll_box.viewport_y);
1804 println!("scroll_box viewport_h: {}", tree_list.scroll_box.viewport_h);
1805 println!("scroll_box scroll_y: {}", tree_list.scroll_box.scroll_y);
1806 println!("item_height: {}", tree_list.item_height);
1807
1808 let quads = crate::widget::shown_rounded_quads(&tree_list);
1809 println!("Rounded quads count: {}", quads.len());
1810 for (i, q) in quads.iter().enumerate() {
1811 println!("Quad {}: {:?}", i, q);
1812 }
1813 assert!(quads.len() > 1, "Should have more than 1 quad!");
1814 }
1815
1816 #[test]
1817 fn test_keybind_label() {
1818 let mut tree_list = TreeList::new();
1819 tree_list.set_rect(10.0, 52.0, 380.0, 500.0);
1820 tree_list.annotations = vec![Some("menu:flat,adaptive".to_string())];
1821 tree_list.set_flat_keys(vec![
1822 ("input.accel_profile".to_string(), serde_json::Value::String("flat".to_string()))
1823 ]);
1824
1825 let labels = tree_list.own_labels();
1826 for label in &labels {
1827 println!("TEST LABEL: {:?}", label);
1828 }
1829
1830 let has_menu_label = labels.iter().any(|(l, _)| l.text == "(menu)");
1831 assert!(has_menu_label, "Should have (menu) label!");
1832 }
1833
1834 #[test]
1835 fn test_treelist_headers() {
1836 let tree_list = TreeList::new();
1837 let labels = tree_list.own_labels();
1838 assert!(labels.iter().any(|(l, _)| l.text == "Key"), "Should have Key header!");
1839 assert!(labels.iter().any(|(l, _)| l.text == "Type"), "Should have Type header!");
1840 assert!(labels.iter().any(|(l, _)| l.text == "Value"), "Should have Value header!");
1841 }
1842
1843 #[test]
1844 fn test_treelist_search_filtering() {
1845 let mut tree_list = TreeList::new();
1846 tree_list.set_rect(10.0, 52.0, 380.0, 500.0);
1847 tree_list.set_flat_keys(vec![
1848 ("style.data.tree.corner_radius".to_string(), serde_json::Value::Number(serde_json::Number::from(8))),
1849 ("style.data.tree.border_color".to_string(), serde_json::Value::String("#ff0000".to_string())),
1850 ("input.accel_profile".to_string(), serde_json::Value::String("flat".to_string())),
1851 ]);
1852
1853 // Match none
1854 tree_list.query = "nonexistent".to_string();
1855 tree_list.rebuild_tree();
1856 assert!(tree_list.items.is_empty(), "Tree should be empty for nonexistent search query!");
1857
1858 // Match partially on key path
1859 tree_list.query = "corner".to_string();
1860 tree_list.rebuild_tree();
1861 assert!(!tree_list.items.is_empty(), "Tree should have items matching 'corner'!");
1862 let has_corner = tree_list.items.iter().any(|item| match item {
1863 TreeElement::Leaf { name, .. } => name == "corner_radius",
1864 _ => false,
1865 });
1866 assert!(has_corner, "Tree should contain 'corner_radius' item!");
1867 let has_accel = tree_list.items.iter().any(|item| match item {
1868 TreeElement::Leaf { name, .. } => name == "accel_profile",
1869 _ => false,
1870 });
1871 assert!(!has_accel, "Tree should not contain 'accel_profile' item!");
1872
1873 // Match on value
1874 tree_list.query = "flat".to_string();
1875 tree_list.rebuild_tree();
1876 let has_accel = tree_list.items.iter().any(|item| match item {
1877 TreeElement::Leaf { name, .. } => name == "accel_profile",
1878 _ => false,
1879 });
1880 assert!(has_accel, "Tree should contain 'accel_profile' when matching on value 'flat'!");
1881
1882 // Collapse matching section when filtered
1883 tree_list.query = "corner".to_string();
1884 tree_list.collapsed_sections.insert("style.data.tree".to_string());
1885 tree_list.rebuild_tree();
1886 let has_corner = tree_list.items.iter().any(|item| match item {
1887 TreeElement::Leaf { name, .. } => name == "corner_radius",
1888 _ => false,
1889 });
1890 assert!(!has_corner, "Tree should NOT contain 'corner_radius' item when its parent section 'style.data.tree' is collapsed!");
1891
1892 let has_collapsed_section = tree_list.items.iter().any(|item| match item {
1893 TreeElement::Section { path, collapsed, .. } => path == "style.data.tree" && *collapsed,
1894 _ => false,
1895 });
1896 assert!(has_collapsed_section, "Tree should contain 'style.data.tree' collapsed section!");
1897 }
1898
1899 #[test]
1900 fn test_treelist_double_click_rename() {
1901 let mut ctx = UiContext::new();
1902 let h = ctx.insert(TreeList::new());
1903 ctx.lend_h(h, |t, ctx| {
1904 t.set_rect(0.0, 0.0, 380.0, 500.0);
1905 t.attach_embedded(ctx);
1906 });
1907 ctx[h].set_flat_keys(vec![
1908 ("style.control.dropdown.color".to_string(), serde_json::Value::String("#ff00ff".to_string()))
1909 ]);
1910 let double_click = |ctx: &mut UiContext, py: f32| {
1911 ctx.lend_h(h, |t, ctx| {
1912 t.mouse_input(MouseButton::Left, ElementState::Pressed, 10.0, py, ctx);
1913 std::thread::sleep(std::time::Duration::from_millis(10));
1914 t.mouse_input(MouseButton::Left, ElementState::Pressed, 10.0, py, ctx);
1915 });
1916 };
1917 // The editor is the context's while a rename is up, and given back when it commits.
1918 let commit = |ctx: &mut UiContext, name: &str| {
1919 let eb = ctx[h].edit_box.handle().expect("the editor is in the context while it is up");
1920 let b = &mut ctx[eb];
1921 b.text = name.to_string();
1922 b.edit_buffer = name.to_string();
1923 b.editing = false;
1924 ctx.lend_h(h, |t, ctx| t.tick(0.016, ctx));
1925 assert!(ctx.get(eb).is_none(), "the committed editor left the context");
1926 };
1927
1928 // 1. Test renaming a section (row 0)
1929 let list_top = ctx[h].scroll_box.viewport_y;
1930 double_click(&mut ctx, list_top + 10.0);
1931 assert!(ctx[h].editing_key_idx.is_some());
1932 assert_eq!(ctx[h].edit_box.get(&ctx).text, "style"); // Pre-populated with relative name!
1933 commit(&mut ctx, "theme");
1934 let req = ctx[h].take_rename_request();
1935 assert_eq!(req, Some(("style".to_string(), "theme".to_string())));
1936
1937 // 2. Test renaming a leaf (row 3)
1938 ctx[h].rebuild_tree();
1939 let py3 = list_top + 3.0 * ctx[h].item_height + 10.0; // Click row 3 (Leaf "color")
1940 double_click(&mut ctx, py3);
1941 assert!(ctx[h].editing_key_idx.is_some());
1942 assert_eq!(ctx[h].edit_box.get(&ctx).text, "color"); // Pre-populated with relative name "color"!
1943 commit(&mut ctx, "bg_color");
1944 let req = ctx[h].take_rename_request();
1945 assert_eq!(req, Some(("style.control.dropdown.color".to_string(), "style.control.dropdown.bg_color".to_string())));
1946 }
1947 }