BREAKING: Add ContentPages and Invoke(sender) (#395)

## BREAKING API CHANGES APPROACHING

Closes #307 
Closes #238 
Removes `Command` from `ITag`
Adds `IDetailsCommand` (to achieve the same goal as the above originally had)
Adds `ITreeContent`, based on a hunch
Adds `ShowToast` and `Confirm` to `CommandResult` too, but without UX yet

Extensions from before this change will need to be updated. 
* The `InvokableCommand` change should be trivial - the helpers should abstract that delta away for you.
* The `ContentPage` change should be pretty easy to make. 
  * Both `MarkdownPage` and `FormPage` are now just `ContentPage`
  * `FormPage.Forms()` -> `ContentPage.GetContent()`
  * `MarkdownPage.Bodies()` -> `ContentPage.GetContent()`
  * `IForm`s become `IFormContent`. Methods become properties (not that bad)
* I'm only deprecating the old Markdown and Form pages - I'll fully remove them before we OSS, but I'll give everyone a couple weeks to port them.
* No one was using `Tag.Command` and it always seemed iffy at best - better not.
This commit is contained in:
Mike Griese
2025-02-03 16:30:46 -06:00
committed by GitHub
parent 1a623ce136
commit edb61457f4
48 changed files with 2202 additions and 326 deletions

View File

@@ -3,14 +3,13 @@
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
namespace PokedexExtension;
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "This is sample code")]
public class Pokemon
{
public int Number { get; set; }
@@ -21,6 +20,8 @@ public class Pokemon
public string IconUrl => $"https://serebii.net/pokedex-sv/icon/new/{Number:D3}.png";
public string SerebiiUrl => $"https://serebii.net/pokedex-sv/{Number:D3}.shtml";
public Pokemon(int number, string name, List<string> types)
{
Number = number;
@@ -29,17 +30,42 @@ public class Pokemon
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")]
internal sealed partial class PokemonPage : NoOpCommand
internal sealed partial class OpenPokemonCommand : InvokableCommand
{
public PokemonPage(Pokemon pokemon)
public OpenPokemonCommand()
{
Name = pokemon.Name;
Icon = new(pokemon.IconUrl);
Name = "Open";
}
public override ICommandResult Invoke(object sender)
{
if (sender is PokemonListItem item)
{
var pokemon = item.Pokemon;
Process.Start(new ProcessStartInfo(pokemon.SerebiiUrl) { UseShellExecute = true });
}
return CommandResult.KeepOpen();
}
}
internal sealed partial class PokemonListItem : ListItem
{
private static readonly OpenPokemonCommand _command = new();
public Pokemon Pokemon { get; private set; }
public PokemonListItem(Pokemon p)
: base(_command)
{
Pokemon = p;
Title = Pokemon.Name;
Icon = new(Pokemon.IconUrl);
Subtitle = $"#{Pokemon.Number}";
Tags = Pokemon.Types.Select(t => new Tag() { Text = t, Background = PokedexExtensionPage.GetColorForType(t) }).ToArray();
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")]
internal sealed partial class PokedexExtensionPage : ListPage
{
private readonly List<Pokemon> _kanto =
@@ -448,14 +474,7 @@ internal sealed partial class PokedexExtensionPage : ListPage
public override IListItem[] GetItems() => _kanto.AsEnumerable().Concat(_johto.AsEnumerable()).Concat(_hoenn.AsEnumerable()).Select(GetPokemonListItem).ToArray();
private static ListItem GetPokemonListItem(Pokemon pokemon)
{
return new ListItem(new PokemonPage(pokemon))
{
Subtitle = $"#{pokemon.Number}",
Tags = pokemon.Types.Select(t => new Tag() { Text = t, Background = GetColorForType(t) }).ToArray(),
};
}
private static ListItem GetPokemonListItem(Pokemon pokemon) => new PokemonListItem(pokemon);
// Dictionary mapping Pokémon types to their corresponding colors
private static readonly Dictionary<string, OptionalColor> TypeColors = new()

View File

@@ -0,0 +1,338 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
namespace SamplePagesExtension;
internal sealed partial class SampleContentPage : ContentPage
{
private readonly SampleContentForm sampleForm = new();
private readonly MarkdownContent sampleMarkdown = new() { Body = "# Sample page with mixed content \n This page has both markdown, and form content" };
public override IContent[] GetContent() => [sampleMarkdown, sampleForm];
public SampleContentPage()
{
Name = "Sample Content";
Icon = new(string.Empty);
}
}
[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")]
internal sealed partial class SampleContentForm : FormContent
{
public SampleContentForm()
{
TemplateJson = $$"""
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.6",
"body": [
{
"type": "TextBlock",
"size": "medium",
"weight": "bolder",
"text": " ${ParticipantInfoForm.title}",
"horizontalAlignment": "center",
"wrap": true,
"style": "heading"
},
{
"type": "Input.Text",
"label": "Name",
"style": "text",
"id": "SimpleVal",
"isRequired": true,
"errorMessage": "Name is required",
"placeholder": "Enter your name"
},
{
"type": "Input.Text",
"label": "Homepage",
"style": "url",
"id": "UrlVal",
"placeholder": "Enter your homepage url"
},
{
"type": "Input.Text",
"label": "Email",
"style": "email",
"id": "EmailVal",
"placeholder": "Enter your email"
},
{
"type": "Input.Text",
"label": "Phone",
"style": "tel",
"id": "TelVal",
"placeholder": "Enter your phone number"
},
{
"type": "Input.Text",
"label": "Comments",
"style": "text",
"isMultiline": true,
"id": "MultiLineVal",
"placeholder": "Enter any comments"
},
{
"type": "Input.Number",
"label": "Quantity (Minimum -5, Maximum 5)",
"min": -5,
"max": 5,
"value": 1,
"id": "NumVal",
"errorMessage": "The quantity must be between -5 and 5"
},
{
"type": "Input.Date",
"label": "Due Date",
"id": "DateVal",
"value": "2017-09-20"
},
{
"type": "Input.Time",
"label": "Start time",
"id": "TimeVal",
"value": "16:59"
},
{
"type": "TextBlock",
"size": "medium",
"weight": "bolder",
"text": "${Survey.title} ",
"horizontalAlignment": "center",
"wrap": true,
"style": "heading"
},
{
"type": "Input.ChoiceSet",
"id": "CompactSelectVal",
"label": "${Survey.questions[0].question}",
"style": "compact",
"value": "1",
"choices": [
{
"$data": "${Survey.questions[0].items}",
"title": "${choice}",
"value": "${value}"
}
]
},
{
"type": "Input.ChoiceSet",
"id": "SingleSelectVal",
"label": "${Survey.questions[1].question}",
"style": "expanded",
"value": "1",
"choices": [
{
"$data": "${Survey.questions[1].items}",
"title": "${choice}",
"value": "${value}"
}
]
},
{
"type": "Input.ChoiceSet",
"id": "MultiSelectVal",
"label": "${Survey.questions[2].question}",
"isMultiSelect": true,
"value": "1,3",
"choices": [
{
"$data": "${Survey.questions[2].items}",
"title": "${choice}",
"value": "${value}"
}
]
},
{
"type": "TextBlock",
"size": "medium",
"weight": "bolder",
"text": "Input.Toggle",
"horizontalAlignment": "center",
"wrap": true,
"style": "heading"
},
{
"type": "Input.Toggle",
"label": "Please accept the terms and conditions:",
"title": "${Survey.questions[3].question}",
"valueOn": "true",
"valueOff": "false",
"id": "AcceptsTerms",
"isRequired": true,
"errorMessage": "Accepting the terms and conditions is required"
},
{
"type": "Input.Toggle",
"label": "How do you feel about red cars?",
"title": "${Survey.questions[4].question}",
"valueOn": "RedCars",
"valueOff": "NotRedCars",
"id": "ColorPreference"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit",
"data": {
"id": "1234567890"
}
},
{
"type": "Action.ShowCard",
"title": "Show Card",
"card": {
"type": "AdaptiveCard",
"body": [
{
"type": "Input.Text",
"label": "Enter comment",
"style": "text",
"id": "CommentVal"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "OK"
}
]
}
}
]
}
""";
DataJson = $$"""
{
"ParticipantInfoForm": {
"title": "Input.Text elements"
},
"Survey": {
"title": "Input ChoiceSet",
"questions": [
{
"question": "What color do you want? (compact)",
"items": [
{
"choice": "Red",
"value": "1"
},
{
"choice": "Green",
"value": "2"
},
{
"choice": "Blue",
"value": "3"
}
]
},
{
"question": "What color do you want? (expanded)",
"items": [
{
"choice": "Red",
"value": "1"
},
{
"choice": "Green",
"value": "2"
},
{
"choice": "Blue",
"value": "3"
}
]
},
{
"question": "What color do you want? (multiselect)",
"items": [
{
"choice": "Red",
"value": "1"
},
{
"choice": "Green",
"value": "2"
},
{
"choice": "Blue",
"value": "3"
}
]
},
{
"question": "I accept the terms and conditions (True/False)"
},
{
"question": "Red cars are better than other cars"
}
]
}
}
""";
}
public override CommandResult SubmitForm(string payload)
{
var formInput = JsonNode.Parse(payload)?.AsObject();
if (formInput == null)
{
return CommandResult.GoHome();
}
// Application.Current.GetService<ILocalSettingsService>().SaveSettingAsync("GlobalHotkey", formInput["hotkey"]?.ToString() ?? string.Empty);
return CommandResult.GoHome();
}
}
[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")]
internal sealed partial class SampleTreeContentPage : ContentPage
{
private readonly TreeContent myContentTree;
public override IContent[] GetContent() => [myContentTree];
public SampleTreeContentPage()
{
Name = Title = "Sample Content";
Icon = new("\uE81E");
myContentTree = new()
{
RootContent = new MarkdownContent() { Body = "# This page has nested content" },
Children = [
new TreeContent()
{
RootContent = new MarkdownContent() { Body = "Yo dog" },
Children = [
new TreeContent()
{
RootContent = new MarkdownContent() { Body = "I heard you like content" },
Children = [
new MarkdownContent() { Body = "So we put content in your content" },
new FormContent() { TemplateJson = "{\"$schema\":\"http://adaptivecards.io/schemas/adaptive-card.json\",\"type\":\"AdaptiveCard\",\"version\":\"1.6\",\"body\":[{\"type\":\"TextBlock\",\"size\":\"medium\",\"weight\":\"bolder\",\"text\":\"Mix and match why don't you\",\"horizontalAlignment\":\"center\",\"wrap\":true,\"style\":\"heading\"},{\"type\":\"TextBlock\",\"text\":\"You can have forms here too\",\"horizontalAlignment\":\"Right\",\"wrap\":true}],\"actions\":[{\"type\":\"Action.Submit\",\"title\":\"It's a form, you get it\",\"data\":{\"id\":\"LoginVal\"}}]}" },
new MarkdownContent() { Body = "Another markdown down here" },
],
},
new MarkdownContent() { Body = "**slaps roof**" },
new MarkdownContent() { Body = "This baby can fit so much content" },
],
}
],
};
}
}

View File

@@ -11,7 +11,7 @@ internal sealed partial class SampleListPage : ListPage
{
public SampleListPage()
{
Icon = new(string.Empty);
Icon = new("\uEA37");
Name = "Sample List Page";
}

View File

@@ -12,8 +12,8 @@ internal sealed partial class SampleListPageWithDetails : ListPage
{
public SampleListPageWithDetails()
{
Icon = new(string.Empty);
Name = "Sample List Page with Details";
Icon = new("\uE8A0");
Name = Title = "Sample List Page with Details";
this.ShowDetails = true;
}

View File

@@ -10,59 +10,85 @@ namespace SamplePagesExtension;
public partial class SamplesListPage : ListPage
{
private readonly IListItem[] _commands = [
new ListItem(new SampleListPage())
{
Title = "List Page Sample Command",
Subtitle = "Display a list of items",
},
new ListItem(new SampleListPageWithDetails())
{
Title = "List Page With Details",
Subtitle = "A list of items, each with additional details to display",
},
new ListItem(new SampleUpdatingItemsPage())
{
Title = "List page with items that change",
Subtitle = "The items on the list update themselves in real time",
},
new ListItem(new SampleDynamicListPage())
{
Title = "Dynamic List Page Command",
Subtitle = "Changes the list of items in response to the typed query",
},
new ListItem(new SampleMarkdownPage())
{
Title = "Markdown Page Sample Command",
Subtitle = "Display a page of rendered markdown",
},
new ListItem(new SampleMarkdownManyBodies())
{
Title = "Markdown with multiple blocks",
Subtitle = "A page with multiple blocks of rendered markdown",
},
new ListItem(new SampleMarkdownDetails())
{
Title = "Markdown with details",
Subtitle = "A page with markdown and details",
},
// List pages
new ListItem(new SampleListPage())
{
Title = "List Page Sample Command",
Subtitle = "Display a list of items",
},
new ListItem(new SampleListPageWithDetails())
{
Title = "List Page With Details",
Subtitle = "A list of items, each with additional details to display",
},
new ListItem(new SampleUpdatingItemsPage())
{
Title = "List page with items that change",
Subtitle = "The items on the list update themselves in real time",
},
new ListItem(new SampleDynamicListPage())
{
Title = "Dynamic List Page Command",
Subtitle = "Changes the list of items in response to the typed query",
},
new ListItem(new SampleFormPage())
{
Title = "Form Page Sample Command",
Subtitle = "Define inputs to retrieve input from the user",
},
new ListItem(new SampleSettingsPage())
{
Title = "Sample settings page",
Subtitle = "A demo of the settings helpers",
},
// Content pages
new ListItem(new SampleContentPage())
{
Title = "Sample content page",
Subtitle = "Display mixed forms, markdown, and other types of content",
},
new ListItem(new SampleTreeContentPage())
{
Title = "Sample nested content",
Subtitle = "Example of nesting a tree of content",
},
new ListItem(new SampleCommentsPage())
{
Title = "Sample of nested comments",
Subtitle = "Demo of using nested trees of content to create a comment thread-like experience",
Icon = new("\uE90A"), // Comment
},
new ListItem(new EvilSamplesPage())
{
Title = "Evil samples",
Subtitle = "Samples designed to break the palette in many different evil ways",
}
// DEPRECATED: Markdown pages
new ListItem(new SampleMarkdownPage())
{
Title = "Markdown Page Sample Command",
Subtitle = "Display a page of rendered markdown",
},
new ListItem(new SampleMarkdownManyBodies())
{
Title = "Markdown with multiple blocks",
Subtitle = "A page with multiple blocks of rendered markdown",
},
new ListItem(new SampleMarkdownDetails())
{
Title = "Markdown with details",
Subtitle = "A page with markdown and details",
},
// DEPRECATED: Form pages
new ListItem(new SampleFormPage())
{
Title = "Form Page Sample Command",
Subtitle = "Define inputs to retrieve input from the user",
},
// Settings helpers
new ListItem(new SampleSettingsPage())
{
Title = "Sample settings page",
Subtitle = "A demo of the settings helpers",
},
// Evil edge cases
// Anything weird that might break the palette - put that in here.
new ListItem(new EvilSamplesPage())
{
Title = "Evil samples",
Subtitle = "Samples designed to break the palette in many different evil ways",
}
];
public SamplesListPage()

View File

@@ -13,6 +13,6 @@ public partial class SelfImmolateCommand : InvokableCommand
public override ICommandResult Invoke()
{
Process.GetCurrentProcess().Kill();
return base.Invoke();
return CommandResult.GoHome();
}
}

View File

@@ -78,5 +78,6 @@ public partial class ActionBarViewModel : ObservableObject,
// InvokeItemCommand is what this will be in Xaml due to source generator
[RelayCommand]
private void InvokeItem(CommandContextItemViewModel item) => WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command));
private void InvokeItem(CommandContextItemViewModel item) =>
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command, item.Model));
}

View File

@@ -9,7 +9,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandContextItemViewModel(ICommandContextItem contextItem, IPageContext context) : CommandItemViewModel(new(contextItem), context)
{
private readonly ExtensionObject<ICommandContextItem> _contextItemModel = new(contextItem);
public ExtensionObject<ICommandContextItem> Model { get; } = new(contextItem);
public bool IsCritical { get; private set; }
@@ -19,7 +19,7 @@ public partial class CommandContextItemViewModel(ICommandContextItem contextItem
{
base.InitializeProperties();
var contextItem = _contextItemModel.Unsafe;
var contextItem = Model.Unsafe;
if (contextItem == null)
{
return; // throw?

View File

@@ -179,12 +179,13 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel
break;
case nameof(Icon):
var listIcon = model.Icon;
var iconInfo = listIcon != null ? listIcon : Command.Unsafe!.Icon;
var iconInfo = listIcon ?? Command.Unsafe!.Icon;
Icon = new(iconInfo);
Icon.InitializeProperties();
break;
// TODO! MoreCommands array, which needs to also raise HasMoreCommands
// TODO GH #360 - make MoreCommands observable
// which needs to also raise HasMoreCommands
}
UpdateProperty(propertyName);

View File

@@ -0,0 +1,145 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using AdaptiveCards.ObjectModel.WinUI3;
using AdaptiveCards.Templating;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Windows.Data.Json;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ContentFormViewModel(IFormContent _form, IPageContext context) :
ContentViewModel(context)
{
private readonly ExtensionObject<IFormContent> _formModel = new(_form);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public string TemplateJson { get; protected set; } = "{}";
public string StateJson { get; protected set; } = "{}";
public string DataJson { get; protected set; } = "{}";
public AdaptiveCardParseResult? Card { get; private set; }
public override void InitializeProperties()
{
var model = _formModel.Unsafe;
if (model == null)
{
return;
}
try
{
TemplateJson = model.TemplateJson;
StateJson = model.StateJson;
DataJson = model.DataJson;
AdaptiveCardTemplate template = new(TemplateJson);
var cardJson = template.Expand(DataJson);
Card = AdaptiveCard.FromJsonString(cardJson);
}
catch (Exception e)
{
// If we fail to parse the card JSON, then display _our own card_
// with the exception
AdaptiveCardTemplate template = new(ErrorCardJson);
// todo: we could probably stick Card.Errors in there too
var dataJson = $$"""
{
"error_message": {{JsonSerializer.Serialize(e.Message)}},
"error_stack": {{JsonSerializer.Serialize(e.StackTrace)}},
"inner_exception": {{JsonSerializer.Serialize(e.InnerException?.Message)}},
"template_json": {{JsonSerializer.Serialize(TemplateJson)}},
"data_json": {{JsonSerializer.Serialize(DataJson)}}
}
""";
var cardJson = template.Expand(dataJson);
Card = AdaptiveCard.FromJsonString(cardJson);
}
UpdateProperty(nameof(Card));
}
public void HandleSubmit(IAdaptiveActionElement action, JsonObject inputs)
{
if (action is AdaptiveOpenUrlAction openUrlAction)
{
WeakReferenceMessenger.Default.Send<LaunchUriMessage>(new(openUrlAction.Url));
return;
}
if (action is AdaptiveSubmitAction or AdaptiveExecuteAction)
{
// Get the data and inputs
// var data = submitAction.DataJson.Stringify();
var inputString = inputs.Stringify();
// _ = data;
_ = inputString;
try
{
var model = _formModel.Unsafe!;
if (model != null)
{
var result = model.SubmitForm(inputString);
WeakReferenceMessenger.Default.Send<HandleCommandResultMessage>(new(new(result)));
}
}
catch (Exception ex)
{
PageContext.ShowException(ex);
}
}
}
private static readonly string ErrorCardJson = """
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Error parsing form from extension",
"wrap": true,
"style": "heading",
"size": "ExtraLarge",
"weight": "Bolder",
"color": "Attention"
},
{
"type": "TextBlock",
"wrap": true,
"text": "${error_message}",
"color": "Attention"
},
{
"type": "TextBlock",
"text": "${error_stack}",
"fontType": "Monospace"
},
{
"type": "TextBlock",
"wrap": true,
"text": "Inner exception:"
},
{
"type": "TextBlock",
"wrap": true,
"text": "${inner_exception}",
"color": "Attention"
}
]
}
""";
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using AdaptiveCards.ObjectModel.WinUI3;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Models;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ContentMarkdownViewModel(IMarkdownContent _markdown, IPageContext context) :
ContentViewModel(context)
{
public ExtensionObject<IMarkdownContent> Model { get; } = new(_markdown);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public string Body { get; protected set; } = string.Empty;
public AdaptiveCardParseResult? Card { get; private set; }
public override void InitializeProperties()
{
var model = Model.Unsafe;
if (model == null)
{
return;
}
Body = model.Body;
UpdateProperty(nameof(Body));
model.PropChanged += Model_PropChanged;
}
private void Model_PropChanged(object sender, PropChangedEventArgs args)
{
try
{
var propName = args.PropertyName;
FetchProperty(propName);
}
catch (Exception ex)
{
PageContext.ShowException(ex);
}
}
protected void FetchProperty(string propertyName)
{
var model = Model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Body):
Body = model.Body;
break;
}
UpdateProperty(propertyName);
}
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ContentPageViewModel : PageViewModel
{
private readonly ExtensionObject<IContentPage> _model;
[ObservableProperty]
public partial ObservableCollection<ContentViewModel> Content { get; set; } = [];
public List<CommandContextItemViewModel> Commands { get; private set; } = [];
public bool HasCommands => Commands.Count > 0;
public DetailsViewModel? Details { get; private set; }
[MemberNotNullWhen(true, nameof(Details))]
public bool HasDetails => Details != null;
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, CommandPaletteHost host)
: base(model, scheduler, host)
{
_model = new(model);
}
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, ItemsChangedEventArgs args) => FetchContent();
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchContent()
{
List<ContentViewModel> newContent = [];
try
{
var newItems = _model.Unsafe!.GetContent();
foreach (var item in newItems)
{
var viewModel = ViewModelFromContent(item, PageContext);
if (viewModel != null)
{
viewModel.InitializeProperties();
newContent.Add(viewModel);
}
}
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
throw;
}
// Now, back to a UI thread to update the observable collection
Task.Factory.StartNew(
() =>
{
ListHelpers.InPlaceUpdateList(Content, newContent);
},
CancellationToken.None,
TaskCreationOptions.None,
PageContext.Scheduler);
}
public static ContentViewModel? ViewModelFromContent(IContent content, IPageContext context)
{
ContentViewModel? viewModel = content switch
{
IFormContent form => new ContentFormViewModel(form, context),
IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context),
ITreeContent tree => new ContentTreeViewModel(tree, context),
_ => null,
};
return viewModel;
}
public override void InitializeProperties()
{
base.InitializeProperties();
var model = _model.Unsafe;
if (model == null)
{
return; // throw?
}
Commands = model.Commands
.Where(contextItem => contextItem is ICommandContextItem)
.Select(contextItem => (contextItem as ICommandContextItem)!)
.Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext))
.ToList();
var extensionDetails = model.Details;
if (extensionDetails != null)
{
Details = new(extensionDetails, PageContext);
Details.InitializeProperties();
}
UpdateDetails();
FetchContent();
model.ItemsChanged += Model_ItemsChanged;
}
protected override void FetchProperty(string propertyName)
{
base.FetchProperty(propertyName);
var model = this._model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
// case nameof(Commands):
// TODO GH #360 - make MoreCommands observable
// this.ShowDetails = model.ShowDetails;
// break;
case nameof(Details):
var extensionDetails = model.Details;
Details = extensionDetails != null ? new(extensionDetails, PageContext) : null;
UpdateDetails();
break;
}
UpdateProperty(propertyName);
}
private void UpdateDetails()
{
UpdateProperty(nameof(Details));
UpdateProperty(nameof(HasDetails));
Task.Factory.StartNew(
() =>
{
if (HasDetails)
{
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(Details));
}
else
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
},
CancellationToken.None,
TaskCreationOptions.None,
PageContext.Scheduler);
}
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Models;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ContentTreeViewModel(ITreeContent _tree, IPageContext context) :
ContentViewModel(context)
{
public ExtensionObject<ITreeContent> Model { get; } = new(_tree);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public ContentViewModel? RootContent { get; protected set; }
public ObservableCollection<ContentViewModel> Children { get; } = [];
public bool HasChildren => Children.Count > 0;
public ObservableCollection<ContentViewModel> StupidGames => [RootContent];
public override void InitializeProperties()
{
var model = Model.Unsafe;
if (model == null)
{
return;
}
var root = model.RootContent;
if (root != null)
{
RootContent = ContentPageViewModel.ViewModelFromContent(root, PageContext);
RootContent?.InitializeProperties();
UpdateProperty(nameof(RootContent));
UpdateProperty(nameof(StupidGames));
}
FetchContent();
model.PropChanged += Model_PropChanged;
model.ItemsChanged += Model_ItemsChanged;
}
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, ItemsChangedEventArgs args) => FetchContent();
private void Model_PropChanged(object sender, PropChangedEventArgs args)
{
try
{
var propName = args.PropertyName;
FetchProperty(propName);
}
catch (Exception ex)
{
PageContext.ShowException(ex);
}
}
protected void FetchProperty(string propertyName)
{
var model = Model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(RootContent):
var root = model.RootContent;
if (root != null)
{
RootContent = ContentPageViewModel.ViewModelFromContent(root, PageContext);
}
else
{
root = null;
}
UpdateProperty(nameof(StupidGames));
break;
}
UpdateProperty(propertyName);
}
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchContent()
{
List<ContentViewModel> newContent = [];
try
{
var newItems = Model.Unsafe!.GetChildren();
foreach (var item in newItems)
{
var viewModel = ContentPageViewModel.ViewModelFromContent(item, PageContext);
if (viewModel != null)
{
viewModel.InitializeProperties();
newContent.Add(viewModel);
}
}
}
catch (Exception ex)
{
PageContext.ShowException(ex);
throw;
}
// Now, back to a UI thread to update the observable collection
Task.Factory.StartNew(
() =>
{
ListHelpers.InPlaceUpdateList(Children, newContent);
},
CancellationToken.None,
TaskCreationOptions.None,
PageContext.Scheduler);
UpdateProperty(nameof(HasChildren));
}
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
public abstract partial class ContentViewModel(IPageContext context) :
ExtensionObjectViewModel(context)
{
}

