/**********************************************************************

  Audacity: A Digital Audio Editor

  KeyConfigPrefs.cpp

  Brian Gunlogson
  Dominic Mazzoni
  James Crook

*******************************************************************//*!

\class KeyConfigPrefs
\brief A PrefsPanel for keybindings.

The code for displaying keybindings is similar to code in MousePrefs.
It would be nice to create a NEW 'Bindings' class which both
KeyConfigPrefs and MousePrefs use.

*//*********************************************************************/
#include "KeyConfigPrefs.h"

#include <wx/setup.h> // for wxUSE_* macros
#include <wx/defs.h>
#include <wx/ffile.h>
#include <wx/menu.h>
#include <wx/button.h>
#include <wx/radiobut.h>
#include <wx/stattext.h>
#include <wx/statbox.h>
#include <wx/textctrl.h>

#include "ActiveProject.h"
#include "MenuCreator.h" // for KeyEventToKeyString
#include "Prefs.h"
#include "Project.h"
#include "../ProjectWindows.h"
#include "XMLFileReader.h"

#include "SelectFile.h"
#include "ShuttleGui.h"

#include "FileNames.h"

#include "../widgets/BasicMenu.h"
#include "../widgets/KeyView.h"
#include "AudacityMessageBox.h"
#include "wxWidgetsWindowPlacement.h"

#if wxUSE_ACCESSIBILITY
#include "WindowAccessible.h"
#endif

//
// KeyConfigPrefs
//
#define AssignDefaultsButtonID  17001
#define CurrentComboID          17002
#define SetButtonID             17003
#define ClearButtonID           17004
#define CommandsListID          17005
#define ExportButtonID          17006
#define ImportButtonID          17007
#define FilterID                17008
#define ViewByTreeID            17009
#define ViewByNameID            17010
#define ViewByKeyID             17011
#define FilterTimerID           17012

// EMPTY_SHORTCUT means "user chose to have no shortcut"
#define EMPTY_SHORTCUT          ("")
// NO_SHORTCUT means "user made no choice"
#define NO_SHORTCUT             (wxString)((wxChar)7)

BEGIN_EVENT_TABLE(KeyConfigPrefs, PrefsPanel)
   EVT_BUTTON(AssignDefaultsButtonID, KeyConfigPrefs::OnDefaults)
   EVT_BUTTON(SetButtonID, KeyConfigPrefs::OnSet)
   EVT_BUTTON(ClearButtonID, KeyConfigPrefs::OnClear)
   EVT_BUTTON(ExportButtonID, KeyConfigPrefs::OnExport)
   EVT_BUTTON(ImportButtonID, KeyConfigPrefs::OnImport)
   EVT_LISTBOX(CommandsListID, KeyConfigPrefs::OnSelected)
   EVT_RADIOBUTTON(ViewByTreeID, KeyConfigPrefs::OnViewBy)
   EVT_RADIOBUTTON(ViewByNameID, KeyConfigPrefs::OnViewBy)
   EVT_RADIOBUTTON(ViewByKeyID, KeyConfigPrefs::OnViewBy)
   EVT_TIMER(FilterTimerID, KeyConfigPrefs::OnFilterTimer)
END_EVENT_TABLE()

KeyConfigPrefs::KeyConfigPrefs(
   wxWindow * parent, wxWindowID winid, AudacityProject *pProject,
   const CommandID &name)
:  PrefsPanel(parent, winid, XO("Shortcuts")),
   mView(NULL),
   mKey(NULL),
   mFilter(NULL),
   mFilterTimer(this, FilterTimerID),
   mFilterPending(false)
   , mProject{ pProject }
{
   Populate();
   if (!name.empty()) {
      auto index = mView->GetIndexByName(name);
      mView->SelectNode(index);
   }

   // See bug #2315 for discussion. This should be reviewed
   // and (possibly) removed after wx3.1.3.
   Bind(wxEVT_SHOW, &KeyConfigPrefs::OnShow, this);
}

