/ src / modules / cmdpal / Microsoft.CmdPal.UI / Controls / SearchBar.xaml.cs
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  }