SearchBar.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 CommunityToolkit.Mvvm.Messaging; 6 using CommunityToolkit.WinUI; 7 using Microsoft.CmdPal.Core.ViewModels; 8 using Microsoft.CmdPal.Core.ViewModels.Commands; 9 using Microsoft.CmdPal.Core.ViewModels.Messages; 10 using Microsoft.CmdPal.Ext.ClipboardHistory.Messages; 11 using Microsoft.CmdPal.UI.ViewModels; 12 using Microsoft.CmdPal.UI.Views; 13 using Microsoft.Extensions.DependencyInjection; 14 using Microsoft.UI.Dispatching; 15 using Microsoft.UI.Input; 16 using Microsoft.UI.Xaml; 17 using Microsoft.UI.Xaml.Controls; 18 using Microsoft.UI.Xaml.Input; 19 using CoreVirtualKeyStates = Windows.UI.Core.CoreVirtualKeyStates; 20 using VirtualKey = Windows.System.VirtualKey; 21 22 namespace Microsoft.CmdPal.UI.Controls; 23 24 public sealed partial class SearchBar : UserControl, 25 IRecipient<GoHomeMessage>, 26 IRecipient<FocusSearchBoxMessage>, 27 IRecipient<UpdateSuggestionMessage>, 28 ICurrentPageAware 29 { 30 private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); 31 32 /// <summary> 33 /// Gets the <see cref="DispatcherQueueTimer"/> that we create to track keyboard input and throttle/debounce before we make queries. 34 /// </summary> 35 private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); 36 private bool _isBackspaceHeld; 37 38 // Inline text suggestions 39 // In 0.4-0.5 we would replace the text of the search box with the TextToSuggest 40 // This was really cool for navigating paths in run and pretty much nowhere else. 41 // We'll have to try another approach, but for now, the code is still testable. 42 // You can test this by setting the CMDPAL_ENABLE_SUGGESTION_SELECTION env var to 1 43 private bool _inSuggestion; 44 45 private bool InSuggestion => _inSuggestion && IsTextToSuggestEnabled; 46 47 private string? _lastText; 48 49 private string? _deletedSuggestion; 50 51 // 0.6+ suggestions 52 private string? _textToSuggest; 53 54 private SettingsModel Settings => App.Current.Services.GetRequiredService<SettingsModel>(); 55 56 public PageViewModel? CurrentPageViewModel 57 { 58 get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); 59 set => SetValue(CurrentPageViewModelProperty, value); 60 } 61 62 // Using a DependencyProperty as the backing store for CurrentPageViewModel. This enables animation, styling, binding, etc... 63 public static readonly DependencyProperty CurrentPageViewModelProperty = 64 DependencyProperty.Register(nameof(CurrentPageViewModel), typeof(PageViewModel), typeof(SearchBar), new PropertyMetadata(null, OnCurrentPageViewModelChanged)); 65 66 private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 67 { 68 //// TODO: If the Debounce timer hasn't fired, we may want to store the current Filter in the OldValue/prior VM, but we don't want that to go actually do work... 69 var @this = (SearchBar)d; 70 71 if (@this is not null 72 && e.OldValue is PageViewModel old) 73 { 74 old.PropertyChanged -= @this.Page_PropertyChanged; 75 } 76 77 if (@this is not null 78 && e.NewValue is PageViewModel page) 79 { 80 // TODO: In some cases we probably want commands to clear a filter 81 // somewhere in the process, so we need to figure out when that is. 82 @this.FilterBox.Text = page.SearchTextBox; 83 @this.FilterBox.Select(@this.FilterBox.Text.Length, 0); 84 85 page.PropertyChanged += @this.Page_PropertyChanged; 86 } 87 } 88 89 public SearchBar() 90 { 91 this.InitializeComponent(); 92 WeakReferenceMessenger.Default.Register<GoHomeMessage>(this); 93 WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this); 94 WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this); 95 } 96 97 public void ClearSearch() 98 { 99 // TODO GH #239 switch back when using the new MD text block 100 // _ = _queue.EnqueueAsync(() => 101 _queue.TryEnqueue(new(() => 102 { 103 this.FilterBox.Text = string.Empty; 104 105 if (CurrentPageViewModel is not null) 106 { 107 CurrentPageViewModel.SearchTextBox = string.Empty; 108 } 109 })); 110 } 111 112 public void SelectSearch() 113 { 114 // TODO GH #239 switch back when using the new MD text block 115 // _ = _queue.EnqueueAsync(() => 116 _queue.TryEnqueue(new(() => 117 { 118 this.FilterBox.SelectAll(); 119 })); 120 } 121 122 private void FilterBox_KeyDown(object sender, KeyRoutedEventArgs e) 123 { 124 if (e.Handled) 125 { 126 return; 127 } 128 129 var ctrlPressed = (InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control) & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down; 130 if (ctrlPressed && e.Key == VirtualKey.I) 131 { 132 // Today you learned that Ctrl+I in a TextBox will insert a tab 133 // We don't want that, so we'll suppress it, this way it can be used for other purposes 134 e.Handled = true; 135 } 136 else if (e.Key == VirtualKey.Escape) 137 { 138 switch (Settings.EscapeKeyBehaviorSetting) 139 { 140 case EscapeKeyBehavior.AlwaysGoBack: 141 WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); 142 break; 143 144 case EscapeKeyBehavior.AlwaysDismiss: 145 WeakReferenceMessenger.Default.Send<DismissMessage>(new(ForceGoHome: true)); 146 break; 147 148 case EscapeKeyBehavior.AlwaysHide: 149 WeakReferenceMessenger.Default.Send<HideWindowMessage>(new()); 150 break; 151 152 case EscapeKeyBehavior.ClearSearchFirstThenGoBack: 153 default: 154 if (string.IsNullOrEmpty(FilterBox.Text)) 155 { 156 WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); 157 } 158 else 159 { 160 // Clear the search box 161 FilterBox.Text = string.Empty; 162 163 // hack TODO GH #245 164 if (CurrentPageViewModel is not null) 165 { 166 CurrentPageViewModel.SearchTextBox = FilterBox.Text; 167 } 168 } 169 170 break; 171 } 172 173 e.Handled = true; 174 } 175 else if (e.Key == VirtualKey.Back) 176 { 177 // hack TODO GH #245 178 if (CurrentPageViewModel is not null) 179 { 180 CurrentPageViewModel.SearchTextBox = FilterBox.Text; 181 } 182 } 183 } 184 185 private void FilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) 186 { 187 if (e.Key == VirtualKey.Back) 188 { 189 if (string.IsNullOrEmpty(FilterBox.Text)) 190 { 191 if (!_isBackspaceHeld) 192 { 193 // Navigate back on single backspace when empty 194 WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new(true)); 195 } 196 197 e.Handled = true; 198 } 199 else 200 { 201 // Mark backspace as held to handle continuous deletion 202 _isBackspaceHeld = true; 203 } 204 } 205 else if (e.Key == VirtualKey.Up) 206 { 207 WeakReferenceMessenger.Default.Send<NavigatePreviousCommand>(); 208 209 e.Handled = true; 210 } 211 else if (e.Key == VirtualKey.Left) 212 { 213 // Check if we're in a grid view, and if so, send grid navigation command 214 var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true }; 215 216 // Special handling is required if we're in grid view. 217 if (isGridView) 218 { 219 WeakReferenceMessenger.Default.Send<NavigateLeftCommand>(); 220 e.Handled = true; 221 } 222 } 223 else if (e.Key == VirtualKey.Right) 224 { 225 // Check if the "replace search text with suggestion" feature from 0.4-0.5 is enabled. 226 // If it isn't, then only use the suggestion when the caret is at the end of the input. 227 if (!IsTextToSuggestEnabled) 228 { 229 if (!string.IsNullOrEmpty(_textToSuggest) && 230 FilterBox.SelectionStart == FilterBox.Text.Length) 231 { 232 FilterBox.Text = _textToSuggest; 233 FilterBox.Select(_textToSuggest.Length, 0); 234 e.Handled = true; 235 return; 236 } 237 } 238 239 // Here, we're using the "replace search text with suggestion" feature. 240 if (InSuggestion) 241 { 242 _inSuggestion = false; 243 _lastText = null; 244 DoFilterBoxUpdate(); 245 } 246 247 // Wouldn't want to perform text completion *and* move the selected item, so only perform this if text suggestion wasn't performed. 248 if (!e.Handled) 249 { 250 // Check if we're in a grid view, and if so, send grid navigation command 251 var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true }; 252 253 // Special handling is required if we're in grid view. 254 if (isGridView) 255 { 256 WeakReferenceMessenger.Default.Send<NavigateRightCommand>(); 257 e.Handled = true; 258 } 259 } 260 } 261 else if (e.Key == VirtualKey.Down) 262 { 263 WeakReferenceMessenger.Default.Send<NavigateNextCommand>(); 264 265 e.Handled = true; 266 } 267 else if (e.Key == VirtualKey.PageDown) 268 { 269 WeakReferenceMessenger.Default.Send<NavigatePageDownCommand>(); 270 e.Handled = true; 271 } 272 else if (e.Key == VirtualKey.PageUp) 273 { 274 WeakReferenceMessenger.Default.Send<NavigatePageUpCommand>(); 275 e.Handled = true; 276 } 277 278 if (InSuggestion) 279 { 280 if ( 281 e.Key == VirtualKey.Back || 282 e.Key == VirtualKey.Delete 283 ) 284 { 285 _deletedSuggestion = FilterBox.Text; 286 287 FilterBox.Text = _lastText ?? string.Empty; 288 FilterBox.Select(FilterBox.Text.Length, 0); 289 290 // Logger.LogInfo("deleting suggestion"); 291 _inSuggestion = false; 292 _lastText = null; 293 294 e.Handled = true; 295 return; 296 } 297 298 var ignoreLeave = 299 300 e.Key == VirtualKey.Up || 301 e.Key == VirtualKey.Down || 302 e.Key == VirtualKey.Left || 303 e.Key == VirtualKey.Right || 304 305 e.Key == VirtualKey.RightMenu || 306 e.Key == VirtualKey.LeftMenu || 307 e.Key == VirtualKey.Menu || 308 e.Key == VirtualKey.Shift || 309 e.Key == VirtualKey.RightShift || 310 e.Key == VirtualKey.LeftShift || 311 e.Key == VirtualKey.RightControl || 312 e.Key == VirtualKey.LeftControl || 313 e.Key == VirtualKey.Control; 314 if (ignoreLeave) 315 { 316 return; 317 } 318 319 // Logger.LogInfo("leaving suggestion"); 320 _inSuggestion = false; 321 _lastText = null; 322 } 323 } 324 325 private void FilterBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e) 326 { 327 if (e.Key == VirtualKey.Back) 328 { 329 // Reset the backspace state on key release 330 _isBackspaceHeld = false; 331 } 332 } 333 334 private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) 335 { 336 // Logger.LogInfo($"FilterBox_TextChanged: {FilterBox.Text}"); 337 338 // TERRIBLE HACK TODO GH #245 339 // There's weird wacky bugs with debounce currently. We're trying 340 // to get them ingested, but while we wait for the toolkit feeds to 341 // bubble, just manually send the first character, always 342 // (otherwise aliases just stop working) 343 if (FilterBox.Text.Length == 1) 344 { 345 DoFilterBoxUpdate(); 346 347 return; 348 } 349 350 if (InSuggestion) 351 { 352 // Logger.LogInfo($"-- skipping, in suggestion --"); 353 return; 354 } 355 356 // TODO: We could encapsulate this in a Behavior if we wanted to bind to the Filter property. 357 _debounceTimer.Debounce( 358 () => 359 { 360 DoFilterBoxUpdate(); 361 }, 362 //// Couldn't find a good recommendation/resource for value here. PT uses 50ms as default, so that is a reasonable default 363 //// This seems like a useful testing site for typing times: https://keyboardtester.info/keyboard-latency-test/ 364 //// i.e. if another keyboard press comes in within 50ms of the last, we'll wait before we fire off the request 365 interval: TimeSpan.FromMilliseconds(50), 366 //// If we're not already waiting, and this is blanking out or the first character type, we'll start filtering immediately instead to appear more responsive and either clear the filter to get back home faster or at least chop to the first starting letter. 367 immediate: FilterBox.Text.Length <= 1); 368 } 369 370 private void DoFilterBoxUpdate() 371 { 372 if (InSuggestion) 373 { 374 // Logger.LogInfo($"--- skipping ---"); 375 return; 376 } 377 378 // Actually plumb Filtering to the view model 379 if (CurrentPageViewModel is not null) 380 { 381 CurrentPageViewModel.SearchTextBox = FilterBox.Text; 382 383 // Telemetry: Track search query count for session metrics (only non-empty queries) 384 if (!string.IsNullOrWhiteSpace(FilterBox.Text)) 385 { 386 WeakReferenceMessenger.Default.Send<SearchQueryMessage>(new()); 387 } 388 } 389 } 390 391 // Used to handle the case when a ListPage's `SearchText` may have changed 392 private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) 393 { 394 var property = e.PropertyName; 395 396 if (CurrentPageViewModel is ListViewModel list) 397 { 398 if (property == nameof(ListViewModel.SearchText)) 399 { 400 // Only if the text actually changed... 401 // (sometimes this triggers on a round-trip of the SearchText) 402 if (FilterBox.Text != list.SearchText) 403 { 404 // ... Update our displayed text, and... 405 FilterBox.Text = list.SearchText; 406 407 // ... Move the cursor to the end of the input 408 FilterBox.Select(FilterBox.Text.Length, 0); 409 } 410 } 411 else if (property == nameof(ListViewModel.InitialSearchText)) 412 { 413 // GH #38712: 414 // The ListPage will notify us of the `InitialSearchText` when 415 // we first load the view model. We can use that as an 416 // opportunity to immediately select the search text. That lets 417 // the user start typing a new search without manually 418 // selecting the old one. 419 SelectSearch(); 420 } 421 } 422 } 423 424 public void Receive(GoHomeMessage message) => ClearSearch(); 425 426 public void Receive(FocusSearchBoxMessage message) => FilterBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); 427 428 public void Receive(UpdateSuggestionMessage message) 429 { 430 if (!IsTextToSuggestEnabled) 431 { 432 _textToSuggest = message.TextToSuggest; 433 return; 434 } 435 436 var suggestion = message.TextToSuggest; 437 438 _queue.TryEnqueue(new(() => 439 { 440 var clearSuggestion = string.IsNullOrEmpty(suggestion); 441 442 if (clearSuggestion && _inSuggestion) 443 { 444 // Logger.LogInfo($"Cleared suggestion \"{_lastText}\" to {suggestion}"); 445 _inSuggestion = false; 446 FilterBox.Text = _lastText ?? string.Empty; 447 _lastText = null; 448 return; 449 } 450 451 if (clearSuggestion) 452 { 453 _deletedSuggestion = null; 454 return; 455 } 456 457 if (suggestion == _deletedSuggestion) 458 { 459 return; 460 } 461 else 462 { 463 _deletedSuggestion = null; 464 } 465 466 var currentText = _lastText ?? FilterBox.Text; 467 468 _lastText = currentText; 469 470 // if (_inSuggestion) 471 // { 472 // Logger.LogInfo($"Suggestion from \"{_lastText}\" to {suggestion}"); 473 // } 474 // else 475 // { 476 // Logger.LogInfo($"Entering suggestion from \"{_lastText}\" to {suggestion}"); 477 // } 478 _inSuggestion = true; 479 480 var matchedChars = 0; 481 var suggestionStartsWithQuote = suggestion.Length > 0 && suggestion[0] == '"'; 482 var currentStartsWithQuote = currentText.Length > 0 && currentText[0] == '"'; 483 var skipCheckingFirst = suggestionStartsWithQuote && !currentStartsWithQuote; 484 for (int i = skipCheckingFirst ? 1 : 0, j = 0; 485 i < suggestion.Length && j < currentText.Length; 486 i++, j++) 487 { 488 if (string.Equals( 489 suggestion[i].ToString(), 490 currentText[j].ToString(), 491 StringComparison.OrdinalIgnoreCase)) 492 { 493 matchedChars++; 494 } 495 else 496 { 497 break; 498 } 499 } 500 501 var first = skipCheckingFirst ? "\"" : string.Empty; 502 var second = currentText.AsSpan(0, matchedChars); 503 var third = suggestion.AsSpan(matchedChars + (skipCheckingFirst ? 1 : 0)); 504 505 var newText = string.Concat( 506 first, 507 second, 508 third); 509 510 FilterBox.Text = newText; 511 512 var wrappedInQuotes = suggestionStartsWithQuote && suggestion.Last() == '"'; 513 if (wrappedInQuotes) 514 { 515 FilterBox.Select( 516 (skipCheckingFirst ? 1 : 0) + matchedChars, 517 Math.Max(0, suggestion.Length - matchedChars - 1 + (skipCheckingFirst ? -1 : 0))); 518 } 519 else 520 { 521 FilterBox.Select(matchedChars, suggestion.Length - matchedChars); 522 } 523 })); 524 } 525 526 private static bool IsTextToSuggestEnabled => _textToSuggestEnabled.Value; 527 528 private static Lazy<bool> _textToSuggestEnabled = new(() => QueryTextToSuggestEnabled()); 529 530 private static bool QueryTextToSuggestEnabled() 531 { 532 var env = System.Environment.GetEnvironmentVariable("CMDPAL_ENABLE_SUGGESTION_SELECTION"); 533 return !string.IsNullOrEmpty(env) && 534 (env == "1" || env.Equals("true", System.StringComparison.OrdinalIgnoreCase)); 535 } 536 }