ComponentInterfaceSymbol KeyConfigPrefs::GetSymbol() const
{
   return KEY_CONFIG_PREFS_PLUGIN_SYMBOL;
}

TranslatableString KeyConfigPrefs::GetDescription() const
{
   return XO("Preferences for KeyConfig");
}

ManualPageID KeyConfigPrefs::HelpPageName()
{
   return "Keyboard_Preferences";
}

void KeyConfigPrefs::Populate()
{
   ShuttleGui S(this, eIsCreatingFromPrefs);

   if (!mProject) {
      S.StartVerticalLay(true);
      {
         S.StartStatic( {}, true);
         {
            S.AddTitle(XO("Keyboard preferences currently unavailable."));
            S.AddTitle(XO("Open a new project to modify keyboard shortcuts."));
         }
         S.EndStatic();
      }
      S.EndVerticalLay();

      return;
   }

   PopulateOrExchange(S);

   mCommandSelected = wxNOT_FOUND;

   mManager = &CommandManager::Get( *mProject );

   // For speed, don't sort here.  We're just creating.
   // Instead sort when we do SetView later in this function.
   RefreshBindings(false);

   if (mViewByTree->GetValue()) {
      mViewType = ViewByTree;
   }
   else if (mViewByName->GetValue()) {
      mViewType = ViewByName;
   }
   else if (mViewByKey->GetValue()) {
      mViewType = ViewByKey;
      mFilterLabel->SetLabel(_("&Hotkey:"));
      mFilter->SetName(wxStripMenuCodes(mFilterLabel->GetLabel()));
   }

   mView->SetView(mViewType);
}