View File

@@ -12,7 +12,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListItemViewModel(IListItem model, IPageContext context)
: CommandItemViewModel(new(model), context)
{
private readonly ExtensionObject<IListItem> _listItemModel = new(model);
public ExtensionObject<IListItem> Model { get; } = new(model);
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
@@ -33,7 +33,7 @@ public partial class ListItemViewModel(IListItem model, IPageContext context)
{
base.InitializeProperties();
var li = _listItemModel.Unsafe;
var li = Model.Unsafe;
if (li == null)
{
return; // throw?
@@ -67,7 +67,7 @@ public partial class ListItemViewModel(IListItem model, IPageContext context)
{
base.FetchProperty(propertyName);
var model = this._listItemModel.Unsafe;
var model = this.Model.Unsafe;
if (model == null)
{
return; // throw?
@@ -108,7 +108,7 @@ public partial class ListItemViewModel(IListItem model, IPageContext context)
public override string ToString() => $"{Name} ListItemViewModel";
public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm._listItemModel.Equals(this._listItemModel);
public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model);
public override int GetHashCode() => _listItemModel.GetHashCode();
public override int GetHashCode() => Model.GetHashCode();
}

View File

@@ -37,7 +37,7 @@ public partial class ListViewModel : PageViewModel
public string ModelPlaceholderText { get => string.IsNullOrEmpty(field) ? "Type here to search..." : field; private set; } = string.Empty;
public override string PlaceholderText { get => ModelPlaceholderText; }
public override string PlaceholderText => ModelPlaceholderText;
public string SearchText { get; private set; } = string.Empty;
@@ -49,6 +49,7 @@ public partial class ListViewModel : PageViewModel
_model = new(model);
}
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, ItemsChangedEventArgs args) => FetchItems();
protected override void OnFilterUpdated(string filter)
@@ -194,14 +195,14 @@ public partial class ListViewModel : PageViewModel
// InvokeItemCommand is what this will be in Xaml due to source generator
[RelayCommand]
private void InvokeItem(ListItemViewModel item) =>
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command));
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command, item.Model));
[RelayCommand]
private void InvokeSecondaryCommand(ListItemViewModel item)
{
if (item.SecondaryCommand != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.SecondaryCommand.Command));
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.SecondaryCommand.Command, item.Model));
}
}

