diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 7a6e99362..c259b6d46 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -309,6 +309,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/diagramcontexttoolbar.h ${QET_DIR}/sources/diagramgestureoverlay.cpp ${QET_DIR}/sources/diagramgestureoverlay.h + ${QET_DIR}/sources/gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.h ${QET_DIR}/sources/commandsearchpopup.cpp ${QET_DIR}/sources/commandsearchpopup.h ${QET_DIR}/sources/titleblockcell.cpp @@ -864,6 +866,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/configpage/toolbarcommandsconfigpage.h ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.cpp ${QET_DIR}/sources/ui/configpage/toolbarsconfigpage.h + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.h ${QET_DIR}/sources/undocommand/addelementtextcommand.cpp ${QET_DIR}/sources/undocommand/addelementtextcommand.h diff --git a/sources/diagramgestureoverlay.cpp b/sources/diagramgestureoverlay.cpp index 03d2606d1..c1bedd8d7 100644 --- a/sources/diagramgestureoverlay.cpp +++ b/sources/diagramgestureoverlay.cpp @@ -58,14 +58,16 @@ bool DiagramGestureOverlay::isEnabled() /** @brief DiagramGestureOverlay::showAt - Show the ring centred on @a center with @a actions, at most eight, - clockwise from the top. + Show the ring centred on @a center with @a sectors directions (4 or 8) + and @a actions, one per direction clockwise from the top. */ void DiagramGestureOverlay::showAt(const QPoint ¢er, - const QList &actions) + const QList &actions, + int sectors) { m_center = center; - m_actions = actions.mid(0, sectors); + m_sectors = sectors == 4 ? 4 : 8; + m_actions = actions.mid(0, m_sectors); m_active = -1; move(center - QPoint(overlay_width / 2, outer_radius + 1)); show(); @@ -88,7 +90,7 @@ int DiagramGestureOverlay::sectorAt(const QPoint &viewport_pos) const if (angle < 0) { angle += 360; } - return int(qRound(angle / (360.0 / sectors))) % sectors; + return int(qRound(angle / (360.0 / m_sectors))) % m_sectors; } /** @@ -102,7 +104,7 @@ QAction *DiagramGestureOverlay::actionAt(const QPoint &viewport_pos) const return nullptr; } QAction *action = m_actions.at(sector); - return action->isEnabled() ? action : nullptr; + return action && action->isEnabled() ? action : nullptr; } void DiagramGestureOverlay::setPointer(const QPoint &viewport_pos) @@ -120,7 +122,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); const QPointF c(overlay_width / 2, outer_radius + 1); - const qreal step = 360.0 / sectors; + const qreal step = 360.0 / m_sectors; QColor base = palette().color(QPalette::Window); base.setAlpha(225); @@ -130,7 +132,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) //Highlight the sector the mouse points at, if it holds a command if (m_active >= 0 && m_active < m_actions.count() - && m_actions.at(m_active)->isEnabled()) + && m_actions.at(m_active) && m_actions.at(m_active)->isEnabled()) { QPainterPath wedge; wedge.moveTo(c); @@ -151,6 +153,9 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) for (int i = 0 ; i < m_actions.count() ; ++i) { QAction *action = m_actions.at(i); + if (!action) { + continue; + } const qreal a = qDegreesToRadians(i * step); const QPointF p(c.x() + icon_radius * qSin(a), c.y() - icon_radius * qCos(a)); const QRect r(int(p.x()) - icon_size / 2, int(p.y()) - icon_size / 2, @@ -167,7 +172,7 @@ void DiagramGestureOverlay::paintEvent(QPaintEvent *event) } //Name of the highlighted command, under the ring - if (m_active >= 0 && m_active < m_actions.count()) + if (m_active >= 0 && m_active < m_actions.count() && m_actions.at(m_active)) { const QString name = m_actions.at(m_active)->text().remove(QLatin1Char('&')); const QRect label(0, 2 * outer_radius + 4, width(), 20); diff --git a/sources/diagramgestureoverlay.h b/sources/diagramgestureoverlay.h index 5fca8dfc2..a2bbb4077 100644 --- a/sources/diagramgestureoverlay.h +++ b/sources/diagramgestureoverlay.h @@ -29,8 +29,9 @@ class QAction; mouse's direction highlighted. Releasing runs it, releasing near the centre cancels -- SolidWorks' mouse gestures. - Commands are placed clockwise from the top. The ring only draws; the - view tracks the mouse and asks sectorAt() on release. + Commands are placed clockwise from the top, one per direction (4 or 8). + A null entry is an empty direction. The ring only draws; the view + tracks the mouse and asks sectorAt() on release. */ class DiagramGestureOverlay : public QWidget { @@ -39,14 +40,15 @@ class DiagramGestureOverlay : public QWidget public: explicit DiagramGestureOverlay(QWidget *viewport); - void showAt(const QPoint ¢er, const QList &actions); + void showAt(const QPoint ¢er, const QList &actions, + int sectors = 8); void setPointer(const QPoint &viewport_pos); int sectorAt(const QPoint &viewport_pos) const; QAction *actionAt(const QPoint &viewport_pos) const; - static bool isEnabled(); + int sectors() const { return m_sectors; } - static const int sectors = 8; + static bool isEnabled(); protected: void paintEvent(QPaintEvent *event) override; @@ -55,6 +57,7 @@ class DiagramGestureOverlay : public QWidget QPoint m_center; QList m_actions; int m_active = -1; + int m_sectors = 8; }; #endif // DIAGRAMGESTUREOVERLAY_H diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 0e67bf5ac..88120f9a3 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -42,6 +42,7 @@ #include "diagram.h" #include "diagramcontexttoolbar.h" #include "diagramgestureoverlay.h" +#include "gesturesettings.h" #include "shortcutbarsettings.h" #include "shortcutmanager.h" #include "ElementsCollection/xmlelementcollection.h" @@ -794,7 +795,8 @@ void DiagramView::mouseMoveEvent(QMouseEvent *e) m_diagram->clearEventInterface(); m_swallow_native_menu = true; } - m_gesture_overlay->showAt(m_gesture_origin, selectionCommands()); + m_gesture_overlay->showAt(m_gesture_origin, gestureCommands(), + GestureSettings::directions()); } if (m_gesture_overlay->isVisible()) { m_gesture_overlay->setPointer(pos); @@ -981,6 +983,28 @@ QList DiagramView::selectionCommands() const return actions; } +/** + @brief DiagramView::gestureCommands + @return the gesture ring's commands for the current selection, as this + window's actions. A list the user picked keeps its directions: a + command this window lacks leaves its direction empty (nullptr). + Otherwise the ring shows the shortcut bar's commands, in order. +*/ +QList DiagramView::gestureCommands() const +{ + QETDiagramEditor *qde = diagramEditor(); + const auto context = ShortcutBarSettings::contextFor(m_diagram->selectedItems()); + if (!qde || !GestureSettings::isCustom(context)) { + return selectionCommands(); + } + QList actions; + for (const QString &id : GestureSettings::ids(context)) { + actions << (id.isEmpty() ? nullptr + : ShortcutManager::instance().action(id, qde)); + } + return actions; +} + /** @brief DiagramView::showContextToolbar After a click that leaves something selected, show the shortcut bar's diff --git a/sources/diagramview.h b/sources/diagramview.h index ebf3fd1f8..e7a99827d 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -154,6 +154,7 @@ class DiagramView : public PaletteGraphicsView void placeCellRulers(); void showContextToolbar(const QPoint &viewport_pos); QList selectionCommands() const; + QList gestureCommands() const; /// Lowest and highest allowed value of the view transform scale (m11). /// Prevents wheel-zoom from driving the transform to overflow, which diff --git a/sources/gesturesettings.cpp b/sources/gesturesettings.cpp new file mode 100644 index 000000000..298c4a3c5 --- /dev/null +++ b/sources/gesturesettings.cpp @@ -0,0 +1,125 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech 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. + + QElectroTech 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 QElectroTech. If not, see . +*/ +#include "gesturesettings.h" + +#include + +namespace { +const QString directions_key = QStringLiteral("diagrameditor/gestures/directions"); + +QString settingsKey(ShortcutBarSettings::Context context) +{ + switch (context) + { + case ShortcutBarSettings::Canvas: + return QStringLiteral("diagrameditor/gestures/canvas"); + case ShortcutBarSettings::Selection: + return QStringLiteral("diagrameditor/gestures/selection"); + case ShortcutBarSettings::Conductor: + return QStringLiteral("diagrameditor/gestures/conductor"); + } + return QString(); +} +} + +/** + @return how many directions the ring has: 8 (the default) or 4 +*/ +int GestureSettings::directions() +{ + return QSettings().value(directions_key, 8).toInt() == 4 ? 4 : 8; +} + +/** + @brief GestureSettings::setDirections + Save 4 or 8 directions. 8 is the default and removes the key. +*/ +void GestureSettings::setDirections(int directions) +{ + QSettings settings; + if (directions == 4) { + settings.setValue(directions_key, 4); + } else { + settings.remove(directions_key); + } +} + +/** + @return true if the user picked the commands for @a context, false + when the ring follows the shortcut bar +*/ +bool GestureSettings::isCustom(ShortcutBarSettings::Context context) +{ + return QSettings().contains(settingsKey(context)); +} + +/** + @return the ids on the ring for @a context: the user's list if they + picked one, the defaults otherwise +*/ +QStringList GestureSettings::ids(ShortcutBarSettings::Context context) +{ + QSettings settings; + const QString key = settingsKey(context); + if (!settings.contains(key)) { + return defaultIds(context); + } + return settings.value(key).toStringList(); +} + +/** + @return the shortcut bar's commands for @a context, without its pinned + elements: what the ring has shown since it was added +*/ +QStringList GestureSettings::defaultIds(ShortcutBarSettings::Context context) +{ + QStringList ids; + for (const QString &id : ShortcutBarSettings::ids(context)) { + if (!ShortcutBarSettings::isElement(id)) { + ids << id; + } + } + return ids; +} + +/** + @brief GestureSettings::setIds + Save @a ids for @a context; trailing empty directions are dropped. + Saving the defaults removes the key, so the ring follows the shortcut + bar again. +*/ +void GestureSettings::setIds(ShortcutBarSettings::Context context, QStringList ids) +{ + while (!ids.isEmpty() && ids.constLast().isEmpty()) { + ids.removeLast(); + } + if (ids == defaultIds(context)) { + reset(context); + } else { + QSettings().setValue(settingsKey(context), ids); + } +} + +/** + @brief GestureSettings::reset + Make the ring for @a context follow the shortcut bar again. +*/ +void GestureSettings::reset(ShortcutBarSettings::Context context) +{ + QSettings().remove(settingsKey(context)); +} diff --git a/sources/gesturesettings.h b/sources/gesturesettings.h new file mode 100644 index 000000000..2089e877f --- /dev/null +++ b/sources/gesturesettings.h @@ -0,0 +1,47 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech 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. + + QElectroTech 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 QElectroTech. If not, see . +*/ +#ifndef GESTURESETTINGS_H +#define GESTURESETTINGS_H + +#include "shortcutbarsettings.h" + +#include + +/** + @brief The commands on the mouse gesture ring (right-drag on a folio). + + Until the user picks them, the ring shows the shortcut bar's commands + for the selection, as it always has. A picked list is stored in + QSettings, one key per context, and is positional: entry i is the + command in direction i, clockwise from the top, and an empty entry is + an empty direction. Pinned elements are never on the ring. +*/ +class GestureSettings +{ + public: + static int directions(); + static void setDirections(int directions); + + static bool isCustom(ShortcutBarSettings::Context context); + static QStringList ids(ShortcutBarSettings::Context context); + static QStringList defaultIds(ShortcutBarSettings::Context context); + static void setIds(ShortcutBarSettings::Context context, QStringList ids); + static void reset(ShortcutBarSettings::Context context); +}; + +#endif // GESTURESETTINGS_H diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index d8efc99d1..6d7f03356 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -41,6 +41,7 @@ #include "ui/aboutqetdialog.h" #include "ui/configpage/generalconfigurationpage.h" #include "ui/configpage/shortcutsconfigpage.h" +#include "ui/configpage/gesturesconfigpage.h" #include "ui/configpage/shortcutbarconfigpage.h" #include "ui/configpage/toolbarsconfigpage.h" #include "ui/configpage/toolbarcommandsconfigpage.h" @@ -2227,6 +2228,7 @@ void QETApp::configureQET() cd.addPage(new PrintConfigPage()); cd.addPage(new ShortcutsConfigPage()); cd.addPage(new ShortcutBarConfigPage()); + cd.addPage(new GesturesConfigPage()); cd.addPage(new ToolbarsConfigPage()); cd.addPage(new ToolbarCommandsConfigPage()); #ifdef QET_SPACEMOUSE_SUPPORT diff --git a/sources/ui/configpage/gesturesconfigpage.cpp b/sources/ui/configpage/gesturesconfigpage.cpp new file mode 100644 index 000000000..a14d80ef3 --- /dev/null +++ b/sources/ui/configpage/gesturesconfigpage.cpp @@ -0,0 +1,475 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech 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. + + QElectroTech 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 QElectroTech. If not, see . +*/ +#include "gesturesconfigpage.h" + +#include "../../gesturesettings.h" +#include "../../qeticons.h" +#include "../../shortcutmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +const int ring_radius = 100; +const int ring_inner = 26; +const int slot_radius = 68; +const int slot_icon = 24; + /// What QListWidget puts on a drag +const QString list_mime = QStringLiteral("application/x-qabstractitemmodeldatalist"); + +/** + @return the command id carried by a drag from the command list, or an + empty string +*/ +QString droppedId(const QMimeData *mime) +{ + if (!mime || !mime->hasFormat(list_mime)) { + return QString(); + } + QByteArray data = mime->data(list_mime); + QDataStream stream(&data, QIODevice::ReadOnly); + int row, column; + QMap roles; + stream >> row >> column >> roles; + return roles.value(Qt::UserRole).toString(); +} +} + +GestureRingEditor::GestureRingEditor(QWidget *parent) : + QWidget(parent) +{ + setAcceptDrops(true); + setFocusPolicy(Qt::StrongFocus); + setMinimumSize(sizeHint()); +} + +QSize GestureRingEditor::sizeHint() const +{ + return QSize(2 * ring_radius + 8, 2 * ring_radius + 8); +} + +void GestureRingEditor::setIds(const QStringList &ids) +{ + m_ids = ids; + update(); +} + +void GestureRingEditor::setDirections(int directions) +{ + m_directions = directions == 4 ? 4 : 8; + setCurrentSlot(qMin(m_current, m_directions - 1)); + update(); +} + +void GestureRingEditor::setDescriptions(const QHash &descriptions) +{ + m_descriptions = descriptions; + update(); +} + +void GestureRingEditor::setCurrentSlot(int slot) +{ + if (slot < 0 || slot >= m_directions) { + return; + } + const bool changed = slot != m_current; + m_current = slot; + update(); + if (changed) { + emit currentSlotChanged(slot); + } +} + +/** + @return the slot in the direction of @a pos from the centre, 0 at the + top then clockwise, or -1 at the centre or outside the ring +*/ +int GestureRingEditor::slotAt(const QPoint &pos) const +{ + const QPointF d = QPointF(pos) - QPointF(rect().center()); + const qreal distance = qHypot(d.x(), d.y()); + if (distance < ring_inner || distance > ring_radius) { + return -1; + } + qreal angle = qRadiansToDegrees(qAtan2(d.x(), -d.y())); + if (angle < 0) { + angle += 360; + } + return int(qRound(angle / (360.0 / m_directions))) % m_directions; +} + +/** + @brief GestureRingEditor::place + Put @a id in the current slot. A command already elsewhere on the ring + moves here, so a command has one direction. +*/ +void GestureRingEditor::place(const QString &id) +{ + if (id.isEmpty()) { + return; + } + const int old = m_ids.indexOf(id); + if (old >= 0) { + m_ids[old].clear(); + } + while (m_ids.count() <= m_current) { + m_ids << QString(); + } + m_ids[m_current] = id; + update(); + emit edited(); +} + +void GestureRingEditor::clearCurrent() +{ + if (m_current < m_ids.count() && !m_ids.at(m_current).isEmpty()) { + m_ids[m_current].clear(); + update(); + emit edited(); + } +} + +QString GestureRingEditor::idAt(int slot) const +{ + return slot < m_ids.count() ? m_ids.at(slot) : QString(); +} + +QPointF GestureRingEditor::slotCenter(int slot) const +{ + const qreal a = qDegreesToRadians(slot * 360.0 / m_directions); + const QPointF c = QRectF(rect()).center(); + return QPointF(c.x() + slot_radius * qSin(a), c.y() - slot_radius * qCos(a)); +} + +void GestureRingEditor::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event) + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QPointF c = QRectF(rect()).center(); + const qreal step = 360.0 / m_directions; + const QRectF outer(c.x() - ring_radius, c.y() - ring_radius, + 2 * ring_radius, 2 * ring_radius); + + QColor line = palette().color(QPalette::Text); + line.setAlpha(110); + painter.setPen(line); + painter.setBrush(palette().color(QPalette::Base)); + painter.drawEllipse(outer); + + //The chosen slot + QPainterPath wedge; + wedge.moveTo(c); + wedge.arcTo(outer, 90 - m_current * step - step / 2, step); + wedge.closeSubpath(); + painter.setPen(Qt::NoPen); + QColor highlight = palette().color(QPalette::Highlight); + if (!hasFocus()) { + highlight.setAlpha(140); + } + painter.setBrush(highlight); + painter.drawPath(wedge); + + //Lines between the slots + painter.setPen(line); + for (int i = 0 ; i < m_directions ; ++i) { + const qreal a = qDegreesToRadians(i * step + step / 2); + painter.drawLine(QPointF(c.x() + ring_inner * qSin(a), c.y() - ring_inner * qCos(a)), + QPointF(c.x() + ring_radius * qSin(a), c.y() - ring_radius * qCos(a))); + } + painter.setBrush(palette().color(QPalette::Window)); + painter.drawEllipse(c, ring_inner, ring_inner); + + for (int i = 0 ; i < m_directions ; ++i) + { + const QString id = idAt(i); + if (id.isEmpty()) { + continue; + } + const QPointF p = slotCenter(i); + const QRect r(int(p.x()) - slot_icon / 2, int(p.y()) - slot_icon / 2, + slot_icon, slot_icon); + QAction *action = ShortcutManager::instance().action(id, nullptr); + if (action && !action->icon().isNull()) { + action->icon().paint(&painter, r); + } else { + painter.setPen(palette().color(i == m_current ? QPalette::HighlightedText + : QPalette::Text)); + painter.drawText(r.adjusted(-12, 0, 12, 0), Qt::AlignCenter, + m_descriptions.value(id, id).left(3)); + } + } +} + +void GestureRingEditor::mousePressEvent(QMouseEvent *event) +{ + setCurrentSlot(slotAt(event->position().toPoint())); + event->accept(); +} + +void GestureRingEditor::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace) { + clearCurrent(); + } else if (event->key() == Qt::Key_Right || event->key() == Qt::Key_Down) { + setCurrentSlot((m_current + 1) % m_directions); + } else if (event->key() == Qt::Key_Left || event->key() == Qt::Key_Up) { + setCurrentSlot((m_current + m_directions - 1) % m_directions); + } else { + QWidget::keyPressEvent(event); + } +} + +void GestureRingEditor::dragEnterEvent(QDragEnterEvent *event) +{ + if (!droppedId(event->mimeData()).isEmpty()) { + event->acceptProposedAction(); + } +} + +void GestureRingEditor::dragMoveEvent(QDragMoveEvent *event) +{ + const int slot = slotAt(event->position().toPoint()); + if (slot < 0) { + event->ignore(); + return; + } + setCurrentSlot(slot); + event->acceptProposedAction(); +} + +void GestureRingEditor::dropEvent(QDropEvent *event) +{ + const int slot = slotAt(event->position().toPoint()); + const QString id = droppedId(event->mimeData()); + if (slot < 0 || id.isEmpty()) { + event->ignore(); + return; + } + setCurrentSlot(slot); + place(id); + event->acceptProposedAction(); +} + +/** + @brief GesturesConfigPage::GesturesConfigPage + @param parent +*/ +GesturesConfigPage::GesturesConfigPage(QWidget *parent) : + ConfigPage(parent) +{ + const QStringList available = ShortcutBarSettings::availableIds(); + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) { + if (available.contains(info.id)) { + m_descriptions.insert(info.id, info.description); + } + } + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + m_pending.insert(c, GestureSettings::ids(c)); + m_custom.insert(c, GestureSettings::isCustom(c)); + } + + auto *explanation = new QLabel( + tr("Faites glisser avec le bouton droit sur un folio : un anneau " + "de commandes s'ouvre, relâchez vers celle à lancer. Tant que " + "vous ne changez pas l'anneau d'un contexte, il montre les " + "commandes de la barre de raccourcis. Les gestes s'activent " + "ou se désactivent dans la page Général."), this); + explanation->setWordWrap(true); + + m_directions = new QComboBox(this); + m_directions->setObjectName(QStringLiteral("directionsCombo")); + m_directions->addItem(tr("8 directions"), 8); + m_directions->addItem(tr("4 directions"), 4); + m_directions->setCurrentIndex(m_directions->findData(GestureSettings::directions())); + + m_context = new QComboBox(this); + m_context->setObjectName(QStringLiteral("contextCombo")); + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + m_context->addItem(ShortcutBarSettings::title(c), c); + } + + m_available = new QListWidget(this); + m_available->setObjectName(QStringLiteral("availableList")); + m_available->setSortingEnabled(true); + m_available->setDragEnabled(true); + m_available->setDragDropMode(QAbstractItemView::DragOnly); + for (auto it = m_descriptions.cbegin(); it != m_descriptions.cend(); ++it) { + QIcon icon; + if (QAction *action = ShortcutManager::instance().action(it.key(), nullptr)) { + icon = action->icon(); + } + auto *item = new QListWidgetItem(icon, it.value(), m_available); + item->setData(Qt::UserRole, it.key()); + } + + m_ring = new GestureRingEditor(this); + m_ring->setObjectName(QStringLiteral("ringEditor")); + m_ring->setDescriptions(m_descriptions); + m_ring->setDirections(GestureSettings::directions()); + + m_slot_name = new QLabel(this); + m_slot_name->setAlignment(Qt::AlignCenter); + m_slot_name->setWordWrap(true); + m_state = new QLabel(this); + m_state->setWordWrap(true); + + auto *place = new QPushButton(tr("Placer →"), this); + place->setObjectName(QStringLiteral("placeButton")); + auto *clear = new QPushButton(tr("Vider la direction"), this); + auto *follow = new QPushButton(tr("Comme la barre de raccourcis"), this); + follow->setObjectName(QStringLiteral("followBarButton")); + + auto *ring_column = new QVBoxLayout(); + ring_column->addWidget(m_ring, 0, Qt::AlignHCenter); + ring_column->addWidget(m_slot_name); + ring_column->addWidget(clear); + ring_column->addStretch(); + + auto *grid = new QGridLayout(); + grid->addWidget(new QLabel(tr("Commandes disponibles"), this), 0, 0); + grid->addWidget(new QLabel(tr("Anneau, la direction choisie en surbrillance"), this), 0, 2); + grid->addWidget(m_available, 1, 0); + grid->addWidget(place, 1, 1, Qt::AlignVCenter); + grid->addLayout(ring_column, 1, 2); + grid->setColumnStretch(0, 1); + grid->setColumnStretch(2, 1); + + auto *top_row = new QHBoxLayout(); + top_row->addWidget(new QLabel(tr("Directions :"), this)); + top_row->addWidget(m_directions); + top_row->addSpacing(12); + top_row->addWidget(new QLabel(tr("Contexte :"), this)); + top_row->addWidget(m_context, 1); + + auto *state_row = new QHBoxLayout(); + state_row->addWidget(m_state, 1); + state_row->addWidget(follow); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(explanation); + layout->addLayout(top_row); + layout->addLayout(state_row); + layout->addLayout(grid); + + connect(m_directions, qOverload(&QComboBox::currentIndexChanged), this, [this]() { + m_ring->setDirections(m_directions->currentData().toInt()); + updateState(); + }); + connect(m_context, qOverload(&QComboBox::currentIndexChanged), + this, &GesturesConfigPage::showContext); + connect(m_ring, &GestureRingEditor::edited, this, [this]() { + m_pending.insert(m_shown, m_ring->ids()); + m_custom.insert(m_shown, true); + updateState(); + }); + connect(m_ring, &GestureRingEditor::currentSlotChanged, + this, &GesturesConfigPage::updateState); + connect(place, &QPushButton::clicked, this, &GesturesConfigPage::placeSelected); + connect(m_available, &QListWidget::itemDoubleClicked, + this, &GesturesConfigPage::placeSelected); + connect(clear, &QPushButton::clicked, m_ring, &GestureRingEditor::clearCurrent); + connect(follow, &QPushButton::clicked, this, &GesturesConfigPage::followBar); + + showContext(); +} + +/** + @brief GesturesConfigPage::applyConf + Save the directions and every context's ring. +*/ +void GesturesConfigPage::applyConf() +{ + GestureSettings::setDirections(m_directions->currentData().toInt()); + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + if (m_custom.value(c)) { + GestureSettings::setIds(c, m_pending.value(c)); + } else { + GestureSettings::reset(c); + } + } +} + +QString GesturesConfigPage::title() const +{ + return tr("Gestes de la souris", "configuration page title"); +} + +QIcon GesturesConfigPage::icon() const +{ + return QET::Icons::ConfigureShortcuts; +} + +void GesturesConfigPage::showContext() +{ + m_shown = static_cast( + m_context->currentData().toInt()); + m_ring->setIds(m_pending.value(m_shown)); + updateState(); +} + +void GesturesConfigPage::placeSelected() +{ + if (QListWidgetItem *item = m_available->currentItem()) { + m_ring->place(item->data(Qt::UserRole).toString()); + } +} + +/** + @brief GesturesConfigPage::followBar + Make the shown context's ring follow the shortcut bar again. +*/ +void GesturesConfigPage::followBar() +{ + m_custom.insert(m_shown, false); + m_pending.insert(m_shown, GestureSettings::defaultIds(m_shown)); + m_ring->setIds(m_pending.value(m_shown)); + updateState(); +} + +/** + @brief GesturesConfigPage::updateState + Name the chosen direction's command, and say whether the shown ring is + the user's or the shortcut bar's. +*/ +void GesturesConfigPage::updateState() +{ + const int slot = m_ring->currentSlot(); + const QStringList ids = m_ring->ids(); + const QString id = slot < ids.count() ? ids.at(slot) : QString(); + m_slot_name->setText(id.isEmpty() ? tr("(direction vide)") + : m_descriptions.value(id, id)); + m_state->setText(m_custom.value(m_shown) + ? tr("Anneau personnalisé pour ce contexte.") + : tr("Cet anneau suit la barre de raccourcis.")); +} diff --git a/sources/ui/configpage/gesturesconfigpage.h b/sources/ui/configpage/gesturesconfigpage.h new file mode 100644 index 000000000..7659fca0c --- /dev/null +++ b/sources/ui/configpage/gesturesconfigpage.h @@ -0,0 +1,112 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech 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. + + QElectroTech 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 QElectroTech. If not, see . +*/ +#ifndef GESTURESCONFIGPAGE_H +#define GESTURESCONFIGPAGE_H + +#include "configpage.h" +#include "../../shortcutbarsettings.h" + +#include +#include + +class QComboBox; +class QLabel; +class QListWidget; + +/** + @brief The gesture ring as an editor: one slot per direction, clockwise + from the top. Click a slot to choose it; drop a command from the + command list on a slot to put it there; Delete empties the chosen slot. +*/ +class GestureRingEditor : public QWidget +{ + Q_OBJECT + + public: + explicit GestureRingEditor(QWidget *parent = nullptr); + + void setIds(const QStringList &ids); + QStringList ids() const { return m_ids; } + void setDirections(int directions); + void setDescriptions(const QHash &descriptions); + int currentSlot() const { return m_current; } + void setCurrentSlot(int slot); + int slotAt(const QPoint &pos) const; + void place(const QString &id); + void clearCurrent(); + + QSize sizeHint() const override; + + signals: + void edited(); + void currentSlotChanged(int slot); + + protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dragMoveEvent(QDragMoveEvent *event) override; + void dropEvent(QDropEvent *event) override; + + private: + QString idAt(int slot) const; + QPointF slotCenter(int slot) const; + + QStringList m_ids; + QHash m_descriptions; + int m_directions = 8; + int m_current = 0; +}; + +/** + @brief The GesturesConfigPage class + Choose 4 or 8 directions for the mouse gesture ring, and which command + sits in each direction for each selection context. A context the user + does not edit keeps following the shortcut bar. Saved by applyConf(). +*/ +class GesturesConfigPage : public ConfigPage +{ + Q_OBJECT + + public: + explicit GesturesConfigPage(QWidget *parent = nullptr); + + void applyConf() override; + QString title() const override; + QIcon icon() const override; + + private: + void showContext(); + void placeSelected(); + void followBar(); + void updateState(); + + QComboBox *m_directions; + QComboBox *m_context; + QListWidget *m_available; + GestureRingEditor *m_ring; + QLabel *m_slot_name; + QLabel *m_state; + ShortcutBarSettings::Context m_shown = ShortcutBarSettings::Canvas; + QHash m_pending; + QHash m_custom; + QHash m_descriptions; +}; + +#endif // GESTURESCONFIGPAGE_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e37a26c43..2f2e6b048 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -318,6 +318,29 @@ set_tests_properties(tst_diagramtoolbarsettings PROPERTIES ENVIRONMENT "QT_QPA_P target_include_directories(tst_diagramtoolbarsettings PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_diagramtoolbarsettings PRIVATE Qt::Test Qt::Widgets Qt::Svg) +# GestureSettings, the gesture ring's 4 / 8 directions and empty slots, and +# its configuration page: the ring follows the shortcut bar until the user +# picks commands, then keeps each one in its direction. +add_executable( + tst_gesturesettings + tst_gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.cpp + ${QET_DIR}/sources/gesturesettings.h + ${QET_DIR}/sources/shortcutbarsettings.cpp + ${QET_DIR}/sources/diagramgestureoverlay.cpp + ${QET_DIR}/sources/diagramgestureoverlay.h + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/gesturesconfigpage.h + ${QET_DIR}/sources/ui/configpage/configpage.h + ${QET_DIR}/sources/shortcutmanager.cpp + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetstyle.cpp) +add_test(NAME tst_gesturesettings COMMAND tst_gesturesettings) +set_tests_properties(tst_gesturesettings PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_gesturesettings PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_gesturesettings PRIVATE Qt::Test Qt::Widgets Qt::Svg Qt::Xml pugixml::pugixml) + # scriptheader.h is header-only: the // ==QETScript== block that turns a # stored script into a button; and ShortcutManager::unregisterAction(), which # takes a deleted script's command out of the lists again. diff --git a/tests/qttest/tst_gesturesettings.cpp b/tests/qttest/tst_gesturesettings.cpp new file mode 100644 index 000000000..b06930ab5 --- /dev/null +++ b/tests/qttest/tst_gesturesettings.cpp @@ -0,0 +1,214 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech 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. + + QElectroTech 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 QElectroTech. If not, see . +*/ +#include "diagramgestureoverlay.h" +#include "gesturesettings.h" +#include "shortcutmanager.h" +#include "ui/configpage/gesturesconfigpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + The mouse gesture ring's own command list: until the user picks one it + follows the shortcut bar as before; a picked list keeps its directions; + 4 or 8 directions; and the configuration page that edits it. +*/ +class tst_gesturesettings : public QObject +{ + Q_OBJECT + + QMainWindow m_window; + + QAction *addCommand(const QString &id, const QString &text) + { + auto *action = new QAction(text, &m_window); + ShortcutManager::instance().registerAction( + action, id, QStringLiteral("Éditeur de schémas"), QKeySequence()); + return action; + } + + QListWidgetItem *listItem(QListWidget *list, const QString &id) + { + for (int i = 0 ; i < list->count() ; ++i) { + if (list->item(i)->data(Qt::UserRole).toString() == id) { + return list->item(i); + } + } + return nullptr; + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + QSettings().remove(QStringLiteral("diagrameditor")); + addCommand(QStringLiteral("diagrameditor.copy"), QStringLiteral("Copier")); + addCommand(QStringLiteral("diagrameditor.cut"), QStringLiteral("Couper")); + addCommand(QStringLiteral("diagrameditor.paste"), QStringLiteral("Coller")); + } + + void cleanup() + { + QSettings().remove(QStringLiteral("diagrameditor")); + } + + void defaultsFollowTheBar() + { + QCOMPARE(GestureSettings::directions(), 8); + for (const auto c : ShortcutBarSettings::contexts()) { + QVERIFY(!GestureSettings::isCustom(c)); + QCOMPARE(GestureSettings::ids(c), ShortcutBarSettings::ids(c)); + } + //A changed bar still reaches the ring, without its elements + ShortcutBarSettings::setIds(ShortcutBarSettings::Selection, + {QStringLiteral("diagrameditor.copy"), + QStringLiteral("common://10_electric/relay.elmt"), + QStringLiteral("diagrameditor.cut")}); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Selection), + QStringList({QStringLiteral("diagrameditor.copy"), + QStringLiteral("diagrameditor.cut")})); + } + + void pickedListKeepsDirections() + { + GestureSettings::setIds(ShortcutBarSettings::Canvas, + {QStringLiteral("diagrameditor.copy"), QString(), + QStringLiteral("diagrameditor.paste"), QString(), QString()}); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas), + QStringList({QStringLiteral("diagrameditor.copy"), QString(), + QStringLiteral("diagrameditor.paste")})); + //The bar no longer drives it + ShortcutBarSettings::setIds(ShortcutBarSettings::Canvas, + {QStringLiteral("diagrameditor.cut")}); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas).first(), + QStringLiteral("diagrameditor.copy")); + //Other contexts still follow the bar + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Selection)); + } + + void savingTheDefaultsFollowsTheBarAgain() + { + GestureSettings::setIds(ShortcutBarSettings::Conductor, {QStringLiteral("diagrameditor.cut")}); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Conductor)); + GestureSettings::setIds(ShortcutBarSettings::Conductor, + GestureSettings::defaultIds(ShortcutBarSettings::Conductor)); + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Conductor)); + } + + void directions() + { + GestureSettings::setDirections(4); + QCOMPARE(GestureSettings::directions(), 4); + GestureSettings::setDirections(8); + QVERIFY(!QSettings().contains(QStringLiteral("diagrameditor/gestures/directions"))); + QSettings().setValue(QStringLiteral("diagrameditor/gestures/directions"), 5); + QCOMPARE(GestureSettings::directions(), 8); + } + + void overlayDirectionsAndEmptySlots() + { + QWidget viewport; + viewport.resize(400, 400); + DiagramGestureOverlay overlay(&viewport); + QAction *copy = ShortcutManager::instance().action(QStringLiteral("diagrameditor.copy"), &m_window); + QAction *paste = ShortcutManager::instance().action(QStringLiteral("diagrameditor.paste"), &m_window); + const QPoint c(200, 200); + const QPoint right(260, 200), down(200, 260); + + overlay.showAt(c, {copy, nullptr, paste}, 4); + QCOMPARE(overlay.sectors(), 4); + QCOMPARE(overlay.sectorAt(right), 1); + QCOMPARE(overlay.actionAt(right), nullptr); + QCOMPARE(overlay.actionAt(down), paste); + overlay.setPointer(right); + QVERIFY(!overlay.grab().isNull()); + + //Today's ring: eight directions + overlay.showAt(c, {copy, nullptr, paste}); + QCOMPARE(overlay.sectors(), 8); + QCOMPARE(overlay.sectorAt(right), 2); + QCOMPARE(overlay.actionAt(right), paste); + } + + void pagePlacesAndSaves() + { + { + GesturesConfigPage page; + auto *directions = page.findChild(QStringLiteral("directionsCombo")); + auto *list = page.findChild(QStringLiteral("availableList")); + auto *ring = page.findChild(QStringLiteral("ringEditor")); + QCOMPARE(directions->currentData().toInt(), 8); + QCOMPARE(ring->ids(), GestureSettings::defaultIds(ShortcutBarSettings::Canvas)); + + directions->setCurrentIndex(directions->findData(4)); + ring->setCurrentSlot(1); + list->setCurrentItem(listItem(list, QStringLiteral("diagrameditor.cut"))); + page.findChild(QStringLiteral("placeButton"))->click(); + page.applyConf(); + } + QCOMPARE(GestureSettings::directions(), 4); + QVERIFY(GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + QCOMPARE(GestureSettings::ids(ShortcutBarSettings::Canvas).at(1), + QStringLiteral("diagrameditor.cut")); + //Contexts left alone still follow the bar + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Selection)); + + { + GesturesConfigPage page; + page.findChild(QStringLiteral("followBarButton"))->click(); + page.applyConf(); + } + QVERIFY(!GestureSettings::isCustom(ShortcutBarSettings::Canvas)); + } + + void dropOnASlot() + { + GesturesConfigPage page; + auto *list = page.findChild(QStringLiteral("availableList")); + auto *ring = page.findChild(QStringLiteral("ringEditor")); + ring->resize(ring->sizeHint()); + const QModelIndex index = list->model()->index( + list->row(listItem(list, QStringLiteral("diagrameditor.paste"))), 0); + QMimeData *mime = list->model()->mimeData({index}); + //The right-hand slot of eight + const QPointF target = QRectF(ring->rect()).center() + QPointF(68, 0); + QCOMPARE(ring->slotAt(target.toPoint()), 2); + QDropEvent drop(target, Qt::CopyAction, mime, Qt::LeftButton, Qt::NoModifier); + //Straight to the widget: QApplication drops drag events when + //no real drag is running + QVERIFY(static_cast(ring)->event(&drop)); + QVERIFY(drop.isAccepted()); + QCOMPARE(ring->currentSlot(), 2); + QCOMPARE(ring->ids().value(2), QStringLiteral("diagrameditor.paste")); + //Moved, not copied: paste was already on the default canvas ring + QCOMPARE(ring->ids().count(QStringLiteral("diagrameditor.paste")), 1); + delete mime; + } +}; + +QTEST_MAIN(tst_gesturesettings) +#include "tst_gesturesettings.moc"