/// Normally in classes derived from PrefsPanel this function
/// is used both to populate the panel and to exchange data with it.
/// With KeyConfigPrefs all the exchanges are handled specially,
/// so this is only used in populating the panel.
void KeyConfigPrefs::PopulateOrExchange(ShuttleGui & S)
{
   ChoiceSetting Setting{ L"/Prefs/KeyConfig/ViewBy",
      {
         { wxT("tree"), XXO("&Tree") },
         { wxT("name"), XXO("&Name") },
         { wxT("key"), XXO("&Key") },
      },
      0 // tree
   };

   S.SetBorder(2);

   S.StartStatic(XO("Key Bindings"), 1);
   {
      S.StartHorizontalLay(wxEXPAND, 0);
      {
         S.Position(wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL).AddTitle(XO("View by:"));

         // Bug 2692: Place button group in panel so tabbing will work and,
         // on the Mac, VoiceOver will announce as radio buttons.
         S.StartPanel();
         {
            S.StartHorizontalLay();
            {
               S.StartRadioButtonGroup(Setting);
               {
                  mViewByTree = S.Id(ViewByTreeID)
                     .Name(XO("View by tree"))
                     .TieRadioButton();
                  mViewByName = S.Id(ViewByNameID)
                     .Name(XO("View by name"))
                     .TieRadioButton();
                  mViewByKey = S.Id(ViewByKeyID)
                     .Name(XO("View by key"))
                     .TieRadioButton();
#if !defined(__WXMAC__) && wxUSE_ACCESSIBILITY
                  // so that name can be set on a standard control
                  if (mViewByTree) mViewByTree->SetAccessible(safenew WindowAccessible(mViewByTree));
                  if (mViewByName) mViewByName->SetAccessible(safenew WindowAccessible(mViewByName));
                  if (mViewByKey) mViewByKey->SetAccessible(safenew WindowAccessible(mViewByKey));
#endif
               }
               S.EndRadioButtonGroup();
            }
            S.EndHorizontalLay();
         }
         S.EndPanel();

         S.AddSpace(wxDefaultCoord, wxDefaultCoord, 1);

         S.StartHorizontalLay(wxALIGN_CENTER_VERTICAL, 0);
         {
            mFilterLabel = S.Position(wxALIGN_CENTER_VERTICAL).AddVariableText(XO("Searc&h:"));

            if (!mFilter) {
               mFilter = safenew wxTextCtrl(S.GetParent(),
                                        FilterID,
                                        wxT(""),
                                        wxDefaultPosition,
#if defined(__WXMAC__)
                                        wxSize(300, -1),
#else
                                        wxSize(210, -1),
#endif
                                        wxTE_PROCESS_ENTER);
               mFilter->SetName(wxStripMenuCodes(mFilterLabel->GetLabel()));
            }
            S.Position(wxALIGN_NOT | wxALIGN_LEFT)
               .ConnectRoot(wxEVT_KEY_DOWN,
                            &KeyConfigPrefs::OnFilterKeyDown)
               .ConnectRoot(wxEVT_CHAR,
                            &KeyConfigPrefs::OnFilterChar)
               .AddWindow(mFilter);
         }
         S.EndHorizontalLay();
      }
      S.EndHorizontalLay();

      S.AddSpace(wxDefaultCoord, 2);

      S.StartHorizontalLay(wxEXPAND, 1);
      {
         if (!mView) {
            mView = safenew KeyView(S.GetParent(), CommandsListID);
            mView->SetName(_("Bindings"));
         }
         S.Prop(true)
            .Position(wxEXPAND)
            .AddWindow(mView);
      }
      S.EndHorizontalLay();

      S.StartThreeColumn();
      {
         if (!mKey) {
            mKey = safenew wxTextCtrl(S.GetParent(),
                                  CurrentComboID,
                                  wxT(""),
                                  wxDefaultPosition,
#if defined(__WXMAC__)
                                  wxSize(300, -1),
#else
                                  wxSize(210, -1),
#endif
                                  wxTE_PROCESS_ENTER);
#if !defined(__WXMAC__) && wxUSE_ACCESSIBILITY
            // so that name can be set on a standard control
            mKey->SetAccessible(safenew WindowAccessible(mKey));
#endif
            mKey->SetName(_("Short cut"));
         }
         S
            .ConnectRoot(wxEVT_KEY_DOWN,
                      &KeyConfigPrefs::OnHotkeyKeyDown)
            .ConnectRoot(wxEVT_CHAR,
                      &KeyConfigPrefs::OnHotkeyChar)
            .ConnectRoot(wxEVT_KILL_FOCUS,
                      &KeyConfigPrefs::OnHotkeyKillFocus)
            .ConnectRoot(wxEVT_CONTEXT_MENU,
                      &KeyConfigPrefs::OnHotkeyContext)
            .AddWindow(mKey);

         /* i18n-hint: (verb)*/
         mSet = S.Id(SetButtonID).AddButton(XXO("&Set"));
         /* i18n-hint: (verb)*/
         mClear = S.Id(ClearButtonID).AddButton(XXO("Cl&ear"));
      }
      S.EndThreeColumn();

#if defined(__WXMAC__)
      S.AddFixedText(XO("Note: Pressing Cmd+Q will quit. All other keys are valid."));
#endif

      S.StartThreeColumn();
      {
         S.Id(ImportButtonID).AddButton(XXO("&Import..."));
         S.Id(ExportButtonID).AddButton(XXO("&Export..."));
         S.Id(AssignDefaultsButtonID).AddButton(XXO("&Defaults"));
      }
      S.EndThreeColumn();
   }
   S.EndStatic();


   // Need to layout so that the KeyView is properly sized before populating.
   // Otherwise, the initial selection is not scrolled into view.
   Layout();
}

void KeyConfigPrefs::RefreshBindings(bool bSort)
{
   TranslatableStrings Labels;
   TranslatableStrings Categories;
   TranslatableStrings Prefixes;

   mNames.clear();
   mKeys.clear();
   mDefaultKeys.clear();
   mStandardDefaultKeys.clear();
   mManager->GetAllCommandData(
      mNames,
      mKeys,
      mDefaultKeys,
      Labels,
      Categories,
      Prefixes,
      true); // True to include effects (list items), false otherwise.

   mStandardDefaultKeys = mDefaultKeys;
   FilterKeys( mStandardDefaultKeys );

   mView->RefreshBindings(mNames,
                          Categories,
                          Prefixes,
                          Labels,
                          mKeys,
                          bSort);
   //Not needed as NEW nodes are already shown expanded.
   //mView->ExpandAll();

   mNewKeys = mKeys;
}

