diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..bed60e2 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,114 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8-bom +trim_trailing_whitespace = true +insert_final_newline = false + +[*.{cs,vb}] +# Indentation preferences +indent_size = 4 +indent_style = space +tab_width = 4 + +# Newline preferences +csharp_new_line_before_open_brace = none +csharp_new_line_before_else = false +csharp_new_line_before_catch = false +csharp_new_line_before_finally = false +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation options +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Spacing options +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_name_and_open_parenthesis = false:none +csharp_space_between_method_call_name_and_opening_parenthesis = false:none + +# Code style defaults +csharp_using_directive_placement = outside_namespace:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_prefer_braces = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Expression-level preferences +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = false:suggestion +csharp_style_var_elsewhere = false:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion + +# Naming conventions +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, method, field, event, delegate +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_rule.private_fields_should_be_camel_case_with_underscore.severity = suggestion +dotnet_naming_rule.private_fields_should_be_camel_case_with_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_camel_case_with_underscore.style = camel_case_with_underscore + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_style.camel_case_with_underscore.required_prefix = _ +dotnet_naming_style.camel_case_with_underscore.required_suffix = +dotnet_naming_style.camel_case_with_underscore.word_separator = +dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case + +[*.xaml] +indent_size = 4 +indent_style = space diff --git a/src/FlaUInspect.sln b/src/FlaUInspect.sln index 98f78ab..c7563c1 100644 --- a/src/FlaUInspect.sln +++ b/src/FlaUInspect.sln @@ -1,14 +1,15 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11312.210 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FlaUInspect", "FlaUInspect\FlaUInspect.csproj", "{B1C3A9D6-326F-4A42-A2B8-C7605D111F47}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E443DA57-D2BF-49DA-A583-DCE901F5EB84}" ProjectSection(SolutionItems) = preProject - ..\README.md = ..\README.md + .editorconfig = .editorconfig ..\LICENSE = ..\LICENSE + ..\README.md = ..\README.md EndProjectSection EndProject Global diff --git a/src/FlaUInspect/App.xaml.cs b/src/FlaUInspect/App.xaml.cs index 858961d..cecfb72 100644 --- a/src/FlaUInspect/App.xaml.cs +++ b/src/FlaUInspect/App.xaml.cs @@ -13,27 +13,24 @@ private void ApplicationStart(object sender, StartupEventArgs e) { string applicationVersion = versionAttribute?.Version ?? "N/A"; InternalLogger logger = new (); -#if AUTOMATION_UIA3 - MainViewModel mainViewModel = new (AutomationType.UIA3, applicationVersion, logger); - MainWindow mainWindow = new () { DataContext = mainViewModel }; - - //Re-enable normal shutdown mode. - Current.ShutdownMode = ShutdownMode.OnMainWindowClose; - Current.MainWindow = mainWindow; - mainWindow.Show(); -#elif AUTOMATION_UIA2 - MainViewModel mainViewModel = new (AutomationType.UIA2, applicationVersion, logger); - MainWindow mainWindow = new() { DataContext = mainViewModel }; - //Re-enable normal shutdown mode. - Current.ShutdownMode = ShutdownMode.OnMainWindowClose; - Current.MainWindow = mainWindow; - mainWindow.Show(); -#else Current.ShutdownMode = ShutdownMode.OnExplicitShutdown; ChooseVersionWindow dialog = new (); +#if AUTOMATION_UIA3 + dialog.SelectedAutomationType = AutomationType.UIA3; +#elif AUTOMATION_UIA2 + dialog.SelectedAutomationType = AutomationType.UIA2; +#else + var args = Environment.GetCommandLineArgs(); + if (args.Any(a=>a.Equals("--uia2", StringComparison.OrdinalIgnoreCase))) + dialog.SelectedAutomationType = AutomationType.UIA2; + else if (args.Any(a=>a.Equals("--uia3", StringComparison.OrdinalIgnoreCase))) + dialog.SelectedAutomationType = AutomationType.UIA3; + else if (dialog.ShowDialog() != true) + return; +#endif + - if (dialog.ShowDialog() == true) { MainViewModel mainViewModel = new (dialog.SelectedAutomationType, applicationVersion, logger); MainWindow mainWindow = new () { DataContext = mainViewModel }; @@ -42,7 +39,7 @@ private void ApplicationStart(object sender, StartupEventArgs e) { Current.ShutdownMode = ShutdownMode.OnMainWindowClose; Current.MainWindow = mainWindow; mainWindow.Show(); - } -#endif + + } } \ No newline at end of file diff --git a/src/FlaUInspect/Core/FindByType.cs b/src/FlaUInspect/Core/FindByType.cs new file mode 100644 index 0000000..442f849 --- /dev/null +++ b/src/FlaUInspect/Core/FindByType.cs @@ -0,0 +1,17 @@ +namespace FlaUInspect.Core; + +public enum FindByType { + ByAutomationId, + ByName, + ByClassName, + ByControlType, + FindFirstByXPath, + ByText, + ByFrameworkId, + ByLocalizedControlType, + ByValue, +} + +public static class FindByTypeValues { + public static FindByType[] All { get; } = Enum.GetValues(); +} diff --git a/src/FlaUInspect/Core/PatternItemsFactory.cs b/src/FlaUInspect/Core/PatternItemsFactory.cs index 161357e..2541309 100644 --- a/src/FlaUInspect/Core/PatternItemsFactory.cs +++ b/src/FlaUInspect/Core/PatternItemsFactory.cs @@ -101,7 +101,7 @@ private static IEnumerable AddTogglePatternDetails(AutomationElemen yield break; } ITogglePattern pattern = element.Patterns.Toggle.Pattern; - yield return PatternItem.FromAutomationProperty("ToggleState", pattern.ToggleState); + yield return new PatternItem("ToggleState", "Toggle", pattern.Toggle); } private static IEnumerable AddTextPatternDetails(AutomationElement? element) { diff --git a/src/FlaUInspect/Resources/RibbonIcons.xaml b/src/FlaUInspect/Resources/RibbonIcons.xaml index 6eca9c6..aa403a7 100644 --- a/src/FlaUInspect/Resources/RibbonIcons.xaml +++ b/src/FlaUInspect/Resources/RibbonIcons.xaml @@ -139,7 +139,7 @@ - + GetProperty(); @@ -36,6 +40,38 @@ public bool IsSelected { public ExtendedObservableCollection Children { get; set; } = []; + public ICommand RefreshItemCommand => + _refreshItemCommand ??= new((_) => { + Children.Clear(); + IsExpanded = true; + }); + + public ICommand FocusCommand => + _focusCommand ??= new((_) => { + try { + AutomationElement.Focus(); + } catch { } + }); + + + private ObservableCollection? _mouseActions; + public ObservableCollection MouseActions { get => _mouseActions ??= BuildMouseActions(); } + + private ObservableCollection BuildMouseActions() { + return [ + CreateMenuItem("Left Click", () => AutomationElement?.Click()), + CreateMenuItem("Right Click", () => AutomationElement?.RightClick()), + CreateMenuItem("Double Click", () => AutomationElement?.DoubleClick()), + ]; + } + + private MenuItem CreateMenuItem(string header, Action value) { + return new MenuItem { + Header = header, + Command = new RelayCommand(_ => value()) + }; + } + public string XPath => AutomationElement == null ? string.Empty : Debug.GetXPathToElement(AutomationElement); public event Action? SelectionChanged; diff --git a/src/FlaUInspect/ViewModels/MainViewModel.cs b/src/FlaUInspect/ViewModels/MainViewModel.cs index 2cbdae8..f94b5ae 100644 --- a/src/FlaUInspect/ViewModels/MainViewModel.cs +++ b/src/FlaUInspect/ViewModels/MainViewModel.cs @@ -33,11 +33,12 @@ public class MainViewModel : ObservableObject { private RelayCommand? _openErrorListCommand; private PatternItemsFactory? _patternItemsFactory; private RelayCommand? _refreshCommand; - private RelayCommand? _refreshItemCommand; private AutomationElement? _rootElement; private RelayCommand? _startNewInstanceCommand; private ITreeWalker? _treeWalker; + public SearchViewModel Search { get; } + public MainViewModel(AutomationType automationType, string applicationVersion, InternalLogger logger) { _logger = logger; ApplicationVersion = applicationVersion; @@ -49,6 +50,11 @@ public MainViewModel(AutomationType automationType, string applicationVersion, I Elements = []; BindingOperations.EnableCollectionSynchronization(Elements, _itemsLock); + Search = new SearchViewModel( + () => SelectedItem, + () => Elements.FirstOrDefault(), + () => _treeWalker, + ElementToSelectChanged); } public ICommand OpenErrorListCommand => @@ -146,23 +152,21 @@ public ElementViewModel? SelectedItem { if (value != null) { ReadPatternsForSelectedItem(value.AutomationElement); } + if (!Search.IsNavigating) { + Search.NotifySelectionChanged(); + } } } } - public ICommand RefreshItemCommand => - _refreshItemCommand ??= new RelayCommand(o => { - if (o is ElementViewModel item) { - item.Children.Clear(); - item.IsExpanded = true; - } - }); public IEnumerable ElementPatterns { get => _elementPatterns ?? Enumerable.Empty(); private set => SetProperty(ref _elementPatterns, value as ObservableCollection); } + public ObservableCollection PatternActionContextItems {get; } = new(); + public ICommand InfoCommand => _infoCommand ??= new RelayCommand(_ => { IsInfoVisible = !IsInfoVisible; }); @@ -197,6 +201,7 @@ private void ReadPatternsForSelectedItem(AutomationElement? selectedItemAutomati HashSet supportedPatterns = [.. selectedItemAutomationElement.GetSupportedPatterns()]; IDictionary patternItemsForElement = _patternItemsFactory.CreatePatternItemsForElement(selectedItemAutomationElement, supportedPatterns); + PatternActionContextItems.Clear(); foreach (ElementPatternItem elementPattern in ElementPatterns) { elementPattern.IsVisible = elementPattern.PatternIdName == PatternItemsFactory.Identification || elementPattern.PatternIdName == PatternItemsFactory.Details @@ -207,6 +212,13 @@ private void ReadPatternsForSelectedItem(AutomationElement? selectedItemAutomati if (patternItemsForElement.TryGetValue(elementPattern.PatternIdName, out PatternItem[]? children)) { foreach (PatternItem patternItem in children) { elementPattern.Children.Add(patternItem); + if (patternItem.HasExecutableAction) { + System.Windows.Controls.MenuItem actionMenuItem = new() { + Header = $"{patternItem.Key}" + }; + actionMenuItem.Click += (_, _) => patternItem.Action?.Invoke(); + PatternActionContextItems.Add(actionMenuItem); + } } } diff --git a/src/FlaUInspect/ViewModels/SearchViewModel.cs b/src/FlaUInspect/ViewModels/SearchViewModel.cs new file mode 100644 index 0000000..cd6508e --- /dev/null +++ b/src/FlaUInspect/ViewModels/SearchViewModel.cs @@ -0,0 +1,245 @@ +using System.Windows.Input; +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUInspect.Core; + +namespace FlaUInspect.ViewModels; + +public class SearchViewModel : ObservableObject { + private readonly Func _getSelectedItem; + private readonly Func _getFirstElement; + private readonly Func _getTreeWalker; + private readonly Action _navigateToElement; + + private RelayCommand? _findNextCommand; + private RelayCommand? _findPreviousCommand; + private ElementViewModel? _searchStartNode; + private IEnumerator? _searchEnumerator; + private readonly List _searchHistory = []; + private int _searchHistoryIndex = -1; + private string _lastSearchText = string.Empty; + private FindByType _lastFindByType; + private bool _searchExhausted; + private bool _userChangedSelection; + + public SearchViewModel( + Func getSelectedItem, + Func getFirstElement, + Func getTreeWalker, + Action navigateToElement) { + _getSelectedItem = getSelectedItem; + _getFirstElement = getFirstElement; + _getTreeWalker = getTreeWalker; + _navigateToElement = navigateToElement; + } + + public bool IsNavigating { get; private set; } + + public FindByType SelectedFindByType { + get => GetProperty(); + set => SetProperty(value); + } + + public string SearchText { + get => GetProperty() ?? string.Empty; + set { + if (SetProperty(value)) { + ResetSearchState(); + } + } + } + + public string PositionText { + get => GetProperty() ?? string.Empty; + private set => SetProperty(value); + } + + public ICommand FindNextCommand => _findNextCommand ??= new RelayCommand(_ => FindNext(), _ => !string.IsNullOrWhiteSpace(SearchText)); + + public ICommand FindPreviousCommand => _findPreviousCommand ??= new RelayCommand(_ => FindPrevious(), _ => _searchHistoryIndex > 0); + + public void NotifySelectionChanged() { + _userChangedSelection = true; + } + + private void UpdatePositionText() { + if (_searchHistory.Count == 0) { + PositionText = _searchExhausted ? "0/0" : string.Empty; + } else { + var total = _searchExhausted ? _searchHistory.Count.ToString() : "?"; + PositionText = $"{_searchHistoryIndex + 1}/{total}"; + } + } + + private void ResetSearchState() { + _searchEnumerator?.Dispose(); + _searchEnumerator = null; + _searchHistory.Clear(); + _searchHistoryIndex = -1; + _searchStartNode = null; + _searchExhausted = false; + UpdatePositionText(); + } + + private void FindNext() { + if (string.IsNullOrWhiteSpace(SearchText)) return; + + var selectedItem = _getSelectedItem(); + + if (_searchHistoryIndex < _searchHistory.Count - 1) { + _searchHistoryIndex++; + NavigateToSearchResult(_searchHistory[_searchHistoryIndex]); + UpdatePositionText(); + return; + } + + var shouldStartNewSearch = _searchEnumerator == null + || _lastSearchText != SearchText + || _lastFindByType != SelectedFindByType + || (_userChangedSelection && !IsDescendantOfSearchStart(selectedItem)); + + _userChangedSelection = false; + + if (shouldStartNewSearch) { + _searchStartNode = selectedItem ?? _getFirstElement(); + if (_searchStartNode == null) return; + + _lastSearchText = SearchText; + _lastFindByType = SelectedFindByType; + _searchHistory.Clear(); + _searchHistoryIndex = -1; + _searchExhausted = false; + _searchEnumerator?.Dispose(); + _searchEnumerator = EnumerateMatchingElements(_searchStartNode, SearchText, SelectedFindByType).GetEnumerator(); + } + + if (_searchEnumerator!.MoveNext()) { + var found = _searchEnumerator.Current; + _searchHistory.Add(found); + _searchHistoryIndex = _searchHistory.Count - 1; + NavigateToSearchResult(found); + } else { + _searchExhausted = true; + } + UpdatePositionText(); + } + + private bool IsDescendantOfSearchStart(ElementViewModel? node) { + if (_searchStartNode == null || node == null) return false; + if (node == _searchStartNode) return true; + + var current = node.AutomationElement; + var target = _searchStartNode.AutomationElement; + if (current == null || target == null) return false; + + var treeWalker = _getTreeWalker(); + if (treeWalker == null) return false; + + try { + var parent = treeWalker.GetParent(current); + while (parent != null) { + if (parent.Equals(target)) return true; + parent = treeWalker.GetParent(parent); + } + } catch { + return false; + } + return false; + } + + private void FindPrevious() { + if (_searchHistoryIndex > 0) { + _searchHistoryIndex--; + NavigateToSearchResult(_searchHistory[_searchHistoryIndex]); + UpdatePositionText(); + } + } + + private void NavigateToSearchResult(ElementViewModel element) { + if (element.AutomationElement == null) return; + IsNavigating = true; + try { + _navigateToElement(element.AutomationElement); + } finally { + IsNavigating = false; + } + } + + private static IEnumerable EnumerateMatchingElements(ElementViewModel startVm, string searchText, FindByType findBy) { + if (startVm.AutomationElement == null) yield break; + + // Stack contains: (node, childIndex, wasExpandedByUs, foundMatchInSubtree) + var stack = new Stack<(ElementViewModel vm, int childIdx, bool expandedByUs, bool foundMatch)>(); + + // Start with the root, skip checking it + bool startWasExpanded = startVm.IsExpanded; + if (!startWasExpanded) startVm.IsExpanded = true; + stack.Push((startVm, 0, !startWasExpanded, false)); + + while (stack.Count > 0) { + var (current, childIdx, expandedByUs, foundMatch) = stack.Pop(); + + // Process children + if (childIdx < current.Children.Count) { + var child = current.Children[childIdx]; + // Push current back with next child index, preserving foundMatch state + stack.Push((current, childIdx + 1, expandedByUs, foundMatch)); + + if (child?.AutomationElement != null) { + bool isMatch = MatchesCondition(child.AutomationElement, searchText, findBy); + if (isMatch) { + // Update parent's foundMatch flag + if (stack.Count > 0) { + var parent = stack.Pop(); + stack.Push((parent.vm, parent.childIdx, parent.expandedByUs, true)); + } + yield return child; + } + + // Expand child and push it for processing + bool childWasExpanded = child.IsExpanded; + if (!childWasExpanded) child.IsExpanded = true; + stack.Push((child, 0, !childWasExpanded, isMatch)); + } + } else { + // Done with all children of current node + // If we expanded this node and found no match in subtree, collapse it + if (expandedByUs && !foundMatch) { + current.IsExpanded = false; + } + // Propagate foundMatch to parent + if (foundMatch && stack.Count > 0) { + var parent = stack.Pop(); + stack.Push((parent.vm, parent.childIdx, parent.expandedByUs, true)); + } + } + } + } + + private static bool MatchesCondition(AutomationElement element, string searchText, FindByType findBy) { + try { + return findBy switch { + FindByType.FindFirstByXPath => element.FindFirstByXPath(searchText) != null, + FindByType.ByText => (element.Properties.Name.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false) + || (element.AsTextBox()?.Text?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false), + FindByType.ByFrameworkId => element.Properties.FrameworkId.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, + FindByType.ByLocalizedControlType => element.Properties.LocalizedControlType.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, + FindByType.ByName => element.Properties.Name.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, + FindByType.ByAutomationId => element.Properties.AutomationId.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, + FindByType.ByValue => GetValueFromElement(element)?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, + FindByType.ByControlType => element.Properties.ControlType.ValueOrDefault.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase), + FindByType.ByClassName => element.Properties.ClassName.ValueOrDefault?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false, + _ => false + }; + } catch { + return false; + } + } + + private static string? GetValueFromElement(AutomationElement element) { + if (element.Patterns.Value.IsSupported) { + return element.Patterns.Value.Pattern.Value.ValueOrDefault; + } + return element.AsTextBox()?.Text; + } +} diff --git a/src/FlaUInspect/Views/ChooseVersionWindow.xaml.cs b/src/FlaUInspect/Views/ChooseVersionWindow.xaml.cs index 94e257b..285c4e5 100644 --- a/src/FlaUInspect/Views/ChooseVersionWindow.xaml.cs +++ b/src/FlaUInspect/Views/ChooseVersionWindow.xaml.cs @@ -11,7 +11,7 @@ public ChooseVersionWindow() { InitializeComponent(); } - public AutomationType SelectedAutomationType { get; private set; } + public AutomationType SelectedAutomationType { get; set; } private void Uia2ButtonClick(object sender, RoutedEventArgs e) { SelectedAutomationType = AutomationType.UIA2; diff --git a/src/FlaUInspect/Views/MainWindow.xaml b/src/FlaUInspect/Views/MainWindow.xaml index 824f95d..bcdad50 100644 --- a/src/FlaUInspect/Views/MainWindow.xaml +++ b/src/FlaUInspect/Views/MainWindow.xaml @@ -178,6 +178,57 @@ + + + + + + + + + + + + + + + + diff --git a/src/FlaUInspect/Views/MainWindow.xaml.cs b/src/FlaUInspect/Views/MainWindow.xaml.cs index 121e820..d222399 100644 --- a/src/FlaUInspect/Views/MainWindow.xaml.cs +++ b/src/FlaUInspect/Views/MainWindow.xaml.cs @@ -9,24 +9,69 @@ public MainWindow() { InitializeComponent(); Loaded += MainWindow_Loaded; } - + public MainViewModel? vm => DataContext as MainViewModel; private void MainWindow_Loaded(object sender, EventArgs e) { - if (DataContext is MainViewModel mainViewModel) { - mainViewModel.Initialize(); - } + vm?.Initialize(); } - private void TreeView_OnSelectedItemChanged(object sender, RoutedPropertyChangedEventArgs e) { - if (DataContext is MainViewModel mainViewModel) { - mainViewModel.SelectedItem = e.NewValue as ElementViewModel; + private async void TreeView_OnSelectedItemChanged(object sender, RoutedPropertyChangedEventArgs e) { + if (vm != null) { + vm.SelectedItem = e.NewValue as ElementViewModel; if (sender is TreeViewItem item) { item.BringIntoView(); + e.Handled = true; + } else if (sender is TreeView tv) { + var ci = ContainerFromItemRecursive(tv.ItemContainerGenerator, e.NewValue); + await Task.Delay(25); + if (ci is TreeViewItem tvi && centerNextSelectedItem) { // we only do this when we know it is going to badly scroll so this should be slightly better + centerNextSelectedItem = false; + BringIntoViewCentered(tvi); + } + e.Handled = true; } } } + private void BringIntoViewCentered(FrameworkElement element) { + if (element == null) return; + + element.BringIntoView(); + + var scrollViewer = GetScrollViewer(element); + if (scrollViewer == null) return; + + var elementPoint = element.TransformToAncestor(scrollViewer).Transform(new Point(0, 0)); + var scrollTarget = scrollViewer.VerticalOffset + elementPoint.Y - (scrollViewer.ViewportHeight / 2) + (element.ActualHeight / 2); + + scrollViewer.ScrollToVerticalOffset(scrollTarget); + } + + private ScrollViewer? GetScrollViewer(DependencyObject o) { + DependencyObject? parent = o; + while (parent != null) { + if (parent is ScrollViewer sv) return sv; + parent = System.Windows.Media.VisualTreeHelper.GetParent(parent); + } + return null; + } + public static TreeViewItem ContainerFromItemRecursive(ItemContainerGenerator root, object item) { + if (root == null) + return null; + + var treeViewItem = root.ContainerFromItem(item) as TreeViewItem; + if (treeViewItem != null) + return treeViewItem; + foreach (var subItem in root.Items) { + treeViewItem = root.ContainerFromItem(subItem) as TreeViewItem; + var search = ContainerFromItemRecursive(treeViewItem?.ItemContainerGenerator, item); + if (search != null) + return search; + } + return null; + } + private void InvokePatternActionHandler(object sender, RoutedEventArgs e) { PatternItem? vm = (PatternItem)((Button)sender).DataContext; @@ -36,4 +81,14 @@ private void InvokePatternActionHandler(object sender, RoutedEventArgs e) { }); } } + private bool centerNextSelectedItem; + private void TreeViewItem_ContextMenuOpening(object sender, ContextMenuEventArgs e) { + var dc = (e.Source as FrameworkElement)?.DataContext; + if (dc == null) + return; + if (dc is ElementViewModel evm && evm.IsSelected == false) { + centerNextSelectedItem = true; + evm.IsSelected = true; + } + } } \ No newline at end of file