diff --git a/.prettierignore b/.prettierignore index 1fafca728e1..f5fc58936ac 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ # Ignore submodule files lib/*/ conan-pkgs/*/ +cmake/sanitizers-cmake/ .github/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bdfc0b1205..947bf049d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Major: Added customizable shortcuts. (#2340) - Minor: Added middle click split to open in browser (#3356) - Minor: Added new search predicate to filter for messages matching a regex (#3282) - Minor: Add `{channel.name}`, `{channel.id}`, `{stream.game}`, `{stream.title}`, `{my.id}`, `{my.name}` placeholders for commands (#3155) diff --git a/chatterino.pro b/chatterino.pro index fb850cd1329..a8e9f184313 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -159,6 +159,10 @@ SOURCES += \ src/controllers/highlights/HighlightModel.cpp \ src/controllers/highlights/HighlightPhrase.cpp \ src/controllers/highlights/UserHighlightModel.cpp \ + src/controllers/hotkeys/Hotkey.cpp \ + src/controllers/hotkeys/HotkeyController.cpp \ + src/controllers/hotkeys/HotkeyHelpers.cpp \ + src/controllers/hotkeys/HotkeyModel.cpp \ src/controllers/ignores/IgnoreController.cpp \ src/controllers/ignores/IgnoreModel.cpp \ src/controllers/moderationactions/ModerationAction.cpp \ @@ -266,6 +270,7 @@ SOURCES += \ src/widgets/dialogs/BadgePickerDialog.cpp \ src/widgets/dialogs/ChannelFilterEditorDialog.cpp \ src/widgets/dialogs/ColorPickerDialog.cpp \ + src/widgets/dialogs/EditHotkeyDialog.cpp \ src/widgets/dialogs/EmotePopup.cpp \ src/widgets/dialogs/IrcConnectionEditor.cpp \ src/widgets/dialogs/LastRunCrashDialog.cpp \ @@ -389,6 +394,12 @@ HEADERS += \ src/controllers/highlights/HighlightModel.hpp \ src/controllers/highlights/HighlightPhrase.hpp \ src/controllers/highlights/UserHighlightModel.hpp \ + src/controllers/hotkeys/ActionNames.hpp \ + src/controllers/hotkeys/Hotkey.hpp \ + src/controllers/hotkeys/HotkeyCategory.hpp \ + src/controllers/hotkeys/HotkeyController.hpp \ + src/controllers/hotkeys/HotkeyHelpers.hpp \ + src/controllers/hotkeys/HotkeyModel.hpp \ src/controllers/ignores/IgnoreController.hpp \ src/controllers/ignores/IgnoreModel.hpp \ src/controllers/ignores/IgnorePhrase.hpp \ @@ -512,7 +523,6 @@ HEADERS += \ src/util/SampleCheerMessages.hpp \ src/util/SampleLinks.hpp \ src/util/SharedPtrElementLess.hpp \ - src/util/Shortcut.hpp \ src/util/SplitCommand.hpp \ src/util/StandardItemHelper.hpp \ src/util/StreamerMode.hpp \ @@ -528,6 +538,7 @@ HEADERS += \ src/widgets/dialogs/BadgePickerDialog.hpp \ src/widgets/dialogs/ChannelFilterEditorDialog.hpp \ src/widgets/dialogs/ColorPickerDialog.hpp \ + src/widgets/dialogs/EditHotkeyDialog.hpp \ src/widgets/dialogs/EmotePopup.hpp \ src/widgets/dialogs/IrcConnectionEditor.hpp \ src/widgets/dialogs/LastRunCrashDialog.hpp \ @@ -604,7 +615,8 @@ RESOURCES += \ DISTFILES += FORMS += \ - src/widgets/dialogs/IrcConnectionEditor.ui + src/widgets/dialogs/IrcConnectionEditor.ui \ + src/widgets/dialogs/EditHotkeyDialog.ui # do not use windows min/max macros #win32 { diff --git a/src/Application.cpp b/src/Application.cpp index 4de581187d8..01306c58cf9 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -7,6 +7,7 @@ #include "common/Version.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/CommandController.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/notifications/NotificationController.hpp" #include "debug/AssertInGuiThread.hpp" @@ -54,6 +55,7 @@ Application::Application(Settings &_settings, Paths &_paths) , fonts(&this->emplace()) , emotes(&this->emplace()) , accounts(&this->emplace()) + , hotkeys(&this->emplace()) , windows(&this->emplace()) , toasts(&this->emplace()) diff --git a/src/Application.hpp b/src/Application.hpp index 9c575ae3af7..f05183f7b74 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -15,6 +15,7 @@ class PubSub; class CommandController; class AccountController; class NotificationController; +class HotkeyController; class Theme; class WindowManager; @@ -51,6 +52,7 @@ class Application Fonts *const fonts{}; Emotes *const emotes{}; AccountController *const accounts{}; + HotkeyController *const hotkeys{}; WindowManager *const windows{}; Toasts *const toasts{}; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 37fb9f4a5be..561983d3776 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -88,6 +88,17 @@ set(SOURCE_FILES controllers/highlights/UserHighlightModel.cpp controllers/highlights/UserHighlightModel.hpp + controllers/hotkeys/ActionNames.hpp + controllers/hotkeys/Hotkey.cpp + controllers/hotkeys/Hotkey.hpp + controllers/hotkeys/HotkeyCategory.hpp + controllers/hotkeys/HotkeyController.cpp + controllers/hotkeys/HotkeyController.hpp + controllers/hotkeys/HotkeyHelpers.cpp + controllers/hotkeys/HotkeyHelpers.hpp + controllers/hotkeys/HotkeyModel.cpp + controllers/hotkeys/HotkeyModel.hpp + controllers/ignores/IgnoreController.cpp controllers/ignores/IgnoreController.hpp controllers/ignores/IgnoreModel.cpp @@ -335,6 +346,8 @@ set(SOURCE_FILES widgets/dialogs/ChannelFilterEditorDialog.hpp widgets/dialogs/ColorPickerDialog.cpp widgets/dialogs/ColorPickerDialog.hpp + widgets/dialogs/EditHotkeyDialog.cpp + widgets/dialogs/EditHotkeyDialog.hpp widgets/dialogs/EmotePopup.cpp widgets/dialogs/EmotePopup.hpp widgets/dialogs/IrcConnectionEditor.cpp diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index a54b3296c68..a9edaef86ce 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -15,6 +15,7 @@ Q_LOGGING_CATEGORY(chatterinoCommon, "chatterino.common", logThreshold); Q_LOGGING_CATEGORY(chatterinoEmoji, "chatterino.emoji", logThreshold); Q_LOGGING_CATEGORY(chatterinoFfzemotes, "chatterino.ffzemotes", logThreshold); Q_LOGGING_CATEGORY(chatterinoHelper, "chatterino.helper", logThreshold); +Q_LOGGING_CATEGORY(chatterinoHotkeys, "chatterino.hotkeys", logThreshold); Q_LOGGING_CATEGORY(chatterinoHTTP, "chatterino.http", logThreshold); Q_LOGGING_CATEGORY(chatterinoImage, "chatterino.image", logThreshold); Q_LOGGING_CATEGORY(chatterinoIrc, "chatterino.irc", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index e588ad48ac2..2687b7862bc 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -11,6 +11,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoCommon); Q_DECLARE_LOGGING_CATEGORY(chatterinoEmoji); Q_DECLARE_LOGGING_CATEGORY(chatterinoFfzemotes); Q_DECLARE_LOGGING_CATEGORY(chatterinoHelper); +Q_DECLARE_LOGGING_CATEGORY(chatterinoHotkeys); Q_DECLARE_LOGGING_CATEGORY(chatterinoHTTP); Q_DECLARE_LOGGING_CATEGORY(chatterinoImage); Q_DECLARE_LOGGING_CATEGORY(chatterinoIrc); diff --git a/src/controllers/hotkeys/ActionNames.hpp b/src/controllers/hotkeys/ActionNames.hpp new file mode 100644 index 00000000000..ff5d7679050 --- /dev/null +++ b/src/controllers/hotkeys/ActionNames.hpp @@ -0,0 +1,203 @@ +#pragma once + +#include "HotkeyCategory.hpp" + +#include + +#include + +namespace chatterino { + +// ActionDefinition is an action that can be performed with a hotkey +struct ActionDefinition { + // displayName is the value that would be shown to a user when they edit or create a hotkey for an action + QString displayName; + + QString argumentDescription = ""; + + // minCountArguments is the minimum amount of arguments the action accepts + // Example action: "Select Tab" in a popup window accepts 1 argument for which tab to select + uint8_t minCountArguments = 0; + + // maxCountArguments is the maximum amount of arguments the action accepts + uint8_t maxCountArguments = minCountArguments; +}; + +using ActionDefinitionMap = std::map; + +inline const std::map actionNames{ + {HotkeyCategory::PopupWindow, + { + {"reject", ActionDefinition{"Confirmable popups: Cancel"}}, + {"accept", ActionDefinition{"Confirmable popups: Confirm"}}, + {"delete", ActionDefinition{"Close"}}, + {"openTab", + ActionDefinition{ + "Select Tab", + "", + 1, + }}, + {"scrollPage", + ActionDefinition{ + "Scroll", + "", + 1, + }}, + {"search", ActionDefinition{"Focus search box"}}, + }}, + {HotkeyCategory::Split, + { + {"changeChannel", ActionDefinition{"Change channel"}}, + {"clearMessages", ActionDefinition{"Clear messages"}}, + {"createClip", ActionDefinition{"Create a clip"}}, + {"delete", ActionDefinition{"Close"}}, + {"focus", + ActionDefinition{ + "Focus neighbouring split", + "", + 1, + }}, + {"openInBrowser", ActionDefinition{"Open channel in browser"}}, + {"openInCustomPlayer", + ActionDefinition{"Open stream in custom player"}}, + {"openInStreamlink", ActionDefinition{"Open stream in streamlink"}}, + {"openModView", ActionDefinition{"Open mod view in browser"}}, + {"openViewerList", ActionDefinition{"Open viewer list"}}, + {"pickFilters", ActionDefinition{"Pick filters"}}, + {"reconnect", ActionDefinition{"Reconnect to chat"}}, + {"reloadEmotes", + ActionDefinition{ + "Reload emotes", + "[channel or subscriber]", + 0, + 1, + }}, + {"runCommand", + ActionDefinition{ + "Run a command", + "", + 1, + }}, + {"scrollPage", + ActionDefinition{ + "Scroll", + "", + 1, + }}, + {"scrollToBottom", ActionDefinition{"Scroll to the bottom"}}, + {"setChannelNotification", + ActionDefinition{ + "Set channel live notification", + "[on or off. default: toggle]", + 0, + 1, + }}, + {"setModerationMode", + ActionDefinition{ + "Set moderation mode", + "[on or off. default: toggle]", + 0, + 1, + }}, + {"showSearch", ActionDefinition{"Search"}}, + {"startWatching", ActionDefinition{"Start watching"}}, + {"debug", ActionDefinition{"Show debug popup"}}, + }}, + {HotkeyCategory::SplitInput, + { + {"clear", ActionDefinition{"Clear message"}}, + {"copy", + ActionDefinition{ + "Copy", + "", + 1, + }}, + {"cursorToStart", + ActionDefinition{ + "To start of message", + "", + 1, + }}, + {"cursorToEnd", + ActionDefinition{ + "To end of message", + "", + 1, + }}, + {"nextMessage", ActionDefinition{"Choose next sent message"}}, + {"openEmotesPopup", ActionDefinition{"Open emotes list"}}, + {"paste", ActionDefinition{"Paste"}}, + {"previousMessage", + ActionDefinition{"Choose previously sent message"}}, + {"redo", ActionDefinition{"Redo"}}, + {"selectAll", ActionDefinition{"Select all"}}, + {"sendMessage", + ActionDefinition{ + "Send message", + "[keepInput to not clear the text after sending]", + 0, + 1, + }}, + {"undo", ActionDefinition{"Undo"}}, + + }}, + {HotkeyCategory::Window, + { +#ifdef C_DEBUG + {"addCheerMessage", ActionDefinition{"Debug: Add cheer test message"}}, + {"addEmoteMessage", ActionDefinition{"Debug: Add emote test message"}}, + {"addLinkMessage", + ActionDefinition{"Debug: Add test message with a link"}}, + {"addMiscMessage", ActionDefinition{"Debug: Add misc test message"}}, + {"addRewardMessage", + ActionDefinition{"Debug: Add reward test message"}}, +#endif + {"moveTab", + ActionDefinition{ + "Move tab", + "", + 1, + }}, + {"newSplit", ActionDefinition{"Create a new split"}}, + {"newTab", ActionDefinition{"Create a new tab"}}, + {"openSettings", ActionDefinition{"Open settings"}}, + {"openTab", + ActionDefinition{ + "Select tab", + "", + 1, + }}, + {"openQuickSwitcher", ActionDefinition{"Open the quick switcher"}}, + {"popup", + ActionDefinition{ + "New popup", + "", + 1, + }}, + {"quit", ActionDefinition{"Quit Chatterino"}}, + {"removeTab", ActionDefinition{"Remove current tab"}}, + {"reopenSplit", ActionDefinition{"Reopen closed split"}}, + {"setStreamerMode", + ActionDefinition{ + "Set streamer mode", + "[on, off, toggle, or auto. default: toggle]", + 0, + 1, + }}, + {"toggleLocalR9K", ActionDefinition{"Toggle local R9K"}}, + {"zoom", + ActionDefinition{ + "Zoom in/out", + "", + 1, + }}, + {"setTabVisibility", + ActionDefinition{ + "Set tab visibility", + "[on, off, or toggle. default: toggle]", + 0, + 1, + }}}}, +}; + +} // namespace chatterino diff --git a/src/controllers/hotkeys/Hotkey.cpp b/src/controllers/hotkeys/Hotkey.cpp new file mode 100644 index 00000000000..99017e08c5a --- /dev/null +++ b/src/controllers/hotkeys/Hotkey.cpp @@ -0,0 +1,93 @@ +#include "controllers/hotkeys/Hotkey.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "controllers/hotkeys/ActionNames.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" + +namespace chatterino { + +Hotkey::Hotkey(HotkeyCategory category, QKeySequence keySequence, + QString action, std::vector arguments, QString name) + : category_(category) + , keySequence_(keySequence) + , action_(action) + , arguments_(arguments) + , name_(name) +{ +} + +const QKeySequence &Hotkey::keySequence() const +{ + return this->keySequence_; +} + +QString Hotkey::name() const +{ + return this->name_; +} + +HotkeyCategory Hotkey::category() const +{ + return this->category_; +} + +QString Hotkey::action() const +{ + return this->action_; +} + +bool Hotkey::validAction() const +{ + auto categoryActionsIt = actionNames.find(this->category_); + if (categoryActionsIt == actionNames.end()) + { + // invalid category + return false; + } + + auto actionDefinitionIt = categoryActionsIt->second.find(this->action()); + + return actionDefinitionIt != categoryActionsIt->second.end(); +} + +std::vector Hotkey::arguments() const +{ + return this->arguments_; +} + +QString Hotkey::getCategory() const +{ + return getApp()->hotkeys->categoryDisplayName(this->category_); +} + +Qt::ShortcutContext Hotkey::getContext() const +{ + switch (this->category_) + { + case HotkeyCategory::Window: + return Qt::WindowShortcut; + case HotkeyCategory::Split: + return Qt::WidgetWithChildrenShortcut; + case HotkeyCategory::SplitInput: + return Qt::WidgetWithChildrenShortcut; + case HotkeyCategory::PopupWindow: + return Qt::WindowShortcut; + } + qCDebug(chatterinoHotkeys) + << "Using default shortcut context for" << this->getCategory() + << "and hopeing for the best."; + return Qt::WidgetShortcut; +} + +QString Hotkey::toString() const +{ + return this->keySequence().toString(QKeySequence::NativeText); +} + +QString Hotkey::toPortableString() const +{ + return this->keySequence().toString(QKeySequence::PortableText); +} + +} // namespace chatterino diff --git a/src/controllers/hotkeys/Hotkey.hpp b/src/controllers/hotkeys/Hotkey.hpp new file mode 100644 index 00000000000..8952c2ffbde --- /dev/null +++ b/src/controllers/hotkeys/Hotkey.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include "controllers/hotkeys/HotkeyCategory.hpp" + +#include +#include + +namespace chatterino { + +class Hotkey +{ +public: + Hotkey(HotkeyCategory category, QKeySequence keySequence, QString action, + std::vector arguments, QString name); + virtual ~Hotkey() = default; + + /** + * @brief Returns the OS-specific string representation of the hotkey + * + * Suitable for showing in the GUI + * e.g. Ctrl+F5 or Command+F5 + */ + QString toString() const; + + /** + * @brief Returns the portable string representation of the hotkey + * + * Suitable for saving to/loading from file + * e.g. Ctrl+F5 or Shift+Ctrl+R + */ + QString toPortableString() const; + + /** + * @brief Returns the category where this hotkey is active. This is labeled the "Category" in the UI. + * + * See enum HotkeyCategory for more information about the various hotkey categories + */ + HotkeyCategory category() const; + + /** + * @brief Returns the action which describes what this Hotkey is meant to do + * + * For example, in the Window category there's a "showSearch" action which opens a search popup + */ + QString action() const; + + bool validAction() const; + + /** + * @brief Returns a list of arguments this hotkey has bound to it + * + * Some actions require a set of arguments that the user can provide, for example the "openTab" action takes an argument for which tab to switch to. can be a number or a word like next or previous + */ + std::vector arguments() const; + + /** + * @brief Returns the display name of the hotkey + * + * For example, in the Split category there's a "showSearch" action that has a default hotkey with the name "default show search" + */ + QString name() const; + + /** + * @brief Returns the user-friendly text representation of the hotkeys category + * + * Suitable for showing in the GUI. + * e.g. Split input box for HotkeyCategory::SplitInput + */ + QString getCategory() const; + + /** + * @brief Returns the programmating key sequence of the hotkey + * + * The actual key codes required for the hotkey to trigger specifically on e.g CTRL+F5 + */ + const QKeySequence &keySequence() const; + +private: + HotkeyCategory category_; + QKeySequence keySequence_; + QString action_; + std::vector arguments_; + QString name_; + + /** + * @brief Returns the programmatic context of the hotkey to help Qt decide how to apply the hotkey + * + * The returned value is based off the hotkeys given category + */ + Qt::ShortcutContext getContext() const; + + friend class HotkeyController; +}; + +} // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyCategory.hpp b/src/controllers/hotkeys/HotkeyCategory.hpp new file mode 100644 index 00000000000..5110e48d74a --- /dev/null +++ b/src/controllers/hotkeys/HotkeyCategory.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace chatterino { + +// HotkeyCategory describes where the hotkeys action takes place. +// Each HotkeyCategory represents a widget that has customizable hotkeys. This +// is needed because more than one widget can have the same or similar action. +enum class HotkeyCategory { + PopupWindow, + Split, + SplitInput, + Window, +}; + +struct HotkeyCategoryData { + QString name; + QString displayName; +}; + +} // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyController.cpp b/src/controllers/hotkeys/HotkeyController.cpp new file mode 100644 index 00000000000..2dafc0c702f --- /dev/null +++ b/src/controllers/hotkeys/HotkeyController.cpp @@ -0,0 +1,531 @@ +#include "controllers/hotkeys/HotkeyController.hpp" + +#include "common/QLogging.hpp" +#include "controllers/hotkeys/HotkeyModel.hpp" +#include "singletons/Settings.hpp" + +#include + +namespace chatterino { + +static bool hotkeySortCompare_(const std::shared_ptr &a, + const std::shared_ptr &b) +{ + if (a->category() == b->category()) + { + return a->name() < b->name(); + } + + return a->category() < b->category(); +} + +HotkeyController::HotkeyController() + : hotkeys_(hotkeySortCompare_) +{ + this->loadHotkeys(); + this->signalHolder_.managedConnect( + this->hotkeys_.delayedItemsChanged, [this]() { + qCDebug(chatterinoHotkeys) << "Reloading hotkeys!"; + this->onItemsUpdated.invoke(); + }); +} + +HotkeyModel *HotkeyController::createModel(QObject *parent) +{ + HotkeyModel *model = new HotkeyModel(parent); + model->initialize(&this->hotkeys_); + return model; +} + +std::vector HotkeyController::shortcutsForCategory( + HotkeyCategory category, + std::map)>> actionMap, + QWidget *parent) +{ + std::vector output; + for (const auto &hotkey : this->hotkeys_) + { + if (hotkey->category() != category) + { + continue; + } + auto target = actionMap.find(hotkey->action()); + if (target == actionMap.end()) + { + qCDebug(chatterinoHotkeys) + << qPrintable(parent->objectName()) + << "Unimplemeneted hotkey action:" << hotkey->action() << "in " + << hotkey->getCategory(); + continue; + } + if (!target->second) + { + // Widget has chosen to explicitly not handle this action + continue; + } + auto createShortcutFromKeySeq = [&](QKeySequence qs) { + auto s = new QShortcut(qs, parent); + s->setContext(hotkey->getContext()); + auto functionPointer = target->second; + QObject::connect(s, &QShortcut::activated, parent, + [functionPointer, hotkey, this]() { + QString output = + functionPointer(hotkey->arguments()); + if (!output.isEmpty()) + { + this->showHotkeyError(hotkey, output); + } + }); + output.push_back(s); + }; + auto qs = QKeySequence(hotkey->keySequence()); + + auto stringified = qs.toString(QKeySequence::NativeText); + if (stringified.contains("Return")) + { + stringified.replace("Return", "Enter"); + auto copy = QKeySequence(stringified, QKeySequence::NativeText); + createShortcutFromKeySeq(copy); + } + createShortcutFromKeySeq(qs); + } + return output; +} + +void HotkeyController::save() +{ + this->saveHotkeys(); +} + +std::shared_ptr HotkeyController::getHotkeyByName(QString name) +{ + for (auto &hotkey : this->hotkeys_) + { + if (hotkey->name() == name) + { + return hotkey; + } + } + return nullptr; +} + +int HotkeyController::replaceHotkey(QString oldName, + std::shared_ptr newHotkey) +{ + int i = 0; + for (auto &hotkey : this->hotkeys_) + { + if (hotkey->name() == oldName) + { + this->hotkeys_.removeAt(i); + break; + } + i++; + } + return this->hotkeys_.append(newHotkey); +} + +boost::optional HotkeyController::hotkeyCategoryFromName( + QString categoryName) +{ + for (const auto &[category, data] : this->categories()) + { + if (data.name == categoryName) + { + return category; + } + } + qCDebug(chatterinoHotkeys) << "Unknown category: " << categoryName; + return {}; +} + +bool HotkeyController::isDuplicate(std::shared_ptr hotkey, + QString ignoreNamed) +{ + for (const auto &shared : this->hotkeys_) + { + if (shared->name() == ignoreNamed || shared->name() == hotkey->name()) + { + // Given hotkey is the same as shared, just before it was being edited. + continue; + } + + if (shared->category() == hotkey->category() && + shared->keySequence() == hotkey->keySequence()) + { + return true; + } + } + return false; +} + +QString HotkeyController::categoryDisplayName(HotkeyCategory category) const +{ + if (this->hotkeyCategories_.count(category) == 0) + { + qCWarning(chatterinoHotkeys) << "Invalid HotkeyCategory passed to " + "categoryDisplayName function"; + return QString(); + } + + const auto &categoryData = this->hotkeyCategories_.at(category); + + return categoryData.displayName; +} + +QString HotkeyController::categoryName(HotkeyCategory category) const +{ + if (this->hotkeyCategories_.count(category) == 0) + { + qCWarning(chatterinoHotkeys) << "Invalid HotkeyCategory passed to " + "categoryName function"; + return QString(); + } + + const auto &categoryData = this->hotkeyCategories_.at(category); + + return categoryData.name; +} + +const std::map + &HotkeyController::categories() const +{ + return this->hotkeyCategories_; +} + +void HotkeyController::loadHotkeys() +{ + auto defaultHotkeysAdded = + pajlada::Settings::Setting>::get( + "/hotkeys/addedDefaults"); + auto set = std::set(defaultHotkeysAdded.begin(), + defaultHotkeysAdded.end()); + + auto keys = pajlada::Settings::SettingManager::getObjectKeys("/hotkeys"); + this->addDefaults(set); + pajlada::Settings::Setting>::set( + "/hotkeys/addedDefaults", std::vector(set.begin(), set.end())); + + qCDebug(chatterinoHotkeys) << "Loading hotkeys..."; + for (const auto &key : keys) + { + if (key == "addedDefaults") + { + continue; + } + + auto section = "/hotkeys/" + key; + auto categoryName = + pajlada::Settings::Setting::get(section + "/category"); + auto keySequence = + pajlada::Settings::Setting::get(section + "/keySequence"); + auto action = + pajlada::Settings::Setting::get(section + "/action"); + auto arguments = pajlada::Settings::Setting>::get( + section + "/arguments"); + qCDebug(chatterinoHotkeys) + << "Hotkey " << categoryName << keySequence << action << arguments; + + if (categoryName.isEmpty() || keySequence.isEmpty() || action.isEmpty()) + { + continue; + } + auto category = this->hotkeyCategoryFromName(categoryName); + if (!category) + { + continue; + } + this->hotkeys_.append(std::make_shared( + *category, QKeySequence(keySequence), action, arguments, + QString::fromStdString(key))); + } +} + +void HotkeyController::saveHotkeys() +{ + auto defaultHotkeysAdded = + pajlada::Settings::Setting>::get( + "/hotkeys/addedDefaults"); + + // make sure that hotkeys are deleted + pajlada::Settings::SettingManager::getInstance()->set( + "/hotkeys", rapidjson::Value(rapidjson::kObjectType)); + + // re-add /hotkeys/addedDefaults as previous set call deleted that key + pajlada::Settings::Setting>::set( + "/hotkeys/addedDefaults", + std::vector(defaultHotkeysAdded.begin(), + defaultHotkeysAdded.end())); + + for (const auto &hotkey : this->hotkeys_) + { + auto section = "/hotkeys/" + hotkey->name().toStdString(); + pajlada::Settings::Setting::set(section + "/action", + hotkey->action()); + pajlada::Settings::Setting::set( + section + "/keySequence", hotkey->keySequence().toString()); + + auto categoryName = this->categoryName(hotkey->category()); + pajlada::Settings::Setting::set(section + "/category", + categoryName); + pajlada::Settings::Setting>::set( + section + "/arguments", hotkey->arguments()); + } +} + +void HotkeyController::addDefaults(std::set &addedHotkeys) +{ + // popup window + { + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("Escape"), "delete", + std::vector(), "close popup window"); + for (int i = 0; i < 8; i++) + { + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence(QString("Ctrl+%1").arg(i + 1)), + "openTab", {QString::number(i)}, + QString("popup select tab #%1").arg(i + 1)); + } + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("Ctrl+9"), "openTab", {"last"}, + "popup select last tab"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("Ctrl+Tab"), "openTab", {"next"}, + "popup select next tab"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("Ctrl+Shift+Tab"), "openTab", + {"previous"}, "popup select previous tab"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("PgUp"), "scrollPage", {"up"}, + "popup scroll up"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("PgDown"), "scrollPage", {"down"}, + "popup scroll down"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("Return"), "accept", + std::vector(), "popup accept"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("Escape"), "reject", + std::vector(), "popup reject"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::PopupWindow, + QKeySequence("Ctrl+F"), "search", + std::vector(), "popup focus search box"); + } + + // split + { + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Ctrl+W"), "delete", + std::vector(), "delete"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Ctrl+R"), "changeChannel", + std::vector(), "change channel"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Ctrl+F"), "showSearch", + std::vector(), "show search"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Ctrl+F5"), "reconnect", + std::vector(), "reconnect"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("F5"), "reloadEmotes", + std::vector(), "reload emotes"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Alt+x"), "createClip", + std::vector(), "create clip"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Alt+left"), "focus", {"left"}, + "focus left"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Alt+down"), "focus", {"down"}, + "focus down"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Alt+up"), "focus", {"up"}, + "focus up"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Alt+right"), "focus", {"right"}, + "focus right"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("PgUp"), "scrollPage", {"up"}, + "scroll page up"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("PgDown"), "scrollPage", {"down"}, + "scroll page down"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Ctrl+End"), "scrollToBottom", + std::vector(), "scroll to bottom"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("F10"), "debug", + std::vector(), "open debug popup"); + } + + // split input + { + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Ctrl+E"), "openEmotesPopup", + std::vector(), "emote picker"); + + // all variations of send message :) + { + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Return"), "sendMessage", + std::vector(), "send message"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Ctrl+Return"), "sendMessage", + {"keepInput"}, "send message and keep text"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Shift+Return"), "sendMessage", + std::vector(), "send message"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Ctrl+Shift+Return"), + "sendMessage", {"keepInput"}, + "send message and keep text"); + } + + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Home"), "cursorToStart", + {"withoutSelection"}, "go to start of input"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("End"), "cursorToEnd", + {"withoutSelection"}, "go to end of input"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Shift+Home"), "cursorToStart", + {"withSelection"}, + "go to start of input with selection"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Shift+End"), "cursorToEnd", + {"withSelection"}, + "go to end of input with selection"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Up"), "previousMessage", + std::vector(), "previous message"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::SplitInput, + QKeySequence("Down"), "nextMessage", + std::vector(), "next message"); + } + + // window + { + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+P"), "openSettings", + std::vector(), "open settings"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+T"), "newSplit", + std::vector(), "new split"); + for (int i = 0; i < 8; i++) + { + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence(QString("Ctrl+%1").arg(i + 1)), + "openTab", {QString::number(i)}, + QString("select tab #%1").arg(i + 1)); + } + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+9"), "openTab", {"last"}, + "select last tab"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+Tab"), "openTab", {"next"}, + "select next tab"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+Shift+Tab"), "openTab", + {"previous"}, "select previous tab"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+N"), "popup", {"split"}, + "new popup window"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+Shift+N"), "popup", {"window"}, + "new popup window from tab"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence::ZoomIn, "zoom", {"in"}, "zoom in"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence::ZoomOut, "zoom", {"out"}, "zoom out"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("CTRL+0"), "zoom", {"reset"}, + "zoom reset"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+Shift+T"), "newTab", + std::vector(), "new tab"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+Shift+W"), "removeTab", + std::vector(), "remove tab"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+G"), "reopenSplit", + std::vector(), "reopen split"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+H"), "toggleLocalR9K", + std::vector(), "toggle local r9k"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+K"), "openQuickSwitcher", + std::vector(), "open quick switcher"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+U"), "setTabVisibility", + {"toggle"}, "toggle tab visibility"); + } +} + +void HotkeyController::resetToDefaults() +{ + std::set addedSet; + pajlada::Settings::Setting>::set( + "/hotkeys/addedDefaults", + std::vector(addedSet.begin(), addedSet.end())); + auto size = this->hotkeys_.raw().size(); + for (unsigned long i = 0; i < size; i++) + { + this->hotkeys_.removeAt(0); + } + + // add defaults back + this->saveHotkeys(); + this->loadHotkeys(); +} + +void HotkeyController::tryAddDefault(std::set &addedHotkeys, + HotkeyCategory category, + QKeySequence keySequence, QString action, + std::vector args, QString name) +{ + qCDebug(chatterinoHotkeys) << "Try add default" << name; + if (addedHotkeys.count(name) != 0) + { + qCDebug(chatterinoHotkeys) << "Already exists"; + return; // hotkey was added before + } + qCDebug(chatterinoHotkeys) << "Inserted"; + this->hotkeys_.append( + std::make_shared(category, keySequence, action, args, name)); + addedHotkeys.insert(name); +} + +void HotkeyController::showHotkeyError(const std::shared_ptr &hotkey, + QString warning) +{ + auto msgBox = new QMessageBox( + QMessageBox::Icon::Warning, "Hotkey error", + QString( + "There was an error while executing your hotkey named \"%1\": \n%2") + .arg(hotkey->name(), warning), + QMessageBox::Ok); + msgBox->exec(); +} + +} // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyController.hpp b/src/controllers/hotkeys/HotkeyController.hpp new file mode 100644 index 00000000000..069c14bf14b --- /dev/null +++ b/src/controllers/hotkeys/HotkeyController.hpp @@ -0,0 +1,131 @@ +#pragma once + +#include "common/SignalVector.hpp" +#include "common/Singleton.hpp" +#include "controllers/hotkeys/HotkeyCategory.hpp" + +#include +#include +#include + +#include + +class QShortcut; + +namespace chatterino { + +class Hotkey; + +class HotkeyModel; + +class HotkeyController final : public Singleton +{ +public: + using HotkeyFunction = std::function)>; + using HotkeyMap = std::map; + + HotkeyController(); + HotkeyModel *createModel(QObject *parent); + + std::vector shortcutsForCategory(HotkeyCategory category, + HotkeyMap actionMap, + QWidget *parent); + + void save() override; + std::shared_ptr getHotkeyByName(QString name); + + /** + * @brief removes the hotkey with the oldName and inserts newHotkey at the end + * + * @returns the new index in the SignalVector + **/ + int replaceHotkey(QString oldName, std::shared_ptr newHotkey); + boost::optional hotkeyCategoryFromName( + QString categoryName); + + /** + * @brief checks if the hotkey is duplicate + * + * @param hotkey the hotkey to check + * @param ignoreNamed name of hotkey to ignore. Useful for ensuring we don't fail if the hotkey's name is being edited + * + * @returns true if the given hotkey is a duplicate, false if it's not + **/ + [[nodiscard]] bool isDuplicate(std::shared_ptr hotkey, + QString ignoreNamed); + + /** + * @brief Returns the display name of the given hotkey category + * + * @returns the display name, or an empty string if an invalid hotkey category was given + **/ + [[nodiscard]] QString categoryDisplayName(HotkeyCategory category) const; + + /** + * @brief Returns the name of the given hotkey category + * + * @returns the name, or an empty string if an invalid hotkey category was given + **/ + [[nodiscard]] QString categoryName(HotkeyCategory category) const; + + /** + * @returns a const map with the HotkeyCategory enum as its key, and HotkeyCategoryData as the value. + **/ + [[nodiscard]] const std::map + &categories() const; + + pajlada::Signals::NoArgSignal onItemsUpdated; + +private: + /** + * @brief load hotkeys from under the /hotkeys settings path + **/ + void loadHotkeys(); + + /** + * @brief save hotkeys to the /hotkeys path + * + * This is done by first fully clearing the /hotkeys object, then reapplying all hotkeys + * from the hotkeys_ object + **/ + void saveHotkeys(); + + /** + * @brief try to load all default hotkeys + * + * New hotkeys must be added to this function + **/ + void addDefaults(std::set &addedHotkeys); + + /** + * @brief remove all user-made changes to hotkeys and reset to the default hotkeys + **/ + void resetToDefaults(); + + /** + * @brief try to add a hotkey if it hasn't already been added or modified by the user + **/ + void tryAddDefault(std::set &addedHotkeys, HotkeyCategory category, + QKeySequence keySequence, QString action, + std::vector args, QString name); + + /** + * @brief show an error dialog about a hotkey in a standard format + **/ + static void showHotkeyError(const std::shared_ptr &hotkey, + QString warning); + + friend class KeyboardSettingsPage; + + SignalVector> hotkeys_; + pajlada::Signals::SignalHolder signalHolder_; + + const std::map hotkeyCategories_ = { + {HotkeyCategory::PopupWindow, {"popupWindow", "Popup Windows"}}, + {HotkeyCategory::Split, {"split", "Split"}}, + {HotkeyCategory::SplitInput, {"splitInput", "Split input box"}}, + {HotkeyCategory::Window, {"window", "Window"}}, + }; +}; + +} // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyHelpers.cpp b/src/controllers/hotkeys/HotkeyHelpers.cpp new file mode 100644 index 00000000000..d998d76656a --- /dev/null +++ b/src/controllers/hotkeys/HotkeyHelpers.cpp @@ -0,0 +1,30 @@ +#include "controllers/hotkeys/HotkeyHelpers.hpp" + +#include + +namespace chatterino { + +std::vector parseHotkeyArguments(QString argumentString) +{ + std::vector arguments; + + argumentString = argumentString.trimmed(); + + if (argumentString.isEmpty()) + { + // argumentString is empty, early out to ensure we don't end up with a vector with one empty element + return arguments; + } + + auto argList = argumentString.split("\n"); + + // convert the QStringList to our preferred std::vector + for (const auto &arg : argList) + { + arguments.push_back(arg.trimmed()); + } + + return arguments; +} + +} // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyHelpers.hpp b/src/controllers/hotkeys/HotkeyHelpers.hpp new file mode 100644 index 00000000000..4e63569fff8 --- /dev/null +++ b/src/controllers/hotkeys/HotkeyHelpers.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +#include + +namespace chatterino { + +std::vector parseHotkeyArguments(QString argumentString); + +} // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyModel.cpp b/src/controllers/hotkeys/HotkeyModel.cpp new file mode 100644 index 00000000000..33fd406434f --- /dev/null +++ b/src/controllers/hotkeys/HotkeyModel.cpp @@ -0,0 +1,121 @@ +#include "controllers/hotkeys/HotkeyModel.hpp" + +#include "common/QLogging.hpp" +#include "util/StandardItemHelper.hpp" + +namespace chatterino { + +HotkeyModel::HotkeyModel(QObject *parent) + : SignalVectorModel>(2, parent) +{ +} + +// turn a vector item into a model row +std::shared_ptr HotkeyModel::getItemFromRow( + std::vector &row, const std::shared_ptr &original) +{ + return original; +} + +// turns a row in the model into a vector item +void HotkeyModel::getRowFromItem(const std::shared_ptr &item, + std::vector &row) +{ + QFont font("Segoe UI", 10); + + if (!item->validAction()) + { + font.setStrikeOut(true); + } + + setStringItem(row[0], item->name(), false); + row[0]->setData(font, Qt::FontRole); + + setStringItem(row[1], item->toString(), false); + row[1]->setData(font, Qt::FontRole); +} + +int HotkeyModel::beforeInsert(const std::shared_ptr &item, + std::vector &row, + int proposedIndex) +{ + const auto category = item->getCategory(); + if (this->categoryCount_[category]++ == 0) + { + auto newRow = this->createRow(); + + setStringItem(newRow[0], category, false, false); + newRow[0]->setData(QFont("Segoe UI Light", 16), Qt::FontRole); + + // make sure category headers aren't editable + for (unsigned long i = 1; i < newRow.size(); i++) + { + setStringItem(newRow[i], "", false, false); + } + + this->insertCustomRow(std::move(newRow), proposedIndex); + + return proposedIndex + 1; + } + + auto [currentCategoryModelIndex, nextCategoryModelIndex] = + this->getCurrentAndNextCategoryModelIndex(category); + + if (nextCategoryModelIndex != -1 && proposedIndex >= nextCategoryModelIndex) + { + // The proposed index would have landed under the wrong category, we offset by -1 to compensate + return proposedIndex - 1; + } + + return proposedIndex; +} + +void HotkeyModel::afterRemoved(const std::shared_ptr &item, + std::vector &row, int index) +{ + auto it = this->categoryCount_.find(item->getCategory()); + assert(it != this->categoryCount_.end()); + + if (it->second <= 1) + { + this->categoryCount_.erase(it); + this->removeCustomRow(index - 1); + } + else + { + it->second--; + } +} + +std::tuple HotkeyModel::getCurrentAndNextCategoryModelIndex( + const QString &category) const +{ + int modelIndex = 0; + + int currentCategoryModelIndex = -1; + int nextCategoryModelIndex = -1; + + for (const auto &row : this->rows()) + { + if (row.isCustomRow) + { + QString customRowValue = + row.items[0]->data(Qt::EditRole).toString(); + if (currentCategoryModelIndex != -1) + { + nextCategoryModelIndex = modelIndex; + break; + } + if (customRowValue == category) + { + currentCategoryModelIndex = modelIndex; + } + } + + modelIndex += 1; + } + + return {currentCategoryModelIndex, nextCategoryModelIndex}; +} + +} // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyModel.hpp b/src/controllers/hotkeys/HotkeyModel.hpp new file mode 100644 index 00000000000..f23b9537314 --- /dev/null +++ b/src/controllers/hotkeys/HotkeyModel.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "common/SignalVectorModel.hpp" +#include "controllers/hotkeys/Hotkey.hpp" +#include "util/QStringHash.hpp" + +#include + +namespace chatterino { + +class HotkeyController; + +class HotkeyModel : public SignalVectorModel> +{ +public: + HotkeyModel(QObject *parent); + +protected: + // turn a vector item into a model row + virtual std::shared_ptr getItemFromRow( + std::vector &row, + const std::shared_ptr &original) override; + + // turns a row in the model into a vector item + virtual void getRowFromItem(const std::shared_ptr &item, + std::vector &row) override; + + virtual int beforeInsert(const std::shared_ptr &item, + std::vector &row, + int proposedIndex) override; + + virtual void afterRemoved(const std::shared_ptr &item, + std::vector &row, + int index) override; + + friend class HotkeyController; + +private: + std::tuple getCurrentAndNextCategoryModelIndex( + const QString &category) const; + + std::unordered_map categoryCount_; +}; + +} // namespace chatterino diff --git a/src/controllers/hotkeys/README.md b/src/controllers/hotkeys/README.md new file mode 100644 index 00000000000..bccdd624ced --- /dev/null +++ b/src/controllers/hotkeys/README.md @@ -0,0 +1,95 @@ +# Custom Hotkeys + +## Table of Contents + +- [Glossary](#Glossary) +- [Adding new hotkeys](#Adding_new_hotkeys) +- [Adding new hotkey categories](#Adding_new_hotkey_categories) + +## Glossary + +| Word | Meaning | +| ----------------------- | ----------------------------------------------------------------------------------------- | +| Shortcut | `QShortcut` object created from a hotkey. | +| Hotkey | Template for creating shortcuts in the right categories. See [Hotkey object][hotkey.hpp]. | +| Category | Place where hotkeys' actions are executed. | +| Action | Code that makes a hotkey do something. | +| Keybinding or key combo | The keys you press on the keyboard to do something. | + +## Adding new hotkeys + +Adding new hotkeys to a widget that already has hotkeys is quite easy. + +### Add an action + +1. Locate the call to `getApp()->hotkeys->shortcutsForCategory(...)`, it is located in the `addShortcuts()` method +2. Above that should be a `HotkeyController::HotkeyMap` named `actions` +3. Add your new action inside that map, it should return a non-empty QString only when configuration errors are found. +4. Go to `ActionNames.hpp` and add a definition for your hotkey with a nice user-friendly name. Be sure to double-check the argument count. + +### Add a default + +Defaults are stored in `HotkeyController.cpp` in the `resetToDefaults()` method. To add a default just add a call to `tryAddDefault` in the appropriate section. Make sure that the name you gave the hotkey is unique. + +```cpp +void HotkeyController::tryAddDefault(std::set &addedHotkeys, + HotkeyCategory category, + QKeySequence keySequence, QString action, + std::vector args, QString name) +``` + +- where `action` is the action you added before, +- `category` — same category that is in the `shortcutsForCategory` call +- `name` — **unique** name of the default hotkey +- `keySequence` - key combo for the hotkey + +## Adding new hotkey categories + +If you want to add hotkeys to new widget that doesn't already have them it's a bit more work. + +### Add the `HotkeyCategory` value + +Add a value for the `HotkeyCategory` enum in [`HotkeyCategory.hpp`][hotkeycategory.hpp]. If you widget is a popup, it's best to use the existing `PopupWindow` category. + +### Add a nice name for the category + +Add a string name and display name for the category in [`HotkeyController.hpp`][hotkeycontroller.hpp] to `hotkeyCategoryNames` and `hotkeyCategoryDisplayNames`. + +### Add a shortcut context + +To make sure shortcuts created from your hotkeys are only executed in the right places, you need to add a shortcut context for Qt. This is done in `Hotkey.cpp` in `Hotkey::getContext()`. +See the [ShortcutContext enum docs for possible values](https://doc.qt.io/qt-5/qt.html#ShortcutContext-enum) + +### Override `addShortcuts` + +If the widget you're adding Hotkeys is a `BaseWidget` or a `BaseWindow`. You can override the `addShortcuts()` method. You should also add a call to it in the constructor. Here is some template/example code: + +```cpp +void YourWidget::addShortcuts() +{ + HotkeyController::HotkeyMap actions{ + {"barrelRoll", // replace this with your action code + [this](std::vector arguments) -> QString { + // DO A BARREL ROLL + return ""; // only return text if there is a configuration error. + }}, + }; + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory(HotkeyCategory::PopupWindow /* or your category name */, + actions, this); +} +``` + +## Renaming defaults + +Renaming defaults is currently not possible. If you were to rename one, it would get recreated for everyone probably leading to broken shortcuts, don't do this until a proper mechanism has been made. + + + +[actionnames.hpp]: https://github.com/Chatterino/chatterino2/blob/custom_hotkeys/src/controllers/hotkeys/ActionNames.hpp +[hotkey.cpp]: https://github.com/Chatterino/chatterino2/blob/custom_hotkeys/src/controllers/hotkeys/Hotkey.cpp +[hotkey.hpp]: https://github.com/Chatterino/chatterino2/blob/custom_hotkeys/src/controllers/hotkeys/Hotkey.hpp +[hotkeycontroller.cpp]: https://github.com/Chatterino/chatterino2/blob/custom_hotkeys/src/controllers/hotkeys/HotkeyController.cpp +[hotkeycontroller.hpp]: https://github.com/Chatterino/chatterino2/blob/custom_hotkeys/src/controllers/hotkeys/HotkeyController.hpp +[hotkeymodel.cpp]: https://github.com/Chatterino/chatterino2/blob/custom_hotkeys/src/controllers/hotkeys/HotkeyModel.cpp +[hotkeymodel.hpp]: https://github.com/Chatterino/chatterino2/blob/custom_hotkeys/src/controllers/hotkeys/HotkeyModel.hpp +[hotkeycategory.hpp]: https://github.com/Chatterino/chatterino2/blob/custom_hotkeys/src/controllers/hotkeys/HotkeyCategory.hpp diff --git a/src/util/Shortcut.hpp b/src/util/Shortcut.hpp deleted file mode 100644 index 1b20d1c1dde..00000000000 --- a/src/util/Shortcut.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include - -namespace chatterino { - -template -inline void createShortcut(WidgetType *w, const char *key, Func func) -{ - auto s = new QShortcut(QKeySequence(key), w); - s->setContext(Qt::WidgetWithChildrenShortcut); - QObject::connect(s, &QShortcut::activated, w, func); -} - -template -inline void createWindowShortcut(WidgetType *w, const char *key, Func func) -{ - auto s = new QShortcut(QKeySequence(key), w); - s->setContext(Qt::WindowShortcut); - QObject::connect(s, &QShortcut::activated, w, func); -} - -} // namespace chatterino diff --git a/src/widgets/BaseWidget.cpp b/src/widgets/BaseWidget.cpp index cd650afcde6..7a165492477 100644 --- a/src/widgets/BaseWidget.cpp +++ b/src/widgets/BaseWidget.cpp @@ -2,6 +2,8 @@ #include "BaseSettings.hpp" #include "BaseTheme.hpp" +#include "common/QLogging.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "widgets/BaseWindow.hpp" #include @@ -25,6 +27,16 @@ BaseWidget::BaseWidget(QWidget *parent, Qt::WindowFlags f) this->update(); }); } +void BaseWidget::clearShortcuts() +{ + for (auto shortcut : this->shortcuts_) + { + shortcut->setKey(QKeySequence()); + shortcut->removeEventFilter(this); + shortcut->deleteLater(); + } + this->shortcuts_.clear(); +} float BaseWidget::scale() const { diff --git a/src/widgets/BaseWidget.hpp b/src/widgets/BaseWidget.hpp index 1c165a81194..2b415a1d9da 100644 --- a/src/widgets/BaseWidget.hpp +++ b/src/widgets/BaseWidget.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -40,11 +41,19 @@ class BaseWidget : public QWidget virtual void scaleChangedEvent(float newScale); virtual void themeChangedEvent(); + [[deprecated("addShortcuts called without overriding it")]] virtual void + addShortcuts() + { + } void setScale(float value); Theme *theme; + std::vector shortcuts_; + void clearShortcuts(); + pajlada::Signals::SignalHolder signalHolder_; + private: float scale_{1.f}; boost::optional overrideScale_; @@ -52,8 +61,6 @@ class BaseWidget : public QWidget std::vector widgets_; - pajlada::Signals::SignalHolder signalHolder_; - friend class BaseWindow; }; diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index 6cc7c69a3b4..bc966f33c1f 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -5,7 +5,6 @@ #include "boost/algorithm/algorithm.hpp" #include "util/DebugCount.hpp" #include "util/PostToThread.hpp" -#include "util/Shortcut.hpp" #include "util/WindowsHelper.hpp" #include "widgets/Label.hpp" #include "widgets/TooltipWidget.hpp" @@ -83,10 +82,6 @@ BaseWindow::BaseWindow(FlagsEnum _flags, QWidget *parent) this->updateScale(); - createWindowShortcut(this, "CTRL+0", [] { - getSettings()->uiScale.setValue(1); - }); - this->resize(300, 150); #ifdef USEWINSDK diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index 0537126e81b..1ee79819df0 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -6,7 +6,6 @@ #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/InitUpdateButton.hpp" -#include "util/Shortcut.hpp" #include "widgets/Window.hpp" #include "widgets/dialogs/SettingsDialog.hpp" #include "widgets/helper/NotebookButton.hpp" @@ -19,7 +18,6 @@ #include #include #include -#include #include #include #include diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index 1f4fd4e7b42..a45391100d3 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -3,15 +3,16 @@ #include "Application.hpp" #include "common/Credentials.hpp" #include "common/Modes.hpp" +#include "common/QLogging.hpp" #include "common/Version.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/Updates.hpp" #include "singletons/WindowManager.hpp" #include "util/InitUpdateButton.hpp" -#include "util/Shortcut.hpp" #include "widgets/AccountSwitchPopup.hpp" #include "widgets/Notebook.hpp" #include "widgets/dialogs/SettingsDialog.hpp" @@ -37,7 +38,6 @@ #include #include #include -#include #include #include @@ -49,7 +49,6 @@ Window::Window(WindowType type) , notebook_(new SplitNotebook(this)) { this->addCustomTitlebarButtons(); - this->addDebugStuff(); this->addShortcuts(); this->addLayout(); @@ -72,6 +71,11 @@ Window::Window(WindowType type) this->resize(int(300 * this->scale()), int(500 * this->scale())); } + this->signalHolder_.managedConnect(getApp()->hotkeys->onItemsUpdated, + [this]() { + this->clearShortcuts(); + this->addShortcuts(); + }); if (type == WindowType::Main || type == WindowType::Popup) { getSettings()->tabDirection.connect([this](int val) { @@ -180,7 +184,7 @@ void Window::addCustomTitlebarButtons() this->userLabel_->setMinimumWidth(20 * scale()); } -void Window::addDebugStuff() +void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) { #ifndef NDEBUG std::vector cheerMessages, subMessages, miscMessages, linkMessages, @@ -242,30 +246,33 @@ void Window::addDebugStuff() emoteTestMessages.emplace_back(R"(@badge-info=subscriber/34;badges=moderator/1,subscriber/24;color=#FF0000;display-name=테스트계정420;emotes=41:6-13,15-22;flags=;id=a3196c7e-be4c-4b49-9c5a-8b8302b50c2a;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1590922213730;turbo=0;user-id=117166826;user-type=mod :testaccount_420!testaccount_420@testaccount_420.tmi.twitch.tv PRIVMSG #pajlada :-tags Kreygasm,Kreygasm (no space))"); // clang-format on - createWindowShortcut(this, "F6", [=] { + actions.emplace("addMiscMessage", [=](std::vector) -> QString { const auto &messages = miscMessages; static int index = 0; auto app = getApp(); const auto &msg = messages[index++ % messages.size()]; app->twitch.server->addFakeMessage(msg); + return ""; }); - createWindowShortcut(this, "F7", [=] { + actions.emplace("addCheerMessage", [=](std::vector) -> QString { const auto &messages = cheerMessages; static int index = 0; const auto &msg = messages[index++ % messages.size()]; getApp()->twitch.server->addFakeMessage(msg); + return ""; }); - createWindowShortcut(this, "F8", [=] { + actions.emplace("addLinkMessage", [=](std::vector) -> QString { const auto &messages = linkMessages; static int index = 0; auto app = getApp(); const auto &msg = messages[index++ % messages.size()]; app->twitch.server->addFakeMessage(msg); + return ""; }); - createWindowShortcut(this, "F9", [=] { + actions.emplace("addRewardMessage", [=](std::vector) -> QString { rapidjson::Document doc; auto app = getApp(); static bool alt = true; @@ -284,139 +291,375 @@ void Window::addDebugStuff() doc["data"]["message"]["data"]["redemption"]); alt = !alt; } + return ""; }); - createWindowShortcut(this, "F11", [=] { + actions.emplace("addEmoteMessage", [=](std::vector) -> QString { const auto &messages = emoteTestMessages; static int index = 0; const auto &msg = messages[index++ % messages.size()]; getApp()->twitch.server->addFakeMessage(msg); + return ""; }); - #endif -} // namespace chatterino +} void Window::addShortcuts() { - /// Initialize program-wide hotkeys - // Open settings - createWindowShortcut(this, "CTRL+P", [this] { - SettingsDialog::showDialog(this); - }); - - // Switch tab - createWindowShortcut(this, "CTRL+T", [this] { - this->notebook_->getOrAddSelectedPage()->appendNewSplit(true); - }); - - // CTRL + 1-8 to open corresponding tab. - for (auto i = 0; i < 8; i++) - { - const auto openTab = [this, i] { - this->notebook_->selectIndex(i); - }; - createWindowShortcut(this, QString("CTRL+%1").arg(i + 1).toUtf8(), - openTab); - } - - createWindowShortcut(this, "CTRL+9", [this] { - this->notebook_->selectLastTab(); - }); - - createWindowShortcut(this, "CTRL+TAB", [this] { - this->notebook_->selectNextTab(); - }); - createWindowShortcut(this, "CTRL+SHIFT+TAB", [this] { - this->notebook_->selectPreviousTab(); - }); - - createWindowShortcut(this, "CTRL+N", [this] { - if (auto page = dynamic_cast( - this->notebook_->getSelectedPage())) - { - if (auto split = page->getSelectedSplit()) - { - split->popup(); - } - } - }); - - createWindowShortcut(this, "CTRL+SHIFT+N", [this] { - if (auto page = dynamic_cast( - this->notebook_->getSelectedPage())) - { - page->popup(); - } - }); - - // Zoom in - { - auto s = new QShortcut(QKeySequence::ZoomIn, this); - s->setContext(Qt::WindowShortcut); - QObject::connect(s, &QShortcut::activated, this, [] { - getSettings()->setClampedUiScale( - getSettings()->getClampedUiScale() + 0.1f); - }); - } - - // Zoom out - { - auto s = new QShortcut(QKeySequence::ZoomOut, this); - s->setContext(Qt::WindowShortcut); - QObject::connect(s, &QShortcut::activated, this, [] { - getSettings()->setClampedUiScale( - getSettings()->getClampedUiScale() - 0.1f); - }); - } - - // New tab - createWindowShortcut(this, "CTRL+SHIFT+T", [this] { - this->notebook_->addPage(true); - }); - - // Close tab - createWindowShortcut(this, "CTRL+SHIFT+W", [this] { - this->notebook_->removeCurrentPage(); - }); - - // Reopen last closed split - createWindowShortcut(this, "CTRL+G", [this] { - if (ClosedSplits::empty()) - { - return; - } - ClosedSplits::SplitInfo si = ClosedSplits::pop(); - SplitContainer *splitContainer{nullptr}; - if (si.tab) - { - splitContainer = dynamic_cast(si.tab->page); - } - if (!splitContainer) - { - splitContainer = this->notebook_->getOrAddSelectedPage(); - } - this->notebook_->select(splitContainer); - Split *split = new Split(splitContainer); - split->setChannel( - getApp()->twitch.server->getOrAddChannel(si.channelName)); - split->setFilters(si.filters); - splitContainer->appendSplit(split); - }); - - createWindowShortcut(this, "CTRL+H", [] { - getSettings()->hideSimilar.setValue(!getSettings()->hideSimilar); - getApp()->windows->forceLayoutChannelViews(); - }); - - createWindowShortcut(this, "CTRL+K", [this] { - auto quickSwitcher = - new QuickSwitcherPopup(&getApp()->windows->getMainWindow()); - quickSwitcher->show(); - }); - - createWindowShortcut(this, "CTRL+U", [this] { - this->notebook_->setShowTabs(!this->notebook_->getShowTabs()); - }); + HotkeyController::HotkeyMap actions{ + {"openSettings", // Open settings + [this](std::vector) -> QString { + SettingsDialog::showDialog(this); + return ""; + }}, + {"newSplit", // Create a new split + [this](std::vector) -> QString { + this->notebook_->getOrAddSelectedPage()->appendNewSplit(true); + return ""; + }}, + {"openTab", // CTRL + 1-8 to open corresponding tab. + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "openTab shortcut called without arguments. " + "Takes only " + "one argument: tab specifier"; + return "openTab shortcut called without arguments. " + "Takes only " + "one argument: tab specifier"; + } + auto target = arguments.at(0); + if (target == "last") + { + this->notebook_->selectLastTab(); + } + else if (target == "next") + { + this->notebook_->selectNextTab(); + } + else if (target == "previous") + { + this->notebook_->selectPreviousTab(); + } + else + { + bool ok; + int result = target.toInt(&ok); + if (ok) + { + this->notebook_->selectIndex(result); + } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid argument for openTab shortcut"; + return QString("Invalid argument for openTab " + "shortcut: \"%1\". Use \"last\", " + "\"next\", \"previous\" or an integer.") + .arg(target); + } + } + return ""; + }}, + {"popup", + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + return "popup action called without arguments. Takes only " + "one: \"split\" or \"window\"."; + } + if (arguments.at(0) == "split") + { + if (auto page = dynamic_cast( + this->notebook_->getSelectedPage())) + { + if (auto split = page->getSelectedSplit()) + { + split->popup(); + } + } + } + else if (arguments.at(0) == "window") + { + if (auto page = dynamic_cast( + this->notebook_->getSelectedPage())) + { + page->popup(); + } + } + else + { + return "Invalid popup target. Use \"split\" or \"window\"."; + } + return ""; + }}, + {"zoom", + [](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "zoom shortcut called without arguments. Takes " + "only " + "one argument: \"in\", \"out\", or \"reset\""; + return "zoom shortcut called without arguments. Takes " + "only " + "one argument: \"in\", \"out\", or \"reset\""; + } + auto change = 0.0f; + auto direction = arguments.at(0); + if (direction == "reset") + { + getSettings()->uiScale.setValue(1); + return ""; + } + + if (direction == "in") + { + change = 0.1f; + } + else if (direction == "out") + { + change = -0.1f; + } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid zoom direction, use \"in\", \"out\", or " + "\"reset\""; + return "Invalid zoom direction, use \"in\", \"out\", or " + "\"reset\""; + } + getSettings()->setClampedUiScale( + getSettings()->getClampedUiScale() + change); + return ""; + }}, + {"newTab", + [this](std::vector) -> QString { + this->notebook_->addPage(true); + return ""; + }}, + {"removeTab", + [this](std::vector) -> QString { + this->notebook_->removeCurrentPage(); + return ""; + }}, + {"reopenSplit", + [this](std::vector) -> QString { + if (ClosedSplits::empty()) + { + return ""; + } + ClosedSplits::SplitInfo si = ClosedSplits::pop(); + SplitContainer *splitContainer{nullptr}; + if (si.tab) + { + splitContainer = dynamic_cast(si.tab->page); + } + if (!splitContainer) + { + splitContainer = this->notebook_->getOrAddSelectedPage(); + } + this->notebook_->select(splitContainer); + Split *split = new Split(splitContainer); + split->setChannel( + getApp()->twitch.server->getOrAddChannel(si.channelName)); + split->setFilters(si.filters); + splitContainer->appendSplit(split); + return ""; + }}, + {"toggleLocalR9K", + [](std::vector) -> QString { + getSettings()->hideSimilar.setValue(!getSettings()->hideSimilar); + getApp()->windows->forceLayoutChannelViews(); + return ""; + }}, + {"openQuickSwitcher", + [](std::vector) -> QString { + auto quickSwitcher = + new QuickSwitcherPopup(&getApp()->windows->getMainWindow()); + quickSwitcher->show(); + return ""; + }}, + {"quit", + [](std::vector) -> QString { + QApplication::exit(); + return ""; + }}, + {"moveTab", + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "moveTab shortcut called without arguments. " + "Takes only one argument: new index (number, " + "\"next\" " + "or \"previous\")"; + return "moveTab shortcut called without arguments. " + "Takes only one argument: new index (number, " + "\"next\" " + "or \"previous\")"; + } + int newIndex = -1; + bool indexIsGenerated = + false; // indicates if `newIndex` was generated using target="next" or target="previous" + + auto target = arguments.at(0); + qCDebug(chatterinoHotkeys) << target; + if (target == "next") + { + newIndex = this->notebook_->getSelectedIndex() + 1; + indexIsGenerated = true; + } + else if (target == "previous") + { + newIndex = this->notebook_->getSelectedIndex() - 1; + indexIsGenerated = true; + } + else + { + bool ok; + int result = target.toInt(&ok); + if (!ok) + { + qCWarning(chatterinoHotkeys) + << "Invalid argument for moveTab shortcut"; + return QString("Invalid argument for moveTab shortcut: " + "%1. Use \"next\" or \"previous\" or an " + "integer.") + .arg(target); + } + newIndex = result; + } + if (newIndex >= this->notebook_->getPageCount() || 0 > newIndex) + { + if (indexIsGenerated) + { + return ""; // don't error out on generated indexes, ie move tab right + } + qCWarning(chatterinoHotkeys) + << "Invalid index for moveTab shortcut:" << newIndex; + return QString("Invalid index for moveTab shortcut: %1.") + .arg(newIndex); + } + this->notebook_->rearrangePage(this->notebook_->getSelectedPage(), + newIndex); + return ""; + }}, + {"setStreamerMode", + [](std::vector arguments) -> QString { + auto mode = 2; + if (arguments.size() != 0) + { + auto arg = arguments.at(0); + if (arg == "off") + { + mode = 0; + } + else if (arg == "on") + { + mode = 1; + } + else if (arg == "toggle") + { + mode = 2; + } + else if (arg == "auto") + { + mode = 3; + } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid argument for setStreamerMode hotkey: " + << arg; + return QString("Invalid argument for setStreamerMode " + "hotkey: %1. Use \"on\", \"off\", " + "\"toggle\" or \"auto\".") + .arg(arg); + } + } + + if (mode == 0) + { + getSettings()->enableStreamerMode.setValue( + StreamerModeSetting::Disabled); + } + else if (mode == 1) + { + getSettings()->enableStreamerMode.setValue( + StreamerModeSetting::Enabled); + } + else if (mode == 2) + { + if (isInStreamerMode()) + { + getSettings()->enableStreamerMode.setValue( + StreamerModeSetting::Disabled); + } + else + { + getSettings()->enableStreamerMode.setValue( + StreamerModeSetting::Enabled); + } + } + else if (mode == 3) + { + getSettings()->enableStreamerMode.setValue( + StreamerModeSetting::DetectObs); + } + return ""; + }}, + {"setTabVisibility", + [this](std::vector arguments) -> QString { + auto mode = 2; + if (arguments.size() != 0) + { + auto arg = arguments.at(0); + if (arg == "off") + { + mode = 0; + } + else if (arg == "on") + { + mode = 1; + } + else if (arg == "toggle") + { + mode = 2; + } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid argument for setStreamerMode hotkey: " + << arg; + return QString("Invalid argument for setTabVisibility " + "hotkey: %1. Use \"on\", \"off\" or " + "\"toggle\".") + .arg(arg); + } + } + + if (mode == 0) + { + this->notebook_->setShowTabs(false); + } + else if (mode == 1) + { + this->notebook_->setShowTabs(true); + } + else if (mode == 2) + { + this->notebook_->setShowTabs(!this->notebook_->getShowTabs()); + } + return ""; + }}, + }; + + this->addDebugStuff(actions); + + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::Window, actions, this); } void Window::addMenuBar() diff --git a/src/widgets/Window.hpp b/src/widgets/Window.hpp index 0e6a7712ca0..7eed35700b0 100644 --- a/src/widgets/Window.hpp +++ b/src/widgets/Window.hpp @@ -33,8 +33,10 @@ class Window : public BaseWindow private: void addCustomTitlebarButtons(); - void addDebugStuff(); - void addShortcuts(); + void addDebugStuff( + std::map)>> + &actions); + void addShortcuts() override; void addLayout(); void onAccountSelected(); void addMenuBar(); diff --git a/src/widgets/dialogs/ColorPickerDialog.cpp b/src/widgets/dialogs/ColorPickerDialog.cpp index 867fe06e186..eb591125c4a 100644 --- a/src/widgets/dialogs/ColorPickerDialog.cpp +++ b/src/widgets/dialogs/ColorPickerDialog.cpp @@ -106,6 +106,10 @@ ColorPickerDialog::ColorPickerDialog(const QColor &initial, QWidget *parent) this->selectColor(initial, false); } +void ColorPickerDialog::addShortcuts() +{ +} + ColorPickerDialog::~ColorPickerDialog() { if (this->htmlColorValidator_) diff --git a/src/widgets/dialogs/ColorPickerDialog.hpp b/src/widgets/dialogs/ColorPickerDialog.hpp index 73e9d059630..8b6b616e727 100644 --- a/src/widgets/dialogs/ColorPickerDialog.hpp +++ b/src/widgets/dialogs/ColorPickerDialog.hpp @@ -108,5 +108,7 @@ class ColorPickerDialog : public BasePopup void initColorPicker(LayoutCreator &creator); void initSpinBoxes(LayoutCreator &creator); void initHtmlColor(LayoutCreator &creator); + + void addShortcuts() override; }; } // namespace chatterino diff --git a/src/widgets/dialogs/EditHotkeyDialog.cpp b/src/widgets/dialogs/EditHotkeyDialog.cpp new file mode 100644 index 00000000000..457d5fd3a16 --- /dev/null +++ b/src/widgets/dialogs/EditHotkeyDialog.cpp @@ -0,0 +1,312 @@ +#include "widgets/dialogs/EditHotkeyDialog.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "controllers/hotkeys/ActionNames.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" +#include "controllers/hotkeys/HotkeyHelpers.hpp" +#include "ui_EditHotkeyDialog.h" + +namespace chatterino { + +EditHotkeyDialog::EditHotkeyDialog(const std::shared_ptr hotkey, + bool isAdd, QWidget *parent) + : QDialog(parent, Qt::WindowStaysOnTopHint) + , ui_(new Ui::EditHotkeyDialog) + , data_(hotkey) +{ + this->ui_->setupUi(this); + // dynamically add category names to the category picker + for (const auto &[_, hotkeyCategory] : getApp()->hotkeys->categories()) + { + this->ui_->categoryPicker->addItem(hotkeyCategory.displayName, + hotkeyCategory.name); + } + + this->ui_->warningLabel->hide(); + + if (hotkey) + { + if (!hotkey->validAction()) + { + this->showEditError("Invalid action, make sure you select the " + "correct action before saving."); + } + + // editing a hotkey + + // update pickers/input boxes to values from Hotkey object + this->ui_->categoryPicker->setCurrentIndex(size_t(hotkey->category())); + this->ui_->keyComboEdit->setKeySequence( + QKeySequence::fromString(hotkey->keySequence().toString())); + this->ui_->nameEdit->setText(hotkey->name()); + // update arguments + QString argsText; + bool first = true; + for (const auto &arg : hotkey->arguments()) + { + if (!first) + { + argsText += '\n'; + } + + argsText += arg; + + first = false; + } + this->ui_->argumentsEdit->setPlainText(argsText); + } + else + { + // adding a new hotkey + this->setWindowTitle("Add hotkey"); + this->ui_->categoryPicker->setCurrentIndex( + size_t(HotkeyCategory::SplitInput)); + this->ui_->argumentsEdit->setPlainText(""); + } +} + +EditHotkeyDialog::~EditHotkeyDialog() +{ + delete this->ui_; +} + +std::shared_ptr EditHotkeyDialog::data() +{ + return this->data_; +} + +void EditHotkeyDialog::afterEdit() +{ + auto arguments = + parseHotkeyArguments(this->ui_->argumentsEdit->toPlainText()); + + auto category = getApp()->hotkeys->hotkeyCategoryFromName( + this->ui_->categoryPicker->currentData().toString()); + if (!category) + { + this->showEditError("Invalid Hotkey Category."); + + return; + } + QString nameText = this->ui_->nameEdit->text(); + + // check if another hotkey with this name exists, accounts for editing a hotkey + bool isEditing = bool(this->data_); + if (getApp()->hotkeys->getHotkeyByName(nameText)) + { + // A hotkey with this name already exists + if (isEditing && this->data()->name() == nameText) + { + // The hotkey that already exists is the one we are editing + } + else + { + // The user is either creating a hotkey with a name that already exists, or + // the user is editing an already-existing hotkey and changing its name to a hotkey that already exists + this->showEditError("Hotkey with this name already exists."); + return; + } + } + if (nameText.isEmpty()) + { + this->showEditError("Hotkey name is missing"); + return; + } + if (this->ui_->keyComboEdit->keySequence().count() == 0) + { + this->showEditError("Key Sequence is missing"); + return; + } + if (this->ui_->actionPicker->currentText().isEmpty()) + { + this->showEditError("Action name cannot be empty"); + return; + } + + auto firstKeyInt = this->ui_->keyComboEdit->keySequence()[0]; + bool hasModifier = ((firstKeyInt & Qt::CTRL) == Qt::CTRL) || + ((firstKeyInt & Qt::ALT) == Qt::ALT) || + ((firstKeyInt & Qt::META) == Qt::META); + bool isKeyExcempt = ((firstKeyInt & Qt::Key_Escape) == Qt::Key_Escape) || + ((firstKeyInt & Qt::Key_Enter) == Qt::Key_Enter) || + ((firstKeyInt & Qt::Key_Return) == Qt::Key_Return); + + if (!isKeyExcempt && !hasModifier && !this->shownSingleKeyWarning) + { + this->showEditError( + "Warning: using keybindings without modifiers can lead to not " + "being\nable to use the key for the normal purpose.\nPress the " + "submit button again to do it anyway."); + this->shownSingleKeyWarning = true; + return; + } + + // use raw name from item data if possible, otherwise fallback to what the user has entered. + auto actionTemp = this->ui_->actionPicker->currentData(); + QString action = this->ui_->actionPicker->currentText(); + if (actionTemp.isValid()) + { + action = actionTemp.toString(); + } + + auto hotkey = std::make_shared( + *category, this->ui_->keyComboEdit->keySequence(), action, arguments, + nameText); + auto keyComboWasEdited = + this->data() && + this->ui_->keyComboEdit->keySequence() != this->data()->keySequence(); + auto nameWasEdited = this->data() && nameText != this->data()->name(); + + if (isEditing) + { + if (keyComboWasEdited || nameWasEdited) + { + if (getApp()->hotkeys->isDuplicate(hotkey, this->data()->name())) + { + this->showEditError( + "Keybinding needs to be unique in the category."); + return; + } + } + } + else + { + if (getApp()->hotkeys->isDuplicate(hotkey, QString())) + { + this->showEditError( + "Keybinding needs to be unique in the category."); + return; + } + } + + this->data_ = hotkey; + this->accept(); +} + +void EditHotkeyDialog::updatePossibleActions() +{ + const auto &hotkeys = getApp()->hotkeys; + auto category = hotkeys->hotkeyCategoryFromName( + this->ui_->categoryPicker->currentData().toString()); + if (!category) + { + this->showEditError("Invalid Hotkey Category."); + + return; + } + auto currentText = this->ui_->actionPicker->currentData().toString(); + if (this->data_ && + (currentText.isEmpty() || this->data_->category() == category)) + { + // is editing + currentText = this->data_->action(); + } + this->ui_->actionPicker->clear(); + qCDebug(chatterinoHotkeys) + << "update possible actions for" << (int)*category << currentText; + auto actions = actionNames.find(*category); + if (actions != actionNames.end()) + { + int indexToSet = -1; + for (const auto &action : actions->second) + { + this->ui_->actionPicker->addItem(action.second.displayName, + action.first); + if (action.first == currentText) + { + // update action raw name to display name + indexToSet = this->ui_->actionPicker->model()->rowCount() - 1; + } + } + if (indexToSet != -1) + { + this->ui_->actionPicker->setCurrentIndex(indexToSet); + } + } + else + { + qCDebug(chatterinoHotkeys) << "key missing!!!!"; + } +} + +void EditHotkeyDialog::updateArgumentsInput() +{ + auto currentText = this->ui_->actionPicker->currentData().toString(); + if (currentText.isEmpty()) + { + this->ui_->argumentsEdit->setEnabled(true); + return; + } + const auto &hotkeys = getApp()->hotkeys; + auto category = hotkeys->hotkeyCategoryFromName( + this->ui_->categoryPicker->currentData().toString()); + if (!category) + { + this->showEditError("Invalid Hotkey category."); + + return; + } + auto allActions = actionNames.find(*category); + if (allActions != actionNames.end()) + { + const auto &actionsMap = allActions->second; + auto definition = actionsMap.find(currentText); + if (definition == actionsMap.end()) + { + auto text = QString("Newline separated arguments for the action\n" + " - Unable to find action named \"%1\"") + .arg(currentText); + this->ui_->argumentsEdit->setPlaceholderText(text); + return; + } + const ActionDefinition &def = definition->second; + + if (def.maxCountArguments != 0) + { + QString text = + "Arguments wrapped in <> are required.\nArguments wrapped in " + "[] " + "are optional.\nArguments are separated by a newline."; + if (!def.argumentDescription.isEmpty()) + { + this->ui_->argumentsDescription->setVisible(true); + this->ui_->argumentsDescription->setText( + def.argumentDescription); + } + else + { + this->ui_->argumentsDescription->setVisible(false); + } + + text = QString("Arguments wrapped in <> are required."); + if (def.maxCountArguments != def.minCountArguments) + { + text += QString("\nArguments wrapped in [] are optional."); + } + + text += "\nArguments are separated by a newline."; + + this->ui_->argumentsEdit->setEnabled(true); + this->ui_->argumentsEdit->setPlaceholderText(text); + + this->ui_->argumentsLabel->setVisible(true); + this->ui_->argumentsDescription->setVisible(true); + this->ui_->argumentsEdit->setVisible(true); + } + else + { + this->ui_->argumentsLabel->setVisible(false); + this->ui_->argumentsDescription->setVisible(false); + this->ui_->argumentsEdit->setVisible(false); + } + } +} + +void EditHotkeyDialog::showEditError(QString errorText) +{ + this->ui_->warningLabel->setText(errorText); + this->ui_->warningLabel->show(); +} + +} // namespace chatterino diff --git a/src/widgets/dialogs/EditHotkeyDialog.hpp b/src/widgets/dialogs/EditHotkeyDialog.hpp new file mode 100644 index 00000000000..71923824668 --- /dev/null +++ b/src/widgets/dialogs/EditHotkeyDialog.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "controllers/hotkeys/Hotkey.hpp" + +#include + +#include + +namespace Ui { + +class EditHotkeyDialog; + +} // namespace Ui + +namespace chatterino { + +class EditHotkeyDialog : public QDialog +{ + Q_OBJECT + +public: + explicit EditHotkeyDialog(const std::shared_ptr data, + bool isAdd = false, QWidget *parent = nullptr); + ~EditHotkeyDialog() final; + + std::shared_ptr data(); + +protected slots: + /** + * @brief validates the hotkey + * + * fired by the ok button + **/ + void afterEdit(); + + /** + * @brief updates the list of actions based on the category + * + * fired by the category picker changing + **/ + void updatePossibleActions(); + + /** + * @brief updates the arguments description and input visibility + * + * fired by the action picker changing + **/ + void updateArgumentsInput(); + +private: + void showEditError(QString errorText); + + Ui::EditHotkeyDialog *ui_; + std::shared_ptr data_; + + bool shownSingleKeyWarning = false; +}; + +} // namespace chatterino diff --git a/src/widgets/dialogs/EditHotkeyDialog.ui b/src/widgets/dialogs/EditHotkeyDialog.ui new file mode 100644 index 00000000000..d7f265b0d6e --- /dev/null +++ b/src/widgets/dialogs/EditHotkeyDialog.ui @@ -0,0 +1,235 @@ + + + EditHotkeyDialog + + + + 0 + 0 + 400 + 300 + + + + Edit Hotkey + + + + + + true + + + + 0 + 0 + + + + + 75 + true + true + + + + Something went wrong, you should never +see this message :) + + + + + + + + + Name: + + + nameEdit + + + + + + + + + + true + + + false + + + A description of what the hotkey does. + + + + + + + Category: + + + categoryPicker + + + + + + + Action: + + + actionPicker + + + + + + + false + + + + + + + Keybinding: + + + keyComboEdit + + + + + + + + + + Arguments: + + + argumentsEdit + + + + + + + You should never see this message :) + + + argumentsDescription + + + + + + + + + + Newline separated arguments for the action + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + nameEdit + categoryPicker + actionPicker + keyComboEdit + argumentsEdit + + + + + buttons + accepted() + EditHotkeyDialog + afterEdit() + + + 257 + 290 + + + 157 + 274 + + + + + buttons + rejected() + EditHotkeyDialog + reject() + + + 325 + 290 + + + 286 + 274 + + + + + categoryPicker + currentIndexChanged(int) + EditHotkeyDialog + updatePossibleActions() + + + 246 + 85 + + + 75 + 218 + + + + + actionPicker + currentIndexChanged(int) + EditHotkeyDialog + updateArgumentsInput() + + + 148 + 119 + + + 74 + 201 + + + + + + afterEdit() + updatePossibleActions() + updateArgumentsInput() + + diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index d76a0102a62..e6828fc6e8b 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -2,7 +2,9 @@ #include "Application.hpp" #include "common/CompletionModel.hpp" +#include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "debug/Benchmark.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" @@ -10,13 +12,11 @@ #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Emotes.hpp" #include "singletons/WindowManager.hpp" -#include "util/Shortcut.hpp" #include "widgets/Notebook.hpp" #include "widgets/Scrollbar.hpp" #include "widgets/helper/ChannelView.hpp" #include -#include #include namespace chatterino { @@ -137,8 +137,8 @@ EmotePopup::EmotePopup(QWidget *parent) auto layout = new QVBoxLayout(this); this->getLayoutContainer()->setLayout(layout); - auto notebook = new Notebook(this); - layout->addWidget(notebook); + this->notebook_ = new Notebook(this); + layout->addWidget(this->notebook_); layout->setMargin(0); auto clicked = [this](const Link &link) { @@ -152,7 +152,7 @@ EmotePopup::EmotePopup(QWidget *parent) MessageElementFlag::Default, MessageElementFlag::AlwaysShow, MessageElementFlag::EmoteImages}); view->setEnableScrollingToBottom(false); - notebook->addPage(view, tabTitle); + this->notebook_->addPage(view, tabTitle); view->linkClicked.connect(clicked); return view; @@ -164,43 +164,99 @@ EmotePopup::EmotePopup(QWidget *parent) this->viewEmojis_ = makeView("Emojis"); this->loadEmojis(); + this->addShortcuts(); + this->signalHolder_.managedConnect(getApp()->hotkeys->onItemsUpdated, + [this]() { + this->clearShortcuts(); + this->addShortcuts(); + }); +} +void EmotePopup::addShortcuts() +{ + HotkeyController::HotkeyMap actions{ + {"openTab", // CTRL + 1-8 to open corresponding tab. + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "openTab shortcut called without arguments. Takes " + "only one argument: tab specifier"; + return "openTab shortcut called without arguments. " + "Takes only one argument: tab specifier"; + } + auto target = arguments.at(0); + if (target == "last") + { + this->notebook_->selectLastTab(); + } + else if (target == "next") + { + this->notebook_->selectNextTab(); + } + else if (target == "previous") + { + this->notebook_->selectPreviousTab(); + } + else + { + bool ok; + int result = target.toInt(&ok); + if (ok) + { + this->notebook_->selectIndex(result); + } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid argument for openTab shortcut"; + return QString("Invalid argument for openTab " + "shortcut: \"%1\". Use \"last\", " + "\"next\", \"previous\" or an integer.") + .arg(target); + } + } + return ""; + }}, + {"delete", + [this](std::vector) -> QString { + this->close(); + return ""; + }}, + {"scrollPage", + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "scrollPage hotkey called without arguments!"; + return "scrollPage hotkey called without arguments!"; + } + auto direction = arguments.at(0); + auto channelView = dynamic_cast( + this->notebook_->getSelectedPage()); + + auto &scrollbar = channelView->getScrollBar(); + if (direction == "up") + { + scrollbar.offset(-scrollbar.getLargeChange()); + } + else if (direction == "down") + { + scrollbar.offset(scrollbar.getLargeChange()); + } + else + { + qCWarning(chatterinoHotkeys) << "Unknown scroll direction"; + } + return ""; + }}, + + {"reject", nullptr}, + {"accept", nullptr}, + {"search", nullptr}, + }; - // CTRL + 1-8 to open corresponding tab - for (auto i = 0; i < 8; i++) - { - const auto openTab = [this, i, notebook] { - notebook->selectIndex(i); - }; - createWindowShortcut(this, QString("CTRL+%1").arg(i + 1).toUtf8(), - openTab); - } - - // Open last tab (first one from right) - createWindowShortcut(this, "CTRL+9", [=] { - notebook->selectLastTab(); - }); - - // Cycle through tabs - createWindowShortcut(this, "CTRL+Tab", [=] { - notebook->selectNextTab(); - }); - createWindowShortcut(this, "CTRL+Shift+Tab", [=] { - notebook->selectPreviousTab(); - }); - - // Scroll with Page Up / Page Down - createWindowShortcut(this, "PgUp", [=] { - auto &scrollbar = - dynamic_cast(notebook->getSelectedPage()) - ->getScrollBar(); - scrollbar.offset(-scrollbar.getLargeChange()); - }); - createWindowShortcut(this, "PgDown", [=] { - auto &scrollbar = - dynamic_cast(notebook->getSelectedPage()) - ->getScrollBar(); - scrollbar.offset(scrollbar.getLargeChange()); - }); + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::PopupWindow, actions, this); } void EmotePopup::loadChannel(ChannelPtr _channel) diff --git a/src/widgets/dialogs/EmotePopup.hpp b/src/widgets/dialogs/EmotePopup.hpp index 18647f26ded..8ed416bb023 100644 --- a/src/widgets/dialogs/EmotePopup.hpp +++ b/src/widgets/dialogs/EmotePopup.hpp @@ -1,6 +1,7 @@ #pragma once #include "widgets/BasePopup.hpp" +#include "widgets/Notebook.hpp" #include @@ -28,6 +29,9 @@ class EmotePopup : public BasePopup ChannelView *channelEmotesView_{}; ChannelView *subEmotesView_{}; ChannelView *viewEmojis_{}; + + Notebook *notebook_; + void addShortcuts() override; }; } // namespace chatterino diff --git a/src/widgets/dialogs/SelectChannelDialog.cpp b/src/widgets/dialogs/SelectChannelDialog.cpp index 41dcf143573..9eeb762d2b5 100644 --- a/src/widgets/dialogs/SelectChannelDialog.cpp +++ b/src/widgets/dialogs/SelectChannelDialog.cpp @@ -1,10 +1,11 @@ #include "SelectChannelDialog.hpp" #include "Application.hpp" +#include "common/QLogging.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Theme.hpp" #include "util/LayoutCreator.hpp" -#include "util/Shortcut.hpp" #include "widgets/Notebook.hpp" #include "widgets/dialogs/IrcConnectionEditor.hpp" #include "widgets/helper/NotebookTab.hpp" @@ -237,27 +238,15 @@ SelectChannelDialog::SelectChannelDialog(QWidget *parent) this->ui_.notebook->selectIndex(TAB_TWITCH); this->ui_.twitch.channel->setFocus(); - // Shortcuts - createWindowShortcut(this, "Return", [=] { - this->ok(); - }); - createWindowShortcut(this, "Esc", [=] { - this->close(); - }); - // restore ui state // fourtf: enable when releasing irc if (getSettings()->enableExperimentalIrc) { this->ui_.notebook->selectIndex(getSettings()->lastSelectChannelTab); - createWindowShortcut(this, "Ctrl+Tab", [=] { - this->ui_.notebook->selectNextTab(); - }); - createWindowShortcut(this, "CTRL+Shift+Tab", [=] { - this->ui_.notebook->selectPreviousTab(); - }); } + this->addShortcuts(); + this->ui_.irc.servers->getTableView()->selectRow( getSettings()->lastSelectIrcConn); } @@ -516,4 +505,80 @@ void SelectChannelDialog::themeChangedEvent() } } +void SelectChannelDialog::addShortcuts() +{ + HotkeyController::HotkeyMap actions{ + {"accept", + [this](std::vector) -> QString { + this->ok(); + return ""; + }}, + {"reject", + [this](std::vector) -> QString { + this->close(); + return ""; + }}, + + // these make no sense, so they aren't implemented + {"scrollPage", nullptr}, + {"search", nullptr}, + {"delete", nullptr}, + }; + + if (getSettings()->enableExperimentalIrc) + { + actions.insert( + {"openTab", [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "openTab shortcut called without arguments. " + "Takes only " + "one argument: tab specifier"; + return "openTab shortcut called without arguments. " + "Takes only one argument: tab specifier"; + } + auto target = arguments.at(0); + if (target == "last") + { + this->ui_.notebook->selectLastTab(); + } + else if (target == "next") + { + this->ui_.notebook->selectNextTab(); + } + else if (target == "previous") + { + this->ui_.notebook->selectPreviousTab(); + } + else + { + bool ok; + int result = target.toInt(&ok); + if (ok) + { + this->ui_.notebook->selectIndex(result); + } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid argument for openTab shortcut"; + return QString("Invalid argument for openTab " + "shortcut: \"%1\". Use \"last\", " + "\"next\", \"previous\" or an integer.") + .arg(target); + } + } + return ""; + }}); + } + else + { + actions.emplace("openTab", nullptr); + } + + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::PopupWindow, actions, this); +} + } // namespace chatterino diff --git a/src/widgets/dialogs/SelectChannelDialog.hpp b/src/widgets/dialogs/SelectChannelDialog.hpp index 3d7e7346c04..707ef32cc7b 100644 --- a/src/widgets/dialogs/SelectChannelDialog.hpp +++ b/src/widgets/dialogs/SelectChannelDialog.hpp @@ -64,6 +64,8 @@ class SelectChannelDialog final : public BaseWindow void ok(); friend class EventFilter; + + void addShortcuts() override; }; } // namespace chatterino diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index 47db42b4d4b..d8210b325c3 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -3,9 +3,9 @@ #include "Application.hpp" #include "common/Args.hpp" #include "controllers/commands/CommandController.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "singletons/Resources.hpp" #include "util/LayoutCreator.hpp" -#include "util/Shortcut.hpp" #include "widgets/helper/Button.hpp" #include "widgets/settingspages/AboutPage.hpp" #include "widgets/settingspages/AccountsPage.hpp" @@ -30,6 +30,7 @@ SettingsDialog::SettingsDialog(QWidget *parent) {BaseWindow::Flags::DisableCustomScaling, BaseWindow::Flags::Dialog}, parent) { + this->setObjectName("SettingsDialog"); this->setWindowTitle("Chatterino Settings"); this->resize(915, 600); this->themeChangedEvent(); @@ -40,14 +41,35 @@ SettingsDialog::SettingsDialog(QWidget *parent) this->overrideBackgroundColor_ = QColor("#111111"); this->scaleChangedEvent(this->scale()); // execute twice to width of item - createWindowShortcut(this, "CTRL+F", [this] { - this->ui_.search->setFocus(); - this->ui_.search->selectAll(); - }); - // Disable the ? button in the titlebar until we decide to use it this->setWindowFlags(this->windowFlags() & ~Qt::WindowContextHelpButtonHint); + this->addShortcuts(); + this->signalHolder_.managedConnect(getApp()->hotkeys->onItemsUpdated, + [this]() { + this->clearShortcuts(); + this->addShortcuts(); + }); +} + +void SettingsDialog::addShortcuts() +{ + HotkeyController::HotkeyMap actions{ + {"search", + [this](std::vector) -> QString { + this->ui_.search->setFocus(); + this->ui_.search->selectAll(); + return ""; + }}, + {"delete", nullptr}, + {"accept", nullptr}, + {"reject", nullptr}, + {"scrollPage", nullptr}, + {"openTab", nullptr}, + }; + + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::PopupWindow, actions, this); } void SettingsDialog::initUi() @@ -63,7 +85,7 @@ void SettingsDialog::initUi() .withoutMargin() .emplace() .assign(&this->ui_.search); - edit->setPlaceholderText("Find in settings... (Ctrl+F)"); + edit->setPlaceholderText("Find in settings... (Ctrl+F by default)"); QObject::connect(edit.getElement(), &QLineEdit::textChanged, this, &SettingsDialog::filterElements); @@ -172,7 +194,7 @@ void SettingsDialog::addTabs() this->addTab([]{return new IgnoresPage;}, "Ignores", ":/settings/ignore.svg"); this->addTab([]{return new FiltersPage;}, "Filters", ":/settings/filters.svg"); this->ui_.tabContainer->addSpacing(16); - this->addTab([]{return new KeyboardSettingsPage;}, "Keybindings", ":/settings/keybinds.svg"); + this->addTab([]{return new KeyboardSettingsPage;}, "Hotkeys", ":/settings/keybinds.svg"); this->addTab([]{return new ModerationPage;}, "Moderation", ":/settings/moderation.svg", SettingsTabId::Moderation); this->addTab([]{return new NotificationPage;}, "Live Notifications", ":/settings/notification2.svg"); this->addTab([]{return new ExternalToolsPage;}, "External tools", ":/settings/externaltools.svg"); diff --git a/src/widgets/dialogs/SettingsDialog.hpp b/src/widgets/dialogs/SettingsDialog.hpp index f30cde34b7b..5176b76dd39 100644 --- a/src/widgets/dialogs/SettingsDialog.hpp +++ b/src/widgets/dialogs/SettingsDialog.hpp @@ -60,6 +60,7 @@ class SettingsDialog : public BaseWindow void onOkClicked(); void onCancelClicked(); + void addShortcuts() override; struct { QWidget *tabContainerContainer{}; diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 9cc6c25ec39..6315e201e94 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -3,8 +3,10 @@ #include "Application.hpp" #include "common/Channel.hpp" #include "common/NetworkRequest.hpp" +#include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/highlights/HighlightBlacklistUser.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/IvrApi.hpp" @@ -18,9 +20,9 @@ #include "util/Helpers.hpp" #include "util/LayoutCreator.hpp" #include "util/PostToThread.hpp" -#include "util/Shortcut.hpp" #include "util/StreamerMode.hpp" #include "widgets/Label.hpp" +#include "widgets/Scrollbar.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/EffectLabel.hpp" #include "widgets/helper/Line.hpp" @@ -140,10 +142,47 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent) else this->setAttribute(Qt::WA_DeleteOnClose); - // Close the popup when Escape is pressed - createWindowShortcut(this, "Escape", [this] { - this->deleteLater(); - }); + HotkeyController::HotkeyMap actions{ + {"delete", + [this](std::vector) -> QString { + this->deleteLater(); + return ""; + }}, + {"scrollPage", + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "scrollPage hotkey called without arguments!"; + return "scrollPage hotkey called without arguments!"; + } + auto direction = arguments.at(0); + + auto &scrollbar = this->ui_.latestMessages->getScrollBar(); + if (direction == "up") + { + scrollbar.offset(-scrollbar.getLargeChange()); + } + else if (direction == "down") + { + scrollbar.offset(scrollbar.getLargeChange()); + } + else + { + qCWarning(chatterinoHotkeys) << "Unknown scroll direction"; + } + return ""; + }}, + + // these actions make no sense in the context of a usercard, so they aren't implemented + {"reject", nullptr}, + {"accept", nullptr}, + {"openTab", nullptr}, + {"search", nullptr}, + }; + + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::PopupWindow, actions, this); auto layout = LayoutCreator(this->getLayoutContainer()) .setLayoutType(); diff --git a/src/widgets/helper/EditableModelView.hpp b/src/widgets/helper/EditableModelView.hpp index cd186ef078f..87ea0939791 100644 --- a/src/widgets/helper/EditableModelView.hpp +++ b/src/widgets/helper/EditableModelView.hpp @@ -31,6 +31,8 @@ class EditableModelView : public QWidget QHBoxLayout *buttons_{}; void moveRow(int dir); + +public: void selectRow(int row); }; diff --git a/src/widgets/helper/ResizingTextEdit.cpp b/src/widgets/helper/ResizingTextEdit.cpp index 0182289b3a0..da718868d6b 100644 --- a/src/widgets/helper/ResizingTextEdit.cpp +++ b/src/widgets/helper/ResizingTextEdit.cpp @@ -27,6 +27,7 @@ ResizingTextEdit::ResizingTextEdit() }); this->setFocusPolicy(Qt::ClickFocus); + this->installEventFilter(this); } QSize ResizingTextEdit::sizeHint() const @@ -95,6 +96,22 @@ QString ResizingTextEdit::textUnderCursor(bool *hadSpace) const return lastWord; } +bool ResizingTextEdit::eventFilter(QObject *, QEvent *event) +{ + // makes QShortcuts work in the ResizingTextEdit + if (event->type() != QEvent::ShortcutOverride) + { + return false; + } + auto ev = static_cast(event); + ev->ignore(); + if ((ev->key() == Qt::Key_C || ev->key() == Qt::Key_Insert) && + ev->modifiers() == Qt::ControlModifier) + { + return false; + } + return true; +} void ResizingTextEdit::keyPressEvent(QKeyEvent *event) { event->ignore(); diff --git a/src/widgets/helper/ResizingTextEdit.hpp b/src/widgets/helper/ResizingTextEdit.hpp index d3726e27afd..1ee423f481b 100644 --- a/src/widgets/helper/ResizingTextEdit.hpp +++ b/src/widgets/helper/ResizingTextEdit.hpp @@ -43,6 +43,7 @@ class ResizingTextEdit : public QTextEdit QCompleter *completer_ = nullptr; bool completionInProgress_ = false; + bool eventFilter(QObject *widget, QEvent *event) override; private slots: void insertCompletion(const QString &completion); }; diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index d96e0fa6c53..6c9ac963412 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -6,6 +6,7 @@ #include #include "common/Channel.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "messages/Message.hpp" #include "messages/search/AuthorPredicate.hpp" #include "messages/search/ChannelPredicate.hpp" @@ -13,7 +14,6 @@ #include "messages/search/MessageFlagsPredicate.hpp" #include "messages/search/RegexPredicate.hpp" #include "messages/search/SubstringPredicate.hpp" -#include "util/Shortcut.hpp" #include "widgets/helper/ChannelView.hpp" namespace chatterino { @@ -60,11 +60,32 @@ SearchPopup::SearchPopup(QWidget *parent) { this->initLayout(); this->resize(400, 600); + this->addShortcuts(); +} - createShortcut(this, "CTRL+F", [this] { - this->searchInput_->setFocus(); - this->searchInput_->selectAll(); - }); +void SearchPopup::addShortcuts() +{ + HotkeyController::HotkeyMap actions{ + {"search", + [this](std::vector) -> QString { + this->searchInput_->setFocus(); + this->searchInput_->selectAll(); + return ""; + }}, + {"delete", + [this](std::vector) -> QString { + this->close(); + return ""; + }}, + + {"reject", nullptr}, + {"accept", nullptr}, + {"openTab", nullptr}, + {"scrollPage", nullptr}, + }; + + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::PopupWindow, actions, this); } void SearchPopup::setChannelFilters(FilterSetPtr filters) diff --git a/src/widgets/helper/SearchPopup.hpp b/src/widgets/helper/SearchPopup.hpp index 1938f0b4d4e..cf9499ec245 100644 --- a/src/widgets/helper/SearchPopup.hpp +++ b/src/widgets/helper/SearchPopup.hpp @@ -26,6 +26,7 @@ class SearchPopup : public BasePopup private: void initLayout(); void search(); + void addShortcuts() override; /** * @brief Only retains those message from a list of messages that satisfy a diff --git a/src/widgets/listview/GenericListView.cpp b/src/widgets/listview/GenericListView.cpp index 588c9559150..d38c7b0eaee 100644 --- a/src/widgets/listview/GenericListView.cpp +++ b/src/widgets/listview/GenericListView.cpp @@ -1,4 +1,5 @@ -#include "GenericListView.hpp" +#include "widgets/listview/GenericListView.hpp" + #include "singletons/Theme.hpp" #include "widgets/listview/GenericListModel.hpp" @@ -18,7 +19,7 @@ GenericListView::GenericListView() auto *item = GenericListItem::fromVariant(index.data()); item->action(); - emit this->closeRequested(); + this->requestClose(); }); } @@ -42,64 +43,58 @@ void GenericListView::setInvokeActionOnTab(bool value) bool GenericListView::eventFilter(QObject * /*watched*/, QEvent *event) { - if (!this->model_) + if (this->model_ == nullptr) + { return false; + } if (event->type() == QEvent::KeyPress) { auto *keyEvent = static_cast(event); int key = keyEvent->key(); - const QModelIndex &curIdx = this->currentIndex(); - const int curRow = curIdx.row(); - const int count = this->model_->rowCount(curIdx); - - if (key == Qt::Key_Enter || key == Qt::Key_Return || - (key == Qt::Key_Tab && this->invokeActionOnTab_)) + if (key == Qt::Key_Enter || key == Qt::Key_Return) { - // keep this before the other tab handler - if (count <= 0) - return true; - - const auto index = this->currentIndex(); - auto *item = GenericListItem::fromVariant(index.data()); - - item->action(); - - emit this->closeRequested(); + this->acceptCompletion(); return true; } - else if (key == Qt::Key_Down || key == Qt::Key_Tab) - { - if (count <= 0) - return true; - const int newRow = (curRow + 1) % count; + if (key == Qt::Key_Tab) + { + if (this->invokeActionOnTab_) + { + this->acceptCompletion(); + } + else + { + this->focusNextCompletion(); + } - this->setCurrentIndex(curIdx.siblingAtRow(newRow)); return true; } - else if (key == Qt::Key_Up || - (!this->invokeActionOnTab_ && key == Qt::Key_Backtab)) - { - if (count <= 0) - return true; - int newRow = curRow - 1; - if (newRow < 0) - newRow += count; + if (key == Qt::Key_Backtab && !this->invokeActionOnTab_) + { + this->focusPreviousCompletion(); + return true; + } - this->setCurrentIndex(curIdx.siblingAtRow(newRow)); + if (key == Qt::Key_Down) + { + this->focusNextCompletion(); return true; } - else if (key == Qt::Key_Escape) + + if (key == Qt::Key_Up) { - emit this->closeRequested(); + this->focusPreviousCompletion(); return true; } - else + + if (key == Qt::Key_Escape) { - return false; + this->requestClose(); + return true; } } @@ -126,4 +121,63 @@ void GenericListView::refreshTheme(const Theme &theme) this->setStyleSheet(listStyle); } +bool GenericListView::acceptCompletion() +{ + const QModelIndex &curIdx = this->currentIndex(); + const int curRow = curIdx.row(); + const int count = this->model_->rowCount(curIdx); + if (count <= 0) + { + return false; + } + + const auto index = this->currentIndex(); + auto *item = GenericListItem::fromVariant(index.data()); + + item->action(); + + this->requestClose(); + + return true; +} + +void GenericListView::focusNextCompletion() +{ + const QModelIndex &curIdx = this->currentIndex(); + const int curRow = curIdx.row(); + const int count = this->model_->rowCount(curIdx); + if (count <= 0) + { + return; + } + + const int newRow = (curRow + 1) % count; + + this->setCurrentIndex(curIdx.siblingAtRow(newRow)); +} + +void GenericListView::focusPreviousCompletion() +{ + const QModelIndex &curIdx = this->currentIndex(); + const int curRow = curIdx.row(); + const int count = this->model_->rowCount(curIdx); + if (count <= 0) + { + return; + } + + int newRow = curRow - 1; + if (newRow < 0) + { + newRow += count; + } + + this->setCurrentIndex(curIdx.siblingAtRow(newRow)); +} + +void GenericListView::requestClose() +{ + emit this->closeRequested(); +} + } // namespace chatterino diff --git a/src/widgets/listview/GenericListView.hpp b/src/widgets/listview/GenericListView.hpp index 2faee1913df..10aea451678 100644 --- a/src/widgets/listview/GenericListView.hpp +++ b/src/widgets/listview/GenericListView.hpp @@ -1,9 +1,10 @@ #pragma once -#include #include "widgets/listview/GenericItemDelegate.hpp" #include "widgets/listview/GenericListItem.hpp" +#include + namespace chatterino { class GenericListModel; @@ -31,6 +32,28 @@ class GenericListView : public QListView private: bool invokeActionOnTab_{}; + + /** + * @brief Gets the currently selected item (if any) and calls its action + * + * @return true if an action was called on an item, false if no item was selected and thus no action was called + **/ + bool acceptCompletion(); + + /** + * @brief Select the next item in the list. Wraps around if the bottom of the list has been reached. + **/ + void focusNextCompletion(); + + /** + * @brief Select the previous item in the list. Wraps around if the top of the list has been reached. + **/ + void focusPreviousCompletion(); + + /** + * @brief Request for the GUI powering this list view to be closed. Shorthand for emit this->closeRequested() + **/ + void requestClose(); }; } // namespace chatterino diff --git a/src/widgets/settingspages/KeyboardSettingsPage.cpp b/src/widgets/settingspages/KeyboardSettingsPage.cpp index 94e702be9f8..ac16bf7ec48 100644 --- a/src/widgets/settingspages/KeyboardSettingsPage.cpp +++ b/src/widgets/settingspages/KeyboardSettingsPage.cpp @@ -1,81 +1,100 @@ #include "KeyboardSettingsPage.hpp" +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" +#include "controllers/hotkeys/HotkeyModel.hpp" #include "util/LayoutCreator.hpp" +#include "widgets/dialogs/EditHotkeyDialog.hpp" #include +#include #include +#include namespace chatterino { KeyboardSettingsPage::KeyboardSettingsPage() { - auto layout = - LayoutCreator(this).setLayoutType(); - - auto scroll = layout.emplace(); - - this->setStyleSheet("QLabel, #container { background: #333 }"); - - auto form = new QFormLayout(this); - scroll->setWidgetResizable(true); - auto widget = new QWidget(); - widget->setLayout(form); - widget->setObjectName("container"); - scroll->setWidget(widget); - - form->addRow(new QLabel("Hold Ctrl"), new QLabel("Show resize handles")); - form->addRow(new QLabel("Hold Ctrl + Alt"), - new QLabel("Show split overlay")); - - form->addItem(new QSpacerItem(16, 16)); - form->addRow(new QLabel("Ctrl + ScrollDown/-"), new QLabel("Zoom out")); - form->addRow(new QLabel("Ctrl + ScrollUp/+"), new QLabel("Zoom in")); - form->addRow(new QLabel("Ctrl + 0"), new QLabel("Reset zoom size")); - - form->addItem(new QSpacerItem(16, 16)); - form->addRow(new QLabel("Ctrl + T"), new QLabel("Create new split")); - form->addRow(new QLabel("Ctrl + W"), new QLabel("Close current split")); - form->addRow(new QLabel("Ctrl + N"), - new QLabel("Open current split as a popup")); - form->addRow(new QLabel("Ctrl + K"), new QLabel("Jump to split")); - form->addRow(new QLabel("Ctrl + G"), - new QLabel("Reopen last closed split")); - - form->addRow(new QLabel("Ctrl + Shift + T"), new QLabel("Create new tab")); - form->addRow(new QLabel("Ctrl + Shift + W"), - new QLabel("Close current tab")); - form->addRow(new QLabel("Ctrl + Shift + N"), - new QLabel("Open current tab as a popup")); - form->addRow(new QLabel("Ctrl + H"), - new QLabel("Hide/Show similar messages (See General->R9K)")); - - form->addItem(new QSpacerItem(16, 16)); - form->addRow(new QLabel("Ctrl + 1/2/3/..."), - new QLabel("Select tab 1/2/3/...")); - form->addRow(new QLabel("Ctrl + 9"), new QLabel("Select last tab")); - form->addRow(new QLabel("Ctrl + Tab"), new QLabel("Select next tab")); - form->addRow(new QLabel("Ctrl + Shift + Tab"), - new QLabel("Select previous tab")); - - form->addRow(new QLabel("Alt + ←/↑/→/↓"), - new QLabel("Select left/upper/right/bottom split")); - form->addRow(new QLabel("Ctrl + U"), - new QLabel("Toggle visibility of tabs")); - - form->addItem(new QSpacerItem(16, 16)); - form->addRow(new QLabel("Ctrl + R"), new QLabel("Change channel")); - form->addRow(new QLabel("Ctrl + F"), - new QLabel("Search in current channel")); - form->addRow(new QLabel("Ctrl + E"), new QLabel("Open Emote menu")); - form->addRow(new QLabel("Ctrl + P"), new QLabel("Open Settings menu")); - form->addRow(new QLabel("F5"), - new QLabel("Reload subscriber and channel emotes")); - form->addRow(new QLabel("Ctrl + F5"), new QLabel("Reconnect channels")); - form->addRow(new QLabel("Alt + X"), new QLabel("Create a clip")); - - form->addItem(new QSpacerItem(16, 16)); - form->addRow(new QLabel("PageUp"), new QLabel("Scroll up")); - form->addRow(new QLabel("PageDown"), new QLabel("Scroll down")); + LayoutCreator layoutCreator(this); + auto layout = layoutCreator.emplace(); + + auto model = getApp()->hotkeys->createModel(nullptr); + EditableModelView *view = + layout.emplace(model).getElement(); + + view->setTitles({"Hotkey name", "Keybinding"}); + view->getTableView()->horizontalHeader()->setVisible(true); + view->getTableView()->horizontalHeader()->setStretchLastSection(false); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + QHeaderView::ResizeToContents); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + 1, QHeaderView::Stretch); + + view->addButtonPressed.connect([view, model] { + EditHotkeyDialog dialog(nullptr); + bool wasAccepted = dialog.exec() == 1; + + if (wasAccepted) + { + auto newHotkey = dialog.data(); + int vectorIndex = getApp()->hotkeys->hotkeys_.append(newHotkey); + getApp()->hotkeys->save(); + + // Select and scroll to newly added hotkey + auto modelRow = model->getModelIndexFromVectorIndex(vectorIndex); + auto modelIndex = model->index(modelRow, 0); + view->selectRow(modelRow); + view->getTableView()->scrollTo(modelIndex, + QAbstractItemView::PositionAtCenter); + } + }); + + QObject::connect(view->getTableView(), &QTableView::doubleClicked, + [this, view, model](const QModelIndex &clicked) { + this->tableCellClicked(clicked, view, model); + }); + + QPushButton *resetEverything = new QPushButton("Reset to defaults"); + QObject::connect(resetEverything, &QPushButton::clicked, [this]() { + auto reply = QMessageBox::question( + this, "Reset hotkeys", + "Are you sure you want to reset hotkeys to defaults?", + QMessageBox::Yes | QMessageBox::Cancel); + + if (reply == QMessageBox::Yes) + { + getApp()->hotkeys->resetToDefaults(); + } + }); + view->addCustomButton(resetEverything); +} + +void KeyboardSettingsPage::tableCellClicked(const QModelIndex &clicked, + EditableModelView *view, + HotkeyModel *model) +{ + auto hotkey = getApp()->hotkeys->getHotkeyByName( + clicked.siblingAtColumn(0).data(Qt::EditRole).toString()); + if (!hotkey) + { + return; // clicked on header or invalid hotkey + } + EditHotkeyDialog dialog(hotkey); + bool wasAccepted = dialog.exec() == 1; + + if (wasAccepted) + { + auto newHotkey = dialog.data(); + auto vectorIndex = + getApp()->hotkeys->replaceHotkey(hotkey->name(), newHotkey); + getApp()->hotkeys->save(); + + // Select the replaced hotkey + auto modelRow = model->getModelIndexFromVectorIndex(vectorIndex); + auto modelIndex = model->index(modelRow, 0); + view->selectRow(modelRow); + } } } // namespace chatterino diff --git a/src/widgets/settingspages/KeyboardSettingsPage.hpp b/src/widgets/settingspages/KeyboardSettingsPage.hpp index d1e90d3731a..1da25070b82 100644 --- a/src/widgets/settingspages/KeyboardSettingsPage.hpp +++ b/src/widgets/settingspages/KeyboardSettingsPage.hpp @@ -1,13 +1,20 @@ #pragma once +#include "widgets/helper/EditableModelView.hpp" #include "widgets/settingspages/SettingsPage.hpp" namespace chatterino { +class HotkeyModel; + class KeyboardSettingsPage : public SettingsPage { public: KeyboardSettingsPage(); + +private: + void tableCellClicked(const QModelIndex &clicked, EditableModelView *view, + HotkeyModel *model); }; } // namespace chatterino diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index a8e71461eff..a04172725c6 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -6,6 +6,9 @@ #include "common/NetworkRequest.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandController.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" +#include "controllers/notifications/NotificationController.hpp" #include "providers/twitch/EmoteValue.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -17,9 +20,9 @@ #include "util/Clipboard.hpp" #include "util/Helpers.hpp" #include "util/NuulsUploader.hpp" -#include "util/Shortcut.hpp" #include "util/StreamLink.hpp" #include "widgets/Notebook.hpp" +#include "widgets/Scrollbar.hpp" #include "widgets/TooltipWidget.hpp" #include "widgets/Window.hpp" #include "widgets/dialogs/QualityPopup.hpp" @@ -97,51 +100,6 @@ Split::Split(QWidget *parent) this->vbox_->addWidget(this->view_, 1); this->vbox_->addWidget(this->input_); - // Initialize chat widget-wide hotkeys - // CTRL+W: Close Split - createShortcut(this, "CTRL+W", &Split::deleteFromContainer); - - // CTRL+R: Change Channel - createShortcut(this, "CTRL+R", &Split::changeChannel); - - // CTRL+F: Search - createShortcut(this, "CTRL+F", &Split::showSearch); - - // F5: reload emotes - createShortcut(this, "F5", &Split::reloadChannelAndSubscriberEmotes); - - // CTRL+F5: reconnect - createShortcut(this, "CTRL+F5", &Split::reconnect); - - // Alt+X: create clip LUL - createShortcut(this, "Alt+X", [this] { - if (const auto type = this->getChannel()->getType(); - type != Channel::Type::Twitch && - type != Channel::Type::TwitchWatching) - { - return; - } - - auto *twitchChannel = - dynamic_cast(this->getChannel().get()); - - twitchChannel->createClip(); - }); - - // F10 - createShortcut(this, "F10", [] { - auto *popup = new DebugPopup; - popup->setAttribute(Qt::WA_DeleteOnClose); - popup->setWindowTitle("Chatterino - Debug popup"); - popup->show(); - }); - - // xd - // CreateShortcut(this, "ALT+SHIFT+RIGHT", &Split::doIncFlexX); - // CreateShortcut(this, "ALT+SHIFT+LEFT", &Split::doDecFlexX); - // CreateShortcut(this, "ALT+SHIFT+UP", &Split::doIncFlexY); - // CreateShortcut(this, "ALT+SHIFT+DOWN", &Split::doDecFlexY); - this->input_->ui_.textEdit->installEventFilter(parent); // update placeholder text on Twitch account change and channel change @@ -293,6 +251,302 @@ Split::Split(QWidget *parent) this->setAcceptDrops(val); }, this->managedConnections_); + this->addShortcuts(); + this->managedConnect(getApp()->hotkeys->onItemsUpdated, [this]() { + this->clearShortcuts(); + this->addShortcuts(); + }); +} + +void Split::addShortcuts() +{ + HotkeyController::HotkeyMap actions{ + {"delete", + [this](std::vector) -> QString { + this->deleteFromContainer(); + return ""; + }}, + {"changeChannel", + [this](std::vector) -> QString { + this->changeChannel(); + return ""; + }}, + {"showSearch", + [this](std::vector) -> QString { + this->showSearch(); + return ""; + }}, + {"reconnect", + [this](std::vector) -> QString { + this->reconnect(); + return ""; + }}, + {"debug", + [](std::vector) -> QString { + auto *popup = new DebugPopup; + popup->setAttribute(Qt::WA_DeleteOnClose); + popup->setWindowTitle("Chatterino - Debug popup"); + popup->show(); + return ""; + }}, + {"focus", + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + return "focus action requires only one argument: the " + "focus direction Use \"up\", \"above\", \"down\", " + "\"below\", \"left\" or \"right\"."; + } + auto direction = arguments.at(0); + if (direction == "up" || direction == "above") + { + this->actionRequested.invoke(Action::SelectSplitAbove); + } + else if (direction == "down" || direction == "below") + { + this->actionRequested.invoke(Action::SelectSplitBelow); + } + else if (direction == "left") + { + this->actionRequested.invoke(Action::SelectSplitLeft); + } + else if (direction == "right") + { + this->actionRequested.invoke(Action::SelectSplitRight); + } + else + { + return "focus in unknown direction. Use \"up\", " + "\"above\", \"down\", \"below\", \"left\" or " + "\"right\"."; + } + return ""; + }}, + {"scrollToBottom", + [this](std::vector) -> QString { + this->getChannelView().getScrollBar().scrollToBottom( + getSettings()->enableSmoothScrollingNewMessages.getValue()); + return ""; + }}, + {"scrollPage", + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "scrollPage hotkey called without arguments!"; + return "scrollPage hotkey called without arguments!"; + } + auto direction = arguments.at(0); + + auto &scrollbar = this->getChannelView().getScrollBar(); + if (direction == "up") + { + scrollbar.offset(-scrollbar.getLargeChange()); + } + else if (direction == "down") + { + scrollbar.offset(scrollbar.getLargeChange()); + } + else + { + qCWarning(chatterinoHotkeys) << "Unknown scroll direction"; + } + return ""; + }}, + {"pickFilters", + [this](std::vector) -> QString { + this->setFiltersDialog(); + return ""; + }}, + {"startWatching", + [this](std::vector) -> QString { + this->startWatching(); + return ""; + }}, + {"openInBrowser", + [this](std::vector) -> QString { + if (this->getChannel()->getType() == Channel::Type::TwitchWhispers) + { + this->openWhispersInBrowser(); + } + else + { + this->openInBrowser(); + } + + return ""; + }}, + {"openInStreamlink", + [this](std::vector) -> QString { + this->openInStreamlink(); + return ""; + }}, + {"openInCustomPlayer", + [this](std::vector) -> QString { + this->openWithCustomScheme(); + return ""; + }}, + {"openModView", + [this](std::vector) -> QString { + this->openModViewInBrowser(); + return ""; + }}, + {"createClip", + [this](std::vector) -> QString { + // Alt+X: create clip LUL + if (const auto type = this->getChannel()->getType(); + type != Channel::Type::Twitch && + type != Channel::Type::TwitchWatching) + { + return "Cannot create clip it non-twitch channel."; + } + + auto *twitchChannel = + dynamic_cast(this->getChannel().get()); + + twitchChannel->createClip(); + return ""; + }}, + {"reloadEmotes", + [this](std::vector arguments) -> QString { + auto reloadChannel = true; + auto reloadSubscriber = true; + if (arguments.size() != 0) + { + auto arg = arguments.at(0); + if (arg == "channel") + { + reloadSubscriber = false; + } + else if (arg == "subscriber") + { + reloadChannel = false; + } + } + + if (reloadChannel) + { + this->header_->reloadChannelEmotes(); + } + if (reloadSubscriber) + { + this->header_->reloadSubscriberEmotes(); + } + return ""; + }}, + {"setModerationMode", + [this](std::vector arguments) -> QString { + if (!this->getChannel()->isTwitchChannel()) + { + return "Cannot set moderation mode in non-twitch channel."; + } + auto mode = 2; + // 0 is off + // 1 is on + // 2 is toggle + if (arguments.size() != 0) + { + auto arg = arguments.at(0); + if (arg == "off") + { + mode = 0; + } + else if (arg == "on") + { + mode = 1; + } + else + { + mode = 2; + } + } + + if (mode == 0) + { + this->setModerationMode(false); + } + else if (mode == 1) + { + this->setModerationMode(true); + } + else + { + this->setModerationMode(!this->getModerationMode()); + } + return ""; + }}, + {"openViewerList", + [this](std::vector) -> QString { + this->showViewerList(); + return ""; + }}, + {"clearMessages", + [this](std::vector) -> QString { + this->clear(); + return ""; + }}, + {"runCommand", + [this](std::vector arguments) -> QString { + if (arguments.size() == 0) + { + qCWarning(chatterinoHotkeys) + << "runCommand hotkey called without arguments!"; + return "runCommand hotkey called without arguments!"; + } + QString command = getApp()->commands->execCommand( + arguments.at(0).replace('\n', ' '), this->getChannel(), false); + this->getChannel()->sendMessage(command); + return ""; + }}, + {"setChannelNotification", + [this](std::vector arguments) -> QString { + if (!this->getChannel()->isTwitchChannel()) + { + return "Cannot set channel notifications for non-twitch " + "channel."; + } + auto mode = 2; + // 0 is off + // 1 is on + // 2 is toggle + if (arguments.size() != 0) + { + auto arg = arguments.at(0); + if (arg == "off") + { + mode = 0; + } + else if (arg == "on") + { + mode = 1; + } + else + { + mode = 2; + } + } + + if (mode == 0) + { + getApp()->notifications->removeChannelNotification( + this->getChannel()->getName(), Platform::Twitch); + } + else if (mode == 1) + { + getApp()->notifications->addChannelNotification( + this->getChannel()->getName(), Platform::Twitch); + } + else + { + getApp()->notifications->updateChannelNotification( + this->getChannel()->getName(), Platform::Twitch); + } + return ""; + }}, + }; + + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::Split, actions, this); } Split::~Split() @@ -844,6 +1098,22 @@ void Split::copyToClipboard() crossPlatformCopy(this->view_->getSelectedText()); } +void Split::startWatching() +{ +#ifdef USEWEBENGINE + ChannelPtr _channel = this->getChannel(); + TwitchChannel *tc = dynamic_cast(_channel.get()); + + if (tc != nullptr) + { + StreamView *view = new StreamView( + _channel, + "https://player.twitch.tv/?parent=twitch.tv&channel=" + tc->name); + view->setAttribute(Qt::WA_DeleteOnClose, true); + view->show(); + } +#endif +} void Split::setFiltersDialog() { SelectChannelFiltersDialog d(this->getFilters(), this); diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp index c3861699460..35113ba3236 100644 --- a/src/widgets/splits/Split.hpp +++ b/src/widgets/splits/Split.hpp @@ -114,6 +114,7 @@ class Split : public BaseWidget, pajlada::Signals::SignalHolder void channelNameUpdated(const QString &newChannelName); void handleModifiers(Qt::KeyboardModifiers modifiers); void updateInputPlaceholder(); + void addShortcuts() override; /** * @brief Opens Twitch channel stream in a browser player (opens a formatted link) @@ -168,6 +169,7 @@ public slots: void openInStreamlink(); void openWithCustomScheme(); void copyToClipboard(); + void startWatching(); void setFiltersDialog(); void showSearch(); void showViewerList(); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 29b5ab92eb4..230128ae7d8 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -348,20 +348,8 @@ std::unique_ptr SplitHeader::createMainMenu() menu->addAction("Set filters", this->split_, &Split::setFiltersDialog); menu->addSeparator(); #ifdef USEWEBENGINE - this->dropdownMenu.addAction("Start watching", this, [this] { - ChannelPtr _channel = this->split->getChannel(); - TwitchChannel *tc = dynamic_cast(_channel.get()); - - if (tc != nullptr) - { - StreamView *view = new StreamView( - _channel, - "https://player.twitch.tv/?parent=twitch.tv&channel=" + - tc->name); - view->setAttribute(Qt::WA_DeleteOnClose, true); - view->show(); - } - }); + this->dropdownMenu.addAction("Start watching", this->split_, + &Split::startWatching); #endif auto *twitchChannel = diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index b02c97b7514..c447b8403c7 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -1,7 +1,9 @@ #include "widgets/splits/SplitInput.hpp" #include "Application.hpp" +#include "common/QLogging.hpp" #include "controllers/commands/CommandController.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "messages/Link.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -31,6 +33,7 @@ SplitInput::SplitInput(Split *_chatWidget) : BaseWidget(_chatWidget) , split_(_chatWidget) { + this->installEventFilter(this); this->initLayout(); auto completer = @@ -45,10 +48,16 @@ SplitInput::SplitInput(Split *_chatWidget) // misc this->installKeyPressedEvent(); + this->addShortcuts(); this->ui_.textEdit->focusLost.connect([this] { this->hideCompletionPopup(); }); this->scaleChangedEvent(this->scale()); + this->signalHolder_.managedConnect(getApp()->hotkeys->onItemsUpdated, + [this]() { + this->clearShortcuts(); + this->addShortcuts(); + }); } void SplitInput::initLayout() @@ -202,229 +211,297 @@ void SplitInput::openEmotePopup() this->emotePopup_->activateWindow(); } -void SplitInput::installKeyPressedEvent() +void SplitInput::addShortcuts() { - auto app = getApp(); + HotkeyController::HotkeyMap actions{ + {"cursorToStart", + [this](std::vector arguments) -> QString { + if (arguments.size() != 1) + { + qCWarning(chatterinoHotkeys) + << "Invalid cursorToStart arguments. Argument 0: select " + "(\"withSelection\" or \"withoutSelection\")"; + return "Invalid cursorToStart arguments. Argument 0: select " + "(\"withSelection\" or \"withoutSelection\")"; + } + QTextCursor cursor = this->ui_.textEdit->textCursor(); + auto place = QTextCursor::Start; + auto stringTakeSelection = arguments.at(0); + bool select; + if (stringTakeSelection == "withSelection") + { + select = true; + } + else if (stringTakeSelection == "withoutSelection") + { + select = false; + } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid cursorToStart select argument (0)!"; + return "Invalid cursorToStart select argument (0)!"; + } + + cursor.movePosition(place, + select ? QTextCursor::MoveMode::KeepAnchor + : QTextCursor::MoveMode::MoveAnchor); + this->ui_.textEdit->setTextCursor(cursor); + return ""; + }}, + {"cursorToEnd", + [this](std::vector arguments) -> QString { + if (arguments.size() != 1) + { + qCWarning(chatterinoHotkeys) + << "Invalid cursorToEnd arguments. Argument 0: select " + "(\"withSelection\" or \"withoutSelection\")"; + return "Invalid cursorToEnd arguments. Argument 0: select " + "(\"withSelection\" or \"withoutSelection\")"; + } + QTextCursor cursor = this->ui_.textEdit->textCursor(); + auto place = QTextCursor::End; + auto stringTakeSelection = arguments.at(0); + bool select; + if (stringTakeSelection == "withSelection") + { + select = true; + } + else if (stringTakeSelection == "withoutSelection") + { + select = false; + } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid cursorToEnd select argument (0)!"; + return "Invalid cursorToEnd select argument (0)!"; + } + + cursor.movePosition(place, + select ? QTextCursor::MoveMode::KeepAnchor + : QTextCursor::MoveMode::MoveAnchor); + this->ui_.textEdit->setTextCursor(cursor); + return ""; + }}, + {"openEmotesPopup", + [this](std::vector) -> QString { + this->openEmotePopup(); + return ""; + }}, + {"sendMessage", + [this](std::vector arguments) -> QString { + auto c = this->split_->getChannel(); + if (c == nullptr) + return ""; + + QString message = ui_.textEdit->toPlainText(); + + message = message.replace('\n', ' '); + QString sendMessage = + getApp()->commands->execCommand(message, c, false); + + c->sendMessage(sendMessage); + // don't add duplicate messages and empty message to message history + if ((this->prevMsg_.isEmpty() || + !this->prevMsg_.endsWith(message)) && + !message.trimmed().isEmpty()) + { + this->prevMsg_.append(message); + } + bool shouldClearInput = true; + if (arguments.size() != 0 && arguments.at(0) == "keepInput") + { + shouldClearInput = false; + } + + if (shouldClearInput) + { + this->currMsg_ = QString(); + this->ui_.textEdit->setPlainText(QString()); + } + this->prevIndex_ = this->prevMsg_.size(); + return ""; + }}, + {"previousMessage", + [this](std::vector) -> QString { + if (this->prevMsg_.size() && this->prevIndex_) + { + if (this->prevIndex_ == (this->prevMsg_.size())) + { + this->currMsg_ = ui_.textEdit->toPlainText(); + } + + this->prevIndex_--; + this->ui_.textEdit->setPlainText( + this->prevMsg_.at(this->prevIndex_)); + + QTextCursor cursor = this->ui_.textEdit->textCursor(); + cursor.movePosition(QTextCursor::End); + this->ui_.textEdit->setTextCursor(cursor); + } + return ""; + }}, + {"nextMessage", + [this](std::vector) -> QString { + // If user did not write anything before then just do nothing. + if (this->prevMsg_.isEmpty()) + { + return ""; + } + bool cursorToEnd = true; + QString message = ui_.textEdit->toPlainText(); + + if (this->prevIndex_ != (this->prevMsg_.size() - 1) && + this->prevIndex_ != this->prevMsg_.size()) + { + this->prevIndex_++; + this->ui_.textEdit->setPlainText( + this->prevMsg_.at(this->prevIndex_)); + } + else + { + this->prevIndex_ = this->prevMsg_.size(); + if (message == this->prevMsg_.at(this->prevIndex_ - 1)) + { + // If user has just come from a message history + // Then simply get currMsg_. + this->ui_.textEdit->setPlainText(this->currMsg_); + } + else if (message != this->currMsg_) + { + // If user are already in current message + // And type something new + // Then replace currMsg_ with new one. + this->currMsg_ = message; + } + // If user is already in current message + // Then don't touch cursos. + cursorToEnd = + (message == this->prevMsg_.at(this->prevIndex_ - 1)); + } + + if (cursorToEnd) + { + QTextCursor cursor = this->ui_.textEdit->textCursor(); + cursor.movePosition(QTextCursor::End); + this->ui_.textEdit->setTextCursor(cursor); + } + return ""; + }}, + {"undo", + [this](std::vector) -> QString { + this->ui_.textEdit->undo(); + return ""; + }}, + {"redo", + [this](std::vector) -> QString { + this->ui_.textEdit->redo(); + return ""; + }}, + {"copy", + [this](std::vector arguments) -> QString { + // XXX: this action is unused at the moment, a qt standard shortcut is used instead + if (arguments.size() == 0) + { + return "copy action takes only one argument: the source " + "of the copy \"split\", \"input\" or " + "\"auto\". If the source is \"split\", only text " + "from the chat will be copied. If it is " + "\"splitInput\", text from the input box will be " + "copied. Automatic will pick whichever has a " + "selection"; + } + bool copyFromSplit = false; + auto mode = arguments.at(0); + if (mode == "split") + { + copyFromSplit = true; + } + else if (mode == "splitInput") + { + copyFromSplit = false; + } + else if (mode == "auto") + { + const auto &cursor = this->ui_.textEdit->textCursor(); + copyFromSplit = !cursor.hasSelection(); + } + + if (copyFromSplit) + { + this->split_->copyToClipboard(); + } + else + { + this->ui_.textEdit->copy(); + } + return ""; + }}, + {"paste", + [this](std::vector) -> QString { + this->ui_.textEdit->paste(); + return ""; + }}, + {"clear", + [this](std::vector) -> QString { + this->ui_.textEdit->setText(""); + this->ui_.textEdit->moveCursor(QTextCursor::Start); + return ""; + }}, + {"selectAll", + [this](std::vector) -> QString { + this->ui_.textEdit->selectAll(); + return ""; + }}, + }; + + this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( + HotkeyCategory::SplitInput, actions, this); +} - this->ui_.textEdit->keyPressed.connect([this, app](QKeyEvent *event) { +bool SplitInput::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::ShortcutOverride || + event->type() == QEvent::Shortcut) + { if (auto popup = this->inputCompletionPopup_.get()) { if (popup->isVisible()) { - if (popup->eventFilter(nullptr, event)) - { - event->accept(); - return; - } - } - } - - if (event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) - { - auto c = this->split_->getChannel(); - if (c == nullptr) - return; - - QString message = ui_.textEdit->toPlainText(); - - message = message.replace('\n', ' '); - QString sendMessage = app->commands->execCommand(message, c, false); - - c->sendMessage(sendMessage); - // don't add duplicate messages and empty message to message history - if ((this->prevMsg_.isEmpty() || - !this->prevMsg_.endsWith(message)) && - !message.trimmed().isEmpty()) - { - this->prevMsg_.append(message); - } - - event->accept(); - if (!(event->modifiers() & Qt::ControlModifier)) - { - this->currMsg_ = QString(); - this->ui_.textEdit->setPlainText(QString()); - } - this->prevIndex_ = this->prevMsg_.size(); - } - else if (event->key() == Qt::Key_Up) - { - if ((event->modifiers() & Qt::ShiftModifier) != 0) - { - return; - } - if (event->modifiers() == Qt::AltModifier) - { - this->split_->actionRequested.invoke( - Split::Action::SelectSplitAbove); - } - else - { - if (this->prevMsg_.size() && this->prevIndex_) - { - if (this->prevIndex_ == (this->prevMsg_.size())) - { - this->currMsg_ = ui_.textEdit->toPlainText(); - } - - this->prevIndex_--; - this->ui_.textEdit->setPlainText( - this->prevMsg_.at(this->prevIndex_)); - - QTextCursor cursor = this->ui_.textEdit->textCursor(); - cursor.movePosition(QTextCursor::End); - this->ui_.textEdit->setTextCursor(cursor); + // Stop shortcut from triggering by saying we will handle it ourselves + event->accept(); - // Don't let the keyboard event propagate further, we've - // handled it - event->accept(); - } + // Return false means the underlying event isn't stopped, it will continue to propagate + return false; } } - else if (event->key() == Qt::Key_Home) - { - QTextCursor cursor = this->ui_.textEdit->textCursor(); - cursor.movePosition( - QTextCursor::Start, - event->modifiers() & Qt::KeyboardModifier::ShiftModifier - ? QTextCursor::MoveMode::KeepAnchor - : QTextCursor::MoveMode::MoveAnchor); - this->ui_.textEdit->setTextCursor(cursor); - - event->accept(); - } - else if (event->key() == Qt::Key_End) - { - if (event->modifiers() == Qt::ControlModifier) - { - this->split_->getChannelView().getScrollBar().scrollToBottom( - getSettings()->enableSmoothScrollingNewMessages.getValue()); - } - else - { - QTextCursor cursor = this->ui_.textEdit->textCursor(); - cursor.movePosition( - QTextCursor::End, - event->modifiers() & Qt::KeyboardModifier::ShiftModifier - ? QTextCursor::MoveMode::KeepAnchor - : QTextCursor::MoveMode::MoveAnchor); - this->ui_.textEdit->setTextCursor(cursor); - } - event->accept(); - } - else if (event->key() == Qt::Key_H && - event->modifiers() == Qt::AltModifier) - { - // h: vim binding for left - this->split_->actionRequested.invoke( - Split::Action::SelectSplitLeft); - - event->accept(); - } - else if (event->key() == Qt::Key_J && - event->modifiers() == Qt::AltModifier) - { - // j: vim binding for down - this->split_->actionRequested.invoke( - Split::Action::SelectSplitBelow); - - event->accept(); - } - else if (event->key() == Qt::Key_K && - event->modifiers() == Qt::AltModifier) - { - // k: vim binding for up - this->split_->actionRequested.invoke( - Split::Action::SelectSplitAbove); + } - event->accept(); - } - else if (event->key() == Qt::Key_L && - event->modifiers() == Qt::AltModifier) - { - // l: vim binding for right - this->split_->actionRequested.invoke( - Split::Action::SelectSplitRight); + return BaseWidget::eventFilter(obj, event); +} - event->accept(); - } - else if (event->key() == Qt::Key_Down) +void SplitInput::installKeyPressedEvent() +{ + this->ui_.textEdit->keyPressed.disconnectAll(); + this->ui_.textEdit->keyPressed.connect([this](QKeyEvent *event) { + if (auto popup = this->inputCompletionPopup_.get()) { - if ((event->modifiers() & Qt::ShiftModifier) != 0) - { - return; - } - if (event->modifiers() == Qt::AltModifier) - { - this->split_->actionRequested.invoke( - Split::Action::SelectSplitBelow); - } - else + if (popup->isVisible()) { - // If user did not write anything before then just do nothing. - if (this->prevMsg_.isEmpty()) + if (popup->eventFilter(nullptr, event)) { + event->accept(); return; } - bool cursorToEnd = true; - QString message = ui_.textEdit->toPlainText(); - - if (this->prevIndex_ != (this->prevMsg_.size() - 1) && - this->prevIndex_ != this->prevMsg_.size()) - { - this->prevIndex_++; - this->ui_.textEdit->setPlainText( - this->prevMsg_.at(this->prevIndex_)); - } - else - { - this->prevIndex_ = this->prevMsg_.size(); - if (message == this->prevMsg_.at(this->prevIndex_ - 1)) - { - // If user has just come from a message history - // Then simply get currMsg_. - this->ui_.textEdit->setPlainText(this->currMsg_); - } - else if (message != this->currMsg_) - { - // If user are already in current message - // And type something new - // Then replace currMsg_ with new one. - this->currMsg_ = message; - } - // If user is already in current message - // Then don't touch cursos. - cursorToEnd = - (message == this->prevMsg_.at(this->prevIndex_ - 1)); - } - - if (cursorToEnd) - { - QTextCursor cursor = this->ui_.textEdit->textCursor(); - cursor.movePosition(QTextCursor::End); - this->ui_.textEdit->setTextCursor(cursor); - } } } - else if (event->key() == Qt::Key_Left) - { - if (event->modifiers() == Qt::AltModifier) - { - this->split_->actionRequested.invoke( - Split::Action::SelectSplitLeft); - } - } - else if (event->key() == Qt::Key_Right) - { - if (event->modifiers() == Qt::AltModifier) - { - this->split_->actionRequested.invoke( - Split::Action::SelectSplitRight); - } - } - else if ((event->key() == Qt::Key_C || - event->key() == Qt::Key_Insert) && - event->modifiers() == Qt::ControlModifier) + + // One of the last remaining of it's kind, the copy shortcut. + // For some bizarre reason Qt doesn't want this key be rebound. + // TODO(Mm2PL): Revisit in Qt6, maybe something changed? + if ((event->key() == Qt::Key_C || event->key() == Qt::Key_Insert) && + event->modifiers() == Qt::ControlModifier) { if (this->split_->view_->hasSelection()) { @@ -432,25 +509,6 @@ void SplitInput::installKeyPressedEvent() event->accept(); } } - else if (event->key() == Qt::Key_E && - event->modifiers() == Qt::ControlModifier) - { - this->openEmotePopup(); - } - else if (event->key() == Qt::Key_PageUp) - { - auto &scrollbar = this->split_->getChannelView().getScrollBar(); - scrollbar.offset(-scrollbar.getLargeChange()); - - event->accept(); - } - else if (event->key() == Qt::Key_PageDown) - { - auto &scrollbar = this->split_->getChannelView().getScrollBar(); - scrollbar.offset(scrollbar.getLargeChange()); - - event->accept(); - } }); } diff --git a/src/widgets/splits/SplitInput.hpp b/src/widgets/splits/SplitInput.hpp index 5dc54bdc5a4..5a9688fe7b4 100644 --- a/src/widgets/splits/SplitInput.hpp +++ b/src/widgets/splits/SplitInput.hpp @@ -44,7 +44,9 @@ class SplitInput : public BaseWidget virtual void mousePressEvent(QMouseEvent *event) override; private: + void addShortcuts() override; void initLayout(); + bool eventFilter(QObject *obj, QEvent *event) override; void installKeyPressedEvent(); void onCursorPositionChanged(); void onTextChanged(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4c15a49029d..268a7203dec 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/TwitchAccount.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Helpers.cpp ${CMAKE_CURRENT_LIST_DIR}/src/RatelimitBucket.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Hotkeys.cpp # Add your new file above this line! ) diff --git a/tests/src/Hotkeys.cpp b/tests/src/Hotkeys.cpp new file mode 100644 index 00000000000..ebbfe50297f --- /dev/null +++ b/tests/src/Hotkeys.cpp @@ -0,0 +1,86 @@ +#include "controllers/hotkeys/HotkeyHelpers.hpp" + +#include + +#include + +using namespace chatterino; + +struct argumentTest { + const char *label; + QString input; + std::vector expected; +}; + +TEST(HotkeyHelpers, parseHotkeyArguments) +{ + std::vector tests{ + { + "Empty input must result in an empty vector", + "", + {}, + }, + { + "Leading and trailing newlines/spaces are removed", + "\n", + {}, + }, + { + "Single argument", + "foo", + {"foo"}, + }, + { + "Single argument with trailing space trims the space", + "foo ", + {"foo"}, + }, + { + "Single argument with trailing newline trims the newline", + "foo\n", + {"foo"}, + }, + { + "Multiple arguments with leading and trailing spaces trims them", + " foo \n bar \n baz ", + {"foo", "bar", "baz"}, + }, + { + "Multiple trailing newlines are trimmed", + "foo\n\n", + {"foo"}, + }, + { + "Leading newline is trimmed", + "\nfoo", + {"foo"}, + }, + { + "Leading newline + space trimmed", + "\n foo", + {"foo"}, + }, + { + "Multiple leading newline trimmed", + "\n\nfoo", + {"foo"}, + }, + { + "2 rows results in 2 vectors", + "foo\nbar", + {"foo", "bar"}, + }, + { + "Multiple newlines in the middle are not trimmed", + "foo\n\nbar", + {"foo", "", "bar"}, + }, + }; + + for (const auto &[label, input, expected] : tests) + { + auto output = parseHotkeyArguments(input); + + EXPECT_EQ(output, expected) << label; + } +}