// RefreshKeyInfo is used to update mKeys vector only
// Introduced for efficiency purposes to avoid unnecessary usage of RefreshBinding
void KeyConfigPrefs::RefreshKeyInfo()
{
   mKeys.clear();

   for (const auto & name : mNames)
      mKeys.push_back(mManager->GetKeyFromName(name));
}

// Removes all shortcuts
// Doesn't call RefreshBindings()
void KeyConfigPrefs::ClearAllKeys()
{
   const NormalizedKeyString noKey{ NO_SHORTCUT };
   for (const auto & command : mNames)
      mManager->SetKeyFromName(command, noKey);
}

// Checks if the given vector of keys contains illegal duplicates.
// In case it does, stores the prefixed labels of operations 
// with illegal key duplicates in fMatching and sMatching.
// Search for duplicates fully implemented here 
// to avoid possible problems with legal shortcut duplicates.  
bool KeyConfigPrefs::ContainsIllegalDups(
   TranslatableString & fMatching, TranslatableString & sMatching) const
{
   using IndexesArray = std::vector<int>;
   std::unordered_map<NormalizedKeyString, IndexesArray> seen;

   for (size_t i{ 0 }; i < mKeys.size(); i++)
   {
      if (mKeys[i] == EMPTY_SHORTCUT || mKeys[i] == NO_SHORTCUT)
         continue;

      if (seen.count(mKeys[i]) == 0)
         seen.insert({ mKeys[i], {(int)i} });
      else
      {
         IndexesArray checkMe{ seen.at(mKeys[i]) };
         for (int index : checkMe)
         {
            if (mDefaultKeys[i] == EMPTY_SHORTCUT ||
               mDefaultKeys[i] != mDefaultKeys[index])
            {
               fMatching = mManager->GetPrefixedLabelFromName(mNames[i]);
               sMatching = mManager->GetPrefixedLabelFromName(mNames[index]);
               return true;
            }
            else
               seen.at(mKeys[i]).push_back(index);
         }
      }
   }
   return false;
}


// This function tries to add the given shortcuts(keys) "toAdd" 
// to the already existing shortcuts(keys). Shortcuts are added only if 
//      1. the shortcut for the operation isn't defined already
//      2. the added shortcut doesn't create illegal shortcut duplicate
// The names of operations for which the second condition was violated 
// are returned in a single error message
TranslatableString KeyConfigPrefs::MergeWithExistingKeys(
   const std::vector<NormalizedKeyString> &toAdd)
{
   TranslatableString disabledShortcuts;

   auto searchAddInKeys = [&](size_t index)
   {
      for (size_t k{ 0 }; k < toAdd.size(); k++)
         if (k == index)
            continue;
         else if (toAdd[index] == mKeys[k] &&
            (mDefaultKeys[k] == EMPTY_SHORTCUT ||
             mDefaultKeys[k] != mDefaultKeys[index]))
            return (int)k;

      return -1;
   };
   
   const NormalizedKeyString noKey{ EMPTY_SHORTCUT };

   for (size_t i{ 0 }; i < toAdd.size(); i++)
   {
      if (mKeys[i] != NO_SHORTCUT)
         continue;
      else if (toAdd[i] == EMPTY_SHORTCUT)
         mManager->SetKeyFromIndex(i, noKey);
      else
      {
         int sRes{ searchAddInKeys(i) };

         if (sRes == -1)
            mManager->SetKeyFromIndex(i, toAdd[i]);
         else
         {
            TranslatableString name{ mManager->GetKeyFromName(mNames[sRes]).GET(), {} };
            
            disabledShortcuts +=
               XO(
"\n   *   \"%s\"  (because the shortcut \'%s\' is used by \"%s\")\n")
                  .Format(
                     mManager->GetPrefixedLabelFromName(mNames[i]),
                     name,
                     mManager->GetPrefixedLabelFromName(mNames[sRes]) );
            
            mManager->SetKeyFromIndex(i, noKey);
         }
      }
   }

   return disabledShortcuts;
}

