diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index ddc1ba0e0..1d8d7812b 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -287,6 +287,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/shortcutmanager.h ${QET_DIR}/sources/shortcutbarsettings.cpp ${QET_DIR}/sources/shortcutbarsettings.h + ${QET_DIR}/sources/commandsearchpopup.cpp + ${QET_DIR}/sources/commandsearchpopup.h ${QET_DIR}/sources/titleblockcell.cpp ${QET_DIR}/sources/titleblockcell.h ${QET_DIR}/sources/titleblockproperties.cpp diff --git a/sources/cellruler.cpp b/sources/cellruler.cpp index 299d70eb8..b9055e713 100644 --- a/sources/cellruler.cpp +++ b/sources/cellruler.cpp @@ -73,7 +73,7 @@ void CellRuler::paintEvent(QPaintEvent *event) Q_UNUSED(event) QPainter painter(this); - painter.fillRect(rect(), palette().color(QPalette::Button)); + painter.fillRect(rect(), background()); const bool horizontal = m_orientation == Qt::Horizontal; const int length = horizontal ? width() : height(); @@ -92,12 +92,15 @@ void CellRuler::paintEvent(QPaintEvent *event) return; } const BorderTitleBlock &border = diagram->border_and_titleblock; - const QRectF inside = border.insideBorderRect(); const QTransform transform = m_view->viewportTransform(); const int count = horizontal ? border.columnsCount() : border.rowsCount(); const qreal cell_size = horizontal ? border.columnsWidth() : border.rowsHeight(); - const qreal first = horizontal ? inside.left() : inside.top(); + //Where BorderTitleBlock::draw() puts the first cell: after the + //other header's room even when that header is hidden, which + //insideBorderRect() does not account for + const qreal first = Diagram::margin + + (horizontal ? border.rowsHeaderWidth() : border.columnsHeaderHeight()); const qreal scale = horizontal ? transform.m11() : transform.m22(); const qreal offset = (horizontal ? transform.dx() : transform.dy()) + m_leading_space; const qreal cell_pixels = cell_size * scale; @@ -165,6 +168,25 @@ void CellRuler::paintEvent(QPaintEvent *event) if (m_leading_space > 0) { painter.fillRect(horizontal ? QRect(0, 0, m_leading_space, depth - 1) : QRect(0, 0, depth - 1, m_leading_space), - palette().color(QPalette::Button)); + background()); } } + +/** + @brief CellRuler::background + @return the button colour laid over the window colour, always opaque. + The Windows 11 style gives buttons a translucent colour; filled with it + as is, a ruler (painted with Qt::WA_OpaquePaintEvent, so never cleared + first) would let every previous frame show through, and zooming would + leave a shadow of the old labels behind the new ones. +*/ +QColor CellRuler::background() const +{ + const QColor window = palette().color(QPalette::Window); + const QColor button = palette().color(QPalette::Button); + const qreal alpha = button.alphaF(); + return QColor::fromRgbF( + button.redF() * alpha + window.redF() * (1 - alpha), + button.greenF() * alpha + window.greenF() * (1 - alpha), + button.blueF() * alpha + window.blueF() * (1 - alpha)); +} diff --git a/sources/cellruler.h b/sources/cellruler.h index 619b2d52e..586918210 100644 --- a/sources/cellruler.h +++ b/sources/cellruler.h @@ -44,6 +44,8 @@ class CellRuler : public QWidget void paintEvent(QPaintEvent *event) override; private: + QColor background() const; + Qt::Orientation m_orientation; DiagramView *m_view; /// Pixels before the viewport starts, left empty: the corner the diff --git a/sources/commandsearchpopup.cpp b/sources/commandsearchpopup.cpp new file mode 100644 index 000000000..57c1efbd1 --- /dev/null +++ b/sources/commandsearchpopup.cpp @@ -0,0 +1,236 @@ +/* + 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 "commandsearchpopup.h" + +#include "shortcutmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/** + @brief CommandSearchPopup::CommandSearchPopup + @param owner : the window whose commands are searched; also the parent +*/ +CommandSearchPopup::CommandSearchPopup(QWidget *owner) : + QFrame(owner, Qt::Popup) +{ + setFrameShape(QFrame::StyledPanel); + setFrameShadow(QFrame::Raised); + setMinimumWidth(380); + + m_search = new QLineEdit(this); + m_search->setPlaceholderText(tr("Rechercher une commande…")); + m_search->setClearButtonEnabled(true); + m_list = new QListWidget(this); + m_list->setIconSize(QSize(20, 20)); + m_list->setMinimumHeight(280); + m_list->setFocusPolicy(Qt::NoFocus); + + auto *hint = new QLabel(tr("Entrée pour lancer · Échap pour fermer"), this); + hint->setEnabled(false); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(6, 6, 6, 6); + layout->setSpacing(4); + layout->addWidget(m_search); + layout->addWidget(m_list); + layout->addWidget(hint); + + connect(m_search, &QLineEdit::textChanged, this, &CommandSearchPopup::filter); + connect(m_list, &QListWidget::itemClicked, this, [this]() { runCurrent(); }); +} + +/** + @brief CommandSearchPopup::fold + @return @a text lower-cased, without accents and without the "&" of + mnemonics, for matching +*/ +QString CommandSearchPopup::fold(const QString &text) +{ + QString out; + const QString decomposed = text.normalized(QString::NormalizationForm_D); + out.reserve(decomposed.size()); + for (const QChar c : decomposed) { + if (c.category() != QChar::Mark_NonSpacing && c != QLatin1Char('&')) { + out.append(c.toLower()); + } + } + return out; +} + +/** + @brief CommandSearchPopup::popUpAt + Rebuild the command list -- actions come and go with windows, and their + enabled state changes -- then show at @a global_pos, kept on screen. +*/ +void CommandSearchPopup::popUpAt(const QPoint &global_pos) +{ + collect(); + m_search->clear(); + filter(); + + adjustSize(); + QPoint pos = global_pos; + if (QScreen *screen = QGuiApplication::screenAt(global_pos)) { + const QRect avail = screen->availableGeometry(); + pos.setX(qBound(avail.left(), pos.x(), avail.right() - width())); + pos.setY(qBound(avail.top(), pos.y(), avail.bottom() - height())); + } + move(pos); + show(); + m_search->setFocus(); +} + +/** + @brief CommandSearchPopup::collect + Every command registered by the owning window, except this search. +*/ +void CommandSearchPopup::collect() +{ + m_commands.clear(); + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) + { + if (info.id == QLatin1String("diagrameditor.command_search")) { + continue; + } + QAction *action = ShortcutManager::instance().action(info.id, parentWidget()); + if (!action || !action->isVisible()) { + continue; + } + const QString text = action->text().remove(QLatin1Char('&')); + if (text.isEmpty()) { + continue; + } + m_commands.append({action, text, fold(text)}); + } +} + +/** + @brief CommandSearchPopup::filter + Show the commands matching the search text, best first: a name starting + with it, then a word starting with it, then containing it. An empty + search lists everything, alphabetically. +*/ +void CommandSearchPopup::filter() +{ + const QString needle = fold(m_search->text().trimmed()); + + QList> hits; + for (const Command &command : std::as_const(m_commands)) + { + int score = 0; + if (needle.isEmpty()) { + score = 1; + } else if (command.folded.startsWith(needle)) { + score = 3; + } else if (command.folded.contains(QLatin1Char(' ') + needle)) { + score = 2; + } else if (command.folded.contains(needle)) { + score = 1; + } + if (score) { + hits.append({score, &command}); + } + } + std::stable_sort(hits.begin(), hits.end(), [](const auto &a, const auto &b) { + if (a.first != b.first) { + return a.first > b.first; + } + return a.second->folded < b.second->folded; + }); + + m_list->clear(); + for (const auto &hit : std::as_const(hits)) + { + QAction *action = hit.second->action; + const QKeySequence key = action->shortcut(); + auto *item = new QListWidgetItem( + action->icon(), + key.isEmpty() ? hit.second->text + : QStringLiteral("%1 (%2)").arg(hit.second->text, + key.toString(QKeySequence::NativeText))); + item->setData(Qt::UserRole, QVariant::fromValue(static_cast(action))); + if (!action->isEnabled()) { + item->setFlags(item->flags() & ~Qt::ItemIsEnabled); + } + m_list->addItem(item); + } + + //Preselect the first command that can run, so Enter works at once + for (int i = 0 ; i < m_list->count() ; ++i) { + if (m_list->item(i)->flags() & Qt::ItemIsEnabled) { + m_list->setCurrentRow(i); + break; + } + } +} + +/** + @brief CommandSearchPopup::runCurrent + Close, then trigger the highlighted command: it may open a dialog or + start a tool on the folio, which need the focus the popup holds. +*/ +void CommandSearchPopup::runCurrent() +{ + QListWidgetItem *item = m_list->currentItem(); + if (!item || !(item->flags() & Qt::ItemIsEnabled)) { + return; + } + auto *action = static_cast(item->data(Qt::UserRole).value()); + hide(); + if (action) { + action->trigger(); + } +} + +/** + @brief CommandSearchPopup::keyPressEvent + Up and Down move through the list while typing goes on in the search + field; Enter runs, Esc closes. +*/ +void CommandSearchPopup::keyPressEvent(QKeyEvent *event) +{ + switch (event->key()) + { + case Qt::Key_Escape: + hide(); + return; + case Qt::Key_Return: + case Qt::Key_Enter: + runCurrent(); + return; + case Qt::Key_Down: + case Qt::Key_Up: + case Qt::Key_PageDown: + case Qt::Key_PageUp: + QCoreApplication::sendEvent(m_list, event); + return; + default: + break; + } + QFrame::keyPressEvent(event); +} diff --git a/sources/commandsearchpopup.h b/sources/commandsearchpopup.h new file mode 100644 index 000000000..ff71ea667 --- /dev/null +++ b/sources/commandsearchpopup.h @@ -0,0 +1,65 @@ +/* + 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 COMMANDSEARCHPOPUP_H +#define COMMANDSEARCHPOPUP_H + +#include +#include + +class QAction; +class QLineEdit; +class QListWidget; + +/** + @brief Type part of a command's name, press Enter to run it. + + Lists every command the owning window registered with ShortcutManager, + best match first, with its key if it has one. Matching ignores case and + accents, so "editer" finds "Éditer". Disabled commands are listed, + greyed, but cannot be run. +*/ +class CommandSearchPopup : public QFrame +{ + Q_OBJECT + + public: + explicit CommandSearchPopup(QWidget *owner); + + void popUpAt(const QPoint &global_pos); + static QString fold(const QString &text); + + protected: + void keyPressEvent(QKeyEvent *event) override; + + private: + void collect(); + void filter(); + void runCurrent(); + + struct Command { + QAction *action; + QString text; ///< as shown + QString folded; ///< for matching + }; + + QLineEdit *m_search = nullptr; + QListWidget *m_list = nullptr; + QList m_commands; +}; + +#endif // COMMANDSEARCHPOPUP_H diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 9d9f3edf7..534166c9c 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -30,6 +30,7 @@ #include "qetgraphicsitem/conductortextitem.h" #include "qetgraphicsitem/independenttextitem.h" #include "qeticons.h" +#include "qetpalette.h" #include "titleblock/integrationmovetemplateshandler.h" #include "ui/diagrampropertiesdialog.h" #include "ui/multipastedialog.h" @@ -112,6 +113,7 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) : m_top_ruler = new CellRuler(Qt::Horizontal, this); m_side_ruler = new CellRuler(Qt::Vertical, this); m_cell_rulers_shown = QSettings().value("diagrameditor/cell_rulers", false).toBool(); + m_cell_lines_shown = QSettings().value("diagrameditor/cell_lines", false).toBool(); connect(&m_diagram->border_and_titleblock, &BorderTitleBlock::borderChanged, this, &DiagramView::updateCellRulers); connect(&m_diagram->border_and_titleblock, &BorderTitleBlock::displayChanged, this, &DiagramView::updateCellRulers); updateCellRulers(); @@ -1254,6 +1256,72 @@ void DiagramView::paintingInverted(bool inverted) m_diagram->setInvertedLightness(inverted); } +/** + @brief DiagramView::setCellLinesShown + Show or hide the lines that mark the columns and the rows of the folio + border across the drawing, in this view only: printing and exporting + never draw them. + @param shown +*/ +void DiagramView::setCellLinesShown(bool shown) +{ + m_cell_lines_shown = shown; + viewport()->update(); +} + +/** + @brief DiagramView::drawBackground + Reimplemented from PaletteGraphicsView: over the folio background, the + cell lines when they are shown. Dashed and faint, so they do not read + as conductors, and under every item. + @param painter + @param rect +*/ +void DiagramView::drawBackground(QPainter *painter, const QRectF &rect) +{ + PaletteGraphicsView::drawBackground(painter, rect); + + const BorderTitleBlock &border = m_diagram->border_and_titleblock; + if (!m_cell_lines_shown || !border.borderIsDisplayed()) { + return; + } + + //Where the border draws its cells, whether or not the other + //header is displayed + const QPointF origin(Diagram::margin + border.rowsHeaderWidth(), + Diagram::margin + border.columnsHeaderHeight()); + const qreal right = origin.x() + border.columnsCount() * border.columnsWidth(); + const qreal bottom = origin.y() + border.rowsCount() * border.rowsHeight(); + + QPainter *p = scenePainter(painter); + p->save(); + p->setRenderHint(QPainter::Antialiasing, false); + QColor color = QET::Palette::gridDotColor(Diagram::background_color, + invertsLightness()); + color.setAlpha(70); + QPen pen(color, 1, Qt::DashLine); + pen.setCosmetic(true); + p->setPen(pen); + + if (border.columnsAreDisplayed()) { + for (int i = 1 ; i < border.columnsCount() ; ++i) { + const qreal x = origin.x() + i * border.columnsWidth(); + if (x >= rect.left() && x <= rect.right()) { + p->drawLine(QPointF(x, origin.y()), QPointF(x, bottom)); + } + } + } + if (border.rowsAreDisplayed()) { + for (int i = 1 ; i < border.rowsCount() ; ++i) { + const qreal y = origin.y() + i * border.rowsHeight(); + if (y >= rect.top() && y <= rect.bottom()) { + p->drawLine(QPointF(origin.x(), y), QPointF(right, y)); + } + } + } + p->restore(); +} + /** @brief DiagramView::setCellRulersShown Show or hide the rulers that keep the column numbers and the row @@ -1268,17 +1336,24 @@ void DiagramView::setCellRulersShown(bool shown) /** @brief DiagramView::updateCellRulers - Show each ruler when the rulers are wanted and the folio shows the - matching header, and give it room in the margins of the view. The - part of the folio in sight stays in sight when the viewport resizes. + Show each ruler when the rulers are wanted, the folio shows the + matching header and that header is not already wholly in sight, and + give it room in the margins of the view. The drawing does not move on + screen when a ruler comes or goes: the ruler covers or uncovers the + edge of the viewport, as if it lay over it. */ void DiagramView::updateCellRulers() { const BorderTitleBlock &border = m_diagram->border_and_titleblock; + const QRectF in_sight = mapToScene(viewport()->rect()).boundingRect(); + const QRectF columns = border.columnsRect(); + const QRectF rows = border.rowsRect(); const bool top = m_cell_rulers_shown - && border.borderIsDisplayed() && border.columnsAreDisplayed(); + && border.borderIsDisplayed() && border.columnsAreDisplayed() + && (columns.top() < in_sight.top() || columns.bottom() > in_sight.bottom()); const bool side = m_cell_rulers_shown - && border.borderIsDisplayed() && border.rowsAreDisplayed(); + && border.borderIsDisplayed() && border.rowsAreDisplayed() + && (rows.left() < in_sight.left() || rows.right() > in_sight.right()); const int thickness = m_top_ruler->thickness(); m_top_ruler->setVisible(top); @@ -1287,9 +1362,12 @@ void DiagramView::updateCellRulers() const QMargins margins(side ? thickness : 0, top ? thickness : 0, 0, 0); if (margins != viewportMargins()) { - const QPointF centre = mapToScene(viewport()->rect().center()); + const QPointF origin = mapToScene(viewport()->rect().center()); + const QPoint before = viewport()->mapToGlobal(mapFromScene(origin)); setViewportMargins(margins); - centerOn(centre); + const QPoint moved = viewport()->mapToGlobal(mapFromScene(origin)) - before; + horizontalScrollBar()->setValue(horizontalScrollBar()->value() + moved.x()); + verticalScrollBar()->setValue(verticalScrollBar()->value() + moved.y()); } placeCellRulers(); m_top_ruler->update(); @@ -1339,11 +1417,17 @@ void DiagramView::paintEvent(QPaintEvent *event) { PaletteGraphicsView::paintEvent(event); - //Scrolling and zooming both repaint the viewport: follow them + //Scrolling and zooming both repaint the viewport: follow them. + //Showing or hiding a ruler resizes the viewport, which cannot be + //done while it paints. if (viewportTransform() != m_rulers_transform) { m_rulers_transform = viewportTransform(); m_top_ruler->update(); m_side_ruler->update(); + if (m_cell_rulers_shown) { + QMetaObject::invokeMethod(this, &DiagramView::updateCellRulers, + Qt::QueuedConnection); + } } if (m_free_rubberbanding && m_free_rubberband.count() >= 3) diff --git a/sources/diagramview.h b/sources/diagramview.h index 03eb067d5..a9e76547f 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -68,7 +68,8 @@ class DiagramView : public PaletteGraphicsView bool m_cell_rulers_shown = false; /// Last viewport transform the rulers were painted for QTransform m_rulers_transform; - + bool m_cell_lines_shown = false; + public: QString title() const; @@ -84,11 +85,12 @@ class DiagramView : public PaletteGraphicsView /// cursor query (QCursor::pos()/setPos() are silently ignored by /// several window managers and compositors, Wayland included). QPoint lastMousePos() const { return m_last_mouse_pos; } - void setCellRulersShown(bool shown); bool startElementPlacement(const ElementsLocation &location, const QPointF &scene_pos); QPointF defaultPlacementPos() const; + void setCellRulersShown(bool shown); + void setCellLinesShown(bool shown); protected: void mouseDoubleClickEvent(QMouseEvent *) override; @@ -103,6 +105,7 @@ class DiagramView : public PaletteGraphicsView bool m_releasing_focus = false; void paintEvent(QPaintEvent *event) override; bool viewportEvent(QEvent *event) override; + void drawBackground(QPainter *painter, const QRectF &rect) override; void paintingInverted(bool inverted) override; void mousePressEvent(QMouseEvent *) override; void mouseMoveEvent(QMouseEvent *) override; diff --git a/sources/palettegraphicsview.h b/sources/palettegraphicsview.h index 080897545..96d2ace36 100644 --- a/sources/palettegraphicsview.h +++ b/sources/palettegraphicsview.h @@ -80,6 +80,11 @@ class PaletteGraphicsView : public QGraphicsView nothing by default. */ virtual void paintingInverted(bool inverted); + /// @return the painter the scene really paints with: the + /// off-screen image's while painting inverted, else \a painter. + /// For subclasses that draw more in drawBackground(). + QPainter *scenePainter(QPainter *painter) + { return m_inverting ? &m_buffer_painter : painter; } private: void paintInverted(QPaintEvent *event); diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index e5f5f81cb..0aa2c9cac 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -25,6 +25,7 @@ #include "ElementsCollection/elementpickerpopup.h" #include "shortcutbarsettings.h" #include "qetgraphicsitem/conductor.h" +#include "commandsearchpopup.h" #include "QWidgetAnimation/qwidgetanimation.h" #include "autoNum/ui/autonumberingdockwidget.h" #include "conductornumexport.h" @@ -545,6 +546,18 @@ void QETDiagramEditor::setUpActions() dv->setCellRulersShown(checked); }); + //Draw the limits of the folio columns and rows across the drawing + m_cell_lines = new QAction(tr("Afficher les limites des cases"), this); + m_cell_lines->setStatusTip(tr("Trace les limites des colonnes et des lignes du folio sur le schéma, à l'écran seulement")); + m_cell_lines->setCheckable(true); + m_cell_lines->setChecked(settings.value("diagrameditor/cell_lines", false).toBool()); + connect(m_cell_lines, &QAction::triggered, [this](bool checked) { + QSettings().setValue("diagrameditor/cell_lines", checked); + foreach (ProjectView *prjv, this->openedProjects()) + foreach (DiagramView *dv, prjv->diagram_views()) + dv->setCellLinesShown(checked); + }); + //Edit current diagram properties m_edit_diagram_properties = new QAction(QET::Icons::DialogInformation, tr("Propriétés du folio"), this); ShortcutManager::instance().registerAction(m_edit_diagram_properties, "diagrameditor.edit_diagram_properties", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_L); @@ -826,6 +839,26 @@ void QETDiagramEditor::setUpActions() connect(m_insert_last_element, &QAction::triggered, this, &QETDiagramEditor::insertLastElement); addAction(m_insert_last_element); + //Type to find and run any command, as SolidWorks' "Search Commands" + //and the command palette of many editors. Ctrl+Shift+P, the key those + //editors use, is taken by the autonumbering dock; M for "menu". + m_command_search = new QAction(tr("Rechercher une commande…"), this); + m_command_search->setStatusTip( + tr("Tapez une partie du nom d'une commande et appuyez sur Entrée pour la lancer", + "status bar tip")); + ShortcutManager::instance().registerAction( + m_command_search, "diagrameditor.command_search", + tr("Éditeur de schémas"), Qt::CTRL | Qt::SHIFT | Qt::Key_M); + connect(m_command_search, &QAction::triggered, this, [this]() { + if (!m_command_search_popup) { + m_command_search_popup = new CommandSearchPopup(this); + } + const QRect area = geometry(); + m_command_search_popup->popUpAt( + area.contains(QCursor::pos()) ? QCursor::pos() + : area.center()); + }); + addAction(m_command_search); //Cursor-anchored picker. Insert is unbound anywhere in the tree and //reads correctly for the action, which keeps A free for the far more @@ -988,8 +1021,9 @@ void QETDiagramEditor::setUpActions() add_path->setCheckable(true); connect(&m_add_item_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::addItemGroupTriggered); - //No default key, but an id: they can then be bound in the Shortcuts - //page and placed on the shortcut bar, like every other command. + //No default key, but an id: they can then be found by the command + //search, bound in the Shortcuts page and placed on the shortcut bar, + //like every other command. for (QAction *action : m_add_item_actions_group.actions()) { ShortcutManager::instance().registerAction( action, "diagrameditor.add_" + action->data().toString(), @@ -1151,6 +1185,7 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_insert_last_element); menu_edition -> addAction(m_show_element_picker); menu_edition -> addAction(m_show_shortcut_bar); + menu_edition -> addAction(m_command_search); menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a @@ -1231,6 +1266,7 @@ void QETDiagramEditor::setUpMenu() menu_affichage -> addMenu(m_text_grid_menu); menu_affichage -> addAction(m_draw_guides); menu_affichage -> addAction(m_cell_rulers); + menu_affichage -> addAction(m_cell_lines); menu_affichage -> addMenu(m_background_color_button->menu()); menu_affichage -> addSeparator(); menu_affichage -> addActions(m_zoom_actions_group.actions()); @@ -2066,6 +2102,7 @@ void QETDiagramEditor::slot_updateActions() m_draw_grid-> setEnabled(opened_diagram); m_draw_guides-> setEnabled(opened_diagram); m_cell_rulers-> setEnabled(opened_diagram); + m_cell_lines-> setEnabled(opened_diagram); //Project menu m_project_edit_properties -> setEnabled(opened_project); diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 7a6abc53a..f04c3184e 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -46,6 +46,7 @@ class RecentFiles; class DiagramPropertiesEditorDockWidget; class ElementsCollectionWidget; class ElementPickerPopup; +class CommandSearchPopup; class AutoNumberingDockWidget; class TerminalNumberingDialog; @@ -219,6 +220,7 @@ class QETDiagramEditor : public QETMainWindow *m_draw_grid, ///< Switch the background grid display or not *m_draw_guides = nullptr, ///< Switch the custom guides display or not *m_cell_rulers = nullptr, ///< Keep the folio column/row headers in sight or not + *m_cell_lines = nullptr, ///< Draw the folio column/row limits across the drawing or not *m_project_edit_properties, ///< Edit the properties of the current project. *m_project_add_diagram, ///< Add a diagram to the current project. *m_remove_diagram_from_project, ///< Delete a diagram from the current project @@ -278,6 +280,8 @@ class QETDiagramEditor : public QETMainWindow QAction *m_show_element_picker = nullptr; QAction *m_show_shortcut_bar = nullptr; ElementPickerPopup *m_element_picker = nullptr; ///< Built on first use + QAction *m_command_search = nullptr; + CommandSearchPopup *m_command_search_popup = nullptr; ///< Built on first use ElementsCollectionWidget *m_element_collection_widget; /// Last element placed from the collection, for "insert last" ElementsLocation m_last_inserted_element; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3296bc016..98d3a17fa 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -156,6 +156,19 @@ add_test(NAME tst_qetstrings COMMAND tst_qetstrings) target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml) +# CommandSearchPopup: accent-blind matching, ranking, and that Enter runs +# only the owning window's highlighted, enabled command. +add_executable( + tst_commandsearch + tst_commandsearch.cpp + ${QET_DIR}/sources/commandsearchpopup.cpp + ${QET_DIR}/sources/commandsearchpopup.h + ${QET_DIR}/sources/shortcutmanager.cpp) +add_test(NAME tst_commandsearch COMMAND tst_commandsearch) +set_tests_properties(tst_commandsearch PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +target_include_directories(tst_commandsearch PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_commandsearch PRIVATE Qt::Test Qt::Widgets) + # QETSql::isSingleReadOnlyStatement() -- read-only enforcement for every # project-database query, including the ones a .qet file carries. Compiles # sqlreadonly.cpp alone against its own in-memory SQLite, so the security diff --git a/tests/qttest/tst_commandsearch.cpp b/tests/qttest/tst_commandsearch.cpp new file mode 100644 index 000000000..b4acca186 --- /dev/null +++ b/tests/qttest/tst_commandsearch.cpp @@ -0,0 +1,117 @@ +/* + 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 "commandsearchpopup.h" +#include "shortcutmanager.h" + +#include +#include +#include +#include +#include + +/** + CommandSearchPopup: accent- and case-blind matching, ranking, and that + Enter runs the highlighted command of the owning window only. +*/ +class tst_commandsearch : public QObject +{ + Q_OBJECT + +private: + QAction *add(QWidget *owner, const QString &id, const QString &text) + { + auto *action = new QAction(text, owner); + ShortcutManager::instance().registerAction(action, id, QStringLiteral("test"), QKeySequence()); + return action; + } + + QStringList shown(CommandSearchPopup &popup) + { + QStringList texts; + auto *list = popup.findChild(); + for (int i = 0 ; i < list->count() ; ++i) { + texts << list->item(i)->text(); + } + return texts; + } + +private slots: + void fold_data() + { + QTest::addColumn("input"); + QTest::addColumn("expected"); + QTest::newRow("accents") << QStringLiteral("Éditer l'élément") << QStringLiteral("editer l'element"); + QTest::newRow("mnemonic") << QStringLiteral("&Fichier") << QStringLiteral("fichier"); + QTest::newRow("cedilla") << QStringLiteral("Façade") << QStringLiteral("facade"); + QTest::newRow("plain") << QStringLiteral("zoom") << QStringLiteral("zoom"); + } + + void fold() + { + QFETCH(QString, input); + QFETCH(QString, expected); + QCOMPARE(CommandSearchPopup::fold(input), expected); + } + + void ranksAndRunsTheBestMatch() + { + QWidget owner; + QWidget other; + QAction *rotate = add(&owner, QStringLiteral("t.rotate"), QStringLiteral("Pivoter")); + add(&owner, QStringLiteral("t.rotate_texts"), QStringLiteral("Orienter les textes")); + QAction *edit = add(&owner, QStringLiteral("t.edit"), QStringLiteral("Éditer l'item sélectionné")); + add(&owner, QStringLiteral("t.text"), QStringLiteral("Ajouter un champ de texte")); + //Same id family, another window: must not be listed + add(&other, QStringLiteral("t.other"), QStringLiteral("Texte d'une autre fenêtre")); + + CommandSearchPopup popup(&owner); + popup.popUpAt(QPoint(0, 0)); + auto *search = popup.findChild(); + + search->setText(QStringLiteral("texte")); + //word start ("…textes") before a contained match; other window absent + QCOMPARE(shown(popup), (QStringList{QStringLiteral("Ajouter un champ de texte"), + QStringLiteral("Orienter les textes")})); + + search->setText(QStringLiteral("editer")); + QCOMPARE(shown(popup).value(0), QStringLiteral("Éditer l'item sélectionné")); + QSignalSpy edited(edit, &QAction::triggered); + QSignalSpy rotated(rotate, &QAction::triggered); + QTest::keyClick(&popup, Qt::Key_Return); + QCOMPARE(edited.count(), 1); + QCOMPARE(rotated.count(), 0); + QVERIFY(!popup.isVisible()); + } + + void disabledCommandsCannotRun() + { + QWidget owner; + QAction *paste = add(&owner, QStringLiteral("u.paste"), QStringLiteral("Coller")); + paste->setEnabled(false); + CommandSearchPopup popup(&owner); + popup.popUpAt(QPoint(0, 0)); + popup.findChild()->setText(QStringLiteral("coller")); + QCOMPARE(shown(popup), QStringList{QStringLiteral("Coller")}); + QSignalSpy pasted(paste, &QAction::triggered); + QTest::keyClick(&popup, Qt::Key_Return); + QCOMPARE(pasted.count(), 0); + } +}; + +QTEST_MAIN(tst_commandsearch) +#include "tst_commandsearch.moc"