diff --git a/.github/actions/spelling/allow/allow.txt b/.github/actions/spelling/allow/allow.txt index 13e2e78ed3b..843a1ae91e6 100644 --- a/.github/actions/spelling/allow/allow.txt +++ b/.github/actions/spelling/allow/allow.txt @@ -95,6 +95,7 @@ slnt Sos ssh stakeholders +sxn timeline timelines timestamped diff --git a/.github/actions/spelling/expect/expect.txt b/.github/actions/spelling/expect/expect.txt index 722bf19093b..626777f6bdb 100644 --- a/.github/actions/spelling/expect/expect.txt +++ b/.github/actions/spelling/expect/expect.txt @@ -315,6 +315,7 @@ CPLINFO cplusplus CPPCORECHECK cppcorecheckrules +cpprest cpprestsdk cppwinrt CProc @@ -1452,6 +1453,7 @@ PPEB ppf ppguid ppidl +pplx PPROC ppropvar ppsi diff --git a/src/cascadia/LocalTests_TerminalApp/pch.h b/src/cascadia/LocalTests_TerminalApp/pch.h index 75aabc570b8..f82561888b1 100644 --- a/src/cascadia/LocalTests_TerminalApp/pch.h +++ b/src/cascadia/LocalTests_TerminalApp/pch.h @@ -74,3 +74,5 @@ Author(s): #include "../../inc/DefaultSettings.h" #include + +#include diff --git a/src/cascadia/TerminalApp/SuggestionsControl.cpp b/src/cascadia/TerminalApp/SuggestionsControl.cpp new file mode 100644 index 00000000000..83694267a6d --- /dev/null +++ b/src/cascadia/TerminalApp/SuggestionsControl.cpp @@ -0,0 +1,1103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "pch.h" +#include "ActionPaletteItem.h" +#include "CommandLinePaletteItem.h" +#include "SuggestionsControl.h" +#include + +#include "SuggestionsControl.g.cpp" + +using namespace winrt; +using namespace winrt::TerminalApp; +using namespace winrt::Windows::UI::Core; +using namespace winrt::Windows::UI::Xaml; +using namespace winrt::Windows::UI::Xaml::Controls; +using namespace winrt::Windows::System; +using namespace winrt::Windows::Foundation; +using namespace winrt::Windows::Foundation::Collections; +using namespace winrt::Microsoft::Terminal::Settings::Model; + +namespace winrt::TerminalApp::implementation +{ + SuggestionsControl::SuggestionsControl() + { + InitializeComponent(); + + _itemTemplateSelector = Resources().Lookup(winrt::box_value(L"PaletteItemTemplateSelector")).try_as(); + _listItemTemplate = Resources().Lookup(winrt::box_value(L"ListItemTemplate")).try_as(); + + _filteredActions = winrt::single_threaded_observable_vector(); + _nestedActionStack = winrt::single_threaded_vector(); + _currentNestedCommands = winrt::single_threaded_vector(); + _allCommands = winrt::single_threaded_vector(); + + _switchToMode(); + + // Whatever is hosting us will enable us by setting our visibility to + // "Visible". When that happens, set focus to our search box. + RegisterPropertyChangedCallback(UIElement::VisibilityProperty(), [this](auto&&, auto&&) { + if (Visibility() == Visibility::Visible) + { + // Force immediate binding update so we can select an item + Bindings->Update(); + UpdateLayout(); // THIS ONE IN PARTICULAR SEEMS LOAD BEARING. + // Without the UpdateLayout call, our ListView won't have a + // chance to instantiate ListViewItem's. If we don't have those, + // then our call to `SelectedItem()` below is going to return + // null. If it does that, then we won't be able to focus + // ourselves when we're opened. + + // Select the correct element in the list, depending on which + // direction we were opened in. + // + // Make sure to use _scrollToIndex, to move the scrollbar too! + if (_direction == TerminalApp::SuggestionsDirection::TopDown) + { + _scrollToIndex(0); + } + else // BottomUp + { + _scrollToIndex(_filteredActionsView().Items().Size() - 1); + } + + if (_mode == SuggestionsMode::Palette) + { + // Toss focus into the search box in palette mode + _searchBox().Visibility(Visibility::Visible); + _searchBox().Focus(FocusState::Programmatic); + } + else if (_mode == SuggestionsMode::Menu) + { + // Toss focus onto the selected item in menu mode. + // Don't just focus the _filteredActionsView, because that will always select the 0th element. + + _searchBox().Visibility(Visibility::Collapsed); + + if (const auto& dependencyObj = SelectedItem().try_as()) + { + Input::FocusManager::TryFocusAsync(dependencyObj, FocusState::Programmatic); + } + } + + TraceLoggingWrite( + g_hTerminalAppProvider, // handle to TerminalApp tracelogging provider + "SuggestionsControlOpened", + TraceLoggingDescription("Event emitted when the Command Palette is opened"), + TraceLoggingWideString(L"Action", "Mode", "which mode the palette was opened in"), + TraceLoggingKeyword(MICROSOFT_KEYWORD_MEASURES), + TelemetryPrivacyDataTag(PDT_ProductAndServiceUsage)); + } + else + { + // Raise an event to return control to the Terminal. + _dismissPalette(); + } + }); + + // Focusing the ListView when the Command Palette control is set to Visible + // for the first time fails because the ListView hasn't finished loading by + // the time Focus is called. Luckily, We can listen to SizeChanged to know + // when the ListView has been measured out and is ready, and we'll immediately + // revoke the handler because we only needed to handle it once on initialization. + _sizeChangedRevoker = _filteredActionsView().SizeChanged(winrt::auto_revoke, [this](auto /*s*/, auto /*e*/) { + // This does only fire once, when the size changes, which is the + // very first time it's opened. It does not fire for subsequent + // openings. + + _sizeChangedRevoker.revoke(); + }); + + _filteredActionsView().SelectionChanged({ this, &SuggestionsControl::_selectedCommandChanged }); + } + + TerminalApp::SuggestionsMode SuggestionsControl::Mode() const + { + return _mode; + } + void SuggestionsControl::Mode(TerminalApp::SuggestionsMode mode) + { + _mode = mode; + + if (_mode == SuggestionsMode::Palette) + { + _searchBox().Visibility(Visibility::Visible); + _searchBox().Focus(FocusState::Programmatic); + } + else if (_mode == SuggestionsMode::Menu) + { + _searchBox().Visibility(Visibility::Collapsed); + _filteredActionsView().Focus(FocusState::Programmatic); + } + } + + // Method Description: + // - Moves the focus up or down the list of commands. If we're at the top, + // we'll loop around to the bottom, and vice-versa. + // Arguments: + // - moveDown: if true, we're attempting to move to the next item in the + // list. Otherwise, we're attempting to move to the previous. + // Return Value: + // - + void SuggestionsControl::SelectNextItem(const bool moveDown) + { + auto selected = _filteredActionsView().SelectedIndex(); + const auto numItems = ::base::saturated_cast(_filteredActionsView().Items().Size()); + + // Do not try to select an item if + // - the list is empty + // - if no item is selected and "up" is pressed + if (numItems != 0 && (selected != -1 || moveDown)) + { + // Wraparound math. By adding numItems and then calculating modulo numItems, + // we clamp the values to the range [0, numItems) while still supporting moving + // upward from 0 to numItems - 1. + const auto newIndex = ((numItems + selected + (moveDown ? 1 : -1)) % numItems); + _filteredActionsView().SelectedIndex(newIndex); + _filteredActionsView().ScrollIntoView(_filteredActionsView().SelectedItem()); + } + } + + // Method Description: + // - Scroll the command palette to the specified index + // Arguments: + // - index within a list view of commands + // Return Value: + // - + void SuggestionsControl::_scrollToIndex(uint32_t index) + { + auto numItems = _filteredActionsView().Items().Size(); + + if (numItems == 0) + { + // if the list is empty no need to scroll + return; + } + + auto clampedIndex = std::clamp(index, 0, numItems - 1); + _filteredActionsView().SelectedIndex(clampedIndex); + _filteredActionsView().ScrollIntoView(_filteredActionsView().SelectedItem()); + } + + // Method Description: + // - Computes the number of visible commands + // Arguments: + // - + // Return Value: + // - the approximate number of items visible in the list (in other words the size of the page) + uint32_t SuggestionsControl::_getNumVisibleItems() + { + if (const auto container = _filteredActionsView().ContainerFromIndex(0)) + { + if (const auto item = container.try_as()) + { + const auto itemHeight = ::base::saturated_cast(item.ActualHeight()); + const auto listHeight = ::base::saturated_cast(_filteredActionsView().ActualHeight()); + return listHeight / itemHeight; + } + } + return 0; + } + + // Method Description: + // - Scrolls the focus one page up the list of commands. + // Arguments: + // - + // Return Value: + // - + void SuggestionsControl::ScrollPageUp() + { + auto selected = _filteredActionsView().SelectedIndex(); + auto numVisibleItems = _getNumVisibleItems(); + _scrollToIndex(selected - numVisibleItems); + } + + // Method Description: + // - Scrolls the focus one page down the list of commands. + // Arguments: + // - + // Return Value: + // - + void SuggestionsControl::ScrollPageDown() + { + auto selected = _filteredActionsView().SelectedIndex(); + auto numVisibleItems = _getNumVisibleItems(); + _scrollToIndex(selected + numVisibleItems); + } + + // Method Description: + // - Moves the focus to the top item in the list of commands. + // Arguments: + // - + // Return Value: + // - + void SuggestionsControl::ScrollToTop() + { + _scrollToIndex(0); + } + + // Method Description: + // - Moves the focus to the bottom item in the list of commands. + // Arguments: + // - + // Return Value: + // - + void SuggestionsControl::ScrollToBottom() + { + _scrollToIndex(_filteredActionsView().Items().Size() - 1); + } + + Windows::UI::Xaml::FrameworkElement SuggestionsControl::SelectedItem() + { + auto index = _filteredActionsView().SelectedIndex(); + const auto container = _filteredActionsView().ContainerFromIndex(index); + const auto item = container.try_as(); + return item; + } + + // Method Description: + // - Called when the command selection changes. We'll use this to preview the selected action. + // Arguments: + // - + // Return Value: + // - + void SuggestionsControl::_selectedCommandChanged(const IInspectable& /*sender*/, + const Windows::UI::Xaml::RoutedEventArgs& /*args*/) + { + const auto selectedCommand = _filteredActionsView().SelectedItem(); + const auto filteredCommand{ selectedCommand.try_as() }; + + _PropertyChangedHandlers(*this, Windows::UI::Xaml::Data::PropertyChangedEventArgs{ L"SelectedItem" }); + + // Make sure to not send the preview if we're collapsed. This can + // sometimes fire after we've been closed, which can trigger us to + // preview the action for the empty text (as we've cleared the search + // text as a part of closing). + const bool isVisible{ this->Visibility() == Visibility::Visible }; + + if (filteredCommand != nullptr && + isVisible) + { + if (const auto actionPaletteItem{ filteredCommand.Item().try_as() }) + { + PreviewAction.raise(*this, actionPaletteItem.Command()); + } + } + } + + void SuggestionsControl::_previewKeyDownHandler(const IInspectable& /*sender*/, + const Windows::UI::Xaml::Input::KeyRoutedEventArgs& e) + { + const auto key = e.OriginalKey(); + const auto coreWindow = CoreWindow::GetForCurrentThread(); + const auto ctrlDown = WI_IsFlagSet(coreWindow.GetKeyState(winrt::Windows::System::VirtualKey::Control), CoreVirtualKeyStates::Down); + + if (key == VirtualKey::Home && ctrlDown) + { + ScrollToTop(); + e.Handled(true); + } + else if (key == VirtualKey::End && ctrlDown) + { + ScrollToBottom(); + e.Handled(true); + } + else if (key == VirtualKey::Up) + { + // Move focus to the next item in the list. + SelectNextItem(false); + e.Handled(true); + } + else if (key == VirtualKey::Down) + { + // Move focus to the previous item in the list. + SelectNextItem(true); + e.Handled(true); + } + else if (key == VirtualKey::PageUp) + { + // Move focus to the first visible item in the list. + ScrollPageUp(); + e.Handled(true); + } + else if (key == VirtualKey::PageDown) + { + // Move focus to the last visible item in the list. + ScrollPageDown(); + e.Handled(true); + } + else if (key == VirtualKey::Enter || + key == VirtualKey::Tab || + key == VirtualKey::Right) + { + // If the user pressed enter, tab, or the right arrow key, then + // we'll want to dispatch the command that's selected as they + // accepted the suggestion. + + if (const auto& button = e.OriginalSource().try_as + + + + + + + + + + + + + + + + diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj index e284d7abe2d..2b933ddfe91 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj @@ -68,6 +68,9 @@ Designer + + Designer + @@ -159,6 +162,9 @@ TerminalWindow.idl + + SuggestionsControl.xaml + @@ -262,6 +268,9 @@ + + SuggestionsControl.xaml + @@ -325,6 +334,10 @@ CommandPalette.xaml Code + + SuggestionsControl.xaml + Code + diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 8140af22b3e..18515a8afa9 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -1466,6 +1466,11 @@ namespace winrt::TerminalApp::implementation { CommandPaletteElement().Visibility(Visibility::Collapsed); } + if (_suggestionsControlIs(Visibility::Visible) && + cmd.ActionAndArgs().Action() != ShortcutAction::ToggleCommandPalette) + { + SuggestionsElement().Visibility(Visibility::Collapsed); + } // Let's assume the user has bound the dead key "^" to a sendInput command that sends "b". // If the user presses the two keys "^a" it'll produce "bâ", despite us marking the key event as handled. @@ -1654,6 +1659,12 @@ namespace winrt::TerminalApp::implementation term.ShowWindowChanged({ get_weak(), &TerminalPage::_ShowWindowChangedHandler }); + // Don't even register for the event if the feature is compiled off. + if constexpr (Feature_ShellCompletions::IsEnabled()) + { + term.CompletionsChanged({ get_weak(), &TerminalPage::_ControlCompletionsChangedHandler }); + } + term.ContextMenu().Opening({ this, &TerminalPage::_ContextMenuOpened }); term.SelectionContextMenu().Opening({ this, &TerminalPage::_SelectionMenuOpened }); } @@ -1825,6 +1836,37 @@ namespace winrt::TerminalApp::implementation return p; } + SuggestionsControl TerminalPage::LoadSuggestionsUI() + { + if (const auto p = SuggestionsElement()) + { + return p; + } + + return _loadSuggestionsElementSlowPath(); + } + bool TerminalPage::_suggestionsControlIs(WUX::Visibility visibility) + { + const auto p = SuggestionsElement(); + return p && p.Visibility() == visibility; + } + + SuggestionsControl TerminalPage::_loadSuggestionsElementSlowPath() + { + const auto p = FindName(L"SuggestionsElement").as(); + + p.RegisterPropertyChangedCallback(UIElement::VisibilityProperty(), [this](auto&&, auto&&) { + if (SuggestionsElement().Visibility() == Visibility::Collapsed) + { + _FocusActiveControl(nullptr, nullptr); + } + }); + p.DispatchCommandRequested({ this, &TerminalPage::_OnDispatchCommandRequested }); + p.PreviewAction({ this, &TerminalPage::_PreviewActionHandler }); + + return p; + } + // Method Description: // - Warn the user that they are about to close all open windows, then // signal that we want to close everything. @@ -2787,7 +2829,7 @@ namespace winrt::TerminalApp::implementation // Arguments: // - sender (not used) // - args: the arguments specifying how to set the display status to ShowWindow for our window handle - void TerminalPage::_ShowWindowChangedHandler(const IInspectable& /*sender*/, const Microsoft::Terminal::Control::ShowWindowArgs args) + void TerminalPage::_ShowWindowChangedHandler(const IInspectable /*sender*/, const Microsoft::Terminal::Control::ShowWindowArgs args) { _ShowWindowChangedHandlers(*this, args); } @@ -4649,6 +4691,79 @@ namespace winrt::TerminalApp::implementation _updateThemeColors(); } + winrt::fire_and_forget TerminalPage::_ControlCompletionsChangedHandler(const IInspectable sender, + const CompletionsChangedEventArgs args) + { + // This will come in on a background (not-UI, not output) thread. + + // This won't even get hit if the velocity flag is disabled - we gate + // registering for the event based off of + // Feature_ShellCompletions::IsEnabled back in _RegisterTerminalEvents + + // User must explicitly opt-in on Preview builds + if (!_settings.GlobalSettings().EnableShellCompletionMenu()) + { + co_return; + } + + // Parse the json string into a collection of actions + try + { + auto commandsCollection = Command::ParsePowerShellMenuComplete(args.MenuJson(), + args.ReplacementLength()); + + auto weakThis{ get_weak() }; + Dispatcher().RunAsync(CoreDispatcherPriority::Normal, [weakThis, commandsCollection, sender]() { + // On the UI thread... + if (const auto& page{ weakThis.get() }) + { + // Open the Suggestions UI with the commands from the control + page->_OpenSuggestions(sender.try_as(), commandsCollection, SuggestionsMode::Menu); + } + }); + } + CATCH_LOG(); + } + + void TerminalPage::_OpenSuggestions( + const TermControl& sender, + IVector commandsCollection, + winrt::TerminalApp::SuggestionsMode mode) + { + // ON THE UI THREAD + assert(Dispatcher().HasThreadAccess()); + + if (commandsCollection == nullptr) + { + return; + } + if (commandsCollection.Size() == 0) + { + if (const auto p = SuggestionsElement()) + { + p.Visibility(Visibility::Collapsed); + } + return; + } + + const auto& control{ sender ? sender : _GetActiveControl() }; + if (!control) + { + return; + } + + const auto& sxnUi{ LoadSuggestionsUI() }; + + const auto characterSize{ control.CharacterDimensions() }; + // This is in control-relative space. We'll need to convert it to page-relative space. + const auto cursorPos{ control.CursorPositionInDips() }; + const auto controlTransform = control.TransformToVisual(this->Root()); + const auto realCursorPos{ controlTransform.TransformPoint({ cursorPos.X, cursorPos.Y }) }; // == controlTransform + cursorPos + const Windows::Foundation::Size windowDimensions{ gsl::narrow_cast(ActualWidth()), gsl::narrow_cast(ActualHeight()) }; + + sxnUi.Open(mode, commandsCollection, realCursorPos, windowDimensions, characterSize.Height); + } + void TerminalPage::_ContextMenuOpened(const IInspectable& sender, const IInspectable& /*args*/) { diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 7a6866e20ec..e14b46fee72 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -117,6 +117,8 @@ namespace winrt::TerminalApp::implementation winrt::hstring ApplicationVersion(); CommandPalette LoadCommandPalette(); + SuggestionsControl LoadSuggestionsUI(); + winrt::fire_and_forget RequestQuit(); winrt::fire_and_forget CloseWindow(bool bypassDialog); @@ -280,6 +282,8 @@ namespace winrt::TerminalApp::implementation __declspec(noinline) CommandPalette _loadCommandPaletteSlowPath(); bool _commandPaletteIs(winrt::Windows::UI::Xaml::Visibility visibility); + __declspec(noinline) SuggestionsControl _loadSuggestionsElementSlowPath(); + bool _suggestionsControlIs(winrt::Windows::UI::Xaml::Visibility visibility); winrt::Windows::Foundation::IAsyncOperation _ShowDialogHelper(const std::wstring_view& name); @@ -481,6 +485,7 @@ namespace winrt::TerminalApp::implementation void _RunRestorePreviews(); void _PreviewColorScheme(const Microsoft::Terminal::Settings::Model::SetColorSchemeArgs& args); void _PreviewAdjustOpacity(const Microsoft::Terminal::Settings::Model::AdjustOpacityArgs& args); + winrt::Microsoft::Terminal::Settings::Model::ActionAndArgs _lastPreviewedAction{ nullptr }; std::vector> _restorePreviewFuncs{}; @@ -513,7 +518,11 @@ namespace winrt::TerminalApp::implementation void _updateAllTabCloseButtons(const winrt::TerminalApp::TabBase& focusedTab); void _updatePaneResources(const winrt::Windows::UI::Xaml::ElementTheme& requestedTheme); - void _ShowWindowChangedHandler(const IInspectable& sender, const winrt::Microsoft::Terminal::Control::ShowWindowArgs args); + winrt::fire_and_forget _ControlCompletionsChangedHandler(const winrt::Windows::Foundation::IInspectable sender, const winrt::Microsoft::Terminal::Control::CompletionsChangedEventArgs args); + void _OpenSuggestions(const Microsoft::Terminal::Control::TermControl& sender, Windows::Foundation::Collections::IVector commandsCollection, winrt::TerminalApp::SuggestionsMode mode); + + void _ShowWindowChangedHandler(const IInspectable sender, const winrt::Microsoft::Terminal::Control::ShowWindowArgs args); + winrt::fire_and_forget _windowPropertyChanged(const IInspectable& sender, const winrt::Windows::UI::Xaml::Data::PropertyChangedEventArgs& args); void _onTabDragStarting(const winrt::Microsoft::UI::Xaml::Controls::TabView& sender, const winrt::Microsoft::UI::Xaml::Controls::TabViewTabDragStartingEventArgs& e); diff --git a/src/cascadia/TerminalApp/TerminalPage.xaml b/src/cascadia/TerminalApp/TerminalPage.xaml index 00fb12f86b4..600ec505c67 100644 --- a/src/cascadia/TerminalApp/TerminalPage.xaml +++ b/src/cascadia/TerminalApp/TerminalPage.xaml @@ -175,6 +175,14 @@ PreviewKeyDown="_KeyDownHandler" Visibility="Collapsed" /> + +