// See bug #2315 for discussion. This should be reviewed
// and (possibly) removed after wx3.1.3.
void KeyConfigPrefs::OnShow(wxShowEvent & event)
{
   event.Skip();

   // This is required to prevent a crash if Preferences 
   // were opened without a project.
   if (event.IsShown() && mView != nullptr)
   {
      mView->Refresh();
   }
}

void KeyConfigPrefs::OnImport(wxCommandEvent & WXUNUSED(event))
{
   wxString file = wxT("Audacity-keys.xml");

   file = SelectFile(FileNames::Operation::Open,
      XO("Select an XML file containing Audacity keyboard shortcuts..."),
      wxEmptyString,
      file,
      wxT(""),
      { FileNames::XMLFiles, FileNames::AllFiles },
      wxRESIZE_BORDER,
      this);

   if (!file) {
      return;
   }

   // this RefreshKeyInfo is here to account for 
   // potential OnSet() function executions before importing
   RefreshKeyInfo();

   // saving pre-import settings
   const std::vector<NormalizedKeyString> oldKeys{ mKeys };

   // clearing all pre-import settings
   ClearAllKeys();

   // getting new settings
   XMLFileReader reader;
   if (!reader.Parse(mManager, file)) {
      AudacityMessageBox(
         reader.GetErrorStr(),
         XO("Error Importing Keyboard Shortcuts"),
         wxOK | wxCENTRE,
         this);
   }

   RefreshKeyInfo();

   // checking new setting for duplicates
   // if there are duplicates, throwing error and returning to pre-import state
   TranslatableString fMatching;
   TranslatableString sMatching;

   if (ContainsIllegalDups(fMatching, sMatching))
   {
      // restore the old pre-import hotkeys stored in oldKeys
      for (size_t k{ 0 }; k < mNames.size(); k++)
         mManager->SetKeyFromName(mNames[k], oldKeys[k]);
      mKeys = oldKeys;

      // output an error message
      AudacityMessageBox(
         XO(
"The file with the shortcuts contains illegal shortcut duplicates for \"%s\" and \"%s\".\nNothing is imported.")
            .Format( fMatching, sMatching ),
         XO("Error Importing Keyboard Shortcuts"),
         wxICON_ERROR | wxCENTRE, this);

      // stop the function
      return;
   }

   // adding possible old settings to the new settings and recording the conflicting ones
   TranslatableString disabledShortcuts{ MergeWithExistingKeys(oldKeys) };

   RefreshBindings(true);
   
   TranslatableString message{ 
      XO("Loaded %d keyboard shortcuts\n").Format(mManager->GetNumberOfKeysRead()) };

   if (disabledShortcuts.Translation() != (""))
      message += XO("\nThe following commands are not mentioned in the imported file, "
         "but have their shortcuts removed because of the conflict with other new shortcuts:\n") +
         disabledShortcuts;

   AudacityMessageBox(message, XO("Loading Keyboard Shortcuts"), wxOK | wxCENTRE);
}

void KeyConfigPrefs::OnExport(wxCommandEvent & WXUNUSED(event))
{
   wxString file = wxT("Audacity-keys.xml");

   file = SelectFile(FileNames::Operation::Export,
      XO("Export Keyboard Shortcuts As:"),
      wxEmptyString,
      file,
      wxT("xml"),
      { FileNames::XMLFiles, FileNames::AllFiles },
      wxFD_SAVE | wxFD_OVERWRITE_PROMPT | wxRESIZE_BORDER,
      this);

   if (!file) {
      return;
   }

   GuardedCall( [&] {
      XMLFileWriter prefFile{ file, XO("Error Exporting Keyboard Shortcuts") };
      mManager->WriteXML(prefFile);
      prefFile.Commit();
   } );
}



