mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-26 20:04:12 +02:00
Compare commits
53 Commits
ac40d110fb
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d7036ad807 | |||
| 09fb81b99a | |||
| 852c32fc07 | |||
| e82548bee9 | |||
| 60ab39b0f6 | |||
| db56262924 | |||
| 86cb7e430e | |||
| 3d260edcdb | |||
| 53706a80d8 | |||
| 1e74bcd9de | |||
| 45aec7735b | |||
| 923367d2a8 | |||
| 6dd260a7b2 | |||
| 132c564aef | |||
| e9c4969af7 | |||
| 040c4b5e29 | |||
| 71d708e2da | |||
| b490720652 | |||
| 519f2245f6 | |||
| b61193f66f | |||
| 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 | |||
| 4f309770f0 | |||
| bb037ee3ff | |||
| d1256872c2 | |||
| 800189debb | |||
| 522e008c26 | |||
| c1cc9a5b98 |
@@ -25,6 +25,7 @@ message(" - find_spacemouse")
|
||||
# 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")
|
||||
@@ -33,6 +34,7 @@ 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)
|
||||
find_package(PkgConfig)
|
||||
@@ -73,6 +75,16 @@ if(QET_ENABLE_SPACEMOUSE)
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
@@ -282,6 +285,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/recentfiles.h
|
||||
${QET_DIR}/sources/shortcutmanager.cpp
|
||||
${QET_DIR}/sources/shortcutmanager.h
|
||||
${QET_DIR}/sources/commandsearchpopup.cpp
|
||||
${QET_DIR}/sources/commandsearchpopup.h
|
||||
${QET_DIR}/sources/titleblockcell.cpp
|
||||
${QET_DIR}/sources/titleblockcell.h
|
||||
${QET_DIR}/sources/titleblockproperties.cpp
|
||||
@@ -905,6 +910,13 @@ if(QET_SPACEMOUSE_BACKEND_HID_ENABLED)
|
||||
)
|
||||
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
|
||||
|
||||
+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,6 +99,7 @@ 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 \
|
||||
@@ -258,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
|
||||
@@ -398,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..."
|
||||
@@ -502,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>
|
||||
+545
-81
@@ -14,22 +14,59 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Record raw USB reports from a 3Dconnexion 3D mouse, for QElectroTech.
|
||||
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 the discussion.
|
||||
Nothing is sent anywhere.
|
||||
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.
|
||||
|
||||
sudo python3 spacemouse-capture.py # finds the device itself
|
||||
sudo python3 spacemouse-capture.py --list # just show what it finds
|
||||
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.
|
||||
|
||||
sudo is needed because /dev/hidraw* is usually readable by root only.
|
||||
spacenavd can keep running. If the recording comes out empty, stop it
|
||||
(`sudo systemctl stop spacenavd`) and try again.
|
||||
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.
|
||||
|
||||
Only the standard library is used, so it runs on any Linux with Python 3.
|
||||
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
|
||||
@@ -38,6 +75,7 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -58,134 +96,560 @@ STEPS = [
|
||||
('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 find_devices():
|
||||
"""Return [{hidraw, name, vendor, product, sysfs}] for 3Dconnexion devices."""
|
||||
found = []
|
||||
for sysdir in sorted(glob.glob('/sys/class/hidraw/hidraw*')):
|
||||
|
||||
def other_readers():
|
||||
if sys.platform == 'win32':
|
||||
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({
|
||||
'hidraw': '/dev/' + os.path.basename(sysdir),
|
||||
'name': uevent.get('HID_NAME', '?'),
|
||||
'vendor': '%04x' % vendor,
|
||||
'product': '%04x' % product,
|
||||
'sysfs': sysdir,
|
||||
})
|
||||
return found
|
||||
|
||||
|
||||
def read_descriptor(sysdir):
|
||||
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:
|
||||
with open(os.path.join(sysdir, 'device', 'report_descriptor'), 'rb') as f:
|
||||
return f.read().hex()
|
||||
except OSError as e:
|
||||
return 'unreadable: %s' % e
|
||||
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))
|
||||
|
||||
|
||||
def record(fd, 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([fd], [], [], left)
|
||||
if not ready:
|
||||
continue
|
||||
# --- 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:
|
||||
data = os.read(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()])
|
||||
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__.splitlines()[0])
|
||||
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='hidraw path, if more than one device is found')
|
||||
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()
|
||||
|
||||
devices = find_devices()
|
||||
Device = {'darwin': MacDevice, 'win32': WinDevice}.get(sys.platform, HidrawDevice)
|
||||
devices = Device.find()
|
||||
if args.list:
|
||||
for d in devices:
|
||||
print('%(hidraw)s %(vendor)s:%(product)s %(name)s' % d)
|
||||
print('%s %s:%s %s' % (d.path, d.vendor, d.product, d.name))
|
||||
if not devices:
|
||||
print('No 3Dconnexion device found under /sys/class/hidraw.')
|
||||
print('No 3Dconnexion device found.')
|
||||
return 0 if devices else 1
|
||||
|
||||
if args.device:
|
||||
dev = next((d for d in devices if d['hidraw'] == args.device),
|
||||
{'hidraw': args.device, 'name': '?', 'vendor': '?', 'product': '?', 'sysfs': None})
|
||||
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(' %(hidraw)s %(name)s' % d for d in devices))
|
||||
'\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()
|
||||
elif dev['sysfs']:
|
||||
descriptor = read_descriptor(dev['sysfs'])
|
||||
else:
|
||||
descriptor = 'unknown'
|
||||
descriptor = dev.descriptor()
|
||||
|
||||
try:
|
||||
fd = os.open(dev['hidraw'], os.O_RDONLY | os.O_NONBLOCK)
|
||||
except PermissionError:
|
||||
sys.exit('Permission denied on %s -- run with sudo.' % dev['hidraw'])
|
||||
|
||||
print('Recording from %s (%s, %s:%s).' % (dev['hidraw'], dev['name'], dev['vendor'], dev['product']))
|
||||
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 1',
|
||||
'tool': 'spacemouse-capture.py 2',
|
||||
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
|
||||
'system': platform.platform(),
|
||||
'device': {k: dev[k] for k in ('name', 'vendor', 'product')},
|
||||
'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 (%d s)... ' % seconds)
|
||||
reports = record(fd, seconds)
|
||||
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:
|
||||
os.close(fd)
|
||||
dev.close()
|
||||
|
||||
out = args.output or 'spacemouse-capture-%s.json' % dev['product']
|
||||
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. Try stopping spacenavd first: sudo systemctl stop spacenavd')
|
||||
print('Nothing was recorded. ' + dev.empty_hint())
|
||||
else:
|
||||
print('Please attach this file to discussion #599. Thank you!')
|
||||
return 0
|
||||
|
||||
@@ -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,6 +17,7 @@
|
||||
*/
|
||||
#include "bordertitleblock.h"
|
||||
|
||||
#include "bordercelllabels.h"
|
||||
#include "createdxf.h"
|
||||
#include "diagram.h"
|
||||
#include "diagramposition.h"
|
||||
@@ -29,6 +30,7 @@
|
||||
|
||||
#include <QLocale>
|
||||
#include <QPainter>
|
||||
#include <QRegularExpression>
|
||||
#include <utility>
|
||||
|
||||
#define MIN_COLUMN_COUNT 3
|
||||
@@ -533,6 +535,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 +547,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 +571,7 @@ void BorderTitleBlock::draw(QPainter *painter)
|
||||
painter -> drawText(lettered_rectangle,
|
||||
Qt::AlignVCenter
|
||||
| Qt::AlignCenter,
|
||||
row_string);
|
||||
row_string = incrementLetters(row_string);
|
||||
BorderCellLabels::rowLabel(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,6 +930,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
|
||||
@@ -227,6 +227,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;
|
||||
|
||||
@@ -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
|
||||
@@ -34,7 +34,6 @@
|
||||
#include <QRegularExpression>
|
||||
#include <QSqlDriver>
|
||||
#include <QSqlError>
|
||||
#include <sqlite3.h>
|
||||
|
||||
|
||||
|
||||
@@ -205,10 +204,9 @@ 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;
|
||||
@@ -226,24 +224,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1301,23 +1299,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
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,6 @@ class QETProject;
|
||||
class Diagram;
|
||||
class Conductor;
|
||||
class Terminal;
|
||||
struct sqlite3;
|
||||
|
||||
/**
|
||||
@brief The projectDataBase class
|
||||
@@ -156,12 +155,6 @@ class projectDataBase : public QObject
|
||||
m_cascade_remove_conductor_query,
|
||||
m_cascade_remove_element_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);
|
||||
|
||||
#ifdef QET_EXPORT_PROJECT_DB
|
||||
public:
|
||||
static void exportDb(projectDataBase *db,
|
||||
|
||||
@@ -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,75 @@ 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);
|
||||
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) {
|
||||
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
|
||||
|
||||
+76
-30
@@ -42,7 +42,10 @@
|
||||
#include "qetinformation.h"
|
||||
#include "qetproject.h"
|
||||
#include "diagramsortkeys.h"
|
||||
#include "textgrid.h"
|
||||
#include <QTextStream>
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <cassert>
|
||||
#include <math.h>
|
||||
|
||||
@@ -99,6 +102,43 @@ 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)};
|
||||
// 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;
|
||||
@@ -1223,37 +1263,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));
|
||||
@@ -2662,6 +2685,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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -233,6 +233,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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
+210
-3
@@ -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"
|
||||
@@ -43,6 +45,7 @@
|
||||
#include <QDropEvent>
|
||||
#include <QPainter>
|
||||
#include <QPointer>
|
||||
#include <algorithm>
|
||||
|
||||
/**
|
||||
Constructeur
|
||||
@@ -107,6 +110,14 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) :
|
||||
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]()
|
||||
{
|
||||
@@ -385,6 +396,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
|
||||
@@ -584,6 +612,7 @@ 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;
|
||||
|
||||
@@ -1169,6 +1198,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 +1359,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());
|
||||
@@ -1298,10 +1492,15 @@ 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_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
|
||||
{
|
||||
@@ -1317,11 +1516,19 @@ QList<QAction *> DiagramView::contextMenuActions() const
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-2
@@ -24,6 +24,7 @@
|
||||
#include <QClipboard>
|
||||
#include "palettegraphicsview.h"
|
||||
|
||||
class CellRuler;
|
||||
class Conductor;
|
||||
class Diagram;
|
||||
class QETDiagramEditor;
|
||||
@@ -55,13 +56,20 @@ class DiagramView : public PaletteGraphicsView
|
||||
QAction *m_multi_paste = nullptr;
|
||||
QAction *m_create_template = nullptr;
|
||||
QPoint m_paste_here_pos;
|
||||
QPoint m_last_mouse_pos = QPoint(-1, -1);
|
||||
QPointF m_drag_last_pos;
|
||||
bool m_fresh_focus_in,
|
||||
m_first_activation = true;
|
||||
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,7 +79,15 @@ 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; }
|
||||
void setCellRulersShown(bool shown);
|
||||
void setCellLinesShown(bool shown);
|
||||
|
||||
protected:
|
||||
void mouseDoubleClickEvent(QMouseEvent *) override;
|
||||
void contextMenuEvent(QContextMenuEvent *) override;
|
||||
@@ -84,6 +100,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;
|
||||
@@ -106,6 +124,8 @@ class DiagramView : public PaletteGraphicsView
|
||||
QRectF viewedSceneRect() const;
|
||||
bool mustIntegrateTitleBlockTemplate(const TitleBlockTemplateLocation &) const;
|
||||
bool gestures() const;
|
||||
void updateCellRulers();
|
||||
void placeCellRulers();
|
||||
|
||||
/// Lowest and highest allowed value of the view transform scale (m11).
|
||||
/// Prevents wheel-zoom from driving the transform to overflow, which
|
||||
@@ -133,6 +153,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);
|
||||
|
||||
@@ -589,6 +589,29 @@ void ElementPropertiesEditorWidget::populateSlaveGroupsTable()
|
||||
contact_ct->setValue(group.contactCount);
|
||||
ui->m_slave_groups_table->setCellWidget(i, 2, contact_ct);
|
||||
|
||||
// When the contact count changes, keep the terminal count in step
|
||||
// with it, otherwise the two drift apart (both are edited
|
||||
// independently): the stored terminals-per-contact ratio is kept,
|
||||
// or the contact type default (2, 3 for a switch) is used when the
|
||||
// stored values don't divide evenly (inconsistent legacy data).
|
||||
const int old_contacts = group.contactCount;
|
||||
const int old_terminals = group.terminalCount;
|
||||
connect(contact_ct, QOverload<int>::of(&QSpinBox::valueChanged),
|
||||
this, [this, i, old_contacts, old_terminals](int val) {
|
||||
if (i < m_data.m_slave_contact_groups.size()) {
|
||||
readSlaveGroupsFromTable();
|
||||
auto &group = m_data.m_slave_contact_groups[i];
|
||||
int per_pole = old_terminals / qMax(1, old_contacts);
|
||||
if (per_pole < 1 || old_terminals % qMax(1, old_contacts) != 0)
|
||||
per_pole = group.type == ElementData::SW ? 3 : 2;
|
||||
else if (group.type == ElementData::SW && per_pole < 3)
|
||||
per_pole = 3; //a switch needs common, NC and NO
|
||||
group.contactCount = val;
|
||||
group.terminalCount = val * per_pole;
|
||||
populateSlaveGroupsTable();
|
||||
}
|
||||
});
|
||||
|
||||
// Terminal count
|
||||
auto *terminal_ct = new QSpinBox(ui->m_slave_groups_table);
|
||||
terminal_ct->setMinimum(1);
|
||||
|
||||
@@ -22,8 +22,11 @@
|
||||
#include "qetapp.h"
|
||||
#include "qetgraphicsitem/dynamicelementtextitem.h"
|
||||
#include "qetgraphicsitem/elementtextitemgroup.h"
|
||||
#include "qetdiagrameditor.h"
|
||||
#include "textgrid.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QSettings>
|
||||
|
||||
/**
|
||||
@brief ElementTextsMover::ElementTextsMover
|
||||
@@ -80,6 +83,19 @@ int ElementTextsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_ite
|
||||
return -1;
|
||||
|
||||
m_movement_running = true;
|
||||
|
||||
m_status_bar.clear();
|
||||
if (!diagram->views().isEmpty())
|
||||
if (const auto qde = QETApp::diagramEditorAncestorOf(diagram->views().at(0)))
|
||||
m_status_bar = qde->statusBar();
|
||||
if (m_status_bar)
|
||||
{
|
||||
const qreal divisor = QSettings().value(TextGrid::settings_key, 1).toReal();
|
||||
m_status_bar->showMessage(divisor > 0
|
||||
? QObject::tr("Grille des textes %1. Relâcher Maj et maintenir Ctrl pour placer librement.")
|
||||
.arg(TextGrid::ratioLabel(divisor))
|
||||
: QObject::tr("Grille des textes désactivée."));
|
||||
}
|
||||
|
||||
return m_items_hash.size();
|
||||
}
|
||||
@@ -101,7 +117,7 @@ void ElementTextsMover::continueMovement(QGraphicsSceneMouseEvent *event)
|
||||
button_down_parent_pos = qgi->mapToParent(qgi->mapFromScene(event->buttonDownScenePos(Qt::LeftButton)));
|
||||
|
||||
QPointF new_pos = m_items_hash.value(qgi) + current_parent_pos - button_down_parent_pos;
|
||||
event->modifiers() == Qt::ControlModifier ? qgi->setPos(new_pos) : qgi->setPos(Diagram::snapToGrid(new_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? qgi->setPos(new_pos) : qgi->setPos(Diagram::snapToTextGrid(new_pos));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,13 +128,19 @@ void ElementTextsMover::continueMovement(QGraphicsSceneMouseEvent *event)
|
||||
void ElementTextsMover::endMovement()
|
||||
{
|
||||
//No movement or no items to move
|
||||
if (m_status_bar)
|
||||
m_status_bar->clearMessage();
|
||||
|
||||
if(!m_movement_running || m_items_hash.isEmpty())
|
||||
return;
|
||||
|
||||
//Movement is null
|
||||
QGraphicsItem *qgi = m_items_hash.keys().first();
|
||||
if(qgi->pos() == m_items_hash.value(qgi))
|
||||
{
|
||||
m_movement_running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
QUndoCommand *undo = new QUndoCommand(undoText());
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
#include <QSet>
|
||||
#include <QPointF>
|
||||
#include <QHash>
|
||||
#include <QPointer>
|
||||
#include <QStatusBar>
|
||||
|
||||
class QGraphicsItem;
|
||||
class DiagramTextItem;
|
||||
@@ -55,6 +57,7 @@ class ElementTextsMover
|
||||
QHash <DiagramTextItem *, QPointF> m_texts_hash;
|
||||
QHash <QGraphicsItemGroup *, QPointF> m_grps_hash;
|
||||
QHash <QGraphicsItem *, QPointF> m_items_hash;
|
||||
QPointer<QStatusBar> m_status_bar;
|
||||
int m_text_count = 0,
|
||||
m_group_count = 0;
|
||||
};
|
||||
|
||||
@@ -80,6 +80,11 @@ class PaletteGraphicsView : public QGraphicsView
|
||||
nothing by default.
|
||||
*/
|
||||
virtual void paintingInverted(bool inverted);
|
||||
/// @return the painter the scene really paints with: the
|
||||
/// off-screen image's while painting inverted, else \a painter.
|
||||
/// For subclasses that draw more in drawBackground().
|
||||
QPainter *scenePainter(QPainter *painter)
|
||||
{ return m_inverting ? &m_buffer_painter : painter; }
|
||||
|
||||
private:
|
||||
void paintInverted(QPaintEvent *event);
|
||||
|
||||
@@ -388,6 +388,29 @@ void convertUriToGoTo(const QString &pdfPath)
|
||||
out.close();
|
||||
}
|
||||
|
||||
void removeUnusedPdfxNamespace(const QString &pdfPath)
|
||||
{
|
||||
QFile f(pdfPath);
|
||||
if (!f.open(QIODevice::ReadOnly)) return;
|
||||
QByteArray data = f.readAll();
|
||||
f.close();
|
||||
|
||||
// Qt only fills the pdfxid namespace in when it writes PDF/X-4.
|
||||
if (data.contains("pdfxid:GTS_PDFXVersion")) return;
|
||||
|
||||
static const QByteArray decl =
|
||||
" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"";
|
||||
const int pos = data.indexOf(decl);
|
||||
if (pos == -1) return;
|
||||
|
||||
// Same length, so the stream /Length and the xref offsets stay valid.
|
||||
data.replace(pos, decl.size(), QByteArray(decl.size(), ' '));
|
||||
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return;
|
||||
f.write(data);
|
||||
f.close();
|
||||
}
|
||||
|
||||
void convertComponentInfoAnnotations(const QString &pdfPath,
|
||||
const QList<ComponentInfo> &annotations)
|
||||
{
|
||||
|
||||
@@ -74,6 +74,15 @@ namespace PdfLinks {
|
||||
*/
|
||||
void convertUriToGoTo(const QString &pdfPath);
|
||||
|
||||
/**
|
||||
Post-process a Qt-generated PDF file: blank out the PDF/X namespace
|
||||
declaration Qt 6 writes into the XMP metadata of every PDF. Adobe
|
||||
Acrobat draws small text too bold when it is present (bugtracker #340).
|
||||
Replaced in place with spaces, so no offset changes. No-op for a real
|
||||
PDF/X file or when the declaration is absent.
|
||||
*/
|
||||
void removeUnusedPdfxNamespace(const QString &pdfPath);
|
||||
|
||||
struct ComponentInfo {
|
||||
QString contents;
|
||||
};
|
||||
|
||||
@@ -874,6 +874,7 @@ void ProjectPrintWindow::print()
|
||||
// Convert URI link annotations into native internal GoTo/FitR
|
||||
// actions so cross-references jump inside the document.
|
||||
PdfLinks::convertUriToGoTo(pdfFile);
|
||||
PdfLinks::removeUnusedPdfxNamespace(pdfFile);
|
||||
|
||||
this->close();
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ XRefProperties::XRefProperties()
|
||||
{
|
||||
m_show_power_ctc = true;
|
||||
m_show_terminal_name = true;
|
||||
m_show_all_configured_slaves = false;
|
||||
m_display = Cross;
|
||||
m_snap_to = Bottom;
|
||||
m_prefix_keys << "power" << "delay" << "switch";
|
||||
@@ -51,6 +52,7 @@ void XRefProperties::toSettings(QSettings &settings,
|
||||
{
|
||||
settings.setValue(prefix % "showpowerctc", m_show_power_ctc);
|
||||
settings.setValue(prefix % "showterminalname", m_show_terminal_name);
|
||||
settings.setValue(prefix % "showallconfiguredslaves", m_show_all_configured_slaves);
|
||||
QString display = m_display == Cross? "cross" : "contacts";
|
||||
settings.setValue(prefix % "displayhas", display);
|
||||
QString snap = m_snap_to == Bottom? "bottom" : "label";
|
||||
@@ -84,6 +86,7 @@ void XRefProperties::fromSettings(const QSettings &settings,
|
||||
{
|
||||
m_show_power_ctc = settings.value(prefix % "showpowerctc", true).toBool();
|
||||
m_show_terminal_name = settings.value(prefix % "showterminalname", true).toBool();
|
||||
m_show_all_configured_slaves = settings.value(prefix % "showallconfiguredslaves", false).toBool();
|
||||
QString display = settings.value(prefix % "displayhas", "cross").toString();
|
||||
display == "cross"? m_display = Cross : m_display = Contacts;
|
||||
QString snap = settings.value(prefix % "snapto", "label").toString();
|
||||
@@ -115,6 +118,7 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const
|
||||
|
||||
xml_element.setAttribute("showpowerctc", m_show_power_ctc? "true" : "false");
|
||||
xml_element.setAttribute("showterminalname", m_show_terminal_name? "true" : "false");
|
||||
xml_element.setAttribute("showallconfiguredslaves", m_show_all_configured_slaves? "true" : "false");
|
||||
QString display = m_display == Cross? "cross" : "contacts";
|
||||
xml_element.setAttribute("displayhas", display);
|
||||
QString snap = m_snap_to == Bottom? "bottom" : "label";
|
||||
@@ -147,6 +151,7 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const
|
||||
bool XRefProperties::fromXml(const QDomElement &xml_element) {
|
||||
m_show_power_ctc = xml_element.attribute("showpowerctc") == "true";
|
||||
m_show_terminal_name = xml_element.attribute("showterminalname", "true") == "true";
|
||||
m_show_all_configured_slaves = xml_element.attribute("showallconfiguredslaves", "false") == "true";
|
||||
QString display = xml_element.attribute("displayhas", "cross");
|
||||
display == "cross"? m_display = Cross : m_display = Contacts;
|
||||
QString snap = xml_element.attribute("snapto", "label");
|
||||
@@ -200,6 +205,7 @@ QHash<QString, XRefProperties> XRefProperties::defaultProperties()
|
||||
bool XRefProperties::operator ==(const XRefProperties &xrp) const{
|
||||
return (m_show_power_ctc == xrp.m_show_power_ctc
|
||||
&& m_show_terminal_name == xrp.m_show_terminal_name
|
||||
&& m_show_all_configured_slaves == xrp.m_show_all_configured_slaves
|
||||
&& m_display == xrp.m_display
|
||||
&& m_snap_to == xrp.m_snap_to
|
||||
&& m_prefix == xrp.m_prefix
|
||||
|
||||
@@ -60,6 +60,9 @@ class XRefProperties : public PropertiesInterface
|
||||
void setShowTerminalName (const bool a) {m_show_terminal_name = a;}
|
||||
bool showTerminalName () const {return m_show_terminal_name;}
|
||||
|
||||
void setShowAllConfiguredSlaves (const bool a) {m_show_all_configured_slaves = a;}
|
||||
bool showAllConfiguredSlaves () const {return m_show_all_configured_slaves;}
|
||||
|
||||
void setDisplayHas (const DisplayHas dh) {m_display = dh;}
|
||||
DisplayHas displayHas () const {return m_display;}
|
||||
|
||||
@@ -88,6 +91,7 @@ class XRefProperties : public PropertiesInterface
|
||||
private:
|
||||
bool m_show_power_ctc;
|
||||
bool m_show_terminal_name;
|
||||
bool m_show_all_configured_slaves;
|
||||
DisplayHas m_display;
|
||||
SnapTo m_snap_to;
|
||||
Qt::AlignmentFlag m_xref_pos;
|
||||
|
||||
@@ -2231,6 +2231,7 @@ void QETApp::configureQET()
|
||||
// affiche le dialogue puis evite de le lier a un quelconque widget parent
|
||||
cd.exec();
|
||||
cd.setParent(nullptr, cd.windowFlags());
|
||||
emit textGridChanged();
|
||||
|
||||
#ifdef QET_SPACEMOUSE_SUPPORT
|
||||
if (m_space_mouse_listener) {
|
||||
|
||||
@@ -260,6 +260,10 @@ class QETApp : public QObject
|
||||
|
||||
static QString m_interface_language;
|
||||
|
||||
signals:
|
||||
/// The text grid setting changed, see TextGrid.
|
||||
void textGridChanged();
|
||||
|
||||
public slots:
|
||||
void systray(QSystemTrayIcon::ActivationReason);
|
||||
void reduceEveryEditor();
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
#include "scripting/qetscripting.h"
|
||||
#endif
|
||||
#include <QCoreApplication>
|
||||
#include <QToolButton>
|
||||
#include "ElementsCollection/elementscollectionwidget.h"
|
||||
#include "commandsearchpopup.h"
|
||||
#include "QWidgetAnimation/qwidgetanimation.h"
|
||||
#include "autoNum/ui/autonumberingdockwidget.h"
|
||||
#include "conductornumexport.h"
|
||||
@@ -48,6 +50,7 @@
|
||||
#include "qeticons.h"
|
||||
#include "qetmessagebox.h"
|
||||
#include "recentfiles.h"
|
||||
#include "textgrid.h"
|
||||
#include "shortcutmanager.h"
|
||||
#include "ui/bomexportdialog.h"
|
||||
#include "ui/conductorcolortoolbutton.h"
|
||||
@@ -367,10 +370,18 @@ void QETDiagramEditor::setUpActions()
|
||||
//original, where it was easy to miss entirely; now it appears
|
||||
//under the cursor and follows it until a click, Return, or Escape
|
||||
//to cancel -- the same interaction as placing a new element.
|
||||
const QPoint view_pos = dv->viewport()->mapFromGlobal(QCursor::pos());
|
||||
const QPointF start_pos = dv->viewport()->rect().contains(view_pos)
|
||||
? dv->mapToScene(view_pos)
|
||||
: dv->mapToScene(dv->viewport()->rect().center());
|
||||
//
|
||||
//dv->lastMousePos() (an ordinary Qt mouse-move position), not
|
||||
//QCursor::pos() (a global, OS-level cursor query): several
|
||||
//window managers and compositors -- Wayland in particular --
|
||||
//silently refuse that query, returning a stale or wrong
|
||||
//position, which is exactly what made the pasted content land
|
||||
//far from the cursor instead of under it.
|
||||
const QPoint last_pos = dv->lastMousePos();
|
||||
const QPoint view_pos = (last_pos.x() >= 0 && dv->viewport()->rect().contains(last_pos))
|
||||
? last_pos
|
||||
: dv->viewport()->rect().center();
|
||||
const QPointF start_pos = dv->mapToScene(view_pos);
|
||||
|
||||
dv->diagram()->setEventInterface(
|
||||
new DiagramEventAddPaste(dv->diagram(), start_pos));
|
||||
@@ -473,6 +484,33 @@ void QETDiagramEditor::setUpActions()
|
||||
}
|
||||
});
|
||||
|
||||
//Snap step for dragged texts, as a fraction of the folio grid
|
||||
m_text_grid_menu = new QMenu(tr("Grille des textes"), this);
|
||||
m_text_grid_menu->setIcon(QET::Icons::Grid);
|
||||
m_text_grid_menu->setToolTipsVisible(true);
|
||||
m_text_grid_button = new QToolButton(this);
|
||||
m_text_grid_button->setMenu(m_text_grid_menu);
|
||||
m_text_grid_button->setPopupMode(QToolButton::InstantPopup);
|
||||
m_text_grid_button->setToolButtonStyle(Qt::ToolButtonTextOnly);
|
||||
m_text_grid_button->setToolTip(tr("Grille d'accrochage des textes déplacés à la souris.\n"
|
||||
"Maintenir Ctrl pendant le déplacement pour placer librement."));
|
||||
auto text_grid_group = new QActionGroup(this);
|
||||
for (const qreal divisor : TextGrid::divisors)
|
||||
{
|
||||
QAction *action = m_text_grid_menu->addAction(
|
||||
divisor > 0 ? TextGrid::ratioLabel(divisor) : tr("Désactivée"));
|
||||
action->setCheckable(true);
|
||||
action->setData(divisor);
|
||||
text_grid_group->addAction(action);
|
||||
}
|
||||
connect(text_grid_group, &QActionGroup::triggered, this, [](QAction *action) {
|
||||
QSettings().setValue(TextGrid::settings_key, action->data());
|
||||
emit QETApp::instance()->textGridChanged();
|
||||
});
|
||||
connect(QETApp::instance(), &QETApp::textGridChanged,
|
||||
this, &QETDiagramEditor::updateTextGridButton);
|
||||
updateTextGridButton();
|
||||
|
||||
// Draw or not the custom guides
|
||||
m_draw_guides = new QAction ( QIcon::fromTheme("guides"), tr("Afficher les guides"), this);
|
||||
m_draw_guides->setStatusTip(tr("Affiche ou masque les guides"));
|
||||
@@ -485,6 +523,30 @@ void QETDiagramEditor::setUpActions()
|
||||
}
|
||||
});
|
||||
|
||||
//Keep the column numbers and row letters of the folio in sight
|
||||
m_cell_rulers = new QAction(tr("Garder les en-têtes visibles"), this);
|
||||
m_cell_rulers->setStatusTip(tr("Garde les numéros de colonne et les lettres de ligne du folio visibles au bord de la vue"));
|
||||
m_cell_rulers->setCheckable(true);
|
||||
m_cell_rulers->setChecked(settings.value("diagrameditor/cell_rulers", false).toBool());
|
||||
connect(m_cell_rulers, &QAction::triggered, [this](bool checked) {
|
||||
QSettings().setValue("diagrameditor/cell_rulers", checked);
|
||||
foreach (ProjectView *prjv, this->openedProjects())
|
||||
foreach (DiagramView *dv, prjv->diagram_views())
|
||||
dv->setCellRulersShown(checked);
|
||||
});
|
||||
|
||||
//Draw the limits of the folio columns and rows across the drawing
|
||||
m_cell_lines = new QAction(tr("Afficher les limites des cases"), this);
|
||||
m_cell_lines->setStatusTip(tr("Trace les limites des colonnes et des lignes du folio sur le schéma, à l'écran seulement"));
|
||||
m_cell_lines->setCheckable(true);
|
||||
m_cell_lines->setChecked(settings.value("diagrameditor/cell_lines", false).toBool());
|
||||
connect(m_cell_lines, &QAction::triggered, [this](bool checked) {
|
||||
QSettings().setValue("diagrameditor/cell_lines", checked);
|
||||
foreach (ProjectView *prjv, this->openedProjects())
|
||||
foreach (DiagramView *dv, prjv->diagram_views())
|
||||
dv->setCellLinesShown(checked);
|
||||
});
|
||||
|
||||
//Edit current diagram properties
|
||||
m_edit_diagram_properties = new QAction(QET::Icons::DialogInformation, tr("Propriétés du folio"), this);
|
||||
ShortcutManager::instance().registerAction(m_edit_diagram_properties, "diagrameditor.edit_diagram_properties", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_L);
|
||||
@@ -747,6 +809,27 @@ void QETDiagramEditor::setUpActions()
|
||||
ShortcutManager::instance().registerAction(m_rotate_texts, "diagrameditor.rotate_texts", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_Space);
|
||||
ShortcutManager::instance().registerAction(m_edit_selection, "diagrameditor.edit_selection", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_E);
|
||||
|
||||
//Type to find and run any command, as SolidWorks' "Search Commands"
|
||||
//and the command palette of many editors. Ctrl+Shift+P, the key those
|
||||
//editors use, is taken by the autonumbering dock; M for "menu".
|
||||
m_command_search = new QAction(tr("Rechercher une commande…"), this);
|
||||
m_command_search->setStatusTip(
|
||||
tr("Tapez une partie du nom d'une commande et appuyez sur Entrée pour la lancer",
|
||||
"status bar tip"));
|
||||
ShortcutManager::instance().registerAction(
|
||||
m_command_search, "diagrameditor.command_search",
|
||||
tr("Éditeur de schémas"), Qt::CTRL | Qt::SHIFT | Qt::Key_M);
|
||||
connect(m_command_search, &QAction::triggered, this, [this]() {
|
||||
if (!m_command_search_popup) {
|
||||
m_command_search_popup = new CommandSearchPopup(this);
|
||||
}
|
||||
const QRect area = geometry();
|
||||
m_command_search_popup->popUpAt(
|
||||
area.contains(QCursor::pos()) ? QCursor::pos()
|
||||
: area.center());
|
||||
});
|
||||
addAction(m_command_search);
|
||||
|
||||
m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip"));
|
||||
m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip"));
|
||||
m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip"));
|
||||
@@ -878,6 +961,13 @@ void QETDiagramEditor::setUpActions()
|
||||
add_path->setCheckable(true);
|
||||
|
||||
connect(&m_add_item_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::addItemGroupTriggered);
|
||||
//No default key, but an id: they can then be found by the command
|
||||
//search and bound in the Shortcuts page, like every other command.
|
||||
for (QAction *action : m_add_item_actions_group.actions()) {
|
||||
ShortcutManager::instance().registerAction(
|
||||
action, "diagrameditor.add_" + action->data().toString(),
|
||||
tr("Éditeur de schémas"), QKeySequence());
|
||||
}
|
||||
|
||||
//Depth action
|
||||
m_depth_action_group = QET::depthActionGroup(this);
|
||||
@@ -948,6 +1038,7 @@ void QETDiagramEditor::setUpToolBar()
|
||||
view_tool_bar -> addWidget(new DiagramEditorHandlerSizeWidget(this));
|
||||
view_tool_bar -> addSeparator();
|
||||
view_tool_bar -> addAction(m_draw_grid);
|
||||
view_tool_bar -> addWidget(m_text_grid_button);
|
||||
view_tool_bar -> addAction(m_draw_guides);
|
||||
view_tool_bar -> addWidget(m_background_color_button);
|
||||
view_tool_bar -> addSeparator();
|
||||
@@ -1030,15 +1121,16 @@ void QETDiagramEditor::setUpMenu()
|
||||
menu_edition -> addAction(m_paste);
|
||||
menu_edition -> addAction(m_duplicate);
|
||||
menu_edition -> addAction(m_configure_duplicate);
|
||||
menu_edition -> addAction(m_command_search);
|
||||
menu_edition -> addSeparator();
|
||||
//The same actions the "Ajouter" toolbar holds. They were toolbar-only,
|
||||
//which left them unreachable for anyone working without a mouse: a
|
||||
//toolbar button has no key, so text fields, images and every drawing
|
||||
//shape simply could not be added. m_depth_action_group below has
|
||||
//always been in both places; this brings these into line with it.
|
||||
QMenu *menu_add_item = menu_edition -> addMenu(tr("A&jouter"));
|
||||
menu_add_item -> setIcon(QET::Icons::Add);
|
||||
menu_add_item -> addActions(m_add_item_actions_group.actions());
|
||||
m_add_item_menu = menu_edition -> addMenu(tr("A&jouter"));
|
||||
m_add_item_menu -> setIcon(QET::Icons::Add);
|
||||
m_add_item_menu -> addActions(m_add_item_actions_group.actions());
|
||||
menu_edition -> addSeparator();
|
||||
menu_edition -> addActions(m_select_actions_group.actions());
|
||||
menu_edition -> addSeparator();
|
||||
@@ -1048,6 +1140,12 @@ void QETDiagramEditor::setUpMenu()
|
||||
menu_edition -> addSeparator();
|
||||
menu_edition -> addAction(m_edit_diagram_properties);
|
||||
menu_edition -> addActions(m_row_column_actions_group.actions());
|
||||
//Not added to a menu here: it exists so the folio's context menu can
|
||||
//hold the row and column actions one level down (see
|
||||
//DiagramView::contextMenuActions()).
|
||||
m_row_column_menu = new QMenu(tr("Lignes et colonnes"), this);
|
||||
m_row_column_menu -> setIcon(QET::Icons::EditTableInsertColumnRight);
|
||||
m_row_column_menu -> addActions(m_row_column_actions_group.actions());
|
||||
menu_edition -> addSeparator();
|
||||
menu_edition -> addActions(m_depth_action_group->actions());
|
||||
menu_edition -> addSeparator();
|
||||
@@ -1101,7 +1199,10 @@ void QETDiagramEditor::setUpMenu()
|
||||
menu_affichage -> addAction(m_mode_visualise);
|
||||
menu_affichage -> addSeparator();
|
||||
menu_affichage -> addAction(m_draw_grid);
|
||||
menu_affichage -> addMenu(m_text_grid_menu);
|
||||
menu_affichage -> addAction(m_draw_guides);
|
||||
menu_affichage -> addAction(m_cell_rulers);
|
||||
menu_affichage -> addAction(m_cell_lines);
|
||||
menu_affichage -> addMenu(m_background_color_button->menu());
|
||||
menu_affichage -> addSeparator();
|
||||
menu_affichage -> addActions(m_zoom_actions_group.actions());
|
||||
@@ -1933,6 +2034,8 @@ void QETDiagramEditor::slot_updateActions()
|
||||
m_background_color_button-> setEnabled(opened_diagram);
|
||||
m_draw_grid-> setEnabled(opened_diagram);
|
||||
m_draw_guides-> setEnabled(opened_diagram);
|
||||
m_cell_rulers-> setEnabled(opened_diagram);
|
||||
m_cell_lines-> setEnabled(opened_diagram);
|
||||
|
||||
//Project menu
|
||||
m_project_edit_properties -> setEnabled(opened_project);
|
||||
@@ -2330,6 +2433,8 @@ void QETDiagramEditor::openBackupFiles(QList<KAutoSaveFile *> backup_files)
|
||||
//Create the project
|
||||
DialogWaiting::instance(this);
|
||||
|
||||
//QETProject takes ownership of file and deletes it, whether or not it opens
|
||||
const QString file_name = file->managedFile().fileName();
|
||||
QETProject *project = new QETProject(file, this);
|
||||
if (project->state() != QETProject::Ok)
|
||||
{
|
||||
@@ -2340,7 +2445,7 @@ void QETDiagramEditor::openBackupFiles(QList<KAutoSaveFile *> backup_files)
|
||||
tr("Échec de l'ouverture du projet", "message box title"),
|
||||
QString(tr(
|
||||
"Une erreur est survenue lors de l'ouverture du fichier %1.",
|
||||
"message box content")).arg(file->managedFile().fileName()));
|
||||
"message box content")).arg(file_name));
|
||||
}
|
||||
delete project;
|
||||
DialogWaiting::dropInstance();
|
||||
@@ -3214,3 +3319,23 @@ void QETDiagramEditor::slot_runScript() {
|
||||
QetScripting::runOnProject(script_path, project, currentDiagramView());
|
||||
}
|
||||
#endif
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::updateTextGridButton
|
||||
Show the current text grid on its toolbar button and check it in its menu.
|
||||
*/
|
||||
void QETDiagramEditor::updateTextGridButton()
|
||||
{
|
||||
const qreal divisor = QSettings().value(TextGrid::settings_key, 1).toReal();
|
||||
for (QAction *action : m_text_grid_menu->actions())
|
||||
{
|
||||
if (qFuzzyCompare(action->data().toReal() + 1, divisor + 1))
|
||||
{
|
||||
action->setChecked(true);
|
||||
m_text_grid_button->setText(tr("Textes %1").arg(action->text()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
//A divisor the menu does not offer, set by hand in the config file
|
||||
m_text_grid_button->setText(tr("Textes %1").arg(TextGrid::ratioLabel(divisor)));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
#include <QSignalMapper>
|
||||
#include <QUndoGroup>
|
||||
|
||||
class QToolButton;
|
||||
|
||||
class QMdiSubWindow;
|
||||
class QETProject;
|
||||
class QETResult;
|
||||
@@ -43,6 +45,7 @@ class ElementsLocation;
|
||||
class RecentFiles;
|
||||
class DiagramPropertiesEditorDockWidget;
|
||||
class ElementsCollectionWidget;
|
||||
class CommandSearchPopup;
|
||||
class AutoNumberingDockWidget;
|
||||
class TerminalNumberingDialog;
|
||||
|
||||
@@ -164,6 +167,7 @@ class QETDiagramEditor : public QETMainWindow
|
||||
void subWindowActivated(QMdiSubWindow *subWindows);
|
||||
|
||||
private slots:
|
||||
void updateTextGridButton();
|
||||
void selectionChanged();
|
||||
|
||||
public:
|
||||
@@ -177,6 +181,10 @@ class QETDiagramEditor : public QETMainWindow
|
||||
m_row_column_actions_group, /// Action related to add/remove rows/column in diagram
|
||||
m_selection_actions_group, ///Action related to edit a selected item
|
||||
*m_depth_action_group = nullptr;
|
||||
|
||||
QMenu
|
||||
*m_add_item_menu = nullptr, ///< Submenu of m_add_item_actions_group
|
||||
*m_row_column_menu = nullptr; ///< Submenu of m_row_column_actions_group
|
||||
|
||||
private:
|
||||
QActionGroup
|
||||
@@ -205,6 +213,8 @@ class QETDiagramEditor : public QETMainWindow
|
||||
*m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor
|
||||
*m_draw_grid, ///< Switch the background grid display or not
|
||||
*m_draw_guides = nullptr, ///< Switch the custom guides display or not
|
||||
*m_cell_rulers = nullptr, ///< Keep the folio column/row headers in sight or not
|
||||
*m_cell_lines = nullptr, ///< Draw the folio column/row limits across the drawing or not
|
||||
*m_project_edit_properties, ///< Edit the properties of the current project.
|
||||
*m_project_add_diagram, ///< Add a diagram to the current project.
|
||||
*m_remove_diagram_from_project, ///< Delete a diagram from the current project
|
||||
@@ -244,6 +254,8 @@ class QETDiagramEditor : public QETMainWindow
|
||||
ConductorColorToolButton *m_conductor_color_button = nullptr;
|
||||
///< Diagram background color picker, in the "Affichage" toolbar
|
||||
DiagramBgColorToolButton *m_background_color_button = nullptr;
|
||||
QMenu *m_text_grid_menu = nullptr; ///< Snap step used when dragging texts
|
||||
QToolButton *m_text_grid_button = nullptr;
|
||||
|
||||
QList <QAction *> m_zoom_action_toolBar; ///Only zoom action must displayed in the toolbar
|
||||
|
||||
@@ -258,6 +270,8 @@ class QETDiagramEditor : public QETMainWindow
|
||||
*m_qdw_elmt_collection,
|
||||
*qdw_undo; /// Dock for the undo list
|
||||
|
||||
QAction *m_command_search = nullptr;
|
||||
CommandSearchPopup *m_command_search_popup = nullptr; ///< Built on first use
|
||||
ElementsCollectionWidget *m_element_collection_widget;
|
||||
|
||||
DiagramPropertiesEditorDockWidget *m_selection_properties_editor;
|
||||
|
||||
@@ -185,7 +185,7 @@ void ConductorTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
|
||||
|
||||
if (parent_conductor_) {
|
||||
if (parent_conductor_->nearShape().contains(intended_pos)) {
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(intended_pos) : setPos(Diagram::snapToGrid(intended_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(intended_pos) : setPos(Diagram::snapToTextGrid(intended_pos));
|
||||
parent_conductor_ -> setHighlighted(Conductor::Normal);
|
||||
} else {
|
||||
parent_conductor_ -> setHighlighted(Conductor::Alert);
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
#include "terminal.h"
|
||||
#include "../properties/elementdata.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
//define the height of the header.
|
||||
static int header = 5;
|
||||
//define the minimal height of the cross (without header)
|
||||
@@ -194,6 +196,37 @@ QString CrossRefItem::elementPositionText(
|
||||
return txt;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::showAllConfiguredSlaves
|
||||
@param elmt : the element displaying the cross reference
|
||||
@param xrp : xref properties of that element
|
||||
@return true when the contact comb must show every slave contact the
|
||||
master defines, even those no slave is linked to yet. That is the case
|
||||
when the user asked for it, when the comb (contacts) display is the
|
||||
one in use, and when the master really declares contact groups --
|
||||
an element which declares none behaves exactly as before.
|
||||
*/
|
||||
bool CrossRefItem::showAllConfiguredSlaves(
|
||||
const Element *elmt,
|
||||
const XRefProperties &xrp)
|
||||
{
|
||||
if (!elmt) return false;
|
||||
if (!xrp.showAllConfiguredSlaves()) return false;
|
||||
if (xrp.displayHas() != XRefProperties::Contacts) return false;
|
||||
|
||||
return !elmt->elementData().m_slave_contact_groups.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::mustDrawAllConfiguredSlaves
|
||||
@return showAllConfiguredSlaves for the element of this item and the
|
||||
current properties.
|
||||
*/
|
||||
bool CrossRefItem::mustDrawAllConfiguredSlaves() const
|
||||
{
|
||||
return showAllConfiguredSlaves(m_element, m_properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::updateProperties
|
||||
update the current properties
|
||||
@@ -256,8 +289,11 @@ void CrossRefItem::updateLabel()
|
||||
QTimer::singleShot(0, this, [this]{ update(); });
|
||||
return;
|
||||
}
|
||||
//Draw cross or contact, only if master element is linked.
|
||||
else if (! m_element->linkedElements().isEmpty())
|
||||
//Draw cross or contact, if master element is linked, or if the user
|
||||
//asks for the contact comb to show the contact groups of the master
|
||||
//even before they get a slave.
|
||||
else if (! m_element->linkedElements().isEmpty()
|
||||
|| mustDrawAllConfiguredSlaves())
|
||||
{
|
||||
m_update_map = true;
|
||||
XRefProperties::DisplayHas dh = m_properties.displayHas();
|
||||
@@ -358,7 +394,8 @@ void CrossRefItem::paint(
|
||||
if (m_element->elementData().m_master_type == ElementData::PLC)
|
||||
return;
|
||||
|
||||
if (m_element->linkedElements().isEmpty()) return;
|
||||
if (m_element->linkedElements().isEmpty()
|
||||
&& !mustDrawAllConfiguredSlaves()) return;
|
||||
|
||||
QPen pen_;
|
||||
pen_.setWidthF(0.5);
|
||||
@@ -727,6 +764,41 @@ void CrossRefItem::drawAsCross(QPainter &painter)
|
||||
fillCrossRef(painter);
|
||||
}
|
||||
|
||||
namespace {
|
||||
/**
|
||||
@brief contactOption
|
||||
Map the contact group declared by a master onto the CONTACTS flags
|
||||
used by CrossRefItem::drawContact, so a group no slave is linked to
|
||||
yet is drawn like the slave it waits for.
|
||||
@param group : the contact group of the master
|
||||
@return the flags describing the contact to draw
|
||||
*/
|
||||
int contactOption(const ElementData::SlaveContactGroup &group)
|
||||
{
|
||||
int option = 0;
|
||||
|
||||
switch (group.type)
|
||||
{
|
||||
case ElementData::NO: option = CrossRefItem::NO; break;
|
||||
case ElementData::NC: option = CrossRefItem::NC; break;
|
||||
case ElementData::SW: option = CrossRefItem::SW; break;
|
||||
case ElementData::Other: option = CrossRefItem::Other; break;
|
||||
}
|
||||
|
||||
switch (group.subtype)
|
||||
{
|
||||
case ElementData::Power: option += CrossRefItem::Power; break;
|
||||
case ElementData::DelayOn: option += CrossRefItem::DelayOn; break;
|
||||
case ElementData::DelayOff: option += CrossRefItem::DelayOff; break;
|
||||
case ElementData::delayOnOff: option += CrossRefItem::DelayOnOff; break;
|
||||
case ElementData::SSimple:
|
||||
case ElementData::PLCSlave: break;
|
||||
}
|
||||
|
||||
return option;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::drawAsContacts
|
||||
Draw this crossref with symbolic contacts
|
||||
@@ -734,37 +806,99 @@ void CrossRefItem::drawAsCross(QPainter &painter)
|
||||
*/
|
||||
void CrossRefItem::drawAsContacts(QPainter &painter)
|
||||
{
|
||||
if (m_element -> isFree())
|
||||
if (m_element -> isFree() && !mustDrawAllConfiguredSlaves())
|
||||
return;
|
||||
|
||||
m_drawed_contacts = 0;
|
||||
if (m_update_map) m_hovered_contacts_map.clear();
|
||||
QRectF bounding_rect;
|
||||
|
||||
//Draw each linked contact
|
||||
foreach (Element *elmt, m_element->linkedElements())
|
||||
//Draw every contact group of the master, in the order the master
|
||||
//defines them, when the user asked for it and the master declares
|
||||
//contact groups. Otherwise the comb keeps its historical behavior:
|
||||
//linked slaves only, in position order.
|
||||
if (mustDrawAllConfiguredSlaves())
|
||||
{
|
||||
DiagramContext info = elmt->kindInformations();
|
||||
const QVector<ElementData::SlaveContactGroup> groups =
|
||||
m_element->elementData().m_slave_contact_groups;
|
||||
|
||||
for (int i=0; i<info["number"].toInt(); i++)
|
||||
//A contact group waits for exactly one slave: a slave assigned to
|
||||
//a group is drawn where the master puts it, whatever its position
|
||||
//on the diagram.
|
||||
QHash<int, Element *> slotted;
|
||||
QList<Element *> unassigned;
|
||||
foreach (Element *elmt, m_element->linkedElements()) //position order
|
||||
{
|
||||
int option = 0;
|
||||
|
||||
QString state = info["state"].toString();
|
||||
if (state == "NO") option = NO;
|
||||
else if (state == "NC") option = NC;
|
||||
else if (state == "SW") option = SW;
|
||||
else if (state == "Other") option = Other;
|
||||
|
||||
QString type = info["type"].toString();
|
||||
if (type == "power") option += Power;
|
||||
else if (type == "delayOn") option += DelayOn;
|
||||
else if (type == "delayOff") option += DelayOff;
|
||||
else if (type == "delayOnOff") option += DelayOnOff;
|
||||
|
||||
QRectF br = drawContact(painter, option, elmt, i);
|
||||
bounding_rect = bounding_rect.united(br);
|
||||
const int index = m_element->groupIndexForElement(elmt);
|
||||
if (index >= 0 && index < groups.size() && !slotted.contains(index))
|
||||
slotted.insert(index, elmt);
|
||||
else
|
||||
unassigned << elmt;
|
||||
}
|
||||
|
||||
for (int i = 0; i < groups.size(); ++i)
|
||||
{
|
||||
if (Element *slave = slotted.value(i, nullptr))
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawLinkedSlaveContacts(painter, slave));
|
||||
else
|
||||
{
|
||||
//No slave is linked to this group yet: the contact
|
||||
//symbol of the group is drawn with the terminal names
|
||||
//the master defines for it, there is no cross reference
|
||||
//to show for it.
|
||||
const int option = contactOption(groups.at(i));
|
||||
const int poles = qMax(1, groups.at(i).contactCount);
|
||||
QStringList labels = groups.at(i).labels;
|
||||
|
||||
//A single pole simple contact (NO or NC) reads its two
|
||||
//numbers the other way round (checked against the
|
||||
//diagram). Changeover contacts are not handled here:
|
||||
//their labels are mapped to the right contact half in
|
||||
//drawContact(), per pole, so multi pole changeovers work
|
||||
//too. Groups with several NO/NC poles keep the order the
|
||||
//master defines: a 3 pole power contact already reads
|
||||
//correctly that way.
|
||||
if (poles == 1 && (option & NOC))
|
||||
std::reverse(labels.begin(), labels.end());
|
||||
|
||||
//The declared terminals are distributed over the declared
|
||||
//poles. terminalCount and contactCount are edited
|
||||
//independently in the element editor, so the list can be
|
||||
//shorter than two entries per pole (three for a switch):
|
||||
//every pole then gets its share of what exists, instead
|
||||
//of a fixed 2/3 stride starving all but the first poles.
|
||||
const int per_pole = labels.size() / poles;
|
||||
const int extra = labels.size() % poles;
|
||||
int begin = 0;
|
||||
for (int pole = 0; pole < poles; ++pole)
|
||||
{
|
||||
const int count = per_pole + (pole < extra ? 1 : 0);
|
||||
const QStringList pole_labels = labels.mid(begin, count);
|
||||
begin += count;
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawContact(painter,
|
||||
option,
|
||||
nullptr,
|
||||
pole,
|
||||
pole_labels));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Slaves the master doesn't assign to one of its groups (a link
|
||||
//made before the master declared groups, for example) keep their
|
||||
//usual place: the end of the comb, in position order.
|
||||
foreach (Element *elmt, unassigned)
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawLinkedSlaveContacts(painter, elmt));
|
||||
}
|
||||
else
|
||||
{
|
||||
//Draw each linked contact, in position order
|
||||
foreach (Element *elmt, m_element->linkedElements())
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawLinkedSlaveContacts(painter, elmt));
|
||||
}
|
||||
|
||||
bounding_rect.adjust(-30, -4, 4, 4);
|
||||
@@ -773,17 +907,61 @@ void CrossRefItem::drawAsContacts(QPainter &painter)
|
||||
m_shape_path.addRect(bounding_rect);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::drawLinkedSlaveContacts
|
||||
Draw the contact symbols of one slave linked to this master.
|
||||
@param painter : painter to use
|
||||
@param elmt : the slave element to draw
|
||||
@return the bounding rect of the draw
|
||||
*/
|
||||
QRectF CrossRefItem::drawLinkedSlaveContacts(QPainter &painter, Element *elmt)
|
||||
{
|
||||
QRectF bounding_rect;
|
||||
DiagramContext info = elmt->kindInformations();
|
||||
|
||||
for (int i=0; i<info["number"].toInt(); i++)
|
||||
{
|
||||
int option = 0;
|
||||
|
||||
QString state = info["state"].toString();
|
||||
if (state == "NO") option = NO;
|
||||
else if (state == "NC") option = NC;
|
||||
else if (state == "SW") option = SW;
|
||||
else if (state == "Other") option = Other;
|
||||
|
||||
QString type = info["type"].toString();
|
||||
if (type == "power") option += Power;
|
||||
else if (type == "delayOn") option += DelayOn;
|
||||
else if (type == "delayOff") option += DelayOff;
|
||||
else if (type == "delayOnOff") option += DelayOnOff;
|
||||
|
||||
bounding_rect = bounding_rect.united(
|
||||
drawContact(painter, option, elmt, i));
|
||||
}
|
||||
|
||||
return bounding_rect;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief CrossRefItem::drawContact
|
||||
Draw one contact, the type of contact to draw is define in flags.
|
||||
@param painter : painter to use
|
||||
@param flags : define how to draw the contact (see enul CONTACTS)
|
||||
@param elmt : the element to display text (the position of the contact)
|
||||
@param elmt : the element to display text (the position of the contact).
|
||||
It may be nullptr when the contact comes from a contact group the master
|
||||
defines but no slave is linked to yet: no position text, no hover/click
|
||||
support, and the terminal names then come from master_labels.
|
||||
@param pole_index : which contact of the group is drawn (0 based), used
|
||||
to pick the right pair of terminal names of a linked multi-pole contact.
|
||||
@param master_labels : the terminal names the master declares for this
|
||||
pole (sliced from ElementData::SlaveContactGroup::labels by the caller),
|
||||
used when elmt is nullptr so an empty slot shows the numbers the master
|
||||
declares, the same way a linked slave would show them.
|
||||
@return The bounding rect of the draw (contact + text)
|
||||
*/
|
||||
QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, int pole_index)
|
||||
QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, int pole_index, const QStringList &master_labels)
|
||||
{
|
||||
QString str = elementPositionText(elmt);
|
||||
QString str = elmt ? elementPositionText(elmt) : QString();
|
||||
|
||||
// Collect terminal names from the element definition (.elmt)
|
||||
// e.g. name="13" and name="14" on each terminal
|
||||
@@ -791,12 +969,12 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
// For SW contacts with typed terminals (No/Nc/Common), filter by role.
|
||||
QStringList terminal_names;
|
||||
const bool is_power_ctc =
|
||||
elmt->kindInformations()["type"].toString() == "power";
|
||||
elmt && elmt->kindInformations()["type"].toString() == "power";
|
||||
const bool is_sw = (flags & SW) && !(flags & NOC);
|
||||
|
||||
// Check if SW terminals have explicit No/Nc/Common types
|
||||
bool sw_has_typed_terminals = false;
|
||||
if (is_sw) {
|
||||
if (is_sw && elmt) {
|
||||
for (Terminal *t : elmt->terminals()) {
|
||||
if (!t) continue;
|
||||
if (t->terminalType() == TerminalData::No ||
|
||||
@@ -808,11 +986,34 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
}
|
||||
}
|
||||
|
||||
for (Terminal *t : elmt->terminals()) {
|
||||
if (!t) continue;
|
||||
const QString tname = t->name();
|
||||
if (!tname.isEmpty())
|
||||
terminal_names << tname;
|
||||
if (elmt) {
|
||||
for (Terminal *t : elmt->terminals()) {
|
||||
if (!t) continue;
|
||||
const QString tname = t->name();
|
||||
if (!tname.isEmpty())
|
||||
terminal_names << tname;
|
||||
}
|
||||
} else if (!master_labels.isEmpty()) {
|
||||
//Empty slot of the contact comb: the slave is missing but the
|
||||
//master already declares the terminal names, so the slot shows
|
||||
//them instead of staying mute. master_labels contains exactly
|
||||
//the terminals of this pole (the caller slices the declared
|
||||
//terminal list over the declared poles), in the order a linked
|
||||
//slave would receive them.
|
||||
if (is_sw) {
|
||||
//The labels are stored in terminal order (for a typical
|
||||
//changeover contact: common, NC, NO, i.e. 11, 12, 14), while
|
||||
//the symbol draws NC bottom-left, NO top-left and the common
|
||||
//on the right: every stored entry goes to its own position.
|
||||
//Entries the master doesn't declare (terminal count lower
|
||||
//than three) simply stay empty instead of landing on the
|
||||
//wrong contact half like the raw stored order would.
|
||||
terminal_names << master_labels.value(1)
|
||||
<< master_labels.value(2)
|
||||
<< master_labels.value(0);
|
||||
} else {
|
||||
terminal_names = master_labels;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_power_ctc) {
|
||||
@@ -860,7 +1061,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
QRectF bounding_rect = QRectF(0, offset, 24, 10);
|
||||
|
||||
QPen pen = painter.pen();
|
||||
m_hovered_contact == elmt ? pen.setColor(Qt::blue) :pen.setColor(Qt::black);
|
||||
elmt && m_hovered_contact == elmt ? pen.setColor(Qt::blue) :pen.setColor(Qt::black);
|
||||
painter.setPen(pen);
|
||||
|
||||
//Draw NO or NC contact
|
||||
@@ -956,11 +1157,18 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
}
|
||||
}
|
||||
|
||||
//The hit rect is registered even when the position text is
|
||||
//empty: a linked contact had (and keeps) its hover/click entry
|
||||
//in that case too, only the drawing is skipped. Free slots
|
||||
//(elmt == nullptr) have nothing to click and stay out of the map.
|
||||
QRectF text_rect = painter.boundingRect(QRectF(30, offset, 5, 10), Qt::AlignLeft | Qt::AlignVCenter, str);
|
||||
painter.drawText(text_rect, Qt::AlignLeft | Qt::AlignVCenter, str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
if (!str.isEmpty())
|
||||
{
|
||||
painter.drawText(text_rect, Qt::AlignLeft | Qt::AlignVCenter, str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
}
|
||||
|
||||
if (m_update_map)
|
||||
if (m_update_map && elmt)
|
||||
m_hovered_contacts_map.insert(elmt, text_rect);
|
||||
|
||||
++m_drawed_contacts;
|
||||
@@ -1038,12 +1246,16 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
QRectF(30, offset+4, 5, 10),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
painter.drawText(text_rect,
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
|
||||
if (m_update_map)
|
||||
if (!str.isEmpty())
|
||||
{
|
||||
painter.drawText(text_rect,
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
}
|
||||
//Hit rect kept even for an empty position text (as before),
|
||||
//free slots are not clickable.
|
||||
if (m_update_map && elmt)
|
||||
m_hovered_contacts_map.insert(elmt, text_rect);
|
||||
|
||||
//a switch contact take place of two normal contact
|
||||
@@ -1067,15 +1279,19 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in
|
||||
|
||||
//Draw position text
|
||||
QRectF text_rect = painter.boundingRect(
|
||||
QRectF(30, offset, 5, 10),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
painter.drawText(text_rect,
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
|
||||
if (m_update_map)
|
||||
QRectF(30, offset, 5, 10),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
if (!str.isEmpty())
|
||||
{
|
||||
painter.drawText(text_rect,
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
str);
|
||||
bounding_rect = bounding_rect.united(text_rect);
|
||||
}
|
||||
//Hit rect kept even for an empty position text (as before),
|
||||
//free slots are not clickable.
|
||||
if (m_update_map && elmt)
|
||||
m_hovered_contacts_map.insert(elmt, text_rect);
|
||||
++m_drawed_contacts;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ class CrossRefItem : public QGraphicsObject
|
||||
enum { Type = UserType + 1009 };
|
||||
int type() const override { return Type; }
|
||||
|
||||
/// Returns true when \a xrp asks the contact comb of \a elmt to show
|
||||
/// every slave contact the master defines, even the ones no slave is
|
||||
/// linked to yet. \a elmt must be a master element.
|
||||
static bool showAllConfiguredSlaves(
|
||||
const Element *elmt,
|
||||
const XRefProperties &xrp);
|
||||
|
||||
/**
|
||||
@brief The CONTACTS enum
|
||||
*/
|
||||
@@ -121,7 +128,13 @@ class CrossRefItem : public QGraphicsObject
|
||||
void drawAsCross(QPainter &painter);
|
||||
void drawAsContacts(QPainter &painter);
|
||||
void drawAsPlcTable(QPainter &painter);
|
||||
QRectF drawContact(QPainter &painter, int flags, Element *elmt, int pole_index = 0);
|
||||
bool mustDrawAllConfiguredSlaves() const;
|
||||
QRectF drawLinkedSlaveContacts(QPainter &painter, Element *elmt);
|
||||
QRectF drawContact(QPainter &painter,
|
||||
int flags,
|
||||
Element *elmt,
|
||||
int pole_index = 0,
|
||||
const QStringList &master_labels = QStringList());
|
||||
void fillCrossRef(QPainter &painter);
|
||||
void AddExtraInfo(QPainter &painter, const QString&);
|
||||
QList<Element *> NOElements() const;
|
||||
|
||||
@@ -890,7 +890,17 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
||||
*/
|
||||
void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods)
|
||||
{
|
||||
const QPointF pivotScene = pos() + m_transform.pivot;
|
||||
// mapToScene(), not the pos() + m_transform.pivot shortcut: that
|
||||
// shortcut only holds because the pivot is a fixed point of
|
||||
// m_transform.toMatrix() -- true exactly as long as m_transform is
|
||||
// the item's ONLY transform. It silently breaks the moment anything
|
||||
// else is composed on top (QGraphicsItem's own scale()/rotation(),
|
||||
// or a future parent-group transform), giving a scenePivot that
|
||||
// doesn't match where the pivot handle is actually drawn (rebuildHandles()
|
||||
// already uses mapToScene() for that, which is why the handle looks
|
||||
// right even when a drag computed from this shortcut doesn't).
|
||||
// mapToScene() is correct regardless of what else is composed in.
|
||||
const QPointF pivotScene = mapToScene(m_transform.pivot);
|
||||
QTransform undoRotation;
|
||||
undoRotation.rotate(-m_transform.rotation);
|
||||
const QPointF postScaleOffset = undoRotation.map(mapToScene(localPos) - pivotScene);
|
||||
@@ -961,7 +971,9 @@ void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::Keyboa
|
||||
*/
|
||||
void DiagramImageItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
|
||||
{
|
||||
const QPointF scenePivot = pos() + m_transform.pivot;
|
||||
// mapToScene(), not pos() + m_transform.pivot: see dragResize()'s
|
||||
// identical comment for why.
|
||||
const QPointF scenePivot = mapToScene(m_transform.pivot);
|
||||
const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x()));
|
||||
|
||||
const QPointF reference = scaleAndShearOffset(cornerPosition(cornerIndex, pixmap_.width(), pixmap_.height()));
|
||||
@@ -995,7 +1007,9 @@ void DiagramImageItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt
|
||||
|
||||
const qreal rad = qDegreesToRadians(m_transform.rotation);
|
||||
const qreal c = qCos(rad), s = qSin(rad);
|
||||
const QPointF targetRel = scenePos - pos() - pivot;
|
||||
// mapToScene(pivot), not pos() + pivot: see dragResize()'s identical
|
||||
// comment for why.
|
||||
const QPointF targetRel = scenePos - mapToScene(pivot);
|
||||
const QPointF M(targetRel.x() * c + targetRel.y() * s,
|
||||
-targetRel.x() * s + targetRel.y() * c);
|
||||
|
||||
|
||||
@@ -370,7 +370,7 @@ void DiagramTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
|
||||
|
||||
//Set the actual pos
|
||||
QPointF new_pos = event->scenePos() + m_mouse_to_origin_movement;
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToGrid(new_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToTextGrid(new_pos));
|
||||
|
||||
|
||||
//Update the actual movement for other selected item
|
||||
|
||||
@@ -639,7 +639,7 @@ void DynamicElementTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
//DiagramTextItem::mouseMoveEvent() for independent texts.
|
||||
//Without it this was the only text move in the editor that
|
||||
//ignored the grid.
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToGrid(new_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToTextGrid(new_pos));
|
||||
|
||||
if(diagram())
|
||||
diagram()->elementTextsMover().continueMovement(event);
|
||||
@@ -810,8 +810,12 @@ QVariant DynamicElementTextItem::itemChange(QGraphicsItem::GraphicsItemChange ch
|
||||
connect(m_parent_element.data(), &Element::linkedElementChanged, this, &DynamicElementTextItem::updateXref);
|
||||
if(m_parent_element.data()->diagram())
|
||||
connect(m_parent_element.data()->diagram()->project(), &QETProject::XRefPropertiesChanged, this, &DynamicElementTextItem::updateXref);
|
||||
if(!m_parent_element.data()->linkedElements().isEmpty())
|
||||
updateXref();
|
||||
//Also call updateXref for a master without any linked slave:
|
||||
//when the contact comb must show every contact group the master
|
||||
//defines, the cross ref is expected the moment the element lands
|
||||
//on the diagram, not only after the first link or the first
|
||||
//settings change.
|
||||
updateXref();
|
||||
}
|
||||
|
||||
m_first_scene_change = false;
|
||||
@@ -1582,8 +1586,10 @@ void DynamicElementTextItem::updateXref()
|
||||
|
||||
if(m_text_from == DynamicElementTextItem::ElementInfo &&
|
||||
m_info_name == "label" &&
|
||||
!m_parent_element.data()->linkedElements().isEmpty() &&
|
||||
xrp.snapTo() == XRefProperties::Label)
|
||||
xrp.snapTo() == XRefProperties::Label &&
|
||||
(!m_parent_element.data()->linkedElements().isEmpty()
|
||||
|| CrossRefItem::showAllConfiguredSlaves(
|
||||
m_parent_element.data(), xrp)))
|
||||
{
|
||||
//For add a Xref, this text must not be in a group
|
||||
if(!parentGroup())
|
||||
|
||||
@@ -60,12 +60,48 @@ ElementTextItemGroup::ElementTextItemGroup(const QString &name,
|
||||
this,
|
||||
&ElementTextItemGroup::updateXref);
|
||||
if(parent->diagram())
|
||||
connect(parent->diagram()->project(),
|
||||
m_project_xref_connection = connect(
|
||||
parent->diagram()->project(),
|
||||
&QETProject::XRefPropertiesChanged,
|
||||
this,
|
||||
&ElementTextItemGroup::updateXref);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ElementTextItemGroup::itemChange
|
||||
The group is very often built while its element is not on a scene yet
|
||||
(project or element loading): the connection to the project was then
|
||||
impossible and the first updateXref() ran without a diagram, so the
|
||||
cross ref of a master waiting for its slaves stayed invisible until an
|
||||
unrelated settings change happened to refresh it. Do both here, the
|
||||
moment the group really reaches the scene.
|
||||
@param change
|
||||
@param value
|
||||
@return
|
||||
*/
|
||||
QVariant ElementTextItemGroup::itemChange(
|
||||
QGraphicsItem::GraphicsItemChange change,
|
||||
const QVariant &value)
|
||||
{
|
||||
if (change == QGraphicsItem::ItemSceneHasChanged)
|
||||
{
|
||||
if (m_parent_element
|
||||
&& m_parent_element->diagram()
|
||||
&& m_parent_element->diagram()->project())
|
||||
{
|
||||
QETProject *project = m_parent_element->diagram()->project();
|
||||
if (!m_project_xref_connection)
|
||||
m_project_xref_connection = connect(
|
||||
project,
|
||||
&QETProject::XRefPropertiesChanged,
|
||||
this,
|
||||
&ElementTextItemGroup::updateXref);
|
||||
updateXref();
|
||||
}
|
||||
}
|
||||
return QGraphicsItemGroup::itemChange(change, value);
|
||||
}
|
||||
|
||||
ElementTextItemGroup::~ElementTextItemGroup()
|
||||
{}
|
||||
|
||||
@@ -652,7 +688,7 @@ void ElementTextItemGroup::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
button_down_parent_pos = mapToParent(mapFromScene(event->buttonDownScenePos(Qt::LeftButton)));
|
||||
|
||||
QPointF new_pos = m_initial_position + current_parent_pos - button_down_parent_pos;
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToGrid(new_pos));
|
||||
event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToTextGrid(new_pos));
|
||||
|
||||
if(diagram())
|
||||
diagram()->elementTextsMover().continueMovement(event);
|
||||
@@ -772,13 +808,13 @@ void ElementTextItemGroup::updateXref()
|
||||
{
|
||||
QETProject *project = m_parent_element->diagram()->project();
|
||||
|
||||
if(m_parent_element->linkType() == Element::Master &&
|
||||
!m_parent_element->linkedElements().isEmpty())
|
||||
if(m_parent_element->linkType() == Element::Master)
|
||||
{
|
||||
|
||||
XRefProperties xrp = project->defaultXRefProperties(m_parent_element->kindInformations()["type"].toString());
|
||||
|
||||
if(xrp.snapTo() == XRefProperties::Label)
|
||||
|
||||
if(xrp.snapTo() == XRefProperties::Label &&
|
||||
(!m_parent_element->linkedElements().isEmpty()
|
||||
|| CrossRefItem::showAllConfiguredSlaves(m_parent_element, xrp)))
|
||||
{
|
||||
//At least one text owned by this group must be set with
|
||||
//textFrom -> element info and element info name -> label
|
||||
|
||||
@@ -101,6 +101,8 @@ class ElementTextItemGroup : public QObject, public QGraphicsItemGroup
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
|
||||
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;
|
||||
QVariant itemChange(GraphicsItemChange change,
|
||||
const QVariant &value) override;
|
||||
|
||||
private:
|
||||
void updateXref();
|
||||
@@ -120,6 +122,7 @@ class ElementTextItemGroup : public QObject, public QGraphicsItemGroup
|
||||
Element *m_parent_element = nullptr;
|
||||
QList<QMetaObject::Connection> m_update_slave_Xref_connection;
|
||||
QGraphicsTextItem *m_slave_Xref_item = nullptr;
|
||||
QMetaObject::Connection m_project_xref_connection;
|
||||
QMetaObject::Connection m_XrefChanged_timer,
|
||||
m_linked_changed_timer;
|
||||
};
|
||||
|
||||
@@ -167,6 +167,14 @@ QVariant MasterElement::itemChange(QGraphicsItem::GraphicsItemChange change, con
|
||||
m_Xref_item = new CrossRefItem(this);
|
||||
m_Xref_item->updateLabel();
|
||||
}
|
||||
// Same idea for a master whose contact comb must show every
|
||||
// contact group it defines: the cross ref is expected even
|
||||
// before any slave is linked to it.
|
||||
else if (!m_Xref_item && mustShowXrefWithoutSlave())
|
||||
{
|
||||
m_Xref_item = new CrossRefItem(this);
|
||||
m_Xref_item->updateLabel();
|
||||
}
|
||||
}
|
||||
return Element::itemChange(change, value);
|
||||
}
|
||||
@@ -193,6 +201,26 @@ void MasterElement::xrefPropertiesChanged()
|
||||
aboutDeleteXref();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief MasterElement::mustShowXrefWithoutSlave
|
||||
@return true when the cross ref of this master has to be shown even
|
||||
though no slave is linked to it yet: the user asks the contact comb to
|
||||
display every contact group the master defines, the comb (contacts)
|
||||
display is the one in use, and the cross ref is owned by the element
|
||||
itself (snap to bottom).
|
||||
*/
|
||||
bool MasterElement::mustShowXrefWithoutSlave() const
|
||||
{
|
||||
if (!diagram() || !diagram()->project())
|
||||
return false;
|
||||
|
||||
const XRefProperties xrp = diagram()->project()->defaultXRefProperties(
|
||||
kindInformations()["type"].toString());
|
||||
|
||||
return xrp.snapTo() == XRefProperties::Bottom
|
||||
&& CrossRefItem::showAllConfiguredSlaves(this, xrp);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief MasterElement::aboutDeleteXref
|
||||
Check if Xref item must be displayed, if not, delete it.
|
||||
@@ -220,6 +248,11 @@ void MasterElement::aboutDeleteXref()
|
||||
return;
|
||||
}
|
||||
|
||||
// The contact comb shows the contact groups the master defines, linked
|
||||
// or not: keep the item even when it draws nothing so far.
|
||||
if (mustShowXrefWithoutSlave())
|
||||
return;
|
||||
|
||||
if (m_Xref_item->boundingRect().isNull())
|
||||
{
|
||||
delete m_Xref_item;
|
||||
|
||||
@@ -60,6 +60,7 @@ class MasterElement : public Element
|
||||
private:
|
||||
void xrefPropertiesChanged();
|
||||
void aboutDeleteXref ();
|
||||
bool mustShowXrefWithoutSlave() const;
|
||||
void connectSlavePositionUpdates(Element *slave);
|
||||
void disconnectSlavePositionUpdates(Element *slave);
|
||||
|
||||
|
||||
@@ -2385,7 +2385,7 @@ void QetShapeItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardMo
|
||||
: QetGraphicsHandlerUtility::rectForPosAtIndex(localRect(), localPos, index);
|
||||
|
||||
if (mods & Qt::ShiftModifier)
|
||||
newRect = lockAspectRatio(localRect(), newRect, index, mirrored);
|
||||
newRect = lockAspectRatio(QRectF(m_old_P1, m_old_P2).normalized(), newRect, index, mirrored);
|
||||
|
||||
setRect(newRect.normalized());
|
||||
}
|
||||
|
||||
@@ -197,3 +197,36 @@ bool ShortcutManager::trigger(const QString &id) const
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@return the QAction registered under @a id that belongs to @a owner --
|
||||
that is, has @a owner among its ancestors -- or nullptr. Several windows
|
||||
of the same kind each register their own action under one id, so a
|
||||
window asking for "its" action has to say which window it is.
|
||||
@param id
|
||||
@param owner : the window, or nullptr for the first live action
|
||||
*/
|
||||
QAction *ShortcutManager::action(const QString &id, const QObject *owner) const
|
||||
{
|
||||
auto it = m_entries.find(id);
|
||||
if (it == m_entries.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
for (const QPointer<QObject> &target : qAsConst(it->targets))
|
||||
{
|
||||
auto *action = qobject_cast<QAction *>(target.data());
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
if (!owner) {
|
||||
return action;
|
||||
}
|
||||
for (const QObject *o = action->parent(); o; o = o->parent()) {
|
||||
if (o == owner) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <QStringList>
|
||||
|
||||
class QObject;
|
||||
class QAction;
|
||||
|
||||
/**
|
||||
@brief The ShortcutManager class
|
||||
@@ -84,6 +85,7 @@ class ShortcutManager
|
||||
/// multi-window case, not a guaranteed-correct dispatch.
|
||||
/// @return whether a live target was found and triggered.
|
||||
bool trigger(const QString &id) const;
|
||||
QAction *action(const QString &id, const QObject *owner) const;
|
||||
|
||||
private:
|
||||
ShortcutManager() = default;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
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 "connexionbackend.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFileInfo>
|
||||
#include <QMutex>
|
||||
#include <QMutexLocker>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
//The part of 3DconnexionClient's interface QET uses, with the
|
||||
//values Blender uses (intern/ghost/intern/GHOST_NDOFManagerCocoa.mm).
|
||||
constexpr quint32 MSG_DEVICE_STATE = 0x33645352; // '3dSR'
|
||||
constexpr quint16 CMD_HANDLE_BUTTONS = 2;
|
||||
constexpr quint16 CMD_HANDLE_AXIS = 3;
|
||||
constexpr quint16 MODE_TAKE_OVER = 1;
|
||||
constexpr quint32 MASK_ALL = 0x3fff;
|
||||
constexpr quint32 MASK_ALL_BUTTONS = 0xffffffff;
|
||||
constexpr quint32 SIGNATURE = 0x51456c54; // 'QElT'
|
||||
|
||||
//ConnexionDeviceState is packed to 2 bytes. Offsets of the fields read.
|
||||
constexpr int STATE_CLIENT = 2;
|
||||
constexpr int STATE_COMMAND = 4;
|
||||
constexpr int STATE_AXIS = 30;
|
||||
constexpr int STATE_BUTTONS = 44;
|
||||
|
||||
using MessageHandler = void (*)(quint32, quint32, void *);
|
||||
using DeviceHandler = void (*)(quint32);
|
||||
using SetConnexionHandlers = qint16 (*)(MessageHandler, DeviceHandler, DeviceHandler, bool);
|
||||
using RegisterConnexionClient = quint16 (*)(quint32, const quint8 *, quint16, quint32);
|
||||
using SetConnexionClientButtonMask = void (*)(quint16, quint32);
|
||||
using CleanupConnexionHandlers = void (*)();
|
||||
using UnregisterConnexionClient = void (*)(quint16);
|
||||
|
||||
//3DxWare's callbacks carry no context, so the one client lives here.
|
||||
//The mutex is shared with 3DxWare's thread.
|
||||
QMutex instance_mutex;
|
||||
ConnexionBackend *instance = nullptr;
|
||||
|
||||
void deviceChanged(quint32) {}
|
||||
}
|
||||
|
||||
const char ConnexionBackend::DEFAULT_LIBRARY[] =
|
||||
"/Library/Frameworks/3DconnexionClient.framework/3DconnexionClient";
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::ConnexionBackend
|
||||
Load 3DxWare's client library and register with it. Not installed, or
|
||||
installed but not running, leaves isAvailable() false: the ordinary
|
||||
case, never reported as an error.
|
||||
@param parent
|
||||
@param library : the client library; only tests pass another
|
||||
*/
|
||||
ConnexionBackend::ConnexionBackend(QObject *parent, const QString &library) :
|
||||
SpaceMouseBackend(parent),
|
||||
m_library(library)
|
||||
{
|
||||
{
|
||||
QMutexLocker lock(&instance_mutex);
|
||||
if (instance) {
|
||||
return; //3DxWare takes one set of handlers per process
|
||||
}
|
||||
}
|
||||
if (!m_library.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto set_handlers = reinterpret_cast<SetConnexionHandlers>(
|
||||
m_library.resolve("SetConnexionHandlers"));
|
||||
const auto register_client = reinterpret_cast<RegisterConnexionClient>(
|
||||
m_library.resolve("RegisterConnexionClient"));
|
||||
const auto button_mask = reinterpret_cast<SetConnexionClientButtonMask>(
|
||||
m_library.resolve("SetConnexionClientButtonMask"));
|
||||
m_cleanup = reinterpret_cast<CleanupConnexionHandlers>(
|
||||
m_library.resolve("CleanupConnexionHandlers"));
|
||||
m_unregister = reinterpret_cast<UnregisterConnexionClient>(
|
||||
m_library.resolve("UnregisterConnexionClient"));
|
||||
if (!set_handlers || !register_client || !m_cleanup || !m_unregister) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
QMutexLocker lock(&instance_mutex);
|
||||
instance = this;
|
||||
}
|
||||
//Fails while 3DxWare is installed but its driver is not running.
|
||||
if (set_handlers(&ConnexionBackend::onMessage, deviceChanged, deviceChanged, true) != 0) {
|
||||
shutdown();
|
||||
return;
|
||||
}
|
||||
m_handlers_installed = true;
|
||||
|
||||
//3DxWare only sends a client its messages while that application
|
||||
//is in front, and recognises it by its executable's name (a Pascal
|
||||
//string). Take-over mode stops 3DxWare's own actions in QET, so the
|
||||
//view never moves twice.
|
||||
QByteArray name = QFileInfo(QCoreApplication::applicationFilePath())
|
||||
.fileName().toUtf8().left(255);
|
||||
name.prepend(char(name.size()));
|
||||
m_client = register_client(SIGNATURE,
|
||||
reinterpret_cast<const quint8 *>(name.constData()),
|
||||
MODE_TAKE_OVER, MASK_ALL);
|
||||
if (!m_client) {
|
||||
shutdown();
|
||||
return;
|
||||
}
|
||||
if (button_mask) {
|
||||
button_mask(m_client, MASK_ALL_BUTTONS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::~ConnexionBackend
|
||||
*/
|
||||
ConnexionBackend::~ConnexionBackend()
|
||||
{
|
||||
shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::sampleFromAxes
|
||||
3DxWare reports y up and z away from the user; the raw USB reports, and
|
||||
so every other backend, have y towards the user and z down. Derived from
|
||||
Blender, whose 3DxWare and spacenavd code paths must agree.
|
||||
@param axis : TX, TY, TZ, RX, RY, RZ as 3DxWare sends them
|
||||
@return the same movement in QET's convention
|
||||
*/
|
||||
SpaceMouseSample ConnexionBackend::sampleFromAxes(const qint16 axis[6])
|
||||
{
|
||||
SpaceMouseSample sample;
|
||||
sample.x = axis[0];
|
||||
sample.y = -axis[2];
|
||||
sample.z = -axis[1];
|
||||
sample.rx = axis[3];
|
||||
sample.ry = -axis[5];
|
||||
sample.rz = -axis[4];
|
||||
return sample;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::newlyPressed
|
||||
@param before : the button bitmask of the previous message
|
||||
@param now : the button bitmask of this one
|
||||
@return the 0-based buttons pressed since \a before, as HidBackend numbers them
|
||||
*/
|
||||
QList<int> ConnexionBackend::newlyPressed(quint32 before, quint32 now)
|
||||
{
|
||||
QList<int> pressed;
|
||||
const quint32 down = now & ~before;
|
||||
for (int bit = 0; bit < 32; ++bit) {
|
||||
if (down & (quint32(1) << bit)) {
|
||||
pressed.append(bit);
|
||||
}
|
||||
}
|
||||
return pressed;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::onMessage
|
||||
Runs on 3DxWare's thread. Copies what matters out of the message and
|
||||
hands it to the main thread.
|
||||
@param type : the message type
|
||||
@param argument : a ConnexionDeviceState for MSG_DEVICE_STATE
|
||||
*/
|
||||
void ConnexionBackend::onMessage(quint32, quint32 type, void *argument)
|
||||
{
|
||||
if (type != MSG_DEVICE_STATE || !argument) {
|
||||
return;
|
||||
}
|
||||
const char *state = static_cast<const char *>(argument);
|
||||
quint16 client;
|
||||
quint16 command;
|
||||
qint16 axis[6];
|
||||
quint32 buttons;
|
||||
std::memcpy(&client, state + STATE_CLIENT, sizeof client);
|
||||
std::memcpy(&command, state + STATE_COMMAND, sizeof command);
|
||||
std::memcpy(axis, state + STATE_AXIS, sizeof axis);
|
||||
std::memcpy(&buttons, state + STATE_BUTTONS, sizeof buttons);
|
||||
|
||||
//Queued with the backend as context: if it is deleted before the
|
||||
//main thread gets to it, Qt drops the call.
|
||||
QMutexLocker lock(&instance_mutex);
|
||||
ConnexionBackend *backend = instance;
|
||||
if (!backend) {
|
||||
return;
|
||||
}
|
||||
QMetaObject::invokeMethod(backend, [=]() {
|
||||
backend->handleState(client, command, axis, buttons);
|
||||
}, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::handleState
|
||||
On the main thread: turn one device state into signals.
|
||||
*/
|
||||
void ConnexionBackend::handleState(quint16 client, quint16 command,
|
||||
const qint16 axis[6], quint32 buttons)
|
||||
{
|
||||
if (client != m_client) {
|
||||
return; //3DxWare sends every state to every client
|
||||
}
|
||||
if (command == CMD_HANDLE_AXIS) {
|
||||
emit motion(sampleFromAxes(axis));
|
||||
} else if (command == CMD_HANDLE_BUTTONS) {
|
||||
//State first: a button can open a dialog whose event loop
|
||||
//delivers the next message before emit returns.
|
||||
const QList<int> pressed = newlyPressed(m_buttons, buttons);
|
||||
m_buttons = buttons;
|
||||
for (int button : pressed) {
|
||||
emit buttonPressed(button);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConnexionBackend::shutdown
|
||||
Unregister and stop 3DxWare's callbacks. The library stays loaded, since
|
||||
3DxWare's thread may still be returning from one.
|
||||
*/
|
||||
void ConnexionBackend::shutdown()
|
||||
{
|
||||
if (m_client && m_unregister) {
|
||||
m_unregister(m_client);
|
||||
}
|
||||
m_client = 0;
|
||||
if (m_handlers_installed && m_cleanup) {
|
||||
m_cleanup();
|
||||
}
|
||||
m_handlers_installed = false;
|
||||
|
||||
QMutexLocker lock(&instance_mutex);
|
||||
if (instance == this) {
|
||||
instance = nullptr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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 CONNEXIONBACKEND_H
|
||||
#define CONNEXIONBACKEND_H
|
||||
|
||||
#include "spacemousebackend.h"
|
||||
|
||||
#include <QLibrary>
|
||||
|
||||
/**
|
||||
@brief The ConnexionBackend class
|
||||
SpaceMouseBackend that reads the device through 3Dconnexion's own macOS
|
||||
driver, 3DxWare, the way Blender does. Only compiled in on macOS
|
||||
(cmake/find_spacemouse.cmake).
|
||||
|
||||
3DxWare installs a driver extension that takes the device over, so
|
||||
with 3DxWare installed HidBackend can open the device but receives
|
||||
nothing (discussion #599). This backend asks 3DxWare for the motion
|
||||
instead. SpaceMouseListener tries it first and falls back to HidBackend
|
||||
when 3DxWare is not installed or not running, so the device works
|
||||
either way.
|
||||
|
||||
The client library, 3DconnexionClient.framework, is loaded at run time
|
||||
from where 3DxWare installs it: nothing is linked, bundled or needed to
|
||||
build. The few declarations below are written here, as Blender does,
|
||||
rather than taken from 3Dconnexion's SDK, whose headers may not be
|
||||
redistributed.
|
||||
|
||||
3DxWare delivers its messages on a thread of its own; they are passed
|
||||
to the main thread before any signal is emitted.
|
||||
*/
|
||||
class ConnexionBackend : public SpaceMouseBackend
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static const char DEFAULT_LIBRARY[];
|
||||
|
||||
explicit ConnexionBackend(QObject *parent = nullptr,
|
||||
const QString &library = QString::fromLatin1(DEFAULT_LIBRARY));
|
||||
~ConnexionBackend() override;
|
||||
|
||||
bool isAvailable() const override { return m_client != 0; }
|
||||
|
||||
/// 3DxWare's six axes, in its own order and signs, as QET's
|
||||
/// raw USB convention (+x right, +y towards the user, +z down).
|
||||
static SpaceMouseSample sampleFromAxes(const qint16 axis[6]);
|
||||
/// The 0-based buttons set in \a now but not in \a before.
|
||||
static QList<int> newlyPressed(quint32 before, quint32 now);
|
||||
|
||||
private:
|
||||
static void onMessage(quint32 connection, quint32 type, void *argument);
|
||||
void handleState(quint16 client, quint16 command,
|
||||
const qint16 axis[6], quint32 buttons);
|
||||
void shutdown();
|
||||
|
||||
QLibrary m_library;
|
||||
quint16 m_client = 0;
|
||||
bool m_handlers_installed = false;
|
||||
quint32 m_buttons = 0;
|
||||
|
||||
void (*m_cleanup)() = nullptr;
|
||||
void (*m_unregister)(quint16) = nullptr;
|
||||
};
|
||||
|
||||
#endif // CONNEXIONBACKEND_H
|
||||
@@ -36,7 +36,9 @@
|
||||
Two implementations, chosen at build time (cmake/find_spacemouse.cmake):
|
||||
SpnavBackend (Linux, through spacenavd/libspnav) and HidBackend (any
|
||||
platform, directly over USB through hidapi, with no 3Dconnexion driver
|
||||
or SDK). Both report the same values for the same movement.
|
||||
or SDK). On macOS a third, ConnexionBackend, reads through 3DxWare and
|
||||
is tried first at run time. All report the same values for the same
|
||||
movement.
|
||||
*/
|
||||
class SpaceMouseBackend : public QObject
|
||||
{
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
#ifdef QET_SPACEMOUSE_BACKEND_HID
|
||||
# include "hidbackend.h"
|
||||
#endif
|
||||
#ifdef QET_SPACEMOUSE_BACKEND_CONNEXION
|
||||
# include "connexionbackend.h"
|
||||
#endif
|
||||
|
||||
#include "../diagramview.h"
|
||||
#include "../editor/elementview.h"
|
||||
@@ -48,10 +51,24 @@ SpaceMouseListener::SpaceMouseListener(QObject *parent) :
|
||||
QObject(parent),
|
||||
m_settings(SpaceMouseSettings::load())
|
||||
{
|
||||
#if defined(QET_SPACEMOUSE_BACKEND_CONNEXION)
|
||||
//When 3DxWare is installed and running it has the device to
|
||||
//itself, so ask it first; otherwise read the device directly.
|
||||
auto *connexion = new ConnexionBackend(this);
|
||||
if (connexion->isAvailable()) {
|
||||
m_backend = connexion;
|
||||
} else {
|
||||
delete connexion;
|
||||
}
|
||||
#endif
|
||||
#if defined(QET_SPACEMOUSE_BACKEND_SPNAV)
|
||||
m_backend = new SpnavBackend(this);
|
||||
if (!m_backend) {
|
||||
m_backend = new SpnavBackend(this);
|
||||
}
|
||||
#elif defined(QET_SPACEMOUSE_BACKEND_HID)
|
||||
m_backend = new HidBackend(this);
|
||||
if (!m_backend) {
|
||||
m_backend = new HidBackend(this);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (m_backend) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
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 TEXTGRID_H
|
||||
#define TEXTGRID_H
|
||||
|
||||
#include <QList>
|
||||
#include <QPointF>
|
||||
#include <QString>
|
||||
#include <QtMath>
|
||||
|
||||
/**
|
||||
The text grid: the step texts snap to when dragged with the mouse,
|
||||
a fraction of the folio grid. A divisor of 1 is the folio grid itself,
|
||||
0 means no grid. Because every step divides the folio grid, a text
|
||||
snapped to it still lines up with every element and with the texts
|
||||
of other elements.
|
||||
*/
|
||||
namespace TextGrid
|
||||
{
|
||||
/// The choices offered in the menu and the preferences, 0 first.
|
||||
inline const QList<qreal> divisors{0, 1, 2, 5, 10};
|
||||
|
||||
/// QSettings key holding the divisor.
|
||||
inline const QString settings_key{QStringLiteral("diagrameditor/text_grid_divisor")};
|
||||
|
||||
/// "1:5" for 5. Not meaningful for 0.
|
||||
inline QString ratioLabel(qreal divisor) {
|
||||
return QStringLiteral("1:") + QString::number(divisor);
|
||||
}
|
||||
|
||||
/**
|
||||
@return p snapped to a grid of x_grid / divisor by y_grid / divisor,
|
||||
or rounded to the nearest pixel when divisor is 0 or less.
|
||||
*/
|
||||
inline QPointF snap(const QPointF &p, int x_grid, int y_grid, qreal divisor)
|
||||
{
|
||||
if (divisor <= 0 || x_grid <= 0 || y_grid <= 0)
|
||||
return QPointF(qRound(p.x()), qRound(p.y()));
|
||||
|
||||
const qreal x_step = x_grid / divisor;
|
||||
const qreal y_step = y_grid / divisor;
|
||||
return QPointF(qRound(p.x() / x_step) * x_step,
|
||||
qRound(p.y() / y_step) * y_step);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // TEXTGRID_H
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "../../utils/qetsettings.h"
|
||||
#include "../../utils/qetutils.h"
|
||||
#include "../../qetmessagebox.h"
|
||||
#include "../../textgrid.h"
|
||||
#include "../nokde/kcolorbutton.h"
|
||||
#include <QFileDialog>
|
||||
#include <QFontDialog>
|
||||
@@ -68,6 +69,15 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
|
||||
ui->guides_startup_cb->setChecked(settings.value("diagrameditor/guides_display_startup", false).toBool());
|
||||
ui->DiagramEditor_xGrid_sb->setValue(settings.value("diagrameditor/Xgrid", 10).toInt());
|
||||
ui->DiagramEditor_yGrid_sb->setValue(settings.value("diagrameditor/Ygrid", 10).toInt());
|
||||
for (const qreal divisor : TextGrid::divisors)
|
||||
ui->DiagramEditor_textGrid_cb->addItem(
|
||||
divisor > 0 ? TextGrid::ratioLabel(divisor) : tr("Désactivée"),
|
||||
divisor);
|
||||
int text_grid_index = ui->DiagramEditor_textGrid_cb->findData(
|
||||
settings.value(TextGrid::settings_key, 1).toReal());
|
||||
if (text_grid_index < 0)
|
||||
text_grid_index = ui->DiagramEditor_textGrid_cb->findData(qreal(1));
|
||||
ui->DiagramEditor_textGrid_cb->setCurrentIndex(text_grid_index);
|
||||
ui->DiagramEditor_xKeyGrid_sb->setValue(settings.value("diagrameditor/key_Xgrid", 10).toInt());
|
||||
ui->DiagramEditor_yKeyGrid_sb->setValue(settings.value("diagrameditor/key_Ygrid", 10).toInt());
|
||||
ui->DiagramEditor_xKeyGridFine_sb->setValue(settings.value("diagrameditor/key_fine_Xgrid", 1).toInt());
|
||||
@@ -287,6 +297,7 @@ void GeneralConfigurationPage::applyConf()
|
||||
//Grid step and key navigation
|
||||
settings.setValue("diagrameditor/Xgrid", ui->DiagramEditor_xGrid_sb->value());
|
||||
settings.setValue("diagrameditor/Ygrid", ui->DiagramEditor_yGrid_sb->value());
|
||||
settings.setValue(TextGrid::settings_key, ui->DiagramEditor_textGrid_cb->currentData());
|
||||
settings.setValue("diagrameditor/key_Xgrid", ui->DiagramEditor_xKeyGrid_sb->value());
|
||||
settings.setValue("diagrameditor/key_Ygrid", ui->DiagramEditor_yKeyGrid_sb->value());
|
||||
settings.setValue("diagrameditor/key_fine_Xgrid", ui->DiagramEditor_xKeyGridFine_sb->value());
|
||||
|
||||
@@ -784,6 +784,26 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="Label_Diagram_textGrid">
|
||||
<property name="text">
|
||||
<string>Grille des textes déplacés à la souris</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Fraction de la grille des folios. Maintenir Ctrl pendant le déplacement pour placer librement.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="DiagramEditor_textGrid_cb">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
|
||||
@@ -18,12 +18,15 @@
|
||||
#include "jumptoelementdialog.h"
|
||||
|
||||
#include "../diagram.h"
|
||||
#include "../diagramview.h"
|
||||
#include "../qetproject.h"
|
||||
#include "../qetgraphicsitem/element.h"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QRegularExpression>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
/**
|
||||
@@ -38,7 +41,7 @@ JumpToElementDialog::JumpToElementDialog(Diagram *diagram, QWidget *parent) :
|
||||
setWindowTitle(tr("Atteindre un élément", "window title"));
|
||||
|
||||
m_filter_edit = new QLineEdit(this);
|
||||
m_filter_edit->setPlaceholderText(tr("Nom, label ou information de l'élément…"));
|
||||
m_filter_edit->setPlaceholderText(tr("Nom, label ou information de l'élément, ou case (ex. B13 ou 3-B13)…"));
|
||||
m_filter_edit->installEventFilter(this);
|
||||
|
||||
m_result_list = new QListWidget(this);
|
||||
@@ -86,6 +89,7 @@ void JumpToElementDialog::buildCandidates()
|
||||
|
||||
Candidate candidate;
|
||||
candidate.element = element;
|
||||
candidate.label = label;
|
||||
candidate.display_text = label.isEmpty() ? name : (label + QStringLiteral(" — ") + name);
|
||||
|
||||
QStringList search_parts;
|
||||
@@ -111,6 +115,7 @@ void JumpToElementDialog::updateFilteredList(const QString &filter_text)
|
||||
m_result_list->clear();
|
||||
|
||||
const QString needle = filter_text.trimmed().toLower();
|
||||
bool exact_label_match = false;
|
||||
for (int i = 0; i < m_candidates.size(); ++i) {
|
||||
const Candidate &candidate = m_candidates.at(i);
|
||||
if (!candidate.element) {
|
||||
@@ -121,6 +126,18 @@ void JumpToElementDialog::updateFilteredList(const QString &filter_text)
|
||||
}
|
||||
auto *list_item = new QListWidgetItem(candidate.display_text, m_result_list);
|
||||
list_item->setData(Qt::UserRole, i);
|
||||
if (candidate.label.compare(needle, Qt::CaseInsensitive) == 0) {
|
||||
exact_label_match = true;
|
||||
}
|
||||
}
|
||||
|
||||
//A cell of the border, on this folio (ex : B13) or on another one
|
||||
//(ex : 3-B13, folio 3, the way cross references write it), comes
|
||||
//first, unless an element is labelled exactly like it: Enter keeps
|
||||
//jumping to that element.
|
||||
if (QListWidgetItem *cell_item = cellItem(needle)) {
|
||||
m_result_list->insertItem(exact_label_match ? m_result_list->count() : 0,
|
||||
cell_item);
|
||||
}
|
||||
|
||||
if (m_result_list->count() > 0) {
|
||||
@@ -142,6 +159,17 @@ void JumpToElementDialog::activateCurrentItem()
|
||||
}
|
||||
|
||||
const int index = current->data(Qt::UserRole).toInt();
|
||||
if (index == -1) {
|
||||
const QList<Diagram *> diagrams = m_diagram->project()
|
||||
? m_diagram->project()->diagrams()
|
||||
: QList<Diagram *>();
|
||||
const int folio = current->data(Qt::UserRole + 2).toInt();
|
||||
if (folio >= 0 && folio < diagrams.size()) {
|
||||
zoomToCell(diagrams.at(folio), current->data(Qt::UserRole + 1).toRectF());
|
||||
}
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
if (index < 0 || index >= m_candidates.size()) {
|
||||
reject();
|
||||
return;
|
||||
@@ -159,6 +187,93 @@ void JumpToElementDialog::activateCurrentItem()
|
||||
accept();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief JumpToElementDialog::cellItem
|
||||
@param needle : the typed text, trimmed and lower case
|
||||
@return a new list item for the cell \a needle names, on m_diagram
|
||||
(ex : b13) or on the folio at a position of the project (ex : 3-b13,
|
||||
3b13, p3b13), or nullptr if \a needle names no cell of an existing
|
||||
folio. The item holds -1, the cell rect and the folio position.
|
||||
*/
|
||||
QListWidgetItem *JumpToElementDialog::cellItem(const QString &needle) const
|
||||
{
|
||||
if (!m_diagram || !m_diagram->project()) {
|
||||
return nullptr;
|
||||
}
|
||||
const QList<Diagram *> diagrams = m_diagram->project()->diagrams();
|
||||
|
||||
Diagram *diagram = m_diagram;
|
||||
QString cell = needle;
|
||||
QString text;
|
||||
QRectF cell_rect = diagram->border_and_titleblock.cellRect(cell);
|
||||
if (cell_rect.isNull()) {
|
||||
//A folio position then a cell, like %f-%l%c in cross references
|
||||
static const QRegularExpression folio_cell_re(
|
||||
QStringLiteral("^p?\\s*(\\d{1,4})\\s*[-/.:]?\\s*([a-z]+\\s*\\d{1,4})$"));
|
||||
const QRegularExpressionMatch match = folio_cell_re.match(needle);
|
||||
if (!match.hasMatch()) {
|
||||
return nullptr;
|
||||
}
|
||||
const int folio = match.captured(1).toInt();
|
||||
if (folio < 1 || folio > diagrams.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
diagram = diagrams.at(folio - 1);
|
||||
cell = match.captured(2);
|
||||
cell_rect = diagram->border_and_titleblock.cellRect(cell);
|
||||
if (cell_rect.isNull()) {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
cell = cell.remove(QLatin1Char(' ')).toUpper();
|
||||
if (diagram == m_diagram) {
|
||||
text = tr("Case %1").arg(cell);
|
||||
} else {
|
||||
const QString title = diagram->title();
|
||||
text = title.isEmpty()
|
||||
? tr("Folio %1, case %2").arg(diagrams.indexOf(diagram) + 1).arg(cell)
|
||||
: tr("Folio %1 (%2), case %3").arg(diagrams.indexOf(diagram) + 1).arg(title, cell);
|
||||
}
|
||||
|
||||
auto *item = new QListWidgetItem(text);
|
||||
item->setData(Qt::UserRole, -1);
|
||||
item->setData(Qt::UserRole + 1, cell_rect);
|
||||
item->setData(Qt::UserRole + 2, diagrams.indexOf(diagram));
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief JumpToElementDialog::zoomToCell
|
||||
Show \a diagram and zoom its view on \a cell_rect with one cell of
|
||||
context around it.
|
||||
@param diagram : the folio holding the cell
|
||||
@param cell_rect : the cell, in scene coordinate
|
||||
*/
|
||||
void JumpToElementDialog::zoomToCell(Diagram *diagram, const QRectF &cell_rect)
|
||||
{
|
||||
const QRectF rect = cell_rect.adjusted(-cell_rect.width(), -cell_rect.height(),
|
||||
cell_rect.width(), cell_rect.height());
|
||||
const bool other_folio = diagram != m_diagram;
|
||||
if (other_folio) {
|
||||
diagram->showMe();
|
||||
}
|
||||
for (QGraphicsView *view : diagram->views()) {
|
||||
if (auto *diagram_view = qobject_cast<DiagramView *>(view)) {
|
||||
if (other_folio) {
|
||||
//A folio shown for the first time is laid out at
|
||||
//its size once the tab switch has been handled
|
||||
QMetaObject::invokeMethod(diagram_view, [diagram_view, rect]() {
|
||||
diagram_view->zoomToRect(rect);
|
||||
}, Qt::QueuedConnection);
|
||||
} else {
|
||||
diagram_view->zoomToRect(rect);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief JumpToElementDialog::eventFilter
|
||||
Redirect Up/Down/Enter/Escape typed in the filter field to the result
|
||||
|
||||
@@ -25,13 +25,15 @@ class Diagram;
|
||||
class Element;
|
||||
class QLineEdit;
|
||||
class QListWidget;
|
||||
class QListWidgetItem;
|
||||
|
||||
/**
|
||||
@brief The JumpToElementDialog class
|
||||
A lightweight, transient "quick open" popup: type part of an element's
|
||||
label or other information to live-filter the elements on a diagram,
|
||||
then Enter to select the chosen element on the diagram and scroll it
|
||||
into view. Up/Down move through the filtered list, Escape cancels
|
||||
into view. Typing a cell of the border instead (ex : B13, or 3-B13 for
|
||||
the third folio of the project) offers to zoom on that cell. Up/Down move through the filtered list, Escape cancels
|
||||
without changing the current selection.
|
||||
*/
|
||||
class JumpToElementDialog : public QDialog
|
||||
@@ -51,9 +53,12 @@ class JumpToElementDialog : public QDialog
|
||||
|
||||
private:
|
||||
void buildCandidates();
|
||||
QListWidgetItem *cellItem(const QString &needle) const;
|
||||
void zoomToCell(Diagram *diagram, const QRectF &cell_rect);
|
||||
|
||||
struct Candidate {
|
||||
QPointer<Element> element;
|
||||
QString label;
|
||||
QString display_text;
|
||||
QString search_text;
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ XRefPropertiesWidget::XRefPropertiesWidget(QHash <QString, XRefProperties> prope
|
||||
ui->setupUi(this);
|
||||
buildUi();
|
||||
connect(ui->m_display_has_cross_rb, &QRadioButton::toggled, ui->m_cross_properties_gb, &QWidget::setEnabled);
|
||||
connect(ui->m_display_has_contacts_rb, &QRadioButton::toggled, ui->m_show_all_slaves_cb, &QWidget::setEnabled);
|
||||
connect(ui->m_type_cb, qOverload<int>(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::typeChanged);
|
||||
connect(ui->m_snap_to_cb, qOverload<int>(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::enableOffsetSB);
|
||||
updateDisplay();
|
||||
@@ -50,6 +51,7 @@ XRefPropertiesWidget::XRefPropertiesWidget(QHash <QString, XRefProperties> prope
|
||||
XRefPropertiesWidget::~XRefPropertiesWidget()
|
||||
{
|
||||
disconnect(ui->m_display_has_cross_rb, &QRadioButton::toggled, ui->m_cross_properties_gb, &QWidget::setEnabled);
|
||||
disconnect(ui->m_display_has_contacts_rb, &QRadioButton::toggled, ui->m_show_all_slaves_cb, &QWidget::setEnabled);
|
||||
disconnect(ui->m_type_cb, qOverload<int>(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::typeChanged);
|
||||
disconnect(ui->m_snap_to_cb, qOverload<int>(&QComboBox::currentIndexChanged), this, &XRefPropertiesWidget::enableOffsetSB);
|
||||
delete ui;
|
||||
@@ -143,6 +145,7 @@ void XRefPropertiesWidget::saveProperties(int index) {
|
||||
else if(ui->m_xrefpos_cb->itemData(ui->m_xrefpos_cb->currentIndex()).toString() == "text_field") xrp.setXrefPos(Qt::AlignHCenter);
|
||||
xrp.setShowPowerContac(ui->m_show_power_cb->isChecked());
|
||||
xrp.setShowTerminalName(ui->m_show_terminal_name_cb->isChecked());
|
||||
xrp.setShowAllConfiguredSlaves(ui->m_show_all_slaves_cb->isChecked());
|
||||
xrp.setPrefix("power", ui->m_power_prefix_le->text());
|
||||
xrp.setPrefix("delay", ui->m_delay_prefix_le->text());
|
||||
xrp.setPrefix("switch", ui->m_switch_prefix_le->text());
|
||||
@@ -200,10 +203,30 @@ void XRefPropertiesWidget::updateDisplay()
|
||||
else if(xrp.getXrefPos() == Qt::AlignHCenter) ui->m_xrefpos_cb->setCurrentIndex(ui->m_xrefpos_cb->findData("text_field"));
|
||||
ui->m_show_power_cb->setChecked(xrp.showPowerContact());
|
||||
ui->m_show_terminal_name_cb->setChecked(xrp.showTerminalName());
|
||||
ui->m_show_all_slaves_cb->setChecked(xrp.showAllConfiguredSlaves());
|
||||
//The radio button only emits toggled() when it really changes: loading
|
||||
//a type whose display did not change left the checkbox with the enabled
|
||||
//state of the previously displayed type (it stayed clickable although
|
||||
//the cross display was selected). Set the state explicitly here.
|
||||
ui->m_show_all_slaves_cb->setEnabled(
|
||||
ui->m_display_has_contacts_rb->isChecked());
|
||||
ui->m_power_prefix_le-> setText(xrp.prefix("power"));
|
||||
ui->m_delay_prefix_le-> setText(xrp.prefix("delay"));
|
||||
ui->m_switch_prefix_le->setText(xrp.prefix("switch"));
|
||||
ui->m_cross_properties_gb->setDisabled(!ui->m_display_has_cross_rb->isChecked());
|
||||
|
||||
//The cross ref of a PLC master is always drawn as its IO table, and
|
||||
//the slaves are referenced directly into that table: the contacts/
|
||||
//cross choice, the two display checkboxes and the cross options below
|
||||
//have no effect at all for this type, so they are hidden instead of
|
||||
//being offered for nothing. The positioning settings and the labels
|
||||
//(the table really uses them) stay available.
|
||||
const bool is_plc = type == QLatin1String("plc");
|
||||
ui->m_display_has_contacts_rb->setVisible(!is_plc);
|
||||
ui->m_display_has_cross_rb->setVisible(!is_plc);
|
||||
ui->m_show_terminal_name_cb->setVisible(!is_plc);
|
||||
ui->m_show_all_slaves_cb->setVisible(!is_plc);
|
||||
ui->m_cross_properties_gb->setVisible(!is_plc);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,6 +151,16 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="m_show_all_slaves_cb">
|
||||
<property name="toolTip">
|
||||
<string>Afficher dans le peigne de contacts tous les contacts esclaves définis par le maître, même ceux qui ne sont pas encore reliés, dans l'ordre défini par le maître</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Afficher tous les esclaves définis par le maître</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="m_display_has_contacts_rb">
|
||||
<property name="text">
|
||||
@@ -327,6 +337,7 @@
|
||||
<tabstop>m_slave_le</tabstop>
|
||||
<tabstop>m_show_power_cb</tabstop>
|
||||
<tabstop>m_show_terminal_name_cb</tabstop>
|
||||
<tabstop>m_show_all_slaves_cb</tabstop>
|
||||
<tabstop>m_power_prefix_le</tabstop>
|
||||
<tabstop>m_delay_prefix_le</tabstop>
|
||||
<tabstop>m_switch_prefix_le</tabstop>
|
||||
|
||||
@@ -84,6 +84,12 @@ add_test(NAME tst_diagramsortkeys COMMAND tst_diagramsortkeys)
|
||||
target_include_directories(tst_diagramsortkeys PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_diagramsortkeys PRIVATE Qt::Test)
|
||||
|
||||
# bordercelllabels.h is header-only too.
|
||||
add_executable(tst_bordercelllabels tst_bordercelllabels.cpp)
|
||||
add_test(NAME tst_bordercelllabels COMMAND tst_bordercelllabels)
|
||||
target_include_directories(tst_bordercelllabels PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_bordercelllabels PRIVATE Qt::Test)
|
||||
|
||||
# contactusage.h is a header-only helper holding the contact counting
|
||||
# rules, so this test builds independently of the rest of the QET sources.
|
||||
add_executable(tst_contactusage tst_contactusage.cpp)
|
||||
@@ -91,6 +97,13 @@ add_test(NAME tst_contactusage COMMAND tst_contactusage)
|
||||
target_include_directories(tst_contactusage PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_contactusage PRIVATE Qt::Test)
|
||||
|
||||
# textgrid.h is a header-only helper holding the text snap rules, so this
|
||||
# test builds independently of the rest of the QET sources.
|
||||
add_executable(tst_textgrid tst_textgrid.cpp)
|
||||
add_test(NAME tst_textgrid COMMAND tst_textgrid)
|
||||
target_include_directories(tst_textgrid PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_textgrid PRIVATE Qt::Test)
|
||||
|
||||
add_executable(
|
||||
tst_qetpalette
|
||||
tst_qetpalette.cpp
|
||||
@@ -143,21 +156,30 @@ add_test(NAME tst_qetstrings COMMAND tst_qetstrings)
|
||||
target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml)
|
||||
|
||||
# QETSql::isSingleReadOnlyStatement() -- read-only enforcement for every
|
||||
# project-database query, including the ones a .qet file carries. Compiles
|
||||
# sqlreadonly.cpp alone against its own in-memory SQLite, so the security
|
||||
# CommandSearchPopup: accent-blind matching, ranking, and that Enter runs
|
||||
# only the owning window's highlighted, enabled command.
|
||||
add_executable(
|
||||
tst_commandsearch
|
||||
tst_commandsearch.cpp
|
||||
${QET_DIR}/sources/commandsearchpopup.cpp
|
||||
${QET_DIR}/sources/commandsearchpopup.h
|
||||
${QET_DIR}/sources/shortcutmanager.cpp)
|
||||
add_test(NAME tst_commandsearch COMMAND tst_commandsearch)
|
||||
set_tests_properties(tst_commandsearch PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
target_include_directories(tst_commandsearch PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_commandsearch PRIVATE Qt::Test Qt::Widgets)
|
||||
|
||||
# QETSql::execReadOnly() -- read-only enforcement for every project-database
|
||||
# query, including the ones a .qet file carries. Compiles sqlreadonly.cpp
|
||||
# alone against its own in-memory QSQLITE connection, so the security
|
||||
# property is checked without standing up a QETProject.
|
||||
find_package(SQLite3 REQUIRED)
|
||||
if(NOT TARGET SQLite3::SQLite3 AND TARGET SQLite::SQLite3)
|
||||
add_library(SQLite3::SQLite3 ALIAS SQLite::SQLite3)
|
||||
endif()
|
||||
add_executable(
|
||||
tst_sqlreadonly
|
||||
tst_sqlreadonly.cpp
|
||||
${QET_DIR}/sources/dataBase/sqlreadonly.cpp)
|
||||
add_test(NAME tst_sqlreadonly COMMAND tst_sqlreadonly)
|
||||
target_include_directories(tst_sqlreadonly PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_sqlreadonly PRIVATE Qt::Test SQLite3::SQLite3)
|
||||
target_link_libraries(tst_sqlreadonly PRIVATE Qt::Test Qt::Sql)
|
||||
|
||||
# QetSettings::scriptingEnabled() -- whether QElectroTech may run a script.
|
||||
# Compiles qetsettings.cpp alone: the setting is deliberately a plain
|
||||
@@ -203,6 +225,23 @@ add_test(NAME tst_spacemousehid COMMAND tst_spacemousehid)
|
||||
target_include_directories(tst_spacemousehid PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_spacemousehid PRIVATE Qt::Test Qt::Core)
|
||||
|
||||
# ConnexionBackend -- the macOS 3DxWare backend. Built on every platform and
|
||||
# run against fakeconnexion, a stand-in for 3DxWare's client library that
|
||||
# exports the same calls and answers from a thread of its own.
|
||||
add_library(fakeconnexion MODULE fakeconnexion.cpp)
|
||||
target_link_libraries(fakeconnexion PRIVATE Qt::Core)
|
||||
add_executable(
|
||||
tst_spacemouseconnexion
|
||||
tst_spacemouseconnexion.cpp
|
||||
${QET_DIR}/sources/spacemouse/connexionbackend.cpp
|
||||
${QET_DIR}/sources/spacemouse/spacemousebackend.h)
|
||||
add_dependencies(tst_spacemouseconnexion fakeconnexion)
|
||||
add_test(NAME tst_spacemouseconnexion COMMAND tst_spacemouseconnexion)
|
||||
target_compile_definitions(tst_spacemouseconnexion PRIVATE
|
||||
FAKE_CONNEXION="$<TARGET_FILE:fakeconnexion>")
|
||||
target_include_directories(tst_spacemouseconnexion PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_spacemouseconnexion PRIVATE Qt::Test Qt::Core)
|
||||
|
||||
# CrashHandler::formatInt() -- the async-signal-safe decimal formatter the
|
||||
# signal handler uses for the "Signal: N" line of a crash dump. Compiles
|
||||
# crashhandler.cpp and logring.cpp alongside; the handler deliberately
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
/*
|
||||
A stand-in for 3DxWare's 3DconnexionClient.framework, for
|
||||
tst_spacemouseconnexion: it exports the calls ConnexionBackend makes,
|
||||
plus fake*() controls the test uses to script 3DxWare's side. Like the
|
||||
real driver, it delivers messages on a thread of its own.
|
||||
*/
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#define FAKE_EXPORT extern "C" Q_DECL_EXPORT
|
||||
|
||||
namespace {
|
||||
using MessageHandler = void (*)(uint32_t, uint32_t, void *);
|
||||
using DeviceHandler = void (*)(uint32_t);
|
||||
|
||||
MessageHandler handler = nullptr;
|
||||
int16_t handlers_result = 0;
|
||||
uint16_t client_result = 7;
|
||||
std::string registered_name;
|
||||
uint32_t registered_mode = 0;
|
||||
uint32_t registered_mask = 0;
|
||||
uint32_t registered_buttons = 0;
|
||||
int unregisters = 0;
|
||||
int cleanups = 0;
|
||||
}
|
||||
|
||||
FAKE_EXPORT int16_t SetConnexionHandlers(MessageHandler message, DeviceHandler, DeviceHandler, bool)
|
||||
{
|
||||
if (handlers_result == 0) {
|
||||
handler = message;
|
||||
}
|
||||
return handlers_result;
|
||||
}
|
||||
|
||||
FAKE_EXPORT void CleanupConnexionHandlers()
|
||||
{
|
||||
handler = nullptr;
|
||||
++cleanups;
|
||||
}
|
||||
|
||||
FAKE_EXPORT uint16_t RegisterConnexionClient(uint32_t, const uint8_t *name, uint16_t mode, uint32_t mask)
|
||||
{
|
||||
registered_name.assign(reinterpret_cast<const char *>(name) + 1, name[0]);
|
||||
registered_mode = mode;
|
||||
registered_mask = mask;
|
||||
return client_result;
|
||||
}
|
||||
|
||||
FAKE_EXPORT void SetConnexionClientButtonMask(uint16_t, uint32_t mask)
|
||||
{
|
||||
registered_buttons = mask;
|
||||
}
|
||||
|
||||
FAKE_EXPORT void UnregisterConnexionClient(uint16_t)
|
||||
{
|
||||
++unregisters;
|
||||
}
|
||||
|
||||
/// Reset, and set what SetConnexionHandlers and RegisterConnexionClient return.
|
||||
FAKE_EXPORT void fakeReset(int16_t handlers, uint16_t client)
|
||||
{
|
||||
handler = nullptr;
|
||||
handlers_result = handlers;
|
||||
client_result = client;
|
||||
registered_name.clear();
|
||||
registered_mode = registered_mask = registered_buttons = 0;
|
||||
unregisters = cleanups = 0;
|
||||
}
|
||||
|
||||
/// What the backend registered with: name, mode, mask, button mask, and
|
||||
/// how many times it unregistered and cleaned up.
|
||||
FAKE_EXPORT const char *fakeRegistration(uint32_t *mode, uint32_t *mask, uint32_t *buttons,
|
||||
int *unregistered, int *cleaned)
|
||||
{
|
||||
*mode = registered_mode;
|
||||
*mask = registered_mask;
|
||||
*buttons = registered_buttons;
|
||||
*unregistered = unregisters;
|
||||
*cleaned = cleanups;
|
||||
return registered_name.c_str();
|
||||
}
|
||||
|
||||
/// Send one ConnexionDeviceState (packed to 2 bytes, 48 bytes long) from
|
||||
/// another thread, as 3DxWare does.
|
||||
FAKE_EXPORT void fakeSend(uint16_t client, uint16_t command, const int16_t axis[6], uint32_t buttons)
|
||||
{
|
||||
unsigned char state[48] = {};
|
||||
std::memcpy(state + 2, &client, 2);
|
||||
std::memcpy(state + 4, &command, 2);
|
||||
std::memcpy(state + 30, axis, 12);
|
||||
std::memcpy(state + 44, &buttons, 4);
|
||||
std::thread driver([&state]() {
|
||||
if (handler) {
|
||||
handler(0, 0x33645352, state); // '3dSR'
|
||||
}
|
||||
});
|
||||
driver.join();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "bordercelllabels.h"
|
||||
|
||||
class tst_bordercelllabels : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
// A copy of BorderTitleBlock::incrementLetters(), the walk the folio
|
||||
// border used to draw its row labels with, as the reference rowLabel()
|
||||
// must reproduce.
|
||||
static QString incrementLetters(const QString &string)
|
||||
{
|
||||
if (string.isEmpty())
|
||||
return QStringLiteral("A");
|
||||
const QString first_digits(string.left(string.length() - 1));
|
||||
const QChar last_digit(string.at(string.length() - 1));
|
||||
if (last_digit != QLatin1Char('Z'))
|
||||
return first_digits + QChar(last_digit.unicode() + 1);
|
||||
return incrementLetters(first_digits) + QLatin1Char('A');
|
||||
}
|
||||
|
||||
private slots:
|
||||
void rowLabelsFollowTheBorderSequence()
|
||||
{
|
||||
QString expected(QStringLiteral("A"));
|
||||
for (int row = 1; row <= 1000; ++row) {
|
||||
QCOMPARE(BorderCellLabels::rowLabel(row), expected);
|
||||
expected = incrementLetters(expected);
|
||||
}
|
||||
}
|
||||
|
||||
void rowLabelSamples()
|
||||
{
|
||||
QCOMPARE(BorderCellLabels::rowLabel(1), QStringLiteral("A"));
|
||||
QCOMPARE(BorderCellLabels::rowLabel(26), QStringLiteral("Z"));
|
||||
QCOMPARE(BorderCellLabels::rowLabel(27), QStringLiteral("AA"));
|
||||
QCOMPARE(BorderCellLabels::rowLabel(52), QStringLiteral("AZ"));
|
||||
QCOMPARE(BorderCellLabels::rowLabel(53), QStringLiteral("BA"));
|
||||
QCOMPARE(BorderCellLabels::rowLabel(702), QStringLiteral("ZZ"));
|
||||
QCOMPARE(BorderCellLabels::rowLabel(703), QStringLiteral("AAA"));
|
||||
}
|
||||
|
||||
void columnLabels()
|
||||
{
|
||||
QCOMPARE(BorderCellLabels::columnLabel(1, true), QStringLiteral("0"));
|
||||
QCOMPARE(BorderCellLabels::columnLabel(1, false), QStringLiteral("1"));
|
||||
QCOMPARE(BorderCellLabels::columnLabel(17, true), QStringLiteral("16"));
|
||||
QCOMPARE(BorderCellLabels::columnLabel(17, false), QStringLiteral("17"));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_APPLESS_MAIN(tst_bordercelllabels)
|
||||
|
||||
#include "tst_bordercelllabels.moc"
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "commandsearchpopup.h"
|
||||
#include "shortcutmanager.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QSignalSpy>
|
||||
#include <QTest>
|
||||
|
||||
/**
|
||||
CommandSearchPopup: accent- and case-blind matching, ranking, and that
|
||||
Enter runs the highlighted command of the owning window only.
|
||||
*/
|
||||
class tst_commandsearch : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private:
|
||||
QAction *add(QWidget *owner, const QString &id, const QString &text)
|
||||
{
|
||||
auto *action = new QAction(text, owner);
|
||||
ShortcutManager::instance().registerAction(action, id, QStringLiteral("test"), QKeySequence());
|
||||
return action;
|
||||
}
|
||||
|
||||
QStringList shown(CommandSearchPopup &popup)
|
||||
{
|
||||
QStringList texts;
|
||||
auto *list = popup.findChild<QListWidget *>();
|
||||
for (int i = 0 ; i < list->count() ; ++i) {
|
||||
texts << list->item(i)->text();
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
private slots:
|
||||
void fold_data()
|
||||
{
|
||||
QTest::addColumn<QString>("input");
|
||||
QTest::addColumn<QString>("expected");
|
||||
QTest::newRow("accents") << QStringLiteral("Éditer l'élément") << QStringLiteral("editer l'element");
|
||||
QTest::newRow("mnemonic") << QStringLiteral("&Fichier") << QStringLiteral("fichier");
|
||||
QTest::newRow("cedilla") << QStringLiteral("Façade") << QStringLiteral("facade");
|
||||
QTest::newRow("plain") << QStringLiteral("zoom") << QStringLiteral("zoom");
|
||||
}
|
||||
|
||||
void fold()
|
||||
{
|
||||
QFETCH(QString, input);
|
||||
QFETCH(QString, expected);
|
||||
QCOMPARE(CommandSearchPopup::fold(input), expected);
|
||||
}
|
||||
|
||||
void ranksAndRunsTheBestMatch()
|
||||
{
|
||||
QWidget owner;
|
||||
QWidget other;
|
||||
QAction *rotate = add(&owner, QStringLiteral("t.rotate"), QStringLiteral("Pivoter"));
|
||||
add(&owner, QStringLiteral("t.rotate_texts"), QStringLiteral("Orienter les textes"));
|
||||
QAction *edit = add(&owner, QStringLiteral("t.edit"), QStringLiteral("Éditer l'item sélectionné"));
|
||||
add(&owner, QStringLiteral("t.text"), QStringLiteral("Ajouter un champ de texte"));
|
||||
//Same id family, another window: must not be listed
|
||||
add(&other, QStringLiteral("t.other"), QStringLiteral("Texte d'une autre fenêtre"));
|
||||
|
||||
CommandSearchPopup popup(&owner);
|
||||
popup.popUpAt(QPoint(0, 0));
|
||||
auto *search = popup.findChild<QLineEdit *>();
|
||||
|
||||
search->setText(QStringLiteral("texte"));
|
||||
//word start ("…textes") before a contained match; other window absent
|
||||
QCOMPARE(shown(popup), (QStringList{QStringLiteral("Ajouter un champ de texte"),
|
||||
QStringLiteral("Orienter les textes")}));
|
||||
|
||||
search->setText(QStringLiteral("editer"));
|
||||
QCOMPARE(shown(popup).value(0), QStringLiteral("Éditer l'item sélectionné"));
|
||||
QSignalSpy edited(edit, &QAction::triggered);
|
||||
QSignalSpy rotated(rotate, &QAction::triggered);
|
||||
QTest::keyClick(&popup, Qt::Key_Return);
|
||||
QCOMPARE(edited.count(), 1);
|
||||
QCOMPARE(rotated.count(), 0);
|
||||
QVERIFY(!popup.isVisible());
|
||||
}
|
||||
|
||||
void disabledCommandsCannotRun()
|
||||
{
|
||||
QWidget owner;
|
||||
QAction *paste = add(&owner, QStringLiteral("u.paste"), QStringLiteral("Coller"));
|
||||
paste->setEnabled(false);
|
||||
CommandSearchPopup popup(&owner);
|
||||
popup.popUpAt(QPoint(0, 0));
|
||||
popup.findChild<QLineEdit *>()->setText(QStringLiteral("coller"));
|
||||
QCOMPARE(shown(popup), QStringList{QStringLiteral("Coller")});
|
||||
QSignalSpy pasted(paste, &QAction::triggered);
|
||||
QTest::keyClick(&popup, Qt::Key_Return);
|
||||
QCOMPARE(pasted.count(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(tst_commandsearch)
|
||||
#include "tst_commandsearch.moc"
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
/*
|
||||
ConnexionBackend, the macOS backend that reads a 3D mouse through
|
||||
3DxWare. It is tested on every platform against fakeconnexion, which
|
||||
exports the calls 3DxWare's client library does and sends messages from
|
||||
its own thread as 3DxWare does. What only a Mac with 3DxWare can check
|
||||
-- that the real library behaves like the fake -- is not tested here.
|
||||
*/
|
||||
#include "spacemouse/connexionbackend.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFileInfo>
|
||||
#include <QLibrary>
|
||||
#include <QSignalSpy>
|
||||
#include <QtTest>
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace {
|
||||
constexpr quint16 CLIENT = 7;
|
||||
constexpr quint16 AXIS = 3;
|
||||
constexpr quint16 BUTTONS = 2;
|
||||
|
||||
using Reset = void (*)(qint16, quint16);
|
||||
using Registration = const char *(*)(quint32 *, quint32 *, quint32 *, int *, int *);
|
||||
using Send = void (*)(quint16, quint16, const qint16 *, quint32);
|
||||
}
|
||||
|
||||
class TstSpaceMouseConnexion : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void initTestCase();
|
||||
void init();
|
||||
void noLibrary();
|
||||
void driverNotRunning();
|
||||
void registration();
|
||||
void motion();
|
||||
void otherClients();
|
||||
void buttons();
|
||||
void deletedBeforeDelivery();
|
||||
void oneClientPerProcess();
|
||||
void realLibrary();
|
||||
|
||||
private:
|
||||
QList<SpaceMouseSample> collect(ConnexionBackend &backend,
|
||||
const std::function<void()> &send);
|
||||
|
||||
QLibrary m_fake{QStringLiteral(FAKE_CONNEXION)};
|
||||
Reset m_reset = nullptr;
|
||||
Registration m_registration = nullptr;
|
||||
Send m_send = nullptr;
|
||||
};
|
||||
|
||||
/*
|
||||
A fake that fails to load fails every test that uses it, in init(),
|
||||
rather than here: realLibrary() does not use it, and CI runs it alone
|
||||
under code-signing settings that may refuse the fake too.
|
||||
*/
|
||||
void TstSpaceMouseConnexion::initTestCase()
|
||||
{
|
||||
if (m_fake.load()) {
|
||||
m_reset = reinterpret_cast<Reset>(m_fake.resolve("fakeReset"));
|
||||
m_registration = reinterpret_cast<Registration>(m_fake.resolve("fakeRegistration"));
|
||||
m_send = reinterpret_cast<Send>(m_fake.resolve("fakeSend"));
|
||||
}
|
||||
}
|
||||
|
||||
void TstSpaceMouseConnexion::init()
|
||||
{
|
||||
if (qstrcmp(QTest::currentTestFunction(), "realLibrary") == 0) {
|
||||
return;
|
||||
}
|
||||
QVERIFY2(m_reset && m_registration && m_send, qPrintable(m_fake.errorString()));
|
||||
m_reset(0, CLIENT);
|
||||
}
|
||||
|
||||
QList<SpaceMouseSample> TstSpaceMouseConnexion::collect(
|
||||
ConnexionBackend &backend, const std::function<void()> &send)
|
||||
{
|
||||
QList<SpaceMouseSample> samples;
|
||||
auto c = connect(&backend, &SpaceMouseBackend::motion,
|
||||
[&samples](const SpaceMouseSample &s) { samples.append(s); });
|
||||
send();
|
||||
QCoreApplication::processEvents();
|
||||
disconnect(c);
|
||||
return samples;
|
||||
}
|
||||
|
||||
/// 3DxWare not installed: the ordinary case.
|
||||
void TstSpaceMouseConnexion::noLibrary()
|
||||
{
|
||||
ConnexionBackend backend(nullptr, QStringLiteral("/nonexistent/3DconnexionClient"));
|
||||
QVERIFY(!backend.isAvailable());
|
||||
}
|
||||
|
||||
/// Installed but its driver is not running: SetConnexionHandlers fails, and
|
||||
/// nothing is left registered for 3DxWare to call.
|
||||
void TstSpaceMouseConnexion::driverNotRunning()
|
||||
{
|
||||
m_reset(-36, CLIENT);
|
||||
{
|
||||
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
QVERIFY(!backend.isAvailable());
|
||||
}
|
||||
quint32 mode, mask, buttons;
|
||||
int unregistered, cleaned;
|
||||
m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
|
||||
QCOMPARE(unregistered, 0);
|
||||
QCOMPARE(cleaned, 0);
|
||||
|
||||
//And a registration refused (client 0) leaves nothing either.
|
||||
m_reset(0, 0);
|
||||
{
|
||||
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
QVERIFY(!backend.isAvailable());
|
||||
}
|
||||
m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
|
||||
QCOMPARE(cleaned, 1);
|
||||
}
|
||||
|
||||
/// Registers by executable name, in take-over mode, for everything, and
|
||||
/// undoes it all on destruction.
|
||||
void TstSpaceMouseConnexion::registration()
|
||||
{
|
||||
quint32 mode, mask, buttons;
|
||||
int unregistered, cleaned;
|
||||
{
|
||||
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
QVERIFY(backend.isAvailable());
|
||||
const QString name = QString::fromUtf8(
|
||||
m_registration(&mode, &mask, &buttons, &unregistered, &cleaned));
|
||||
QCOMPARE(name, QFileInfo(QCoreApplication::applicationFilePath()).fileName());
|
||||
QCOMPARE(mode, 1u);
|
||||
QCOMPARE(mask, 0x3fffu);
|
||||
QCOMPARE(buttons, 0xffffffffu);
|
||||
QCOMPARE(unregistered, 0);
|
||||
}
|
||||
m_registration(&mode, &mask, &buttons, &unregistered, &cleaned);
|
||||
QCOMPARE(unregistered, 1);
|
||||
QCOMPARE(cleaned, 1);
|
||||
}
|
||||
|
||||
/// Motion arrives from 3DxWare's thread and is emitted on this one, in
|
||||
/// QET's axis convention.
|
||||
void TstSpaceMouseConnexion::motion()
|
||||
{
|
||||
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
QVERIFY(backend.isAvailable());
|
||||
|
||||
QThread *emitted_on = nullptr;
|
||||
connect(&backend, &SpaceMouseBackend::motion,
|
||||
[&emitted_on]() { emitted_on = QThread::currentThread(); });
|
||||
const qint16 axis[6] = {10, 20, 30, 40, 50, 60};
|
||||
const QList<SpaceMouseSample> samples = collect(backend, [&]() {
|
||||
m_send(CLIENT, AXIS, axis, 0);
|
||||
});
|
||||
QCOMPARE(samples.size(), 1);
|
||||
QCOMPARE(emitted_on, QThread::currentThread());
|
||||
|
||||
const SpaceMouseSample &s = samples.first();
|
||||
QCOMPARE(s.x, 10);
|
||||
QCOMPARE(s.y, -30);
|
||||
QCOMPARE(s.z, -20);
|
||||
QCOMPARE(s.rx, 40);
|
||||
QCOMPARE(s.ry, -60);
|
||||
QCOMPARE(s.rz, -50);
|
||||
}
|
||||
|
||||
/// 3DxWare sends every state to every client; only ours counts.
|
||||
void TstSpaceMouseConnexion::otherClients()
|
||||
{
|
||||
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
const qint16 axis[6] = {1, 2, 3, 4, 5, 6};
|
||||
QCOMPARE(collect(backend, [&]() { m_send(CLIENT + 1, AXIS, axis, 0); }).size(), 0);
|
||||
}
|
||||
|
||||
/// A press is a bit set that was clear; 0-based, as HidBackend numbers them.
|
||||
void TstSpaceMouseConnexion::buttons()
|
||||
{
|
||||
ConnexionBackend backend(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
QSignalSpy spy(&backend, &SpaceMouseBackend::buttonPressed);
|
||||
const qint16 still[6] = {};
|
||||
|
||||
m_send(CLIENT, BUTTONS, still, 0b101);
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(spy.size(), 2);
|
||||
QCOMPARE(spy.at(0).at(0).toInt(), 0);
|
||||
QCOMPARE(spy.at(1).at(0).toInt(), 2);
|
||||
|
||||
m_send(CLIENT, BUTTONS, still, 0b111);
|
||||
m_send(CLIENT, BUTTONS, still, 0);
|
||||
m_send(CLIENT, BUTTONS, still, 0b100);
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(spy.size(), 4);
|
||||
QCOMPARE(spy.at(2).at(0).toInt(), 1);
|
||||
QCOMPARE(spy.at(3).at(0).toInt(), 2);
|
||||
|
||||
QCOMPARE(ConnexionBackend::newlyPressed(0, 0x80000000u), QList<int>{31});
|
||||
QVERIFY(ConnexionBackend::newlyPressed(0b11, 0b01).isEmpty());
|
||||
}
|
||||
|
||||
/// A message queued for a backend deleted before the main thread reads it
|
||||
/// is dropped, not delivered to freed memory.
|
||||
void TstSpaceMouseConnexion::deletedBeforeDelivery()
|
||||
{
|
||||
auto *backend = new ConnexionBackend(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
const qint16 axis[6] = {1, 2, 3, 4, 5, 6};
|
||||
m_send(CLIENT, AXIS, axis, 0);
|
||||
delete backend;
|
||||
QCoreApplication::processEvents();
|
||||
//And 3DxWare calling after shutdown finds no one to call.
|
||||
m_send(CLIENT, AXIS, axis, 0);
|
||||
QCoreApplication::processEvents();
|
||||
}
|
||||
|
||||
/// 3DxWare takes one set of handlers per process.
|
||||
void TstSpaceMouseConnexion::oneClientPerProcess()
|
||||
{
|
||||
ConnexionBackend first(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
ConnexionBackend second(nullptr, QStringLiteral(FAKE_CONNEXION));
|
||||
QVERIFY(first.isAvailable());
|
||||
QVERIFY(!second.isAvailable());
|
||||
|
||||
const qint16 axis[6] = {5, 0, 0, 0, 0, 0};
|
||||
QCOMPARE(collect(first, [&]() { m_send(CLIENT, AXIS, axis, 0); }).size(), 1);
|
||||
}
|
||||
|
||||
/// On a Mac with 3DxWare installed: this process can load 3DxWare's real
|
||||
/// library. A Developer ID build with the hardened runtime needs
|
||||
/// misc/qelectrotech.entitlements for that; an ad-hoc signature does not
|
||||
/// enforce library validation, so CI cannot show the difference. Whether
|
||||
/// 3DxWare's driver then answers depends on the machine, so it is not checked.
|
||||
void TstSpaceMouseConnexion::realLibrary()
|
||||
{
|
||||
QLibrary library(QString::fromLatin1(ConnexionBackend::DEFAULT_LIBRARY));
|
||||
if (!QFileInfo::exists(library.fileName())) {
|
||||
QSKIP("3DxWare is not installed");
|
||||
}
|
||||
QVERIFY2(library.load(), qPrintable(library.errorString()));
|
||||
QVERIFY(library.resolve("SetConnexionHandlers"));
|
||||
|
||||
ConnexionBackend backend;
|
||||
qInfo("3DxWare's driver %s", backend.isAvailable() ? "answered" : "did not answer");
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TstSpaceMouseConnexion)
|
||||
#include "tst_spacemouseconnexion.moc"
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
/*
|
||||
QETSql::isSingleReadOnlyStatement() -- the read-only enforcement every
|
||||
QETSql::execReadOnly() -- the read-only enforcement every
|
||||
project-database query goes through.
|
||||
|
||||
The case that matters most here is the CTE prefix. SQLite has allowed
|
||||
@@ -27,6 +27,10 @@
|
||||
<query> is stored in the .qet and executed on load, so the text can
|
||||
arrive from a file rather than from the person at the keyboard.
|
||||
|
||||
The connection is a QSQLITE one, as in QElectroTech, so this runs
|
||||
through whichever SQLite the Qt driver carries -- the point of #1045,
|
||||
where the previous check crashed because it did not.
|
||||
|
||||
This test owns its own in-memory database and links nothing of
|
||||
QElectroTech but sqlreadonly.cpp, so it stays a fast, hermetic check of
|
||||
the security property itself.
|
||||
@@ -35,8 +39,8 @@
|
||||
#include "dataBase/sqlreadonly.h"
|
||||
|
||||
#include <QtTest>
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
|
||||
class TstSqlReadOnly : public QObject
|
||||
{
|
||||
@@ -55,58 +59,85 @@ class TstSqlReadOnly : public QObject
|
||||
void refusesTrailingStatement();
|
||||
void refusesEmptyAndCommentOnly_data();
|
||||
void refusesEmptyAndCommentOnly();
|
||||
void refusesWithoutAConnection();
|
||||
void reportsAReason();
|
||||
void doesNotExecuteWhatItRefuses();
|
||||
void refusedQueryCannotBeRunAgain();
|
||||
void acceptedQueryRunAgainStillReads();
|
||||
void leavesTheConnectionWritable();
|
||||
|
||||
private:
|
||||
sqlite3 *m_db = nullptr;
|
||||
QSqlDatabase m_db;
|
||||
int rowCount();
|
||||
bool isAccepted(const QString &query, QString *error = nullptr);
|
||||
};
|
||||
|
||||
int TstSqlReadOnly::rowCount()
|
||||
{
|
||||
sqlite3_stmt *st = nullptr;
|
||||
sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM element", -1, &st, nullptr);
|
||||
sqlite3_step(st);
|
||||
const int n = sqlite3_column_int(st, 0);
|
||||
sqlite3_finalize(st);
|
||||
return n;
|
||||
QSqlQuery q(m_db);
|
||||
q.exec(QStringLiteral("SELECT COUNT(*) FROM element"));
|
||||
q.next();
|
||||
return q.value(0).toInt();
|
||||
}
|
||||
|
||||
bool TstSqlReadOnly::isAccepted(const QString &query, QString *error)
|
||||
{
|
||||
QString reason;
|
||||
const QSqlQuery q = QETSql::execReadOnly(m_db, query, &reason);
|
||||
if (error) {
|
||||
*error = reason;
|
||||
}
|
||||
// Accepted means both: no reason given, and a query that actually ran.
|
||||
// A refusal must be both too, or a caller could act on either half.
|
||||
const bool accepted = reason.isEmpty();
|
||||
if (accepted != q.isActive()) {
|
||||
qWarning() << "reason and query state disagree for" << query
|
||||
<< reason << q.isActive();
|
||||
return !accepted; // fails whichever way the test expected
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::initTestCase()
|
||||
{
|
||||
QCOMPARE(sqlite3_open(":memory:", &m_db), SQLITE_OK);
|
||||
QCOMPARE(sqlite3_exec(m_db,
|
||||
"CREATE TABLE element (uuid TEXT);"
|
||||
"INSERT INTO element VALUES ('a'),('b');", nullptr, nullptr, nullptr),
|
||||
SQLITE_OK);
|
||||
m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"),
|
||||
QStringLiteral("tst_sqlreadonly"));
|
||||
QVERIFY(m_db.open());
|
||||
QSqlQuery q(m_db);
|
||||
QVERIFY(q.exec(QStringLiteral("CREATE TABLE element (uuid TEXT)")));
|
||||
QVERIFY(q.exec(QStringLiteral("INSERT INTO element VALUES ('a'),('b')")));
|
||||
QCOMPARE(rowCount(), 2);
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::cleanupTestCase()
|
||||
{
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
m_db.close();
|
||||
m_db = QSqlDatabase();
|
||||
QSqlDatabase::removeDatabase(QStringLiteral("tst_sqlreadonly"));
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::acceptsOrdinaryReads()
|
||||
{
|
||||
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element"));
|
||||
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT uuid FROM element WHERE uuid = 'a'"));
|
||||
// A semicolon inside a string literal is not a second statement. The
|
||||
// textual check this replaced rejected exactly this.
|
||||
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT ';' AS semicolon"));
|
||||
QVERIFY(isAccepted("SELECT * FROM element"));
|
||||
QVERIFY(isAccepted("SELECT uuid FROM element WHERE uuid = 'a'"));
|
||||
// A semicolon inside a string literal is not a second statement.
|
||||
QVERIFY(isAccepted("SELECT ';' AS semicolon"));
|
||||
// One trailing semicolon is ordinary punctuation, not a second statement.
|
||||
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element;"));
|
||||
QVERIFY(isAccepted("SELECT * FROM element;"));
|
||||
|
||||
// And the rows come back: the returned query is the one that ran.
|
||||
QSqlQuery q = QETSql::execReadOnly(m_db, "SELECT uuid FROM element ORDER BY uuid");
|
||||
QStringList uuids;
|
||||
while (q.next()) {
|
||||
uuids << q.value(0).toString();
|
||||
}
|
||||
QCOMPARE(uuids, QStringList({"a", "b"}));
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::acceptsLegitimateCommonTableExpression()
|
||||
{
|
||||
// WITH must keep working -- the fix is not "ban CTEs".
|
||||
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db,
|
||||
"WITH x AS (SELECT 1 AS n) SELECT n FROM x"));
|
||||
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db,
|
||||
QVERIFY(isAccepted("WITH x AS (SELECT 1 AS n) SELECT n FROM x"));
|
||||
QVERIFY(isAccepted(
|
||||
"WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) "
|
||||
"SELECT n FROM c LIMIT 3"));
|
||||
}
|
||||
@@ -117,13 +148,15 @@ void TstSqlReadOnly::refusesCtePrefixedWrites_data()
|
||||
QTest::newRow("delete") << "WITH x AS (SELECT 1) DELETE FROM element";
|
||||
QTest::newRow("update") << "WITH x AS (SELECT 1) UPDATE element SET uuid = 'pwned'";
|
||||
QTest::newRow("insert") << "WITH x AS (SELECT 1) INSERT INTO element VALUES ('injected')";
|
||||
QTest::newRow("returning") << "WITH x AS (SELECT 1) DELETE FROM element RETURNING uuid";
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::refusesCtePrefixedWrites()
|
||||
{
|
||||
QFETCH(QString, query);
|
||||
QVERIFY2(!QETSql::isSingleReadOnlyStatement(m_db, query),
|
||||
QVERIFY2(!isAccepted(query),
|
||||
qPrintable(QStringLiteral("accepted a write: %1").arg(query)));
|
||||
QCOMPARE(rowCount(), 2);
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::refusesBareWrites_data()
|
||||
@@ -133,18 +166,23 @@ void TstSqlReadOnly::refusesBareWrites_data()
|
||||
QTest::newRow("update") << "UPDATE element SET uuid = 'pwned'";
|
||||
QTest::newRow("insert") << "INSERT INTO element VALUES ('injected')";
|
||||
QTest::newRow("drop") << "DROP TABLE element";
|
||||
QTest::newRow("create") << "CREATE TABLE injected (x)";
|
||||
QTest::newRow("temp") << "CREATE TEMP TABLE injected (x)";
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::refusesBareWrites()
|
||||
{
|
||||
QFETCH(QString, query);
|
||||
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, query));
|
||||
QVERIFY2(!isAccepted(query),
|
||||
qPrintable(QStringLiteral("accepted a write: %1").arg(query)));
|
||||
QCOMPARE(rowCount(), 2);
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::refusesTrailingStatement()
|
||||
{
|
||||
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "SELECT 1; DROP TABLE element"));
|
||||
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "SELECT 1; SELECT 2"));
|
||||
QVERIFY(!isAccepted("SELECT 1; DROP TABLE element"));
|
||||
QVERIFY(!isAccepted("SELECT 1; SELECT 2"));
|
||||
QCOMPARE(rowCount(), 2);
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::refusesEmptyAndCommentOnly_data()
|
||||
@@ -157,42 +195,71 @@ void TstSqlReadOnly::refusesEmptyAndCommentOnly_data()
|
||||
|
||||
void TstSqlReadOnly::refusesEmptyAndCommentOnly()
|
||||
{
|
||||
// sqlite3_prepare_v2() reports success and a null statement for these;
|
||||
// sqlite3_stmt_readonly() must never be handed that.
|
||||
QFETCH(QString, query);
|
||||
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, query));
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::refusesWithoutAConnection()
|
||||
{
|
||||
// Fails closed: with no connection there is nothing to ask, and
|
||||
// guessing from the text is the weakness this replaced.
|
||||
QVERIFY(!QETSql::isSingleReadOnlyStatement(nullptr, "SELECT * FROM element"));
|
||||
QVERIFY(!isAccepted(query));
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::reportsAReason()
|
||||
{
|
||||
QString reason;
|
||||
QVERIFY(!QETSql::isSingleReadOnlyStatement(
|
||||
m_db, "WITH x AS (SELECT 1) DELETE FROM element", &reason));
|
||||
QVERIFY(!isAccepted("WITH x AS (SELECT 1) DELETE FROM element", &reason));
|
||||
QVERIFY2(!reason.isEmpty(), "a refusal must say why");
|
||||
|
||||
reason = QStringLiteral("stale");
|
||||
QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element", &reason));
|
||||
QVERIFY(isAccepted("SELECT * FROM element", &reason));
|
||||
QVERIFY2(reason.isEmpty(), "an accepted query must not leave a reason behind");
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::doesNotExecuteWhatItRefuses()
|
||||
{
|
||||
// The check compiles the statement to inspect it. Proving the table is
|
||||
// untouched afterwards is what says it compiled without running it --
|
||||
// and this same assertion goes red if the refusals above ever stop
|
||||
// refusing, since then the caller would run the DELETE for real.
|
||||
// Proving the table is untouched afterwards is what says the write was
|
||||
// refused rather than run -- and this same assertion goes red if the
|
||||
// refusals above ever stop refusing.
|
||||
QCOMPARE(rowCount(), 2);
|
||||
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "WITH x AS (SELECT 1) DELETE FROM element"));
|
||||
QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "DELETE FROM element"));
|
||||
QVERIFY(!isAccepted("WITH x AS (SELECT 1) DELETE FROM element"));
|
||||
QVERIFY(!isAccepted("DELETE FROM element"));
|
||||
QCOMPARE(rowCount(), 2);
|
||||
}
|
||||
|
||||
QTEST_APPLESS_MAIN(TstSqlReadOnly)
|
||||
void TstSqlReadOnly::refusedQueryCannotBeRunAgain()
|
||||
{
|
||||
// Several callers of projectDataBase::newQuery() call exec() again on
|
||||
// what it returns, and query_only is off by then. A refused query
|
||||
// must therefore come back with nothing left to run.
|
||||
QSqlQuery q = QETSql::execReadOnly(m_db, "WITH x AS (SELECT 1) DELETE FROM element");
|
||||
QVERIFY(!q.exec());
|
||||
QCOMPARE(rowCount(), 2);
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::acceptedQueryRunAgainStillReads()
|
||||
{
|
||||
QSqlQuery q = QETSql::execReadOnly(m_db, "SELECT uuid FROM element");
|
||||
QVERIFY(q.exec());
|
||||
int n = 0;
|
||||
while (q.next()) {
|
||||
++n;
|
||||
}
|
||||
QCOMPARE(n, 2);
|
||||
}
|
||||
|
||||
void TstSqlReadOnly::leavesTheConnectionWritable()
|
||||
{
|
||||
// query_only must not outlive the call, whatever its outcome: the
|
||||
// project database is rebuilt by writes on this same connection.
|
||||
QETSql::execReadOnly(m_db, "SELECT * FROM element");
|
||||
QETSql::execReadOnly(m_db, "DELETE FROM element");
|
||||
QETSql::execReadOnly(m_db, "not even sql");
|
||||
|
||||
QSqlQuery q(m_db);
|
||||
QVERIFY(q.exec(QStringLiteral("PRAGMA query_only")));
|
||||
QVERIFY(q.next());
|
||||
QCOMPARE(q.value(0).toInt(), 0);
|
||||
|
||||
QVERIFY(q.exec(QStringLiteral("INSERT INTO element VALUES ('c')")));
|
||||
QCOMPARE(rowCount(), 3);
|
||||
QVERIFY(q.exec(QStringLiteral("DELETE FROM element WHERE uuid = 'c'")));
|
||||
QCOMPARE(rowCount(), 2);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TstSqlReadOnly)
|
||||
#include "tst_sqlreadonly.moc"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "textgrid.h"
|
||||
|
||||
class tst_textgrid : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
// A label starting off the grid at (-13.3, 13.7), the case from
|
||||
// discussion #1020: the sideways jump shrinks with a finer text grid.
|
||||
void snapsToStep_data()
|
||||
{
|
||||
QTest::addColumn<int>("grid");
|
||||
QTest::addColumn<qreal>("divisor");
|
||||
QTest::addColumn<QPointF>("expected");
|
||||
|
||||
QTest::newRow("off rounds to pixel") << 10 << 0.0 << QPointF(-13, 14);
|
||||
QTest::newRow("1:1 is the folio grid") << 10 << 1.0 << QPointF(-10, 10);
|
||||
QTest::newRow("1:2") << 10 << 2.0 << QPointF(-15, 15);
|
||||
QTest::newRow("1:5") << 10 << 5.0 << QPointF(-14, 14);
|
||||
QTest::newRow("1:10") << 10 << 10.0 << QPointF(-13, 14);
|
||||
QTest::newRow("grid 7, 1:2 steps 3.5") << 7 << 2.0 << QPointF(-14, 14);
|
||||
}
|
||||
|
||||
void snapsToStep()
|
||||
{
|
||||
QFETCH(int, grid);
|
||||
QFETCH(qreal, divisor);
|
||||
QFETCH(QPointF, expected);
|
||||
|
||||
const QPointF snapped = TextGrid::snap(QPointF(-13.3, 13.7), grid, grid, divisor);
|
||||
QCOMPARE(snapped.x(), expected.x());
|
||||
QCOMPARE(snapped.y(), expected.y());
|
||||
}
|
||||
|
||||
// Every folio grid point is also a text grid point, so a text can
|
||||
// always sit exactly where an element or a wire does, and texts of
|
||||
// different elements can line up. This is why every divisor offered
|
||||
// is a whole number: 1:2.5 on a grid of 10 steps by 4, which misses 10.
|
||||
void folioGridPointsAreKept_data()
|
||||
{
|
||||
QTest::addColumn<int>("grid");
|
||||
QTest::addColumn<qreal>("divisor");
|
||||
|
||||
for (int grid : {10, 7, 5})
|
||||
for (qreal divisor : TextGrid::divisors)
|
||||
if (divisor > 0)
|
||||
QTest::newRow(qPrintable(QStringLiteral("grid %1, %2")
|
||||
.arg(grid).arg(TextGrid::ratioLabel(divisor))))
|
||||
<< grid << divisor;
|
||||
}
|
||||
|
||||
void folioGridPointsAreKept()
|
||||
{
|
||||
QFETCH(int, grid);
|
||||
QFETCH(qreal, divisor);
|
||||
|
||||
for (int k = -20; k <= 20; ++k) {
|
||||
const QPointF on_grid(k * grid, -k * grid);
|
||||
QCOMPARE(TextGrid::snap(on_grid, grid, grid, divisor), on_grid);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate X and Y grid sizes are honoured independently.
|
||||
void usesEachAxisGrid()
|
||||
{
|
||||
QCOMPARE(TextGrid::snap(QPointF(13, 13), 10, 20, 2), QPointF(15, 10));
|
||||
}
|
||||
|
||||
void ratioLabel()
|
||||
{
|
||||
QCOMPARE(TextGrid::ratioLabel(2), QStringLiteral("1:2"));
|
||||
QCOMPARE(TextGrid::ratioLabel(10), QStringLiteral("1:10"));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(tst_textgrid)
|
||||
|
||||
#include "tst_textgrid.moc"
|
||||
Reference in New Issue
Block a user