mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-26 11:54:14 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 923367d2a8 | |||
| 6dd260a7b2 | |||
| 132c564aef | |||
| e9c4969af7 | |||
| 040c4b5e29 | |||
| 71d708e2da | |||
| b490720652 | |||
| 519f2245f6 | |||
| 0ca4b9f6c3 | |||
| a08cdf7272 | |||
| 2fdb225936 | |||
| 95f3ea2f6d | |||
| 0e02190eb2 | |||
| 2807771200 | |||
| 9738345b45 | |||
| ce0e7e6867 | |||
| d392ab4b8f | |||
| ebd4a57563 | |||
| 23d83ec153 |
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
+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,170 @@
|
||||
/*
|
||||
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(), palette().color(QPalette::Button));
|
||||
|
||||
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 QRectF inside = border.insideBorderRect();
|
||||
const QTransform transform = m_view->viewportTransform();
|
||||
|
||||
const int count = horizontal ? border.columnsCount() : border.rowsCount();
|
||||
const qreal cell_size = horizontal ? border.columnsWidth() : border.rowsHeight();
|
||||
const qreal first = horizontal ? inside.left() : inside.top();
|
||||
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),
|
||||
palette().color(QPalette::Button));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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:
|
||||
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
|
||||
@@ -42,6 +42,7 @@
|
||||
#include "qetinformation.h"
|
||||
#include "qetproject.h"
|
||||
#include "diagramsortkeys.h"
|
||||
#include "textgrid.h"
|
||||
#include <QTextStream>
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
@@ -2684,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);
|
||||
|
||||
+125
-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"
|
||||
@@ -43,6 +44,7 @@
|
||||
#include <QDropEvent>
|
||||
#include <QPainter>
|
||||
#include <QPointer>
|
||||
#include <algorithm>
|
||||
|
||||
/**
|
||||
Constructeur
|
||||
@@ -107,6 +109,13 @@ 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();
|
||||
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 +394,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
|
||||
@@ -1170,6 +1196,82 @@ void DiagramView::paintingInverted(bool inverted)
|
||||
m_diagram->setInvertedLightness(inverted);
|
||||
}
|
||||
|
||||
/**
|
||||
@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 and the folio shows the
|
||||
matching header, and give it room in the margins of the view. The
|
||||
part of the folio in sight stays in sight when the viewport resizes.
|
||||
*/
|
||||
void DiagramView::updateCellRulers()
|
||||
{
|
||||
const BorderTitleBlock &border = m_diagram->border_and_titleblock;
|
||||
const bool top = m_cell_rulers_shown
|
||||
&& border.borderIsDisplayed() && border.columnsAreDisplayed();
|
||||
const bool side = m_cell_rulers_shown
|
||||
&& border.borderIsDisplayed() && border.rowsAreDisplayed();
|
||||
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 centre = mapToScene(viewport()->rect().center());
|
||||
setViewportMargins(margins);
|
||||
centerOn(centre);
|
||||
}
|
||||
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
|
||||
@@ -1179,6 +1281,13 @@ void DiagramView::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
PaletteGraphicsView::paintEvent(event);
|
||||
|
||||
//Scrolling and zooming both repaint the viewport: follow them
|
||||
if (viewportTransform() != m_rulers_transform) {
|
||||
m_rulers_transform = viewportTransform();
|
||||
m_top_ruler->update();
|
||||
m_side_ruler->update();
|
||||
}
|
||||
|
||||
if (m_free_rubberbanding && m_free_rubberband.count() >= 3)
|
||||
{
|
||||
QPainter painter(viewport());
|
||||
@@ -1299,10 +1408,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
|
||||
{
|
||||
@@ -1318,11 +1432,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <QClipboard>
|
||||
#include "palettegraphicsview.h"
|
||||
|
||||
class CellRuler;
|
||||
class Conductor;
|
||||
class Diagram;
|
||||
class QETDiagramEditor;
|
||||
@@ -62,6 +63,11 @@ class DiagramView : public PaletteGraphicsView
|
||||
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;
|
||||
|
||||
|
||||
public:
|
||||
@@ -78,6 +84,7 @@ class DiagramView : public PaletteGraphicsView
|
||||
/// 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);
|
||||
|
||||
protected:
|
||||
void mouseDoubleClickEvent(QMouseEvent *) override;
|
||||
@@ -91,6 +98,7 @@ 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 paintingInverted(bool inverted) override;
|
||||
void mousePressEvent(QMouseEvent *) override;
|
||||
void mouseMoveEvent(QMouseEvent *) override;
|
||||
@@ -113,6 +121,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
|
||||
@@ -140,6 +150,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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,6 +20,7 @@
|
||||
#include "scripting/qetscripting.h"
|
||||
#endif
|
||||
#include <QCoreApplication>
|
||||
#include <QToolButton>
|
||||
#include "ElementsCollection/elementscollectionwidget.h"
|
||||
#include "QWidgetAnimation/qwidgetanimation.h"
|
||||
#include "autoNum/ui/autonumberingdockwidget.h"
|
||||
@@ -48,6 +49,7 @@
|
||||
#include "qeticons.h"
|
||||
#include "qetmessagebox.h"
|
||||
#include "recentfiles.h"
|
||||
#include "textgrid.h"
|
||||
#include "shortcutmanager.h"
|
||||
#include "ui/bomexportdialog.h"
|
||||
#include "ui/conductorcolortoolbutton.h"
|
||||
@@ -481,6 +483,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"));
|
||||
@@ -493,6 +522,18 @@ 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);
|
||||
});
|
||||
|
||||
//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);
|
||||
@@ -956,6 +997,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();
|
||||
@@ -1044,9 +1086,9 @@ void QETDiagramEditor::setUpMenu()
|
||||
//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();
|
||||
@@ -1056,6 +1098,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();
|
||||
@@ -1109,7 +1157,9 @@ 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 -> addMenu(m_background_color_button->menu());
|
||||
menu_affichage -> addSeparator();
|
||||
menu_affichage -> addActions(m_zoom_actions_group.actions());
|
||||
@@ -1941,6 +1991,7 @@ 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);
|
||||
|
||||
//Project menu
|
||||
m_project_edit_properties -> setEnabled(opened_project);
|
||||
@@ -3224,3 +3275,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;
|
||||
@@ -164,6 +166,7 @@ class QETDiagramEditor : public QETMainWindow
|
||||
void subWindowActivated(QMdiSubWindow *subWindows);
|
||||
|
||||
private slots:
|
||||
void updateTextGridButton();
|
||||
void selectionChanged();
|
||||
|
||||
public:
|
||||
@@ -177,6 +180,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 +212,7 @@ 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_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 +252,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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -688,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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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