mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-27 20:44:13 +02:00
Compare commits
164 Commits
9987f05835
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1331ed6677 | |||
| ae400d4da6 | |||
| 7fdd4417a4 | |||
| 6637127910 | |||
| 38d7ecd67a | |||
| 27b60ec30e | |||
| 4bd5a6e5ac | |||
| 58cf62c782 | |||
| 83bb7e14e0 | |||
| 239065af7d | |||
| adcf11270c | |||
| 539708da3c | |||
| f62a44827b | |||
| b350a2dbaa | |||
| e1152ba570 | |||
| 8184ee1a18 | |||
| d56e99702c | |||
| 08325061fd | |||
| e9610d3f96 | |||
| d5027eadbb | |||
| da09206826 | |||
| 9812166071 | |||
| a399923575 | |||
| 1d980b164f | |||
| 20a3e4f636 | |||
| 4278ef864e | |||
| f98d5d00e9 | |||
| 166ee34f52 | |||
| 4efe28ce03 | |||
| 7c4355338b | |||
| f7591e0c9d | |||
| 4c3e86c07e | |||
| 44cabbcfe4 | |||
| a2a30ef7c4 | |||
| 0b4f480dfe | |||
| 05260de87b | |||
| db154a8d06 | |||
| 7dc1d345b0 | |||
| ba1811a0cf | |||
| a3dc8d3ad4 | |||
| b1fe1ef7a7 | |||
| 9584fc4b7c | |||
| 25baf48153 | |||
| 7f51a3e10a | |||
| 503823bfce | |||
| 30c97612a2 | |||
| 8cdf51405a | |||
| 4a5755e552 | |||
| ef8b3866ba | |||
| f6f53e3f52 | |||
| 5120feb417 | |||
| cd38f6f2a2 | |||
| 29fe731728 | |||
| b740508b7f | |||
| 76eea09192 | |||
| 30e83d8c52 | |||
| 3a50cd9b0e | |||
| acb770909e | |||
| a9ad6e2f34 | |||
| ff795258c4 | |||
| 0cb424bd06 | |||
| 47e5a8072b | |||
| 5bd55801e9 | |||
| 5ccb089cdd | |||
| 416ababc41 | |||
| 63771a41b5 | |||
| 57905a5789 | |||
| 451cccd615 | |||
| af28a49b72 | |||
| 926bbcf75f | |||
| 6bcdb87524 | |||
| f850d0bee8 | |||
| d7036ad807 | |||
| 09fb81b99a | |||
| 501379def2 | |||
| 52a831e1fa | |||
| c1bb3b33f4 | |||
| f91ec74dde | |||
| b08d0692b7 | |||
| 627b818a30 | |||
| 0d2befbc00 | |||
| db387525e2 | |||
| 6763103d57 | |||
| 852c32fc07 | |||
| e82548bee9 | |||
| 60ab39b0f6 | |||
| b57c37ca62 | |||
| 329831d986 | |||
| b80d6e7cd3 | |||
| 6d42556d9f | |||
| 2da101ca90 | |||
| 07501d604e | |||
| 73ad43bd8f | |||
| c1d9e60656 | |||
| 92cbc8fd2a | |||
| db56262924 | |||
| 86cb7e430e | |||
| 3d260edcdb | |||
| 53706a80d8 | |||
| 1e74bcd9de | |||
| 45aec7735b | |||
| 923367d2a8 | |||
| 6dd260a7b2 | |||
| 132c564aef | |||
| e9c4969af7 | |||
| 040c4b5e29 | |||
| 71d708e2da | |||
| b490720652 | |||
| 519f2245f6 | |||
| b61193f66f | |||
| e6291eb8e8 | |||
| 0ca4b9f6c3 | |||
| a08cdf7272 | |||
| 2fdb225936 | |||
| 95f3ea2f6d | |||
| 0e02190eb2 | |||
| 2807771200 | |||
| 4cffdf93b6 | |||
| 9738345b45 | |||
| 286002fe25 | |||
| ce0e7e6867 | |||
| 5f0b015186 | |||
| e5738c5b7d | |||
| c3c264cd78 | |||
| d392ab4b8f | |||
| ebd4a57563 | |||
| 23d83ec153 | |||
| d43c535cc9 | |||
| 71782aec20 | |||
| 59dc59cf2f | |||
| 469c8cbc70 | |||
| 1330dbb435 | |||
| e7f19de2da | |||
| 4c9a307cb9 | |||
| 4381c6caa3 | |||
| 07d7aedc26 | |||
| 5622308ba2 | |||
| 149ae961cc | |||
| ac40d110fb | |||
| 4f309770f0 | |||
| 72cf86ece0 | |||
| 8aefdcc481 | |||
| 3a45625d69 | |||
| 988357057f | |||
| e9676ef87e | |||
| ca507dc0f3 | |||
| 1932150ea6 | |||
| 23241fba69 | |||
| f2f1051914 | |||
| 313aafc95b | |||
| aaeaff55cc | |||
| bb037ee3ff | |||
| 17ebbffbca | |||
| 04e2279a7f | |||
| d1256872c2 | |||
| 800189debb | |||
| 522e008c26 | |||
| c1cc9a5b98 | |||
| 4e754cfac1 | |||
| 151b16ba76 | |||
| 0902725a59 | |||
| 280051745a | |||
| e3ede85efd | |||
| 95c2f6cf6d |
@@ -57,6 +57,7 @@ jobs:
|
||||
mingw-w64-ucrt-x86_64-nsis
|
||||
mingw-w64-ucrt-x86_64-angleproject
|
||||
mingw-w64-ucrt-x86_64-qt6-declarative
|
||||
mingw-w64-ucrt-x86_64-hidapi
|
||||
|
||||
- name: Cache ccache
|
||||
uses: actions/cache@v5
|
||||
@@ -120,6 +121,8 @@ jobs:
|
||||
-DCMAKE_POLICY_DEFAULT_CMP0077=NEW \
|
||||
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
-DQET_EXPORT_PROJECT_DB=ON \
|
||||
-DQET_ENABLE_SPACEMOUSE=ON \
|
||||
-DQET_SPACEMOUSE_BACKEND=hid \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=/ucrt64/bin/ccache \
|
||||
-DCMAKE_CXX_COMPILER_LAUNCHER=/ucrt64/bin/ccache \
|
||||
-DSQLite3_INCLUDE_DIR=/ucrt64/include \
|
||||
@@ -146,6 +149,11 @@ jobs:
|
||||
SIZE=$(stat -c%s "$EXE")
|
||||
echo "Exe found: $EXE ($SIZE bytes)"
|
||||
[ "$SIZE" -gt 100000 ] || { echo "ERROR: exe too small"; exit 1; }
|
||||
# 3D mouse support (discussion #599): CMake only warns when hidapi
|
||||
# is missing, which would ship a package without it -- fail here.
|
||||
ldd "$EXE" | grep -qi 'libhidapi' \
|
||||
|| { echo "ERROR: exe not linked against hidapi -- 3D mouse support missing"; exit 1; }
|
||||
echo "3D mouse support: linked against $(ldd "$EXE" | grep -io 'libhidapi[^ ]*' | head -1)"
|
||||
|
||||
- name: Deploy — copy exe + windeployqt + DLLs
|
||||
shell: msys2 {0}
|
||||
@@ -191,6 +199,8 @@ jobs:
|
||||
echo "=== $DLL_COUNT DLLs present after scan ==="
|
||||
ls -lh "$BIN/QElectroTech.exe" || { echo "ERROR: exe missing from bin/"; exit 1; }
|
||||
[ "$DLL_COUNT" -gt 5 ] || { echo "ERROR: too few DLLs"; exit 1; }
|
||||
# Without it QElectroTech.exe would not start at all.
|
||||
[ -f "$BIN/libhidapi-0.dll" ] || { echo "ERROR: libhidapi-0.dll not deployed"; exit 1; }
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
|
||||
cp /ucrt64/bin/libgcc_s_seh-1.dll "$BIN/"
|
||||
|
||||
+3
-2
@@ -173,8 +173,9 @@ endif()
|
||||
if(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED)
|
||||
list(APPEND QET_SPACEMOUSE_LIBRARIES PkgConfig::SPNAV)
|
||||
endif()
|
||||
# A future Windows/macOS (3DxWare) backend would list.APPEND its own SDK
|
||||
# libraries here, behind its own *_ENABLED guard, alongside this one.
|
||||
if(QET_SPACEMOUSE_BACKEND_HID_ENABLED)
|
||||
list(APPEND QET_SPACEMOUSE_LIBRARIES PkgConfig::HIDAPI)
|
||||
endif()
|
||||
|
||||
if(APPLE)
|
||||
# CFBundleIdentifier must not be empty. CMake's default Info.plist
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"finish-args": [
|
||||
"--socket=wayland",
|
||||
"--socket=fallback-x11",
|
||||
"--device=dri",
|
||||
"--device=all",
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=cups",
|
||||
@@ -33,6 +33,23 @@
|
||||
"modules": [
|
||||
"tkinter.json",
|
||||
"pypi-dependencies.json",
|
||||
{
|
||||
"name": "hidapi",
|
||||
"buildsystem": "cmake-ninja",
|
||||
"config-opts": [
|
||||
"-DCMAKE_INSTALL_PREFIX=/app",
|
||||
"-DCMAKE_BUILD_TYPE=Release",
|
||||
"-DHIDAPI_BUILD_HIDTEST=OFF",
|
||||
"-DHIDAPI_WITH_TESTS=OFF"
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://github.com/libusb/hidapi.git",
|
||||
"tag": "hidapi-0.15.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "qelectrotech",
|
||||
"buildsystem": "cmake",
|
||||
@@ -45,7 +62,9 @@
|
||||
"-DBUILD_PUGIXML=ON",
|
||||
"-DFETCHCONTENT_FULLY_DISCONNECTED=ON",
|
||||
"-DFETCHCONTENT_SOURCE_DIR_PUGIXML=/run/build/qelectrotech/pugixml",
|
||||
"-DQET_EXPORT_PROJECT_DB=ON"
|
||||
"-DQET_EXPORT_PROJECT_DB=ON",
|
||||
"-DQET_ENABLE_SPACEMOUSE=ON",
|
||||
"-DQET_SPACEMOUSE_BACKEND=hid"
|
||||
],
|
||||
"post-install": [
|
||||
"mv ${FLATPAK_DEST}/share/mime/packages/qelectrotech.xml ${FLATPAK_DEST}/share/mime/packages/org.qelectrotech.QElectroTech.xml"
|
||||
|
||||
@@ -48,7 +48,7 @@ option(BUILD_WITH_KF "Build with KDE Frameworks" ON)
|
||||
# strict behaviour, and only developers who opt in trade that for the speed.
|
||||
option(QET_ENABLE_PCH "Use precompiled headers (developer build speed; may mask missing #includes)" OFF)
|
||||
|
||||
# Phase 1 (Linux, libspnav) of discussion #599: 3Dconnexion SpaceMouse/
|
||||
# SpacePilot pan/zoom support. Off by default -- see cmake/find_spacemouse.cmake
|
||||
# for what happens when it is on but libspnav isn't found.
|
||||
option(QET_ENABLE_SPACEMOUSE "Build with 3Dconnexion/libspnav 3D mouse support for pan/zoom (Linux, requires libspnav-dev and spacenavd)" OFF)
|
||||
# Discussion #599: 3Dconnexion SpaceMouse/SpacePilot pan/zoom support. Off by
|
||||
# default -- see cmake/find_spacemouse.cmake for the backends and what happens
|
||||
# when it is on but no library is found.
|
||||
option(QET_ENABLE_SPACEMOUSE "Build with 3D mouse (3Dconnexion SpaceMouse) support for pan/zoom; needs libspnav or hidapi, see QET_SPACEMOUSE_BACKEND" OFF)
|
||||
|
||||
+59
-27
@@ -20,44 +20,76 @@ message(" - find_spacemouse")
|
||||
# so none of this runs, and the default build is entirely unaffected: no new
|
||||
# dependency, no new source files, no new symbols.
|
||||
#
|
||||
# When it is on, the platform decides which backend (if any) is available --
|
||||
# see sources/spacemouse/spacemousebackend.h for what a backend is. Only one
|
||||
# exists today: SpnavBackend, Linux, via libspnav. A Windows/macOS backend
|
||||
# would need 3Dconnexion's proprietary 3DxWare SDK and is not implemented
|
||||
# (see discussion #599's own phase split); until it is, turning this option
|
||||
# on there downgrades cleanly with a warning rather than failing configure.
|
||||
# When it is on, QET_SPACEMOUSE_BACKEND picks how the device is read -- see
|
||||
# sources/spacemouse/spacemousebackend.h for what a backend is:
|
||||
# spnav Linux, through the spacenavd daemon (libspnav)
|
||||
# hid any platform, directly over USB (hidapi), no 3Dconnexion driver
|
||||
# auto spnav on Linux when libspnav is found, hid otherwise
|
||||
# On macOS the 3DxWare backend is added to whichever of these is chosen.
|
||||
# A backend whose library is not found downgrades the option to off with a
|
||||
# warning, rather than failing configure for an opt-in feature.
|
||||
set(QET_SPACEMOUSE_BACKEND "auto" CACHE STRING "3D mouse backend: auto, spnav or hid")
|
||||
set_property(CACHE QET_SPACEMOUSE_BACKEND PROPERTY STRINGS auto spnav hid)
|
||||
|
||||
set(QET_SPACEMOUSE_ENABLED FALSE)
|
||||
set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED FALSE)
|
||||
set(QET_SPACEMOUSE_BACKEND_HID_ENABLED FALSE)
|
||||
set(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED FALSE)
|
||||
|
||||
if(QET_ENABLE_SPACEMOUSE)
|
||||
if(UNIX AND NOT APPLE)
|
||||
# libspnav is looked for via its pkg-config file (present on every
|
||||
# distro package of it this was checked against: Debian/Ubuntu's
|
||||
# libspnav-dev ships /usr/share/pkgconfig/spnav.pc). Not found does
|
||||
# not hard-fail the whole configure -- it downgrades the option back
|
||||
# to off with a clear message, so a developer who doesn't have the
|
||||
# library installed still gets a normal build instead of a configure
|
||||
# error for an opt-in feature they didn't ask to block on.
|
||||
find_package(PkgConfig)
|
||||
if(PkgConfig_FOUND)
|
||||
pkg_check_modules(SPNAV IMPORTED_TARGET spnav)
|
||||
endif()
|
||||
find_package(PkgConfig)
|
||||
|
||||
set(_qet_spacemouse_try_spnav FALSE)
|
||||
set(_qet_spacemouse_try_hid FALSE)
|
||||
if(QET_SPACEMOUSE_BACKEND STREQUAL "spnav")
|
||||
set(_qet_spacemouse_try_spnav TRUE)
|
||||
elseif(QET_SPACEMOUSE_BACKEND STREQUAL "hid")
|
||||
set(_qet_spacemouse_try_hid TRUE)
|
||||
else()
|
||||
if(UNIX AND NOT APPLE)
|
||||
set(_qet_spacemouse_try_spnav TRUE)
|
||||
endif()
|
||||
set(_qet_spacemouse_try_hid TRUE)
|
||||
endif()
|
||||
|
||||
# libspnav: Debian/Ubuntu's libspnav-dev ships spnav.pc.
|
||||
if(_qet_spacemouse_try_spnav AND PkgConfig_FOUND)
|
||||
pkg_check_modules(SPNAV IMPORTED_TARGET spnav)
|
||||
if(SPNAV_FOUND)
|
||||
set(QET_SPACEMOUSE_ENABLED TRUE)
|
||||
set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED TRUE)
|
||||
add_definitions(-DQET_SPACEMOUSE_SUPPORT)
|
||||
add_definitions(-DQET_SPACEMOUSE_BACKEND_SPNAV)
|
||||
message("QET_ENABLE_SPACEMOUSE ON (backend: libspnav ${SPNAV_VERSION})")
|
||||
else()
|
||||
message(WARNING "QET_ENABLE_SPACEMOUSE is ON but libspnav was not found via pkg-config "
|
||||
"(install libspnav-dev, or the equivalent for your distribution) -- "
|
||||
"building WITHOUT 3D mouse support.")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# hidapi: hidapi-hidraw.pc on Linux (libhidapi-dev), hidapi.pc from
|
||||
# MSYS2 (mingw-w64-ucrt-x86_64-hidapi) and Homebrew (hidapi).
|
||||
if(NOT QET_SPACEMOUSE_ENABLED AND _qet_spacemouse_try_hid AND PkgConfig_FOUND)
|
||||
pkg_search_module(HIDAPI IMPORTED_TARGET hidapi-hidraw hidapi)
|
||||
if(HIDAPI_FOUND)
|
||||
set(QET_SPACEMOUSE_ENABLED TRUE)
|
||||
set(QET_SPACEMOUSE_BACKEND_HID_ENABLED TRUE)
|
||||
add_definitions(-DQET_SPACEMOUSE_BACKEND_HID)
|
||||
message("QET_ENABLE_SPACEMOUSE ON (backend: hidapi ${HIDAPI_VERSION})")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# macOS: with 3DxWare installed the device can only be read through
|
||||
# 3DxWare, so its backend comes too, whichever backend was asked for.
|
||||
# It loads 3DxWare's library at run time and needs nothing to build.
|
||||
if(APPLE)
|
||||
set(QET_SPACEMOUSE_ENABLED TRUE)
|
||||
set(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED TRUE)
|
||||
add_definitions(-DQET_SPACEMOUSE_BACKEND_CONNEXION)
|
||||
message("QET_ENABLE_SPACEMOUSE ON (backend: 3DxWare when installed)")
|
||||
endif()
|
||||
|
||||
if(QET_SPACEMOUSE_ENABLED)
|
||||
add_definitions(-DQET_SPACEMOUSE_SUPPORT)
|
||||
else()
|
||||
message(WARNING "QET_ENABLE_SPACEMOUSE is ON but no 3D mouse backend is implemented yet "
|
||||
"for this platform (only Linux/libspnav exists today -- see "
|
||||
"https://github.com/qelectrotech/qelectrotech-source-mirror/discussions/599) "
|
||||
"-- building WITHOUT 3D mouse support.")
|
||||
message(WARNING "QET_ENABLE_SPACEMOUSE is ON but no library was found for the "
|
||||
"'${QET_SPACEMOUSE_BACKEND}' backend (libspnav-dev for spnav, "
|
||||
"hidapi for hid, via pkg-config) -- building WITHOUT 3D mouse support.")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@@ -168,6 +168,9 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/borderproperties.h
|
||||
${QET_DIR}/sources/bordertitleblock.cpp
|
||||
${QET_DIR}/sources/bordertitleblock.h
|
||||
${QET_DIR}/sources/bordercelllabels.h
|
||||
${QET_DIR}/sources/cellruler.cpp
|
||||
${QET_DIR}/sources/cellruler.h
|
||||
${QET_DIR}/sources/conductorautonumerotation.cpp
|
||||
${QET_DIR}/sources/conductorautonumerotation.h
|
||||
${QET_DIR}/sources/conductornumexport.cpp
|
||||
@@ -189,6 +192,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/configdialog.h
|
||||
${QET_DIR}/sources/createdxf.cpp
|
||||
${QET_DIR}/sources/createdxf.h
|
||||
${QET_DIR}/sources/dxfexport.cpp
|
||||
${QET_DIR}/sources/dxfexport.h
|
||||
${QET_DIR}/sources/dxfpaintdevice.cpp
|
||||
${QET_DIR}/sources/dxfpaintdevice.h
|
||||
${QET_DIR}/sources/diagramcommands.cpp
|
||||
@@ -229,6 +234,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/exportpropertieswidget.h
|
||||
${QET_DIR}/sources/genericpanel.cpp
|
||||
${QET_DIR}/sources/genericpanel.h
|
||||
${QET_DIR}/sources/itemgroups.cpp
|
||||
${QET_DIR}/sources/itemgroups.h
|
||||
${QET_DIR}/sources/lastusedstyle.cpp
|
||||
${QET_DIR}/sources/lastusedstyle.h
|
||||
${QET_DIR}/sources/machine_info.cpp
|
||||
@@ -282,6 +289,14 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/recentfiles.h
|
||||
${QET_DIR}/sources/shortcutmanager.cpp
|
||||
${QET_DIR}/sources/shortcutmanager.h
|
||||
${QET_DIR}/sources/shortcutbarsettings.cpp
|
||||
${QET_DIR}/sources/shortcutbarsettings.h
|
||||
${QET_DIR}/sources/diagramcontexttoolbar.cpp
|
||||
${QET_DIR}/sources/diagramcontexttoolbar.h
|
||||
${QET_DIR}/sources/diagramgestureoverlay.cpp
|
||||
${QET_DIR}/sources/diagramgestureoverlay.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
|
||||
@@ -314,6 +329,7 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/autoNum/ui/selectautonumw.cpp
|
||||
${QET_DIR}/sources/autoNum/ui/selectautonumw.h
|
||||
|
||||
${QET_DIR}/sources/dataBase/legacyelementtypes.h
|
||||
${QET_DIR}/sources/dataBase/projectdatabase.cpp
|
||||
${QET_DIR}/sources/dataBase/projectdatabase.h
|
||||
${QET_DIR}/sources/dataBase/sqlreadonly.cpp
|
||||
@@ -448,6 +464,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/ElementsCollection/elementpreviewdelegate.h
|
||||
${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.cpp
|
||||
${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.h
|
||||
${QET_DIR}/sources/ElementsCollection/elementpickerpopup.cpp
|
||||
${QET_DIR}/sources/ElementsCollection/elementpickerpopup.h
|
||||
${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.cpp
|
||||
${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.h
|
||||
${QET_DIR}/sources/ElementsCollection/elementslocation.cpp
|
||||
@@ -703,6 +721,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/ui/borderpropertieswidget.h
|
||||
${QET_DIR}/sources/ui/compositetexteditdialog.cpp
|
||||
${QET_DIR}/sources/ui/compositetexteditdialog.h
|
||||
${QET_DIR}/sources/ui/conductorpropertieseditorwidget.cpp
|
||||
${QET_DIR}/sources/ui/conductorpropertieseditorwidget.h
|
||||
${QET_DIR}/sources/ui/contactgroupselectiondialog.cpp
|
||||
${QET_DIR}/sources/ui/contactgroupselectiondialog.h
|
||||
${QET_DIR}/sources/ui/conductorpropertiesdialog.cpp
|
||||
@@ -794,6 +814,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/ui/configpage/guidespropertieswidget.h
|
||||
${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.cpp
|
||||
${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h
|
||||
${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.cpp
|
||||
${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.h
|
||||
|
||||
${QET_DIR}/sources/undocommand/addelementtextcommand.cpp
|
||||
${QET_DIR}/sources/undocommand/addelementtextcommand.h
|
||||
@@ -819,6 +841,10 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/undocommand/removediagramcommand.h
|
||||
${QET_DIR}/sources/undocommand/setautonumcontextcommand.cpp
|
||||
${QET_DIR}/sources/undocommand/setautonumcontextcommand.h
|
||||
${QET_DIR}/sources/undocommand/groupitemscommand.cpp
|
||||
${QET_DIR}/sources/undocommand/groupitemscommand.h
|
||||
${QET_DIR}/sources/undocommand/alignselectioncommand.cpp
|
||||
${QET_DIR}/sources/undocommand/alignselectioncommand.h
|
||||
${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp
|
||||
${QET_DIR}/sources/undocommand/rotateselectioncommand.h
|
||||
${QET_DIR}/sources/undocommand/promoteshapecommand.cpp
|
||||
@@ -882,6 +908,8 @@ if(QET_SPACEMOUSE_ENABLED)
|
||||
${QET_DIR}/sources/spacemouse/spacemousebuttonmap.h
|
||||
${QET_DIR}/sources/spacemouse/spacemouselistener.cpp
|
||||
${QET_DIR}/sources/spacemouse/spacemouselistener.h
|
||||
${QET_DIR}/sources/spacemouse/spacemousemotion.cpp
|
||||
${QET_DIR}/sources/spacemouse/spacemousemotion.h
|
||||
${QET_DIR}/sources/ui/configpage/spacemouseconfigpage.cpp
|
||||
${QET_DIR}/sources/ui/configpage/spacemouseconfigpage.h
|
||||
)
|
||||
@@ -894,6 +922,22 @@ if(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED)
|
||||
)
|
||||
endif()
|
||||
|
||||
if(QET_SPACEMOUSE_BACKEND_HID_ENABLED)
|
||||
list(APPEND QET_SRC_FILES
|
||||
${QET_DIR}/sources/spacemouse/hidbackend.cpp
|
||||
${QET_DIR}/sources/spacemouse/hidbackend.h
|
||||
${QET_DIR}/sources/spacemouse/spacemousehid.cpp
|
||||
${QET_DIR}/sources/spacemouse/spacemousehid.h
|
||||
)
|
||||
endif()
|
||||
|
||||
if(QET_SPACEMOUSE_BACKEND_CONNEXION_ENABLED)
|
||||
list(APPEND QET_SRC_FILES
|
||||
${QET_DIR}/sources/spacemouse/connexionbackend.cpp
|
||||
${QET_DIR}/sources/spacemouse/connexionbackend.h
|
||||
)
|
||||
endif()
|
||||
|
||||
set(TS_FILES
|
||||
${QET_DIR}/lang/qet_ar.ts
|
||||
${QET_DIR}/lang/qet_ca.ts
|
||||
|
||||
+2968
-1648
File diff suppressed because it is too large
Load Diff
+3200
-1489
File diff suppressed because it is too large
Load Diff
+2954
-1527
File diff suppressed because it is too large
Load Diff
+2915
-1522
File diff suppressed because it is too large
Load Diff
+2801
-1380
File diff suppressed because it is too large
Load Diff
+2915
-1522
File diff suppressed because it is too large
Load Diff
+3097
-1666
File diff suppressed because it is too large
Load Diff
+2896
-1571
File diff suppressed because it is too large
Load Diff
+2813
-1735
File diff suppressed because it is too large
Load Diff
+2860
-1714
File diff suppressed because it is too large
Load Diff
+2867
-1706
File diff suppressed because it is too large
Load Diff
+2773
-1385
File diff suppressed because it is too large
Load Diff
+2903
-1566
File diff suppressed because it is too large
Load Diff
+2873
-1584
File diff suppressed because it is too large
Load Diff
+2900
-1529
File diff suppressed because it is too large
Load Diff
+2879
-1562
File diff suppressed because it is too large
Load Diff
+2900
-1579
File diff suppressed because it is too large
Load Diff
+2926
-1525
File diff suppressed because it is too large
Load Diff
+2798
-1389
File diff suppressed because it is too large
Load Diff
+2777
-1699
File diff suppressed because it is too large
Load Diff
+3228
-1489
File diff suppressed because it is too large
Load Diff
+2842
-1685
File diff suppressed because it is too large
Load Diff
+2799
-1386
File diff suppressed because it is too large
Load Diff
+2878
-1678
File diff suppressed because it is too large
Load Diff
+2862
-1766
File diff suppressed because it is too large
Load Diff
+2943
-1526
File diff suppressed because it is too large
Load Diff
+2838
-1742
File diff suppressed because it is too large
Load Diff
+2859
-1745
File diff suppressed because it is too large
Load Diff
+2838
-1742
File diff suppressed because it is too large
Load Diff
+2927
-1526
File diff suppressed because it is too large
Load Diff
+2897
-1527
File diff suppressed because it is too large
Load Diff
+2943
-1526
File diff suppressed because it is too large
Load Diff
+2773
-1385
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -62,6 +62,6 @@
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.3.0</string>
|
||||
<string>14.0.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -99,11 +99,14 @@ fi
|
||||
|
||||
cmake -S . -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \
|
||||
-DQT_VERSION_MAJOR=$QT_MAJOR \
|
||||
-DBUILD_WITH_KF=$BUILD_WITH_KF \
|
||||
-DBUILD_KF=OFF \
|
||||
-DQET_EXPORT_PROJECT_DB=ON \
|
||||
-DPACKAGE_TESTS=OFF
|
||||
-DPACKAGE_TESTS=OFF \
|
||||
-DQET_ENABLE_SPACEMOUSE=ON \
|
||||
-DQET_SPACEMOUSE_BACKEND=hid
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: cmake configure failed."
|
||||
@@ -256,6 +259,7 @@ echo "Install Info.plist and app icon:"
|
||||
cp -R ${current_dir}/misc/Info.plist $BUNDLE/Contents/
|
||||
cp -R ${current_dir}/ico/mac_icon/*.icns $BUNDLE/Contents/Resources/
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION r$HEAD" "$BUNDLE/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion 14.0.0" "$BUNDLE/Contents/Info.plist"
|
||||
|
||||
### add missing files ###############################################
|
||||
echo
|
||||
@@ -396,10 +400,12 @@ done
|
||||
|
||||
echo "-- Signing main executable..."
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
|
||||
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
|
||||
"$BUNDLE/Contents/MacOS/$APPNAME"
|
||||
|
||||
echo "-- Signing bundle..."
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime "$BUNDLE"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
|
||||
--entitlements "${current_dir}/misc/qelectrotech.entitlements" "$BUNDLE"
|
||||
|
||||
echo
|
||||
echo "Verifying bundle signature..."
|
||||
@@ -500,8 +506,10 @@ find "$MOUNT_POINT/$BUNDLE/Contents/PlugIns" \( -name "*.dylib" -o -name "*.so"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime "$lib"
|
||||
done
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
|
||||
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
|
||||
"$MOUNT_POINT/$BUNDLE/Contents/MacOS/$APPNAME"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options=runtime \
|
||||
--entitlements "${current_dir}/misc/qelectrotech.entitlements" \
|
||||
"$MOUNT_POINT/$BUNDLE"
|
||||
|
||||
echo "Verifying bundle signature inside DMG..."
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- 3D mouse with 3DxWare installed: lets QElectroTech load
|
||||
3Dconnexion's client library, which 3Dconnexion signs, at run time
|
||||
(sources/spacemouse/connexionbackend.h). The hardened runtime
|
||||
refuses it otherwise, and the 3D mouse does nothing. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+14
-6
@@ -33,7 +33,7 @@ here read the model.
|
||||
| `qet_diff` | **what an edit actually changed** — element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips |
|
||||
| `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute |
|
||||
| `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts |
|
||||
| `qet_export` | run a headless export (pdf, png, svg, bom, cables, wires, wiring, nets, links, info) |
|
||||
| `qet_export` | run a headless export (pdf, png, svg, dxf, bom, cables, wires, wiring, nets, links, info) |
|
||||
| `qet_edit` | **change a project** — place, move, rotate, label, wire, number, cross-reference, add text, shapes and images, restyle a symbol's text fields, delete; then diff the result |
|
||||
| `qet_element_build` | **author a `.elmt`** — draw a new symbol, with terminals to wire it by |
|
||||
| `qet_project_new` | **start from nothing** — an empty project with a title and folios |
|
||||
@@ -271,6 +271,13 @@ Python, plus the hang guard on `addConductor` and the database refresh in
|
||||
QElectroTech applies to its own custom-query box, not one invented here.
|
||||
An empty result and a failed query are told apart: `row_count` 0 with no
|
||||
`error` means nothing matched, and a typo'd column name says so.
|
||||
- **Texts, shapes and images are in the database too**, one row each in
|
||||
`drawing_item_view` (`uuid`, `kind`, `folio`, position and size, and a
|
||||
`description`: the shape type or the text). The uuid is the one saved in
|
||||
the file, and every `qet_edit` op that takes a text, shape or image
|
||||
`index` also takes that uuid, which does not shift the way an index
|
||||
does. `qet_element_build` gives every part of a symbol a uuid as well,
|
||||
returned in `part_uuids`; `qet_element_info` lists them in `part_list`.
|
||||
- **`qet_export` isolates its launch.** SingleApplication keys its socket
|
||||
on `applicationFilePath()`, so a second launch of the same binary path
|
||||
forwards its request to an already-running instance and returns *that*
|
||||
@@ -295,11 +302,12 @@ Python, plus the hang guard on `addConductor` and the database refresh in
|
||||
no-op edit. Where an element predates persisted uuids the end cannot be
|
||||
resolved and keeps a `#`-marked unstable key; the diff then reports
|
||||
`unstable_keys` and says so rather than pretending to be comparable.
|
||||
- **Texts, shapes and images have no uuid**, so `qet_diff` cannot say "the same
|
||||
text, edited": an edited text reads as the old one removed and a new one
|
||||
added, both shown. Shapes and images are keyed by position, so a restyle
|
||||
or rescale *is* reported as a change to that item, but a move reads as a
|
||||
removal plus an addition. The folio `version` attribute is left out of the
|
||||
- **Texts, shapes and images are keyed by uuid** when every one on both sides
|
||||
has one, so an edit or a move reads as a change to that item. A file saved
|
||||
before they carried a uuid has none; for such a pair (including a legacy
|
||||
file against its first re-save) that kind falls back to position, where an
|
||||
edited text reads as removed plus added and a move as a removal plus an
|
||||
addition. Each section says which it used in `keyed_by`. The folio `version` attribute is left out of the
|
||||
comparison on purpose: QElectroTech rewrites it on every save, and
|
||||
including it made every folio of any re-saved project look edited.
|
||||
- **Elements** written before persisted uuids fall back to a positional key,
|
||||
|
||||
+139
-54
@@ -69,6 +69,7 @@ EXPORT_FORMATS = {
|
||||
"pdf": "--export-pdf",
|
||||
"png": "--export-png",
|
||||
"svg": "--export-svg",
|
||||
"dxf": "--export-dxf",
|
||||
"bom": "--export-bom",
|
||||
"cables": "--export-cables",
|
||||
"wires": "--export-wires",
|
||||
@@ -314,32 +315,42 @@ def _plain_text(html: str) -> str:
|
||||
def _extras(root: ET.Element) -> dict:
|
||||
"""Everything a folio holds besides elements and conductors.
|
||||
|
||||
Independent texts, shapes and images have no uuid, so nothing here can
|
||||
say "this is the same text, edited". A change to one therefore reads as
|
||||
the old one removed and a new one added, with both shown -- accurate,
|
||||
if less tidy than a move. Position is the identity they have.
|
||||
Independent texts, shapes and images are records, not a keyed dict:
|
||||
files written since they carry a uuid are compared by it (see
|
||||
_diff_items), so an edited or moved text reads as that text, changed.
|
||||
Older files have only position to go on, and there a change reads as
|
||||
the old one removed and a new one added, with both shown.
|
||||
"""
|
||||
folios, texts, shapes, images = {}, {}, {}, {}
|
||||
folios, texts, shapes, images = {}, [], [], []
|
||||
|
||||
def record(el, label, value):
|
||||
return {"uuid": el.get("uuid", ""), "key": tuple(label.values()),
|
||||
"label": label, "value": value}
|
||||
|
||||
for n, d in _folios(root):
|
||||
folios[n] = {f: d.get(f, "") for f in _FOLIO_FIELDS}
|
||||
for t in d.iter("input"):
|
||||
key = (n, t.get("x", ""), t.get("y", ""), _plain_text(t.get("text", "")))
|
||||
texts[key] = {"rotation": t.get("rotation", "0"),
|
||||
"font": t.get("font", ""), "color": t.get("color", "")}
|
||||
texts.append(record(
|
||||
t, {"folio": n, "x": t.get("x", ""), "y": t.get("y", ""),
|
||||
"text": _plain_text(t.get("text", ""))},
|
||||
{"rotation": t.get("rotation", "0"),
|
||||
"font": t.get("font", ""), "color": t.get("color", "")}))
|
||||
for sh in d.iter("shape"):
|
||||
pen, brush = sh.find("pen"), sh.find("brush")
|
||||
key = (n, sh.get("type", ""), sh.get("x1", ""), sh.get("y1", ""),
|
||||
sh.get("x2", ""), sh.get("y2", ""))
|
||||
shapes[key] = {
|
||||
"line_color": pen.get("color", "") if pen is not None else "",
|
||||
"line_style": pen.get("style", "") if pen is not None else "",
|
||||
"line_width": pen.get("widthF", "") if pen is not None else "",
|
||||
"fill": (brush.get("color", "") if brush is not None and
|
||||
brush.get("style", "") != "NoBrush" else "none"),
|
||||
"rotation": sh.get("rotation", "0")}
|
||||
shapes.append(record(
|
||||
sh, {"folio": n, "type": sh.get("type", ""),
|
||||
"from": [sh.get("x1", ""), sh.get("y1", "")],
|
||||
"to": [sh.get("x2", ""), sh.get("y2", "")]},
|
||||
{"line_color": pen.get("color", "") if pen is not None else "",
|
||||
"line_style": pen.get("style", "") if pen is not None else "",
|
||||
"line_width": pen.get("widthF", "") if pen is not None else "",
|
||||
"fill": (brush.get("color", "") if brush is not None and
|
||||
brush.get("style", "") != "NoBrush" else "none"),
|
||||
"rotation": sh.get("rotation", "0")}))
|
||||
for im in d.iter("image"):
|
||||
key = (n, im.get("x", ""), im.get("y", ""))
|
||||
images[key] = {"scale": im.get("size", ""), "rotation": im.get("rotation", "")}
|
||||
images.append(record(
|
||||
im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")},
|
||||
{"scale": im.get("size", ""), "rotation": im.get("rotation", "")}))
|
||||
|
||||
element_texts = {}
|
||||
for n, d in _folios(root):
|
||||
@@ -388,6 +399,32 @@ def _diff_keyed(a: dict, b: dict, label) -> dict:
|
||||
"changed": changed[:50]}
|
||||
|
||||
|
||||
def _diff_items(a: list, b: list) -> dict:
|
||||
"""_diff_keyed() over _extras() records of one kind.
|
||||
|
||||
Keyed on uuid only when every item on both sides has one. A file saved
|
||||
before these items carried a uuid has none, and the first save by a
|
||||
current QElectroTech gives them one, so a mixed pair falls back to
|
||||
position for the whole kind rather than reading as everything removed
|
||||
and re-added. On uuid, position is part of what is compared, so a move
|
||||
is a change to that item.
|
||||
"""
|
||||
by_uuid = all(r["uuid"] for r in a + b)
|
||||
|
||||
def key(r):
|
||||
return r["uuid"] if by_uuid else str(r["key"])
|
||||
|
||||
def keyed(rs):
|
||||
return {key(r): ({**r["label"], **r["value"]} if by_uuid else r["value"])
|
||||
for r in rs}
|
||||
|
||||
labels = {key(r): ({**r["label"], "uuid": r["uuid"]} if by_uuid else r["label"])
|
||||
for r in a + b}
|
||||
out = _diff_keyed(keyed(a), keyed(b), lambda k: labels[k])
|
||||
out["keyed_by"] = "uuid" if by_uuid else "position"
|
||||
return out
|
||||
|
||||
|
||||
def _diff_extras(before: ET.Element, after: ET.Element) -> dict:
|
||||
a, b = _extras(before), _extras(after)
|
||||
out = {}
|
||||
@@ -407,13 +444,9 @@ def _diff_extras(before: ET.Element, after: ET.Element) -> dict:
|
||||
if len(a["folios"]) != len(b["folios"]) and folio_changes:
|
||||
out["folios"]["note"] = ("the folio count changed, so changes listed here may be "
|
||||
"later folios shifting position rather than edits")
|
||||
out["texts"] = _diff_keyed(a["texts"], b["texts"],
|
||||
lambda k: {"folio": k[0], "x": k[1], "y": k[2], "text": k[3]})
|
||||
out["shapes"] = _diff_keyed(a["shapes"], b["shapes"],
|
||||
lambda k: {"folio": k[0], "type": k[1],
|
||||
"from": [k[2], k[3]], "to": [k[4], k[5]]})
|
||||
out["images"] = _diff_keyed(a["images"], b["images"],
|
||||
lambda k: {"folio": k[0], "x": k[1], "y": k[2]})
|
||||
out["texts"] = _diff_items(a["texts"], b["texts"])
|
||||
out["shapes"] = _diff_items(a["shapes"], b["shapes"])
|
||||
out["images"] = _diff_items(a["images"], b["images"])
|
||||
# Keyed by element, what the field is bound to, and the nth such field.
|
||||
# A field's own text is also compared ("shows"), so relabelling an
|
||||
# element shows up here as well as in the element's information.
|
||||
@@ -594,14 +627,20 @@ def tool_element_info(path: str) -> dict:
|
||||
ordered, ambiguous = _terminals_in_index_order(list(root.iter("terminal")))
|
||||
terminals = [{"index": i, "x": t.get("x"), "y": t.get("y"),
|
||||
"orientation": t.get("orientation"),
|
||||
"name": t.get("name", ""), "type": t.get("type", "")}
|
||||
"name": t.get("name", ""), "type": t.get("type", ""),
|
||||
"uuid": t.get("uuid", "")}
|
||||
for i, t in enumerate(ordered)]
|
||||
info_fields = sorted({(i.text or "").strip()
|
||||
for i in root.iter("info_name") if (i.text or "").strip()})
|
||||
parts = {}
|
||||
part_list = []
|
||||
desc = root.find("description")
|
||||
for child in (desc if desc is not None else []):
|
||||
parts[child.tag] = parts.get(child.tag, 0) + 1
|
||||
if child.tag != "terminal":
|
||||
# uuid is empty for a part saved before parts carried one; the
|
||||
# element editor gives it one on the next save.
|
||||
part_list.append({"type": child.tag, "uuid": child.get("uuid", "")})
|
||||
return {
|
||||
"file": str(Path(path).expanduser()),
|
||||
"type": root.get("type", ""), "link_type": root.get("link_type", ""),
|
||||
@@ -614,6 +653,7 @@ def tool_element_info(path: str) -> dict:
|
||||
"index is undefined" if ambiguous else ""),
|
||||
"info_fields": info_fields,
|
||||
"parts": parts,
|
||||
"part_list": part_list,
|
||||
}
|
||||
|
||||
|
||||
@@ -851,11 +891,15 @@ def _validate_part(index: int, part) -> str:
|
||||
for key in spec["required"]:
|
||||
if key not in part:
|
||||
raise ValueError(f"part {index} ({kind}) is missing {key!r}")
|
||||
allowed = set(spec["required"]) | set(spec["optional"]) | {"type", "style", "antialias"}
|
||||
allowed = set(spec["required"]) | set(spec["optional"]) | {"type", "style", "antialias", "uuid"}
|
||||
for key in part:
|
||||
if key not in allowed:
|
||||
raise ValueError(f"part {index} ({kind}): unexpected {key!r}; "
|
||||
f"allowed: {', '.join(sorted(allowed))}")
|
||||
if "uuid" in part and not (isinstance(part["uuid"], str)
|
||||
and _UUID_RE.fullmatch(part["uuid"])):
|
||||
raise ValueError(f"part {index} ({kind}): uuid must look like "
|
||||
f"{{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}}, got {part['uuid']!r}")
|
||||
if kind == "polygon":
|
||||
pts = part["points"]
|
||||
if not isinstance(pts, list) or len(pts) < 2:
|
||||
@@ -866,9 +910,19 @@ def _validate_part(index: int, part) -> str:
|
||||
return kind
|
||||
|
||||
|
||||
def _part_element(part: dict) -> ET.Element:
|
||||
def _part_uuid(part: dict) -> str:
|
||||
"""The caller's uuid for this part, braced as QElectroTech writes it, or
|
||||
a new one. Every part carries one, as the element editor saves them."""
|
||||
given = part.get("uuid")
|
||||
if given:
|
||||
return given if given.startswith("{") else "{" + given + "}"
|
||||
return "{" + str(__import__("uuid").uuid4()) + "}"
|
||||
|
||||
|
||||
def _part_element(part: dict, uuid: str) -> ET.Element:
|
||||
kind = part["type"]
|
||||
node = ET.Element(kind)
|
||||
node.set("uuid", uuid)
|
||||
if kind == "polygon":
|
||||
for n, (px, py) in enumerate(part["points"], start=1):
|
||||
node.set(f"x{n}", _fmt(px))
|
||||
@@ -960,8 +1014,11 @@ def tool_element_build(output: str, names: dict, parts: list,
|
||||
ET.SubElement(kind, "kindInformation", {"name": key}).text = str(informations[key])
|
||||
ET.SubElement(root, "informations")
|
||||
description = ET.SubElement(root, "description")
|
||||
for part in parts:
|
||||
description.append(_part_element(part))
|
||||
part_uuids = [_part_uuid(part) for part in parts]
|
||||
if len(set(part_uuids)) != len(part_uuids):
|
||||
raise ValueError("two parts were given the same uuid")
|
||||
for part, part_uuid in zip(parts, part_uuids):
|
||||
description.append(_part_element(part, part_uuid))
|
||||
for t in terminals:
|
||||
attrs = {"x": _fmt(t["x"]), "y": _fmt(t["y"]),
|
||||
"orientation": t["orientation"],
|
||||
@@ -985,6 +1042,8 @@ def tool_element_build(output: str, names: dict, parts: list,
|
||||
# caller listed them in.
|
||||
"terminal_index_order": [t["name"] or f"({t['x']},{t['y']})"
|
||||
for t in check["terminals"]],
|
||||
# In the order the parts were given.
|
||||
"part_uuids": part_uuids,
|
||||
"verified": check}
|
||||
|
||||
|
||||
@@ -1034,8 +1093,9 @@ OPS = {
|
||||
("value", "str")]),
|
||||
"remove_plc_io": ("removePlcIO", [("folio", "folio"), ("element", "elmt"),
|
||||
("index", "folio")]),
|
||||
# Texts and shapes have no uuid; they are addressed by index into a
|
||||
# position-sorted listing, and add_text/add_shape return that index so
|
||||
# Texts, shapes and images are addressed by "index": their index in a
|
||||
# position-sorted listing, which add_text/add_shape return, or their
|
||||
# uuid, which does not shift when another one is added. So
|
||||
# it can be named as "$id". Indexes shift when one is added or deleted.
|
||||
"delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"),
|
||||
("terminal", "num")]),
|
||||
@@ -1091,38 +1151,38 @@ OPS = {
|
||||
("to_folio", "folio"), ("x", "num"), ("y", "num")]),
|
||||
"add_text": ("addText", [("folio", "folio"), ("text", "str"),
|
||||
("x", "num"), ("y", "num")]),
|
||||
"set_text": ("setTextContent", [("folio", "folio"), ("index", "folio"),
|
||||
"set_text": ("setTextContent", [("folio", "folio"), ("index", "text"),
|
||||
("text", "str")]),
|
||||
"set_text_color": ("setTextColor", [("folio", "folio"), ("index", "folio"),
|
||||
"set_text_color": ("setTextColor", [("folio", "folio"), ("index", "text"),
|
||||
("color", "str")]),
|
||||
"rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "folio"),
|
||||
"rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "text"),
|
||||
("angle", "num")]),
|
||||
"delete_text": ("deleteText", [("folio", "folio"), ("index", "folio")]),
|
||||
"delete_text": ("deleteText", [("folio", "folio"), ("index", "text")]),
|
||||
"add_shape": ("addShape", [("folio", "folio"), ("shape", "str"),
|
||||
("x1", "num"), ("y1", "num"),
|
||||
("x2", "num"), ("y2", "num")]),
|
||||
"set_shape": ("setShapeProperty", [("folio", "folio"), ("index", "folio"),
|
||||
"set_shape": ("setShapeProperty", [("folio", "folio"), ("index", "shape"),
|
||||
("property", "str"), ("value", "str")]),
|
||||
"add_image": ("addImage", [("folio", "folio"), ("file", "str"),
|
||||
("x", "num"), ("y", "num")]),
|
||||
"add_pdf_page": ("addPdfPage", [("folio", "folio"), ("file", "str"),
|
||||
("page", "num"), ("dpi", "num"),
|
||||
("x", "num"), ("y", "num")]),
|
||||
"scale_image": ("setImageScale", [("folio", "folio"), ("index", "folio"),
|
||||
"scale_image": ("setImageScale", [("folio", "folio"), ("index", "image"),
|
||||
("factor", "num")]),
|
||||
"rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "folio"),
|
||||
"rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "image"),
|
||||
("angle", "num")]),
|
||||
"delete_image": ("deleteImage", [("folio", "folio"), ("index", "folio")]),
|
||||
"delete_shape": ("deleteShape", [("folio", "folio"), ("index", "folio")]),
|
||||
"delete_image": ("deleteImage", [("folio", "folio"), ("index", "image")]),
|
||||
"delete_shape": ("deleteShape", [("folio", "folio"), ("index", "shape")]),
|
||||
"add_polygon": ("addPolygon", [("folio", "folio"), ("points", "points"),
|
||||
("closed", "bool")]),
|
||||
"set_shape_polygon": ("setShapePolygon", [("folio", "folio"), ("index", "folio"),
|
||||
"set_shape_polygon": ("setShapePolygon", [("folio", "folio"), ("index", "shape"),
|
||||
("points", "points")]),
|
||||
"add_path": ("addPath", [("folio", "folio"), ("nodes", "nodes"),
|
||||
("closed", "bool")]),
|
||||
"set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "folio"),
|
||||
"set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "shape"),
|
||||
("nodes", "nodes")]),
|
||||
"set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "folio"),
|
||||
"set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "shape"),
|
||||
("closed", "bool")]),
|
||||
"add_table": ("addTable", [("folio", "folio"), ("kind", "str"),
|
||||
("name", "str"), ("query", "str")]),
|
||||
@@ -1161,6 +1221,9 @@ _REQUIRED_METHODS = sorted({m for m, _ in OPS.values() if m} |
|
||||
|
||||
_MARKER = "QETEDIT "
|
||||
|
||||
_UUID_RE = re.compile(r"\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}?")
|
||||
|
||||
|
||||
def _js(value) -> str:
|
||||
"""A JSON literal is a JavaScript literal for every type used here."""
|
||||
@@ -1175,11 +1238,15 @@ def _build_script(operations: list, output: str) -> str:
|
||||
JavaScript exception.
|
||||
"""
|
||||
refs: set[str] = set()
|
||||
# Resolvers a uuid reference needs; required only when one is used, so
|
||||
# an index-only edit still runs on a build that predates them.
|
||||
uuid_methods: set[str] = set()
|
||||
folio_js = "0"
|
||||
lines = [
|
||||
"// generated by qet-mcp; do not edit",
|
||||
"var R = {};", # $name -> value from an earlier op
|
||||
"var missing = [];",
|
||||
f"var need = {_js(_REQUIRED_METHODS)};",
|
||||
"var need = @NEED@;",
|
||||
"for (var i = 0; i < need.length; i++) {",
|
||||
" if (typeof qet[need[i]] !== 'function') missing.push(need[i]);",
|
||||
"}",
|
||||
@@ -1228,6 +1295,18 @@ def _build_script(operations: list, output: str) -> str:
|
||||
raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of "
|
||||
f"integer indices, got {value!r}")
|
||||
return _js(value)
|
||||
if kind in ("text", "shape", "image"):
|
||||
# A uuid names the item for good; it is turned into the index
|
||||
# the call takes at run time, by the item's own folio.
|
||||
if isinstance(value, str) and _UUID_RE.fullmatch(value):
|
||||
method = {"text": "textIndex", "shape": "shapeIndex",
|
||||
"image": "imageIndex"}[kind]
|
||||
uuid_methods.add(method)
|
||||
return f"qet.{method}({folio_js}, {_js(value)})"
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"operation {op_index}: {key!r} must be a {kind} index, "
|
||||
f"its uuid, or a \"$name\" reference, got {value!r}")
|
||||
return _js(value)
|
||||
if kind == "folio":
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"operation {op_index}: {key!r} must be a folio index "
|
||||
@@ -1314,6 +1393,7 @@ def _build_script(operations: list, output: str) -> str:
|
||||
for key, kind in spec:
|
||||
if key not in op:
|
||||
raise ValueError(f"operation {i} ({name}) is missing {key!r}")
|
||||
folio_js = args[0] if args else "0"
|
||||
args.append(ref_or(op[key], kind, i, key))
|
||||
|
||||
ident = op.get("id")
|
||||
@@ -1359,7 +1439,8 @@ def _build_script(operations: list, output: str) -> str:
|
||||
lines.append(f" qet.log({_js(_MARKER)} + JSON.stringify("
|
||||
"{kind: 'save', result: saved, stopped_early: stop}));")
|
||||
lines.append("}")
|
||||
return "\n".join(lines) + "\n"
|
||||
need = _js(sorted(set(_REQUIRED_METHODS) | uuid_methods))
|
||||
return "\n".join(line.replace("@NEED@", need) for line in lines) + "\n"
|
||||
|
||||
|
||||
def _parse_script_output(text: str) -> dict:
|
||||
@@ -2106,7 +2187,7 @@ TOOLS = [
|
||||
},
|
||||
{
|
||||
"name": "qet_export",
|
||||
"description": "Run a QElectroTech export headlessly (pdf, png, svg, bom, "
|
||||
"description": "Run a QElectroTech export headlessly (pdf, png, svg, dxf, bom, "
|
||||
"cables, wires, wiring, nets, links, info). Launches the "
|
||||
"binary in an isolated sandbox so it cannot be captured by, "
|
||||
"or capture, a running QElectroTech.",
|
||||
@@ -2268,10 +2349,12 @@ TOOLS = [
|
||||
"that element; set_element_text takes " +
|
||||
", ".join(ELEMENT_TEXT_PROPERTIES) + ". A field bound with source "
|
||||
"\"info\" follows set_label/set_info. Indexes shift on delete. "
|
||||
"Texts and shapes have no uuid: add_text/add_shape return an "
|
||||
"index you can name as \"$id\", and the other text/shape ops "
|
||||
"take it as \"index\". Indexes shift when one is added or "
|
||||
"deleted. Shapes: " + ", ".join(SHAPES) + "; set_shape takes " + ", ".join(SHAPE_PROPERTIES) +
|
||||
"Texts, shapes and images: add_text/add_shape/add_image return an "
|
||||
"index you can name as \"$id\", and the other text/shape/image "
|
||||
"ops take it as \"index\". Indexes shift when one is added or "
|
||||
"deleted; \"index\" also accepts the item's uuid (from the "
|
||||
"project database's drawing_item_view, or a saved file), which "
|
||||
"does not. Shapes: " + ", ".join(SHAPES) + "; set_shape takes " + ", ".join(SHAPE_PROPERTIES) +
|
||||
" (fill accepts a colour or \"none\"). "
|
||||
"add_shape's own \"polygon\" is always the degenerate two-point "
|
||||
"form (it shares add_shape's p1/p2 shape); add_polygon takes as "
|
||||
@@ -2291,7 +2374,7 @@ TOOLS = [
|
||||
"shapes are listed by current on-folio position, so changing one "
|
||||
"shape's points can reorder it relative to the others -- re-list "
|
||||
"before addressing one by index again if more than one is being "
|
||||
"edited in the same run. "
|
||||
"edited in the same run, or address it by uuid. "
|
||||
"Tables: add_table places a BOM/nomenclature or summary table "
|
||||
"(kind is \"nomenclature\" or \"summary\") built from a query "
|
||||
"against a project database view -- run qet.query() (the "
|
||||
@@ -2512,7 +2595,9 @@ TOOLS = [
|
||||
'line x1,y1,x2,y2; rect/ellipse/arc x,y,width,height '
|
||||
"(arc also start,angle); circle x,y,diameter; polygon "
|
||||
'points:[[x,y],...] and closed; text x,y,text with optional '
|
||||
"size, rotation, color. Any part may carry style and antialias. "
|
||||
"size, rotation, color. Any part may carry style and antialias, "
|
||||
"and a uuid to name it by; parts without one get a new uuid, "
|
||||
"returned in part_uuids. "
|
||||
"Coordinates are the element's own, with (0,0) at its origin.",
|
||||
"items": {"type": "object"},
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ needs_examples = unittest.skipUnless(have_binary and have_examples,
|
||||
COIL = "common://10_electric/10_allpole/310_relays_contactors_contacts/01_coils/bobine_ka_a_remanence.elmt"
|
||||
SLAVE = ("common://10_electric/10_allpole/310_relays_contactors_contacts/"
|
||||
"02_contacts_cross_referencing/15_protection_contacts/contact_relais_nf_esclave.elmt")
|
||||
SHARED_UUID = "{11111111-2222-3333-4444-555555555555}"
|
||||
# The shipped "going/coming arrow" pair -- a folio-jump link, next_report on
|
||||
# one folio linked to previous_report on the next, one terminal each. Same
|
||||
# category of element as issue #974's custom "naechste_folie_rechts.elmt" /
|
||||
@@ -328,6 +329,29 @@ class EditValidation(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "list of strings"):
|
||||
self.build([{"op": "add_autonum", "kind": "conductor", "name": "a", "parts": "string:W"}])
|
||||
|
||||
def test_a_uuid_index_is_resolved_at_run_time(self):
|
||||
u = "{11111111-1111-4111-8111-111111111111}"
|
||||
script = self.build([{"op": "set_shape", "folio": 2, "index": u,
|
||||
"property": "fill", "value": "none"},
|
||||
{"op": "delete_text", "folio": 1, "index": u},
|
||||
{"op": "delete_image", "folio": 0, "index": u}])
|
||||
self.assertIn(f'qet.setShapeProperty(2, qet.shapeIndex(2, "{u}"), ', script)
|
||||
self.assertIn(f'qet.deleteText(1, qet.textIndex(1, "{u}"))', script)
|
||||
self.assertIn(f'qet.deleteImage(0, qet.imageIndex(0, "{u}"))', script)
|
||||
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
|
||||
self.assertTrue({"shapeIndex", "textIndex", "imageIndex"} <= set(need))
|
||||
|
||||
def test_an_index_only_edit_does_not_need_the_uuid_resolvers(self):
|
||||
script = self.build([{"op": "delete_shape", "folio": 0, "index": 3}])
|
||||
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
|
||||
self.assertFalse({"shapeIndex", "textIndex", "imageIndex"} & set(need))
|
||||
|
||||
def test_a_string_index_that_is_not_a_uuid_is_refused(self):
|
||||
for bad in ("3", "{nope}", "11111111-1111"):
|
||||
with self.subTest(index=bad):
|
||||
with self.assertRaises(ValueError):
|
||||
self.build([{"op": "delete_shape", "folio": 0, "index": bad}])
|
||||
|
||||
def test_indexed_references_and_element_lists(self):
|
||||
script = self.build([
|
||||
{"op": "add_folio", "id": "f"},
|
||||
@@ -536,6 +560,30 @@ class ElementBuild(unittest.TestCase):
|
||||
{"x": 0, "y": -20, "orientation": "n", "name": "high"}])
|
||||
self.assertEqual(r["terminal_index_order"], ["high", "low"])
|
||||
|
||||
def test_every_part_gets_its_own_uuid(self):
|
||||
parts = [{"type": "line", "x1": 0, "y1": 0, "x2": 10, "y2": 0},
|
||||
{"type": "rect", "x": 0, "y": 0, "width": 5, "height": 5},
|
||||
{"type": "text", "x": 0, "y": 0, "text": "K"}]
|
||||
r = self.build(parts=parts)
|
||||
desc = ET.parse(self.out).getroot().find("description")
|
||||
written = [e.get("uuid") for e in desc if e.tag != "terminal"]
|
||||
self.assertEqual(written, r["part_uuids"])
|
||||
self.assertEqual(len(set(written)), 3)
|
||||
self.assertEqual([p["uuid"] for p in r["verified"]["part_list"]], written)
|
||||
|
||||
def test_a_given_part_uuid_is_kept_and_braced(self):
|
||||
r = self.build(parts=[{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0,
|
||||
"uuid": "11111111-1111-4111-8111-111111111111"}])
|
||||
self.assertEqual(r["part_uuids"], ["{11111111-1111-4111-8111-111111111111}"])
|
||||
|
||||
def test_bad_or_repeated_part_uuids_are_refused(self):
|
||||
line = {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}
|
||||
u = "{11111111-1111-4111-8111-111111111111}"
|
||||
for parts in ([{**line, "uuid": "nope"}], [{**line, "uuid": u}, {**line, "uuid": u}]):
|
||||
with self.subTest(parts=parts):
|
||||
with self.assertRaises(ValueError):
|
||||
self.build(parts=parts)
|
||||
|
||||
def test_output_reads_back(self):
|
||||
r = self.build(names={"en": "Coil", "fr": "Bobine"})
|
||||
self.assertEqual(r["verified"]["names"], {"en": "Coil", "fr": "Bobine"})
|
||||
@@ -632,14 +680,17 @@ class Diff(unittest.TestCase):
|
||||
|
||||
def project(self, name, *, texts=(), shapes=(), images=(), author="", version="1",
|
||||
strips=(), conductors=()):
|
||||
inputs = "".join(f'<input x="{x}" y="{y}" rotation="0" font="f" '
|
||||
# An optional last member of each tuple is the item's uuid.
|
||||
uid = lambda u: f' uuid="{u[0]}"' if u else ""
|
||||
inputs = "".join(f'<input x="{x}" y="{y}" rotation="0" font="f"{uid(u)} '
|
||||
f'text="<html><body><p>{t}</p></body></html>"/>'
|
||||
for x, y, t in texts)
|
||||
shp = "".join(f'<shape type="Rectangle" x1="{a}" y1="{b}" x2="{c}" y2="{d}">'
|
||||
for x, y, t, *u in texts)
|
||||
shp = "".join(f'<shape type="Rectangle" x1="{a}" y1="{b}" x2="{c}" y2="{d}"{uid(u)}>'
|
||||
f'<pen color="{pc}" style="SolidLine" widthF="1"/>'
|
||||
f'<brush color="#fff" style="SolidPattern"/></shape>'
|
||||
for a, b, c, d, pc in shapes)
|
||||
img = "".join(f'<image x="{x}" y="{y}" size="{s}" rotation="0"/>' for x, y, s in images)
|
||||
for a, b, c, d, pc, *u in shapes)
|
||||
img = "".join(f'<image x="{x}" y="{y}" size="{s}" rotation="0"{uid(u)}/>'
|
||||
for x, y, s, *u in images)
|
||||
st = "".join(f'<terminal_strip><terminal_strip_data uuid="{u}"><informations>'
|
||||
f'<information name="name">{n}</information></informations>'
|
||||
f'</terminal_strip_data><layout/></terminal_strip>' for u, n in strips)
|
||||
@@ -669,6 +720,39 @@ class Diff(unittest.TestCase):
|
||||
self.assertEqual([t["text"] for t in d["removed"]], ["note"])
|
||||
self.assertEqual([t["text"] for t in d["added"]], ["note EDITED"])
|
||||
|
||||
U1 = "{11111111-1111-4111-8111-111111111111}"
|
||||
U2 = "{22222222-2222-4222-8222-222222222222}"
|
||||
|
||||
def test_edited_text_with_a_uuid_is_a_change_to_that_text(self):
|
||||
a = self.project("a.qet", texts=[(1, 2, "note", self.U1)])
|
||||
b = self.project("b.qet", texts=[(1, 2, "note EDITED", self.U1)])
|
||||
d = m.tool_diff(a, b)["texts"]
|
||||
self.assertEqual(d["keyed_by"], "uuid")
|
||||
self.assertEqual((d["added"], d["removed"]), ([], []))
|
||||
self.assertEqual(d["changed"][0]["changed"]["text"], ["note", "note EDITED"])
|
||||
|
||||
def test_moved_shape_with_a_uuid_is_a_change_to_that_shape(self):
|
||||
a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000", self.U1),
|
||||
(9, 9, 12, 12, "#000000", self.U2)])
|
||||
b = self.project("b.qet", shapes=[(100, 0, 105, 5, "#000000", self.U1),
|
||||
(9, 9, 12, 12, "#000000", self.U2)])
|
||||
d = m.tool_diff(a, b)["shapes"]
|
||||
self.assertEqual((d["added"], d["removed"]), ([], []))
|
||||
self.assertEqual(len(d["changed"]), 1)
|
||||
self.assertEqual(d["changed"][0]["item"]["uuid"], self.U1)
|
||||
self.assertEqual(d["changed"][0]["changed"]["from"], [["0", "0"], ["100", "0"]])
|
||||
|
||||
def test_a_file_without_uuids_against_its_resave_is_keyed_by_position(self):
|
||||
"""The first save by a current QElectroTech adds uuids to a legacy
|
||||
file: that must not read as every item removed and re-added."""
|
||||
a = self.project("a.qet", texts=[(1, 2, "note")], images=[(3, 3, 1)])
|
||||
b = self.project("b.qet", texts=[(1, 2, "note", self.U1)], images=[(3, 3, 1, self.U2)])
|
||||
d = m.tool_diff(a, b)
|
||||
for k in ("texts", "images"):
|
||||
with self.subTest(section=k):
|
||||
self.assertEqual(d[k]["keyed_by"], "position")
|
||||
self.assertFalse(d[k]["added"] or d[k]["removed"] or d[k]["changed"])
|
||||
|
||||
def test_shape_restyle_is_a_change_to_that_item(self):
|
||||
a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000")])
|
||||
b = self.project("b.qet", shapes=[(0, 0, 5, 5, "#ff0000")])
|
||||
@@ -1975,6 +2059,85 @@ class Integration(unittest.TestCase):
|
||||
self.assertEqual(len(d["shapes"]["added"]), 1)
|
||||
self.assertEqual(len(d["images"]["added"]), 1)
|
||||
|
||||
def test_drawing_items_are_in_the_database_and_addressable_by_uuid(self):
|
||||
base = self.sb.new()
|
||||
r = self.ok(self.sb.edit(base, [
|
||||
{"op": "add_text", "folio": 0, "text": "note", "x": 50, "y": 50},
|
||||
{"op": "add_shape", "folio": 0, "shape": "rectangle", "x1": 10, "y1": 10, "x2": 200, "y2": 120}]))
|
||||
root = ET.parse(r["output"]).getroot()
|
||||
shape_uuid = next(root.iter("shape")).get("uuid")
|
||||
text_uuid = next(root.iter("input")).get("uuid")
|
||||
rows = m.tool_query(BINARY, r["output"],
|
||||
"SELECT uuid, kind, folio, description FROM drawing_item_view "
|
||||
"ORDER BY kind")["rows"]
|
||||
self.assertEqual([(x["uuid"], x["kind"], x["folio"]) for x in rows],
|
||||
[(shape_uuid, "shape", 1), (text_uuid, "text", 1)])
|
||||
self.assertEqual(rows[1]["description"], "note")
|
||||
|
||||
# A second shape placed above the first shifts its index to 1; the
|
||||
# uuid still names it.
|
||||
r2 = self.ok(self.sb.edit(r["output"], [
|
||||
{"op": "add_shape", "folio": 0, "shape": "line", "x1": 0, "y1": 0, "x2": 5, "y2": 0},
|
||||
{"op": "set_shape", "folio": 0, "index": shape_uuid, "property": "fill", "value": "#00ff00"}],
|
||||
out="out2.qet"))
|
||||
d = r2["diff"]["shapes"]
|
||||
self.assertEqual(d["keyed_by"], "uuid")
|
||||
self.assertEqual([c["item"]["uuid"] for c in d["changed"]], [shape_uuid])
|
||||
self.assertEqual(len(d["added"]), 1)
|
||||
|
||||
def _old_file(self, same_shape_uuid=False):
|
||||
"""A folio with a text and two shapes, as a version without
|
||||
drawing-item uuids wrote it -- or, with same_shape_uuid, hand-edited
|
||||
so that both shapes claim the same one."""
|
||||
r = self.ok(self.sb.edit(self.sb.new(), [
|
||||
{"op": "add_text", "folio": 0, "text": "note", "x": 50, "y": 50},
|
||||
{"op": "add_shape", "folio": 0, "shape": "rectangle", "x1": 10, "y1": 10, "x2": 200, "y2": 120},
|
||||
{"op": "add_shape", "folio": 0, "shape": "line", "x1": 0, "y1": 0, "x2": 5, "y2": 0}]))
|
||||
tree = ET.parse(r["output"])
|
||||
shapes = list(tree.getroot().iter("shape"))
|
||||
for item in [*tree.getroot().iter("input"), *shapes]:
|
||||
del item.attrib["uuid"]
|
||||
if same_shape_uuid:
|
||||
for shape in shapes:
|
||||
shape.set("uuid", SHARED_UUID)
|
||||
tree.write(self.sb.p("old.qet"), encoding="utf-8", xml_declaration=True)
|
||||
return self.sb.p("old.qet")
|
||||
|
||||
def _db_uuids(self, project):
|
||||
rows = m.tool_query(BINARY, project, "SELECT uuid, kind FROM drawing_item_view")["rows"]
|
||||
return sorted((x["uuid"], x["kind"]) for x in rows)
|
||||
|
||||
@staticmethod
|
||||
def _file_uuids(project):
|
||||
root = ET.parse(project).getroot()
|
||||
return sorted((item.get("uuid"), kind)
|
||||
for tag, kind in (("shape", "shape"), ("input", "text"))
|
||||
for item in root.iter(tag))
|
||||
|
||||
def test_a_file_without_drawing_uuids_gets_the_same_ones_on_every_open(self):
|
||||
old = self._old_file()
|
||||
first = self._db_uuids(old)
|
||||
self.assertEqual(len(first), 3)
|
||||
self.assertEqual(len({u for u, _ in first}), 3, "each item gets its own")
|
||||
self.assertEqual(self._db_uuids(old), first)
|
||||
|
||||
def test_the_first_save_writes_those_uuids_and_a_reopen_keeps_them(self):
|
||||
old = self._old_file()
|
||||
derived = self._db_uuids(old)
|
||||
saved = self.ok(self.sb.edit(old, [{"op": "add_folio"}], out="saved.qet"))["output"]
|
||||
self.assertEqual(self._file_uuids(saved), derived)
|
||||
self.assertEqual(self._db_uuids(saved), derived)
|
||||
# Saving the same old file again writes the same uuids (#754).
|
||||
again = self.ok(self.sb.edit(old, [{"op": "add_folio"}], out="again.qet"))["output"]
|
||||
self.assertEqual(self._file_uuids(again), derived)
|
||||
|
||||
def test_a_uuid_repeated_in_a_hand_edited_file_names_one_item_only(self):
|
||||
old = self._old_file(same_shape_uuid=True)
|
||||
shapes = [u for u, kind in self._db_uuids(old) if kind == "shape"]
|
||||
self.assertEqual(len(set(shapes)), 2)
|
||||
self.assertIn(SHARED_UUID, shapes)
|
||||
self.assertEqual([u for u, kind in self._db_uuids(old) if kind == "shape"], shapes)
|
||||
|
||||
# ---- polygon and path shapes ----
|
||||
|
||||
def test_add_polygon_with_more_than_two_points(self):
|
||||
|
||||
Executable
+659
@@ -0,0 +1,659 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
r"""Record raw USB reports from a 3Dconnexion 3D mouse, for QElectroTech.
|
||||
|
||||
QElectroTech's Windows/macOS 3D mouse support reads the device directly
|
||||
over USB, so it has to decode each device model's raw reports itself.
|
||||
This records what your device sends while you make a few guided
|
||||
movements, and saves it to one file you can attach to discussion #599.
|
||||
Nothing is sent anywhere. One recording per device model is enough, from
|
||||
any of the three systems: the device sends the same reports on all of them.
|
||||
|
||||
It takes about two minutes. Each step says what to do: press Enter, make
|
||||
the movement and hold it until the next prompt. Push firmly.
|
||||
|
||||
Linux
|
||||
Python 3 is already installed.
|
||||
1. Download: curl -LO https://raw.githubusercontent.com/qelectrotech/qelectrotech-source-mirror/master/misc/spacemouse-capture.py
|
||||
2. List: sudo python3 spacemouse-capture.py --list
|
||||
3. Record: sudo python3 spacemouse-capture.py --seconds 3
|
||||
If --list shows several devices (a Logitech receiver shows up as
|
||||
several), add --device /dev/hidrawN with the 3D mouse's line.
|
||||
sudo is needed because /dev/hidraw* is readable by root only.
|
||||
spacenavd can keep running; if the recording comes out empty, stop it
|
||||
(sudo systemctl stop spacenavd) and try again.
|
||||
|
||||
macOS
|
||||
Python 3 comes with the Xcode command line tools; if "python3" asks
|
||||
to install them, accept. Open Terminal, then:
|
||||
1. Download: curl -LO https://raw.githubusercontent.com/qelectrotech/qelectrotech-source-mirror/master/misc/spacemouse-capture.py
|
||||
2. Record: python3 spacemouse-capture.py --seconds 3
|
||||
No sudo. If it says another program holds the device, quit 3DxWare
|
||||
(or uninstall it) and try again. If it says macOS refused access,
|
||||
allow Terminal in System Settings > Privacy & Security > Input
|
||||
Monitoring, and try again. (Rather not use Terminal? The SpaceMouse
|
||||
Check app does the same with a window: see discussion #599.)
|
||||
|
||||
Windows
|
||||
Install Python 3 from https://www.python.org/downloads/ (tick "Add
|
||||
python.exe to PATH") or from the Microsoft Store. Open a Command
|
||||
Prompt (Windows key, type cmd, Enter), then:
|
||||
1. cd %USERPROFILE%\Downloads
|
||||
2. Download: curl -LO https://raw.githubusercontent.com/qelectrotech/qelectrotech-source-mirror/master/misc/spacemouse-capture.py
|
||||
3. Record: python spacemouse-capture.py --seconds 3
|
||||
No administrator rights needed, and 3DxWare can keep running.
|
||||
Windows does not give out the device's report descriptor, so a
|
||||
recording made on Linux or macOS is a little more complete.
|
||||
|
||||
Every system
|
||||
--list only show the 3D mice found
|
||||
--device PATH pick one, if several are found (a path from --list)
|
||||
--seconds N multiply the time per step (3 triples it)
|
||||
-o FILE where to save (default: spacemouse-capture-<id>.json)
|
||||
|
||||
The file is saved in the current folder: attach it to discussion #599.
|
||||
Only Python's standard library is used.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
VENDORS = {0x046D: 'Logitech (older 3Dconnexion)', 0x256F: '3Dconnexion'}
|
||||
|
||||
STEPS = [
|
||||
('rest', 'Do not touch the device.', 3),
|
||||
('right', 'Push the cap to the RIGHT and hold it, then let go.', 4),
|
||||
('left', 'Push the cap to the LEFT and hold it, then let go.', 4),
|
||||
('away', 'Push the cap AWAY from you and hold it, then let go.', 4),
|
||||
('toward', 'Pull the cap TOWARDS you and hold it, then let go.', 4),
|
||||
('down', 'Press the cap DOWN and hold it, then let go.', 4),
|
||||
('up', 'Lift the cap UP and hold it, then let go.', 4),
|
||||
('twist_cw', 'TWIST the cap CLOCKWISE (seen from above) and hold, then let go.', 4),
|
||||
('twist_ccw', 'TWIST the cap ANTICLOCKWISE and hold, then let go.', 4),
|
||||
('tilt_away', 'TILT the cap AWAY from you and hold, then let go.', 4),
|
||||
('tilt_right', 'TILT the cap to the RIGHT and hold, then let go.', 4),
|
||||
('buttons', 'Press each button once, slowly, one at a time, in any order.', 15),
|
||||
]
|
||||
|
||||
# Processes that may also be reading the device.
|
||||
OTHER_READERS = ('3dconnexion', '3dx', 'spacenavd') # 3DxWare: 3DconnexionHelper, 3DxNLServer...
|
||||
|
||||
|
||||
def other_readers():
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
out = subprocess.run(['tasklist', '/fo', 'csv', '/nh'], capture_output=True,
|
||||
text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ['unknown']
|
||||
names = {line.split('","')[0].strip('"') for line in out.splitlines() if line}
|
||||
return sorted(n for n in names if any(r in n.lower() for r in OTHER_READERS))
|
||||
try:
|
||||
out = subprocess.run(['ps', '-A', '-o', 'comm='], capture_output=True,
|
||||
text=True, timeout=5).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ['unknown']
|
||||
names = {os.path.basename(line.strip()) for line in out.splitlines()}
|
||||
return sorted(n for n in names if any(r in n.lower() for r in OTHER_READERS))
|
||||
|
||||
|
||||
# --- Linux: /dev/hidraw ----------------------------------------------------
|
||||
|
||||
class HidrawDevice:
|
||||
backend = 'hidraw'
|
||||
|
||||
def __init__(self, path, name='?', vendor='?', product='?', sysfs=None):
|
||||
self.path, self.name, self.vendor, self.product = path, name, vendor, product
|
||||
self.sysfs = sysfs
|
||||
self.fd = None
|
||||
|
||||
@staticmethod
|
||||
def find():
|
||||
found = []
|
||||
for sysdir in sorted(glob.glob('/sys/class/hidraw/hidraw*')):
|
||||
try:
|
||||
with open(os.path.join(sysdir, 'device', 'uevent')) as f:
|
||||
uevent = dict(line.strip().split('=', 1) for line in f if '=' in line)
|
||||
except OSError:
|
||||
continue
|
||||
# HID_ID=0003:0000256F:0000C635 (bus:vendor:product)
|
||||
try:
|
||||
_bus, vendor, product = (int(x, 16) for x in uevent.get('HID_ID', '').split(':'))
|
||||
except ValueError:
|
||||
continue
|
||||
if vendor not in VENDORS:
|
||||
continue
|
||||
found.append(HidrawDevice('/dev/' + os.path.basename(sysdir),
|
||||
uevent.get('HID_NAME', '?'),
|
||||
'%04x' % vendor, '%04x' % product, sysdir))
|
||||
return found
|
||||
|
||||
def descriptor(self):
|
||||
if not self.sysfs:
|
||||
return 'unknown'
|
||||
try:
|
||||
with open(os.path.join(self.sysfs, 'device', 'report_descriptor'), 'rb') as f:
|
||||
return f.read().hex()
|
||||
except OSError as e:
|
||||
return 'unreadable: %s' % e
|
||||
|
||||
def open(self):
|
||||
try:
|
||||
self.fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
except PermissionError:
|
||||
sys.exit('Permission denied on %s -- run with sudo.' % self.path)
|
||||
|
||||
def record(self, seconds):
|
||||
"""Read every report arriving within `seconds`; return [[ms, hex], ...]."""
|
||||
reports = []
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
left = seconds - (time.monotonic() - start)
|
||||
if left <= 0:
|
||||
return reports
|
||||
ready, _, _ = select.select([self.fd], [], [], left)
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
data = os.read(self.fd, 64)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not data: # only a test FIFO with no writer does this
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
reports.append([round((time.monotonic() - start) * 1000, 1), data.hex()])
|
||||
|
||||
def drain(self):
|
||||
"""Drop reports queued while waiting for Enter."""
|
||||
while True:
|
||||
try:
|
||||
if not os.read(self.fd, 64):
|
||||
return
|
||||
except BlockingIOError:
|
||||
return
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
|
||||
def empty_hint(self):
|
||||
return 'Try stopping spacenavd first: sudo systemctl stop spacenavd'
|
||||
|
||||
|
||||
# --- macOS: IOKit, the same calls hidapi's mac backend makes ----------------
|
||||
|
||||
class MacHid:
|
||||
"""ctypes bindings to the few CoreFoundation/IOKit calls needed."""
|
||||
|
||||
def __init__(self):
|
||||
import ctypes as c
|
||||
self.c = c
|
||||
cf = c.CDLL('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation')
|
||||
io = c.CDLL('/System/Library/Frameworks/IOKit.framework/IOKit')
|
||||
vp, i32, u32, idx = c.c_void_p, c.c_int32, c.c_uint32, c.c_long
|
||||
|
||||
def fn(lib, name, res, *args):
|
||||
f = getattr(lib, name)
|
||||
f.restype, f.argtypes = res, list(args)
|
||||
return f
|
||||
|
||||
self.CFStringCreateWithCString = fn(cf, 'CFStringCreateWithCString', vp, vp, c.c_char_p, u32)
|
||||
self.CFStringGetCString = fn(cf, 'CFStringGetCString', c.c_bool, vp, c.c_char_p, idx, u32)
|
||||
self.CFGetTypeID = fn(cf, 'CFGetTypeID', c.c_ulong, vp)
|
||||
self.CFNumberGetTypeID = fn(cf, 'CFNumberGetTypeID', c.c_ulong)
|
||||
self.CFStringGetTypeID = fn(cf, 'CFStringGetTypeID', c.c_ulong)
|
||||
self.CFDataGetTypeID = fn(cf, 'CFDataGetTypeID', c.c_ulong)
|
||||
self.CFNumberGetValue = fn(cf, 'CFNumberGetValue', c.c_bool, vp, idx, vp)
|
||||
self.CFDataGetLength = fn(cf, 'CFDataGetLength', idx, vp)
|
||||
self.CFDataGetBytePtr = fn(cf, 'CFDataGetBytePtr', vp, vp)
|
||||
self.CFSetGetCount = fn(cf, 'CFSetGetCount', idx, vp)
|
||||
self.CFSetGetValues = fn(cf, 'CFSetGetValues', None, vp, c.POINTER(vp))
|
||||
self.CFRunLoopGetCurrent = fn(cf, 'CFRunLoopGetCurrent', vp)
|
||||
self.CFRunLoopRunInMode = fn(cf, 'CFRunLoopRunInMode', i32, vp, c.c_double, c.c_bool)
|
||||
self.kCFRunLoopDefaultMode = vp.in_dll(cf, 'kCFRunLoopDefaultMode').value
|
||||
|
||||
self.IOHIDManagerCreate = fn(io, 'IOHIDManagerCreate', vp, vp, u32)
|
||||
self.IOHIDManagerSetDeviceMatching = fn(io, 'IOHIDManagerSetDeviceMatching', None, vp, vp)
|
||||
self.IOHIDManagerScheduleWithRunLoop = fn(io, 'IOHIDManagerScheduleWithRunLoop', None, vp, vp, vp)
|
||||
self.IOHIDManagerCopyDevices = fn(io, 'IOHIDManagerCopyDevices', vp, vp)
|
||||
self.IOHIDDeviceGetProperty = fn(io, 'IOHIDDeviceGetProperty', vp, vp, vp)
|
||||
self.IOHIDDeviceOpen = fn(io, 'IOHIDDeviceOpen', i32, vp, u32)
|
||||
self.IOHIDDeviceClose = fn(io, 'IOHIDDeviceClose', i32, vp, u32)
|
||||
self.IOHIDDeviceScheduleWithRunLoop = fn(io, 'IOHIDDeviceScheduleWithRunLoop', None, vp, vp, vp)
|
||||
self.IOHIDDeviceUnscheduleFromRunLoop = fn(io, 'IOHIDDeviceUnscheduleFromRunLoop', None, vp, vp, vp)
|
||||
# void (*)(void *ctx, IOReturn, void *sender, IOHIDReportType, uint32_t id, uint8_t *, CFIndex)
|
||||
self.ReportCallback = c.CFUNCTYPE(None, vp, i32, vp, c.c_int, u32, c.POINTER(c.c_uint8), idx)
|
||||
self.IOHIDDeviceRegisterInputReportCallback = fn(
|
||||
io, 'IOHIDDeviceRegisterInputReportCallback', None, vp, vp, idx, self.ReportCallback, vp)
|
||||
|
||||
def cfstr(self, s):
|
||||
return self.CFStringCreateWithCString(None, s.encode(), 0x08000100) # UTF-8
|
||||
|
||||
def prop(self, dev, key):
|
||||
"""A device property as int, str or bytes, or None."""
|
||||
c = self.c
|
||||
ref = self.IOHIDDeviceGetProperty(dev, self.cfstr(key))
|
||||
if not ref:
|
||||
return None
|
||||
t = self.CFGetTypeID(ref)
|
||||
if t == self.CFNumberGetTypeID():
|
||||
v = c.c_int64()
|
||||
self.CFNumberGetValue(ref, 4, c.byref(v)) # kCFNumberSInt64Type
|
||||
return v.value
|
||||
if t == self.CFStringGetTypeID():
|
||||
buf = c.create_string_buffer(256)
|
||||
return buf.value.decode('utf-8', 'replace') if self.CFStringGetCString(
|
||||
ref, buf, len(buf), 0x08000100) else None
|
||||
if t == self.CFDataGetTypeID():
|
||||
return c.string_at(self.CFDataGetBytePtr(ref), self.CFDataGetLength(ref))
|
||||
return None
|
||||
|
||||
|
||||
class MacDevice:
|
||||
backend = 'iokit'
|
||||
_hid = None
|
||||
|
||||
def __init__(self, ref, name, vendor, product, location):
|
||||
self.ref, self.name, self.vendor, self.product = ref, name, vendor, product
|
||||
self.path = 'iokit:%08x' % location
|
||||
self.reports = []
|
||||
self.start = time.monotonic()
|
||||
|
||||
@classmethod
|
||||
def hid(cls):
|
||||
if cls._hid is None:
|
||||
cls._hid = MacHid()
|
||||
return cls._hid
|
||||
|
||||
@classmethod
|
||||
def find(cls):
|
||||
h = cls.hid()
|
||||
mgr = h.IOHIDManagerCreate(None, 0)
|
||||
h.IOHIDManagerSetDeviceMatching(mgr, None)
|
||||
h.IOHIDManagerScheduleWithRunLoop(mgr, h.CFRunLoopGetCurrent(), h.kCFRunLoopDefaultMode)
|
||||
devset = h.IOHIDManagerCopyDevices(mgr)
|
||||
if not devset:
|
||||
return []
|
||||
n = h.CFSetGetCount(devset)
|
||||
refs = (h.c.c_void_p * n)()
|
||||
h.CFSetGetValues(devset, refs)
|
||||
found = []
|
||||
for ref in refs:
|
||||
vendor = h.prop(ref, 'VendorID')
|
||||
if vendor not in VENDORS:
|
||||
continue
|
||||
# The same test as QET's SpaceMouseHid::isSpaceMouse(): Generic
|
||||
# Desktop / Multi-axis Controller, or no usage at all. This also
|
||||
# skips Logitech's ordinary mice and keyboards.
|
||||
usage = (h.prop(ref, 'PrimaryUsagePage') or 0, h.prop(ref, 'PrimaryUsage') or 0)
|
||||
if usage not in ((1, 8), (0, 0)):
|
||||
continue
|
||||
found.append(cls(ref, h.prop(ref, 'Product') or '?', '%04x' % vendor,
|
||||
'%04x' % (h.prop(ref, 'ProductID') or 0),
|
||||
h.prop(ref, 'LocationID') or 0))
|
||||
return sorted(found, key=lambda d: d.path)
|
||||
|
||||
def descriptor(self):
|
||||
d = self.hid().prop(self.ref, 'ReportDescriptor')
|
||||
return d.hex() if d else 'unreadable'
|
||||
|
||||
def open(self):
|
||||
h = self.hid()
|
||||
ret = h.IOHIDDeviceOpen(self.ref, 0) & 0xFFFFFFFF # kIOHIDOptionsTypeNone: shared
|
||||
if ret == 0xE00002C5:
|
||||
sys.exit('The device is held exclusively by another program '
|
||||
'(kIOReturnExclusiveAccess). Quit 3DxWare and try again.')
|
||||
if ret in (0xE00002E2, 0xE00002C1):
|
||||
sys.exit('macOS refused access (0x%08X). Allow Terminal under System Settings > '
|
||||
'Privacy & Security > Input Monitoring, then try again.' % ret)
|
||||
if ret:
|
||||
sys.exit('Could not open the device (IOReturn 0x%08X).' % ret)
|
||||
size = h.prop(self.ref, 'MaxInputReportSize') or 64
|
||||
self.buf = h.c.create_string_buffer(size)
|
||||
|
||||
def on_report(_ctx, _result, _sender, _type, _id, data, length):
|
||||
self.reports.append([round((time.monotonic() - self.start) * 1000, 1),
|
||||
h.c.string_at(data, length).hex()])
|
||||
self.callback = h.ReportCallback(on_report) # must outlive the device
|
||||
h.IOHIDDeviceRegisterInputReportCallback(self.ref, self.buf, size, self.callback, None)
|
||||
h.IOHIDDeviceScheduleWithRunLoop(self.ref, h.CFRunLoopGetCurrent(), h.kCFRunLoopDefaultMode)
|
||||
|
||||
def record(self, seconds):
|
||||
h = self.hid()
|
||||
self.reports, self.start = [], time.monotonic()
|
||||
while True:
|
||||
left = seconds - (time.monotonic() - self.start)
|
||||
if left <= 0:
|
||||
return self.reports
|
||||
h.CFRunLoopRunInMode(h.kCFRunLoopDefaultMode, left, False)
|
||||
|
||||
def drain(self):
|
||||
self.hid().CFRunLoopRunInMode(self.hid().kCFRunLoopDefaultMode, 0.05, False)
|
||||
self.reports = []
|
||||
|
||||
def close(self):
|
||||
h = self.hid()
|
||||
h.IOHIDDeviceUnscheduleFromRunLoop(self.ref, h.CFRunLoopGetCurrent(), h.kCFRunLoopDefaultMode)
|
||||
h.IOHIDDeviceClose(self.ref, 0)
|
||||
|
||||
def empty_hint(self):
|
||||
return ('Quit 3DxWare if it is running, or allow Terminal under System Settings > '
|
||||
'Privacy & Security > Input Monitoring, and try again.')
|
||||
|
||||
|
||||
# --- Windows: hid.dll, the same calls hidapi's Windows backend makes ---------
|
||||
|
||||
class WinHid:
|
||||
"""ctypes bindings to the SetupAPI/HID/kernel32 calls needed."""
|
||||
|
||||
def __init__(self):
|
||||
import ctypes as c
|
||||
from ctypes import wintypes as w
|
||||
self.c, self.w = c, w
|
||||
self.hid = c.WinDLL('hid')
|
||||
self.setupapi = c.WinDLL('setupapi')
|
||||
self.k32 = c.WinDLL('kernel32', use_last_error=True)
|
||||
k, sa = self.k32, self.setupapi
|
||||
|
||||
class GUID(c.Structure):
|
||||
_fields_ = [('Data1', w.DWORD), ('Data2', w.WORD), ('Data3', w.WORD),
|
||||
('Data4', c.c_ubyte * 8)]
|
||||
|
||||
class InterfaceData(c.Structure):
|
||||
_fields_ = [('cbSize', w.DWORD), ('InterfaceClassGuid', GUID),
|
||||
('Flags', w.DWORD), ('Reserved', c.c_void_p)]
|
||||
|
||||
class Attributes(c.Structure):
|
||||
_fields_ = [('Size', w.ULONG), ('VendorID', w.USHORT), ('ProductID', w.USHORT),
|
||||
('VersionNumber', w.USHORT)]
|
||||
|
||||
class Caps(c.Structure):
|
||||
_fields_ = [('Usage', w.USHORT), ('UsagePage', w.USHORT),
|
||||
('InputReportByteLength', w.USHORT), ('OutputReportByteLength', w.USHORT),
|
||||
('FeatureReportByteLength', w.USHORT), ('Reserved', w.USHORT * 17),
|
||||
('rest', w.USHORT * 10)]
|
||||
|
||||
class Overlapped(c.Structure):
|
||||
_fields_ = [('Internal', c.c_void_p), ('InternalHigh', c.c_void_p),
|
||||
('Offset', w.DWORD), ('OffsetHigh', w.DWORD), ('hEvent', w.HANDLE)]
|
||||
|
||||
self.GUID, self.InterfaceData, self.Attributes = GUID, InterfaceData, Attributes
|
||||
self.Caps, self.Overlapped = Caps, Overlapped
|
||||
|
||||
k.CreateFileW.restype = w.HANDLE
|
||||
k.CreateFileW.argtypes = [w.LPCWSTR, w.DWORD, w.DWORD, c.c_void_p, w.DWORD, w.DWORD, w.HANDLE]
|
||||
k.CreateEventW.restype = w.HANDLE
|
||||
k.CreateEventW.argtypes = [c.c_void_p, w.BOOL, w.BOOL, w.LPCWSTR]
|
||||
k.ReadFile.argtypes = [w.HANDLE, c.c_void_p, w.DWORD, c.c_void_p, c.c_void_p]
|
||||
k.GetOverlappedResult.argtypes = [w.HANDLE, c.c_void_p, c.POINTER(w.DWORD), w.BOOL]
|
||||
k.WaitForSingleObject.argtypes = [w.HANDLE, w.DWORD]
|
||||
k.WaitForSingleObject.restype = w.DWORD
|
||||
k.CancelIo.argtypes = [w.HANDLE]
|
||||
k.CloseHandle.argtypes = [w.HANDLE]
|
||||
sa.SetupDiGetClassDevsW.restype = w.HANDLE
|
||||
sa.SetupDiGetClassDevsW.argtypes = [c.c_void_p, w.LPCWSTR, w.HWND, w.DWORD]
|
||||
sa.SetupDiEnumDeviceInterfaces.argtypes = [w.HANDLE, c.c_void_p, c.c_void_p, w.DWORD, c.c_void_p]
|
||||
sa.SetupDiGetDeviceInterfaceDetailW.argtypes = [w.HANDLE, c.c_void_p, c.c_void_p, w.DWORD,
|
||||
c.POINTER(w.DWORD), c.c_void_p]
|
||||
sa.SetupDiDestroyDeviceInfoList.argtypes = [w.HANDLE]
|
||||
self.hid.HidD_GetHidGuid.argtypes = [c.c_void_p]
|
||||
self.hid.HidD_GetAttributes.argtypes = [w.HANDLE, c.c_void_p]
|
||||
self.hid.HidD_GetPreparsedData.argtypes = [w.HANDLE, c.POINTER(c.c_void_p)]
|
||||
self.hid.HidD_FreePreparsedData.argtypes = [c.c_void_p]
|
||||
self.hid.HidP_GetCaps.argtypes = [c.c_void_p, c.c_void_p]
|
||||
self.hid.HidD_GetProductString.argtypes = [w.HANDLE, c.c_void_p, w.ULONG]
|
||||
|
||||
INVALID = (2 ** 64 - 1, 2 ** 32 - 1, -1) # INVALID_HANDLE_VALUE, 64/32-bit
|
||||
|
||||
def paths(self):
|
||||
"""Every HID interface path on the system."""
|
||||
c, w = self.c, self.w
|
||||
guid = self.GUID()
|
||||
self.hid.HidD_GetHidGuid(c.byref(guid))
|
||||
info = self.setupapi.SetupDiGetClassDevsW(c.byref(guid), None, None, 0x12) # PRESENT|INTERFACE
|
||||
paths = []
|
||||
i = 0
|
||||
while True:
|
||||
data = self.InterfaceData()
|
||||
data.cbSize = c.sizeof(data)
|
||||
if not self.setupapi.SetupDiEnumDeviceInterfaces(info, None, c.byref(guid), i, c.byref(data)):
|
||||
break
|
||||
i += 1
|
||||
needed = w.DWORD()
|
||||
self.setupapi.SetupDiGetDeviceInterfaceDetailW(info, c.byref(data), None, 0, c.byref(needed), None)
|
||||
buf = c.create_string_buffer(needed.value)
|
||||
# SP_DEVICE_INTERFACE_DETAIL_DATA_W: DWORD cbSize, then the path.
|
||||
c.cast(buf, c.POINTER(w.DWORD))[0] = 8 if c.sizeof(c.c_void_p) == 8 else 6
|
||||
if self.setupapi.SetupDiGetDeviceInterfaceDetailW(info, c.byref(data), buf, needed, None, None):
|
||||
paths.append(c.wstring_at(c.addressof(buf) + 4))
|
||||
self.setupapi.SetupDiDestroyDeviceInfoList(info)
|
||||
return paths
|
||||
|
||||
def open(self, path, access):
|
||||
# FILE_SHARE_READ|WRITE, OPEN_EXISTING, FILE_FLAG_OVERLAPPED
|
||||
h = self.k32.CreateFileW(path, access, 3, None, 3, 0x40000000, None)
|
||||
return None if h is None or h in self.INVALID else h
|
||||
|
||||
def describe(self, h):
|
||||
"""(vendor, product, usage_page, usage, input_length, name) of an open handle."""
|
||||
c = self.c
|
||||
attrs = self.Attributes()
|
||||
attrs.Size = c.sizeof(attrs)
|
||||
if not self.hid.HidD_GetAttributes(h, c.byref(attrs)):
|
||||
return None
|
||||
page = usage = length = 0
|
||||
pre = c.c_void_p()
|
||||
if self.hid.HidD_GetPreparsedData(h, c.byref(pre)):
|
||||
caps = self.Caps()
|
||||
self.hid.HidP_GetCaps(pre, c.byref(caps))
|
||||
page, usage, length = caps.UsagePage, caps.Usage, caps.InputReportByteLength
|
||||
self.hid.HidD_FreePreparsedData(pre)
|
||||
name = c.create_unicode_buffer(128)
|
||||
if not self.hid.HidD_GetProductString(h, name, c.sizeof(name)):
|
||||
name.value = '?'
|
||||
return attrs.VendorID, attrs.ProductID, page, usage, length, name.value
|
||||
|
||||
|
||||
class WinDevice:
|
||||
backend = 'windows-hid'
|
||||
_hid = None
|
||||
|
||||
def __init__(self, path, name, vendor, product, length):
|
||||
self.path, self.name, self.vendor, self.product = path, name, vendor, product
|
||||
self.length = length or 64
|
||||
self.handle = None
|
||||
|
||||
@classmethod
|
||||
def hid(cls):
|
||||
if cls._hid is None:
|
||||
cls._hid = WinHid()
|
||||
return cls._hid
|
||||
|
||||
@classmethod
|
||||
def find(cls):
|
||||
h = cls.hid()
|
||||
found = []
|
||||
for path in h.paths():
|
||||
handle = h.open(path, 0) # no access: enough to read attributes
|
||||
if handle is None:
|
||||
continue
|
||||
try:
|
||||
d = h.describe(handle)
|
||||
finally:
|
||||
h.k32.CloseHandle(handle)
|
||||
if not d:
|
||||
continue
|
||||
vendor, product, page, usage, length, name = d
|
||||
# The same test as QET's SpaceMouseHid::isSpaceMouse().
|
||||
if vendor not in VENDORS or (page, usage) not in ((1, 8), (0, 0)):
|
||||
continue
|
||||
found.append(cls(path, name, '%04x' % vendor, '%04x' % product, length))
|
||||
return found
|
||||
|
||||
def descriptor(self):
|
||||
# Windows only gives out a parsed form of it.
|
||||
return ''
|
||||
|
||||
def open(self):
|
||||
h = self.hid()
|
||||
self.handle = h.open(self.path, 0x80000000) # GENERIC_READ
|
||||
if self.handle is None:
|
||||
sys.exit('Could not open the device (error %d).' % h.c.get_last_error())
|
||||
self.event = h.k32.CreateEventW(None, True, False, None)
|
||||
self.buf = h.c.create_string_buffer(self.length)
|
||||
self.pending = False
|
||||
self.start = time.monotonic()
|
||||
|
||||
def _read(self, wait_ms):
|
||||
"""One report if it arrives within wait_ms, else None."""
|
||||
h = self.hid()
|
||||
c, w = h.c, h.w
|
||||
if not self.pending:
|
||||
self.ov = h.Overlapped()
|
||||
self.ov.hEvent = self.event
|
||||
h.k32.ReadFile(self.handle, self.buf, self.length, None, c.byref(self.ov))
|
||||
self.pending = True
|
||||
if h.k32.WaitForSingleObject(self.event, max(0, int(wait_ms))) != 0:
|
||||
return None
|
||||
self.pending = False
|
||||
n = w.DWORD()
|
||||
if not h.k32.GetOverlappedResult(self.handle, c.byref(self.ov), c.byref(n), False):
|
||||
return None
|
||||
data = self.buf.raw[:n.value]
|
||||
# Windows puts a report ID in front even when the device has none;
|
||||
# hidapi drops that 0, so QET never sees it.
|
||||
return data[1:] if data[:1] == b'\0' else data
|
||||
|
||||
def record(self, seconds):
|
||||
reports = []
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
left = seconds - (time.monotonic() - start)
|
||||
if left <= 0:
|
||||
return reports
|
||||
data = self._read(left * 1000)
|
||||
if data:
|
||||
reports.append([round((time.monotonic() - start) * 1000, 1), data.hex()])
|
||||
|
||||
def drain(self):
|
||||
while self._read(0):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
h = self.hid()
|
||||
if self.pending:
|
||||
h.k32.CancelIo(self.handle)
|
||||
h.k32.CloseHandle(self.event)
|
||||
h.k32.CloseHandle(self.handle)
|
||||
|
||||
def empty_hint(self):
|
||||
return 'Check the cable, push the cap firmly, and try again.'
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('--list', action='store_true', help='only list matching devices')
|
||||
ap.add_argument('--device', help='device path from --list, if more than one is found')
|
||||
ap.add_argument('--descriptor', help=argparse.SUPPRESS) # testing without a device
|
||||
ap.add_argument('--yes', action='store_true', help=argparse.SUPPRESS) # no Enter prompts
|
||||
ap.add_argument('-o', '--output', help='output file (default: spacemouse-capture-<product>.json)')
|
||||
ap.add_argument('--seconds', type=float, default=1.0,
|
||||
help='multiply each step\'s recording time (default: 1.0, e.g. 3 triples it)')
|
||||
args = ap.parse_args()
|
||||
|
||||
Device = {'darwin': MacDevice, 'win32': WinDevice}.get(sys.platform, HidrawDevice)
|
||||
devices = Device.find()
|
||||
if args.list:
|
||||
for d in devices:
|
||||
print('%s %s:%s %s' % (d.path, d.vendor, d.product, d.name))
|
||||
if not devices:
|
||||
print('No 3Dconnexion device found.')
|
||||
return 0 if devices else 1
|
||||
|
||||
if args.device:
|
||||
dev = next((d for d in devices if d.path == args.device), None)
|
||||
if dev is None:
|
||||
if Device is not HidrawDevice:
|
||||
sys.exit('No device %s (try --list)' % args.device)
|
||||
dev = HidrawDevice(args.device)
|
||||
elif len(devices) == 1:
|
||||
dev = devices[0]
|
||||
elif not devices:
|
||||
sys.exit('No 3Dconnexion device found. Is it plugged in? (try --list)')
|
||||
else:
|
||||
sys.exit('Several devices found, pick one with --device:\n' +
|
||||
'\n'.join(' %s %s' % (d.path, d.name) for d in devices))
|
||||
|
||||
if args.descriptor:
|
||||
with open(args.descriptor, 'rb') as f:
|
||||
descriptor = f.read().hex()
|
||||
else:
|
||||
descriptor = dev.descriptor()
|
||||
|
||||
dev.open()
|
||||
print('Recording from %s (%s, %s:%s).' % (dev.path, dev.name, dev.vendor, dev.product))
|
||||
readers = other_readers()
|
||||
if readers:
|
||||
print('Also running: %s. If nothing gets recorded, quit it and try again.' % ', '.join(readers))
|
||||
print('For each step, press Enter, do the movement, and wait for the next prompt.\n')
|
||||
|
||||
result = {
|
||||
'tool': 'spacemouse-capture.py 2',
|
||||
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
|
||||
'system': platform.platform(),
|
||||
'backend': dev.backend,
|
||||
'other_readers': readers,
|
||||
'device': {'name': dev.name, 'vendor': dev.vendor, 'product': dev.product},
|
||||
'report_descriptor': descriptor,
|
||||
'steps': [],
|
||||
}
|
||||
try:
|
||||
for i, (key, text, seconds) in enumerate(STEPS, 1):
|
||||
seconds = seconds * args.seconds
|
||||
print('[%d/%d] %s' % (i, len(STEPS), text))
|
||||
if not args.yes:
|
||||
input(' Press Enter to start (%.0f s)... ' % seconds)
|
||||
dev.drain()
|
||||
reports = dev.record(seconds)
|
||||
print(' %d reports recorded.\n' % len(reports))
|
||||
result['steps'].append({'step': key, 'instruction': text, 'reports': reports})
|
||||
except KeyboardInterrupt:
|
||||
print('\nStopped early -- saving what was recorded so far.')
|
||||
finally:
|
||||
dev.close()
|
||||
|
||||
out = args.output or 'spacemouse-capture-%s.json' % dev.product
|
||||
with open(out, 'w') as f:
|
||||
json.dump(result, f, indent=1)
|
||||
total = sum(len(s['reports']) for s in result['steps'])
|
||||
print('Saved %s (%d reports in total).' % (out, total))
|
||||
if total == 0:
|
||||
print('Nothing was recorded. ' + dev.empty_hint())
|
||||
else:
|
||||
print('Please attach this file to discussion #599. Thank you!')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,940 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "elementpickerpopup.h"
|
||||
|
||||
#include "elementscollectionwidget.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QListWidget>
|
||||
#include <QMenu>
|
||||
#include <QPushButton>
|
||||
#include <QToolButton>
|
||||
#include <QCloseEvent>
|
||||
#include <QCursor>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QScreen>
|
||||
#include <QSizeGrip>
|
||||
#include <QStandardItemModel>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QSettings>
|
||||
|
||||
#include <limits>
|
||||
|
||||
#include "../qetapp.h"
|
||||
#include "../shortcutmanager.h"
|
||||
#include "elementslocation.h"
|
||||
|
||||
//The palette is read from disk each time the picker opens. It is meant as
|
||||
//a shortlist, and a large custom collection would otherwise make opening
|
||||
//slow and the grid unusable.
|
||||
static const int max_palette_entries = 60;
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::ElementPickerPopup
|
||||
@param source : the collection widget whose model the search runs against
|
||||
@param parent
|
||||
*/
|
||||
ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source,
|
||||
QWidget *parent) :
|
||||
QFrame(parent, Qt::Popup),
|
||||
m_source(source)
|
||||
{
|
||||
setFrameShape(QFrame::StyledPanel);
|
||||
setFrameShadow(QFrame::Raised);
|
||||
setMinimumWidth(340);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(6, 6, 6, 6);
|
||||
layout->setSpacing(4);
|
||||
|
||||
//Command row, shown when the picker is opened as the shortcut bar
|
||||
m_commands = new QWidget(this);
|
||||
m_commands_layout = new QHBoxLayout(m_commands);
|
||||
m_commands_layout->setContentsMargins(0, 0, 0, 0);
|
||||
m_commands_layout->setSpacing(2);
|
||||
m_commands->hide();
|
||||
//SolidWorks: right-click the bar to customise it
|
||||
m_commands->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
connect(m_commands, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) {
|
||||
QMenu menu;
|
||||
menu.addAction(tr("Personnaliser la barre…"), this, &ElementPickerPopup::startCustomising);
|
||||
menu.exec(m_commands->mapToGlobal(pos));
|
||||
});
|
||||
|
||||
//Customising, shown instead of everything else. Two lists that
|
||||
//commands are dragged between: the bar, left to right, and the
|
||||
//commands that are not on it.
|
||||
m_editor = new QWidget(this);
|
||||
auto *editor_layout = new QVBoxLayout(m_editor);
|
||||
editor_layout->setContentsMargins(0, 0, 0, 0);
|
||||
auto *help = new QLabel(
|
||||
tr("Glissez les commandes et les éléments dans la barre, hors de la "
|
||||
"barre, ou d'une place à l'autre. Un double-clic fait passer une "
|
||||
"commande ou un élément d'une liste à l'autre."), m_editor);
|
||||
help->setWordWrap(true);
|
||||
m_edit_row = new QListWidget(m_editor);
|
||||
m_edit_row->setFlow(QListView::LeftToRight);
|
||||
m_edit_row->setWrapping(false);
|
||||
m_edit_row->setIconSize(QSize(24, 24));
|
||||
m_edit_row->setFixedHeight(44);
|
||||
m_edit_available = new QListWidget(m_editor);
|
||||
m_edit_available->setIconSize(QSize(20, 20));
|
||||
m_edit_available->setMinimumHeight(220);
|
||||
for (QListWidget *list : {m_edit_row, m_edit_available}) {
|
||||
list->setDragDropMode(QAbstractItemView::DragDrop);
|
||||
list->setDefaultDropAction(Qt::MoveAction);
|
||||
list->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
}
|
||||
//A drop copies the item as it was in the other list, so a command
|
||||
//dragged onto the bar would keep its text and one dragged off would
|
||||
//have none. Set the text for the list it has landed in.
|
||||
//Deferred: a drop inserts the row first and copies the item's data
|
||||
//into it afterwards.
|
||||
auto relabel = [this](QListWidget *list, bool icon_only) {
|
||||
return [this, list, icon_only]() {
|
||||
QTimer::singleShot(0, this, [list, icon_only]() {
|
||||
for (int i = 0 ; i < list->count() ; ++i) {
|
||||
QListWidgetItem *item = list->item(i);
|
||||
item->setText(icon_only && !item->icon().isNull()
|
||||
? QString() : item->toolTip());
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
connect(m_edit_row->model(), &QAbstractItemModel::rowsInserted,
|
||||
this, relabel(m_edit_row, true));
|
||||
connect(m_edit_available->model(), &QAbstractItemModel::rowsInserted,
|
||||
this, relabel(m_edit_available, false));
|
||||
//An element dragged off the bar onto the commands is removed from
|
||||
//the bar, not listed as a command
|
||||
connect(m_edit_available->model(), &QAbstractItemModel::rowsInserted, this, [this]() {
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
for (int i = m_edit_available->count() - 1 ; i >= 0 ; --i) {
|
||||
if (ShortcutBarSettings::isElement(
|
||||
m_edit_available->item(i)->data(Qt::UserRole).toString())) {
|
||||
delete m_edit_available->takeItem(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
connect(m_edit_row, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) {
|
||||
const QString id = item->data(Qt::UserRole).toString();
|
||||
delete item;
|
||||
if (!ShortcutBarSettings::isElement(id)) {
|
||||
m_edit_available->addItem(barItem(id, false));
|
||||
}
|
||||
});
|
||||
connect(m_edit_available, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) {
|
||||
const QString id = item->data(Qt::UserRole).toString();
|
||||
delete item;
|
||||
m_edit_row->addItem(barItem(id, true));
|
||||
});
|
||||
|
||||
//Elements to pin: the same ranked search as the picker, dragged
|
||||
//onto the bar. Copied, not moved, so a result can be pinned and
|
||||
//still be seen in the list.
|
||||
m_edit_symbols_box = new QWidget(m_editor);
|
||||
auto *symbols_layout = new QVBoxLayout(m_edit_symbols_box);
|
||||
symbols_layout->setContentsMargins(0, 0, 0, 0);
|
||||
m_edit_symbols_search = new QLineEdit(m_edit_symbols_box);
|
||||
m_edit_symbols_search->setPlaceholderText(tr("Rechercher un élément…"));
|
||||
m_edit_symbols_search->setClearButtonEnabled(true);
|
||||
m_edit_symbols = new QListWidget(m_edit_symbols_box);
|
||||
m_edit_symbols->setIconSize(QSize(32, 32));
|
||||
m_edit_symbols->setMinimumHeight(220);
|
||||
m_edit_symbols->setDragDropMode(QAbstractItemView::DragOnly);
|
||||
m_edit_symbols->setDefaultDropAction(Qt::CopyAction);
|
||||
m_edit_symbols->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
symbols_layout->addWidget(new QLabel(tr("Éléments :"), m_edit_symbols_box));
|
||||
symbols_layout->addWidget(m_edit_symbols_search);
|
||||
symbols_layout->addWidget(m_edit_symbols);
|
||||
|
||||
auto *symbols_timer = new QTimer(this);
|
||||
symbols_timer->setSingleShot(true);
|
||||
symbols_timer->setInterval(300);
|
||||
connect(m_edit_symbols_search, &QLineEdit::textChanged, this,
|
||||
[symbols_timer]() { symbols_timer->start(); });
|
||||
connect(symbols_timer, &QTimer::timeout, this, &ElementPickerPopup::runSymbolSearch);
|
||||
connect(m_edit_symbols, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) {
|
||||
m_edit_row->addItem(barItem(item->data(Qt::UserRole).toString(), true));
|
||||
});
|
||||
auto *defaults = new QPushButton(tr("Valeurs par défaut"), m_editor);
|
||||
auto *cancel = new QPushButton(tr("Annuler"), m_editor);
|
||||
auto *done = new QPushButton(tr("Terminé"), m_editor);
|
||||
done->setDefault(true);
|
||||
connect(defaults, &QPushButton::clicked, this, [this]() {
|
||||
fillCustomising(ShortcutBarSettings::defaultIds(m_context));
|
||||
});
|
||||
connect(cancel, &QPushButton::clicked, this, [this]() { finishCustomising(false); });
|
||||
connect(done, &QPushButton::clicked, this, [this]() { finishCustomising(true); });
|
||||
auto *editor_buttons = new QHBoxLayout();
|
||||
editor_buttons->addWidget(defaults);
|
||||
editor_buttons->addStretch();
|
||||
editor_buttons->addWidget(cancel);
|
||||
editor_buttons->addWidget(done);
|
||||
editor_layout->addWidget(help);
|
||||
editor_layout->addWidget(new QLabel(tr("Dans la barre :"), m_editor));
|
||||
editor_layout->addWidget(m_edit_row);
|
||||
auto *lists = new QHBoxLayout();
|
||||
auto *commands_column = new QVBoxLayout();
|
||||
commands_column->addWidget(new QLabel(tr("Autres commandes :"), m_editor));
|
||||
commands_column->addWidget(m_edit_available);
|
||||
lists->addLayout(commands_column);
|
||||
lists->addWidget(m_edit_symbols_box);
|
||||
editor_layout->addLayout(lists);
|
||||
editor_layout->addLayout(editor_buttons);
|
||||
m_editor->hide();
|
||||
|
||||
m_search = new QLineEdit(this);
|
||||
m_search->setPlaceholderText(tr("Rechercher un élément…"));
|
||||
m_search->setClearButtonEnabled(true);
|
||||
|
||||
m_model = new QStandardItemModel(this);
|
||||
m_view = new QListView(this);
|
||||
m_view->setModel(m_model);
|
||||
m_view->setIconSize(QSize(40, 40));
|
||||
m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
||||
m_view->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
m_view->setMinimumHeight(260);
|
||||
|
||||
m_hint = new QLabel(tr("Entrée pour insérer · Échap pour fermer"), this);
|
||||
m_hint->setEnabled(false);
|
||||
//Wrapped rather than cut off when the bar is made narrow
|
||||
m_hint->setWordWrap(true);
|
||||
|
||||
//The bar's width is the user's: dragging the grip wraps the tiles
|
||||
//onto more rows, and the width is kept for next time
|
||||
m_grip = new QSizeGrip(this);
|
||||
m_grip->setToolTip(tr("Glisser pour changer la largeur de la barre"));
|
||||
m_grip->installEventFilter(this);
|
||||
auto *hint_row = new QHBoxLayout();
|
||||
hint_row->addWidget(m_hint, 1);
|
||||
hint_row->addWidget(m_grip, 0, Qt::AlignBottom | Qt::AlignRight);
|
||||
|
||||
layout->addWidget(m_commands);
|
||||
layout->addWidget(m_search);
|
||||
layout->addWidget(m_view);
|
||||
layout->addLayout(hint_row);
|
||||
layout->addWidget(m_editor);
|
||||
|
||||
//Search as you type, after a short idle: shorter than the dock's
|
||||
//500 ms, since the popup is opened and closed in a few seconds, but
|
||||
//the collection is large enough that every keystroke would show.
|
||||
auto *timer = new QTimer(this);
|
||||
timer->setSingleShot(true);
|
||||
timer->setInterval(300);
|
||||
connect(m_search, &QLineEdit::textChanged, this,
|
||||
[timer]() { timer->start(); });
|
||||
connect(timer, &QTimer::timeout, this, &ElementPickerPopup::runSearch);
|
||||
|
||||
connect(m_view, &QListView::doubleClicked, this,
|
||||
[this](const QModelIndex &) { chooseCurrent(); });
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::popUpAt
|
||||
Show the picker at @a global_pos, kept on screen, with the search field
|
||||
focused and any previous query cleared.
|
||||
@param global_pos
|
||||
*/
|
||||
void ElementPickerPopup::popUpAt(const QPoint &global_pos)
|
||||
{
|
||||
m_bar_mode = false;
|
||||
setCommands({});
|
||||
show(global_pos);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::popUpShortcutBar
|
||||
Show the picker at @a global_pos with the shortcut bar's commands for
|
||||
@a context above it.
|
||||
@param global_pos
|
||||
@param context
|
||||
*/
|
||||
void ElementPickerPopup::popUpShortcutBar(const QPoint &global_pos,
|
||||
ShortcutBarSettings::Context context)
|
||||
{
|
||||
m_bar_mode = true;
|
||||
m_context = context;
|
||||
setCommands(ShortcutBarSettings::ids(context));
|
||||
show(global_pos);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::commandAction
|
||||
@return the action registered under @a id by the window this popup
|
||||
belongs to, or nullptr
|
||||
*/
|
||||
QAction *ElementPickerPopup::commandAction(const QString &id) const
|
||||
{
|
||||
return ShortcutManager::instance().action(id, parentWidget());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::show
|
||||
Reset the search and show at @a global_pos, kept on screen.
|
||||
*/
|
||||
void ElementPickerPopup::show(const QPoint &global_pos)
|
||||
{
|
||||
m_search->clear();
|
||||
m_model->clear();
|
||||
showPalette();
|
||||
|
||||
keepOnScreen(global_pos);
|
||||
QFrame::show();
|
||||
m_search->setFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::keepOnScreen
|
||||
Fit the popup to its contents and move it to @a global_pos, kept fully on
|
||||
the screen that point is on: opening at the cursor near a right or bottom
|
||||
edge would otherwise push it off.
|
||||
*/
|
||||
void ElementPickerPopup::keepOnScreen(const QPoint &global_pos)
|
||||
{
|
||||
adjustSize();
|
||||
//The sizes the user gave the customising window and the bar
|
||||
if (m_customising) {
|
||||
const QSize size = ShortcutBarSettings::editorSize();
|
||||
if (size.isValid()) {
|
||||
resize(size.expandedTo(minimumSizeHint()));
|
||||
}
|
||||
} else if (m_bar_mode && ShortcutBarSettings::barWidth() > 0) {
|
||||
resize(qMax(ShortcutBarSettings::barWidth(), minimumSizeHint().width()),
|
||||
height());
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::setCommands
|
||||
Show @a ids as a row of buttons above the search field, or hide the row
|
||||
when there are none. A disabled command keeps its place, greyed out, so
|
||||
the row looks the same every time for a given selection. An id no live
|
||||
action carries, or a pinned element that no longer exists, is skipped.
|
||||
|
||||
Clicking a button closes the picker first, then triggers the action: a
|
||||
command such as "add a line" starts a mode on the folio, which needs the
|
||||
focus the popup holds. A pinned element is placed, as if chosen from the
|
||||
picker.
|
||||
@param ids : command ids and pinned elements' collection paths
|
||||
*/
|
||||
void ElementPickerPopup::setCommands(const QStringList &ids)
|
||||
{
|
||||
while (QLayoutItem *item = m_commands_layout->takeAt(0)) {
|
||||
delete item->widget();
|
||||
delete item;
|
||||
}
|
||||
qDeleteAll(m_tiles);
|
||||
m_tiles.clear();
|
||||
m_tile_rows = new QVBoxLayout();
|
||||
m_tile_rows->setSpacing(2);
|
||||
m_commands_layout->addLayout(m_tile_rows);
|
||||
|
||||
for (const QString &id : ids)
|
||||
{
|
||||
if (ShortcutBarSettings::isElement(id))
|
||||
{
|
||||
const ElementsLocation location(id);
|
||||
if (!location.exist()) {
|
||||
continue;
|
||||
}
|
||||
auto *button = new QToolButton(m_commands);
|
||||
button->setAutoRaise(true);
|
||||
button->setIconSize(QSize(24, 24));
|
||||
button->setIcon(location.icon());
|
||||
button->setToolTip(location.name());
|
||||
button->setFocusPolicy(Qt::NoFocus);
|
||||
connect(button, &QToolButton::clicked, this, [this, location]() {
|
||||
hide();
|
||||
emit elementChosen(location);
|
||||
});
|
||||
m_tiles << button;
|
||||
continue;
|
||||
}
|
||||
|
||||
QAction *action = commandAction(id);
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
auto *button = new QToolButton(m_commands);
|
||||
button->setAutoRaise(true);
|
||||
button->setIconSize(QSize(24, 24));
|
||||
const QString text = action->text().remove(QLatin1Char('&'));
|
||||
if (action->icon().isNull()) {
|
||||
button->setText(text);
|
||||
button->setToolButtonStyle(Qt::ToolButtonTextOnly);
|
||||
} else {
|
||||
button->setIcon(action->icon());
|
||||
}
|
||||
const QKeySequence key = action->shortcut();
|
||||
QString tip = key.isEmpty()
|
||||
? text
|
||||
: QStringLiteral("%1 (%2)").arg(
|
||||
text, key.toString(QKeySequence::NativeText));
|
||||
//The status tip says what the command does, which a short
|
||||
//name such as "Orienter les textes" does not
|
||||
if (!action->statusTip().isEmpty()) {
|
||||
tip += QLatin1Char('\n') + action->statusTip();
|
||||
}
|
||||
button->setToolTip(tip);
|
||||
button->setEnabled(action->isEnabled());
|
||||
button->setFocusPolicy(Qt::NoFocus);
|
||||
connect(button, &QToolButton::clicked, this, [this, action]() {
|
||||
hide();
|
||||
action->trigger();
|
||||
});
|
||||
m_tiles << button;
|
||||
}
|
||||
m_commands_layout->addStretch();
|
||||
if (m_bar_mode)
|
||||
{
|
||||
auto *customise = new QToolButton(m_commands);
|
||||
customise->setAutoRaise(true);
|
||||
customise->setText(QStringLiteral("…"));
|
||||
customise->setToolTip(tr("Personnaliser la barre…"));
|
||||
customise->setFocusPolicy(Qt::NoFocus);
|
||||
connect(customise, &QToolButton::clicked, this, &ElementPickerPopup::startCustomising);
|
||||
m_commands_layout->addWidget(customise, 0, Qt::AlignTop);
|
||||
}
|
||||
layoutTiles(ShortcutBarSettings::barWidth());
|
||||
m_grip->setVisible(m_bar_mode);
|
||||
//An empty bar still shows, so it can be customised back
|
||||
m_commands->setVisible(m_bar_mode || !ids.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::layoutTiles
|
||||
Put the bar's tiles in rows no wider than the bar at @a bar_width, in
|
||||
their order, as many per row as fit. A @a bar_width of 0 or less, the
|
||||
width the user never set, keeps them on one row.
|
||||
*/
|
||||
void ElementPickerPopup::layoutTiles(int bar_width)
|
||||
{
|
||||
if (!m_tile_rows) {
|
||||
return;
|
||||
}
|
||||
//Emptying a row layout leaves its buttons alone; they go into the
|
||||
//new rows below
|
||||
while (QLayoutItem *item = m_tile_rows->takeAt(0)) {
|
||||
delete item->layout();
|
||||
}
|
||||
|
||||
//Room left beside the tiles: the popup's margins and the "…" button
|
||||
const int limit = bar_width > 0
|
||||
? bar_width - 12 - 30
|
||||
: std::numeric_limits<int>::max();
|
||||
QHBoxLayout *row = nullptr;
|
||||
int used = 0;
|
||||
for (QToolButton *tile : std::as_const(m_tiles))
|
||||
{
|
||||
const int w = tile->sizeHint().width() + m_tile_rows->spacing();
|
||||
if (!row || (used + w > limit && used > 0)) {
|
||||
row = new QHBoxLayout();
|
||||
row->setSpacing(2);
|
||||
row->addStretch();
|
||||
m_tile_rows->addLayout(row);
|
||||
used = 0;
|
||||
}
|
||||
//Before the stretch, which stays last so the row is left-aligned
|
||||
row->insertWidget(row->count() - 1, tile);
|
||||
used += w;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::eventFilter
|
||||
Drive the size grip by hand. QSizeGrip asks the window manager to do the
|
||||
resize, and a popup is a window the window manager does not manage, so
|
||||
on X11 nothing would happen. Only the width follows the mouse: the tiles
|
||||
re-flow into rows as it changes, the height follows the rows, and the
|
||||
width is saved when the grip is let go.
|
||||
*/
|
||||
bool ElementPickerPopup::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched != m_grip) {
|
||||
return QFrame::eventFilter(watched, event);
|
||||
}
|
||||
switch (event->type())
|
||||
{
|
||||
case QEvent::MouseButtonPress: {
|
||||
auto *me = static_cast<QMouseEvent *>(event);
|
||||
if (me->button() != Qt::LeftButton) {
|
||||
break;
|
||||
}
|
||||
m_grip_active = true;
|
||||
m_grip_press_x = me->globalPosition().toPoint().x();
|
||||
m_grip_press_width = width();
|
||||
return true;
|
||||
}
|
||||
case QEvent::MouseMove: {
|
||||
if (!m_grip_active) {
|
||||
break;
|
||||
}
|
||||
auto *me = static_cast<QMouseEvent *>(event);
|
||||
const int w = qMax(minimumWidth(), m_grip_press_width
|
||||
+ me->globalPosition().toPoint().x() - m_grip_press_x);
|
||||
layoutTiles(w);
|
||||
layout()->activate();
|
||||
resize(w, sizeHint().height());
|
||||
return true;
|
||||
}
|
||||
case QEvent::MouseButtonRelease:
|
||||
if (!m_grip_active) {
|
||||
break;
|
||||
}
|
||||
m_grip_active = false;
|
||||
ShortcutBarSettings::setBarWidth(width());
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return QFrame::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::barItem
|
||||
@return a list item for @a id, a command or a pinned element: icon only,
|
||||
for the bar row, or icon and text, for the other lists. The id is in
|
||||
UserRole.
|
||||
*/
|
||||
QListWidgetItem *ElementPickerPopup::barItem(const QString &id, bool icon_only) const
|
||||
{
|
||||
QString text = id;
|
||||
QIcon icon;
|
||||
if (ShortcutBarSettings::isElement(id)) {
|
||||
const ElementsLocation location(id);
|
||||
if (location.exist()) {
|
||||
text = location.name();
|
||||
icon = location.icon();
|
||||
}
|
||||
} else if (QAction *action = commandAction(id)) {
|
||||
text = action->text().remove(QLatin1Char('&'));
|
||||
icon = action->icon();
|
||||
}
|
||||
auto *item = new QListWidgetItem();
|
||||
item->setData(Qt::UserRole, id);
|
||||
item->setToolTip(text);
|
||||
if (!icon.isNull()) {
|
||||
item->setIcon(icon);
|
||||
}
|
||||
if (!icon_only || item->icon().isNull()) {
|
||||
item->setText(text);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::startCustomising
|
||||
Turn the bar into a small window holding the two lists. A Qt::Popup
|
||||
closes as soon as the mouse is pressed outside it and keeps the mouse
|
||||
grab during a drag, so the window is re-shown as a tool window for the
|
||||
time of the edit.
|
||||
*/
|
||||
void ElementPickerPopup::startCustomising()
|
||||
{
|
||||
const QPoint where = pos();
|
||||
m_customising = true;
|
||||
hide();
|
||||
setWindowFlags(Qt::Tool);
|
||||
setWindowTitle(tr("Personnaliser la barre de raccourcis : %1")
|
||||
.arg(ShortcutBarSettings::title(m_context)));
|
||||
|
||||
fillCustomising(ShortcutBarSettings::ids(m_context));
|
||||
m_commands->hide();
|
||||
setPickerVisible(false);
|
||||
m_grip->hide();
|
||||
//Elements are pinned to the empty-folio bar only: with something
|
||||
//selected, the bar is for acting on it, not for adding more
|
||||
m_edit_symbols_search->clear();
|
||||
m_edit_symbols->clear();
|
||||
m_edit_symbols_box->setVisible(m_context == ShortcutBarSettings::Canvas);
|
||||
m_editor->show();
|
||||
|
||||
keepOnScreen(where);
|
||||
QFrame::show();
|
||||
activateWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::fillCustomising
|
||||
Put @a ids on the bar row, and every other available command in the
|
||||
list below it.
|
||||
*/
|
||||
void ElementPickerPopup::fillCustomising(const QStringList &ids)
|
||||
{
|
||||
m_edit_row->clear();
|
||||
m_edit_available->clear();
|
||||
for (const QString &id : ids) {
|
||||
m_edit_row->addItem(barItem(id, true));
|
||||
}
|
||||
for (const QString &id : ShortcutBarSettings::availableIds()) {
|
||||
if (!ids.contains(id)) {
|
||||
m_edit_available->addItem(barItem(id, false));
|
||||
}
|
||||
}
|
||||
m_edit_available->sortItems();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::runSymbolSearch
|
||||
Fill the customising window's element list with the ranked hits for its
|
||||
search field. Elements embedded in a project are left out: their path
|
||||
names the project as it is loaded now, so it would not find them again
|
||||
in a later session.
|
||||
*/
|
||||
void ElementPickerPopup::runSymbolSearch()
|
||||
{
|
||||
m_edit_symbols->clear();
|
||||
if (!m_source) {
|
||||
return;
|
||||
}
|
||||
const QVector<ElementSearchHit> hits =
|
||||
m_source->rankedSearch(m_edit_symbols_search->text());
|
||||
for (const ElementSearchHit &hit : hits)
|
||||
{
|
||||
if (ElementsLocation(hit.path).isProject()) {
|
||||
continue;
|
||||
}
|
||||
auto *item = new QListWidgetItem(hit.icon, hit.name);
|
||||
item->setData(Qt::UserRole, hit.path);
|
||||
item->setToolTip(QStringLiteral("%1\n%2").arg(hit.name, hit.folder));
|
||||
m_edit_symbols->addItem(item);
|
||||
if (m_edit_symbols->count() >= max_palette_entries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::finishCustomising
|
||||
Leave the edit, saving the bar row when @a save, and show the bar again
|
||||
at the cursor so the result can be seen and used straight away. The
|
||||
window's size is kept either way.
|
||||
*/
|
||||
void ElementPickerPopup::finishCustomising(bool save)
|
||||
{
|
||||
if (save)
|
||||
{
|
||||
QStringList ids;
|
||||
for (int i = 0 ; i < m_edit_row->count() ; ++i) {
|
||||
ids << m_edit_row->item(i)->data(Qt::UserRole).toString();
|
||||
}
|
||||
ShortcutBarSettings::setIds(m_context, ids);
|
||||
}
|
||||
|
||||
ShortcutBarSettings::setEditorSize(size());
|
||||
m_customising = false;
|
||||
hide();
|
||||
m_editor->hide();
|
||||
setPickerVisible(true);
|
||||
setWindowFlags(Qt::Popup);
|
||||
setWindowTitle(QString());
|
||||
//At the cursor, like any other opening of the bar
|
||||
popUpShortcutBar(QCursor::pos(), m_context);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::closeEvent
|
||||
Closing the customising window from its title bar cancels the edit,
|
||||
like the Annuler button, rather than leaving the bar half-edited.
|
||||
*/
|
||||
void ElementPickerPopup::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
if (m_customising) {
|
||||
event->ignore();
|
||||
finishCustomising(false);
|
||||
return;
|
||||
}
|
||||
QFrame::closeEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::setPickerVisible
|
||||
Show or hide the search field, the results and the hint line.
|
||||
*/
|
||||
void ElementPickerPopup::setPickerVisible(bool visible)
|
||||
{
|
||||
m_search->setVisible(visible);
|
||||
m_view->setVisible(visible);
|
||||
m_hint->setVisible(visible);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::runSearch
|
||||
*/
|
||||
void ElementPickerPopup::runSearch()
|
||||
{
|
||||
m_model->clear();
|
||||
if (!m_source) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_search->text().isEmpty()) {
|
||||
const bool was_hidden = m_view->isHidden();
|
||||
showPalette();
|
||||
if (was_hidden != m_view->isHidden()) {
|
||||
keepOnScreen(pos());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_view->isHidden()) {
|
||||
m_view->show();
|
||||
keepOnScreen(pos());
|
||||
}
|
||||
m_palette_mode = false;
|
||||
m_view->setViewMode(QListView::ListMode);
|
||||
m_view->setGridSize(QSize());
|
||||
m_view->setIconSize(QSize(40, 40));
|
||||
|
||||
const QVector<ElementSearchHit> hits = m_source->rankedSearch(m_search->text());
|
||||
for (const ElementSearchHit &hit : hits)
|
||||
{
|
||||
auto *item = new QStandardItem(hit.name);
|
||||
item->setIcon(hit.icon);
|
||||
item->setEditable(false);
|
||||
item->setToolTip(hit.folder);
|
||||
item->setData(hit.path, Qt::UserRole + 2);
|
||||
m_model->appendRow(item);
|
||||
}
|
||||
|
||||
//Preselect the best hit so Enter works straight from the search field
|
||||
//without arrowing down first -- that is the whole point of the popup.
|
||||
if (m_model->rowCount()) {
|
||||
m_view->setCurrentIndex(m_model->index(0, 0));
|
||||
}
|
||||
m_hint->setText(hits.isEmpty()
|
||||
? tr("Aucun résultat")
|
||||
: tr("Entrée pour insérer · Échap pour fermer"));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::chooseCurrent
|
||||
Emit the highlighted element and close.
|
||||
*/
|
||||
void ElementPickerPopup::chooseCurrent()
|
||||
{
|
||||
const QModelIndex index = m_view->currentIndex();
|
||||
if (!index.isValid()) {
|
||||
return;
|
||||
}
|
||||
const QString path = index.data(Qt::UserRole + 2).toString();
|
||||
if (path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ElementsLocation location(path);
|
||||
if (!location.exist()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Close before emitting: placement mode wants the focus, and a popup
|
||||
//still up would keep the grab.
|
||||
hide();
|
||||
emit elementChosen(location);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::keyPressEvent
|
||||
Drive the list from the search field, so the hands never leave the
|
||||
keyboard: Up/Down move the selection, Enter places, Esc closes.
|
||||
@param event
|
||||
*/
|
||||
void ElementPickerPopup::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
switch (event->key())
|
||||
{
|
||||
case Qt::Key_Escape:
|
||||
if (m_customising) {
|
||||
finishCustomising(false);
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
return;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
if (m_customising && m_edit_symbols_search->hasFocus()) {
|
||||
//Enter in the element search pins the best hit rather
|
||||
//than closing the window. Searched now, not on the
|
||||
//timer, so a quick Enter after typing gets this text.
|
||||
runSymbolSearch();
|
||||
if (QListWidgetItem *item = m_edit_symbols->item(0)) {
|
||||
m_edit_row->addItem(barItem(item->data(Qt::UserRole).toString(), true));
|
||||
}
|
||||
} else if (m_customising) {
|
||||
finishCustomising(true);
|
||||
} else {
|
||||
chooseCurrent();
|
||||
}
|
||||
return;
|
||||
case Qt::Key_Down:
|
||||
case Qt::Key_Up:
|
||||
case Qt::Key_PageDown:
|
||||
case Qt::Key_PageUp:
|
||||
if (!m_customising && m_model->rowCount()) {
|
||||
//Forwarded rather than focus-switched, so typing carries
|
||||
//on going to the search field.
|
||||
QCoreApplication::sendEvent(m_view, event);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
QFrame::keyPressEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::showPalette
|
||||
With an empty search field, show the quick palette as an icon grid.
|
||||
|
||||
The palette is a folder, not a config file. QET already has a user-owned,
|
||||
drag-populated, icon-rendering collection -- so a palette is just a
|
||||
directory whose subfolders are categories and whose contents are entries.
|
||||
That gives set-up (drag into the folder), ordering (the 01_/02_ filename
|
||||
convention the shipped collection already uses), team sharing
|
||||
(companyElementsDir) and version control for free, with no schema, parser,
|
||||
merge rules or "reset to defaults" to build or maintain.
|
||||
|
||||
Only the path lives in QSettings, defaulting to the user's custom
|
||||
collection.
|
||||
*/
|
||||
void ElementPickerPopup::showPalette()
|
||||
{
|
||||
m_palette_mode = true;
|
||||
m_model->clear();
|
||||
|
||||
//Elements pinned to the bar replace the folder grid: the bar already
|
||||
//shows the user's shortlist, and a second one below it is noise.
|
||||
//The search still works; the list comes back as soon as one types.
|
||||
if (m_bar_mode && ShortcutBarSettings::hasElements(m_context)) {
|
||||
m_view->hide();
|
||||
m_hint->setText(tr("Tapez pour rechercher un élément · Échap pour fermer"));
|
||||
return;
|
||||
}
|
||||
m_view->show();
|
||||
|
||||
m_view->setViewMode(QListView::IconMode);
|
||||
m_view->setIconSize(QSize(48, 48));
|
||||
m_view->setGridSize(QSize(92, 84));
|
||||
m_view->setResizeMode(QListView::Adjust);
|
||||
m_view->setWordWrap(true);
|
||||
m_view->setMovement(QListView::Static);
|
||||
|
||||
QSettings settings;
|
||||
const QString path = settings.value(
|
||||
QStringLiteral("elementscollection/palette-path"),
|
||||
QETApp::customElementsDir()).toString();
|
||||
|
||||
const int count = loadPaletteDir(path, QString(), 0);
|
||||
|
||||
if (!count) {
|
||||
m_hint->setText(
|
||||
tr("Palette vide — glissez des éléments dans votre collection "
|
||||
"personnelle, ou tapez pour rechercher"));
|
||||
} else {
|
||||
m_hint->setText(tr("Entrée pour insérer · Échap pour fermer"));
|
||||
m_view->setCurrentIndex(m_model->index(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementPickerPopup::loadPaletteDir
|
||||
Add every .elmt under @a dir_path to the grid, recursing into subfolders.
|
||||
@param dir_path
|
||||
@param prefix : folder path so far, shown as the entry's tooltip
|
||||
@param depth : recursion guard -- a palette is a shortlist, and the whole
|
||||
shipped collection would not be usable as a grid anyway
|
||||
@return how many entries were added
|
||||
*/
|
||||
int ElementPickerPopup::loadPaletteDir(const QString &dir_path,
|
||||
const QString &prefix, int depth)
|
||||
{
|
||||
if (depth > 3 || m_model->rowCount() >= max_palette_entries) {
|
||||
return 0;
|
||||
}
|
||||
QDir dir(dir_path);
|
||||
if (!dir.exists()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
//Sorted by name, which is what makes the 01_/02_ filename convention
|
||||
//work as the ordering mechanism.
|
||||
const QFileInfoList entries = dir.entryInfoList(
|
||||
QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||
|
||||
for (const QFileInfo &info : entries)
|
||||
{
|
||||
if (m_model->rowCount() >= max_palette_entries) {
|
||||
break;
|
||||
}
|
||||
if (info.isDir()) {
|
||||
added += loadPaletteDir(info.absoluteFilePath(),
|
||||
prefix.isEmpty()
|
||||
? info.fileName()
|
||||
: prefix + QStringLiteral(" / ")
|
||||
+ info.fileName(),
|
||||
depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (info.suffix().compare(QStringLiteral("elmt"),
|
||||
Qt::CaseInsensitive) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ElementsLocation location(info.absoluteFilePath());
|
||||
if (!location.exist()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *item = new QStandardItem(location.name().isEmpty()
|
||||
? info.completeBaseName()
|
||||
: location.name());
|
||||
item->setIcon(location.icon());
|
||||
item->setEditable(false);
|
||||
item->setToolTip(prefix.isEmpty() ? info.completeBaseName() : prefix);
|
||||
item->setData(info.absoluteFilePath(), Qt::UserRole + 2);
|
||||
item->setTextAlignment(Qt::AlignHCenter | Qt::AlignTop);
|
||||
m_model->appendRow(item);
|
||||
++added;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef ELEMENTPICKERPOPUP_H
|
||||
#define ELEMENTPICKERPOPUP_H
|
||||
|
||||
#include "elementslocation.h"
|
||||
#include "../shortcutbarsettings.h"
|
||||
|
||||
#include <QFrame>
|
||||
|
||||
class ElementsCollectionWidget;
|
||||
class QLineEdit;
|
||||
class QListView;
|
||||
class QStandardItemModel;
|
||||
class QLabel;
|
||||
class QAction;
|
||||
class QHBoxLayout;
|
||||
class QListWidget;
|
||||
class QListWidgetItem;
|
||||
class QSizeGrip;
|
||||
class QToolButton;
|
||||
class QVBoxLayout;
|
||||
|
||||
/**
|
||||
@brief A cursor-anchored element picker.
|
||||
|
||||
Opens where the mouse is, focused on its search field: type to filter,
|
||||
Enter to place, Esc to close. It is a recall tool, as opposed to the
|
||||
Collections dock, which is a browsing tool.
|
||||
|
||||
It deliberately does not build its own ElementsCollectionModel. Loading the
|
||||
collection is already the slow part of startup and a second copy would
|
||||
double it, so the picker asks the dock's widget to run the query -- see
|
||||
ElementsCollectionWidget::rankedSearch(). That also means the two always
|
||||
agree on results and ranking.
|
||||
*/
|
||||
class ElementPickerPopup : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ElementPickerPopup(ElementsCollectionWidget *source,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
void popUpAt(const QPoint &global_pos);
|
||||
void popUpShortcutBar(const QPoint &global_pos,
|
||||
ShortcutBarSettings::Context context);
|
||||
|
||||
signals:
|
||||
/// Emitted when the user picks an element; the popup has closed
|
||||
void elementChosen(const ElementsLocation &location);
|
||||
|
||||
protected:
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void runSearch();
|
||||
void chooseCurrent();
|
||||
void showPalette();
|
||||
void show(const QPoint &global_pos);
|
||||
void setCommands(const QStringList &ids);
|
||||
void layoutTiles(int bar_width);
|
||||
QAction *commandAction(const QString &id) const;
|
||||
QListWidgetItem *barItem(const QString &id, bool icon_only) const;
|
||||
void startCustomising();
|
||||
void fillCustomising(const QStringList &ids);
|
||||
void finishCustomising(bool save);
|
||||
void runSymbolSearch();
|
||||
void setPickerVisible(bool visible);
|
||||
void keepOnScreen(const QPoint &global_pos);
|
||||
int loadPaletteDir(const QString &dir_path, const QString &prefix,
|
||||
int depth);
|
||||
|
||||
ElementsCollectionWidget *m_source = nullptr;
|
||||
QLineEdit *m_search = nullptr;
|
||||
QListView *m_view = nullptr;
|
||||
QStandardItemModel *m_model = nullptr;
|
||||
QLabel *m_hint = nullptr;
|
||||
QWidget *m_commands = nullptr;
|
||||
QHBoxLayout *m_commands_layout = nullptr;
|
||||
/// The bar's tiles, commands and elements, in order
|
||||
QList<QToolButton *> m_tiles;
|
||||
QVBoxLayout *m_tile_rows = nullptr;
|
||||
QSizeGrip *m_grip = nullptr;
|
||||
/// The size grip is being dragged, from this x at this width
|
||||
bool m_grip_active = false;
|
||||
int m_grip_press_x = 0;
|
||||
int m_grip_press_width = 0;
|
||||
/// Opened as the shortcut bar, as opposed to the plain picker
|
||||
bool m_bar_mode = false;
|
||||
ShortcutBarSettings::Context m_context = ShortcutBarSettings::Canvas;
|
||||
/// Customising the bar in place
|
||||
bool m_customising = false;
|
||||
QWidget *m_editor = nullptr;
|
||||
QListWidget *m_edit_row = nullptr;
|
||||
QListWidget *m_edit_available = nullptr;
|
||||
/// Element search, for pinning elements to the bar
|
||||
QWidget *m_edit_symbols_box = nullptr;
|
||||
QLineEdit *m_edit_symbols_search = nullptr;
|
||||
QListWidget *m_edit_symbols = nullptr;
|
||||
bool m_palette_mode = true;
|
||||
};
|
||||
|
||||
#endif // ELEMENTPICKERPOPUP_H
|
||||
@@ -503,6 +503,10 @@ void ElementsCollectionModel::addProject(QETProject *project, bool set_data)
|
||||
connect(project->embeddedElementCollection(),
|
||||
&XmlElementCollection::directoryRemoved,
|
||||
this, &ElementsCollectionModel::itemRemovedFromCollection);
|
||||
connect(project, &QETProject::projectTitleChanged,
|
||||
this, &ElementsCollectionModel::projectNameChanged);
|
||||
connect(project, &QETProject::projectFilePathChanged,
|
||||
this, &ElementsCollectionModel::projectNameChanged);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -534,6 +538,10 @@ void ElementsCollectionModel::removeProject(QETProject *project)
|
||||
&XmlElementCollection::directoryRemoved,
|
||||
this,
|
||||
&ElementsCollectionModel::itemRemovedFromCollection);
|
||||
disconnect(project, &QETProject::projectTitleChanged,
|
||||
this, &ElementsCollectionModel::projectNameChanged);
|
||||
disconnect(project, &QETProject::projectFilePathChanged,
|
||||
this, &ElementsCollectionModel::projectNameChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,3 +782,15 @@ void ElementsCollectionModel::updateItem(const QString& path)
|
||||
eci->setUpData();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionModel::projectNameChanged
|
||||
Update the displayed name of the collection of project,
|
||||
when its title or its file path changed.
|
||||
@param project
|
||||
*/
|
||||
void ElementsCollectionModel::projectNameChanged(QETProject *project)
|
||||
{
|
||||
if (XmlProjectElementCollectionItem *xpeci = m_project_hash.value(project))
|
||||
xpeci->updateProjectName();
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ class ElementsCollectionModel : public QStandardItemModel
|
||||
void elementIntegratedToCollection (const QString& path);
|
||||
void itemRemovedFromCollection (const QString& path);
|
||||
void updateItem (const QString& path);
|
||||
void projectNameChanged (QETProject *project);
|
||||
|
||||
private:
|
||||
QList <QETProject *> m_project_list;
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDesktopServices>
|
||||
#include <QSettings>
|
||||
#include <QShortcut>
|
||||
#include <QListView>
|
||||
#include <QStandardItemModel>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
@@ -49,6 +55,32 @@
|
||||
#include <QStatusBar>
|
||||
#include <QLineEdit>
|
||||
|
||||
namespace {
|
||||
/**
|
||||
@brief The SearchResultsView class
|
||||
The flat list of search results. Rows are not backed by the collection
|
||||
model, so the drag is built here from the path each row carries, with
|
||||
the same content and pixmap as a drag from the tree.
|
||||
*/
|
||||
class SearchResultsView : public QListView
|
||||
{
|
||||
public:
|
||||
using QListView::QListView;
|
||||
|
||||
protected:
|
||||
void startDrag(Qt::DropActions supportedActions) override
|
||||
{
|
||||
const QString path =
|
||||
currentIndex().data(Qt::UserRole + 2).toString();
|
||||
if (path.isEmpty()) {
|
||||
QListView::startDrag(supportedActions);
|
||||
return;
|
||||
}
|
||||
ElementsTreeView::execElementDrag(this, ElementsLocation(path));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::ElementsCollectionWidget
|
||||
Default constructor.
|
||||
@@ -217,8 +249,26 @@ void ElementsCollectionWidget::setUpWidget()
|
||||
m_tab_widget->addTab(m_tree_view, tr("Collections"));
|
||||
m_tab_widget->addTab(m_macros_tree_view, tr("Modèles"));
|
||||
|
||||
//Flat ranked search results.
|
||||
//The tree search hides non-matching rows, so hits stay scattered
|
||||
//through five levels of expanded folders -- searching "diode" leaves
|
||||
//you scrolling a tree to find them. This shows the same matches as a
|
||||
//ranked list instead, and takes the tab widget's place while a search
|
||||
//is active.
|
||||
m_search_model = new QStandardItemModel(this);
|
||||
m_search_results = new SearchResultsView(this);
|
||||
m_search_results->setModel(m_search_model);
|
||||
m_search_results->setDragDropMode(QAbstractItemView::DragOnly);
|
||||
m_search_results->setIconSize(QSize(50, 50));
|
||||
m_search_results->setUniformItemSizes(false);
|
||||
m_search_results->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
||||
m_search_results->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
m_search_results->setMouseTracking(true);
|
||||
m_search_results->hide();
|
||||
|
||||
m_main_vlayout->addWidget(m_search_field);
|
||||
m_main_vlayout->addWidget(m_tab_widget);
|
||||
m_main_vlayout->addWidget(m_search_results);
|
||||
|
||||
m_progress_bar = new QProgressBar(this);
|
||||
m_progress_bar->setFormat(QObject::tr("chargement %p% (%v sur %m)"));
|
||||
@@ -266,15 +316,7 @@ void ElementsCollectionWidget::setUpConnection()
|
||||
this, &ElementsCollectionWidget::dirProperties);
|
||||
|
||||
connect(m_tree_view, &QTreeView::doubleClicked,
|
||||
[this](const QModelIndex &index)
|
||||
{
|
||||
this->m_index_at_context_menu = index ;
|
||||
ElementCollectionItem *eci = elementCollectionItemForIndex(index);
|
||||
if (eci && eci->collectionPath().endsWith(".qetmak")) {
|
||||
return; // Do nothing on double click for macros
|
||||
}
|
||||
this->editElement();
|
||||
});
|
||||
[this](const QModelIndex &index) { this->activateIndex(index); });
|
||||
|
||||
connect(m_tree_view, &QTreeView::entered,
|
||||
[this] (const QModelIndex &index) {
|
||||
@@ -284,19 +326,58 @@ void ElementsCollectionWidget::setUpConnection()
|
||||
qde->statusBar()->showMessage(eci->localName());
|
||||
});
|
||||
|
||||
//Enter on the highlighted item does the same as a double click, so a
|
||||
//run of elements can be placed without leaving the keyboard. Bound as
|
||||
//a shortcut on the view rather than by reimplementing keyPressEvent,
|
||||
//which would mean subclassing ElementsTreeView for one key.
|
||||
for (const auto key : {Qt::Key_Return, Qt::Key_Enter}) {
|
||||
auto *sc = new QShortcut(QKeySequence(key), m_tree_view);
|
||||
sc->setContext(Qt::WidgetShortcut);
|
||||
connect(sc, &QShortcut::activated, this, [this]() {
|
||||
this->activateIndex(m_tree_view->currentIndex());
|
||||
});
|
||||
}
|
||||
|
||||
//The flat results list carries the collection path directly, so it
|
||||
//does not go through activateIndex() -- there is no tree index behind
|
||||
//a row to look an ElementCollectionItem up from.
|
||||
auto place_from_results = [this](const QModelIndex &index) {
|
||||
const QString path = index.data(Qt::UserRole + 2).toString();
|
||||
if (path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
ElementsLocation location(path);
|
||||
if (location.exist()) {
|
||||
emit insertElementRequested(location);
|
||||
}
|
||||
};
|
||||
connect(m_search_results, &QListView::doubleClicked, this, place_from_results);
|
||||
for (const auto key : {Qt::Key_Return, Qt::Key_Enter}) {
|
||||
auto *sc = new QShortcut(QKeySequence(key), m_search_results);
|
||||
sc->setContext(Qt::WidgetShortcut);
|
||||
connect(sc, &QShortcut::activated, this, [this, place_from_results]() {
|
||||
place_from_results(m_search_results->currentIndex());
|
||||
});
|
||||
}
|
||||
//Down from the search field moves into the results, so the whole
|
||||
//type-then-place run happens without touching the mouse.
|
||||
auto *to_results = new QShortcut(QKeySequence(Qt::Key_Down), m_search_field);
|
||||
to_results->setContext(Qt::WidgetShortcut);
|
||||
connect(to_results, &QShortcut::activated, this, [this]() {
|
||||
if (!m_search_results->isVisible() || !m_search_model->rowCount()) {
|
||||
return;
|
||||
}
|
||||
m_search_results->setFocus();
|
||||
if (!m_search_results->currentIndex().isValid()) {
|
||||
m_search_results->setCurrentIndex(m_search_model->index(0, 0));
|
||||
}
|
||||
});
|
||||
|
||||
connect(m_macros_tree_view, &QTreeView::customContextMenuRequested,
|
||||
this, &ElementsCollectionWidget::customContextMenu);
|
||||
|
||||
connect(m_macros_tree_view, &QTreeView::doubleClicked,
|
||||
[this](const QModelIndex &index)
|
||||
{
|
||||
this->m_index_at_context_menu = index ;
|
||||
ElementCollectionItem *eci = elementCollectionItemForIndex(index);
|
||||
if (eci && eci->collectionPath().endsWith(".qetmak")) {
|
||||
return; // Do nothing on double click for macros
|
||||
}
|
||||
this->editElement();
|
||||
});
|
||||
[this](const QModelIndex &index) { this->activateIndex(index); });
|
||||
|
||||
connect(m_macros_tree_view, &QTreeView::entered,
|
||||
[this] (const QModelIndex &index) {
|
||||
@@ -405,6 +486,60 @@ void ElementsCollectionWidget::openDir()
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::activateIndex
|
||||
What a double click (or Enter) on @a index does.
|
||||
|
||||
Historically this opened the element editor, which is the slowest action
|
||||
available on a symbol you are most likely about to place. Placing is now
|
||||
the default and editing has moved to the context menu, where it already
|
||||
was. The old behaviour is preserved behind a preference for anyone who
|
||||
relies on it.
|
||||
@param index
|
||||
*/
|
||||
void ElementsCollectionWidget::activateIndex(const QModelIndex &index)
|
||||
{
|
||||
m_index_at_context_menu = index;
|
||||
|
||||
ElementCollectionItem *eci = elementCollectionItemForIndex(index);
|
||||
if (!eci) {
|
||||
return;
|
||||
}
|
||||
//Macros are placed, never edited, and were already skipped here.
|
||||
const bool is_macro = eci->collectionPath().endsWith(".qetmak");
|
||||
|
||||
QSettings settings;
|
||||
const bool insert = settings.value(
|
||||
QStringLiteral("elementscollection/double-click-inserts"), true).toBool();
|
||||
|
||||
if (insert) {
|
||||
insertCurrentElement();
|
||||
return;
|
||||
}
|
||||
if (!is_macro) {
|
||||
editElement();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::insertCurrentElement
|
||||
Ask for the current item to be placed on the folio.
|
||||
*/
|
||||
void ElementsCollectionWidget::insertCurrentElement()
|
||||
{
|
||||
ElementCollectionItem *eci =
|
||||
elementCollectionItemForIndex(m_index_at_context_menu);
|
||||
if (!(eci && eci->isElement())) {
|
||||
return;
|
||||
}
|
||||
|
||||
ElementsLocation location(eci->collectionPath());
|
||||
if (!location.exist()) {
|
||||
return;
|
||||
}
|
||||
emit insertElementRequested(location);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::editElement
|
||||
Edit the element represented by the current selected item
|
||||
@@ -415,10 +550,19 @@ void ElementsCollectionWidget::editElement()
|
||||
|
||||
if ( !(eci && eci->isElement()) ) return;
|
||||
|
||||
// Prevent the element editor from opening for macros
|
||||
if (eci->collectionPath().endsWith(".qetmak")) return;
|
||||
editLocation(ElementsLocation(eci->collectionPath()));
|
||||
}
|
||||
|
||||
ElementsLocation location(eci->collectionPath());
|
||||
/**
|
||||
@brief ElementsCollectionWidget::editLocation
|
||||
Open the element editor on @a location. Macros have no editor and are
|
||||
ignored.
|
||||
@param location
|
||||
*/
|
||||
void ElementsCollectionWidget::editLocation(const ElementsLocation &location)
|
||||
{
|
||||
// Prevent the element editor from opening for macros
|
||||
if (!location.exist() || location.path().endsWith(".qetmak")) return;
|
||||
|
||||
QETApp *app = QETApp::instance();
|
||||
app->openElementLocations(QList<ElementsLocation>() << location);
|
||||
@@ -932,6 +1076,7 @@ void ElementsCollectionWidget::search()
|
||||
//Reset the search
|
||||
if (text.isEmpty())
|
||||
{
|
||||
clearFlatResults();
|
||||
QModelIndex current_index = m_tree_view->currentIndex();
|
||||
m_tree_view->reset();
|
||||
|
||||
@@ -958,10 +1103,20 @@ void ElementsCollectionWidget::search()
|
||||
return;
|
||||
}
|
||||
|
||||
QSettings settings;
|
||||
if (settings.value(QStringLiteral("elementscollection/search-flat-list"),
|
||||
true).toBool())
|
||||
{
|
||||
showFlatResults(rankedSearch(text, m_showed_index));
|
||||
return;
|
||||
}
|
||||
|
||||
//Tree search: hide what does not match, expand what does.
|
||||
clearFlatResults();
|
||||
hideCollection(true);
|
||||
const QStringList text_list = text.split("+", Qt::SkipEmptyParts);
|
||||
QModelIndexList match_index;
|
||||
for (QString txt : text_list) {
|
||||
for (const QString &txt : text_list) {
|
||||
match_index << m_model->match(m_showed_index.isValid()
|
||||
? m_model->index(0,0,m_showed_index)
|
||||
: m_model->index(0,0),
|
||||
@@ -972,10 +1127,157 @@ void ElementsCollectionWidget::search()
|
||||
| Qt::MatchRecursive);
|
||||
}
|
||||
|
||||
for(QModelIndex index : match_index)
|
||||
for (const QModelIndex &index : std::as_const(match_index))
|
||||
showAndExpandItem(index);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::rankedSearch
|
||||
Run the collection search for @a text and return the element hits, best
|
||||
first. The hits carry no model index, so a list that is not backed by the
|
||||
collection model can show them too.
|
||||
@param text : search text, "+" separating terms
|
||||
@param within : restrict the search to this item's children, or the
|
||||
whole collection when invalid
|
||||
@return ranked hits, best first
|
||||
*/
|
||||
QVector<ElementSearchHit> ElementsCollectionWidget::rankedSearch(
|
||||
const QString &text,
|
||||
const QModelIndex &within)
|
||||
{
|
||||
QVector<ElementSearchHit> hits;
|
||||
if (!m_model || text.size() < 3) {
|
||||
return hits;
|
||||
}
|
||||
|
||||
const QStringList terms = text.split("+", Qt::SkipEmptyParts);
|
||||
QModelIndexList matches;
|
||||
for (const QString &term : terms) {
|
||||
matches << m_model->match(within.isValid()
|
||||
? m_model->index(0, 0, within)
|
||||
: m_model->index(0, 0),
|
||||
Qt::UserRole+1,
|
||||
QVariant(term),
|
||||
-1,
|
||||
Qt::MatchContains
|
||||
| Qt::MatchRecursive);
|
||||
}
|
||||
|
||||
const QString needle = terms.value(0).toLower();
|
||||
QVector<QPair<int, ElementSearchHit>> scored;
|
||||
QSet<QString> seen;
|
||||
|
||||
for (const QModelIndex &index : std::as_const(matches))
|
||||
{
|
||||
ElementCollectionItem *eci = elementCollectionItemForIndex(index);
|
||||
if (!(eci && eci->isElement())) {
|
||||
continue;
|
||||
}
|
||||
const QString path = eci->collectionPath();
|
||||
//match() is run once per "+"-separated term, so the same element
|
||||
//can arrive several times.
|
||||
if (path.isEmpty() || seen.contains(path)) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(path);
|
||||
|
||||
ElementSearchHit hit;
|
||||
hit.path = path;
|
||||
hit.name = index.data(Qt::DisplayRole).toString();
|
||||
hit.icon = qvariant_cast<QIcon>(index.data(Qt::DecorationRole));
|
||||
//Where it lives, so two similarly-named symbols are tellable apart
|
||||
QStringList parts;
|
||||
for (QModelIndex p = index.parent(); p.isValid(); p = p.parent()) {
|
||||
parts.prepend(p.data(Qt::DisplayRole).toString());
|
||||
}
|
||||
hit.folder = parts.join(QStringLiteral(" / "));
|
||||
|
||||
const QString hay = index.data(Qt::UserRole + 1).toString().toLower();
|
||||
scored.append({rankMatch(needle, hit.name, hay), hit});
|
||||
}
|
||||
|
||||
std::stable_sort(scored.begin(), scored.end(),
|
||||
[](const QPair<int, ElementSearchHit> &a,
|
||||
const QPair<int, ElementSearchHit> &b) {
|
||||
return a.first > b.first;
|
||||
});
|
||||
hits.reserve(scored.size());
|
||||
for (const auto &p : std::as_const(scored)) {
|
||||
hits.append(p.second);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::rankMatch
|
||||
Score a hit so the list can be ordered by how well it matches.
|
||||
|
||||
The tree search treats every hit equally, which is fine when they stay in
|
||||
place but useless in a ranked list: typing "diode" should not put an
|
||||
element whose *description* mentions diodes above one actually called
|
||||
"Diode". Name beats element-info field, earlier beats later, shorter beats
|
||||
longer.
|
||||
@param needle : lower-cased search text
|
||||
@param name : the element's display name
|
||||
@param haystack : the full indexed string (name + every info field)
|
||||
@return a score, higher is better
|
||||
*/
|
||||
int ElementsCollectionWidget::rankMatch(const QString &needle,
|
||||
const QString &name,
|
||||
const QString &haystack)
|
||||
{
|
||||
const QString n = name.toLower();
|
||||
int score = 0;
|
||||
|
||||
if (n == needle) {
|
||||
score = 1000;
|
||||
} else if (n.startsWith(needle)) {
|
||||
score = 800;
|
||||
} else if (n.contains(needle)) {
|
||||
score = 600 - qMin(n.indexOf(needle), 99);
|
||||
} else if (haystack.contains(needle)) {
|
||||
//Matched only on an info field -- manufacturer, reference,
|
||||
//description. Still a real hit, just a weaker one.
|
||||
score = 300;
|
||||
}
|
||||
//Among equally-placed hits prefer the shorter name: "Diode" over
|
||||
//"Diode Zener bidirectional".
|
||||
return score - qMin(n.size(), 99);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::showFlatResults
|
||||
Replace the tree with a flat list of @a hits, in the order given.
|
||||
@param hits : from rankedSearch()
|
||||
*/
|
||||
void ElementsCollectionWidget::showFlatResults(const QVector<ElementSearchHit> &hits)
|
||||
{
|
||||
m_search_model->clear();
|
||||
|
||||
for (const ElementSearchHit &hit : hits)
|
||||
{
|
||||
auto *item = new QStandardItem(hit.icon, hit.name);
|
||||
item->setEditable(false);
|
||||
item->setToolTip(hit.folder);
|
||||
item->setData(hit.path, Qt::UserRole + 2);
|
||||
m_search_model->appendRow(item);
|
||||
}
|
||||
|
||||
m_tab_widget->hide();
|
||||
m_search_results->show();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::clearFlatResults
|
||||
Put the tree back when the search field is emptied.
|
||||
*/
|
||||
void ElementsCollectionWidget::clearFlatResults()
|
||||
{
|
||||
m_search_model->clear();
|
||||
m_search_results->hide();
|
||||
m_tab_widget->show();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsCollectionWidget::hideCollection
|
||||
Hide all collection displayed in this tree
|
||||
|
||||
@@ -43,6 +43,22 @@ class ElementsTreeView;
|
||||
and all action needed to use this widget.
|
||||
This is the element collection widget used in the diagram editor.
|
||||
*/
|
||||
class QListView;
|
||||
class QStandardItemModel;
|
||||
|
||||
/**
|
||||
@brief One ranked hit from a collection search.
|
||||
Carries everything a result row needs, so a list that is not backed by
|
||||
the collection model can show it.
|
||||
*/
|
||||
struct ElementSearchHit
|
||||
{
|
||||
QString path; ///< collection path, enough to build an ElementsLocation
|
||||
QString name; ///< display name
|
||||
QString folder; ///< where it lives, for telling similar names apart
|
||||
QIcon icon;
|
||||
};
|
||||
|
||||
class ElementsCollectionWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -56,6 +72,8 @@ class ElementsCollectionWidget : public QWidget
|
||||
void removeProject (QETProject *project);
|
||||
void highlightUnusedElement();
|
||||
void setCurrentLocation(const ElementsLocation &location);
|
||||
QVector<ElementSearchHit> rankedSearch(const QString &text,
|
||||
const QModelIndex &within = QModelIndex());
|
||||
|
||||
protected:
|
||||
void leaveEvent(QEvent *event) override;
|
||||
@@ -86,9 +104,26 @@ class ElementsCollectionWidget : public QWidget
|
||||
public slots:
|
||||
void reload();
|
||||
void loadingFinished();
|
||||
void insertCurrentElement();
|
||||
void editLocation(const ElementsLocation &location);
|
||||
|
||||
signals:
|
||||
/**
|
||||
Emitted when the user asks for an element to be placed on the
|
||||
current folio. Whoever hosts this widget decides which view
|
||||
receives it -- the dock is inside a diagram editor, but the
|
||||
picker popup is not, so the widget must not reach for an
|
||||
ancestor editor itself.
|
||||
*/
|
||||
void insertElementRequested(const ElementsLocation &location);
|
||||
|
||||
private:
|
||||
void locationWasSaved(const ElementsLocation& location);
|
||||
void activateIndex(const QModelIndex &index);
|
||||
void showFlatResults(const QVector<ElementSearchHit> &hits);
|
||||
void clearFlatResults();
|
||||
static int rankMatch(const QString &needle, const QString &name,
|
||||
const QString &haystack);
|
||||
|
||||
|
||||
private:
|
||||
@@ -100,6 +135,9 @@ class ElementsCollectionWidget : public QWidget
|
||||
ElementsTreeView *m_tree_view;
|
||||
ElementsTreeView *m_macros_tree_view = nullptr;
|
||||
QTabWidget *m_tab_widget = nullptr;
|
||||
/// Flat ranked results, shown in place of the tree while searching
|
||||
QListView *m_search_results = nullptr;
|
||||
QStandardItemModel *m_search_model = nullptr;
|
||||
QVBoxLayout *m_main_vlayout;
|
||||
QMenu *m_context_menu;
|
||||
QModelIndex m_index_at_context_menu;
|
||||
|
||||
@@ -82,13 +82,27 @@ void ElementsTreeView::startDrag(Qt::DropActions supportedActions)
|
||||
@param location : location to use for create the content of the QDrag
|
||||
*/
|
||||
void ElementsTreeView::startElementDrag(const ElementsLocation &location)
|
||||
{
|
||||
execElementDrag(this, location);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementsTreeView::execElementDrag
|
||||
Build and run the QDrag for @a location, from @a source.
|
||||
Static so that a view which is not an ElementsTreeView -- the flat list
|
||||
of search results -- starts exactly the same drag as the tree.
|
||||
@param source : the widget the drag starts from
|
||||
@param location : location to use for create the content of the QDrag
|
||||
*/
|
||||
void ElementsTreeView::execElementDrag(QWidget *source,
|
||||
const ElementsLocation &location)
|
||||
{
|
||||
if (! location.exist()) return;
|
||||
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 2, 0)
|
||||
QDrag* drag = new QDrag(this);
|
||||
QDrag* drag = new QDrag(source);
|
||||
#else
|
||||
QScopedPointer<QDrag> drag(new QDrag(this));
|
||||
QScopedPointer<QDrag> drag(new QDrag(source));
|
||||
#endif
|
||||
|
||||
QString location_str = location.toString();
|
||||
@@ -209,7 +223,7 @@ void ElementsTreeView::startElementDrag(const ElementsLocation &location)
|
||||
&elmt_creation_state));
|
||||
if (elmt_creation_state) { return; }
|
||||
|
||||
QPixmap elmt_pixmap(QET::Palette::forPalette(temp_elmt->pixmap(), palette()));
|
||||
QPixmap elmt_pixmap(QET::Palette::forPalette(temp_elmt->pixmap(), source->palette()));
|
||||
QPoint elmt_hotspot(temp_elmt->hotspot());
|
||||
|
||||
//Adjust the size of the pixmap if he is too big
|
||||
|
||||
@@ -32,6 +32,8 @@ class ElementsTreeView : public QTreeView
|
||||
{
|
||||
public:
|
||||
ElementsTreeView(QWidget *parent = nullptr);
|
||||
static void execElementDrag(QWidget *source,
|
||||
const ElementsLocation &location);
|
||||
|
||||
protected:
|
||||
void startDrag(Qt::DropActions supportedActions) override;
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
#include "../qetproject.h"
|
||||
#include "xmlelementcollection.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
|
||||
/**
|
||||
@brief XmlProjectElementCollectionItem::XmlProjectElementCollectionItem
|
||||
Constructor
|
||||
@@ -58,10 +60,7 @@ QString XmlProjectElementCollectionItem::localName()
|
||||
return text();
|
||||
|
||||
if (isCollectionRoot()) {
|
||||
if (m_project->title().isEmpty())
|
||||
setText(QObject::tr("Projet sans titre"));
|
||||
else
|
||||
setText(m_project->title());
|
||||
updateProjectName();
|
||||
}
|
||||
else {
|
||||
ElementsLocation location (embeddedPath(), m_project);
|
||||
@@ -71,6 +70,25 @@ QString XmlProjectElementCollectionItem::localName()
|
||||
return text();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief XmlProjectElementCollectionItem::updateProjectName
|
||||
Set the displayed name of the collection root from the project :
|
||||
its title, or its file name when it has no title, like the project panel.
|
||||
Does nothing if this item is not the root of the collection.
|
||||
*/
|
||||
void XmlProjectElementCollectionItem::updateProjectName()
|
||||
{
|
||||
if (!isCollectionRoot() || !m_project)
|
||||
return;
|
||||
|
||||
if (!m_project->title().isEmpty())
|
||||
setText(m_project->title());
|
||||
else if (!m_project->filePath().isEmpty())
|
||||
setText(QFileInfo(m_project->filePath()).completeBaseName());
|
||||
else
|
||||
setText(QObject::tr("Projet sans titre"));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief XmlProjectElementCollectionItem::name
|
||||
@return The collection name of this item
|
||||
|
||||
@@ -45,6 +45,7 @@ class XmlProjectElementCollectionItem : public ElementCollectionItem
|
||||
bool isCollectionRoot() const override;
|
||||
void addChildAtPath(const QString &collection_name) override;
|
||||
QETProject * project() const;
|
||||
void updateProjectName();
|
||||
|
||||
void setProject (QETProject *project,
|
||||
bool set_data = true,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef ALIGNMENT_H
|
||||
#define ALIGNMENT_H
|
||||
|
||||
#include "textgrid.h"
|
||||
|
||||
#include <QPointF>
|
||||
|
||||
/**
|
||||
The geometry behind the align commands, kept free of any scene so it
|
||||
can be tested on its own.
|
||||
*/
|
||||
namespace Alignment
|
||||
{
|
||||
/**
|
||||
@return the movement that puts p on a grid of x_grid by y_grid,
|
||||
divided by divisor as TextGrid::snap() does, or a null point when
|
||||
p is already on it. Unlike Diagram::snapToGrid(), this never looks
|
||||
at the keyboard: a command run from a shortcut with Ctrl in it must
|
||||
not quietly round to the pixel instead.
|
||||
Less than a millionth of a pixel counts as on the grid: positions
|
||||
that went through arithmetic carry residues of that size, and
|
||||
qFuzzyIsNull() (1e-12) is too strict to absorb them.
|
||||
*/
|
||||
inline QPointF gridOffset(const QPointF &p, int x_grid, int y_grid, qreal divisor = 1)
|
||||
{
|
||||
const QPointF offset = TextGrid::snap(p, x_grid, y_grid, divisor) - p;
|
||||
auto clean = [](qreal v) { return qAbs(v) < 1e-6 ? 0.0 : v; };
|
||||
return QPointF(clean(offset.x()), clean(offset.y()));
|
||||
}
|
||||
}
|
||||
|
||||
#endif // ALIGNMENT_H
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef BORDERCELLLABELS_H
|
||||
#define BORDERCELLLABELS_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
/// The labels the border of a folio writes on its rows and columns, shared
|
||||
/// by BorderTitleBlock::draw() and the cell rulers of DiagramView so the
|
||||
/// two cannot disagree. Header-only so it can be unit-tested directly --
|
||||
/// see tests/qttest/tst_bordercelllabels.cpp.
|
||||
namespace BorderCellLabels {
|
||||
|
||||
/// @return the label of row \a row, counted from 1: A..Z, then AA, AB...
|
||||
/// (the sequence BorderTitleBlock::incrementLetters() walks through).
|
||||
inline QString rowLabel(int row)
|
||||
{
|
||||
QString label;
|
||||
while (row > 0) {
|
||||
--row;
|
||||
label.prepend(QChar('A' + row % 26));
|
||||
row /= 26;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/// @return the label of column \a column, counted from 1. When
|
||||
/// \a starts_at_zero (the "border-columns_0" setting) the first column
|
||||
/// is labelled 0.
|
||||
inline QString columnLabel(int column, bool starts_at_zero)
|
||||
{
|
||||
return QString::number(starts_at_zero ? column - 1 : column);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // BORDERCELLLABELS_H
|
||||
@@ -17,7 +17,9 @@
|
||||
*/
|
||||
#include "bordertitleblock.h"
|
||||
|
||||
#include "bordercelllabels.h"
|
||||
#include "createdxf.h"
|
||||
#include "dxfexport.h"
|
||||
#include "diagram.h"
|
||||
#include "diagramposition.h"
|
||||
#include "math.h"
|
||||
@@ -29,6 +31,7 @@
|
||||
|
||||
#include <QLocale>
|
||||
#include <QPainter>
|
||||
#include <QRegularExpression>
|
||||
#include <utility>
|
||||
|
||||
#define MIN_COLUMN_COUNT 3
|
||||
@@ -533,6 +536,8 @@ void BorderTitleBlock::draw(QPainter *painter)
|
||||
|
||||
//Draw the nums of columns
|
||||
if (display_border_ && display_columns_) {
|
||||
const bool columns_start_at_zero =
|
||||
settings.value("border-columns_0", true).toBool();
|
||||
for (int i = 1 ; i <= columns_count_ ; ++ i) {
|
||||
QRectF numbered_rectangle = QRectF(
|
||||
diagram_rect_.topLeft().x()
|
||||
@@ -543,23 +548,15 @@ void BorderTitleBlock::draw(QPainter *painter)
|
||||
columns_header_height_
|
||||
);
|
||||
painter -> drawRect(numbered_rectangle);
|
||||
if (settings.value("border-columns_0", true).toBool()){
|
||||
painter -> drawText(numbered_rectangle,
|
||||
Qt::AlignVCenter
|
||||
| Qt::AlignCenter,
|
||||
QString("%1").arg(i - 1));
|
||||
}else{
|
||||
painter -> drawText(numbered_rectangle,
|
||||
Qt::AlignVCenter
|
||||
| Qt::AlignCenter,
|
||||
QString("%1").arg(i));
|
||||
}
|
||||
BorderCellLabels::columnLabel(i, columns_start_at_zero));
|
||||
}
|
||||
}
|
||||
|
||||
//Draw the nums of rows
|
||||
if (display_border_ && display_rows_) {
|
||||
QString row_string("A");
|
||||
for (int i = 1 ; i <= rows_count_ ; ++ i) {
|
||||
QRectF lettered_rectangle = QRectF(
|
||||
diagram_rect_.topLeft().x(),
|
||||
@@ -575,8 +572,7 @@ void BorderTitleBlock::draw(QPainter *painter)
|
||||
painter -> drawText(lettered_rectangle,
|
||||
Qt::AlignVCenter
|
||||
| Qt::AlignCenter,
|
||||
row_string);
|
||||
row_string = incrementLetters(row_string);
|
||||
BorderCellLabels::rowLabel(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,6 +707,7 @@ void BorderTitleBlock::drawDxf(
|
||||
|
||||
// render the titleblock, using the TitleBlockTemplate object
|
||||
if (display_titleblock_) {
|
||||
Createdxf::layer = DxfExport::Layer::TitleBlock;
|
||||
//qp -> translate(titleblock_rect_.topLeft());
|
||||
QRectF rect = titleBlockRect();
|
||||
m_titleblock_template_renderer -> renderDxf(rect,
|
||||
@@ -935,6 +932,46 @@ void BorderTitleBlock::updateDiagramContextForTitleBlock(
|
||||
m_titleblock_template_renderer -> setContext(context);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief BorderTitleBlock::cellRect
|
||||
Convert a cell written the way the border labels it (ex : B13, the row
|
||||
letter(s) then the column number) to its rect in scene coordinate.
|
||||
This is the reverse of convertPosition().
|
||||
@param cell : the cell to convert, case and surrounding spaces ignored
|
||||
@return the rect of the cell, or a null QRectF if \a cell is not a
|
||||
cell reference or lies outside of the border.
|
||||
*/
|
||||
QRectF BorderTitleBlock::cellRect(const QString &cell) const
|
||||
{
|
||||
static const QRegularExpression cell_re(
|
||||
QStringLiteral("^\\s*([A-Za-z]+)\\s*(\\d{1,4})\\s*$"));
|
||||
const QRegularExpressionMatch match = cell_re.match(cell);
|
||||
if (!match.hasMatch())
|
||||
return QRectF();
|
||||
|
||||
//Row letters count like A..Z, AA, AB... (see incrementLetters())
|
||||
int row = 0;
|
||||
for (const QChar c : match.captured(1).toUpper()) {
|
||||
row = row * 26 + (c.unicode() - 'A' + 1);
|
||||
if (row > rows_count_)
|
||||
return QRectF();
|
||||
}
|
||||
|
||||
int column = match.captured(2).toInt();
|
||||
QSettings settings;
|
||||
if (settings.value("border-columns_0", true).toBool())
|
||||
++column;
|
||||
|
||||
if (row < 1 || column < 1 || column > columns_count_)
|
||||
return QRectF();
|
||||
|
||||
const QPointF top_left = insideBorderRect().topLeft();
|
||||
return QRectF(top_left.x() + (column - 1) * columns_width_,
|
||||
top_left.y() + (row - 1) * rows_height_,
|
||||
columns_width_,
|
||||
rows_height_);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief BorderTitleBlock::incrementLetters
|
||||
increments string with Letters A to Z
|
||||
|
||||
@@ -159,6 +159,7 @@ class BorderTitleBlock : public QObject
|
||||
void setDiagramHeight(const qreal &);
|
||||
|
||||
DiagramPosition convertPosition(const QPointF &);
|
||||
QRectF cellRect(const QString &cell) const;
|
||||
|
||||
// methods to set title block basic data
|
||||
void setFolio(const QString &folio);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "cellruler.h"
|
||||
|
||||
#include "bordercelllabels.h"
|
||||
#include "diagram.h"
|
||||
#include "diagramview.h"
|
||||
|
||||
#include <QPainter>
|
||||
#include <QSettings>
|
||||
|
||||
/**
|
||||
@brief CellRuler::CellRuler
|
||||
@param orientation : Qt::Horizontal for the column numbers along the
|
||||
top, Qt::Vertical for the row letters along the left
|
||||
@param view : the view this ruler is placed on
|
||||
*/
|
||||
CellRuler::CellRuler(Qt::Orientation orientation, DiagramView *view) :
|
||||
QWidget(view),
|
||||
m_orientation(orientation),
|
||||
m_view(view)
|
||||
{
|
||||
setAttribute(Qt::WA_OpaquePaintEvent);
|
||||
hide();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CellRuler::thickness
|
||||
@return the height of the top ruler, which is also the width of the
|
||||
side ruler, so the corner they share is square. Constant whatever the
|
||||
zoom.
|
||||
*/
|
||||
int CellRuler::thickness() const
|
||||
{
|
||||
const QFontMetrics metrics = fontMetrics();
|
||||
return qMax(metrics.height(), metrics.horizontalAdvance(QStringLiteral("WW"))) + 6;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CellRuler::setLeadingSpace
|
||||
@param space : pixels left empty before the first pixel of the
|
||||
viewport, along this ruler
|
||||
*/
|
||||
void CellRuler::setLeadingSpace(int space)
|
||||
{
|
||||
m_leading_space = space;
|
||||
update();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CellRuler::paintEvent
|
||||
Draw a cell for each column (or row) of the folio border, at the
|
||||
position the view shows it. When the cells get too small for their
|
||||
labels, only one label every 2, 5, 10... cells is written.
|
||||
*/
|
||||
void CellRuler::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
|
||||
QPainter painter(this);
|
||||
painter.fillRect(rect(), background());
|
||||
|
||||
const bool horizontal = m_orientation == Qt::Horizontal;
|
||||
const int length = horizontal ? width() : height();
|
||||
const int depth = horizontal ? height() : width();
|
||||
|
||||
//Separate the ruler from the drawing
|
||||
painter.setPen(palette().color(QPalette::Dark));
|
||||
if (horizontal) {
|
||||
painter.drawLine(0, depth - 1, length, depth - 1);
|
||||
} else {
|
||||
painter.drawLine(depth - 1, 0, depth - 1, length);
|
||||
}
|
||||
|
||||
Diagram *diagram = m_view->diagram();
|
||||
if (!diagram) {
|
||||
return;
|
||||
}
|
||||
const BorderTitleBlock &border = diagram->border_and_titleblock;
|
||||
const QTransform transform = m_view->viewportTransform();
|
||||
|
||||
const int count = horizontal ? border.columnsCount() : border.rowsCount();
|
||||
const qreal cell_size = horizontal ? border.columnsWidth() : border.rowsHeight();
|
||||
//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;
|
||||
if (count < 1 || cell_pixels <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool columns_start_at_zero =
|
||||
QSettings().value("border-columns_0", true).toBool();
|
||||
auto label = [&](int index) {
|
||||
return horizontal ? BorderCellLabels::columnLabel(index, columns_start_at_zero)
|
||||
: BorderCellLabels::rowLabel(index);
|
||||
};
|
||||
|
||||
//Room one label needs along the ruler, and the smallest step
|
||||
//between written labels that gives it that room
|
||||
const QFontMetrics metrics = fontMetrics();
|
||||
const int label_room = horizontal
|
||||
? metrics.horizontalAdvance(label(count)) + 6
|
||||
: metrics.height() + 2;
|
||||
int step = 1;
|
||||
for (int candidate : {1, 2, 5, 10, 20, 50, 100, 200, 500}) {
|
||||
step = candidate;
|
||||
if (candidate * cell_pixels >= label_room) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 1 ; i <= count ; ++i) {
|
||||
const qreal start = offset + (first + (i - 1) * cell_size) * scale;
|
||||
const qreal end = start + cell_pixels;
|
||||
if (end < m_leading_space || start > length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//Cell edges, drawn only when the cells are wide enough
|
||||
//for them to read as cells rather than as a hatching
|
||||
painter.setPen(palette().color(QPalette::Dark));
|
||||
if (cell_pixels >= 4) {
|
||||
if (horizontal) {
|
||||
painter.drawLine(QPointF(start, 0), QPointF(start, depth - 1));
|
||||
if (i == count) painter.drawLine(QPointF(end, 0), QPointF(end, depth - 1));
|
||||
} else {
|
||||
painter.drawLine(QPointF(0, start), QPointF(depth - 1, start));
|
||||
if (i == count) painter.drawLine(QPointF(0, end), QPointF(depth - 1, end));
|
||||
}
|
||||
}
|
||||
|
||||
//Written labels are the ones a multiple of step: 0, 5, 10...
|
||||
//for the columns, A, F, K... for the rows
|
||||
const int position = (horizontal && !columns_start_at_zero) ? i : i - 1;
|
||||
if (position % step != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
painter.setPen(palette().color(QPalette::ButtonText));
|
||||
const qreal centre = (start + end) / 2;
|
||||
const QRectF text_rect = horizontal
|
||||
? QRectF(centre - label_room / 2.0, 0, label_room, depth - 1)
|
||||
: QRectF(0, centre - label_room / 2.0, depth - 1, label_room);
|
||||
painter.drawText(text_rect, Qt::AlignCenter | Qt::TextDontClip, label(i));
|
||||
}
|
||||
|
||||
//Keep the corner empty: the other ruler's labels do not belong there
|
||||
if (m_leading_space > 0) {
|
||||
painter.fillRect(horizontal ? QRect(0, 0, m_leading_space, depth - 1)
|
||||
: QRect(0, 0, depth - 1, m_leading_space),
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef CELLRULER_H
|
||||
#define CELLRULER_H
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class DiagramView;
|
||||
|
||||
/**
|
||||
@brief The CellRuler class
|
||||
A bar along the top or the left edge of a DiagramView that repeats the
|
||||
column numbers or the row letters of the folio border, aligned with the
|
||||
cells at any zoom, so they stay in sight however far the view is
|
||||
scrolled. It sits in the view's margins, outside of the scene: printing
|
||||
and exporting are unaffected.
|
||||
*/
|
||||
class CellRuler : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
CellRuler(Qt::Orientation orientation, DiagramView *view);
|
||||
|
||||
int thickness() const;
|
||||
void setLeadingSpace(int space);
|
||||
|
||||
protected:
|
||||
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
|
||||
/// side ruler fills when both rulers are shown.
|
||||
int m_leading_space = 0;
|
||||
};
|
||||
|
||||
#endif // CELLRULER_H
|
||||
@@ -24,8 +24,11 @@
|
||||
#include "dataBase/projectdatabase.h"
|
||||
#include "diagram.h"
|
||||
#include "diagramcontext.h"
|
||||
#include "dxfexport.h"
|
||||
#include "exportproperties.h"
|
||||
#include "pdf_links.h"
|
||||
#include "qetgraphicsitem/conductor.h"
|
||||
#include "qetgraphicsitem/diagramimageitem.h"
|
||||
#include "qetgraphicsitem/element.h"
|
||||
#include "qetgraphicsitem/terminal.h"
|
||||
#include "qetproject.h"
|
||||
@@ -69,6 +72,7 @@ const QHash<QString, QString> &exportFlags()
|
||||
{"--export-pdf", "pdf"},
|
||||
{"--export-png", "png"},
|
||||
{"--export-svg", "svg"},
|
||||
{"--export-dxf", "dxf"},
|
||||
{"--export-cables", "cables"},
|
||||
{"--export-wires", "wires"},
|
||||
{"--export-bom", "bom"},
|
||||
@@ -227,6 +231,7 @@ int exportPdf(QETProject &project, const QString &output,
|
||||
// Rewrite the URI link annotations into native internal GoTo actions, so
|
||||
// the cross-references jump inside the document in any PDF viewer.
|
||||
PdfLinks::convertUriToGoTo(output);
|
||||
PdfLinks::removeUnusedPdfxNamespace(output);
|
||||
|
||||
out << "Exported " << diagrams.size() << " page(s) -> " << output << "\n";
|
||||
return 0;
|
||||
@@ -278,6 +283,57 @@ int exportImages(QETProject &project, const QString &format,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// One DXF file per diagram, written by the same code as the export dialog
|
||||
/// (DxfExport) with the dialog's default options -- the export settings of
|
||||
/// the preferences -- so both give the same file.
|
||||
int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals)
|
||||
{
|
||||
const QList<Diagram *> diagrams = project.diagrams();
|
||||
if (diagrams.isEmpty()) {
|
||||
err << "No diagrams to export.\n";
|
||||
return 1;
|
||||
}
|
||||
QDir().mkpath(out_dir);
|
||||
|
||||
ExportProperties properties = ExportProperties::defaultExportProperties();
|
||||
properties.format = QStringLiteral("DXF");
|
||||
if (showTerminals)
|
||||
properties.draw_terminals = true;
|
||||
|
||||
int index = 0;
|
||||
bool has_images = false;
|
||||
for (Diagram *diagram : diagrams) {
|
||||
++index;
|
||||
const QString path = QDir(out_dir).filePath(
|
||||
diagramStem(diagram, index) + ".dxf");
|
||||
|
||||
// Createdxf answers a file it cannot open with a message box and
|
||||
// exit(0), which headless means no message and a success code.
|
||||
QFile probe(path);
|
||||
if (!probe.open(QIODevice::WriteOnly)) {
|
||||
err << "Cannot open '" << path << "' for writing.\n";
|
||||
return 1;
|
||||
}
|
||||
probe.close();
|
||||
|
||||
const QSize size = DxfExport::folioSize(diagram, properties);
|
||||
DxfExport::write(diagram, size.width(), size.height(), path, properties);
|
||||
|
||||
for (QGraphicsItem *item : diagram->items()) {
|
||||
if (qgraphicsitem_cast<DiagramImageItem *>(item)) {
|
||||
has_images = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out << " " << path << "\n";
|
||||
}
|
||||
if (has_images)
|
||||
err << "Note: DXF has no pictures in this format; "
|
||||
"each picture is exported as an outline box.\n";
|
||||
out << "Exported " << diagrams.size() << " diagram(s) -> " << out_dir << "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
int exportCsv(QETProject &project, const QString &format, const QString &output)
|
||||
{
|
||||
QString csv;
|
||||
@@ -889,6 +945,8 @@ int run(const QStringList &args)
|
||||
}
|
||||
if (format == "pdf")
|
||||
return exportPdf(project, output, showTerminals);
|
||||
if (format == "dxf")
|
||||
return exportDxf(project, output, showTerminals);
|
||||
if (format == "cables" || format == "wires")
|
||||
return exportCsv(project, format, output);
|
||||
if (format == "bom")
|
||||
|
||||
@@ -45,6 +45,7 @@ namespace CLIExport {
|
||||
qelectrotech --export-pdf <project.qet> <output.pdf> [--show-terminals]
|
||||
qelectrotech --export-png <project.qet> <output_dir> [--show-terminals]
|
||||
qelectrotech --export-svg <project.qet> <output_dir> [--show-terminals]
|
||||
qelectrotech --export-dxf <project.qet> <output_dir> [--show-terminals]
|
||||
qelectrotech --export-cables <project.qet> <output.csv>
|
||||
qelectrotech --export-wires <project.qet> <output.csv>
|
||||
qelectrotech --export-bom <project.qet> <output.csv>
|
||||
@@ -57,11 +58,14 @@ namespace CLIExport {
|
||||
qelectrotech --set-titleblock <project.qet> <output.qet> key=value...
|
||||
|
||||
PDF: one multi-page document (one diagram per page).
|
||||
PNG/SVG: one file per diagram, named <output_dir>/<NN>_<title>.<ext>.
|
||||
PNG/SVG/DXF: one file per diagram, named <output_dir>/<NN>_<title>.<ext>.
|
||||
DXF: the same file the export dialog writes with its default options
|
||||
(the export settings of the preferences).
|
||||
--show-terminals: also paint terminal markers (red stroke + blue
|
||||
docking dot) and terminal names, as the interactive editor
|
||||
does; off by default, matching the GUI export dialog's
|
||||
default. Has no effect on the non-image export modes.
|
||||
default. For DXF, draws the terminal markers. Has no effect on
|
||||
the list export modes.
|
||||
cables: wiring list (one row per conductor) as CSV.
|
||||
wires: list of distinct wire numbers as CSV.
|
||||
bom: bill of materials (one row per element) as CSV.
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "commandsearchpopup.h"
|
||||
|
||||
#include "shortcutmanager.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QGuiApplication>
|
||||
#include <QKeyEvent>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QScreen>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
/**
|
||||
@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<QPair<int, const Command *>> 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<void *>(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<QAction *>(item->data(Qt::UserRole).value<void *>());
|
||||
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);
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef COMMANDSEARCHPOPUP_H
|
||||
#define COMMANDSEARCHPOPUP_H
|
||||
|
||||
#include <QFrame>
|
||||
#include <QList>
|
||||
|
||||
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<Command> m_commands;
|
||||
};
|
||||
|
||||
#endif // COMMANDSEARCHPOPUP_H
|
||||
+78
-20
@@ -20,14 +20,39 @@
|
||||
#include <QTextStream>
|
||||
#include <QMessageBox>
|
||||
#include <QString>
|
||||
#include "exportdialog.h"
|
||||
#include "dxfexport.h"
|
||||
|
||||
|
||||
const double Createdxf::sheetWidth = 4000;
|
||||
const double Createdxf::sheetHeight = 2700;
|
||||
|
||||
double Createdxf::xScale = 1;
|
||||
|
||||
namespace {
|
||||
/// @p text on one line. A DXF value ends at the line break, so a break
|
||||
/// inside a text would shift every group code after it by one line and
|
||||
/// a reader would take values for codes (FINDINGS F052). Callers that
|
||||
/// mean several lines split them first; this is the safety net.
|
||||
QString singleLine(QString text)
|
||||
{
|
||||
text.replace(QLatin1String("\r\n"), QLatin1String(" "));
|
||||
text.replace(QLatin1Char('\r'), QLatin1Char(' '));
|
||||
text.replace(QLatin1Char('\n'), QLatin1Char(' '));
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Colour code of an entity. 0 is BYBLOCK, which means nothing outside a
|
||||
/// block and so fell back to the default colour; it is also what black
|
||||
/// maps to (RGBcodeTable[0]). BYLAYER (256) looks the same, the layers
|
||||
/// being colour 7, and lets a CAD user recolour a whole layer at once
|
||||
/// (discussion #1071). Real colours are kept.
|
||||
int entityColour(int colour)
|
||||
{
|
||||
return colour == 0 ? 256 : colour;
|
||||
}
|
||||
}
|
||||
double Createdxf::yScale = 1;
|
||||
QString Createdxf::layer = QStringLiteral("0");
|
||||
|
||||
Createdxf::Createdxf()
|
||||
{
|
||||
@@ -37,9 +62,12 @@ Createdxf::~Createdxf()
|
||||
{
|
||||
}
|
||||
|
||||
/* Header section of every DXF file.*/
|
||||
void Createdxf::dxfBegin (const QString& fileName)
|
||||
/* Header section of every DXF file.
|
||||
@param layers : the layers the entities will be written on, declared in
|
||||
the LAYER table beside layer "0", which every DXF has. */
|
||||
void Createdxf::dxfBegin (const QString& fileName, const QStringList &layers)
|
||||
{
|
||||
layer = QStringLiteral("0");
|
||||
|
||||
// Creation of an output stream object in text mode.
|
||||
// Header section of every dxf file.
|
||||
@@ -209,6 +237,35 @@ void Createdxf::dxfBegin (const QString& fileName)
|
||||
To_Dxf << 0.00 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n";
|
||||
|
||||
To_Dxf << "ENDTAB" << "\r\n";
|
||||
|
||||
// Layers: "0" plus the ones the entities use, all white/black
|
||||
// (colour 7) and continuous, so a file looks the same as before
|
||||
// until the user changes a layer.
|
||||
QStringList all_layers{QStringLiteral("0")};
|
||||
for (const QString &name : layers) {
|
||||
if (!all_layers.contains(name))
|
||||
all_layers << name;
|
||||
}
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "TABLE" << "\r\n";
|
||||
To_Dxf << 2 << "\r\n";
|
||||
To_Dxf << "LAYER" << "\r\n";
|
||||
To_Dxf << 70 << "\r\n";
|
||||
To_Dxf << all_layers.size() << "\r\n";
|
||||
for (const QString &name : std::as_const(all_layers)) {
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "LAYER" << "\r\n";
|
||||
To_Dxf << 2 << "\r\n";
|
||||
To_Dxf << name << "\r\n";
|
||||
To_Dxf << 70 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << 62 << "\r\n";
|
||||
To_Dxf << 7 << "\r\n";
|
||||
To_Dxf << 6 << "\r\n";
|
||||
To_Dxf << "CONTINUOUS" << "\r\n";
|
||||
}
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "ENDTAB" << "\r\n";
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "ENDSEC" << "\r\n";
|
||||
@@ -234,6 +291,7 @@ void Createdxf::dxfBegin (const QString& fileName)
|
||||
*/
|
||||
void Createdxf::dxfEnd(const QString& fileName)
|
||||
{
|
||||
layer = QStringLiteral("0");
|
||||
// Creation of an output stream object in text mode.
|
||||
if (!fileName.isEmpty()) {
|
||||
QFile file(fileName);
|
||||
@@ -284,9 +342,9 @@ void Createdxf::drawCircle(
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "CIRCLE" << "\r\n";
|
||||
To_Dxf << 8 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n"; // Layer number (default layer in autocad)
|
||||
To_Dxf << layer << "\r\n"; // Layer name
|
||||
To_Dxf << 62 << "\r\n";
|
||||
To_Dxf << colour << "\r\n"; // Colour Code
|
||||
To_Dxf << entityColour(colour) << "\r\n"; // Colour Code
|
||||
To_Dxf << 10 << "\r\n"; // XYZ is the Center point of circle
|
||||
To_Dxf << x << "\r\n"; // X in UCS (User Coordinate System)coordinates
|
||||
To_Dxf << 20 << "\r\n";
|
||||
@@ -332,9 +390,9 @@ void Createdxf::drawLine (
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "LINE" << "\r\n";
|
||||
To_Dxf << 8 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n"; // Layer number (default layer in autocad)
|
||||
To_Dxf << layer << "\r\n"; // Layer name
|
||||
To_Dxf << 62 << "\r\n";
|
||||
To_Dxf << colour << "\r\n"; // Colour Code
|
||||
To_Dxf << entityColour(colour) << "\r\n"; // Colour Code
|
||||
To_Dxf << 10 << "\r\n";
|
||||
To_Dxf << x1 << "\r\n"; // X in UCS (User Coordinate System)coordinates
|
||||
To_Dxf << 20 << "\r\n";
|
||||
@@ -569,7 +627,7 @@ void Createdxf::drawArcEllipse(
|
||||
arc_endAngle = temp;
|
||||
}
|
||||
|
||||
QPointF transformed_point = ExportDialog::rotation_transformed(
|
||||
QPointF transformed_point = DxfExport::rotation_transformed(
|
||||
center_x,
|
||||
center_y,
|
||||
hotspot_x,
|
||||
@@ -657,9 +715,9 @@ void Createdxf::drawArc(
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "ARC" << "\r\n";
|
||||
To_Dxf << 8 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n"; // Layer number (default layer in autocad)
|
||||
To_Dxf << layer << "\r\n"; // Layer name
|
||||
To_Dxf << 62 << "\r\n";
|
||||
To_Dxf << color << "\r\n"; // Colour Code
|
||||
To_Dxf << entityColour(color) << "\r\n"; // Colour Code
|
||||
To_Dxf << 10 << "\r\n"; // XYZ is the Center point of circle
|
||||
To_Dxf << x << "\r\n"; // X in UCS (User Coordinate System)coordinates
|
||||
To_Dxf << 20 << "\r\n";
|
||||
@@ -713,9 +771,9 @@ void Createdxf::drawText(
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "TEXT" << "\r\n";
|
||||
To_Dxf << 8 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n"; // Layer number (default layer in autocad)
|
||||
To_Dxf << layer << "\r\n"; // Layer name
|
||||
To_Dxf << 62 << "\r\n";
|
||||
To_Dxf << colour << "\r\n"; // Colour Code
|
||||
To_Dxf << entityColour(colour) << "\r\n"; // Colour Code
|
||||
To_Dxf << 10 << "\r\n"; // XYZ
|
||||
To_Dxf << x << "\r\n"; // X in UCS (User Coordinate System)coordinates
|
||||
To_Dxf << 20 << "\r\n";
|
||||
@@ -727,7 +785,7 @@ void Createdxf::drawText(
|
||||
To_Dxf << 41 << "\r\n";
|
||||
To_Dxf << xScaleW << "\r\n"; // X Scale
|
||||
To_Dxf << 1 << "\r\n";
|
||||
To_Dxf << text << "\r\n"; // Text Value
|
||||
To_Dxf << singleLine(text) << "\r\n"; // Text Value
|
||||
To_Dxf << 50 << "\r\n";
|
||||
To_Dxf << rotation << "\r\n"; // Text Rotation
|
||||
file.close();
|
||||
@@ -766,9 +824,9 @@ void Createdxf::drawTextAligned(
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "TEXT" << "\r\n";
|
||||
To_Dxf << 8 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n"; // Layer number (default layer in autocad)
|
||||
To_Dxf << layer << "\r\n"; // Layer name
|
||||
To_Dxf << 62 << "\r\n";
|
||||
To_Dxf << colour << "\r\n"; // Colour Code
|
||||
To_Dxf << entityColour(colour) << "\r\n"; // Colour Code
|
||||
To_Dxf << 10 << "\r\n"; // XYZ
|
||||
To_Dxf << x << "\r\n"; // X in UCS (User Coordinate System)coordinates
|
||||
To_Dxf << 20 << "\r\n";
|
||||
@@ -780,7 +838,7 @@ void Createdxf::drawTextAligned(
|
||||
To_Dxf << 41 << "\r\n";
|
||||
To_Dxf << xScaleW << "\r\n"; // X Scale
|
||||
To_Dxf << 1 << "\r\n";
|
||||
To_Dxf << text << "\r\n"; // Text Value
|
||||
To_Dxf << singleLine(text) << "\r\n"; // Text Value
|
||||
To_Dxf << 50 << "\r\n";
|
||||
To_Dxf << rotation << "\r\n"; // Text Rotation
|
||||
#if 0
|
||||
@@ -847,9 +905,9 @@ void Createdxf::drawPolyline(const QString &filepath,
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "POLYLINE" << "\r\n";
|
||||
To_Dxf << 8 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n"; // Layer number (default layer in autocad)
|
||||
To_Dxf << layer << "\r\n"; // Layer name
|
||||
To_Dxf << 62 << "\r\n";
|
||||
To_Dxf << colorcode << "\r\n"; // Colour Code
|
||||
To_Dxf << entityColour(colorcode) << "\r\n"; // Colour Code
|
||||
To_Dxf << 66 << "\r\n";
|
||||
To_Dxf << 1 << "\r\n";
|
||||
To_Dxf << 70 << "\r\n";
|
||||
@@ -873,7 +931,7 @@ void Createdxf::drawPolyline(const QString &filepath,
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "VERTEX" << "\r\n";
|
||||
To_Dxf << 8 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n"; // Layer number (default layer in autocad)
|
||||
To_Dxf << layer << "\r\n"; // Layer name
|
||||
To_Dxf << 70 << "\r\n";
|
||||
To_Dxf << 32 << "\r\n";
|
||||
To_Dxf << 10 << "\r\n";
|
||||
@@ -887,7 +945,7 @@ void Createdxf::drawPolyline(const QString &filepath,
|
||||
To_Dxf << 0 << "\r\n";
|
||||
To_Dxf << "SEQEND" << "\r\n";
|
||||
To_Dxf << 8 << "\r\n";
|
||||
To_Dxf << 0 << "\r\n"; // Layer number (default layer in autocad)
|
||||
To_Dxf << layer << "\r\n"; // Layer name
|
||||
|
||||
file.close();
|
||||
}
|
||||
|
||||
+5
-1
@@ -18,6 +18,7 @@
|
||||
#ifndef CREATEDXF_H
|
||||
#define CREATEDXF_H
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QtCore>
|
||||
#include <QtWidgets>
|
||||
|
||||
@@ -30,7 +31,7 @@ class Createdxf
|
||||
public:
|
||||
Createdxf();
|
||||
~Createdxf();
|
||||
static void dxfBegin (const QString&);
|
||||
static void dxfBegin (const QString&, const QStringList &layers = QStringList());
|
||||
static void dxfEnd(const QString&);
|
||||
// you can add more functions to create more drawings.
|
||||
static void drawCircle(
|
||||
@@ -159,6 +160,9 @@ class Createdxf
|
||||
static const double sheetHeight;
|
||||
static double xScale;
|
||||
static double yScale;
|
||||
/// Layer every entity is written on, "0" unless set; the
|
||||
/// layers themselves are declared by dxfBegin() (discussion #1071).
|
||||
static QString layer;
|
||||
};
|
||||
|
||||
#endif // CREATEDXF_H
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef LEGACYELEMENTTYPES_H
|
||||
#define LEGACYELEMENTTYPES_H
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QString>
|
||||
|
||||
/**
|
||||
Element type names in queries saved by versions before June 2022.
|
||||
The project database then held Element::linkTypeToString() ("Simple",
|
||||
"Terminale", ...); since it holds ElementData::typeToString()
|
||||
("simple", "terminal", ...). SQLite compares text case-sensitively, so
|
||||
a nomenclature table saved with element_type = 'Simple' silently lost
|
||||
every simple element, and its continuation tables were deleted as
|
||||
empty.
|
||||
*/
|
||||
namespace LegacyElementTypes
|
||||
{
|
||||
/**
|
||||
@return query with every element_type = '<old name>' comparison
|
||||
rewritten to the current name. Anything else is left as it is,
|
||||
so a query saved by a current version comes back unchanged.
|
||||
*/
|
||||
inline QString upgradeQuery(const QString &query)
|
||||
{
|
||||
static const QRegularExpression re(QStringLiteral(
|
||||
"element_type\\s*=\\s*'(Simple|NextReport|PreviousReport|Master"
|
||||
"|Slave|Terminale|Thumbnail)'"));
|
||||
|
||||
QString upgraded = query;
|
||||
auto it = re.globalMatch(query);
|
||||
int shift = 0;
|
||||
while (it.hasNext())
|
||||
{
|
||||
const auto match = it.next();
|
||||
const QString old_name = match.captured(1);
|
||||
QString new_name;
|
||||
if (old_name == QLatin1String("Simple")) new_name = QStringLiteral("simple");
|
||||
else if (old_name == QLatin1String("NextReport")) new_name = QStringLiteral("next_report");
|
||||
else if (old_name == QLatin1String("PreviousReport")) new_name = QStringLiteral("previous_report");
|
||||
else if (old_name == QLatin1String("Master")) new_name = QStringLiteral("master");
|
||||
else if (old_name == QLatin1String("Slave")) new_name = QStringLiteral("slave");
|
||||
else if (old_name == QLatin1String("Terminale")) new_name = QStringLiteral("terminal");
|
||||
else new_name = QStringLiteral("thumbnail");
|
||||
|
||||
upgraded.replace(match.capturedStart(1) + shift, old_name.size(), new_name);
|
||||
shift += new_name.size() - old_name.size();
|
||||
}
|
||||
return upgraded;
|
||||
}
|
||||
}
|
||||
|
||||
#endif // LEGACYELEMENTTYPES_H
|
||||
@@ -22,19 +22,24 @@
|
||||
#include "../diagram.h"
|
||||
#include "../diagramposition.h"
|
||||
#include "../elementprovider.h"
|
||||
#include "../itemgroups.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetgraphicsitem/conductor.h"
|
||||
#include "../qetgraphicsitem/diagramimageitem.h"
|
||||
#include "../qetgraphicsitem/element.h"
|
||||
#include "../qetgraphicsitem/independenttextitem.h"
|
||||
#include "../qetgraphicsitem/qetshapeitem.h"
|
||||
#include "../qetgraphicsitem/terminal.h"
|
||||
#include "../qetinformation.h"
|
||||
#include "../qetproject.h"
|
||||
|
||||
#include <QLocale>
|
||||
#include <QMetaEnum>
|
||||
#include <QTextDocument>
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QSqlDriver>
|
||||
#include <QSqlError>
|
||||
#include <sqlite3.h>
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +109,7 @@ void projectDataBase::updateDB()
|
||||
//refresh, and what they read back is unchanged either way.
|
||||
if (!m_content_changed)
|
||||
{
|
||||
flushDrawingItems();
|
||||
emit dataBaseUpdated();
|
||||
return;
|
||||
}
|
||||
@@ -113,6 +119,7 @@ void projectDataBase::updateDB()
|
||||
populateElementTable();
|
||||
populateElementInfoTable();
|
||||
populateConductorTable();
|
||||
populateDrawingItemTables();
|
||||
m_content_changed = false;
|
||||
|
||||
emit dataBaseUpdated();
|
||||
@@ -205,14 +212,18 @@ bool projectDataBase::isReadOnlySelect(const QString &query, QString *error)
|
||||
@param query the SQL text to run -- must be a single read-only
|
||||
SELECT/WITH statement, see isReadOnlySelect()
|
||||
@param error set to a human-readable reason when the query was rejected
|
||||
before ever reaching the database
|
||||
@return a QSqlQuery with query as query and the internal database of
|
||||
this class as database to use, or an unexecuted, harmless QSqlQuery if
|
||||
the query was rejected
|
||||
or failed
|
||||
@return the executed query, on the internal database of this class, or
|
||||
an empty, harmless QSqlQuery if the query was rejected or failed
|
||||
*/
|
||||
QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
|
||||
QString reason;
|
||||
|
||||
//Drawing-item rows are rewritten lazily, see drawingItemChanged().
|
||||
//Every read from outside comes through here, so this is the one
|
||||
//place the queue has to be emptied for a reader to see current rows.
|
||||
flushDrawingItems();
|
||||
|
||||
// First gate: which kind of statement is acceptable here at all. A
|
||||
// textual check is the right tool for that and the wrong tool for
|
||||
// anything else -- see isReadOnlySelect()'s own comment. It is what
|
||||
@@ -226,24 +237,24 @@ QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
|
||||
return QSqlQuery(m_data_base);
|
||||
}
|
||||
|
||||
// Second gate, and the one that actually enforces read-only: SQLite is
|
||||
// asked about the statement it compiled, instead of the text being read
|
||||
// for clues. The first gate cannot see through a CTE prefix --
|
||||
// "WITH x AS (SELECT 1) DELETE FROM element" starts with WITH, contains
|
||||
// no semicolon, and deletes every row. That matters beyond the
|
||||
// custom-query box, because this path is reachable from a file: a
|
||||
// <graphics_table>'s saved <query> is read straight out of the .qet by
|
||||
// ProjectDBModel::fromXml() and executed by fillValue(), so opening or
|
||||
// exporting a project someone else produced would have been enough.
|
||||
if (!QETSql::isSingleReadOnlyStatement(sqliteHandle(&m_data_base), query, &reason)) {
|
||||
// Second gate, and the one that actually enforces read-only: SQLite
|
||||
// runs the statement with query_only set and refuses a write itself,
|
||||
// instead of the text being read for clues. The first gate cannot see
|
||||
// through a CTE prefix -- "WITH x AS (SELECT 1) DELETE FROM element"
|
||||
// starts with WITH, contains no semicolon, and deletes every row. That
|
||||
// matters beyond the custom-query box, because this path is reachable
|
||||
// from a file: a <graphics_table>'s saved <query> is read straight out
|
||||
// of the .qet by ProjectDBModel::fromXml() and executed by fillValue(),
|
||||
// so opening or exporting a project someone else produced would have
|
||||
// been enough.
|
||||
QSqlQuery result = QETSql::execReadOnly(m_data_base, query, &reason);
|
||||
if (!reason.isEmpty()) {
|
||||
qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query;
|
||||
if (error) {
|
||||
*error = reason;
|
||||
}
|
||||
return QSqlQuery(m_data_base);
|
||||
}
|
||||
|
||||
return QSqlQuery(query, m_data_base);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,6 +402,13 @@ void projectDataBase::addDiagram(Diagram *diagram)
|
||||
qDebug() << "projectDataBase::addDiagram insert info error : " << m_insert_diagram_info_query.lastError();
|
||||
}
|
||||
|
||||
//A folio put back by undoing its removal comes with its items already
|
||||
//on it, and their rows went with it: queue them again.
|
||||
const QList<QGraphicsItem *> items = diagram->items();
|
||||
for (QGraphicsItem *item : items) {
|
||||
addDrawingItem(item);
|
||||
}
|
||||
|
||||
//The information "folio" of other existing diagram can have the variable %total,
|
||||
//so when a new diagram is added this variable change.
|
||||
//We need to update this information in the database.
|
||||
@@ -450,6 +468,31 @@ void projectDataBase::removeDiagram(Diagram *diagram)
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QString &table : {QStringLiteral("shape"),
|
||||
QStringLiteral("independent_text"),
|
||||
QStringLiteral("image")})
|
||||
{
|
||||
QSqlQuery cascade(m_data_base);
|
||||
cascade.prepare(QStringLiteral("DELETE FROM %1 WHERE diagram_uuid = :uuid").arg(table));
|
||||
cascade.bindValue(QStringLiteral(":uuid"), uuid_str);
|
||||
if (!cascade.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram" << table << "cascade error : "
|
||||
<< cascade.lastError();
|
||||
m_data_base.rollback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
//The folio's items keep existing (the removal can be undone), but
|
||||
//their rows are gone: forget them, so that nothing is deleted or
|
||||
//skipped later on the strength of a row that no longer exists.
|
||||
const QList<QObject *> tracked = m_drawing_item_row.keys();
|
||||
for (QObject *object : tracked) {
|
||||
auto *item = dynamic_cast<QGraphicsItem *>(object);
|
||||
if (item && item->scene() == diagram) {
|
||||
forgetDrawingItem(object);
|
||||
}
|
||||
}
|
||||
|
||||
m_remove_diagram_query.bindValue(":uuid", uuid_str);
|
||||
if (!m_remove_diagram_query.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram delete error : " << m_remove_diagram_query.lastError();
|
||||
@@ -608,6 +651,340 @@ void projectDataBase::bindConductorValues(QSqlQuery &query, Conductor *conductor
|
||||
query.bindValue(QStringLiteral(":text"), conductor->properties().text);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/// Table holding @p object's row, or an empty string if it has none.
|
||||
QString drawingItemTable(QObject *object)
|
||||
{
|
||||
if (qobject_cast<QetShapeItem *>(object)) return QStringLiteral("shape");
|
||||
if (qobject_cast<IndependentTextItem *>(object)) return QStringLiteral("independent_text");
|
||||
if (qobject_cast<DiagramImageItem *>(object)) return QStringLiteral("image");
|
||||
return QString();
|
||||
}
|
||||
|
||||
QUuid drawingItemUuid(QObject *object)
|
||||
{
|
||||
if (auto s = qobject_cast<QetShapeItem *>(object)) return s->uuid();
|
||||
if (auto t = qobject_cast<IndependentTextItem *>(object)) return t->uuid();
|
||||
if (auto i = qobject_cast<DiagramImageItem *>(object)) return i->uuid();
|
||||
return QUuid();
|
||||
}
|
||||
|
||||
/// The group_uuid column of @p item's row: its group, or NULL.
|
||||
QVariant groupValue(const QGraphicsItem *item)
|
||||
{
|
||||
const QUuid group = ItemGroups::groupOf(item);
|
||||
return group.isNull() ? QVariant() : QVariant(group.toString());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
@brief projectDataBase::addDrawingItem
|
||||
Start keeping a row for a shape, an independent text or an image that
|
||||
was just added to a folio. Anything else is ignored, so Diagram::addItem()
|
||||
can pass every item it gets.
|
||||
|
||||
The row is not written here but queued, like every later change to it:
|
||||
see drawingItemChanged() for why.
|
||||
@param item
|
||||
*/
|
||||
void projectDataBase::addDrawingItem(QGraphicsItem *item)
|
||||
{
|
||||
QGraphicsObject *object = item ? item->toGraphicsObject() : nullptr;
|
||||
if (!object || drawingItemTable(object).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto unique = Qt::UniqueConnection;
|
||||
connect(object, &QGraphicsObject::xChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(object, &QGraphicsObject::yChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(object, &QGraphicsObject::rotationChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(object, &QObject::destroyed, this, &projectDataBase::drawingItemDestroyed, unique);
|
||||
|
||||
if (auto shape = qobject_cast<QetShapeItem *>(object)) {
|
||||
connect(shape, &QetShapeItem::uuidChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(shape, &QetShapeItem::geometryChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(shape, &QetShapeItem::transformChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(shape, &QetShapeItem::penChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(shape, &QetShapeItem::brushChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
}
|
||||
else if (auto text = qobject_cast<IndependentTextItem *>(object)) {
|
||||
connect(text, &IndependentTextItem::uuidChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
//Sent by the document, not the item: drawingItemChanged() walks
|
||||
//back up to the item. It is the one signal that catches every way
|
||||
//the text changes -- typing, undo, a script's setTextContent().
|
||||
connect(text->document(), &QTextDocument::contentsChanged,
|
||||
this, &projectDataBase::drawingItemChanged, unique);
|
||||
}
|
||||
else if (auto image = qobject_cast<DiagramImageItem *>(object)) {
|
||||
connect(image, &DiagramImageItem::uuidChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(image, &DiagramImageItem::transformChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
connect(image, &DiagramImageItem::pixmapChanged, this, &projectDataBase::drawingItemChanged, unique);
|
||||
}
|
||||
|
||||
m_dirty_drawing_items.insert(object);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::removeDrawingItem
|
||||
Drop the row of a shape, independent text or image taken off its folio.
|
||||
The item itself usually lives on in the undo stack, so it is also
|
||||
disconnected: a change to it there must not bring its row back.
|
||||
@param item
|
||||
*/
|
||||
void projectDataBase::removeDrawingItem(QGraphicsItem *item)
|
||||
{
|
||||
QGraphicsObject *object = item ? item->toGraphicsObject() : nullptr;
|
||||
if (!object || drawingItemTable(object).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnect(object, nullptr, this, nullptr);
|
||||
if (auto text = qobject_cast<IndependentTextItem *>(object)) {
|
||||
disconnect(text->document(), nullptr, this, nullptr);
|
||||
}
|
||||
|
||||
const QUuid row = m_drawing_item_row.value(object);
|
||||
if (!row.isNull() && m_drawing_row_owner.value(row) == object)
|
||||
{
|
||||
QSqlQuery remove(m_data_base);
|
||||
remove.prepare(QStringLiteral("DELETE FROM %1 WHERE uuid = :uuid")
|
||||
.arg(drawingItemTable(object)));
|
||||
remove.bindValue(QStringLiteral(":uuid"), row.toString());
|
||||
if (!remove.exec()) {
|
||||
qDebug() << "projectDataBase::removeDrawingItem delete error : " << remove.lastError();
|
||||
}
|
||||
}
|
||||
forgetDrawingItem(object);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::itemGroupChanged
|
||||
@a item joined or left a group (discussion #1070). An element's row is
|
||||
updated at once; a drawing item's is queued like any other change to it.
|
||||
@param item
|
||||
*/
|
||||
void projectDataBase::itemGroupChanged(QGraphicsItem *item)
|
||||
{
|
||||
if (auto element = qgraphicsitem_cast<Element *>(item))
|
||||
{
|
||||
QSqlQuery update(m_data_base);
|
||||
update.prepare(QStringLiteral("UPDATE element SET group_uuid = :group_uuid WHERE uuid = :uuid"));
|
||||
update.bindValue(QStringLiteral(":group_uuid"), groupValue(element));
|
||||
update.bindValue(QStringLiteral(":uuid"), element->uuid().toString());
|
||||
if (!update.exec()) {
|
||||
qDebug() << "projectDataBase::itemGroupChanged update error : " << update.lastError();
|
||||
}
|
||||
m_content_changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
QGraphicsObject *object = item ? item->toGraphicsObject() : nullptr;
|
||||
if (object && !drawingItemTable(object).isEmpty()) {
|
||||
m_dirty_drawing_items.insert(object);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::drawingItemChanged
|
||||
Queue the sender's row to be rewritten.
|
||||
|
||||
Queued rather than written: a move sends xChanged/yChanged for every
|
||||
mouse step of every selected item, and nothing reads the rows between
|
||||
two steps. newQuery() and updateDB() flush the queue before anything
|
||||
does, so a reader never sees a stale row -- a queued write only costs a
|
||||
set insertion.
|
||||
*/
|
||||
void projectDataBase::drawingItemChanged()
|
||||
{
|
||||
QObject *object = sender();
|
||||
//QTextDocument::contentsChanged: the item is an ancestor of the
|
||||
//document (item -> text control -> document).
|
||||
while (object && drawingItemTable(object).isEmpty()) {
|
||||
object = object->parent();
|
||||
}
|
||||
if (object) {
|
||||
m_dirty_drawing_items.insert(object);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::drawingItemDestroyed
|
||||
An item deleted while still on its folio (e.g. by the scene's own
|
||||
destructor) never went through removeDrawingItem(). Its type can no
|
||||
longer be asked -- this runs from QObject's destructor -- so its row,
|
||||
if it wrote one, is looked for in all three tables.
|
||||
@param object
|
||||
*/
|
||||
void projectDataBase::drawingItemDestroyed(QObject *object)
|
||||
{
|
||||
const QUuid row = m_drawing_item_row.value(object);
|
||||
if (!row.isNull() && m_drawing_row_owner.value(row) == object)
|
||||
{
|
||||
for (const QString &table : {QStringLiteral("shape"),
|
||||
QStringLiteral("independent_text"),
|
||||
QStringLiteral("image")})
|
||||
{
|
||||
QSqlQuery remove(m_data_base);
|
||||
remove.prepare(QStringLiteral("DELETE FROM %1 WHERE uuid = :uuid").arg(table));
|
||||
remove.bindValue(QStringLiteral(":uuid"), row.toString());
|
||||
remove.exec();
|
||||
}
|
||||
}
|
||||
forgetDrawingItem(object);
|
||||
}
|
||||
|
||||
void projectDataBase::forgetDrawingItem(QObject *object)
|
||||
{
|
||||
const QUuid row = m_drawing_item_row.take(object);
|
||||
if (!row.isNull() && m_drawing_row_owner.value(row) == object) {
|
||||
m_drawing_row_owner.remove(row);
|
||||
}
|
||||
m_dirty_drawing_items.remove(object);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::writeDrawingItem
|
||||
Write @p object's row under its current uuid.
|
||||
@return false if the row must wait: another live item still owns that
|
||||
uuid. That is a pasted copy in the moment between being added to the
|
||||
folio and PasteDiagramCommand giving it its own uuid; writing then would
|
||||
overwrite its source's row with the copy's position. The copy's
|
||||
uuidChanged() queues it again once it has one.
|
||||
*/
|
||||
bool projectDataBase::writeDrawingItem(QObject *object)
|
||||
{
|
||||
auto *item = dynamic_cast<QGraphicsItem *>(object);
|
||||
auto *diagram = item ? qobject_cast<Diagram *>(item->scene()) : nullptr;
|
||||
if (!diagram || !m_project || !m_project->diagrams().contains(diagram)) {
|
||||
//Not on a folio of this project (any more): nothing to write,
|
||||
//and nothing to wait for.
|
||||
return true;
|
||||
}
|
||||
|
||||
const QUuid uuid = drawingItemUuid(object);
|
||||
QObject *owner = m_drawing_row_owner.value(uuid);
|
||||
if (owner && owner != object) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlQuery *query = nullptr;
|
||||
const QRectF rect = item->sceneBoundingRect();
|
||||
if (auto shape = qobject_cast<QetShapeItem *>(object))
|
||||
{
|
||||
query = &m_insert_shape_query;
|
||||
const QMetaEnum type = QetShapeItem::staticMetaObject.enumerator(
|
||||
QetShapeItem::staticMetaObject.indexOfEnumerator("ShapeType"));
|
||||
query->bindValue(QStringLiteral(":type"), QString::fromLatin1(type.valueToKey(shape->shapeType())));
|
||||
query->bindValue(QStringLiteral(":color"), shape->pen().color().name());
|
||||
query->bindValue(QStringLiteral(":fill"), shape->brush().style() == Qt::NoBrush
|
||||
? QStringLiteral("none")
|
||||
: shape->brush().color().name());
|
||||
}
|
||||
else if (auto text = qobject_cast<IndependentTextItem *>(object))
|
||||
{
|
||||
query = &m_insert_independent_text_query;
|
||||
query->bindValue(QStringLiteral(":text"), text->toPlainText());
|
||||
query->bindValue(QStringLiteral(":rotation"), text->rotation());
|
||||
}
|
||||
else if (auto image = qobject_cast<DiagramImageItem *>(object))
|
||||
{
|
||||
query = &m_insert_image_query;
|
||||
query->bindValue(QStringLiteral(":pixel_width"), image->pixmap().width());
|
||||
query->bindValue(QStringLiteral(":pixel_height"), image->pixmap().height());
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//Renewed since its last write (a paste, a folio duplication): its
|
||||
//old row was its own, and describes nothing now.
|
||||
const QUuid previous = m_drawing_item_row.value(object);
|
||||
if (!previous.isNull() && previous != uuid
|
||||
&& m_drawing_row_owner.value(previous) == object)
|
||||
{
|
||||
QSqlQuery remove(m_data_base);
|
||||
remove.prepare(QStringLiteral("DELETE FROM %1 WHERE uuid = :uuid")
|
||||
.arg(drawingItemTable(object)));
|
||||
remove.bindValue(QStringLiteral(":uuid"), previous.toString());
|
||||
remove.exec();
|
||||
m_drawing_row_owner.remove(previous);
|
||||
}
|
||||
|
||||
query->bindValue(QStringLiteral(":uuid"), uuid.toString());
|
||||
query->bindValue(QStringLiteral(":diagram_uuid"), diagram->uuid().toString());
|
||||
query->bindValue(QStringLiteral(":pos"), diagram->convertPosition(rect.topLeft()).toString());
|
||||
query->bindValue(QStringLiteral(":x"), rect.x());
|
||||
query->bindValue(QStringLiteral(":y"), rect.y());
|
||||
query->bindValue(QStringLiteral(":width"), rect.width());
|
||||
query->bindValue(QStringLiteral(":height"), rect.height());
|
||||
query->bindValue(QStringLiteral(":group_uuid"), groupValue(item));
|
||||
if (!query->exec()) {
|
||||
qDebug() << "projectDataBase::writeDrawingItem error : " << query->lastError();
|
||||
return true;
|
||||
}
|
||||
|
||||
m_drawing_item_row.insert(object, uuid);
|
||||
m_drawing_row_owner.insert(uuid, object);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::flushDrawingItems
|
||||
Write every queued drawing-item row. A row that has to wait for its uuid
|
||||
(see writeDrawingItem()) stays queued.
|
||||
*/
|
||||
void projectDataBase::flushDrawingItems()
|
||||
{
|
||||
if (m_dirty_drawing_items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QSet<QObject *> dirty = m_dirty_drawing_items;
|
||||
//One transaction for the batch, unless a caller already holds one.
|
||||
const bool own_transaction = m_data_base.transaction();
|
||||
for (QObject *object : dirty) {
|
||||
if (writeDrawingItem(object)) {
|
||||
m_dirty_drawing_items.remove(object);
|
||||
}
|
||||
}
|
||||
if (own_transaction) {
|
||||
m_data_base.commit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::populateDrawingItemTables
|
||||
Rebuild the shape, independent_text and image tables from every folio.
|
||||
*/
|
||||
void projectDataBase::populateDrawingItemTables()
|
||||
{
|
||||
QSqlQuery query_(m_data_base);
|
||||
query_.exec(QStringLiteral("DELETE FROM shape"));
|
||||
query_.exec(QStringLiteral("DELETE FROM independent_text"));
|
||||
query_.exec(QStringLiteral("DELETE FROM image"));
|
||||
m_drawing_item_row.clear();
|
||||
m_drawing_row_owner.clear();
|
||||
m_dirty_drawing_items.clear();
|
||||
|
||||
//Queued directly, not through addDrawingItem(): every one of them
|
||||
//came onto its folio through Diagram::addItem(), which connected it
|
||||
//already, and a full rebuild runs on every load.
|
||||
for (auto diagram : m_project->diagrams())
|
||||
{
|
||||
const QList<QGraphicsItem *> items = diagram->items();
|
||||
for (QGraphicsItem *item : items)
|
||||
{
|
||||
QGraphicsObject *object = item->toGraphicsObject();
|
||||
if (object && !drawingItemTable(object).isEmpty()) {
|
||||
m_dirty_drawing_items.insert(object);
|
||||
}
|
||||
}
|
||||
}
|
||||
flushDrawingItems();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::createDataBase
|
||||
Create the data base
|
||||
@@ -644,6 +1021,7 @@ bool projectDataBase::createDataBase()
|
||||
"pos VARCHAR(6) NOT NULL,"
|
||||
"type VARCHAR(50),"
|
||||
"sub_type VARCHAR(50),"
|
||||
"group_uuid VARCHAR(50),"
|
||||
"FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid)"
|
||||
")");
|
||||
if (!query_.exec(element_table)) {
|
||||
@@ -737,9 +1115,38 @@ bool projectDataBase::createDataBase()
|
||||
}
|
||||
}
|
||||
|
||||
//The folio's drawing furniture: shapes, independent texts, images.
|
||||
//x, y, width and height are the item's bounding rect on the folio,
|
||||
//pos the folio cell of its top left corner, as for element.
|
||||
const QString drawing_columns(
|
||||
"uuid VARCHAR(50) PRIMARY KEY NOT NULL, "
|
||||
"diagram_uuid VARCHAR(50) NOT NULL, "
|
||||
"pos VARCHAR(6), "
|
||||
"x REAL, y REAL, width REAL, height REAL, "
|
||||
"group_uuid VARCHAR(50), ");
|
||||
for (const QString &table : {
|
||||
QStringLiteral("CREATE TABLE shape (") + drawing_columns +
|
||||
"type VARCHAR(20), color VARCHAR(20), fill VARCHAR(20), "
|
||||
"FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))",
|
||||
QStringLiteral("CREATE TABLE independent_text (") + drawing_columns +
|
||||
"text TEXT, rotation REAL, "
|
||||
"FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))",
|
||||
QStringLiteral("CREATE TABLE image (") + drawing_columns +
|
||||
"pixel_width INTEGER, pixel_height INTEGER, "
|
||||
"FOREIGN KEY (diagram_uuid) REFERENCES diagram (uuid))",
|
||||
QStringLiteral("CREATE INDEX idx_shape_diagram ON shape (diagram_uuid)"),
|
||||
QStringLiteral("CREATE INDEX idx_independent_text_diagram ON independent_text (diagram_uuid)"),
|
||||
QStringLiteral("CREATE INDEX idx_image_diagram ON image (diagram_uuid)") })
|
||||
{
|
||||
if (!query_.exec(table)) {
|
||||
qDebug() << "drawing item table query : " << query_.lastError();
|
||||
}
|
||||
}
|
||||
|
||||
createElementNomenclatureView();
|
||||
createSummaryView();
|
||||
createWiringListView();
|
||||
createDrawingItemView();
|
||||
prepareQuery();
|
||||
updateDB();
|
||||
return true;
|
||||
@@ -934,6 +1341,32 @@ void projectDataBase::createWiringListView()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::createDrawingItemView
|
||||
One row per shape, independent text and image, whichever table holds it:
|
||||
find anything by uuid without knowing its kind first. folio is the
|
||||
folio's position in the project, starting at 1; description is the
|
||||
shape type, the text, or empty for an image.
|
||||
*/
|
||||
void projectDataBase::createDrawingItemView()
|
||||
{
|
||||
QSqlQuery query(m_data_base);
|
||||
const QString create_view(
|
||||
"CREATE VIEW drawing_item_view AS "
|
||||
"SELECT i.uuid, i.kind, d.pos AS folio, i.diagram_uuid, i.pos, "
|
||||
"i.x, i.y, i.width, i.height, i.description FROM ("
|
||||
"SELECT uuid, 'shape' AS kind, diagram_uuid, pos, x, y, width, height, "
|
||||
"type AS description FROM shape "
|
||||
"UNION ALL SELECT uuid, 'text', diagram_uuid, pos, x, y, width, height, "
|
||||
"text FROM independent_text "
|
||||
"UNION ALL SELECT uuid, 'image', diagram_uuid, pos, x, y, width, height, "
|
||||
"'' FROM image"
|
||||
") AS i LEFT JOIN diagram AS d ON d.uuid = i.diagram_uuid");
|
||||
if (!query.exec(create_view)) {
|
||||
qDebug() << query.lastError();
|
||||
}
|
||||
}
|
||||
|
||||
void projectDataBase::populateDiagramTable()
|
||||
{
|
||||
QSqlQuery query_(m_data_base);
|
||||
@@ -1119,6 +1552,23 @@ void projectDataBase::prepareQuery()
|
||||
m_remove_diagram_query = QSqlQuery(m_data_base);
|
||||
m_remove_diagram_query.prepare("DELETE FROM diagram WHERE uuid=:uuid");
|
||||
|
||||
//DRAWING ITEMS. OR REPLACE: a row is rewritten in place on every
|
||||
//change, see writeDrawingItem().
|
||||
const QString drawing_columns("uuid, diagram_uuid, pos, x, y, width, height, group_uuid");
|
||||
const QString drawing_values(":uuid, :diagram_uuid, :pos, :x, :y, :width, :height, :group_uuid");
|
||||
m_insert_shape_query = QSqlQuery(m_data_base);
|
||||
m_insert_shape_query.prepare("INSERT OR REPLACE INTO shape (" + drawing_columns +
|
||||
", type, color, fill) VALUES (" + drawing_values +
|
||||
", :type, :color, :fill)");
|
||||
m_insert_independent_text_query = QSqlQuery(m_data_base);
|
||||
m_insert_independent_text_query.prepare("INSERT OR REPLACE INTO independent_text (" + drawing_columns +
|
||||
", text, rotation) VALUES (" + drawing_values +
|
||||
", :text, :rotation)");
|
||||
m_insert_image_query = QSqlQuery(m_data_base);
|
||||
m_insert_image_query.prepare("INSERT OR REPLACE INTO image (" + drawing_columns +
|
||||
", pixel_width, pixel_height) VALUES (" + drawing_values +
|
||||
", :pixel_width, :pixel_height)");
|
||||
|
||||
//INSERT DIAGRAM INFO
|
||||
m_insert_diagram_info_query = QSqlQuery(m_data_base);
|
||||
QStringList bind_diag_info_values;
|
||||
@@ -1149,7 +1599,7 @@ void projectDataBase::prepareQuery()
|
||||
m_diagram_info_order_changed.prepare("UPDATE diagram_info SET folio = :folio WHERE diagram_uuid = :uuid");
|
||||
|
||||
//INSERT ELEMENT
|
||||
QString insert_element_query("INSERT INTO element (uuid, diagram_uuid, pos, type, sub_type) VALUES (:uuid, :diagram_uuid, :pos, :type, :sub_type)");
|
||||
QString insert_element_query("INSERT INTO element (uuid, diagram_uuid, pos, type, sub_type, group_uuid) VALUES (:uuid, :diagram_uuid, :pos, :type, :sub_type, :group_uuid)");
|
||||
m_insert_elements_query = QSqlQuery(m_data_base);
|
||||
m_insert_elements_query.prepare(insert_element_query);
|
||||
|
||||
@@ -1263,6 +1713,7 @@ void projectDataBase::bindElementValues(QSqlQuery &query, Element *element, Diag
|
||||
query.bindValue(QStringLiteral(":pos"), diagram->convertPosition(element->scenePos()).toString());
|
||||
query.bindValue(QStringLiteral(":type"), element_data.typeToString());
|
||||
query.bindValue(QStringLiteral(":sub_type"), element_data.masterTypeToString());
|
||||
query.bindValue(QStringLiteral(":group_uuid"), groupValue(element));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1301,23 +1752,6 @@ void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief projectDataBase::sqliteHandle
|
||||
@param db
|
||||
@return the sqlite3 handler class used internally by db
|
||||
*/
|
||||
sqlite3 *projectDataBase::sqliteHandle(QSqlDatabase *db)
|
||||
{
|
||||
sqlite3 *handle = nullptr;
|
||||
|
||||
QVariant v = db->driver()->handle();
|
||||
if (v.isValid() && qstrcmp(v.typeName(), "sqlite3*") == 0) {
|
||||
handle = *static_cast<sqlite3 **>(v.data());
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
#ifdef QET_EXPORT_PROJECT_DB
|
||||
|
||||
/**
|
||||
@@ -1364,6 +1798,7 @@ void projectDataBase::exportDb(projectDataBase *db,
|
||||
// VACUUM INTO creates a standalone copy of the current database without
|
||||
// requiring access to the SQLite driver's native connection handle.
|
||||
const auto escaped_path = path_.replace("'", "''");
|
||||
db->flushDrawingItems();
|
||||
QSqlQuery query(db->m_data_base);
|
||||
if (!query.exec("VACUUM INTO '" % escaped_path % "'")) {
|
||||
qWarning() << "Unable to export project database:" << query.lastError().text();
|
||||
|
||||
@@ -23,13 +23,16 @@
|
||||
#include <QSqlQuery>
|
||||
#include <QPointer>
|
||||
#include <QFileDialog>
|
||||
#include <QHash>
|
||||
#include <QSet>
|
||||
#include <QUuid>
|
||||
|
||||
class Element;
|
||||
class QETProject;
|
||||
class Diagram;
|
||||
class Conductor;
|
||||
class Terminal;
|
||||
struct sqlite3;
|
||||
class QGraphicsItem;
|
||||
|
||||
/**
|
||||
@brief The projectDataBase class
|
||||
@@ -98,9 +101,18 @@ class projectDataBase : public QObject
|
||||
void removeConductor (Conductor *conductor);
|
||||
void updateConductor (Conductor *conductor);
|
||||
|
||||
//Shapes, independent texts and images: the folio's drawing
|
||||
//furniture. Anything else passed here is ignored.
|
||||
void addDrawingItem (QGraphicsItem *item);
|
||||
void removeDrawingItem (QGraphicsItem *item);
|
||||
void itemGroupChanged (QGraphicsItem *item);
|
||||
|
||||
private slots:
|
||||
//Refresh the sender()'s row after Conductor::setProperties().
|
||||
void conductorPropertiesChanged();
|
||||
//Queue the sender()'s drawing-item row for rewriting.
|
||||
void drawingItemChanged();
|
||||
void drawingItemDestroyed(QObject *object);
|
||||
|
||||
public:
|
||||
|
||||
@@ -112,11 +124,16 @@ class projectDataBase : public QObject
|
||||
void createElementNomenclatureView();
|
||||
void createSummaryView();
|
||||
void createWiringListView();
|
||||
void createDrawingItemView();
|
||||
void populateDiagramTable();
|
||||
void populateElementTable();
|
||||
void populateElementInfoTable();
|
||||
void populateDiagramInfoTable();
|
||||
void populateConductorTable();
|
||||
void populateDrawingItemTables();
|
||||
bool writeDrawingItem(QObject *object);
|
||||
void flushDrawingItems();
|
||||
void forgetDrawingItem(QObject *object);
|
||||
void bindConductorValues(QSqlQuery &query, Conductor *conductor, Diagram *diagram);
|
||||
void watchConductor(Conductor *conductor);
|
||||
void insertTerminal(Terminal *terminal);
|
||||
@@ -154,13 +171,20 @@ class projectDataBase : public QObject
|
||||
m_cascade_remove_element_info_query,
|
||||
m_cascade_remove_terminal_query,
|
||||
m_cascade_remove_conductor_query,
|
||||
m_cascade_remove_element_query;
|
||||
m_cascade_remove_element_query,
|
||||
m_insert_shape_query,
|
||||
m_insert_independent_text_query,
|
||||
m_insert_image_query;
|
||||
|
||||
public:
|
||||
// Deliberately outside the QET_EXPORT_PROJECT_DB guard below:
|
||||
// newQuery() needs the raw connection to ask SQLite whether a
|
||||
// query only reads, and that check runs in every build.
|
||||
static sqlite3 *sqliteHandle(QSqlDatabase *db);
|
||||
//Which uuid's row each drawing item last wrote, and which item
|
||||
//wrote each row. A pasted copy is added to the folio still
|
||||
//carrying its source's uuid and renewed only afterwards, so two
|
||||
//live items can briefly share one: the row belongs to whichever
|
||||
//wrote it, and the other waits in m_dirty_drawing_items until its
|
||||
//uuid is its own. @see writeDrawingItem().
|
||||
QHash<QObject *, QUuid> m_drawing_item_row;
|
||||
QHash<QUuid, QObject *> m_drawing_row_owner;
|
||||
QSet<QObject *> m_dirty_drawing_items;
|
||||
|
||||
#ifdef QET_EXPORT_PROJECT_DB
|
||||
public:
|
||||
|
||||
@@ -18,15 +18,14 @@
|
||||
#include "sqlreadonly.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <QSqlError>
|
||||
|
||||
namespace QETSql {
|
||||
|
||||
/**
|
||||
@brief QETSql::isSingleReadOnlyStatement
|
||||
Ask SQLite itself whether @p query is exactly one statement, and whether
|
||||
that statement only reads.
|
||||
@brief QETSql::execReadOnly
|
||||
Run @p query on @p db with SQLite's query_only pragma set, so that
|
||||
SQLite itself refuses anything that would write.
|
||||
|
||||
Why SQLite is asked rather than the text inspected: a check on the
|
||||
query's first keyword cannot see what the statement actually does.
|
||||
@@ -37,98 +36,82 @@ namespace QETSql {
|
||||
WITH x AS (SELECT 1) DELETE FROM element
|
||||
@endcode
|
||||
|
||||
begins with WITH, contains no semicolon, and deletes every row.
|
||||
sqlite3_stmt_readonly() reports on the statement SQLite compiled, not
|
||||
on how it was spelled, so the same query is correctly refused here
|
||||
while an ordinary WITH ... SELECT still passes.
|
||||
begins with WITH, contains no semicolon, and deletes every row. With
|
||||
query_only set, SQLite fails that statement with SQLITE_READONLY when it
|
||||
tries to start writing, before any row is touched, while an ordinary
|
||||
WITH ... SELECT still runs.
|
||||
|
||||
The statement is compiled and immediately finalised; sqlite3_prepare_v2()
|
||||
does not run it, so nothing is executed to reach this verdict.
|
||||
Why a pragma rather than sqlite3_stmt_readonly(): that needs the
|
||||
driver's native sqlite3 handle passed to the libsqlite3 QElectroTech
|
||||
links. The QSQLITE plugin of the Qt online installer carries its own
|
||||
private copy of SQLite, so that handle belongs to another library and
|
||||
the call crashes (qelectrotech-source-mirror#1045). A pragma goes
|
||||
through the driver, whichever SQLite it uses.
|
||||
|
||||
This is a read-only test, NOT a statement-type allowlist: SQLite
|
||||
considers ATTACH, BEGIN and several PRAGMAs read-only too, because none
|
||||
of them change the contents of the database. Callers that need to
|
||||
restrict which *kind* of statement is acceptable must say so separately
|
||||
-- projectDataBase::newQuery() keeps isReadOnlySelect() in front of this
|
||||
A statement that succeeded here is read-only, so running the returned
|
||||
query again, as several callers do, runs a read-only statement again.
|
||||
A refused one comes back as an empty query with nothing to run again:
|
||||
query_only is only set for the duration of this call.
|
||||
|
||||
Qt's SQLite driver refuses a second statement after the first one, so
|
||||
"SELECT 1; DROP TABLE element" is refused too.
|
||||
|
||||
This is a read-only test, NOT a statement-type allowlist: query_only
|
||||
does not refuse ATTACH, BEGIN or most PRAGMAs, because none of them
|
||||
change the contents of the database. Callers that need to restrict
|
||||
which *kind* of statement is acceptable must say so separately --
|
||||
projectDataBase::newQuery() keeps isReadOnlySelect() in front of this
|
||||
for exactly that reason.
|
||||
|
||||
@param handle the connection the query would run on. A null handle is
|
||||
refused rather than waved through: without it there is nothing to ask,
|
||||
and guessing from the text is the weakness this exists to replace.
|
||||
@param db the connection to run the query on
|
||||
@param query the raw SQL text
|
||||
@param error set to a human-readable reason when this returns false
|
||||
@return true if @p query is a single, read-only statement
|
||||
@param error set to a human-readable reason when the query is refused
|
||||
@return the executed query, or an empty query on @p db if @p query was
|
||||
refused or failed
|
||||
*/
|
||||
bool isSingleReadOnlyStatement(sqlite3 *handle, const QString &query, QString *error)
|
||||
QSqlQuery execReadOnly(const QSqlDatabase &db, const QString &query, QString *error)
|
||||
{
|
||||
if (error) {
|
||||
error->clear();
|
||||
}
|
||||
|
||||
if (!handle) {
|
||||
if (!QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = ON"))) {
|
||||
if (error) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Impossible de vérifier la requête : "
|
||||
"aucune connexion SQLite disponible.");
|
||||
"la base de données ne peut pas être mise en lecture seule.");
|
||||
}
|
||||
return false;
|
||||
return QSqlQuery(db);
|
||||
}
|
||||
|
||||
const QByteArray utf8 = query.toUtf8();
|
||||
sqlite3_stmt *statement = nullptr;
|
||||
const char *tail = nullptr;
|
||||
QSqlQuery result(db);
|
||||
const bool ok = result.exec(query);
|
||||
//Switching query_only off aborts a statement SQLite is still
|
||||
//stepping through -- an unsorted UNION ALL then ends after its
|
||||
//first row. So the checked run is finished first, and a statement
|
||||
//that passed is run again for the caller: SQLite refuses a write
|
||||
//at its first step, so passing that step proves it read-only.
|
||||
result.finish();
|
||||
QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = OFF"));
|
||||
|
||||
if (sqlite3_prepare_v2(handle, utf8.constData(), utf8.size(),
|
||||
&statement, &tail) != SQLITE_OK)
|
||||
{
|
||||
if (error) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Requête SQL invalide : %1")
|
||||
.arg(QString::fromUtf8(sqlite3_errmsg(handle)));
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
return false;
|
||||
if (ok) {
|
||||
result.exec(query);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Whitespace or a bare comment compiles successfully to no statement
|
||||
// at all, and sqlite3_stmt_readonly() must not be handed that.
|
||||
if (!statement) {
|
||||
if (error) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"La requête ne contient aucune instruction.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool read_only = sqlite3_stmt_readonly(statement) != 0;
|
||||
sqlite3_finalize(statement);
|
||||
|
||||
if (!read_only) {
|
||||
if (error) {
|
||||
if (error) {
|
||||
// SQLITE_READONLY is 8; extended codes keep it in the low byte.
|
||||
if ((result.lastError().nativeErrorCode().toInt() & 0xff) == 8) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Seules les requêtes en lecture seule sont autorisées : "
|
||||
"cette requête modifierait la base de données.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// tail points just past the first statement, semicolon included.
|
||||
// Anything left once semicolons and spacing are stripped is a second
|
||||
// statement -- caught structurally here, where "SELECT ';'" is a
|
||||
// perfectly ordinary query rather than a suspicious string.
|
||||
if (tail) {
|
||||
QString rest = QString::fromUtf8(tail);
|
||||
rest.remove(QLatin1Char(';'));
|
||||
if (!rest.trimmed().isEmpty()) {
|
||||
if (error) {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Une seule requête est autorisée.");
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
*error = QCoreApplication::translate("QETSql",
|
||||
"Requête SQL invalide : %1")
|
||||
.arg(result.lastError().databaseText());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return QSqlQuery(db);
|
||||
}
|
||||
|
||||
} // namespace QETSql
|
||||
|
||||
@@ -18,26 +18,26 @@
|
||||
#ifndef SQLREADONLY_H
|
||||
#define SQLREADONLY_H
|
||||
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
#include <QString>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
/**
|
||||
Deciding whether a piece of SQL only reads.
|
||||
Running a piece of SQL only if it reads.
|
||||
|
||||
Deliberately its own translation unit, depending on nothing but QString
|
||||
and SQLite: it is the enforcement point for every query QElectroTech
|
||||
runs against a project database, including queries that arrive from
|
||||
outside the application (a .qet file's saved report/table query), so it
|
||||
is worth being able to test it in isolation -- see
|
||||
Deliberately its own translation unit, depending on nothing but Qt SQL:
|
||||
it is the enforcement point for every query QElectroTech runs against a
|
||||
project database, including queries that arrive from outside the
|
||||
application (a .qet file's saved report/table query), so it is worth
|
||||
being able to test it in isolation -- see
|
||||
tests/qttest/tst_sqlreadonly.cpp, which links this file and nothing
|
||||
else of QElectroTech.
|
||||
*/
|
||||
namespace QETSql {
|
||||
|
||||
bool isSingleReadOnlyStatement(sqlite3 *handle,
|
||||
const QString &query,
|
||||
QString *error = nullptr);
|
||||
QSqlQuery execReadOnly(const QSqlDatabase &db,
|
||||
const QString &query,
|
||||
QString *error = nullptr);
|
||||
}
|
||||
|
||||
#endif // SQLREADONLY_H
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "../../properties/elementdata.h"
|
||||
#include "../../qetapp.h"
|
||||
#include "../../qetinformation.h"
|
||||
#include "../legacyelementtypes.h"
|
||||
#include "../projectdatabase.h"
|
||||
#include "ui_elementquerywidget.h"
|
||||
|
||||
@@ -674,7 +675,9 @@ void ElementQueryWidget::on_m_load_pb_clicked()
|
||||
|
||||
auto value_object = value.toObject();
|
||||
if (value_object.value("query").isString()) {
|
||||
setQuery(value_object.value("query").toString());
|
||||
//A query saved before June 2022 names element types the old way
|
||||
setQuery(LegacyElementTypes::upgradeQuery(
|
||||
value_object.value("query").toString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+201
-32
@@ -42,7 +42,12 @@
|
||||
#include "qetinformation.h"
|
||||
#include "qetproject.h"
|
||||
#include "diagramsortkeys.h"
|
||||
#include "itemgroups.h"
|
||||
#include "textgrid.h"
|
||||
#include <QGraphicsView>
|
||||
#include <QTextStream>
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <cassert>
|
||||
#include <math.h>
|
||||
|
||||
@@ -99,6 +104,44 @@ namespace {
|
||||
QString b = terminalSortKey(cond->terminal2);
|
||||
return (a <= b) ? (a + QLatin1Char('>') + b) : (b + QLatin1Char('>') + a);
|
||||
}
|
||||
|
||||
/// Serialize @p items and append them to a new @p tag block of @p root,
|
||||
/// in stacking order (@p stack_rank). items() cannot be trusted for
|
||||
/// this: with NoIndex, the first removeItem() on the scene sorts Qt's
|
||||
/// item list by pointer address, so from then on items() hands them over
|
||||
/// in a per-run order (bugtracker #343). Stacking order is what reloading
|
||||
/// the file rebuilds, so the drawing is unchanged and a resave is stable.
|
||||
template <typename T>
|
||||
void appendInStackingOrder(QDomDocument &document, QDomElement &root,
|
||||
const QString &tag, const QVector<T *> &items,
|
||||
const QHash<const QGraphicsItem *, int> &stack_rank)
|
||||
{
|
||||
if (items.isEmpty())
|
||||
return;
|
||||
struct Entry { int rank; QString xml_text; QDomElement xml; };
|
||||
QVector<Entry> sorted;
|
||||
for (T *item : items) {
|
||||
Entry entry{stack_rank.value(item, INT_MAX), QString(),
|
||||
item->toXml(document)};
|
||||
ItemGroups::write(entry.xml, item);
|
||||
// Only an item the stacking query missed needs a tiebreak.
|
||||
if (entry.rank == INT_MAX) {
|
||||
QTextStream stream(&entry.xml_text);
|
||||
entry.xml.save(stream, 0);
|
||||
}
|
||||
sorted.append(entry);
|
||||
}
|
||||
std::stable_sort(sorted.begin(), sorted.end(),
|
||||
[](const Entry &a, const Entry &b) {
|
||||
return a.rank != b.rank ? a.rank < b.rank
|
||||
: a.xml_text < b.xml_text;
|
||||
});
|
||||
|
||||
auto block = document.createElement(tag);
|
||||
for (const auto &entry : sorted)
|
||||
block.appendChild(entry.xml);
|
||||
root.appendChild(block);
|
||||
}
|
||||
}
|
||||
|
||||
int Diagram::xGrid = 10;
|
||||
@@ -170,6 +213,7 @@ Diagram::Diagram(QETProject *project) :
|
||||
connect(&border_and_titleblock,
|
||||
&BorderTitleBlock::needTitleBlockTemplate,
|
||||
this, &Diagram::setTitleBlockTemplate);
|
||||
|
||||
connect(&border_and_titleblock,
|
||||
&BorderTitleBlock::informationChanged,
|
||||
this, &Diagram::titleChanged);
|
||||
@@ -392,7 +436,9 @@ void Diagram::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||
}
|
||||
}
|
||||
|
||||
rememberSelection();
|
||||
QGraphicsScene::mousePressEvent(event);
|
||||
completeGroupSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -431,6 +477,9 @@ void Diagram::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||
}
|
||||
|
||||
QGraphicsScene::mouseReleaseEvent(event);
|
||||
//A click on an already selected item changes the selection on
|
||||
//release, not on press (Ctrl toggles it, a plain click keeps only it).
|
||||
completeGroupSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1205,8 +1254,9 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) {
|
||||
if (!list_elements.isEmpty()) {
|
||||
auto dom_elements = document.createElement(QStringLiteral("elements"));
|
||||
for (auto elmt : list_elements) {
|
||||
dom_elements.appendChild(elmt->toXml(document,
|
||||
table_adr_id));
|
||||
QDomElement dom_element = elmt->toXml(document, table_adr_id);
|
||||
ItemGroups::write(dom_element, elmt);
|
||||
dom_elements.appendChild(dom_element);
|
||||
// If copy is active we have to undo the changes we have made during creating(filling) 'list_elements'
|
||||
if(is_copy_command && (elmt->linkType() == Element::Slave || elmt->linkType()&Element::AllReport))
|
||||
restoreText(elmt);
|
||||
@@ -1223,37 +1273,20 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) {
|
||||
dom_root.appendChild(dom_conductors);
|
||||
}
|
||||
|
||||
if (!list_texts.isEmpty()) {
|
||||
auto dom_texts = document.createElement(QStringLiteral("inputs"));
|
||||
for (auto dti : list_texts) {
|
||||
dom_texts.appendChild(dti->toXml(document));
|
||||
}
|
||||
dom_root.appendChild(dom_texts);
|
||||
}
|
||||
|
||||
if (!list_images.isEmpty()) {
|
||||
auto dom_images = document.createElement(QStringLiteral("images"));
|
||||
for (auto dii : list_images) {
|
||||
dom_images.appendChild(dii->toXml(document));
|
||||
}
|
||||
dom_root.appendChild(dom_images);
|
||||
}
|
||||
|
||||
if (!list_shapes.isEmpty()) {
|
||||
auto dom_shapes = document.createElement(QStringLiteral("shapes"));
|
||||
for (auto dii : list_shapes) {
|
||||
dom_shapes.appendChild(dii -> toXml(document));
|
||||
}
|
||||
dom_root.appendChild(dom_shapes);
|
||||
}
|
||||
|
||||
if (table_vector.size()) {
|
||||
auto tables = document.createElement(QStringLiteral("tables"));
|
||||
for (auto table : table_vector) {
|
||||
tables.appendChild(table->toXml(document));
|
||||
}
|
||||
dom_root.appendChild(tables);
|
||||
// A rect query, unlike items(), returns true stacking order (z, then
|
||||
// insertion order) even with NoIndex.
|
||||
QHash<const QGraphicsItem *, int> stack_rank;
|
||||
{
|
||||
const QList<QGraphicsItem *> stacked = items(
|
||||
QRectF(-1e9, -1e9, 2e9, 2e9), Qt::IntersectsItemBoundingRect,
|
||||
Qt::AscendingOrder);
|
||||
for (int i = 0 ; i < stacked.size() ; ++i)
|
||||
stack_rank.insert(stacked.at(i), i);
|
||||
}
|
||||
appendInStackingOrder(document, dom_root, QStringLiteral("inputs"), list_texts, stack_rank);
|
||||
appendInStackingOrder(document, dom_root, QStringLiteral("images"), list_images, stack_rank);
|
||||
appendInStackingOrder(document, dom_root, QStringLiteral("shapes"), list_shapes, stack_rank);
|
||||
appendInStackingOrder(document, dom_root, QStringLiteral("tables"), table_vector, stack_rank);
|
||||
|
||||
if (!strip_vector.isEmpty()) {
|
||||
dom_root.appendChild(TerminalStripItemXml::toXml(strip_vector, document));
|
||||
@@ -1643,10 +1676,34 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
delete nvel_elmt;
|
||||
qDebug() << QStringLiteral("Diagram::fromXml() : Le chargement des parametres d'un element a echoue");
|
||||
} else {
|
||||
ItemGroups::setGroup(nvel_elmt, ItemGroups::read(element_xml));
|
||||
added_elements << nvel_elmt;
|
||||
}
|
||||
}
|
||||
|
||||
//Texts, images and shapes written before they carried a uuid (or
|
||||
//carrying one already used on this folio, from a hand-edited file)
|
||||
//get one derived from the folio uuid, their kind and their order in
|
||||
//the file, so that loading the same file twice gives the same uuids.
|
||||
//A random one would be a different identity on every load, and
|
||||
//toXml() writes it out, which is what #754 was for conductors.
|
||||
//Only for a folio being loaded: a paste renews them all anyway.
|
||||
QSet<QUuid> used_uuids;
|
||||
auto settle_uuid = [&](auto *item, const QDomElement &xml,
|
||||
const QString &kind, int index) {
|
||||
const QUuid persisted(xml.attribute(QStringLiteral("uuid")));
|
||||
if (consider_informations
|
||||
&& (persisted.isNull() || used_uuids.contains(persisted))) {
|
||||
item->setUuid(QUuid::createUuidV5(
|
||||
m_uuid,
|
||||
QStringLiteral("%1\n%2\n%3")
|
||||
.arg(kind)
|
||||
.arg(index)
|
||||
.arg(persisted.toString())));
|
||||
}
|
||||
used_uuids.insert(item->uuid());
|
||||
};
|
||||
|
||||
// Load text
|
||||
QList<IndependentTextItem *> added_texts;
|
||||
for (auto text_xml : QET::findInDomElement(root,
|
||||
@@ -1654,6 +1711,8 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
QStringLiteral("input"))) {
|
||||
IndependentTextItem *iti = new IndependentTextItem();
|
||||
iti -> fromXml(text_xml);
|
||||
settle_uuid(iti, text_xml, QStringLiteral("input"), added_texts.size());
|
||||
ItemGroups::setGroup(iti, ItemGroups::read(text_xml));
|
||||
addItem(iti);
|
||||
added_texts << iti;
|
||||
}
|
||||
@@ -1665,6 +1724,8 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
QStringLiteral("image"))) {
|
||||
DiagramImageItem *dii = new DiagramImageItem ();
|
||||
dii -> fromXml(image_xml);
|
||||
settle_uuid(dii, image_xml, QStringLiteral("image"), added_images.size());
|
||||
ItemGroups::setGroup(dii, ItemGroups::read(image_xml));
|
||||
addItem(dii);
|
||||
added_images << dii;
|
||||
}
|
||||
@@ -1676,6 +1737,8 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
QStringLiteral("shape"))) {
|
||||
QetShapeItem *dii = new QetShapeItem (QPointF(0,0));
|
||||
dii -> fromXml(shape_xml);
|
||||
settle_uuid(dii, shape_xml, QStringLiteral("shape"), added_shapes.size());
|
||||
ItemGroups::setGroup(dii, ItemGroups::read(shape_xml));
|
||||
addItem(dii);
|
||||
added_shapes << dii;
|
||||
}
|
||||
@@ -1869,6 +1932,13 @@ void Diagram::addItem(QGraphicsItem *item)
|
||||
m_project->dataBase()->addConductor(conductor);
|
||||
break;
|
||||
}
|
||||
case QetShapeItem::Type:
|
||||
case IndependentTextItem::Type:
|
||||
case DiagramImageItem::Type:
|
||||
{
|
||||
m_project->dataBase()->addDrawingItem(item);
|
||||
break;
|
||||
}
|
||||
default: {break;}
|
||||
}
|
||||
}
|
||||
@@ -1900,6 +1970,13 @@ void Diagram::removeItem(QGraphicsItem *item)
|
||||
m_project->dataBase()->removeConductor(conductor);
|
||||
break;
|
||||
}
|
||||
case QetShapeItem::Type:
|
||||
case IndependentTextItem::Type:
|
||||
case DiagramImageItem::Type:
|
||||
{
|
||||
m_project->dataBase()->removeDrawingItem(item);
|
||||
break;
|
||||
}
|
||||
default: {break;}
|
||||
}
|
||||
|
||||
@@ -2064,6 +2141,75 @@ void Diagram::selectAllTextFields()
|
||||
emit selectionChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief Diagram::setItemGroup
|
||||
Put @a item in @a group, or take it out of its group if @a group is
|
||||
null, and keep its row in the project database in step.
|
||||
@param item
|
||||
@param group
|
||||
*/
|
||||
void Diagram::setItemGroup(QGraphicsItem *item, const QUuid &group)
|
||||
{
|
||||
ItemGroups::setGroup(item, group);
|
||||
if (m_project) {
|
||||
m_project->dataBase()->itemGroupChanged(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief Diagram::completeGroupSelection
|
||||
Make the selection whole groups again after the user changed it with a
|
||||
click, see ItemGroups::completeSelection(). Called from the mouse
|
||||
handlers and, when a rubber band is released, from DiagramView -- not
|
||||
on every selectionChanged(): code that selects items itself (export,
|
||||
search, Tab cycling) deselects and reselects one item at a time and
|
||||
must not have groups pulled back in behind it.
|
||||
Left alone while a rubber band is being dragged, which reselects exactly
|
||||
what it covers on every mouse step.
|
||||
*/
|
||||
void Diagram::completeGroupSelection()
|
||||
{
|
||||
for (QGraphicsView *view : views()) {
|
||||
if (!view->rubberBandRect().isNull()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
QList<QGraphicsItem *> previous;
|
||||
for (const QPointer<QGraphicsObject> &item : std::as_const(m_previous_selection)) {
|
||||
if (item) {
|
||||
previous << item.data();
|
||||
}
|
||||
}
|
||||
|
||||
//One selectionChanged() for the whole group, not one per member:
|
||||
//the properties dock rebuilds on each.
|
||||
blockSignals(true);
|
||||
const bool changed = ItemGroups::completeSelection(
|
||||
this, previous,
|
||||
QApplication::keyboardModifiers().testFlag(Qt::ControlModifier));
|
||||
blockSignals(false);
|
||||
if (changed) {
|
||||
emit selectionChanged();
|
||||
}
|
||||
rememberSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief Diagram::rememberSelection
|
||||
Keep the current selection, the "before" completeGroupSelection() needs
|
||||
to tell a member being Ctrl+clicked off.
|
||||
*/
|
||||
void Diagram::rememberSelection()
|
||||
{
|
||||
m_previous_selection.clear();
|
||||
for (QGraphicsItem *item : selectedItems()) {
|
||||
if (QGraphicsObject *object = item->toGraphicsObject()) {
|
||||
m_previous_selection << object;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief Diagram::selectNextItem
|
||||
Select the next (or, if @a forward is false, the previous) selectable
|
||||
@@ -2662,6 +2808,29 @@ QPointF Diagram::snapToGrid(const QPointF &p)
|
||||
return (QPointF(p_x, p_y));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief Diagram::snapToTextGrid
|
||||
Return the nearest point of p on the text grid, see TextGrid.
|
||||
Ctrl held rounds to the nearest pixel instead, as snapToGrid() does.
|
||||
@param p point to find the nearest snapped point
|
||||
@return
|
||||
*/
|
||||
QPointF Diagram::snapToTextGrid(const QPointF &p)
|
||||
{
|
||||
QSettings settings;
|
||||
const qreal divisor =
|
||||
QApplication::keyboardModifiers().testFlag(Qt::ControlModifier)
|
||||
? 0
|
||||
: settings.value(TextGrid::settings_key, 1).toReal();
|
||||
|
||||
return TextGrid::snap(p,
|
||||
settings.value(QStringLiteral("diagrameditor/Xgrid"),
|
||||
Diagram::xGrid).toInt(),
|
||||
settings.value(QStringLiteral("diagrameditor/Ygrid"),
|
||||
Diagram::yGrid).toInt(),
|
||||
divisor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include "qgimanager.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QPointer>
|
||||
#include <QUuid>
|
||||
#include <QtWidgets>
|
||||
#include <QtXml>
|
||||
@@ -138,6 +139,10 @@ class Diagram : public QGraphicsScene
|
||||
bool m_freeze_new_conductors_;
|
||||
QUuid m_uuid = QUuid::createUuid();
|
||||
|
||||
//Selection before the current click, see completeGroupSelection()
|
||||
QList<QPointer<QGraphicsObject>> m_previous_selection;
|
||||
void rememberSelection();
|
||||
|
||||
bool uuidUsedByOtherDiagram(const QUuid &uuid) const;
|
||||
QUuid derivedUuid(const QDomElement &root, const QString &reason) const;
|
||||
|
||||
@@ -233,6 +238,7 @@ class Diagram : public QGraphicsScene
|
||||
BorderOptions borderOptions();
|
||||
DiagramPosition convertPosition(const QPointF &);
|
||||
static QPointF snapToGrid(const QPointF &p);
|
||||
static QPointF snapToTextGrid(const QPointF &p);
|
||||
|
||||
bool drawTerminals() const;
|
||||
void setDrawTerminals(bool);
|
||||
@@ -281,6 +287,7 @@ class Diagram : public QGraphicsScene
|
||||
const QString& title, const QString& seq,
|
||||
NumerotationContext *nc);
|
||||
void changeZValue(QET::DepthOption option);
|
||||
void setItemGroup(QGraphicsItem *item, const QUuid &group);
|
||||
|
||||
public slots:
|
||||
void adjustSceneRect ();
|
||||
@@ -298,6 +305,7 @@ class Diagram : public QGraphicsScene
|
||||
void invertSelection();
|
||||
void selectAllConductors();
|
||||
void selectAllTextFields();
|
||||
void completeGroupSelection();
|
||||
|
||||
signals:
|
||||
void showDiagram (Diagram *);
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
#include "diagramcommands.h"
|
||||
|
||||
#include "diagram.h"
|
||||
#include "itemgroups.h"
|
||||
#include "qetgraphicsitem/conductortextitem.h"
|
||||
#include "qetgraphicsitem/diagramimageitem.h"
|
||||
#include "qetgraphicsitem/dynamicelementtextitem.h"
|
||||
#include "qetgraphicsitem/element.h"
|
||||
#include "qetgraphicsitem/elementtextitemgroup.h"
|
||||
#include "qetgraphicsitem/independenttextitem.h"
|
||||
#include "qetgraphicsitem/qetshapeitem.h"
|
||||
#include "qetinformation.h"
|
||||
#include "qgimanager.h"
|
||||
|
||||
@@ -100,6 +104,15 @@ void PasteDiagramCommand::redo()
|
||||
for (Conductor *c : all_pasted_conductors) {
|
||||
c -> newUuid();
|
||||
}
|
||||
for (IndependentTextItem *t : std::as_const(content.m_text_fields)) {
|
||||
t -> newUuid();
|
||||
}
|
||||
for (DiagramImageItem *i : std::as_const(content.m_images)) {
|
||||
i -> newUuid();
|
||||
}
|
||||
for (QetShapeItem *s : std::as_const(content.m_shapes)) {
|
||||
s -> newUuid();
|
||||
}
|
||||
|
||||
//this is the first paste, we do some actions for the new element
|
||||
for (Element *e : elmts_list)
|
||||
@@ -204,6 +217,27 @@ void PasteDiagramCommand::redo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Pasted groups become new groups: the members of one source
|
||||
//group all get the same new uuid, never the source's, or the
|
||||
//copy would join the original's group. After the elements got
|
||||
//their own uuids: the database row is found by uuid, and before
|
||||
//that it would have been the source element's row.
|
||||
QHash<QUuid, QUuid> renewed_groups;
|
||||
for (QGraphicsItem *item : content.items(DiagramContent::Elements
|
||||
| DiagramContent::TextFields
|
||||
| DiagramContent::Images
|
||||
| DiagramContent::Shapes))
|
||||
{
|
||||
const QUuid source_group = ItemGroups::groupOf(item);
|
||||
if (source_group.isNull()) {
|
||||
continue;
|
||||
}
|
||||
if (!renewed_groups.contains(source_group)) {
|
||||
renewed_groups.insert(source_group, QUuid::createUuid());
|
||||
}
|
||||
diagram -> setItemGroup(item, renewed_groups.value(source_group));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagramcontexttoolbar.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QEvent>
|
||||
#include <QGraphicsOpacityEffect>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMouseEvent>
|
||||
#include <QSettings>
|
||||
#include <QStringBuilder>
|
||||
#include <QToolButton>
|
||||
|
||||
namespace {
|
||||
/// Fully opaque within this distance of the toolbar, in pixels...
|
||||
const int opaque_distance = 30;
|
||||
/// ...fading to hidden at this one
|
||||
const int hide_distance = 200;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramContextToolbar::DiagramContextToolbar
|
||||
@param viewport : the view's viewport, which it is drawn on and watches
|
||||
*/
|
||||
DiagramContextToolbar::DiagramContextToolbar(QWidget *viewport) :
|
||||
QFrame(viewport)
|
||||
{
|
||||
setFrameShape(QFrame::StyledPanel);
|
||||
setAutoFillBackground(true);
|
||||
setFocusPolicy(Qt::NoFocus);
|
||||
|
||||
m_layout = new QHBoxLayout(this);
|
||||
m_layout->setContentsMargins(2, 2, 2, 2);
|
||||
m_layout->setSpacing(1);
|
||||
|
||||
m_opacity = new QGraphicsOpacityEffect(this);
|
||||
setGraphicsEffect(m_opacity);
|
||||
hide();
|
||||
|
||||
viewport->installEventFilter(this);
|
||||
//Key presses go to the view, not its viewport
|
||||
if (viewport->parentWidget()) {
|
||||
viewport->parentWidget()->installEventFilter(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@return whether the toolbar should be shown at all (a preference)
|
||||
*/
|
||||
bool DiagramContextToolbar::isEnabled()
|
||||
{
|
||||
return QSettings().value(QStringLiteral("diagrameditor/context_toolbar"),
|
||||
true).toBool();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramContextToolbar::showAt
|
||||
Show @a actions just above and to the right of @a viewport_pos, kept
|
||||
inside the viewport. Clicking a button triggers the action and leaves
|
||||
the toolbar up, so a command such as rotate can be clicked again.
|
||||
*/
|
||||
void DiagramContextToolbar::showAt(const QPoint &viewport_pos,
|
||||
const QList<QAction *> &actions)
|
||||
{
|
||||
while (QLayoutItem *item = m_layout->takeAt(0)) {
|
||||
delete item->widget();
|
||||
delete item;
|
||||
}
|
||||
for (QAction *action : actions.mid(0, max_actions))
|
||||
{
|
||||
auto *button = new QToolButton(this);
|
||||
button->setAutoRaise(true);
|
||||
button->setFocusPolicy(Qt::NoFocus);
|
||||
button->setIconSize(QSize(20, 20));
|
||||
const QString text = action->text().remove(QLatin1Char('&'));
|
||||
if (action->icon().isNull()) {
|
||||
button->setText(text);
|
||||
} else {
|
||||
button->setIcon(action->icon());
|
||||
}
|
||||
//The status tip says what the command does, which a short
|
||||
//name such as "Orienter les textes" does not
|
||||
button->setToolTip(action->statusTip().isEmpty()
|
||||
? text
|
||||
: text % QLatin1Char('\n') % action->statusTip());
|
||||
button->setEnabled(action->isEnabled());
|
||||
connect(button, &QToolButton::clicked, action, &QAction::trigger);
|
||||
m_layout->addWidget(button);
|
||||
}
|
||||
if (actions.isEmpty()) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
adjustSize();
|
||||
const QRect area = parentWidget()->rect();
|
||||
QPoint pos = viewport_pos + QPoint(16, -height() - 16);
|
||||
pos.setX(qBound(area.left(), pos.x(), area.right() - width()));
|
||||
pos.setY(qBound(area.top(), pos.y(), area.bottom() - height()));
|
||||
move(pos);
|
||||
m_opacity->setOpacity(1.0);
|
||||
show();
|
||||
raise();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramContextToolbar::followMouse
|
||||
Fade with the mouse's distance from the toolbar, and hide past
|
||||
hide_distance.
|
||||
*/
|
||||
void DiagramContextToolbar::followMouse(const QPoint &viewport_pos)
|
||||
{
|
||||
const QRect r = geometry();
|
||||
const int dx = qMax(qMax(r.left() - viewport_pos.x(), 0), viewport_pos.x() - r.right());
|
||||
const int dy = qMax(qMax(r.top() - viewport_pos.y(), 0), viewport_pos.y() - r.bottom());
|
||||
const int distance = qMax(dx, dy);
|
||||
|
||||
if (distance >= hide_distance) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
const qreal fade = qreal(distance - opaque_distance)
|
||||
/ (hide_distance - opaque_distance);
|
||||
m_opacity->setOpacity(qBound(0.15, 1.0 - fade, 1.0));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramContextToolbar::eventFilter
|
||||
Watch the viewport and the view while shown: follow the mouse, and hide
|
||||
on a press elsewhere, a wheel turn or a key press.
|
||||
*/
|
||||
bool DiagramContextToolbar::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
Q_UNUSED(watched)
|
||||
if (!isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (event->type())
|
||||
{
|
||||
case QEvent::MouseMove:
|
||||
followMouse(static_cast<QMouseEvent *>(event)->position().toPoint());
|
||||
break;
|
||||
case QEvent::MouseButtonPress:
|
||||
case QEvent::Wheel:
|
||||
case QEvent::KeyPress:
|
||||
case QEvent::Leave:
|
||||
hide();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef DIAGRAMCONTEXTTOOLBAR_H
|
||||
#define DIAGRAMCONTEXTTOOLBAR_H
|
||||
|
||||
#include <QFrame>
|
||||
#include <QList>
|
||||
|
||||
class QAction;
|
||||
class QGraphicsOpacityEffect;
|
||||
class QHBoxLayout;
|
||||
|
||||
/**
|
||||
@brief A small row of commands shown beside the cursor after a click
|
||||
selects something on a folio, as SolidWorks does.
|
||||
|
||||
It lives on the view's viewport, so it never takes the focus or opens a
|
||||
window. It fades as the mouse moves away and hides once the mouse is far
|
||||
enough, on a click elsewhere, a wheel turn or a key press. The commands
|
||||
are the shortcut bar's for the selection (ShortcutBarSettings).
|
||||
*/
|
||||
class DiagramContextToolbar : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DiagramContextToolbar(QWidget *viewport);
|
||||
|
||||
void showAt(const QPoint &viewport_pos, const QList<QAction *> &actions);
|
||||
static bool isEnabled();
|
||||
|
||||
/// At most this many commands, so it stays small
|
||||
static const int max_actions = 8;
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void followMouse(const QPoint &viewport_pos);
|
||||
|
||||
QHBoxLayout *m_layout = nullptr;
|
||||
QGraphicsOpacityEffect *m_opacity = nullptr;
|
||||
};
|
||||
|
||||
#endif // DIAGRAMCONTEXTTOOLBAR_H
|
||||
@@ -117,7 +117,16 @@ void DiagramEventAddImage::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||
}
|
||||
else if (m_image && !m_pressed && event->button() == Qt::RightButton)
|
||||
{
|
||||
m_image->setRotation(m_image->rotation() + 90);
|
||||
// rotationAngle()/setRotationAngle(), not QGraphicsItem's own
|
||||
// rotation()/setRotation(): DiagramImageItem's whole handle/undo/
|
||||
// XML-save machinery reads exclusively from its own m_transform
|
||||
// (see diagramimageitem.cpp's toXml() comment) and never looks at
|
||||
// QGraphicsItem's built-in convenience property at all -- using
|
||||
// it here left a rotation that displayed correctly in this tool
|
||||
// but silently vanished the moment the item was saved and
|
||||
// reloaded, and additionally desynced the rotate-handle's pivot
|
||||
// math once the image was later selected for editing.
|
||||
m_image->setRotationAngle(m_image->rotationAngle() + 90);
|
||||
event->setAccepted(true);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +176,22 @@ void DiagramEventAddImage::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
}
|
||||
else
|
||||
{
|
||||
// setPivot(QPointF(0, 0)), not left at its default
|
||||
// boundingRect().center(): DiagramImageItem's own m_transform
|
||||
// is pivot-centered, so with the default center pivot the
|
||||
// setPos(qMin(...)) below would no longer land on the image's
|
||||
// actual top-left corner the moment scale != 1 (the corner
|
||||
// only coincides with pos() when the pivot is the local
|
||||
// origin). Anchoring here to (0, 0) -- exactly the same
|
||||
// temporary-anchor trick DiagramImageItem::handlerMousePressEvent()
|
||||
// already uses for its own Resize handles -- makes pos()
|
||||
// keep meaning "scene position of the top-left corner"
|
||||
// regardless of scale, so the qMin(...) line below still
|
||||
// needs no change at all. setPivot() itself is a no-op past
|
||||
// the first call (same pivot value), and compensates pos()
|
||||
// automatically so nothing visibly jumps at the switch.
|
||||
m_image->setPivot(QPointF(0, 0));
|
||||
|
||||
const QSizeF naturalSize = m_image->boundingRect().size();
|
||||
if (naturalSize.width() > 0 && naturalSize.height() > 0)
|
||||
{
|
||||
@@ -179,7 +204,12 @@ void DiagramEventAddImage::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
// free-form one -- breaking aspect ratio on purpose is
|
||||
// its own, separate, larger piece of work.
|
||||
const qreal newScale = qBound(0.01, qMax(scaleX, scaleY), 50.0);
|
||||
m_image->setScale(newScale);
|
||||
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own
|
||||
// scale(): see the mousePressEvent right-click rotate
|
||||
// comment above for why -- identical reasoning, identical
|
||||
// fix.
|
||||
m_image->setScaleFactorX(newScale);
|
||||
m_image->setScaleFactorY(newScale);
|
||||
}
|
||||
m_image->setPos(qMin(m_press_pos.x(), pos.x()), qMin(m_press_pos.y(), pos.y()));
|
||||
}
|
||||
@@ -206,6 +236,17 @@ void DiagramEventAddImage::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
if (m_image && m_pressed && event->button() == Qt::LeftButton)
|
||||
{
|
||||
// Undo whatever temporary origin-anchoring mouseMoveEvent's
|
||||
// resize-drag branch did (a no-op if it never engaged): every
|
||||
// other DiagramImageItem code path -- handles, undo, XML save --
|
||||
// expects an image's pivot to be its bounding-rect center unless
|
||||
// the user deliberately customized it, exactly like a completed
|
||||
// handle-resize already resets it via resetPivotToBoundingRectCenter().
|
||||
// Doing this before reading pos() below is what makes the pushed
|
||||
// command capture the final, center-pivot position rather than
|
||||
// the drag's temporary corner-anchored one.
|
||||
m_image->setPivot(m_image->boundingRect().center());
|
||||
|
||||
m_diagram->undoStack().push(new AddGraphicsObjectCommand(m_image, m_diagram, m_image->pos()));
|
||||
|
||||
for (QGraphicsView *view : m_diagram->views()) {
|
||||
@@ -246,10 +287,32 @@ void DiagramEventAddImage::wheelEvent(QGraphicsSceneWheelEvent *event)
|
||||
return;
|
||||
}
|
||||
|
||||
qreal scaling = m_image->scale();
|
||||
event->delta() > 1? scaling += 0.01 : scaling -= 0.01;
|
||||
if (scaling>0.01 && scaling <= 2) {
|
||||
m_image->setScale(scaling);
|
||||
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own scale(): see
|
||||
// the right-click rotate comment in mousePressEvent for why. Wheel-
|
||||
// scaling only ever runs while !m_pressed (guarded above), i.e. before
|
||||
// any drag-resize has anchored the pivot to the origin (see
|
||||
// mouseMoveEvent), so the pivot here is still the default
|
||||
// boundingRect().center() and this scales the image in place around
|
||||
// its own middle, exactly like before.
|
||||
//
|
||||
// Step each axis from its own current value rather than reading X and
|
||||
// writing it back to both: scaleFactorX and scaleFactorY cannot
|
||||
// actually differ at this point today (every other mutator in this
|
||||
// class -- the drag-resize branch above, and this same wheelEvent --
|
||||
// only ever sets them to the same value, and mouseReleaseEvent commits
|
||||
// and ends this tool on any left-button release, so a handle-based
|
||||
// non-uniform resize can never happen first and leave this instance
|
||||
// still alive). Stepping both from their own value rather than
|
||||
// collapsing Y to X costs nothing today and removes the trap if that
|
||||
// invariant ever stops holding.
|
||||
qreal scalingX = m_image->scaleFactorX();
|
||||
qreal scalingY = m_image->scaleFactorY();
|
||||
const qreal step = event->delta() > 1 ? 0.01 : -0.01;
|
||||
scalingX += step;
|
||||
scalingY += step;
|
||||
if (scalingX > 0.01 && scalingX <= 2 && scalingY > 0.01 && scalingY <= 2) {
|
||||
m_image->setScaleFactorX(scalingX);
|
||||
m_image->setScaleFactorY(scalingY);
|
||||
}
|
||||
|
||||
event->setAccepted(true);
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
DiagramEventAddPaste::DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos) :
|
||||
DiagramEventInterface(diagram)
|
||||
{
|
||||
Q_UNUSED(start_pos); // items stay at their original XML position
|
||||
//DiagramEventInterface::init() is called by Diagram::setEventInterface
|
||||
//only when it is replacing an earlier interface, so call it here as
|
||||
//DiagramEventAddMacro does.
|
||||
@@ -77,21 +76,26 @@
|
||||
const QList<QGraphicsItem *> movable = m_content.items(MovableItems);
|
||||
if (movable.isEmpty()) return;
|
||||
|
||||
//Compute the top-left of all items' positions (not bounding
|
||||
//rects) and snap to grid: this is the point that gets placed
|
||||
//under the cursor, and the baseline moveTo() measures from.
|
||||
QPointF top_left;
|
||||
bool first = true;
|
||||
//Compute the top-left of all items' actual on-screen bounding
|
||||
//boxes (not their raw pos()) and snap to grid: this is the point
|
||||
//that gets placed under the cursor, and the baseline moveTo()
|
||||
//measures from. mapToScene(boundingRect()) matters here, not
|
||||
//pos() alone: pos() is the scene location of an item's local
|
||||
//origin, but for anything with a pivot-centered transform (a
|
||||
//scaled or rotated image, in particular) that origin can sit far
|
||||
//from where the item is actually drawn -- pivot + scale*(0 -
|
||||
//pivot) is nowhere near (0, 0) once scale is well under 1. Using
|
||||
//pos() here silently pasted content at the right *delta* from a
|
||||
//point that wasn't actually where the content visually was,
|
||||
//producing a constant, scale-dependent offset between the cursor
|
||||
//and the pasted picture. Diagram::fromXml()'s own position
|
||||
//parameter already gets this right the same way, for the same
|
||||
//reason.
|
||||
QRectF items_rect;
|
||||
for (auto *item : movable) {
|
||||
const QPointF p = item->pos();
|
||||
if (first) {
|
||||
top_left = p;
|
||||
first = false;
|
||||
} else {
|
||||
if (p.x() < top_left.x()) top_left.setX(p.x());
|
||||
if (p.y() < top_left.y()) top_left.setY(p.y());
|
||||
}
|
||||
items_rect = items_rect.united(item->mapToScene(item->boundingRect()).boundingRect());
|
||||
}
|
||||
const QPointF top_left = items_rect.topLeft();
|
||||
QSettings settings;
|
||||
const int xGrid = settings.value(QStringLiteral("diagrameditor/Xgrid"),
|
||||
Diagram::xGrid).toInt();
|
||||
@@ -102,9 +106,24 @@
|
||||
qRound(p.x() / xGrid) * xGrid,
|
||||
qRound(p.y() / yGrid) * yGrid);
|
||||
};
|
||||
const QPointF grid_origin = snapGrid(top_left);
|
||||
const QPointF grid_origin = snapGrid(start_pos);
|
||||
|
||||
//Store each item's original position. moveTo() applies a
|
||||
//Land the pasted content under the cursor immediately, rather than
|
||||
//leaving it at the copied source's own coordinates: fromXml() above
|
||||
//loads items at their original position purely because it doesn't
|
||||
//know the target yet, not because that is where a paste should end
|
||||
//up. The previous approach instead left items there and warped the
|
||||
//OS cursor to match -- QCursor::setPos() is silently ignored by
|
||||
//many window managers and compositors (Wayland in particular), so
|
||||
//on any of those the warp simply never happened and the paste was
|
||||
//left wherever it had originally been copied from, which could be
|
||||
//anywhere on the folio -- exactly the "far from the cursor" bug.
|
||||
const QPointF initial_delta = grid_origin - snapGrid(top_left);
|
||||
for (auto *item : movable) {
|
||||
item->setPos(item->pos() + initial_delta);
|
||||
}
|
||||
|
||||
//Store each item's now-placed position. moveTo() applies a
|
||||
//grid-snapped delta from the baseline to these, so items
|
||||
//preserve their layout and move in whole grid steps.
|
||||
for (auto *item : movable) {
|
||||
@@ -134,14 +153,6 @@
|
||||
if (const auto qde = QETApp::diagramEditorAncestorOf(view)) {
|
||||
m_status_bar = qde->statusBar();
|
||||
}
|
||||
//Warp the cursor to the group's grid-snapped origin so
|
||||
//the actual cursor position matches m_initial_cursor.
|
||||
//Without this the first mouseMoveEvent computes a large
|
||||
//delta (cursor is still at the Ctrl+V press location)
|
||||
//and the items jump on first touch.
|
||||
const QPoint view_pos = view->mapFromScene(m_initial_cursor);
|
||||
const QPoint global_pos = view->viewport()->mapToGlobal(view_pos);
|
||||
QCursor::setPos(global_pos);
|
||||
}
|
||||
}
|
||||
showHint();
|
||||
|
||||
@@ -84,7 +84,10 @@ void DiagramEventAddPdf::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||
}
|
||||
else if (m_image && event->button() == Qt::RightButton)
|
||||
{
|
||||
m_image->setRotation(m_image->rotation() + 90);
|
||||
// rotationAngle()/setRotationAngle(), not QGraphicsItem's own
|
||||
// rotation()/setRotation(): see DiagramEventAddImage's identical
|
||||
// fix (mousePressEvent) for why -- same class, same reasoning.
|
||||
m_image->setRotationAngle(m_image->rotationAngle() + 90);
|
||||
event->setAccepted(true);
|
||||
}
|
||||
}
|
||||
@@ -132,14 +135,35 @@ void DiagramEventAddPdf::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
|
||||
*/
|
||||
void DiagramEventAddPdf::wheelEvent(QGraphicsSceneWheelEvent *event)
|
||||
{
|
||||
if (!m_is_added || !m_image || event->modifiers() != Qt::CTRL) {
|
||||
// event->modifiers() & Qt::ControlModifier, not != Qt::CTRL: the same
|
||||
// exact-equality bug already found and fixed elsewhere this session --
|
||||
// Ctrl held together with any other modifier would silently fail to
|
||||
// register as Ctrl at all.
|
||||
if (!m_is_added || !m_image || !(event->modifiers() & Qt::ControlModifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
qreal scaling = m_image->scale();
|
||||
event->delta() > 1 ? scaling += 0.01 : scaling -= 0.01;
|
||||
if (scaling > 0.01 && scaling <= 2) {
|
||||
m_image->setScale(scaling);
|
||||
// scaleFactorX()/scaleFactorY(), not QGraphicsItem's own scale(): see
|
||||
// DiagramEventAddImage's identical fix for why. No drag-to-resize
|
||||
// exists here, and the pivot is never touched elsewhere in this
|
||||
// class, so it stays at its default boundingRect().center() and this
|
||||
// scales the page in place around its own middle, exactly like
|
||||
// before.
|
||||
//
|
||||
// Step each axis from its own current value rather than reading X and
|
||||
// writing it back to both: scaleFactorX and scaleFactorY cannot
|
||||
// actually differ here today (nothing in this class ever sets them to
|
||||
// different values, and there is no drag-resize at all), but stepping
|
||||
// both independently costs nothing and removes the trap if that ever
|
||||
// changes.
|
||||
qreal scalingX = m_image->scaleFactorX();
|
||||
qreal scalingY = m_image->scaleFactorY();
|
||||
const qreal step = event->delta() > 1 ? 0.01 : -0.01;
|
||||
scalingX += step;
|
||||
scalingY += step;
|
||||
if (scalingX > 0.01 && scalingX <= 2 && scalingY > 0.01 && scalingY <= 2) {
|
||||
m_image->setScaleFactorX(scalingX);
|
||||
m_image->setScaleFactorY(scalingY);
|
||||
}
|
||||
|
||||
event->setAccepted(true);
|
||||
|
||||
@@ -87,9 +87,9 @@ DiagramEventAddShape::~DiagramEventAddShape()
|
||||
Applies a drag/click position to the in-progress shape, honouring two
|
||||
modifiers that mirror how the very same shape can already be edited
|
||||
afterward, once placed:
|
||||
- Ctrl, for Rectangle/Ellipse only: the first click becomes the
|
||||
shape's *center* rather than a corner, growing symmetrically as
|
||||
the cursor moves away from it -- the same meaning Ctrl already
|
||||
- Ctrl, for Rectangle/Ellipse only: the first click's point acts as
|
||||
the shape's *center* rather than a corner, growing symmetrically
|
||||
as the cursor moves away from it -- the same meaning Ctrl already
|
||||
has on a Resize handle (anchor at center). Deliberately not
|
||||
offered for Line: unlike the Rectangle/Ellipse case, there's no
|
||||
established convention for "a line grows symmetrically from its
|
||||
@@ -100,12 +100,18 @@ DiagramEventAddShape::~DiagramEventAddShape()
|
||||
dragged dimensions is currently larger and mirroring that onto
|
||||
the other, preserving the direction the user is actually
|
||||
dragging in.
|
||||
Both can combine (Ctrl+Shift: a centered square/circle). Whether or
|
||||
not Ctrl is currently held, the non-anchored branch always rebuilds
|
||||
from m_anchor_point rather than nudging the existing rect/line --
|
||||
otherwise, if Ctrl had been held earlier in the same drag (moving the
|
||||
shape's own first point to a mirrored position), releasing it would
|
||||
leave that point stuck there instead of actually restoring it.
|
||||
Both can combine (Ctrl+Shift: a centered square/circle). Whether Ctrl
|
||||
currently anchors from the center is re-decided on every call, from
|
||||
the live modifiers passed in here -- not frozen at whatever was held
|
||||
on the first click. Every comparable tool (Illustrator, Photoshop,
|
||||
Figma, Inkscape...) lets you press or release the center-origin
|
||||
modifier at any point mid-drag, with the shape immediately jumping to
|
||||
match; that jump is the expected feedback for changing which point is
|
||||
anchored, not a glitch. Freezing the choice at the first click instead
|
||||
meant holding Ctrl anywhere other than the initial mouse-down did
|
||||
nothing visible -- which, tried the more natural way (drag first,
|
||||
then reach for Ctrl once you decide you want it centered), read as
|
||||
"Ctrl doesn't work" rather than as a deliberate one-shot decision.
|
||||
*/
|
||||
void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifiers mods)
|
||||
{
|
||||
@@ -125,14 +131,13 @@ void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifie
|
||||
return;
|
||||
}
|
||||
|
||||
// m_center_anchored is decided once, in mousePressEvent, not
|
||||
// re-checked here on every call -- re-checking it live meant
|
||||
// releasing Ctrl mid-drag (something you'd naturally do the moment
|
||||
// your hand gets tired holding it, long before you're done resizing)
|
||||
// silently snapped the shape back to corner-anchored, discarding
|
||||
// what felt like an already-made decision. Deciding it once at the
|
||||
// first click matches "I held Ctrl when I clicked, so this shape is
|
||||
// centered" -- a single, predictable rule instead of a live toggle.
|
||||
m_center_anchored = (mods & Qt::ControlModifier)
|
||||
&& (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse);
|
||||
if (m_center_anchored)
|
||||
showCenterMarker(m_anchor_point);
|
||||
else
|
||||
hideCenterMarker();
|
||||
|
||||
QPointF target = pos;
|
||||
|
||||
if ((mods & Qt::ShiftModifier)
|
||||
@@ -218,10 +223,9 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
m_shape_item = new QetShapeItem(pos, pos, m_shape_type);
|
||||
m_anchor_point = pos;
|
||||
// Decided once, here, rather than re-checked on every mouse
|
||||
// move for the rest of the drag -- see applyPosition()'s doc
|
||||
// comment for why continuous re-checking made releasing Ctrl
|
||||
// mid-drag feel like a bug rather than a deliberate choice.
|
||||
// Initial feedback only -- applyPosition() re-decides this
|
||||
// live on every subsequent move, from whatever Ctrl state is
|
||||
// held at the time.
|
||||
m_center_anchored = (event->modifiers() & Qt::ControlModifier)
|
||||
&& (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse);
|
||||
if (m_center_anchored)
|
||||
|
||||
@@ -59,7 +59,7 @@ class DiagramEventAddShape : public DiagramEventInterface
|
||||
QGraphicsLineItem *m_help_horiz, *m_help_verti;
|
||||
QPointF m_anchor_point; // the shape's first-click point -- meaningful once m_shape_item exists
|
||||
QGraphicsEllipseItem *m_center_marker = nullptr; // shown only while Ctrl-anchoring is actually in effect, so it doubles as confirmation that it is
|
||||
bool m_center_anchored = false; // decided once, at the first click -- see applyPosition()'s doc comment for why
|
||||
bool m_center_anchored = false; // re-decided live on every applyPosition() call, from current Ctrl state
|
||||
QPointF m_last_mouse_scene_pos; // raw, unsnapped -- lets a modifier-only change re-snap correctly when reapplied
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagramgestureoverlay.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QSettings>
|
||||
#include <QtMath>
|
||||
|
||||
namespace {
|
||||
const int outer_radius = 84;
|
||||
/// Releasing closer than this to the centre cancels
|
||||
const int inner_radius = 22;
|
||||
const int icon_radius = 56;
|
||||
const int icon_size = 22;
|
||||
/// Wider than the ring, so a long command name under it is not cut
|
||||
const int overlay_width = 280;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramGestureOverlay::DiagramGestureOverlay
|
||||
@param viewport : the view's viewport, which it is drawn on. It lets the
|
||||
mouse through, so the view keeps receiving the drag.
|
||||
*/
|
||||
DiagramGestureOverlay::DiagramGestureOverlay(QWidget *viewport) :
|
||||
QWidget(viewport)
|
||||
{
|
||||
setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
setFixedSize(overlay_width, 2 * outer_radius + 2 + 24);
|
||||
hide();
|
||||
}
|
||||
|
||||
/**
|
||||
@return whether right-drag gestures are on (a preference)
|
||||
*/
|
||||
bool DiagramGestureOverlay::isEnabled()
|
||||
{
|
||||
return QSettings().value(QStringLiteral("diagrameditor/mouse_gestures"),
|
||||
true).toBool();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramGestureOverlay::showAt
|
||||
Show the ring centred on @a center with @a actions, at most eight,
|
||||
clockwise from the top.
|
||||
*/
|
||||
void DiagramGestureOverlay::showAt(const QPoint ¢er,
|
||||
const QList<QAction *> &actions)
|
||||
{
|
||||
m_center = center;
|
||||
m_actions = actions.mid(0, sectors);
|
||||
m_active = -1;
|
||||
move(center - QPoint(overlay_width / 2, outer_radius + 1));
|
||||
show();
|
||||
raise();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramGestureOverlay::sectorAt
|
||||
@return the sector in the direction of @a viewport_pos from the centre,
|
||||
0 at the top then clockwise, or -1 within the cancel radius
|
||||
*/
|
||||
int DiagramGestureOverlay::sectorAt(const QPoint &viewport_pos) const
|
||||
{
|
||||
const QPoint d = viewport_pos - m_center;
|
||||
if (qHypot(d.x(), d.y()) < inner_radius) {
|
||||
return -1;
|
||||
}
|
||||
//Angle clockwise from the top, in degrees
|
||||
qreal angle = qRadiansToDegrees(qAtan2(d.x(), -d.y()));
|
||||
if (angle < 0) {
|
||||
angle += 360;
|
||||
}
|
||||
return int(qRound(angle / (360.0 / sectors))) % sectors;
|
||||
}
|
||||
|
||||
/**
|
||||
@return the command in the direction of @a viewport_pos, or nullptr when
|
||||
there is none there or it is disabled
|
||||
*/
|
||||
QAction *DiagramGestureOverlay::actionAt(const QPoint &viewport_pos) const
|
||||
{
|
||||
const int sector = sectorAt(viewport_pos);
|
||||
if (sector < 0 || sector >= m_actions.count()) {
|
||||
return nullptr;
|
||||
}
|
||||
QAction *action = m_actions.at(sector);
|
||||
return action->isEnabled() ? action : nullptr;
|
||||
}
|
||||
|
||||
void DiagramGestureOverlay::setPointer(const QPoint &viewport_pos)
|
||||
{
|
||||
const int sector = sectorAt(viewport_pos);
|
||||
if (sector != m_active) {
|
||||
m_active = sector;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
void DiagramGestureOverlay::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
const QPointF c(overlay_width / 2, outer_radius + 1);
|
||||
const qreal step = 360.0 / sectors;
|
||||
|
||||
QColor base = palette().color(QPalette::Window);
|
||||
base.setAlpha(225);
|
||||
painter.setPen(palette().color(QPalette::Mid));
|
||||
painter.setBrush(base);
|
||||
painter.drawEllipse(c, outer_radius, outer_radius);
|
||||
|
||||
//Highlight the sector the mouse points at, if it holds a command
|
||||
if (m_active >= 0 && m_active < m_actions.count()
|
||||
&& m_actions.at(m_active)->isEnabled())
|
||||
{
|
||||
QPainterPath wedge;
|
||||
wedge.moveTo(c);
|
||||
//Qt angles run counter-clockwise from 3 o'clock
|
||||
const qreal start = 90 - m_active * step - step / 2;
|
||||
wedge.arcTo(QRectF(c.x() - outer_radius, c.y() - outer_radius,
|
||||
2 * outer_radius, 2 * outer_radius), start, step);
|
||||
wedge.closeSubpath();
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(palette().color(QPalette::Highlight));
|
||||
painter.drawPath(wedge);
|
||||
}
|
||||
|
||||
painter.setPen(palette().color(QPalette::Mid));
|
||||
painter.setBrush(palette().color(QPalette::Window));
|
||||
painter.drawEllipse(c, inner_radius, inner_radius);
|
||||
|
||||
for (int i = 0 ; i < m_actions.count() ; ++i)
|
||||
{
|
||||
QAction *action = m_actions.at(i);
|
||||
const qreal a = qDegreesToRadians(i * step);
|
||||
const QPointF p(c.x() + icon_radius * qSin(a), c.y() - icon_radius * qCos(a));
|
||||
const QRect r(int(p.x()) - icon_size / 2, int(p.y()) - icon_size / 2,
|
||||
icon_size, icon_size);
|
||||
const QIcon::Mode mode = action->isEnabled() ? QIcon::Normal : QIcon::Disabled;
|
||||
if (!action->icon().isNull()) {
|
||||
action->icon().paint(&painter, r, Qt::AlignCenter, mode);
|
||||
} else {
|
||||
painter.setPen(palette().color(action->isEnabled() ? QPalette::Active : QPalette::Disabled,
|
||||
QPalette::WindowText));
|
||||
painter.drawText(r.adjusted(-12, 0, 12, 0), Qt::AlignCenter,
|
||||
action->text().remove(QLatin1Char('&')).left(3));
|
||||
}
|
||||
}
|
||||
|
||||
//Name of the highlighted command, under the ring
|
||||
if (m_active >= 0 && m_active < m_actions.count())
|
||||
{
|
||||
const QString name = m_actions.at(m_active)->text().remove(QLatin1Char('&'));
|
||||
const QRect label(0, 2 * outer_radius + 4, width(), 20);
|
||||
QFont f = font();
|
||||
f.setBold(true);
|
||||
painter.setFont(f);
|
||||
const QRect text = painter.fontMetrics().boundingRect(label, Qt::AlignCenter, name)
|
||||
.adjusted(-6, -2, 6, 2);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(base);
|
||||
painter.drawRoundedRect(text, 4, 4);
|
||||
painter.setPen(palette().color(QPalette::WindowText));
|
||||
painter.drawText(label, Qt::AlignCenter, name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef DIAGRAMGESTUREOVERLAY_H
|
||||
#define DIAGRAMGESTUREOVERLAY_H
|
||||
|
||||
#include <QList>
|
||||
#include <QWidget>
|
||||
|
||||
class QAction;
|
||||
|
||||
/**
|
||||
@brief The ring shown while right-dragging on a folio: up to eight
|
||||
commands around the point where the button went down, the one in the
|
||||
mouse's direction highlighted. Releasing runs it, releasing near the
|
||||
centre cancels -- SolidWorks' mouse gestures.
|
||||
|
||||
Commands are placed clockwise from the top. The ring only draws; the
|
||||
view tracks the mouse and asks sectorAt() on release.
|
||||
*/
|
||||
class DiagramGestureOverlay : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DiagramGestureOverlay(QWidget *viewport);
|
||||
|
||||
void showAt(const QPoint ¢er, const QList<QAction *> &actions);
|
||||
void setPointer(const QPoint &viewport_pos);
|
||||
int sectorAt(const QPoint &viewport_pos) const;
|
||||
QAction *actionAt(const QPoint &viewport_pos) const;
|
||||
|
||||
static bool isEnabled();
|
||||
|
||||
static const int sectors = 8;
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
|
||||
private:
|
||||
QPoint m_center;
|
||||
QList<QAction *> m_actions;
|
||||
int m_active = -1;
|
||||
};
|
||||
|
||||
#endif // DIAGRAMGESTUREOVERLAY_H
|
||||
@@ -29,7 +29,11 @@
|
||||
DiagramPosition::DiagramPosition(const QString &letter, unsigned int number) {
|
||||
// purifie les lettres
|
||||
letter_ = letter.toUpper();
|
||||
letter_.remove(QRegularExpression("[^A-Z]"));
|
||||
//Compiled once: building it here cost more than the rest of the
|
||||
//position conversion together, once per element and per drawing item
|
||||
//on every rebuild of the project database.
|
||||
static const QRegularExpression not_a_letter(QStringLiteral("[^A-Z]"));
|
||||
letter_.remove(not_a_letter);
|
||||
number_ = number;
|
||||
}
|
||||
|
||||
|
||||
+571
-5
@@ -16,6 +16,7 @@
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagramview.h"
|
||||
#include "cellruler.h"
|
||||
#include "lastusedstyle.h"
|
||||
#include "qetproject.h"
|
||||
#include "QPropertyUndoCommand/qpropertyundocommand.h"
|
||||
@@ -29,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"
|
||||
@@ -36,6 +38,10 @@
|
||||
#include "utils/conductorcreator.h"
|
||||
#include "undocommand/addgraphicsobjectcommand.h"
|
||||
#include "diagram.h"
|
||||
#include "diagramcontexttoolbar.h"
|
||||
#include "diagramgestureoverlay.h"
|
||||
#include "shortcutbarsettings.h"
|
||||
#include "shortcutmanager.h"
|
||||
#include "ElementsCollection/xmlelementcollection.h"
|
||||
#include "NameList/nameslist.h"
|
||||
#include "elementdialog.h"
|
||||
@@ -43,6 +49,8 @@
|
||||
#include <QDropEvent>
|
||||
#include <QPainter>
|
||||
#include <QPointer>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
|
||||
/**
|
||||
Constructeur
|
||||
@@ -95,6 +103,9 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) :
|
||||
m_create_template = new QAction(tr("Créer un template", "context menu action"), this);
|
||||
connect(m_create_template, &QAction::triggered, this, &DiagramView::createTemplateFromSelection);
|
||||
|
||||
//Filled each time the context menu opens, see updateFolioReportMenu()
|
||||
m_folio_report_menu = new QMenu(tr("Renvoi de folio"), this);
|
||||
|
||||
//setup three separators, to be use in context menu
|
||||
for(int i=0 ; i<3 ; ++i)
|
||||
{
|
||||
@@ -102,11 +113,34 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) :
|
||||
m_separators.last()->setSeparator(true);
|
||||
}
|
||||
|
||||
m_context_toolbar = new DiagramContextToolbar(viewport());
|
||||
m_gesture_overlay = new DiagramGestureOverlay(viewport());
|
||||
connect(m_diagram, &QGraphicsScene::selectionChanged, this, [this]() {
|
||||
if (m_diagram->selectedItems().isEmpty()) {
|
||||
m_context_toolbar->hide();
|
||||
}
|
||||
});
|
||||
|
||||
connect(m_diagram, &Diagram::showDiagram, this, &DiagramView::showDiagram);
|
||||
//The diagram leaves group completion alone while a rubber band is
|
||||
//dragged; finish it when the band is released (null rect).
|
||||
connect(this, &QGraphicsView::rubberBandChanged, this, [this](QRect rect) {
|
||||
if (rect.isNull()) {
|
||||
m_diagram->completeGroupSelection();
|
||||
}
|
||||
});
|
||||
connect(m_diagram, &QGraphicsScene::sceneRectChanged, this, &DiagramView::adjustSceneRect);
|
||||
connect(&(m_diagram -> border_and_titleblock), &BorderTitleBlock::informationChanged, this, &DiagramView::updateWindowTitle);
|
||||
connect(diagram, &Diagram::findElementRequired, this, &DiagramView::findElementRequired);
|
||||
|
||||
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();
|
||||
|
||||
QShortcut *edit_conductor_color_shortcut = new QShortcut(QKeySequence(Qt::Key_F2), this);
|
||||
connect(edit_conductor_color_shortcut, &QShortcut::activated, [this]()
|
||||
{
|
||||
@@ -223,14 +257,72 @@ void DiagramView::handleElementDrop(QDropEvent *event)
|
||||
QPointF drop_pos;
|
||||
drop_pos = mapToScene(event->position().toPoint());
|
||||
|
||||
startElementPlacement(location, drop_pos);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::startElementPlacement
|
||||
Enter the interactive placement mode for @a location, with the pending
|
||||
element starting at @a scene_pos.
|
||||
|
||||
This is the mode where the element follows the cursor on the grid, a left
|
||||
click drops a copy, Space rotates it and the element stays loaded so a run
|
||||
of identical symbols can be placed with successive clicks.
|
||||
|
||||
Split out of handleElementDrop() so that placement is reachable without a
|
||||
drag: the mode itself was always general, it simply had no caller other
|
||||
than the end of a drop.
|
||||
@param location : the element or macro to place
|
||||
@param scene_pos : where the pending element first appears, in scene
|
||||
coordinates
|
||||
@return true if the placement mode was entered
|
||||
*/
|
||||
bool DiagramView::startElementPlacement(const ElementsLocation &location,
|
||||
const QPointF &scene_pos)
|
||||
{
|
||||
if (!diagram() || !(location.isElement() && location.exist())) {
|
||||
return false;
|
||||
}
|
||||
if (diagram()->isReadOnly()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (location.path().endsWith(".qetmak")) {
|
||||
diagram()->setEventInterface(new DiagramEventAddMacro(location, diagram(), drop_pos));
|
||||
diagram()->setEventInterface(
|
||||
new DiagramEventAddMacro(location, diagram(), scene_pos));
|
||||
} else {
|
||||
diagram()->setEventInterface(new DiagramEventAddElement(location, diagram(), drop_pos));
|
||||
//DiagramEventAddElement takes a non-const reference, so it needs
|
||||
//an lvalue it may modify. Copying keeps the caller's location
|
||||
//untouched -- QETDiagramEditor stores the same one for
|
||||
//"insert last element".
|
||||
ElementsLocation loc(location);
|
||||
diagram()->setEventInterface(
|
||||
new DiagramEventAddElement(loc, diagram(), scene_pos));
|
||||
emit elementPlacementStarted(location);
|
||||
}
|
||||
|
||||
//Set focus to the view to get event
|
||||
this->setFocus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::defaultPlacementPos
|
||||
@return where a pending element should appear when placement was not
|
||||
started by a drop, so there is no cursor position to use.
|
||||
|
||||
The cursor is used when it is over the view -- picking up a placement where
|
||||
the user is already looking -- and the centre of the visible area
|
||||
otherwise.
|
||||
*/
|
||||
QPointF DiagramView::defaultPlacementPos() const
|
||||
{
|
||||
const QPoint local = mapFromGlobal(QCursor::pos());
|
||||
if (viewport() && viewport()->rect().contains(local)) {
|
||||
return mapToScene(local);
|
||||
}
|
||||
return mapToScene(viewport() ? viewport()->rect().center()
|
||||
: rect().center());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,6 +397,7 @@ void DiagramView::handleTextDrop(QDropEvent *e) {
|
||||
*/
|
||||
void DiagramView::setVisualisationMode()
|
||||
{
|
||||
m_ctrl_shift_panning = false;
|
||||
setDragMode(ScrollHandDrag);
|
||||
applyReadOnly();
|
||||
setInteractive(false);
|
||||
@@ -316,6 +409,7 @@ void DiagramView::setVisualisationMode()
|
||||
*/
|
||||
void DiagramView::setSelectionMode()
|
||||
{
|
||||
m_ctrl_shift_panning = false;
|
||||
setDragMode(RubberBandDrag);
|
||||
setInteractive(true);
|
||||
applyReadOnly();
|
||||
@@ -385,6 +479,23 @@ void DiagramView::zoomReset()
|
||||
adjustGridToZoom();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::zoomToRect
|
||||
Adjust zoom to fit \a rect, in scene coordinate, in the view.
|
||||
@param rect
|
||||
*/
|
||||
void DiagramView::zoomToRect(const QRectF &rect)
|
||||
{
|
||||
fitInView(rect, Qt::KeepAspectRatio);
|
||||
//Zooming in makes the scroll bars appear, which resizes the viewport
|
||||
//from a queued call; that resize is anchored under the mouse and
|
||||
//would scroll away from rect, so center again once it has run.
|
||||
QMetaObject::invokeMethod(this, [this, rect]() {
|
||||
centerOn(rect.center());
|
||||
}, Qt::QueuedConnection);
|
||||
adjustGridToZoom();
|
||||
}
|
||||
|
||||
/**
|
||||
Copie les elements selectionnes du schema dans le presse-papier puis les supprime
|
||||
Copies the selected elements from the diagram to the clipboard and then deletes them
|
||||
@@ -529,6 +640,12 @@ void DiagramView::mousePressEvent(QMouseEvent *e)
|
||||
{
|
||||
e->ignore();
|
||||
|
||||
//Ctrl+Shift panning whose key release was missed (see
|
||||
//focusOutEvent()): a click without them ends it
|
||||
if (m_ctrl_shift_panning && !isCtrlShifting(e)) {
|
||||
setSelectionMode();
|
||||
}
|
||||
|
||||
if (m_fresh_focus_in)
|
||||
{
|
||||
switchToVisualisationModeIfNeeded(e);
|
||||
@@ -537,6 +654,50 @@ void DiagramView::mousePressEvent(QMouseEvent *e)
|
||||
|
||||
if (m_event_interface && m_event_interface->mousePressEvent(e)) return;
|
||||
|
||||
if (e->button() == Qt::LeftButton) {
|
||||
m_press_pos = e->position().toPoint();
|
||||
}
|
||||
|
||||
//Right button: a click opens the context menu on release, a drag is
|
||||
//a gesture (DiagramGestureOverlay). Left alone while a text is edited.
|
||||
m_swallow_native_menu = false;
|
||||
m_gesture_over_tool = false;
|
||||
if (e->button() == Qt::RightButton
|
||||
&& DiagramGestureOverlay::isEnabled()
|
||||
&& !m_diagram->focusItem())
|
||||
{
|
||||
m_gesture_tracking = true;
|
||||
m_gesture_origin = e->position().toPoint();
|
||||
m_context_toolbar->hide();
|
||||
|
||||
//A tool is running, often one a gesture just started. A right
|
||||
//click still goes to it -- it cancels or finishes the tool -- so
|
||||
//the press carries on to the scene. A drag ends the tool and
|
||||
//shows the ring (see mouseMoveEvent).
|
||||
if (m_diagram->eventInterfaceIsRunning()) {
|
||||
m_gesture_over_tool = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_swallow_native_menu = true;
|
||||
|
||||
//Select what is under the mouse, as the context menu does, so
|
||||
//a gesture acts on it
|
||||
if (QGraphicsItem *item = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) {
|
||||
if (!item->isSelected()) {
|
||||
m_diagram->clearSelection();
|
||||
//Clearing the selection can delete handler items, so
|
||||
//look the item up again (see contextMenuEvent)
|
||||
if (QGraphicsItem *again = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) {
|
||||
again->setSelected(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//Start drag view when hold the middle button
|
||||
if (e->button() == Qt::MiddleButton)
|
||||
{
|
||||
@@ -584,9 +745,35 @@ void DiagramView::mousePressEvent(QMouseEvent *e)
|
||||
*/
|
||||
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()));
|
||||
if (m_event_interface && m_event_interface->mouseMoveEvent(e)) return;
|
||||
|
||||
if (m_gesture_tracking)
|
||||
{
|
||||
const QPoint pos = e->position().toPoint();
|
||||
if (!m_gesture_overlay->isVisible()
|
||||
&& (pos - m_gesture_origin).manhattanLength() > 2 * QApplication::startDragDistance()) {
|
||||
if (m_gesture_over_tool) {
|
||||
//The drag is a gesture: end the tool, and ignore the
|
||||
//platform's right-click menu like any other gesture
|
||||
m_diagram->clearEventInterface();
|
||||
m_swallow_native_menu = true;
|
||||
}
|
||||
m_gesture_overlay->showAt(m_gesture_origin, selectionCommands());
|
||||
}
|
||||
if (m_gesture_overlay->isVisible()) {
|
||||
m_gesture_overlay->setPointer(pos);
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
//Not a drag yet: a running tool keeps following the mouse
|
||||
if (!m_gesture_over_tool) {
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag the view
|
||||
if (e->buttons() == Qt::MiddleButton)
|
||||
{
|
||||
@@ -648,6 +835,40 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e)
|
||||
{
|
||||
if (m_event_interface && m_event_interface->mouseReleaseEvent(e)) return;
|
||||
|
||||
//A plain right click on a running tool falls through: the tool
|
||||
//handles it, as it always has
|
||||
if (m_gesture_tracking && e->button() == Qt::RightButton
|
||||
&& !(m_gesture_over_tool && !m_gesture_overlay->isVisible()))
|
||||
{
|
||||
m_gesture_tracking = false;
|
||||
const QPoint pos = e->position().toPoint();
|
||||
if (m_gesture_overlay->isVisible())
|
||||
{
|
||||
//A gesture: run the command it points at, if any
|
||||
QAction *action = m_gesture_overlay->actionAt(pos);
|
||||
m_gesture_overlay->hide();
|
||||
if (action) {
|
||||
action->trigger();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//A plain right click: the context menu, opened here on
|
||||
//release on every platform
|
||||
QContextMenuEvent menu_event(QContextMenuEvent::Mouse, pos,
|
||||
e->globalPosition().toPoint(),
|
||||
e->modifiers());
|
||||
m_menu_from_gesture = true;
|
||||
contextMenuEvent(&menu_event);
|
||||
m_menu_from_gesture = false;
|
||||
}
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
if (e->button() == Qt::RightButton) {
|
||||
m_gesture_tracking = false;
|
||||
}
|
||||
|
||||
// Stop drag view
|
||||
if (e->button() == Qt::MiddleButton)
|
||||
{
|
||||
@@ -692,7 +913,60 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e)
|
||||
e->accept();
|
||||
}
|
||||
else
|
||||
{
|
||||
QGraphicsView::mouseReleaseEvent(e);
|
||||
|
||||
//A click, not a drag: moving items or a rubber band selection
|
||||
//should not be followed by a toolbar under the mouse.
|
||||
const QPoint pos = e->position().toPoint();
|
||||
if (e->button() == Qt::LeftButton
|
||||
&& (pos - m_press_pos).manhattanLength() < QApplication::startDragDistance()) {
|
||||
showContextToolbar(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::selectionCommands
|
||||
@return the shortcut bar's commands for the current selection, as this
|
||||
window's actions
|
||||
*/
|
||||
QList<QAction *> DiagramView::selectionCommands() const
|
||||
{
|
||||
QList<QAction *> actions;
|
||||
QETDiagramEditor *qde = diagramEditor();
|
||||
if (!qde) {
|
||||
return actions;
|
||||
}
|
||||
const auto context = ShortcutBarSettings::contextFor(m_diagram->selectedItems());
|
||||
for (const QString &id : ShortcutBarSettings::ids(context)) {
|
||||
if (QAction *action = ShortcutManager::instance().action(id, qde)) {
|
||||
actions << action;
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::showContextToolbar
|
||||
After a click that leaves something selected, show the shortcut bar's
|
||||
commands for that selection beside the cursor. Not while placing or
|
||||
drawing, nor on a read-only folio, nor when switched off in the
|
||||
configuration.
|
||||
@param viewport_pos : where the click was
|
||||
*/
|
||||
void DiagramView::showContextToolbar(const QPoint &viewport_pos)
|
||||
{
|
||||
const QList<QGraphicsItem *> selection = m_diagram->selectedItems();
|
||||
QETDiagramEditor *qde = diagramEditor();
|
||||
if (selection.isEmpty() || !qde
|
||||
|| m_diagram->isReadOnly() || m_diagram->eventInterfaceIsRunning()
|
||||
|| !DiagramContextToolbar::isEnabled()) {
|
||||
m_context_toolbar->hide();
|
||||
return;
|
||||
}
|
||||
|
||||
m_context_toolbar->showAt(viewport_pos, selectionCommands());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -772,6 +1046,23 @@ void DiagramView::focusInEvent(QFocusEvent *e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::focusOutEvent
|
||||
Leave the Ctrl+Shift panning: without the focus, the view will not see
|
||||
Ctrl or Shift being released, which is what normally ends it. A
|
||||
Ctrl+Shift shortcut that opens a window (Ctrl+Shift+M, the command
|
||||
search) left the view panning, ignoring clicks on the folio, on Windows,
|
||||
where no other key release reached the view to end it.
|
||||
@param e
|
||||
*/
|
||||
void DiagramView::focusOutEvent(QFocusEvent *e)
|
||||
{
|
||||
if (m_ctrl_shift_panning) {
|
||||
setSelectionMode();
|
||||
}
|
||||
PaletteGraphicsView::focusOutEvent(e);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::focusNextPrevChild
|
||||
By default, QWidget intercepts Tab/Shift+Tab to move keyboard focus to
|
||||
@@ -825,6 +1116,18 @@ void DiagramView::keyPressEvent(QKeyEvent *e)
|
||||
focusNextChild();
|
||||
}
|
||||
return;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
//Repeat the last drawing or placing command, as SolidWorks
|
||||
//does. Not while a tool is running or a text has the focus:
|
||||
//both use Enter themselves.
|
||||
if (e->modifiers() == Qt::NoModifier
|
||||
&& !m_diagram->eventInterfaceIsRunning()
|
||||
&& !m_diagram->focusItem()
|
||||
&& diagramEditor()->repeatLastCommand()) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case Qt::Key_PageUp:
|
||||
current_project->changeTabUp();
|
||||
return;
|
||||
@@ -1169,6 +1472,158 @@ 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
|
||||
letters of the folio border in sight along the edges of this view.
|
||||
@param shown
|
||||
*/
|
||||
void DiagramView::setCellRulersShown(bool shown)
|
||||
{
|
||||
m_cell_rulers_shown = shown;
|
||||
updateCellRulers();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::updateCellRulers
|
||||
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()
|
||||
&& (columns.top() < in_sight.top() || columns.bottom() > in_sight.bottom());
|
||||
const bool side = m_cell_rulers_shown
|
||||
&& 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);
|
||||
m_side_ruler->setVisible(side);
|
||||
m_side_ruler->setLeadingSpace(top ? thickness : 0);
|
||||
|
||||
const QMargins margins(side ? thickness : 0, top ? thickness : 0, 0, 0);
|
||||
if (margins != viewportMargins()) {
|
||||
const QPointF origin = mapToScene(viewport()->rect().center());
|
||||
const QPoint before = viewport()->mapToGlobal(mapFromScene(origin));
|
||||
setViewportMargins(margins);
|
||||
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();
|
||||
m_side_ruler->update();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::placeCellRulers
|
||||
Lay the rulers along the top and the left edges of the viewport, the
|
||||
side ruler covering the corner too when both are shown.
|
||||
*/
|
||||
void DiagramView::placeCellRulers()
|
||||
{
|
||||
if (!m_top_ruler) {
|
||||
return;
|
||||
}
|
||||
const QRect viewport_rect = viewport()->geometry();
|
||||
const int thickness = m_top_ruler->thickness();
|
||||
const int corner = m_top_ruler->isHidden() ? 0 : thickness;
|
||||
m_top_ruler->setGeometry(viewport_rect.left(), viewport_rect.top() - thickness,
|
||||
viewport_rect.width(), thickness);
|
||||
m_side_ruler->setGeometry(viewport_rect.left() - thickness, viewport_rect.top() - corner,
|
||||
thickness, viewport_rect.height() + corner);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::viewportEvent
|
||||
Keep the rulers along the viewport when it resizes, which it also does
|
||||
without the view resizing, when the scroll bars come and go.
|
||||
@param event
|
||||
@return what QGraphicsView::viewportEvent() returns
|
||||
*/
|
||||
bool DiagramView::viewportEvent(QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::Resize) {
|
||||
placeCellRulers();
|
||||
}
|
||||
return PaletteGraphicsView::viewportEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::paintEvent
|
||||
Reimplemented from QGraphicsView
|
||||
@@ -1178,6 +1633,19 @@ void DiagramView::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
PaletteGraphicsView::paintEvent(event);
|
||||
|
||||
//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)
|
||||
{
|
||||
QPainter painter(viewport());
|
||||
@@ -1202,6 +1670,7 @@ bool DiagramView::switchToVisualisationModeIfNeeded(QInputEvent *e) {
|
||||
if (isCtrlShifting(e) && !selectedItemHasFocus()) {
|
||||
if (dragMode() != QGraphicsView::ScrollHandDrag) {
|
||||
setVisualisationMode();
|
||||
m_ctrl_shift_panning = true;
|
||||
return(true);
|
||||
}
|
||||
}
|
||||
@@ -1298,10 +1767,17 @@ QList<QAction *> DiagramView::contextMenuActions() const
|
||||
{
|
||||
if (m_diagram->selectedItems().isEmpty())
|
||||
{
|
||||
//Drawing comes first. The row and column actions change
|
||||
//the folio's layout and are rarely wanted, so they sit one
|
||||
//level down where a stray click cannot reach them.
|
||||
list << m_paste_here;
|
||||
list << m_separators.at(0);
|
||||
list << qde->m_insert_last_element;
|
||||
list << m_folio_report_menu->menuAction();
|
||||
list << qde->m_add_item_menu->menuAction();
|
||||
list << m_separators.at(1);
|
||||
list << qde->m_edit_diagram_properties;
|
||||
list << qde->m_row_column_actions_group.actions();
|
||||
list << qde->m_row_column_menu->menuAction();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1313,15 +1789,24 @@ QList<QAction *> DiagramView::contextMenuActions() const
|
||||
list << qde->m_conductor_reset;
|
||||
list << m_separators.at(1);
|
||||
list << qde->m_selection_actions_group.actions();
|
||||
list << qde->m_align_menu->menuAction();
|
||||
list << m_separators.at(2);
|
||||
list << qde->m_depth_action_group->actions();
|
||||
}
|
||||
|
||||
//Remove from the context menu the actions which are disabled.
|
||||
//Remove from the context menu the actions which are disabled,
|
||||
//and the submenus in which every action is disabled.
|
||||
const QList<QAction *> actions = list;
|
||||
for(QAction *action : actions)
|
||||
{
|
||||
if (!action->isEnabled()) {
|
||||
bool usable = action->isEnabled();
|
||||
if (usable && action->menu())
|
||||
{
|
||||
const QList<QAction *> sub_actions = action->menu()->actions();
|
||||
usable = std::any_of(sub_actions.cbegin(), sub_actions.cend(),
|
||||
[](QAction *a) { return a->isEnabled(); });
|
||||
}
|
||||
if (!usable) {
|
||||
list.removeAll(action);
|
||||
}
|
||||
}
|
||||
@@ -1353,6 +1838,14 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e)
|
||||
//right-click gets.
|
||||
const bool from_keyboard = e->reason() == QContextMenuEvent::Keyboard;
|
||||
|
||||
//With gestures on, a right press is tracked by mousePressEvent and
|
||||
//the menu opened on release; the platform's own event (sent on press
|
||||
//on X11, on release on Windows) would open it a second time.
|
||||
if (!from_keyboard && m_swallow_native_menu && !m_menu_from_gesture) {
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (from_keyboard)
|
||||
{
|
||||
//Aim at the selection when there is one, so the menu appears
|
||||
@@ -1401,6 +1894,7 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e)
|
||||
{
|
||||
m_paste_here_pos = menu_pos;
|
||||
m_paste_here->setEnabled(Diagram::clipboardMayContainDiagram());
|
||||
updateFolioReportMenu();
|
||||
}
|
||||
|
||||
QList <QAction *> list = contextMenuActions();
|
||||
@@ -1413,6 +1907,78 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::updateFolioReportMenu
|
||||
Fill the "Renvoi de folio" submenu of the context menu with the folio
|
||||
report elements this project already uses -- the ones in its embedded
|
||||
collection -- or, when it has none yet, the coming and going arrows of
|
||||
the common collection. An entry places its element where the context
|
||||
menu was opened. Left empty, and so hidden, on a read-only diagram.
|
||||
*/
|
||||
void DiagramView::updateFolioReportMenu()
|
||||
{
|
||||
m_folio_report_menu->clear();
|
||||
if (m_diagram->isReadOnly()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QList<ElementsLocation> locations;
|
||||
QETProject *project = m_diagram->project();
|
||||
XmlElementCollection *collection =
|
||||
project ? project->embeddedElementCollection() : nullptr;
|
||||
if (collection)
|
||||
{
|
||||
QSet<QString> names;
|
||||
const QDomNodeList definitions =
|
||||
collection->root().elementsByTagName(QStringLiteral("definition"));
|
||||
for (int i = 0 ; i < definitions.count() ; ++i)
|
||||
{
|
||||
const QDomElement definition = definitions.at(i).toElement();
|
||||
const QString link_type = definition.attribute(QStringLiteral("link_type"));
|
||||
if (link_type != QLatin1String("next_report")
|
||||
&& link_type != QLatin1String("previous_report")) {
|
||||
continue;
|
||||
}
|
||||
const ElementsLocation location =
|
||||
collection->domToLocation(definition.parentNode().toElement());
|
||||
//A project keeps dated copies of the same element
|
||||
//(01previous_folio-20140521204742.elmt), so list each name once.
|
||||
if (!location.exist() || names.contains(location.name())) {
|
||||
continue;
|
||||
}
|
||||
names.insert(location.name());
|
||||
locations << location;
|
||||
}
|
||||
}
|
||||
|
||||
if (locations.isEmpty())
|
||||
{
|
||||
for (const auto path : {
|
||||
"common://10_electric/10_allpole/100_folio_referencing/01coming_arrow.elmt",
|
||||
"common://10_electric/10_allpole/100_folio_referencing/02going_arrow.elmt"})
|
||||
{
|
||||
const ElementsLocation location(QString::fromLatin1(path));
|
||||
if (location.exist()) {
|
||||
locations << location;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(locations.begin(), locations.end(),
|
||||
[](const ElementsLocation &a, const ElementsLocation &b) {
|
||||
return a.name().localeAwareCompare(b.name()) < 0;
|
||||
});
|
||||
|
||||
for (const ElementsLocation &location : std::as_const(locations))
|
||||
{
|
||||
QAction *action = m_folio_report_menu->addAction(location.icon(),
|
||||
location.name());
|
||||
connect(action, &QAction::triggered, this, [this, location]() {
|
||||
startElementPlacement(location, mapToScene(m_paste_here_pos));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief DiagramView::createTemplateFromSelection
|
||||
* Triggered from the context menu to create a new template (macro) from the current selection.
|
||||
|
||||
+52
-1
@@ -24,12 +24,16 @@
|
||||
#include <QClipboard>
|
||||
#include "palettegraphicsview.h"
|
||||
|
||||
class CellRuler;
|
||||
class Conductor;
|
||||
class Diagram;
|
||||
class DiagramContextToolbar;
|
||||
class DiagramGestureOverlay;
|
||||
class QETDiagramEditor;
|
||||
class DVEventInterface;
|
||||
class QInputEvent;
|
||||
class QGestureEvent;
|
||||
class QMenu;
|
||||
|
||||
/**
|
||||
This class provides a widget to render an electric diagram in an editable,
|
||||
@@ -54,14 +58,37 @@ class DiagramView : public PaletteGraphicsView
|
||||
QAction *m_paste_here = nullptr;
|
||||
QAction *m_multi_paste = nullptr;
|
||||
QAction *m_create_template = nullptr;
|
||||
QMenu *m_folio_report_menu = nullptr;
|
||||
QPoint m_paste_here_pos;
|
||||
QPoint m_press_pos;
|
||||
DiagramContextToolbar *m_context_toolbar = nullptr;
|
||||
/// Right-drag gestures: tracking since the right button went down
|
||||
bool m_gesture_tracking = false;
|
||||
/// The platform's own right-click menu event is to be ignored:
|
||||
/// the view opens the menu itself on release
|
||||
bool m_swallow_native_menu = false;
|
||||
bool m_menu_from_gesture = false;
|
||||
/// The right press went to a running tool; a drag ends the tool
|
||||
bool m_gesture_over_tool = false;
|
||||
QPoint m_gesture_origin;
|
||||
DiagramGestureOverlay *m_gesture_overlay = nullptr;
|
||||
QPoint m_last_mouse_pos = QPoint(-1, -1);
|
||||
QPointF m_drag_last_pos;
|
||||
bool m_fresh_focus_in,
|
||||
m_first_activation = true;
|
||||
/// True while the view pans because Ctrl+Shift is held, as opposed
|
||||
/// to the visualisation mode chosen from the toolbar.
|
||||
bool m_ctrl_shift_panning = false;
|
||||
QList<QAction *> m_separators;
|
||||
QPolygonF m_free_rubberband;
|
||||
bool m_free_rubberbanding = false;
|
||||
|
||||
CellRuler *m_top_ruler = nullptr;
|
||||
CellRuler *m_side_ruler = nullptr;
|
||||
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;
|
||||
@@ -71,12 +98,25 @@ class DiagramView : public PaletteGraphicsView
|
||||
void editSelection();
|
||||
void setEventInterface (DVEventInterface *event_interface);
|
||||
QList<QAction *> contextMenuActions() const;
|
||||
/// Last mouse position seen by mouseMoveEvent(), in viewport
|
||||
/// coordinates -- (-1, -1) if the mouse hasn't moved over this
|
||||
/// view yet. Filled from ordinary Qt mouse events, not a global
|
||||
/// cursor query (QCursor::pos()/setPos() are silently ignored by
|
||||
/// several window managers and compositors, Wayland included).
|
||||
QPoint lastMousePos() const { return m_last_mouse_pos; }
|
||||
|
||||
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;
|
||||
void contextMenuEvent(QContextMenuEvent *) override;
|
||||
void wheelEvent(QWheelEvent *) override;
|
||||
void focusInEvent(QFocusEvent *) override;
|
||||
void focusOutEvent(QFocusEvent *) override;
|
||||
void keyPressEvent(QKeyEvent *) override;
|
||||
void keyReleaseEvent(QKeyEvent *) override;
|
||||
bool event(QEvent *) override;
|
||||
@@ -84,6 +124,8 @@ class DiagramView : public PaletteGraphicsView
|
||||
///Set for one call only, by the Escape handler, to let focus leave the view.
|
||||
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;
|
||||
@@ -99,6 +141,7 @@ class DiagramView : public PaletteGraphicsView
|
||||
|
||||
private:
|
||||
void handleElementDrop(QDropEvent *);
|
||||
void updateFolioReportMenu();
|
||||
void handleTitleBlockDrop(QDropEvent *);
|
||||
void handleTextDrop(QDropEvent *);
|
||||
void scrollOnMovement(QKeyEvent *);
|
||||
@@ -106,6 +149,10 @@ class DiagramView : public PaletteGraphicsView
|
||||
QRectF viewedSceneRect() const;
|
||||
bool mustIntegrateTitleBlockTemplate(const TitleBlockTemplateLocation &) const;
|
||||
bool gestures() const;
|
||||
void updateCellRulers();
|
||||
void placeCellRulers();
|
||||
void showContextToolbar(const QPoint &viewport_pos);
|
||||
QList<QAction *> selectionCommands() const;
|
||||
|
||||
/// Lowest and highest allowed value of the view transform scale (m11).
|
||||
/// Prevents wheel-zoom from driving the transform to overflow, which
|
||||
@@ -125,6 +172,9 @@ class DiagramView : public PaletteGraphicsView
|
||||
/// Signal emitted when free rubberband changed.
|
||||
/// When free rubberband selection ends this signal will be emitted with null value.
|
||||
void freeRubberBandChanged(QPolygonF polygon);
|
||||
/// Signal emitted when the placement mode is entered for an
|
||||
/// element (not a macro), whether from a drop or not.
|
||||
void elementPlacementStarted(const ElementsLocation &);
|
||||
|
||||
public slots:
|
||||
void setVisualisationMode();
|
||||
@@ -133,6 +183,7 @@ class DiagramView : public PaletteGraphicsView
|
||||
void zoomFit();
|
||||
void zoomContent();
|
||||
void zoomReset();
|
||||
void zoomToRect(const QRectF &rect);
|
||||
void cut();
|
||||
void copy();
|
||||
void paste(const QPointF & = QPointF(), QClipboard::Mode = QClipboard::Clipboard);
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "dxfexport.h"
|
||||
|
||||
#include "conductorsegment.h"
|
||||
#include "createdxf.h"
|
||||
#include "diagram.h"
|
||||
#include "dxfpaintdevice.h"
|
||||
#include "exportproperties.h"
|
||||
#include "factory/elementpicturefactory.h"
|
||||
#include "qetgraphicsitem/ViewItem/qetgraphicstableitem.h"
|
||||
#include "qetgraphicsitem/conductor.h"
|
||||
#include "qetgraphicsitem/conductortextitem.h"
|
||||
#include "qetgraphicsitem/crossrefitem.h"
|
||||
#include "qetgraphicsitem/diagramimageitem.h"
|
||||
#include "qetgraphicsitem/diagramtextitem.h"
|
||||
#include "qetgraphicsitem/dynamicelementtextitem.h"
|
||||
#include "qetgraphicsitem/element.h"
|
||||
#include "qetgraphicsitem/independenttextitem.h"
|
||||
#include "qetgraphicsitem/qetshapeitem.h"
|
||||
#include "qetgraphicsitem/terminal.h"
|
||||
|
||||
#include <QGraphicsSimpleTextItem>
|
||||
#include <QSet>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
/**
|
||||
@brief DxfExport::folioSize
|
||||
@return the size of @a diagram as exported with @a properties: its border
|
||||
and title block, or just its items, following properties.exported_area.
|
||||
The export dialog offers this size by default, and --export-dxf uses it.
|
||||
*/
|
||||
QSize DxfExport::folioSize(Diagram *diagram, const ExportProperties &properties)
|
||||
{
|
||||
const bool state_useBorder = diagram -> useBorder();
|
||||
diagram -> setUseBorder(properties.exported_area == QET::BorderArea);
|
||||
const QSize size = diagram -> imageSize();
|
||||
diagram -> setUseBorder(state_useBorder);
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DxfExport::write
|
||||
Export @a diagram as a DXF file. Moved here unchanged from
|
||||
ExportDialog::generateDxf(), except that the options come from
|
||||
@a properties instead of the dialog, so the command line can use it.
|
||||
The diagram's own display options are applied for the export and put
|
||||
back afterwards, as the dialog always did.
|
||||
@param diagram : folio to export
|
||||
@param width : width of the export, as offered by folioSize()
|
||||
@param height : height of the export
|
||||
@param path : file to write
|
||||
@param properties : export options
|
||||
*/
|
||||
void DxfExport::write(Diagram *diagram, int width, int height,
|
||||
const QString &path, const ExportProperties &properties)
|
||||
{
|
||||
//Non-const: BorderTitleBlock::drawDxf() takes a QString &.
|
||||
QString file_path = path;
|
||||
const ExportProperties previous = diagram -> applyProperties(properties);
|
||||
|
||||
width -= 2*Diagram::margin;
|
||||
height -= 2*Diagram::margin;
|
||||
|
||||
Createdxf::xScale = Createdxf::sheetWidth / double(width);
|
||||
Createdxf::yScale = Createdxf::sheetHeight / double(height);
|
||||
|
||||
Createdxf::dxfBegin(file_path, Layer::all());
|
||||
|
||||
//Each kind of content on its own layer (discussion #1071). The
|
||||
//border's title block switches to Layer::TitleBlock itself, see
|
||||
//BorderTitleBlock::drawDxf().
|
||||
Createdxf::layer = Layer::Border;
|
||||
//Add project elements (lines, rectangles, circles, texts) to dxf file
|
||||
if (properties.draw_border) {
|
||||
QRectF rect(Diagram::margin,Diagram::margin,width,height);
|
||||
Createdxf::drawRectangle(file_path,rect,0);
|
||||
}
|
||||
diagram -> border_and_titleblock.drawDxf(file_path, 0);
|
||||
|
||||
// Build the lists of elements.
|
||||
QList<Element *> list_elements;
|
||||
QList<Conductor *> list_conductors;
|
||||
QList<DiagramTextItem *> list_texts;
|
||||
QList<DiagramImageItem *> list_images;
|
||||
//Slave cross-reference labels. They hang off a DynamicElementTextItem
|
||||
//as plain QGraphicsTextItem children, so neither cast below picks them
|
||||
//up and they were missing from the DXF entirely.
|
||||
QList<QGraphicsTextItem *> list_xref_texts;
|
||||
//Master-side cross-reference item (the table/cross drawn next to a
|
||||
//report/master element). It paints itself with hand-written
|
||||
//QPainter code across three modes (drawAsCross/drawAsContacts/
|
||||
//drawAsPlcTable), so instead of hand-porting each one it's replayed
|
||||
//through DxfPaintEngine, which reuses paint() unmodified.
|
||||
QList<CrossRefItem *> list_master_xrefs;
|
||||
QList<QLineF *> list_lines;
|
||||
QList<QRectF *> list_rectangles;
|
||||
//QList<QRectF *> list_ellipses;
|
||||
QList <QetShapeItem *> list_shapes;
|
||||
QList <QetGraphicsTableItem *> list_tables;
|
||||
// QList <Terminal *> list_terminals;
|
||||
|
||||
// Determine les elements a "XMLiser"
|
||||
//In stacking order (z, then insertion order, which a load makes the
|
||||
//file's order), not items() order: that follows memory addresses, so
|
||||
//the same folio came out in a different order on every run (FINDINGS
|
||||
//F050), as saving did before bugtracker #343. A rect query gives
|
||||
//stacking order even with NoIndex; anything it misses keeps its
|
||||
//items() position after the rest.
|
||||
QList<QGraphicsItem *> stacked_items = diagram -> items(
|
||||
QRectF(-1e9, -1e9, 2e9, 2e9), Qt::IntersectsItemBoundingRect,
|
||||
Qt::AscendingOrder);
|
||||
{
|
||||
const QSet<QGraphicsItem *> ranked(stacked_items.cbegin(), stacked_items.cend());
|
||||
for (QGraphicsItem *qgi : diagram -> items()) {
|
||||
if (!ranked.contains(qgi)) {
|
||||
stacked_items << qgi;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (QGraphicsItem *qgi : std::as_const(stacked_items)) {
|
||||
if (Element *elmt = qgraphicsitem_cast<Element *>(qgi)) {
|
||||
list_elements << elmt;
|
||||
} else if (Conductor *f = qgraphicsitem_cast<Conductor *>(qgi)) {
|
||||
list_conductors << f;
|
||||
} else if (IndependentTextItem *iti = qgraphicsitem_cast<IndependentTextItem *>(qgi)) {
|
||||
list_texts << iti;
|
||||
} else if (DiagramImageItem *dii = qgraphicsitem_cast<DiagramImageItem *>(qgi)) {
|
||||
list_images << dii;
|
||||
} else if (QetShapeItem *dii = qgraphicsitem_cast<QetShapeItem *>(qgi)) {
|
||||
list_shapes << dii;
|
||||
} else if (DynamicElementTextItem *deti = qgraphicsitem_cast<DynamicElementTextItem *>(qgi)) {
|
||||
list_texts << deti;
|
||||
if (QGraphicsTextItem *xref = deti->slaveXrefItem()) {
|
||||
list_xref_texts << xref;
|
||||
}
|
||||
} else if (QetGraphicsTableItem *gti = qgraphicsitem_cast<QetGraphicsTableItem *>(qgi)) {
|
||||
list_tables << gti;
|
||||
} else if (CrossRefItem *xref = qgraphicsitem_cast<CrossRefItem *>(qgi)) {
|
||||
list_master_xrefs << xref;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw shapes
|
||||
Createdxf::layer = Layer::Shapes;
|
||||
foreach (QetShapeItem *qsi, list_shapes) qsi->toDXF(file_path, qsi->pen());
|
||||
|
||||
// Draw tables
|
||||
Createdxf::layer = Layer::Tables;
|
||||
foreach (QetGraphicsTableItem *gti, list_tables) {
|
||||
gti->toDXF(file_path);
|
||||
}
|
||||
|
||||
//Draw elements
|
||||
foreach(Element *elmt, list_elements)
|
||||
{
|
||||
double rotation_angle = elmt -> orientation() * 90;
|
||||
|
||||
qreal elem_pos_x = elmt -> pos().x();
|
||||
qreal elem_pos_y = elmt -> pos().y();// - (diagram -> margin / 2);
|
||||
|
||||
ElementPictureFactory::primitives primitives = ElementPictureFactory::instance()->getPrimitives(elmt->location());
|
||||
|
||||
Createdxf::layer = Layer::SymbolTexts;
|
||||
for(QGraphicsSimpleTextItem *text : primitives.m_texts)
|
||||
{
|
||||
qreal fontSize = text->font().pointSizeF();
|
||||
if (fontSize < 0)
|
||||
fontSize = text->font().pixelSize();
|
||||
|
||||
qreal x = elem_pos_x + text->pos().x();
|
||||
qreal y = elem_pos_y + text->pos().y();
|
||||
|
||||
qreal angle = text -> rotation() + rotation_angle;
|
||||
qreal angler = angle * M_PI/180;
|
||||
int xdir = -sin(angler);
|
||||
int ydir = -cos(angler);
|
||||
|
||||
QPointF transformed_point = rotation_transformed(x, y, elem_pos_x, elem_pos_y, -rotation_angle);
|
||||
x = transformed_point.x() - ydir * fontSize * 0.5;
|
||||
y = transformed_point.y() - xdir * fontSize * 0.5;
|
||||
QStringList lines = text->text().split('\n');
|
||||
qreal offset = fontSize * 1.6;
|
||||
for (QString line : lines)
|
||||
{
|
||||
if (line.size() > 0 && line != "_" ) {
|
||||
Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360 - angle, 0, 0.72);
|
||||
}
|
||||
x += offset * xdir;
|
||||
y -= offset * ydir;
|
||||
}
|
||||
}
|
||||
|
||||
Createdxf::layer = Layer::Symbols;
|
||||
for (QLineF line : primitives.m_lines)
|
||||
{
|
||||
QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle);
|
||||
QLineF l = t.map(line);
|
||||
Createdxf::drawLine(file_path, l, 0);
|
||||
}
|
||||
|
||||
for (QRectF rect : primitives.m_rectangles)
|
||||
{
|
||||
QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle);
|
||||
QRectF r = t.mapRect(rect);
|
||||
Createdxf::drawRectangle(file_path,r,0);
|
||||
}
|
||||
|
||||
for (QRectF circle_rect : primitives.m_circles)
|
||||
{
|
||||
QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle);
|
||||
QPointF c = t.map(QPointF(circle_rect.center().x(),circle_rect.center().y()));
|
||||
Createdxf::drawCircle(file_path,c,circle_rect.width()/2,0);
|
||||
}
|
||||
|
||||
for (QVector<QPointF> polygon : primitives.m_polygons)
|
||||
{
|
||||
if (polygon.size() == 0)
|
||||
continue;
|
||||
QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle);
|
||||
QPolygonF poly = t.map(polygon);
|
||||
if(poly.isClosed())
|
||||
Createdxf::drawPolygon(file_path,poly,0);
|
||||
else
|
||||
Createdxf::drawPolyline(file_path,poly,0);
|
||||
}
|
||||
|
||||
// Draw arcs and ellipses
|
||||
for (QVector<qreal> arc : primitives.m_arcs)
|
||||
{
|
||||
if (arc.size() == 0)
|
||||
continue;
|
||||
qreal x = (elem_pos_x + arc.at(0));
|
||||
qreal y = (elem_pos_y + arc.at(1));
|
||||
qreal w = arc.at(2);
|
||||
qreal h = arc.at(3);
|
||||
qreal startAngle = arc.at(4);
|
||||
qreal spanAngle = arc .at(5);
|
||||
QRectF r(x,y,w,h);
|
||||
QPointF hotspot(elem_pos_x,elem_pos_y);
|
||||
Createdxf::drawArcEllipse(file_path, r, startAngle, spanAngle, hotspot, rotation_angle, 0);
|
||||
}
|
||||
if (properties.draw_terminals) {
|
||||
Createdxf::layer = Layer::Terminals;
|
||||
// Draw terminals
|
||||
QList<Terminal *> list_terminals = elmt->terminals();
|
||||
QColor col("red");
|
||||
QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle);
|
||||
foreach(Terminal *tp, list_terminals) {
|
||||
QPointF c = t.map(QPointF(tp->dock_elmt_.x(),tp->dock_elmt_.y()));
|
||||
Createdxf::drawCircle(file_path,c,3.0,Createdxf::dxfColor(col));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Draw conductors
|
||||
foreach(Conductor *cond, list_conductors) {
|
||||
QPolygonF poly;
|
||||
bool firstseg = true;
|
||||
foreach(ConductorSegment *segment, cond -> segmentsList()) {
|
||||
//Createdxf::drawLine(file_path,QLineF(cond->pos()+segment->firstPoint(),cond->pos()+segment->secondPoint()),0);
|
||||
if(firstseg){
|
||||
poly << cond->pos()+segment->firstPoint();
|
||||
firstseg = false;
|
||||
}
|
||||
poly << cond->pos()+segment->secondPoint();
|
||||
}
|
||||
Createdxf::layer = Layer::Wires;
|
||||
//The wire's own colour, as on the folio (a two-colour wire gets
|
||||
//its main one). Black comes out as BYLAYER, see Createdxf.
|
||||
const ConductorProperties wire_properties = cond -> properties();
|
||||
Createdxf::drawPolyline(file_path, poly, Createdxf::dxfColor(wire_properties.color));
|
||||
//Draw conductor text item
|
||||
Createdxf::layer = Layer::WireNumbers;
|
||||
ConductorTextItem *textItem = cond -> textItem();
|
||||
|
||||
if (textItem) {
|
||||
qreal fontSize = textItem -> font().pointSizeF();
|
||||
if (fontSize < 0)
|
||||
fontSize = textItem -> font().pixelSize();
|
||||
qreal angle = textItem -> rotation();
|
||||
qreal angler = angle * M_PI/180;
|
||||
int xdir = -sin(angler);
|
||||
int ydir = -cos(angler);
|
||||
|
||||
qreal x = (cond->pos().x() + textItem -> pos().x())
|
||||
+ xdir * fontSize * 1.8
|
||||
- ydir * fontSize;
|
||||
qreal y = (cond->pos().y() + textItem -> pos().y())
|
||||
- ydir * fontSize * 1.8
|
||||
- xdir * fontSize * 0.9;
|
||||
QStringList lines = textItem->toPlainText().split('\n');
|
||||
qreal offset = fontSize * 1.6;
|
||||
foreach (QString line, lines) {
|
||||
if (line.size() > 0 && line != "_" )
|
||||
Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360-angle,
|
||||
Createdxf::dxfColor(wire_properties.text_color), 0.72 );
|
||||
x += offset * xdir;
|
||||
y -= offset * ydir;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the junctions
|
||||
Createdxf::layer = Layer::Junctions;
|
||||
QList<QPointF> junctions_list = cond->junctions();
|
||||
if (!junctions_list.isEmpty()) {
|
||||
foreach(QPointF point, junctions_list) {
|
||||
Createdxf::drawEllipse(file_path,QRectF(cond->pos().x() + point.x() - 1.5, cond->pos().y() + point.y() - 1.5, 3.0, 3.0),0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Draw text items
|
||||
foreach(DiagramTextItem *dti, list_texts) {
|
||||
//A free text, or a symbol's own text (its label and the like)
|
||||
Createdxf::layer = qgraphicsitem_cast<IndependentTextItem *>(dti)
|
||||
? Layer::Texts : Layer::SymbolTexts;
|
||||
qreal fontSize = dti -> font().pointSizeF();
|
||||
if (fontSize < 0)
|
||||
fontSize = dti -> font().pixelSize();
|
||||
|
||||
qreal angle = dti -> rotation();
|
||||
|
||||
QGraphicsItem *parent = dti->parentItem();
|
||||
while (parent) {
|
||||
angle += parent->rotation();
|
||||
parent = parent->parentItem();
|
||||
}
|
||||
|
||||
qreal angler = angle * M_PI/180;
|
||||
int xdir = -sin(angler);
|
||||
int ydir = -cos(angler);
|
||||
qreal x = (dti->scenePos().x())
|
||||
+ xdir * fontSize * 1.8
|
||||
- ydir * fontSize;
|
||||
qreal y = dti->scenePos().y()
|
||||
- ydir * fontSize * 1.8
|
||||
- xdir * fontSize * 0.9;
|
||||
QStringList lines = dti -> toPlainText().split('\n');
|
||||
qreal offset = fontSize * 1.6;
|
||||
foreach (QString line, lines) {
|
||||
if (line.size() > 0 && line != "_" )
|
||||
Createdxf::drawText(file_path, line, QPointF(x, y), fontSize, 360-angle, Createdxf::dxfColor(dti->color()), 0.72 );
|
||||
x += offset * xdir;
|
||||
y -= offset * ydir;
|
||||
}
|
||||
}
|
||||
|
||||
//Draw the slave cross-reference labels
|
||||
Createdxf::layer = Layer::Xrefs;
|
||||
for (QGraphicsTextItem *xref : std::as_const(list_xref_texts))
|
||||
{
|
||||
qreal fontSize = xref->font().pointSizeF();
|
||||
if (fontSize < 0)
|
||||
fontSize = xref->font().pixelSize();
|
||||
|
||||
qreal angle = xref->rotation();
|
||||
QGraphicsItem *parent = xref->parentItem();
|
||||
while (parent) {
|
||||
angle += parent->rotation();
|
||||
parent = parent->parentItem();
|
||||
}
|
||||
|
||||
qreal angler = angle * M_PI/180;
|
||||
int xdir = -sin(angler);
|
||||
int ydir = -cos(angler);
|
||||
qreal x = xref->scenePos().x()
|
||||
+ xdir * fontSize * 1.8
|
||||
- ydir * fontSize;
|
||||
qreal y = xref->scenePos().y()
|
||||
- ydir * fontSize * 1.8
|
||||
- xdir * fontSize * 0.9;
|
||||
|
||||
const QStringList lines = xref->toPlainText().split('\n');
|
||||
const qreal offset = fontSize * 1.6;
|
||||
for (const QString &line : lines) {
|
||||
if (line.size() > 0 && line != QLatin1String("_")) {
|
||||
Createdxf::drawText(file_path, line, QPointF(x, y), fontSize,
|
||||
360-angle, Createdxf::dxfColor(xref->defaultTextColor()), 0.72);
|
||||
}
|
||||
x += offset * xdir;
|
||||
y -= offset * ydir;
|
||||
}
|
||||
}
|
||||
|
||||
//Draw the master-side cross-reference items (table/cross), replaying
|
||||
//their existing paint() unmodified through DxfPaintEngine instead of
|
||||
//hand-porting drawAsCross()/drawAsContacts()/drawAsPlcTable().
|
||||
Createdxf::layer = Layer::Xrefs;
|
||||
for (CrossRefItem *xref : std::as_const(list_master_xrefs))
|
||||
{
|
||||
DxfPaintDevice dxf_device(file_path);
|
||||
QPainter painter(&dxf_device);
|
||||
painter.setWorldTransform(xref->sceneTransform());
|
||||
xref->paintForExport(&painter);
|
||||
painter.end();
|
||||
}
|
||||
|
||||
//Draw images -- collected above (list_images) but never actually
|
||||
//drawn until now, an existing gap this reuses the same paint()
|
||||
//-replay approach to fix: DiagramImageItem::paint() has no
|
||||
//viewport-dependent logic (unlike CrossRefItem, which needs its own
|
||||
//paintForExport() for that reason), so it's called directly with a
|
||||
//default QStyleOptionGraphicsItem rather than needing an export-
|
||||
//specific variant of its own. DxfPaintEngine::drawPixmap() is what
|
||||
//actually turns the drawPixmap() call inside paint() into a
|
||||
//placeholder outline, since this DXF dialect has no raster image
|
||||
//entity to draw instead.
|
||||
Createdxf::layer = Layer::Images;
|
||||
for (DiagramImageItem *image : std::as_const(list_images))
|
||||
{
|
||||
DxfPaintDevice dxf_device(file_path);
|
||||
QPainter painter(&dxf_device);
|
||||
painter.setWorldTransform(image->sceneTransform());
|
||||
image->paintForExport(&painter);
|
||||
painter.end();
|
||||
}
|
||||
|
||||
Createdxf::dxfEnd(file_path);
|
||||
|
||||
diagram -> applyProperties(previous);
|
||||
}
|
||||
|
||||
QPointF DxfExport::rotation_transformed(qreal px,
|
||||
qreal py,
|
||||
qreal origin_x,
|
||||
qreal origin_y,
|
||||
qreal angle) {
|
||||
|
||||
angle *= -3.14159265 / 180;
|
||||
|
||||
float s = sin(angle);
|
||||
float c = cos(angle);
|
||||
|
||||
// Vector to rotate:
|
||||
qreal Vx = px - origin_x;
|
||||
qreal Vy = py - origin_y;
|
||||
|
||||
// rotate vector
|
||||
float xnew = Vx * c - Vy * s;
|
||||
float ynew = Vx * s + Vy * c;
|
||||
|
||||
return QPointF(xnew + origin_x, ynew + origin_y);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef DXFEXPORT_H
|
||||
#define DXFEXPORT_H
|
||||
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
class Diagram;
|
||||
class ExportProperties;
|
||||
|
||||
/**
|
||||
Writes a folio as a DXF file. Used by the export dialog and by
|
||||
--export-dxf (discussion #1072), so both write the same file for the
|
||||
same options.
|
||||
*/
|
||||
namespace DxfExport
|
||||
{
|
||||
/// The layers of an exported folio (discussion #1071). Fixed and
|
||||
/// untranslated, so a CAD user's layer filters and scripts work
|
||||
/// whatever language QElectroTech runs in; prefixed so they sort
|
||||
/// together and do not clash with the recipient's own layers.
|
||||
namespace Layer
|
||||
{
|
||||
inline const QString Border = QStringLiteral("QET_BORDER");
|
||||
inline const QString TitleBlock = QStringLiteral("QET_TITLEBLOCK");
|
||||
inline const QString Symbols = QStringLiteral("QET_SYMBOLS");
|
||||
inline const QString SymbolTexts = QStringLiteral("QET_SYMBOL_TEXTS");
|
||||
inline const QString Terminals = QStringLiteral("QET_TERMINALS");
|
||||
inline const QString Wires = QStringLiteral("QET_WIRES");
|
||||
inline const QString WireNumbers = QStringLiteral("QET_WIRE_NUMBERS");
|
||||
inline const QString Junctions = QStringLiteral("QET_JUNCTIONS");
|
||||
inline const QString Texts = QStringLiteral("QET_TEXTS");
|
||||
inline const QString Xrefs = QStringLiteral("QET_XREFS");
|
||||
inline const QString Shapes = QStringLiteral("QET_SHAPES");
|
||||
inline const QString Tables = QStringLiteral("QET_TABLES");
|
||||
inline const QString Images = QStringLiteral("QET_IMAGES");
|
||||
|
||||
inline QStringList all()
|
||||
{
|
||||
return {Border, TitleBlock, Symbols, SymbolTexts, Terminals,
|
||||
Wires, WireNumbers, Junctions, Texts, Xrefs, Shapes,
|
||||
Tables, Images};
|
||||
}
|
||||
}
|
||||
|
||||
QSize folioSize(Diagram *diagram, const ExportProperties &properties);
|
||||
void write(Diagram *diagram, int width, int height,
|
||||
const QString &path, const ExportProperties &properties);
|
||||
QPointF rotation_transformed(qreal px, qreal py,
|
||||
qreal origin_x, qreal origin_y, qreal angle);
|
||||
}
|
||||
|
||||
#endif // DXFEXPORT_H
|
||||
@@ -18,15 +18,17 @@
|
||||
#include "pastepartscommand.h"
|
||||
|
||||
#include "../elementview.h"
|
||||
#include "../graphicspart/partdynamictextfield.h"
|
||||
#include "../graphicspart/partterminal.h"
|
||||
#include "../graphicspart/parttext.h"
|
||||
|
||||
/**
|
||||
@brief PastePartsCommand::PastePartsCommand
|
||||
@param view : view where this command work
|
||||
@param content_to_paste : content to paste
|
||||
@param parent : parent undo command
|
||||
@note all terminal stored in content_to_paste get a new uuid
|
||||
in the constructor of this class to avoid have several terminal
|
||||
@note all terminal and primitives stored in content_to_paste get a new
|
||||
uuid in the constructor of this class to avoid have several parts
|
||||
of an element with the same uuid.
|
||||
*/
|
||||
PastePartsCommand::PastePartsCommand(
|
||||
@@ -40,6 +42,20 @@ PastePartsCommand::PastePartsCommand(
|
||||
if (qgi->type() == PartTerminal::Type) {
|
||||
auto part_terminal = static_cast<PartTerminal*>(qgi);
|
||||
part_terminal->setNewUuid();
|
||||
}
|
||||
//Same for the primitives, whose uuid would otherwise name both
|
||||
//the original and its copy.
|
||||
else if (qgi->type() == PartText::Type) {
|
||||
static_cast<PartText *>(qgi)->setNewUuid();
|
||||
}
|
||||
//Only the definition's own identity: a placed element gives
|
||||
//each of its dynamic texts a fresh uuid anyway (see
|
||||
//Element::parseDynamicText()).
|
||||
else if (qgi->type() == PartDynamicTextField::Type) {
|
||||
static_cast<PartDynamicTextField *>(qgi)->setNewUuid();
|
||||
}
|
||||
else if (auto graphic_part = dynamic_cast<CustomElementGraphicPart *>(qgi)) {
|
||||
graphic_part->setNewUuid();
|
||||
}
|
||||
m_pasted_content.append(qgi);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,18 @@ void ElementView::scaleClamped(qreal factor)
|
||||
scale(factor, factor);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementView::zoom
|
||||
Zoom by an arbitrary factor, for a continuous input such as a 3D mouse.
|
||||
Same clamping as the wheel zoom.
|
||||
@param zoom_factor : > 1 zooms in, < 1 zooms out
|
||||
*/
|
||||
void ElementView::zoom(qreal zoom_factor)
|
||||
{
|
||||
adjustSceneRect();
|
||||
scaleClamped(zoom_factor);
|
||||
}
|
||||
|
||||
/**
|
||||
Agrandit le schema (+33% = inverse des -25 % de zoomMoins())
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,7 @@ class ElementView : public QGraphicsView {
|
||||
ElementScene *scene() const;
|
||||
void setScene(ElementScene *);
|
||||
QRectF viewedSceneRect() const;
|
||||
void zoom(qreal zoom_factor);
|
||||
|
||||
protected:
|
||||
void mousePressEvent(QMouseEvent *) override;
|
||||
|
||||
@@ -507,6 +507,9 @@ void CustomElementGraphicPart::stylesToXml(QDomElement &qde) const
|
||||
if (color != color_map.end()) { css_like_styles += color.value(); }
|
||||
|
||||
qde.setAttribute("style", css_like_styles);
|
||||
//Every graphic part writes and reads its styles through this pair,
|
||||
//so its uuid is kept here rather than in six toXml()/fromXml().
|
||||
qde.setAttribute("uuid", m_uuid.toString());
|
||||
qde.setAttribute("antialias", _antialiased ? "true" : "false");
|
||||
}
|
||||
|
||||
@@ -520,6 +523,13 @@ void CustomElementGraphicPart::stylesFromXml(const QDomElement &qde)
|
||||
{
|
||||
resetStyles();
|
||||
|
||||
//Absent from definitions written before parts carried one: keep the
|
||||
//fresh uuid this part was built with, and the next save persists it.
|
||||
const QUuid uuid(qde.attribute(QStringLiteral("uuid")));
|
||||
if (!uuid.isNull()) {
|
||||
m_uuid = uuid;
|
||||
}
|
||||
|
||||
//Get the list of pair style/value
|
||||
QStringList styles = qde.attribute("style").split(";", Qt::SkipEmptyParts);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#define CUSTOM_ELEMENT_GRAPHIC_PART_H
|
||||
|
||||
#include <QGraphicsObject>
|
||||
#include <QUuid>
|
||||
#include "customelementpart.h"
|
||||
|
||||
class QETElementEditor;
|
||||
@@ -305,6 +306,12 @@ class CustomElementGraphicPart : public QGraphicsObject, public CustomElementPar
|
||||
const QColor &/*color*/) {}
|
||||
virtual void resetAllHandlerColor() {}
|
||||
|
||||
//Identity of this primitive inside its element definition, for
|
||||
//tools that address one part of a drawing (a script, the MCP
|
||||
//server). Saved with the styles, see stylesToXml().
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void setNewUuid() {m_uuid = QUuid::createUuid();}
|
||||
|
||||
protected:
|
||||
void stylesToXml (QDomElement &) const;
|
||||
void stylesFromXml(const QDomElement &);
|
||||
@@ -323,6 +330,7 @@ class CustomElementGraphicPart : public QGraphicsObject, public CustomElementPar
|
||||
// attributes
|
||||
bool m_hovered;
|
||||
private:
|
||||
QUuid m_uuid = QUuid::createUuid();
|
||||
LineStyle _linestyle;
|
||||
LineWeight _lineweight;
|
||||
Filling _filling ;
|
||||
|
||||
@@ -75,6 +75,8 @@ class PartDynamicTextField : public QGraphicsTextItem, public CustomElementPart
|
||||
QString xmlName() const override;
|
||||
static QString xmlTaggName() {return QString("dynamic_text");}
|
||||
bool isUseless() const override {return false;}
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void setNewUuid() {m_uuid = QUuid::createUuid();}
|
||||
QRectF sceneGeometricRect() const override {return sceneBoundingRect();}
|
||||
void startUserTransformation(const QRectF &initial_selection_rect) override;
|
||||
void handleUserTransformation(const QRectF &initial_selection_rect, const QRectF &new_selection_rect) override;
|
||||
|
||||
@@ -389,6 +389,11 @@ void PartTerminal::setNewUuid()
|
||||
d -> m_uuid = QUuid::createUuid();
|
||||
}
|
||||
|
||||
QUuid PartTerminal::uuid() const
|
||||
{
|
||||
return d -> m_uuid;
|
||||
}
|
||||
|
||||
void PartTerminal::setShowName(bool show)
|
||||
{
|
||||
if (d->m_show_name == show) return;
|
||||
|
||||
@@ -141,6 +141,9 @@ class PartTerminal : public CustomElementGraphicPart
|
||||
void setMasterLabelIndex(int index);
|
||||
|
||||
void setNewUuid();
|
||||
//The terminal's own uuid, the one saved on <terminal>: hides
|
||||
//CustomElementGraphicPart::uuid(), which a terminal never saves.
|
||||
QUuid uuid() const;
|
||||
|
||||
QRectF labelRect() const;
|
||||
|
||||
|
||||
@@ -115,6 +115,11 @@ void PartText::flip(qreal axis_y) {
|
||||
void PartText::fromXml(const QDomElement &xml_element) {
|
||||
bool ok;
|
||||
|
||||
const QUuid uuid(xml_element.attribute(QStringLiteral("uuid")));
|
||||
if (!uuid.isNull()) {
|
||||
m_uuid = uuid;
|
||||
}
|
||||
|
||||
if (xml_element.hasAttribute("size")) {
|
||||
int font_size = xml_element.attribute("size").toInt(&ok);
|
||||
if (!ok || font_size < 1) {
|
||||
@@ -169,6 +174,7 @@ const QDomElement PartText::toXml(QDomDocument &xml_document) const
|
||||
xml_element.setAttribute("font", QETUtils::fontToString(font()));
|
||||
xml_element.setAttribute("rotation", QString::number(rot));
|
||||
xml_element.setAttribute("color", defaultTextColor().name());
|
||||
xml_element.setAttribute("uuid", m_uuid.toString());
|
||||
|
||||
// Only written when different from the historical behaviour, so
|
||||
// existing .elmt files round-trip byte-identical.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user