mirror of
https://github.com/microsoft/terminal.git
synced 2025-12-11 04:38:24 -06:00
This pull request adds an Extensions page to the Settings UI, which lets you enable/disable extensions and see how they affect your settings (i.e. adding/modifying profiles and adding color schemes). This page is specifically designed for fragment extensions and dynamic profile generators, but can be expanded on in the future as we develop a more advanced extensions model. App extensions extract the name and icon from the extension package and display it in the UI. Dynamic profile generators extract the name and icon from the generator and display it in the UI. We prefer to use the display name for breadcrumbs when possible. A "NEW" badge was added to the Extensions page's `NavigationViewItem` to highlight that it's new. It goes away once the user visits it. ## Detailed Description of the Pull Request / Additional comments - Settings Model changes: - `FragmentSettings` represents a parsed json fragment extension. - `FragmentProfileEntry` and `FragmentColorSchemeEntry` are used to track profiles and color schemes added/modified - `ExtensionPackage` bundles the `FragmentSettings` together. This is how we represent multiple JSON files in one extension. - `IDynamicProfileGenerator` exposes a `DisplayName` and `Icon` - `ExtensionPackage`s created from app extensions extract the `DisplayName` and `Icon` from the extension - `ApplicationState` is used to track which badges have been dismissed and prevent them from appearing again - a `std::unordered_set` is used to keep track of the dismissed badges, but we only expose a get and append function via the IDL to interact with it - Editor changes - view models: - `ExtensionsViewModel` operates as the main view model for the page. - `FragmentProfileViewModel` and `FragmentColorSchemeViewModel` are used to reference specific components of fragments. They also provide support for navigating to the linked profile or color scheme via the settings UI! - `ExtensionPackageViewModel` is a VM for a group of extensions exposed by a single source. This is mainly needed because a single source can have multiple JSON fragments in it. This is used for the navigators on the main page. Can be extended to provide additional information (i.e. package logo, package name, etc.) - `CurrentExtensionPackage` is used to track which extension package is currently in view, if applicable (similar to how the new tab menu page works) - Editor changes - views: - `Extensions.xaml` uses _a lot_ of data templates. These are reused in `ItemsControl`s to display extension components. - `ExtensionPackageTemplateSelector` is used to display `ExtensionPackage`s with metadata vs simple ones that just have a source (i.e. Git) - Added a `NewInfoBadge` style that is just an InfoBadge with "New" in it instead of a number or an icon. Based on https://github.com/microsoft/PowerToys/pull/36939 - The visibility is bound to a `get` call to the `ApplicationState` conducted via the `ExtensionsPageViewModel`. The VM is also responsible for updating the state. - Lazy loading extension objects - Since most instances of Terminal won't actually open the settings UI, it doesn't make sense to create all the extension objects upon startup. Instead, we defer creating those objects until the user actually navigates to the Extensions page. This is most of the work that happened in `CascadiaSettingsSerialization.cpp`. The `SettingsLoader` can be used specifically to load and create the extension objects. ## Validation Steps ✅ Keyboard navigation feels right ✅ Screen reader reads all info on screen properly ✅ Accessibility Insights FastPass found no issues ✅ "Discard changes" retains subpage, but removes any changes ✅ Extensions page nav item displays a badge if page hasn't been visited ✅ The badge is dismissed when the user visits the page ## Follow-ups - Streamline a process for adding extensions from the new page - Long-term, we can reuse the InfoBadge system and make the following minor changes: - `SettingContainer`: display the badge and add logic to read/write `ApplicationState` appropriately (similarly to above) - `XPageViewModel`: - count all the badges that will be displayed and expose/bind that to `InfoBadge.Value` - If a whole page is new, we can just style the badge using the `NewInfoBadge` style
202 lines
9.2 KiB
C++
202 lines
9.2 KiB
C++
// Copyright (c) Microsoft Corporation.
|
|
// Licensed under the MIT license.
|
|
|
|
#include "pch.h"
|
|
#include "NewTabMenu.h"
|
|
#include "NewTabMenu.g.cpp"
|
|
#include "NewTabMenuEntryTemplateSelector.g.cpp"
|
|
#include "EnumEntry.h"
|
|
|
|
#include "NewTabMenuViewModel.h"
|
|
|
|
#include <LibraryResources.h>
|
|
|
|
using namespace winrt::Windows::UI::Xaml;
|
|
using namespace winrt::Windows::UI::Xaml::Navigation;
|
|
using namespace winrt::Windows::Foundation;
|
|
using namespace winrt::Microsoft::Terminal::Settings::Model;
|
|
|
|
namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
|
|
{
|
|
NewTabMenu::NewTabMenu()
|
|
{
|
|
InitializeComponent();
|
|
|
|
// Ideally, we'd bind IsEnabled to something like mtu:Converters.isEmpty(NewTabMenuListView.SelectedItems.Size) in the XAML,
|
|
// but the XAML compiler can't find NewTabMenuListView when we try that. Rather than copying the list of selected items over
|
|
// to the view model, we'll just do this instead (much simpler).
|
|
NewTabMenuListView().SelectionChanged([this](auto&&, auto&&) {
|
|
const auto list = NewTabMenuListView();
|
|
MoveToFolderButton().IsEnabled(list.SelectedItems().Size() > 0);
|
|
DeleteMultipleButton().IsEnabled(list.SelectedItems().Size() > 0);
|
|
});
|
|
|
|
Automation::AutomationProperties::SetName(MoveToFolderButton(), RS_(L"NewTabMenu_MoveToFolderTextBlock/Text"));
|
|
Automation::AutomationProperties::SetName(DeleteMultipleButton(), RS_(L"NewTabMenu_DeleteMultipleTextBlock/Text"));
|
|
Automation::AutomationProperties::SetName(AddProfileComboBox(), RS_(L"NewTabMenu_AddProfile/Header"));
|
|
Automation::AutomationProperties::SetName(AddProfileButton(), RS_(L"NewTabMenu_AddProfileButton/[using:Windows.UI.Xaml.Controls]ToolTipService/ToolTip"));
|
|
Automation::AutomationProperties::SetName(AddSeparatorButton(), RS_(L"NewTabMenu_AddSeparatorButton/[using:Windows.UI.Xaml.Controls]ToolTipService/ToolTip"));
|
|
Automation::AutomationProperties::SetName(AddFolderButton(), RS_(L"NewTabMenu_AddFolderButton/[using:Windows.UI.Xaml.Controls]ToolTipService/ToolTip"));
|
|
Automation::AutomationProperties::SetName(AddMatchProfilesButton(), RS_(L"NewTabMenu_AddMatchProfilesTextBlock/Text"));
|
|
Automation::AutomationProperties::SetName(AddRemainingProfilesButton(), RS_(L"NewTabMenu_AddRemainingProfilesButton/[using:Windows.UI.Xaml.Controls]ToolTipService/ToolTip"));
|
|
}
|
|
|
|
void NewTabMenu::OnNavigatedTo(const NavigationEventArgs& e)
|
|
{
|
|
_ViewModel = e.Parameter().as<Editor::NewTabMenuViewModel>();
|
|
}
|
|
|
|
void NewTabMenu::FolderPickerDialog_Opened(const IInspectable& /*sender*/, const Controls::ContentDialogOpenedEventArgs& /*e*/)
|
|
{
|
|
// Ideally, we'd bind IsPrimaryButtonEnabled to something like mtu:Converters.isEmpty(FolderTree.SelectedItems.Size) in the XAML.
|
|
// Similar to above, the XAML compiler can't find FolderTree when we try that.
|
|
// To make matters worse, SelectionChanged doesn't exist for WinUI 2's TreeView.
|
|
// Let's just select the first item and call it a day.
|
|
_ViewModel.GenerateFolderTree();
|
|
_ViewModel.CurrentFolderTreeViewSelectedItem(_ViewModel.FolderTree().First().Current());
|
|
}
|
|
|
|
void NewTabMenu::FolderPickerDialog_PrimaryButtonClick(const IInspectable& /*sender*/, const Controls::ContentDialogButtonClickEventArgs& /*e*/)
|
|
{
|
|
// copy selected items first (it updates as we move entries)
|
|
std::vector<Editor::NewTabMenuEntryViewModel> entries;
|
|
for (const auto& item : NewTabMenuListView().SelectedItems())
|
|
{
|
|
entries.push_back(item.as<Editor::NewTabMenuEntryViewModel>());
|
|
}
|
|
|
|
// now actually move them
|
|
_ViewModel.RequestMoveEntriesToFolder(single_threaded_vector<Editor::NewTabMenuEntryViewModel>(std::move(entries)), FolderTreeView().SelectedItem().as<Editor::FolderTreeViewEntry>().FolderEntryVM());
|
|
}
|
|
|
|
void NewTabMenu::EditEntry_Clicked(const IInspectable& sender, const RoutedEventArgs& /*e*/)
|
|
{
|
|
const auto folderVM = sender.as<FrameworkElement>().DataContext().as<Editor::FolderEntryViewModel>();
|
|
_ViewModel.CurrentFolder(folderVM);
|
|
}
|
|
|
|
void NewTabMenu::ReorderEntry_Clicked(const IInspectable& sender, const RoutedEventArgs& /*e*/)
|
|
{
|
|
const auto btn = sender.as<Controls::Button>();
|
|
const auto entry = btn.DataContext().as<Editor::NewTabMenuEntryViewModel>();
|
|
const auto direction = unbox_value<hstring>(btn.Tag());
|
|
|
|
_ViewModel.RequestReorderEntry(entry, direction == L"Up");
|
|
}
|
|
|
|
void NewTabMenu::DeleteEntry_Clicked(const IInspectable& sender, const RoutedEventArgs& /*e*/)
|
|
{
|
|
const auto entry = sender.as<Controls::Button>().DataContext().as<Editor::NewTabMenuEntryViewModel>();
|
|
_ViewModel.RequestDeleteEntry(entry);
|
|
}
|
|
|
|
safe_void_coroutine NewTabMenu::MoveMultiple_Click(const IInspectable& /*sender*/, const RoutedEventArgs& /*e*/)
|
|
{
|
|
co_await FindName(L"FolderPickerDialog").as<Controls::ContentDialog>().ShowAsync();
|
|
}
|
|
|
|
void NewTabMenu::DeleteMultiple_Click(const IInspectable& /*sender*/, const RoutedEventArgs& /*e*/)
|
|
{
|
|
// copy selected items first (it updates as we delete entries)
|
|
std::vector<Editor::NewTabMenuEntryViewModel> entries;
|
|
for (const auto& item : NewTabMenuListView().SelectedItems())
|
|
{
|
|
entries.push_back(item.as<Editor::NewTabMenuEntryViewModel>());
|
|
}
|
|
|
|
// now actually delete them
|
|
for (const auto& vm : entries)
|
|
{
|
|
_ViewModel.RequestDeleteEntry(vm);
|
|
}
|
|
}
|
|
|
|
void NewTabMenu::AddProfileButton_Clicked(const IInspectable& /*sender*/, const RoutedEventArgs& /*e*/)
|
|
{
|
|
_ScrollToEntry(_ViewModel.RequestAddSelectedProfileEntry());
|
|
}
|
|
|
|
void NewTabMenu::AddSeparatorButton_Clicked(const IInspectable& /*sender*/, const RoutedEventArgs& /*e*/)
|
|
{
|
|
_ScrollToEntry(_ViewModel.RequestAddSeparatorEntry());
|
|
}
|
|
|
|
void NewTabMenu::AddFolderButton_Clicked(const IInspectable& /*sender*/, const RoutedEventArgs& /*e*/)
|
|
{
|
|
_ScrollToEntry(_ViewModel.RequestAddFolderEntry());
|
|
}
|
|
|
|
void NewTabMenu::AddMatchProfilesButton_Clicked(const IInspectable& /*sender*/, const RoutedEventArgs& /*e*/)
|
|
{
|
|
_ScrollToEntry(_ViewModel.RequestAddProfileMatcherEntry());
|
|
}
|
|
|
|
void NewTabMenu::AddRemainingProfilesButton_Clicked(const IInspectable& /*sender*/, const RoutedEventArgs& /*e*/)
|
|
{
|
|
_ScrollToEntry(_ViewModel.RequestAddRemainingProfilesEntry());
|
|
}
|
|
|
|
// As a QOL improvement, we scroll to the newly added entry.
|
|
// Calling ScrollIntoView() on its own causes the items to briefly disappear.
|
|
// Calling UpdateLayout() beforehand remedies this issue.
|
|
void NewTabMenu::_ScrollToEntry(const Editor::NewTabMenuEntryViewModel& entry)
|
|
{
|
|
const auto& listView = NewTabMenuListView();
|
|
listView.UpdateLayout();
|
|
listView.ScrollIntoView(entry);
|
|
}
|
|
|
|
void NewTabMenu::AddFolderNameTextBox_KeyDown(const IInspectable& /*sender*/, const Input::KeyRoutedEventArgs& e)
|
|
{
|
|
if (e.Key() == Windows::System::VirtualKey::Enter)
|
|
{
|
|
// We need to manually set the FolderName here because the TextBox's TextChanged event hasn't fired yet.
|
|
if (const auto folderName = FolderNameTextBox().Text(); !folderName.empty())
|
|
{
|
|
_ViewModel.AddFolderName(folderName);
|
|
const auto entry = _ViewModel.RequestAddFolderEntry();
|
|
NewTabMenuListView().ScrollIntoView(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
void NewTabMenu::AddFolderNameTextBox_TextChanged(const IInspectable& sender, const Controls::TextChangedEventArgs& /*e*/)
|
|
{
|
|
const auto isTextEmpty = sender.as<Controls::TextBox>().Text().empty();
|
|
AddFolderButton().IsEnabled(!isTextEmpty);
|
|
}
|
|
|
|
DataTemplate NewTabMenuEntryTemplateSelector::SelectTemplateCore(const IInspectable& item, const DependencyObject& /*container*/)
|
|
{
|
|
return SelectTemplateCore(item);
|
|
}
|
|
|
|
DataTemplate NewTabMenuEntryTemplateSelector::SelectTemplateCore(const IInspectable& item)
|
|
{
|
|
if (const auto entryVM = item.try_as<Editor::NewTabMenuEntryViewModel>())
|
|
{
|
|
switch (entryVM.Type())
|
|
{
|
|
case Model::NewTabMenuEntryType::Profile:
|
|
return ProfileEntryTemplate();
|
|
case Model::NewTabMenuEntryType::Action:
|
|
return ActionEntryTemplate();
|
|
case Model::NewTabMenuEntryType::Separator:
|
|
return SeparatorEntryTemplate();
|
|
case Model::NewTabMenuEntryType::Folder:
|
|
return FolderEntryTemplate();
|
|
case Model::NewTabMenuEntryType::MatchProfiles:
|
|
return MatchProfilesEntryTemplate();
|
|
case Model::NewTabMenuEntryType::RemainingProfiles:
|
|
return RemainingProfilesEntryTemplate();
|
|
case Model::NewTabMenuEntryType::Invalid:
|
|
default:
|
|
assert(false);
|
|
return nullptr;
|
|
}
|
|
}
|
|
assert(false);
|
|
return nullptr;
|
|
}
|
|
}
|