using FasdDesktopUi.Basics.Models; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Threading; namespace FasdDesktopUi.Basics.UserControls { public partial class HierarchicalSelectionControl : UserControl { private readonly ObservableCollection visibleItems = new ObservableCollection(); private readonly Dictionary itemLookup = new Dictionary(); private readonly DispatcherTimer searchDelayTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; private string lastSearchText = string.Empty; private bool suppressTreeSelectionChanged; private TextBox searchTextBox; private TreeView treeViewControl; private ScrollViewer itemsScrollViewer; public ObservableCollection VisibleItems => visibleItems; public event EventHandler DropDownOpened; public event EventHandler DropDownClosed; public HierarchicalSelectionControl() { InitializeComponent(); searchDelayTimer.Tick += SearchDelayTimer_Tick; } public override void OnApplyTemplate() { base.OnApplyTemplate(); EnsureTemplateParts(); if (treeViewControl != null) treeViewControl.ItemsSource = VisibleItems; } #region DependencyProperties public ObservableCollection ItemsSource { get => (ObservableCollection)GetValue(ItemsSourceProperty); set => SetValue(ItemsSourceProperty, value); } public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register( nameof(ItemsSource), typeof(ObservableCollection), typeof(HierarchicalSelectionControl), new PropertyMetadata(null, OnItemsSourceChanged)); private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is HierarchicalSelectionControl control) { control.RebuildLookup(); control.ApplyFilter(control.lastSearchText); } } public HierarchicalSelectionItem SelectedItem { get => (HierarchicalSelectionItem)GetValue(SelectedItemProperty); set => SetValue(SelectedItemProperty, value); } public static readonly DependencyProperty SelectedItemProperty = DependencyProperty.Register( nameof(SelectedItem), typeof(HierarchicalSelectionItem), typeof(HierarchicalSelectionControl), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedItemChanged)); private static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is HierarchicalSelectionControl control) { control.TryExpandToSelectedItem(); control.SyncTreeSelectionWithSelectedItem(bringIntoView: false); } } public Brush ComboBoxBackground { get => (Brush)GetValue(ComboBoxBackgroundProperty); set => SetValue(ComboBoxBackgroundProperty, value); } public static readonly DependencyProperty ComboBoxBackgroundProperty = DependencyProperty.Register(nameof(ComboBoxBackground), typeof(Brush), typeof(HierarchicalSelectionControl), new PropertyMetadata(Brushes.Transparent)); public string SearchPlaceholderText { get => (string)GetValue(SearchPlaceholderTextProperty); set => SetValue(SearchPlaceholderTextProperty, value); } public static readonly DependencyProperty SearchPlaceholderTextProperty = DependencyProperty.Register(nameof(SearchPlaceholderText), typeof(string), typeof(HierarchicalSelectionControl), new PropertyMetadata(string.Empty)); #endregion #region UI Event Handling private void ComboBoxControl_DropDownOpened(object sender, EventArgs e) { EnsureTemplateParts(); searchTextBox?.Focus(); searchTextBox?.SelectAll(); suppressTreeSelectionChanged = false; SyncTreeSelectionWithSelectedItem(bringIntoView: true); DropDownOpened?.Invoke(this, e); } private void ComboBoxControl_DropDownClosed(object sender, EventArgs e) { searchDelayTimer.Stop(); suppressTreeSelectionChanged = false; DropDownClosed?.Invoke(this, e); } private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) { searchDelayTimer.Stop(); searchDelayTimer.Start(); } private void SearchDelayTimer_Tick(object sender, EventArgs e) { searchDelayTimer.Stop(); lastSearchText = searchTextBox?.Text ?? string.Empty; ApplyFilter(lastSearchText); } private void TreeViewControl_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs e) { if (suppressTreeSelectionChanged) return; if (e.NewValue is HierarchicalSelectionItem selected) { var original = ResolveOriginalItem(selected); if (original != null && !Equals(SelectedItem, original)) { SelectedItem = original; } suppressTreeSelectionChanged = true; ComboBoxControl.IsDropDownOpen = false; } } #endregion #region Data Preparation and Filtering private HierarchicalSelectionItem ResolveOriginalItem(HierarchicalSelectionItem item) { if (item == null) return null; if (!string.IsNullOrWhiteSpace(item.Id) && itemLookup.TryGetValue(item.Id, out var original)) return original; return null; } private void RebuildLookup() { itemLookup.Clear(); if (ItemsSource == null) return; foreach (var entry in ItemsSource.SelectMany(item => item.SelfAndDescendants())) { if (string.IsNullOrWhiteSpace(entry.Id)) continue; itemLookup[entry.Id] = entry; } } private void ApplyFilter(string searchText) { visibleItems.Clear(); if (ItemsSource == null) return; if (string.IsNullOrWhiteSpace(searchText)) { foreach (var item in ItemsSource) { item.SetExpandedRecursive(false); visibleItems.Add(item); } TryExpandToSelectedItem(); SyncTreeSelectionWithSelectedItem(bringIntoView: false); return; } var comparison = StringComparison.CurrentCultureIgnoreCase; foreach (var root in ItemsSource) { var clone = root.CloneBranch(node => node.FullPath?.IndexOf(searchText, comparison) >= 0 || node.DisplayName?.IndexOf(searchText, comparison) >= 0); if (clone == null) continue; clone.SetExpandedRecursive(true); visibleItems.Add(clone); } // If the selected item is part of current results, keep it visually selected. SyncTreeSelectionWithSelectedItem(bringIntoView: false); } private void TryExpandToSelectedItem() { if (SelectedItem == null || string.IsNullOrWhiteSpace(SelectedItem.Id)) return; if (!string.IsNullOrWhiteSpace(lastSearchText)) return; var chain = SelectedItem; while (chain != null) { chain.IsExpanded = true; chain = chain.Parent; } } #endregion #region Tree Selection Sync private void SyncTreeSelectionWithSelectedItem(bool bringIntoView) { if (SelectedItem == null || string.IsNullOrWhiteSpace(SelectedItem.Id)) return; EnsureTemplateParts(); if (treeViewControl == null) return; // Wait for popup/template layout to finish so item containers are generated. Dispatcher.BeginInvoke(new Action(() => { var target = FindVisibleItemById(VisibleItems, SelectedItem.Id); if (target == null) return; var ancestor = target.Parent; while (ancestor != null) { ancestor.IsExpanded = true; ancestor = ancestor.Parent; } treeViewControl.UpdateLayout(); var targetContainer = GetTreeViewItemContainer(treeViewControl, target); if (targetContainer == null) return; suppressTreeSelectionChanged = true; try { targetContainer.IsSelected = true; if (bringIntoView) targetContainer.BringIntoView(); } finally { suppressTreeSelectionChanged = false; } }), DispatcherPriority.Loaded); } private static HierarchicalSelectionItem FindVisibleItemById(IEnumerable items, string id) { if (items == null || string.IsNullOrWhiteSpace(id)) return null; foreach (var item in items) { if (item == null) continue; if (string.Equals(item.Id, id, StringComparison.OrdinalIgnoreCase)) return item; var childMatch = FindVisibleItemById(item.Children, id); if (childMatch != null) return childMatch; } return null; } private static TreeViewItem GetTreeViewItemContainer(ItemsControl root, object targetItem) { if (root == null || targetItem == null) return null; var directContainer = root.ItemContainerGenerator.ContainerFromItem(targetItem) as TreeViewItem; if (directContainer != null) return directContainer; foreach (var child in root.Items) { var childContainer = root.ItemContainerGenerator.ContainerFromItem(child) as TreeViewItem; if (childContainer == null) continue; childContainer.UpdateLayout(); var result = GetTreeViewItemContainer(childContainer, targetItem); if (result != null) return result; } return null; } #endregion #region Template and Scroll Handling private void EnsureTemplateParts() { if (treeViewControl == null) { treeViewControl = ComboBoxControl.Template.FindName("PART_TreeView", ComboBoxControl) as TreeView; if (treeViewControl != null) { treeViewControl.SelectedItemChanged += TreeViewControl_SelectedItemChanged; treeViewControl.ItemsSource = VisibleItems; } } if (searchTextBox == null) { searchTextBox = ComboBoxControl.Template.FindName("PART_SearchTextBox", ComboBoxControl) as TextBox; if (searchTextBox != null) searchTextBox.TextChanged += SearchTextBox_TextChanged; } if (itemsScrollViewer == null) { itemsScrollViewer = ComboBoxControl.Template.FindName("PART_ItemsScrollViewer", ComboBoxControl) as ScrollViewer; } } private void ItemsScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e) { var scroller = itemsScrollViewer ?? sender as ScrollViewer; if (scroller == null || scroller.ScrollableHeight <= 0) return; var lines = SystemParameters.WheelScrollLines; if (lines < 0) { if (e.Delta < 0) scroller.PageDown(); else scroller.PageUp(); e.Handled = true; return; } if (lines == 0) { scroller.ScrollToVerticalOffset(scroller.VerticalOffset - e.Delta); e.Handled = true; return; } var direction = e.Delta < 0 ? 1 : -1; var stepCount = Math.Max(1, Math.Abs(e.Delta) / 120) * lines; for (var i = 0; i < stepCount; i++) { if (direction > 0) scroller.LineDown(); else scroller.LineUp(); } e.Handled = true; } #endregion #region Keyboard protected override void OnPreviewKeyDown(KeyEventArgs e) { base.OnPreviewKeyDown(e); if (!IsEnabled) return; if (!ComboBoxControl.IsDropDownOpen && (e.Key == Key.Enter || e.Key == Key.Down || e.Key == Key.Space)) { ComboBoxControl.IsDropDownOpen = true; e.Handled = true; return; } if (ComboBoxControl.IsDropDownOpen && e.Key == Key.Escape) { ComboBoxControl.IsDropDownOpen = false; e.Handled = true; } } #endregion } }