|
| 1 | +using System.Windows.Input; |
| 2 | +using FlaUI.Core; |
| 3 | +using FlaUI.Core.AutomationElements; |
| 4 | +using FlaUInspect.Core; |
| 5 | + |
| 6 | +namespace FlaUInspect.ViewModels; |
| 7 | + |
| 8 | +public class SearchViewModel : ObservableObject { |
| 9 | + private readonly Func<ElementViewModel?> _getSelectedItem; |
| 10 | + private readonly Func<ElementViewModel?> _getFirstElement; |
| 11 | + private readonly Func<ITreeWalker?> _getTreeWalker; |
| 12 | + private readonly Action<AutomationElement> _navigateToElement; |
| 13 | + |
| 14 | + private RelayCommand? _findNextCommand; |
| 15 | + private RelayCommand? _findPreviousCommand; |
| 16 | + private ElementViewModel? _searchStartNode; |
| 17 | + private IEnumerator<ElementViewModel>? _searchEnumerator; |
| 18 | + private readonly List<ElementViewModel> _searchHistory = []; |
| 19 | + private int _searchHistoryIndex = -1; |
| 20 | + private string _lastSearchText = string.Empty; |
| 21 | + private FindByType _lastFindByType; |
| 22 | + private bool _searchExhausted; |
| 23 | + private bool _userChangedSelection; |
| 24 | + |
| 25 | + public SearchViewModel( |
| 26 | + Func<ElementViewModel?> getSelectedItem, |
| 27 | + Func<ElementViewModel?> getFirstElement, |
| 28 | + Func<ITreeWalker?> getTreeWalker, |
| 29 | + Action<AutomationElement> navigateToElement) { |
| 30 | + _getSelectedItem = getSelectedItem; |
| 31 | + _getFirstElement = getFirstElement; |
| 32 | + _getTreeWalker = getTreeWalker; |
| 33 | + _navigateToElement = navigateToElement; |
| 34 | + } |
| 35 | + |
| 36 | + public bool IsNavigating { get; private set; } |
| 37 | + |
| 38 | + public FindByType SelectedFindByType { |
| 39 | + get => GetProperty<FindByType>(); |
| 40 | + set => SetProperty(value); |
| 41 | + } |
| 42 | + |
| 43 | + public string SearchText { |
| 44 | + get => GetProperty<string>() ?? string.Empty; |
| 45 | + set { |
| 46 | + if (SetProperty(value)) { |
| 47 | + ResetSearchState(); |
| 48 | + } |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + public string PositionText { |
| 53 | + get => GetProperty<string>() ?? string.Empty; |
| 54 | + private set => SetProperty(value); |
| 55 | + } |
| 56 | + |
| 57 | + public ICommand FindNextCommand => _findNextCommand ??= new RelayCommand(_ => FindNext(), _ => !string.IsNullOrWhiteSpace(SearchText)); |
| 58 | + |
| 59 | + public ICommand FindPreviousCommand => _findPreviousCommand ??= new RelayCommand(_ => FindPrevious(), _ => _searchHistoryIndex > 0); |
| 60 | + |
| 61 | + public void NotifySelectionChanged() { |
| 62 | + _userChangedSelection = true; |
| 63 | + } |
| 64 | + |
| 65 | + private void UpdatePositionText() { |
| 66 | + if (_searchHistory.Count == 0) { |
| 67 | + PositionText = _searchExhausted ? "0/0" : string.Empty; |
| 68 | + } else { |
| 69 | + var total = _searchExhausted ? _searchHistory.Count.ToString() : "?"; |
| 70 | + PositionText = $"{_searchHistoryIndex + 1}/{total}"; |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + private void ResetSearchState() { |
| 75 | + _searchEnumerator?.Dispose(); |
| 76 | + _searchEnumerator = null; |
| 77 | + _searchHistory.Clear(); |
| 78 | + _searchHistoryIndex = -1; |
| 79 | + _searchStartNode = null; |
| 80 | + _searchExhausted = false; |
| 81 | + UpdatePositionText(); |
| 82 | + } |
| 83 | + |
| 84 | + private void FindNext() { |
| 85 | + if (string.IsNullOrWhiteSpace(SearchText)) return; |
| 86 | + |
| 87 | + var selectedItem = _getSelectedItem(); |
| 88 | + |
| 89 | + if (_searchHistoryIndex < _searchHistory.Count - 1) { |
| 90 | + _searchHistoryIndex++; |
| 91 | + NavigateToSearchResult(_searchHistory[_searchHistoryIndex]); |
| 92 | + UpdatePositionText(); |
| 93 | + return; |
| 94 | + } |
| 95 | + |
| 96 | + var shouldStartNewSearch = _searchEnumerator == null |
| 97 | + || _lastSearchText != SearchText |
| 98 | + || _lastFindByType != SelectedFindByType |
| 99 | + || (_userChangedSelection && !IsDescendantOfSearchStart(selectedItem)); |
| 100 | + |
| 101 | + _userChangedSelection = false; |
| 102 | + |
| 103 | + if (shouldStartNewSearch) { |
| 104 | + _searchStartNode = selectedItem ?? _getFirstElement(); |
| 105 | + if (_searchStartNode == null) return; |
| 106 | + |
| 107 | + _lastSearchText = SearchText; |
| 108 | + _lastFindByType = SelectedFindByType; |
| 109 | + _searchHistory.Clear(); |
| 110 | + _searchHistoryIndex = -1; |
| 111 | + _searchExhausted = false; |
| 112 | + _searchEnumerator?.Dispose(); |
| 113 | + _searchEnumerator = EnumerateMatchingElements(_searchStartNode, SearchText, SelectedFindByType).GetEnumerator(); |
| 114 | + } |
| 115 | + |
| 116 | + if (_searchEnumerator!.MoveNext()) { |
| 117 | + var found = _searchEnumerator.Current; |
| 118 | + _searchHistory.Add(found); |
| 119 | + _searchHistoryIndex = _searchHistory.Count - 1; |
| 120 | + NavigateToSearchResult(found); |
| 121 | + } else { |
| 122 | + _searchExhausted = true; |
| 123 | + } |
| 124 | + UpdatePositionText(); |
| 125 | + } |
| 126 | + |
| 127 | + private bool IsDescendantOfSearchStart(ElementViewModel? node) { |
| 128 | + if (_searchStartNode == null || node == null) return false; |
| 129 | + if (node == _searchStartNode) return true; |
| 130 | + |
| 131 | + var current = node.AutomationElement; |
| 132 | + var target = _searchStartNode.AutomationElement; |
| 133 | + if (current == null || target == null) return false; |
| 134 | + |
| 135 | + var treeWalker = _getTreeWalker(); |
| 136 | + if (treeWalker == null) return false; |
| 137 | + |
| 138 | + try { |
| 139 | + var parent = treeWalker.GetParent(current); |
| 140 | + while (parent != null) { |
| 141 | + if (parent.Equals(target)) return true; |
| 142 | + parent = treeWalker.GetParent(parent); |
| 143 | + } |
| 144 | + } catch { |
| 145 | + return false; |
| 146 | + } |
| 147 | + return false; |
| 148 | + } |
| 149 | + |
| 150 | + private void FindPrevious() { |
| 151 | + if (_searchHistoryIndex > 0) { |
| 152 | + _searchHistoryIndex--; |
| 153 | + NavigateToSearchResult(_searchHistory[_searchHistoryIndex]); |
| 154 | + UpdatePositionText(); |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + private void NavigateToSearchResult(ElementViewModel element) { |
| 159 | + if (element.AutomationElement == null) return; |
| 160 | + IsNavigating = true; |
| 161 | + try { |
| 162 | + _navigateToElement(element.AutomationElement); |
| 163 | + } finally { |
| 164 | + IsNavigating = false; |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + private static IEnumerable<ElementViewModel> EnumerateMatchingElements(ElementViewModel startVm, string searchText, FindByType findBy) { |
| 169 | + if (startVm.AutomationElement == null) yield break; |
| 170 | + |
| 171 | + var stack = new Stack<(ElementViewModel vm, bool skipCheck)>(); |
| 172 | + stack.Push((startVm, true)); |
| 173 | + |
| 174 | + while (stack.Count > 0) { |
| 175 | + var (current, skipCheck) = stack.Pop(); |
| 176 | + if (current.AutomationElement == null) continue; |
| 177 | + |
| 178 | + if (!skipCheck && MatchesCondition(current.AutomationElement, searchText, findBy)) { |
| 179 | + yield return current; |
| 180 | + } |
| 181 | + |
| 182 | + if (!current.IsExpanded) { |
| 183 | + current.IsExpanded = true; |
| 184 | + } |
| 185 | + |
| 186 | + for (int i = current.Children.Count - 1; i >= 0; i--) { |
| 187 | + var child = current.Children[i]; |
| 188 | + if (child != null) { |
| 189 | + stack.Push((child, false)); |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | + } |
| 194 | + |
| 195 | + private static bool MatchesCondition(AutomationElement element, string searchText, FindByType findBy) { |
| 196 | + try { |
| 197 | + return findBy switch { |
| 198 | + FindByType.FindFirstByXPath => element.FindFirstByXPath(searchText) != null, |
| 199 | + FindByType.ByText => (element.Properties.Name.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false) |
| 200 | + || (element.AsTextBox()?.Text?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false), |
| 201 | + FindByType.ByFrameworkId => element.Properties.FrameworkId.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, |
| 202 | + FindByType.ByLocalizedControlType => element.Properties.LocalizedControlType.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, |
| 203 | + FindByType.ByName => element.Properties.Name.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, |
| 204 | + FindByType.ByAutomationId => element.Properties.AutomationId.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, |
| 205 | + FindByType.ByValue => GetValueFromElement(element)?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, |
| 206 | + FindByType.ByControlType => element.Properties.ControlType.ValueOrDefault.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase), |
| 207 | + FindByType.ByClassName => element.Properties.ClassName.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, |
| 208 | + _ => false |
| 209 | + }; |
| 210 | + } catch { |
| 211 | + return false; |
| 212 | + } |
| 213 | + } |
| 214 | + |
| 215 | + private static string? GetValueFromElement(AutomationElement element) { |
| 216 | + if (element.Patterns.Value.IsSupported) { |
| 217 | + return element.Patterns.Value.Pattern.Value.ValueOrDefault; |
| 218 | + } |
| 219 | + return element.AsTextBox()?.Text; |
| 220 | + } |
| 221 | +} |
0 commit comments