View File

@@ -4,6 +4,7 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
@@ -38,7 +39,7 @@ public partial class MarkdownPageViewModel : PageViewModel
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchContent()
{
List<string> newBodies = new();
List<string> newBodies = [];
try
{
var newItems = _model.Unsafe!.Bodies();

View File

@@ -10,6 +10,27 @@ namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Used to do a command - navigate to a page or invoke it
/// </summary>
public record PerformCommandMessage(ExtensionObject<ICommand> Command)
public record PerformCommandMessage
{
public ExtensionObject<ICommand> Command { get; }
public object? Context { get; }
public PerformCommandMessage(ExtensionObject<ICommand> command)
{
Command = command;
Context = null;
}
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<IListItem> context)
{
Command = command;
Context = context.Unsafe;
}
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<ICommandContextItem> context)
{
Command = command;
Context = context.Unsafe;
}
}

View File

@@ -33,7 +33,6 @@ public partial class TagViewModel(ITag _tag, IPageContext context) : ExtensionOb
return;
}
Command = new(model.Command);
Text = model.Text;
Foreground = model.Foreground;
Background = model.Background;

View File

@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.CmdPal.UI;
public partial class ContentTemplateSelector : DataTemplateSelector
{
// Define the (currently empty) data templates to return
// These will be "filled-in" in the XAML code.
public DataTemplate? FormTemplate { get; set; }
public DataTemplate? MarkdownTemplate { get; set; }
public DataTemplate? TreeTemplate { get; set; }
protected override DataTemplate? SelectTemplateCore(object item)
{
if (item is ContentViewModel element)
{
var data = element;
return data switch
{
ContentFormViewModel => FormTemplate,
ContentMarkdownViewModel => MarkdownTemplate,
ContentTreeViewModel => TreeTemplate,
_ => null,
};
}
return null;
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.ContentFormControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
mc:Ignorable="d">
<UserControl.Resources>
<ResourceDictionary>
<converters:StringVisibilityConverter
x:Key="StringNotEmptyToVisibilityConverter"
EmptyValue="Collapsed"
NotEmptyValue="Visible" />
</ResourceDictionary>
</UserControl.Resources>
<Grid x:Name="ContentGrid" />
</UserControl>

View File

@@ -0,0 +1,88 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using AdaptiveCards.ObjectModel.WinUI3;
using AdaptiveCards.Rendering.WinUI3;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class ContentFormControl : UserControl
{
private static readonly AdaptiveCardRenderer _renderer;
private ContentFormViewModel? _viewModel;
// LOAD-BEARING: if you don't hang onto a reference to the RenderedAdaptiveCard
// then the GC might clean it up sometime, even while the card is in the UI
// tree. If this gets GC'd, then it'll revoke our Action handler, and the
// form will do seemingly nothing.
private RenderedAdaptiveCard? _renderedCard;
public ContentFormViewModel? ViewModel { get => _viewModel; set => AttachViewModel(value); }
static ContentFormControl()
{
_renderer = new AdaptiveCardRenderer();
}
public ContentFormControl()
{
this.InitializeComponent();
var lightTheme = ActualTheme == Microsoft.UI.Xaml.ElementTheme.Light;
_renderer.HostConfig = lightTheme ? AdaptiveCardsConfig.Light : AdaptiveCardsConfig.Dark;
// TODO in the future, we should handle ActualThemeChanged and replace
// our rendered card with one for that theme. But today is not that day
}
private void AttachViewModel(ContentFormViewModel? vm)
{
if (_viewModel != null)
{
_viewModel.PropertyChanged -= ViewModel_PropertyChanged;
}
_viewModel = vm;
if (_viewModel != null)
{
_viewModel.PropertyChanged += ViewModel_PropertyChanged;
var c = _viewModel.Card;
if (c != null)
{
DisplayCard(c);
}
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (ViewModel == null)
{
return;
}
if (e.PropertyName == nameof(ViewModel.Card))
{
var c = ViewModel.Card;
if (c != null)
{
DisplayCard(c);
}
}
}
private void DisplayCard(AdaptiveCardParseResult result)
{
_renderedCard = _renderer.RenderAdaptiveCard(result.AdaptiveCard);
ContentGrid.Children.Clear();
ContentGrid.Children.Add(_renderedCard.FrameworkElement);
_renderedCard.Action += Rendered_Action;
}
private void Rendered_Action(RenderedAdaptiveCard sender, AdaptiveActionEventArgs args) =>
ViewModel?.HandleSubmit(args.Action, args.Inputs.AsJson());
}

View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.ContentPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Interactions="using:Microsoft.Xaml.Interactions.Core"
xmlns:Interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:cmdPalControls="using:Microsoft.CmdPal.UI.Controls"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:toolkit="using:CommunityToolkit.WinUI.UI.Controls"
xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
mc:Ignorable="d">
<Page.Resources>
<ResourceDictionary>
<StackLayout x:Name="VerticalStackLayout" Orientation="Vertical" Spacing="8"/>
<cmdpalUI:ContentTemplateSelector
x:Key="ContentTemplateSelector"
FormTemplate="{StaticResource FormContentTemplate}"
MarkdownTemplate="{StaticResource MarkdownContentTemplate}"
TreeTemplate="{StaticResource TreeContentTemplate}"
/>
<cmdpalUI:ContentTemplateSelector
x:Key="NestedContentTemplateSelector"
FormTemplate="{StaticResource NestedFormContentTemplate}"
MarkdownTemplate="{StaticResource NestedMarkdownContentTemplate}"
TreeTemplate="{StaticResource TreeContentTemplate}"
/>
<DataTemplate x:Key="FormContentTemplate" x:DataType="viewmodels:ContentFormViewModel">
<Grid
Margin="0,4,4,4"
Padding="12,8,8,8"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1,1,1,2"
CornerRadius="{StaticResource OverlayCornerRadius}">
<cmdPalControls:ContentFormControl ViewModel="{x:Bind}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="MarkdownContentTemplate" x:DataType="viewmodels:ContentMarkdownViewModel">
<Grid
Margin="0,4,4,4"
Padding="12,8,8,8"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1,1,1,2"
CornerRadius="{StaticResource OverlayCornerRadius}">
<toolkit:MarkdownTextBlock
Background="Transparent"
Header3FontSize="12"
Header3FontWeight="Normal"
Header3Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"
Text="{x:Bind Body, Mode=OneWay}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="NestedFormContentTemplate" x:DataType="viewmodels:ContentFormViewModel">
<Grid>
<cmdPalControls:ContentFormControl ViewModel="{x:Bind}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="NestedMarkdownContentTemplate" x:DataType="viewmodels:ContentMarkdownViewModel">
<Grid >
<toolkit:MarkdownTextBlock
Background="Transparent"
Header3FontSize="12"
Header3FontWeight="Normal"
Header3Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"
Text="{x:Bind Body, Mode=OneWay}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="TreeContentTemplate" x:DataType="viewmodels:ContentTreeViewModel">
<StackPanel
Orientation="Vertical"
Margin="0,4,4,4"
Padding="12,8,8,8"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1,1,1,2"
CornerRadius="{StaticResource OverlayCornerRadius}">
<ItemsRepeater
Margin="8"
VerticalAlignment="Stretch"
Layout="{StaticResource VerticalStackLayout}"
ItemsSource="{x:Bind StupidGames, Mode=OneWay}">
<ItemsRepeater.ItemTemplate>
<cmdpalUI:ContentTemplateSelector
FormTemplate="{StaticResource NestedFormContentTemplate}"
MarkdownTemplate="{StaticResource NestedMarkdownContentTemplate}"
TreeTemplate="{StaticResource TreeContentTemplate}"
/>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
<ItemsRepeater
Margin="8"
VerticalAlignment="Stretch"
Visibility="{x:Bind HasChildren, Mode=OneWay}"
Layout="{StaticResource VerticalStackLayout}"
ItemsSource="{x:Bind Children, Mode=OneWay}">
<ItemsRepeater.ItemTemplate>
<cmdpalUI:ContentTemplateSelector
FormTemplate="{StaticResource FormContentTemplate}"
MarkdownTemplate="{StaticResource MarkdownContentTemplate}"
TreeTemplate="{StaticResource TreeContentTemplate}"
/>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</StackPanel>
</DataTemplate>
</ResourceDictionary>
</Page.Resources>
<ScrollView VerticalAlignment="Top" VerticalScrollMode="Enabled">
<ItemsRepeater
Margin="8"
VerticalAlignment="Stretch"
Layout="{StaticResource VerticalStackLayout}"
ItemTemplate="{StaticResource ContentTemplateSelector}"
ItemsSource="{x:Bind ViewModel.Content, Mode=OneWay}">
</ItemsRepeater>
</ScrollView>
</Page>

View File

@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class ContentPage : Page
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
public ContentPageViewModel? ViewModel
{
get => (ContentPageViewModel?)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
// Using a DependencyProperty as the backing store for ViewModel. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(ContentPageViewModel), typeof(FormsPage), new PropertyMetadata(null));
public ContentPage()
{
this.InitializeComponent();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (e.Parameter is ContentPageViewModel vm)
{
ViewModel = vm;
}
base.OnNavigatedTo(e);
}
protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) => base.OnNavigatingFrom(e);
}

View File

@@ -2,10 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Common;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

View File

@@ -132,6 +132,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
},
IFormPage formsPage => new FormsPageViewModel(formsPage, _mainTaskScheduler, host),
IMarkdownPage markdownPage => new MarkdownPageViewModel(markdownPage, _mainTaskScheduler, host),
IContentPage contentPage => new ContentPageViewModel(contentPage, _mainTaskScheduler, host),
_ => throw new NotSupportedException(),
};
@@ -145,6 +146,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IListPage => typeof(ListPage),
IFormPage => typeof(FormsPage),
IMarkdownPage => typeof(MarkdownPage),
IContentPage => typeof(ContentPage),
_ => throw new NotSupportedException(),
},
pageViewModel,
@@ -165,8 +167,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
else if (command is IInvokableCommand invokable)
{
// TODO Handle results
var result = invokable.Invoke();
var result = invokable.Invoke(message.Context);
HandleCommandResult(result);
}
}

