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"