Skip to content

Commit 5f5fd50

Browse files
committed
Added Descendant searching on common fields
New VM to keep the clutter out of the mainvm.
1 parent 7c63405 commit 5f5fd50

4 files changed

Lines changed: 301 additions & 2 deletions

File tree

src/FlaUInspect/Core/FindByType.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace FlaUInspect.Core;
2+
3+
public enum FindByType {
4+
ByAutomationId,
5+
ByName,
6+
ByClassName,
7+
ByControlType,
8+
FindFirstByXPath,
9+
ByText,
10+
ByFrameworkId,
11+
ByLocalizedControlType,
12+
ByValue,
13+
}
14+
15+
public static class FindByTypeValues {
16+
public static FindByType[] All { get; } = Enum.GetValues<FindByType>();
17+
}

src/FlaUInspect/ViewModels/MainViewModel.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ public class MainViewModel : ObservableObject {
3737
private RelayCommand? _startNewInstanceCommand;
3838
private ITreeWalker? _treeWalker;
3939

40+
public SearchViewModel Search { get; }
41+
4042
public MainViewModel(AutomationType automationType, string applicationVersion, InternalLogger logger) {
4143
_logger = logger;
4244
ApplicationVersion = applicationVersion;
@@ -48,6 +50,11 @@ public MainViewModel(AutomationType automationType, string applicationVersion, I
4850
Elements = [];
4951
BindingOperations.EnableCollectionSynchronization(Elements, _itemsLock);
5052

53+
Search = new SearchViewModel(
54+
() => SelectedItem,
55+
() => Elements.FirstOrDefault(),
56+
() => _treeWalker,
57+
ElementToSelectChanged);
5158
}
5259

5360
public ICommand OpenErrorListCommand =>
@@ -145,6 +152,9 @@ public ElementViewModel? SelectedItem {
145152
if (value != null) {
146153
ReadPatternsForSelectedItem(value.AutomationElement);
147154
}
155+
if (!Search.IsNavigating) {
156+
Search.NotifySelectionChanged();
157+
}
148158
}
149159
}
150160
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
}

src/FlaUInspect/Views/MainWindow.xaml

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,57 @@
178178
</Viewbox>
179179
</StackPanel>
180180
</ToggleButton>
181+
<Rectangle Width="12"
182+
Height="32" />
183+
<GroupBox Header="Search Descendants"
184+
Padding="4,0"
185+
Margin="0,-2"
186+
VerticalAlignment="Center">
187+
<StackPanel Orientation="Horizontal"
188+
VerticalAlignment="Center">
189+
<ComboBox x:Name="FindByComboBox"
190+
Width="140"
191+
VerticalAlignment="Center"
192+
ItemsSource="{Binding Source={x:Static core:FindByTypeValues.All}}"
193+
SelectedItem="{Binding Search.SelectedFindByType}" />
194+
<TextBox x:Name="SearchTextBox"
195+
Width="150"
196+
Margin="4,0,0,0" ToolTip="Value to Search, hit Enter to find, up/down arrow to navigate between results"
197+
VerticalAlignment="Center"
198+
VerticalContentAlignment="Center"
199+
Text="{Binding Search.SearchText, UpdateSourceTrigger=PropertyChanged}">
200+
<TextBox.InputBindings>
201+
<KeyBinding Key="Return"
202+
Command="{Binding Search.FindNextCommand}" />
203+
<KeyBinding Key="Down"
204+
Command="{Binding Search.FindNextCommand}" />
205+
<KeyBinding Key="Up"
206+
Command="{Binding Search.FindPreviousCommand}" />
207+
</TextBox.InputBindings>
208+
</TextBox>
209+
<Button
210+
Margin="2,0,0,0"
211+
VerticalAlignment="Center"
212+
Command="{Binding Search.FindPreviousCommand}"
213+
Content=""
214+
Padding="2"
215+
FontSize="10"
216+
ToolTip="Previous result" />
217+
<Button
218+
Margin="2,0,0,0"
219+
Padding="2"
220+
VerticalAlignment="Center"
221+
Command="{Binding Search.FindNextCommand}"
222+
Content=""
223+
FontSize="10"
224+
ToolTip="Next result" />
225+
<TextBlock Width="40"
226+
Margin="4,0,0,0"
227+
VerticalAlignment="Center"
228+
Text="{Binding Search.PositionText}"
229+
ToolTip="Current result / Total results" />
230+
</StackPanel>
231+
</GroupBox>
181232
</StackPanel>
182233
<StackPanel HorizontalAlignment="Right"
183234
Orientation="Horizontal">
@@ -266,11 +317,11 @@
266317
ItemsSource="{Binding Children}">
267318
<StackPanel Orientation="Horizontal" Tag="{Binding DataContext, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=Window}}" ContextMenuOpening="TreeViewItem_ContextMenuOpening">
268319
<StackPanel.ContextMenu>
269-
<ContextMenu>
320+
<ContextMenu>
270321
<MenuItem Header="Refresh Children" Command="{Binding RefreshItemCommand}" />
271322
<MenuItem Header="Focus" Command="{Binding FocusCommand}" IsCheckable="False" />
272323
<MenuItem Header="Pattern Actions" ItemsSource="{Binding PlacementTarget.Tag.PatternActionContextItems, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=ContextMenu}}" Visibility="{Binding PlacementTarget.Tag.PatternActionContextItems.Count, Converter={StaticResource CountToVisibilityConverter}, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=ContextMenu}}" />
273-
<MenuItem Header="Mouse Actions" ItemsSource="{Binding MouseActions}" />
324+
<MenuItem Header="Mouse Actions" ItemsSource="{Binding MouseActions}" />
274325
</ContextMenu>
275326
</StackPanel.ContextMenu>
276327
<Viewbox Width="16"

0 commit comments

Comments
 (0)