1use crate::icons::icons;
6use crate::localization as l10n;
7use crate::ui_kit::{IconButton, PanelHeader, filter::filter_input};
8use egui::{Frame, RichText, Ui, vec2};
9
10use super::super::controls::dropdown;
11use super::actions::ObjectTreeAction;
12use super::config::ObjectTreeConfig;
13use super::context_menu::handle_keyboard_shortcuts;
14use super::data_window::show_data_window;
15use super::source_tree::render_source_tree;
16use super::state::ObjectTreeState;
17use super::types::{DataWindowInfo, SourceItem, SourceType};
18use crate::ui_kit::ext::UiExt;
19
20pub struct ObjectTreePanel {
44 config: ObjectTreeConfig,
46 state: ObjectTreeState,
48}
49
50impl Default for ObjectTreePanel {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl ObjectTreePanel {
57 pub fn new() -> Self {
59 Self {
60 config: ObjectTreeConfig::default(),
61 state: ObjectTreeState::new(),
62 }
63 }
64
65 pub fn with_config(config: ObjectTreeConfig) -> Self {
67 Self {
68 config,
69 state: ObjectTreeState::new(),
70 }
71 }
72
73 pub fn state_mut(&mut self) -> &mut ObjectTreeState {
75 &mut self.state
76 }
77
78 pub fn state(&self) -> &ObjectTreeState {
80 &self.state
81 }
82
83 pub fn config(&self) -> &ObjectTreeConfig {
85 &self.config
86 }
87
88 pub fn set_config(&mut self, config: ObjectTreeConfig) {
90 self.config = config;
91 }
92
93 pub fn show(
97 &mut self,
98 ui: &mut Ui,
99 data_window: Option<&DataWindowInfo>,
100 sources: &mut [SourceItem],
101 ) -> ObjectTreeAction {
102 let mut action = ObjectTreeAction::None;
103 for item in sources.iter().filter(|s| s.is_drawing()) {
104 for depth in 1..=item.folder.len() {
105 self.state.folders.insert(item.folder[..depth].to_vec());
106 }
107 }
108
109 if self.config.group_by_type {
110 sources.sort_by_key(|s| {
111 let group = match s.source_type {
112 SourceType::DataSource => 0,
113 SourceType::Indicator => 1,
114 SourceType::Drawing => 2,
115 SourceType::Template => 3,
116 };
117 (group, std::cmp::Reverse(s.z_index))
118 });
119 } else {
120 sources.sort_by_key(|s| std::cmp::Reverse(s.z_index));
121 }
122
123 if self.config.enable_keyboard_shortcuts {
125 let kb_action = handle_keyboard_shortcuts(ui, &mut self.state, sources);
126 if !kb_action.is_none() {
127 action = kb_action;
128 }
129 }
130
131 Frame::new().fill(ui.visuals().panel_fill).show(ui, |ui| {
133 self.show_header(ui, &mut action);
135
136 ui.separator();
137
138 if self.config.show_data_window
140 && let Some(data) = data_window
141 {
142 let expanded = show_data_window(ui, data, self.state.data_window_expanded);
143 self.state.data_window_expanded = expanded;
144 ui.separator();
145 }
146
147 self.show_filter_controls(ui);
149
150 ui.space_sm();
151
152 ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
153 self.show_footer(ui, sources);
154 ui.separator();
155 let mut tree_config = self.config.clone();
156 tree_config.max_height = tree_config.max_height.min(ui.available_height());
157 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
158 let tree_action =
159 render_source_tree(ui, sources, &tree_config, &mut self.state);
160 if !tree_action.is_none() {
161 action = tree_action;
162 }
163 });
164 });
165 });
166
167 if let Some(parent) = self.state.creating_folder.clone() {
168 use crate::ui_kit::modal::dialogs::{PromptDialog, PromptResult};
169 let mut open = true;
170 match PromptDialog::new(ui.scope_id().with("new_folder"), "New folder")
171 .placeholder("Folder name")
172 .show(ui.ctx(), &mut open)
173 {
174 PromptResult::Submitted(name) => {
175 self.state.creating_folder = None;
176 if !name.trim().is_empty() {
177 action = ObjectTreeAction::CreateFolder(parent, name);
178 }
179 }
180 PromptResult::Cancelled => self.state.creating_folder = None,
181 PromptResult::None => {}
182 }
183 }
184
185 if let Some(id) = self.state.renaming_id {
186 use crate::ui_kit::modal::dialogs::{PromptDialog, PromptResult};
187 let mut open = true;
188 match PromptDialog::new(ui.scope_id().with(("rename_object", id)), "Rename object")
189 .initial_value(&self.state.rename_text)
190 .show(ui.ctx(), &mut open)
191 {
192 PromptResult::Submitted(name) if !name.trim().is_empty() => {
193 self.state.cancel_rename();
194 action = ObjectTreeAction::Rename(id, name);
195 }
196 PromptResult::Cancelled | PromptResult::Submitted(_) => self.state.cancel_rename(),
197 PromptResult::None => {}
198 }
199 }
200
201 self.handle_selection_action(&action, sources);
203
204 action
205 }
206
207 fn show_header(&mut self, ui: &mut Ui, action: &mut ObjectTreeAction) {
209 PanelHeader::new("Objects")
210 .strong()
211 .no_separator()
212 .show(ui, |ui| {
213 if IconButton::new(&icons::PLUS)
214 .tooltip("New folder with selected drawings")
215 .show(ui)
216 .clicked()
217 {
218 self.state.creating_folder = Some(Vec::new());
219 }
220 let settings = IconButton::new(&icons::SETTINGS)
222 .tooltip("Object tree settings")
223 .show(ui);
224 egui::Popup::menu(&settings).show(|ui| {
225 ui.checkbox(
226 &mut self.config.show_data_window,
227 l10n::text(ui.ctx(), "Show Data Window"),
228 );
229 ui.checkbox(
230 &mut self.config.group_by_type,
231 l10n::text(ui.ctx(), "Group by Type"),
232 );
233 ui.checkbox(
234 &mut self.config.show_color_indicators,
235 l10n::text(ui.ctx(), "Show Colors"),
236 );
237 ui.checkbox(
238 &mut self.config.show_item_counts,
239 l10n::text(ui.ctx(), "Show Counts"),
240 );
241 });
242
243 if IconButton::new(&icons::CHEVRON_DOWN)
245 .tooltip("Expand all")
246 .show(ui)
247 .clicked()
248 {
249 self.state.expand_all_groups();
250 }
251
252 if IconButton::new(&icons::CHEVRON_UP)
254 .tooltip("Collapse all")
255 .show(ui)
256 .clicked()
257 {
258 self.state.collapse_all_groups();
259 }
260
261 let more = IconButton::new(&icons::UI_MORE_HORIZONTAL)
263 .tooltip("Object actions")
264 .show(ui);
265 egui::Popup::menu(&more).show(|ui| {
266 if ui.button(l10n::text(ui.ctx(), "Show all")).clicked() {
267 *action = ObjectTreeAction::ShowAll;
268 ui.close();
269 }
270 if ui.button(l10n::text(ui.ctx(), "Hide all")).clicked() {
271 *action = ObjectTreeAction::HideAll;
272 ui.close();
273 }
274 ui.separator();
275 if ui.button(l10n::text(ui.ctx(), "Lock all")).clicked() {
276 *action = ObjectTreeAction::LockAll;
277 ui.close();
278 }
279 if ui.button(l10n::text(ui.ctx(), "Unlock all")).clicked() {
280 *action = ObjectTreeAction::UnlockAll;
281 ui.close();
282 }
283 ui.separator();
284 if self.state.selection_count() > 0
285 && ui.button(l10n::text(ui.ctx(), "Delete selected")).clicked()
286 {
287 *action = ObjectTreeAction::DeleteSelected;
288 ui.close();
289 }
290 });
291 });
292 }
293
294 fn show_filter_controls(&mut self, ui: &mut Ui) {
296 ui.horizontal(|ui| {
297 let type_width = 108.0;
298 let field_width =
299 (ui.available_width() - type_width - ui.spacing().item_spacing.x).max(0.0);
300 ui.allocate_ui(vec2(field_width, 28.0), |ui| {
301 ui.spacing_mut().interact_size.y = 28.0;
302 filter_input(ui, &mut self.state.filter_text);
303 });
304 ui.spacing_mut().interact_size.y = 28.0;
305 ui.allocate_ui(vec2(type_width, 28.0), |ui| {
306 dropdown(
307 ui,
308 "object_tree_type_filter",
309 self.state.filter_type.map_or("All", |t| t.display_name()),
310 |ui| {
311 for source_type in [
312 None,
313 Some(SourceType::DataSource),
314 Some(SourceType::Indicator),
315 Some(SourceType::Drawing),
316 Some(SourceType::Template),
317 ] {
318 let label = source_type.map_or("All", |t| t.display_name());
319 ui.selectable_value(
320 &mut self.state.filter_type,
321 source_type,
322 l10n::text(ui.ctx(), label),
323 );
324 }
325 },
326 )
327 .accessible_name(l10n::text(ui.ctx(), "Object type"));
328 });
329 });
330 }
331
332 fn show_footer(&self, ui: &mut Ui, sources: &[SourceItem]) {
334 let total = sources.len();
335 let visible = sources.iter().filter(|s| s.visible).count();
336 let selected = self.state.selection_count();
337 let indicators = sources
338 .iter()
339 .filter(|s| s.source_type == SourceType::Indicator)
340 .count();
341 let drawings = sources
342 .iter()
343 .filter(|s| s.source_type == SourceType::Drawing)
344 .count();
345
346 ui.add(
347 egui::Label::new(
348 RichText::new(l10n::format_text(
349 ui.ctx(),
350 "{0} objects • {1} indicators • {2} drawings • {3} visible{4}",
351 &[
352 ("0", format!("{}", total)),
353 ("1", format!("{}", indicators)),
354 ("2", format!("{}", drawings)),
355 ("3", format!("{}", visible)),
356 (
357 "4",
358 (if selected > 0 {
359 format!(" • {selected} selected")
360 } else {
361 String::new()
362 })
363 .to_string(),
364 ),
365 ],
366 ))
367 .color(ui.visuals().weak_text_color())
368 .size(11.0),
369 )
370 .wrap(),
371 );
372 }
373
374 fn handle_selection_action(&mut self, action: &ObjectTreeAction, sources: &[SourceItem]) {
376 let filter = self.state.filter_text.to_lowercase();
377 let source_ids: Vec<usize> = sources
378 .iter()
379 .filter(|s| super::source_tree::matches_filter(s, &filter, &self.state))
380 .map(|s| s.id)
381 .collect();
382
383 match action {
384 ObjectTreeAction::Select(id) => {
385 self.state.select(*id);
386 }
387 ObjectTreeAction::AddToSelection(id) => {
388 self.state.toggle_selection(*id);
389 }
390 ObjectTreeAction::RangeSelect(id) => {
391 let ordered = if self.state.visible_ids.is_empty() {
392 source_ids
393 } else {
394 self.state.visible_ids.clone()
395 };
396 self.state.range_select(*id, &ordered);
397 }
398 ObjectTreeAction::ClearSelection => {
399 self.state.clear_selection();
400 }
401 ObjectTreeAction::SelectAll => {
402 self.state.select_all(&source_ids);
403 }
404 _ => {}
405 }
406 }
407
408 pub fn selected_ids(&self) -> Vec<usize> {
410 self.state.selected_ids.iter().copied().collect()
411 }
412
413 pub fn reset(&mut self) {
415 self.state = ObjectTreeState::new();
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn selected_drawings_can_be_nested_hidden_locked_and_moved_without_replacing_them() {
425 use crate::drawings::{ChartPoint, Drawing, DrawingToolType};
426 use egui_kittest::{Harness, kittest::Queryable};
427 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
428 let mut chart = crate::widget::ChartBuilder::new().build();
429 for name in ["Support", "Resistance", "Outside"] {
430 let mut drawing = Drawing::new(0, DrawingToolType::TrendLine);
431 drawing.label = Some(name.into());
432 drawing.stored_id = Some(name.into());
433 drawing.z_order = match name {
434 "Resistance" => 3,
435 "Outside" => 2,
436 _ => 0,
437 };
438 drawing.chart_points =
439 vec![ChartPoint::new(1.0, 100.0), ChartPoint::new(2.0, 110.0)];
440 drawing.completed = true;
441 chart.drawings_mut().add_synced_drawing(drawing).unwrap();
442 }
443 let ids: Vec<_> = chart.drawings().drawings.iter().map(|d| d.id).collect();
444 let mut panel = ObjectTreePanel::with_config(ObjectTreeConfig {
445 show_data_window: false,
446 ..Default::default()
447 });
448 panel.state_mut().select_all(&ids[..2]);
449 let mut h = Harness::builder()
450 .allow_missing_glyphs()
451 .with_size(vec2(220.0, 600.0))
452 .with_context_setup(move |ctx| crate::theme::apply_to_egui(ctx, &theme))
453 .build_ui_state(
454 |ui, (panel, chart): &mut (ObjectTreePanel, crate::Chart)| {
455 let mut sources: Vec<_> = chart
456 .drawings()
457 .drawings
458 .iter()
459 .map(|d| {
460 let mut item = SourceItem::drawing(d.id, d.tool_type, d.color32());
461 item.label = d.label.clone();
462 item.folder = d.folder.clone();
463 item.visible = d.visible;
464 item.locked = d.locked;
465 item.z_index = d.z_order;
466 item
467 })
468 .collect();
469 let action = panel.show(ui, None, &mut sources);
470 assert!(panel.apply_to_chart(chart, &action, &sources));
471 },
472 (panel, chart),
473 );
474 h.run();
475 h.get_by_label("New folder with selected drawings").click();
476 h.run();
477 h.event(egui::Event::Text("Analysis / one".into()));
478 h.key_press(egui::Key::Enter);
479 h.run();
480 let caret_right = h.get_by_label("Toggle folder").rect().right();
481 let name_left = h
482 .output()
483 .shapes
484 .iter()
485 .find_map(|shape| match &shape.shape {
486 egui::Shape::Text(text) if text.galley.text() == "Analysis / one" => {
487 Some(text.pos.x)
488 }
489 _ => None,
490 })
491 .unwrap();
492 assert!(
493 name_left - caret_right <= 12.0,
494 "folder name must follow its expand control"
495 );
496 h.get_by_label("Analysis / one").click_secondary();
497 h.run();
498 h.get_by_label("New subfolder with selected drawings")
499 .click();
500 h.run();
501 h.event(egui::Event::Text("Levels".into()));
502 h.key_press(egui::Key::Enter);
503 h.run();
504 let saved = |h: &Harness<'_, (ObjectTreePanel, crate::Chart)>| {
505 h.state()
506 .1
507 .drawings()
508 .drawings
509 .iter()
510 .map(|d| serde_json::to_value(d).unwrap())
511 .collect::<Vec<_>>()
512 };
513 h.get_by_label("Resistance").click();
514 h.run();
515 h.get_by_label("Support").click_modifiers(egui::Modifiers {
516 shift: true,
517 ..Default::default()
518 });
519 h.run();
520 assert_eq!(h.state().0.state().selection_count(), 2);
521 for filter_type in [None, Some(SourceType::Drawing)] {
522 h.state_mut().0.state_mut().filter_type = filter_type;
523 h.run();
524 h.query_all_by_label("Toggle folder")
525 .next()
526 .unwrap()
527 .click();
528 h.run();
529 assert!(h.query_by_label("Support").is_none());
530 h.state_mut().0.state_mut().filter_text = "Support".into();
531 h.run();
532 h.get_by_label("Support");
533 assert!(h.query_by_label("Outside").is_none());
534 h.state_mut().0.state_mut().filter_text.clear();
535 h.run();
536 h.query_all_by_label("Toggle folder")
537 .next()
538 .unwrap()
539 .click();
540 h.run();
541 }
542 h.state_mut().0.state_mut().filter_type = None;
543 let nested = saved(&h);
544 for (index, name) in ["Support", "Resistance", "Outside"].iter().enumerate() {
545 assert_eq!(nested[index]["label"], *name);
546 assert_eq!(
547 h.state().1.drawings().drawings[index].stored_id.as_deref(),
548 Some(*name)
549 );
550 assert_eq!(
551 nested[index]["folder"],
552 if index < 2 {
553 serde_json::json!(["Analysis / one", "Levels"])
554 } else {
555 serde_json::json!([])
556 }
557 );
558 }
559 h.query_all_by_label("Hide folder").next().unwrap().click();
560 h.run();
561 assert_eq!(
562 saved(&h)
563 .iter()
564 .map(|d| d["visible"].as_bool().unwrap())
565 .collect::<Vec<_>>(),
566 [false, false, true]
567 );
568 h.query_all_by_label("Lock folder").next().unwrap().click();
569 h.run();
570 assert_eq!(
571 saved(&h)
572 .iter()
573 .map(|d| d["locked"].as_bool().unwrap())
574 .collect::<Vec<_>>(),
575 [true, true, false]
576 );
577 assert!(h.state_mut().1.drawings_mut().undo());
578 assert_eq!(
579 saved(&h)
580 .iter()
581 .map(|d| d["locked"].as_bool().unwrap())
582 .collect::<Vec<_>>(),
583 [false, false, false]
584 );
585 assert!(h.state_mut().1.drawings_mut().redo());
586 h.run();
587 h.get_by_label("Support").click_secondary();
588 h.run();
589 h.get_by_label("Move to folder").click();
590 h.run();
591 h.get_by_label("Root").click();
592 h.run();
593 assert!(
594 saved(&h)
595 .iter()
596 .all(|d| d["folder"] == serde_json::json!([]))
597 );
598 assert!(h.state_mut().1.drawings_mut().undo());
599 assert_eq!(saved(&h)[0]["folder"], nested[0]["folder"]);
600 assert_eq!(saved(&h)[1]["folder"], nested[1]["folder"]);
601 assert_eq!(
602 h.state().1.drawings().drawings[0].chart_points,
603 [ChartPoint::new(1.0, 100.0), ChartPoint::new(2.0, 110.0)]
604 );
605 let path = vec!["Analysis / one".into(), "Levels".into()];
606 let sources: Vec<_> = ids[..2]
607 .iter()
608 .map(|&id| SourceItem::drawing(id, DrawingToolType::TrendLine, egui::Color32::RED))
609 .collect();
610 let (panel, chart) = h.state_mut();
611 for action in [
612 ObjectTreeAction::MoveToFolder(ids[..2].to_vec(), path.clone()),
613 ObjectTreeAction::SetFolderVisible(path.clone(), false),
614 ObjectTreeAction::SetFolderLocked(path, true),
615 ] {
616 assert!(panel.apply_to_chart(chart, &action, &sources));
617 assert!(
618 chart.drawings().can_redo(),
619 "unchanged {action:?} cleared redo"
620 );
621 }
622 }
623 }
624
625 #[test]
626 fn select_all_and_range_selection_respect_the_filter() {
627 let mut panel = ObjectTreePanel::new();
628 let sources = [
629 SourceItem::indicator(1, "Fast", egui::Color32::BLUE),
630 SourceItem::data_source(2, "Other"),
631 SourceItem::indicator(3, "Slow", egui::Color32::BLUE),
632 ];
633 panel.state.filter_type = Some(SourceType::Indicator);
634 for action in [
635 ObjectTreeAction::SelectAll,
636 ObjectTreeAction::RangeSelect(3),
637 ] {
638 panel.state.select(1);
639 panel.handle_selection_action(&action, &sources);
640 assert!(panel.state.is_selected(1));
641 assert!(panel.state.is_selected(3));
642 assert!(!panel.state.is_selected(2));
643 }
644 }
645
646 #[test]
647 fn filter_controls_align_and_footer_stays_inside_a_narrow_panel() {
648 for (theme, width) in [
649 (crate::theme::Theme::light(), 220.0),
650 (crate::theme::Theme::light(), 280.0),
651 (crate::theme::Theme::dark(), 220.0),
652 (crate::theme::Theme::dark(), 280.0),
653 ] {
654 let ctx = egui::Context::default();
655 crate::theme::apply_to_egui(&ctx, &theme);
656 let mut panel = ObjectTreePanel::new();
657 let mut sources: Vec<_> = (0..30)
658 .map(|id| SourceItem::indicator(id, "SMA", egui::Color32::BLUE))
659 .collect();
660 panel.state.select_all(&(0..30).collect::<Vec<_>>());
661 for _ in 0..2 {
662 let mut output = ctx.run_ui(
663 egui::RawInput {
664 screen_rect: Some(egui::Rect::from_min_size(
665 egui::Pos2::ZERO,
666 vec2(width, 360.0),
667 )),
668 ..Default::default()
669 },
670 |ui| {
671 panel.show(ui, None, &mut sources);
672 },
673 );
674 output.textures_delta.clear();
675 let controls: Vec<_> = output
676 .shapes
677 .iter()
678 .filter_map(|shape| match &shape.shape {
679 egui::Shape::Rect(rect)
680 if rect.rect.width() > 70.0
681 && (20.0..32.0).contains(&rect.rect.height()) =>
682 {
683 Some(rect.rect)
684 }
685 _ => None,
686 })
687 .collect();
688 assert!(!controls.is_empty(), "{controls:?}");
689 assert_eq!(controls[0].height(), 28.0, "filter at {width}");
690 let dropdown = output
691 .shapes
692 .iter()
693 .find_map(|shape| match &shape.shape {
694 egui::Shape::Text(text) if text.galley.text() == "All" => {
695 Some(egui::Rect::from_min_size(text.pos, text.galley.size()))
696 }
697 _ => None,
698 })
699 .unwrap();
700 assert!(
701 (dropdown.center().y - controls[0].center().y).abs() < 2.0,
702 "dropdown and filter must align at {width}"
703 );
704 assert!(dropdown.right() <= width, "dropdown at {width}");
705 let footer = output
706 .shapes
707 .iter()
708 .find_map(|shape| match &shape.shape {
709 egui::Shape::Text(text) if text.galley.text().starts_with("30 objects") => {
710 Some(text)
711 }
712 _ => None,
713 })
714 .unwrap();
715 assert!(
716 footer.pos.y + footer.galley.rect.bottom() <= 360.0,
717 "footer at {width}: bottom {}",
718 footer.pos.y + footer.galley.rect.bottom()
719 );
720 output.drop_without_applying_deltas();
721 }
722 }
723 }
724}