From 3738b3c1d8a60865244c51e9f7963b4672aa5343 Mon Sep 17 00:00:00 2001 From: DaTweety <129504390+DaTweety@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:20:00 +0200 Subject: [PATCH] Folio-wide crosshair under the mouse (Display menu) A new option in the Display menu, off by default and kept in the settings: two thin lines cross the whole folio, inside its border, through the grid point under the mouse, so that one sees at a glance whether symbols and terminals are in line, also while dragging them. They are drawn by the view in drawForeground(), on screen only: no item is added to the folio, so nothing of them is saved, exported or printed. Only the strips the lines leave and enter are repainted, and they go when the mouse leaves the view. Co-Authored-By: Claude Opus 5.5 --- sources/diagramview.cpp | 116 ++++++++++++++++++++++++++++ sources/diagramview.h | 12 +++ sources/qetdiagrameditor.cpp | 14 ++++ sources/qetdiagrameditor.h | 1 + tests/qttest/CMakeLists.txt | 9 +++ tests/qttest/crosshair_probe.cpp | 109 ++++++++++++++++++++++++++ tests/qttest/run_crosshair_probe.py | 60 ++++++++++++++ 7 files changed, 321 insertions(+) create mode 100644 tests/qttest/crosshair_probe.cpp create mode 100755 tests/qttest/run_crosshair_probe.py 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)