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 <noreply@anthropic.com>
This commit is contained in:
DaTweety
2026-10-19 13:20:00 +02:00
parent 4fee464919
commit 3738b3c1d8
7 changed files with 321 additions and 0 deletions
+116
View File
@@ -47,6 +47,7 @@
#include "utils/conductorcreator.h"
#include "undocommand/addgraphicsobjectcommand.h"
#include "diagram.h"
#include <QSettings>
#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);
}
+12
View File
@@ -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;
+14
View File
@@ -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<DiagramView *>())
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);
+1
View File
@@ -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
+9
View File
@@ -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 $<TARGET_FILE:qelectrotech>
--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
+109
View File
@@ -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 <QApplication>
#include <QFontDatabase>
#include <QImage>
#include <QMouseEvent>
#include <QSettings>
#include <QTemporaryDir>
#include <cstdio>
#include <stdexcept>
#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;
}
+60
View File
@@ -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)