From 1e74bcd9deca412f0beb725cea89107b928272c6 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 21:06:13 +1200 Subject: [PATCH 1/5] Cell rulers: no ghost labels on Windows, hidden while the folio's own header shows The Windows 11 style gives QPalette::Button a translucent colour (#FFFFFFB3). The rulers filled their background with it, and since they paint with WA_OpaquePaintEvent nothing clears them first: every zoom step blended the new labels over the old ones, leaving a fading trail. Fill with the button colour composed over the window colour instead, which is always opaque. Each ruler is now also hidden while the folio's own column (or row) header is wholly in sight, so zoomed out only the folio's headers show, not both. When a ruler comes or goes the drawing keeps its place on screen: the ruler covers or uncovers the edge of the viewport. Co-Authored-By: Claude Opus 5.5 --- sources/cellruler.cpp | 23 +++++++++++++++++++++-- sources/cellruler.h | 2 ++ sources/diagramview.cpp | 32 ++++++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/sources/cellruler.cpp b/sources/cellruler.cpp index 299d70eb8..61fa98399 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(); @@ -165,6 +165,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/diagramview.cpp b/sources/diagramview.cpp index 918b6bbbf..b2677d04d 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -1210,17 +1210,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); @@ -1229,9 +1236,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(); @@ -1281,11 +1291,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) From 53706a80d8a13c8bab3361eadd3193228c67fda4 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 21:17:19 +1200 Subject: [PATCH 2/5] Add an option to draw the folio cell limits across the drawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affichage › "Afficher les limites des cases" draws a faint dashed line at every column and row limit of the folio border, so that zoomed into the middle of a folio you can see where a cell ends, not only which one the headers name. Off by default, remembered once set (diagrameditor/cell_lines). Drawn by DiagramView::drawBackground(), under every item and only in the view: printing and export render the Diagram, never the view, so they cannot pick the lines up. PaletteGraphicsView gains scenePainter() so a subclass can draw there on the inverted (dark palette) path too. The lines follow the border's own cell positions, and each direction is hidden when the folio hides that header. Co-Authored-By: Claude Opus 5.5 --- sources/diagramview.cpp | 68 +++++++++++++++++++++++++++++++++++ sources/diagramview.h | 7 ++-- sources/palettegraphicsview.h | 5 +++ sources/qetdiagrameditor.cpp | 14 ++++++++ sources/qetdiagrameditor.h | 1 + 5 files changed, 93 insertions(+), 2 deletions(-) diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 918b6bbbf..2d44211b5 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(); @@ -1196,6 +1198,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 diff --git a/sources/diagramview.h b/sources/diagramview.h index 9550e4898..f2abdd505 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; @@ -85,7 +86,8 @@ class DiagramView : public PaletteGraphicsView /// several window managers and compositors, Wayland included). QPoint lastMousePos() const { return m_last_mouse_pos; } void setCellRulersShown(bool shown); - + void setCellLinesShown(bool shown); + protected: void mouseDoubleClickEvent(QMouseEvent *) override; void contextMenuEvent(QContextMenuEvent *) override; @@ -99,6 +101,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 c97613ba7..852ea9612 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -534,6 +534,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); @@ -1160,6 +1172,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()); @@ -1992,6 +2005,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 c0bece505..2907f7484 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -213,6 +213,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 From 3d260edcdb83f86e458130a87dff2df76e45fd62 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 21:18:35 +1200 Subject: [PATCH 3/5] Cell rulers: line the top ruler up with the columns when the row header is hidden BorderTitleBlock::draw() leaves the row header's room before the first column even when the row header is hidden; insideBorderRect() does not, so the top ruler was one header width out of line on such a folio. Take the first cell's position from the header sizes, as draw() does. Co-Authored-By: Claude Opus 5.5 --- sources/cellruler.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sources/cellruler.cpp b/sources/cellruler.cpp index 61fa98399..b9055e713 100644 --- a/sources/cellruler.cpp +++ b/sources/cellruler.cpp @@ -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; From 86cb7e430e30883965f6d33ae206edb452c821b0 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 13:37:51 +1200 Subject: [PATCH 4/5] Add a command search: type part of a command's name, press Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+Shift+M (Édition → "Rechercher une commande…") opens a small search box at the cursor listing every command of the diagram editor window, as SolidWorks' "Search Commands" and the command palette of many editors do. Typing narrows it, best match first: name starting with the text, then a word starting with it, then containing it. Matching ignores case, accents and mnemonic "&", so "editer" finds "Éditer l'item sélectionné". Each row shows the command's key when it has one, which also teaches the keys. Disabled commands are listed, greyed, and cannot be run. Enter runs the highlighted one after closing the box; Esc closes. The list is ShortcutManager's registry, restricted to the actions this window owns (ShortcutManager::action(id, owner)), so a second editor window's commands never appear and nothing has to be listed by hand. Ctrl+Shift+P, the usual key for this, is already the autonumbering dock's. tst_commandsearch covers the folding, the ranking, that another window's commands are left out and that a disabled command does not run; both behaviours were checked to fail the test when broken. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- cmake/qet_compilation_vars.cmake | 2 + sources/commandsearchpopup.cpp | 236 +++++++++++++++++++++++++++++ sources/commandsearchpopup.h | 65 ++++++++ sources/qetdiagrameditor.cpp | 23 +++ sources/qetdiagrameditor.h | 3 + sources/shortcutmanager.cpp | 33 ++++ sources/shortcutmanager.h | 2 + tests/qttest/CMakeLists.txt | 13 ++ tests/qttest/tst_commandsearch.cpp | 117 ++++++++++++++ 9 files changed, 494 insertions(+) create mode 100644 sources/commandsearchpopup.cpp create mode 100644 sources/commandsearchpopup.h create mode 100644 tests/qttest/tst_commandsearch.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 1bf68b48d..b822eb726 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -285,6 +285,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/recentfiles.h ${QET_DIR}/sources/shortcutmanager.cpp ${QET_DIR}/sources/shortcutmanager.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/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/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index c97613ba7..cfd10a222 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -22,6 +22,7 @@ #include #include #include "ElementsCollection/elementscollectionwidget.h" +#include "commandsearchpopup.h" #include "QWidgetAnimation/qwidgetanimation.h" #include "autoNum/ui/autonumberingdockwidget.h" #include "conductornumexport.h" @@ -796,6 +797,27 @@ void QETDiagramEditor::setUpActions() ShortcutManager::instance().registerAction(m_rotate_texts, "diagrameditor.rotate_texts", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_Space); ShortcutManager::instance().registerAction(m_edit_selection, "diagrameditor.edit_selection", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_E); + //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); + m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip")); m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip")); m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip")); @@ -1080,6 +1102,7 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_paste); menu_edition -> addAction(m_duplicate); menu_edition -> addAction(m_configure_duplicate); + 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 diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index c0bece505..fc8a324d8 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -45,6 +45,7 @@ class ElementsLocation; class RecentFiles; class DiagramPropertiesEditorDockWidget; class ElementsCollectionWidget; +class CommandSearchPopup; class AutoNumberingDockWidget; class TerminalNumberingDialog; @@ -268,6 +269,8 @@ class QETDiagramEditor : public QETMainWindow *m_qdw_elmt_collection, *qdw_undo; /// Dock for the undo list + QAction *m_command_search = nullptr; + CommandSearchPopup *m_command_search_popup = nullptr; ///< Built on first use ElementsCollectionWidget *m_element_collection_widget; DiagramPropertiesEditorDockWidget *m_selection_properties_editor; diff --git a/sources/shortcutmanager.cpp b/sources/shortcutmanager.cpp index 6f8e856a4..56d43b228 100644 --- a/sources/shortcutmanager.cpp +++ b/sources/shortcutmanager.cpp @@ -197,3 +197,36 @@ bool ShortcutManager::trigger(const QString &id) const } return false; } + +/** + @return the QAction registered under @a id that belongs to @a owner -- + that is, has @a owner among its ancestors -- or nullptr. Several windows + of the same kind each register their own action under one id, so a + window asking for "its" action has to say which window it is. + @param id + @param owner : the window, or nullptr for the first live action +*/ +QAction *ShortcutManager::action(const QString &id, const QObject *owner) const +{ + auto it = m_entries.find(id); + if (it == m_entries.end()) { + return nullptr; + } + + for (const QPointer &target : qAsConst(it->targets)) + { + auto *action = qobject_cast(target.data()); + if (!action) { + continue; + } + if (!owner) { + return action; + } + for (const QObject *o = action->parent(); o; o = o->parent()) { + if (o == owner) { + return action; + } + } + } + return nullptr; +} diff --git a/sources/shortcutmanager.h b/sources/shortcutmanager.h index d40448c52..b42cd0d44 100644 --- a/sources/shortcutmanager.h +++ b/sources/shortcutmanager.h @@ -26,6 +26,7 @@ #include class QObject; +class QAction; /** @brief The ShortcutManager class @@ -84,6 +85,7 @@ class ShortcutManager /// multi-window case, not a guaranteed-correct dispatch. /// @return whether a live target was found and triggered. bool trigger(const QString &id) const; + QAction *action(const QString &id, const QObject *owner) const; private: ShortcutManager() = default; 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" From db562629245ecca9e71b83c980f2ed7e3dc9fc31 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 21:35:47 +1200 Subject: [PATCH 5/5] Register the drawing tools so the command search finds them Ajouter une ligne, un rectangle, une ellipse... had no ShortcutManager id, so the command search could not list them. Give each one an id with no default key; they can also be bound in the Shortcuts page now. Co-Authored-By: Claude Opus 5.5 --- sources/qetdiagrameditor.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index cfd10a222..a4255a1b5 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -949,6 +949,13 @@ 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 found by the command + //search and bound in the Shortcuts page, like every other command. + for (QAction *action : m_add_item_actions_group.actions()) { + ShortcutManager::instance().registerAction( + action, "diagrameditor.add_" + action->data().toString(), + tr("Éditeur de schémas"), QKeySequence()); + } //Depth action m_depth_action_group = QET::depthActionGroup(this);