SettingsWindow.xaml.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System.Collections.ObjectModel; 6 using CommunityToolkit.Mvvm.Messaging; 7 using ManagedCommon; 8 using Microsoft.CmdPal.UI.Helpers; 9 using Microsoft.CmdPal.UI.Messages; 10 using Microsoft.CmdPal.UI.ViewModels; 11 using Microsoft.CmdPal.UI.ViewModels.Messages; 12 using Microsoft.UI.Input; 13 using Microsoft.UI.Windowing; 14 using Microsoft.UI.Xaml; 15 using Microsoft.UI.Xaml.Controls; 16 using Microsoft.UI.Xaml.Input; 17 using Microsoft.UI.Xaml.Navigation; 18 using Windows.System; 19 using Windows.UI.Core; 20 using WinUIEx; 21 using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; 22 using TitleBar = Microsoft.UI.Xaml.Controls.TitleBar; 23 24 namespace Microsoft.CmdPal.UI.Settings; 25 26 public sealed partial class SettingsWindow : WindowEx, 27 IDisposable, 28 IRecipient<NavigateToExtensionSettingsMessage>, 29 IRecipient<QuitMessage> 30 { 31 private readonly LocalKeyboardListener _localKeyboardListener; 32 33 private readonly NavigationViewItem? _internalNavItem; 34 35 public ObservableCollection<Crumb> BreadCrumbs { get; } = []; 36 37 // Gets or sets optional action invoked after NavigationView is loaded. 38 public Action NavigationViewLoaded { get; set; } = () => { }; 39 40 public SettingsWindow() 41 { 42 this.InitializeComponent(); 43 this.ExtendsContentIntoTitleBar = true; 44 this.SetIcon(); 45 var title = RS_.GetString("SettingsWindowTitle"); 46 this.AppWindow.Title = title; 47 this.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall; 48 this.AppTitleBar.Title = title; 49 PositionCentered(); 50 51 WeakReferenceMessenger.Default.Register<NavigateToExtensionSettingsMessage>(this); 52 WeakReferenceMessenger.Default.Register<QuitMessage>(this); 53 54 _localKeyboardListener = new LocalKeyboardListener(); 55 _localKeyboardListener.KeyPressed += LocalKeyboardListener_OnKeyPressed; 56 _localKeyboardListener.Start(); 57 Closed += SettingsWindow_Closed; 58 RootElement.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(RootElement_OnPointerPressed), true); 59 60 if (!BuildInfo.IsCiBuild) 61 { 62 _internalNavItem = new NavigationViewItem 63 { 64 Content = "Internal Tools", 65 Icon = new FontIcon { Glyph = "\uEC7A" }, 66 Tag = "Internal", 67 }; 68 NavView.MenuItems.Add(_internalNavItem); 69 } 70 else 71 { 72 _internalNavItem = null; 73 } 74 75 Navigate("General"); 76 } 77 78 private void SettingsWindow_Closed(object sender, WindowEventArgs args) 79 { 80 Dispose(); 81 } 82 83 // Handles NavigationView loaded event. 84 // Sets up initial navigation and accessibility notifications. 85 private void NavView_Loaded(object sender, RoutedEventArgs e) 86 { 87 // Delay necessary to ensure NavigationView visual state can match navigation 88 Task.Delay(500).ContinueWith(_ => this.NavigationViewLoaded?.Invoke(), TaskScheduler.FromCurrentSynchronizationContext()); 89 90 if (sender is NavigationView navigationView) 91 { 92 // Register for pane open/close changes to announce to screen readers 93 navigationView.RegisterPropertyChangedCallback(NavigationView.IsPaneOpenProperty, AnnounceNavigationPaneStateChanged); 94 } 95 } 96 97 // Announces navigation pane open/close state to screen readers for accessibility. 98 private void AnnounceNavigationPaneStateChanged(DependencyObject sender, DependencyProperty dp) 99 { 100 if (sender is NavigationView navigationView) 101 { 102 UIHelper.AnnounceActionForAccessibility( 103 ue: (UIElement)sender, 104 (sender as NavigationView)?.IsPaneOpen == true ? RS_.GetString("NavigationPaneOpened") : RS_.GetString("NavigationPaneClosed"), 105 "NavigationViewPaneIsOpenChangeNotificationId"); 106 } 107 } 108 109 private void NavView_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) 110 { 111 var selectedItem = args.InvokedItemContainer; 112 Navigate((selectedItem.Tag as string)!); 113 } 114 115 internal void Navigate(string page) 116 { 117 Type? pageType; 118 switch (page) 119 { 120 case "General": 121 pageType = typeof(GeneralPage); 122 break; 123 case "Appearance": 124 pageType = typeof(AppearancePage); 125 break; 126 case "Extensions": 127 pageType = typeof(ExtensionsPage); 128 break; 129 case "Internal": 130 pageType = typeof(InternalPage); 131 break; 132 case "": 133 // intentional no-op: empty tag means no navigation 134 pageType = null; 135 break; 136 default: 137 // unknown page, no-op and log 138 pageType = null; 139 Logger.LogError($"Unknown settings page tag '{page}'"); 140 break; 141 } 142 143 if (pageType is not null) 144 { 145 NavFrame.Navigate(pageType); 146 } 147 } 148 149 private void Navigate(ProviderSettingsViewModel extension) 150 { 151 NavFrame.Navigate(typeof(ExtensionPage), extension); 152 } 153 154 private void PositionCentered() 155 { 156 var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest); 157 if (displayArea is not null) 158 { 159 var centeredPosition = AppWindow.Position; 160 centeredPosition.X = (displayArea.WorkArea.Width - AppWindow.Size.Width) / 2; 161 centeredPosition.Y = (displayArea.WorkArea.Height - AppWindow.Size.Height) / 2; 162 AppWindow.Move(centeredPosition); 163 } 164 } 165 166 public void Receive(NavigateToExtensionSettingsMessage message) => Navigate(message.ProviderSettingsVM); 167 168 private void NavigationBreadcrumbBar_ItemClicked(BreadcrumbBar sender, BreadcrumbBarItemClickedEventArgs args) 169 { 170 if (args.Item is Crumb crumb) 171 { 172 if (crumb.Data is string data) 173 { 174 if (!string.IsNullOrEmpty(data)) 175 { 176 Navigate(data); 177 } 178 } 179 } 180 } 181 182 private void Window_Activated(object sender, Microsoft.UI.Xaml.WindowActivatedEventArgs args) 183 { 184 WeakReferenceMessenger.Default.Send<Microsoft.UI.Xaml.WindowActivatedEventArgs>(args); 185 } 186 187 private void Window_Closed(object sender, WindowEventArgs args) 188 { 189 WeakReferenceMessenger.Default.Send<SettingsWindowClosedMessage>(); 190 191 WeakReferenceMessenger.Default.UnregisterAll(this); 192 } 193 194 private void NavView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args) 195 { 196 if (args.DisplayMode is NavigationViewDisplayMode.Compact or NavigationViewDisplayMode.Minimal) 197 { 198 AppTitleBar.IsPaneToggleButtonVisible = true; 199 WorkAroundIcon.Margin = new Thickness(8, 0, 16, 0); // Required for workaround, see XAML comment 200 } 201 else 202 { 203 AppTitleBar.IsPaneToggleButtonVisible = false; 204 WorkAroundIcon.Margin = new Thickness(16, 0, 8, 0); // Required for workaround, see XAML comment 205 } 206 } 207 208 public void Receive(QuitMessage message) 209 { 210 // This might come in on a background thread 211 DispatcherQueue.TryEnqueue(() => Close()); 212 } 213 214 private void AppTitleBar_PaneToggleRequested(TitleBar sender, object args) 215 { 216 NavView.IsPaneOpen = !NavView.IsPaneOpen; 217 } 218 219 private void TryGoBack() 220 { 221 if (NavFrame.CanGoBack) 222 { 223 NavFrame.GoBack(); 224 } 225 } 226 227 private void TitleBar_BackRequested(TitleBar sender, object args) 228 { 229 TryGoBack(); 230 } 231 232 private void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) 233 { 234 switch (e.Key) 235 { 236 case VirtualKey.GoBack: 237 case VirtualKey.XButton1: 238 TryGoBack(); 239 break; 240 241 case VirtualKey.Left: 242 var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); 243 if (altPressed) 244 { 245 TryGoBack(); 246 } 247 248 break; 249 } 250 } 251 252 private void RootElement_OnPointerPressed(object sender, PointerRoutedEventArgs e) 253 { 254 try 255 { 256 if (e.Pointer.PointerDeviceType == PointerDeviceType.Mouse) 257 { 258 var ptrPt = e.GetCurrentPoint(RootElement); 259 if (ptrPt.Properties.IsXButton1Pressed) 260 { 261 TryGoBack(); 262 } 263 } 264 } 265 catch (Exception ex) 266 { 267 Logger.LogError("Error handling mouse button press event", ex); 268 } 269 } 270 271 public void Dispose() 272 { 273 _localKeyboardListener?.Dispose(); 274 } 275 276 private void NavFrame_OnNavigated(object sender, NavigationEventArgs e) 277 { 278 BreadCrumbs.Clear(); 279 280 if (e.SourcePageType == typeof(GeneralPage)) 281 { 282 NavView.SelectedItem = GeneralPageNavItem; 283 var pageType = RS_.GetString("Settings_PageTitles_GeneralPage"); 284 BreadCrumbs.Add(new(pageType, pageType)); 285 } 286 else if (e.SourcePageType == typeof(AppearancePage)) 287 { 288 NavView.SelectedItem = AppearancePageNavItem; 289 var pageType = RS_.GetString("Settings_PageTitles_AppearancePage"); 290 BreadCrumbs.Add(new(pageType, pageType)); 291 } 292 else if (e.SourcePageType == typeof(ExtensionsPage)) 293 { 294 NavView.SelectedItem = ExtensionPageNavItem; 295 var pageType = RS_.GetString("Settings_PageTitles_ExtensionsPage"); 296 BreadCrumbs.Add(new(pageType, pageType)); 297 } 298 else if (e.SourcePageType == typeof(ExtensionPage) && e.Parameter is ProviderSettingsViewModel vm) 299 { 300 NavView.SelectedItem = ExtensionPageNavItem; 301 var extensionsPageType = RS_.GetString("Settings_PageTitles_ExtensionsPage"); 302 BreadCrumbs.Add(new(extensionsPageType, extensionsPageType)); 303 BreadCrumbs.Add(new(vm.DisplayName, vm)); 304 } 305 else if (e.SourcePageType == typeof(InternalPage) && _internalNavItem is not null) 306 { 307 NavView.SelectedItem = _internalNavItem; 308 var pageType = "Internal"; 309 BreadCrumbs.Add(new(pageType, pageType)); 310 } 311 else 312 { 313 BreadCrumbs.Add(new($"[{e.SourcePageType?.Name}]", string.Empty)); 314 Logger.LogError($"Unknown breadcrumb for page type '{e.SourcePageType}'"); 315 } 316 } 317 } 318 319 public readonly struct Crumb 320 { 321 public Crumb(string label, object data) 322 { 323 Label = label; 324 Data = data; 325 } 326 327 public string Label { get; } 328 329 public object Data { get; } 330 331 public override string ToString() => Label; 332 }