View File

@@ -48,10 +48,7 @@ public sealed partial class MainPage : Microsoft.UI.Xaml.Controls.Page
RootFrame.Navigate(typeof(ListPage), rootListVm, new DrillInNavigationTransitionInfo());
}
private void ExtensionService_OnExtensionsChanged(object? sender, EventArgs e)
{
_ = LoadAllCommands();
}
private void ExtensionService_OnExtensionsChanged(object? sender, EventArgs e) => _ = LoadAllCommands();
private void HackyBadClearFilter()
{
@@ -116,7 +113,7 @@ public sealed partial class MainPage : Microsoft.UI.Xaml.Controls.Page
TryAllowForeground(command);
if (command is IInvokableCommand invokable)
{
HandleResult(invokable.Invoke());
HandleResult(invokable.Invoke(null));
return;
}
else if (command is IListPage listPage)
@@ -236,10 +233,7 @@ public sealed partial class MainPage : Microsoft.UI.Xaml.Controls.Page
}
}
private void RequestGoHomeHandler(object sender, object args)
{
DoGoHome();
}
private void RequestGoHomeHandler(object sender, object args) => DoGoHome();
private void DoGoHome()
{
@@ -254,10 +248,7 @@ public sealed partial class MainPage : Microsoft.UI.Xaml.Controls.Page
}
}
private void AppendLog(string message)
{
_log += message + "\n";
}
private void AppendLog(string message) => _log += message + "\n";
private async Task LoadExtensions()
{

View File

@@ -13,6 +13,10 @@ foreach ($item in $json.children) {
# Each line that starts with `runtimeclass` or `interface` should be prefixed with the contract attribute
$code = $code -replace "(?m)^(runtimeclass|interface) ", "[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]`n`$1 "
# if there are two [contract] attributes, remove the second one
$code = $code -replace "(?m)^\[contract\(Microsoft.CmdPal.Extensions.ExtensionsContract, ([0-9]+)\)\]\n\[contract\(Microsoft.CmdPal.Extensions.ExtensionsContract, 1\)\]", "[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, `$1)]"
# all the lines that start with `(whitespace)async (T)` should be translated to `IAsyncOperation<T>`
$code = $code -replace "(?m)^(\s*)async\s+(void)\s+([A-Za-z0-9_]+)\s*\(", "`$1Windows.Foundation.IAsyncAction `$3("
$code = $code -replace "(?m)^(\s*)async\s+([A-Za-z0-9_<>]+)\s+([A-Za-z0-9_]+)\s*\(", "`$1Windows.Foundation.IAsyncOperation<`$2> `$3("
@@ -33,6 +37,10 @@ foreach ($item in $json.children) {
# Each line that starts with `runtimeclass` or `interface` should be prefixed with the contract attribute
$code = $code -replace "(?m)^(runtimeclass|interface) ", "[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]`n`$1 "
# if there are two [contract] attributes, remove the second one
$code = $code -replace "(?m)^\[contract\(Microsoft.CmdPal.Extensions.ExtensionsContract, ([0-9]+)\)\]\n\[contract\(Microsoft.CmdPal.Extensions.ExtensionsContract, 1\)\]", "[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, `$1)]"
# all the lines that start with `(whitespace)async (T)` should be translated to `IAsyncOperation<T>`
$code = $code -replace "(?m)^(\s*)async\s+(void)\s+([A-Za-z0-9_]+)\s*\(", "`$1Windows.Foundation.IAsyncAction `$3("
$code = $code -replace "(?m)^(\s*)async\s+([A-Za-z0-9_<>]+)\s+([A-Za-z0-9_]+)\s*\(", "`$1Windows.Foundation.IAsyncOperation<`$2> `$3("
@@ -72,7 +80,7 @@ namespace Microsoft.CmdPal.Extensions
String Icon { get; };
Windows.Storage.Streams.IRandomAccessStreamReference Data { get; };
};
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
runtimeclass IconInfo {
IconInfo(String iconString);

View File

@@ -1,7 +1,7 @@
---
author: Mike Griese
created on: 2024-07-19
last updated: 2025-01-08
last updated: 2025-02-03
issue id: n/a
---
@@ -57,9 +57,12 @@ functionality.
- [Filtering the list](#filtering-the-list)
- [Markdown Pages](#markdown-pages)
- [Form Pages](#form-pages)
- [Content Pages](#content-pages)
- [Markdown Content](#markdown-content)
- [Form Content](#form-content)
- [Other types](#other-types)
- [`ContextItem`s](#contextitems)
- [Icons - `IconInfo` and `IconData`](#icons---iconinfo-and-icondatatype)
- [Icons - `IconInfo` and `IconData`](#icons---iconinfo-and-icondata)
- [`OptionalColor`](#optionalcolor)
- [`Details`](#details)
- [`INotifyPropChanged`](#inotifypropchanged)
@@ -80,6 +83,8 @@ functionality.
- [URI activation](#uri-activation)
- [Custom "empty list" messages](#custom-empty-list-messages)
- [Footnotes](#footnotes)
- [Generating the `.idl`](#generating-the-idl)
- [Adding APIs](#adding-apis)
## Background
@@ -256,7 +261,7 @@ commands won't change over time. These extensions can be cached to save
resources.
Command providers can opt out of this behavior by setting `Frozen=false` in
their extension. We'll call these extensions "**fresh, never frozen**".
their extension. We'll call these extensions "**fresh, never frozen**".
As some examples:
* The "Hacker News" extension, only has a single top-level action. Once we load
@@ -265,7 +270,7 @@ As some examples:
* Similarly for something like the GitHub extension - it's got multiple
top-level commands (My issues, Issue search, Repo search, etc), but these
top-level commands never change. This is a **frozen** extension.
* The "Quick Links" extension has a dynamic list of top-level actions.
* The "Quick Links" extension has a dynamic list of top-level actions.
This is a **fresh** extension.[^3]
* The "Media Controls" extension only has a single top-level action, but it
needs to be running to be able to update it's title and icon. So we can't just
@@ -311,18 +316,18 @@ The structure of the data DevPal caches will look something like the following:
```
In this data you can see:
* We cache some basic info about each extension we've seen. This includes
* the Package Family Name (a unique identifier per-app package),
* the COM CLSID for that extension,
* We cache some basic info about each extension we've seen. This includes
* the Package Family Name (a unique identifier per-app package),
* the COM CLSID for that extension,
* the display name for that extension,
* and if that extension is frozen or not.
* and if that extension is frozen or not.
* We also cache the list of top-level commands for that extension. We'll store
the basic amount of info we need to recreate that command in the top-level
list.
On a cold launch, DevPal will do the following:
1. SLOW: First we start up WASDK and XAML. Unavoidable cost.
1. SLOW: First we start up WASDK and XAML. Unavoidable cost.
2. FAST: We load builtin extensions. These are just extensions in DLLs, so
there's nothing to it.
3. FAST: We load our cache of extensions from disk, and note which are frozen vs fresh
@@ -342,10 +347,10 @@ On a cold launch, DevPal will do the following:
5. SLOW: We open the package catalog for more commands
* Extensions that we've seen before in our cache:
* If it's fresh, we'll start it, and fill in commands from `TopLevelCommands` into the palette
* If it's frozen, we'll leave it be. We've already got stubs for it.
* Extensions we've never seen before:
* If it's frozen, we'll leave it be. We've already got stubs for it.
* Extensions we've never seen before:
* Start it up.
* Check if it's fresh or frozen.
* Check if it's fresh or frozen.
* Call `TopLevelCommands`, and put all of them in the list
* Create a extension cache entry for that app.
* If the provider is frozen: we can actually release the
@@ -355,9 +360,9 @@ On a cold launch, DevPal will do the following:
6. We start a package catalog change watcher to be notified by the OS for
changes to the list of installed extensions
After 1, we can display the UI. It won't have any commands though, so maybe we should wait.
After 1, we can display the UI. It won't have any commands though, so maybe we should wait.
After 2, we'd have some commands, but nothing from extensions
After 4, the palette is ready to be used, with all the frozen extension commands. This is probably good enough for most use cases.
After 4, the palette is ready to be used, with all the frozen extension commands. This is probably good enough for most use cases.
Most of the time, when the user "launches" devPal, we won't run through this
whole process. The slowest part of startup is standing up WASDK and WinUI. After
@@ -383,7 +388,7 @@ command), we need to quickly load that app and get the command for it.
3. Check if the extension is already in the warm extension cache. If it is, we
recently reheated a command from this provider. We can skip step 4 and go
straight to step 5
4. Use the CLSID from the cache to `CoCreateInstance` this extension, and get its `ICommandProvider`.
4. Use the CLSID from the cache to `CoCreateInstance` this extension, and get its `ICommandProvider`.
* If that fails: display an error message.
5. Try to load the command from the provider. This is done in two steps:
1. If the cached command had an `id`, try to look up the command with
@@ -393,7 +398,7 @@ command), we need to quickly load that app and get the command for it.
null): all `TopLevelItems` on that `CommandProvider`.
* Search through all the returned commands with the same `id` or
`icon/title/subtitle/name`, and return that one.
6. If we found the command from the provider, navigate to it or invoke it.
6. If we found the command from the provider, navigate to it or invoke it.
##### Microwaved commands
@@ -409,7 +414,7 @@ keep warm at a given time. We'll probably also want to offer an option like
the memory usage as much.
> [WARNING!]
>
>
> If your command provider returns a `IFallbackCommandItem`s from
> `FallbackCommands`, and that provider is marked `frozen`, DevPal will always
> treat your provider as "fresh". Otherwise, devpal wouldn't be able to call
@@ -428,7 +433,7 @@ that limit of recent commands, we'll release our reference to the COM object for
that extension, and re-mark commands from it as "stubs". Upon the release of
that reference, the extension is free to clean itself up. For extensions that
use the helpers library, they can override `CommandProvider.Dispose` to do
cleanup in there.
cleanup in there.
## Installing extensions
@@ -490,7 +495,7 @@ anything that a 1p built-in can do.
The SDK for DevPal is split into two namespaces:
* `Microsoft.Windows.Run` - This namespace contains the interfaces that
developers will implement to create extensions for DevPal.
* `Microsoft.Windows.Run.Extensions` - This namespace contains helper classes
* `Microsoft.CmdPal.Extensions.Helpers` - This namespace contains helper classes
that developers can use to make creating extensions easier.
The first is highly abstract, and gives developers total control over the
@@ -540,9 +545,11 @@ enum CommandResultKind {
Dismiss, // Reset the palette to the main page and dismiss
GoHome, // Go back to the main page, but keep it open
GoBack, // Go back one level
Hide, // Keep this page open, but hide the palette.
Hide, // Keep this page open, but hide the palette.
KeepOpen, // Do nothing.
GoToPage, // Go to another page. GoToPageArgs will tell you where.
ShowToast, // Display a transient message to the user
Confirm, // Display a confirmation dialog
};
enum NavigationMode {
@@ -561,13 +568,24 @@ interface IGoToPageArgs requires ICommandResultArgs{
String PageId { get; };
NavigationMode NavigationMode { get; };
}
interface IToastArgs requires ICommandResultArgs{
String Message { get; };
ICommandResult Result { get; };
}
interface IConfirmationArgs requires ICommandResultArgs{
String Title { get; };
String Description { get; };
ICommand PrimaryCommand { get; };
Boolean IsPrimaryCommandCritical { get; };
}
// This is a "leaf" of the UI. This is something that can be "done" by the user.
// * A ListPage
// * the MoreCommands flyout of for a ListItem or a MarkdownPage
interface IInvokableCommand requires ICommand {
ICommandResult Invoke();
ICommandResult Invoke(Object sender);
}
```
If a developer wants to add a simple action to DevPal, they can create a
@@ -577,7 +595,7 @@ method will be called when the user selects the action in DevPal.
As a simple example[^1]:
```cs
class HackerNewsAction : Microsoft.Windows.Run.Extensions.InvokableCommand {
class HackerNewsAction : Microsoft.CmdPal.Extensions.Helpers.InvokableCommand {
public class HackerNewsAction()
{
Name = "Hacker News";
@@ -603,6 +621,31 @@ The `Id` property is optional. This can be set but the extension author to
support more efficient command lookup in
[`ICommandProvider.GetCommand()`, below](#getcommand).
When `Invoke` is called, the host app will pass in a `sender` object that
represents the context of where that command was invoked from. This can be
different types depending on where the command is being used:
* `TopLevelCommands` (and fallbacks)
* Sender is the `ICommandItem` for the top-level command that was invoked
* `IListPage.GetItems`
* Sender is the `IListItem` for the list item selected for that command
* `ICommandItem.MoreCommands` (context menus)
* Sender is the `IListItem` which the command was attached to for a list page, or
* the `ICommandItem` of the top-level command (if this is a context item on a top level command)
* `IContentPage.Commands`
* Sender is the `IContentPage` itself
The helpers library also exposes a `Invoke()` method on `InvokableCommand` which
takes no parameters, as a convenience for developers who don't need the `sender`
object.
Using the `sender` parameter can be useful for big lists of items where the
actionable information for each item is practically the same. Consider a big
list of links. An extension developer can implement this as a single
`IInvokableCommand` that opens a URL based on the `sender` object passed in.
Then each list item would store the URL to open and the title of the link. This
creates less overhead for the extension and host to communicate.
#### Results
Commands can return a `CommandResult` to indicate what DevPal should do after
@@ -638,9 +681,29 @@ Use cases for each `CommandResultKind`:
stay in its current state.
* `GoToPage` - Navigate to a different page in DevPal. The `GoToPageArgs`
will specify which page to navigate to.
* [TODO!]: Do we actually need this, now that all the commands can be pages?
* Does this satisfy "I want to pop the stack, but then push something else
onto the stack"? Versus the default which is just "add this to the stack"?
* `Push`: The new page gets added to the current navigation stack. Going back
from the requested page will take you to the current page.
* `GoBack`: Go back one level, then navigate to the page. Going back from the
requested page will take you to the page before the current page.
* `GoHome`: Clear the back stack, then navigate to the page. Going back from
the requested page will take you to the home page (the L0).
* `ShowToast` - Display a transient desktop-level message to the user. This is
especially useful for displaying confirmation that an action took place, when
the palette will be closed. Consider the `CopyTextCommand` in the helpers -
this command will show a toast with the text "Copied to clipboard", then
dismiss the palette.
* Once the message is displayed, the palette will then react to the `Result`.
In the helpers library, the `ToastArgs`'s default `Result` value is
`Dismiss`.
* Only one toast can be displayed at a time. If a new toast is requested
before the previous one is dismissed, the new toast will replace the old
one. This includes if the `Result` of one `IToastArgs` is another
`IToastArgs`.
* `Confirm`: Display a confirmation dialog to the user. This is useful for
actions that are destructive or irreversible. The `ConfirmationArgs` will
specify the title, and description for the dialog. The primary button of the
dialog will activate the `Command`. If `IsPrimaryCommandCritical` is `true`,
the primary button will be red, indicating that it is a destructive action.
### Pages
@@ -654,7 +717,7 @@ information that the host application will then use to render the page.
interface IPage requires ICommand {
String Title { get; };
Boolean IsLoading { get; };
OptionalColor AccentColor { get; };
}
```
@@ -663,9 +726,8 @@ When a user selects an action that implements `IPage`, DevPal will navigate
to that page, pushing it onto the UI stack.
Pages can be one of several types, each detailed below:
* [List](#List)
* [Markdown](#Markdown)
* [Form](#Form)
* [List](#List-Pages)
* [Content](#Content-Pages)
If a page returns a null or empty `Title`, DevPal will display the `Name` of the
`ICommand` instead.
@@ -706,7 +768,7 @@ Lists can be either "static" or "dynamic":
results - it's the extension's responsibility to filter them.
* Ex: The GitHub extension may want to allow the user to type `is:issue
is:open`, then return a list of open issues, without string matching on
the text.
the text.
```csharp
@@ -719,7 +781,7 @@ interface ICommandItem requires INotifyPropChanged {
IconInfo Icon{ get; };
String Title{ get; };
String Subtitle{ get; };
}
}
interface ICommandContextItem requires ICommandItem, IContextItem {
Boolean IsCritical { get; }; // READ: "make this red"
@@ -741,7 +803,7 @@ interface IGridProperties {
}
interface IListPage requires IPage, INotifyItemsChanged {
// DevPal will be responsible for filtering the list of items, unless the
// DevPal will be responsible for filtering the list of items, unless the
// class implements IDynamicListPage
String SearchText { get; };
String PlaceholderText { get; };
@@ -751,7 +813,7 @@ interface IListPage requires IPage, INotifyItemsChanged {
Boolean HasMoreItems { get; };
ICommandItem EmptyContent { get; };
IListItem[] GetItems();
IListItem[] GetItems();
void LoadMore();
}
@@ -767,7 +829,7 @@ Lists are comprised of a collection of `IListItems`.
![Another mockup of the elements of a list item](./list-elements-mock-002.png)
> NOTE: The above diagram is from before Nov 2024. It doesn't properly include the relationship between `ICommandItems` and list items.
> NOTE: The above diagram is from before Nov 2024. It doesn't properly include the relationship between `ICommandItems` and list items.
> A more up-to-date explainer of the elements of the UI can be found in
> ["Rendering of ICommandItems in Lists and Menus"](#rendering-of-icommanditems-in-lists-and-menus)
@@ -787,13 +849,13 @@ the commands for the currently selected item.
The elements of a ListPage (`IListItem`s) and the context menu
(`ICommandContextItem`) both share the same base type. Basically, they're both a
list of things which have:
* A `ICommand` to invoke or navigate to.
* a `Title` which might replace their `Command`'s `Name`,
* an `Icon` which might replace their `Command`'s `Icon`,
* A `ICommand` to invoke or navigate to.
* a `Title` which might replace their `Command`'s `Name`,
* an `Icon` which might replace their `Command`'s `Icon`,
* A `Subtitle`, which is visible on the list, and a _tooltip_ for a context menu
* They might also have `MoreCommands`:
* For a `IListItem`, this is the context menu.
* For a ContextItem in the context menu, this creates a sub-context menu.
* For a `IListItem`, this is the context menu.
* For a ContextItem in the context menu, this creates a sub-context menu.
For more details on the structure of the `MoreCommands` property, see the
[`ContextItem`s](#contextitems) section below.
@@ -917,21 +979,21 @@ class NewsPost {
string Poster;
int Points;
}
class LinkAction(NewsPost post) : Microsoft.Windows.Run.Extensions.InvokableCommand {
class LinkAction(NewsPost post) : Microsoft.CmdPal.Extensions.Helpers.InvokableCommand {
public string Name => "Open link";
public ActionResult Invoke() {
public CommandResult Invoke() {
Process.Start(new ProcessStartInfo(post.Url) { UseShellExecute = true });
return ActionResult.KeepOpen;
return CommandResult.KeepOpen;
}
}
class CommentAction(NewsPost post) : Microsoft.Windows.Run.Extensions.InvokableCommand {
class CommentAction(NewsPost post) : Microsoft.CmdPal.Extensions.Helpers.InvokableCommand {
public string Name => "Open comments";
public ActionResult Invoke() {
public CommandResult Invoke() {
Process.Start(new ProcessStartInfo(post.CommentsUrl) { UseShellExecute = true });
return ActionResult.KeepOpen;
return CommandResult.KeepOpen;
}
}
class NewsListItem(NewsPost post) : Microsoft.Windows.Run.Extensions.ListItem {
class NewsListItem(NewsPost post) : Microsoft.CmdPal.Extensions.Helpers.ListItem {
public string Title => post.Title;
public string Subtitle => post.Poster;
public IContextItem[] Commands => [
@@ -940,7 +1002,7 @@ class NewsListItem(NewsPost post) : Microsoft.Windows.Run.Extensions.ListItem {
];
public ITag[] Tags => [ new Tag(){ Text=post.Points } ];
}
class HackerNewsPage: Microsoft.Windows.Run.Extensions.ListPage {
class HackerNewsPage: Microsoft.CmdPal.Extensions.Helpers.ListPage {
public bool Loading => true;
IListItem[] GetItems() {
List<NewsItem> items = /* do some RSS feed stuff */;
@@ -987,16 +1049,16 @@ Here's a breakdown of how a dynamic list responds to the CmdPal. In this
example, we'll use a hypothetical GitHub issue search extension, which allows
the user to type a query and get a list of issues back.
1. CmdPal loads the `ListPage` from the extension.
1. CmdPal loads the `ListPage` from the extension.
2. It is a `IDynamicListPage`, so the command palette knows not to do any
host-side filtering.
3. CmdPal reads the `SearchText` from the ListPage
3. CmdPal reads the `SearchText` from the ListPage
- it returns `is:issue is:open` as initial text
4. CmdPal reads the `HasMoreItems` from the ListPage
4. CmdPal reads the `HasMoreItems` from the ListPage
- it returns `true`
5. CmdPal calls `GetItems()`
- the extension returns the first 25 items that match the query.
6. User scrolls the page to the bottom
5. CmdPal calls `GetItems()`
- the extension returns the first 25 items that match the query.
6. User scrolls the page to the bottom
- CmdPal calls `GetMore` on the ListPage, to let it know it should start
fetching more results
7. The extension raises a `ItemsChanged(40)`, to indicate that it now has 40 items
@@ -1089,7 +1151,7 @@ applied to top-level `IListItem`s:
* We won't display any context menu commands for these entries
* We won't display the Details for these entries (nor the context action to show
details)
* Icons which are a `IRandomAccessStream` will not work as expected.
* Icons which are a `IRandomAccessStream` will not work as expected.
* If you create a top-level `IListItem` that implements `IFallbackHandler`,
DevPal will treat your `ICommandProvider` as fresh, never frozen, regardless
of the value of `Frozen` you set.
@@ -1100,12 +1162,6 @@ on nested pages will all work exactly as expected. -->
#### Markdown Pages
This is a page that displays a block of markdown text. This is useful for
showing a lot of information in a small space. Markdown provides a rich set of
simple formatting options.
![](./markdown-mock.png)
```csharp
interface IMarkdownPage requires IPage {
String[] Bodies(); // TODO! should this be an IBody, so we can make it observable?
@@ -1114,75 +1170,8 @@ interface IMarkdownPage requires IPage {
}
```
A markdown page may also have a `Details` property, which will be displayed in
the same way as the details for a list item. This is useful for showing
additional information about the page, like a description, a preview of a file,
or a link to more information.
Similar to the `List` page, the `Commands` property is a list of commands that the
user can take on the page. These are the commands that will be shown in the "More
actions" flyout. Unlike the `List` page, the `Commands` property is not
associated with any specific item on the page, rather, these commands are global
to the page itself.
An example markdown page for an issue on GitHub:
```cs
class GitHubIssue {
string Title;
string Url;
string Body;
string Author;
string[] Tags;
string[] AssignedTo;
}
class OpenLinkAction(GitHubIssue issue) : Microsoft.Windows.Run.Extensions.InvokableCommand {
public string Name => "Open";
public ActionResult Invoke() {
Process.Start(new ProcessStartInfo(issue.Url) { UseShellExecute = true });
return ActionResult.KeepOpen;
}
}
class GithubIssuePage(GithubIssue issue): Microsoft.Windows.Run.Extensions.MarkdownPage {
public bool Loading => true;
public string Body() {
issue.Body = /* fetch the body from the API */;
this.IsLoading = false;
return issue.Body;
}
public IContextItem[] Commands => [ new CommandContextItem(new OpenLinkAction(issue)) ];
public IDetails Details() {
return new Details(){
Title = "",
Body = "",
Metadata = [
new Microsoft.Windows.Run.Extensions.DetailsTags(){
Key = "Author",
Tags = new(){ new Tag(){ Text = issue.Author } }
},
new Microsoft.Windows.Run.Extensions.DetailsTags(){
Key = "Assigned To",
Tags = issue.AssignedTo.Select((user) => new Tag(){ Text = user }).ToArray()
},
new Microsoft.Windows.Run.Extensions.DetailsTags(){
Key = "Tags",
Tags = issue.Tags.Select((tag) => new Tag(){ Text = tag }).ToArray()
}
]
};
}
}
```
#### Form Pages
A form page allows the user to input data to the extension. This is useful for
actions that might require additional information from the user. For example:
imagine a "Send Teams message" action. This action might require the user to
input the message they want to send, and give the user a dropdown to pick the
chat to send the message to.
![](./form-page-prototype.png)
```csharp
@@ -1197,7 +1186,120 @@ interface IFormPage requires IPage {
}
```
Form pages are powered by [Adaptive Cards](https://adaptivecards.io/). This
#### Content Pages
Content pages are used for extensions that want to display richer content than
just a list of commands to the user. These pages are useful for displaying
things like documents and forms. You can mix and match different types of
content on a single page, and even nest content within other content.
```csharp
[uuid("b64def0f-8911-4afa-8f8f-042bd778d088")]
interface IContent requires INotifyPropChanged {
}
interface IFormContent requires IContent {
String TemplateJson { get; };
String DataJson { get; };
String StateJson { get; };
ICommandResult SubmitForm(String payload);
}
interface IMarkdownContent requires IContent {
String Body { get; };
}
interface ITreeContent requires IContent, INotifyItemsChanged {
IContent RootContent { get; };
IContent[] GetChildren();
}
interface IContentPage requires IPage, INotifyItemsChanged {
IContent[] GetContent();
IDetails Details { get; };
IContextItem[] Commands { get; };
}
```
Content pages may also have a `Details` property, which will be displayed in
the same way as the details for a list item. This is useful for showing
additional information about the page, like a description, a preview of a file,
or a link to more information.
Similar to the `List` page, the `Commands` property is a list of commands that the
user can take on the page. These are the commands that will be shown in the "More
actions" flyout. Unlike the `List` page, the `Commands` property is not
associated with any specific item on the page, rather, these commands are global
to the page itself.
##### Markdown Content
This is a block of content that displays text formatted with Markdown. This is
useful for showing a lot of information in a small space. Markdown provides a
rich set of simple formatting options.
![](./markdown-mock.png)
An example markdown page for an issue on GitHub:
```cs
class GitHubIssue {
string Title;
string Url;
string Body;
string Author;
string[] Tags;
string[] AssignedTo;
}
class GithubIssuePage: Microsoft.CmdPal.Extensions.Helpers.ContentPage {
private readonly MarkdownContent issueBody;
public GithubIssuePage(GithubIssue issue)
{
Commands = [ new CommandContextItem(new Microsoft.CmdPal.Extensions.Helpers.OpenUrlCommand(issue.Url)) ];
Details = new Details(){
Title = "",
Body = "",
Metadata = [
new Microsoft.CmdPal.Extensions.Helpers.DetailsTags(){
Key = "Author",
Tags = [new Tag(issue.Author)]
},
new Microsoft.CmdPal.Extensions.Helpers.DetailsTags(){
Key = "Assigned To",
Tags = issue.AssignedTo.Select((user) => new Tag(user)).ToArray()
},
new Microsoft.CmdPal.Extensions.Helpers.DetailsTags(){
Key = "Tags",
Tags = issue.Tags.Select((tag) => new Tag(tag)).ToArray()
}
]
};
issueBody = new MarkdownContent(issue.Body);
}
public override IContent[] GetContent() => [issueBody];
}
```
> [!NOTE]
> A real GitHub extension would likely load the issue body asynchronously. In
> that case, the page could start a background thread to fetch the content, then
> raise the ItemsChanged to signal the host to retrieve the new `IContent`.
##### Form Content
Forms allow the user to input data to the extension. This is useful for
actions that might require additional information from the user. For example:
imagine a "Send Teams message" action. This action might require the user to
input the message they want to send, and give the user a dropdown to pick the
chat to send the message to.
![](./form-page-prototype.png)
Form content is powered by [Adaptive Cards](https://adaptivecards.io/). This
allows extension developers a rich set of controls to use in their forms. Each
page can have as many forms as it needs. These forms will be displayed to the
user as separate "cards", in the order they are returned by the extension.
@@ -1209,7 +1311,6 @@ When the user submits the form, the `SubmitForm` method will be called with the
JSON payload of the form. The extension is responsible for parsing this payload
and acting on it.
[TODO!discussion]: Do we want to stick the `Actions` on this page type too? Or does that not make sense?
### Other types
@@ -1288,7 +1389,7 @@ struct OptionalColor
We also define `OptionalColor` as a helper struct here. Yes, this is also just
an `IReference<Color>`. However, `IReference` has some weird ownership semantics
that just make it a pain for something as simple as "maybe this color doesn't
have a value set".
have a value set".
#### `Details`
@@ -1317,7 +1418,6 @@ interface ITag {
OptionalColor Foreground { get; };
OptionalColor Background { get; };
String ToolTip { get; };
ICommand Command { get; };
};
[uuid("6a6dd345-37a3-4a1e-914d-4f658a4d583d")]
@@ -1339,6 +1439,9 @@ interface IDetailsLink requires IDetailsData {
Windows.Foundation.Uri Link { get; };
String Text { get; };
}
interface IDetailsCommand requires IDetailsData {
ICommand Command { get; };
}
[uuid("58070392-02bb-4e89-9beb-47ceb8c3d741")]
interface IDetailsSeparator requires IDetailsData {}
```
@@ -1378,7 +1481,7 @@ This is the interface that an extension must implement to provide commands to De
```csharp
interface ICommandSettings {
IFormPage SettingsPage { get; };
IContentPage SettingsPage { get; };
};
interface IFallbackHandler {
@@ -1409,7 +1512,7 @@ interface ICommandProvider requires Windows.Foundation.IClosable, INotifyItemsCh
`TopLevelCommands` is the method that DevPal will call to get the list of actions
that should be shown when the user opens DevPal. These are the commands that will
allow the user to interact with the rest of your extension. They can be simple
actions, or they can be pages that the user can navigate to.
actions, or they can be pages that the user can navigate to.
`TopLevelCommands` returns a list of `ICommandItem`s. These are basically just a
simpler form of `IListItem`, which can be displayed even as a stub (as described
@@ -1421,14 +1524,14 @@ something like an extension that might require the user to login before
accessing certain pages within the extension. Command Providers which are
`Frozen=true` can also use this event to change their list of cached commands,
since the only time an extension can raise this event is when it's already
running.
running.
`Id` is only necessary to set if your extension implements multiple providers in
the same package identity. This is an uncommon scenario which most developers
shouldn't need to worry about. If you do set `Id`, it should be a stable string
across package versions. DevPal will use this Id for tracking settings for each
provider within a package. Changing this string will result in the user's
settings for your extension being lost.
settings for your extension being lost.
#### Fallback commands
@@ -1462,7 +1565,7 @@ As an example, here's how a developer might implement a fallback action that
changes its name to be mOcKiNgCaSe.
```cs
public class SpongebotPage : Microsoft.Windows.Run.Extensions.MarkdownPage, IFallbackHandler
public class SpongebotPage : Microsoft.CmdPal.Extensions.Helpers.MarkdownPage, IFallbackHandler
{
// Name, Icon, IPropertyChanged: all those are defined in the MarkdownPage base class
public SpongebotPage()
@@ -1496,7 +1599,7 @@ internal sealed class SpongebotCommandsProvider : CommandProvider
{
public ICommandItem[] TopLevelCommands() => [];
public IFallbackCommandItem[] FallbackCommands()
{
{
var spongebotPage = new SpongebotPage();
var listItem = new FallbackCommandItem(spongebotPage);
// ^ The FallbackCommandItem ctor will automatically set its FallbackHandler to the
@@ -1506,7 +1609,7 @@ internal sealed class SpongebotCommandsProvider : CommandProvider
}
```
`Microsoft.Windows.Run.Extensions.FallbackCommandItem` in the SDK helpers will automatically set
`Microsoft.CmdPal.Extensions.Helpers.FallbackCommandItem` in the SDK helpers will automatically set
the `FallbackHandler` property on the `IFallbackCommandItem` to the `Command` it's
initialized with, if that command implements `IFallbackHandler`. This allows the
action to directly update itself in response to the query. You may also specify
@@ -1521,7 +1624,7 @@ If an extension's own list page wants to implement a similar fallback mechanism
- it's free to use `IDynamicListPage` to listen for changes to the query and
have its own ListItem it updates manually.
> [!IMPORTANT]
> [!IMPORTANT]
> If your extension has top-level `FallbackCommandItem`s, then
> DevPal will treat your `ICommandProvider` as fresh, never frozen, regardless
of the value of `Frozen` you set.
@@ -1536,7 +1639,7 @@ that command.
For command providers that have multiple top-level commands, this can be a
helpful short-circuit. The extension won't need to construct instances of all
the `IListItem`s for all its top-level commands. Instead, the extension can just
instantiate the requested one.
instantiate the requested one.
##### Settings
@@ -1585,7 +1688,7 @@ We'll provide default implementations for the following interfaces:
This will allow developers to quickly create extensions without having to worry
about implementing every part of the interface. You can see that reference
implementation in
`extensionsdk\Microsoft.Windows.Run.Extensions.Lib\DefaultClasses.cs`.
`extensionsdk\Microsoft.CmdPal.Extensions.Helpers.Lib\DefaultClasses.cs`.
In addition to the default implementations we provide for the interfaces above,
we should provide a set of helper classes that make it easier for developers to
@@ -1594,7 +1697,7 @@ write extensions.
For example, we should have something like:
```cs
class OpenUrlAction(string targetUrl, ActionResult result) : Microsoft.Windows.Run.Extensions.InvokableCommand {
class OpenUrlAction(string targetUrl, CommandResult result) : Microsoft.CmdPal.Extensions.Helpers.InvokableCommand {
public OpenUrlAction()
{
Name = "Open";
@@ -1612,7 +1715,7 @@ that no longer do we need to add additional classes for the actions. We just use
the helper:
```cs
class NewsListItem : Microsoft.Windows.Run.Extensions.ListItem {
class NewsListItem : Microsoft.CmdPal.Extensions.Helpers.ListItem {
private NewsPost _post;
public NewsListItem(NewsPost post)
{
@@ -1629,7 +1732,7 @@ class NewsListItem : Microsoft.Windows.Run.Extensions.ListItem {
];
public ITag[] Tags => [ new Tag(){ Text=post.Poster, new Tag(){ Text=post.Points } } ];
}
class HackerNewsPage: Microsoft.Windows.Run.Extensions.ListPage {
class HackerNewsPage: Microsoft.CmdPal.Extensions.Helpers.ListPage {
public HackerNewsPage()
{
Loading = true;
@@ -1678,7 +1781,7 @@ class MyAppSettings {
public Helpers.Settings Settings => _settings;
public MyAppSettings() {
// Define the structure of your settings here.
// Define the structure of your settings here.
var onOffSetting = new Helpers.ToggleSetting("onOff", "Enable feature", "This feature will do something cool", true);
var textSetting = new Helpers.TextSetting("whatever", "Text setting", "This is a text setting", "Default text");
_settings.Add(onOffSetting);
@@ -1695,11 +1798,11 @@ class MyAppSettings {
/* You can save the settings to the file here */
var mySettingsFilePath = /* whatever */;
string mySettingsJson = mySettings.Settings.GetState();
// Or you could raise a event to indicate to the rest of your app that settings have changed.
// Or you could raise a event to indicate to the rest of your app that settings have changed.
}
}
class MySettingsPage : Microsoft.Windows.Run.Extensions.FormPage
class MySettingsPage : Microsoft.CmdPal.Extensions.Helpers.FormPage
{
private readonly MyAppSettings mySettings;
public MySettingsPage(MyAppSettings s) {
@@ -1707,26 +1810,26 @@ class MySettingsPage : Microsoft.Windows.Run.Extensions.FormPage
mySettings.Settings.SettingsChanged += SettingsChanged;
}
public override IForm[] Forms() {
// If you haven't already:
// If you haven't already:
mySettings.Settings.LoadSavedData();
return mySettings.Settings.ToForms();
}
private void SettingsChanged(object sender, Settings args)
{
/* Do something with the new settings here */
var onOff = _settings.GetSetting<bool>("onOff");
ExtensionHost.LogMessage(new LogMessage() { Message = $"MySettingsPage: Changed the value of onOff to {onOff}" });
// Possibly even:
// Possibly even:
mySettings.SaveSettings();
}
}
// elsewhere in your app:
MyAppSettings instance = /* Up to you how you want to pass this around.
Singleton, dependency injection, whatever. */
MyAppSettings instance = /* Up to you how you want to pass this around.
Singleton, dependency injection, whatever. */
var onOff = instance.Settings.Get("onOff");
```
@@ -1738,7 +1841,7 @@ var onOff = instance.Settings.Get("onOff");
<img src="./grid-actions-mock.png" height="300px" /> <img src="./grid-status-loading-mock.png" height="300px" /> <img src="./grid-status-success-mock.png" height="300px" />
Extensions will want to be able to communicate feedback to the user, based on
what's going on inside the extension.
what's going on inside the extension.
Consider a `winget` extension that allows the user to search for and install
packages. When the user starts an install, the extension should be able to show
@@ -1831,20 +1934,20 @@ When displaying a command context menu item:
* The text is `ICommandItem.Title ?? ICommandItem.Command.Name`
* The tooltip is `ICommandItem.Subtitle`
When displaying a `IListItem`'s default `Command` as a context item, we'll make a new
When displaying a `IListItem`'s default `Command` as a context item, we'll make a new
```cs
ICommandContextItem(){
ICommandContextItem(){
Command = ICommandItem.Command,
MoreCommands = null,
Icon = Command.Icon, // use icon from command, not list item
Icon = Command.Icon, // use icon from command, not list item
Title = Command.Name, // Use command's name, not list item
Subtitle = IListItem.Title, // Use the title of the list item as the tooltip on the context menu
IsCritical = false,
}
```
If a `ICommandItem` in a context menu has `MoreCommands`, then activating it will open a submenu with those items.
If a `ICommandItem` in a context menu has `MoreCommands` AND a non-null `Command`, then activating it will open a submenu with the `Command` first (following the same rules above for building a context item from a default `Command`), followed by the items in `MoreCommands`.
If a `ICommandItem` in a context menu has `MoreCommands`, then activating it will open a submenu with those items.
If a `ICommandItem` in a context menu has `MoreCommands` AND a non-null `Command`, then activating it will open a submenu with the `Command` first (following the same rules above for building a context item from a default `Command`), followed by the items in `MoreCommands`.
When displaying a page:
* The title will be `IPage.Title ?? ICommand.Name`
@@ -1875,7 +1978,7 @@ classDiagram
IInvokableCommand --|> ICommand
class IInvokableCommand {
ICommandResult Invoke()
ICommandResult Invoke(object context)
}
class IForm {
@@ -2028,7 +2131,7 @@ I had originally started to spec this out as:
```cs
interface IInvokableCommandWithParameters requires ICommand {
ActionParameters Parameters { get; };
ActionResult InvokeWithArgs(ActionArguments args);
CommandResult InvokeWithArgs(ActionArguments args);
}
```
@@ -2042,7 +2145,7 @@ follow - these are not part of the current SDK spec.
> [!NOTE]
>
> A thought: what if a action returns a `ActionResult.Entity`, then that takes
> A thought: what if a action returns a `CommandResult.Entity`, then that takes
> devpal back home, but leaves the entity in the query box. This would allow for
> a Quicksilver-like "thing, do" flow. That command would prepopulate the
> parameters. So we would then filter top-level commands based on things that can
@@ -2050,7 +2153,7 @@ follow - these are not part of the current SDK spec.
>
> For example: The user uses the "Search for file" list page. They find the file
> they're looking for. That file's ListItem has a context item "With
> {filename}..." that then returns a `ActionResult.Entity` with the file entity.
> {filename}..." that then returns a `CommandResult.Entity` with the file entity.
> The user is taken back to the main page, and a file picker badge (with that
> filename) is at the top of the search box. In that state, the only commands
> now shown are ones that can accept a File entity. This could be things like
@@ -2101,6 +2204,8 @@ Is that just a `Details` object? A markdown body?
## Footnotes
### Generating the `.idl`
The `.idl` for this SDK can be generated directly from this file. To do so, run the following command:
```ps1
@@ -2112,9 +2217,16 @@ The `.idl` for this SDK can be generated directly from this file. To do so, run
Or, to generate straight to the place I'm consuming it from:
```ps1
.\doc\initial-sdk-spec\generate-interface.ps1 > .\extensionsdk\Microsoft.CmdPal.Extensions\Microsoft.Windows.Run.Extensions.idl
.\doc\initial-sdk-spec\generate-interface.ps1 > .\extensionsdk\Microsoft.CmdPal.Extensions\Microsoft.CmdPal.Extensions.Helpers.idl
```
### Adding APIs
Almost all of the SDK defined here is in terms of interfaces. Unfortunately,
this prevents us from being able to use `[contract]` attributes to add to the
interfaces. We'll instead need to rely on the tried-and-true method of adding a
`IFoo2` when we want to add methods to `IFoo`.
[^1]: In this example, as in other places, I've referenced a
`Microsoft.DevPal.Extensions.InvokableCommand` class, as the base for that action.
Our SDK will include partial class implementations for interfaces like
@@ -2141,4 +2253,4 @@ Or, to generate straight to the place I'm consuming it from:
palette re-cache the results of `TopLevelItems`.
[Dev Home Extension]: https://learn.microsoft.com/en-us/windows/dev-home/extensions
[`ISupportIncrementalLoading`]: https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.data.isupportincrementalloading
[`ISupportIncrementalLoading`]: https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.data.isupportincrementalloading

View File

@@ -6,20 +6,15 @@ namespace Microsoft.CmdPal.Extensions.Helpers;
public class CommandResult : ICommandResult
{
// TODO: is Args needed?
private ICommandResultArgs? _args;
private CommandResultKind _kind = CommandResultKind.Dismiss;
public ICommandResultArgs? Args { get; private set; }
// TODO: is Args needed?
public ICommandResultArgs? Args => _args;
public CommandResultKind Kind => _kind;
public CommandResultKind Kind { get; private set; } = CommandResultKind.Dismiss;
public static CommandResult Dismiss()
{
return new CommandResult()
{
_kind = CommandResultKind.Dismiss,
Kind = CommandResultKind.Dismiss,
};
}
@@ -27,8 +22,8 @@ public class CommandResult : ICommandResult
{
return new CommandResult()
{
_kind = CommandResultKind.GoHome,
_args = null,
Kind = CommandResultKind.GoHome,
Args = null,
};
}
@@ -36,8 +31,8 @@ public class CommandResult : ICommandResult
{
return new CommandResult()
{
_kind = CommandResultKind.GoBack,
_args = null,
Kind = CommandResultKind.GoBack,
Args = null,
};
}
@@ -45,8 +40,8 @@ public class CommandResult : ICommandResult
{
return new CommandResult()
{
_kind = CommandResultKind.Hide,
_args = null,
Kind = CommandResultKind.Hide,
Args = null,
};
}
@@ -54,8 +49,44 @@ public class CommandResult : ICommandResult
{
return new CommandResult()
{
_kind = CommandResultKind.KeepOpen,
_args = null,
Kind = CommandResultKind.KeepOpen,
Args = null,
};
}
public static CommandResult GoToPage(GoToPageArgs args)
{
return new CommandResult()
{
Kind = CommandResultKind.GoToPage,
Args = args,
};
}
public static CommandResult ShowToast(ToastArgs args)
{
return new CommandResult()
{
Kind = CommandResultKind.ShowToast,
Args = args,
};
}
public static CommandResult ShowToast(string message)
{
return new CommandResult()
{
Kind = CommandResultKind.ShowToast,
Args = new ToastArgs() { Message = message },
};
}
public static CommandResult Confirm(ConfirmationArgs args)
{
return new CommandResult()
{
Kind = CommandResultKind.Confirm,
Args = args,
};
}
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Extensions.Helpers;
public partial class ConfirmationArgs : IConfirmationArgs
{
public string? Title { get; set; }
public string? Description { get; set; }
public ICommand? PrimaryCommand { get; set; }
public bool IsPrimaryCommandCritical { get; set; }
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Windows.Foundation;
namespace Microsoft.CmdPal.Extensions.Helpers;
public abstract partial class ContentPage : Page, IContentPage
{
public event TypedEventHandler<object, ItemsChangedEventArgs>? ItemsChanged;
public IDetails? Details
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(Details));
}
}
public IContextItem[] Commands { get; set; } = [];
public abstract IContent[] GetContent();
protected void RaiseItemsChanged(int totalItems)
{
try
{
// TODO #181 - This is the same thing that BaseObservable has to deal with.
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems));
}
catch
{
}
}
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Extensions.Helpers;
public partial class DetailsCommand : IDetailsCommand
{
public ICommand? Command { get; set; }
}

View File

@@ -9,4 +9,23 @@ public partial class DetailsLink : IDetailsLink
public Uri? Link { get; set; }
public string Text { get; set; } = string.Empty;
public DetailsLink()
{
}
public DetailsLink(string url)
: this(url, url)
{
}
public DetailsLink(string url, string text)
{
if (Uri.TryCreate(url, default(UriCreationOptions), out var newUri))
{
Link = newUri;
}
Text = text;
}
}

View File

@@ -4,7 +4,7 @@
namespace Microsoft.CmdPal.Extensions.Helpers;
public abstract class Form : IForm
public abstract partial class Form : IForm
{
public virtual string Data { get; set; } = string.Empty;

View File

@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Extensions.Helpers;
public partial class FormContent : BaseObservable, IFormContent
{
public virtual string DataJson
{
get;
set
{
field = value;
OnPropertyChanged(nameof(DataJson));
}
}
= string.Empty;
public virtual string StateJson
{
get;
set
{
field = value;
OnPropertyChanged(nameof(StateJson));
}
}
= string.Empty;
public virtual string TemplateJson
{
get;
set
{
field = value;
OnPropertyChanged(nameof(TemplateJson));
}
}
= string.Empty;
public virtual ICommandResult SubmitForm(string payload) => CommandResult.KeepOpen();
}

View File

@@ -4,7 +4,7 @@
namespace Microsoft.CmdPal.Extensions.Helpers;
public class GoToPageArgs : IGoToPageArgs
public partial class GoToPageArgs : IGoToPageArgs
{
public required string PageId { get; set; }

View File

@@ -4,7 +4,9 @@
namespace Microsoft.CmdPal.Extensions.Helpers;
public class InvokableCommand : Command, IInvokableCommand
public abstract class InvokableCommand : Command, IInvokableCommand
{
public virtual ICommandResult Invoke() => throw new NotImplementedException();
public virtual ICommandResult Invoke() => CommandResult.KeepOpen();
public virtual ICommandResult Invoke(object? sender) => Invoke();
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Extensions.Helpers;
public partial class MarkdownContent : BaseObservable, IMarkdownContent
{
public virtual string Body
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Body));
}
}
= string.Empty;
}

View File

@@ -7,6 +7,7 @@
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
<ProjectPriFileName>Microsoft.CmdPal.Extensions.Helpers.pri</ProjectPriFileName>
<PlatformTarget>AnyCPU</PlatformTarget>

View File

@@ -6,38 +6,36 @@ namespace Microsoft.CmdPal.Extensions.Helpers;
public partial class StatusMessage : BaseObservable, IStatusMessage
{
private MessageState _messageState = MessageState.Info;
private string _message = string.Empty;
private IProgressState? _progressState;
public string Message
{
get => _message;
get;
set
{
_message = value;
field = value;
OnPropertyChanged(nameof(Message));
}
}
= string.Empty;
public MessageState State
{
get => _messageState;
get;
set
{
_messageState = value;
field = value;
OnPropertyChanged(nameof(State));
}
}
= MessageState.Info;
public IProgressState? Progress
{
get => _progressState;
get;
set
{
_progressState = value;
field = value;
OnPropertyChanged(nameof(Progress));
}
}

View File

@@ -8,10 +8,7 @@ public class Tag : BaseObservable, ITag
{
private OptionalColor _foreground;
private OptionalColor _background;
private IconInfo _icon = new(string.Empty);
private string _text = string.Empty;
private string _toolTip = string.Empty;
private ICommand? _command;
public OptionalColor Foreground
{
@@ -35,14 +32,16 @@ public class Tag : BaseObservable, ITag
public IconInfo Icon
{
get => _icon;
get;
set
{
_icon = value;
field = value;
OnPropertyChanged(nameof(Icon));
}
}
= new(string.Empty);
public string Text
{
get => _text;
@@ -55,23 +54,15 @@ public class Tag : BaseObservable, ITag
public string ToolTip
{
get => _toolTip;
get;
set
{
_toolTip = value;
field = value;
OnPropertyChanged(nameof(ToolTip));
}
}
public ICommand? Command
{
get => _command;
set
{
_command = value;
OnPropertyChanged(nameof(Command));
}
}
= string.Empty;
public Tag()
{

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Extensions.Helpers;
public partial class ToastArgs : IToastArgs
{
public string? Message { get; set; }
public ICommandResult? Result { get; set; } = CommandResult.Dismiss();
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Extensions.Helpers;
public partial class ToastStatusMessage
{
private readonly Lock _showLock = new();
private bool _shown;
public StatusMessage Message { get; init; }
public int Duration { get; init; } = 2500;
public ToastStatusMessage(StatusMessage message)
{
Message = message;
}
public ToastStatusMessage(string text)
{
Message = new StatusMessage() { Message = text };
}
public void Show()
{
lock (_showLock)
{
if (!_shown)
{
ExtensionHost.ShowStatus(Message);
_ = Task.Run(() =>
{
Thread.Sleep(Duration);
lock (_showLock)
{
_shown = false;
ExtensionHost.HideStatus(Message);
}
});
_shown = true;
}
}
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Windows.Foundation;
namespace Microsoft.CmdPal.Extensions.Helpers;
public partial class TreeContent : BaseObservable, ITreeContent
{
public IContent[] Children { get; set; } = [];
public virtual IContent? RootContent
{
get;
set
{
field = value;
OnPropertyChanged(nameof(RootContent));
}
}
public event TypedEventHandler<object, ItemsChangedEventArgs>? ItemsChanged;
public virtual IContent[] GetChildren() => Children;
protected void RaiseItemsChanged(int totalItems)
{
try
{
// TODO #181 - This is the same thing that BaseObservable has to deal with.
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems));
}
catch
{
}
}
}

View File

@@ -22,7 +22,7 @@ namespace Microsoft.CmdPal.Extensions
String Icon { get; };
Windows.Storage.Streams.IRandomAccessStreamReference Data { get; };
};
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
runtimeclass IconInfo {
IconInfo(String iconString);
@@ -78,9 +78,11 @@ namespace Microsoft.CmdPal.Extensions
Dismiss, // Reset the palette to the main page and dismiss
GoHome, // Go back to the main page, but keep it open
GoBack, // Go back one level
Hide, // Keep this page open, but hide the palette.
Hide, // Keep this page open, but hide the palette.
KeepOpen, // Do nothing.
GoToPage, // Go to another page. GoToPageArgs will tell you where.
ShowToast, // Display a transient message to the user
Confirm, // Display a confirmation dialog
};
enum NavigationMode {
@@ -102,15 +104,28 @@ namespace Microsoft.CmdPal.Extensions
String PageId { get; };
NavigationMode NavigationMode { get; };
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IToastArgs requires ICommandResultArgs{
String Message { get; };
ICommandResult Result { get; };
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IConfirmationArgs requires ICommandResultArgs{
String Title { get; };
String Description { get; };
ICommand PrimaryCommand { get; };
Boolean IsPrimaryCommandCritical { get; };
}
// This is a "leaf" of the UI. This is something that can be "done" by the user.
// * A ListPage
// * the MoreCommands flyout of for a ListItem or a MarkdownPage
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IInvokableCommand requires ICommand {
ICommandResult Invoke();
ICommandResult Invoke(Object sender);
}
[uuid("ef5db50c-d26b-4aee-9343-9f98739ab411")]
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IFilterItem {}
@@ -153,7 +168,6 @@ namespace Microsoft.CmdPal.Extensions
OptionalColor Foreground { get; };
OptionalColor Background { get; };
String ToolTip { get; };
ICommand Command { get; };
};
[uuid("6a6dd345-37a3-4a1e-914d-4f658a4d583d")]
@@ -180,6 +194,10 @@ namespace Microsoft.CmdPal.Extensions
Windows.Foundation.Uri Link { get; };
String Text { get; };
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IDetailsCommand requires IDetailsData {
ICommand Command { get; };
}
[uuid("58070392-02bb-4e89-9beb-47ceb8c3d741")]
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IDetailsSeparator requires IDetailsData {}
@@ -231,7 +249,7 @@ namespace Microsoft.CmdPal.Extensions
interface IPage requires ICommand {
String Title { get; };
Boolean IsLoading { get; };
OptionalColor AccentColor { get; };
}
@@ -246,7 +264,7 @@ namespace Microsoft.CmdPal.Extensions
IconInfo Icon{ get; };
String Title{ get; };
String Subtitle{ get; };
}
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface ICommandContextItem requires ICommandItem, IContextItem {
@@ -273,7 +291,7 @@ namespace Microsoft.CmdPal.Extensions
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IListPage requires IPage, INotifyItemsChanged {
// DevPal will be responsible for filtering the list of items, unless the
// DevPal will be responsible for filtering the list of items, unless the
// class implements IDynamicListPage
String SearchText { get; };
String PlaceholderText { get; };
@@ -283,7 +301,7 @@ namespace Microsoft.CmdPal.Extensions
Boolean HasMoreItems { get; };
ICommandItem EmptyContent { get; };
IListItem[] GetItems();
IListItem[] GetItems();
void LoadMore();
}
@@ -312,9 +330,40 @@ namespace Microsoft.CmdPal.Extensions
IForm[] Forms();
}
[uuid("b64def0f-8911-4afa-8f8f-042bd778d088")]
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IContent requires INotifyPropChanged {
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IFormContent requires IContent {
String TemplateJson { get; };
String DataJson { get; };
String StateJson { get; };
ICommandResult SubmitForm(String payload);
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IMarkdownContent requires IContent {
String Body { get; };
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface ITreeContent requires IContent, INotifyItemsChanged {
IContent RootContent { get; };
IContent[] GetChildren();
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface IContentPage requires IPage, INotifyItemsChanged {
IContent[] GetContent();
IDetails Details { get; };
IContextItem[] Commands { get; };
}
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]
interface ICommandSettings {
IFormPage SettingsPage { get; };
IContentPage SettingsPage { get; };
};
[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]

View File

@@ -0,0 +1,8 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")]
[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "This is sample code")]

View File

@@ -0,0 +1,181 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
namespace SamplePagesExtension;
internal sealed partial class SampleCommentsPage : ContentPage
{
private readonly TreeContent myContentTree;
public override IContent[] GetContent() => [myContentTree];
public SampleCommentsPage()
{
Name = "View Posts";
Icon = new("\uE90A"); // Comment
myContentTree = new()
{
RootContent = new MarkdownContent()
{
Body = """
# Example of a thread of comments
You can use TreeContent in combination with FormContent to build a structure like a page with comments.
The forms on this page use the AdaptiveCard `Action.ShowCard` action to show a nested, hidden card on the form.
""",
},
Children = [
new PostContent("First")
{
Replies = [
new PostContent("Oh very insightful. I hadn't considered that"),
new PostContent("Second"),
new PostContent("ah the ol switcheroo"),
],
},
new PostContent("First\nEDIT: shoot")
{
Replies = [
new PostContent("delete this"),
],
},
new PostContent("Do you think they get the picture")
{
Replies = [
new PostContent("Probably! Now go build and be happy"),
],
}
],
};
}
}
[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")]
internal sealed partial class PostContent : TreeContent
{
public List<IContent> Replies { get; init; } = [];
private readonly ToastStatusMessage _toast = new(new StatusMessage() { Message = "Reply posted", State = MessageState.Success });
public PostContent(string body)
{
RootContent = new PostForm(body, this);
}
public override IContent[] GetChildren() => Replies.ToArray();
public void Post()
{
RaiseItemsChanged(Replies.Count);
_toast.Show();
}
}
[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")]
internal sealed partial class PostForm : FormContent
{
private readonly PostContent _parent;
public PostForm(string postBody, PostContent parent)
{
_parent = parent;
TemplateJson = """
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.6",
"body": [
{
"type": "TextBlock",
"text": "${postBody}",
"wrap": true
}
],
"actions": [
{
"type": "Action.ShowCard",
"title": "${replyCard.title}",
"card": {
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.6",
"body": [
{
"type": "Container",
"id": "${replyCard.idPrefix}Properties",
"items": [
{
"$data": "${replyCard.fields}",
"type": "Input.Text",
"label": "${label}",
"id": "${id}",
"isRequired": "${required}",
"isMultiline": true,
"errorMessage": "'${label}' is required"
}
]
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Post"
}
]
}
},
{
"type": "Action.Submit",
"title": "Favorite"
},
{
"type": "Action.Submit",
"title": "View on web"
}
]
}
""";
DataJson = $$"""
{
"postBody": {{JsonSerializer.Serialize(postBody)}},
"replyCard": {
"title": "Reply",
"idPrefix": "reply",
"fields": [
{
"label": "Reply",
"id": "ReplyBody",
"required": true,
"placeholder": "Write a reply here"
}
]
}
}
""";
}
public override ICommandResult SubmitForm(string payload)
{
var data = JsonNode.Parse(payload);
_ = data;
var reply = data["ReplyBody"];
var s = reply?.AsValue()?.ToString();
if (!string.IsNullOrEmpty(s))
{
_parent.Replies.Add(new PostContent(s));
_parent.Post();
}
return CommandResult.KeepOpen();
}
}