/ src / modules / cmdpal / Core / Microsoft.CmdPal.Core.ViewModels / ListItemViewModel.cs
ListItemViewModel.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.Diagnostics.CodeAnalysis;
  6  using Microsoft.CmdPal.Core.ViewModels.Commands;
  7  using Microsoft.CmdPal.Core.ViewModels.Models;
  8  using Microsoft.CommandPalette.Extensions;
  9  using Microsoft.CommandPalette.Extensions.Toolkit;
 10  
 11  namespace Microsoft.CmdPal.Core.ViewModels;
 12  
 13  public partial class ListItemViewModel : CommandItemViewModel
 14  {
 15      public new ExtensionObject<IListItem> Model { get; }
 16  
 17      public List<TagViewModel>? Tags { get; set; }
 18  
 19      // Remember - "observable" properties from the model (via PropChanged)
 20      // cannot be marked [ObservableProperty]
 21      public bool HasTags => (Tags?.Count ?? 0) > 0;
 22  
 23      public string TextToSuggest { get; private set; } = string.Empty;
 24  
 25      public string Section { get; private set; } = string.Empty;
 26  
 27      public bool IsSectionOrSeparator { get; private set; }
 28  
 29      public DetailsViewModel? Details { get; private set; }
 30  
 31      [MemberNotNullWhen(true, nameof(Details))]
 32      public bool HasDetails => Details is not null;
 33  
 34      public string AccessibleName { get; private set; } = string.Empty;
 35  
 36      public bool ShowTitle { get; private set; }
 37  
 38      public bool ShowSubtitle { get; private set; }
 39  
 40      public bool LayoutShowsTitle
 41      {
 42          get;
 43          set
 44          {
 45              if (SetProperty(ref field, value))
 46              {
 47                  UpdateShowsTitle();
 48              }
 49          }
 50      }
 51  
 52      public bool LayoutShowsSubtitle
 53      {
 54          get;
 55          set
 56          {
 57              if (SetProperty(ref field, value))
 58              {
 59                  UpdateShowsSubtitle();
 60              }
 61          }
 62      }
 63  
 64      public ListItemViewModel(IListItem model, WeakReference<IPageContext> context)
 65          : base(new(model), context)
 66      {
 67          Model = new ExtensionObject<IListItem>(model);
 68      }
 69  
 70      public override void InitializeProperties()
 71      {
 72          if (IsInitialized)
 73          {
 74              return;
 75          }
 76  
 77          // This sets IsInitialized = true
 78          base.InitializeProperties();
 79  
 80          var li = Model.Unsafe;
 81          if (li is null)
 82          {
 83              return; // throw?
 84          }
 85  
 86          UpdateTags(li.Tags);
 87          Section = li.Section ?? string.Empty;
 88          IsSectionOrSeparator = IsSeparator(li);
 89          UpdateProperty(nameof(Section), nameof(IsSectionOrSeparator));
 90  
 91          UpdateAccessibleName();
 92      }
 93  
 94      private bool IsSeparator(IListItem item)
 95      {
 96          return item.Command is null;
 97      }
 98  
 99      public override void SlowInitializeProperties()
100      {
101          base.SlowInitializeProperties();
102          var model = Model.Unsafe;
103          if (model is null)
104          {
105              return;
106          }
107  
108          var extensionDetails = model.Details;
109          if (extensionDetails is not null)
110          {
111              Details = new(extensionDetails, PageContext);
112              Details.InitializeProperties();
113              UpdateProperty(nameof(Details), nameof(HasDetails));
114          }
115  
116          AddShowDetailsCommands();
117  
118          TextToSuggest = model.TextToSuggest;
119          UpdateProperty(nameof(TextToSuggest));
120      }
121  
122      protected override void FetchProperty(string propertyName)
123      {
124          base.FetchProperty(propertyName);
125  
126          var model = this.Model.Unsafe;
127          if (model is null)
128          {
129              return; // throw?
130          }
131  
132          switch (propertyName)
133          {
134              case nameof(model.Tags):
135                  UpdateTags(model.Tags);
136                  break;
137              case nameof(model.TextToSuggest):
138                  TextToSuggest = model.TextToSuggest ?? string.Empty;
139                  UpdateProperty(nameof(TextToSuggest));
140                  break;
141              case nameof(model.Section):
142                  Section = model.Section ?? string.Empty;
143                  IsSectionOrSeparator = IsSeparator(model);
144                  UpdateProperty(nameof(Section), nameof(IsSectionOrSeparator));
145                  break;
146              case nameof(model.Command):
147                  IsSectionOrSeparator = IsSeparator(model);
148                  UpdateProperty(nameof(IsSectionOrSeparator));
149                  break;
150              case nameof(Details):
151                  var extensionDetails = model.Details;
152                  Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null;
153                  Details?.InitializeProperties();
154                  UpdateProperty(nameof(Details), nameof(HasDetails));
155                  UpdateShowDetailsCommand();
156                  break;
157              case nameof(model.MoreCommands):
158                  UpdateProperty(nameof(MoreCommands));
159                  AddShowDetailsCommands();
160                  break;
161              case nameof(model.Title):
162                  UpdateProperty(nameof(Title));
163                  UpdateShowsTitle();
164                  UpdateAccessibleName();
165                  break;
166              case nameof(model.Subtitle):
167                  UpdateProperty(nameof(Subtitle));
168                  UpdateShowsSubtitle();
169                  UpdateAccessibleName();
170                  break;
171              default:
172                  UpdateProperty(propertyName);
173                  break;
174          }
175      }
176  
177      // TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes?
178      // TODO: Do we want to save off the score here so we can sort by it in our ListViewModel?
179      public override string ToString() => $"{Name} ListItemViewModel";
180  
181      public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model);
182  
183      public override int GetHashCode() => Model.GetHashCode();
184  
185      private void AddShowDetailsCommands()
186      {
187          // If the parent page has ShowDetails = false and we have details,
188          // then we should add a show details action in the context menu.
189          if (HasDetails &&
190              PageContext.TryGetTarget(out var pageContext) &&
191              pageContext is ListViewModel listViewModel &&
192              !listViewModel.ShowDetails)
193          {
194              // Check if "Show Details" action already exists to prevent duplicates
195              if (!MoreCommands.Any(cmd => cmd is CommandContextItemViewModel contextItemViewModel &&
196                                          contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId))
197              {
198                  // Create the view model for the show details command
199                  var showDetailsCommand = new ShowDetailsCommand(Details);
200                  var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
201                  var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
202                  showDetailsContextItemViewModel.SlowInitializeProperties();
203                  MoreCommands.Add(showDetailsContextItemViewModel);
204              }
205  
206              UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
207          }
208      }
209  
210      // This method is called when the details change to make sure we
211      // have the latest details in the show details command.
212      private void UpdateShowDetailsCommand()
213      {
214          // If the parent page has ShowDetails = false and we have details,
215          // then we should add a show details action in the context menu.
216          if (HasDetails &&
217              PageContext.TryGetTarget(out var pageContext) &&
218              pageContext is ListViewModel listViewModel &&
219              !listViewModel.ShowDetails)
220          {
221              var existingCommand = MoreCommands.FirstOrDefault(cmd =>
222                                          cmd is CommandContextItemViewModel contextItemViewModel &&
223                                          contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId);
224  
225              // If the command already exists, remove it to update with the new details
226              if (existingCommand is not null)
227              {
228                  MoreCommands.Remove(existingCommand);
229              }
230  
231              // Create the view model for the show details command
232              var showDetailsCommand = new ShowDetailsCommand(Details);
233              var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
234              var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
235              showDetailsContextItemViewModel.SlowInitializeProperties();
236              MoreCommands.Add(showDetailsContextItemViewModel);
237  
238              UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
239          }
240      }
241  
242      private void UpdateTags(ITag[]? newTagsFromModel)
243      {
244          var newTags = newTagsFromModel?.Select(t =>
245          {
246              var vm = new TagViewModel(t, PageContext);
247              vm.InitializeProperties();
248              return vm;
249          })
250              .ToList() ?? [];
251  
252          DoOnUiThread(
253              () =>
254              {
255                  // Tags being an ObservableCollection instead of a List lead to
256                  // many COM exception issues.
257                  Tags = [.. newTags];
258  
259                  // We're already in UI thread, so just raise the events
260                  OnPropertyChanged(nameof(Tags));
261                  OnPropertyChanged(nameof(HasTags));
262              });
263      }
264  
265      private void UpdateShowsTitle()
266      {
267          var oldShowTitle = ShowTitle;
268          ShowTitle = LayoutShowsTitle;
269          if (oldShowTitle != ShowTitle)
270          {
271              UpdateProperty(nameof(ShowTitle));
272          }
273      }
274  
275      private void UpdateShowsSubtitle()
276      {
277          var oldShowSubtitle = ShowSubtitle;
278          ShowSubtitle = LayoutShowsSubtitle && !string.IsNullOrWhiteSpace(Subtitle);
279          if (oldShowSubtitle != ShowSubtitle)
280          {
281              UpdateProperty(nameof(ShowSubtitle));
282          }
283      }
284  
285      protected override void UnsafeCleanup()
286      {
287          base.UnsafeCleanup();
288  
289          // Tags don't have event handlers or anything to cleanup
290          Tags?.ForEach(t => t.SafeCleanup());
291          Details?.SafeCleanup();
292  
293          var model = Model.Unsafe;
294          if (model is not null)
295          {
296              // We don't need to revoke the PropChanged event handler here,
297              // because we are just overriding CommandItem's FetchProperty and
298              // piggy-backing off their PropChanged
299          }
300      }
301  
302      protected void UpdateAccessibleName()
303      {
304          AccessibleName = Title + ", " + Subtitle;
305          UpdateProperty(nameof(AccessibleName));
306      }
307  }