diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 288ee0915..115cf47ac 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -47,6 +47,7 @@ #include "utils/conductorcreator.h" #include "undocommand/addgraphicsobjectcommand.h" #include "diagram.h" +#include #include "foliogrid.h" #include "diagramcontexttoolbar.h" #include "imagedrop.h" @@ -1040,6 +1041,7 @@ void DiagramView::mouseMoveEvent(QMouseEvent *e) { m_last_mouse_pos = e->pos(); setToolTip(tr("X: %1 Y: %2").arg(e->pos().x()).arg(e->pos().y())); + moveCrosshair(e->pos()); if (m_event_interface && m_event_interface->mouseMoveEvent(e)) return; if (m_gesture_tracking) @@ -1840,6 +1842,117 @@ void DiagramView::setCellLinesShown(bool shown) viewport()->update(); } +namespace { + /// The setting, read once and then kept here for every view + int s_crosshair = -1; +} + +/** + @brief DiagramView::crosshairShown + @return true when the folio-wide crosshair follows the mouse: two thin + lines across the whole folio, through the grid point under the mouse, + to see at a glance whether symbols and terminals are in line. Drawn + on screen only, never exported nor printed. +*/ +bool DiagramView::crosshairShown() +{ + if (s_crosshair < 0) { + QSettings settings; + s_crosshair = settings.value(QStringLiteral("diagrameditor/crosshair"), false).toBool(); + } + return s_crosshair; +} + +/** + @brief DiagramView::setCrosshairShown + @param shown : show the folio-wide crosshair in every view; kept in + the settings. The views repaint themselves at the next mouse move. +*/ +void DiagramView::setCrosshairShown(bool shown) +{ + s_crosshair = shown; + QSettings settings; + settings.setValue(QStringLiteral("diagrameditor/crosshair"), shown); +} + +/** + @brief DiagramView::crosshairArea + @return the part of the viewport the two lines through @a scene_pos + cover, a little wider than the lines +*/ +QRect DiagramView::crosshairArea(const QPointF &scene_pos) const +{ + const QPoint p = mapFromScene(scene_pos); + const QRect all = viewport()->rect(); + return QRect(all.left(), p.y() - 2, all.width(), 5) + | QRect(p.x() - 2, all.top(), 5, all.height()); +} + +/** + @brief DiagramView::moveCrosshair + Follow the mouse with the crosshair, on the grid, repainting only the + lines it leaves and the lines it draws. + @param viewport_pos : the mouse, in viewport coordinates +*/ +void DiagramView::moveCrosshair(const QPoint &viewport_pos) +{ + if (!crosshairShown()) { + hideCrosshair(); + return; + } + const QPointF pos = Diagram::snapToGrid(mapToScene(viewport_pos)); + if (m_crosshair_visible && pos == m_crosshair_pos) + return; + if (m_crosshair_visible) + viewport()->update(crosshairArea(m_crosshair_pos)); + m_crosshair_pos = pos; + m_crosshair_visible = true; + viewport()->update(crosshairArea(m_crosshair_pos)); +} + +/** + @brief DiagramView::hideCrosshair + Take the crosshair away, when the mouse leaves the view or the + setting is switched off. +*/ +void DiagramView::hideCrosshair() +{ + if (!m_crosshair_visible) + return; + m_crosshair_visible = false; + viewport()->update(crosshairArea(m_crosshair_pos)); +} + +/** + @brief DiagramView::drawForeground + Over everything else, the folio-wide crosshair, inside the folio's + border only. + @param painter + @param rect +*/ +void DiagramView::drawForeground(QPainter *painter, const QRectF &rect) +{ + PaletteGraphicsView::drawForeground(painter, rect); + if (!m_crosshair_visible || !crosshairShown() || !m_diagram) + return; + + const QRectF area = m_diagram->border_and_titleblock.insideBorderRect(); + if (!area.contains(m_crosshair_pos)) + return; + + QPainter *p = scenePainter(painter); + p->save(); + QPen pen(Diagram::background_color == Qt::darkGray ? QColor(Qt::lightGray) + : QColor(0, 120, 215)); + pen.setCosmetic(true); + pen.setWidthF(0); + p->setPen(pen); + p->setRenderHint(QPainter::Antialiasing, false); + p->drawLine(QLineF(area.left(), m_crosshair_pos.y(), area.right(), m_crosshair_pos.y())); + p->drawLine(QLineF(m_crosshair_pos.x(), area.top(), m_crosshair_pos.x(), area.bottom())); + p->restore(); +} + /** @brief DiagramView::drawBackground Reimplemented from PaletteGraphicsView: over the folio background, the @@ -1976,6 +2089,9 @@ bool DiagramView::viewportEvent(QEvent *event) if (event->type() == QEvent::Resize) { placeCellRulers(); } + if (event->type() == QEvent::Leave) { + hideCrosshair(); + } return PaletteGraphicsView::viewportEvent(event); } diff --git a/sources/diagramview.h b/sources/diagramview.h index 2453e7b4b..6a00b4b0f 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -74,6 +74,11 @@ class DiagramView : public PaletteGraphicsView QPoint m_gesture_origin; DiagramGestureOverlay *m_gesture_overlay = nullptr; QPoint m_last_mouse_pos = QPoint(-1, -1); + QPointF m_crosshair_pos; + bool m_crosshair_visible = false; + void moveCrosshair(const QPoint &viewport_pos); + void hideCrosshair(); + QRect crosshairArea(const QPointF &scene_pos) const; QPointF m_drag_last_pos; bool m_fresh_focus_in, m_first_activation = true; @@ -111,6 +116,12 @@ class DiagramView : public PaletteGraphicsView QPointF defaultPlacementPos() const; void setCellRulersShown(bool shown); void setCellLinesShown(bool shown); + /// The folio-wide crosshair under the mouse (Display menu), + /// one setting for every view + static bool crosshairShown(); + static void setCrosshairShown(bool shown); + bool crosshairVisible() const { return m_crosshair_visible; } + QPointF crosshairPos() const { return m_crosshair_pos; } protected: void mouseDoubleClickEvent(QMouseEvent *) override; @@ -127,6 +138,7 @@ class DiagramView : public PaletteGraphicsView void paintEvent(QPaintEvent *event) override; bool viewportEvent(QEvent *event) override; void drawBackground(QPainter *painter, const QRectF &rect) override; + void drawForeground(QPainter *painter, const QRectF &rect) override; void paintingInverted(bool inverted) override; void mousePressEvent(QMouseEvent *) override; void mouseMoveEvent(QMouseEvent *) override; diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 955d61159..527623837 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -590,6 +590,19 @@ void QETDiagramEditor::setUpActions() } }); + //A crosshair across the whole folio under the mouse, to see + //whether symbols and terminals are in line + m_crosshair = new QAction(tr("Folio-wide crosshair"), this); + ShortcutManager::instance().registerAction(m_crosshair, "diagrameditor.crosshair", tr("Diagram editors"), QKeySequence()); + m_crosshair->setStatusTip(tr("Show two lines across the whole folio under the mouse, to see whether parts are in line")); + m_crosshair->setCheckable(true); + m_crosshair->setChecked(DiagramView::crosshairShown()); + connect(m_crosshair, &QAction::triggered, [this](bool checked) { + DiagramView::setCrosshairShown(checked); + for (DiagramView *view : findChildren()) + view->viewport()->update(); + }); + //Snap step for dragged texts, as a fraction of the folio grid m_text_grid_menu = new QMenu(tr("Text grid"), this); m_text_grid_menu->setIcon(QET::Icons::Grid); @@ -1703,6 +1716,7 @@ void QETDiagramEditor::setUpMenu() menu_affichage -> addAction(m_mode_visualise); menu_affichage -> addSeparator(); menu_affichage -> addAction(m_draw_grid); + menu_affichage -> addAction(m_crosshair); menu_affichage -> addMenu(m_text_grid_menu); menu_affichage -> addAction(m_draw_guides); menu_affichage -> addAction(m_cell_rulers); diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 891d02e7a..df1d2c1bb 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -264,6 +264,7 @@ class QETDiagramEditor : public QETMainWindow *m_configure_duplicate, ///< Reopen the duplicate offset/direction dialog (#991) *m_auto_conductor, ///< Enable/Disable the use of auto conductor *m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor + *m_crosshair, ///< Show the folio-wide crosshair *m_add_cable = nullptr, ///< Draw the trunk line of a cable *m_draw_grid, ///< Switch the background grid display or not *m_draw_guides = nullptr, ///< Switch the custom guides display or not diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 55b60d702..644cf4725 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -103,6 +103,15 @@ if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") --source ${CMAKE_CURRENT_SOURCE_DIR}/cable_undo_probe.cpp --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) set_tests_properties(tst_cableundointegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE) + # The folio-wide crosshair follows the mouse on the grid, on screen + # only, and goes when the mouse leaves or the option is off. + add_test(NAME tst_crosshairintegration + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_crosshair_probe.py + --build ${CMAKE_BINARY_DIR} --binary $ + --source ${CMAKE_CURRENT_SOURCE_DIR}/crosshair_probe.cpp + --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) + set_tests_properties(tst_crosshairintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") endif() if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") add_test(NAME tst_darkimageintegration diff --git a/tests/qttest/crosshair_probe.cpp b/tests/qttest/crosshair_probe.cpp new file mode 100644 index 000000000..66573e45d --- /dev/null +++ b/tests/qttest/crosshair_probe.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// The folio-wide crosshair, linked against the application's objects. +// +// With the Display option on, two lines cross the whole folio through the +// grid point under the mouse, to see at a glance whether parts are in +// line. They are drawn on screen only: no item is added to the folio, so +// nothing of them is saved, exported or printed. They go when the mouse +// leaves the view and when the option is off, and the option is kept in +// the settings. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../sources/diagram.h" +#include "../../sources/diagramview.h" +#include "../../sources/qetproject.h" +#include "../../sources/qetmessagebox.h" + +static void check(bool value, const char *message) +{ + if (!value) throw std::runtime_error(message); + std::printf("ok: %s\n", message); +} + +static void moveTo(DiagramView &view, const QPoint &p) +{ + QMouseEvent event(QEvent::MouseMove, QPointF(p), view.viewport()->mapToGlobal(QPointF(p)), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &event); +} + +/// How many pixels of the crosshair colour lie along one row of the view +static int bluePixels(const QImage &image, int row) +{ + int n = 0; + for (int x = 0; x < image.width(); ++x) { + const QColor c = image.pixelColor(x, row); + if (c.blue() > 150 && c.red() < 80) ++n; + } + return n; +} + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + std::freopen(argv[2], "w", stdout); + + QTemporaryDir settings; + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settings.path()); + QCoreApplication::setOrganizationName("QETCrosshairRegression"); + QETProject::setBackupEnabled(false); + QET::QetMessageBox::setNonInteractive(true); + QFontDatabase::addApplicationFont(":/fonts/LiberationSans-Regular.ttf"); + + try + { + QETProject project(QString::fromLocal8Bit(argv[1])); + check(project.state() == QETProject::Ok, "fixture opens"); + project.buildFolios(project.diagrams()); + Diagram *folio = project.diagrams().first(); + + DiagramView view(folio); + view.resize(800, 600); + view.centerOn(folio->border_and_titleblock.insideBorderRect().center()); + QApplication::processEvents(); + const int items = folio->items().size(); + + check(!DiagramView::crosshairShown(), "off by default"); + const QPoint mouse(403, 297); + moveTo(view, mouse); + check(!view.crosshairVisible(), "no crosshair while the option is off"); + + DiagramView::setCrosshairShown(true); + moveTo(view, mouse); + check(view.crosshairVisible(), "the crosshair follows the mouse once on"); + const QPointF at = view.crosshairPos(); + check(at == Diagram::snapToGrid(view.mapToScene(mouse)), "it stands on the grid point under the mouse"); + check(folio->items().size() == items, "nothing is added to the folio"); + + const QImage image = view.viewport()->grab().toImage(); + const int row = view.mapFromScene(at).y(); + check(bluePixels(image, row) > image.width() / 2, "a line crosses the view through that point"); + check(bluePixels(image, row + 20) < image.width() / 4, "and not elsewhere"); + + QEvent leave(QEvent::Leave); + QApplication::sendEvent(view.viewport(), &leave); + check(!view.crosshairVisible(), "it goes when the mouse leaves the view"); + + DiagramView::setCrosshairShown(false); + moveTo(view, mouse); + check(!view.crosshairVisible(), "and when the option is switched off"); + check(!QSettings().value("diagrameditor/crosshair").toBool(), "the option is kept in the settings"); + std::puts("PASS"); + } + catch (const std::exception &error) + { + std::printf("FAIL: %s\n", error.what()); + std::fflush(stdout); + return 1; + } + std::fflush(stdout); + return 0; +} diff --git a/tests/qttest/run_crosshair_probe.py b/tests/qttest/run_crosshair_probe.py new file mode 100755 index 000000000..ad7efb2f7 --- /dev/null +++ b/tests/qttest/run_crosshair_probe.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Link the crosshair integration probe using an existing Ninja app build. + +Reuses app objects without duplicating the app or introducing a core-library +refactor. No application source or existing executable is replaced. The +generated manifest, probe binary, reports and export fixtures stay in build/. +""" +import argparse +import pathlib +import re +import shutil +import subprocess +import sys + +parser = argparse.ArgumentParser() +parser.add_argument('--build', required=True) +parser.add_argument('--binary', required=True) +parser.add_argument('--source', required=True) +parser.add_argument('--fixture', required=True) +args = parser.parse_args() +build = pathlib.Path(args.build).resolve() +binary = pathlib.Path(args.binary).resolve() +source = pathlib.Path(args.source).resolve() +fixture = pathlib.Path(args.fixture).resolve() +manifest = (build / 'build.ninja').read_text(encoding='utf-8') +match = re.search(r'^build (\S*?/sources/main\.cpp\.(?:obj|o)):', manifest, re.MULTILINE) +if not match: + sys.exit('Cannot find the application main object in the Ninja build.') +main_object = match.group(1) +probe_object = main_object.replace('main.cpp.', 'crosshair_probe.cpp.') +probe_binary = binary.with_name('crosshair_probe' + binary.suffix) +def escape(path): + return pathlib.Path(path).as_posix().replace('$', '$$').replace(':', '$:').replace(' ', '$ ') +main_source = source.parents[2] / 'sources' / 'main.cpp' +manifest = manifest.replace(main_object, probe_object) +manifest = manifest.replace(escape(main_source), escape(source)) +if binary.suffix: + manifest = manifest.replace(binary.name, probe_binary.name) +else: + old_target = escape(binary.name if binary.parent == build else binary) + new_target = escape(probe_binary.name if probe_binary.parent == build else probe_binary) + manifest = manifest.replace('build ' + old_target + ':', 'build ' + new_target + ':') + old_file = binary.name if binary.parent == build else binary.as_posix() + new_file = probe_binary.name if probe_binary.parent == build else probe_binary.as_posix() + manifest = re.sub(r'^ TARGET_FILE = ' + re.escape(old_file) + r'$', + ' TARGET_FILE = ' + new_file, manifest, flags=re.MULTILINE) +probe_manifest = build / 'crosshair-probe.ninja' +probe_manifest.write_text(manifest, encoding='utf-8') +ninja = shutil.which('ninja') +if not ninja: + sys.exit('Ninja is required for the integration probe.') +target = probe_binary.name if probe_binary.parent == build else str(probe_binary) +subprocess.run([ninja, '-C', str(build), '-f', probe_manifest.name, target], check=True) +output = build / 'crosshair' +output.mkdir(exist_ok=True) +report = output / 'results.txt' +result = subprocess.run([str(probe_binary), str(fixture), str(report), str(output)], timeout=90) +if report.exists(): + print(report.read_text(encoding='utf-8', errors='replace')) +sys.exit(result.returncode)