diff --git a/include/GuiAction.h b/include/GuiAction.h new file mode 100644 index 00000000000..7ed675ec161 --- /dev/null +++ b/include/GuiAction.h @@ -0,0 +1,152 @@ +/* + * GuiAction.h - declaration of class GuiAction (and related ones) + * + * This file is part of LMMS - https://lmms.io + * + * Copyright (c) 2026 yohannd1 + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_GUI_ACTION_H +#define LMMS_GUI_ACTION_H + +#include +#include +#include + +#include +#include + +namespace lmms { + +class ActionData; + +class ActionTrigger +{ +public: + struct Never //!< Can never be triggered + { + }; + + struct KeyPressed + { + Qt::KeyboardModifiers mods; + Qt::Key key; + bool repeat; + }; + + struct KeyHeld + { + Qt::KeyboardModifiers mods; + Qt::Key key; + }; + + //! Top type for all possible triggers + typedef std::variant Any; + + static Any pressed(Qt::KeyboardModifiers mods, Qt::Key key, bool repeat = true); + static Any held(Qt::KeyboardModifiers mods, Qt::Key key); +}; + +class ActionContainer +{ +public: + /** + Attempts to register a new action, but refuses if it is already registered. Returns whether the insertion + happened. + */ + static bool tryRegister(QString name, ActionTrigger::Any trigger); + + //! Find an action by its name. Returns null when it was not found. + static ActionData* findData(const QString& name); + + using MappingIterator = std::map::iterator; + static MappingIterator mappingsBegin(); + static MappingIterator mappingsEnd(); + +private: + ActionContainer() = delete; + ~ActionContainer() = delete; + + //! Map owning the data to all known acitons. + static std::map s_dataMap; +}; + +class ActionData : public QObject +{ + Q_OBJECT + +public: + /** + Obtains the data of the action with the specified name. Constructs one if it has not been present, and returns it. + + For now, to avoid memory safety issues, ActionData instances are never removed or freed. + */ + static ActionData* get(const QString& name, ActionTrigger::Any trigger = ActionTrigger::Never{}); + + const QString& name() const; + const ActionTrigger::Any& trigger() const; + void setTrigger(ActionTrigger::Any&& newTrigger); + + friend class ActionContainer; + +signals: + void modified(); + +private: + ActionData(QString name, ActionTrigger::Any trigger); + + QString m_name; + ActionTrigger::Any m_trigger; +}; + +/** + Do not change the parent of this object! (FIXME: implement this, perhaps) + + TODO: think of a better name. `ActionListener` or `CommandListener` might be good? +*/ +class GuiAction : public QObject +{ + Q_OBJECT + +public: + GuiAction(QObject* parent, ActionData* data); + ~GuiAction(); + +protected: + bool eventFilter(QObject* watched, QEvent* event) override; + +signals: + void activated(); + void deactivated(); + +private: + ActionData* m_data; + bool m_active; + uint32_t m_mods; +}; + +/** + Estabilishes a one-way sync between an ActionData and a QAction, such that changes to the ActionData affect the + state of the QAction. Useful for menu actions with keybindings. +*/ +void syncActionDataToQAction(ActionData* data, QAction* action); + +} // namespace lmms + +#endif // LMMS_GUI_ACTION_H diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 061d05eb407..095c528f955 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -16,6 +16,7 @@ SET(LMMS_SRCS gui/FileBrowser.cpp gui/FileRevealer.cpp gui/FileSearchJob.cpp + gui/GuiAction.cpp gui/GuiApplication.cpp gui/LadspaControlView.cpp gui/LfoControllerDialog.cpp diff --git a/src/gui/GuiAction.cpp b/src/gui/GuiAction.cpp new file mode 100644 index 00000000000..12dcb50f938 --- /dev/null +++ b/src/gui/GuiAction.cpp @@ -0,0 +1,187 @@ +/* + * GuiAction.cpp - action listener for flexible keybindings + * + * This file is part of LMMS - https://lmms.io + * + * Copyright (c) 2026 yohannd1 + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include +#include // TODO: remove +#include +#include +#include + +#include "GuiAction.h" + +namespace lmms { + +std::map ActionContainer::s_dataMap; + +bool ActionContainer::tryRegister(QString name, ActionTrigger::Any trigger) +{ + auto it = s_dataMap.find(name); + if (it != s_dataMap.end()) { return false; } + s_dataMap[name] = new ActionData(name, trigger); + return true; +} + +ActionData* ActionContainer::findData(const QString& name) +{ + auto it = s_dataMap.find(name); + return (it == s_dataMap.end()) ? nullptr : s_dataMap.at(name); +} + +ActionData::ActionData(QString name, ActionTrigger::Any trigger) + : QObject(nullptr) + , m_name{name} + , m_trigger{trigger} +{ +} + +ActionData* ActionData::get(const QString& name, ActionTrigger::Any trigger) +{ + ActionContainer::tryRegister(name, trigger); + return ActionContainer::findData(name); +} + +const ActionTrigger::Any& ActionData::trigger() const +{ + return m_trigger; +} + +void ActionData::setTrigger(ActionTrigger::Any&& newTrigger) +{ + m_trigger = newTrigger; + emit modified(); +} + +ActionTrigger::Any ActionTrigger::pressed(Qt::KeyboardModifiers mods, Qt::Key key, bool repeat) +{ + return ActionTrigger::KeyPressed{.mods = mods, .key = key, .repeat = repeat}; +} + +ActionTrigger::Any ActionTrigger::held(Qt::KeyboardModifiers mods, Qt::Key key) { + return ActionTrigger::KeyHeld{.mods = mods, .key = key}; +} + +GuiAction::GuiAction(QObject* parent, ActionData* data) + : QObject(parent) + , m_data{data} + , m_active{false} +{ + if (parent != nullptr) { parent->installEventFilter(this); } + connect(data, &ActionData::modified, this, [this] { m_active = false; }); +} + +GuiAction::~GuiAction() +{ +} + +ActionContainer::MappingIterator ActionContainer::mappingsBegin() +{ + return s_dataMap.begin(); +} + +ActionContainer::MappingIterator ActionContainer::mappingsEnd() +{ + return s_dataMap.end(); +} + +bool GuiAction::eventFilter(QObject* watched, QEvent* event) +{ + const auto& trigger_g = m_data->trigger(); + if (std::holds_alternative(trigger_g)) + { + const auto& trigger = std::get(trigger_g); + if (event->type() == QEvent::KeyPress) + { + auto* ke = dynamic_cast(event); + assert(ke != nullptr); + + // FIXME: "This function cannot always be trusted. The user can + // confuse it by pressing both Shift keys simultaneously and + // releasing one of them, for example." @ + // https://doc.qt.io/qt-6/qkeyevent.html#modifiers + + if (ke->key() == trigger.key && ke->modifiers() == trigger.mods + && !(ke->isAutoRepeat() && !trigger.repeat)) + { + m_active = false; + emit activated(); + return true; + } + } + } + else if (std::holds_alternative(trigger_g)) + { + const auto& trigger = std::get(trigger_g); + if (!m_active && event->type() == QEvent::KeyPress) + { + auto* ke = dynamic_cast(event); + assert(ke != nullptr); + + if (ke->key() == trigger.key && ke->modifiers() == trigger.mods) + { + m_active = true; + emit activated(); + return true; + } + } + else if (m_active && event->type() == QEvent::KeyRelease) + { + auto* ke = dynamic_cast(event); + assert(ke != nullptr); + + // Ignore auto-repeat releases + if (ke->key() == trigger.key && !ke->isAutoRepeat()) + { + m_active = false; + emit deactivated(); + return true; + } + } + } + + return QObject::eventFilter(watched, event); +} + +void syncActionDataToQAction(ActionData* data, QAction* action) +{ + auto updateAction = [action, data] + { + const auto& trigger_g = data->trigger(); + if (std::holds_alternative(trigger_g)) + { + const auto& trigger = std::get(trigger_g); + action->setShortcut(QKeySequence{static_cast(trigger.mods + trigger.key)}); + action->setAutoRepeat(trigger.repeat); + } + else + { + qWarning() << "Expected KeyPressed trigger! QAction will have no trigger keybinding."; + action->setShortcut(QKeySequence{}); + } + }; + + updateAction(); + QObject::connect(data, &ActionData::modified, action, updateAction); +} + +} // namespace lmms diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 7e9c16ab97f..5cee0fda0fe 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include "AboutDialog.h" #include "AutomationEditor.h" @@ -44,9 +45,10 @@ #include "ExportProjectDialog.h" #include "FileBrowser.h" #include "FileDialog.h" +#include "GuiAction.h" +#include "GuiApplication.h" #include "Metronome.h" #include "MixerView.h" -#include "GuiApplication.h" #include "ImportFilter.h" #include "InstrumentTrackView.h" #include "InstrumentTrackWindow.h" @@ -264,8 +266,8 @@ void MainWindow::finalize() resetWindowTitle(); setWindowIcon( embed::getIconPixmap( "icon_small" ) ); - auto addAction = [this](QMenu* menu, std::string_view icon, const QString& text, - const QKeySequence& shortcut, auto(MainWindow::* slot)()) -> QAction* + auto addAction = [this](QMenu* menu, std::string_view icon, const QString& text, const QKeySequence& shortcut, + auto(MainWindow::* slot)()) -> QAction* { #if (QT_VERSION >= QT_VERSION_CHECK(6, 3, 0)) return menu->addAction(embed::getIconPixmap(icon), text, shortcut, this, slot); @@ -274,12 +276,55 @@ void MainWindow::finalize() #endif }; + auto menuAddAction = [this, addAction](QMenu* menu, std::string_view icon, const QString& text, ActionData* data, + auto(MainWindow::* slot)()) -> void + { + const auto emptyShortcut = QKeySequence{}; + auto* action = addAction(menu, icon, text, emptyShortcut, slot); + syncActionDataToQAction(data, action); + }; + // project-popup-menu auto project_menu = new QMenu(this); - menuBar()->addMenu( project_menu )->setText( tr( "&File" ) ); + menuBar()->addMenu(project_menu)->setText(tr("&File")); + + static auto* adNewProject = ActionData::get("project_new", ActionTrigger::pressed(Qt::ControlModifier, Qt::Key_N)); + menuAddAction(project_menu, "project_new", tr("&New"), adNewProject, &MainWindow::createNewProject); + + static auto testActionData = ActionData::get("test", ActionTrigger::held(Qt::ControlModifier, Qt::Key_J)); + auto testAction = new GuiAction(this, testActionData); + connect(testAction, &GuiAction::activated, this, [this] { qDebug() << "ON"; }); + connect(testAction, &GuiAction::deactivated, this, [this] { + qDebug() << "OFF"; + adNewProject->setTrigger(ActionTrigger::pressed(Qt::ControlModifier, Qt::Key_H)); + }); + + // static auto testActionData = ActionData::get("test", ActionTrigger::held(Qt::ControlModifier, Qt::Key_J)); + // auto testAction = new GuiAction(this, testActionData); + // connect(testAction, &GuiAction::activated, this, [this] { qDebug() << "ON"; }); + // connect(testAction, &GuiAction::deactivated, this, [this] { + // qDebug() << "OFF"; + // testActionData->setTrigger(ActionTrigger::pressed(Qt::AltModifier, Qt::Key_H)); + // }); + + static auto lkData = ActionData::get("listKeybindings", ActionTrigger::pressed(Qt::ControlModifier, Qt::Key_K)); + auto lkAction = new GuiAction(this, lkData); + connect(lkAction, &GuiAction::activated, this, [this] { + auto s = QString{}; + + for (auto it = ActionContainer::mappingsBegin(); it != ActionContainer::mappingsEnd(); it++) + { + const auto name = (*it).first; + s += name; + s += "\n"; + } - addAction(project_menu, "project_new", tr("&New"), - QKeySequence::New, &MainWindow::createNewProject); + auto* d = new QDialog(this); + auto* l = new QLabel(d); + l->setTextFormat(Qt::PlainText); + l->setText(s); + d->exec(); + }); auto templates_menu = new TemplatesMenu( this ); project_menu->addMenu(templates_menu);