// 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" #include "../../types/inc/utils.hpp" 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; using namespace std::chrono_literals; 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(); } }); _sizeChangedRevoker = _filteredActionsView().SizeChanged(winrt::auto_revoke, [this](auto /*s*/, auto /*e*/) { // When we're in BottomUp mode, we need to adjust our own position // so that our bottom is aligned with our origin. This will ensure // that as the menu changes in size (as we filter results), the menu // stays "attached" to the cursor. if (Visibility() == Visibility::Visible && _direction == TerminalApp::SuggestionsDirection::BottomUp) { this->_recalculateTopMargin(); } }); _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() }; PropertyChanged.raise(*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() }) { const auto& cmd = actionPaletteItem.Command(); PreviewAction.raise(*this, cmd); const auto description{ cmd.Description() }; if (const auto& selected{ SelectedItem() }) { selected.SetValue(Automation::AutomationProperties::FullDescriptionProperty(), winrt::box_value(description)); } if (!description.empty()) { _openTooltip(cmd); } else { // If there's no description, then just close the tooltip. _descriptionsView().Visibility(Visibility::Collapsed); _descriptionsBackdrop().Visibility(Visibility::Collapsed); _recalculateTopMargin(); } } } } void SuggestionsControl::_openTooltip(Command cmd) { const auto description{ cmd.Description() }; if (description.empty()) { return; } // Build the contents of the "tooltip" based on the description // // First, the title. This is just the name of the command. _descriptionTitle().Inlines().Clear(); Documents::Run titleRun; titleRun.Text(cmd.Name()); _descriptionTitle().Inlines().Append(titleRun); // Now fill up the "subtitle" part of the "tooltip" with the actual // description itself. const auto& inlines{ _descriptionComment().Inlines() }; inlines.Clear(); // Split the filtered description on '\n` const auto lines = ::Microsoft::Console::Utils::SplitString(description, L'\n'); // build a Run + LineBreak, and add them to the text block for (const auto& line : lines) { // Trim off any `\r`'s in the string. Pwsh completions will // frequently have these embedded. std::wstring trimmed{ line }; trimmed.erase(std::remove(trimmed.begin(), trimmed.end(), L'\r'), trimmed.end()); if (trimmed.empty()) { continue; } Documents::Run textRun; textRun.Text(trimmed); inlines.Append(textRun); inlines.Append(Documents::LineBreak{}); } // Now, make ourselves visible. _descriptionsView().Visibility(Visibility::Visible); _descriptionsBackdrop().Visibility(Visibility::Visible); // and update the padding to account for our new contents. _recalculateTopMargin(); return; } 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