// There currently is only one clickable AButton
// so we just do what it needs.
void KeyConfigPrefs::OnDefaults(wxCommandEvent & WXUNUSED(event))
{
   wxMenu Menu;
   Menu.Append( 1, _("Standard") );
   Menu.Append( 2, _("Full") );
   Menu.Bind( wxEVT_COMMAND_MENU_SELECTED, &KeyConfigPrefs::OnImportDefaults, this );
   BasicMenu::Handle( &Menu ).Popup( wxWidgetsWindowPlacement{ this } );
}

void KeyConfigPrefs::FilterKeys( std::vector<NormalizedKeyString> & arr )
{
   const auto &MaxListOnly = CommandManager::ExcludedList();

   // Remove items that are in MaxList.
   for (size_t i = 0; i < arr.size(); i++) {
      if( std::binary_search(MaxListOnly.begin(), MaxListOnly.end(), arr[i]) )
         arr[i] = {};
   }
}

void KeyConfigPrefs::OnImportDefaults(wxCommandEvent & event)
{
   gPrefs->DeleteEntry(wxT("/GUI/Shortcuts/FullDefaults"));
   gPrefs->Flush();

   mNewKeys = mDefaultKeys;
   if( event.GetId() == 1 )
      FilterKeys( mNewKeys );

   for (size_t i = 0; i < mNewKeys.size(); i++) {
      mManager->SetKeyFromIndex(i, mNewKeys[i]);
   }

   RefreshBindings(true);
}

void KeyConfigPrefs::OnHotkeyKeyDown(wxKeyEvent & e)
{
   wxTextCtrl *t = (wxTextCtrl *)e.GetEventObject();

   // Make sure we can navigate away from the hotkey textctrl.
   // On Linux and OSX, it can get stuck, but it doesn't hurt
   // to do it for Windows as well.
   //
   // Mac note:  Don't waste time trying to figure out why the
   // focus goes back to the prefs tree.  Unless Voiceover is
   // active, buttons on the Mac do not accept focus and all the
   // controls between this one and the tree control are buttons.
   if (e.GetKeyCode() == WXK_TAB) {
      t->Navigate(e.ShiftDown()
                 ? wxNavigationKeyEvent::IsBackward
                 : wxNavigationKeyEvent::IsForward);
      return;
   }

   t->SetValue(KeyEventToKeyString(e).Display());
}

void KeyConfigPrefs::OnHotkeyChar(wxEvent & WXUNUSED(e))
{
   // event.Skip() not performed, so event will not be processed further.
}

void KeyConfigPrefs::OnHotkeyKillFocus(wxEvent & e)
{
   if (mKey->GetValue().empty() && mCommandSelected != wxNOT_FOUND) {
      mKey->AppendText(mView->GetKey(mCommandSelected).Display());
   }

   e.Skip();
}

void KeyConfigPrefs::OnHotkeyContext(wxEvent & WXUNUSED(e))
{
   // event.Skip() not performed, so event will not be processed further.
}

void KeyConfigPrefs::OnFilterTimer(wxTimerEvent & WXUNUSED(e))
{
   // The filter timer has expired, so set the filter
   if (mFilterPending)
   {
      // Do not reset mFilterPending here...possible race
      mView->SetFilter(mFilter->GetValue());
   }
}

