diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index 5721e4111..1bf68b48d 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -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
diff --git a/lang/qet_de.qm b/lang/qet_de.qm
deleted file mode 100644
index 61dab31c8..000000000
Binary files a/lang/qet_de.qm and /dev/null differ
diff --git a/misc/Info.plist b/misc/Info.plist
index 16da6501c..3d138a781 100644
--- a/misc/Info.plist
+++ b/misc/Info.plist
@@ -62,6 +62,6 @@
NSPrincipalClass
NSApplication
LSMinimumSystemVersion
- 12.3.0
+ 14.0.0
diff --git a/misc/MacQetDeploy_arm64_cmake.sh b/misc/MacQetDeploy_arm64_cmake.sh
index e548f24a5..1d247cee7 100644
--- a/misc/MacQetDeploy_arm64_cmake.sh
+++ b/misc/MacQetDeploy_arm64_cmake.sh
@@ -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
diff --git a/misc/spacemouse-capture.py b/misc/spacemouse-capture.py
index 71d5b573c..bd0db5c27 100755
--- a/misc/spacemouse-capture.py
+++ b/misc/spacemouse-capture.py
@@ -14,22 +14,59 @@
#
# You should have received a copy of the GNU General Public License
# along with QElectroTech. If not, see .
-"""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-.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-.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
diff --git a/sources/bordercelllabels.h b/sources/bordercelllabels.h
new file mode 100644
index 000000000..d5dbb6e29
--- /dev/null
+++ b/sources/bordercelllabels.h
@@ -0,0 +1,51 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#ifndef BORDERCELLLABELS_H
+#define BORDERCELLLABELS_H
+
+#include
+
+/// 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
diff --git a/sources/bordertitleblock.cpp b/sources/bordertitleblock.cpp
index 4a2a2dfdc..7878d1a9f 100644
--- a/sources/bordertitleblock.cpp
+++ b/sources/bordertitleblock.cpp
@@ -17,6 +17,7 @@
*/
#include "bordertitleblock.h"
+#include "bordercelllabels.h"
#include "createdxf.h"
#include "diagram.h"
#include "diagramposition.h"
@@ -29,6 +30,7 @@
#include
#include
+#include
#include
#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
diff --git a/sources/bordertitleblock.h b/sources/bordertitleblock.h
index 28da7360c..4065bf488 100644
--- a/sources/bordertitleblock.h
+++ b/sources/bordertitleblock.h
@@ -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);
diff --git a/sources/cellruler.cpp b/sources/cellruler.cpp
new file mode 100644
index 000000000..299d70eb8
--- /dev/null
+++ b/sources/cellruler.cpp
@@ -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 .
+*/
+#include "cellruler.h"
+
+#include "bordercelllabels.h"
+#include "diagram.h"
+#include "diagramview.h"
+
+#include
+#include
+
+/**
+ @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));
+ }
+}
diff --git a/sources/cellruler.h b/sources/cellruler.h
new file mode 100644
index 000000000..619b2d52e
--- /dev/null
+++ b/sources/cellruler.h
@@ -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 .
+*/
+#ifndef CELLRULER_H
+#define CELLRULER_H
+
+#include
+
+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
diff --git a/sources/diagram.cpp b/sources/diagram.cpp
index 01a6e63d2..e8c6b17f9 100644
--- a/sources/diagram.cpp
+++ b/sources/diagram.cpp
@@ -42,6 +42,7 @@
#include "qetinformation.h"
#include "qetproject.h"
#include "diagramsortkeys.h"
+#include "textgrid.h"
#include
#include
#include
@@ -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);
+}
+
/**
diff --git a/sources/diagram.h b/sources/diagram.h
index 198323bbc..244299a04 100644
--- a/sources/diagram.h
+++ b/sources/diagram.h
@@ -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);
diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp
index 06149836b..9d9f3edf7 100644
--- a/sources/diagramview.cpp
+++ b/sources/diagramview.cpp
@@ -16,6 +16,7 @@
along with QElectroTech. If not, see .
*/
#include "diagramview.h"
+#include "cellruler.h"
#include "lastusedstyle.h"
#include "qetproject.h"
#include "QPropertyUndoCommand/qpropertyundocommand.h"
@@ -43,6 +44,7 @@
#include
#include
#include
+#include
/**
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]()
{
@@ -443,6 +452,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
@@ -1228,6 +1254,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
@@ -1237,6 +1339,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());
@@ -1357,10 +1466,15 @@ QList 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
{
@@ -1376,11 +1490,19 @@ QList 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 actions = list;
for(QAction *action : actions)
{
- if (!action->isEnabled()) {
+ bool usable = action->isEnabled();
+ if (usable && action->menu())
+ {
+ const QList 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);
}
}
diff --git a/sources/diagramview.h b/sources/diagramview.h
index 43201fd2e..03eb067d5 100644
--- a/sources/diagramview.h
+++ b/sources/diagramview.h
@@ -24,6 +24,7 @@
#include
#include "palettegraphicsview.h"
+class CellRuler;
class Conductor;
class Diagram;
class QETDiagramEditor;
@@ -62,6 +63,11 @@ class DiagramView : public PaletteGraphicsView
QList 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);
bool startElementPlacement(const ElementsLocation &location,
const QPointF &scene_pos);
@@ -95,6 +102,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;
@@ -117,6 +125,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
@@ -147,6 +157,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);
diff --git a/sources/elementtextsmover.cpp b/sources/elementtextsmover.cpp
index ddf2f235c..8835dc9a8 100644
--- a/sources/elementtextsmover.cpp
+++ b/sources/elementtextsmover.cpp
@@ -22,8 +22,11 @@
#include "qetapp.h"
#include "qetgraphicsitem/dynamicelementtextitem.h"
#include "qetgraphicsitem/elementtextitemgroup.h"
+#include "qetdiagrameditor.h"
+#include "textgrid.h"
#include
+#include
/**
@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());
diff --git a/sources/elementtextsmover.h b/sources/elementtextsmover.h
index fd4ad61af..a1b793a43 100644
--- a/sources/elementtextsmover.h
+++ b/sources/elementtextsmover.h
@@ -21,6 +21,8 @@
#include
#include
#include
+#include
+#include
class QGraphicsItem;
class DiagramTextItem;
@@ -55,6 +57,7 @@ class ElementTextsMover
QHash m_texts_hash;
QHash m_grps_hash;
QHash m_items_hash;
+ QPointer m_status_bar;
int m_text_count = 0,
m_group_count = 0;
};
diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp
index c57a907cd..48d9001b6 100644
--- a/sources/qetapp.cpp
+++ b/sources/qetapp.cpp
@@ -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) {
diff --git a/sources/qetapp.h b/sources/qetapp.h
index ad3850c79..5c0b15106 100644
--- a/sources/qetapp.h
+++ b/sources/qetapp.h
@@ -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();
diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp
index cc5c546f3..540907430 100644
--- a/sources/qetdiagrameditor.cpp
+++ b/sources/qetdiagrameditor.cpp
@@ -20,6 +20,7 @@
#include "scripting/qetscripting.h"
#endif
#include
+#include
#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"
@@ -488,6 +490,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"));
@@ -500,6 +529,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);
@@ -983,6 +1024,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();
@@ -1072,9 +1114,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();
@@ -1084,6 +1126,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();
@@ -1137,7 +1185,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());
@@ -1970,6 +2020,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);
@@ -3301,3 +3352,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)));
+}
diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h
index 622c78c16..3cb2659ec 100644
--- a/sources/qetdiagrameditor.h
+++ b/sources/qetdiagrameditor.h
@@ -28,6 +28,8 @@
#include
#include
+class QToolButton;
+
class QMdiSubWindow;
class QETProject;
class QETResult;
@@ -167,6 +169,7 @@ class QETDiagramEditor : public QETMainWindow
void subWindowActivated(QMdiSubWindow *subWindows);
private slots:
+ void updateTextGridButton();
void selectionChanged();
public:
@@ -180,6 +183,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
@@ -208,6 +215,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
@@ -247,6 +255,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 m_zoom_action_toolBar; ///Only zoom action must displayed in the toolbar
diff --git a/sources/qetgraphicsitem/conductortextitem.cpp b/sources/qetgraphicsitem/conductortextitem.cpp
index e5e2201ac..624b5ea77 100644
--- a/sources/qetgraphicsitem/conductortextitem.cpp
+++ b/sources/qetgraphicsitem/conductortextitem.cpp
@@ -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);
diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp
index 4587ab647..771a3d42d 100644
--- a/sources/qetgraphicsitem/diagramtextitem.cpp
+++ b/sources/qetgraphicsitem/diagramtextitem.cpp
@@ -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
diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp
index 1316d992b..fda123da9 100644
--- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp
+++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp
@@ -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);
diff --git a/sources/qetgraphicsitem/elementtextitemgroup.cpp b/sources/qetgraphicsitem/elementtextitemgroup.cpp
index 253c10146..9e4016fcf 100644
--- a/sources/qetgraphicsitem/elementtextitemgroup.cpp
+++ b/sources/qetgraphicsitem/elementtextitemgroup.cpp
@@ -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);
diff --git a/sources/textgrid.h b/sources/textgrid.h
new file mode 100644
index 000000000..10e77fa76
--- /dev/null
+++ b/sources/textgrid.h
@@ -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 .
+*/
+#ifndef TEXTGRID_H
+#define TEXTGRID_H
+
+#include
+#include
+#include
+#include
+
+/**
+ 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 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
diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp
index 5f6194410..c59390c24 100644
--- a/sources/ui/configpage/generalconfigurationpage.cpp
+++ b/sources/ui/configpage/generalconfigurationpage.cpp
@@ -23,6 +23,7 @@
#include "../../utils/qetsettings.h"
#include "../../utils/qetutils.h"
#include "../../qetmessagebox.h"
+#include "../../textgrid.h"
#include "../nokde/kcolorbutton.h"
#include
#include
@@ -71,6 +72,15 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->m_collection_dblclick_edits->setChecked(!settings.value("elementscollection/double-click-inserts", true).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());
@@ -291,6 +301,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());
diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui
index 9d4bd92e9..2ab58cdc2 100644
--- a/sources/ui/configpage/generalconfigurationpage.ui
+++ b/sources/ui/configpage/generalconfigurationpage.ui
@@ -794,6 +794,26 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
+ -
+
+
+ Grille des textes déplacés à la souris
+
+
+ Fraction de la grille des folios. Maintenir Ctrl pendant le déplacement pour placer librement.
+
+
+
+ -
+
+
+
+ 80
+ 0
+
+
+
+
diff --git a/sources/ui/jumptoelementdialog.cpp b/sources/ui/jumptoelementdialog.cpp
index 9203cd909..65b2615b3 100644
--- a/sources/ui/jumptoelementdialog.cpp
+++ b/sources/ui/jumptoelementdialog.cpp
@@ -18,12 +18,15 @@
#include "jumptoelementdialog.h"
#include "../diagram.h"
+#include "../diagramview.h"
+#include "../qetproject.h"
#include "../qetgraphicsitem/element.h"
#include
#include
#include
#include
+#include
#include
/**
@@ -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 diagrams = m_diagram->project()
+ ? m_diagram->project()->diagrams()
+ : QList();
+ 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 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(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
diff --git a/sources/ui/jumptoelementdialog.h b/sources/ui/jumptoelementdialog.h
index 603b3932b..880e4efa2 100644
--- a/sources/ui/jumptoelementdialog.h
+++ b/sources/ui/jumptoelementdialog.h
@@ -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;
+ QString label;
QString display_text;
QString search_text;
};
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index 2fec5922f..3296bc016 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -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
diff --git a/tests/qttest/tst_bordercelllabels.cpp b/tests/qttest/tst_bordercelllabels.cpp
new file mode 100644
index 000000000..7c4ca074b
--- /dev/null
+++ b/tests/qttest/tst_bordercelllabels.cpp
@@ -0,0 +1,55 @@
+#include
+
+#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"
diff --git a/tests/qttest/tst_textgrid.cpp b/tests/qttest/tst_textgrid.cpp
new file mode 100644
index 000000000..a58251c3f
--- /dev/null
+++ b/tests/qttest/tst_textgrid.cpp
@@ -0,0 +1,80 @@
+#include
+
+#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("grid");
+ QTest::addColumn("divisor");
+ QTest::addColumn("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("grid");
+ QTest::addColumn("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"