void KeyConfigPrefs::OnFilterKeyDown(wxKeyEvent & e)
{
   wxTextCtrl *t = (wxTextCtrl *)e.GetEventObject();
   int keycode = e.GetKeyCode();

   // Make sure we can navigate away from the hotkey textctrl.
   // On Linux and OSX, it an get stuck, but it doesn't hurt
   // to do it for Windows as well.
   if (keycode == WXK_TAB) {
      wxNavigationKeyEvent nevent;
      nevent.SetWindowChange(e.ControlDown());
      nevent.SetDirection(!e.ShiftDown());
      nevent.SetEventObject(t);
      nevent.SetCurrentFocus(t);
      t->GetParent()->GetEventHandler()->ProcessEvent(nevent);

      return;
   }

   if (mViewType == ViewByKey) {
      wxString key = KeyEventToKeyString(e).Display();
      t->SetValue(key);

      if (!key.empty()) {
         mView->SetFilter(t->GetValue());
      }
   }
   else
   {
      if (keycode == WXK_RETURN) {
         mFilterPending = false;
         mView->SetFilter(t->GetValue());
      }
      else {
         mFilterPending = true;
         mFilterTimer.Start(500, wxTIMER_ONE_SHOT);

         e.Skip();
      }
   }
}

void KeyConfigPrefs::OnFilterChar(wxEvent & e)
{
   if (mViewType != ViewByKey)
   {
      e.Skip();
   }
}

// Given a hotkey combination, returns the name (description) of the
// corresponding command, or the empty string if none is found.
CommandID KeyConfigPrefs::NameFromKey(const NormalizedKeyString & key)
{
   return mView->GetNameByKey(key);
}

// Sets the selected command to have this key
// This is not yet a committed change, which will happen on a save.
void KeyConfigPrefs::SetKeyForSelected(const NormalizedKeyString & key)
{
   auto name = mView->GetName(mCommandSelected);

   if (!mView->CanSetKey(mCommandSelected))
   {
      AudacityMessageBox(
         XO("You may not assign a key to this entry"),
         XO("Error"),
         wxICON_ERROR | wxCENTRE,
         this);
      return;
   }

   mView->SetKey(mCommandSelected, key);
   mManager->SetKeyFromName(name, key);
   mNewKeys[ make_iterator_range( mNames ).index( name ) ] = key;
}


void KeyConfigPrefs::OnSet(wxCommandEvent & WXUNUSED(event))
{
   if (mCommandSelected == wxNOT_FOUND) {
      AudacityMessageBox(
         XO("You must select a binding before assigning a shortcut"),
         XO("Error"),
         wxICON_WARNING | wxCENTRE,
         this);
      return;
   }

   CommandID newCommand{ mView->GetName(mCommandSelected) };
   NormalizedKeyString enteredKey{ mKey->GetValue() };
   NormalizedKeyString newComDefaultKey{ 
      mManager->GetDefaultKeyFromName(newCommand) };
   CommandIDs oldCommands;

   // collecting commands competing for the same shortcut
   for (size_t i{ 0 }; i < mNames.size(); i++)
   {
      if (mNewKeys[i] == enteredKey)
      {
         // ignore the Set button if the same shortcut is used
         if (mNames[i] == newCommand)
            return;

         if (newComDefaultKey == EMPTY_SHORTCUT ||
            mDefaultKeys[i] != newComDefaultKey)
         {
            oldCommands.push_back(mNames[i]);
         }
      }
   }

   // Prevent same hotkey combination being used twice.
   if (!oldCommands.empty()) {
      auto newlabel = Verbatim( wxT("'%s - %s'") )
         .Format(
            mManager->GetCategoryFromName(newCommand),
            mManager->GetPrefixedLabelFromName(newCommand) );
      auto oldlabel = Verbatim(wxT("'%s - %s'"))
         .Format(
            mManager->GetCategoryFromName(oldCommands[0]),
            mManager->GetPrefixedLabelFromName(oldCommands[0]));

      for (size_t i{ 1 }; i < oldCommands.size(); i++)
         oldlabel += XO("\n\n\t and\n\n\t") +
         Verbatim(wxT("'%s - %s'")).Format(
            mManager->GetCategoryFromName(oldCommands[i]),
            mManager->GetPrefixedLabelFromName(oldCommands[i]));
      
      if (wxCANCEL == AudacityMessageBox(
            XO(
"The keyboard shortcut '%s' is already assigned to:\n\n\t%s\n\n\nClick OK to assign the shortcut to\n\n\t%s\n\ninstead. Otherwise, click Cancel.")
               .Format(
                  mKey->GetValue(),
                  oldlabel,
                  newlabel
               ),
            XO("Warning"),
            wxOK | wxCANCEL | wxICON_STOP | wxCENTRE,
            this))
      {
         return;
      }
      
      for (const auto & command : oldCommands)
      {
         mView->SetKeyByName(command, {});
         mManager->SetKeyFromName(command, {});
         mNewKeys[make_iterator_range(mNames).index(command)] = {};
      }
   }

   SetKeyForSelected(enteredKey);
}

void KeyConfigPrefs::OnClear(wxCommandEvent& WXUNUSED(event))
{
   mKey->Clear();

   if (mCommandSelected != wxNOT_FOUND) {
      SetKeyForSelected({});
   }
}

void KeyConfigPrefs::OnSelected(wxCommandEvent & WXUNUSED(e))
{
   mCommandSelected = mView->GetSelected();
   mKey->Clear();

   if (mCommandSelected != wxNOT_FOUND) {
      bool canset = mView->CanSetKey(mCommandSelected);
      if (canset) {
         mKey->AppendText(mView->GetKey(mCommandSelected).Display());
      }

      mKey->Enable(canset);
      mSet->Enable(canset);
      mClear->Enable(canset);
   }
}

void KeyConfigPrefs::OnViewBy(wxCommandEvent & e)
{
   switch (e.GetId())
   {
      case ViewByTreeID:
         mViewType = ViewByTree;
         mFilterLabel->SetLabel(_("Searc&h:"));
      break;

      case ViewByNameID:
         mViewType = ViewByName;
         mFilterLabel->SetLabel(_("Searc&h:"));
      break;

      case ViewByKeyID:
         mViewType = ViewByKey;
         mFilterLabel->SetLabel(_("&Hotkey:"));
      break;
   }

   mView->SetView(mViewType);
   mFilter->SetName(wxStripMenuCodes(mFilterLabel->GetLabel()));
}

bool KeyConfigPrefs::Commit()
{
   // On the Mac, preferences may be changed without any active
   // projects.  This means that the CommandManager isn't available
   // either.  So we can't attempt to save preferences, otherwise
   // NULL ptr dereferences will happen in ShuttleGui because the
   // radio buttons are never created.  (See Populate() above.)
   if ( !mProject ) {
      return true;
   }

   ShuttleGui S(this, eIsSavingToPrefs);
   PopulateOrExchange(S);

   bool bFull = gPrefs->ReadBool(wxT("/GUI/Shortcuts/FullDefaults"), false);
   for (size_t i = 0; i < mNames.size(); i++) {
      const auto &dkey = bFull ? mDefaultKeys[i] : mStandardDefaultKeys[i];
      // using GET to interpret CommandID as a config path component
      auto name = wxT("/NewKeys/") + mNames[i].GET();
      const auto &key = mNewKeys[i];

      if (gPrefs->HasEntry(name)) {
         if (key != NormalizedKeyString{ gPrefs->ReadObject(name, key) } ) {
            gPrefs->Write(name, key);
         }
         if (key == dkey) {
            gPrefs->DeleteEntry(name);
         }
      }
      else {
         if (key != dkey) {
            gPrefs->Write(name, key);
         }
      }
   }

   return gPrefs->Flush();
}

void KeyConfigPrefs::Cancel()
{
   // Restore original key values
   for (size_t i = 0; i < mNames.size(); i++) {
      mManager->SetKeyFromIndex(i, mKeys[i]);
   }

   return;
}

PrefsPanel::Factory
KeyConfigPrefsFactory( const CommandID &name )
{
   return [=](wxWindow *parent, wxWindowID winid, AudacityProject *pProject)
   {
      wxASSERT(parent); // to justify safenew
      auto result = safenew KeyConfigPrefs{ parent, winid, pProject, name };
      return result;
   };
}
namespace{
PrefsPanel::Registration sAttachment{ "KeyConfig",
   KeyConfigPrefsFactory()
};
}
